From e0d9008ae4d3f427e695a66447e28350f09f76f1 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 6 Aug 2026 13:28:50 +0200 Subject: [PATCH 1/3] feat(scripts-cypress): ship a real dual ESM/CJS build for type:module consumers (#36361) --- nx.json | 10 ++++---- scripts/cypress/.swcrc | 17 ++++++++++++++ scripts/cypress/config/api-extractor.json | 11 +++++++++ .../{eslint.config.js => eslint.config.cjs} | 0 .../cypress/{src => }/fixtures/example.json | 0 .../{jest.config.js => jest.config.cjs} | 0 scripts/cypress/package.json | 23 +++++++++++++++++-- scripts/cypress/project.json | 17 +++++++++++++- scripts/cypress/src/base.config.ts | 10 +++++--- scripts/cypress/src/browser/index.ts | 2 +- scripts/cypress/src/index.ts | 22 +++++++++++++++--- scripts/cypress/{src => }/support/commands.js | 3 ++- .../{src => }/support/component-index.html | 0 .../cypress/{src => }/support/component.js | 4 ++-- scripts/cypress/tsconfig.lib.json | 5 +++- .../src/__tests__/cli.e2e.test.ts | 7 ++++-- tools/react-integration-tester/src/args.ts | 7 ++++-- .../src/files/cypress.config.ts.template | 7 ++++-- 18 files changed, 121 insertions(+), 24 deletions(-) create mode 100644 scripts/cypress/.swcrc create mode 100644 scripts/cypress/config/api-extractor.json rename scripts/cypress/{eslint.config.js => eslint.config.cjs} (100%) rename scripts/cypress/{src => }/fixtures/example.json (100%) rename scripts/cypress/{jest.config.js => jest.config.cjs} (100%) rename scripts/cypress/{src => }/support/commands.js (90%) rename scripts/cypress/{src => }/support/component-index.html (100%) rename scripts/cypress/{src => }/support/component.js (90%) diff --git a/nx.json b/nx.json index ec7986898dad7f..9af8108ca3dd7c 100644 --- a/nx.json +++ b/nx.json @@ -60,7 +60,12 @@ "cache": true }, "e2e": { - "dependsOn": [], + "dependsOn": [ + { + "target": "build", + "projects": "scripts-cypress" + } + ], "cache": true, "inputs": ["default", "{projectRoot}/cypress.config.ts", "!{projectRoot}/**/?(*.)+cy.[jt]s?(x)?"] }, @@ -163,16 +168,13 @@ "apps/public-docsite-v9-headless/**", "apps/rit-tests-v9/*", "apps/rit-tests-v8/*", - "tools/**/*", "scripts/**/*", "packages/eslint-plugin/**", - "packages/tokens/**", "packages/react-conformance/**", "packages/react-components/**/*", "packages/charts/react-charts/**/*", - "packages/foundation-legacy/**", "packages/jest-serializer-merge-styles/**", "packages/react/**", diff --git a/scripts/cypress/.swcrc b/scripts/cypress/.swcrc new file mode 100644 index 00000000000000..53b02735890448 --- /dev/null +++ b/scripts/cypress/.swcrc @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/swcrc", + "exclude": ["/**/*.cy.ts", "/**/*.cy.tsx", "/**/*.spec.ts", "/**/*.spec.tsx", "/**/*.test.ts", "/**/*.test.tsx"], + "jsc": { + "baseUrl": ".", + "parser": { + "syntax": "typescript", + "tsx": false, + "decorators": false, + "dynamicImport": false + }, + "externalHelpers": false, + "target": "es2022" + }, + "minify": false, + "sourceMaps": true +} diff --git a/scripts/cypress/config/api-extractor.json b/scripts/cypress/config/api-extractor.json new file mode 100644 index 00000000000000..7f4e3a5a911d0f --- /dev/null +++ b/scripts/cypress/config/api-extractor.json @@ -0,0 +1,11 @@ +{ + "$schema": "https://developer.microsoft.com/json-schemas/api-extractor/v7/api-extractor.schema.json", + "extends": "@fluentui/scripts-api-extractor/api-extractor.common.v-next.json", + "apiReport": { + "enabled": false + }, + "compiler": { + "skipLibCheck": true + }, + "mainEntryPointFilePath": "/../../../../dist/out-tsc/types/index.d.ts" +} diff --git a/scripts/cypress/eslint.config.js b/scripts/cypress/eslint.config.cjs similarity index 100% rename from scripts/cypress/eslint.config.js rename to scripts/cypress/eslint.config.cjs diff --git a/scripts/cypress/src/fixtures/example.json b/scripts/cypress/fixtures/example.json similarity index 100% rename from scripts/cypress/src/fixtures/example.json rename to scripts/cypress/fixtures/example.json diff --git a/scripts/cypress/jest.config.js b/scripts/cypress/jest.config.cjs similarity index 100% rename from scripts/cypress/jest.config.js rename to scripts/cypress/jest.config.cjs diff --git a/scripts/cypress/package.json b/scripts/cypress/package.json index cbdd71e71e8fab..80288adf426511 100644 --- a/scripts/cypress/package.json +++ b/scripts/cypress/package.json @@ -1,7 +1,26 @@ { "name": "@fluentui/scripts-cypress", + "type": "module", "version": "0.0.1", "private": true, - "main": "src/index.ts", - "browser": "src/browser/index.ts" + "main": "lib-commonjs/index.cjs", + "module": "lib/index.js", + "browser": "lib/browser/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "browser": "./lib/browser/index.js", + "import": "./lib/index.js", + "require": "./lib-commonjs/index.cjs" + }, + "./package.json": "./package.json" + }, + "files": [ + "lib", + "lib-commonjs", + "dist/*.d.ts", + "support", + "fixtures" + ] } diff --git a/scripts/cypress/project.json b/scripts/cypress/project.json index 0737a81aad4c90..98a2d404c19fd4 100644 --- a/scripts/cypress/project.json +++ b/scripts/cypress/project.json @@ -3,5 +3,20 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "scripts/cypress/src", "projectType": "library", - "tags": ["tools"] + "tags": ["tools"], + "targets": { + "build": { + "executor": "@fluentui/workspace-plugin:build", + "outputs": ["{projectRoot}/lib", "{projectRoot}/lib-commonjs"], + "options": { + "sourceRoot": "src", + "outputPathRoot": "{projectRoot}", + "moduleOutput": [ + { "module": "es6", "outputPath": "lib" }, + { "module": "commonjs", "outputPath": "lib-commonjs" } + ], + "generateApi": true + } + } + } } diff --git a/scripts/cypress/src/base.config.ts b/scripts/cypress/src/base.config.ts index 0f7cc2b1ccb7ef..dae5802c4e66e1 100644 --- a/scripts/cypress/src/base.config.ts +++ b/scripts/cypress/src/base.config.ts @@ -58,7 +58,7 @@ const cypressWebpackConfig = (): Configuration => { baseWebpackConfig.resolve.plugins ??= []; baseWebpackConfig.resolve.plugins.push( new TsconfigPathsPlugin({ - configFile: path.resolve(__dirname, '../../../tsconfig.base.json'), + configFile: path.resolve(import.meta.dirname, '../../../tsconfig.base.json'), }), ); @@ -82,8 +82,12 @@ interface BaseConfig extends Cypress.ConfigOptions { * This is a workaround for the issue where Cypress does not resolve the paths correctly, as it * internally prepend the __dirname, making them invalid * + * Heads up! `import.meta.dirname` is compiled by SWC to `__dirname` for the CommonJS (`lib-commonjs`) + * output and kept as-is for the ESM (`lib`) output. Both live one level under the package root, so + * `../support` and `../fixtures` resolve to the package's `support/` and `fixtures/` source folders + * (which Cypress' webpack bundles for the browser). */ -const sharedConfigSupportRootDir = path.join(__dirname, './support'); +const sharedConfigSupportRootDir = path.join(import.meta.dirname, '../support'); const projectSupportDir = path.relative(projectRoot, sharedConfigSupportRootDir); export const baseConfig = defineConfig({ @@ -106,7 +110,7 @@ export const baseConfig = defineConfig({ // Screenshots go under /cypress/screenshots and can be useful to look at after failures in // local headless runs (especially if the failure is specific to headless runs) // screenshotOnRunFailure: isLocalRun && argv.mode === 'run', - fixturesFolder: path.join(__dirname, './fixtures'), + fixturesFolder: path.join(import.meta.dirname, '../fixtures'), }) as BaseConfig; /** diff --git a/scripts/cypress/src/browser/index.ts b/scripts/cypress/src/browser/index.ts index b14e496984a283..5c3f2addd5a959 100644 --- a/scripts/cypress/src/browser/index.ts +++ b/scripts/cypress/src/browser/index.ts @@ -1,2 +1,2 @@ // Browser-only entry: expose just the custom mount used in Component Testing -export { mount } from './mount'; +export { mount } from './mount.js'; diff --git a/scripts/cypress/src/index.ts b/scripts/cypress/src/index.ts index 54f6d609d26ba9..1bee2e376c2d0a 100644 --- a/scripts/cypress/src/index.ts +++ b/scripts/cypress/src/index.ts @@ -1,7 +1,23 @@ -export { baseConfig, baseWebpackConfig } from './base.config'; +/** + * `@fluentui/scripts-cypress` ships a real dual build (ESM under `lib/`, CommonJS under + * `lib-commonjs/*.cjs`, `type: module`). Consumers resolve the format that matches them: + * - `type: module` packages' `cypress.config.ts` → `import` condition → `lib/index.js` (ESM) + * - CommonJS configs (and the rit harness via ts-node `require`) → `require` condition → `lib-commonjs/index.cjs` + * + * Because it's built (not consumed as raw `.ts` source), per-file TS transpilers like ts-node never + * recompile this package against a consumer's tsconfig, so a CommonJS consumer no longer has to become + * `type: module` to use it. The `mount` browser API is served via the package `browser` field. + */ + +import type { mount as cypressMount } from '@cypress/react'; + +export { baseConfig, baseWebpackConfig } from './base.config.js'; // =========== BROWSER APIs ================== // TODO: Browser related APIs should be exposed via export maps or moved to separate package -// Expose Browser specific API under same barrel -export declare const mount: typeof import('./browser').mount; +// Expose Browser specific API under same barrel; resolved at runtime via the package `browser` field. +// The type is aliased from `@cypress/react` rather than `typeof import('./browser/index.js')` because +// api-extractor cannot roll up a relative dynamic import - it leaks into `dist/index.d.ts` as an +// unresolvable specifier, silently degrading `mount` to `any` for consumers. +export declare const mount: typeof cypressMount; diff --git a/scripts/cypress/src/support/commands.js b/scripts/cypress/support/commands.js similarity index 90% rename from scripts/cypress/src/support/commands.js rename to scripts/cypress/support/commands.js index 860efb5728624c..554dc82ed8139e 100644 --- a/scripts/cypress/src/support/commands.js +++ b/scripts/cypress/support/commands.js @@ -1,4 +1,5 @@ /* eslint-disable no-undef */ +import { realPress } from 'cypress-real-events/commands/realPress.js'; // *********************************************** // This example commands.js shows you how to // create various custom commands and overwrite @@ -37,5 +38,5 @@ const CYPRESS_MAJOR_VERSION = typeof Cypress !== 'undefined' && Cypress.version ? Number(String(Cypress.version).split('.')[0]) : undefined; if (CYPRESS_MAJOR_VERSION === 13) { - Cypress.Commands.add('press', /** @type {any} */ (require('cypress-real-events/commands/realPress').realPress)); + Cypress.Commands.add('press', /** @type {any} */ (realPress)); } diff --git a/scripts/cypress/src/support/component-index.html b/scripts/cypress/support/component-index.html similarity index 100% rename from scripts/cypress/src/support/component-index.html rename to scripts/cypress/support/component-index.html diff --git a/scripts/cypress/src/support/component.js b/scripts/cypress/support/component.js similarity index 90% rename from scripts/cypress/src/support/component.js rename to scripts/cypress/support/component.js index 55f84fff391330..837d01d704c7b9 100644 --- a/scripts/cypress/src/support/component.js +++ b/scripts/cypress/support/component.js @@ -14,8 +14,8 @@ // *********************************************************** // Import commands.js using ES2015 syntax: -import 'cypress-real-events/support'; -import './commands'; +import 'cypress-real-events/support.js'; +import './commands.js'; // Alternatively you can use CommonJS syntax: // require('./commands') diff --git a/scripts/cypress/tsconfig.lib.json b/scripts/cypress/tsconfig.lib.json index 2cd7456061fb19..ba543c88d3aa35 100644 --- a/scripts/cypress/tsconfig.lib.json +++ b/scripts/cypress/tsconfig.lib.json @@ -2,8 +2,11 @@ "extends": "./tsconfig.json", "compilerOptions": { "noEmit": false, - "lib": ["ES2019"], + "declaration": true, + "rootDir": "./src", + "declarationDir": "../../dist/out-tsc/types", "outDir": "../../dist/out-tsc", + "lib": ["ES2019", "dom"], "types": ["node", "cypress"] }, "exclude": ["**/*.spec.ts", "**/*.test.ts"], diff --git a/tools/react-integration-tester/src/__tests__/cli.e2e.test.ts b/tools/react-integration-tester/src/__tests__/cli.e2e.test.ts index 832920b0649efe..cabcca6f7f76bd 100644 --- a/tools/react-integration-tester/src/__tests__/cli.e2e.test.ts +++ b/tools/react-integration-tester/src/__tests__/cli.e2e.test.ts @@ -363,8 +363,11 @@ describe('rit CLI e2e', () => { "import { join, resolve } from 'node:path'; import baseConfig from '../../../../proj/cypress.config.ts'; - // Resolve dependencies from the shared react-version root folder (injected by CLI) - const usedNodeModulesPath = join(__dirname, '..', 'node_modules'); + // Resolve dependencies from the shared react-version root folder (injected by CLI). + // Cypress executes this config with \`cwd\` set to the prepared project root (where this file lives), + // so \`process.cwd()\` is equivalent to the config directory and works in both CommonJS and ESM + // (\`type: module\`) sandboxes — unlike \`__dirname\`, which is undefined under native ESM. + const usedNodeModulesPath = join(process.cwd(), '..', 'node_modules'); const config = { ...baseConfig }; diff --git a/tools/react-integration-tester/src/args.ts b/tools/react-integration-tester/src/args.ts index 6193116264cd67..f598bc1a8164ca 100644 --- a/tools/react-integration-tester/src/args.ts +++ b/tools/react-integration-tester/src/args.ts @@ -131,7 +131,10 @@ export function parseArgs(processArgs: string[]): Required { } satisfies Required; function resolveConfigPath(projectRoot: string): string | undefined { - const defaultConfigPath = resolve(projectRoot, 'rit.config.js'); + // web packages ship as `type: module`, so their CommonJS rit config uses `.cjs`; fall back to `.js` + const defaultConfigPath = [resolve(projectRoot, 'rit.config.cjs'), resolve(projectRoot, 'rit.config.js')].find( + candidate => existsSync(candidate), + ); if (argv.config) { const userProvidedConfigPath = resolve(projectRoot, argv.config); @@ -141,6 +144,6 @@ export function parseArgs(processArgs: string[]): Required { return userProvidedConfigPath; } - return existsSync(defaultConfigPath) ? defaultConfigPath : undefined; + return defaultConfigPath; } } diff --git a/tools/react-integration-tester/src/files/cypress.config.ts.template b/tools/react-integration-tester/src/files/cypress.config.ts.template index cf2b5523a08628..b743cada6754e7 100644 --- a/tools/react-integration-tester/src/files/cypress.config.ts.template +++ b/tools/react-integration-tester/src/files/cypress.config.ts.template @@ -1,8 +1,11 @@ import { join, resolve } from 'node:path'; import baseConfig from '<%= relativePathToProjectRoot %>/<%= cypress.pathToProjectConfig %>'; -// Resolve dependencies from the shared react-version root folder (injected by CLI) -const usedNodeModulesPath = join(__dirname, '<%= usedNodeModulesDirRelative %>', 'node_modules'); +// Resolve dependencies from the shared react-version root folder (injected by CLI). +// Cypress executes this config with `cwd` set to the prepared project root (where this file lives), +// so `process.cwd()` is equivalent to the config directory and works in both CommonJS and ESM +// (`type: module`) sandboxes — unlike `__dirname`, which is undefined under native ESM. +const usedNodeModulesPath = join(process.cwd(), '<%= usedNodeModulesDirRelative %>', 'node_modules'); const config = { ...baseConfig }; From fb77d56db3daa54062b30eb6679763ecd2990eeb Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 6 Aug 2026 15:49:41 +0200 Subject: [PATCH 2/3] fix(eslint-rules): base-hook-no-forbidden-runtime - analyze base hook dependencies per symbol, not per file (#36498) --- .../src/local-barrel.ts | 3 + .../src/local-heavy.ts | 7 + .../src/local-light.ts | 7 + .../src/local-trigger.ts | 5 + .../stubs/barrel-pkg/clean.ts | 5 + .../stubs/barrel-pkg/dirty.ts | 5 + .../stubs/barrel-pkg/index.ts | 4 + .../stubs/component-pkg/index.ts | 2 + .../stubs/component-pkg/types.ts | 6 + .../stubs/component-pkg/widget.ts | 8 + .../stubs/heavy-runtime/sub.ts | 3 + .../stubs/relay-pkg/index.ts | 7 + .../stubs/typed-dist-pkg/index.d.ts | 8 + .../stubs/unlisted-pkg/index.ts | 5 + .../stubs/watched-pkg/heavy.ts | 9 +- .../stubs/watched-pkg/index.ts | 1 + .../stubs/wrapper-pkg/index.ts | 4 + .../stubs/wrapper-pkg/useBenign.ts | 7 + .../stubs/wrapper-pkg/useBenignRef.ts | 5 + .../stubs/wrapper-pkg/useDeep.ts | 7 + .../stubs/wrapper-pkg/useDeepInner.ts | 5 + .../tsconfig.json | 7 + .../base-hook-no-forbidden-runtime.spec.ts | 341 +++++++++- .../rules/base-hook-no-forbidden-runtime.ts | 600 ++++++++++-------- 24 files changed, 774 insertions(+), 287 deletions(-) create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-barrel.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-heavy.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-light.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-trigger.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/clean.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/dirty.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/types.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/widget.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/sub.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/relay-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/typed-dist-pkg/index.d.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/unlisted-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/index.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenign.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenignRef.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeep.ts create mode 100644 tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeepInner.ts diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-barrel.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-barrel.ts new file mode 100644 index 00000000000000..5a65ea2fdd3c97 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-barrel.ts @@ -0,0 +1,3 @@ +// Local folder barrel — same conflation risk as a package barrel. +export { useLocalLight } from './local-light'; +export { useLocalHeavy } from './local-heavy'; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-heavy.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-heavy.ts new file mode 100644 index 00000000000000..ead6e00c52fe16 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-heavy.ts @@ -0,0 +1,7 @@ +import { runHeavy } from 'heavy-runtime'; + +export type LocalHeavyType = { tag: 'local-heavy' }; + +export function useLocalHeavy(): { tag: 'heavy' } { + return runHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-light.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-light.ts new file mode 100644 index 00000000000000..898a8ebf4420de --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-light.ts @@ -0,0 +1,7 @@ +import { runLight } from 'light-helper'; + +export type LocalLightOptions = { mode: 'light' }; + +export function useLocalLight(opts?: LocalLightOptions): void { + runLight(opts); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-trigger.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-trigger.ts new file mode 100644 index 00000000000000..bb137a1600b014 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-trigger.ts @@ -0,0 +1,5 @@ +import { useLocalHeavy } from './local-heavy'; + +export function useLocalTrigger(): { tag: 'heavy' } { + return useLocalHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/clean.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/clean.ts new file mode 100644 index 00000000000000..42878376875f01 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/clean.ts @@ -0,0 +1,5 @@ +import { runLight } from 'light-helper'; + +export function useCleanExport(): void { + runLight(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/dirty.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/dirty.ts new file mode 100644 index 00000000000000..68e11614ee93f2 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/dirty.ts @@ -0,0 +1,5 @@ +import { runHeavy } from 'heavy-runtime'; + +export function useDirtyExport(): { tag: 'heavy' } { + return runHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/index.ts new file mode 100644 index 00000000000000..c480ee8ec51c18 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/index.ts @@ -0,0 +1,4 @@ +// Barrel exposing one clean and one forbidden-runtime-dependent export. Importing the clean one +// must not inherit the dependencies of its sibling. +export { useCleanExport } from './clean'; +export { useDirtyExport } from './dirty'; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/index.ts new file mode 100644 index 00000000000000..2e8873df7b17b2 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/index.ts @@ -0,0 +1,2 @@ +export { Widget } from './widget'; +export type { WidgetHostProps, WidgetSlots } from './types'; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/types.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/types.ts new file mode 100644 index 00000000000000..afb8971e26b199 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/types.ts @@ -0,0 +1,6 @@ +import type { Widget } from './widget'; + +// `typeof Widget` extracts the component's type; it does not consume its runtime. +export type WidgetSlots = { widget: typeof Widget }; + +export type WidgetHostProps = { slots: WidgetSlots }; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/widget.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/widget.ts new file mode 100644 index 00000000000000..ca211e6e729c25 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/widget.ts @@ -0,0 +1,8 @@ +import { runHeavy } from 'heavy-runtime'; + +export type WidgetProps = { tag: 'widget' }; + +// A component whose implementation is dirty but whose type surface is not. +export const Widget = (props: WidgetProps): { tag: 'heavy' } => { + return runHeavy(); +}; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/sub.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/sub.ts new file mode 100644 index 00000000000000..173d20bee9105f --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/heavy-runtime/sub.ts @@ -0,0 +1,3 @@ +export function runHeavySub(): { tag: 'heavy-sub' } { + return { tag: 'heavy-sub' }; +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/relay-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/relay-pkg/index.ts new file mode 100644 index 00000000000000..e4511b8426937e --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/relay-pkg/index.ts @@ -0,0 +1,7 @@ +import { useBenign } from 'wrapper-pkg'; + +// Mirrors `useActiveDescendant`: an intermediate package that consumes only the benign export of +// the wrapper package. +export function useRelay(): void { + useBenign(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/typed-dist-pkg/index.d.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/typed-dist-pkg/index.d.ts new file mode 100644 index 00000000000000..614d4904acbbdb --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/typed-dist-pkg/index.d.ts @@ -0,0 +1,8 @@ +import type { HeavyOptions } from 'heavy-runtime'; + +// Stands in for a package linted against built output rather than source. +export declare type DistHeavy = { tag: 'dist-heavy'; options: HeavyOptions }; + +export declare type DistClean = { tag: 'dist-clean' }; + +export declare function useDistClean(): DistClean; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/unlisted-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/unlisted-pkg/index.ts new file mode 100644 index 00000000000000..c9f11c7efe77b4 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/unlisted-pkg/index.ts @@ -0,0 +1,5 @@ +import { runHeavy } from 'heavy-runtime'; + +export function useUnlisted(): { tag: 'heavy' } { + return runHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts index 9798a4b8d6b35e..fc570732bb31d5 100644 --- a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts @@ -1,7 +1,12 @@ import { runHeavy } from 'heavy-runtime'; +import type { HeavyOptions } from 'heavy-runtime'; -export type HeavyType = { tag: 'heavy' }; +// Coupled to the forbidden runtime through its own shape, not merely through a sibling export. +export type HeavyType = { tag: 'heavy'; options?: HeavyOptions }; -export function useHeavy(): HeavyType { +// Clean sibling living in the very same file as the forbidden import above. +export type CleanTag = { tag: 'clean' }; + +export function useHeavy(): { tag: 'heavy' } { return runHeavy(); } diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts index ecc54ec64c43da..324be823ed5554 100644 --- a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts @@ -5,5 +5,6 @@ export { useLight } from './light'; export type { LightOptions } from './light'; // Re-export of a type-only thing from the heavy module — must not count as a runtime reach. export type { HeavyType } from './heavy'; +export type { CleanTag } from './heavy'; export type HeavyWrapper = { tag: 'heavy-wrapper'; inner: HeavyType }; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/index.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/index.ts new file mode 100644 index 00000000000000..94f970617b507a --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/index.ts @@ -0,0 +1,4 @@ +// Wrapper package standing in for `@fluentui/react-tabster`: not forbidden itself, but some of +// its exports reach the forbidden runtime and some do not. +export { useBenign } from './useBenign'; +export { useDeep } from './useDeep'; diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenign.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenign.ts new file mode 100644 index 00000000000000..19c2189a47b4a1 --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenign.ts @@ -0,0 +1,7 @@ +import { useBenignRef } from './useBenignRef'; + +// Mirrors `useOnKeyboardNavigationChange`: lives in the wrapper package but bottoms out in a +// benign dependency, never in the forbidden runtime. +export function useBenign(): void { + useBenignRef(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenignRef.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenignRef.ts new file mode 100644 index 00000000000000..6bb8a2d92cb08a --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useBenignRef.ts @@ -0,0 +1,5 @@ +import { runLight } from 'light-helper'; + +export function useBenignRef(): void { + runLight(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeep.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeep.ts new file mode 100644 index 00000000000000..75669b043d801d --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeep.ts @@ -0,0 +1,7 @@ +import { useDeepInner } from './useDeepInner'; + +// Mirrors `useTabsterAttributes`: same wrapper package as `useBenign`, but its implementation +// reaches the forbidden runtime two hops down. +export function useDeep(): { tag: 'heavy' } { + return useDeepInner(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeepInner.ts b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeepInner.ts new file mode 100644 index 00000000000000..4f6673e8ad227a --- /dev/null +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeepInner.ts @@ -0,0 +1,5 @@ +import { runHeavy } from 'heavy-runtime'; + +export function useDeepInner(): { tag: 'heavy' } { + return runHeavy(); +} diff --git a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json index a01e6c8cb85941..0e0e24ef90756f 100644 --- a/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json +++ b/tools/eslint-rules/rules/__fixtures__/base-hook-no-forbidden-runtime/tsconfig.json @@ -12,7 +12,14 @@ "paths": { "watched-pkg": ["./stubs/watched-pkg/index.ts"], "watched-pkg/*": ["./stubs/watched-pkg/*"], + "unlisted-pkg": ["./stubs/unlisted-pkg/index.ts"], + "barrel-pkg": ["./stubs/barrel-pkg/index.ts"], + "wrapper-pkg": ["./stubs/wrapper-pkg/index.ts"], + "relay-pkg": ["./stubs/relay-pkg/index.ts"], + "component-pkg": ["./stubs/component-pkg/index.ts"], + "typed-dist-pkg": ["./stubs/typed-dist-pkg/index.d.ts"], "heavy-runtime": ["./stubs/heavy-runtime/index.ts"], + "heavy-runtime/*": ["./stubs/heavy-runtime/*"], "light-helper": ["./stubs/light-helper/index.ts"], "cyclic-pkg": ["./stubs/cyclic-pkg/index.ts"], "cyclic-heavy-pkg": ["./stubs/cyclic-heavy-pkg/index.ts"] diff --git a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts index 9421f2e1785da0..ccd9b8c3d58f01 100644 --- a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.spec.ts @@ -33,16 +33,7 @@ ruleTester.run(RULE_NAME, rule, { }; `, }, - // \`keyborg\` is not in the default forbidden runtime list — bindings imported from it are allowed inside base hooks. - { - code: ` - import { createKeyborg, KEYBORG_FOCUSIN } from 'keyborg'; - export const useThingBase_unstable = (props, ref) => { - return { kb: createKeyborg(window), evt: KEYBORG_FOCUSIN }; - }; - `, - }, - // No watched/forbidden imports — base hook body is not inspected at all. + // No imports at all — base hook body is not inspected. { code: ` export const useThingBase_unstable = (props, ref) => { @@ -52,7 +43,7 @@ ruleTester.run(RULE_NAME, rule, { }, ], invalid: [ - // Referencing a watched-package binding inside a base hook without typed services available + // Referencing an imported binding inside a base hook without typed services available // surfaces a one-shot `typedServicesUnavailable` diagnostic so the misconfiguration is visible. { code: ` @@ -65,7 +56,24 @@ ruleTester.run(RULE_NAME, rule, { { messageId: 'typedServicesUnavailable', data: { - watchedPackages: '@fluentui/react-tabster', + forbiddenRuntimes: 'tabster', + }, + }, + ], + }, + // Every import is analyzed transitively, not just a fixed allow-list of packages — so even + // an unrelated package such as `keyborg` needs typed services to be cleared. + { + code: ` + import { createKeyborg, KEYBORG_FOCUSIN } from 'keyborg'; + export const useThingBase_unstable = (props, ref) => { + return { kb: createKeyborg(window), evt: KEYBORG_FOCUSIN }; + }; + `, + errors: [ + { + messageId: 'typedServicesUnavailable', + data: { forbiddenRuntimes: 'tabster', }, }, @@ -79,18 +87,14 @@ ruleTester.run(RULE_NAME, rule, { // --------------------------------------------------------------------------- const typedRuleTester = new RuleTester(); -const transitiveOptions: readonly [{ watchedPackages: string[]; forbiddenRuntimes: string[] }] = [ +const transitiveOptions: readonly [{ forbiddenRuntimes: string[] }] = [ { - watchedPackages: ['watched-pkg'], forbiddenRuntimes: ['heavy-runtime'], }, ]; -const transitiveOptionsAllowTypeImports: readonly [ - { watchedPackages: string[]; forbiddenRuntimes: string[]; allowTypeImports: boolean }, -] = [ +const transitiveOptionsAllowTypeImports: readonly [{ forbiddenRuntimes: string[]; allowTypeImports: boolean }] = [ { - watchedPackages: ['watched-pkg'], forbiddenRuntimes: ['heavy-runtime'], allowTypeImports: true, }, @@ -140,16 +144,119 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { }; `, }, + // Relative import of a local module that does not reach the forbidden runtime. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useLocalLight } from './local-light'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useLocalLight(); + return { props, ref }; + }; + `, + }, + // Barrels must stay transparent: pulling a clean export from a package barrel must not + // inherit the dependencies of the forbidden-runtime sibling exported next to it. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useCleanExport } from 'barrel-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useCleanExport(); + return { props, ref }; + }; + `, + }, + // Same guarantee for a local folder barrel. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useLocalLight } from './local-barrel'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useLocalLight(); + return { props, ref }; + }; + `, + }, + // A wrapper-package export whose implementation bottoms out in a benign dependency is allowed, + // even though sibling exports of the same package do reach the forbidden runtime. This is the + // `useOnKeyboardNavigationChange` -> `keyborg` shape. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useBenign } from 'wrapper-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useBenign(); + return { props, ref }; + }; + `, + }, + // Same guarantee one package further out: `useActiveDescendant` -> `useOnKeyboardNavigationChange` + // -> `keyborg` must stay clean across package boundaries. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useRelay } from 'relay-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useRelay(); + return { props, ref }; + }; + `, + }, + // A `.d.ts`-declared symbol that does not touch the forbidden runtime stays valid. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useDistClean } from 'typed-dist-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + useDistClean(); + return { props, ref }; + }; + `, + }, + // A props type doing `typeof SomeComponent` describes the component's shape; it does not + // consume its runtime, so the component's implementation must not be walked. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { WidgetHostProps } from 'component-pkg'; + export const useThingBase_unstable = (props: WidgetHostProps, ref) => { + return { props, ref }; + }; + `, + }, + // A symbol whose own shape does not touch the forbidden runtime is fine even when the file + // declaring it imports that runtime for a *sibling* export. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { CleanTag } from 'watched-pkg'; + export const useThingBase_unstable = (props: CleanTag, ref) => { + return { props, ref }; + }; + `, + }, // Cyclic re-export graph must not infinite-loop; \`useA\` does not reach heavy-runtime. { languageOptions: typedLanguageOptions, filename: TYPED_FILENAME, - options: [ - { - watchedPackages: ['cyclic-pkg'], - forbiddenRuntimes: ['heavy-runtime'], - }, - ], + options: transitiveOptions, code: ` import { useA } from 'cyclic-pkg'; export const useThingBase_unstable = (props: { a: number }, ref) => { @@ -246,12 +353,7 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { { languageOptions: typedLanguageOptions, filename: TYPED_FILENAME, - options: [ - { - watchedPackages: ['cyclic-heavy-pkg'], - forbiddenRuntimes: ['heavy-runtime'], - }, - ], + options: transitiveOptions, code: ` import { useB } from 'cyclic-heavy-pkg'; export const useThingBase_unstable = (props: { a: number }, ref) => { @@ -266,7 +368,7 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { importedName: 'useB', package: 'cyclic-heavy-pkg', runtime: 'heavy-runtime', - viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/b.ts', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/cyclic-heavy-pkg/a.ts', }, }, ], @@ -388,9 +490,8 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { }, ], }, - // Indirect type leakage: `HeavyWrapper` is declared in `watched-pkg/index.ts` (not in `heavy.ts`), - // but its defining file value-re-exports `./heavy`, so the type-graph reach from `index.ts` still - // includes `heavy-runtime`. The base hook surface is therefore tied to the forbidden runtime. + // Indirect type leakage: `HeavyWrapper` is declared in `watched-pkg/index.ts`, but its own + // shape embeds `HeavyType`, which is itself typed by the forbidden runtime. { languageOptions: typedLanguageOptions, filename: TYPED_FILENAME, @@ -409,7 +510,177 @@ typedRuleTester.run(`${RULE_NAME} (typed)`, rule, { importedName: 'HeavyWrapper', package: 'watched-pkg', runtime: 'heavy-runtime', - viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/index.ts', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/watched-pkg/heavy.ts', + }, + }, + ], + }, + // The forbidden-runtime sibling of a barrel is still reported when it is the binding actually + // referenced. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useDirtyExport } from 'barrel-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: useDirtyExport() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useDirtyExport', + package: 'barrel-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/barrel-pkg/dirty.ts', + }, + }, + ], + }, + // Relative imports are analyzed too: `./local-trigger` -> `./local-heavy` -> `heavy-runtime`. + // This is the shape that let `@fluentui/react-tabster` leak into `useDropdownBase_unstable`. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useLocalTrigger } from './local-trigger'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: useLocalTrigger() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useLocalTrigger', + package: './local-trigger', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/src/local-heavy.ts', + }, + }, + ], + }, + // A package that appears in no option list is still analyzed transitively. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useUnlisted } from 'unlisted-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: useUnlisted() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useUnlisted', + package: 'unlisted-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/unlisted-pkg/index.ts', + }, + }, + ], + }, + // The sibling export of that same wrapper package is still reported, with `via` naming the + // innermost file rather than the package entry point. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { useDeep } from 'wrapper-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: useDeep() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'useDeep', + package: 'wrapper-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/wrapper-pkg/useDeepInner.ts', + }, + }, + ], + }, + // Declaration files are traversed too, so a package linted against built output still has its + // type coupling to the forbidden runtime detected. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import type { DistHeavy } from 'typed-dist-pkg'; + export const useThingBase_unstable = (props: DistHeavy, ref) => { + return { props, ref }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'DistHeavy', + package: 'typed-dist-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/typed-dist-pkg/index.d.ts', + }, + }, + ], + }, + // Actually rendering that same component in a base hook is a runtime dependency and is reported. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { Widget } from 'component-pkg'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, components: { widget: Widget } }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeReach', + data: { + hookName: 'useThingBase_unstable', + importedName: 'Widget', + package: 'component-pkg', + runtime: 'heavy-runtime', + viaFile: 'rules/__fixtures__/base-hook-no-forbidden-runtime/stubs/component-pkg/widget.ts', + }, + }, + ], + }, + // A subpath specifier of a forbidden runtime is normalized to its package name. + { + languageOptions: typedLanguageOptions, + filename: TYPED_FILENAME, + options: transitiveOptions, + code: ` + import { runHeavySub } from 'heavy-runtime/sub'; + export const useThingBase_unstable = (props: { a: number }, ref) => { + return { props, ref, x: runHeavySub() }; + }; + `, + errors: [ + { + messageId: 'forbiddenRuntimeDirect', + data: { + hookName: 'useThingBase_unstable', + importedName: 'runHeavySub', + package: 'heavy-runtime', }, }, ], diff --git a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts index d836462110315e..d58d28edcf6428 100644 --- a/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts +++ b/tools/eslint-rules/rules/base-hook-no-forbidden-runtime.ts @@ -19,29 +19,22 @@ const BASE_HOOK_NAME_PATTERN = /^use[A-Z]\w*Base_unstable$/; */ type BaseHookFunction = TSESTree.FunctionDeclaration | TSESTree.FunctionExpression | TSESTree.ArrowFunctionExpression; -const DEFAULT_WATCHED_PACKAGES: ReadonlyArray = ['@fluentui/react-tabster']; +// Only the runtime itself is listed. Wrapper packages such as `@fluentui/react-tabster` are +// deliberately absent: the analysis is symbol-level, so a wrapper export that bottoms out in +// `keyborg` or plain DOM is legitimately allowed, while one that reaches `tabster` is not. const DEFAULT_FORBIDDEN_RUNTIMES: ReadonlyArray = ['tabster']; type Options = [ { /** - * Packages whose imported symbols must be analyzed transitively. - * A symbol imported from one of these packages is allowed inside a base - * hook only if its defining source file does not reach any - * `forbiddenRuntimes` package via value imports. - */ - watchedPackages?: string[]; - /** - * Runtime packages whose presence in the transitive value-import graph of - * a referenced symbol is forbidden inside base hooks. Direct imports from - * these packages are also forbidden. + * Runtime packages whose presence in the transitive import graph of a referenced symbol is + * forbidden inside base hooks. Direct imports from these packages are also forbidden. */ forbiddenRuntimes?: string[]; /** - * When `true`, type-only imports (both from `forbiddenRuntimes` packages directly and - * from `watchedPackages` whose defining module reaches a forbidden runtime) are permitted - * inside base hooks. Type-only imports emit no runtime code, so this option trades API - * decoupling for ergonomics. + * When `true`, type-only imports (both from `forbiddenRuntimes` packages directly and from + * modules whose import graph reaches a forbidden runtime) are permitted inside base hooks. + * Type-only imports emit no runtime code, so this option trades API decoupling for ergonomics. * * Defaults to `false` — type-only imports are checked the same way as value imports, to * keep the base hook's public API fully decoupled from forbidden runtimes. @@ -53,8 +46,7 @@ type Options = [ type MessageIds = 'forbiddenRuntimeDirect' | 'forbiddenRuntimeReach' | 'typedServicesUnavailable'; /** - * The original (imported) name of an import specifier, used for diagnostics and for matching - * against a forbidden/watched package's exports. + * The original (imported) name of an import specifier, used for diagnostics. * * - named import (`import { Foo }`) → `'Foo'` * - aliased named import (`import { Foo as Bar }`) → `'Foo'` (the original, not the alias) @@ -67,17 +59,20 @@ type ImportSpecifierNode = | TSESTree.ImportNamespaceSpecifier; /** - * A locally-declared binding originating from a tracked import (a watched or forbidden-runtime - * package). Built when walking `ImportDeclaration` nodes so body references can be matched in - * O(1) via a `Map`. + * A locally-declared binding originating from any import declaration. Built when walking + * `ImportDeclaration` nodes so body references can be matched in O(1) via a + * `Map`. */ interface TrackedImport { - /** The package the binding came from (a watched OR forbidden-runtime package). */ + /** Package name for bare specifiers, or the specifier as written for relative imports. */ package: string; /** Original imported name (not the local alias). `default` or `*` for default / namespace. */ importedName: string; - /** Kind of package — controls how the reference is checked. */ - kind: 'watched' | 'forbidden'; + /** + * `forbidden` when the import comes straight from a forbidden-runtime package, `transitive` + * for everything else — which is then resolved through the module graph on demand. + */ + kind: 'forbidden' | 'transitive'; /** * `true` when the binding is type-only (either the declaration is `import type ...` * or the specifier is `import { type Foo }`). Used to gate whether direct usage in a @@ -86,31 +81,32 @@ interface TrackedImport { isTypeOnly: boolean; /** The specifier node (used for symbol lookup via ParserServices). */ specifier: ImportSpecifierNode; + /** Memoized analysis result for value references — `undefined` until first resolved. */ + valueHit?: Hit | null; + /** Memoized analysis result for type references — `undefined` until first resolved. */ + typeHit?: Hit | null; } /** - * Result of a single transitive-reach DFS over a source file's import graph. - * - `value` — packages reachable via value (non type-only) imports only. Used to decide - * whether a runtime reference can pull a forbidden runtime at execution time. - * - `all` — packages reachable via value OR type imports. Used to decide whether a - * type reference can leak a forbidden runtime through the public API surface. - * `value` is always a subset of `all`. + * A forbidden runtime found while walking what a symbol actually depends on. */ -interface Reach { - value: ReadonlySet; - all: ReadonlySet; +interface Hit { + /** The forbidden runtime package that was reached. */ + runtime: string; + /** Shortened path of the file where the forbidden dependency enters the graph. */ + via: string; } -type SymbolReach = Reach & { viaFile: string }; - /** - * Per-Program cache: source file path → reach sets transitively computed from that file. - * Both `value` and `all` sets are filled in a single DFS pass to share resolution work. - * - * Keyed by `ts.Program` identity so the cache is invalidated whenever - * typescript-eslint rebuilds the Program. + * Per-Program memo of symbol-level results, split by whether type positions were followed. + * Keyed by `ts.Program` identity so the cache dies with the Program that produced the symbols. */ -const programCache = new WeakMap>(); +interface AnalysisCache { + value: Map; + all: Map; +} + +const programCache = new WeakMap(); export const rule = ESLintUtils.RuleCreator(() => __filename)({ name: RULE_NAME, @@ -124,11 +120,6 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) __filename)([...watchedPackages, ...forbiddenRuntimes]); // Map of locally-declared variable identity → original import origin metadata. Keyed by Variable // identity (not name) so re-declarations / shadowing inside the base hook resolve correctly. @@ -189,9 +177,8 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) __filename) __filename) visitScope(child, hookFn, hookName)); } /** - * Resolves the watched-package import to its defining module via TS `Program`, then queries the - * transitive import graph for forbidden runtimes (both value-only and value+type sets). + * Resolves the import binding to its symbol and asks the symbol-level analysis whether what + * that symbol actually depends on reaches a forbidden runtime. * Returns `null` (and flips the `typedServicesNeededButMissing` flag) when typed services * aren't available, so the caller can silently skip and we can warn once on `Program:exit`. + * Memoized per binding × reference kind, since a binding is usually referenced many times. */ - function computeSymbolReach(origin: TrackedImport): SymbolReach | null { + function analyzeOrigin(origin: TrackedImport, followTypes: boolean): Hit | null { + const cached = followTypes ? origin.typeHit : origin.valueHit; + if (cached !== undefined) { + return cached; + } + const result = resolveOriginHit(origin, followTypes); + if (followTypes) { + origin.typeHit = result; + } else { + origin.valueHit = result; + } + return result; + } + + function resolveOriginHit(origin: TrackedImport, followTypes: boolean): Hit | null { const services = getTypedServices(); if (!services) { typedServicesNeededButMissing = true; @@ -297,49 +293,39 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) { const specTypeOnly = @@ -353,7 +339,7 @@ export const rule = ESLintUtils.RuleCreator(() => __filename) __filename), - inProgress: Set, -): Reach { - const fileName = sourceFile.fileName; - const cached = cache.get(fileName); - if (cached) { - return cached; - } - const value = new Set(); - const all = new Set(); - const result: Reach = { value, all }; - cache.set(fileName, result); - if (inProgress.has(fileName)) { + checker: ts.TypeChecker, + symbol: ts.Symbol, + followTypes: boolean, + forbiddenRuntimes: ReadonlySet, +): Hit | null { + const caches = getAnalysisCache(program); + const cache = followTypes ? caches.all : caches.value; + const inProgress = new Set(); + + function visitSymbol(current: ts.Symbol): Hit | null { + const cached = cache.get(current); + if (cached !== undefined) { + return cached; + } + if (inProgress.has(current)) { + return null; + } + inProgress.add(current); + + let result: Hit | null = null; + try { + for (const declaration of current.declarations ?? []) { + result = visitDeclaration(current, declaration); + if (result) { + break; + } + } + } finally { + inProgress.delete(current); + } + + cache.set(current, result); return result; } - inProgress.add(fileName); - try { - for (const imp of collectImports(sourceFile)) { - if (isBareSpecifier(imp.specifier)) { - const pkg = packageNameOf(imp.specifier); - all.add(pkg); - if (!imp.typeOnly) { - value.add(pkg); - } - } - const resolved = resolveModule(program, sourceFile, imp.specifier, imp.literal); - if (!resolved) { - continue; + function visitDeclaration(owner: ts.Symbol, declaration: ts.Declaration): Hit | null { + const edge = getModuleEdge(declaration); + if (edge) { + if (edge.typeOnly && !followTypes) { + return null; } - const childSourceFile = program.getSourceFile(resolved); - if (!childSourceFile) { - continue; + if (edge.packageName !== undefined && forbiddenRuntimes.has(edge.packageName)) { + return { runtime: edge.packageName, via: shortenPath(declaration.getSourceFile().fileName) }; } - const childReach = computeReach(program, childSourceFile, cache, inProgress); - for (const pkg of childReach.all) { - all.add(pkg); + // A namespace binding stands for the entire module; there is no single symbol to follow, + // so the specifier check above is as far as the analysis goes. + return edge.isNamespace ? null : visitAliasTarget(owner); + } + // Whole-module symbols (`export = React`, ambient namespaces) bind everything a module + // exports and have no single declaration to follow. + if (ts.isSourceFile(declaration) || ts.isModuleDeclaration(declaration)) { + return null; + } + return walkReferences(declaration); + } + + /** + * Follows an alias to the leaf symbol it ultimately resolves to. This is what makes re-export + * barrels transparent. Because the hop skips intermediate specifiers, the leaf's own source + * file is also checked against the forbidden list to catch `export { x } from 'tabster'` chains. + */ + function visitAliasTarget(alias: ts.Symbol): Hit | null { + let target: ts.Symbol; + try { + target = checker.getAliasedSymbol(alias); + } catch { + return null; + } + if (target === alias) { + return null; + } + return owningForbiddenPackage(target, forbiddenRuntimes) ?? visitSymbol(target); + } + + /** + * Walks the identifiers `declaration` references, recursing into each referenced symbol. + * Identifiers that are not references (property names, declaration names, import specifier + * names) are skipped. + * + * The two modes never mix. A runtime query follows only value positions; a type query follows + * only type positions. Crossing between them would make `typeof SomeComponent` in a props type + * drag in that component's whole implementation, which is API coupling that does not exist. + */ + function walkReferences(declaration: ts.Node): Hit | null { + let hit: Hit | null = null; + + const visit = (node: ts.Node): void => { + if (hit) { + return; } - if (!imp.typeOnly) { - // A type-only edge does not propagate runtime reach: it can only widen the `all` set. - for (const pkg of childReach.value) { - value.add(pkg); + if (ts.isIdentifier(node) && isReferencePosition(node) && isInTypePosition(node) === followTypes) { + const referenced = checker.getSymbolAtLocation(node); + if (referenced) { + hit = visitSymbol(referenced); + if (hit) { + return; + } } } - } - } finally { - inProgress.delete(fileName); + ts.forEachChild(node, visit); + }; + + ts.forEachChild(declaration, visit); + return hit; } - return result; + + return visitSymbol(symbol); } -interface ImportEdge { - specifier: string; - literal: ts.StringLiteralLike; - /** `true` when this edge only carries type information (no runtime side-effect). */ - typeOnly: boolean; +function getAnalysisCache(program: ts.Program): AnalysisCache { + let cache = programCache.get(program); + if (!cache) { + cache = { value: new Map(), all: new Map() }; + programCache.set(program, cache); + } + return cache; } /** - * Enumerates every module specifier in `sourceFile`, tagging each edge as value (`typeOnly: false`) - * or type-only (`typeOnly: true`). `import type` / `export type`, fully type-only named import or - * export clauses, and `import type =` are emitted with `typeOnly: true`. Side-effect imports - * (no clause) are emitted as value edges. + * `true` when the leaf declaration of `symbol` lives inside one of the forbidden packages, which + * covers re-export chains that `getAliasedSymbol` collapses in a single hop. */ -function collectImports(sourceFile: ts.SourceFile): ImportEdge[] { - const result: ImportEdge[] = []; - for (const stmt of sourceFile.statements) { - if (ts.isImportDeclaration(stmt)) { - let typeOnly = false; - if (stmt.importClause?.isTypeOnly) { - typeOnly = true; - } else if ( - stmt.importClause && - stmt.importClause.namedBindings && - ts.isNamedImports(stmt.importClause.namedBindings) - ) { - const named = stmt.importClause.namedBindings; - const hasValue = !!stmt.importClause.name || named.elements.some(element => !element.isTypeOnly); - typeOnly = !hasValue; - } - if (ts.isStringLiteralLike(stmt.moduleSpecifier)) { - result.push({ specifier: stmt.moduleSpecifier.text, literal: stmt.moduleSpecifier, typeOnly }); - } - continue; - } - if (ts.isExportDeclaration(stmt) && stmt.moduleSpecifier && ts.isStringLiteralLike(stmt.moduleSpecifier)) { - let typeOnly = stmt.isTypeOnly; - if (!typeOnly && stmt.exportClause && ts.isNamedExports(stmt.exportClause)) { - typeOnly = stmt.exportClause.elements.every(element => element.isTypeOnly); - } - result.push({ specifier: stmt.moduleSpecifier.text, literal: stmt.moduleSpecifier, typeOnly }); - continue; - } - if ( - ts.isImportEqualsDeclaration(stmt) && - ts.isExternalModuleReference(stmt.moduleReference) && - ts.isStringLiteralLike(stmt.moduleReference.expression) - ) { - result.push({ - specifier: stmt.moduleReference.expression.text, - literal: stmt.moduleReference.expression, - typeOnly: stmt.isTypeOnly, - }); +function owningForbiddenPackage(symbol: ts.Symbol, forbiddenRuntimes: ReadonlySet): Hit | null { + for (const declaration of symbol.declarations ?? []) { + const fileName = declaration.getSourceFile().fileName; + const owner = packageFromNodeModulesPath(fileName); + if (owner !== undefined && forbiddenRuntimes.has(owner)) { + return { runtime: owner, via: shortenPath(fileName) }; } } - return result; + return null; } -// --------------------------------------------------------------------------- -// Module resolution helpers -// --------------------------------------------------------------------------- +/** + * The npm package a file belongs to, when the file sits under a `node_modules` directory. + */ +function packageFromNodeModulesPath(fileName: string): string | undefined { + const segments = toPosixPath(fileName).split('/'); + const index = segments.lastIndexOf('node_modules'); + if (index === -1) { + return undefined; + } + const first = segments[index + 1]; + if (first === undefined) { + return undefined; + } + const second = segments[index + 2]; + return first.startsWith('@') && second !== undefined ? `${first}/${second}` : first; +} + +interface ModuleEdge { + /** Bare package the binding comes from, or `undefined` for relative and local re-exports. */ + packageName: string | undefined; + /** `true` when the edge only carries type information (no runtime side-effect). */ + typeOnly: boolean; + /** `true` for `import * as ns` / `export * as ns`, which bind a whole module rather than a symbol. */ + isNamespace: boolean; +} /** - * Resolves `specifier` (as used in `sourceFile`) to an absolute file path using TS Program module - * resolution APIs available in TypeScript >= 5.3. Returns `undefined` if the module cannot be - * resolved (e.g. ambient declarations, broken paths). + * Describes the cross-module edge a declaration represents, or `null` when the declaration is not + * an import/export binding. */ -function resolveModule( - program: ts.Program, - sourceFile: ts.SourceFile, - specifier: string, - literal: ts.StringLiteralLike, -): string | undefined { - const getResolvedModule = ( - program as unknown as { - getResolvedModule?: ( - file: ts.SourceFile, - moduleName: string, - mode?: ts.ResolutionMode, - ) => { resolvedModule?: ts.ResolvedModuleFull } | undefined; - } - ).getResolvedModule; - if (typeof getResolvedModule !== 'function') { +function getModuleEdge(declaration: ts.Declaration): ModuleEdge | null { + if (ts.isImportSpecifier(declaration)) { + const importClause = declaration.parent.parent; + return { + packageName: barePackageOf(importClause.parent.moduleSpecifier), + typeOnly: declaration.isTypeOnly || importClause.isTypeOnly, + isNamespace: false, + }; + } + if (ts.isImportClause(declaration)) { + return { + packageName: barePackageOf(declaration.parent.moduleSpecifier), + typeOnly: declaration.isTypeOnly, + isNamespace: false, + }; + } + if (ts.isNamespaceImport(declaration)) { + const importClause = declaration.parent; + return { + packageName: barePackageOf(importClause.parent.moduleSpecifier), + typeOnly: importClause.isTypeOnly, + isNamespace: true, + }; + } + if (ts.isExportSpecifier(declaration)) { + const exportDeclaration = declaration.parent.parent; + return { + packageName: exportDeclaration.moduleSpecifier + ? barePackageOf(exportDeclaration.moduleSpecifier) + : /* local re-export */ undefined, + typeOnly: declaration.isTypeOnly || exportDeclaration.isTypeOnly, + isNamespace: false, + }; + } + if (ts.isNamespaceExport(declaration)) { + const exportDeclaration = declaration.parent; + return { + packageName: exportDeclaration.moduleSpecifier ? barePackageOf(exportDeclaration.moduleSpecifier) : undefined, + typeOnly: exportDeclaration.isTypeOnly, + isNamespace: true, + }; + } + if (ts.isImportEqualsDeclaration(declaration)) { + const reference = declaration.moduleReference; + return { + packageName: ts.isExternalModuleReference(reference) ? barePackageOf(reference.expression) : undefined, + typeOnly: declaration.isTypeOnly, + isNamespace: true, + }; + } + return null; +} + +function barePackageOf(moduleSpecifier: ts.Expression): string | undefined { + if (!ts.isStringLiteralLike(moduleSpecifier) || !isBareSpecifier(moduleSpecifier.text)) { return undefined; } + return packageNameOf(moduleSpecifier.text); +} - const mode = ( - ts as unknown as { - getModeForUsageLocation?: (file: ts.SourceFile, usage: ts.StringLiteralLike) => ts.ResolutionMode; - } - ).getModeForUsageLocation?.(sourceFile, literal); +/** + * `false` for identifiers that merely name something (property names, declaration names, import + * and export specifier names) rather than referring to a binding worth following. + */ +function isReferencePosition(node: ts.Identifier): boolean { + const parent = node.parent; + if (!parent) { + return false; + } + if (ts.isPropertyAccessExpression(parent) && parent.name === node) { + return false; + } + if (ts.isQualifiedName(parent) && parent.right === node) { + return false; + } + if (ts.isPropertyAssignment(parent) && parent.name === node) { + return false; + } + if (ts.isBindingElement(parent) && parent.propertyName === node) { + return false; + } + if (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) { + return false; + } + if (ts.isImportClause(parent) || ts.isNamespaceImport(parent) || ts.isNamespaceExport(parent)) { + return false; + } + // `{ foo }` binds and references `foo` at once, so it is a reference despite being a `name`. + if (ts.isShorthandPropertyAssignment(parent)) { + return true; + } + return (parent as ts.Node & { name?: ts.Node }).name !== node; +} - const resolutionResult = getResolvedModule.call(program, sourceFile, specifier, mode); - return resolutionResult?.resolvedModule?.resolvedFileName; +/** + * `true` when the identifier sits inside a type annotation, type alias body or `typeof` query. + */ +function isInTypePosition(node: ts.Node): boolean { + let current: ts.Node | undefined = node.parent; + while (current) { + if (ts.isTypeNode(current)) { + return true; + } + current = current.parent; + } + return false; } /** From eeaab4c87045c1839ec9d66e52e6a16c0328d027 Mon Sep 17 00:00:00 2001 From: Martin Hochel Date: Thu, 6 Aug 2026 16:12:56 +0200 Subject: [PATCH 3/3] feat(verify-bundle-isolation): implement verify bundle isolation CLI and hook it to headless package (#36511) --- .github/workflows/pr.yml | 2 +- ...-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json | 7 + package.json | 2 + .../library/bundle-isolation.config.json | 10 + .../library/eslint.config.js | 12 +- .../library/package.json | 1 + .../library/project.json | 14 + tools/verify-bundle-isolation/README.md | 178 +++++++++ .../bin/verify-bundle-isolation.js | 15 + .../verify-bundle-isolation/eslint.config.js | 18 + tools/verify-bundle-isolation/jest.config.js | 14 + tools/verify-bundle-isolation/package.json | 17 + tools/verify-bundle-isolation/project.json | 7 + tools/verify-bundle-isolation/schema.json | 51 +++ .../src/bundle-isolation-plugin.spec.ts | 212 ++++++++++ .../src/bundle-isolation-plugin.ts | 277 +++++++++++++ tools/verify-bundle-isolation/src/cli.ts | 193 +++++++++ .../src/config.spec.ts | 113 ++++++ tools/verify-bundle-isolation/src/config.ts | 84 ++++ .../src/report.spec.ts | 333 ++++++++++++++++ tools/verify-bundle-isolation/src/report.ts | 374 ++++++++++++++++++ tools/verify-bundle-isolation/tsconfig.json | 22 ++ .../verify-bundle-isolation/tsconfig.lib.json | 12 + .../tsconfig.spec.json | 10 + yarn.lock | 67 +++- 25 files changed, 2037 insertions(+), 8 deletions(-) create mode 100644 change/@fluentui-react-headless-components-preview-8a1f0c62-4d3e-47b5-9c0a-1f2e6b7d5a94.json create mode 100644 packages/react-components/react-headless-components-preview/library/bundle-isolation.config.json create mode 100644 tools/verify-bundle-isolation/README.md create mode 100755 tools/verify-bundle-isolation/bin/verify-bundle-isolation.js create mode 100644 tools/verify-bundle-isolation/eslint.config.js create mode 100644 tools/verify-bundle-isolation/jest.config.js create mode 100644 tools/verify-bundle-isolation/package.json create mode 100644 tools/verify-bundle-isolation/project.json create mode 100644 tools/verify-bundle-isolation/schema.json create mode 100644 tools/verify-bundle-isolation/src/bundle-isolation-plugin.spec.ts create mode 100644 tools/verify-bundle-isolation/src/bundle-isolation-plugin.ts create mode 100644 tools/verify-bundle-isolation/src/cli.ts create mode 100644 tools/verify-bundle-isolation/src/config.spec.ts create mode 100644 tools/verify-bundle-isolation/src/config.ts create mode 100644 tools/verify-bundle-isolation/src/report.spec.ts create mode 100644 tools/verify-bundle-isolation/src/report.ts create mode 100644 tools/verify-bundle-isolation/tsconfig.json create mode 100644 tools/verify-bundle-isolation/tsconfig.lib.json create mode 100644 tools/verify-bundle-isolation/tsconfig.spec.json 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: