@nifrajs/web 2.14.1 → 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.
- package/dist/boundary.d.ts +158 -0
- package/dist/boundary.d.ts.map +1 -0
- package/dist/boundary.js +237 -0
- package/dist/boundary.js.map +1 -0
- package/dist/build-vite.d.ts.map +1 -1
- package/dist/build-vite.js +5 -3
- package/dist/build-vite.js.map +1 -1
- package/dist/build.d.ts.map +1 -1
- package/dist/build.js +31 -13
- package/dist/build.js.map +1 -1
- package/dist/index.d.ts +30 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +163 -51
- package/dist/index.js.map +1 -1
- package/dist/internal/fetcher-runtime.d.ts +19 -0
- package/dist/internal/fetcher-runtime.d.ts.map +1 -0
- package/dist/internal/fetcher-runtime.js +26 -0
- package/dist/internal/fetcher-runtime.js.map +1 -0
- package/dist/internal/identity-policy.d.ts +51 -0
- package/dist/internal/identity-policy.d.ts.map +1 -0
- package/dist/internal/identity-policy.js +67 -0
- package/dist/internal/identity-policy.js.map +1 -0
- package/dist/internal/parity.d.ts +97 -8
- package/dist/internal/parity.d.ts.map +1 -1
- package/dist/internal/parity.js +281 -47
- package/dist/internal/parity.js.map +1 -1
- package/dist/internal/query-runtime.d.ts +8 -0
- package/dist/internal/query-runtime.d.ts.map +1 -0
- package/dist/internal/query-runtime.js +29 -0
- package/dist/internal/query-runtime.js.map +1 -0
- package/dist/islands.d.ts +2 -2
- package/dist/islands.d.ts.map +1 -1
- package/dist/islands.js +17 -36
- package/dist/islands.js.map +1 -1
- package/dist/manifest.d.ts +41 -6
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js.map +1 -1
- package/dist/render-seam.d.ts +5 -0
- package/dist/render-seam.d.ts.map +1 -1
- package/dist/render-seam.js +10 -0
- package/dist/render-seam.js.map +1 -1
- package/dist/router.d.ts +10 -0
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +233 -26
- package/dist/router.js.map +1 -1
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +8 -6
- package/dist/vite.js.map +1 -1
- package/package.json +13 -2
- package/src/boundary.ts +407 -0
- package/src/build-vite.ts +5 -3
- package/src/build.ts +36 -14
- package/src/index.ts +235 -49
- package/src/internal/fetcher-runtime.ts +36 -0
- package/src/internal/identity-policy.ts +94 -0
- package/src/internal/parity.ts +409 -57
- package/src/internal/query-runtime.ts +31 -0
- package/src/islands.ts +22 -41
- package/src/manifest.ts +50 -3
- package/src/render-seam.ts +6 -0
- package/src/router.ts +271 -29
- package/src/vite.ts +8 -6
package/src/internal/parity.ts
CHANGED
|
@@ -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
|
|
@@ -47,16 +46,86 @@ export interface IdentityParityFinding {
|
|
|
47
46
|
readonly cause: IdentityParityCause
|
|
48
47
|
readonly explanation: string
|
|
49
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
|
|
50
100
|
}
|
|
51
101
|
|
|
52
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
|
+
*/
|
|
53
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. */
|
|
54
125
|
readonly findings: readonly IdentityParityFinding[]
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
/** Scan the governing workspace when the caller is a package subdirectory. */
|
|
59
|
-
readonly useWorkspaceRoot?: boolean
|
|
126
|
+
/** Duplicates the declaration covers. Worth printing, never worth failing. */
|
|
127
|
+
readonly deduplicated: readonly IdentityParityFinding[]
|
|
128
|
+
readonly singleCopy: SingleCopyCoverage
|
|
60
129
|
}
|
|
61
130
|
|
|
62
131
|
export interface BuildManifestLike {
|
|
@@ -81,11 +150,20 @@ export interface DevelopmentParityInput {
|
|
|
81
150
|
readonly routes: Readonly<Record<string, number>>
|
|
82
151
|
readonly publicFiles: readonly string[]
|
|
83
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
|
|
84
156
|
}
|
|
85
157
|
|
|
86
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. */
|
|
87
165
|
const CSS_IMPORT =
|
|
88
|
-
/(?:import\s+(?:[^"']+\s+from\s+)?|from\s
|
|
166
|
+
/(?:import\s+(?:[^"']+\s+from\s+)?|from\s+|(?:import|require)\s*\(\s*)["'][^"']+\.(?:css|scss|sass|less|styl)(?:\?[^"']*)?["']/i
|
|
89
167
|
/** A single-file-component `<style>` block (Svelte/Vue). The bundler extracts these into the app
|
|
90
168
|
* stylesheet even though no `import "...css"` statement exists, so the dev contract must count them
|
|
91
169
|
* or a scoped-style component would look style-free next to a production manifest that carries css. */
|
|
@@ -166,6 +244,16 @@ const workspaceContains = async (
|
|
|
166
244
|
return false
|
|
167
245
|
}
|
|
168
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
|
+
|
|
169
257
|
/** Resolve the workspace root once, even when the caller starts in a workspace package. */
|
|
170
258
|
export async function resolveParityWorkspaceRoot(
|
|
171
259
|
start: string,
|
|
@@ -184,17 +272,33 @@ export async function resolveParityWorkspaceRoot(
|
|
|
184
272
|
}
|
|
185
273
|
}
|
|
186
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
|
+
*/
|
|
187
283
|
const workspaceImporters = async (
|
|
188
284
|
root: string,
|
|
189
285
|
rootPackage: Record<string, unknown>,
|
|
190
|
-
): Promise<
|
|
286
|
+
): Promise<{
|
|
287
|
+
readonly importers: readonly { root: string; package: Record<string, unknown> }[]
|
|
288
|
+
readonly truncated: boolean
|
|
289
|
+
}> => {
|
|
191
290
|
const manifests = new Set<string>([join(root, "package.json")])
|
|
291
|
+
let truncated = false
|
|
192
292
|
for (const pattern of workspacePatterns(rootPackage)) {
|
|
293
|
+
if (truncated) break
|
|
193
294
|
const packagePattern = `${pattern.replace(/\/$/, "")}/package.json`
|
|
194
295
|
for await (const match of new Bun.Glob(packagePattern).scan({ cwd: root, dot: false })) {
|
|
195
296
|
if (match.split(/[\\/]/).includes("node_modules")) continue
|
|
297
|
+
if (manifests.size >= MAX_WORKSPACE_IMPORTERS) {
|
|
298
|
+
truncated = true
|
|
299
|
+
break
|
|
300
|
+
}
|
|
196
301
|
manifests.add(join(root, match))
|
|
197
|
-
if (manifests.size > MAX_WORKSPACE_IMPORTERS) return []
|
|
198
302
|
}
|
|
199
303
|
}
|
|
200
304
|
const importers: { root: string; package: Record<string, unknown> }[] = []
|
|
@@ -202,7 +306,7 @@ const workspaceImporters = async (
|
|
|
202
306
|
const pkg = await readJson(manifest)
|
|
203
307
|
if (pkg !== undefined) importers.push({ root: dirname(manifest), package: pkg })
|
|
204
308
|
}
|
|
205
|
-
return importers
|
|
309
|
+
return { importers, truncated }
|
|
206
310
|
}
|
|
207
311
|
|
|
208
312
|
export const resolvedInstalledCopy = async (
|
|
@@ -231,7 +335,23 @@ export const resolvedInstalledCopy = async (
|
|
|
231
335
|
}
|
|
232
336
|
}
|
|
233
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
|
+
*/
|
|
234
353
|
const linkedRepoBoundary = async (packageRoot: string): Promise<string> => {
|
|
354
|
+
if (packageRoot.split(sep).includes("node_modules")) return packageRoot
|
|
235
355
|
let dir = packageRoot
|
|
236
356
|
for (let depth = 0; depth < 8; depth++) {
|
|
237
357
|
if (await pathExists(join(dir, ".git"))) return dir
|
|
@@ -246,7 +366,7 @@ const linkedPackageRoots = async (
|
|
|
246
366
|
importerRoots: readonly string[],
|
|
247
367
|
manifests: readonly Record<string, unknown>[],
|
|
248
368
|
scanRoot: string,
|
|
249
|
-
): Promise<readonly string[]> => {
|
|
369
|
+
): Promise<{ readonly roots: readonly string[]; readonly truncated: boolean }> => {
|
|
250
370
|
const realScanRoot = await realpath(scanRoot).catch(() => scanRoot)
|
|
251
371
|
const found = new Map<string, true>()
|
|
252
372
|
let probes = 0
|
|
@@ -254,7 +374,11 @@ const linkedPackageRoots = async (
|
|
|
254
374
|
const pkg = manifests[index]
|
|
255
375
|
if (pkg === undefined) continue
|
|
256
376
|
for (const name of dependencyNames(pkg)) {
|
|
257
|
-
|
|
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
|
+
}
|
|
258
382
|
const candidate = join(importerRoot, "node_modules", ...name.split("/"))
|
|
259
383
|
const link = await lstat(candidate).catch(() => undefined)
|
|
260
384
|
if (link === undefined || !link.isSymbolicLink()) continue
|
|
@@ -263,7 +387,7 @@ const linkedPackageRoots = async (
|
|
|
263
387
|
found.set(resolved, true)
|
|
264
388
|
}
|
|
265
389
|
}
|
|
266
|
-
return [...found.keys()]
|
|
390
|
+
return { roots: [...found.keys()], truncated: false }
|
|
267
391
|
}
|
|
268
392
|
|
|
269
393
|
export const displayPath = (cwd: string, path: string): string => {
|
|
@@ -271,29 +395,131 @@ export const displayPath = (cwd: string, path: string): string => {
|
|
|
271
395
|
return rel === "" ? "." : rel
|
|
272
396
|
}
|
|
273
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
|
+
|
|
274
472
|
const identityTargets = (pkg: Record<string, unknown>): readonly string[] =>
|
|
275
|
-
dependencyNames(pkg)
|
|
276
|
-
.filter((name) => name.startsWith("@nifrajs/") || IDENTITY_SENSITIVE_PACKAGES.has(name))
|
|
277
|
-
.sort()
|
|
473
|
+
dependencyNames(pkg).filter(isIdentitySensitivePackage).sort()
|
|
278
474
|
|
|
279
|
-
|
|
475
|
+
/** version-skew is a range problem: one reinstall from the root collapses it. */
|
|
476
|
+
const VERSION_SKEW_REMEDIATION =
|
|
280
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.`
|
|
491
|
+
|
|
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
|
+
}
|
|
281
505
|
|
|
282
506
|
/** Find duplicate identity-sensitive package realpaths without reading application payloads. */
|
|
283
507
|
export async function collectIdentityParity(
|
|
284
508
|
cwd: string,
|
|
285
509
|
rootPackage?: Record<string, unknown>,
|
|
286
|
-
options: IdentityParityOptions = {},
|
|
287
510
|
): Promise<IdentityParityResult> {
|
|
288
|
-
const requestedRoot = resolve(cwd)
|
|
511
|
+
const requestedRoot = realpathOrSelf(resolve(cwd))
|
|
289
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.
|
|
290
516
|
const workspace =
|
|
291
|
-
|
|
292
|
-
?
|
|
293
|
-
:
|
|
517
|
+
localPackage === undefined
|
|
518
|
+
? { root: requestedRoot, package: rootPackage ?? {} }
|
|
519
|
+
: await resolveParityWorkspaceRoot(requestedRoot)
|
|
294
520
|
const scanRoot = workspace.root
|
|
295
521
|
const scanPackage = workspace.package
|
|
296
|
-
const importers = await workspaceImporters(scanRoot, scanPackage)
|
|
522
|
+
const { importers, truncated } = await workspaceImporters(scanRoot, scanPackage)
|
|
297
523
|
const byPackage = new Map<string, Map<string, { version: string; importers: Set<string> }>>()
|
|
298
524
|
const record = (
|
|
299
525
|
name: string,
|
|
@@ -313,20 +539,20 @@ export async function collectIdentityParity(
|
|
|
313
539
|
if (importer.package.name === name) continue
|
|
314
540
|
targets.add(name)
|
|
315
541
|
const copy = await resolvedInstalledCopy(importer.root, scanRoot, name)
|
|
316
|
-
if (copy !== undefined) record(name, copy, displayPath(
|
|
542
|
+
if (copy !== undefined) record(name, copy, displayPath(requestedRoot, importer.root))
|
|
317
543
|
}
|
|
318
544
|
}
|
|
319
545
|
for (const name of targets) {
|
|
320
546
|
const copy = await resolvedInstalledCopy(scanRoot, scanRoot, name)
|
|
321
|
-
if (copy !== undefined) record(name, copy, displayPath(
|
|
547
|
+
if (copy !== undefined) record(name, copy, displayPath(requestedRoot, scanRoot))
|
|
322
548
|
}
|
|
323
549
|
|
|
324
|
-
const
|
|
550
|
+
const linked = await linkedPackageRoots(
|
|
325
551
|
[...importers.map((importer) => importer.root), scanRoot],
|
|
326
552
|
[...importers.map((importer) => importer.package), scanPackage],
|
|
327
553
|
scanRoot,
|
|
328
554
|
)
|
|
329
|
-
for (const linkedRoot of
|
|
555
|
+
for (const linkedRoot of linked.roots) {
|
|
330
556
|
const linkedPackage = await readJson(join(linkedRoot, "package.json"))
|
|
331
557
|
const boundary = await linkedRepoBoundary(linkedRoot)
|
|
332
558
|
const linkedTargets = new Set([
|
|
@@ -336,23 +562,33 @@ export async function collectIdentityParity(
|
|
|
336
562
|
for (const name of linkedTargets) {
|
|
337
563
|
if (linkedPackage?.name === name) continue
|
|
338
564
|
const copy = await resolvedInstalledCopy(linkedRoot, boundary, name)
|
|
339
|
-
if (copy !== undefined) record(name, copy, displayPath(
|
|
565
|
+
if (copy !== undefined) record(name, copy, displayPath(requestedRoot, linkedRoot))
|
|
340
566
|
}
|
|
341
567
|
}
|
|
342
568
|
|
|
569
|
+
const singleCopy = singleCopyCoverage(requestedRoot, scanRoot)
|
|
343
570
|
const findings: IdentityParityFinding[] = []
|
|
571
|
+
const deduplicated: IdentityParityFinding[] = []
|
|
344
572
|
for (const [name, copies] of [...byPackage.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
345
573
|
if (copies.size < 2) continue
|
|
574
|
+
const absolutePaths = [...copies.keys()].sort()
|
|
346
575
|
const resolvedCopies = [...copies.entries()]
|
|
347
576
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
348
577
|
.map(([path, copy]) => ({
|
|
349
578
|
version: copy.version,
|
|
350
|
-
path: displayPath(
|
|
579
|
+
path: displayPath(requestedRoot, path),
|
|
351
580
|
importers: [...copy.importers].sort(),
|
|
352
581
|
}))
|
|
582
|
+
const topology = describeTopology(requestedRoot, scanRoot, absolutePaths)
|
|
583
|
+
const scope = describeScope(requestedRoot, scanRoot, absolutePaths)
|
|
353
584
|
const versions = [...new Set(resolvedCopies.map((copy) => copy.version))].sort()
|
|
354
585
|
const cause: IdentityParityCause = versions.length > 1 ? "version-skew" : "duplicate-path"
|
|
355
|
-
|
|
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 = {
|
|
356
592
|
package: name,
|
|
357
593
|
copies: resolvedCopies,
|
|
358
594
|
versions,
|
|
@@ -360,14 +596,29 @@ export async function collectIdentityParity(
|
|
|
360
596
|
explanation:
|
|
361
597
|
cause === "version-skew"
|
|
362
598
|
? `${name} resolves to multiple versions and physical paths, so module state and symbols are not shared`
|
|
363
|
-
:
|
|
364
|
-
|
|
365
|
-
|
|
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,
|
|
366
618
|
}
|
|
367
|
-
return { workspaceRoot: scanRoot, findings }
|
|
368
619
|
}
|
|
369
620
|
|
|
370
|
-
/** One `- pkg [cause]: ...`
|
|
621
|
+
/** One `- pkg [cause]: ...` block per finding, shared by the hard failure and the dev warning. */
|
|
371
622
|
export function formatIdentityParityFindings(findings: readonly IdentityParityFinding[]): string {
|
|
372
623
|
return findings
|
|
373
624
|
.map(
|
|
@@ -375,25 +626,62 @@ export function formatIdentityParityFindings(findings: readonly IdentityParityFi
|
|
|
375
626
|
`- ${finding.package} [${finding.cause}]: ${finding.explanation}. ${finding.remediation}\n` +
|
|
376
627
|
` versions: ${finding.versions.join(", ")}; paths: ${finding.copies
|
|
377
628
|
.map((copy) => copy.path)
|
|
378
|
-
.join(", ")}
|
|
629
|
+
.join(", ")}` +
|
|
630
|
+
(finding.topology === undefined ? "" : `\n topology: ${finding.topology}`) +
|
|
631
|
+
(finding.scope === undefined ? "" : `\n scope: ${finding.scope}`),
|
|
379
632
|
)
|
|
380
633
|
.join("\n")
|
|
381
634
|
}
|
|
382
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
|
+
|
|
383
652
|
/** `2 primary package findings` / `1 primary package finding`. */
|
|
384
653
|
export const identityParityHeadline = (count: number): string =>
|
|
385
654
|
`${count} primary package finding${count === 1 ? "" : "s"}`
|
|
386
655
|
|
|
387
|
-
/**
|
|
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
|
+
*/
|
|
388
665
|
export async function assertIdentityParity(
|
|
389
666
|
cwd: string,
|
|
390
667
|
rootPackage?: Record<string, unknown>,
|
|
391
|
-
options: IdentityParityOptions = {},
|
|
392
668
|
): Promise<IdentityParityResult> {
|
|
393
|
-
const result = await collectIdentityParity(cwd, rootPackage
|
|
394
|
-
if (result.findings.length === 0)
|
|
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
|
+
}
|
|
395
683
|
throw new Error(
|
|
396
|
-
`[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}):\n${formatIdentityParityFindings(result.findings)}`,
|
|
684
|
+
`[nifra] identity parity failed (${identityParityHeadline(result.findings.length)}; ${identityParityBasis(result)}):\n${formatIdentityParityFindings(result.findings)}`,
|
|
397
685
|
)
|
|
398
686
|
}
|
|
399
687
|
|
|
@@ -449,9 +737,13 @@ export function normalizeBuildManifest(manifest: BuildManifestLike): ParityManif
|
|
|
449
737
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
450
738
|
.map(([route, chunks]) => [route, chunks.length]),
|
|
451
739
|
),
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
),
|
|
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:")),
|
|
455
747
|
},
|
|
456
748
|
publicFiles: [...(manifest.publicFiles ?? [])].sort(),
|
|
457
749
|
css: (manifest.css ?? []).length > 0 ? ["css:present"] : [],
|
|
@@ -530,6 +822,7 @@ export function collectDevelopmentParityInput(
|
|
|
530
822
|
routes,
|
|
531
823
|
publicFiles: publicFilesUnder(publicDir),
|
|
532
824
|
css,
|
|
825
|
+
sourceRoot,
|
|
533
826
|
}
|
|
534
827
|
}
|
|
535
828
|
|
|
@@ -552,11 +845,73 @@ export function compareManifestParity(
|
|
|
552
845
|
development: development.publicFiles,
|
|
553
846
|
production: production.publicFiles,
|
|
554
847
|
})
|
|
555
|
-
|
|
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)
|
|
556
854
|
differences.push({ section: "css", development: development.css, production: production.css })
|
|
557
855
|
return differences
|
|
558
856
|
}
|
|
559
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
|
+
|
|
560
915
|
/** Validate a production manifest against the source manifest a development server should serve. */
|
|
561
916
|
export function assertDevelopmentProductionParity(
|
|
562
917
|
development: DevelopmentParityInput,
|
|
@@ -568,10 +923,7 @@ export function assertDevelopmentProductionParity(
|
|
|
568
923
|
)
|
|
569
924
|
if (differences.length > 0) {
|
|
570
925
|
const detail = differences
|
|
571
|
-
.map(
|
|
572
|
-
(difference) =>
|
|
573
|
-
`${difference.section}: development=${JSON.stringify(difference.development)} production=${JSON.stringify(difference.production)}`,
|
|
574
|
-
)
|
|
926
|
+
.map((difference) => explainParityDifference(difference, development, production))
|
|
575
927
|
.join("; ")
|
|
576
928
|
throw new Error(`[nifra] development and production manifest parity failed: ${detail}`)
|
|
577
929
|
}
|