@hublo/sentinel 1.5.0-alpha.9 → 1.5.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/README.md +9 -7
- package/dist/bin/sentinel.js +61 -16
- package/dist/{chunk-KLEXJFJ6.js → chunk-43HQ6BVJ.js} +61 -15
- package/dist/{chunk-YQRDKWSH.js → chunk-IL5PPLXE.js} +11 -1
- package/dist/chunk-IL5PPLXE.js.map +1 -0
- package/dist/chunk-MOELAQPC.js +10365 -0
- package/dist/chunk-WBDBRGLV.js +214 -0
- package/dist/chunk-WBDBRGLV.js.map +1 -0
- package/dist/{chunk-AMCGMLPS.js → chunk-Y57KA4QS.js} +2 -2
- package/dist/index.d.ts +28 -1
- package/dist/index.js +4 -4
- package/dist/roles/build/nest/toolchain.js +68 -18
- package/dist/roles/build/nest/toolchain.js.map +1 -1
- package/dist/roles/static-analysis/config.d.ts +15 -1
- package/dist/roles/static-analysis/config.js +1 -1
- package/dist/roles/test/setup/jest-parity.js +2 -2
- package/dist/roles/test/setup/workspace-entry.js +2 -2
- package/dist/{validate-AWWMBSH5.js → validate-FRMENKHM.js} +2 -2
- package/docs/bundle-analysis-spec.md +272 -0
- package/docs/sql-verification-study.md +401 -0
- package/docs/static-analysis-adoption.md +285 -0
- package/docs/static-analysis-study.md +824 -18
- package/docs/typescript-adoption.md +1 -1
- package/docs/typescript-traces.md +27 -12
- package/docs/using-sentinel.md +22 -12
- package/docs/validating-a-change.md +185 -0
- package/lint/nest.json +98 -18
- package/lint/node.json +83 -3
- package/lint/react-lib.json +121 -31
- package/lint/react.json +101 -2
- package/package.json +27 -6
- package/plugins/hublo/no-palette-common.js +76 -0
- package/plugins/hublo/no-regex-lookbehind.js +70 -0
- package/plugins/hublo/require-reload-document.js +70 -0
- package/plugins/hublo.js +20 -145
- package/plugins/sonar-cognitive.js +108 -0
- package/dist/chunk-EDFIVLUA.js +0 -100
- package/dist/chunk-EDFIVLUA.js.map +0 -1
- package/dist/chunk-OZZICSSZ.js +0 -6469
- package/dist/chunk-YQRDKWSH.js.map +0 -1
- /package/dist/{chunk-AMCGMLPS.js.map → chunk-Y57KA4QS.js.map} +0 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../../../src/roles/build/nest/nest-service.ts","../../../../src/roles/build/nest/copy-assets.ts","../../../../src/roles/build/nest/node-manifest.ts","../../../../src/roles/build/nest/toolchain.ts"],"sourcesContent":["/**\n * The whole Vite config for a Nest service, so an adopted module holds two lines.\n *\n * ## Why a shape here, and pieces on the React side\n *\n * It looks inconsistent until you measure the two families. The four React apps each carry a\n * heavy, genuinely different config: one has a plugin wedged between two shared ones, another\n * has two different route-tree footers on the branches of one ternary. Two attempts at a\n * shared shape for them were abandoned, and the rule that survived is that sentinel rewrites\n * their IMPORTS and touches nothing else.\n *\n * The Nest side is the opposite by measurement: 38 services, one shared 25-line webpack config\n * between them, and nothing per-module to preserve. Owning the shape there is not a different\n * philosophy, it is the same one applied to a different fact: sentinel provides the FORM where\n * the form is shared, and the PIECES where it is not.\n *\n * The consequences are the point. An adopted module is readable in review. A correction to the\n * form is one publish instead of 38 diffs. And the day Nest becomes ESM-native, `format`\n * changes here rather than in 38 configs.\n *\n * ## It is a composition, not a block\n *\n * `nestService` assembles the same plugins this module exports individually. A service that\n * needs something else can pass `overrides`, or drop to the pieces entirely. And if the React\n * configs ever converge, a `reactApp()` can be built the same way with no new mechanism.\n */\nimport { join } from 'node:path'\n\nimport { mergeConfig, type UserConfig } from 'vite'\n\nimport { copyAssets } from './copy-assets.js'\nimport { decoratorMetadata } from './decorator-metadata.js'\nimport { nodeManifest } from './node-manifest.js'\nimport type { AssetDeclaration, TransformerDeclaration } from './nx-target.js'\nimport { tsconfigAliases } from './tsconfig-aliases.js'\n\nexport interface NestServiceOptions {\n /** The nx project name. Keys the dependency graph, and names the module in messages. */\n project: string\n /** Absolute path to the module's directory, normally `__dirname`. */\n root: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Entry point, relative to `root`. */\n entry?: string\n /**\n * The tsconfig whose emit must be preserved, when the service uses neither conventional name.\n *\n * Relative to `root`. Defaults to `tsconfig.app.json`, then `tsconfig.json`.\n */\n tsconfig?: string\n /** Output directory, relative to `workspaceRoot`. */\n outDir?: string\n /**\n * TypeScript transformers this service compiles with, as its build target declared them.\n *\n * Read off the webpack target by `--init` and written here, never inferred: what a service\n * compiles with is the service's own. Eight services and BFFs run `@nestjs/swagger/plugin`,\n * which writes the `@ApiProperty` decorators their DTOs do not declare by hand. Building\n * without it succeeds and silently costs them most of their published contract (measured on\n * `institution`: 385 insertions, 1378 deletions in the committed OpenAPI document).\n */\n transformers?: readonly TransformerDeclaration[]\n /**\n * Files copied into the output beside the bundle, in the shape `@nx/webpack` declared them.\n *\n * One service here uses it: `planning-period` ships the pug, css and ttf templates it renders\n * printed documents from. Without them the service starts and fails on its first print.\n */\n assets?: readonly AssetDeclaration[]\n /**\n * Anything this service needs that the shape does not give it.\n *\n * Merged with Vite's own `mergeConfig`, never with a merge of our own: plugins concatenate\n * and aliases stack the way Vite does it everywhere else, so there is no second set of\n * semantics to learn or to document.\n */\n overrides?: UserConfig\n}\n\nconst baseConfig = (options: NestServiceOptions): UserConfig => {\n const outDir = join(options.workspaceRoot, options.outDir ?? `dist/${options.project}`)\n const entry = join(options.root, options.entry ?? 'src/main.ts')\n return {\n plugins: [\n decoratorMetadata({\n root: options.root,\n tsconfig: options.tsconfig,\n transformers: options.transformers,\n }),\n ...(options.assets?.length\n ? [copyAssets({ workspaceRoot: options.workspaceRoot, outDir, assets: options.assets })]\n : []),\n nodeManifest({\n project: options.project,\n workspaceRoot: options.workspaceRoot,\n outDir,\n }),\n ],\n resolve: { alias: tsconfigAliases(options.workspaceRoot) },\n ssr: {\n // `importHelpers: true` in the workspace tsconfig makes TypeScript emit `require(\"tslib\")`\n // for `__decorate` and `__metadata`, so every decorated file depends on it. Hoisted into\n // one scope Rollup inlined it and nobody noticed; preserving modules left it external, and\n // the manifest nx generates from the project graph does not list it, because no module\n // DECLARES tslib. The image therefore did not install it and the service died on its first\n // require, only in the image: locally and in CI the workspace root has tslib.\n noExternal: ['tslib'],\n },\n build: {\n // An SSR build targets Node and leaves real packages external, which is what\n // `webpack-node-externals` did: the image installs them from the generated manifest.\n ssr: entry,\n outDir,\n emptyOutDir: true,\n sourcemap: true,\n target: 'node20',\n // The bundle is read by humans when a stack trace points into it, and minifying a\n // server bundle buys nothing: it is never downloaded.\n minify: false,\n rollupOptions: {\n output: {\n format: 'cjs',\n // One output file per source module, instead of hoisting everything into one scope.\n //\n // This is not a preference, it is the only shape that keeps the OpenAPI contract.\n // Merging every module into one scope forces Rollup to rename duplicate class names,\n // and `@nestjs/swagger` keys its schemas on `class.name`, so a renamed class silently\n // becomes a renamed schema in the published API. Measured on `network`, which has two\n // such collisions: single bundle renames `InvalidPermissionError` and\n // `PermissionNotFoundError`; preserving modules renames nothing and reproduces the\n // committed contract byte for byte.\n //\n // The cost is 4.5 MB across 2003 files against 2.9 MB in one, still well under\n // webpack's 6.9 MB for the same service, so nothing regresses against what it replaces.\n preserveModules: true,\n // The image runs `./dist/main.js`, so the entry keeps that name at the root while\n // every other module keeps its own path. Under `preserveModules` a plain string here\n // would be applied to all of them, which numbers them (`main962.js`) and loses the\n // entry.\n entryFileNames: (chunk) => (chunk.facadeModuleId === entry ? 'main.js' : '[name].js'),\n },\n // Nest registers metadata as an IMPORT SIDE EFFECT: a decorator writes into a catalog\n // when its module loads, and nothing references that module afterwards. Rollup may\n // drop such a module; webpack never did.\n //\n // Measured on the first migrated service this changes nothing, so it is insurance\n // rather than a fix, and it is recorded as such rather than credited with the smaller\n // bundle (that comes from barrel re-exports the service does not use).\n treeshake: { moduleSideEffects: true },\n },\n },\n }\n}\n\nexport const nestService = (options: NestServiceOptions): UserConfig =>\n options.overrides === undefined\n ? baseConfig(options)\n : mergeConfig(baseConfig(options), options.overrides)\n","/**\n * The files a service ships beside its bundle, which webpack copied and a bundler does not.\n *\n * `@nx/webpack` takes an `assets` list and copies each match into the output. Nothing in a Vite\n * build does that for a Node target: `publicDir` is a browser concept and is disabled for SSR\n * builds, so an unmigrated `assets` entry is not a smaller output, it is a service that starts\n * and then fails the first time it reaches for a file that is not there.\n *\n * One service in this repo declares it. `planning-period` ships the pug templates, stylesheet and\n * font it renders printed documents from, under `templates/`. Dropping them leaves a build that\n * succeeds, an image that boots, and a printing feature that throws.\n *\n * ## Copied at `closeBundle`, and only what the target declared\n *\n * The declaration is carried through from the webpack target unchanged, globs and all, so this\n * plugin has nothing to decide: it resolves each `input` against the workspace root, matches the\n * glob, and writes under `output` inside the bundle's directory. `closeBundle` because that is\n * when the output directory exists and Vite has finished emptying it.\n *\n * An entry matching nothing FAILS the build. A glob that has quietly stopped matching is the same\n * silent loss as no glob at all, and the point of this file is that this class of loss is loud.\n */\nimport { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'\nimport { dirname, join, relative, sep } from 'node:path'\n\nimport type { Plugin } from 'vite'\n\nimport { globToRegExp } from '../../../shared/glob.js'\n\nimport type { AssetDeclaration } from './nx-target.js'\n\nexport interface CopyAssetsOptions {\n /** Absolute path to the workspace root, which `input` is relative to. */\n workspaceRoot: string\n /** Absolute path to the build's output directory. */\n outDir: string\n assets: readonly AssetDeclaration[]\n}\n\n/** Every file under `root`, as paths relative to it and with forward slashes. */\nfunction filesUnder(root: string): string[] {\n const found: string[] = []\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry)\n if (statSync(full).isDirectory()) walk(full)\n else found.push(relative(root, full).split(sep).join('/'))\n }\n }\n walk(root)\n return found\n}\n\n/** What each declaration copies, or the reason it cannot. */\nexport function resolveAssets(options: CopyAssetsOptions): { from: string; to: string }[] {\n const copies: { from: string; to: string }[] = []\n for (const asset of options.assets) {\n const input = join(options.workspaceRoot, asset.input)\n if (!existsSync(input)) {\n throw new Error(\n `sentinel build(nest): the build target declares an asset from \\`${asset.input}\\`, which ` +\n `does not exist. webpack copied it into the output, so the built service would be ` +\n `missing files it reads at runtime.`,\n )\n }\n const matcher = globToRegExp(asset.glob)\n const matched = filesUnder(input).filter((file) => matcher.test(file))\n if (matched.length === 0) {\n throw new Error(\n `sentinel build(nest): the asset glob \\`${asset.glob}\\` under \\`${asset.input}\\` matches ` +\n `no file. An entry that matches nothing is the same silent loss as no entry at all.`,\n )\n }\n for (const file of matched) {\n copies.push({ from: join(input, file), to: join(options.outDir, asset.output, file) })\n }\n }\n return copies\n}\n\nexport const copyAssets = (options: CopyAssetsOptions): Plugin => ({\n name: 'sentinel:copy-assets',\n // After the bundle is written and the output directory has stopped being emptied.\n closeBundle() {\n for (const { from, to } of resolveAssets(options)) {\n mkdirSync(dirname(to), { recursive: true })\n cpSync(from, to)\n }\n },\n})\n","/**\n * The pruned `package.json` and lockfile a Node image installs from.\n *\n * This is the half that made the nx executor look load-bearing. `--generatePackageJson` was an\n * option of `@nx/webpack:webpack`, and the backend image is built on its two outputs:\n *\n * COPY --from=build /app/dist/apps/nest/<service>/package.json /app/package.json\n * COPY --from=build /app/dist/apps/nest/<service>/pnpm-lock.yaml /app/pnpm-lock.yaml\n * RUN pnpm i --prod=true --frozen-lockfile\n *\n * It is not bound to the executor. The executor supplies one thing, the project graph, and\n * every function that does the work is exported by `@nx/js`. So the same two files can be\n * produced from a Vite plugin, and `--frozen-lockfile` accepting them afterwards is the proof\n * that they agree with each other: pnpm refuses the install otherwise.\n *\n * ## Why the graph, and not something simpler\n *\n * A Nest service here declares almost nothing of its own: measured, one dependency in its\n * manifest against 1463 installed in its image. Everything it uses is declared at the\n * workspace root and reaches it through source imports. So the dependency list can only be\n * computed from what the code imports, which is what the graph knows.\n *\n * Deriving it from the bundle's own externals is not equivalent either: measured on the first\n * service, the bundle statically requires 42 packages while the correct manifest holds 65. The\n * 23 missing ones are transitive or required dynamically at runtime (`fastify` behind\n * `@nestjs/platform-fastify`, `jsonwebtoken`, the LaunchDarkly SDK). Shipping the short list\n * fails in production, not in the build.\n */\nimport { existsSync, writeFileSync } from 'node:fs'\nimport { isBuiltin } from 'node:module'\nimport { join } from 'node:path'\n\nimport type { Plugin, Rollup } from 'vite'\n\nexport interface NodeManifestOptions {\n /** The nx project name, which is how the graph is keyed. */\n project: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Where the bundle is written; the two files land beside it. */\n outDir: string\n /** Entry file name, written as `main` so `node .` resolves inside the image. */\n entry?: string\n /**\n * Packages the IMAGE provides, which are therefore allowed to be required without being\n * declared in the manifest.\n *\n * The generated Prisma clients are the case this exists for: the Dockerfile copies\n * `node_modules/@prisma` from its own stage, so they resolve at runtime while no module\n * declares them. Everything else that is required and undeclared is a bug, and the check\n * below refuses the build.\n */\n providedByImage?: readonly string[]\n}\n\n/** What the Dockerfile copies in itself, so requiring it without declaring it is legitimate. */\nconst DEFAULT_PROVIDED_BY_IMAGE = ['@prisma'] as const\n\n/**\n * Every real package the emitted output requires, from Rollup's own record rather than a scan\n * of the text.\n *\n * `chunk.imports` mixes three things, and only the third is a dependency:\n *\n * - other EMITTED chunks. Under `preserveModules` these are relative output paths that do not\n * begin with `.`, so `apps/…`, `libs/…` and `_virtual/…` read exactly like package names.\n * They are recognised by being keys of the bundle itself.\n * - Node BUILTINS, which appear both bare (`crypto`) and prefixed (`node:crypto`).\n * - genuine external packages, which is what the manifest has to cover.\n */\nconst externalPackages = (bundle: Rollup.OutputBundle): Set<string> => {\n const emitted = new Set(Object.keys(bundle))\n const packages = new Set<string>()\n\n for (const emittedFile of Object.values(bundle)) {\n // Assets carry no imports, and the bundle holds both. Narrowing on the discriminant is what\n // replaced a cast through `unknown`, which asserted the same thing without checking it.\n if (emittedFile.type !== 'chunk') continue\n\n for (const imported of emittedFile.imports) {\n if (emitted.has(imported)) continue\n if (imported.startsWith('.') || imported.startsWith('/')) continue\n if (isBuiltin(imported)) continue\n\n const parts = imported.split('/')\n const name = imported.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]\n if (name) packages.add(name)\n }\n }\n return packages\n}\n\n/**\n * Writes the manifest and lockfile once the bundle is on disk.\n *\n * `closeBundle` rather than `writeBundle`, so the files land after Vite has finished with the\n * directory and cannot be cleared by `emptyOutDir`.\n *\n * `closeBundle` also runs after a FAILED build, where there is no directory to write into. Left\n * alone this hook then threw `ENOENT` on the manifest, and since it is the last error raised it\n * became the one Vite printed, hiding the failure that actually stopped the build. It cost two\n * diagnoses before being recognised, so the missing directory is now read as what it is: the\n * bundle was never written, and this plugin has nothing to say about why.\n */\nexport const nodeManifest = (options: NodeManifestOptions): Plugin => {\n /*\n * Captured while the bundle still exists in memory, and read back once the manifest is built.\n *\n * `undefined` until `generateBundle` runs, deliberately: an empty SET would make the coverage\n * check below pass by having seen nothing, which is the worst way for a guard to succeed. The\n * two states have to be told apart.\n */\n let required: Set<string> | undefined\n\n return {\n name: 'sentinel:node-manifest',\n apply: 'build',\n\n generateBundle(_outputOptions, bundle) {\n required = externalPackages(bundle)\n },\n\n async closeBundle() {\n // `closeBundle` runs after a FAILED build too. The ENTRY is what says a bundle was really\n // written: `emptyOutDir` leaves the directory in place, so its existence alone would let a\n // failed build still produce a manifest describing nothing.\n const entry = options.entry ?? 'main.js'\n if (!existsSync(join(options.outDir, entry))) return\n\n // Imported here rather than at module scope: a front config importing this file must not\n // pay for loading the nx graph machinery it will never call.\n const { createProjectGraphAsync } = await import('nx/src/devkit-exports.js')\n const { createPackageJson, createLockFile, getLockFileName } = await import('@nx/js')\n\n const graph = await createProjectGraphAsync({ exitOnError: false })\n const manifest = createPackageJson(options.project, graph, {\n root: options.workspaceRoot,\n isProduction: true,\n })\n manifest.main = entry\n\n if (required === undefined) {\n throw new Error(\n `sentinel build(nest): the bundle was written but never inspected, so the manifest ` +\n `could not be checked against what it requires. Refusing to write one that might ` +\n `not install.`,\n )\n }\n assertManifestCovers(required, manifest, options)\n\n writeFileSync(join(options.outDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\\n`)\n writeFileSync(\n join(options.outDir, getLockFileName('pnpm')),\n createLockFile(manifest, graph, 'pnpm'),\n )\n },\n }\n}\n\n/**\n * Refuse a bundle that requires something the image will not install.\n *\n * The manifest is derived from the project GRAPH, so it lists what modules declare. The bundle\n * requires what the bundler left external. Those two sets agreeing is an assumption, and it was\n * wrong: `tslib` arrives through `importHelpers` in the workspace tsconfig without any module\n * declaring it, so the manifest omitted it and the service died on its first require.\n *\n * It died only in the image. Locally and in CI the workspace root has the package, so the gap is\n * invisible until a container starts, which is the most expensive place to discover it. Checking\n * here turns that into a build failure naming the package.\n */\nconst assertManifestCovers = (\n required: ReadonlySet<string>,\n manifest: { dependencies?: Record<string, string> },\n options: NodeManifestOptions,\n): void => {\n const declared = new Set(Object.keys(manifest.dependencies ?? {}))\n const provided = options.providedByImage ?? DEFAULT_PROVIDED_BY_IMAGE\n const missing = [...required]\n .filter((name) => !declared.has(name))\n .filter((name) => !provided.some((prefix) => name === prefix || name.startsWith(`${prefix}/`)))\n .sort()\n\n if (missing.length === 0) return\n\n throw new Error(\n `sentinel build(nest): the bundle requires ${missing.length} package(s) the generated ` +\n `manifest does not declare, so the image would not install them: ${missing.join(', ')}. ` +\n `Either declare them in the module's package.json, inline them with ` +\n `\\`ssr.noExternal\\`, or list them in \\`providedByImage\\` if the Dockerfile copies them in.`,\n )\n}\n","/**\n * What `@hublo/sentinel/build/nest` gives an adopted service.\n *\n * The sibling `build/react` entry is a re-export surface: the module keeps its own config and\n * only its import lines move. This one carries a shape as well, because 38 Nest services share\n * one config and have nothing of their own to preserve. See `nest-service.ts` for why the two\n * answers differ, and why that is one rule rather than two.\n *\n * Its own entry point, like the React one, so importing it at build time never drags the CLI\n * and its adapters into a Vite config.\n */\nexport { nestService, type NestServiceOptions } from './nest-service.js'\n\n// The pieces the shape is built from, exported individually so a service that needs something\n// the shape does not give it can compose its own without leaving sentinel.\nexport { decoratorMetadata, type DecoratorMetadataOptions } from './decorator-metadata.js'\nexport { nodeManifest, type NodeManifestOptions } from './node-manifest.js'\nexport { tsconfigAliases, type Alias } from './tsconfig-aliases.js'\n\n// Vite's own, so an adopted config imports everything from one place and the module can drop\n// `vite` from its dependencies.\nexport { defineConfig, loadEnv, mergeConfig } from 'vite'\nexport type { Plugin, PluginOption, UserConfig, UserConfigExport } from 'vite'\n"],"mappings":"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","names":["join","existsSync","join","join","mergeConfig"]}
|
|
1
|
+
{"version":3,"sources":["../../../../src/roles/build/nest/nest-service.ts","../../../../src/roles/build/nest/copy-assets.ts","../../../../src/roles/build/nest/importer-externals.ts","../../../../src/roles/build/nest/node-manifest.ts","../../../../src/roles/build/nest/toolchain.ts"],"sourcesContent":["/**\n * The whole Vite config for a Nest service, so an adopted module holds two lines.\n *\n * ## Why a shape here, and pieces on the React side\n *\n * It looks inconsistent until you measure the two families. The four React apps each carry a\n * heavy, genuinely different config: one has a plugin wedged between two shared ones, another\n * has two different route-tree footers on the branches of one ternary. Two attempts at a\n * shared shape for them were abandoned, and the rule that survived is that sentinel rewrites\n * their IMPORTS and touches nothing else.\n *\n * The Nest side is the opposite by measurement: 38 services, one shared 25-line webpack config\n * between them, and nothing per-module to preserve. Owning the shape there is not a different\n * philosophy, it is the same one applied to a different fact: sentinel provides the FORM where\n * the form is shared, and the PIECES where it is not.\n *\n * The consequences are the point. An adopted module is readable in review. A correction to the\n * form is one publish instead of 38 diffs. And the day Nest becomes ESM-native, `format`\n * changes here rather than in 38 configs.\n *\n * ## It is a composition, not a block\n *\n * `nestService` assembles the same plugins this module exports individually. A service that\n * needs something else can pass `overrides`, or drop to the pieces entirely. And if the React\n * configs ever converge, a `reactApp()` can be built the same way with no new mechanism.\n */\nimport { join } from 'node:path'\n\nimport { mergeConfig, type UserConfig } from 'vite'\n\nimport { copyAssets } from './copy-assets.js'\nimport { decoratorMetadata } from './decorator-metadata.js'\nimport { importerExternals } from './importer-externals.js'\nimport { nodeManifest } from './node-manifest.js'\nimport type { AssetDeclaration, TransformerDeclaration } from './nx-target.js'\nimport { tsconfigAliases } from './tsconfig-aliases.js'\n\nexport interface NestServiceOptions {\n /** The nx project name. Keys the dependency graph, and names the module in messages. */\n project: string\n /** Absolute path to the module's directory, normally `__dirname`. */\n root: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Entry point, relative to `root`. */\n entry?: string\n /**\n * The tsconfig whose emit must be preserved, when the service uses neither conventional name.\n *\n * Relative to `root`. Defaults to `tsconfig.app.json`, then `tsconfig.json`.\n */\n tsconfig?: string\n /** Output directory, relative to `workspaceRoot`. */\n outDir?: string\n /**\n * TypeScript transformers this service compiles with, as its build target declared them.\n *\n * Read off the webpack target by `--init` and written here, never inferred: what a service\n * compiles with is the service's own. Eight services and BFFs run `@nestjs/swagger/plugin`,\n * which writes the `@ApiProperty` decorators their DTOs do not declare by hand. Building\n * without it succeeds and silently costs them most of their published contract (measured on\n * `institution`: 385 insertions, 1378 deletions in the committed OpenAPI document).\n */\n transformers?: readonly TransformerDeclaration[]\n /**\n * Files copied into the output beside the bundle, in the shape `@nx/webpack` declared them.\n *\n * One service here uses it: `planning-period` ships the pug, css and ttf templates it renders\n * printed documents from. Without them the service starts and fails on its first print.\n */\n assets?: readonly AssetDeclaration[]\n /**\n * Anything this service needs that the shape does not give it.\n *\n * Merged with Vite's own `mergeConfig`, never with a merge of our own: plugins concatenate\n * and aliases stack the way Vite does it everywhere else, so there is no second set of\n * semantics to learn or to document.\n */\n overrides?: UserConfig\n}\n\n/**\n * Packages the bundle INLINES rather than requires.\n *\n * `importHelpers: true` in the workspace tsconfig makes TypeScript emit `require(\"tslib\")` for\n * `__decorate` and `__metadata`, so every decorated file depends on it. Hoisted into one scope\n * Rollup inlined it and nobody noticed; preserving modules left it external, and the manifest nx\n * generates from the project graph does not list it, because no module DECLARES tslib. The image\n * therefore did not install it and the service died on its first require, only in the image:\n * locally and in CI the workspace root has tslib.\n *\n * One list, read by both `ssr.noExternal` and `importerExternals`, because the second runs\n * `enforce: 'pre'` and would otherwise win against the first and bring the incident back.\n */\nexport const NEST_SERVICE_NO_EXTERNAL: readonly string[] = ['tslib']\n\nconst baseConfig = (options: NestServiceOptions): UserConfig => {\n const outDir = join(options.workspaceRoot, options.outDir ?? `dist/${options.project}`)\n const entry = join(options.root, options.entry ?? 'src/main.ts')\n const aliases = tsconfigAliases(options.workspaceRoot)\n return {\n plugins: [\n importerExternals({ noExternal: NEST_SERVICE_NO_EXTERNAL, aliases }),\n decoratorMetadata({\n root: options.root,\n tsconfig: options.tsconfig,\n transformers: options.transformers,\n }),\n ...(options.assets?.length\n ? [copyAssets({ workspaceRoot: options.workspaceRoot, outDir, assets: options.assets })]\n : []),\n nodeManifest({\n project: options.project,\n workspaceRoot: options.workspaceRoot,\n outDir,\n }),\n ],\n resolve: { alias: aliases },\n ssr: {\n // See NEST_SERVICE_NO_EXTERNAL: `tslib`, inlined on purpose.\n noExternal: [...NEST_SERVICE_NO_EXTERNAL],\n },\n build: {\n // An SSR build targets Node and leaves real packages external, which is what\n // `webpack-node-externals` did: the image installs them from the generated manifest.\n // `importerExternals` holds that wherever the package is declared, not only at the root.\n ssr: entry,\n outDir,\n emptyOutDir: true,\n sourcemap: true,\n target: 'node20',\n // The bundle is read by humans when a stack trace points into it, and minifying a\n // server bundle buys nothing: it is never downloaded.\n minify: false,\n rollupOptions: {\n output: {\n format: 'cjs',\n // One output file per source module, instead of hoisting everything into one scope.\n //\n // This is not a preference, it is the only shape that keeps the OpenAPI contract.\n // Merging every module into one scope forces Rollup to rename duplicate class names,\n // and `@nestjs/swagger` keys its schemas on `class.name`, so a renamed class silently\n // becomes a renamed schema in the published API. Measured on `network`, which has two\n // such collisions: single bundle renames `InvalidPermissionError` and\n // `PermissionNotFoundError`; preserving modules renames nothing and reproduces the\n // committed contract byte for byte.\n //\n // The cost is 4.5 MB across 2003 files against 2.9 MB in one, still well under\n // webpack's 6.9 MB for the same service, so nothing regresses against what it replaces.\n preserveModules: true,\n // The image runs `./dist/main.js`, so the entry keeps that name at the root while\n // every other module keeps its own path. Under `preserveModules` a plain string here\n // would be applied to all of them, which numbers them (`main962.js`) and loses the\n // entry.\n entryFileNames: (chunk) => (chunk.facadeModuleId === entry ? 'main.js' : '[name].js'),\n },\n // Nest registers metadata as an IMPORT SIDE EFFECT: a decorator writes into a catalog\n // when its module loads, and nothing references that module afterwards. Rollup may\n // drop such a module; webpack never did.\n //\n // Measured on the first migrated service this changes nothing, so it is insurance\n // rather than a fix, and it is recorded as such rather than credited with the smaller\n // bundle (that comes from barrel re-exports the service does not use).\n treeshake: { moduleSideEffects: true },\n },\n },\n }\n}\n\nexport const nestService = (options: NestServiceOptions): UserConfig =>\n options.overrides === undefined\n ? baseConfig(options)\n : mergeConfig(baseConfig(options), options.overrides)\n","/**\n * The files a service ships beside its bundle, which webpack copied and a bundler does not.\n *\n * `@nx/webpack` takes an `assets` list and copies each match into the output. Nothing in a Vite\n * build does that for a Node target: `publicDir` is a browser concept and is disabled for SSR\n * builds, so an unmigrated `assets` entry is not a smaller output, it is a service that starts\n * and then fails the first time it reaches for a file that is not there.\n *\n * One service in this repo declares it. `planning-period` ships the pug templates, stylesheet and\n * font it renders printed documents from, under `templates/`. Dropping them leaves a build that\n * succeeds, an image that boots, and a printing feature that throws.\n *\n * ## Copied at `closeBundle`, and only what the target declared\n *\n * The declaration is carried through from the webpack target unchanged, globs and all, so this\n * plugin has nothing to decide: it resolves each `input` against the workspace root, matches the\n * glob, and writes under `output` inside the bundle's directory. `closeBundle` because that is\n * when the output directory exists and Vite has finished emptying it.\n *\n * An entry matching nothing FAILS the build. A glob that has quietly stopped matching is the same\n * silent loss as no glob at all, and the point of this file is that this class of loss is loud.\n */\nimport { cpSync, existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'\nimport { dirname, join, relative, sep } from 'node:path'\n\nimport type { Plugin } from 'vite'\n\nimport { globToRegExp } from '../../../shared/glob.js'\n\nimport type { AssetDeclaration } from './nx-target.js'\n\nexport interface CopyAssetsOptions {\n /** Absolute path to the workspace root, which `input` is relative to. */\n workspaceRoot: string\n /** Absolute path to the build's output directory. */\n outDir: string\n assets: readonly AssetDeclaration[]\n}\n\n/** Every file under `root`, as paths relative to it and with forward slashes. */\nfunction filesUnder(root: string): string[] {\n const found: string[] = []\n const walk = (dir: string): void => {\n for (const entry of readdirSync(dir)) {\n const full = join(dir, entry)\n if (statSync(full).isDirectory()) walk(full)\n else found.push(relative(root, full).split(sep).join('/'))\n }\n }\n walk(root)\n return found\n}\n\n/** What each declaration copies, or the reason it cannot. */\nexport function resolveAssets(options: CopyAssetsOptions): { from: string; to: string }[] {\n const copies: { from: string; to: string }[] = []\n for (const asset of options.assets) {\n const input = join(options.workspaceRoot, asset.input)\n if (!existsSync(input)) {\n throw new Error(\n `sentinel build(nest): the build target declares an asset from \\`${asset.input}\\`, which ` +\n `does not exist. webpack copied it into the output, so the built service would be ` +\n `missing files it reads at runtime.`,\n )\n }\n const matcher = globToRegExp(asset.glob)\n const matched = filesUnder(input).filter((file) => matcher.test(file))\n if (matched.length === 0) {\n throw new Error(\n `sentinel build(nest): the asset glob \\`${asset.glob}\\` under \\`${asset.input}\\` matches ` +\n `no file. An entry that matches nothing is the same silent loss as no entry at all.`,\n )\n }\n for (const file of matched) {\n copies.push({ from: join(input, file), to: join(options.outDir, asset.output, file) })\n }\n }\n return copies\n}\n\nexport const copyAssets = (options: CopyAssetsOptions): Plugin => ({\n name: 'sentinel:copy-assets',\n // After the bundle is written and the output directory has stopped being emptied.\n closeBundle() {\n for (const { from, to } of resolveAssets(options)) {\n mkdirSync(dirname(to), { recursive: true })\n cpSync(from, to)\n }\n },\n})\n","/**\n * Keep a real package external when Vite would inline it only because the service does not\n * declare it.\n *\n * Vite's SSR build decides externalisation from the service's OWN directory. In hublo/monorepo that\n * held because the root manifest declared nearly every package, so the lookup walked up to\n * `<repo>/node_modules`. Once a package moves off the root to the projects that import it, one\n * declared only by a workspace lib (`@nestjs/jwt`, declared by `libs/nest/auth`, not by `counter`)\n * no longer passes that check, and Vite INLINES it, `node_modules/.pnpm` path included. The build\n * stays green and `assertManifestCovers` cannot see it, because an inlined package is no longer an\n * import. Measured on the 9 Vite Nest services with the root drop applied: `@nestjs/jwt` inlined,\n * `@nestjs/core/*` subpaths inlined, and `require` order shuffled in files that did not change.\n *\n * Vite still RESOLVES such a package correctly, from the importer: only the externalise decision\n * is wrong. So the resolution is delegated to Vite (`this.resolve`), and this plugin only turns an\n * inlined package from `node_modules` into the external Vite would have emitted. A first version\n * resolved with `require.resolve` and built the id itself: it changed 38 to 315 files per service on\n * `main`, because Vite keeps an `exports` subpath as written (`rxjs/operators`, not\n * `rxjs/dist/cjs/operators/index.js`) and carries the package's `sideEffects`, without which\n * Rollup kept barrel modules it used to drop.\n */\nimport { existsSync, readFileSync } from 'node:fs'\nimport { isBuiltin } from 'node:module'\nimport { dirname, extname, join, sep } from 'node:path'\n\nimport type { Plugin } from 'vite'\n\nimport type { Alias } from './tsconfig-aliases.js'\n\nexport interface ImporterExternalsOptions {\n /**\n * Packages that must stay INLINED, the same list `ssr.noExternal` receives.\n *\n * Checked before anything else. This plugin runs `enforce: 'pre'`, and a `resolveId` there\n * wins against `ssr.noExternal`: measured, `tslib` then comes out as `require(\"tslib\")`, which\n * is the exact incident `noExternal` exists for. The image does not install `tslib`, because\n * no module declares it, and the service died on its first require.\n */\n noExternal: readonly string[]\n /** The workspace's tsconfig `paths`: those are source, and Vite bundles them. */\n aliases: readonly Alias[]\n}\n\n/** `@scope/name/sub/path` → `@scope/name`, `name/sub` → `name`. */\nexport const packageName = (specifier: string): string => {\n const parts = specifier.split('/')\n return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : (parts[0] ?? specifier)\n}\n\nconst isBareSpecifier = (specifier: string): boolean =>\n !specifier.startsWith('.') &&\n !specifier.startsWith('/') &&\n !specifier.startsWith('\\0') &&\n !isBuiltin(specifier)\n\n/** The package's own manifest, walked up from a file inside it. */\nconst packageManifest = (file: string, name: string): { exports?: unknown } | undefined => {\n let directory = dirname(file)\n while (directory !== dirname(directory)) {\n const manifest = join(directory, 'package.json')\n if (existsSync(manifest)) {\n try {\n const data = JSON.parse(readFileSync(manifest, 'utf8')) as {\n name?: string\n exports?: unknown\n }\n if (data.name === name) return data\n } catch {\n // A manifest that does not parse is not the package's own; keep walking.\n }\n }\n directory = dirname(directory)\n }\n return undefined\n}\n\n/**\n * The id Vite's `tryNodeResolve` gives an external, reproduced so the output does not move: the\n * specifier as written, except a deep import into a package WITHOUT an `exports` map whose\n * extension the resolution changed, which becomes the resolved file\n * (`@nestjs/core/injector/container` → `@nestjs/core/injector/container.js`).\n */\nconst externalId = (specifier: string, name: string, resolved: string): string => {\n if (specifier === name) return specifier\n if (extname(specifier) === extname(resolved)) return specifier\n if (packageManifest(resolved, name)?.exports !== undefined) return specifier\n const index = resolved.split(sep).join('/').indexOf(specifier)\n return index === -1 ? specifier : resolved.split(sep).join('/').slice(index)\n}\n\nexport const importerExternals = (options: ImporterExternalsOptions): Plugin => ({\n name: 'sentinel:importer-externals',\n enforce: 'pre',\n async resolveId(source, importer, resolveOptions) {\n if (!importer || !isBareSpecifier(source)) return null\n const name = packageName(source)\n // FIRST: a package `ssr.noExternal` inlines is never made external here (see the option).\n if (options.noExternal.includes(name)) return null\n if (options.aliases.some((alias) => alias.find.test(source))) return null\n const resolved = await this.resolve(source, importer, { ...resolveOptions, skipSelf: true })\n // Unresolved, or already external: Vite's own decision stands, byte for byte.\n if (!resolved || resolved.external) return null\n const file = resolved.id.split('?')[0] ?? resolved.id\n // A workspace package linked into `node_modules` resolves to its real path, outside it:\n // that is source, and it stays bundled as it is today.\n if (!file.split(sep).includes('node_modules')) return null\n return {\n id: externalId(source, name, file),\n external: true,\n moduleSideEffects: resolved.moduleSideEffects,\n }\n },\n})\n","/**\n * The pruned `package.json` and lockfile a Node image installs from.\n *\n * This is the half that made the nx executor look load-bearing. `--generatePackageJson` was an\n * option of `@nx/webpack:webpack`, and the backend image is built on its two outputs:\n *\n * COPY --from=build /app/dist/apps/nest/<service>/package.json /app/package.json\n * COPY --from=build /app/dist/apps/nest/<service>/pnpm-lock.yaml /app/pnpm-lock.yaml\n * RUN pnpm i --prod=true --frozen-lockfile\n *\n * It is not bound to the executor. The executor supplies one thing, the project graph, and\n * every function that does the work is exported by `@nx/js`. So the same two files can be\n * produced from a Vite plugin, and `--frozen-lockfile` accepting them afterwards is the proof\n * that they agree with each other: pnpm refuses the install otherwise.\n *\n * ## Why the graph, and not something simpler\n *\n * A Nest service here declares almost nothing of its own: measured, one dependency in its\n * manifest against 1463 installed in its image. Everything it uses is declared at the\n * workspace root and reaches it through source imports. So the dependency list can only be\n * computed from what the code imports, which is what the graph knows.\n *\n * Deriving it from the bundle's own externals is not equivalent either: measured on the first\n * service, the bundle statically requires 42 packages while the correct manifest holds 65. The\n * 23 missing ones are transitive or required dynamically at runtime (`fastify` behind\n * `@nestjs/platform-fastify`, `jsonwebtoken`, the LaunchDarkly SDK). Shipping the short list\n * fails in production, not in the build.\n */\nimport { existsSync, writeFileSync } from 'node:fs'\nimport { isBuiltin } from 'node:module'\nimport { join } from 'node:path'\n\nimport type { Plugin, Rollup } from 'vite'\n\nexport interface NodeManifestOptions {\n /** The nx project name, which is how the graph is keyed. */\n project: string\n /** Absolute path to the workspace root. */\n workspaceRoot: string\n /** Where the bundle is written; the two files land beside it. */\n outDir: string\n /** Entry file name, written as `main` so `node .` resolves inside the image. */\n entry?: string\n /**\n * Packages the IMAGE provides, which are therefore allowed to be required without being\n * declared in the manifest.\n *\n * The generated Prisma clients are the case this exists for: the Dockerfile copies\n * `node_modules/@prisma` from its own stage, so they resolve at runtime while no module\n * declares them. Everything else that is required and undeclared is a bug, and the check\n * below refuses the build.\n */\n providedByImage?: readonly string[]\n}\n\n/** What the Dockerfile copies in itself, so requiring it without declaring it is legitimate. */\nconst DEFAULT_PROVIDED_BY_IMAGE = ['@prisma'] as const\n\n/**\n * Every real package the emitted output requires, from Rollup's own record rather than a scan\n * of the text.\n *\n * `chunk.imports` mixes three things, and only the third is a dependency:\n *\n * - other EMITTED chunks. Under `preserveModules` these are relative output paths that do not\n * begin with `.`, so `apps/…`, `libs/…` and `_virtual/…` read exactly like package names.\n * They are recognised by being keys of the bundle itself.\n * - Node BUILTINS, which appear both bare (`crypto`) and prefixed (`node:crypto`).\n * - genuine external packages, which is what the manifest has to cover.\n */\nconst externalPackages = (bundle: Rollup.OutputBundle): Set<string> => {\n const emitted = new Set(Object.keys(bundle))\n const packages = new Set<string>()\n\n for (const emittedFile of Object.values(bundle)) {\n // Assets carry no imports, and the bundle holds both. Narrowing on the discriminant is what\n // replaced a cast through `unknown`, which asserted the same thing without checking it.\n if (emittedFile.type !== 'chunk') continue\n\n for (const imported of emittedFile.imports) {\n if (emitted.has(imported)) continue\n if (imported.startsWith('.') || imported.startsWith('/')) continue\n if (isBuiltin(imported)) continue\n\n const parts = imported.split('/')\n const name = imported.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]\n if (name) packages.add(name)\n }\n }\n return packages\n}\n\n/**\n * Writes the manifest and lockfile once the bundle is on disk.\n *\n * `closeBundle` rather than `writeBundle`, so the files land after Vite has finished with the\n * directory and cannot be cleared by `emptyOutDir`.\n *\n * `closeBundle` also runs after a FAILED build, where there is no directory to write into. Left\n * alone this hook then threw `ENOENT` on the manifest, and since it is the last error raised it\n * became the one Vite printed, hiding the failure that actually stopped the build. It cost two\n * diagnoses before being recognised, so the missing directory is now read as what it is: the\n * bundle was never written, and this plugin has nothing to say about why.\n */\nexport const nodeManifest = (options: NodeManifestOptions): Plugin => {\n /*\n * Captured while the bundle still exists in memory, and read back once the manifest is built.\n *\n * `undefined` until `generateBundle` runs, deliberately: an empty SET would make the coverage\n * check below pass by having seen nothing, which is the worst way for a guard to succeed. The\n * two states have to be told apart.\n */\n let required: Set<string> | undefined\n\n return {\n name: 'sentinel:node-manifest',\n apply: 'build',\n\n generateBundle(_outputOptions, bundle) {\n required = externalPackages(bundle)\n },\n\n async closeBundle() {\n // `closeBundle` runs after a FAILED build too. The ENTRY is what says a bundle was really\n // written: `emptyOutDir` leaves the directory in place, so its existence alone would let a\n // failed build still produce a manifest describing nothing.\n const entry = options.entry ?? 'main.js'\n if (!existsSync(join(options.outDir, entry))) return\n\n // Imported here rather than at module scope: a front config importing this file must not\n // pay for loading the nx graph machinery it will never call.\n const { createProjectGraphAsync } = await import('nx/src/devkit-exports.js')\n const { createPackageJson, createLockFile, getLockFileName } = await import('@nx/js')\n\n const graph = await createProjectGraphAsync({ exitOnError: false })\n const manifest = createPackageJson(options.project, graph, {\n root: options.workspaceRoot,\n isProduction: true,\n })\n manifest.main = entry\n\n if (required === undefined) {\n throw new Error(\n `sentinel build(nest): the bundle was written but never inspected, so the manifest ` +\n `could not be checked against what it requires. Refusing to write one that might ` +\n `not install.`,\n )\n }\n assertManifestCovers(required, manifest, options)\n\n writeFileSync(join(options.outDir, 'package.json'), `${JSON.stringify(manifest, null, 2)}\\n`)\n writeFileSync(\n join(options.outDir, getLockFileName('pnpm')),\n createLockFile(manifest, graph, 'pnpm'),\n )\n },\n }\n}\n\n/**\n * Refuse a bundle that requires something the image will not install.\n *\n * The manifest is derived from the project GRAPH, so it lists what modules declare. The bundle\n * requires what the bundler left external. Those two sets agreeing is an assumption, and it was\n * wrong: `tslib` arrives through `importHelpers` in the workspace tsconfig without any module\n * declaring it, so the manifest omitted it and the service died on its first require.\n *\n * It died only in the image. Locally and in CI the workspace root has the package, so the gap is\n * invisible until a container starts, which is the most expensive place to discover it. Checking\n * here turns that into a build failure naming the package.\n */\nconst assertManifestCovers = (\n required: ReadonlySet<string>,\n manifest: { dependencies?: Record<string, string> },\n options: NodeManifestOptions,\n): void => {\n const declared = new Set(Object.keys(manifest.dependencies ?? {}))\n const provided = options.providedByImage ?? DEFAULT_PROVIDED_BY_IMAGE\n const missing = [...required]\n .filter((name) => !declared.has(name))\n .filter((name) => !provided.some((prefix) => name === prefix || name.startsWith(`${prefix}/`)))\n .sort()\n\n if (missing.length === 0) return\n\n throw new Error(\n `sentinel build(nest): the bundle requires ${missing.length} package(s) the generated ` +\n `manifest does not declare, so the image would not install them: ${missing.join(', ')}. ` +\n `Either declare them in the module's package.json, inline them with ` +\n `\\`ssr.noExternal\\`, or list them in \\`providedByImage\\` if the Dockerfile copies them in.`,\n )\n}\n","/**\n * What `@hublo/sentinel/build/nest` gives an adopted service.\n *\n * The sibling `build/react` entry is a re-export surface: the module keeps its own config and\n * only its import lines move. This one carries a shape as well, because 38 Nest services share\n * one config and have nothing of their own to preserve. See `nest-service.ts` for why the two\n * answers differ, and why that is one rule rather than two.\n *\n * Its own entry point, like the React one, so importing it at build time never drags the CLI\n * and its adapters into a Vite config.\n */\nexport { nestService, type NestServiceOptions } from './nest-service.js'\n\n// The pieces the shape is built from, exported individually so a service that needs something\n// the shape does not give it can compose its own without leaving sentinel.\nexport { decoratorMetadata, type DecoratorMetadataOptions } from './decorator-metadata.js'\nexport { nodeManifest, type NodeManifestOptions } from './node-manifest.js'\nexport { tsconfigAliases, type Alias } from './tsconfig-aliases.js'\n\n// Vite's own, so an adopted config imports everything from one place and the module can drop\n// `vite` from its dependencies.\nexport { defineConfig, loadEnv, mergeConfig } from 'vite'\nexport type { Plugin, PluginOption, UserConfig, UserConfigExport } from 'vite'\n"],"mappings":"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","names":["join","existsSync","dirname","join","sep","existsSync","isBuiltin","join","join","mergeConfig"]}
|
|
@@ -13,6 +13,9 @@ type StaticDefaults = {
|
|
|
13
13
|
minConfidence: number;
|
|
14
14
|
categories: string[];
|
|
15
15
|
};
|
|
16
|
+
graph: {
|
|
17
|
+
resolution: number;
|
|
18
|
+
};
|
|
16
19
|
tools: {
|
|
17
20
|
jscpd: Record<string, unknown>;
|
|
18
21
|
};
|
|
@@ -27,7 +30,18 @@ interface StaticAnalysisInput {
|
|
|
27
30
|
root?: string;
|
|
28
31
|
/** Files that run although nothing imports them. ADDED to the shipped patterns. */
|
|
29
32
|
entry?: string[];
|
|
30
|
-
/**
|
|
33
|
+
/**
|
|
34
|
+
* Named journeys, as `{ name: [globs] }`. MERGED by name with any shipped ones.
|
|
35
|
+
*
|
|
36
|
+
* ⛔ **No pass reads this yet, and the generated config stub no longer asks for it.** It is the
|
|
37
|
+
* seam for the product axis, where a figure is reported per journey rather than per module, and
|
|
38
|
+
* that is V2 work tracked in issue #98. It stays accepted so a module that already declared one
|
|
39
|
+
* is not broken, and `--inspect` echoes it back, but nothing measures against it.
|
|
40
|
+
*
|
|
41
|
+
* It was in the stub for a while, under a comment telling every adopting team to fill it in. A
|
|
42
|
+
* team that did would have waited for a breakdown that never came and concluded their globs were
|
|
43
|
+
* wrong. Asking for something nothing consumes is worse than not offering it.
|
|
44
|
+
*/
|
|
31
45
|
journeys?: Record<string, string[]>;
|
|
32
46
|
/**
|
|
33
47
|
* The seam. Replaces whatever it names, and every path it sets lands in `moved`.
|
|
@@ -3,13 +3,13 @@ import {
|
|
|
3
3
|
} from "../../../chunk-NUOQXAYR.js";
|
|
4
4
|
import {
|
|
5
5
|
installWorkspaceSetup
|
|
6
|
-
} from "../../../chunk-
|
|
6
|
+
} from "../../../chunk-Y57KA4QS.js";
|
|
7
7
|
import "../../../chunk-ELZHIN6E.js";
|
|
8
8
|
import {
|
|
9
9
|
clearDeepMocks,
|
|
10
10
|
resetDeepMocks
|
|
11
11
|
} from "../../../chunk-2XLX6PFR.js";
|
|
12
|
-
import "../../../chunk-
|
|
12
|
+
import "../../../chunk-IL5PPLXE.js";
|
|
13
13
|
|
|
14
14
|
// src/roles/test/setup/jest-parity.ts
|
|
15
15
|
import { expect, vi } from "vitest";
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
2
|
pinWorkspaceTimezone
|
|
3
|
-
} from "../../../chunk-
|
|
3
|
+
} from "../../../chunk-Y57KA4QS.js";
|
|
4
4
|
import "../../../chunk-ELZHIN6E.js";
|
|
5
|
-
import "../../../chunk-
|
|
5
|
+
import "../../../chunk-IL5PPLXE.js";
|
|
6
6
|
|
|
7
7
|
// src/roles/test/setup/workspace-entry.ts
|
|
8
8
|
await pinWorkspaceTimezone();
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
# Bundle analysis: specification
|
|
2
|
+
|
|
3
|
+
One question: **how much JavaScript does a front application deliver, what does it cost to download
|
|
4
|
+
and to parse, and how does that compare to what the web does and to what is recommended.**
|
|
5
|
+
|
|
6
|
+
One sentence governs the whole design, from the Chrome team:
|
|
7
|
+
|
|
8
|
+
> "900 KB of decompressed JavaScript is still 900 KB to the parser and compiler, even though it may
|
|
9
|
+
> be roughly 300 KB when compressed."
|
|
10
|
+
>
|
|
11
|
+
> [web.dev, Reduce JavaScript payloads with tree shaking](https://web.dev/articles/reduce-javascript-payloads-with-tree-shaking)
|
|
12
|
+
|
|
13
|
+
So bundle size is not one number. It is **two costs**, and they are not interchangeable: compression
|
|
14
|
+
is a network lever and has no effect on the engine. Measured on our own applications, the ratio
|
|
15
|
+
between them varies from **3.30 to 3.70** depending on the entry, so neither figure can be derived
|
|
16
|
+
from the other.
|
|
17
|
+
|
|
18
|
+
## 1. What is measured, and against what
|
|
19
|
+
|
|
20
|
+
| figure | unit | reference | source of the reference |
|
|
21
|
+
| ---------------------- | ------------ | ------------------------------------------- | ----------------------------------- |
|
|
22
|
+
| initial load, transfer | gzip | **170 KB**, and the web median **747.5 KB** | Addy Osmani; HTTP Archive `bytesJs` |
|
|
23
|
+
| initial load, to parse | decompressed | **717 KB** | Addy Osmani, "~0.7MB uncompressed" |
|
|
24
|
+
| heaviest single asset | decompressed | **100 KB** per script | web.dev, "a good target" |
|
|
25
|
+
|
|
26
|
+
Each figure meets a reference **of its own nature**. The transfer figure is compared to transfer
|
|
27
|
+
references, the decompressed figure to a decompressed reference. Mixing the two was the first
|
|
28
|
+
version's mistake, and it inverts verdicts rather than merely shifting them.
|
|
29
|
+
|
|
30
|
+
Bands: `ok` below 0.7x, `near` from 0.7x to 1x, `exceed` above 1x.
|
|
31
|
+
|
|
32
|
+
**Two references on purpose, because they answer different questions.** The HTTP Archive median
|
|
33
|
+
says _are we normal_. The engineering budget says _are we good_. All three fronts sit **below the
|
|
34
|
+
web median and above the budget**, and both statements are true:
|
|
35
|
+
|
|
36
|
+
| | transfer | vs 170 KB | to parse | vs 717 KB | vs median | files |
|
|
37
|
+
| ------- | -------- | --------- | -------- | --------- | ----------- | ----- |
|
|
38
|
+
| front A | 377 KB | **2.22x** | 1202 KB | **1.68x** | 0.50x, `ok` | 44 |
|
|
39
|
+
| front B | 473 KB | **2.78x** | 1681 KB | **2.35x** | 0.63x, `ok` | 88 |
|
|
40
|
+
| front C | 252 KB | 1.48x | 916 KB | 1.28x | 0.34x, `ok` | 5 |
|
|
41
|
+
|
|
42
|
+
The budget carries conditions, and they travel with the number: _"getting interactive in under five
|
|
43
|
+
seconds on a slow 3G connection on a median mobile device"_.
|
|
44
|
+
|
|
45
|
+
Heaviest single asset, decompressed: 722 KB, 842 KB and 534 KB, so **5 to 8 times** the 100 KB
|
|
46
|
+
per-script target. No `manualChunks` is configured on any front.
|
|
47
|
+
|
|
48
|
+
### 1.1 The layer the entry manifest does not see, and it is the larger one
|
|
49
|
+
|
|
50
|
+
⛔ The entry manifest describes what an entry pulls **statically**. The build emits far more, and
|
|
51
|
+
this is not leftovers: every unreferenced file carries the same build's timestamp, checked.
|
|
52
|
+
|
|
53
|
+
What they are, confirmed by opening one: **translation chunks, one per namespace per locale.** One
|
|
54
|
+
front emits 393 namespaces, another 289, against 11 for the third.
|
|
55
|
+
|
|
56
|
+
The measured weight, taking for each namespace its heaviest variant and then its lightest, so the
|
|
57
|
+
pair is a bound rather than an average:
|
|
58
|
+
|
|
59
|
+
| | static entry set | translations, upper bound | translations, lower bound |
|
|
60
|
+
| ------- | ---------------- | ------------------------- | ------------------------- |
|
|
61
|
+
| front A | 1202 KB | 294 KB | 290 KB |
|
|
62
|
+
| front B | 1681 KB | **1917 KB** | 1520 KB |
|
|
63
|
+
| front C | 916 KB | **1233 KB** | 978 KB |
|
|
64
|
+
|
|
65
|
+
⭐ On two of the three fronts the translation layer **outweighs the entire static bundle**. Reporting
|
|
66
|
+
only the entry set would therefore publish less than half of the JavaScript a user can end up
|
|
67
|
+
parsing, while calling it the application's weight.
|
|
68
|
+
|
|
69
|
+
⚠️ The upper bound assumes a user reaches every namespace, which no single session does, and the gap
|
|
70
|
+
to the lower bound is the spread between locales. So it is a ceiling, reported as a ceiling. What is
|
|
71
|
+
certain is the shape: a user downloads one locale, for the namespaces their screens reach.
|
|
72
|
+
|
|
73
|
+
Both available sources are blind to one half of this, which is why the report carries both figures
|
|
74
|
+
rather than one:
|
|
75
|
+
|
|
76
|
+
| source | what it cannot see |
|
|
77
|
+
| -------------------------------------------- | ---------------------------------------------------------- |
|
|
78
|
+
| the entry manifest | the whole dynamic layer, so it understates |
|
|
79
|
+
| the output directory, or an analyser over it | every locale at once, so it overstates by the locale count |
|
|
80
|
+
|
|
81
|
+
## 2. Perimeter: discovered, never listed
|
|
82
|
+
|
|
83
|
+
⛔ No application list anywhere. A front added next month is measured the day it adopts the role,
|
|
84
|
+
with no edit here.
|
|
85
|
+
|
|
86
|
+
**The evidence is the bundler's entry manifest, not a directory.** That distinction is the whole
|
|
87
|
+
design, and the build role already records why: a declared or conventional directory that exists
|
|
88
|
+
proves nothing, because "`dist` can be left over from the previous bundler while Vite now writes
|
|
89
|
+
`.output`, and a check that only looked for presence would pass". A stale directory from a retired
|
|
90
|
+
bundler holds no current manifest, so looking for the manifest cannot be fooled by one.
|
|
91
|
+
|
|
92
|
+
Declared nx `outputs` are consulted when a module declares them, and they cannot be the basis:
|
|
93
|
+
measured on the three fronts, **only one declares any**, and that one declares three candidate
|
|
94
|
+
directories of which two do not exist.
|
|
95
|
+
|
|
96
|
+
⚠️ The manifest is a **TanStack Start** artefact, not a Vite one. A Vite front that is not TanStack
|
|
97
|
+
Start has no per-entry manifest, so it falls outside this release by construction rather than by
|
|
98
|
+
choice, and the report says so for that module instead of staying silent.
|
|
99
|
+
|
|
100
|
+
Two consequences, and they are consequences rather than rules, which is why nothing below is
|
|
101
|
+
hardcoded:
|
|
102
|
+
|
|
103
|
+
| not measured | because |
|
|
104
|
+
| ------------------------------ | ------------------------------------------------------------------------------------------ |
|
|
105
|
+
| the server bundle | it is not in the entry set, and no browser downloads it |
|
|
106
|
+
| per-brand static assets | not in the entry set either, and they are not the bundle |
|
|
107
|
+
| the sum of every emitted chunk | over 2000 files on one front, most of them translations; their sum is a number nobody pays |
|
|
108
|
+
|
|
109
|
+
### 2.1 What the report must state about its own conditions
|
|
110
|
+
|
|
111
|
+
Three facts decide what a figure means, so the report carries all three beside the numbers rather
|
|
112
|
+
than leaving a reader to assume them.
|
|
113
|
+
|
|
114
|
+
| stated | why |
|
|
115
|
+
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
116
|
+
| the build's date | a measurement on a stale build is silently wrong. It happened while drafting this: an entire version was written on a build five weeks old, and nothing in the output said so |
|
|
117
|
+
| the brand | two of the three fronts refuse to build without a brand selected, so the output measured is one brand's. The role does not choose it, the build did, and the report names which one it found |
|
|
118
|
+
| a missing build | reported as a missing build, never as zero |
|
|
119
|
+
|
|
120
|
+
⛔ And no rounding may turn a real value into zero. One front emits a 57-byte stylesheet, which a
|
|
121
|
+
kilobyte column printed as `0 KB`: that reads as "nothing here" when the truth is "a 57-byte file".
|
|
122
|
+
Below a kilobyte the report shows bytes.
|
|
123
|
+
|
|
124
|
+
## 3. What the report says, per application
|
|
125
|
+
|
|
126
|
+
| | |
|
|
127
|
+
| ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
128
|
+
| initial load | per type, both costs, with the file count |
|
|
129
|
+
| the heavy assets | the heaviest per type. ⚠️ The heaviest ONE, not a top 5: the bundle family publishes no list of places, because an asset is generated and its name is a content hash |
|
|
130
|
+
| the heavy and costly JavaScript | both costs side by side, because 842 KB to parse is invisible in 240 KB transferred |
|
|
131
|
+
| **the dynamic layer** | the translations: its weight as an upper and a lower bound, and its group count. ⛔ NOT what a user pays: that is a subset the role cannot name, since it depends on navigation and the manifest does not record which screen pulls which chunk |
|
|
132
|
+
| what the entry set references | read as code-splitting health, never as a user cost. ⚠️ Not named "total emitted", because it is not: the build writes more, and §1.1 is why |
|
|
133
|
+
| **the guide** | what each figure means, why the decompressed one matters, and **what to do about it** |
|
|
134
|
+
|
|
135
|
+
The guide is a deliverable, not decoration. A report that says "too heavy" and stops leaves the
|
|
136
|
+
reader where they started. Each breach carries the concrete lever and its reference: route-level
|
|
137
|
+
splitting, `manualChunks` for the vendor boundary, import on interaction or on visibility, and the
|
|
138
|
+
patterns and web.dev pages behind each.
|
|
139
|
+
|
|
140
|
+
## 4. Where it runs
|
|
141
|
+
|
|
142
|
+
| | |
|
|
143
|
+
| ------------ | ------------------------------------------------------------------------------------------- |
|
|
144
|
+
| locally | like every other metric of the role, after a build |
|
|
145
|
+
| CI | the same command |
|
|
146
|
+
| pull request | a **recap of the figures**, posted every run and updated in place, **not blocking**. See §8 |
|
|
147
|
+
|
|
148
|
+
Not blocking is a choice for now, not an end state. A gate that refuses before the figures are
|
|
149
|
+
familiar teaches people to route around it.
|
|
150
|
+
|
|
151
|
+
## 5. What the practice elsewhere actually is
|
|
152
|
+
|
|
153
|
+
Worth reading before building, because the published cases agree on something our first version got
|
|
154
|
+
wrong: they gate the **drift**, not the level.
|
|
155
|
+
|
|
156
|
+
| case | method | measured |
|
|
157
|
+
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
158
|
+
| a grocery front at scale | a job logs the size of every build; a pull request **fails above +1%** on main bundles; breaches escalate to a performance engineer | the gate itself |
|
|
159
|
+
| a large social front | a build tracker **comments on each pull request** with the breakdown, and logs every production build | "prevented changes that would have increased metrics like TTI, TTFMC and Time to First Tweet by over 100%" |
|
|
160
|
+
| the same front, earlier | 3 JavaScript files, "over 1MB in size (420KB gzip transfer size)", split into ~40 on-demand chunks | main bundle load "over 5 seconds" to "barely 3 seconds", simulated 3G with a 5x slower CPU |
|
|
161
|
+
| a dating front | a bundle analyzer, then polyfill trimming, module replacement and route splitting, with explicit budgets of ~155 KB main and vendor, ~55 KB per async chunk, 20 KB CSS | 166 KB to 101 KB, DCL 5.46s to 4.69s |
|
|
162
|
+
| a file-sharing front | a bundler migration for static tree shaking and automatic splitting | bundle sizes **-33%**, script count -15%, and honestly "modest TTVC improvements" |
|
|
163
|
+
|
|
164
|
+
Both figures of the two-cost model appear in the social front's own write-up: **1 MB raw for 420 KB
|
|
165
|
+
transferred**. That is the spec's first section, measured in production years ago.
|
|
166
|
+
|
|
167
|
+
⚠️ Three corrections to the retellings, so we do not repeat them:
|
|
168
|
+
|
|
169
|
+
- The famous **"100ms = up to 1% incremental revenue"** is about **page load time**, obtained by
|
|
170
|
+
correlating real-user monitoring between converted and non-converted populations. It is not an
|
|
171
|
+
experiment, the word "up to" is in the original and usually dropped, and the deck it comes from
|
|
172
|
+
never mentions JavaScript. It does not say that bundle size drives revenue.
|
|
173
|
+
- The social front's write-up names DevTools, Lighthouse and a React profiler. It does **not** name a
|
|
174
|
+
bundle analyzer, despite the common claim.
|
|
175
|
+
- The dating front's widely quoted "TTI 11.91s to 4.69s" is a conflation. The sourced figure is
|
|
176
|
+
**DCL**.
|
|
177
|
+
|
|
178
|
+
## 6. Traps that make a figure wrong without saying so
|
|
179
|
+
|
|
180
|
+
Each of these produced a plausible number rather than an error, which is why they are in the spec.
|
|
181
|
+
|
|
182
|
+
| trap | symptom | contract |
|
|
183
|
+
| ------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
184
|
+
| the bundler's `base` is in the manifest and not on disk | an entry resolved to **0 KB over 82 files**, which reads as weightless | resolve, retry without the prefix, then **fail loudly**. Never parse the Vite config for the value: it is a const today and an environment variable tomorrow |
|
|
185
|
+
| an id listed as a child with no entry of its own | 15 server routes awarded the entry's weight, and a health endpoint reported as the lightest screen | an entry exists in the route map **and** is reachable |
|
|
186
|
+
| bytes on disk compared to a transfer reference | wrong by a factor of 3.4, and it **inverts** the verdict rather than exaggerating it | section 1 |
|
|
187
|
+
| a size the analyser does not report, defaulted to zero | an application looks lighter than it is | a missing figure is missing, never zero |
|
|
188
|
+
| rounding a real value to zero | a 57-byte stylesheet printed as `0 KB`, which reads as "nothing here" | below a kilobyte, show bytes |
|
|
189
|
+
| taking the entry manifest for the whole output | one front referenced 10 files while the build wrote 1336, and the gap was the translations | §1.1. The static entry set and the dynamic layer are two figures, and neither is the other's total |
|
|
190
|
+
| a stale build output | nothing in the output says how old it is, so a five-week-old figure reads as today's | the report states the build's date and its brand, §2.1 |
|
|
191
|
+
|
|
192
|
+
## 7. What it refuses to answer
|
|
193
|
+
|
|
194
|
+
| refused | why |
|
|
195
|
+
| ---------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
196
|
+
| a cost in milliseconds | ⛔ no primary source publishes a V8 parse throughput. The only published absolutes are device measurements: 1 MB decompressed costs **280 ms on a high-end phone, 1781 ms on a median one, 6010 ms on a sub-$100 one** |
|
|
197
|
+
| which shipped code is unused at runtime | that needs Chrome coverage and source maps. The role's dead-code pass measures **reachability in `src`**, a different question |
|
|
198
|
+
| anything requiring a running application | timings, Core Web Vitals, hydration. The measurement stops where CI stops |
|
|
199
|
+
| whether a screen is too slow | a product question, not a byte count |
|
|
200
|
+
|
|
201
|
+
⚠️ On unused code, the precise statement matters. A function that is never called is **preparsed**,
|
|
202
|
+
not fully parsed, so it costs roughly a third rather than full price. But no delivered byte is free:
|
|
203
|
+
finding where a function ends "requires parsing the full syntax". And the engine cannot help, by
|
|
204
|
+
construction, because the parser must decide "before it even starts parsing a function" whether to
|
|
205
|
+
compile it.
|
|
206
|
+
|
|
207
|
+
Second precision, against a claim that is still repeated: the V8 team **retired** "parse is the
|
|
208
|
+
dominant cost" in 2019, stating that "the dominant costs of processing scripts are now download and
|
|
209
|
+
CPU execution time". The widely quoted per-device parse figures date from 2017.
|
|
210
|
+
|
|
211
|
+
## 8. The recap on the pull request
|
|
212
|
+
|
|
213
|
+
A report nobody opens is a report that changes nothing, and the published cases in section 5 agree:
|
|
214
|
+
both of them put the figures where the review already happens. So this release does not stop at a
|
|
215
|
+
page on disk.
|
|
216
|
+
|
|
217
|
+
| | |
|
|
218
|
+
| --------------- | ---------------------------------------------------------------------------------------------------------------------- |
|
|
219
|
+
| what is posted | one section per module the pull request touched: six rows, one per family, the headline figure and the places it holds |
|
|
220
|
+
| when | every run, not only on a breach: a recap that appears only when something is wrong is a recap nobody learns to read |
|
|
221
|
+
| blocking | **no**, and that is a choice for now rather than an end state |
|
|
222
|
+
| on a second run | the same comment is updated, never a second one |
|
|
223
|
+
|
|
224
|
+
⛔ The division of labour matters, because getting it wrong couples this role to one CI provider.
|
|
225
|
+
**sentinel renders the content, the repository's workflow posts it.** The content is markdown
|
|
226
|
+
produced from the same figures the other three shapes carry, which makes it testable offline and
|
|
227
|
+
identical to what the console printed. Who holds the token, which event triggers it and how a
|
|
228
|
+
comment is found again are the repository's business, not this role's.
|
|
229
|
+
|
|
230
|
+
### 8.1 What it is, as built
|
|
231
|
+
|
|
232
|
+
⚠️ It covers the **whole role**, not the bundle alone, and that is a correction to the line above
|
|
233
|
+
that said "the fronts the pull request touched". A pull request touches libraries and services too,
|
|
234
|
+
and a recap that stayed silent on them would teach a reader that this role only speaks about fronts.
|
|
235
|
+
A module with no build gets five rows and a line saying why the sixth is absent.
|
|
236
|
+
|
|
237
|
+
Three decisions worth stating, because each one was a fork:
|
|
238
|
+
|
|
239
|
+
- **The headline per family is declared, not positional.** Three of the six would be wrong taken as
|
|
240
|
+
"the first metric listed": `importEdges` leads structure and is a size rather than a signal, so a
|
|
241
|
+
big module would look bad for being big, and `entryPoints` leads dead code and is the walk's
|
|
242
|
+
starting condition rather than a result.
|
|
243
|
+
- **The row is named by the metric's own help label**, not the family's name. "Duplication | 0.75%"
|
|
244
|
+
does not say of what; "Duplicated lines" does. One wording, shared with the page.
|
|
245
|
+
- **A marker says where a figure sits against a published reference**, `(ok)` included, and a row
|
|
246
|
+
with no marker has no reference rather than sitting inside one. Those are different facts, and
|
|
247
|
+
six of this role's figures genuinely have none.
|
|
248
|
+
|
|
249
|
+
It reads no source file and lays out no graph: a recap shows no code, so it skips both the 201 file
|
|
250
|
+
reads and the 5-second layout the page pays for.
|
|
251
|
+
|
|
252
|
+
⛔ And it says on itself that it is **not compared with `main`**. See section 9: the delta needs a
|
|
253
|
+
stored baseline. A figure in a pull-request comment is read as a delta by default, and that reading
|
|
254
|
+
is wrong in the dangerous direction, since a module already at 6.81% looks exactly like one this
|
|
255
|
+
branch pushed there.
|
|
256
|
+
|
|
257
|
+
## 9. Deferred, with the reason
|
|
258
|
+
|
|
259
|
+
Recorded in issue #97. Both need something this release does not have, and neither changes a figure
|
|
260
|
+
it produces.
|
|
261
|
+
|
|
262
|
+
| deferred | what it needs |
|
|
263
|
+
| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
264
|
+
| the code cache a content hash discards on every deploy, worth **20-40% of parse and compile time** | module-to-chunk attribution, so participation in the build. Nothing of the sort is emitted today |
|
|
265
|
+
| the **delta** against the default branch, which is what the cases in section 5 actually gate | a stored baseline in CI, which is infrastructure rather than measurement |
|
|
266
|
+
|
|
267
|
+
⚠️ The recap above is the LEVEL, not the delta. The level is what a reader needs to learn the
|
|
268
|
+
figures; the delta is what eventually holds a line, and it arrives with the baseline that makes it
|
|
269
|
+
possible.
|
|
270
|
+
|
|
271
|
+
This release reads the build output and touches no application. That is what keeps it cheap, and it
|
|
272
|
+
is the reason the split falls here.
|