diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index f82fd687e1402e..85d83e704cf12e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -74,7 +74,7 @@ jobs: - name: build, test, lint, test-ssr (affected) run: | - FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging --nxBail + FLUENT_JEST_WORKER=2 yarn nx affected -t build test lint type-check test-ssr test-integration verify-packaging verify-bundle-isolation --nxBail - name: 'Check for unstaged changes' run: | diff --git a/change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json b/change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json new file mode 100644 index 00000000000000..ca8f9a07e94c4d --- /dev/null +++ b/change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json @@ -0,0 +1,7 @@ +{ + "type": "none", + "comment": "chore: verify headless entry points do not bundle tabster, Griffel or react-icons", + "packageName": "@fluentui/react-headless-components-preview", + "email": "martinhochel@microsoft.com", + "dependentChangeType": "none" +} diff --git a/package.json b/package.json index fb0c77964a7db4..47a8d1e74a0101 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "@fluentui/react-integration-tester": "*", "@fluentui/scripts-test-ssr": "*", "@fluentui/storybook-llms-extractor": "*", + "@fluentui/verify-bundle-isolation": "*", "@griffel/babel-preset": "1.5.8", "@griffel/eslint-plugin": "^2.0.0", "@griffel/jest-serializer": "1.1.24", @@ -381,6 +382,7 @@ "swc-loader": "0.2.6", "syncpack/minimatch": "^9.0.7", "tar-fs": "2.1.4", + "webpack": "5.108.4", "ws": "^8.21.1" }, "nx": { diff --git a/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json new file mode 100644 index 00000000000000..d41f176334152c --- /dev/null +++ b/packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json @@ -0,0 +1,10 @@ +{ + "$schema": "../../../../tools/verify-bundle-isolation/schema.json", + "fixturesRoot": "./bundle-size", + "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], + "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], + "allowedViolations": { + "AllComponents.fixture.js": ["@griffel/core", "@griffel/react"], + "TagPicker.fixture.js": ["@griffel/core", "@griffel/react"] + } +} diff --git a/packages/react-components/react-headless-components-preview/library/eslint.config.js b/packages/react-components/react-headless-components-preview/library/eslint.config.js index ec2e7cb1fc479f..6e76685858d5fd 100644 --- a/packages/react-components/react-headless-components-preview/library/eslint.config.js +++ b/packages/react-components/react-headless-components-preview/library/eslint.config.js @@ -2,4 +2,14 @@ const fluentPlugin = require('@fluentui/eslint-plugin'); -module.exports = [...fluentPlugin.configs['flat/react']]; +module.exports = [ + ...fluentPlugin.configs['flat/react'], + { + // Build-time verification tooling - not shipped, runs on Node, reports via stdout. + files: ['scripts/**/*.js'], + rules: { + 'no-console': 'off', + 'import/no-extraneous-dependencies': 'off', + }, + }, +]; diff --git a/packages/react-components/react-headless-components-preview/library/package.json b/packages/react-components/react-headless-components-preview/library/package.json index e896063fdd0a65..5ea5c4f77d04af 100644 --- a/packages/react-components/react-headless-components-preview/library/package.json +++ b/packages/react-components/react-headless-components-preview/library/package.json @@ -386,6 +386,7 @@ }, "devDependencies": { "@fluentui/scripts-cypress": "*", + "@fluentui/verify-bundle-isolation": "*", "@oddbird/popover-polyfill": "^0.6.1" } } diff --git a/packages/react-components/react-headless-components-preview/library/project.json b/packages/react-components/react-headless-components-preview/library/project.json index ecab81877a1e1f..7b99dac170d309 100644 --- a/packages/react-components/react-headless-components-preview/library/project.json +++ b/packages/react-components/react-headless-components-preview/library/project.json @@ -10,6 +10,20 @@ "options": { "exportSubpaths": true } + }, + "verify-bundle-isolation": { + "cache": true, + "dependsOn": ["build", "^build"], + "command": "yarn run -T verify-bundle-isolation", + "options": { + "cwd": "{projectRoot}" + }, + "inputs": ["default", "^default", { "externalDependencies": ["ajv", "webpack"] }], + "outputs": ["{projectRoot}/dist/bundle-isolation"], + "metadata": { + "technologies": ["webpack"], + "description": "Assert entry points do not bundle tabster, Griffel or react-icons" + } } } } diff --git a/tools/verify-bundle-isolation/README.md b/tools/verify-bundle-isolation/README.md new file mode 100644 index 00000000000000..9b2fc31f6dddac --- /dev/null +++ b/tools/verify-bundle-isolation/README.md @@ -0,0 +1,178 @@ +# @fluentui/verify-bundle-isolation + +Fails when a bundle-size fixture retains a runtime a package is meant to stay free of, such as a styling engine or icon +set that should have been tree shaken away. + +## How it works + +Each `*.fixture.js` is bundled with webpack — the same bundler behind the bundle-size numbers — and the resulting module +graph is inspected. A forbidden package that survives tree shaking is reported with the exports that kept it alive, the +modules importing them, and the module in the package under test that pulled those modules in: + +``` + REGRESSION AllComponents.fixture.js - 1 forbidden package not on the allowlist + @griffel/core - 11 modules retained + mergeClasses + <- .../react-portal/lib/components/Portal/usePortalMountNode.js (via lib/tag-picker.js) +``` + +`via` matters when a leak arrives through a dependency: above, nothing imports `react-portal` directly — +`lib/tag-picker.js` re-exports a render function that mounts a portal, which is what drags Griffel in. + +Attribution intersects webpack's `usedExports` with active import connections, and counts an importer only when that +module itself survived into a chunk. Import edges are recorded before tree shaking, so a module importing something it +no longer uses is not reported. + +## Usage + +Add the tool as a devDependency of the package to check and give it a target: + +```jsonc +// package.json +{ "devDependencies": { "@fluentui/verify-bundle-isolation": "*" } } +``` + +```jsonc +// project.json +{ + "targets": { + "verify-bundle-isolation": { + "cache": true, + "dependsOn": ["build", "^build"], + "command": "yarn run -T verify-bundle-isolation", + "options": { "cwd": "{projectRoot}" }, + "inputs": ["default", "^default", { "externalDependencies": ["ajv", "webpack"] }], + "outputs": ["{projectRoot}/dist/bundle-isolation"] + } + } +} +``` + +The check must run against built output, hence `dependsOn`. It reports an error if bundling resolves to package sources +instead, because the verdict would not reflect what ships. + +`^default` is what makes the cache correct: this task's result depends on every dependency's files, and on the tool +itself, which is a dependency by virtue of the devDependency. Replacing it with a hand-written input list silently +serves stale verdicts after a dependency changes. + +The repo pins webpack to a single version through `resolutions` in the root `package.json`. That is deliberate - the +verdict is only meaningful if it comes from the same bundler that produces the bundle-size numbers, and webpack 5.109 +changed module resolution in a way that makes these packages resolve to sources rather than built output. + +| Flag | Default | Description | +| ----------------- | ------------------------------ | -------------------------------------------------------------------- | +| `--config ` | `bundle-isolation.config.json` | Configuration file, resolved from the working directory | +| `--analyze` | off | Also write webpack-bundle-analyzer artifacts per fixture | +| `--strict` | off | Fail on allowed violations too, so the allowlist cannot be relied on | + +## Verdicts + +| Verdict | Exit | Meaning | +| ---------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------- | +| `PASS` | 0 | No forbidden package survived bundling. Only this verdict claims a bundle is free of them. | +| `PASS WITH DEBT` | 0 | Every surviving forbidden package is on the allowlist. The leaks are listed with their module counts and entry points. | +| `FAIL` | 1 | A regression, a stale or orphaned allowlist entry, a fixture that failed to bundle, or — under `--strict` — any allowed violation. | + +Per fixture the report labels each finding `CLEAN`, `ALLOWED`, `REGRESSION`, `STALE` or `ERROR`; a single fixture can +carry more than one label. Module and export counts come from a build with `minimize: false`, so they measure how much +of a package is retained, not what it costs to ship — use monosize for bytes. + +## Output + +`dist/bundle-isolation/` is wiped on every run, so it only ever contains the fixtures that currently exist. + +| Path | Written | Contents | +| ----------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `summary.json` | always | The console verdict in structured form — overall `status`, and per fixture its `status`, `allowedViolations`, `tolerated`, `regressions`, `stale` and full `leaks` map | +| `/report.html` | with `--analyze` | webpack-bundle-analyzer treemap | +| `/report.json` | with `--analyze` | The same data the treemap renders from — module tree with `statSize`, `parsedSize` and `gzipSize` | + +`leaks` maps a forbidden package to the exports that survived tree shaking and the modules importing them, so the +summary answers _what_ leaked and _why_, while the analyzer output answers _how much_ it costs. + +## Configuration + +`bundle-isolation.config.json` in the package root, validated against [`schema.json`](./schema.json). + +```json +{ + "$schema": "../../../../tools/verify-bundle-isolation/schema.json", + "fixturesRoot": "./bundle-size", + "externals": ["react", "react-dom", "react/jsx-runtime", "react/compiler-runtime"], + "forbiddenPackages": ["tabster", "@griffel/*", "@fluentui/react-icons"], + "allowedViolations": { + "AllComponents.fixture.js": ["@fluentui/react-icons"] + } +} +``` + +All configured paths are resolved relative to the package root: + +- `fixturesRoot` is the directory containing bundle-size fixtures. +- `externals` lists host-provided modules excluded from the bundle. +- `forbiddenPackages` lists exact package names or scoped globs such as `@griffel/*`. +- `allowedViolations` maps fixture paths, relative to `fixturesRoot` and always with forward slashes, to tolerated + forbidden packages. + +The two lists do not take the same values. `forbiddenPackages` declares intent, so it accepts globs. `allowedViolations` +records what actually leaked, so it takes **exact resolved package names** and rejects globs - `@griffel/*` there would +let a newly leaked `@griffel/anything` hide behind an entry approved for something else. The debt has to name what it +is: `@griffel/core` and `@griffel/react`, separately. + +`$schema` has to be a workspace-relative path. Editors resolve it against the config file and do not apply Node package +resolution, so `@fluentui/verify-bundle-isolation/schema.json` will not work there despite the export map. The export +map exists for programmatic consumers, which can `require.resolve('@fluentui/verify-bundle-isolation/schema.json')`. + +Validation itself never depends on `$schema` - the CLI always loads the schema shipped alongside it. + +## Fixtures + +Fixtures follow the existing monosize convention in `bundle-size/*.fixture.js`. A fixture imports the public API under +test and uses the import observably so tree shaking cannot discard it. + +```js +import * as Button from '@scope/package/button'; + +console.log(Button); + +export default { + name: 'Button', +}; +``` + +Sharing fixtures keeps isolation checks and bundle-size measurements aligned. + +## Allowed violations + +`allowedViolations` is tracked debt, not an exemption. It is shrink-only: + +- A newly retained forbidden package fails the check. +- A package that no longer survives bundling also fails the check until its entry is removed. +- An entry for a missing fixture fails the check. + +This prevents fixed leaks from being silently reintroduced. Deleting an entry is the goal; adding one is a regression. + +## Layout + +| File | Responsibility | +| -------------------------------- | ------------------------------------------------------------------------------------- | +| `src/bundle-isolation-plugin.ts` | The analysis — which forbidden packages survived, and why. A standard webpack plugin. | +| `src/config.ts` | Configuration loading, fixture discovery, path conventions | +| `src/report.ts` | Turns raw results into a verdict and renders it. No webpack, no file system. | +| `src/cli.ts` | Argument parsing and the webpack run that feeds the above | + +Keeping `report.ts` free of webpack and I/O is what makes the verdict testable without bundling anything; +`bundle-isolation-plugin.spec.ts` covers attribution by bundling a purpose-built module graph. + +## Reuse in another build + +The analysis is a standard webpack plugin, so it can run inside an existing build instead of the one the CLI creates: + +```ts +new BundleIsolationPlugin({ forbiddenPackages, workspaceRoot, packageRoot, onReport }); +``` + +`packageRoot` is optional and only powers the `via` origin. + +It requires `optimization.concatenateModules: false`, because scope hoisting merges modules into a `ConcatenatedModule` +with no per-module `resource`. diff --git a/tools/verify-bundle-isolation/bin/verify-bundle-isolation.js b/tools/verify-bundle-isolation/bin/verify-bundle-isolation.js new file mode 100755 index 00000000000000..24f6e23c4c5a33 --- /dev/null +++ b/tools/verify-bundle-isolation/bin/verify-bundle-isolation.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +// @ts-check + +const { joinPathFragments } = require('@nx/devkit'); +const { registerTsProject } = require('@nx/js/src/internal'); + +registerTsProject(joinPathFragments(__dirname, '..', 'tsconfig.lib.json')); + +const { cli } = require('../src/cli'); + +cli().catch(error => { + console.error(error); + process.exit(1); +}); diff --git a/tools/verify-bundle-isolation/eslint.config.js b/tools/verify-bundle-isolation/eslint.config.js new file mode 100644 index 00000000000000..909643866943c4 --- /dev/null +++ b/tools/verify-bundle-isolation/eslint.config.js @@ -0,0 +1,18 @@ +// @ts-check +const fluentPlugin = require('@fluentui/eslint-plugin'); + +/** @type {import("eslint").Linter.Config[]} */ +module.exports = [ + ...fluentPlugin.configs['flat/node'], + ...fluentPlugin.configs['flat/imports'], + { + rules: { + 'import/no-extraneous-dependencies': [ + 'error', + { + packageDir: ['.', '../../'], + }, + ], + }, + }, +]; diff --git a/tools/verify-bundle-isolation/jest.config.js b/tools/verify-bundle-isolation/jest.config.js new file mode 100644 index 00000000000000..626a3c4d85a083 --- /dev/null +++ b/tools/verify-bundle-isolation/jest.config.js @@ -0,0 +1,14 @@ +// @ts-check + +/** + * @type {import('@jest/types').Config.InitialOptions} + */ +module.exports = { + displayName: 'verify-bundle-isolation', + preset: '../../jest.preset.js', + transform: { + '^.+\\.tsx?$': ['@swc/jest', {}], + }, + coverageDirectory: './coverage', + testEnvironment: 'node', +}; diff --git a/tools/verify-bundle-isolation/package.json b/tools/verify-bundle-isolation/package.json new file mode 100644 index 00000000000000..eb09445ad8ed41 --- /dev/null +++ b/tools/verify-bundle-isolation/package.json @@ -0,0 +1,17 @@ +{ + "name": "@fluentui/verify-bundle-isolation", + "version": "0.0.1", + "description": "Asserts that a package's bundle-size fixtures do not bundle forbidden runtimes", + "private": true, + "type": "commonjs", + "bin": "./bin/verify-bundle-isolation.js", + "exports": { + "./schema.json": "./schema.json", + "./package.json": "./package.json" + }, + "dependencies": { + "ajv": "^8.13.0", + "webpack": "^5.108.4", + "webpack-bundle-analyzer": "^4.10.1" + } +} diff --git a/tools/verify-bundle-isolation/project.json b/tools/verify-bundle-isolation/project.json new file mode 100644 index 00000000000000..25ea2041087390 --- /dev/null +++ b/tools/verify-bundle-isolation/project.json @@ -0,0 +1,7 @@ +{ + "name": "verify-bundle-isolation", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "tools/verify-bundle-isolation/src", + "projectType": "library", + "tags": ["platform:node", "tools"] +} diff --git a/tools/verify-bundle-isolation/schema.json b/tools/verify-bundle-isolation/schema.json new file mode 100644 index 00000000000000..150dc7052fd16e --- /dev/null +++ b/tools/verify-bundle-isolation/schema.json @@ -0,0 +1,51 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Bundle isolation configuration", + "type": "object", + "additionalProperties": false, + "properties": { + "$schema": { + "description": "Path to this schema, relative to the configuration file.", + "type": "string" + }, + "fixturesRoot": { + "description": "Package-relative directory containing bundle-size fixtures.", + "type": "string", + "minLength": 1 + }, + "externals": { + "description": "Modules supplied by the consuming application rather than this bundle.", + "type": "array", + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "forbiddenPackages": { + "description": "Package names or scoped package globs that must not survive tree shaking.", + "type": "array", + "minItems": 1, + "uniqueItems": true, + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedViolations": { + "description": "Bundle-size fixture paths mapped to forbidden packages tolerated as tracked debt. Fixture paths use forward slashes and are relative to fixturesRoot.", + "type": "object", + "additionalProperties": { + "type": "array", + "uniqueItems": true, + "items": { + "description": "Exact resolved package name. Globs are rejected so a tolerated leak cannot silently cover a new one.", + "type": "string", + "minLength": 1, + "pattern": "^[^*]+$" + } + } + } + }, + "required": ["fixturesRoot", "externals", "forbiddenPackages", "allowedViolations"] +} diff --git a/tools/verify-bundle-isolation/src/bundle-isolation-plugin.spec.ts b/tools/verify-bundle-isolation/src/bundle-isolation-plugin.spec.ts new file mode 100644 index 00000000000000..2799011b6b97b8 --- /dev/null +++ b/tools/verify-bundle-isolation/src/bundle-isolation-plugin.spec.ts @@ -0,0 +1,212 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import webpack from 'webpack'; + +import { BundleIsolationPlugin, type BundleIsolationReport } from './bundle-isolation-plugin'; + +jest.setTimeout(60_000); + +describe('BundleIsolationPlugin', () => { + let root: string; + + beforeEach(() => { + // webpack reports resolved real paths, which on macOS differ from the symlinked temp path. + root = realpathSync(mkdtempSync(join(tmpdir(), 'bundle-isolation-plugin-'))); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + describe('attribution', () => { + let report: BundleIsolationReport; + + beforeEach(async () => { + writeFiles(root, { + 'package.json': manifest('test-workspace'), + + // Exports are functions because webpack inlines constant exports and drops the module. + 'node_modules/forbidden-pkg/package.json': manifest('forbidden-pkg'), + 'node_modules/forbidden-pkg/index.js': `export const alpha = () => Date.now();\nexport const beta = () => Math.random();\n`, + + 'node_modules/@scope/styles/package.json': manifest('@scope/styles'), + 'node_modules/@scope/styles/index.js': `export const style = () => Date.now();\n`, + + // Reached only through the package under test, so it must be reported with a `via` origin. + 'node_modules/dep-pkg/package.json': manifest('dep-pkg'), + 'node_modules/dep-pkg/index.js': `import { alpha } from 'forbidden-pkg';\nexport const fromDep = () => alpha();\n`, + + // Imports the same forbidden package but is eliminated, so it must not be blamed. + 'node_modules/innocent-pkg/package.json': manifest('innocent-pkg'), + 'node_modules/innocent-pkg/index.js': `import { beta } from 'forbidden-pkg';\nexport const fromInnocent = () => beta();\n`, + + 'my-pkg/package.json': manifest('my-pkg'), + 'my-pkg/lib/live.js': `import { fromDep } from 'dep-pkg';\nexport const live = () => fromDep();\n`, + 'my-pkg/lib/direct.js': `import { style } from '@scope/styles';\nexport const direct = () => style();\n`, + 'my-pkg/lib/dead.js': `import { fromInnocent } from 'innocent-pkg';\nexport const dead = () => fromInnocent();\n`, + 'my-pkg/lib/index.js': `export * from './live';\nexport * from './direct';\nexport * from './dead';\n`, + + 'entry.js': `import { live, direct } from './my-pkg/lib/index.js';\nconsole.log(live(), direct());\n`, + }); + + report = await bundle({ + root, + packageRoot: join(root, 'my-pkg'), + forbiddenPackages: ['forbidden-pkg', '@scope/*'], + }); + }); + + it('reports forbidden packages that survived tree shaking', () => { + expect(Object.keys(report.leaks).sort()).toEqual(['@scope/styles', 'forbidden-pkg']); + }); + + it('does not blame an importer that was eliminated', () => { + const importers = report.leaks['forbidden-pkg'].exports.flatMap(({ importers: found }) => + found.map(importer => importer.module), + ); + + expect(importers).toEqual([join(root, 'node_modules/dep-pkg/index.js')]); + expect(importers.join()).not.toContain('innocent-pkg'); + }); + + it('names only the exports that are actually used', () => { + expect(report.leaks['forbidden-pkg'].exports.map(({ name }) => name)).toEqual(['alpha']); + }); + + it('traces a leak arriving through a dependency back to the importing module', () => { + expect(report.leaks['forbidden-pkg'].exports[0].importers[0].via).toBe(join('lib', 'live.js')); + }); + + it('reports no origin when the package under test imports the leak itself', () => { + expect(report.leaks['@scope/styles'].exports[0].importers[0].via).toBeNull(); + }); + + it('matches scoped globs', () => { + expect(report.leaks['@scope/styles'].modules).toBe(1); + }); + }); + + it('ignores a package that is not forbidden', async () => { + writeFiles(root, { + 'package.json': manifest('test-workspace'), + 'node_modules/allowed-pkg/package.json': manifest('allowed-pkg'), + 'node_modules/allowed-pkg/index.js': `export const value = () => Date.now();\n`, + 'my-pkg/package.json': manifest('my-pkg'), + 'my-pkg/lib/index.js': `import { value } from 'allowed-pkg';\nexport const use = () => value();\n`, + 'entry.js': `import { use } from './my-pkg/lib/index.js';\nconsole.log(use());\n`, + }); + + const report = await bundle({ root, packageRoot: join(root, 'my-pkg'), forbiddenPackages: ['forbidden-pkg'] }); + + expect(report.leaks).toEqual({}); + }); + + it('flags a bundle that resolved to package sources, since its verdict would be meaningless', async () => { + writeFiles(root, { + 'package.json': manifest('test-workspace'), + 'my-pkg/library/src/index.js': `export const fromSource = () => Date.now();\n`, + 'entry.js': `import { fromSource } from './my-pkg/library/src/index.js';\nconsole.log(fromSource());\n`, + }); + + const report = await bundle({ root, packageRoot: join(root, 'my-pkg'), forbiddenPackages: ['forbidden-pkg'] }); + + expect(report.sourceResolved).toEqual([join(root, 'my-pkg/library/src/index.js')]); + }); + + // webpack reports an import as `ids`, where only the first entry names the export. These two + // cases pin that down: matching any id instead would blame `alpha.beta` for importing `beta`. + describe('imported ids', () => { + it('credits every specifier of a multi-specifier import', async () => { + writeFiles(root, { + 'package.json': manifest('test-workspace'), + 'node_modules/forbidden-pkg/package.json': manifest('forbidden-pkg'), + 'node_modules/forbidden-pkg/index.js': `export const alpha = () => Date.now();\nexport const beta = () => Math.random();\n`, + 'my-pkg/package.json': manifest('my-pkg'), + 'my-pkg/lib/index.js': `import { alpha, beta } from 'forbidden-pkg';\nexport const use = () => alpha() + beta();\n`, + 'entry.js': `import { use } from './my-pkg/lib/index.js';\nconsole.log(use());\n`, + }); + + const report = await bundle({ root, packageRoot: join(root, 'my-pkg'), forbiddenPackages: ['forbidden-pkg'] }); + + expect(report.leaks['forbidden-pkg'].exports.map(({ name }) => name)).toEqual(['alpha', 'beta']); + }); + + it('does not treat a property read on an import as an import of that property', async () => { + writeFiles(root, { + 'package.json': manifest('test-workspace'), + 'node_modules/forbidden-pkg/package.json': manifest('forbidden-pkg'), + 'node_modules/forbidden-pkg/index.js': `export const alpha = { beta: () => Date.now() };\nexport const beta = () => Math.random();\n`, + 'my-pkg/package.json': manifest('my-pkg'), + 'my-pkg/lib/uses-beta.js': `import { beta } from 'forbidden-pkg';\nexport const viaImport = () => beta();\n`, + // Reads `.beta` off `alpha`, so its ids are ["alpha", "beta"] without importing `beta`. + 'my-pkg/lib/uses-alpha.js': `import { alpha } from 'forbidden-pkg';\nexport const viaProperty = () => alpha.beta();\n`, + 'my-pkg/lib/index.js': `export * from './uses-beta';\nexport * from './uses-alpha';\n`, + 'entry.js': `import { viaImport, viaProperty } from './my-pkg/lib/index.js';\nconsole.log(viaImport(), viaProperty());\n`, + }); + + const report = await bundle({ root, packageRoot: join(root, 'my-pkg'), forbiddenPackages: ['forbidden-pkg'] }); + const betaImporters = report.leaks['forbidden-pkg'].exports + .filter(({ name }) => name === 'beta') + .flatMap(({ importers }) => importers.map(importer => importer.module)); + + expect(betaImporters).toEqual([join(root, 'my-pkg/lib/uses-beta.js')]); + }); + }); +}); + +function bundle({ + root, + packageRoot, + forbiddenPackages, +}: { + root: string; + packageRoot: string; + forbiddenPackages: string[]; +}): Promise { + let report: BundleIsolationReport | undefined; + + const compiler = webpack({ + target: 'web', + mode: 'production', + context: root, + entry: join(root, 'entry.js'), + output: { path: join(root, 'out'), filename: 'index.js' }, + optimization: { concatenateModules: false, minimize: false }, + plugins: [ + new BundleIsolationPlugin({ + forbiddenPackages, + workspaceRoot: root, + packageRoot, + onReport: value => { + report = value; + }, + }), + ], + }); + + return new Promise((resolvePromise, rejectPromise) => { + compiler.run((error, stats) => { + compiler.close(() => { + if (error || stats?.hasErrors()) { + rejectPromise(error ?? new Error(stats?.toString({ errors: true }))); + return; + } + resolvePromise(report as BundleIsolationReport); + }); + }); + }); +} + +function writeFiles(root: string, files: Record) { + for (const [path, contents] of Object.entries(files)) { + const target = join(root, path); + mkdirSync(dirname(target), { recursive: true }); + writeFileSync(target, contents); + } +} + +function manifest(name: string) { + return JSON.stringify({ name, version: '1.0.0', sideEffects: false }); +} diff --git a/tools/verify-bundle-isolation/src/bundle-isolation-plugin.ts b/tools/verify-bundle-isolation/src/bundle-isolation-plugin.ts new file mode 100644 index 00000000000000..da4d93192ae52b --- /dev/null +++ b/tools/verify-bundle-isolation/src/bundle-isolation-plugin.ts @@ -0,0 +1,277 @@ +/** + * Reports which forbidden packages survived tree shaking, the exports keeping them alive and the + * modules importing those exports. + * + * Written as a plugin so the analysis can run inside any webpack build - a purpose-built bundle + * like the one the CLI creates, or an existing one such as the monosize bundle-size build. + * + * Requires `optimization.concatenateModules: false`; scope hoisting merges modules into a + * `ConcatenatedModule` with no per-module `resource`, which hides the packages being looked for. + */ +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, isAbsolute, join, relative, sep } from 'node:path'; + +import type { ChunkGraph, Compilation, Compiler, Module, ModuleGraph } from 'webpack'; + +// webpack declares RuntimeSpec internally but does not export it, so recover it from a signature. +type RuntimeSpec = Parameters['add']>[0]; + +export interface Importer { + module: string; + via: string | null; +} + +export interface Leak { + modules: number; + exports: Array<{ name: string; importers: Importer[] }>; +} + +export interface BundleIsolationReport { + leaks: Record; + sourceResolved: string[]; +} + +export interface AnalysisOptions { + forbiddenPackages: string[]; + workspaceRoot: string; + packageRoot?: string; +} + +type ForbiddenOwnerResolver = (modulePath: string) => string | null; + +/** `ExportInfo.getUsed()` returns this when nothing references the export. */ +const UNUSED = 0; + +const PLUGIN_NAME = 'BundleIsolationPlugin'; + +export class BundleIsolationPlugin { + constructor(private options: AnalysisOptions & { onReport: (report: BundleIsolationReport) => void }) {} + + public apply(compiler: Compiler) { + compiler.hooks.afterEmit.tap(PLUGIN_NAME, compilation => { + this.options.onReport(collectLeaks(compilation, this.options)); + }); + } +} + +/** + * webpack records import edges for modules whose imports were later eliminated, so edges alone + * over-report. A package counts as leaked only when its modules are in a chunk, an export is + * reported as used, and the importing module survived as well. + */ +export function collectLeaks(compilation: Compilation, options: AnalysisOptions): BundleIsolationReport { + const { chunkGraph, moduleGraph } = compilation; + const ownerOf = createForbiddenOwnerResolver(options); + const collected: Record< + string, + { modules: number; exports: Map }> } + > = {}; + const sourceResolved: string[] = []; + + for (const module of compilation.modules) { + const resource = resourceOf(module); + if (!resource || chunkGraph.getNumberOfModuleChunks(module) === 0) { + continue; + } + + if (/[/\\]library[/\\]src[/\\]/.test(resource)) { + sourceResolved.push(resource); + } + + const owner = ownerOf(resource); + if (!owner) { + continue; + } + + const leak = (collected[owner] ??= { modules: 0, exports: new Map() }); + leak.modules++; + + const [runtime] = chunkGraph.getModuleRuntimes(module); + + for (const name of usedExportNames(moduleGraph, runtime, module)) { + const importers = externalImporters(moduleGraph, chunkGraph, runtime, module, name, ownerOf); + // Exports only referenced inside the forbidden package are plumbing, not entry points. + if (importers.length === 0) { + continue; + } + // Keyed per module so two modules exporting the same name are not merged. + const key = `${name}\u0000${resource}`; + const known = leak.exports.get(key) ?? { name, importers: new Map() }; + + for (const importer of importers) { + const importerResource = resourceOf(importer) as string; + known.importers.set(importerResource, { + module: importerResource, + via: packageOriginOf(moduleGraph, chunkGraph, importer, options.packageRoot), + }); + } + + leak.exports.set(key, known); + } + } + + const leaks: Record = {}; + for (const [name, leak] of Object.entries(collected)) { + leaks[name] = { + modules: leak.modules, + exports: [...leak.exports.values()] + .map(({ name: exportName, importers }) => ({ + name: exportName, + importers: [...importers.values()].sort((left, right) => left.module.localeCompare(right.module)), + })) + .sort((left, right) => left.name.localeCompare(right.name)), + }; + } + + return { leaks, sourceResolved }; +} + +function usedExportNames(moduleGraph: ModuleGraph, runtime: RuntimeSpec, module: Module): string[] { + const names = []; + + for (const exportInfo of moduleGraph.getExportsInfo(module).orderedExports) { + if (exportInfo.getUsed(runtime) !== UNUSED) { + names.push(exportInfo.name); + } + } + + return names; +} + +function externalImporters( + moduleGraph: ModuleGraph, + chunkGraph: ChunkGraph, + runtime: RuntimeSpec, + module: Module, + exportName: string, + ownerOf: ForbiddenOwnerResolver, +): Module[] { + const importers = new Map(); + + for (const connection of moduleGraph.getIncomingConnections(module)) { + // An eliminated importer keeps an active connection, so its own retention decides. + if (!connection.originModule || chunkGraph.getNumberOfModuleChunks(connection.originModule) === 0) { + continue; + } + + const origin = resourceOf(connection.originModule); + if (!origin || ownerOf(origin) || connection.getActiveState(runtime) === false) { + continue; + } + + // Only the first id names the import. webpack emits one dependency per specifier, so + // `import { a, b }` is already two connections, while `a.b` is a single one with ids + // ["a", "b"] - matching any id would blame that module for importing `b`. + if (importedIds(connection.dependency, moduleGraph)[0] === exportName) { + importers.set(origin, connection.originModule); + } + } + + return [...importers.values()]; +} + +/** + * Walks back over retained modules to the first one owned by the package under test, so a leak + * reached through a dependency points at the code that pulled that dependency in. + */ +function packageOriginOf( + moduleGraph: ModuleGraph, + chunkGraph: ChunkGraph, + module: Module, + packageRoot: string | undefined, +): string | null { + if (!packageRoot) { + return null; + } + + const owned = (candidate: Module) => { + const resource = resourceOf(candidate); + return Boolean(resource && resource.startsWith(packageRoot + sep)); + }; + + if (owned(module)) { + return null; + } + + const visited = new Set([module]); + const queue = [module]; + + while (queue.length > 0) { + const current = queue.shift() as Module; + + for (const connection of moduleGraph.getIncomingConnections(current)) { + const origin = connection.originModule; + if (!origin || visited.has(origin) || chunkGraph.getNumberOfModuleChunks(origin) === 0) { + continue; + } + + visited.add(origin); + if (owned(origin)) { + return relative(packageRoot, resourceOf(origin) as string); + } + + queue.push(origin); + } + } + + return null; +} + +function importedIds(dependency: unknown, moduleGraph: ModuleGraph): string[] { + const candidate = dependency as { getIds?: (graph: ModuleGraph) => string[]; ids?: string[] }; + + if (typeof candidate?.getIds === 'function') { + return candidate.getIds(moduleGraph) ?? []; + } + + return candidate?.ids ?? []; +} + +function resourceOf(module: Module): string | null { + return (module as unknown as { resource?: string }).resource ?? module.nameForCondition() ?? null; +} + +/** + * Maps a module path to the forbidden package owning it, or `null`. + * + * Ownership is resolved by walking up to the nearest `package.json`, which handles both + * `node_modules` dependencies and workspace packages (webpack resolves symlinked workspace + * packages to their real path, so there is no `node_modules` segment to match on). + */ +function createForbiddenOwnerResolver(options: AnalysisOptions): ForbiddenOwnerResolver { + const exact = new Set(options.forbiddenPackages.filter(pattern => !pattern.endsWith('/*'))); + const scopes = options.forbiddenPackages + .filter(pattern => pattern.endsWith('/*')) + .map(pattern => pattern.slice(0, -1)); + const cache = new Map(); + + return function ownerOf(modulePath: string) { + let dir = dirname(isAbsolute(modulePath) ? modulePath : join(options.workspaceRoot, modulePath)); + const visited: string[] = []; + + while (dir && dir !== dirname(dir)) { + if (cache.has(dir)) { + const cached = cache.get(dir) ?? null; + visited.forEach(seen => cache.set(seen, cached)); + return cached; + } + visited.push(dir); + + const manifest = join(dir, 'package.json'); + if (existsSync(manifest)) { + const { name } = JSON.parse(readFileSync(manifest, 'utf-8')); + // Nested manifests without a name (e.g. `{ "type": "module" }` markers) are not package roots. + if (name) { + const owner = exact.has(name) || scopes.some(scope => name.startsWith(scope)) ? name : null; + visited.forEach(seen => cache.set(seen, owner)); + return owner; + } + } + + dir = dirname(dir); + } + + visited.forEach(seen => cache.set(seen, null)); + return null; + }; +} diff --git a/tools/verify-bundle-isolation/src/cli.ts b/tools/verify-bundle-isolation/src/cli.ts new file mode 100644 index 00000000000000..9a018d749c6336 --- /dev/null +++ b/tools/verify-bundle-isolation/src/cli.ts @@ -0,0 +1,193 @@ +/** + * Asserts that no bundle-size fixture in a package bundles a runtime its public API is meant to + * stay free of. + * + * Bundles with webpack so the verdict comes from the same bundler that produces the bundle-size + * numbers, and so `usedExports` can name the exact symbols that survived tree shaking. + * + * Usage: verify-bundle-isolation [--config ] [--analyze] [--strict] + */ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { parseArgs } from 'node:util'; + +import webpack, { type Configuration, type Stats, type WebpackPluginInstance } from 'webpack'; + +import { BundleIsolationPlugin, type BundleIsolationReport } from './bundle-isolation-plugin'; +import { findFixtures, findWorkspaceRoot, fixtureOutputPath, loadConfig, outputRoot, readJson } from './config'; +import { + type FixtureResult, + type Report, + type RuntimeOptions, + createReport, + createSummary, + formatReport, +} from './report'; + +interface Args { + configPath: string; + analyze: boolean; + strict: boolean; +} + +export async function cli(): Promise { + const args = processArgs(); + const packageRoot = dirname(args.configPath); + const workspaceRoot = findWorkspaceRoot(packageRoot); + const config = loadConfig(args.configPath, workspaceRoot); + const packageJson = readJson(join(packageRoot, 'package.json')); + const fixturesRoot = resolve(packageRoot, config.fixturesRoot); + const fixtures = findFixtures(fixturesRoot); + + if (fixtures.length === 0) { + console.error(`No bundle-size fixtures found in ${packageJson.name} - nothing to verify.`); + process.exit(1); + } + + const options: RuntimeOptions = { ...args, config, fixturesRoot, packageRoot, workspaceRoot }; + + // Fixtures come and go; a stale output directory would otherwise be mistaken for a fresh report. + rmSync(outputRoot(packageRoot), { recursive: true, force: true }); + + const results = await Promise.all(fixtures.map(fixture => verifyFixture(fixture, options))); + const report = createReport({ packageName: packageJson.name, results, fixtures, options }); + const summaryPath = writeSummary(report); + + // One stream for the whole report - splitting it would let the shell interleave the verdict. + (report.failed ? console.error : console.log)(formatReport(report, summaryPath)); + + if (report.failed) { + process.exit(1); + } +} + +function processArgs(): Args { + const { values } = parseArgs({ + options: { + config: { type: 'string', default: 'bundle-isolation.config.json' }, + analyze: { type: 'boolean', default: false }, + strict: { type: 'boolean', default: false }, + }, + allowPositionals: false, + }); + + return { + configPath: resolve(process.cwd(), values.config as string), + analyze: values.analyze as boolean, + strict: values.strict as boolean, + }; +} + +async function verifyFixture(fixture: string, options: RuntimeOptions): Promise { + const result: FixtureResult = { fixture, found: [], leaks: {}, sourceResolved: [] }; + + let analysis: BundleIsolationReport | undefined; + let stats: Stats; + + try { + stats = await bundleFixture(fixture, options, report => { + analysis = report; + }); + } catch (error) { + result.error = error instanceof Error ? error.message : String(error); + return result; + } + + if (stats.hasErrors()) { + result.error = (stats.toJson({ all: false, errors: true }).errors ?? []).map(error => error.message).join('\n '); + return result; + } + + if (!analysis) { + result.error = 'the bundle isolation plugin did not report on this build'; + return result; + } + + result.leaks = analysis.leaks; + result.sourceResolved = analysis.sourceResolved; + result.found = Object.keys(analysis.leaks).sort(); + + return result; +} + +function bundleFixture( + fixture: string, + options: RuntimeOptions, + onReport: (report: BundleIsolationReport) => void, +): Promise { + const compiler = webpack(createWebpackConfig(fixture, options, onReport)); + + return new Promise((resolveStats, rejectStats) => { + compiler.run((error, stats) => { + compiler.close(() => { + if (error || !stats) { + rejectStats(error ?? new Error('webpack finished without producing stats')); + return; + } + resolveStats(stats); + }); + }); + }); +} + +function createWebpackConfig( + fixture: string, + options: RuntimeOptions, + onReport: (report: BundleIsolationReport) => void, +): Configuration { + const outputPath = fixtureOutputPath(fixture, options.packageRoot); + + return { + name: 'bundle-isolation', + target: 'web', + mode: 'production', + context: options.workspaceRoot, + entry: join(options.fixturesRoot, fixture), + externals: Object.fromEntries(options.config.externals.map(name => [name, name])), + output: { path: outputPath, filename: 'index.js' }, + performance: { hints: false }, + // Scope hoisting and minification change how code is emitted, not which modules and exports + // survive tree shaking, so both stay off to keep the module graph 1:1 for attribution. + optimization: { concatenateModules: false, minimize: false }, + plugins: [ + new BundleIsolationPlugin({ + forbiddenPackages: options.config.forbiddenPackages, + workspaceRoot: options.workspaceRoot, + packageRoot: options.packageRoot, + onReport, + }), + ...(options.analyze ? createAnalyzerPlugins(outputPath) : []), + ], + }; +} + +/** + * One instance per output format - `analyzerMode` is single valued, so the treemap and its + * underlying data need separate plugins. + */ +function createAnalyzerPlugins(outputPath: string): WebpackPluginInstance[] { + const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); + + return [ + new BundleAnalyzerPlugin({ + analyzerMode: 'static', + reportFilename: join(outputPath, 'report.html'), + openAnalyzer: false, + logLevel: 'silent', + }), + new BundleAnalyzerPlugin({ + analyzerMode: 'json', + reportFilename: join(outputPath, 'report.json'), + logLevel: 'silent', + }), + ]; +} + +function writeSummary(report: Report): string { + const summaryPath = join(outputRoot(report.options.packageRoot), 'summary.json'); + + mkdirSync(dirname(summaryPath), { recursive: true }); + writeFileSync(summaryPath, JSON.stringify(createSummary(report), null, 2) + '\n'); + + return summaryPath; +} diff --git a/tools/verify-bundle-isolation/src/config.spec.ts b/tools/verify-bundle-isolation/src/config.spec.ts new file mode 100644 index 00000000000000..5ca6a51f007bb9 --- /dev/null +++ b/tools/verify-bundle-isolation/src/config.spec.ts @@ -0,0 +1,113 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { findFixtures, fixtureOutputPath, loadConfig, outputRoot, relativeToWorkspace } from './config'; + +describe('loadConfig', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'bundle-isolation-config-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + const valid = { + fixturesRoot: './bundle-size', + externals: ['react'], + forbiddenPackages: ['tabster'], + allowedViolations: {}, + }; + + const load = (config: object) => { + const configPath = join(root, 'bundle-isolation.config.json'); + writeFileSync(configPath, JSON.stringify(config)); + return loadConfig(configPath, root); + }; + + it('returns a valid configuration', () => { + expect(load(valid)).toEqual(valid); + }); + + it('rejects a missing required field rather than silently checking nothing', () => { + expect(() => load({ ...valid, forbiddenPackages: undefined })).toThrow(/must have required property/); + }); + + it('rejects an empty forbidden list, which would make the check meaningless', () => { + expect(() => load({ ...valid, forbiddenPackages: [] })).toThrow(/must NOT have fewer than 1 items/); + }); + + it('rejects unknown fields, so a typo cannot be mistaken for configuration', () => { + expect(() => load({ ...valid, knownViolations: {} })).toThrow(/must NOT have additional properties/); + }); + + it('rejects a glob in allowedViolations, which would silently absorb an unrelated leak', () => { + expect(() => load({ ...valid, allowedViolations: { 'A.fixture.js': ['@griffel/*'] } })).toThrow( + /must match pattern/, + ); + }); + + it('accepts an exact package name in allowedViolations', () => { + expect(load({ ...valid, allowedViolations: { 'A.fixture.js': ['@griffel/core'] } }).allowedViolations).toEqual({ + 'A.fixture.js': ['@griffel/core'], + }); + }); + + it('reports the offending path relative to the workspace', () => { + expect(() => load({ ...valid, externals: 'react' })).toThrow(/bundle-isolation\.config\.json/); + }); +}); + +describe('findFixtures', () => { + let root: string; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'bundle-isolation-fixtures-')); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it('returns an empty list when the directory does not exist', () => { + expect(findFixtures(join(root, 'missing'))).toEqual([]); + }); + + it('finds fixtures recursively and ignores everything else', () => { + mkdirSync(join(root, 'nested'), { recursive: true }); + writeFileSync(join(root, 'B.fixture.js'), ''); + writeFileSync(join(root, 'A.fixture.js'), ''); + writeFileSync(join(root, 'readme.md'), ''); + writeFileSync(join(root, 'nested', 'C.fixture.js'), ''); + + // Asserted as a literal rather than via join(), because these become config keys on every platform. + expect(findFixtures(root)).toEqual(['A.fixture.js', 'B.fixture.js', 'nested/C.fixture.js']); + }); +}); + +describe('paths', () => { + it('derives the output directory from the package root', () => { + expect(outputRoot('/ws/packages/thing')).toBe('/ws/packages/thing/dist/bundle-isolation'); + }); + + it('gives each fixture its own output directory', () => { + expect(fixtureOutputPath('A.fixture.js', '/ws/packages/thing')).toBe('/ws/packages/thing/dist/bundle-isolation/A'); + }); + + it('keeps a nested fixture under its own directory', () => { + expect(fixtureOutputPath('nested/C.fixture.js', '/ws/packages/thing')).toBe( + join('/ws/packages/thing/dist/bundle-isolation/nested/C'), + ); + }); + + it('shortens workspace paths for display', () => { + expect(relativeToWorkspace('/ws/packages/thing/index.js', '/ws')).toBe('packages/thing/index.js'); + }); + + it('leaves paths outside the workspace alone', () => { + expect(relativeToWorkspace('/elsewhere/index.js', '/ws')).toBe('/elsewhere/index.js'); + }); +}); diff --git a/tools/verify-bundle-isolation/src/config.ts b/tools/verify-bundle-isolation/src/config.ts new file mode 100644 index 00000000000000..ee32b657ed5fb6 --- /dev/null +++ b/tools/verify-bundle-isolation/src/config.ts @@ -0,0 +1,84 @@ +/** + * Configuration loading, fixture discovery and the path conventions shared by the CLI and the + * report. + */ +import { existsSync, readFileSync, readdirSync } from 'node:fs'; +import { dirname, isAbsolute, join, sep } from 'node:path'; + +import Ajv, { type ErrorObject } from 'ajv'; + +export interface Config { + fixturesRoot: string; + externals: string[]; + forbiddenPackages: string[]; + allowedViolations: Record; +} + +const schemaPath = join(__dirname, '..', 'schema.json'); +const FIXTURE_SUFFIX = '.fixture.js'; + +export function loadConfig(configPath: string, workspaceRoot: string): Config { + const config = readJson(configPath); + const validate = new Ajv({ allErrors: true }).compile(readJson(schemaPath)); + + if (!validate(config)) { + const errors = (validate.errors ?? []) + .map((error: ErrorObject) => `${error.instancePath || '/'} ${error.message}`) + .join('\n '); + + throw new Error( + `Invalid bundle isolation configuration at ${relativeToWorkspace(configPath, workspaceRoot)}:\n ${errors}`, + ); + } + + return config as Config; +} + +export function findFixtures(fixturesRoot: string): string[] { + if (!existsSync(fixturesRoot)) { + return []; + } + + return ( + readdirSync(fixturesRoot, { recursive: true, withFileTypes: true }) + .filter(entry => entry.isFile() && entry.name.endsWith(FIXTURE_SUFFIX)) + // Fixture paths become config keys, so they stay POSIX rather than following the host separator. + .map(entry => + join(entry.parentPath, entry.name) + .slice(fixturesRoot.length + 1) + .split(sep) + .join('/'), + ) + .sort() + ); +} + +export function findWorkspaceRoot(startDir: string): string { + let dir = startDir; + + while (dir !== dirname(dir)) { + if (existsSync(join(dir, 'nx.json'))) { + return dir; + } + dir = dirname(dir); + } + + throw new Error(`Could not locate the workspace root above ${startDir}`); +} + +export function readJson(filePath: string) { + return JSON.parse(readFileSync(filePath, 'utf-8')); +} + +export function relativeToWorkspace(modulePath: string, workspaceRoot: string): string { + const absolute = isAbsolute(modulePath) ? modulePath : join(workspaceRoot, modulePath); + return absolute.startsWith(workspaceRoot + sep) ? absolute.slice(workspaceRoot.length + 1) : modulePath; +} + +export function outputRoot(packageRoot: string): string { + return join(packageRoot, 'dist', 'bundle-isolation'); +} + +export function fixtureOutputPath(fixture: string, packageRoot: string): string { + return join(outputRoot(packageRoot), fixture.slice(0, -FIXTURE_SUFFIX.length)); +} diff --git a/tools/verify-bundle-isolation/src/report.spec.ts b/tools/verify-bundle-isolation/src/report.spec.ts new file mode 100644 index 00000000000000..a25fb1d89565fb --- /dev/null +++ b/tools/verify-bundle-isolation/src/report.spec.ts @@ -0,0 +1,333 @@ +import { + type FixtureResult, + type RuntimeOptions, + classify, + count, + createReport, + createSummary, + formatReport, + matchesPackagePattern, +} from './report'; + +const workspaceRoot = '/ws'; +const packageRoot = '/ws/packages/thing'; + +describe('classify', () => { + it('reports a fixture with no leaks as clean', () => { + expect(classify(fixtureResult(), []).status).toBe('clean'); + }); + + it('separates tolerated leaks from regressions', () => { + const outcome = classify(fixtureResult({ found: ['allowed-pkg', 'new-pkg'] }), ['allowed-pkg']); + + expect(outcome).toMatchObject({ status: 'regression', tolerated: ['allowed-pkg'], regressions: ['new-pkg'] }); + }); + + it('flags an allowlist entry that no longer leaks as stale', () => { + const outcome = classify(fixtureResult(), ['fixed-pkg']); + + expect(outcome).toMatchObject({ status: 'stale', stale: ['fixed-pkg'], tolerated: [] }); + }); + + it('reports regressions and stale entries from the same fixture', () => { + const outcome = classify(fixtureResult({ found: ['new-pkg'] }), ['fixed-pkg']); + + expect(outcome).toMatchObject({ regressions: ['new-pkg'], stale: ['fixed-pkg'] }); + }); + + it('treats a build failure as an error regardless of the allowlist', () => { + expect(classify(fixtureResult({ error: 'boom' }), []).status).toBe('error'); + }); + + it('treats resolving to package sources as an error, since the verdict would be meaningless', () => { + const outcome = classify(fixtureResult({ sourceResolved: ['/ws/packages/thing/library/src/index.ts'] }), []); + + expect(outcome.status).toBe('error'); + }); +}); + +describe('createReport', () => { + it('passes when nothing leaked', () => { + const report = createReport(input({ results: [fixtureResult()] })); + + expect(report).toMatchObject({ failed: false, status: 'passed' }); + }); + + it('passes with debt when every leak is allowlisted', () => { + const report = createReport( + input({ + results: [fixtureResult({ found: ['allowed-pkg'] })], + allowedViolations: { 'A.fixture.js': ['allowed-pkg'] }, + }), + ); + + expect(report).toMatchObject({ failed: false, status: 'passed-with-debt' }); + }); + + it('fails allowlisted leaks under --strict', () => { + const report = createReport( + input({ + results: [fixtureResult({ found: ['allowed-pkg'] })], + allowedViolations: { 'A.fixture.js': ['allowed-pkg'] }, + strict: true, + }), + ); + + expect(report).toMatchObject({ failed: true, status: 'failed' }); + }); + + it('fails on an allowlist entry for a fixture that does not exist', () => { + const report = createReport(input({ results: [fixtureResult()], allowedViolations: { 'Gone.fixture.js': ['x'] } })); + + expect(report.orphans).toEqual([{ fixture: 'Gone.fixture.js', packages: ['x'] }]); + expect(report.failed).toBe(true); + }); + + it('totals findings across fixtures', () => { + const report = createReport( + input({ + results: [fixtureResult({ found: ['a-pkg'] }), fixtureResult({ fixture: 'B.fixture.js', found: ['b-pkg'] })], + fixtures: ['A.fixture.js', 'B.fixture.js'], + }), + ); + + expect(report.totals).toEqual({ errors: 0, regressions: 2, stale: 0, tolerated: 0 }); + }); +}); + +describe('formatReport', () => { + it('claims a bundle is free of forbidden packages only when nothing leaked', () => { + const report = createReport(input({ results: [fixtureResult()] })); + + expect(formatReport(report, '/ws/summary.json')).toContain('PASS - 1 fixture free of forbidden-pkg, @scope/*'); + }); + + it('never claims a bundle is free of a package that is merely allowlisted', () => { + const text = formatReport( + createReport( + input({ + results: [fixtureResult({ found: ['forbidden-pkg'], leaks: { 'forbidden-pkg': leak() } })], + allowedViolations: { 'A.fixture.js': ['forbidden-pkg'] }, + }), + ), + '/ws/summary.json', + ); + + expect(text).not.toContain('free of'); + expect(text).toContain('PASS WITH DEBT - 1 fixture, 0 regressions, 1 allowed violation'); + }); + + it('lists allowlisted leaks with their size and entry points, ordered by cost', () => { + const text = formatReport( + createReport( + input({ + results: [ + fixtureResult({ + found: ['forbidden-pkg', '@scope/styles'], + leaks: { + 'forbidden-pkg': leak({ modules: 3 }), + '@scope/styles': leak({ modules: 9, via: 'lib/entry.js' }), + }, + }), + ], + allowedViolations: { 'A.fixture.js': ['forbidden-pkg', '@scope/styles'] }, + }), + ), + '/ws/summary.json', + ); + + expect(text).toContain(' ALLOWED A.fixture.js - 2 forbidden packages, 12 modules'); + expect(text).toContain(' via lib/entry.js'); + + const rows = text.split('\n').filter(line => /^ {4}(@scope\/styles|forbidden-pkg)\b/.test(line)); + expect(rows).toEqual([' @scope/styles 9 modules 1 export', ' forbidden-pkg 3 modules 1 export']); + }); + + it('names the packages still kept out, so the allowlist is not read as total defeat', () => { + const text = formatReport( + createReport( + input({ + results: [fixtureResult({ found: ['@scope/styles'], leaks: { '@scope/styles': leak() } })], + allowedViolations: { 'A.fixture.js': ['@scope/styles'] }, + }), + ), + '/ws/summary.json', + ); + + expect(text).toContain(' kept out: forbidden-pkg'); + expect(text).toContain(' allowlist: @scope/styles'); + }); + + it('traces a regression to the importing module and the entry point that pulled it in', () => { + const text = formatReport( + createReport( + input({ + results: [ + fixtureResult({ found: ['forbidden-pkg'], leaks: { 'forbidden-pkg': leak({ via: 'lib/entry.js' }) } }), + ], + }), + ), + '/ws/summary.json', + ); + + expect(text).toContain(' REGRESSION A.fixture.js - 1 forbidden package not on the allowlist'); + expect(text).toContain(' forbidden-pkg - 2 modules retained'); + expect(text).toContain(' <- packages/other/lib/importer.js (via lib/entry.js)'); + expect(text).toContain('FAIL - 1 fixture: 1 regression'); + }); + + it('tells the reader how to lock in a fix rather than reporting it as a plain failure', () => { + const text = formatReport( + createReport(input({ results: [fixtureResult()], allowedViolations: { 'A.fixture.js': ['fixed-pkg'] } })), + '/ws/summary.json', + ); + + expect(text).toContain(' STALE A.fixture.js - no longer pulls in fixed-pkg'); + expect(text).toContain('remove it from allowedViolations in packages/thing/config.json to lock the fix in'); + }); + + it('attributes a --strict failure to the flag rather than to a regression', () => { + const text = formatReport( + createReport( + input({ + results: [fixtureResult({ found: ['forbidden-pkg'], leaks: { 'forbidden-pkg': leak() } })], + allowedViolations: { 'A.fixture.js': ['forbidden-pkg'] }, + strict: true, + }), + ), + '/ws/summary.json', + ); + + expect(text).toContain('FAIL - 1 fixture: 1 allowed violation rejected by --strict'); + }); + + it('points at the analyzer artifacts only when they were produced', () => { + const withoutAnalyze = formatReport(createReport(input({ results: [fixtureResult()] })), '/ws/summary.json'); + const withAnalyze = formatReport( + createReport(input({ results: [fixtureResult()], analyze: true })), + '/ws/summary.json', + ); + + expect(withoutAnalyze).toContain('analyzer: rerun with --analyze'); + expect(withAnalyze).toContain('packages/thing/dist/bundle-isolation//report.html + report.json'); + }); +}); + +describe('createSummary', () => { + it('mirrors the console verdict', () => { + const summary = createSummary( + createReport( + input({ + results: [ + fixtureResult({ found: ['forbidden-pkg'], leaks: { 'forbidden-pkg': leak({ via: 'lib/entry.js' }) } }), + ], + allowedViolations: { 'A.fixture.js': ['forbidden-pkg'] }, + }), + ), + ); + + expect(summary).toMatchObject({ + package: '@fluentui/thing', + status: 'passed-with-debt', + strict: false, + fixtures: [ + { + fixture: 'A.fixture.js', + status: 'allowed', + tolerated: ['forbidden-pkg'], + regressions: [], + leaks: { + 'forbidden-pkg': { + modules: 2, + exports: [ + { name: 'used', importers: [{ module: 'packages/other/lib/importer.js', via: 'lib/entry.js' }] }, + ], + }, + }, + }, + ], + }); + }); + + it('does not point at an analyzer report that was never written', () => { + const summary = createSummary(createReport(input({ results: [fixtureResult()] }))); + + expect(summary.fixtures[0].analyzerReport).toBeNull(); + }); + + it('points at the analyzer report when one was written', () => { + const summary = createSummary(createReport(input({ results: [fixtureResult()], analyze: true }))); + + expect(summary.fixtures[0].analyzerReport).toBe('packages/thing/dist/bundle-isolation/A/report.json'); + }); +}); + +describe('count', () => { + it.each([ + [1, '1 module'], + [0, '0 modules'], + [2, '2 modules'], + ])('pluralises %i', (value, expected) => { + expect(count(value, 'module')).toBe(expected); + }); + + it('uses an explicit plural when appending an s would be wrong', () => { + expect(count(2, 'stale allowlist entry', 'stale allowlist entries')).toBe('2 stale allowlist entries'); + }); +}); + +describe('matchesPackagePattern', () => { + it.each([ + ['@scope/*', '@scope/styles', true], + ['@scope/*', '@other/styles', false], + ['forbidden-pkg', 'forbidden-pkg', true], + ['forbidden-pkg', 'forbidden-pkg-extra', false], + ])('%s vs %s', (pattern, name, expected) => { + expect(matchesPackagePattern(pattern, name)).toBe(expected); + }); +}); + +function fixtureResult({ + fixture = 'A.fixture.js', + found = [] as string[], + leaks = {} as FixtureResult['leaks'], + sourceResolved = [] as string[], + error, +}: Partial = {}): FixtureResult { + return { fixture, found, leaks, sourceResolved, ...(error ? { error } : {}) }; +} + +function leak({ modules = 2, via = null }: { modules?: number; via?: string | null } = {}) { + return { modules, exports: [{ name: 'used', importers: [{ module: '/ws/packages/other/lib/importer.js', via }] }] }; +} + +function input({ + results, + fixtures = ['A.fixture.js'], + allowedViolations = {}, + strict = false, + analyze = false, +}: { + results: FixtureResult[]; + fixtures?: string[]; + allowedViolations?: Record; + strict?: boolean; + analyze?: boolean; +}) { + const options: RuntimeOptions = { + configPath: '/ws/packages/thing/config.json', + analyze, + strict, + fixturesRoot: '/ws/packages/thing/bundle-size', + packageRoot, + workspaceRoot, + config: { + fixturesRoot: './bundle-size', + externals: [], + forbiddenPackages: ['forbidden-pkg', '@scope/*'], + allowedViolations, + }, + }; + + return { packageName: '@fluentui/thing', results, fixtures, options }; +} diff --git a/tools/verify-bundle-isolation/src/report.ts b/tools/verify-bundle-isolation/src/report.ts new file mode 100644 index 00000000000000..e027ccd56eebd8 --- /dev/null +++ b/tools/verify-bundle-isolation/src/report.ts @@ -0,0 +1,374 @@ +/** + * Turns raw per-fixture bundling results into a verdict, and renders that verdict for the console + * and for `summary.json`. Kept free of webpack and of the file system so it can be tested directly. + */ +import { join } from 'node:path'; + +import type { Leak } from './bundle-isolation-plugin'; +import { type Config, fixtureOutputPath, relativeToWorkspace } from './config'; + +export interface RuntimeOptions { + configPath: string; + analyze: boolean; + strict: boolean; + config: Config; + fixturesRoot: string; + packageRoot: string; + workspaceRoot: string; +} + +export interface FixtureResult { + fixture: string; + found: string[]; + leaks: Record; + sourceResolved: string[]; + error?: string; +} + +export type FixtureStatus = 'error' | 'regression' | 'stale' | 'allowed' | 'clean'; + +export interface Outcome extends FixtureResult { + status: FixtureStatus; + allowed: string[]; + tolerated: string[]; + regressions: string[]; + stale: string[]; +} + +export interface Orphan { + fixture: string; + packages: string[]; +} + +export interface Totals { + errors: number; + regressions: number; + stale: number; + tolerated: number; +} + +export interface Report { + packageName: string; + options: RuntimeOptions; + outcomes: Outcome[]; + orphans: Orphan[]; + totals: Totals; + failed: boolean; + status: 'passed' | 'passed-with-debt' | 'failed'; +} + +/** Widest badge plus its trailing gap, so every fixture line starts at the same column. */ +const BADGE_WIDTH = 'REGRESSION'.length + 2; +const MAX_ORIGINS = 3; +const MAX_EXPORTS = 5; +const MAX_IMPORTERS = 2; + +export function createReport({ + packageName, + results, + fixtures, + options, +}: { + packageName: string; + results: FixtureResult[]; + fixtures: string[]; + options: RuntimeOptions; +}): Report { + const outcomes = results.map(result => classify(result, options.config.allowedViolations[result.fixture] ?? [])); + const orphans = orphanedAllowlistEntries(fixtures, options.config.allowedViolations); + const totals: Totals = { + errors: outcomes.filter(outcome => outcome.status === 'error').length, + regressions: sumBy(outcomes, outcome => outcome.regressions.length), + stale: sumBy(outcomes, outcome => outcome.stale.length), + tolerated: sumBy(outcomes, outcome => outcome.tolerated.length), + }; + + const failed = + orphans.length > 0 || + totals.errors > 0 || + totals.regressions > 0 || + totals.stale > 0 || + (options.strict && totals.tolerated > 0); + + return { + packageName, + options, + outcomes, + orphans, + totals, + failed, + status: failed ? 'failed' : totals.tolerated > 0 ? 'passed-with-debt' : 'passed', + }; +} + +export function classify(result: FixtureResult, allowed: string[]): Outcome { + const regressions = result.found.filter(name => !allowed.includes(name)); + const stale = allowed.filter(name => !result.found.includes(name)); + const tolerated = allowed.filter(name => result.found.includes(name)); + + let status: FixtureStatus = 'clean'; + if (result.error || result.sourceResolved.length > 0) { + status = 'error'; + } else if (regressions.length > 0) { + status = 'regression'; + } else if (stale.length > 0) { + status = 'stale'; + } else if (tolerated.length > 0) { + status = 'allowed'; + } + + return { ...result, status, allowed, tolerated, regressions, stale }; +} + +export function orphanedAllowlistEntries(fixtures: string[], allowedViolations: Record): Orphan[] { + return Object.entries(allowedViolations) + .filter(([fixture]) => !fixtures.includes(fixture)) + .map(([fixture, packages]) => ({ fixture, packages })); +} + +export function formatReport(report: Report, summaryPath: string): string { + const { options } = report; + const lines = [ + `Bundle isolation · ${report.packageName}`, + `forbidden: ${options.config.forbiddenPackages.join(', ')}`, + '', + ]; + + for (const outcome of report.outcomes) { + lines.push(...formatFixture(outcome, options), ''); + } + + for (const orphan of report.orphans) { + lines.push( + `${badge('ORPHAN')}${orphan.fixture} - allowlisted (${orphan.packages.join(', ')}) but not a bundle-size fixture`, + ` remove the entry from allowedViolations in ${configLabel(options)}`, + '', + ); + } + + lines.push(...formatVerdict(report), '', ...formatArtifacts(options, summaryPath)); + + return lines.join('\n'); +} + +function formatFixture(outcome: Outcome, options: RuntimeOptions): string[] { + if (outcome.status === 'error') { + return [`${badge('ERROR')}${outcome.fixture}`, ...formatError(outcome, options.workspaceRoot)]; + } + + if (outcome.status === 'clean') { + return [`${badge('CLEAN')}${outcome.fixture}`]; + } + + const lines: string[] = []; + + if (outcome.regressions.length > 0) { + lines.push( + `${badge('REGRESSION')}${outcome.fixture} - ${count( + outcome.regressions.length, + 'forbidden package', + )} not on the allowlist`, + ...outcome.regressions.flatMap(name => describeLeak(name, outcome.leaks[name], options.workspaceRoot)), + ); + } + + if (outcome.stale.length > 0) { + lines.push( + `${badge('STALE')}${outcome.fixture} - no longer pulls in ${outcome.stale.join(', ')}`, + ` remove it from allowedViolations in ${configLabel(options)} to lock the fix in`, + ); + } + + if (outcome.tolerated.length > 0) { + const modules = sumBy(outcome.tolerated, name => outcome.leaks[name].modules); + lines.push( + `${badge('ALLOWED')}${outcome.fixture} - ${count(outcome.tolerated.length, 'forbidden package')}, ${count( + modules, + 'module', + )}`, + ...formatTolerated(outcome, options.workspaceRoot), + ); + } + + return lines; +} + +function formatError(outcome: Outcome, workspaceRoot: string): string[] { + if (outcome.error) { + return [ + ' could not be bundled - is the package built?', + ...outcome.error.split('\n').map(line => ` ${line.trim()}`), + ]; + } + + return [ + ' resolved to package sources instead of built output, so the result is meaningless', + ` e.g. ${relativeToWorkspace(outcome.sourceResolved[0], workspaceRoot)}`, + ]; +} + +/** Ordered by module count so the most expensive debt to pay down is listed first. */ +function formatTolerated(outcome: Outcome, workspaceRoot: string): string[] { + const rows = outcome.tolerated + .map(name => ({ name, leak: outcome.leaks[name] })) + .sort((left, right) => right.leak.modules - left.leak.modules || left.name.localeCompare(right.name)); + + const nameWidth = Math.max(...rows.map(row => row.name.length)); + const moduleWidth = Math.max(...rows.map(row => count(row.leak.modules, 'module').length)); + + return rows.flatMap(({ name, leak }) => [ + ` ${name.padEnd(nameWidth)} ${count(leak.modules, 'module').padStart(moduleWidth)} ${count( + leak.exports.length, + 'export', + )}`, + ...originsOf(leak, workspaceRoot).map(origin => ` via ${origin}`), + ]); +} + +function describeLeak(name: string, leak: Leak, workspaceRoot: string): string[] { + const lines = [` ${name} - ${count(leak.modules, 'module')} retained`]; + + if (leak.exports.length === 0) { + lines.push(' no importing symbol identified - rerun with --analyze to inspect the bundle'); + return lines; + } + + for (const { name: exportName, importers } of leak.exports.slice(0, MAX_EXPORTS)) { + lines.push(` ${exportName}`); + + for (const importer of importers.slice(0, MAX_IMPORTERS)) { + const module = relativeToWorkspace(importer.module, workspaceRoot); + lines.push(` <- ${module}${importer.via ? ` (via ${importer.via})` : ''}`); + } + + const hiddenImporters = importers.length - MAX_IMPORTERS; + if (hiddenImporters > 0) { + lines.push(` <- +${hiddenImporters} more`); + } + } + + const hiddenExports = leak.exports.length - MAX_EXPORTS; + if (hiddenExports > 0) { + lines.push(` ...and ${count(hiddenExports, 'more export')}`); + } + + return lines; +} + +function originsOf(leak: Leak, workspaceRoot: string): string[] { + const origins = new Set( + leak.exports.flatMap(({ importers }) => + importers.map(importer => importer.via ?? relativeToWorkspace(importer.module, workspaceRoot)), + ), + ); + + const listed = [...origins].sort().slice(0, MAX_ORIGINS); + const hidden = origins.size - listed.length; + + return hidden > 0 ? [...listed, `+${count(hidden, 'more entry point')}`] : listed; +} + +function formatVerdict(report: Report): string[] { + const { options, totals, orphans } = report; + const fixtures = count(report.outcomes.length, 'fixture'); + + if (report.failed) { + const parts = [ + totals.errors > 0 && `${count(totals.errors, 'fixture')} failed to bundle`, + totals.regressions > 0 && count(totals.regressions, 'regression'), + totals.stale > 0 && count(totals.stale, 'stale allowlist entry', 'stale allowlist entries'), + orphans.length > 0 && count(orphans.length, 'orphaned allowlist entry', 'orphaned allowlist entries'), + options.strict && totals.tolerated > 0 && `${count(totals.tolerated, 'allowed violation')} rejected by --strict`, + ].filter(Boolean); + + return [`FAIL - ${fixtures}: ${parts.join(', ')}`]; + } + + if (totals.tolerated === 0) { + return [`PASS - ${fixtures} free of ${options.config.forbiddenPackages.join(', ')}`]; + } + + const leaked = [...new Set(report.outcomes.flatMap(outcome => outcome.tolerated))].sort(); + const keptOut = options.config.forbiddenPackages.filter( + pattern => !leaked.some(name => matchesPackagePattern(pattern, name)), + ); + + return [ + `PASS WITH DEBT - ${fixtures}, 0 regressions, ${count(totals.tolerated, 'allowed violation')}`, + ...(keptOut.length > 0 ? [` kept out: ${keptOut.join(', ')}`] : []), + ` allowlist: ${leaked.join(', ')}`, + ` tracked in ${configLabel(options)} - deleting an entry is the goal, adding one is a regression`, + ]; +} + +function formatArtifacts(options: RuntimeOptions, summaryPath: string): string[] { + const analyzer = options.analyze + ? `${relativeToWorkspace(fixtureOutputPath('.fixture.js', options.packageRoot), options.workspaceRoot)}/` + + 'report.html + report.json' + : 'rerun with --analyze for per-fixture treemaps'; + + return [`summary: ${relativeToWorkspace(summaryPath, options.workspaceRoot)}`, `analyzer: ${analyzer}`]; +} + +/** + * Companion to the analyzer treemap: the same verdict, structured so it can be diffed between runs + * or handed to another tool. + */ +export function createSummary(report: Report) { + const { options } = report; + const toWorkspacePath = (path: string) => relativeToWorkspace(path, options.workspaceRoot); + + return { + package: report.packageName, + config: toWorkspacePath(options.configPath), + strict: options.strict, + status: report.status, + forbiddenPackages: options.config.forbiddenPackages, + orphanedAllowlistEntries: report.orphans, + fixtures: report.outcomes.map(outcome => ({ + fixture: outcome.fixture, + status: outcome.status, + analyzerReport: options.analyze + ? toWorkspacePath(join(fixtureOutputPath(outcome.fixture, options.packageRoot), 'report.json')) + : null, + error: outcome.error ?? null, + sourceResolved: outcome.sourceResolved.map(toWorkspacePath), + allowedViolations: outcome.allowed, + tolerated: outcome.tolerated, + regressions: outcome.regressions, + stale: outcome.stale, + leaks: Object.fromEntries( + Object.entries(outcome.leaks).map(([name, leak]) => [ + name, + { + modules: leak.modules, + exports: leak.exports.map(({ name: exportName, importers }) => ({ + name: exportName, + importers: importers.map(importer => ({ module: toWorkspacePath(importer.module), via: importer.via })), + })), + }, + ]), + ), + })), + }; +} + +export function matchesPackagePattern(pattern: string, name: string): boolean { + return pattern.endsWith('/*') ? name.startsWith(pattern.slice(0, -1)) : name === pattern; +} + +export function count(value: number, singular: string, plural?: string): string { + return `${value} ${value === 1 ? singular : plural ?? `${singular}s`}`; +} + +function badge(label: string): string { + return ` ${label.padEnd(BADGE_WIDTH)}`; +} + +function configLabel(options: RuntimeOptions): string { + return relativeToWorkspace(options.configPath, options.workspaceRoot); +} + +function sumBy(items: TItem[], valueOf: (item: TItem) => number): number { + return items.reduce((total, item) => total + valueOf(item), 0); +} diff --git a/tools/verify-bundle-isolation/tsconfig.json b/tools/verify-bundle-isolation/tsconfig.json new file mode 100644 index 00000000000000..a95e1d6f4a43cc --- /dev/null +++ b/tools/verify-bundle-isolation/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "compilerOptions": { + "target": "ES2019", + "pretty": true, + "noEmit": true, + "allowJs": true, + "checkJs": true, + "sourceMap": true, + "noUnusedLocals": true + }, + "include": [], + "files": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/tools/verify-bundle-isolation/tsconfig.lib.json b/tools/verify-bundle-isolation/tsconfig.lib.json new file mode 100644 index 00000000000000..8407b0a4160ae0 --- /dev/null +++ b/tools/verify-bundle-isolation/tsconfig.lib.json @@ -0,0 +1,12 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "lib": ["ES2019"], + "outDir": "../../dist/out-tsc", + "types": ["node"], + "resolveJsonModule": true + }, + "exclude": ["**/*.spec.ts", "**/*.test.ts"], + "include": ["./src/**/*.ts", "./src/**/*.js"] +} diff --git a/tools/verify-bundle-isolation/tsconfig.spec.json b/tools/verify-bundle-isolation/tsconfig.spec.json new file mode 100644 index 00000000000000..a0a0008c224b9f --- /dev/null +++ b/tools/verify-bundle-isolation/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node10", + "outDir": "dist", + "types": ["jest", "node"] + }, + "include": ["**/*.spec.ts", "**/*.test.ts", "**/*.d.ts"] +} diff --git a/yarn.lock b/yarn.lock index 9e56c56c4603ef..59dca6f0f0833c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2820,6 +2820,7 @@ __metadata: "@fluentui/react-northstar": "npm:0.66.5" "@fluentui/scripts-test-ssr": "npm:*" "@fluentui/storybook-llms-extractor": "npm:*" + "@fluentui/verify-bundle-isolation": "npm:*" "@griffel/babel-preset": "npm:1.5.8" "@griffel/eslint-plugin": "npm:^2.0.0" "@griffel/jest-serializer": "npm:1.1.24" @@ -4369,6 +4370,7 @@ __metadata: "@fluentui/react-tooltip": "npm:^9.10.4" "@fluentui/react-utilities": "npm:^9.26.5" "@fluentui/scripts-cypress": "npm:*" + "@fluentui/verify-bundle-isolation": "npm:*" "@oddbird/popover-polyfill": "npm:^0.6.1" "@swc/helpers": "npm:^0.5.1" peerDependencies: @@ -6483,6 +6485,18 @@ __metadata: languageName: unknown linkType: soft +"@fluentui/verify-bundle-isolation@npm:*, @fluentui/verify-bundle-isolation@workspace:tools/verify-bundle-isolation": + version: 0.0.0-use.local + resolution: "@fluentui/verify-bundle-isolation@workspace:tools/verify-bundle-isolation" + dependencies: + ajv: "npm:^8.13.0" + webpack: "npm:^5.108.4" + webpack-bundle-analyzer: "npm:^4.10.1" + bin: + verify-bundle-isolation: ./bin/verify-bundle-isolation.js + languageName: unknown + linkType: soft + "@fluentui/visual-regression-assert@workspace:tools/visual-regression-assert": version: 0.0.0-use.local resolution: "@fluentui/visual-regression-assert@workspace:tools/visual-regression-assert" @@ -12344,15 +12358,15 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.4.0, ajv@npm:^8.9.0, ajv@npm:~8.13.0": - version: 8.13.0 - resolution: "ajv@npm:8.13.0" +"ajv@npm:^8.0.0, ajv@npm:^8.0.1, ajv@npm:^8.13.0, ajv@npm:^8.4.0, ajv@npm:^8.9.0": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" dependencies: fast-deep-equal: "npm:^3.1.3" + fast-uri: "npm:^3.0.1" json-schema-traverse: "npm:^1.0.0" require-from-string: "npm:^2.0.2" - uri-js: "npm:^4.4.1" - checksum: 10c0/14c6497b6f72843986d7344175a1aa0e2c35b1e7f7475e55bc582cddb765fca7e6bf950f465dc7846f817776d9541b706f4b5b3fbedd8dfdeb5fce6f22864264 + checksum: 10c0/5df9a1c8f83863cde1bd3a9ddb426f599718f88e3dc9153616c79fb28e0be455335830d7f21d745576519f057b371352daa31047b6a33d7036fe08777d60cf2a languageName: node linkType: hard @@ -12368,6 +12382,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:~8.13.0": + version: 8.13.0 + resolution: "ajv@npm:8.13.0" + dependencies: + fast-deep-equal: "npm:^3.1.3" + json-schema-traverse: "npm:^1.0.0" + require-from-string: "npm:^2.0.2" + uri-js: "npm:^4.4.1" + checksum: 10c0/14c6497b6f72843986d7344175a1aa0e2c35b1e7f7475e55bc582cddb765fca7e6bf950f465dc7846f817776d9541b706f4b5b3fbedd8dfdeb5fce6f22864264 + languageName: node + linkType: hard + "anchor-markdown-header@npm:~0.5.7": version: 0.5.7 resolution: "anchor-markdown-header@npm:0.5.7" @@ -18270,6 +18296,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.5 + resolution: "fast-uri@npm:3.1.5" + checksum: 10c0/2bf60eb800dd610c65e17be436425dcb21c92aff3a87d442a8bccab0b7b071e88cf1a5d7d1ea946370b937e6fc0375c405c0296c10587e57de4f78be4646d1d0 + languageName: node + linkType: hard + "fastest-levenshtein@npm:^1.0.12": version: 1.0.12 resolution: "fastest-levenshtein@npm:1.0.12" @@ -32213,6 +32246,28 @@ __metadata: languageName: node linkType: hard +"webpack-bundle-analyzer@npm:^4.10.1": + version: 4.10.2 + resolution: "webpack-bundle-analyzer@npm:4.10.2" + dependencies: + "@discoveryjs/json-ext": "npm:0.5.7" + acorn: "npm:^8.0.4" + acorn-walk: "npm:^8.0.0" + commander: "npm:^7.2.0" + debounce: "npm:^1.2.1" + escape-string-regexp: "npm:^4.0.0" + gzip-size: "npm:^6.0.0" + html-escaper: "npm:^2.0.2" + opener: "npm:^1.5.2" + picocolors: "npm:^1.0.0" + sirv: "npm:^2.0.3" + ws: "npm:^7.3.1" + bin: + webpack-bundle-analyzer: lib/bin/analyzer.js + checksum: 10c0/00603040e244ead15b2d92981f0559fa14216381349412a30070a7358eb3994cd61a8221d34a3b3fb8202dc3d1c5ee1fbbe94c5c52da536e5b410aa1cf279a48 + languageName: node + linkType: hard + "webpack-cli@npm:5.1.4": version: 5.1.4 resolution: "webpack-cli@npm:5.1.4" @@ -32390,7 +32445,7 @@ __metadata: languageName: node linkType: hard -"webpack@npm:5, webpack@npm:5.108.4, webpack@npm:^5, webpack@npm:^5.1.0, webpack@npm:^5.106.2": +"webpack@npm:5.108.4": version: 5.108.4 resolution: "webpack@npm:5.108.4" dependencies: