diff --git a/assets/src/css/admin/settings.css b/assets/src/css/admin/settings.css
index 75fa13b..43e0434 100644
--- a/assets/src/css/admin/settings.css
+++ b/assets/src/css/admin/settings.css
@@ -1529,6 +1529,136 @@
border-bottom: 0;
}
+.perform-plugin-impact {
+ display: grid;
+ gap: 24px;
+}
+
+.perform-plugin-impact > *,
+.perform-plugin-impact .components-card__body {
+ min-width: 0;
+}
+
+.perform-plugin-impact__heading {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 24px;
+ padding-bottom: 24px;
+ border-bottom: 1px solid var(--perform-color-border);
+}
+
+.perform-plugin-impact__heading h2 {
+ margin: 0 0 8px;
+ font-size: 24px;
+}
+
+.perform-plugin-impact__heading p:not(.perform-dashboard-eyebrow),
+.perform-plugin-impact__contract p {
+ margin: 0;
+ color: var(--perform-color-text-muted);
+ line-height: 1.5;
+}
+
+.perform-plugin-impact__contract {
+ padding: 16px 20px;
+ border-left: 4px solid var(--perform-color-brand);
+ background: #f6f7f7;
+}
+
+.perform-plugin-impact__contract p {
+ margin-top: 6px;
+}
+
+.perform-plugin-impact__summary {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ border-top: 1px solid var(--perform-color-border);
+ border-bottom: 1px solid var(--perform-color-border);
+}
+
+.perform-plugin-impact__summary > div {
+ padding: 20px 24px;
+ border-right: 1px solid var(--perform-color-border);
+}
+
+.perform-plugin-impact__summary > div:first-child {
+ padding-left: 0;
+}
+
+.perform-plugin-impact__summary > div:last-child {
+ border-right: 0;
+}
+
+.perform-plugin-impact__summary span,
+.perform-plugin-impact__summary small {
+ display: block;
+ color: var(--perform-color-text-muted);
+ font-size: 12px;
+ font-weight: 600;
+}
+
+.perform-plugin-impact__summary strong {
+ display: block;
+ margin: 8px 0 4px;
+ font-size: 24px;
+}
+
+.perform-plugin-impact__table-wrap {
+ max-width: 100%;
+ overflow-x: auto;
+}
+
+.perform-plugin-impact table {
+ width: 100%;
+ border-collapse: collapse;
+ text-align: left;
+}
+
+.perform-plugin-impact th,
+.perform-plugin-impact td {
+ padding: 14px 12px;
+ border-bottom: 1px solid var(--perform-color-border);
+ vertical-align: top;
+}
+
+.perform-plugin-impact thead th {
+ color: var(--perform-color-text-muted);
+ font-size: 12px;
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.perform-plugin-impact th small {
+ display: block;
+ margin-top: 4px;
+ color: var(--perform-color-text-muted);
+ font-weight: 400;
+}
+
+.perform-plugin-impact td span {
+ display: inline-flex;
+ padding: 3px 8px;
+ border-radius: 12px;
+ background: #f6f7f7;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.perform-plugin-impact td span.is-measured {
+ background: #edfaef;
+ color: var(--perform-color-success);
+}
+
+.perform-plugin-impact__unavailable {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 12px 24px;
+ margin: 0;
+ padding-left: 20px;
+ color: var(--perform-color-text-muted);
+}
+
@media screen and (max-width: 782px) {
.perform-settings-page {
@@ -1729,6 +1859,93 @@
border-bottom: 0;
}
+ .perform-plugin-impact__heading {
+ flex-direction: column;
+ }
+
+ .perform-plugin-impact__heading .components-button {
+ justify-content: center;
+ width: 100%;
+ }
+
+ .perform-plugin-impact__summary,
+ .perform-plugin-impact__unavailable {
+ grid-template-columns: minmax(0, 1fr);
+ }
+
+ .perform-plugin-impact__summary > div,
+ .perform-plugin-impact__summary > div:first-child {
+ padding: 16px 0;
+ border-right: 0;
+ border-bottom: 1px solid var(--perform-color-border);
+ }
+
+ .perform-plugin-impact__summary > div:last-child {
+ border-bottom: 0;
+ }
+
+ .perform-plugin-impact__table-wrap {
+ overflow: visible;
+ }
+
+ .perform-plugin-impact table,
+ .perform-plugin-impact tbody,
+ .perform-plugin-impact tr,
+ .perform-plugin-impact th,
+ .perform-plugin-impact td {
+ display: block;
+ width: 100%;
+ }
+
+ .perform-plugin-impact thead {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ .perform-plugin-impact tbody tr {
+ margin-top: 16px;
+ border: 1px solid var(--perform-color-border);
+ }
+
+ .perform-plugin-impact tbody tr:first-child {
+ margin-top: 0;
+ }
+
+ .perform-plugin-impact tbody th,
+ .perform-plugin-impact tbody td {
+ box-sizing: border-box;
+ padding: 12px 14px;
+ }
+
+ .perform-plugin-impact tbody th {
+ background: #f6f7f7;
+ }
+
+ .perform-plugin-impact tbody td {
+ display: grid;
+ grid-template-columns: minmax(92px, 0.45fr) minmax(0, 1fr);
+ gap: 12px;
+ }
+
+ .perform-plugin-impact tbody td::before {
+ content: attr(data-label);
+ color: var(--perform-color-text-muted);
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ }
+
+ .perform-plugin-impact tbody tr > :last-child {
+ border-bottom: 0;
+ }
+
.perform-admin-monitor__table-wrap {
overflow: visible;
}
diff --git a/assets/src/js/admin/PluginImpactPanel.jsx b/assets/src/js/admin/PluginImpactPanel.jsx
new file mode 100644
index 0000000..f2b30de
--- /dev/null
+++ b/assets/src/js/admin/PluginImpactPanel.jsx
@@ -0,0 +1,186 @@
+import { Button, Card, CardBody, CardHeader } from '@wordpress/components';
+import { __, sprintf } from '@wordpress/i18n';
+
+const SETTINGS = window.performwpSettings || {};
+
+const PluginImpactPanel = ( { report = SETTINGS.pluginImpact || {}, onNavigate } ) => {
+ const items = report.items || [];
+ const audit = report.adminAssetAudit || {};
+ const inventoryReady = 'ready' === report.status;
+ let primaryAction = (
+ onNavigate?.( 'admin-assets' ) }>
+ { __( 'View admin asset evidence', 'perform' ) }
+
+ );
+ if ( ! inventoryReady ) {
+ primaryAction = (
+ onNavigate?.( 'inventory' ) }>
+ { __( 'Generate site inventory', 'perform' ) }
+
+ );
+ } else if ( ! audit.enabled ) {
+ primaryAction = (
+ onNavigate?.( 'advanced' ) }>
+ { __( 'Enable admin asset audit', 'perform' ) }
+
+ );
+ }
+
+ return (
+
+
+
+
{ __( 'Measured local evidence', 'perform' ) }
+
{ __( 'Plugin impact', 'perform' ) }
+
+ { __(
+ 'Connect active plugins to admin asset pressure that Perform has actually observed on this site.',
+ 'perform'
+ ) }
+
+
+ { primaryAction }
+
+
+
+
{ __( 'Evidence, not a plugin ranking', 'perform' ) }
+
+ { __(
+ 'Asset presence does not prove a plugin is slow or that its files are safe to disable. Perform does not attribute query, callback, or memory cost to a plugin without a dedicated measurement contract.',
+ 'perform'
+ ) }
+
+
+
+
+
+ { __( 'Active plugins', 'perform' ) }
+ { report.activeCount ?? items.length }
+ { __( 'From site inventory', 'perform' ) }
+
+
+ { __( 'Sampled admin screens', 'perform' ) }
+ { audit.sampledScreens ?? 0 }
+
+ { audit.enabled ? __( 'Audit enabled', 'perform' ) : __( 'Audit not enabled', 'perform' ) }
+
+
+
+ { __( 'Plugin signals', 'perform' ) }
+ { items.filter( ( item ) => item.hasEvidence ).length }
+ { __( 'High-confidence source matches', 'perform' ) }
+
+
+
+ { ! inventoryReady && (
+
+
+ { __( 'Site inventory is required', 'perform' ) }
+
+ { __(
+ 'Generate the private site inventory before matching measured handles to active plugins.',
+ 'perform'
+ ) }
+
+
+
+ ) }
+ { inventoryReady && 0 === items.length && (
+
+
+ { __( 'No active plugins are available in the bounded inventory', 'perform' ) }
+ { __( 'Refresh Site Inventory if the active plugin list has changed.', 'perform' ) }
+
+
+ ) }
+
+ { inventoryReady && items.length > 0 && (
+
+
+
+
{ __( 'Observed admin asset presence', 'perform' ) }
+
+ { sprintf(
+ /* translators: %d: number of sampled admin screens. */
+ __(
+ 'Based on %d sampled admin screens. Zero means not observed in this sample, not zero impact.',
+ 'perform'
+ ),
+ audit.sampledScreens ?? 0
+ ) }
+
+
+
+
+
+
+
+
+ { __( 'Plugin', 'perform' ) }
+ { __( 'Screens', 'perform' ) }
+ { __( 'Scripts', 'perform' ) }
+ { __( 'Styles', 'perform' ) }
+ { __( 'Evidence', 'perform' ) }
+
+
+
+ { items.map( ( item ) => (
+
+
+ { item.name }
+
+ { item.slug }
+ { item.version ? ` · ${ item.version }` : '' }
+
+
+
+ { item.adminAssets?.screenCount || 0 }
+
+
+ { item.adminAssets?.scriptCount || 0 }
+
+
+ { item.adminAssets?.styleCount || 0 }
+
+
+
+ { item.hasEvidence
+ ? __( 'Measured locally', 'perform' )
+ : __( 'Not observed', 'perform' ) }
+
+
+
+ ) ) }
+
+
+
+
+
+ ) }
+
+
+
+
+
{ __( 'Not attributed in this release', 'perform' ) }
+
+ { __(
+ 'These signals need dedicated instrumentation before Perform can connect them to a plugin reliably.',
+ 'perform'
+ ) }
+
+
+
+
+
+ { __( 'Frontend asset ownership and request context', 'perform' ) }
+ { __( 'Database query contribution by plugin', 'perform' ) }
+ { __( 'Callback execution time by plugin', 'perform' ) }
+ { __( 'Memory contribution by plugin', 'perform' ) }
+
+
+
+
+ );
+};
+
+export default PluginImpactPanel;
diff --git a/assets/src/js/admin/PluginImpactPanel.test.jsx b/assets/src/js/admin/PluginImpactPanel.test.jsx
new file mode 100644
index 0000000..0f3271e
--- /dev/null
+++ b/assets/src/js/admin/PluginImpactPanel.test.jsx
@@ -0,0 +1,62 @@
+/* eslint-env jest */
+
+import '@testing-library/jest-dom';
+import { fireEvent, render, screen } from '@testing-library/react';
+
+const PluginImpactPanel = require( './PluginImpactPanel' ).default;
+
+describe( 'PluginImpactPanel', () => {
+ it( 'requires inventory before presenting plugin claims', () => {
+ const navigate = jest.fn();
+ render(
+
+ );
+ expect( screen.getByText( 'Site inventory is required' ) ).toBeInTheDocument();
+ fireEvent.click( screen.getByRole( 'button', { name: 'Generate site inventory' } ) );
+ expect( navigate ).toHaveBeenCalledWith( 'inventory' );
+ } );
+
+ it( 'separates measured asset presence from unavailable attribution', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'Evidence, not a plugin ranking' ) ).toBeInTheDocument();
+ expect( screen.getByText( /Zero means not observed in this sample/ ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Database query contribution by plugin' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Callback execution time by plugin' ) ).toBeInTheDocument();
+ expect( screen.getAllByText( 'Measured locally' ) ).toHaveLength( 1 );
+ } );
+
+ it( 'handles an empty active-plugin inventory', () => {
+ render(
+
+ );
+ expect( screen.getByText( 'No active plugins are available in the bounded inventory' ) ).toBeInTheDocument();
+ } );
+} );
diff --git a/assets/src/js/admin/SettingsApp.jsx b/assets/src/js/admin/SettingsApp.jsx
index 52d094f..20545e8 100644
--- a/assets/src/js/admin/SettingsApp.jsx
+++ b/assets/src/js/admin/SettingsApp.jsx
@@ -16,6 +16,7 @@ const SYSTEM_HEALTH = SETTINGS.systemHealth || {};
const ADMIN_PERFORMANCE = SETTINGS.adminPerformance || {};
const CRON_PRESSURE = SETTINGS.cronPressure || {};
const ADMIN_ASSET_AUDIT = SETTINGS.adminAssetAudit || {};
+const PLUGIN_IMPACT = SETTINGS.pluginImpact || {};
const TAB_KEYS = [ 'dashboard', ...Object.keys( SETTINGS_TABS ) ];
const normalizeTab = ( tab ) => ( TAB_KEYS.includes( tab ) ? tab : 'dashboard' );
@@ -185,6 +186,7 @@ const SettingsApp = () => {
adminPerformance={ ADMIN_PERFORMANCE }
cronPressure={ CRON_PRESSURE }
adminAssetAudit={ ADMIN_ASSET_AUDIT }
+ pluginImpact={ PLUGIN_IMPACT }
activeTab={ activeTab }
onTabChange={ handleTabChange }
fieldValues={ fieldValues }
@@ -199,6 +201,7 @@ const SettingsApp = () => {
'admin-monitor',
'scheduled-tasks',
'admin-assets',
+ 'plugin-impact',
].includes( activeTab ) && (
) }
diff --git a/assets/src/js/admin/SettingsNav.jsx b/assets/src/js/admin/SettingsNav.jsx
index bf73652..ddbda24 100644
--- a/assets/src/js/admin/SettingsNav.jsx
+++ b/assets/src/js/admin/SettingsNav.jsx
@@ -7,6 +7,7 @@ import DatabaseAuditPanel from './DatabaseAuditPanel';
import SiteInventoryPanel from './SiteInventoryPanel';
import CronPressurePanel from './CronPressurePanel';
import AdminAssetAuditPanel from './AdminAssetAuditPanel';
+import PluginImpactPanel from './PluginImpactPanel';
import SettingsFieldRow from './settings/SettingsFieldRow';
const SETTINGS = window.performwpSettings || {};
@@ -22,6 +23,7 @@ const SettingsNav = ( {
adminPerformance,
cronPressure,
adminAssetAudit,
+ pluginImpact,
activeTab: propActiveTab,
onTabChange: propOnTabChange,
fieldValues: propFieldValues,
@@ -136,6 +138,8 @@ const SettingsNav = ( {
content = (
);
+ } else if ( 'plugin-impact' === selectedTabName ) {
+ content = ;
} else if ( 'cache-stats' === selectedTabName ) {
content = ;
}
diff --git a/docs/plugin-impact-report.md b/docs/plugin-impact-report.md
new file mode 100644
index 0000000..7733e38
--- /dev/null
+++ b/docs/plugin-impact-report.md
@@ -0,0 +1,14 @@
+# Plugin impact report
+
+The Plugin Impact report combines two existing local evidence sources: the active-plugin Site Inventory and the opt-in Admin Asset Audit.
+
+It reports high-confidence admin script and style ownership by plugin slug and sampled screen. It does not rank plugins, call an extension slow, or imply that an observed asset is safe to disable.
+
+Perform intentionally reports the following as unavailable in this release:
+
+- frontend asset ownership by request context;
+- database query contribution by plugin;
+- callback execution time by plugin;
+- memory contribution by plugin.
+
+Those signals require dedicated instrumentation and validation before they can support a reliable customer decision. No external benchmark or telemetry service is used.
diff --git a/languages/perform.pot b/languages/perform.pot
index d09c618..0e6cb6d 100644
--- a/languages/perform.pot
+++ b/languages/perform.pot
@@ -7,7 +7,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language-Team: PerformWP \n"
-"POT-Creation-Date: 2026-08-11 04:41+0000\n"
+"POT-Creation-Date: 2026-08-11 05:00+0000\n"
"Report-Msgid-Bugs-To: https://github.com/performwp/perform/issues/new\n"
"X-Poedit-Basepath: ..\n"
"X-Poedit-KeywordsList: __;_e;_ex:1,2c;_n:1,2;_n_noop:1,2;_nx:1,2,4c;_nx_noop:1,2,3c;_x:1,2c;esc_attr__;esc_attr_e;esc_attr_x:1,2c;esc_html__;esc_html_e;esc_html_x:1,2c\n"
@@ -16,63 +16,63 @@ msgstr ""
"X-Poedit-SourceCharset: UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
-#: src/Admin/Actions.php:100
+#: src/Admin/Actions.php:102
msgid "Activity actions"
msgstr ""
-#: src/Admin/Actions.php:101
+#: src/Admin/Actions.php:103
msgid "Export cache activity for review or clear the collected metrics."
msgstr ""
-#: src/Admin/Actions.php:102
+#: src/Admin/Actions.php:104
msgid "Export CSV"
msgstr ""
-#: src/Admin/Actions.php:103
+#: src/Admin/Actions.php:105
msgid "Exporting…"
msgstr ""
-#: src/Admin/Actions.php:104
+#: src/Admin/Actions.php:106
msgid "Cache activity exported."
msgstr ""
-#: src/Admin/Actions.php:105
+#: src/Admin/Actions.php:107
msgid "Cache activity could not be exported."
msgstr ""
-#: src/Admin/Actions.php:106
+#: src/Admin/Actions.php:108
msgid "Clear activity"
msgstr ""
-#: src/Admin/Actions.php:107
+#: src/Admin/Actions.php:109
msgid "Clearing…"
msgstr ""
-#: src/Admin/Actions.php:108
+#: src/Admin/Actions.php:110
msgid "Clear all collected cache activity? This does not clear cached pages."
msgstr ""
-#: src/Admin/Actions.php:109, src/Modules/Cache/PageCache.php:828, src/Modules/Cache/PageCache.php:1090
+#: src/Admin/Actions.php:111, src/Modules/Cache/PageCache.php:828, src/Modules/Cache/PageCache.php:1090
msgid "Cache activity cleared."
msgstr ""
-#: src/Admin/Actions.php:110
+#: src/Admin/Actions.php:112
msgid "Cache activity could not be cleared."
msgstr ""
-#: src/Admin/Actions.php:139
+#: src/Admin/Actions.php:141
msgid "Close Assets Manager"
msgstr ""
-#: src/Admin/Actions.php:136, src/Modules/Assets/AssetsManager.php:274
+#: src/Admin/Actions.php:138, src/Modules/Assets/AssetsManager.php:274
msgid "Assets Manager"
msgstr ""
-#: src/Admin/Actions.php:146, src/Admin/Settings/Menu.php:40, src/Admin/Settings/Menu.php:41, src/Modules/Assets/AssetsManager.php:254
+#: src/Admin/Actions.php:148, src/Admin/Settings/Menu.php:40, src/Admin/Settings/Menu.php:41, src/Modules/Assets/AssetsManager.php:254
msgid "Perform"
msgstr ""
-#: src/Admin/Actions.php:166
+#: src/Admin/Actions.php:168
msgid "Support Forum"
msgstr ""
@@ -700,7 +700,7 @@ msgstr ""
msgid "You are not allowed to clear admin asset audit data."
msgstr ""
-#: src/Admin/Settings/AdminAssetAuditController.php:29, src/Admin/Settings/AdminPerformanceMonitorController.php:36, src/Admin/Settings/AutoloadOptionsAuditController.php:48, src/Admin/Settings/CronPressureAuditController.php:84, src/Admin/Settings/Menu.php:125
+#: src/Admin/Settings/AdminAssetAuditController.php:29, src/Admin/Settings/AdminPerformanceMonitorController.php:36, src/Admin/Settings/AutoloadOptionsAuditController.php:48, src/Admin/Settings/CronPressureAuditController.php:84, src/Admin/Settings/Menu.php:126
msgid "Security check failed."
msgstr ""
@@ -744,7 +744,7 @@ msgstr ""
msgid "Unknown"
msgstr ""
-#: src/Admin/Settings/AutoloadOptionsAuditController.php:38, src/Admin/Settings/CronPressureAuditController.php:79, src/Admin/Settings/Menu.php:114, src/Admin/Settings/SiteInventoryController.php:59
+#: src/Admin/Settings/AutoloadOptionsAuditController.php:38, src/Admin/Settings/CronPressureAuditController.php:79, src/Admin/Settings/Menu.php:115, src/Admin/Settings/SiteInventoryController.php:59
msgid "Insufficient permissions."
msgstr ""
@@ -841,18 +841,22 @@ msgid "Admin Assets"
msgstr ""
#: src/Admin/Settings/Menu.php:59
-msgid "Scheduled Tasks"
+msgid "Plugin Impact"
msgstr ""
#: src/Admin/Settings/Menu.php:60
+msgid "Scheduled Tasks"
+msgstr ""
+
+#: src/Admin/Settings/Menu.php:61
msgid "Cache Stats"
msgstr ""
-#: src/Admin/Settings/Menu.php:241
+#: src/Admin/Settings/Menu.php:242
msgid "Unable to save the settings. Please try again."
msgstr ""
-#: src/Admin/Settings/Menu.php:233
+#: src/Admin/Settings/Menu.php:234
msgid "Settings saved successfully."
msgstr ""
diff --git a/src/Admin/Actions.php b/src/Admin/Actions.php
index 4eca198..977fcb7 100644
--- a/src/Admin/Actions.php
+++ b/src/Admin/Actions.php
@@ -21,6 +21,7 @@
use Perform\Admin\Settings\SiteInventory;
use Perform\Admin\Settings\SystemHealth;
use Perform\Admin\Settings\CronPressureAudit;
+use Perform\Admin\Settings\PluginImpactReport;
// Bailout, if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
@@ -80,6 +81,7 @@ public function registerAssets() {
'adminMonitorNonce' => wp_create_nonce( 'perform_clear_admin_performance_monitor' ),
'adminAssetAudit' => AdminAssetAudit::get_snapshot(),
'adminAssetAuditNonce' => wp_create_nonce( 'perform_clear_admin_asset_audit' ),
+ 'pluginImpact' => PluginImpactReport::get_data(),
'databaseAuditNonce' => wp_create_nonce( 'perform_refresh_autoload_options_audit' ),
'siteInventory' => SiteInventory::get_cached_snapshot(),
'siteInventoryUrl' => esc_url_raw( rest_url( 'perform/v1/site-inventory' ) ),
diff --git a/src/Admin/Settings/Menu.php b/src/Admin/Settings/Menu.php
index 78a9b41..cc25063 100644
--- a/src/Admin/Settings/Menu.php
+++ b/src/Admin/Settings/Menu.php
@@ -56,6 +56,7 @@ public static function get_navigation_tabs() {
$tabs['database'] = esc_html__( 'Database', 'perform' );
$tabs['admin-monitor'] = esc_html__( 'Admin Monitor', 'perform' );
$tabs['admin-assets'] = esc_html__( 'Admin Assets', 'perform' );
+ $tabs['plugin-impact'] = esc_html__( 'Plugin Impact', 'perform' );
$tabs['scheduled-tasks'] = esc_html__( 'Scheduled Tasks', 'perform' );
$tabs['cache-stats'] = esc_html__( 'Cache Stats', 'perform' );
diff --git a/src/Admin/Settings/PluginImpactReport.php b/src/Admin/Settings/PluginImpactReport.php
new file mode 100644
index 0000000..a58b67f
--- /dev/null
+++ b/src/Admin/Settings/PluginImpactReport.php
@@ -0,0 +1,146 @@
+|null $inventory Optional inventory snapshot.
+ * @param array|null $asset_audit Optional admin asset snapshot.
+ * @return array
+ */
+ public static function get_data( $inventory = null, $asset_audit = null ) {
+ $inventory = is_array( $inventory ) ? $inventory : SiteInventory::get_cached_snapshot();
+ $asset_audit = is_array( $asset_audit ) ? $asset_audit : AdminAssetAudit::get_snapshot();
+ $plugins = is_array( $inventory['plugins']['items'] ?? null ) ? $inventory['plugins']['items'] : [];
+ $plugins = array_values(
+ array_filter(
+ array_slice( $plugins, 0, self::MAX_PLUGINS ),
+ static function ( $plugin ) {
+ return is_array( $plugin ) && ! empty( $plugin['active'] );
+ }
+ )
+ );
+
+ $evidence = self::aggregate_admin_assets( (array) ( $asset_audit['screens'] ?? [] ) );
+ $items = [];
+ foreach ( $plugins as $plugin ) {
+ $slug = sanitize_key( is_scalar( $plugin['slug'] ?? null ) ? (string) $plugin['slug'] : '' );
+ if ( '' === $slug ) {
+ continue;
+ }
+ $signals = $evidence[ $slug ] ?? [
+ 'screens' => [],
+ 'scripts' => [],
+ 'styles' => [],
+ ];
+ $items[] = [
+ 'slug' => $slug,
+ 'name' => sanitize_text_field( is_scalar( $plugin['name'] ?? null ) ? (string) $plugin['name'] : $slug ),
+ 'version' => sanitize_text_field( is_scalar( $plugin['version'] ?? null ) ? (string) $plugin['version'] : '' ),
+ 'adminAssets' => [
+ 'screenCount' => count( $signals['screens'] ),
+ 'scriptCount' => count( $signals['scripts'] ),
+ 'styleCount' => count( $signals['styles'] ),
+ 'screens' => array_values( array_slice( array_keys( $signals['screens'] ), 0, AdminAssetAudit::MAX_SCREENS ) ),
+ ],
+ 'hasEvidence' => ! empty( $signals['screens'] ),
+ 'evidenceState' => ! empty( $signals['screens'] ) ? 'measured-locally' : 'not-observed',
+ ];
+ }
+
+ usort(
+ $items,
+ static function ( $left, $right ) {
+ $asset_compare = ( (int) $right['adminAssets']['scriptCount'] + (int) $right['adminAssets']['styleCount'] ) <=> ( (int) $left['adminAssets']['scriptCount'] + (int) $left['adminAssets']['styleCount'] );
+ return 0 !== $asset_compare ? $asset_compare : strcasecmp( (string) $left['name'], (string) $right['name'] );
+ }
+ );
+
+ $inventory_ready = 'ready' === ( $inventory['status'] ?? '' );
+ $audit_enabled = ! empty( $asset_audit['enabled'] );
+ $sample_count = count( (array) ( $asset_audit['screens'] ?? [] ) );
+
+ return [
+ 'status' => $inventory_ready ? 'ready' : 'inventory-needed',
+ 'scope' => 'site',
+ 'isMultisite' => ! empty( $inventory['isMultisite'] ),
+ 'items' => $items,
+ 'activeCount' => max( 0, (int) ( $inventory['plugins']['activeCount'] ?? count( $items ) ) ),
+ 'isTruncated' => ! empty( $inventory['plugins']['isTruncated'] ),
+ 'adminAssetAudit' => [
+ 'enabled' => $audit_enabled,
+ 'sampledScreens' => $sample_count,
+ 'state' => $audit_enabled && $sample_count > 0 ? 'measured-locally' : 'not-measured',
+ ],
+ 'unavailable' => [
+ 'frontendAssets' => 'not-measured',
+ 'queries' => 'not-attributed',
+ 'callbacks' => 'not-attributed',
+ 'memory' => 'not-attributed',
+ ],
+ ];
+ }
+
+ /**
+ * Aggregate only asset handles already attributed with high confidence.
+ *
+ * @param array $screens Prepared admin asset screens.
+ * @return array>>
+ */
+ private static function aggregate_admin_assets( array $screens ) {
+ $evidence = [];
+ foreach ( array_slice( $screens, 0, AdminAssetAudit::MAX_SCREENS ) as $screen ) {
+ if ( ! is_array( $screen ) ) {
+ continue;
+ }
+ $raw_screen_id = is_scalar( $screen['id'] ?? null ) ? (string) $screen['id'] : '';
+ $screen_id = sanitize_key( $raw_screen_id );
+ if ( '' === $screen_id || $screen_id !== $raw_screen_id ) {
+ continue;
+ }
+ foreach ( array_slice( (array) ( $screen['assets'] ?? [] ), 0, AdminAssetAudit::MAX_ASSETS ) as $asset ) {
+ if ( ! is_array( $asset ) || 'high' !== ( $asset['confidence'] ?? '' ) ) {
+ continue;
+ }
+ $raw_source = is_scalar( $asset['source'] ?? null ) ? (string) $asset['source'] : '';
+ $raw_handle = is_scalar( $asset['handle'] ?? null ) ? (string) $asset['handle'] : '';
+ $source = sanitize_key( $raw_source );
+ $handle = sanitize_key( $raw_handle );
+ $type = $asset['type'] ?? '';
+ if ( '' === $source || $source !== $raw_source || '' === $handle || $handle !== $raw_handle || ! in_array( $type, [ 'script', 'style' ], true ) ) {
+ continue;
+ }
+
+ if ( ! isset( $evidence[ $source ] ) ) {
+ $evidence[ $source ] = [
+ 'screens' => [],
+ 'scripts' => [],
+ 'styles' => [],
+ ];
+ }
+ $evidence[ $source ]['screens'][ $screen_id ] = true;
+ $bucket = 'script' === $type ? 'scripts' : 'styles';
+ $evidence[ $source ][ $bucket ][ $handle ] = true;
+ }
+ }
+
+ return $evidence;
+ }
+}
diff --git a/tests/e2e/perform-smoke.spec.js b/tests/e2e/perform-smoke.spec.js
index e0970da..4e49a13 100644
--- a/tests/e2e/perform-smoke.spec.js
+++ b/tests/e2e/perform-smoke.spec.js
@@ -259,6 +259,41 @@ test( 'Admin asset audit is opt-in, bounded, private, clearable, and responsive'
await expect( page.getByText( /Settings saved/ ) ).toBeVisible();
} );
+test( 'Plugin impact report separates measured local evidence from unavailable attribution', async ( { page } ) => {
+ await login( page );
+ await openAdminPage( page, '/wp-admin/options-general.php?page=perform_settings&tab=advanced' );
+ const auditToggle = page.getByRole( 'checkbox', { name: 'Admin Asset Audit' } );
+ if ( ! ( await auditToggle.isChecked() ) ) {
+ await auditToggle.check();
+ }
+ await page.getByRole( 'button', { name: 'Save Settings' } ).click();
+ await expect( page.getByText( /Settings saved/ ) ).toBeVisible();
+
+ await openAdminPage( page, '/wp-admin/options-general.php?page=perform_settings&tab=general' );
+ await openAdminPage( page, '/wp-admin/options-general.php?page=perform_settings&tab=plugin-impact' );
+ await expect( page.getByRole( 'tab', { name: 'Plugin Impact' } ) ).toHaveAttribute( 'aria-selected', 'true' );
+ await expect( page.getByText( 'Evidence, not a plugin ranking' ) ).toBeVisible();
+ await expect( page.getByText( /does not attribute query, callback, or memory cost/ ) ).toBeVisible();
+ await expect( page.getByText( /Zero means not observed in this sample/ ) ).toBeVisible();
+ await expect( page.getByText( 'Database query contribution by plugin' ) ).toBeVisible();
+ await expect( page.getByText( 'Callback execution time by plugin' ) ).toBeVisible();
+ await expect( page.getByRole( 'button', { name: 'Save Settings' } ) ).toHaveCount( 0 );
+
+ await mkdir( 'test-results/proof', { recursive: true } );
+ await page.screenshot( { path: 'test-results/proof/plugin-impact-desktop.png', fullPage: true } );
+ await page.setViewportSize( { width: 390, height: 844 } );
+ await page.screenshot( { path: 'test-results/proof/plugin-impact-mobile.png', fullPage: true } );
+ const hasHorizontalOverflow = await page.evaluate(
+ () => document.documentElement.scrollWidth > document.documentElement.clientWidth
+ );
+ expect( hasHorizontalOverflow ).toBeFalsy();
+
+ await page.getByRole( 'tab', { name: 'Advanced' } ).click();
+ await page.getByRole( 'checkbox', { name: 'Admin Asset Audit' } ).uncheck();
+ await page.getByRole( 'button', { name: 'Save Settings' } ).click();
+ await expect( page.getByText( /Settings saved/ ) ).toBeVisible();
+} );
+
test( 'Cache Stats tab preserves legacy routing, actions, and keyboard access', async ( { page } ) => {
await login( page );
diff --git a/tests/unit-tests/tests-plugin-impact-report.php b/tests/unit-tests/tests-plugin-impact-report.php
new file mode 100644
index 0000000..6bd529c
--- /dev/null
+++ b/tests/unit-tests/tests-plugin-impact-report.php
@@ -0,0 +1,132 @@
+ 'not-run' ],
+ [
+ 'enabled' => false,
+ 'screens' => [],
+ ]
+ );
+
+ $this->assertSame( 'inventory-needed', $report['status'] );
+ $this->assertSame( [], $report['items'] );
+ $this->assertSame( 'not-measured', $report['adminAssetAudit']['state'] );
+ }
+
+ public function test_joins_only_high_confidence_measured_admin_assets_to_active_plugins() {
+ $inventory = $this->inventory(
+ [
+ $this->plugin( 'quiet', 'Quiet Plugin', true ),
+ $this->plugin( 'sample', 'Sample Plugin', true ),
+ $this->plugin( 'inactive', 'Inactive Plugin', false ),
+ ]
+ );
+ $audit = [
+ 'enabled' => true,
+ 'screens' => [
+ [
+ 'id' => 'dashboard',
+ 'assets' => [ $this->asset( 'script', 'sample-admin', 'sample', 'high' ), $this->asset( 'style', 'uncertain', 'quiet', 'low' ) ],
+ ],
+ [
+ 'id' => 'edit-post',
+ 'assets' => [ $this->asset( 'script', 'sample-admin', 'sample', 'high' ), $this->asset( 'style', 'sample-style', 'sample', 'high' ) ],
+ ],
+ ],
+ ];
+
+ $report = PluginImpactReport::get_data( $inventory, $audit );
+
+ $this->assertSame( 'ready', $report['status'] );
+ $this->assertCount( 2, $report['items'] );
+ $this->assertSame( 'sample', $report['items'][0]['slug'] );
+ $this->assertSame( 2, $report['items'][0]['adminAssets']['screenCount'] );
+ $this->assertSame( 1, $report['items'][0]['adminAssets']['scriptCount'] );
+ $this->assertSame( 1, $report['items'][0]['adminAssets']['styleCount'] );
+ $this->assertSame( 'not-observed', $report['items'][1]['evidenceState'] );
+ $this->assertSame( 'not-attributed', $report['unavailable']['queries'] );
+ $this->assertSame( 'not-attributed', $report['unavailable']['memory'] );
+ }
+
+ public function test_many_plugins_remain_bounded_and_privacy_safe() {
+ $plugins = [];
+ for ( $index = 0; $index < 110; ++$index ) {
+ $plugins[] = $this->plugin( 'plugin-' . $index, 'Plugin ' . $index, true );
+ }
+ $report = PluginImpactReport::get_data(
+ $this->inventory( $plugins, true ),
+ [
+ 'enabled' => true,
+ 'screens' => [
+ [
+ 'id' => 'settings?nonce=secret',
+ 'assets' => [ $this->asset( 'script', 'private?token=secret', 'plugin-1', 'high' ) ],
+ ],
+ ],
+ ]
+ );
+
+ $this->assertLessThanOrEqual( PluginImpactReport::MAX_PLUGINS, count( $report['items'] ) );
+ $this->assertTrue( $report['isTruncated'] );
+ $encoded = wp_json_encode( $report );
+ $this->assertStringNotContainsString( 'nonce', $encoded );
+ $this->assertStringNotContainsString( 'secret', $encoded );
+ $this->assertStringNotContainsString( 'token', $encoded );
+ }
+
+ public function test_multisite_scope_is_explicitly_current_site() {
+ $inventory = $this->inventory( [ $this->plugin( 'sample', 'Sample', true ) ] );
+ $inventory['isMultisite'] = true;
+ $report = PluginImpactReport::get_data(
+ $inventory,
+ [
+ 'enabled' => false,
+ 'screens' => [],
+ ]
+ );
+
+ $this->assertTrue( $report['isMultisite'] );
+ $this->assertSame( 'site', $report['scope'] );
+ }
+
+ private function inventory( array $plugins, $truncated = false ) {
+ return [
+ 'status' => 'ready',
+ 'plugins' => [
+ 'activeCount' => count(
+ array_filter(
+ $plugins,
+ static function ( $plugin ) {
+ return ! empty( $plugin['active'] ); }
+ )
+ ),
+ 'items' => $plugins,
+ 'isTruncated' => $truncated,
+ ],
+ 'isMultisite' => false,
+ ];
+ }
+
+ private function plugin( $slug, $name, $active ) {
+ return [
+ 'slug' => $slug,
+ 'name' => $name,
+ 'version' => '1.0',
+ 'active' => $active,
+ ];
+ }
+
+ private function asset( $type, $handle, $source, $confidence ) {
+ return [
+ 'type' => $type,
+ 'handle' => $handle,
+ 'source' => $source,
+ 'confidence' => $confidence,
+ ];
+ }
+}