/** * Shared identity and development/production parity facts. * * The CLI doctor and the web pipelines must answer the same question: will identity-sensitive * modules and emitted app surfaces resolve to one coherent graph? This internal seam owns path and * manifest normalization so those callers cannot quietly grow separate rules. */ import { existsSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs" import { lstat, realpath, stat } from "node:fs/promises" import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path" import { matchesSingleCopyDeclaration, readSingleCopyDeclaration, readSingleCopyRegistration, SINGLE_COPY_REGISTER_SPECIFIER, type SingleCopyRegistration, } from "@nifrajs/core/single-copy" import { discoverRoutes } from "../fs.ts" import { isIdentitySensitivePackage } from "./identity-policy.ts" const DEPENDENCY_FIELDS = [ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", ] as const const MAX_WORKSPACE_IMPORTERS = 2_048 const MAX_LINKED_PACKAGES = 64 const MAX_LINK_PROBES = 4_096 export interface IdentityParityCopy { readonly version: string readonly path: string /** * The resolved realpath of the copy. `path` is display-relative to the invoked directory, which * reads well in a report but cannot be pasted into a resolver or an editor from anywhere else. * Absent on a finding built by hand (a test fixture, an older cached result). */ readonly absolutePath?: string readonly importers: readonly string[] } export type IdentityParityCause = "version-skew" | "duplicate-path" export interface IdentityParityFinding { readonly package: string readonly copies: readonly IdentityParityCopy[] /** Unique package versions observed across the physical copies. */ readonly versions: readonly string[] /** `version-skew` when copies advertise different versions; otherwise the same-version path split. */ readonly cause: IdentityParityCause readonly explanation: string readonly remediation: string /** * Why the copies exist, in install-topology terms: how many physical paths sit under how many * install roots, and whether any of those roots is outside the scanned project. * * A path list alone leaves the reader to reverse-engineer the shape. The two shapes need opposite * fixes: a NESTED install under the scanned root shadows the hoisted copy and one reinstall * collapses it, while a SIBLING install root (a linked checkout, a standalone app beside this one) * owns its own `node_modules` and no reinstall here can touch it. * * Absent on a finding built by hand (a test fixture, an older cached result). */ readonly topology?: string /** * Why a copy the invoked directory does not import is still fatal here. * * The gate is workspace-wide on purpose. Scoping it to the invoked app would be more precise and * would also reintroduce the blindness this guard was built for: a copy that arrives through a * workspace-linked dependency is not visible from the app directory, and that case shipped a * broken dev server while the check reported "none". A workspace-wide answer over-reports in * exchange for never under-reporting - and over-reporting is the failure a developer can see and * act on, where under-reporting is the one nobody knows happened. * * Present only when the answer would otherwise look wrong: a subdirectory was scanned as its * workspace, and at least one copy sits outside that subdirectory. */ readonly scope?: string /** * The copies exist but the app declared this package single-copy, so the resolver collapses them * before anything loads. Reported, never fatal - see `SingleCopyCoverage`. */ readonly deduplicated: boolean } /** * What the app declared about deduplication, and how much of it is actually armed. * * A duplicate physical path is only a defect if something still LOADS both copies. An app consuming a * linked sibling repository cannot collapse the paths without giving up the property it chose `link:` * for - each repository owning its own `node_modules` - so nifra lets it declare the packages instead * (`"nifra": { "singleCopy": [...] }` in package.json) and verifies the declaration here rather than * failing on the raw path count. * * Bundled phases need nothing further: `buildClient`/`buildServer`/dev inject the resolver themselves. * Unbundled phases do, because Bun's runtime never offers a bare specifier to a resolver hook, so the * plugin has to be preloaded to intercept the load. `registration` is that proof, read statically out * of `bunfig.toml`. */ export interface SingleCopyCoverage { /** Declared package names and patterns, exactly as written. Empty when nothing was declared. */ readonly declared: readonly string[] readonly registration: SingleCopyRegistration } export interface IdentityParityResult { /** * The governing workspace root: where importer enumeration starts, and how far a copy lookup may * walk up from an importer. * * EVERY caller resolves this the same way. The doctor and the build/dev preflight used to differ * here - one anchored at the workspace, the other at the app directory - so the same project could * be told it had duplicates by one command and a clean bill by the other. Two answers from one * toolchain is worse than either answer, so the basis is now fixed and reported rather than chosen. */ readonly workspaceRoot: string /** The directory the caller asked about. Differs from `workspaceRoot` when a package subdirectory * is governed by a workspace above it; carried so a report can state the basis it scanned on. */ readonly requestedRoot: string /** * Enumeration stopped at `MAX_WORKSPACE_IMPORTERS`, so this scan is PARTIAL. * * An empty `findings` then means "nothing found in the part that was scanned", never "clean" - a * caller that prints a clean bill on a truncated scan is the exact silence this flag exists to * prevent. */ readonly truncated: boolean /** Findings that no declaration covers - the ones a build must refuse to start on. */ readonly findings: readonly IdentityParityFinding[] /** Duplicates the declaration covers. Worth printing, never worth failing. */ readonly deduplicated: readonly IdentityParityFinding[] readonly singleCopy: SingleCopyCoverage } export interface BuildManifestLike { readonly entry: string readonly assets: readonly string[] readonly routes: Readonly> readonly publicFiles?: readonly string[] readonly css?: readonly string[] } export interface ParityManifest { readonly moduleGraph: { readonly routes: readonly string[] readonly routeChunks: Readonly> readonly emittedAssets: readonly string[] } readonly publicFiles: readonly string[] readonly css: readonly string[] } export interface DevelopmentParityInput { readonly routes: Readonly> readonly publicFiles: readonly string[] readonly css: readonly string[] /** The scanned first-party source root, carried only so a css parity failure can name where the * scanner looked. Optional: callers that hand-build an input for a unit test may omit it. */ readonly sourceRoot?: string } const SOURCE_EXTENSIONS = /\.(?:c|m)?(?:j|t)sx?$|\.(?:mdx|svelte|vue)$/ /** A stylesheet path that the source scanner can prove is a real explicit import. */ const CSS_SPECIFIER = /\.(?:css|scss|sass|less|styl)(?:\?[^"'`]*)?$/i type CssToken = | { readonly kind: "identifier"; readonly value: string; readonly lineStart: boolean } | { readonly kind: "string"; readonly value: string } | { readonly kind: "punctuator"; readonly value: string } /** * Tokenize only the small part of TypeScript/JS needed for the CSS contract. * * A regex over source text is not sound here: documentation routes contain code examples such as * `import "./app.css"` in template literals, and comments can contain the same text. Those are not * imports and must not make a style-free production build fail. This intentionally conservative lexer * drops comments and complete template literals (including `${...}` expressions); missing a stylesheet * is the passing direction of this parity check, while claiming one that is not imported is not. */ const cssTokens = (source: string): readonly CssToken[] => { const tokens: CssToken[] = [] let lineStart = true for (let index = 0; index < source.length; ) { const char = source[index] if (char === undefined) break if (/\s/.test(char)) { if (char === "\n" || char === "\r") lineStart = true index++ continue } if (char === "/" && source[index + 1] === "/") { index += 2 while (index < source.length && source[index] !== "\n") index++ continue } if (char === "/" && source[index + 1] === "*") { const commentStart = index index += 2 while (index < source.length && !(source[index] === "*" && source[index + 1] === "/")) index++ index = Math.min(source.length, index + 2) if (/[\r\n]/.test(source.slice(commentStart, index))) lineStart = true continue } if (char === "`") { const templateStart = index index++ while (index < source.length) { const templateChar = source[index] if (templateChar === "\\") { index += 2 continue } index++ if (templateChar === "`") break } lineStart = /[\r\n]/.test(source.slice(templateStart, index)) continue } if (char === '"' || char === "'") { const quote = char const start = ++index while (index < source.length) { const stringChar = source[index] if (stringChar === "\\") { index += 2 continue } index++ if (stringChar === quote) break } tokens.push({ kind: "string", value: source.slice(start, Math.max(start, index - 1)) }) lineStart = false continue } if (/[A-Za-z_$]/.test(char)) { const start = index++ while (index < source.length && /[A-Za-z0-9_$]/.test(source[index] ?? "")) index++ tokens.push({ kind: "identifier", value: source.slice(start, index), lineStart }) lineStart = false continue } tokens.push({ kind: "punctuator", value: char }) lineStart = false index++ } return tokens } const isStylesheetToken = (token: CssToken | undefined): boolean => token?.kind === "string" && CSS_SPECIFIER.test(token.value) /** Detect static imports, re-exports, literal dynamic imports, and literal require calls. */ const hasStylesheetImport = (source: string): boolean => { const tokens = cssTokens(source) for (let index = 0; index < tokens.length; index++) { const token = tokens[index] if (token?.kind !== "identifier") continue const next = tokens[index + 1] if (token.value === "import") { if (token.lineStart && isStylesheetToken(next)) return true if (next?.value === "(" && isStylesheetToken(tokens[index + 2])) return true } if (token.value === "require" && next?.value === "(" && isStylesheetToken(tokens[index + 2])) return true if (token.value === "from" && isStylesheetToken(next)) { for (let previous = index - 1; previous >= 0; previous--) { const candidate = tokens[previous] if (candidate?.kind === "identifier" && candidate.lineStart) { if (candidate.value === "import" || candidate.value === "export") return true break } if (candidate?.value === ";") break } } } return false } /** A single-file-component `