@nifrajs/web 2.13.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) 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-vite.d.ts.map +1 -1
  6. package/dist/build-vite.js +5 -3
  7. package/dist/build-vite.js.map +1 -1
  8. package/dist/build.d.ts +9 -6
  9. package/dist/build.d.ts.map +1 -1
  10. package/dist/build.js +57 -31
  11. package/dist/build.js.map +1 -1
  12. package/dist/fn.d.ts +2 -2
  13. package/dist/fn.d.ts.map +1 -1
  14. package/dist/fn.js +2 -2
  15. package/dist/fn.js.map +1 -1
  16. package/dist/index.d.ts +30 -20
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +179 -56
  19. package/dist/index.js.map +1 -1
  20. package/dist/internal/fetcher-runtime.d.ts +19 -0
  21. package/dist/internal/fetcher-runtime.d.ts.map +1 -0
  22. package/dist/internal/fetcher-runtime.js +26 -0
  23. package/dist/internal/fetcher-runtime.js.map +1 -0
  24. package/dist/internal/identity-policy.d.ts +51 -0
  25. package/dist/internal/identity-policy.d.ts.map +1 -0
  26. package/dist/internal/identity-policy.js +67 -0
  27. package/dist/internal/identity-policy.js.map +1 -0
  28. package/dist/internal/parity.d.ts +105 -7
  29. package/dist/internal/parity.d.ts.map +1 -1
  30. package/dist/internal/parity.js +303 -56
  31. package/dist/internal/parity.js.map +1 -1
  32. package/dist/internal/query-runtime.d.ts +8 -0
  33. package/dist/internal/query-runtime.d.ts.map +1 -0
  34. package/dist/internal/query-runtime.js +29 -0
  35. package/dist/internal/query-runtime.js.map +1 -0
  36. package/dist/islands.d.ts +2 -2
  37. package/dist/islands.d.ts.map +1 -1
  38. package/dist/islands.js +17 -36
  39. package/dist/islands.js.map +1 -1
  40. package/dist/manifest.d.ts +41 -6
  41. package/dist/manifest.d.ts.map +1 -1
  42. package/dist/manifest.js.map +1 -1
  43. package/dist/render-seam.d.ts +5 -0
  44. package/dist/render-seam.d.ts.map +1 -1
  45. package/dist/render-seam.js +10 -0
  46. package/dist/render-seam.js.map +1 -1
  47. package/dist/router.d.ts +10 -0
  48. package/dist/router.d.ts.map +1 -1
  49. package/dist/router.js +233 -26
  50. package/dist/router.js.map +1 -1
  51. package/dist/vite.d.ts +8 -0
  52. package/dist/vite.d.ts.map +1 -1
  53. package/dist/vite.js +51 -13
  54. package/dist/vite.js.map +1 -1
  55. package/package.json +13 -2
  56. package/src/boundary.ts +407 -0
  57. package/src/build-vite.ts +5 -3
  58. package/src/build.ts +61 -31
  59. package/src/fn.ts +6 -6
  60. package/src/index.ts +255 -54
  61. package/src/internal/fetcher-runtime.ts +36 -0
  62. package/src/internal/identity-policy.ts +94 -0
  63. package/src/internal/parity.ts +447 -70
  64. package/src/internal/query-runtime.ts +31 -0
  65. package/src/islands.ts +22 -41
  66. package/src/manifest.ts +50 -3
  67. package/src/render-seam.ts +6 -0
  68. package/src/router.ts +271 -29
  69. package/src/vite.ts +75 -13
@@ -9,7 +9,15 @@
9
9
  import { existsSync, readdirSync, readFileSync, realpathSync } from "node:fs"
10
10
  import { lstat, realpath, stat } from "node:fs/promises"
11
11
  import { dirname, 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
@@ -36,21 +35,97 @@ export interface IdentityParityCopy {
36
35
  readonly importers: readonly string[]
37
36
  }
38
37
 
38
+ export type IdentityParityCause = "version-skew" | "duplicate-path"
39
+
39
40
  export interface IdentityParityFinding {
40
41
  readonly package: string
41
42
  readonly copies: readonly IdentityParityCopy[]
43
+ /** Unique package versions observed across the physical copies. */
44
+ readonly versions: readonly string[]
45
+ /** `version-skew` when copies advertise different versions; otherwise the same-version path split. */
46
+ readonly cause: IdentityParityCause
42
47
  readonly explanation: string
43
48
  readonly remediation: string
49
+ /**
50
+ * Why the copies exist, in install-topology terms: how many physical paths sit under how many
51
+ * install roots, and whether any of those roots is outside the scanned project.
52
+ *
53
+ * A path list alone leaves the reader to reverse-engineer the shape. The two shapes need opposite
54
+ * fixes: a NESTED install under the scanned root shadows the hoisted copy and one reinstall
55
+ * collapses it, while a SIBLING install root (a linked checkout, a standalone app beside this one)
56
+ * owns its own `node_modules` and no reinstall here can touch it.
57
+ *
58
+ * Absent on a finding built by hand (a test fixture, an older cached result).
59
+ */
60
+ readonly topology?: string
61
+ /**
62
+ * Why a copy the invoked directory does not import is still fatal here.
63
+ *
64
+ * The gate is workspace-wide on purpose. Scoping it to the invoked app would be more precise and
65
+ * would also reintroduce the blindness this guard was built for: a copy that arrives through a
66
+ * workspace-linked dependency is not visible from the app directory, and that case shipped a
67
+ * broken dev server while the check reported "none". A workspace-wide answer over-reports in
68
+ * exchange for never under-reporting - and over-reporting is the failure a developer can see and
69
+ * act on, where under-reporting is the one nobody knows happened.
70
+ *
71
+ * Present only when the answer would otherwise look wrong: a subdirectory was scanned as its
72
+ * workspace, and at least one copy sits outside that subdirectory.
73
+ */
74
+ readonly scope?: string
75
+ /**
76
+ * The copies exist but the app declared this package single-copy, so the resolver collapses them
77
+ * before anything loads. Reported, never fatal - see `SingleCopyCoverage`.
78
+ */
79
+ readonly deduplicated: boolean
80
+ }
81
+
82
+ /**
83
+ * What the app declared about deduplication, and how much of it is actually armed.
84
+ *
85
+ * A duplicate physical path is only a defect if something still LOADS both copies. An app consuming a
86
+ * linked sibling repository cannot collapse the paths without giving up the property it chose `link:`
87
+ * for - each repository owning its own `node_modules` - so nifra lets it declare the packages instead
88
+ * (`"nifra": { "singleCopy": [...] }` in package.json) and verifies the declaration here rather than
89
+ * failing on the raw path count.
90
+ *
91
+ * Bundled phases need nothing further: `buildClient`/`buildServer`/dev inject the resolver themselves.
92
+ * Unbundled phases do, because Bun's runtime never offers a bare specifier to a resolver hook, so the
93
+ * plugin has to be preloaded to intercept the load. `registration` is that proof, read statically out
94
+ * of `bunfig.toml`.
95
+ */
96
+ export interface SingleCopyCoverage {
97
+ /** Declared package names and patterns, exactly as written. Empty when nothing was declared. */
98
+ readonly declared: readonly string[]
99
+ readonly registration: SingleCopyRegistration
44
100
  }
45
101
 
46
102
  export interface IdentityParityResult {
103
+ /**
104
+ * The governing workspace root: where importer enumeration starts, and how far a copy lookup may
105
+ * walk up from an importer.
106
+ *
107
+ * EVERY caller resolves this the same way. The doctor and the build/dev preflight used to differ
108
+ * here - one anchored at the workspace, the other at the app directory - so the same project could
109
+ * be told it had duplicates by one command and a clean bill by the other. Two answers from one
110
+ * toolchain is worse than either answer, so the basis is now fixed and reported rather than chosen.
111
+ */
47
112
  readonly workspaceRoot: string
113
+ /** The directory the caller asked about. Differs from `workspaceRoot` when a package subdirectory
114
+ * is governed by a workspace above it; carried so a report can state the basis it scanned on. */
115
+ readonly requestedRoot: string
116
+ /**
117
+ * Enumeration stopped at `MAX_WORKSPACE_IMPORTERS`, so this scan is PARTIAL.
118
+ *
119
+ * An empty `findings` then means "nothing found in the part that was scanned", never "clean" - a
120
+ * caller that prints a clean bill on a truncated scan is the exact silence this flag exists to
121
+ * prevent.
122
+ */
123
+ readonly truncated: boolean
124
+ /** Findings that no declaration covers - the ones a build must refuse to start on. */
48
125
  readonly findings: readonly IdentityParityFinding[]
49
- }
50
-
51
- export interface IdentityParityOptions {
52
- /** Scan the governing workspace when the caller is a package subdirectory. */
53
- readonly useWorkspaceRoot?: boolean
126
+ /** Duplicates the declaration covers. Worth printing, never worth failing. */
127
+ readonly deduplicated: readonly IdentityParityFinding[]
128
+ readonly singleCopy: SingleCopyCoverage
54
129
  }
55
130
 
56
131
  export interface BuildManifestLike {
@@ -75,11 +150,20 @@ export interface DevelopmentParityInput {
75
150
  readonly routes: Readonly<Record<string, number>>
76
151
  readonly publicFiles: readonly string[]
77
152
  readonly css: readonly string[]
153
+ /** The scanned first-party source root, carried only so a css parity failure can name where the
154
+ * scanner looked. Optional: callers that hand-build an input for a unit test may omit it. */
155
+ readonly sourceRoot?: string
78
156
  }
79
157
 
80
158
  const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/
159
+ /** A first-party reference to a stylesheet by an explicit path specifier. Covers static `import`,
160
+ * re-export `from`, dynamic `import(...)`, and `require(...)`. It stays deliberately *sound*: it only
161
+ * matches when a stylesheet specifier genuinely exists, never on an unreferenced `.css` file in the
162
+ * tree. With the css parity comparison now directional (a scanner miss passes, a false positive fails),
163
+ * a loose pattern would fail correct apps. Specifiers that cannot end in a stylesheet extension (a bare
164
+ * package `exports` subpath) stay unreachable without a resolver; those fall in the passing direction. */
81
165
  const CSS_IMPORT =
82
- /(?:import\s+(?:[^"']+\s+from\s+)?|from\s+)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
166
+ /(?:import\s+(?:[^"']+\s+from\s+)?|from\s+|(?:import|require)\s*\(\s*)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
83
167
  /** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
84
168
  * stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
85
169
  * or a scoped-style component would look style-free next to a production manifest that carries css. */
@@ -160,6 +244,16 @@ const workspaceContains = async (
160
244
  return false
161
245
  }
162
246
 
247
+ /** The real path, or the input when it cannot be resolved. Roots are compared against each other and
248
+ * printed side by side, so they have to be normalized the same way (`/var` vs `/private/var`). */
249
+ const realpathOrSelf = (path: string): string => {
250
+ try {
251
+ return realpathSync(path)
252
+ } catch {
253
+ return path
254
+ }
255
+ }
256
+
163
257
  /** Resolve the workspace root once, even when the caller starts in a workspace package. */
164
258
  export async function resolveParityWorkspaceRoot(
165
259
  start: string,
@@ -178,17 +272,33 @@ export async function resolveParityWorkspaceRoot(
178
272
  }
179
273
  }
180
274
 
275
+ /**
276
+ * Every workspace manifest that can install its own copy of a dependency.
277
+ *
278
+ * A pathological workspace must not make the scan unbounded, but it must not make it LIE either: the
279
+ * cap used to discard everything collected and return an empty list, which reads downstream as "no
280
+ * duplicates" - a clean bill from a scan that never ran. The bounded prefix is kept and `truncated`
281
+ * says the rest was not looked at.
282
+ */
181
283
  const workspaceImporters = async (
182
284
  root: string,
183
285
  rootPackage: Record<string, unknown>,
184
- ): Promise<readonly { root: string; package: Record<string, unknown> }[]> => {
286
+ ): Promise<{
287
+ readonly importers: readonly { root: string; package: Record<string, unknown> }[]
288
+ readonly truncated: boolean
289
+ }> => {
185
290
  const manifests = new Set<string>([join(root, "package.json")])
291
+ let truncated = false
186
292
  for (const pattern of workspacePatterns(rootPackage)) {
293
+ if (truncated) break
187
294
  const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
188
295
  for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
189
296
  if (match.split(/[\\/]/).includes("node_modules")) continue
297
+ if (manifests.size >= MAX_WORKSPACE_IMPORTERS) {
298
+ truncated = true
299
+ break
300
+ }
190
301
  manifests.add(join(root, match))
191
- if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
192
302
  }
193
303
  }
194
304
  const importers: { root: string; package: Record<string, unknown> }[] = []
@@ -196,7 +306,7 @@ const workspaceImporters = async (
196
306
  const pkg = await readJson(manifest)
197
307
  if (pkg !== undefined) importers.push({ root: dirname(manifest), package: pkg })
198
308
  }
199
- return importers
309
+ return { importers, truncated }
200
310
  }
201
311
 
202
312
  export const resolvedInstalledCopy = async (
@@ -225,7 +335,23 @@ export const resolvedInstalledCopy = async (
225
335
  }
226
336
  }
227
337
 
338
+ /**
339
+ * How far above a linked package its dependencies may legitimately live.
340
+ *
341
+ * A linked SOURCE CHECKOUT (`link:../../nifra/packages/core`) really does resolve its imports from its
342
+ * own repo's `node_modules`, so the walk goes up to that repo's `.git` - a duplicate found there is one
343
+ * this project genuinely dual-loads.
344
+ *
345
+ * A path that lands INSIDE a `node_modules` directory is a package-manager store copy, not a checkout:
346
+ * a symlink into another project's store (bun's `.bun/<pkg>@<version>`, an `npm link` target, a shared
347
+ * global store) resolves there. Everything above such a copy belongs to whoever owns that store, and
348
+ * walking up would sweep an unrelated project's entire dependency tree into this project's duplicate
349
+ * report - permanent findings in a repo the developer is not even working in, that no change here can
350
+ * clear. Clamp to the copy itself: only its own nested `node_modules` counts, which is exactly the set
351
+ * of modules it can actually load.
352
+ */
228
353
  const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
354
+ if (packageRoot.split(sep).includes("node_modules")) return packageRoot
229
355
  let dir = packageRoot
230
356
  for (let depth = 0; depth < 8; depth++) {
231
357
  if (await pathExists(join(dir, ".git"))) return dir
@@ -240,7 +366,7 @@ const linkedPackageRoots = async (
240
366
  importerRoots: readonly string[],
241
367
  manifests: readonly Record<string, unknown>[],
242
368
  scanRoot: string,
243
- ): Promise<readonly string[]> => {
369
+ ): Promise<{ readonly roots: readonly string[]; readonly truncated: boolean }> => {
244
370
  const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
245
371
  const found = new Map<string, true>()
246
372
  let probes = 0
@@ -248,7 +374,11 @@ const linkedPackageRoots = async (
248
374
  const pkg = manifests[index]
249
375
  if (pkg === undefined) continue
250
376
  for (const name of dependencyNames(pkg)) {
251
- if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) return [...found.keys()]
377
+ // Same rule as the importer cap: a bounded scan reports that it stopped. Returning the prefix
378
+ // silently would let a duplicate sitting past the cap read as a clean bill.
379
+ if (probes++ >= MAX_LINK_PROBES || found.size >= MAX_LINKED_PACKAGES) {
380
+ return { roots: [...found.keys()], truncated: true }
381
+ }
252
382
  const candidate = join(importerRoot, "node_modules", ...name.split("/"))
253
383
  const link = await lstat(candidate).catch(() => undefined)
254
384
  if (link === undefined || !link.isSymbolicLink()) continue
@@ -257,7 +387,7 @@ const linkedPackageRoots = async (
257
387
  found.set(resolved, true)
258
388
  }
259
389
  }
260
- return [...found.keys()]
390
+ return { roots: [...found.keys()], truncated: false }
261
391
  }
262
392
 
263
393
  export const displayPath = (cwd: string, path: string): string => {
@@ -265,29 +395,131 @@ export const displayPath = (cwd: string, path: string): string => {
265
395
  return rel === "" ? "." : rel
266
396
  }
267
397
 
398
+ /**
399
+ * The install root a copy belongs to: the directory ABOVE its last `node_modules` segment.
400
+ *
401
+ * That directory is what a package manager operates on, so it is the unit a fix is expressed in. A
402
+ * copy that sits in no `node_modules` at all is a source checkout, which is its own install root.
403
+ */
404
+ const installRootOf = (path: string): string => {
405
+ const parts = path.split(sep)
406
+ const last = parts.lastIndexOf("node_modules")
407
+ if (last <= 0) return path
408
+ return parts.slice(0, last).join(sep)
409
+ }
410
+
411
+ /**
412
+ * State the SHAPE of a duplicate, not just its paths.
413
+ *
414
+ * The failure a developer has to act on is a topology, and the two topologies take opposite fixes.
415
+ * Copies under one install root (a nested `node_modules` shadowing the hoisted one) collapse with a
416
+ * single reinstall from that root. Copies under separate roots - a linked checkout, a standalone app
417
+ * beside this one with its own `node_modules` - do not: that second root belongs to another install,
418
+ * and nothing this project runs can merge it. Naming which case this is turns a path list into a
419
+ * decision.
420
+ */
421
+ const describeTopology = (
422
+ base: string,
423
+ scanRoot: string,
424
+ paths: readonly string[],
425
+ ): string | undefined => {
426
+ if (paths.length === 0) return undefined
427
+ const counts = new Map<string, number>()
428
+ for (const path of paths) {
429
+ const root = installRootOf(path)
430
+ counts.set(root, (counts.get(root) ?? 0) + 1)
431
+ }
432
+ const roots = [...counts.entries()].sort(([a], [b]) => a.localeCompare(b))
433
+ const breakdown = roots
434
+ .map(([root, count]) => `${count} under ${displayPath(base, root)}`)
435
+ .join(", ")
436
+ const outside = roots.filter(([root]) => !pathInside(scanRoot, root))
437
+ const shape =
438
+ outside.length > 0
439
+ ? `${outside.length} install root${outside.length === 1 ? " is" : "s are"} outside the scanned root (${outside
440
+ .map(([root]) => displayPath(base, root))
441
+ .join(
442
+ ", ",
443
+ )}), so that copy is installed by another project and reinstalling here cannot remove it`
444
+ : roots.length > 1
445
+ ? "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"
446
+ : "one install root holds every copy, so the split is inside a single install (a nested or stored copy), not across projects"
447
+ return `${paths.length} paths under ${roots.length} install root${roots.length === 1 ? "" : "s"}: ${breakdown}; ${shape}`
448
+ }
449
+
450
+ /**
451
+ * State that the gate is workspace-wide, when that is what makes a finding look wrong.
452
+ *
453
+ * Running `nifra build` inside `apps/web` can fail on a copy held by `apps/admin` - a package this
454
+ * app never imports. That is deliberate, not a miss: the scan is anchored on the workspace because
455
+ * copies reached through a workspace-linked dependency are invisible from the app directory, and
456
+ * anchoring on the app is exactly how this guard once reported "none" while dev was already broken.
457
+ * The trade is over-reporting instead of under-reporting, and the note says so rather than leaving a
458
+ * developer to conclude the tool is confused about which project it is checking.
459
+ */
460
+ const describeScope = (
461
+ requestedRoot: string,
462
+ scanRoot: string,
463
+ paths: readonly string[],
464
+ ): string | undefined => {
465
+ if (requestedRoot === scanRoot) return undefined
466
+ const outside = paths.filter((path) => !pathInside(requestedRoot, path))
467
+ if (outside.length === 0) return undefined
468
+ const here = relative(scanRoot, requestedRoot) || "."
469
+ 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.`
470
+ }
471
+
268
472
  const identityTargets = (pkg: Record<string, unknown>): readonly string[] =>
269
- dependencyNames(pkg)
270
- .filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
271
- .sort()
473
+ dependencyNames(pkg).filter(isIdentitySensitivePackage).sort()
474
+
475
+ /** version-skew is a range problem: one reinstall from the root collapses it. */
476
+ const VERSION_SKEW_REMEDIATION =
477
+ "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy; nifra does not rewrite lockfiles for identity conflicts."
478
+ /** duplicate-path is a topology problem. When the copies come from a linked sibling repo a reinstall
479
+ * does not collapse them - each tree keeps its own copy, which is the whole reason `link:` was chosen.
480
+ * So the fix is either one physical path, or a declaration that nifra can verify and enforce. */
481
+ const duplicatePathRemediation = (name: string): string =>
482
+ `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.`
483
+ const identityRemediation = (cause: IdentityParityCause, name: string): string =>
484
+ cause === "version-skew" ? VERSION_SKEW_REMEDIATION : duplicatePathRemediation(name)
485
+
486
+ /** What is left to do about a duplicate the declaration already covers. */
487
+ const deduplicatedRemediation = (registration: SingleCopyRegistration): string =>
488
+ registration.run || registration.test
489
+ ? "Declared single-copy and enforced: every duplicate is rewritten to this app's copy, so one module instance loads."
490
+ : `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.`
272
491
 
273
- const IDENTITY_REMEDIATION =
274
- "Align dependency ranges and reinstall from the workspace root so every importer resolves one physical copy."
492
+ /**
493
+ * Read the declaration and its runtime proof, preferring the directory the caller is in.
494
+ *
495
+ * A monorepo declares this per app, not once at the root: the app is what owns the `node_modules` the
496
+ * duplicates must collapse into, and two apps in one workspace can legitimately differ. The workspace
497
+ * root is the fallback so a single-package project needs no second file.
498
+ */
499
+ const singleCopyCoverage = (cwd: string, scanRoot: string): SingleCopyCoverage => {
500
+ const declared = readSingleCopyDeclaration(cwd) ?? readSingleCopyDeclaration(scanRoot) ?? []
501
+ const local = readSingleCopyRegistration(cwd)
502
+ const registration = local.run || local.test ? local : readSingleCopyRegistration(scanRoot)
503
+ return { declared, registration }
504
+ }
275
505
 
276
506
  /** Find duplicate identity-sensitive package realpaths without reading application payloads. */
277
507
  export async function collectIdentityParity(
278
508
  cwd: string,
279
509
  rootPackage?: Record<string, unknown>,
280
- options: IdentityParityOptions = {},
281
510
  ): Promise<IdentityParityResult> {
282
- const requestedRoot = resolve(cwd)
511
+ const requestedRoot = realpathOrSelf(resolve(cwd))
283
512
  const localPackage = await readJson(join(requestedRoot, "package.json"))
513
+ // One basis for every caller. A package subdirectory is scanned as the workspace that governs it,
514
+ // because that workspace is what installed the copies it loads - and because a doctor and a build
515
+ // that anchor differently answer the same question differently, which is how a guard loses trust.
284
516
  const workspace =
285
- options.useWorkspaceRoot && localPackage !== undefined
286
- ? await resolveParityWorkspaceRoot(requestedRoot)
287
- : { root: requestedRoot, package: localPackage ?? rootPackage ?? {} }
517
+ localPackage === undefined
518
+ ? { root: requestedRoot, package: rootPackage ?? {} }
519
+ : await resolveParityWorkspaceRoot(requestedRoot)
288
520
  const scanRoot = workspace.root
289
521
  const scanPackage = workspace.package
290
- const importers = await workspaceImporters(scanRoot, scanPackage)
522
+ const { importers, truncated } = await workspaceImporters(scanRoot, scanPackage)
291
523
  const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
292
524
  const record = (
293
525
  name: string,
@@ -307,20 +539,20 @@ export async function collectIdentityParity(
307
539
  if (importer.package.name === name) continue
308
540
  targets.add(name)
309
541
  const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
310
- if (copy !== undefined) record(name, copy, displayPath(cwd, importer.root))
542
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, importer.root))
311
543
  }
312
544
  }
313
545
  for (const name of targets) {
314
546
  const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
315
- if (copy !== undefined) record(name, copy, displayPath(cwd, scanRoot))
547
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, scanRoot))
316
548
  }
317
549
 
318
- const linkedRoots = await linkedPackageRoots(
550
+ const linked = await linkedPackageRoots(
319
551
  [...importers.map((importer) => importer.root), scanRoot],
320
552
  [...importers.map((importer) => importer.package), scanPackage],
321
553
  scanRoot,
322
554
  )
323
- for (const linkedRoot of linkedRoots) {
555
+ for (const linkedRoot of linked.roots) {
324
556
  const linkedPackage = await readJson(join(linkedRoot, "package.json"))
325
557
  const boundary = await linkedRepoBoundary(linkedRoot)
326
558
  const linkedTargets = new Set([
@@ -330,46 +562,127 @@ export async function collectIdentityParity(
330
562
  for (const name of linkedTargets) {
331
563
  if (linkedPackage?.name === name) continue
332
564
  const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
333
- if (copy !== undefined) record(name, copy, displayPath(cwd, linkedRoot))
565
+ if (copy !== undefined) record(name, copy, displayPath(requestedRoot, linkedRoot))
334
566
  }
335
567
  }
336
568
 
569
+ const singleCopy = singleCopyCoverage(requestedRoot, scanRoot)
337
570
  const findings: IdentityParityFinding[] = []
571
+ const deduplicated: IdentityParityFinding[] = []
338
572
  for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
339
573
  if (copies.size < 2) continue
340
- findings.push({
574
+ const absolutePaths = [...copies.keys()].sort()
575
+ const resolvedCopies = [...copies.entries()]
576
+ .sort(([a], [b]) => a.localeCompare(b))
577
+ .map(([path, copy]) => ({
578
+ version: copy.version,
579
+ path: displayPath(requestedRoot, path),
580
+ importers: [...copy.importers].sort(),
581
+ }))
582
+ const topology = describeTopology(requestedRoot, scanRoot, absolutePaths)
583
+ const scope = describeScope(requestedRoot, scanRoot, absolutePaths)
584
+ const versions = [...new Set(resolvedCopies.map((copy) => copy.version))].sort()
585
+ const cause: IdentityParityCause = versions.length > 1 ? "version-skew" : "duplicate-path"
586
+ // A declaration never covers a version skew. Redirecting across versions would hand a package a
587
+ // version it did not ask for, which converts a loud install problem into a quiet behavioural one -
588
+ // so the skew stays fatal and the ranges have to be fixed.
589
+ const covered =
590
+ cause === "duplicate-path" && matchesSingleCopyDeclaration(singleCopy.declared, name)
591
+ const finding: IdentityParityFinding = {
341
592
  package: name,
342
- copies: [...copies.entries()]
343
- .sort(([a], [b]) => a.localeCompare(b))
344
- .map(([path, copy]) => ({
345
- version: copy.version,
346
- path: displayPath(cwd, path),
347
- importers: [...copy.importers].sort(),
348
- })),
349
- explanation: `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
350
- remediation: IDENTITY_REMEDIATION,
351
- })
593
+ copies: resolvedCopies,
594
+ versions,
595
+ cause,
596
+ explanation:
597
+ cause === "version-skew"
598
+ ? `${name} resolves to multiple versions and physical paths, so module state and symbols are not shared`
599
+ : covered
600
+ ? `${name} is installed at more than one physical path and is declared single-copy, so every duplicate is rewritten to this app's copy`
601
+ : `${name} is loaded from more than one physical path, so module state and symbols are not shared`,
602
+ remediation: covered
603
+ ? deduplicatedRemediation(singleCopy.registration)
604
+ : identityRemediation(cause, name),
605
+ ...(topology === undefined ? {} : { topology }),
606
+ ...(scope === undefined ? {} : { scope }),
607
+ deduplicated: covered,
608
+ }
609
+ ;(covered ? deduplicated : findings).push(finding)
610
+ }
611
+ return {
612
+ workspaceRoot: scanRoot,
613
+ requestedRoot,
614
+ truncated: truncated || linked.truncated,
615
+ findings,
616
+ deduplicated,
617
+ singleCopy,
352
618
  }
353
- return { workspaceRoot: scanRoot, findings }
354
619
  }
355
620
 
356
- /** Fail before dev/build when the same identity-sensitive package has multiple realpaths. */
357
- export async function assertIdentityParity(
358
- cwd: string,
359
- rootPackage?: Record<string, unknown>,
360
- options: IdentityParityOptions = {},
361
- ): Promise<IdentityParityResult> {
362
- const result = await collectIdentityParity(cwd, rootPackage, options)
363
- if (result.findings.length === 0) return result
364
- const details = result.findings
621
+ /** One `- pkg [cause]: ...` block per finding, shared by the hard failure and the dev warning. */
622
+ export function formatIdentityParityFindings(findings: readonly IdentityParityFinding[]): string {
623
+ return findings
365
624
  .map(
366
625
  (finding) =>
367
- `${finding.package}: ${finding.explanation}. ${finding.remediation} Paths: ${finding.copies
626
+ `- ${finding.package} [${finding.cause}]: ${finding.explanation}. ${finding.remediation}\n` +
627
+ ` versions: ${finding.versions.join(", ")}; paths: ${finding.copies
368
628
  .map((copy) => copy.path)
369
- .join(", ")}`,
629
+ .join(", ")}` +
630
+ (finding.topology === undefined ? "" : `\n topology: ${finding.topology}`) +
631
+ (finding.scope === undefined ? "" : `\n scope: ${finding.scope}`),
370
632
  )
371
- .join("; ")
372
- throw new Error(`[nifra] identity parity failed: ${details}`)
633
+ .join("\n")
634
+ }
635
+
636
+ /**
637
+ * The basis a scan ran on, for any surface that reports its result.
638
+ *
639
+ * A verdict about installs is only as good as where it looked, and the reader cannot see that from a
640
+ * list of relative paths. Stating it also makes a truncated scan impossible to read as a clean one.
641
+ */
642
+ export function identityParityBasis(result: IdentityParityResult): string {
643
+ const scope =
644
+ result.workspaceRoot === result.requestedRoot
645
+ ? `scanned ${result.workspaceRoot}`
646
+ : `scanned ${result.workspaceRoot} (the workspace governing ${result.requestedRoot})`
647
+ return result.truncated
648
+ ? `${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`
649
+ : scope
650
+ }
651
+
652
+ /** `2 primary package findings` / `1 primary package finding`. */
653
+ export const identityParityHeadline = (count: number): string =>
654
+ `${count} primary package finding${count === 1 ? "" : "s"}`
655
+
656
+ /**
657
+ * Fail before dev/build when the same identity-sensitive package has multiple realpaths AND nothing
658
+ * is collapsing them.
659
+ *
660
+ * A declared package is not a suppression: the build injects the single-copy resolver, so the duplicate
661
+ * genuinely cannot reach the output. Failing on it anyway would leave the only supported answer being
662
+ * to change the install topology, which is the thing an app using linked sibling repositories cannot
663
+ * do. `result.deduplicated` still carries every covered duplicate for the caller to print.
664
+ */
665
+ export async function assertIdentityParity(
666
+ cwd: string,
667
+ rootPackage?: Record<string, unknown>,
668
+ ): Promise<IdentityParityResult> {
669
+ const result = await collectIdentityParity(cwd, rootPackage)
670
+ if (result.findings.length === 0) {
671
+ // A bounded scan that found nothing has not shown there is nothing: the duplicate can be sitting
672
+ // in the part it never reached. `truncated` is exactly that state, so the hard gate refuses to
673
+ // read it as a pass - a guard that answers "clean" from an incomplete scan is how a guard stops
674
+ // being worth having. Reporting surfaces (`nifra doctor`, the dev warning) still print the
675
+ // partial result through `identityParityBasis`; only the gate that must not be wrong stops here.
676
+ if (result.truncated) {
677
+ throw new Error(
678
+ `[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.`,
679
+ )
680
+ }
681
+ return result
682
+ }
683
+ throw new Error(
684
+ `[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}; ${identityParityBasis(result)}):\n${formatIdentityParityFindings(result.findings)}`,
685
+ )
373
686
  }
374
687
 
375
688
  const assetName = (url: string): string => url.replace(/^.*\//, "")
@@ -424,9 +737,13 @@ export function normalizeBuildManifest(manifest: BuildManifestLike): ParityManif
424
737
  .sort(([a], [b]) => a.localeCompare(b))
425
738
  .map(([route, chunks]) => [route, chunks.length]),
426
739
  ),
427
- emittedAssets: logicalStaticAssets(manifest).filter(
428
- (asset) => !asset.startsWith("css:") && !asset.startsWith("public:"),
429
- ),
740
+ // Allowlist, not denylist: the dev/prod module-graph contract is the JavaScript module graph
741
+ // only. `js:entry` and `js:route:<id>:<n>` are the roles dev can reconstruct from source. Which
742
+ // *non-JS* files a bundler emits (svg, woff2, an extracted stylesheet) is an output detail, not
743
+ // a claim dev can make - dev serves those from source. The stylesheet claim that matters keeps
744
+ // its own `css` section; `public/` keeps `publicFiles`. A denylist admitted every future
745
+ // `asset:*` prefix by default, which is how an emitted `asset:svg` hard-failed every build.
746
+ emittedAssets: logicalStaticAssets(manifest).filter((asset) => asset.startsWith("js:")),
430
747
  },
431
748
  publicFiles: [...(manifest.publicFiles ?? [])].sort(),
432
749
  css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
@@ -505,6 +822,7 @@ export function collectDevelopmentParityInput(
505
822
  routes,
506
823
  publicFiles: publicFilesUnder(publicDir),
507
824
  css,
825
+ sourceRoot,
508
826
  }
509
827
  }
510
828
 
@@ -527,11 +845,73 @@ export function compareManifestParity(
527
845
  development: development.publicFiles,
528
846
  production: production.publicFiles,
529
847
  })
530
- if (!equal(development.css, production.css))
848
+ // css is the one section with an inference-based side. The dev scanner (CSS_IMPORT) is
849
+ // sound-but-incomplete by construction, so `dev empty, prod css` is a scanner miss, not an app
850
+ // defect - it passes. Fail only the load-bearing direction: dev found styles the build does not
851
+ // ship, so the production page would render unstyled. module-graph and public-files stay on equality
852
+ // because both sides are ground truth there.
853
+ if (development.css.length > 0 && production.css.length === 0)
531
854
  differences.push({ section: "css", development: development.css, production: production.css })
532
855
  return differences
533
856
  }
534
857
 
858
+ /** The forms `CSS_IMPORT` cannot see, quoted in a css parity failure so the reader knows where to look. */
859
+ const CSS_SCANNER_BLIND_SPOTS =
860
+ "a dynamic import(), a bare package subpath (exports), require(), an @import inside a stylesheet, or plugin-injected css"
861
+
862
+ const symmetricDifference = (
863
+ a: readonly string[],
864
+ b: readonly string[],
865
+ ): { readonly onlyDevelopment: readonly string[]; readonly onlyProduction: readonly string[] } => {
866
+ const left = new Set(a)
867
+ const right = new Set(b)
868
+ return {
869
+ onlyDevelopment: a.filter((value) => !right.has(value)),
870
+ onlyProduction: b.filter((value) => !left.has(value)),
871
+ }
872
+ }
873
+
874
+ /** Turn one parity difference into a message that names the offending files, not two opaque blobs. */
875
+ function explainParityDifference(
876
+ difference: ManifestParityDifference,
877
+ development: DevelopmentParityInput,
878
+ production: BuildManifestLike,
879
+ ): string {
880
+ if (difference.section === "css") {
881
+ const shipped = (production.css ?? []).join(", ")
882
+ const where = development.sourceRoot === undefined ? "" : ` under ${development.sourceRoot}`
883
+ return (
884
+ `css: production ships [${shipped || "(none)"}] but the development scanner found no static ` +
885
+ `stylesheet import${where} - it is blind to ${CSS_SCANNER_BLIND_SPOTS}`
886
+ )
887
+ }
888
+ if (difference.section === "module-graph") {
889
+ const dev = difference.development as ParityManifest["moduleGraph"]
890
+ const prod = difference.production as ParityManifest["moduleGraph"]
891
+ const routes = symmetricDifference(dev.routes, prod.routes)
892
+ const assets = symmetricDifference(dev.emittedAssets, prod.emittedAssets)
893
+ const parts: string[] = []
894
+ if (routes.onlyDevelopment.length > 0 || routes.onlyProduction.length > 0)
895
+ parts.push(
896
+ `routes only in development=${JSON.stringify(routes.onlyDevelopment)} only in production=${JSON.stringify(routes.onlyProduction)}`,
897
+ )
898
+ if (assets.onlyDevelopment.length > 0 || assets.onlyProduction.length > 0)
899
+ parts.push(
900
+ `chunks only in development=${JSON.stringify(assets.onlyDevelopment)} only in production=${JSON.stringify(assets.onlyProduction)}`,
901
+ )
902
+ if (parts.length === 0)
903
+ parts.push(
904
+ `route chunk counts differ: development=${JSON.stringify(dev.routeChunks)} production=${JSON.stringify(prod.routeChunks)}`,
905
+ )
906
+ return `module-graph: ${parts.join("; ")}`
907
+ }
908
+ const files = symmetricDifference(
909
+ difference.development as readonly string[],
910
+ difference.production as readonly string[],
911
+ )
912
+ return `${difference.section}: only in development=${JSON.stringify(files.onlyDevelopment)} only in production=${JSON.stringify(files.onlyProduction)}`
913
+ }
914
+
535
915
  /** Validate a production manifest against the source manifest a development server should serve. */
536
916
  export function assertDevelopmentProductionParity(
537
917
  development: DevelopmentParityInput,
@@ -543,10 +923,7 @@ export function assertDevelopmentProductionParity(
543
923
  )
544
924
  if (differences.length > 0) {
545
925
  const detail = differences
546
- .map(
547
- (difference) =>
548
- `${difference.section}: development=${JSON.stringify(difference.development)} production=${JSON.stringify(difference.production)}`,
549
- )
926
+ .map((difference) => explainParityDifference(difference, development, production))
550
927
  .join("; ")
551
928
  throw new Error(`[nifra] development and production manifest parity failed: ${detail}`)
552
929
  }