From 1a1cec7c64cc414e2112d2f7f57d0db386e0bc88 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 17 Jul 2026 18:28:52 +0530 Subject: [PATCH 1/4] Tools: resolve bins via module resolution instead of node_modules/.bin paths The `docgen`, `wp-create-block` and `wp-scripts` bins were located by hardcoding a `node_modules/.bin` path, which assumes a specific install layout: hoisted installs only create the root `.bin`, isolated ones only the workspace's. Resolve each bin from its package's `bin` field instead and spawn it with `process.execPath`, which works under either layout. Also limit `shell` to the `npm` call. It is only needed for npm's Windows `.cmd` shim, and a shell would split a `process.execPath` containing spaces. --- tools/docs/update-api-docs.js | 25 +++++++++----- tools/validation/test-create-block.mjs | 47 ++++++++++++++++++++------ 2 files changed, 53 insertions(+), 19 deletions(-) diff --git a/tools/docs/update-api-docs.js b/tools/docs/update-api-docs.js index d7b162f69194b2..4438915790e142 100755 --- a/tools/docs/update-api-docs.js +++ b/tools/docs/update-api-docs.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { join, relative, resolve, sep, dirname } = require( 'path' ); +const { relative, resolve, sep, dirname } = require( 'path' ); const { Transform } = require( 'stream' ); const { readFile } = require( 'fs' ).promises; const glob = require( 'fast-glob' ); @@ -19,6 +19,20 @@ const execa = require( 'execa' ); * @typedef {[string,WPReadmeFileTokens]} WPReadmeFileData */ +/** + * Absolute path to the `docgen` bin. Resolved via module resolution rather than + * a `node_modules/.bin` path, whose location depends on the install layout. + * + * @type {string} + */ +const DOCGEN_BIN = ( () => { + const packageJsonPath = require.resolve( '@wordpress/docgen/package.json' ); + return resolve( + dirname( packageJsonPath ), + require( packageJsonPath ).bin.docgen + ); +} )(); + /** * Path to root project directory. * @@ -221,14 +235,7 @@ glob.stream( [ resolve( dirname( file ), path ) ); await execa( - join( - __dirname, - '..', - '..', - 'node_modules', - '.bin', - 'docgen' - ), + DOCGEN_BIN, [ sourcePath, '--output', diff --git a/tools/validation/test-create-block.mjs b/tools/validation/test-create-block.mjs index 0d7f2aa3aa477e..a33c4c9be1b96d 100755 --- a/tools/validation/test-create-block.mjs +++ b/tools/validation/test-create-block.mjs @@ -2,17 +2,23 @@ import { spawnSync } from 'node:child_process'; import fs from 'node:fs'; +import { createRequire } from 'node:module'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import chalk from 'chalk'; const __dirname = path.dirname( fileURLToPath( import.meta.url ) ); const ROOT = path.resolve( __dirname, '../..' ); +const require = createRequire( import.meta.url ); const ES5_BLOCK = path.join( ROOT, 'example-static-es5' ); const STATIC_BLOCK = path.join( ROOT, 'example-static' ); -// `npm` is a `.cmd` shim on Windows that only resolves through the shell. +/* + * `npm` is a `.cmd` shim on Windows that only resolves through the shell. Only + * pass this to `npm`: under a shell, a `process.execPath` containing spaces + * (e.g. `C:\Program Files\nodejs\node.exe`) would be split into two arguments. + */ const NEEDS_SHELL = process.platform === 'win32'; /** @@ -44,7 +50,6 @@ function fail( message ) { function run( command, args, options = {} ) { const result = spawnSync( command, args, { stdio: 'inherit', - shell: NEEDS_SHELL, ...options, } ); if ( result.error ) { @@ -56,14 +61,36 @@ function run( command, args, options = {} ) { } /** - * Execute a command via `npm exec` and exit on failure. + * Resolve a bin via module resolution rather than a `node_modules/.bin` path, + * whose location depends on the install layout. + * + * @param {string} pkg Package declaring the bin. + * @param {string} binName Bin entry name. + * @return {string} Absolute path to the bin script. + */ +function resolveBin( pkg, binName ) { + const packageJsonPath = require.resolve( `${ pkg }/package.json` ); + const { bin } = require( packageJsonPath ); + return path.resolve( + path.dirname( packageJsonPath ), + typeof bin === 'string' ? bin : bin[ binName ] + ); +} + +const WP_CREATE_BLOCK = resolveBin( + '@wordpress/create-block', + 'wp-create-block' +); +const WP_SCRIPTS = resolveBin( '@wordpress/scripts', 'wp-scripts' ); + +/** + * Execute `wp-create-block` and exit on failure. * - * @param {string} bin Binary to execute (must be in `devDependencies`). * @param {string[]} args Command arguments. * @param {Object} options Spawn options. */ -function npmExec( bin, args, options = {} ) { - run( 'npm', [ 'exec', '--no', '--', bin, ...args ], options ); +function wpCreateBlock( args, options = {} ) { + run( process.execPath, [ WP_CREATE_BLOCK, ...args ], options ); } /** @@ -72,7 +99,7 @@ function npmExec( bin, args, options = {} ) { * @param {...string} args Command arguments forwarded to `wp-scripts`. */ function wpScripts( ...args ) { - npmExec( 'wp-scripts', args, { cwd: STATIC_BLOCK } ); + run( process.execPath, [ WP_SCRIPTS, ...args ], { cwd: STATIC_BLOCK } ); } /** @@ -135,7 +162,7 @@ process.on( 'SIGTERM', () => process.exit( 1 ) ); // First test block. status( 'Scaffolding Example Static (ES5) block...' ); -npmExec( 'wp-create-block', [ 'example-static-es5', '-t', 'es5' ], { +wpCreateBlock( [ 'example-static-es5', '-t', 'es5' ], { cwd: ROOT, } ); @@ -144,7 +171,7 @@ expectFileCount( ES5_BLOCK, 'the project root', 1, 8 ); // Second test block. status( 'Scaffolding Example Static block...' ); -npmExec( 'wp-create-block', [ 'example-static', '--no-wp-scripts' ], { +wpCreateBlock( [ 'example-static', '--no-wp-scripts' ], { cwd: ROOT, } ); @@ -199,7 +226,7 @@ wpScripts( 'lint-style' ); // Ensure monorepo prelint:js scripts have run (e.g., build design tokens for ESLint). status( 'Running prelint:js...' ); -run( 'npm', [ 'run', 'prelint:js' ], { cwd: ROOT } ); +run( 'npm', [ 'run', 'prelint:js' ], { cwd: ROOT, shell: NEEDS_SHELL } ); status( 'Linting JavaScript files...' ); wpScripts( 'lint-js' ); From 5ace6165771fc395cd19de8fa4eab556e589c666 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 17 Jul 2026 18:33:14 +0530 Subject: [PATCH 2/4] ESLint Plugin: resolve the TypeScript import resolver explicitly eslint-plugin-import looks up a bare `typescript` resolver key by name, which relies on it being reachable from wherever ESLint happens to run. Resolve `eslint-import-resolver-typescript` to an absolute path from the config that declares it instead, so it is found under any install layout. --- .../eslint-plugin/configs/recommended-with-formatting.js | 4 +++- packages/eslint-plugin/configs/recommended.js | 7 ++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/eslint-plugin/configs/recommended-with-formatting.js b/packages/eslint-plugin/configs/recommended-with-formatting.js index 50565dd1cafb41..725020a12aa98c 100644 --- a/packages/eslint-plugin/configs/recommended-with-formatting.js +++ b/packages/eslint-plugin/configs/recommended-with-formatting.js @@ -36,7 +36,9 @@ module.exports = [ settings: { 'import/extensions': [ '.js', '.jsx' ], 'import/resolver': { - typescript: true, + [ require.resolve( + 'eslint-import-resolver-typescript' + ) ]: true, }, }, rules: { diff --git a/packages/eslint-plugin/configs/recommended.js b/packages/eslint-plugin/configs/recommended.js index 66e5507f3b0b18..85e469d3d693b1 100644 --- a/packages/eslint-plugin/configs/recommended.js +++ b/packages/eslint-plugin/configs/recommended.js @@ -45,9 +45,10 @@ if ( isPackageInstalled( 'typescript' ) ) { { settings: { 'import/resolver': { - typescript: { - extensions: [ '.js', '.jsx', '.ts', '.tsx' ], - }, + [ require.resolve( 'eslint-import-resolver-typescript' ) ]: + { + extensions: [ '.js', '.jsx', '.ts', '.tsx' ], + }, }, }, ignores: [ '**/*.d.ts' ], From dfc60bf0e5187e7866fffbcdb4ccdb1c1614bc74 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 17 Jul 2026 18:40:28 +0530 Subject: [PATCH 3/4] Storybook: resolve packages via module resolution Addon, framework and Emotion package names were passed to Storybook and Vite as bare specifiers, relying on them being reachable from wherever the builder happens to resolve from. Resolve them from this config's own location instead, following Storybook's documented guidance, so they are found under any install layout. --- storybook/main.ts | 24 ++++++++++++++++++------ 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/storybook/main.ts b/storybook/main.ts index d1611e17afd78f..a322c397ed63b8 100644 --- a/storybook/main.ts +++ b/storybook/main.ts @@ -1,4 +1,5 @@ import path from 'node:path'; +import { fileURLToPath } from 'node:url'; import { type InlineConfig, type PluginOption, @@ -10,6 +11,17 @@ import type { StorybookConfig } from '@storybook/react-vite'; import dsTokenFallbacks from '@wordpress/theme/postcss-plugins/postcss-ds-token-fallbacks'; import dsTokenFallbacksJs from '@wordpress/theme/vite-plugins/vite-ds-token-fallbacks'; +/** + * @see https://storybook.js.org/docs/faq#how-do-i-fix-module-resolution-in-special-environments + */ +function getAbsolutePath( packageName: string ) { + return path.dirname( + fileURLToPath( + import.meta.resolve( path.join( packageName, 'package.json' ) ) + ) + ); +} + const { NODE_ENV = 'development' } = process.env; const stories = [ @@ -47,15 +59,15 @@ const config: StorybookConfig = { staticDirs: [ './static' ], addons: [ { - name: '@storybook/addon-docs', + name: getAbsolutePath( '@storybook/addon-docs' ), options: { configureJSX: true }, }, - '@storybook/addon-a11y', + getAbsolutePath( '@storybook/addon-a11y' ), import.meta.resolve( './addons/source-link/preset.ts' ), - 'storybook-addon-tag-badges', + getAbsolutePath( 'storybook-addon-tag-badges' ), import.meta.resolve( './addons/design-system-theme/preset.ts' ), ], - framework: '@storybook/react-vite', + framework: getAbsolutePath( '@storybook/react-vite' ), features: { componentsManifest: NODE_ENV !== 'development', // Use experimental TypeScript LanguageService prop extractor for the @@ -115,9 +127,9 @@ const config: StorybookConfig = { plugins: [ dsTokenFallbacksJs(), react( { - jsxImportSource: '@emotion/react', + jsxImportSource: getAbsolutePath( '@emotion/react' ), babel: { - plugins: [ '@emotion/babel-plugin' ], + plugins: [ getAbsolutePath( '@emotion/babel-plugin' ) ], }, } ) as PluginOption, { From 04f2b2cdd7debf694fba651d0dd6fe4db77dfdb2 Mon Sep 17 00:00:00 2001 From: Manzoor Wani Date: Fri, 17 Jul 2026 18:45:30 +0530 Subject: [PATCH 4/4] Jest: resolve the flakiness reporter explicitly Jest resolves bare custom reporter names relative to `rootDir`, which relies on the reporter being reachable from there. Resolve it from the config that declares it instead, so it is found under any install layout. --- test/unit/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/unit/jest.config.js b/test/unit/jest.config.js index e9d2abbdf1e871..6ef9a151a6d239 100644 --- a/test/unit/jest.config.js +++ b/test/unit/jest.config.js @@ -100,7 +100,7 @@ module.exports = { 'packages/scripts/config/jest-github-actions-reporter/index.js', process.env.CI ? [ - '@flakiness/jest', + require.resolve( '@flakiness/jest' ), { flakinessProject: 'WordPress/gutenberg', duplicates: 'rename',