Commit 7ab5850e85 for woocommerce

commit 7ab5850e85c95596a5a2d5266557cdd9a9534e55
Author: Raluca Stan <ralucastn@gmail.com>
Date:   Mon Jan 12 16:54:46 2026 +0200

    Detect and warn plugins that access blocks dependencies without declaring dependencies (#62229)

    * Update local pickup selector to immediately reflect selection in client

    * Add changelog

    * Update props of local pickup Slot

    * Update tests to check selectedInClient parameter

    * Fix lint error

    * Make isSelectedInClient an optional param

    * Pass selected option to callback to allow callback decide what to do

    * Dependency injection

    * Remove WC_DEBUG_DEPENDENCIES and checks for additional dependencies

    * Improve detection of async calls

    * Revert "Pass selected option to callback to allow callback decide what to do"

    This reverts commit 25ca5e7e55db594df206f47c48b163a2a6160774.

    * Revert "Add changelog"

    This reverts commit dfbd239f74e380fa981be2d86108ef50da6e9bb7.

    * Revert "Update props of local pickup Slot"

    This reverts commit ee4a987db10fb532e53350e50557ac064b2a0989.

    * Revert "Update tests to check selectedInClient parameter"

    This reverts commit 36d625d9ed96c2c16932fa9db536242e5b41d803.

    * Revert "Make isSelectedInClient an optional param"

    This reverts commit 7d870a3eb7c97f99aec01bc948f0758ad0d5e7bb.

    * Revert "Pass selected option to callback to allow callback decide what to do"

    This reverts commit 25ca5e7e55db594df206f47c48b163a2a6160774.

    * Revert commits from intial branch

    * Revert changes from initial branch

    * Add a separate JS file for front-end detection logic

    * Add all scripts that are exposed on the window.wc object

    * Clean up code:
    Remove the registering logic for the initially used JS
    Rename the file for the current JS logic file
    Remove unused AssetApi class usage

    * Align WooCommerce URL detection

    * Make PHP is the source of truth for the global-to-handle mapping

    * Remove proxy set trap and don't use Reflect.set

    * Rename and spacing

    * Only run logic on pages with the checkout blocks

    * Improve proxy creation and registry output
    Now hooks directly to wp_head and wp_print_footer_scripts
    Avoid registry output if proxy didn't work

    * Comment logic for script warnings

    * clean js detection:
    - remove proxyEnabled, we keep it on the php side
    - rename for more clarity

    * Remove WordPress internal scripts from the registry
    As we output this registry in the front-end we don't need all the registered scripts from WordPress, only the ones coming from plugins

    * When we can't identify the calling script from the stack trace, it will warn immediately as an "inline or unknown script" without waiting for or checking the registry.

    * improve logic to detect caller url in stack error

    * Improve detection to prevent recursice proxy get calls
    the wc globals call inside them other exposed wc packages because the webpack externals configuration maps all @woocommerce/* imports to window.wc.*.
    example: priceFormat imports @woocommerce/settings which becomes window.wc.wcSettings at runtime, triggering our proxy.

    * Run dependency detection only when debugging is enabled

    * Simplify recursion prevention in dependency detection
    Instead of parsing stack traces to identify recursive calls, we prevent them from running at all.
    The isChecking flag blocks nested proxy calls while a dependency check is in progress, eliminating the need to
    detect recursion by analyzing stack traces.

    * Changelog

    * fix PHPStan warnings

    * clean comments

    * PhPStan baseline file

    * Add tests for the dependency js file
    This implied exporting parts of the js and using webpack to build the file
    PHP now reads from the build folder

    * Add PHP tests

    * Fix lint issues

    * Fix linting

    * use wp_parse_url to normalize links

    * keep WC_GLOBAL_EXPORTS variable name when minifying

    * Add ventor to JS pattern

    * Follow unit test guidelines

    * Ensure the proxy guard is reset in case caller detection fails

    * Move the registry function on the wc global to avoid conflicts

    * Use template literals instead of string concatenation

    * Update test file

    * Follow guidelines in test file

    * Convert dependency files to TS

    * Improve getFilename

    * Handle malformed registry:
    Registry entry with missing deps property
    Registry entry with non-array deps
    Registry itself being null/undefined/not an object

    * Ensure stack is a string and handle gracefully

    * Ensure currentScript.src is a string

    * Ensure line is a string

    * fix webpack for dependecy warning assets

    * Improve comments on types and shouldSkipLine goal

    * Add different stack format handling

    * Document that exposed function is internal

    * Account for the plugins directory not being /plugins

    * Improve substring checks and account for malformed src

    * Ensure the detection js file is the blocks folder, and not in the build
    so that it's not ignored on live envs

    * Handle wp_json_encode() failure for registry

    * Add @since 10.5.0 annotations to public methods

    * Add null checks and tests

    * lint fix

    ---------

    Co-authored-by: Thomas Roberts <5656702+opr@users.noreply.github.com>

diff --git a/plugins/woocommerce/changelog/dependency-detection b/plugins/woocommerce/changelog/dependency-detection
new file mode 100644
index 0000000000..2144e313ba
--- /dev/null
+++ b/plugins/woocommerce/changelog/dependency-detection
@@ -0,0 +1,4 @@
+Significance: minor
+Type: dev
+
+Add mechanism to detect scripts not declaring blocks dependencies
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts
new file mode 100644
index 0000000000..e31efc088e
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/index.ts
@@ -0,0 +1,214 @@
+/**
+ * WooCommerce Dependency Detection - Entry Point
+ *
+ * This file is the entry point for the webpack build that creates
+ * the inline detection script. It imports utils and wraps them in
+ * the IIFE that PHP outputs to the page.
+ */
+
+/**
+ * Internal dependencies
+ */
+import {
+	isWooCommerceScript,
+	getFilename,
+	parseStackForCallerUrl,
+	getWarningInfo,
+	createWcProxy,
+	type ScriptRegistry,
+	type WcGlobalExportsMap,
+	type WcGlobalKey,
+	type WcDependencyHandle,
+} from './utils';
+
+declare global {
+	// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
+	var __WC_GLOBAL_EXPORTS_PLACEHOLDER__: WcGlobalExportsMap;
+	// eslint-disable-next-line no-var, @typescript-eslint/naming-convention
+	var __WC_PLUGIN_URL_PLACEHOLDER__: string;
+}
+
+/**
+ * Pending check stored when registry isn't loaded yet.
+ */
+interface PendingCheck {
+	callerUrl: string;
+	wcGlobalKey: WcGlobalKey;
+	requiredDependencyHandle: WcDependencyHandle;
+}
+
+( function () {
+	// Set up a placeholder that will be replaced with the real proxy later.
+	// This ensures we capture window.wc before any WC scripts set it.
+	let originalWc: Record< string, unknown > = window.wc || {};
+	let scriptRegistry: ScriptRegistry = {};
+	let registryLoaded = false;
+	const warnedScripts: Record< string, boolean > = {};
+	let pendingChecks: PendingCheck[] = []; // Queue checks until registry is loaded
+
+	// Maps window.wc.* property names to their required script handles.
+	// Injected by PHP from DependencyDetection::WC_GLOBAL_EXPORTS (source of truth).
+	// eslint-disable-next-line no-undef
+	const WC_GLOBAL_EXPORTS: WcGlobalExportsMap =
+		__WC_GLOBAL_EXPORTS_PLACEHOLDER__;
+
+	// WooCommerce plugin URL, injected by PHP to account for custom plugin directories.
+	// eslint-disable-next-line no-undef
+	const WC_PLUGIN_URL: string = __WC_PLUGIN_URL_PLACEHOLDER__;
+	/**
+	 * Get the URL of the script that called this function.
+	 *
+	 * @return The caller script URL or null.
+	 */
+	function getCallerScriptUrl(): string | null {
+		const src = ( document.currentScript as HTMLScriptElement | null )?.src;
+		if ( src && typeof src === 'string' ) {
+			return src.replace( /\?.*$/, '' );
+		}
+
+		// Fallback for scenarios when currentScript isn't available
+		const stack = new Error().stack;
+		return parseStackForCallerUrl(
+			stack ?? null,
+			window.location.pathname
+		);
+	}
+
+	/**
+	 * Perform the actual dependency check and warn if missing.
+	 *
+	 * @param callerUrl                - The URL of the calling script.
+	 * @param wcGlobalKey              - The property being accessed.
+	 * @param requiredDependencyHandle - The required dependency handle.
+	 */
+	function warnIfMissingDependency(
+		callerUrl: string | null,
+		wcGlobalKey: WcGlobalKey,
+		requiredDependencyHandle: WcDependencyHandle
+	): void {
+		const warningKey = ( callerUrl || 'inline' ) + ':' + wcGlobalKey;
+
+		// Don't warn twice for the same script + property combination.
+		if ( warnedScripts[ warningKey ] ) {
+			return;
+		}
+
+		const warning = getWarningInfo(
+			callerUrl,
+			wcGlobalKey,
+			requiredDependencyHandle,
+			scriptRegistry,
+			getFilename
+		);
+
+		if ( warning ) {
+			// eslint-disable-next-line no-console
+			console.warn( warning.message );
+			warnedScripts[ warningKey ] = true;
+		}
+	}
+
+	/**
+	 * Check if a script has declared the required dependency.
+	 *
+	 * @param callerUrl                - The URL of the calling script.
+	 * @param wcGlobalKey              - The property being accessed (e.g., 'blocksCheckout').
+	 * @param requiredDependencyHandle - The required dependency handle.
+	 */
+	function checkDependency(
+		callerUrl: string | null,
+		wcGlobalKey: WcGlobalKey,
+		requiredDependencyHandle: WcDependencyHandle
+	): void {
+		// For null/unknown callerUrl, warn immediately - no registry needed.
+		// We already know it's an inline or unknown script.
+		if ( ! callerUrl ) {
+			warnIfMissingDependency(
+				callerUrl,
+				wcGlobalKey,
+				requiredDependencyHandle
+			);
+			return;
+		}
+
+		// Skip WooCommerce's own scripts - they manage their own dependencies.
+		if ( isWooCommerceScript( callerUrl, WC_PLUGIN_URL ) ) {
+			return;
+		}
+
+		// If registry not loaded yet, queue the check for later.
+		if ( ! registryLoaded ) {
+			pendingChecks.push( {
+				callerUrl,
+				wcGlobalKey,
+				requiredDependencyHandle,
+			} );
+			return;
+		}
+
+		warnIfMissingDependency(
+			callerUrl,
+			wcGlobalKey,
+			requiredDependencyHandle
+		);
+	}
+
+	// Create the proxy using the utility function.
+	let wcProxy = createWcProxy(
+		originalWc,
+		WC_GLOBAL_EXPORTS,
+		getCallerScriptUrl,
+		checkDependency
+	);
+
+	// Define window.wc as a getter/setter to maintain the proxy.
+	Object.defineProperty( window, 'wc', {
+		get() {
+			return wcProxy;
+		},
+		set( newValue: Record< string, unknown > ) {
+			// When WC scripts set window.wc, wrap the new value.
+			// Handle null/undefined to prevent Proxy TypeError.
+			originalWc = newValue || {};
+			wcProxy = createWcProxy(
+				originalWc,
+				WC_GLOBAL_EXPORTS,
+				getCallerScriptUrl,
+				checkDependency
+			);
+		},
+		configurable: true,
+		enumerable: true,
+	} );
+
+	/**
+	 * Update the script registry. Called by WooCommerce PHP to provide
+	 * registered script data for dependency checking.
+	 *
+	 * Not for external use. Calling this will overwrite the registry
+	 * provided by WooCommerce.
+	 *
+	 * @internal
+	 */
+	( window.wc as Record< string, unknown > ).wcUpdateDependencyRegistry =
+		function ( registry: ScriptRegistry ): void {
+			scriptRegistry = registry || {};
+			registryLoaded = true;
+
+			// Process any pending checks now that we have the registry.
+			for ( let i = 0; i < pendingChecks.length; i++ ) {
+				const check = pendingChecks[ i ];
+				warnIfMissingDependency(
+					check.callerUrl,
+					check.wcGlobalKey,
+					check.requiredDependencyHandle
+				);
+			}
+			pendingChecks = [];
+		};
+
+	// eslint-disable-next-line no-console
+	console.info(
+		'[WooCommerce] Dependency detection enabled. Warnings will be shown for scripts that access wc.* globals without proper dependencies.'
+	);
+} )();
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts
new file mode 100644
index 0000000000..a7f86753f8
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/test/utils.test.ts
@@ -0,0 +1,708 @@
+/**
+ * Internal dependencies
+ */
+import {
+	isWooCommerceScript,
+	getFilename,
+	shouldSkipLine,
+	detectStackFormat,
+	extractJsUrl,
+	extractJsUrlV8,
+	extractJsUrlSpiderMonkey,
+	parseStackForCallerUrl,
+	getWarningInfo,
+	createWcProxy,
+	type ScriptRegistry,
+	type WcGlobalExportsMap,
+} from '../utils';
+
+describe( 'Dependency Detection Utils', () => {
+	describe( 'isWooCommerceScript', () => {
+		const wcPluginUrl =
+			'https://example.com/wp-content/plugins/woocommerce/';
+
+		it( 'returns true for WooCommerce core scripts', () => {
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/client/blocks/index.js',
+					wcPluginUrl
+				)
+			).toBe( true );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/assets/js/frontend.js',
+					wcPluginUrl
+				)
+			).toBe( true );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/build/bundle.js',
+					wcPluginUrl
+				)
+			).toBe( true );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/vendor/some-lib.js',
+					wcPluginUrl
+				)
+			).toBe( true );
+		} );
+
+		it( 'returns false for WooCommerce extensions', () => {
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce-subscriptions/assets/js/index.js',
+					wcPluginUrl
+				)
+			).toBe( false );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce-payments/build/index.js',
+					wcPluginUrl
+				)
+			).toBe( false );
+		} );
+
+		it( 'returns false for empty or null URLs', () => {
+			expect( isWooCommerceScript( '', wcPluginUrl ) ).toBe( false );
+			expect( isWooCommerceScript( null, wcPluginUrl ) ).toBe( false );
+		} );
+
+		it( 'falls back to hardcoded pattern when wcPluginUrl is empty', () => {
+			// Standard path should match fallback pattern
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/client/blocks/index.js',
+					''
+				)
+			).toBe( true );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/assets/js/frontend.js',
+					''
+				)
+			).toBe( true );
+			// Extensions should not match
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce-subscriptions/assets/js/index.js',
+					''
+				)
+			).toBe( false );
+			// Custom paths won't work with fallback (expected limitation)
+			expect(
+				isWooCommerceScript(
+					'https://example.com/app/extensions/woocommerce/client/blocks/index.js',
+					''
+				)
+			).toBe( false );
+		} );
+
+		it( 'works with custom plugin directories', () => {
+			const customPluginUrl =
+				'https://example.com/app/extensions/woocommerce/';
+
+			expect(
+				isWooCommerceScript(
+					'https://example.com/app/extensions/woocommerce/client/blocks/index.js',
+					customPluginUrl
+				)
+			).toBe( true );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/app/extensions/woocommerce/assets/js/frontend.js',
+					customPluginUrl
+				)
+			).toBe( true );
+			// Other plugins in custom directory should not match
+			expect(
+				isWooCommerceScript(
+					'https://example.com/app/extensions/woocommerce-subscriptions/assets/js/index.js',
+					customPluginUrl
+				)
+			).toBe( false );
+		} );
+
+		it( 'returns false for scripts in non-asset directories', () => {
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/includes/some-file.js',
+					wcPluginUrl
+				)
+			).toBe( false );
+			expect(
+				isWooCommerceScript(
+					'https://example.com/wp-content/plugins/woocommerce/readme.js',
+					wcPluginUrl
+				)
+			).toBe( false );
+		} );
+	} );
+
+	describe( 'getFilename', () => {
+		it( 'extracts filename from URL', () => {
+			expect(
+				getFilename( 'https://example.com/path/to/script.js' )
+			).toBe( 'script.js' );
+		} );
+
+		it( 'removes query strings', () => {
+			expect(
+				getFilename( 'https://example.com/path/to/script.js?ver=1.0.0' )
+			).toBe( 'script.js' );
+		} );
+
+		it( 'removes hash fragments', () => {
+			expect(
+				getFilename( 'https://example.com/path/to/script.js#section' )
+			).toBe( 'script.js' );
+		} );
+
+		it( 'returns unknown for empty or null URLs', () => {
+			expect( getFilename( '' ) ).toBe( 'unknown' );
+			expect( getFilename( null ) ).toBe( 'unknown' );
+		} );
+
+		it( 'returns unknown for URL with trailing slash and no filename', () => {
+			expect( getFilename( 'https://example.com/' ) ).toBe( 'unknown' );
+			expect( getFilename( '/' ) ).toBe( 'unknown' );
+		} );
+	} );
+
+	describe( 'shouldSkipLine', () => {
+		it( 'skips lines from current page', () => {
+			// Stack trace format: path appears after opening paren, followed by colon and line number
+			expect(
+				shouldSkipLine( '    at someFunc (/cart/:123:45)', '/cart/' )
+			).toBe( true );
+			expect(
+				shouldSkipLine(
+					'    at someFunc (/checkout/:123:45)',
+					'/checkout/'
+				)
+			).toBe( true );
+		} );
+
+		it( 'skips webpack source-mapped files', () => {
+			expect(
+				shouldSkipLine(
+					'    at someFunc (webpack://woocommerce/src/index.js:10:5)',
+					'/cart/'
+				)
+			).toBe( true );
+		} );
+
+		it( 'does not skip external script URLs', () => {
+			expect(
+				shouldSkipLine(
+					'    at someFunc (https://example.com/script.js:10:5)',
+					'/cart/'
+				)
+			).toBe( false );
+		} );
+	} );
+
+	describe( 'detectStackFormat', () => {
+		it( 'detects V8 format (Chrome/Edge/Node)', () => {
+			const v8Stack = `Error
+    at getCallerScriptUrl (checkout/:7:3437)
+    at Object.c [as get] (checkout/:7:2171)
+    at bad-extension.js?ver=1.0.0:31:30`;
+
+			expect( detectStackFormat( v8Stack ) ).toBe( 'v8' );
+		} );
+
+		it( 'detects SpiderMonkey format (Firefox/Safari)', () => {
+			const spiderMonkeyStack = `s@https://store.local/checkout/:7:3437
+c@https://store.local/checkout/:7:2171
+@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:31:7`;
+
+			expect( detectStackFormat( spiderMonkeyStack ) ).toBe(
+				'spidermonkey'
+			);
+		} );
+
+		it( 'returns v8 as default for empty or invalid input', () => {
+			expect( detectStackFormat( '' ) ).toBe( 'v8' );
+			expect( detectStackFormat( null as unknown as string ) ).toBe(
+				'v8'
+			);
+		} );
+	} );
+
+	describe( 'extractJsUrlV8', () => {
+		it( 'extracts full URL with protocol', () => {
+			expect(
+				extractJsUrlV8(
+					'    at someFunc (https://example.com/script.js:10:5)'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'extracts bare filename without protocol', () => {
+			expect( extractJsUrlV8( '    at bad-extension.js:31:30' ) ).toBe(
+				null
+			);
+			// Bare filename needs to be in parentheses for V8 format
+			expect(
+				extractJsUrlV8( '    at someFunc (bad-extension.js:31:30)' )
+			).toBe( 'bad-extension.js' );
+		} );
+
+		it( 'extracts URL with query string', () => {
+			expect(
+				extractJsUrlV8(
+					'    at (https://example.com/script.js?ver=1.0.0:10:5)'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'returns null for non-.js files', () => {
+			expect( extractJsUrlV8( '    at someFunc (cart/:123:45)' ) ).toBe(
+				null
+			);
+		} );
+	} );
+
+	describe( 'extractJsUrlSpiderMonkey', () => {
+		it( 'extracts URL after @ symbol', () => {
+			expect(
+				extractJsUrlSpiderMonkey(
+					'@https://store.local/wp-content/plugins/test/bad-extension.js?ver=1.0.0:31:7'
+				)
+			).toBe(
+				'https://store.local/wp-content/plugins/test/bad-extension.js'
+			);
+		} );
+
+		it( 'extracts URL with function name prefix', () => {
+			expect(
+				extractJsUrlSpiderMonkey(
+					'someFunc@https://example.com/script.js:10:5'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'returns null for V8 format lines', () => {
+			expect(
+				extractJsUrlSpiderMonkey(
+					'    at someFunc (https://example.com/script.js:10:5)'
+				)
+			).toBe( null );
+		} );
+
+		it( 'returns null for non-.js files', () => {
+			expect(
+				extractJsUrlSpiderMonkey(
+					's@https://store.local/checkout/:7:3437'
+				)
+			).toBe( null );
+		} );
+	} );
+
+	describe( 'extractJsUrl', () => {
+		it( 'extracts V8 format URL with explicit format', () => {
+			expect(
+				extractJsUrl(
+					'    at someFunc (https://example.com/script.js:10:5)',
+					'v8'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'extracts SpiderMonkey format URL with explicit format', () => {
+			expect(
+				extractJsUrl(
+					'someFunc@https://example.com/script.js:10:5',
+					'spidermonkey'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'defaults to V8 format when no format specified', () => {
+			expect(
+				extractJsUrl(
+					'    at someFunc (https://example.com/script.js:10:5)'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'extracts URL with query string', () => {
+			expect(
+				extractJsUrl(
+					'    at someFunc (https://example.com/script.js?ver=1.0:10:5)',
+					'v8'
+				)
+			).toBe( 'https://example.com/script.js' );
+			expect(
+				extractJsUrl(
+					'@https://example.com/script.js?ver=1.0:10:5',
+					'spidermonkey'
+				)
+			).toBe( 'https://example.com/script.js' );
+		} );
+
+		it( 'returns null for lines without .js URLs', () => {
+			expect(
+				extractJsUrl( '    at someFunc (cart/:123:45)', 'v8' )
+			).toBe( null );
+			expect( extractJsUrl( 'Error: test error', 'v8' ) ).toBe( null );
+			expect(
+				extractJsUrl(
+					's@https://store.local/checkout/:7:3437',
+					'spidermonkey'
+				)
+			).toBe( null );
+		} );
+
+		it( 'handles http URLs', () => {
+			expect(
+				extractJsUrl(
+					'    at someFunc (http://localhost/script.js:10:5)',
+					'v8'
+				)
+			).toBe( 'http://localhost/script.js' );
+			expect(
+				extractJsUrl(
+					'someFunc@http://localhost/script.js:10:5',
+					'spidermonkey'
+				)
+			).toBe( 'http://localhost/script.js' );
+		} );
+
+		it( 'returns null for non-string input', () => {
+			expect( extractJsUrl( 123 as unknown as string, 'v8' ) ).toBe(
+				null
+			);
+			expect( extractJsUrl( null as unknown as string, 'v8' ) ).toBe(
+				null
+			);
+			expect( extractJsUrl( {} as unknown as string, 'v8' ) ).toBe(
+				null
+			);
+		} );
+	} );
+
+	describe( 'parseStackForCallerUrl', () => {
+		it( 'returns null for empty stack', () => {
+			expect( parseStackForCallerUrl( null, '/cart/' ) ).toBe( null );
+			expect( parseStackForCallerUrl( '', '/cart/' ) ).toBe( null );
+		} );
+
+		it( 'returns null for non-string stack', () => {
+			expect(
+				parseStackForCallerUrl( 123 as unknown as string, '/cart/' )
+			).toBe( null );
+			expect(
+				parseStackForCallerUrl( {} as unknown as string, '/cart/' )
+			).toBe( null );
+		} );
+
+		it( 'finds external script URL in stack trace', () => {
+			const stack = `Error
+    at getCallerScriptUrl (cart/:141:17)
+    at Object.__wcProxyGet [as get] (cart/:286:23)
+    at getBlocksConfiguration (https://example.com/wp-content/plugins/my-plugin/utils.js:10:31)
+    at canMakePayment (https://example.com/wp-content/plugins/my-plugin/index.js:99:32)`;
+
+			expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+				'https://example.com/wp-content/plugins/my-plugin/utils.js'
+			);
+		} );
+
+		it( 'returns null when no external URL found', () => {
+			const stack = `Error
+    at getCallerScriptUrl (cart/:141:17)
+    at Object.__wcProxyGet [as get] (cart/:286:23)`;
+
+			expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe( null );
+		} );
+
+		it( 'handles real-world V8 stack trace with bare filenames', () => {
+			const stack = `Error
+    at getCallerScriptUrl (cart/:141:17)
+    at Object.__wcProxyGet [as get] (cart/:286:23)
+    at getBlocksConfiguration (utils.js:10:31)
+    at canMakePayment (index.js:99:32)
+    at ExpressPaymentMethodConfig.<anonymous> (payment-method-config-helper.ts:30:41)
+    at checkPaymentMethodsCanPay (check-payment-methods.ts:237:21)
+    at async actions.ts:189:29
+    at async updatePaymentMethods (update-payment-methods.ts:24:2)
+    at async index.ts:126:28`;
+
+			// V8 format can include bare filenames without protocol.
+			// First .js file after skipping cart/ lines should be found.
+			expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+				'utils.js'
+			);
+		} );
+
+		it( 'handles stack with versioned script URLs', () => {
+			const stack = `Error
+    at getCallerScriptUrl (cart/:141:17)
+    at Object.__wcProxyGet [as get] (cart/:286:23)
+    at C (https://example.com/wp-content/plugins/extension/index.js?ver=7d1eee3294e4247830b6:19:2191)`;
+
+			expect( parseStackForCallerUrl( stack, '/cart/' ) ).toBe(
+				'https://example.com/wp-content/plugins/extension/index.js'
+			);
+		} );
+
+		it( 'handles SpiderMonkey format stack trace (Firefox/Safari)', () => {
+			const stack = `s@https://store.local/checkout/:7:3437
+c@https://store.local/checkout/:7:2171
+@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:31:7
+setTimeout handler*@https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js?ver=1.0.0:29:11`;
+
+			expect( parseStackForCallerUrl( stack, '/checkout/' ) ).toBe(
+				'https://store.local/wp-content/plugins/wc-dependency-test/bad-extension.js'
+			);
+		} );
+	} );
+
+	describe( 'getWarningInfo', () => {
+		const mockRegistry: ScriptRegistry = {
+			'https://example.com/registered-with-dep.js': {
+				handle: 'my-script-with-dep',
+				deps: [ 'wc-blocks-checkout' ],
+			},
+			'https://example.com/registered-without-dep.js': {
+				handle: 'my-script-without-dep',
+				deps: [],
+			},
+		};
+
+		it( 'returns inline warning for null callerUrl', () => {
+			const result = getWarningInfo(
+				null,
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				mockRegistry
+			);
+
+			expect( result?.type ).toBe( 'inline' );
+			expect( result?.message ).toBe(
+				'[WooCommerce] An inline or unknown script accessed wc.blocksCheckout without proper dependency declaration. This script should declare "wc-blocks-checkout" as a dependency.'
+			);
+		} );
+
+		it( 'returns unregistered warning for unknown script URL', () => {
+			const result = getWarningInfo(
+				'https://example.com/unregistered.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				mockRegistry
+			);
+
+			expect( result?.type ).toBe( 'unregistered' );
+			expect( result?.message ).toBe(
+				'[WooCommerce] Unregistered script "unregistered.js" accessed wc.blocksCheckout. This script should be registered with wp_enqueue_script() and declare "wc-blocks-checkout" as a dependency.'
+			);
+		} );
+
+		it( 'returns missing-dependency warning for registered script without dependency', () => {
+			const result = getWarningInfo(
+				'https://example.com/registered-without-dep.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				mockRegistry
+			);
+
+			expect( result?.type ).toBe( 'missing-dependency' );
+			expect( result?.message ).toBe(
+				'[WooCommerce] Script "my-script-without-dep" accessed wc.blocksCheckout without declaring "wc-blocks-checkout" as a dependency. Add "wc-blocks-checkout" to the script\'s dependencies array.'
+			);
+		} );
+
+		it( 'returns null for registered script with correct dependency', () => {
+			const result = getWarningInfo(
+				'https://example.com/registered-with-dep.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				mockRegistry
+			);
+
+			expect( result ).toBe( null );
+		} );
+
+		it( 'returns unregistered warning for malformed registry entry with missing deps', () => {
+			const malformedRegistry = {
+				'https://example.com/malformed.js': {
+					handle: 'malformed-script',
+				},
+			} as unknown as ScriptRegistry;
+
+			const result = getWarningInfo(
+				'https://example.com/malformed.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				malformedRegistry
+			);
+
+			expect( result?.type ).toBe( 'unregistered' );
+		} );
+
+		it( 'returns unregistered warning for malformed registry entry with missing handle', () => {
+			const malformedRegistry = {
+				'https://example.com/malformed.js': {
+					deps: [ 'wc-blocks-checkout' ],
+				},
+			} as unknown as ScriptRegistry;
+
+			const result = getWarningInfo(
+				'https://example.com/malformed.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				malformedRegistry
+			);
+
+			expect( result?.type ).toBe( 'unregistered' );
+		} );
+
+		it( 'returns unregistered warning for malformed registry entry with non-array deps', () => {
+			const malformedRegistry = {
+				'https://example.com/malformed.js': {
+					handle: 'malformed-script',
+					deps: 'not-an-array',
+				},
+			} as unknown as ScriptRegistry;
+
+			const result = getWarningInfo(
+				'https://example.com/malformed.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				malformedRegistry
+			);
+
+			expect( result?.type ).toBe( 'unregistered' );
+		} );
+
+		it( 'returns unregistered warning when registry is not an object', () => {
+			const result = getWarningInfo(
+				'https://example.com/script.js',
+				'blocksCheckout',
+				'wc-blocks-checkout',
+				null as unknown as ScriptRegistry
+			);
+
+			expect( result?.type ).toBe( 'unregistered' );
+		} );
+	} );
+
+	describe( 'createWcProxy', () => {
+		it( 'returns value for non-tracked properties', () => {
+			const target: Record< string, unknown > = { someProperty: 'value' };
+			const proxy = createWcProxy(
+				target,
+				{} as WcGlobalExportsMap, // No tracked exports
+				jest.fn(),
+				jest.fn()
+			);
+
+			expect( proxy.someProperty ).toBe( 'value' );
+		} );
+
+		it( 'calls checkDependency for tracked properties', () => {
+			const target: Record< string, unknown > = {
+				blocksCheckout: { Component: () => {} },
+			};
+			const wcGlobalExports = {
+				blocksCheckout: 'wc-blocks-checkout',
+			} as WcGlobalExportsMap;
+			const getCallerScriptUrl = jest
+				.fn()
+				.mockReturnValue( 'https://example.com/script.js' );
+			const checkDependency = jest.fn();
+
+			const proxy = createWcProxy(
+				target,
+				wcGlobalExports,
+				getCallerScriptUrl,
+				checkDependency
+			);
+
+			const result = proxy.blocksCheckout;
+
+			expect( getCallerScriptUrl ).toHaveBeenCalled();
+			expect( checkDependency ).toHaveBeenCalledWith(
+				'https://example.com/script.js',
+				'blocksCheckout',
+				'wc-blocks-checkout'
+			);
+			expect( result ).toBe( target.blocksCheckout );
+		} );
+
+		it( 'prevents infinite recursion with guard flag', () => {
+			let accessCount = 0;
+			const target: Record< string, unknown > = {
+				get blocksCheckout(): unknown {
+					accessCount++;
+					// Simulate nested access (like blocksCheckout using wcSettings)
+					if ( accessCount === 1 ) {
+						// First access triggers nested access
+						return this.wcSettings;
+					}
+					return { Component: () => {} };
+				},
+				wcSettings: { currency: 'USD' },
+			};
+
+			const wcGlobalExports = {
+				blocksCheckout: 'wc-blocks-checkout',
+				wcSettings: 'wc-settings',
+			} as WcGlobalExportsMap;
+			const getCallerScriptUrl = jest
+				.fn()
+				.mockReturnValue( 'https://example.com/script.js' );
+			const checkDependency = jest.fn();
+
+			const proxy = createWcProxy(
+				target,
+				wcGlobalExports,
+				getCallerScriptUrl,
+				checkDependency
+			);
+
+			// Access blocksCheckout which internally accesses wcSettings
+			// eslint-disable-next-line no-unused-expressions
+			proxy.blocksCheckout;
+
+			// checkDependency should only be called once (for blocksCheckout),
+			// not twice (the nested wcSettings access should be blocked)
+			expect( checkDependency ).toHaveBeenCalledTimes( 1 );
+			expect( checkDependency ).toHaveBeenCalledWith(
+				'https://example.com/script.js',
+				'blocksCheckout',
+				'wc-blocks-checkout'
+			);
+		} );
+
+		it( 'resets guard flag after access completes', () => {
+			const target: Record< string, unknown > = {
+				blocksCheckout: {},
+				wcSettings: {},
+			};
+			const wcGlobalExports = {
+				blocksCheckout: 'wc-blocks-checkout',
+				wcSettings: 'wc-settings',
+			} as WcGlobalExportsMap;
+			const checkDependency = jest.fn();
+
+			const proxy = createWcProxy(
+				target,
+				wcGlobalExports,
+				jest.fn().mockReturnValue( 'https://example.com/script.js' ),
+				checkDependency
+			);
+
+			// First access
+			// eslint-disable-next-line no-unused-expressions
+			proxy.blocksCheckout;
+			// Second independent access should also trigger check
+			// eslint-disable-next-line no-unused-expressions
+			proxy.wcSettings;
+
+			expect( checkDependency ).toHaveBeenCalledTimes( 2 );
+		} );
+	} );
+} );
diff --git a/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts
new file mode 100644
index 0000000000..5d529e963f
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/assets/js/dependency-detection/utils.ts
@@ -0,0 +1,435 @@
+/**
+ * WooCommerce Dependency Detection - Utility Functions
+ *
+ * Extracted from dependency-detection.js for testability.
+ * These functions are used by the inline detection script.
+ */
+
+/**
+ * Exact mapping of wc.* property names to their required handles.
+ * Must match PHP DependencyDetection::WC_GLOBAL_EXPORTS exactly.
+ *
+ * This interface is used for development-time dependency warnings only.
+ * It is not a public API and may change without notice. Extensions should
+ * not rely on which properties are tracked or the detection behavior.
+ *
+ * @internal
+ */
+export interface WcGlobalExportsMap {
+	wcBlocksRegistry: 'wc-blocks-registry';
+	wcSettings: 'wc-settings';
+	wcBlocksData: 'wc-blocks-data-store';
+	data: 'wc-store-data';
+	wcBlocksSharedContext: 'wc-blocks-shared-context';
+	wcBlocksSharedHocs: 'wc-blocks-shared-hocs';
+	priceFormat: 'wc-price-format';
+	blocksCheckout: 'wc-blocks-checkout';
+	blocksCheckoutEvents: 'wc-blocks-checkout-events';
+	blocksComponents: 'wc-blocks-components';
+	wcTypes: 'wc-types';
+	sanitize: 'wc-sanitize';
+}
+
+/**
+ * Allowed window.wc.* property names that are tracked.
+ *
+ * @internal
+ */
+export type WcGlobalKey = keyof WcGlobalExportsMap;
+
+/**
+ * WooCommerce script dependency handles.
+ *
+ * @internal
+ */
+export type WcDependencyHandle = WcGlobalExportsMap[ WcGlobalKey ];
+
+/**
+ * Script information stored in the registry.
+ */
+export interface ScriptInfo {
+	handle: string;
+	deps: WcDependencyHandle[];
+}
+
+/**
+ * Registry mapping script URLs to their info.
+ */
+export type ScriptRegistry = Record< string, ScriptInfo >;
+
+/**
+ * Warning information returned by getWarningInfo.
+ */
+export interface WarningInfo {
+	type: 'inline' | 'unregistered' | 'missing-dependency';
+	message: string;
+}
+
+/**
+ * WooCommerce asset subdirectories that contain core scripts.
+ */
+const WC_ASSET_DIRS = [ 'client/', 'assets/', 'build/', 'vendor/' ];
+
+/**
+ * Fallback pattern for WooCommerce core scripts when plugin URL is not available.
+ * Matches /plugins/woocommerce/(client|assets|build|vendor)/ but NOT /plugins/woocommerce-subscriptions/ etc.
+ */
+const WC_CORE_SCRIPT_FALLBACK_PATTERN =
+	/\/plugins\/woocommerce\/(client|assets|build|vendor)\//;
+
+/**
+ * Check if a URL belongs to WooCommerce core scripts.
+ *
+ * Uses the plugin URL from the server to account for custom plugin directories
+ * (WP_PLUGIN_DIR, WP_CONTENT_DIR configurations). Falls back to a hardcoded
+ * pattern if the plugin URL is not available.
+ *
+ * @param url         - The script URL to check.
+ * @param wcPluginUrl - The WooCommerce plugin URL from the server.
+ * @return True if this is a WooCommerce core script.
+ */
+export function isWooCommerceScript(
+	url: string | null,
+	wcPluginUrl = ''
+): boolean {
+	if ( ! url ) {
+		return false;
+	}
+
+	// If WC_PLUGIN_URL is not available, fall back to hardcoded pattern.
+	// This handles cases where PHP injection failed.
+	if ( ! wcPluginUrl ) {
+		return WC_CORE_SCRIPT_FALLBACK_PATTERN.test( url );
+	}
+
+	// Check if the URL starts with the WooCommerce plugin URL.
+	if ( ! url.startsWith( wcPluginUrl ) ) {
+		return false;
+	}
+
+	// Get the path after the plugin URL.
+	const relativePath = url.substring( wcPluginUrl.length );
+
+	// Check if it's in one of the known WooCommerce asset directories.
+	for ( let i = 0; i < WC_ASSET_DIRS.length; i++ ) {
+		if ( relativePath.startsWith( WC_ASSET_DIRS[ i ] ) ) {
+			return true;
+		}
+	}
+
+	return false;
+}
+
+/**
+ * Extract filename from a URL.
+ *
+ * @param url - The URL to extract filename from.
+ * @return The filename or 'unknown'.
+ */
+export function getFilename( url: string | null ): string {
+	if ( ! url ) {
+		return 'unknown';
+	}
+
+	const lastSegment = url.split( '/' ).pop();
+	if ( ! lastSegment ) {
+		return 'unknown';
+	}
+
+	const filename = lastSegment.split( '?' )[ 0 ].split( '#' )[ 0 ];
+
+	return filename || 'unknown';
+}
+
+/**
+ * Check if a stack trace line should be skipped when searching for the caller.
+ *
+ * We skip internal lines because we want to find the actual third-party script
+ * that accessed wc.*, not the detection code itself. Lines to skip include:
+ * - Current page lines: These are from our inline detection script (the IIFE
+ *   output by PHP). They appear as "cart/:123" or "checkout/:456" in the stack.
+ * - Webpack source maps: Internal WooCommerce build artifacts that aren't the
+ *   actual caller script.
+ *
+ * @param line        - A single line from the stack trace.
+ * @param currentPage - The current page pathname (e.g., '/cart/', '/checkout/').
+ * @return True if this line should be skipped.
+ */
+export function shouldSkipLine( line: string, currentPage: string ): boolean {
+	// Skip lines from the current page (our inline detection script).
+	if ( line.includes( currentPage + ':' ) ) {
+		return true;
+	}
+
+	// Skip webpack source-mapped files (internal build artifacts).
+	if ( line.includes( 'webpack://' ) ) {
+		return true;
+	}
+
+	return false;
+}
+
+/**
+ * Stack trace format types.
+ * - 'v8': Chrome, Edge, Node.js - format: "at funcName (url:line:col)" with "Error" header
+ * - 'spidermonkey': Firefox (SpiderMonkey), Safari (JavaScriptCore) - format: "funcName@url:line:col" without header
+ */
+export type StackFormatType = 'v8' | 'spidermonkey';
+
+/**
+ * Detect the stack trace format type from a stack string.
+ *
+ * V8 (Chrome/Edge/Node): Lines contain "at " followed by function name and URL in parentheses.
+ * SpiderMonkey (Firefox/Safari): Lines contain "@" between function name and URL.
+ *
+ * @param stack - The stack trace string.
+ * @return The detected format type, defaults to 'v8' if unknown.
+ */
+export function detectStackFormat( stack: string ): StackFormatType {
+	if ( ! stack || typeof stack !== 'string' ) {
+		return 'v8';
+	}
+
+	// SpiderMonkey format: lines have "@" before the URL (e.g., "funcName@https://...")
+	// V8 format: lines have "at " prefix (e.g., "at funcName (https://...)")
+	const lines = stack.split( '\n' );
+
+	// Start from line 0 because SpiderMonkey stacks may not have an "Error" header.
+	for ( let i = 0; i < lines.length; i++ ) {
+		const line = lines[ i ];
+
+		// SpiderMonkey: "@https://" or "@http://" pattern
+		if ( /@https?:\/\//.test( line ) ) {
+			return 'spidermonkey';
+		}
+
+		// V8: "at " prefix pattern
+		if ( /^\s*at\s/.test( line ) ) {
+			return 'v8';
+		}
+	}
+
+	return 'v8';
+}
+
+/**
+ * Extract a .js URL from a V8-format stack trace line (Chrome/Edge/Node).
+ *
+ * V8 format examples:
+ * - "at funcName (https://example.com/script.js:10:5)" - full URL
+ * - "at funcName (script.js:10:5)" - relative/bare filename
+ *
+ * @param line - A single line from the stack trace.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrlV8( line = '' ): string | null {
+	if ( typeof line !== 'string' ) {
+		return null;
+	}
+	// First try to match full URL with protocol
+	const fullUrlMatch = line.match( /(https?:\/\/[^\s)]+?\.js)(?:[?:#]|$)/ );
+	if ( fullUrlMatch ) {
+		return fullUrlMatch[ 1 ];
+	}
+
+	// Fall back to bare filename (e.g., "script.js" without protocol).
+	// Match inside parentheses: ( followed by path ending in .js
+	const bareMatch = line.match( /\(([^()\s]+\.js)(?:[?:#]|$)/ );
+	return bareMatch ? bareMatch[ 1 ] : null;
+}
+
+/**
+ * Extract a .js URL from a SpiderMonkey-format stack trace line (Firefox/Safari).
+ *
+ * SpiderMonkey format: "funcName@https://example.com/script.js:10:7"
+ * The URL comes after "@", followed by line:col.
+ *
+ * @param line - A single line from the stack trace.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrlSpiderMonkey( line = '' ): string | null {
+	if ( typeof line !== 'string' ) {
+		return null;
+	}
+	// Match URL after "@", ending with .js before query/hash/line number.
+	// Use non-greedy match [^\s]+? to stop at first .js occurrence.
+	const match = line.match( /@(https?:\/\/[^\s]+?\.js)(?:[?:#]|$)/ );
+	return match ? match[ 1 ] : null;
+}
+
+/**
+ * Extract a .js URL from a stack trace line using the specified format.
+ *
+ * @param line   - A single line from the stack trace.
+ * @param format - The stack format type to use for extraction.
+ * @return The extracted URL or null.
+ */
+export function extractJsUrl(
+	line = '',
+	format: StackFormatType = 'v8'
+): string | null {
+	if ( typeof line !== 'string' ) {
+		return null;
+	}
+
+	if ( format === 'spidermonkey' ) {
+		return extractJsUrlSpiderMonkey( line );
+	}
+
+	return extractJsUrlV8( line );
+}
+
+/**
+ * Parse an error stack trace to find the calling script URL.
+ *
+ * Detects the stack format (V8 vs SpiderMonkey) once and uses
+ * the appropriate extractor for all lines.
+ *
+ * @param stack       - The error stack trace.
+ * @param currentPage - The current page pathname.
+ * @return The caller URL or null if not found.
+ */
+export function parseStackForCallerUrl(
+	stack: string | null,
+	currentPage: string
+): string | null {
+	if ( ! stack || typeof stack !== 'string' ) {
+		return null;
+	}
+
+	// Detect format once for the entire stack.
+	const format = detectStackFormat( stack );
+	const lines = stack.split( '\n' );
+
+	// V8 stacks have "Error" as line 0, so start at 1.
+	// SpiderMonkey stacks start directly with frames, so start at 0.
+	const startLine = format === 'v8' ? 1 : 0;
+
+	for ( let i = startLine; i < lines.length; i++ ) {
+		const line = lines[ i ];
+
+		// Skip internal lines (our script, webpack).
+		if ( shouldSkipLine( line, currentPage ) ) continue;
+
+		// Found an external URL - return it.
+		const url = extractJsUrl( line, format );
+		if ( url ) {
+			return url;
+		}
+	}
+
+	return null;
+}
+
+/**
+ * Create the warning message for missing dependencies.
+ *
+ * @param callerUrl                - The URL of the calling script.
+ * @param wcGlobalKey              - The property being accessed.
+ * @param requiredDependencyHandle - The required dependency handle.
+ * @param scriptRegistry           - Registry of scripts with their handles and deps.
+ * @param getFilenameFn            - Function to extract filename from URL.
+ * @return Warning info { type, message } or null if no warning needed.
+ */
+export function getWarningInfo(
+	callerUrl: string | null,
+	wcGlobalKey: WcGlobalKey,
+	requiredDependencyHandle: WcDependencyHandle,
+	scriptRegistry: ScriptRegistry,
+	getFilenameFn: ( url: string | null ) => string = getFilename
+): WarningInfo | null {
+	// Case 1: Inline or unknown script.
+	if ( ! callerUrl ) {
+		return {
+			type: 'inline',
+			message: `[WooCommerce] An inline or unknown script accessed wc.${ wcGlobalKey } without proper dependency declaration. This script should declare "${ requiredDependencyHandle }" as a dependency.`,
+		};
+	}
+
+	const scriptInfo =
+		scriptRegistry && typeof scriptRegistry === 'object'
+			? scriptRegistry[ callerUrl ]
+			: undefined;
+
+	// Case 2: Unregistered script or malformed registry entry.
+	if (
+		! scriptInfo ||
+		! scriptInfo.handle ||
+		! Array.isArray( scriptInfo.deps )
+	) {
+		return {
+			type: 'unregistered',
+			message: `[WooCommerce] Unregistered script "${ getFilenameFn(
+				callerUrl
+			) }" accessed wc.${ wcGlobalKey }. This script should be registered with wp_enqueue_script() and declare "${ requiredDependencyHandle }" as a dependency.`,
+		};
+	}
+
+	// Case 3: Missing dependency.
+	if ( scriptInfo.deps.indexOf( requiredDependencyHandle ) === -1 ) {
+		return {
+			type: 'missing-dependency',
+			message: `[WooCommerce] Script "${ scriptInfo.handle }" accessed wc.${ wcGlobalKey } without declaring "${ requiredDependencyHandle }" as a dependency. Add "${ requiredDependencyHandle }" to the script's dependencies array.`,
+		};
+	}
+
+	// No warning needed - dependency is properly declared.
+	return null;
+}
+
+/**
+ * Create a Proxy wrapper for the wc object.
+ *
+ * Intercepts property access on window.wc to check if the calling script
+ * has declared the required dependency. Uses a guard flag (isChecking) to
+ * prevent infinite recursion when accessing a property triggers nested
+ * proxy calls (e.g., wc.blocksCheckout internally uses wc.wcSettings).
+ *
+ * @param target             - The object to wrap.
+ * @param wcGlobalExports    - Map of wc.* properties to required handles.
+ * @param getCallerScriptUrl - Function to get the caller script URL.
+ * @param checkDependency    - Function to check and warn about dependencies.
+ * @return The proxied object.
+ */
+export function createWcProxy< T extends Record< string, unknown > >(
+	target: T,
+	wcGlobalExports: WcGlobalExportsMap,
+	getCallerScriptUrl: () => string | null,
+	checkDependency: (
+		callerUrl: string | null,
+		wcGlobalKey: WcGlobalKey,
+		requiredDependencyHandle: WcDependencyHandle
+	) => void
+): T {
+	let isChecking = false;
+
+	function __wcProxyGet( obj: T, prop: string ): unknown {
+		// Recursive call - skip checking and just return the value.
+		if ( isChecking ) {
+			return obj[ prop as keyof T ];
+		}
+
+		// Check if this property is a tracked wc global export.
+		// Type guard needed for TypeScript to narrow the type.
+		const isTrackedKey = ( key: string ): key is WcGlobalKey =>
+			key in wcGlobalExports;
+
+		if ( isTrackedKey( prop ) ) {
+			// Set guard before any operations that might trigger nested proxy calls.
+			isChecking = true;
+			try {
+				const callerUrl = getCallerScriptUrl();
+				checkDependency( callerUrl, prop, wcGlobalExports[ prop ] );
+				// Get the value (may trigger nested proxy calls, but isChecking blocks them).
+				return obj[ prop as keyof T ];
+			} finally {
+				// Reset guard only after we have the value, even if an error occurs.
+				isChecking = false;
+			}
+		}
+
+		return obj[ prop as keyof T ];
+	}
+
+	return new Proxy( target, { get: __wcProxyGet } );
+}
diff --git a/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js b/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js
new file mode 100644
index 0000000000..f9a9c23743
--- /dev/null
+++ b/plugins/woocommerce/client/blocks/bin/webpack-config-dependency-detection.js
@@ -0,0 +1,104 @@
+/**
+ * Webpack config for WooCommerce Dependency Detection script.
+ *
+ * This builds a standalone JS File that PHP inlines into the page.
+ */
+
+const path = require( 'path' );
+const ProgressBarPlugin = require( 'progress-bar-webpack-plugin' );
+const TerserPlugin = require( 'terser-webpack-plugin' );
+
+/**
+ * Internal dependencies
+ */
+const { getProgressBarPluginConfig } = require( './webpack-helpers' );
+
+const ROOT_DIR = path.resolve( __dirname, '../../../../../' );
+// Output to the standard blocks build directory (gitignored).
+const BUILD_DIR = path.resolve( __dirname, '../build/' );
+const BABEL_CACHE_DIR = path.join(
+	ROOT_DIR,
+	'node_modules/.cache/babel-loader'
+);
+
+module.exports = {
+	entry: {
+		'dependency-detection': './assets/js/dependency-detection/index.ts',
+	},
+	output: {
+		path: BUILD_DIR,
+		filename: '[name].js',
+		// IIFE - no module exports or library wrapping.
+		iife: true,
+		clean: false,
+	},
+	module: {
+		rules: [
+			{
+				test: /\.[jt]s$/,
+				exclude: /node_modules/,
+				use: {
+					loader: 'babel-loader',
+					options: {
+						presets: [
+							[
+								'@wordpress/babel-preset-default',
+								{
+									modules: false,
+									targets: {
+										browsers: [
+											'extends @wordpress/browserslist-config',
+										],
+									},
+								},
+							],
+							'@babel/preset-typescript',
+						],
+						cacheDirectory: BABEL_CACHE_DIR,
+						cacheCompression: false,
+					},
+				},
+			},
+		],
+	},
+	plugins: [
+		new ProgressBarPlugin(
+			getProgressBarPluginConfig( 'Dependency Detection' )
+		),
+	],
+	optimization: {
+		// Always minimize - this is an inline script embedded in page HTML.
+		minimize: true,
+		minimizer: [
+			new TerserPlugin( {
+				// Don't extract license comments to a separate file.
+				extractComments: false,
+				terserOptions: {
+					format: {
+						// Remove all comments from output.
+						comments: false,
+					},
+					compress: {
+						// Don't inline variables - we need WC_GLOBAL_EXPORTS to remain
+						// as a variable assignment for PHP placeholder replacement.
+						reduce_vars: false,
+						inline: false,
+					},
+					mangle: {
+						// Don't mangle top-level names.
+						toplevel: false,
+						// Preserve variable names for readability and PHP placeholder replacement.
+						reserved: [ 'WC_GLOBAL_EXPORTS', 'WC_PLUGIN_URL' ],
+					},
+				},
+			} ),
+		],
+	},
+	// No source maps for inline script.
+	devtool: false,
+	// No externals - this is a standalone script.
+	externals: {},
+	resolve: {
+		extensions: [ '.ts', '.js' ],
+	},
+};
diff --git a/plugins/woocommerce/client/blocks/webpack.config.js b/plugins/woocommerce/client/blocks/webpack.config.js
index b6782d6ff5..2c71a476c0 100644
--- a/plugins/woocommerce/client/blocks/webpack.config.js
+++ b/plugins/woocommerce/client/blocks/webpack.config.js
@@ -15,6 +15,7 @@ const {

 const interactivityBlocksConfig = require( './bin/webpack-config-interactive-blocks.js' );
 const interactivityAPIConfig = require( './bin/webpack-config-interactivity.js' );
+const dependencyDetectionConfig = require( './bin/webpack-config-dependency-detection.js' );

 // Only options shared between all configs should be defined here.
 const sharedConfig = {
@@ -104,6 +105,15 @@ const InteractivityAPIConfig = {
 	...interactivityAPIConfig,
 };

+/**
+ * Config for the dependency detection inline script.
+ * This is a standalone IIFE that PHP reads and inlines.
+ */
+const DependencyDetectionConfig = {
+	...sharedConfig,
+	...dependencyDetectionConfig,
+};
+
 module.exports = [
 	CartAndCheckoutFrontendConfig,
 	CoreConfig,
@@ -115,4 +125,5 @@ module.exports = [
 	StylingConfig,
 	InteractivityBlocksConfig,
 	InteractivityAPIConfig,
+	DependencyDetectionConfig,
 ];
diff --git a/plugins/woocommerce/phpstan-baseline.neon b/plugins/woocommerce/phpstan-baseline.neon
index 383a22b0a9..066199049b 100644
--- a/plugins/woocommerce/phpstan-baseline.neon
+++ b/plugins/woocommerce/phpstan-baseline.neon
@@ -20130,12 +20130,6 @@ parameters:
 			count: 1
 			path: includes/class-wc-tracker.php

-		-
-			message: '#^Parameter \#1 \$block_name of static method Automattic\\WooCommerce\\Internal\\Utilities\\BlocksUtil\:\:get_blocks_from_widget_area\(\) expects array, string given\.$#'
-			identifier: argument.type
-			count: 1
-			path: includes/class-wc-tracker.php
-
 		-
 			message: '#^Parameter \#1 \$size of function wc_let_to_num expects string, string\|false given\.$#'
 			identifier: argument.type
diff --git a/plugins/woocommerce/src/Blocks/DependencyDetection.php b/plugins/woocommerce/src/Blocks/DependencyDetection.php
new file mode 100644
index 0000000000..d5c24f51a9
--- /dev/null
+++ b/plugins/woocommerce/src/Blocks/DependencyDetection.php
@@ -0,0 +1,357 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Blocks;
+
+use Automattic\WooCommerce\Internal\Utilities\BlocksUtil;
+
+/**
+ * DependencyDetection class.
+ *
+ * Provides runtime detection of extensions that use Blocks related WooCommerce globals
+ * (window.wc.*) without properly declaring their PHP script dependencies.
+ *
+ * This runs by default to warn developers about missing dependencies.
+ *
+ * @since 10.5.0
+ * @internal
+ */
+final class DependencyDetection {
+
+	/**
+	 * WooCommerce blocks that use the tracked globals.
+	 *
+	 * Detection script only runs on pages containing these blocks.
+	 *
+	 * @var array<string>
+	 */
+	private const TRACKED_BLOCKS = array(
+		'woocommerce/checkout',
+		'woocommerce/cart',
+		'woocommerce/mini-cart',
+	);
+
+	/**
+	 * Maps window.wc.* property names to their required script handles.
+	 *
+	 * This is the source of truth for both PHP and JS dependency detection.
+	 * Based on wcDepMap and wcHandleMap in client/blocks/bin/webpack-helpers.js.
+	 *
+	 * @var array<string, string>
+	 */
+	private const WC_GLOBAL_EXPORTS = array(
+		'wcBlocksRegistry'      => 'wc-blocks-registry',
+		'wcSettings'            => 'wc-settings',
+		'wcBlocksData'          => 'wc-blocks-data-store',
+		'data'                  => 'wc-store-data',
+		'wcBlocksSharedContext' => 'wc-blocks-shared-context',
+		'wcBlocksSharedHocs'    => 'wc-blocks-shared-hocs',
+		'priceFormat'           => 'wc-price-format',
+		'blocksCheckout'        => 'wc-blocks-checkout',
+		'blocksCheckoutEvents'  => 'wc-blocks-checkout-events',
+		'blocksComponents'      => 'wc-blocks-components',
+		'wcTypes'               => 'wc-types',
+		'sanitize'              => 'wc-sanitize',
+	);
+
+	/**
+	 * Whether the proxy script was output.
+	 *
+	 * Used to ensure we only output the registry if the proxy was set up.
+	 *
+	 * @var bool
+	 */
+	private bool $proxy_output = false;
+
+	/**
+	 * Constructor.
+	 */
+	public function __construct() {
+		$this->init();
+	}
+
+	/**
+	 * Initialize hooks.
+	 *
+	 * @since 10.5.0
+	 */
+	public function init(): void {
+		// Only run when debugging is enabled.
+		if ( ! defined( 'WP_DEBUG' ) || ! WP_DEBUG ) {
+			return;
+		}
+
+		// Output an early inline script to set up the Proxy before any other scripts run.
+		add_action( 'wp_head', array( $this, 'output_early_proxy_setup' ), 1 );
+		add_action( 'admin_head', array( $this, 'output_early_proxy_setup' ), 1 );
+
+		// Output registry late when all scripts (including IntegrationInterface) are registered.
+		add_action( 'wp_print_footer_scripts', array( $this, 'output_script_registry' ), 1 );
+		add_action( 'admin_print_footer_scripts', array( $this, 'output_script_registry' ), 1 );
+	}
+
+	/**
+	 * Output early inline script to set up the Proxy on window.wc.
+	 *
+	 * This must run before any WooCommerce scripts to intercept access.
+	 * The script is loaded from a separate file for better IDE support and testing,
+	 * but output inline to ensure correct timing (before any enqueued scripts).
+	 *
+	 * @since 10.5.0
+	 */
+	public function output_early_proxy_setup(): void {
+		// Only run on pages that have the tracked blocks.
+		if ( ! $this->page_has_tracked_blocks() ) {
+			return;
+		}
+
+		// Load from the production assets directory (built by webpack and copied during release build).
+		$script_path = __DIR__ . '/../../assets/client/blocks/dependency-detection.js';
+
+		if ( ! file_exists( $script_path ) ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local file read for inline script output.
+		$script_content = file_get_contents( $script_path );
+
+		if ( ! $script_content ) {
+			return;
+		}
+
+		// Inject the global-to-handle mapping from PHP (source of truth).
+		$mapping_json = \wp_json_encode( self::WC_GLOBAL_EXPORTS );
+		if ( false === $mapping_json ) {
+			return;
+		}
+		$script_content = str_replace(
+			'__WC_GLOBAL_EXPORTS_PLACEHOLDER__',
+			$mapping_json,
+			$script_content
+		);
+
+		// Inject the WooCommerce plugin URL for script origin detection.
+		// This accounts for custom plugin directories (WP_PLUGIN_DIR, WP_CONTENT_DIR).
+		$wc_plugin_url  = \plugins_url( '/', WC_PLUGIN_FILE );
+		$script_content = str_replace(
+			'__WC_PLUGIN_URL_PLACEHOLDER__',
+			'"' . esc_js( $wc_plugin_url ) . '"',
+			$script_content
+		);
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Script content is from a trusted local file, JSON is safely encoded.
+		echo '<script id="wc-dependency-detection">' . $script_content . '</script>' . "\n";
+
+		$this->proxy_output = true;
+	}
+
+	/**
+	 * Output the script registry JSON for dependency checking.
+	 *
+	 * This runs late (wp_print_footer_scripts) to ensure all scripts,
+	 * including those registered via IntegrationInterface, are captured.
+	 *
+	 * @since 10.5.0
+	 */
+	public function output_script_registry(): void {
+		// Only output registry if the proxy was set up earlier.
+		// This avoids the duplicate page_has_tracked_blocks() check and ensures
+		// we don't output a registry without a proxy to consume it.
+		if ( ! $this->proxy_output ) {
+			return;
+		}
+
+		// Build the script registry mapping URLs to handles and dependencies.
+		$script_registry = $this->build_script_registry();
+		$registry_json   = \wp_json_encode( $script_registry );
+
+		if ( false === $registry_json ) {
+			return;
+		}
+
+		// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON is safely encoded by wp_json_encode.
+		echo '<script id="wc-dependency-detection-registry">if(typeof window.wc.wcUpdateDependencyRegistry==="function"){window.wc.wcUpdateDependencyRegistry(' . $registry_json . ');}</script>' . "\n";
+	}
+
+	/**
+	 * Build a registry of all enqueued scripts with their URLs and dependencies.
+	 *
+	 * @return array<string, array{handle: string, deps: array<string>}>
+	 */
+	private function build_script_registry(): array {
+		$wp_scripts = wp_scripts();
+		$registry   = array();
+
+		foreach ( $wp_scripts->registered as $handle => $script ) {
+			// Skip scripts without a source URL.
+			if ( empty( $script->src ) ) {
+				continue;
+			}
+
+			// Get the full URL.
+			$src = $script->src;
+			if ( ! is_string( $src ) ) {
+				// Skip malformed src.
+				continue;
+			}
+			if ( ! preg_match( '|^(https?:)?//|', $src ) ) {
+				// Relative URL - make it absolute.
+				$src = $wp_scripts->base_url . $src;
+			}
+
+			// Skip WooCommerce's own scripts - we don't need to check those.
+			if ( $this->is_woocommerce_script( $src ) ) {
+				continue;
+			}
+
+			// Skip WordPress core scripts - they won't use wc.* globals.
+			if ( $this->is_wordpress_core_script( $src ) ) {
+				continue;
+			}
+
+			// Normalize the URL for consistent matching.
+			$src = $this->normalize_url( $src );
+
+			$registry[ $src ] = array(
+				'handle' => $handle,
+				'deps'   => $this->get_all_dependencies( $script->deps ),
+			);
+		}
+
+		return $registry;
+	}
+
+	/**
+	 * Check if a script URL belongs to WooCommerce core.
+	 *
+	 * Checks if the script is loaded from the WooCommerce core plugin directory,
+	 * not from third-party extensions that may use similar handle naming.
+	 *
+	 * @param string $url Script URL.
+	 * @return bool
+	 */
+	private function is_woocommerce_script( string $url ): bool {
+		// Get the WooCommerce plugin URL (accounts for custom plugin directories).
+		$wc_plugin_url = \plugins_url( '/', WC_PLUGIN_FILE );
+
+		// Check if the URL starts with the WooCommerce plugin URL and is in a known subdirectory.
+		if ( strpos( $url, $wc_plugin_url ) !== 0 ) {
+			return false;
+		}
+
+		// Get the path after the WooCommerce plugin URL.
+		$relative_path = substr( $url, strlen( $wc_plugin_url ) );
+
+		// Check if it's in one of the known WooCommerce asset directories.
+		return (bool) preg_match( '#^(client|assets|build|vendor)/#', $relative_path );
+	}
+
+	/**
+	 * Check if a script URL belongs to WordPress core.
+	 *
+	 * WordPress core scripts (wp-includes, wp-admin) won't use wc.* globals,
+	 * so we can skip them to reduce registry size.
+	 *
+	 * @param string $url Script URL.
+	 * @return bool
+	 */
+	private function is_wordpress_core_script( string $url ): bool {
+		return (bool) preg_match( '#/(wp-includes|wp-admin)/#', $url );
+	}
+
+	/**
+	 * Recursively get all dependencies including nested ones.
+	 *
+	 * @param array<string> $deps Direct dependencies.
+	 * @return array<string> All dependencies (flattened).
+	 */
+	private function get_all_dependencies( array $deps ): array {
+		$wp_scripts      = wp_scripts();
+		$all_deps        = array();
+		$deps_to_process = $deps;
+
+		while ( ! empty( $deps_to_process ) ) {
+			$handle = array_shift( $deps_to_process );
+
+			if ( in_array( $handle, $all_deps, true ) ) {
+				continue;
+			}
+
+			$all_deps[] = $handle;
+
+			// Add nested dependencies to process.
+			if ( isset( $wp_scripts->registered[ $handle ] ) ) {
+				foreach ( $wp_scripts->registered[ $handle ]->deps as $nested_dep ) {
+					if ( ! in_array( $nested_dep, $all_deps, true ) ) {
+						$deps_to_process[] = $nested_dep;
+					}
+				}
+			}
+		}
+
+		// Filter to only include WooCommerce handles we care about.
+		$wc_handles = array_values( self::WC_GLOBAL_EXPORTS );
+		return array_values(
+			array_filter(
+				$all_deps,
+				function ( $dep ) use ( $wc_handles ) {
+					return in_array( $dep, $wc_handles, true );
+				}
+			)
+		);
+	}
+
+	/**
+	 * Check if the current page contains any of the tracked blocks.
+	 * Checks post content, widget areas, and template parts (header) for blocks.
+	 *
+	 * @return bool True if page has tracked blocks.
+	 */
+	private function page_has_tracked_blocks(): bool {
+		// Check post content for blocks.
+		foreach ( self::TRACKED_BLOCKS as $block_name ) {
+			if ( \has_block( $block_name ) ) {
+				return true;
+			}
+		}
+
+		// Check widget areas for mini-cart (classic themes).
+		$mini_cart_in_widgets = BlocksUtil::get_blocks_from_widget_area( 'woocommerce/mini-cart' );
+		if ( ! empty( $mini_cart_in_widgets ) ) {
+			return true;
+		}
+
+		// Check header template part for mini-cart (block themes).
+		try {
+			$mini_cart_in_header = BlocksUtil::get_block_from_template_part( 'woocommerce/mini-cart', 'header' );
+			if ( ! empty( $mini_cart_in_header ) ) {
+				return true;
+			}
+		} catch ( \Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+			// Template part may not exist in all themes, silently continue.
+		}
+
+		return false;
+	}
+
+	/**
+	 * Normalize a URL by removing query strings and hash fragments.
+	 *
+	 * This helps match URLs in stack traces which don't include query strings.
+	 *
+	 * @param string $url URL to normalize.
+	 * @return string Normalized URL without query string or hash.
+	 */
+	private function normalize_url( string $url ): string {
+		$scheme = wp_parse_url( $url, PHP_URL_SCHEME );
+		$host   = wp_parse_url( $url, PHP_URL_HOST );
+		$path   = wp_parse_url( $url, PHP_URL_PATH );
+
+		if ( $scheme && $host && $path ) {
+			$port = wp_parse_url( $url, PHP_URL_PORT );
+			return $scheme . '://' . $host . ( $port ? ':' . $port : '' ) . $path;
+		}
+
+		return $url;
+	}
+}
diff --git a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
index 91552b4f77..93c7b9b3b3 100644
--- a/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
+++ b/plugins/woocommerce/src/Blocks/Domain/Bootstrap.php
@@ -9,6 +9,7 @@ use Automattic\WooCommerce\Blocks\BlockPatterns;
 use Automattic\WooCommerce\Blocks\BlockTemplatesRegistry;
 use Automattic\WooCommerce\Blocks\BlockTemplatesController;
 use Automattic\WooCommerce\Blocks\BlockTypesController;
+use Automattic\WooCommerce\Blocks\DependencyDetection;
 use Automattic\WooCommerce\Blocks\Patterns\AIPatterns;
 use Automattic\WooCommerce\Blocks\Patterns\PatternRegistry;
 use Automattic\WooCommerce\Blocks\Patterns\PTKClient;
@@ -134,6 +135,7 @@ class Bootstrap {
 		$this->container->get( CheckoutLink::class )->init();
 		$this->container->get( AssetDataRegistry::class );
 		$this->container->get( AssetsController::class );
+		$this->container->get( DependencyDetection::class );

 		// Load assets in admin and on the frontend.
 		if ( ! $is_rest ) {
@@ -222,6 +224,12 @@ class Bootstrap {
 				return new AssetsController( $container->get( AssetApi::class ) );
 			}
 		);
+		$this->container->register(
+			DependencyDetection::class,
+			function () {
+				return new DependencyDetection();
+			}
+		);
 		$this->container->register(
 			PaymentMethodRegistry::class,
 			function () {
diff --git a/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php b/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
index c4417d21eb..77a78a4601 100644
--- a/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
+++ b/plugins/woocommerce/src/Internal/Utilities/BlocksUtil.php
@@ -33,7 +33,7 @@ class BlocksUtil {
 	/**
 	 * Get all instances of the specified block from the widget area.
 	 *
-	 * @param array $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
+	 * @param string $block_name The name (id) of a block, e.g. `woocommerce/mini-cart`.
 	 * @return array Array of blocks as returned by parse_blocks().
 	 */
 	public static function get_blocks_from_widget_area( $block_name ) {
diff --git a/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php b/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php
new file mode 100644
index 0000000000..fa98d06d33
--- /dev/null
+++ b/plugins/woocommerce/tests/php/src/Blocks/DependencyDetectionTest.php
@@ -0,0 +1,310 @@
+<?php
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Tests\Blocks;
+
+use Automattic\WooCommerce\Blocks\DependencyDetection;
+use WC_Unit_Test_Case;
+
+/**
+ * Unit tests for the DependencyDetection class.
+ */
+class DependencyDetectionTest extends WC_Unit_Test_Case {
+
+	/**
+	 * The System Under Test.
+	 *
+	 * @var DependencyDetection
+	 */
+	private DependencyDetection $sut;
+
+	/**
+	 * Reflection class for accessing private methods.
+	 *
+	 * @var \ReflectionClass
+	 */
+	private \ReflectionClass $reflection;
+
+	/**
+	 * Set up the test.
+	 */
+	public function setUp(): void {
+		parent::setUp();
+		$this->sut        = new DependencyDetection();
+		$this->reflection = new \ReflectionClass( DependencyDetection::class );
+	}
+
+	/**
+	 * Clean up after each test.
+	 */
+	public function tearDown(): void {
+		// Clean up any registered test scripts.
+		wp_deregister_script( 'test-plugin-script' );
+		wp_deregister_script( 'test-plugin-with-deps' );
+		wp_deregister_script( 'test-nested-dep' );
+
+		parent::tearDown();
+	}
+
+	/**
+	 * Helper to invoke a private method.
+	 *
+	 * @param string $method_name The method name.
+	 * @param array  $args        The arguments to pass.
+	 * @return mixed The method result.
+	 */
+	private function invoke_private_method( string $method_name, array $args = [] ) {
+		$method = $this->reflection->getMethod( $method_name );
+		$method->setAccessible( true );
+		return $method->invokeArgs( $this->sut, $args );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns true for WooCommerce core client path.
+	 */
+	public function test_is_woocommerce_script_returns_true_for_client_path(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		$url           = $wc_plugin_url . 'client/blocks/index.js';
+		$result        = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns true for WooCommerce core assets path.
+	 */
+	public function test_is_woocommerce_script_returns_true_for_assets_path(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		$url           = $wc_plugin_url . 'assets/js/frontend.js';
+		$result        = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns true for WooCommerce core build path.
+	 */
+	public function test_is_woocommerce_script_returns_true_for_build_path(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		$url           = $wc_plugin_url . 'build/bundle.js';
+		$result        = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns true for WooCommerce core vendor path.
+	 */
+	public function test_is_woocommerce_script_returns_true_for_vendor_path(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		$url           = $wc_plugin_url . 'vendor/some-lib.js';
+		$result        = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertTrue( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns false for scripts in root directory (not in asset dirs).
+	 */
+	public function test_is_woocommerce_script_returns_false_for_root_scripts(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		$url           = $wc_plugin_url . 'readme.js';
+		$result        = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns false for WooCommerce extensions.
+	 */
+	public function test_is_woocommerce_script_returns_false_for_subscriptions(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		// Replace /woocommerce/ with /woocommerce-subscriptions/ in the URL.
+		$url    = str_replace( '/woocommerce/', '/woocommerce-subscriptions/', $wc_plugin_url ) . 'assets/js/index.js';
+		$result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns false for WooCommerce Payments.
+	 */
+	public function test_is_woocommerce_script_returns_false_for_payments(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		// Replace /woocommerce/ with /woocommerce-payments/ in the URL.
+		$url    = str_replace( '/woocommerce/', '/woocommerce-payments/', $wc_plugin_url ) . 'build/index.js';
+		$result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * @testdox is_woocommerce_script returns false for third-party plugins.
+	 */
+	public function test_is_woocommerce_script_returns_false_for_third_party(): void {
+		$wc_plugin_url = plugins_url( '/', WC_PLUGIN_FILE );
+		// Replace /woocommerce/ with /my-plugin/ in the URL.
+		$url    = str_replace( '/woocommerce/', '/my-plugin/', $wc_plugin_url ) . 'assets/js/index.js';
+		$result = $this->invoke_private_method( 'is_woocommerce_script', array( $url ) );
+		$this->assertFalse( $result );
+	}
+
+	/**
+	 * @testdox get_all_dependencies returns empty array for empty input.
+	 */
+	public function test_get_all_dependencies_returns_empty_for_empty_input(): void {
+		$result = $this->invoke_private_method( 'get_all_dependencies', array( array() ) );
+		$this->assertSame( array(), $result );
+	}
+
+	/**
+	 * @testdox get_all_dependencies filters to WC handles only.
+	 */
+	public function test_get_all_dependencies_filters_to_wc_handles(): void {
+		// Register a script with mixed dependencies.
+		wp_register_script( 'test-nested-dep', '', array( 'wc-blocks-checkout', 'jquery' ), '1.0.0', true );
+
+		$result = $this->invoke_private_method( 'get_all_dependencies', array( array( 'test-nested-dep' ) ) );
+
+		// Should only include WC handles, not jquery.
+		$this->assertContains( 'wc-blocks-checkout', $result );
+		$this->assertNotContains( 'jquery', $result );
+		$this->assertNotContains( 'test-nested-dep', $result );
+	}
+
+	/**
+	 * @testdox get_all_dependencies handles circular dependencies.
+	 */
+	public function test_get_all_dependencies_handles_circular_deps(): void {
+		// This test verifies the method doesn't infinite loop.
+		// The WC handles filtering means we won't see the circular dep in output.
+		$result = $this->invoke_private_method( 'get_all_dependencies', array( array( 'wc-blocks-checkout' ) ) );
+
+		// Should complete without error and return the WC handle.
+		$this->assertContains( 'wc-blocks-checkout', $result );
+	}
+
+	/**
+	 * @testdox build_script_registry includes third-party plugin scripts.
+	 */
+	public function test_build_script_registry_includes_plugin_scripts(): void {
+		// Register a third-party plugin script.
+		wp_register_script(
+			'test-plugin-script',
+			'https://example.com/wp-content/plugins/my-plugin/script.js',
+			array( 'wc-blocks-checkout' ),
+			'1.0.0',
+			true
+		);
+
+		$result = $this->invoke_private_method( 'build_script_registry', array() );
+
+		// Find the script in the registry by checking for normalized URL.
+		$found = false;
+		foreach ( $result as $url => $info ) {
+			if ( strpos( $url, 'my-plugin/script.js' ) !== false ) {
+				$found = true;
+				$this->assertSame( 'test-plugin-script', $info['handle'] );
+				$this->assertContains( 'wc-blocks-checkout', $info['deps'] );
+				break;
+			}
+		}
+		$this->assertTrue( $found, 'Third-party plugin script should be in registry' );
+	}
+
+	/**
+	 * @testdox build_script_registry excludes WooCommerce core scripts.
+	 */
+	public function test_build_script_registry_excludes_woocommerce_scripts(): void {
+		$result = $this->invoke_private_method( 'build_script_registry', array() );
+
+		// Check that no WooCommerce core scripts are in the registry.
+		foreach ( $result as $url => $info ) {
+			$this->assertStringNotContainsString(
+				'/plugins/woocommerce/client/',
+				$url,
+				'WooCommerce client scripts should be excluded'
+			);
+			$this->assertStringNotContainsString(
+				'/plugins/woocommerce/assets/',
+				$url,
+				'WooCommerce assets scripts should be excluded'
+			);
+			$this->assertStringNotContainsString(
+				'/plugins/woocommerce/build/',
+				$url,
+				'WooCommerce build scripts should be excluded'
+			);
+		}
+	}
+
+	/**
+	 * @testdox build_script_registry excludes WordPress core scripts.
+	 */
+	public function test_build_script_registry_excludes_wordpress_scripts(): void {
+		$result = $this->invoke_private_method( 'build_script_registry', array() );
+
+		// Check that no WordPress core scripts are in the registry.
+		foreach ( $result as $url => $info ) {
+			$this->assertStringNotContainsString(
+				'/wp-includes/',
+				$url,
+				'WordPress wp-includes scripts should be excluded'
+			);
+			$this->assertStringNotContainsString(
+				'/wp-admin/',
+				$url,
+				'WordPress wp-admin scripts should be excluded'
+			);
+		}
+	}
+
+	/**
+	 * @testdox build_script_registry normalizes URLs by removing query strings.
+	 */
+	public function test_build_script_registry_normalizes_urls(): void {
+		// Register a script (WordPress adds version query string automatically).
+		wp_register_script(
+			'test-plugin-script',
+			'https://example.com/wp-content/plugins/my-plugin/script.js',
+			array(),
+			'1.0.0',
+			true
+		);
+
+		$result = $this->invoke_private_method( 'build_script_registry', array() );
+
+		// Check that URLs don't have query strings.
+		foreach ( $result as $url => $info ) {
+			$this->assertStringNotContainsString(
+				'?',
+				$url,
+				'Registry URLs should not contain query strings'
+			);
+		}
+	}
+
+	/**
+	 * @testdox output_early_proxy_setup outputs nothing when no tracked blocks are present.
+	 */
+	public function test_output_early_proxy_setup_outputs_nothing_without_tracked_blocks(): void {
+		// Create a post without any WooCommerce blocks.
+		$post_id = $this->factory->post->create(
+			array(
+				'post_content' => '<!-- wp:paragraph --><p>Hello World</p><!-- /wp:paragraph -->',
+			)
+		);
+		$this->go_to( get_permalink( $post_id ) );
+
+		ob_start();
+		$this->sut->output_early_proxy_setup();
+		$output = ob_get_clean();
+
+		$this->assertEmpty( $output );
+	}
+
+	/**
+	 * @testdox output_script_registry outputs nothing when proxy was not output.
+	 */
+	public function test_output_script_registry_outputs_nothing_without_proxy(): void {
+		// Don't call output_early_proxy_setup first.
+		ob_start();
+		$this->sut->output_script_registry();
+		$output = ob_get_clean();
+
+		$this->assertEmpty( $output );
+	}
+}