diff --git a/projects/packages/wp-build-polyfills/README.md b/projects/packages/wp-build-polyfills/README.md index 2147a889dea4..e2ba98b75d3f 100644 --- a/projects/packages/wp-build-polyfills/README.md +++ b/projects/packages/wp-build-polyfills/README.md @@ -78,6 +78,19 @@ Consuming packages should add `@automattic/jetpack-wp-build-polyfills` as a devD "build:boot-proxy": "provide-boot-asset-file" ``` +## Safety checks + +Two checks run after `webpack` (see the `build` script) so version skew in the bundled `@wordpress/*` +set fails the build instead of silently blanking a dashboard at runtime (as in Jetpack 16.0): + +- **`validate-boot-asset.js`** — every dependency handle in the boot module's `.asset.php` is a known + Core or polyfill handle (catches a script silently dropped for an unregistered dependency). +- **`validate-export-contract.js`** — every symbol a consumer (`@wordpress/boot`, …) imports from a + polyfilled classic-script provider (`@wordpress/theme`, `@wordpress/notices`, `@wordpress/private-apis`, `@wordpress/views`) exists + in that provider's shipped public API (catches the 16.0 `wp.theme.ThemeProvider is undefined` + case). Run standalone with `pnpm run check-contracts`. The ESM module providers (`route`, `a11y`) + are a follow-up. Regression-tested in `tests/js/validate-export-contract.test.js`. + ## Development ```bash diff --git a/projects/packages/wp-build-polyfills/bin/validate-export-contract-lib.js b/projects/packages/wp-build-polyfills/bin/validate-export-contract-lib.js new file mode 100644 index 000000000000..a8dabd22e3e2 --- /dev/null +++ b/projects/packages/wp-build-polyfills/bin/validate-export-contract-lib.js @@ -0,0 +1,359 @@ +/* global __dirname, process */ +/** + * Export-contract validation: assert every symbol a consumer package imports from + * a polyfilled provider actually exists in the shipped provider's public exports. + * A missing symbol resolves to `undefined` at runtime (blank dashboard, no build + * error) — the Jetpack 16.0 failure mode. Shared by the post-build CLI and tests. + */ + +const { readFileSync, readdirSync, existsSync } = require( 'fs' ); +const path = require( 'path' ); + +/** + * Map a classic-script handle to its npm package name (`wp-theme` → `@wordpress/theme`). + * + * @param {string} handle - A `wp-*` script handle. + * @return {string} The `@wordpress/*` package name. + */ +function handleToPackage( handle ) { + return '@wordpress/' + handle.replace( /^wp-/, '' ); +} + +/** + * Extract the string values of a `const NAME = array( 'a', 'b' );` PHP class constant. + * + * @param {string} phpSource - PHP file contents. + * @param {string} constName - Constant name. + * @return {string[]} The array's string values, or [] if not found. + */ +function parsePhpConstArray( phpSource, constName ) { + const match = phpSource.match( + new RegExp( `const\\s+${ constName }\\s*=\\s*array\\(([^)]*)\\)` ) + ); + return match ? match[ 1 ].match( /'([^']+)'/g )?.map( s => s.replace( /'/g, '' ) ) ?? [] : []; +} + +/** + * Derive the shipped provider/consumer lists from the class constants in + * class-wp-build-polyfills.php (SCRIPT_HANDLES + MODULE_IDS) — the single source of + * truth that also registers them at runtime. Providers are the classic-script globals + * whose exports we verify; consumers are the ESM modules that import them. + * + * @param {string} packageRoot - Polyfill package root. + * @return {{ providers: string[], consumers: string[] }} Providers and consumers. + */ +function getShippedPackages( packageRoot ) { + const php = readFileSync( + path.join( packageRoot, 'src', 'class-wp-build-polyfills.php' ), + 'utf8' + ); + return { + providers: parsePhpConstArray( php, 'SCRIPT_HANDLES' ).map( handleToPackage ), + consumers: parsePhpConstArray( php, 'MODULE_IDS' ), + }; +} + +/** + * Original names of the symbols named-imported from a provider in ESM source. + * Handles `import { A, B as C } from '@wordpress/x'` and the mixed default form + * `import Def, { A } from '@wordpress/x'` (imported name is before `as`); a pure + * default or namespace import has no `{ … }` and is ignored (can't be a missing + * named export). Matches `import` statements only — an `export { … } from + * '@wordpress/x'` re-export is not a consumer import and yields nothing. + * + * @param {string} source - ESM source text. + * @param {string} providerPkg - e.g. '@wordpress/theme'. + * @return {string[]} Sorted, de-duplicated imported symbol names. + */ +function parseNamedImports( source, providerPkg ) { + const found = new Set(); + const escaped = providerPkg.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' ); + // Optional `Default,` before the named block covers `import Def, { A } from …`. + const re = new RegExp( + `import\\s*(?:[\\w$]+\\s*,\\s*)?\\{([^}]*)\\}\\s*from\\s*['"]${ escaped }['"]`, + 'g' + ); + let match; + while ( ( match = re.exec( source ) ) !== null ) { + for ( const specifier of match[ 1 ].split( ',' ) ) { + const name = specifier + .trim() + .split( /\s+as\s+/ )[ 0 ] + .trim(); + if ( name ) { + found.add( name ); + } + } + } + return [ ...found ].sort(); +} + +/** + * Public export names from a provider's built ESM index. Handles consolidated + * `export { A, B as C }` blocks (public name is after `as`) and inline + * declarations `export const/function/class/let/var X`; flags wildcard + * `export *` as opaque so callers skip it rather than emit a false "missing + * export". (`@wordpress/*` ship esbuild-consolidated indexes today, but an + * inline-export bump must not make the check report every symbol missing.) + * + * @param {string} indexSource - Contents of the package's entry point. + * @return {{ names: string[], opaque: boolean }} Public export names + opacity flag. + */ +function parsePublicExports( indexSource ) { + const names = new Set(); + const opaque = /export\s*\*/.test( indexSource ); + + const blockRe = /export\s*\{([^}]*)\}/g; + let match; + while ( ( match = blockRe.exec( indexSource ) ) !== null ) { + for ( const specifier of match[ 1 ].split( ',' ) ) { + const trimmed = specifier.trim(); + if ( ! trimmed ) { + continue; + } + const parts = trimmed.split( /\s+as\s+/ ); + const name = parts[ parts.length - 1 ].trim(); + if ( name ) { + names.add( name ); + } + } + } + + // Inline declarations, e.g. `export const ThemeProvider = …`. + for ( const m of indexSource.matchAll( + /export\s+(?:async\s+)?(?:function|class|const|let|var)\s+([\w$]+)/g + ) ) { + names.add( m[ 1 ] ); + } + + return { names: [ ...names ].sort(), opaque }; +} + +/** + * Contract check for one (consumer → provider) pair. + * + * @param {object} args - The pair and its symbols. + * @param {string} args.consumer - Consumer package name. + * @param {string} args.provider - Provider package name. + * @param {string[]} args.imported - Symbols the consumer imports. + * @param {string[]} args.exported - Provider's public export names. + * @param {boolean} [args.opaque] - True when the provider's exports can't be enumerated. + * @return {{ ok: boolean, consumer: string, provider: string, missing: string[], skipped?: boolean }} Result. + */ +function checkContract( { consumer, provider, imported, exported, opaque = false } ) { + if ( opaque ) { + return { ok: true, consumer, provider, missing: [], skipped: true }; + } + const exportedSet = new Set( exported ); + const missing = imported.filter( name => ! exportedSet.has( name ) ); + return { ok: missing.length === 0, consumer, provider, missing }; +} + +/** + * Resolve a package's directory from a base dir (same resolution the build uses). + * + * @param {string} pkgName - e.g. '@wordpress/theme'. + * @param {string} fromDir - Directory to resolve from. + * @return {string|null} Absolute package directory, or null if unresolvable. + */ +function resolvePackageDir( pkgName, fromDir ) { + try { + return path.dirname( require.resolve( `${ pkgName }/package.json`, { paths: [ fromDir ] } ) ); + } catch { + return null; + } +} + +/** + * Concatenated ESM source of every `*.mjs` under a package's `build-module` dir. + * + * @param {string} pkgDir - Absolute package directory. + * @return {string} Concatenated source, or '' if no build-module dir. + */ +function readBuildModuleSource( pkgDir ) { + const dir = path.join( pkgDir, 'build-module' ); + if ( ! existsSync( dir ) ) { + return ''; + } + const chunks = []; + const walk = current => { + for ( const entry of readdirSync( current, { withFileTypes: true } ) ) { + const full = path.join( current, entry.name ); + if ( entry.isDirectory() ) { + walk( full ); + } else if ( entry.name.endsWith( '.mjs' ) ) { + chunks.push( readFileSync( full, 'utf8' ) ); + } + } + }; + walk( dir ); + return chunks.join( '\n' ); +} + +/** + * Read a provider's public export names from its ESM entry point. + * + * @param {string} pkgDir - Absolute package directory. + * @return {{ names: string[], opaque: boolean } | null} Exports, or null if unreadable. + */ +function readPackageExports( pkgDir ) { + const pkg = JSON.parse( readFileSync( path.join( pkgDir, 'package.json' ), 'utf8' ) ); + // Prefer the `exports` map's ESM entry (how resolution actually works), then + // fall back to `module`/`main`. `@wordpress/boot` already ships no `main`. + const dot = pkg.exports?.[ '.' ]; + const entry = ( typeof dot === 'string' ? dot : dot?.import ) ?? pkg.module ?? pkg.main; + if ( ! entry ) { + return null; + } + const entryPath = path.join( pkgDir, entry ); + return existsSync( entryPath ) ? parsePublicExports( readFileSync( entryPath, 'utf8' ) ) : null; +} + +/** + * Format an actionable error message for failed contracts. + * + * @param {object[]} failures - Failed contract results. + * @param {string[]} errors - Non-contract errors (unreadable packages). + * @return {string} Formatted message. + */ +function formatError( failures, errors ) { + const lines = []; + if ( failures.length ) { + lines.push( + 'Export-contract violation: a polyfilled package imports symbols the shipped', + 'version of another polyfilled package does not export — this resolves to', + '`undefined` at runtime (blank dashboard, no build error; the Jetpack 16.0', + 'failure mode). Bump the provider so its public API matches, keeping the', + '`@wordpress/*` set version-aligned.', + '' + ); + for ( const f of failures ) { + lines.push( + ` ${ f.consumer } imports from ${ f.provider }: [ ${ f.missing.join( + ', ' + ) } ] — not exported.` + ); + } + } + if ( errors.length ) { + lines.push( '', ...errors ); + } + return lines.join( '\n' ); +} + +/** + * Parse WP_BUILD_POLYFILLS_SIMULATE_MISSING (`pkg:Symbol,pkg:Symbol`) into a + * `{ pkg: [ symbol ] }` drop-map. This is a TEST-ONLY hook that lets the CLI's + * failure path be exercised end-to-end (see the CLI test); it is not a + * user-facing feature. + * + * @param {string|undefined} raw - Raw env value. + * @return {object} Map of provider package → symbol[] to drop. + */ +function parseSimulateEnv( raw ) { + const map = {}; + for ( const pair of ( raw || '' ).split( ',' ) ) { + const idx = pair.lastIndexOf( ':' ); + const pkg = idx === -1 ? '' : pair.slice( 0, idx ).trim(); + const symbol = idx === -1 ? '' : pair.slice( idx + 1 ).trim(); + if ( pkg && symbol ) { + ( map[ pkg ] = map[ pkg ] || [] ).push( symbol ); + } + } + return map; +} + +/** + * Validate the export contracts across the shipped package set. Reads the shipped + * versions from the polyfill's own resolution context (same as the build). + * + * @param {object} [options] - Options. + * @param {string} [options.packageRoot] - Polyfill package root. Defaults to this package. + * @param {string[]} [options.providers] - Override provider list (tests). + * @param {string[]} [options.consumers] - Override consumer list (tests). + * @param {object} [options.simulateMissing] - Map of providerPkg → symbol[] to drop, to simulate a skew (tests). + * @return {{ ok: boolean, results: object[], errors: string[], error?: string }} Aggregate result. + */ +function validateExportContracts( options = {} ) { + const packageRoot = options.packageRoot || path.join( __dirname, '..' ); + const shipped = getShippedPackages( packageRoot ); + const providers = options.providers || shipped.providers; + const consumers = options.consumers || shipped.consumers; + const simulateMissing = + options.simulateMissing || parseSimulateEnv( process.env.WP_BUILD_POLYFILLS_SIMULATE_MISSING ); + + // Never drop a package from coverage silently — a check that verifies nothing + // but stays green is the failure mode this exists to prevent. + const warn = message => { + // eslint-disable-next-line no-console + console.warn( `[export-contract] ${ message }` ); + }; + + const errors = []; + const providerExports = {}; + for ( const provider of providers ) { + const dir = resolvePackageDir( provider, packageRoot ); + if ( ! dir ) { + warn( `Provider ${ provider } is not resolvable — not verifying it.` ); + continue; + } + const exp = readPackageExports( dir ); + if ( ! exp ) { + errors.push( `Could not read exports for ${ provider }.` ); + continue; + } + if ( exp.opaque ) { + warn( + `Not verifying ${ provider }: its index uses \`export *\`, so its public ` + + 'exports can’t be enumerated statically.' + ); + } + const dropped = simulateMissing[ provider ] || []; + providerExports[ provider ] = { + names: exp.names.filter( n => ! dropped.includes( n ) ), + opaque: exp.opaque, + }; + } + + const results = []; + for ( const consumer of consumers ) { + const dir = resolvePackageDir( consumer, packageRoot ); + if ( ! dir ) { + warn( `Consumer ${ consumer } is not resolvable — its imports were not checked.` ); + continue; + } + const source = readBuildModuleSource( dir ); + if ( ! source ) { + warn( `Consumer ${ consumer } has no build-module/ — its imports were not checked.` ); + continue; + } + for ( const provider of Object.keys( providerExports ) ) { + const imported = parseNamedImports( source, provider ); + if ( imported.length ) { + results.push( + checkContract( { + consumer, + provider, + imported, + exported: providerExports[ provider ].names, + opaque: providerExports[ provider ].opaque, + } ) + ); + } + } + } + + const failures = results.filter( r => ! r.ok ); + const ok = failures.length === 0 && errors.length === 0; + return { ok, results, errors, error: ok ? undefined : formatError( failures, errors ) }; +} + +module.exports = { + handleToPackage, + parsePhpConstArray, + getShippedPackages, + parseNamedImports, + parsePublicExports, + checkContract, + validateExportContracts, +}; diff --git a/projects/packages/wp-build-polyfills/bin/validate-export-contract.js b/projects/packages/wp-build-polyfills/bin/validate-export-contract.js new file mode 100644 index 000000000000..3a385aa1f525 --- /dev/null +++ b/projects/packages/wp-build-polyfills/bin/validate-export-contract.js @@ -0,0 +1,15 @@ +#!/usr/bin/env node + +/** + * Post-build check: fail the build when a polyfilled package imports a symbol the + * shipped version of another polyfilled package does not export (the Jetpack 16.0 + * blank-dashboard failure mode). See validate-export-contract-lib.js. + */ + +const { validateExportContracts } = require( './validate-export-contract-lib.js' ); + +const result = validateExportContracts(); + +if ( ! result.ok ) { + throw new Error( result.error ); +} diff --git a/projects/packages/wp-build-polyfills/changelog/add-export-contract-validation b/projects/packages/wp-build-polyfills/changelog/add-export-contract-validation new file mode 100644 index 000000000000..cb1b6d45bbb0 --- /dev/null +++ b/projects/packages/wp-build-polyfills/changelog/add-export-contract-validation @@ -0,0 +1,4 @@ +Significance: patch +Type: added + +Add a build-time export-contract check that fails the build when a polyfilled package imports a symbol the shipped version of another polyfilled package does not export — the Jetpack 16.0 blank-dashboard failure mode. diff --git a/projects/packages/wp-build-polyfills/package.json b/projects/packages/wp-build-polyfills/package.json index fff1a5785448..5ce3c7bc161c 100644 --- a/projects/packages/wp-build-polyfills/package.json +++ b/projects/packages/wp-build-polyfills/package.json @@ -16,8 +16,9 @@ "strip-unminified-prod": "bin/strip-unminified-prod.js" }, "scripts": { - "build": "pnpm run clean && webpack && node bin/validate-boot-asset.js", + "build": "pnpm run clean && webpack && node bin/validate-boot-asset.js && node bin/validate-export-contract.js", "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build", + "check-contracts": "node bin/validate-export-contract.js", "clean": "rm -rf build/", "test": "node --test 'tests/js/**/*.test.js'", "test-coverage": "c8 --report-dir=\"$COVERAGE_DIR\" --temp-directory=\"$ARTIFACTS_DIR/v8\" pnpm run test" diff --git a/projects/packages/wp-build-polyfills/tests/js/validate-export-contract.test.js b/projects/packages/wp-build-polyfills/tests/js/validate-export-contract.test.js new file mode 100644 index 000000000000..ce376416f997 --- /dev/null +++ b/projects/packages/wp-build-polyfills/tests/js/validate-export-contract.test.js @@ -0,0 +1,126 @@ +const { execFileSync } = require( 'child_process' ); +const assert = require( 'node:assert/strict' ); +const { describe, it } = require( 'node:test' ); +const path = require( 'path' ); +const { + handleToPackage, + parsePhpConstArray, + getShippedPackages, + parseNamedImports, + parsePublicExports, + checkContract, + validateExportContracts, +} = require( '../../bin/validate-export-contract-lib.js' ); + +const packageRoot = path.join( __dirname, '..', '..' ); + +describe( 'validate-export-contract', () => { + it( 'parseNamedImports extracts imported names, honouring `as` aliases and mixed default imports', () => { + const src = + "import { ThemeProvider } from '@wordpress/theme';\nimport { privateApis as p } from '@wordpress/route';"; + assert.deepEqual( parseNamedImports( src, '@wordpress/theme' ), [ 'ThemeProvider' ] ); + assert.deepEqual( parseNamedImports( src, '@wordpress/route' ), [ 'privateApis' ] ); + // `import Def, { Bar } from …` must still yield the named symbol. + assert.deepEqual( + parseNamedImports( "import Def, { Bar } from '@wordpress/theme';", '@wordpress/theme' ), + [ 'Bar' ] + ); + } ); + + it( 'parsePublicExports reads block, inline and aliased exports, and flags `export *`', () => { + assert.deepEqual( + parsePublicExports( 'export { default2 as SnackbarNotices, store };' ).names, + [ 'SnackbarNotices', 'store' ] + ); + // Inline declarations (not just consolidated `export { … }` blocks). + assert.deepEqual( parsePublicExports( 'export const ThemeProvider = () => {};' ).names, [ + 'ThemeProvider', + ] ); + assert.equal( parsePublicExports( "export * from './x';" ).opaque, true ); + } ); + + // The Jetpack 16.0 regression: boot imports ThemeProvider, but theme 0.15.1 only + // exported it privately, so at runtime `wp.theme.ThemeProvider` was undefined. + it( 'checkContract fails when an imported symbol is not exported (the 16.0 shape)', () => { + const bad = checkContract( { + consumer: '@wordpress/boot', + provider: '@wordpress/theme', + imported: [ 'ThemeProvider' ], + exported: [ 'privateApis' ], + } ); + assert.equal( bad.ok, false ); + assert.deepEqual( bad.missing, [ 'ThemeProvider' ] ); + + const good = checkContract( { + consumer: '@wordpress/boot', + provider: '@wordpress/theme', + imported: [ 'ThemeProvider' ], + exported: [ 'ThemeProvider' ], + } ); + assert.equal( good.ok, true ); + } ); + + it( 'derives providers/consumers from the PHP source of truth, matching webpack', () => { + assert.equal( handleToPackage( 'wp-private-apis' ), '@wordpress/private-apis' ); + assert.deepEqual( parsePhpConstArray( "const X = array( 'wp-theme' );", 'X' ), [ 'wp-theme' ] ); + + const { providers, consumers } = getShippedPackages( packageRoot ); + const webpack = require( '../../webpack.config.js' ); + const built = prefix => + webpack + .filter( c => c.name && c.name.startsWith( prefix ) ) + .map( c => '@wordpress/' + c.name.replace( prefix, '' ) ) + .sort(); + assert.deepEqual( [ ...providers ].sort(), built( 'script-' ) ); + assert.deepEqual( [ ...consumers ].sort(), built( 'module-' ) ); + } ); + + describe( 'against the installed tree', () => { + it( 'passes for the actually-shipped @wordpress/* versions', () => { + const result = validateExportContracts( { packageRoot } ); + assert.equal( result.ok, true, result.error || 'unexpected contract failure' ); + // Guard against a vacuous green: the boot↔theme/notices/private-apis and + // route↔private-apis pairs must actually be scanned. + assert.ok( + result.results.length > 0, + 'no contracts were scanned — the check may be silently verifying nothing' + ); + } ); + + it( 'fails on a simulated skew (theme missing ThemeProvider)', () => { + const result = validateExportContracts( { + packageRoot, + simulateMissing: { '@wordpress/theme': [ 'ThemeProvider' ] }, + } ); + assert.equal( result.ok, false ); + assert.match( result.error, /ThemeProvider/ ); + } ); + } ); + + const cli = path.join( packageRoot, 'bin', 'validate-export-contract.js' ); + + it( 'the CLI exits 0 when contracts hold', () => { + execFileSync( process.execPath, [ cli ], { cwd: packageRoot, stdio: 'pipe' } ); + } ); + + // Covers the actual CI gate end-to-end: the CLI must exit non-zero on a violation. + // WP_BUILD_POLYFILLS_SIMULATE_MISSING is a test-only hook to inject one. + it( 'the CLI exits non-zero and reports the violation on a skew', () => { + assert.throws( + () => + execFileSync( process.execPath, [ cli ], { + cwd: packageRoot, + stdio: 'pipe', + env: { + ...process.env, + WP_BUILD_POLYFILLS_SIMULATE_MISSING: '@wordpress/theme:ThemeProvider', + }, + } ), + err => { + assert.notEqual( err.status, 0 ); + assert.match( String( err.stderr ), /ThemeProvider/ ); + return true; + } + ); + } ); +} );