-
Notifications
You must be signed in to change notification settings - Fork 891
wp-build-polyfills: add export-contract build check to catch version-skew #50676
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
a4f6bb5
wp-build-polyfills: add export-contract build check to catch version-…
CGastrell adbd220
wp-build-polyfills: fix scripts sort order and raise export-contract …
CGastrell 3c4b3cc
wp-build-polyfills: derive validator package lists from the PHP sourc…
CGastrell d9b1a61
wp-build-polyfills: slim the export-contract check per review
CGastrell 9806dbc
wp-build-polyfills: address export-contract review feedback
CGastrell 7277185
wp-build-polyfills: harden the export-contract check per review
CGastrell File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
337 changes: 337 additions & 0 deletions
337
projects/packages/wp-build-polyfills/bin/validate-export-contract-lib.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,337 @@ | ||
| /* 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). | ||
| * | ||
| * @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 `export { A, B as C }` | ||
| * (public name is after `as`); flags wildcard `export *` as opaque so callers skip it | ||
| * rather than emit a false "missing export". | ||
| * | ||
| * @param {string} indexSource - Contents of the package's `module`/`main` entry. | ||
| * @return {{ names: string[], opaque: boolean }} Public export names + opacity flag. | ||
| */ | ||
| function parsePublicExports( indexSource ) { | ||
| const names = new Set(); | ||
| const opaque = /export\s*\*/.test( indexSource ); | ||
| const re = /export\s*\{([^}]*)\}/g; | ||
| let match; | ||
| while ( ( match = re.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 ); | ||
| } | ||
| } | ||
| } | ||
| 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' ) ); | ||
| const entry = 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 ); | ||
|
|
||
| const errors = []; | ||
| const providerExports = {}; | ||
| for ( const provider of providers ) { | ||
| const dir = resolvePackageDir( provider, packageRoot ); | ||
| if ( ! dir ) { | ||
| continue; // Not installed → not shipped. | ||
| } | ||
| const exp = readPackageExports( dir ); | ||
| if ( ! exp ) { | ||
| errors.push( `Could not read exports for ${ provider }.` ); | ||
| continue; | ||
| } | ||
| if ( exp.opaque ) { | ||
| // A barrel (`export *`) can't be statically enumerated, so we can't verify | ||
| // this provider — warn loudly rather than skip it silently, which would be | ||
| // a hole in exactly the protection this check exists for. | ||
| // eslint-disable-next-line no-console | ||
| console.warn( | ||
| `[export-contract] 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 ) { | ||
| continue; | ||
| } | ||
| const source = readBuildModuleSource( dir ); | ||
| if ( ! source ) { | ||
| 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, | ||
| }; |
15 changes: 15 additions & 0 deletions
15
projects/packages/wp-build-polyfills/bin/validate-export-contract.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ); | ||
| } |
4 changes: 4 additions & 0 deletions
4
projects/packages/wp-build-polyfills/changelog/add-export-contract-validation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.