Repository: GoogleChrome/lighthouse
Pull request #10303 (2020-02-06 21:32:14)

core(legacy-javascript): new audit
(previous PR #6730 has too many comments, so here's a new one).

# New Audit: `legacy-javascript` 

**Goal: Encourage developers to serve modern code to modern browsers.**

[Design Doc](https://docs.google.com/document/d/1ItjJwAd6e0Ts6yMbvh8TN3BBh_sAd58rYE1whnpuxaA/edit)

This audit fails if legacy code is found in any first party code. The recommended action is to use the [module/nomodule pattern](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/), in addition to [@babel/preset-modules](https://github.com/babel/preset-modules) (instead of @babel/preset-env).

This first pass does not add this audit to the default config.

## Legacy code, defined

Legacy code contains either polyfills or transformed code for features that modern browsers don't need. "modern browser" is defined as a browser that support ES modules. The relevant polyfills / transforms are in this [spreadsheet](https://docs.google.com/spreadsheets/d/1z28Au8wo8-c2UsM2lDVEOJcI3jOkb2c951xEBqzBKCc/edit).

Polyfills overwrite native prototypes in regular patterns (`String.prototype.startsWith = ...`, `Object.defineProperty(String.prototype, 'startsWith', ...`) or via common libraries (core-js). The patterns that emerge are fairly detectable with a [regex](https://imgflip.com/tag/regex). The major focus was on detecting core-js polyfills, although some effort was made to detect simple ways of polyfilling.

Only some of the transforms are detectable. I went with 3 that were simple and common.

## Woah, [regex](https://gist.githubusercontent.com/connorjclark/542c7ed3727e28cb4ad6ba9bc9109bc2/raw/311a1752a71ffdc09a09e7c4a973c577d78914fb/gistfile1.js). Does it even work?

I wrote a tool to create a bunch of babel projects, varying on individual polyfills / transforms. The results of the tool are also checked into git. All that takes too long to run in CI, so it's just there as a manual verification. Any updates to the audit should also run that tool and check for changes in precision.

## See it

http://misc-hoten.surge.sh/lh-legacy-javascript-pr/www.wikipedia.org_2020-02-06_13-20-38.report.html
http://misc-hoten.surge.sh/lh-legacy-javascript-pr/www.wix.com_2020-02-06_13-27-56.report.html

## Follow up work

1. Fix layout for subRows
1. Use third-party-web
1. Use source maps
1. web.dev article
1. Enable by default
1. Estimate savings to make an opportunity

## Runtime cost

```sh
node lighthouse-core/scripts/compare-timings.js --name my-collection --collect -n 3 --lh-flags='--only-audits=legacy-javascript' --urls https://www.coursehero.com/sg https://www.wix.com https://www.wikipedia.org
node lighthouse-core/scripts/compare-timings.js --name my-collection --summarize --measure-filter 'legacy-javascript'
```

![image](https://user-images.githubusercontent.com/4071474/73978794-e5e43080-48e1-11ea-92ad-aa1cac823526.png)



connorjclark: I found that table to be unreadable in GitHub's restricted width styling. paste the markdown here for a better view: https://dillinger.io/

![image](https://user-images.githubusercontent.com/4071474/74282312-c3348c00-4cd4-11ea-94db-5e5fd712d378.png)

connorjclark: Thanks Patrick, those `Map=/Set=/etc` false positives were especially egregious. I've modified the regexs and added some false positive tests.
patrickhulce: Looking through some more of the output of https://github.com/GoogleChrome/lighthouse/pull/10327 I wonder if it's even worth surfacing things like `Date.now`, I mean what's that saving, like 28 characters? (though "summary-sizes" seems to think the polyfill is 7KB.... how!?) `Date.now=()=>+new Date`
connorjclark: > Looking through some more of the output of #10327 I wonder if it's even worth surfacing things like `Date.now`, I mean what's that saving, like 28 characters? (though "summary-sizes" seems to think the polyfill is 7KB.... how!?) `Date.now=()=>+new Date`

I see this audit as about collecting signals that imply a non-ideal configuration of babel. Taken in isolation, any one of these polyfills might be implemented in an incredibly minimal way. If you add the entire corejs overhead, you might get more than you'd expect (Date.now being 7KB).

Is there a legit reason first party code might polyfill Date.now, without it coming from an overzealous babel transpilation? If not, I'd want to keep it, as it's an additional signal.
patrickhulce: > Is there a legit reason first party code might polyfill Date.now, without it coming from an overzealous babel transpilation?

I can't think of a reason *first-party* code that's using a build system would do this, but the noise it generates in third-party view is the reason I bring it up. I think you may have said at one point that this only really applies to files built with a build system so maybe what I'm reaching is the same conclusion that we should ignore all files that don't appear to be module bundles.
connorjclark: I see. Let me explore some sites myself and see how noisy it is. If it is unusually more common than the others, I think it makes sense to ignore `Date.now`.

> I think you may have said at one point that this only really applies to files built with a build system so maybe what I'm reaching is the same conclusion that we should ignore all files that don't appear to be module bundles.

Yeah, I did have initial concerns about this. They came from a desire of wanting this to be "easily" actionable - if the file is a bundle, it is safe to assume they are using a modern toolchain, and adding / tweaking babel isn't a huge ordeal. It would amount to detecting specific bundle bootstraps (Webpack, Parcel, etc.) / presence of a source map comment. IIRC, @paulirish didn't like the idea, I don't remember why.
patrickhulce: @paulirish how do you feel about only surfacing from bundle-like files now? can I convince you?
connorjclark: resolved another false positive found from running on paul's site.

Here's what Wikipedia gives

```
URL: https://www.wikipedia.org/portal/wikipedia.org/assets/js/index-c1cb7f1287.js
Polyfills: 2

Array.prototype.indexOf at 0:1753
indexOf||(Array.prototype.indexOf=function(e,t){var n;if(null==this)throw new TypeError('"

Function.prototype.bind at 0:2580
pe.bind||(Function.prototype.bind=function(e){if("function"!=typeof this)throw new TypeErr
```

Should ignore those too, I think.
connorjclark: https://www.redditstatic.com/mweb2x/vendors~Mweb.15c8b87d53fe9156dd3a.js

ctrl + f "node_modules"

never seen that in production before (full module paths)
![image](https://user-images.githubusercontent.com/4071474/74401577-8c469f00-4dd6-11ea-9834-642b2918f768.png)

patrickhulce: @connorjclark you're planning on adding "waiting4reviewer" with a specific call to action when you feel you're blocked on us again, correct?
connorjclark: > Smidge better UX for the empty table problem when all polyfills are 3p (doesn't need designer or anything maybe it's just that we don't enable the filter by default?)

I think the `notApplicable` change handles this, right?
patrickhulce: > I think the notApplicable change handles this, right?

Is the table still empty without a message once you expand the notapplicable audit? That seems to be an equivalent problem to the passing audit with an empty table.
connorjclark: NVM, I had strange expectations for what notApplicable does :)

fixed the empty table thing. also, noticed that the row counting logic was wrong - it assumed that each row would have only one url item type. that is not true, so I changed it to count `tr`s.

Patch:
diff --git a/.eslintignore b/.eslintignore
index 2005468ac..1f2d7ebda 100644
--- a/.eslintignore
+++ b/.eslintignore
@@ -12,3 +12,5 @@
 coverage/**
 
 !.eslintrc.js
+
+lighthouse-core/scripts/legacy-javascript/variants
diff --git a/.gitignore b/.gitignore
index 5d81a6f89..77724bcd6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -32,6 +32,8 @@ last-run-results.html
 !lighthouse-core/test/fixtures/traces/**/*.trace.json
 !lighthouse-core/test/fixtures/traces/**/*.devtoolslog.json
 
+lighthouse-core/scripts/legacy-javascript/variants
+
 latest-run
 lantern-data
 timings-data
diff --git a/.travis.yml b/.travis.yml
index f21b1c3f1..fea438d02 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -29,8 +29,6 @@ before_script:
   - google-chrome-stable --version
   - yarn build-all
 script:
-  # Fail if any changes were written to source files (ex, from: build/build-cdt-lib.js).
-  - git diff --exit-code
   - yarn bundlesize
   - yarn diff:sample-json
   - yarn lint
@@ -40,10 +38,14 @@ script:
   - yarn test-clients
   - yarn test-viewer
   - yarn test-lantern
+  - yarn test-legacy-javascript
   - yarn test-bundle
   - yarn i18n:checks
   - yarn dogfood-lhci
   - yarn test-docs
+
+  # Fail if any changes were written to source files (ex, from: build/build-cdt-lib.js).
+  - git diff --exit-code
 before_cache:
   # nyc, jest and other projects store files in here. They mess up the travis build cache.
   - rm -rf ./node_modules/.cache/
diff --git a/build/build-extension.js b/build/build-extension.js
index d4735538d..b93d155ca 100644
--- a/build/build-extension.js
+++ b/build/build-extension.js
@@ -13,11 +13,15 @@ const cpy = require('cpy');
 const browserify = require('browserify');
 const path = require('path');
 
+const argv = process.argv.slice(2);
+const browserBrand = argv[0];
+
 const sourceName = 'popup.js';
 const distName = 'popup-bundle.js';
 
-const sourceDir = __dirname + '/../clients/extension';
-const distDir = __dirname + '/../dist/extension';
+const sourceDir = `${__dirname}/../clients/extension`;
+const distDir = `${__dirname}/../dist/extension-${browserBrand}`;
+const packagePath = `${distDir}/../extension-${browserBrand}-package`;
 
 const manifestVersion = require(`${sourceDir}/manifest.json`).version;
 
@@ -30,13 +34,17 @@ async function buildEntryPoint() {
   const bundleStream = browserify(inFile).bundle();
 
   await mkdir(path.dirname(outFile), {recursive: true});
-  return new Promise((resolve, reject) => {
+  await new Promise((resolve, reject) => {
     const writeStream = fs.createWriteStream(outFile);
     writeStream.on('finish', resolve);
     writeStream.on('error', reject);
 
     bundleStream.pipe(writeStream);
   });
+
+  let outCode = fs.readFileSync(outFile, 'utf-8');
+  outCode = outCode.replace('___BROWSER_BRAND___', browserBrand);
+  fs.writeFileSync(outFile, outCode);
 }
 
 /**
@@ -48,7 +56,6 @@ function copyAssets() {
     'styles/**/*.css',
     'images/**/*',
     'manifest.json',
-    '_locales/**', // currently non-functional
   ], distDir, {
     cwd: sourceDir,
     parents: true,
@@ -61,7 +68,6 @@ function copyAssets() {
  * @return {Promise<void>}
  */
 async function packageExtension() {
-  const packagePath = `${distDir}/../extension-package`;
   await mkdir(packagePath, {recursive: true});
 
   return new Promise((resolve, reject) => {
@@ -81,15 +87,12 @@ async function packageExtension() {
 }
 
 async function run() {
-  const argv = process.argv.slice(2);
-  if (argv.includes('package')) {
-    return packageExtension();
-  }
-
   await Promise.all([
     buildEntryPoint(),
     copyAssets(),
   ]);
+
+  await packageExtension();
 }
 
 run();
diff --git a/clients/extension/_locales/en/messages.json b/clients/extension/_locales/en/messages.json
deleted file mode 100644
index 8e35fe957..000000000
--- a/clients/extension/_locales/en/messages.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "appName": {
-    "message": "Lighthouse",
-    "description": "Helping you avoid Progressive Web App woes."
-  },
-  "appDescription": {
-    "message": "Lighthouse",
-    "description": "Helping you avoid Progressive Web App woes."
-  }
-}
diff --git a/clients/extension/_locales/en/messages_canary.json b/clients/extension/_locales/en/messages_canary.json
deleted file mode 100644
index 92c4d9fa6..000000000
--- a/clients/extension/_locales/en/messages_canary.json
+++ /dev/null
@@ -1,10 +0,0 @@
-{
-  "appName": {
-    "message": "Lighthouse Canary",
-    "description": "Alpha: Helping you avoid Progressive Web App woes."
-  },
-  "appDescription": {
-    "message": "Lighthouse Canary",
-    "description": "Alpha: Helping you avoid Progressive Web App woes."
-  }
-}
diff --git a/clients/extension/manifest.json b/clients/extension/manifest.json
index 218f3a4c0..c537cd5cd 100644
--- a/clients/extension/manifest.json
+++ b/clients/extension/manifest.json
@@ -1,14 +1,13 @@
 {
-  "name": "__MSG_appName__",
-  "version": "100.0.0.0",
+  "name": "Lighthouse",
+  "version": "100.0.0.1",
   "minimum_chrome_version": "72",
   "manifest_version": 2,
-  "description": "__MSG_appDescription__",
+  "description": "Lighthouse is an open-source, automated tool for improving the performance, quality, and correctness of your web apps.",
   "icons": {
     "16": "images/lh_favicon_16x16.png",
     "128": "images/lh_logo_128x128.png"
   },
-  "default_locale": "en",
   "permissions": [
     "activeTab",
     "storage"
diff --git a/clients/extension/popup.html b/clients/extension/popup.html
index c099cc440..fae278fa8 100644
--- a/clients/extension/popup.html
+++ b/clients/extension/popup.html
@@ -28,13 +28,21 @@ Unless required by applicable law or agreed to in writing, software distributed
       <span class="psi-disclaimer"><a href="https://developers.google.com/speed/docs/insights/v5/get-started?utm_source=lh-chrome-ext" title="https://developers.google.com/speed/docs/insights/v5/get-started" target="_blank" rel="noopener nofollow">Uses the PSI API</a></span>
       <div class="errormsg"></div>
     </div>
-    <div class="section section--devtools-info">
-      <h2 class="devtools-header">Chrome DevTools</h2>
-      <span class="devtools-note">
-        You can also run Lighthouse via the DevTools Audits panel.
-        <br><br>
-        Shortcut to open DevTools: <span class="devtools-shortcut"><!-- filled dynamically --></span>
-      </span>
+    <div class="section section--secondary-panel">
+      <div class="hidden browser-brand browser-brand--chrome">
+        <h2 class="section--header">Chrome DevTools</h2>
+        <span class="section--description">
+          You can also run Lighthouse via the DevTools Audits panel.
+          <br><br>
+          Shortcut to open DevTools: <span class="devtools-shortcut"><!-- filled dynamically --></span>
+        </span>
+      </div>
+      <div class="hidden browser-brand browser-brand--firefox">
+        <h2 class="section--header">Node CLI</h2>
+        <span class="section--description">
+          You can also run Lighthouse via the <a href="https://github.com/GoogleChrome/lighthouse#using-the-node-cli">Node CLI</a>.
+        </span>
+      </div>
     </div>
   </main>
 
diff --git a/clients/extension/scripts/popup.js b/clients/extension/scripts/popup.js
index 47c93c482..fefd0d72e 100644
--- a/clients/extension/scripts/popup.js
+++ b/clients/extension/scripts/popup.js
@@ -9,6 +9,19 @@ const SettingsController = require('./settings-controller.js');
 
 const VIEWER_URL = 'https://googlechrome.github.io/lighthouse/viewer/';
 const optionsVisibleClass = 'main--options-visible';
+// Replaced with 'chrome' or 'firefox' in the build script.
+/** @type {string} */
+const BROWSER_BRAND = '___BROWSER_BRAND___';
+
+const CHROME_STRINGS = {
+  localhostErrorMessage: 'Use DevTools to audit pages on localhost.',
+};
+
+const FIREFOX_STRINGS = {
+  localhostErrorMessage: 'Use the Lighthouse Node CLI to audit pages on localhost.',
+};
+
+const STRINGS = BROWSER_BRAND === 'chrome' ? CHROME_STRINGS : FIREFOX_STRINGS;
 
 /**
  * Guaranteed context.querySelector. Always returns an element or throws if
@@ -120,7 +133,7 @@ function getSiteUrl() {
 
       const url = new URL(tabs[0].url);
       if (url.hostname === 'localhost') {
-        reject(new Error('Use DevTools to audit pages on localhost.'));
+        reject(new Error(STRINGS.localhostErrorMessage));
       } else if (/^(chrome|about)/.test(url.protocol)) {
         reject(new Error(`Cannot audit ${url.protocol}// pages.`));
       } else {
@@ -134,7 +147,11 @@ function getSiteUrl() {
  * Initializes the popup's state and UI elements.
  */
 async function initPopup() {
-  fillDevToolsShortcut();
+  if (BROWSER_BRAND === 'chrome') {
+    fillDevToolsShortcut();
+  }
+  const browserBrandEl = find(`.browser-brand--${BROWSER_BRAND}`);
+  browserBrandEl.classList.remove('hidden');
 
   const mainEl = find('main');
   const optionsEl = find('.button--configure');
diff --git a/clients/extension/styles/lighthouse.css b/clients/extension/styles/lighthouse.css
index 44e43470e..17e9779eb 100644
--- a/clients/extension/styles/lighthouse.css
+++ b/clients/extension/styles/lighthouse.css
@@ -46,6 +46,10 @@ body {
   padding: 16px;
 }
 
+.hidden {
+  display: none;
+}
+
 .header__icon {
   margin: 2px auto 0;
 }
@@ -102,13 +106,13 @@ body {
   padding-bottom: 10px;
 }
 
-.devtools-header {
+.section-header {
   font-size: var(--font-size);
   font-weight: 500;
   margin-bottom: 5px;
 }
 
-.devtools-note {
+.section--description {
   font-size: 14px;
   color: grey;
 }
diff --git a/docs/releasing.md b/docs/releasing.md
index a3b5b681f..ab8b233ca 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -140,7 +140,7 @@ npm publish
 # Publish viewer.
 yarn deploy-viewer
 
-# Publish the extension (if it changed).
+# Publish the extensions (if it changed).
 open https://chrome.google.com/webstore/developer/edit/blipmdconlkpinefehnmjammfjpmpbjk
 cd dist/extension-package/
 echo "Upload the package zip to CWS dev dashboard..."
@@ -151,6 +151,8 @@ echo "Upload the package zip to CWS dev dashboard..."
 # Select `lighthouse-X.X.X.X.zip`
 # _Publish_ at the bottom
 
+# For Firefox: https://addons.mozilla.org/en-US/developers/addon/google-lighthouse/versions/submit/
+
 # * Tell the world!!! *
 echo "Complete the _Release publicity_ tasks documented above"
 
diff --git a/lighthouse-core/audits/legacy-javascript.js b/lighthouse-core/audits/legacy-javascript.js
new file mode 100644
index 000000000..53c795d72
--- /dev/null
+++ b/lighthouse-core/audits/legacy-javascript.js
@@ -0,0 +1,372 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * @fileoverview Identifies polyfills and transforms that should not be present if using module/nomodule pattern.
+ * @see https://docs.google.com/document/d/1ItjJwAd6e0Ts6yMbvh8TN3BBh_sAd58rYE1whnpuxaA/edit Design document
+ * @see https://docs.google.com/spreadsheets/d/1z28Au8wo8-c2UsM2lDVEOJcI3jOkb2c951xEBqzBKCc/edit?usp=sharing Legacy babel transforms / polyfills
+ * ./lighthouse-core/scripts/legacy-javascript - verification tool.
+ */
+
+/** @typedef {{name: string, expression: string}} Pattern */
+/** @typedef {{name: string, line: number, column: number}} PatternMatchResult */
+
+const Audit = require('./audit.js');
+const NetworkRecords = require('../computed/network-records.js');
+const MainResource = require('../computed/main-resource.js');
+const URL = require('../lib/url-shim.js');
+const i18n = require('../lib/i18n/i18n.js');
+
+const UIStrings = {
+  /** Title of a Lighthouse audit that tells the user about legacy polyfills and transforms used on the page. This is displayed in a list of audit titles that Lighthouse generates. */
+  title: 'Legacy JavaScript',
+  // eslint-disable-next-line max-len
+  // TODO: web.dev article. this codelab is good starting place: https://web.dev/codelab-serve-modern-code/
+  /** Description of a Lighthouse audit that tells the user about old JavaScript that is no longer needed. This is displayed after a user expands the section to see more. No character length limits. 'Learn More' becomes link text to additional documentation. */
+  description: 'Polyfills and transforms enable legacy browsers to use new JavaScript features. However, many aren\'t necessary for modern browsers. For your bundled JavaScript, adopt a modern script deployment strategy using module/nomodule feature detection to reduce the amount of code shipped to modern browsers, while retaining support for legacy browsers. [Learn More](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)',
+};
+
+const str_ = i18n.createMessageInstanceIdFn(__filename, UIStrings);
+
+/**
+ * Takes a list of patterns (consisting of a name identifier and a RegExp expression string)
+ * and returns match results with line / column information for a given code input.
+ */
+class CodePatternMatcher {
+  /**
+   * @param {Pattern[]} patterns
+   */
+  constructor(patterns) {
+    const patternsExpression = patterns.map(pattern => `(${pattern.expression})`).join('|');
+    this.re = new RegExp(`(^\r\n|\r|\n)|${patternsExpression}`, 'g');
+    this.patterns = patterns;
+  }
+
+  /**
+   * @param {string} code
+   * @return {PatternMatchResult[]}
+   */
+  match(code) {
+    // Reset RegExp state.
+    this.re.lastIndex = 0;
+
+    const seen = new Set();
+    /** @type {PatternMatchResult[]} */
+    const matches = [];
+    /** @type {RegExpExecArray | null} */
+    let result;
+    let line = 0;
+    let lineBeginsAtIndex = 0;
+    // Each pattern maps to one subgroup in the generated regex. For each iteration of RegExp.exec,
+    // only one subgroup will be defined. Exec until no more matches.
+    while ((result = this.re.exec(code)) !== null) {
+      // Discard first value in `result` - it's just the entire match.
+      const captureGroups = result.slice(1);
+      // isNewline - truthy if matching a newline, used to track the line number.
+      // `patternExpressionMatches` maps to each possible pattern in `this.patterns`.
+      // Only one of [isNewline, ...patternExpressionMatches] is ever truthy.
+      const [isNewline, ...patternExpressionMatches] = captureGroups;
+      if (isNewline) {
+        line++;
+        lineBeginsAtIndex = result.index + 1;
+        continue;
+      }
+      const pattern = this.patterns[patternExpressionMatches.findIndex(Boolean)];
+
+      // Don't report more than one instance of a pattern for this code.
+      // Would result in multiple matches for the same pattern, ex: if both '='
+      // and 'Object.defineProperty' are used conditionally based on feature detection.
+      // Would also result in many matches for transform patterns.
+      if (seen.has(pattern)) {
+        continue;
+      }
+      seen.add(pattern);
+
+      matches.push({
+        name: pattern.name,
+        line,
+        column: result.index - lineBeginsAtIndex,
+      });
+    }
+
+    return matches;
+  }
+}
+
+class LegacyJavascript extends Audit {
+  /**
+   * @return {LH.Audit.Meta}
+   */
+  static get meta() {
+    return {
+      id: 'legacy-javascript',
+      scoreDisplayMode: Audit.SCORING_MODES.INFORMATIVE,
+      description: str_(UIStrings.description),
+      title: str_(UIStrings.title),
+      requiredArtifacts: ['devtoolsLogs', 'ScriptElements', 'URL'],
+    };
+  }
+
+  /**
+   * @param {string?} object
+   * @param {string} property
+   */
+  static buildPolyfillExpression(object, property) {
+    const qt = (/** @type {string} */ token) =>
+      `['"]${token}['"]`; // don't worry about matching string delims
+
+    let expression = '';
+
+    if (object) {
+      // String.prototype.startsWith =
+      expression += `${object}\\.${property}\\s?=[^=]`;
+    } else {
+      // Promise =
+      // window.Promise =// Promise =Z
+      // but not: SomePromise =
+      expression += `(?:window\\.|[\\s;]+)${property}\\s?=[^=]`;
+    }
+
+    // String.prototype['startsWith'] =
+    if (object) {
+      expression += `|${object}\\[${qt(property)}\\]\\s?=[^=]`;
+    }
+
+    // Object.defineProperty(String.prototype, 'startsWith'
+    expression += `|defineProperty\\(${object || 'window'},\\s?${qt(property)}`;
+
+    // core-js
+    if (object) {
+      const objectWithoutPrototype = object.replace('.prototype', '');
+      // e(e.S,"Object",{values
+      // Minified + mangled pattern found in CDN babel-polyfill.
+      // see https://cdnjs.cloudflare.com/ajax/libs/babel-polyfill/7.2.5/polyfill.min.js
+      // TODO: perhaps this is the wrong place to check for a CDN polyfill. Remove?
+      // expression += `|;e\\([^,]+,${qt(objectWithoutPrototype)},{${property}:`;
+
+      // Minified pattern.
+      // $export($export.S,"Date",{now:function
+      expression += `|\\$export\\([^,]+,${qt(objectWithoutPrototype)},{${property}:`;
+    } else {
+      // WeakSet, etc.
+      expression += `|function ${property}\\(`;
+    }
+
+    return expression;
+  }
+
+  /**
+   * @return {Pattern[]}
+   */
+  static getPolyfillPatterns() {
+    return [
+      'Array.fill',
+      'Array.from',
+      'Array.isArray',
+      'Array.of',
+      'Array.prototype.filter',
+      'Array.prototype.find',
+      'Array.prototype.findIndex',
+      'Array.prototype.forEach',
+      'Array.prototype.includes',
+      'Array.prototype.lastIndexOf',
+      'Array.prototype.map',
+      'Array.prototype.reduce',
+      'Array.prototype.reduceRight',
+      'Array.prototype.some',
+      'ArrayBuffer',
+      'DataView',
+      'Date.now',
+      'Date.prototype.toISOString',
+      'Date.prototype.toJSON',
+      'Date.prototype.toString',
+      'Float32Array',
+      'Float64Array',
+      'Function.prototype.name',
+      'Int16Array',
+      'Int32Array',
+      'Int8Array',
+      'Map',
+      'Number.isInteger',
+      'Number.isSafeInteger',
+      'Number.parseFloat',
+      'Number.parseInt',
+      'Object.assign',
+      'Object.create',
+      'Object.defineProperties',
+      'Object.defineProperty',
+      'Object.entries',
+      'Object.freeze',
+      'Object.getOwnPropertyDescriptor',
+      'Object.getOwnPropertyDescriptors',
+      'Object.getOwnPropertyNames',
+      'Object.getPrototypeOf',
+      'Object.isExtensible',
+      'Object.isFrozen',
+      'Object.isSealed',
+      'Object.keys',
+      'Object.preventExtensions',
+      'Object.seal',
+      'Object.setPrototypeOf',
+      'Object.values',
+      'Promise',
+      'Reflect.apply',
+      'Reflect.construct',
+      'Reflect.defineProperty',
+      'Reflect.deleteProperty',
+      'Reflect.get',
+      'Reflect.getOwnPropertyDescriptor',
+      'Reflect.getPrototypeOf',
+      'Reflect.has',
+      'Reflect.isExtensible',
+      'Reflect.ownKeys',
+      'Reflect.preventExtensions',
+      'Reflect.set',
+      'Reflect.setPrototypeOf',
+      'Set',
+      'String.fromCodePoint',
+      'String.prototype.codePointAt',
+      'String.prototype.endsWith',
+      'String.prototype.includes',
+      'String.prototype.padEnd',
+      'String.prototype.padStart',
+      'String.prototype.repeat',
+      'String.prototype.startsWith',
+      'String.prototype.trim',
+      'String.raw',
+      'Uint16Array',
+      'Uint32Array',
+      'Uint8Array',
+      'Uint8ClampedArray',
+      'WeakMap',
+      'WeakSet',
+    ].map(polyfillName => {
+      const parts = polyfillName.split('.');
+      const object = parts.length > 1 ? parts.slice(0, parts.length - 1).join('.') : null;
+      const property = parts[parts.length - 1];
+      return {
+        name: polyfillName,
+        expression: this.buildPolyfillExpression(object, property),
+      };
+    });
+  }
+
+  /**
+   * @return {Pattern[]}
+   */
+  static getTransformPatterns() {
+    return [
+      {
+        name: '@babel/plugin-transform-classes',
+        expression: 'Cannot call a class as a function',
+      },
+      {
+        name: '@babel/plugin-transform-regenerator',
+        expression: /regeneratorRuntime\.a?wrap/.source,
+      },
+      {
+        name: '@babel/plugin-transform-spread',
+        expression: /\.apply\(void 0,\s?_toConsumableArray/.source,
+      },
+    ];
+  }
+
+  /**
+   * Returns a collection of match results grouped by script url and with a mapping
+   * to determine the order in which the matches were discovered.
+   *
+   * @param {CodePatternMatcher} matcher
+   * @param {LH.GathererArtifacts['ScriptElements']} scripts
+   * @param {LH.Artifacts.NetworkRequest[]} networkRecords
+   * @return {Map<string, PatternMatchResult[]>}
+   */
+  static detectCodePatternsAcrossScripts(matcher, scripts, networkRecords) {
+    /** @type {Map<string, PatternMatchResult[]>} */
+    const urlToMatchResults = new Map();
+
+    for (const {requestId, content} of Object.values(scripts)) {
+      if (!content) continue;
+      const networkRecord = networkRecords.find(record => record.requestId === requestId);
+      if (!networkRecord) continue;
+      const matches = matcher.match(content);
+      if (!matches.length) continue;
+      urlToMatchResults.set(networkRecord.url, matches);
+    }
+
+    return urlToMatchResults;
+  }
+
+  /**
+   * @param {LH.Artifacts} artifacts
+   * @param {LH.Audit.Context} context
+   * @return {Promise<LH.Audit.Product>}
+   */
+  static async audit(artifacts, context) {
+    const devtoolsLog = artifacts.devtoolsLogs[LegacyJavascript.DEFAULT_PASS];
+    const networkRecords = await NetworkRecords.request(devtoolsLog, context);
+    const mainResource = await MainResource.request({
+      URL: artifacts.URL,
+      devtoolsLog,
+    }, context);
+
+    /** @type {Array<{url: string, signals: string[], locations: LH.Audit.Details.SourceLocationValue[]}>} */
+    const tableRows = [];
+    let signalCount = 0;
+
+    // TODO(cjamcl): Use SourceMaps, and only pattern match if maps are not available.
+    const matcher = new CodePatternMatcher([
+      ...this.getPolyfillPatterns(),
+      ...this.getTransformPatterns(),
+    ]);
+
+    const urlToMatchResults =
+      this.detectCodePatternsAcrossScripts(matcher, artifacts.ScriptElements, networkRecords);
+    urlToMatchResults.forEach((matches, url) => {
+      /** @type {typeof tableRows[number]} */
+      const row = {url, signals: [], locations: []};
+      for (const match of matches) {
+        const {name, line, column} = match;
+        row.signals.push(name);
+        row.locations.push({
+          type: 'source-location',
+          url,
+          line,
+          column,
+          urlProvider: 'network',
+        });
+      }
+      tableRows.push(row);
+      signalCount += row.signals.length;
+    });
+
+    /** @type {LH.Audit.Details.Table['headings']} */
+    const headings = [
+      /* eslint-disable max-len */
+      {key: 'url', itemType: 'url', subRows: {key: 'locations', itemType: 'source-location'}, text: str_(i18n.UIStrings.columnURL)},
+      {key: null, itemType: 'code', subRows: {key: 'signals'}, text: ''},
+      /* eslint-enable max-len */
+    ];
+    const details = Audit.makeTableDetails(headings, tableRows);
+
+    // Only fail if first party code has legacy code.
+    // TODO(cjamcl): Use third-party-web.
+    const foundSignalInFirstPartyCode = tableRows.some(row => {
+      return URL.rootDomainsMatch(row.url, mainResource.url);
+    });
+    return {
+      score: foundSignalInFirstPartyCode ? 0 : 1,
+      notApplicable: !foundSignalInFirstPartyCode,
+      extendedInfo: {
+        signalCount,
+      },
+      details,
+    };
+  }
+}
+
+module.exports = LegacyJavascript;
+module.exports.UIStrings = UIStrings;
diff --git a/lighthouse-core/config/experimental-config.js b/lighthouse-core/config/experimental-config.js
index 134d7c487..d45473374 100644
--- a/lighthouse-core/config/experimental-config.js
+++ b/lighthouse-core/config/experimental-config.js
@@ -20,16 +20,14 @@ const config = {
     ],
   }],
   audits: [
-    // About to be added.
-    // 'bundle-duplication',
+    'legacy-javascript',
   ],
   // @ts-ignore: `title` is required in CategoryJson. setting to the same value as the default
   // config is awkward - easier to omit the property here. Will defer to default config.
   categories: {
     'performance': {
       auditRefs: [
-        // About to be added.
-        // {id: 'bundle-duplication', weight: 0, group: 'load-opportunities'},
+        {id: 'legacy-javascript', weight: 0, group: 'diagnostics'},
       ],
     },
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-US.json b/lighthouse-core/lib/i18n/locales/en-US.json
index b8c437e5f..ea1a908df 100644
--- a/lighthouse-core/lib/i18n/locales/en-US.json
+++ b/lighthouse-core/lib/i18n/locales/en-US.json
@@ -788,6 +788,12 @@
   "lighthouse-core/audits/is-on-https.js | title": {
     "message": "Uses HTTPS"
   },
+  "lighthouse-core/audits/legacy-javascript.js | description": {
+    "message": "Polyfills and transforms enable legacy browsers to use new JavaScript features. However, many aren't necessary for modern browsers. For your bundled JavaScript, adopt a modern script deployment strategy using module/nomodule feature detection to reduce the amount of code shipped to modern browsers, while retaining support for legacy browsers. [Learn More](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)"
+  },
+  "lighthouse-core/audits/legacy-javascript.js | title": {
+    "message": "Legacy JavaScript"
+  },
   "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
     "message": "A fast page load over a cellular network ensures a good mobile user experience. [Learn more](https://web.dev/load-fast-enough-for-pwa)."
   },
diff --git a/lighthouse-core/lib/i18n/locales/en-XL.json b/lighthouse-core/lib/i18n/locales/en-XL.json
index 0d27fdd27..8e2e51e17 100644
--- a/lighthouse-core/lib/i18n/locales/en-XL.json
+++ b/lighthouse-core/lib/i18n/locales/en-XL.json
@@ -788,6 +788,12 @@
   "lighthouse-core/audits/is-on-https.js | title": {
     "message": "Ûśêś ĤT́T̂ṔŜ"
   },
+  "lighthouse-core/audits/legacy-javascript.js | description": {
+    "message": "P̂ól̂ýf̂íl̂ĺŝ án̂d́ t̂ŕâńŝf́ôŕm̂ś êńâb́l̂é l̂éĝáĉý b̂ŕôẃŝér̂ś t̂ó ûśê ńêẃ Ĵáv̂áŜćr̂íp̂t́ f̂éât́ûŕêś. Ĥóŵév̂ér̂, ḿâńŷ ár̂én̂'t́ n̂éĉéŝśâŕŷ f́ôŕ m̂ód̂ér̂ń b̂ŕôẃŝér̂ś. F̂ór̂ ýôúr̂ b́ûńd̂ĺêd́ Ĵáv̂áŜćr̂íp̂t́, âd́ôṕt̂ á m̂ód̂ér̂ń ŝćr̂íp̂t́ d̂ép̂ĺôým̂én̂t́ ŝt́r̂át̂éĝý ûśîńĝ ḿôd́ûĺê/ńôḿôd́ûĺê f́êát̂úr̂é d̂ét̂éĉt́îón̂ t́ô ŕêd́ûćê t́ĥé âḿôún̂t́ ôf́ ĉód̂é ŝh́îṕp̂éd̂ t́ô ḿôd́êŕn̂ b́r̂óŵśêŕŝ, ẃĥíl̂é r̂ét̂áîńîńĝ śûṕp̂ór̂t́ f̂ór̂ ĺêǵâćŷ b́r̂óŵśêŕŝ. [Ĺêár̂ń M̂ór̂é](https://philipwalton.com/articles/deploying-es2015-code-in-production-today/)"
+  },
+  "lighthouse-core/audits/legacy-javascript.js | title": {
+    "message": "L̂éĝáĉý Ĵáv̂áŜćr̂íp̂t́"
+  },
   "lighthouse-core/audits/load-fast-enough-for-pwa.js | description": {
     "message": "Â f́âśt̂ ṕâǵê ĺôád̂ óv̂ér̂ á ĉél̂ĺûĺâŕ n̂ét̂ẃôŕk̂ én̂śûŕêś â ǵôód̂ ḿôb́îĺê úŝér̂ éx̂ṕêŕîén̂ćê. [Ĺêár̂ń m̂ór̂é](https://web.dev/load-fast-enough-for-pwa)."
   },
diff --git a/lighthouse-core/report/html/renderer/details-renderer.js b/lighthouse-core/report/html/renderer/details-renderer.js
index 3f9a1d37a..582b7dcea 100644
--- a/lighthouse-core/report/html/renderer/details-renderer.js
+++ b/lighthouse-core/report/html/renderer/details-renderer.js
@@ -319,6 +319,11 @@ class DetailsRenderer {
     if (heading.subRows) {
       // @ts-ignore: It's ok that there is no text.
       subRows = this._getCanonicalizedHeading(heading.subRows);
+      if (!subRows.key) {
+        // eslint-disable-next-line no-console
+        console.warn('key should not be null');
+      }
+      subRows = {...subRows, key: subRows.key || ''};
     }
 
     return {
@@ -374,10 +379,21 @@ class DetailsRenderer {
       for (const heading of headings) {
         const valueFragment = this._dom.createFragment();
 
-        const value = row[heading.key];
-        const valueElement =
-          value !== undefined && !Array.isArray(value) && this._renderTableValue(value, heading);
-        if (valueElement) valueFragment.appendChild(valueElement);
+        if (heading.key === null && !heading.subRows) {
+          // eslint-disable-next-line no-console
+          console.warn('A header with a null `key` should define `subRows`.');
+        }
+
+        if (heading.key === null) {
+          const emptyElement = this._dom.createElement('div');
+          emptyElement.innerHTML = '&nbsp;';
+          valueFragment.appendChild(emptyElement);
+        } else {
+          const value = row[heading.key];
+          const valueElement =
+            value !== undefined && !Array.isArray(value) && this._renderTableValue(value, heading);
+          if (valueElement) valueFragment.appendChild(valueElement);
+        }
 
         if (heading.subRows) {
           const subRowsHeading = {
diff --git a/lighthouse-core/report/html/renderer/report-ui-features.js b/lighthouse-core/report/html/renderer/report-ui-features.js
index 50e543f0c..5ea22235a 100644
--- a/lighthouse-core/report/html/renderer/report-ui-features.js
+++ b/lighthouse-core/report/html/renderer/report-ui-features.js
@@ -216,6 +216,11 @@ class ReportUIFeatures {
       // This audit deals explicitly with third party resources.
       'uses-rel-preconnect',
     ];
+    // Some audits should hide third party by default.
+    const thirdPartyFilterAuditHideByDefault = [
+      // Only first party resources are actionable.
+      'legacy-javascript',
+    ];
 
     // Get all tables with a text url column.
     /** @type {Array<HTMLTableElement>} */
@@ -230,8 +235,8 @@ class ReportUIFeatures {
       });
 
     tablesWithUrls.forEach((tableEl, index) => {
-      const urlItems = this._getUrlItems(tableEl);
-      const thirdPartyRows = this._getThirdPartyRows(tableEl, urlItems, this.json.finalUrl);
+      const rowEls = getTableRows(tableEl);
+      const thirdPartyRows = this._getThirdPartyRows(rowEls, this.json.finalUrl);
 
       // create input box
       const filterTemplate = this._dom.cloneTemplate('#tmpl-lh-3p-filter', this._templateContext);
@@ -262,43 +267,52 @@ class ReportUIFeatures {
       this._dom.find('.lh-3p-ui-string', filterTemplate).textContent =
           Util.i18n.strings.thirdPartyResourcesLabel;
 
+      const allThirdParty = thirdPartyRows.size === rowEls.length;
+      const allFirstParty = !thirdPartyRows.size;
+
       // If all or none of the rows are 3rd party, disable the checkbox.
-      if (thirdPartyRows.size === urlItems.length || !thirdPartyRows.size) {
+      if (allThirdParty || allFirstParty) {
         filterInput.disabled = true;
-        filterInput.checked = thirdPartyRows.size === urlItems.length;
+        filterInput.checked = allThirdParty;
       }
 
-      // Finally, add checkbox to the DOM.
+      // Add checkbox to the DOM.
       if (!tableEl.parentNode) return; // Keep tsc happy.
       tableEl.parentNode.insertBefore(filterTemplate, tableEl);
+
+      // Hide third-party rows for some audits by default.
+      const containingAudit = tableEl.closest('.lh-audit');
+      if (!containingAudit) throw new Error('.lh-table not within audit');
+      if (thirdPartyFilterAuditHideByDefault.includes(containingAudit.id) && !allThirdParty) {
+        filterInput.click();
+      }
     });
   }
 
   /**
    * From a table with URL entries, finds the rows containing third-party URLs
    * and returns a Map of those rows, mapping from row index to row Element.
-   * @param {HTMLTableElement} el
+   * @param {HTMLElement[]} rowEls
    * @param {string} finalUrl
-   * @param {Array<HTMLElement>} urlItems
-   * @return {Map<number, HTMLTableRowElement>}
+   * @return {Map<number, HTMLElement>}
    */
-  _getThirdPartyRows(el, urlItems, finalUrl) {
+  _getThirdPartyRows(rowEls, finalUrl) {
+    /** @type {Map<number, HTMLElement>} */
+    const thirdPartyRows = new Map();
     const finalUrlRootDomain = Util.getRootDomain(finalUrl);
 
-    /** @type {Map<number, HTMLTableRowElement>} */
-    const thirdPartyRows = new Map();
-    for (const urlItem of urlItems) {
+    rowEls.forEach((rowEl, rowPosition) => {
+      /** @type {HTMLElement|null} */
+      const urlItem = rowEl.querySelector('.lh-text__url');
+      if (!urlItem) return;
+
       const datasetUrl = urlItem.dataset.url;
-      if (!datasetUrl) continue;
+      if (!datasetUrl) return;
       const isThirdParty = Util.getRootDomain(datasetUrl) !== finalUrlRootDomain;
-      if (!isThirdParty) continue;
+      if (!isThirdParty) return;
 
-      const urlRowEl = urlItem.closest('tr');
-      if (urlRowEl) {
-        const rowPosition = getTableRows(el).indexOf(urlRowEl);
-        thirdPartyRows.set(rowPosition, urlRowEl);
-      }
-    }
+      thirdPartyRows.set(Number(rowPosition), rowEl);
+    });
 
     return thirdPartyRows;
   }
diff --git a/lighthouse-core/report/html/report-styles.css b/lighthouse-core/report/html/report-styles.css
index 5ebc13306..6e0858f63 100644
--- a/lighthouse-core/report/html/report-styles.css
+++ b/lighthouse-core/report/html/report-styles.css
@@ -1410,6 +1410,9 @@
   margin-left: 20px;
   color: var(--color-gray-700);
 }
+.lh-sub-row {
+  margin-bottom: 0;
+}
 
 /* Chevron
    https://codepen.io/paulirish/pen/LmzEmK
diff --git a/lighthouse-core/scripts/git3po-rules/pr-assignee.yaml b/lighthouse-core/scripts/git3po-rules/pr-assignee.yaml
new file mode 100644
index 000000000..6990f0a38
--- /dev/null
+++ b/lighthouse-core/scripts/git3po-rules/pr-assignee.yaml
@@ -0,0 +1,22 @@
+# Assign the first requested reviewer
+repo: GoogleChrome/lighthouse
+pullRequests: true
+filters:
+  - type: issue
+    criteria:
+      state: open
+      createdAt:
+        # we started the single-reviewer policy around 2/1/2020
+        $gt: '%%date(feb 1 2020)%%'
+      'assignees.length':
+        $eq: 0
+      'reviewers.length':
+        $eq: 1
+  - type: label
+    label: chillin ❄
+    negate: true
+actions:
+  - type: add_assignee
+    login: '%%reviewer%%'
+  - type: add_label
+    label: waiting4reviewer
diff --git a/lighthouse-core/scripts/legacy-javascript/README.md b/lighthouse-core/scripts/legacy-javascript/README.md
new file mode 100644
index 000000000..88c8cd450
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/README.md
@@ -0,0 +1,21 @@
+# `legacy-javascript` Validation Tool
+
+Run:
+
+```sh
+yarn install
+node run.js
+# STAGE=build|audit|all to just build the audits or run LegacyJavascript on them. Defaults to both (`all`).
+```
+
+`summary-signals.json` - summarizes the signals that LegacyJavascript finds for each variant. Variants in `variantsMissingSignals` (excluding `core-js-3-preset-env-esmodules/true`) signify a lack of detection for that variant. Full coverage isn't necessary.
+
+`summary-sizes.json` - lists the size of each minified variant. Useful for understanding how many bytes each polyfill / transform adds.
+
+## Future Work
+
+* Use real apps to see how over-transpiling affects real code. Necessary for making an opprotunity.
+
+## Notes
+
+Digging into core-js: https://gist.github.com/connorjclark/cc583554ff07cba7cdc416c06721fd6a
diff --git a/lighthouse-core/scripts/legacy-javascript/main.js b/lighthouse-core/scripts/legacy-javascript/main.js
new file mode 100644
index 000000000..e4af66b2f
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/main.js
@@ -0,0 +1,9 @@
+// @ts-nocheck
+/* eslint-disable */
+
+class MyTestClass {};
+async function reg(...args) {
+  await 1;
+}
+const spread = [...[1,2,3], 3, 2, 1];
+reg(...spread);
diff --git a/lighthouse-core/scripts/legacy-javascript/package.json b/lighthouse-core/scripts/legacy-javascript/package.json
new file mode 100644
index 000000000..8a810079d
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/package.json
@@ -0,0 +1,12 @@
+{
+  "name": "legacy-javascript",
+  "license": "Apache-2.0",
+  "dependencies": {
+    "@babel/cli": "^7.8.4",
+    "@babel/core": "^7.8.4",
+    "@babel/preset-env": "^7.8.4",
+    "browserify": "^16.5.0",
+    "glob": "^7.1.6",
+    "terser": "^4.6.3"
+  }
+}
diff --git a/lighthouse-core/scripts/legacy-javascript/run.js b/lighthouse-core/scripts/legacy-javascript/run.js
new file mode 100644
index 000000000..dc6d1f80a
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/run.js
@@ -0,0 +1,321 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+const fs = require('fs');
+const glob = require('glob');
+const {execFileSync} = require('child_process');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+const networkRecordsToDevtoolsLog = require('../../test/network-records-to-devtools-log.js');
+const VARIANT_DIR = `${__dirname}/variants`;
+
+// build, audit, all.
+const STAGE = process.env.STAGE || 'all';
+
+const mainCode = fs.readFileSync(`${__dirname}/main.js`, 'utf-8');
+
+const plugins = LegacyJavascript.getTransformPatterns().map(pattern => pattern.name);
+
+// TODO(cjamcl): Get this from `LegacyJavascript`. Data isn't structured ideally yet, but should be
+// done when SourceMap support is in.
+const polyfills = [
+  'es6.array.copy-within',
+  'es6.array.every',
+  'es6.array.fill',
+  'es6.array.filter',
+  'es6.array.find-index',
+  'es6.array.find',
+  'es6.array.for-each',
+  'es6.array.from',
+  'es6.array.index-of',
+  'es6.array.is-array',
+  'es6.array.iterator',
+  'es6.array.last-index-of',
+  'es6.array.map',
+  'es6.array.of',
+  'es6.array.reduce-right',
+  'es6.array.reduce',
+  'es6.array.some',
+  'es6.array.species',
+  'es6.date.now',
+  'es6.date.to-iso-string',
+  'es6.date.to-json',
+  'es6.date.to-primitive',
+  'es6.date.to-string',
+  'es6.function.bind',
+  'es6.function.has-instance',
+  'es6.function.name',
+  'es6.map',
+  'es6.math.acosh',
+  'es6.math.asinh',
+  'es6.math.atanh',
+  'es6.math.cbrt',
+  'es6.math.clz32',
+  'es6.math.cosh',
+  'es6.math.expm1',
+  'es6.math.fround',
+  'es6.math.hypot',
+  'es6.math.imul',
+  'es6.math.log10',
+  'es6.math.log1p',
+  'es6.math.log2',
+  'es6.math.sign',
+  'es6.math.sinh',
+  'es6.math.tanh',
+  'es6.math.trunc',
+  'es6.number.constructor',
+  'es6.number.epsilon',
+  'es6.number.is-integer',
+  'es6.number.is-safe-integer',
+  'es6.number.max-safe-integer',
+  'es6.number.min-safe-integer',
+  'es6.number.parse-float',
+  'es6.number.parse-int',
+  'es6.object.assign',
+  'es6.object.create',
+  'es6.object.define-properties',
+  'es6.object.define-property',
+  'es6.object.freeze',
+  'es6.object.get-own-property-descriptor',
+  'es6.object.get-own-property-names',
+  'es6.object.get-prototype-of',
+  'es6.object.is-extensible',
+  'es6.object.is-frozen',
+  'es6.object.is-sealed',
+  'es6.object.keys',
+  'es6.object.prevent-extensions',
+  'es6.object.seal',
+  'es6.object.set-prototype-of',
+  'es6.object.to-string',
+  'es6.promise',
+  'es6.reflect.apply',
+  'es6.reflect.construct',
+  'es6.reflect.define-property',
+  'es6.reflect.delete-property',
+  'es6.reflect.get-own-property-descriptor',
+  'es6.reflect.get-prototype-of',
+  'es6.reflect.get',
+  'es6.reflect.has',
+  'es6.reflect.is-extensible',
+  'es6.reflect.own-keys',
+  'es6.reflect.prevent-extensions',
+  'es6.reflect.set-prototype-of',
+  'es6.reflect.set',
+  'es6.set',
+  'es6.string.code-point-at',
+  'es6.string.ends-with',
+  'es6.string.from-code-point',
+  'es6.string.includes',
+  'es6.string.iterator',
+  'es6.string.raw',
+  'es6.string.repeat',
+  'es6.string.starts-with',
+  'es6.string.trim',
+  'es6.typed.array-buffer',
+  'es6.typed.data-view',
+  'es6.typed.float32-array',
+  'es6.typed.float64-array',
+  'es6.typed.int16-array',
+  'es6.typed.int32-array',
+  'es6.typed.int8-array',
+  'es6.typed.uint16-array',
+  'es6.typed.uint32-array',
+  'es6.typed.uint8-array',
+  'es6.typed.uint8-clamped-array',
+  'es6.weak-map',
+  'es6.weak-set',
+  'es7.array.includes',
+  'es7.object.entries',
+  'es7.object.get-own-property-descriptors',
+  'es7.object.values',
+  'es7.string.pad-end',
+  'es7.string.pad-start',
+];
+
+/**
+ * @param {string} command
+ * @param {string[]} args
+ */
+function runCommand(command, args) {
+  execFileSync(command, args, {cwd: __dirname});
+}
+
+/**
+ * @param {number} version
+ */
+function installCoreJs(version) {
+  runCommand('yarn', [
+    'add',
+    `core-js@${version}`,
+  ]);
+}
+
+function removeCoreJs() {
+  try {
+    runCommand('yarn', [
+      'remove',
+      'core-js',
+    ]);
+  } catch (e) { }
+}
+
+/**
+ * @param {{group: string, name: string, code: string, babelrc?: *}} options
+ */
+async function createVariant(options) {
+  const {group, name, code, babelrc} = options;
+  const dir = `${VARIANT_DIR}/${group}/${name.replace(/[^a-zA-Z0-9]+/g, '-')}`;
+
+  if (!fs.existsSync(`${dir}/main.bundle.js`) && (STAGE === 'build' || STAGE === 'all')) {
+    fs.mkdirSync(dir, {recursive: true});
+    fs.writeFileSync(`${dir}/main.js`, code);
+    fs.writeFileSync(`${dir}/.babelrc`, JSON.stringify(babelrc || {}, null, 2));
+    // Not used in this script, but useful for running Lighthouse manually.
+    // Just need to start a web server first.
+    fs.writeFileSync(`${dir}/index.html`, `<title>${name}</title><script src=main.bundle.min.js>`);
+
+    // Note: No babelrc will make babel a glorified `cp`.
+    runCommand('yarn', [
+      'babel',
+      `${dir}/main.js`,
+      '--config-file', `${dir}/.babelrc`,
+      '--ignore', 'node_modules/**/*.js',
+      '-o', `${dir}/main.transpiled.js`,
+    ]);
+
+    // Transform any require statements (like for core-js) into a big bundle.
+    runCommand('yarn', [
+      'browserify',
+      `${dir}/main.transpiled.js`,
+      '-o', `${dir}/main.bundle.js`,
+    ]);
+
+    // Minify.
+    runCommand('yarn', [
+      'terser',
+      `${dir}/main.bundle.js`,
+      '-o', `${dir}/main.bundle.min.js`,
+    ]);
+  }
+
+  if (STAGE === 'audit' || STAGE === 'all') {
+    // Instead of running Lighthouse, use LegacyJavascript directly. Requires some setup.
+    // Much faster than running Lighthouse.
+    const documentUrl = 'http://localhost/index.html'; // These URLs don't matter.
+    const scriptUrl = 'https://localhost/main.bundle.min.js';
+    const networkRecords = [
+      {url: documentUrl},
+      {url: scriptUrl},
+    ];
+    const devtoolsLogs = networkRecordsToDevtoolsLog(networkRecords);
+    const jsRequestWillBeSentEvent = devtoolsLogs.find(e =>
+      e.method === 'Network.requestWillBeSent' && e.params.request.url === scriptUrl);
+    if (!jsRequestWillBeSentEvent) throw new Error('jsRequestWillBeSentEvent is undefined');
+    // @ts-ignore - the log event is not narrowed to 'Network.requestWillBeSent' event from find
+    const jsRequestId = jsRequestWillBeSentEvent.params.requestId;
+    const code = fs.readFileSync(`${dir}/main.bundle.min.js`, 'utf-8').toString();
+    /** @type {Pick<LH.Artifacts, 'devtoolsLogs'|'URL'|'ScriptElements'>} */
+    const artifacts = {
+      URL: {finalUrl: documentUrl, requestedUrl: documentUrl},
+      devtoolsLogs: {
+        [LegacyJavascript.DEFAULT_PASS]: devtoolsLogs,
+      },
+      ScriptElements: [
+        // @ts-ignore - partial ScriptElement excluding unused DOM properties
+        {requestId: jsRequestId, content: code},
+      ],
+    };
+    // @ts-ignore: partial Artifacts.
+    const legacyJavascriptResults = await LegacyJavascript.audit(artifacts, {
+      computedCache: new Map(),
+    });
+    fs.writeFileSync(`${dir}/legacy-javascript.json`,
+      JSON.stringify(legacyJavascriptResults.details.items, null, 2));
+  }
+}
+
+function makeSummary() {
+  let totalSignals = 0;
+  const variants = [];
+  for (const dir of glob.sync('*/*', {cwd: VARIANT_DIR})) {
+    /** @type {Array<{signals: string[]}>} */
+    const legacyJavascriptItems = require(`${VARIANT_DIR}/${dir}/legacy-javascript.json`);
+    const signals = legacyJavascriptItems.reduce((acc, cur) => {
+      totalSignals += cur.signals.length;
+      return acc.concat(cur.signals);
+    }, /** @type {string[]} */ ([])).join(', ');
+    variants.push({name: dir, signals});
+  }
+  return {
+    totalSignals,
+    variantsMissingSignals: variants.filter(v => !v.signals).map(v => v.name),
+    variants,
+  };
+}
+
+async function main() {
+  for (const plugin of plugins) {
+    await createVariant({
+      group: 'only-plugin',
+      name: plugin,
+      code: mainCode,
+      babelrc: {
+        plugins: [plugin],
+      },
+    });
+  }
+
+  for (const coreJsVersion of [2, 3]) {
+    removeCoreJs();
+    installCoreJs(coreJsVersion);
+
+    for (const esmodules of [true, false]) {
+      await createVariant({
+        group: `core-js-${coreJsVersion}-preset-env-esmodules`,
+        name: String(esmodules),
+        code: mainCode,
+        babelrc: {
+          presets: [
+            [
+              '@babel/preset-env',
+              {
+                targets: {esmodules},
+                useBuiltIns: 'entry',
+                corejs: coreJsVersion,
+              },
+            ],
+          ],
+        },
+      });
+    }
+
+    for (const polyfill of polyfills) {
+      await createVariant({
+        group: `core-js-${coreJsVersion}-only-polyfill`,
+        name: polyfill,
+        code: `require("core-js/modules/${polyfill}")`,
+      });
+    }
+  }
+
+  removeCoreJs();
+
+  const summary = makeSummary();
+  fs.writeFileSync(`${__dirname}/summary-signals.json`, JSON.stringify(summary, null, 2));
+  console.log({
+    totalSignals: summary.totalSignals,
+    variantsMissingSignals: summary.variantsMissingSignals,
+  });
+  console.table(summary.variants);
+
+  runCommand('sh', [
+    'update-sizes.sh',
+  ]);
+}
+
+main();
diff --git a/lighthouse-core/scripts/legacy-javascript/summary-signals.json b/lighthouse-core/scripts/legacy-javascript/summary-signals.json
new file mode 100644
index 000000000..e5967863f
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/summary-signals.json
@@ -0,0 +1,1025 @@
+{
+  "totalSignals": 205,
+  "variantsMissingSignals": [
+    "core-js-2-only-polyfill/es6-array-copy-within",
+    "core-js-2-only-polyfill/es6-array-every",
+    "core-js-2-only-polyfill/es6-array-filter",
+    "core-js-2-only-polyfill/es6-array-index-of",
+    "core-js-2-only-polyfill/es6-array-iterator",
+    "core-js-2-only-polyfill/es6-array-map",
+    "core-js-2-only-polyfill/es6-array-reduce",
+    "core-js-2-only-polyfill/es6-array-reduce-right",
+    "core-js-2-only-polyfill/es6-array-some",
+    "core-js-2-only-polyfill/es6-array-species",
+    "core-js-2-only-polyfill/es6-date-to-primitive",
+    "core-js-2-only-polyfill/es6-date-to-string",
+    "core-js-2-only-polyfill/es6-function-bind",
+    "core-js-2-only-polyfill/es6-function-has-instance",
+    "core-js-2-only-polyfill/es6-function-name",
+    "core-js-2-only-polyfill/es6-math-acosh",
+    "core-js-2-only-polyfill/es6-math-asinh",
+    "core-js-2-only-polyfill/es6-math-atanh",
+    "core-js-2-only-polyfill/es6-math-cbrt",
+    "core-js-2-only-polyfill/es6-math-clz32",
+    "core-js-2-only-polyfill/es6-math-cosh",
+    "core-js-2-only-polyfill/es6-math-expm1",
+    "core-js-2-only-polyfill/es6-math-fround",
+    "core-js-2-only-polyfill/es6-math-hypot",
+    "core-js-2-only-polyfill/es6-math-imul",
+    "core-js-2-only-polyfill/es6-math-log10",
+    "core-js-2-only-polyfill/es6-math-log1p",
+    "core-js-2-only-polyfill/es6-math-log2",
+    "core-js-2-only-polyfill/es6-math-sign",
+    "core-js-2-only-polyfill/es6-math-sinh",
+    "core-js-2-only-polyfill/es6-math-tanh",
+    "core-js-2-only-polyfill/es6-math-trunc",
+    "core-js-2-only-polyfill/es6-number-constructor",
+    "core-js-2-only-polyfill/es6-number-epsilon",
+    "core-js-2-only-polyfill/es6-number-max-safe-integer",
+    "core-js-2-only-polyfill/es6-number-min-safe-integer",
+    "core-js-2-only-polyfill/es6-object-freeze",
+    "core-js-2-only-polyfill/es6-object-get-own-property-descriptor",
+    "core-js-2-only-polyfill/es6-object-get-own-property-names",
+    "core-js-2-only-polyfill/es6-object-get-prototype-of",
+    "core-js-2-only-polyfill/es6-object-is-extensible",
+    "core-js-2-only-polyfill/es6-object-is-frozen",
+    "core-js-2-only-polyfill/es6-object-is-sealed",
+    "core-js-2-only-polyfill/es6-object-keys",
+    "core-js-2-only-polyfill/es6-object-prevent-extensions",
+    "core-js-2-only-polyfill/es6-object-seal",
+    "core-js-2-only-polyfill/es6-object-to-string",
+    "core-js-2-only-polyfill/es6-reflect-define-property",
+    "core-js-2-only-polyfill/es6-string-iterator",
+    "core-js-2-only-polyfill/es6-string-trim",
+    "core-js-2-preset-env-esmodules/true",
+    "core-js-3-only-polyfill/es6-array-copy-within",
+    "core-js-3-only-polyfill/es6-array-every",
+    "core-js-3-only-polyfill/es6-array-filter",
+    "core-js-3-only-polyfill/es6-array-index-of",
+    "core-js-3-only-polyfill/es6-array-iterator",
+    "core-js-3-only-polyfill/es6-array-map",
+    "core-js-3-only-polyfill/es6-array-reduce",
+    "core-js-3-only-polyfill/es6-array-reduce-right",
+    "core-js-3-only-polyfill/es6-array-some",
+    "core-js-3-only-polyfill/es6-array-species",
+    "core-js-3-only-polyfill/es6-date-to-primitive",
+    "core-js-3-only-polyfill/es6-date-to-string",
+    "core-js-3-only-polyfill/es6-function-bind",
+    "core-js-3-only-polyfill/es6-function-has-instance",
+    "core-js-3-only-polyfill/es6-function-name",
+    "core-js-3-only-polyfill/es6-math-acosh",
+    "core-js-3-only-polyfill/es6-math-asinh",
+    "core-js-3-only-polyfill/es6-math-atanh",
+    "core-js-3-only-polyfill/es6-math-cbrt",
+    "core-js-3-only-polyfill/es6-math-clz32",
+    "core-js-3-only-polyfill/es6-math-cosh",
+    "core-js-3-only-polyfill/es6-math-expm1",
+    "core-js-3-only-polyfill/es6-math-fround",
+    "core-js-3-only-polyfill/es6-math-hypot",
+    "core-js-3-only-polyfill/es6-math-imul",
+    "core-js-3-only-polyfill/es6-math-log10",
+    "core-js-3-only-polyfill/es6-math-log1p",
+    "core-js-3-only-polyfill/es6-math-log2",
+    "core-js-3-only-polyfill/es6-math-sign",
+    "core-js-3-only-polyfill/es6-math-sinh",
+    "core-js-3-only-polyfill/es6-math-tanh",
+    "core-js-3-only-polyfill/es6-math-trunc",
+    "core-js-3-only-polyfill/es6-number-constructor",
+    "core-js-3-only-polyfill/es6-number-epsilon",
+    "core-js-3-only-polyfill/es6-number-max-safe-integer",
+    "core-js-3-only-polyfill/es6-number-min-safe-integer",
+    "core-js-3-only-polyfill/es6-object-freeze",
+    "core-js-3-only-polyfill/es6-object-get-own-property-descriptor",
+    "core-js-3-only-polyfill/es6-object-get-own-property-names",
+    "core-js-3-only-polyfill/es6-object-get-prototype-of",
+    "core-js-3-only-polyfill/es6-object-is-extensible",
+    "core-js-3-only-polyfill/es6-object-is-frozen",
+    "core-js-3-only-polyfill/es6-object-is-sealed",
+    "core-js-3-only-polyfill/es6-object-keys",
+    "core-js-3-only-polyfill/es6-object-prevent-extensions",
+    "core-js-3-only-polyfill/es6-object-seal",
+    "core-js-3-only-polyfill/es6-object-to-string",
+    "core-js-3-only-polyfill/es6-reflect-define-property",
+    "core-js-3-only-polyfill/es6-string-iterator",
+    "core-js-3-only-polyfill/es6-string-trim",
+    "core-js-3-preset-env-esmodules/true"
+  ],
+  "variants": [
+    {
+      "name": "core-js-2-only-polyfill/es6-array-copy-within",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-every",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-fill",
+      "signals": "Array.fill"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-filter",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-find",
+      "signals": "Array.prototype.find"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-find-index",
+      "signals": "Array.prototype.findIndex"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-for-each",
+      "signals": "Array.prototype.forEach"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-from",
+      "signals": "Array.from"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-index-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-is-array",
+      "signals": "Array.isArray"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-last-index-of",
+      "signals": "Array.prototype.lastIndexOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-map",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-of",
+      "signals": "Array.of"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-reduce",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-reduce-right",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-some",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-array-species",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-now",
+      "signals": "Date.now"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-iso-string",
+      "signals": "Date.prototype.toISOString"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-json",
+      "signals": "Date.prototype.toJSON"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-primitive",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-date-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-bind",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-has-instance",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-function-name",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-map",
+      "signals": "Map"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-acosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-asinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-atanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-cbrt",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-clz32",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-cosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-expm1",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-fround",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-hypot",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-imul",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log10",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log1p",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-log2",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-sign",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-sinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-tanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-math-trunc",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-constructor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-epsilon",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-is-integer",
+      "signals": "Number.isInteger"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-is-safe-integer",
+      "signals": "Number.isSafeInteger"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-max-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-min-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-parse-float",
+      "signals": "Number.parseFloat"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-number-parse-int",
+      "signals": "Number.parseInt"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-assign",
+      "signals": "Object.assign"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-create",
+      "signals": "Object.create"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-define-properties",
+      "signals": "Object.defineProperties"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-define-property",
+      "signals": "Object.defineProperty"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-freeze",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-own-property-descriptor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-own-property-names",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-get-prototype-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-extensible",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-frozen",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-is-sealed",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-keys",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-prevent-extensions",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-seal",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-set-prototype-of",
+      "signals": "Object.setPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-object-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-promise",
+      "signals": "Promise"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-apply",
+      "signals": "Reflect.apply"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-construct",
+      "signals": "Reflect.construct"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-define-property",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-delete-property",
+      "signals": "Reflect.deleteProperty"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get",
+      "signals": "Reflect.get"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get-own-property-descriptor",
+      "signals": "Reflect.getOwnPropertyDescriptor"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-get-prototype-of",
+      "signals": "Reflect.getPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-has",
+      "signals": "Reflect.has"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-is-extensible",
+      "signals": "Reflect.isExtensible"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-own-keys",
+      "signals": "Reflect.ownKeys"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-prevent-extensions",
+      "signals": "Reflect.preventExtensions"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-set",
+      "signals": "Reflect.set"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-reflect-set-prototype-of",
+      "signals": "Reflect.setPrototypeOf"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-set",
+      "signals": "Set"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-code-point-at",
+      "signals": "String.prototype.codePointAt"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-ends-with",
+      "signals": "String.prototype.endsWith"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-from-code-point",
+      "signals": "String.fromCodePoint"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-includes",
+      "signals": "String.prototype.includes"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-raw",
+      "signals": "String.raw"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-repeat",
+      "signals": "String.prototype.repeat"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-starts-with",
+      "signals": "String.prototype.startsWith"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-string-trim",
+      "signals": ""
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-array-buffer",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-data-view",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-float32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-float64-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float64Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int16Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-int8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int8Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint16Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint32Array"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-typed-uint8-clamped-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint8ClampedArray"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-weak-map",
+      "signals": "WeakMap"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es6-weak-set",
+      "signals": "WeakSet"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-array-includes",
+      "signals": "Array.prototype.includes"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-entries",
+      "signals": "Object.entries"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-get-own-property-descriptors",
+      "signals": "Object.getOwnPropertyDescriptors"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-object-values",
+      "signals": "Object.values"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-string-pad-end",
+      "signals": "String.prototype.padEnd"
+    },
+    {
+      "name": "core-js-2-only-polyfill/es7-string-pad-start",
+      "signals": "String.prototype.padStart"
+    },
+    {
+      "name": "core-js-2-preset-env-esmodules/false",
+      "signals": "@babel/plugin-transform-classes, @babel/plugin-transform-regenerator, @babel/plugin-transform-spread"
+    },
+    {
+      "name": "core-js-2-preset-env-esmodules/true",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-copy-within",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-every",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-fill",
+      "signals": "Array.fill"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-filter",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-find",
+      "signals": "Array.prototype.find"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-find-index",
+      "signals": "Array.prototype.findIndex"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-for-each",
+      "signals": "Array.prototype.forEach"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-from",
+      "signals": "Array.from"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-index-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-is-array",
+      "signals": "Array.isArray"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-last-index-of",
+      "signals": "Array.prototype.lastIndexOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-map",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-of",
+      "signals": "Array.of"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-reduce",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-reduce-right",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-some",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-array-species",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-now",
+      "signals": "Date.now"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-iso-string",
+      "signals": "Date.prototype.toISOString"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-json",
+      "signals": "Date.prototype.toJSON"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-primitive",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-date-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-bind",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-has-instance",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-function-name",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-map",
+      "signals": "Map"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-acosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-asinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-atanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-cbrt",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-clz32",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-cosh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-expm1",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-fround",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-hypot",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-imul",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log10",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log1p",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-log2",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-sign",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-sinh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-tanh",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-math-trunc",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-constructor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-epsilon",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-is-integer",
+      "signals": "Number.isInteger"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-is-safe-integer",
+      "signals": "Number.isSafeInteger"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-max-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-min-safe-integer",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-parse-float",
+      "signals": "Number.parseFloat"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-number-parse-int",
+      "signals": "Number.parseInt"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-assign",
+      "signals": "Object.assign"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-create",
+      "signals": "Object.create"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-define-properties",
+      "signals": "Object.defineProperties"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-define-property",
+      "signals": "Object.defineProperty"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-freeze",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-own-property-descriptor",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-own-property-names",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-get-prototype-of",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-extensible",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-frozen",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-is-sealed",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-keys",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-prevent-extensions",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-seal",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-set-prototype-of",
+      "signals": "Object.setPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-object-to-string",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-promise",
+      "signals": "Promise"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-apply",
+      "signals": "Reflect.apply"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-construct",
+      "signals": "Reflect.construct"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-define-property",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-delete-property",
+      "signals": "Reflect.deleteProperty"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get",
+      "signals": "Reflect.get"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get-own-property-descriptor",
+      "signals": "Reflect.getOwnPropertyDescriptor"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-get-prototype-of",
+      "signals": "Reflect.getPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-has",
+      "signals": "Reflect.has"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-is-extensible",
+      "signals": "Reflect.isExtensible"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-own-keys",
+      "signals": "Reflect.ownKeys"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-prevent-extensions",
+      "signals": "Reflect.preventExtensions"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-set",
+      "signals": "Reflect.set"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-reflect-set-prototype-of",
+      "signals": "Reflect.setPrototypeOf"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-set",
+      "signals": "Set"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-code-point-at",
+      "signals": "String.prototype.codePointAt"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-ends-with",
+      "signals": "String.prototype.endsWith"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-from-code-point",
+      "signals": "String.fromCodePoint"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-includes",
+      "signals": "String.prototype.includes"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-iterator",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-raw",
+      "signals": "String.raw"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-repeat",
+      "signals": "String.prototype.repeat"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-starts-with",
+      "signals": "String.prototype.startsWith"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-string-trim",
+      "signals": ""
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-array-buffer",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-data-view",
+      "signals": "ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-float32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-float64-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Float64Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int16Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-int8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Int8Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint16-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint16Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint32-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint32Array"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint8-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-typed-uint8-clamped-array",
+      "signals": "Uint8Array, Object.getOwnPropertyDescriptor, ArrayBuffer, DataView, Uint8ClampedArray"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-weak-map",
+      "signals": "WeakMap"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es6-weak-set",
+      "signals": "WeakSet"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-array-includes",
+      "signals": "Array.prototype.includes"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-entries",
+      "signals": "Object.entries"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-get-own-property-descriptors",
+      "signals": "Object.getOwnPropertyDescriptors"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-object-values",
+      "signals": "Object.values"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-string-pad-end",
+      "signals": "String.prototype.padEnd"
+    },
+    {
+      "name": "core-js-3-only-polyfill/es7-string-pad-start",
+      "signals": "String.prototype.padStart"
+    },
+    {
+      "name": "core-js-3-preset-env-esmodules/false",
+      "signals": "@babel/plugin-transform-classes, @babel/plugin-transform-regenerator, @babel/plugin-transform-spread"
+    },
+    {
+      "name": "core-js-3-preset-env-esmodules/true",
+      "signals": ""
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-classes",
+      "signals": "@babel/plugin-transform-classes"
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-regenerator",
+      "signals": "@babel/plugin-transform-regenerator"
+    },
+    {
+      "name": "only-plugin/-babel-plugin-transform-spread",
+      "signals": "@babel/plugin-transform-spread"
+    }
+  ]
+}
\ No newline at end of file
diff --git a/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt b/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt
new file mode 100644
index 000000000..bea8fbce2
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/summary-sizes.txt
@@ -0,0 +1,244 @@
+variants/core-js-2-only-polyfill
+1482778 total
+  47738 variants/core-js-2-only-polyfill/es6-typed-uint8-clamped-array/main.bundle.min.js
+  47718 variants/core-js-2-only-polyfill/es6-typed-float64-array/main.bundle.min.js
+  47718 variants/core-js-2-only-polyfill/es6-typed-float32-array/main.bundle.min.js
+  47714 variants/core-js-2-only-polyfill/es6-typed-uint32-array/main.bundle.min.js
+  47714 variants/core-js-2-only-polyfill/es6-typed-uint16-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-uint8-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-int32-array/main.bundle.min.js
+  47710 variants/core-js-2-only-polyfill/es6-typed-int16-array/main.bundle.min.js
+  47706 variants/core-js-2-only-polyfill/es6-typed-int8-array/main.bundle.min.js
+  29514 variants/core-js-2-only-polyfill/es6-map/main.bundle.min.js
+  29420 variants/core-js-2-only-polyfill/es6-set/main.bundle.min.js
+  27277 variants/core-js-2-only-polyfill/es6-weak-map/main.bundle.min.js
+  24645 variants/core-js-2-only-polyfill/es6-promise/main.bundle.min.js
+  23127 variants/core-js-2-only-polyfill/es6-typed-array-buffer/main.bundle.min.js
+  22881 variants/core-js-2-only-polyfill/es6-weak-set/main.bundle.min.js
+  20885 variants/core-js-2-only-polyfill/es6-typed-data-view/main.bundle.min.js
+  17640 variants/core-js-2-only-polyfill/es6-array-iterator/main.bundle.min.js
+  17387 variants/core-js-2-only-polyfill/es6-number-constructor/main.bundle.min.js
+  17329 variants/core-js-2-only-polyfill/es6-string-iterator/main.bundle.min.js
+  14880 variants/core-js-2-only-polyfill/es6-reflect-construct/main.bundle.min.js
+  12738 variants/core-js-2-only-polyfill/es7-object-get-own-property-descriptors/main.bundle.min.js
+  12329 variants/core-js-2-only-polyfill/es6-object-create/main.bundle.min.js
+  12184 variants/core-js-2-only-polyfill/es6-array-from/main.bundle.min.js
+  11963 variants/core-js-2-only-polyfill/es6-object-assign/main.bundle.min.js
+  11575 variants/core-js-2-only-polyfill/es6-object-get-own-property-names/main.bundle.min.js
+  11412 variants/core-js-2-only-polyfill/es6-array-find-index/main.bundle.min.js
+  11385 variants/core-js-2-only-polyfill/es6-array-find/main.bundle.min.js
+  11295 variants/core-js-2-only-polyfill/es7-object-entries/main.bundle.min.js
+  11290 variants/core-js-2-only-polyfill/es7-object-values/main.bundle.min.js
+  11240 variants/core-js-2-only-polyfill/es6-array-for-each/main.bundle.min.js
+  11213 variants/core-js-2-only-polyfill/es6-array-filter/main.bundle.min.js
+  11206 variants/core-js-2-only-polyfill/es6-array-every/main.bundle.min.js
+  11199 variants/core-js-2-only-polyfill/es6-array-some/main.bundle.min.js
+  11192 variants/core-js-2-only-polyfill/es6-array-map/main.bundle.min.js
+  11187 variants/core-js-2-only-polyfill/es6-object-keys/main.bundle.min.js
+  11159 variants/core-js-2-only-polyfill/es6-reflect-own-keys/main.bundle.min.js
+  11146 variants/core-js-2-only-polyfill/es6-object-define-properties/main.bundle.min.js
+  10840 variants/core-js-2-only-polyfill/es6-reflect-set/main.bundle.min.js
+  10362 variants/core-js-2-only-polyfill/es6-reflect-get/main.bundle.min.js
+  10323 variants/core-js-2-only-polyfill/es7-array-includes/main.bundle.min.js
+   9992 variants/core-js-2-only-polyfill/es6-array-index-of/main.bundle.min.js
+   9974 variants/core-js-2-only-polyfill/es6-string-ends-with/main.bundle.min.js
+   9928 variants/core-js-2-only-polyfill/es6-string-starts-with/main.bundle.min.js
+   9916 variants/core-js-2-only-polyfill/es6-array-copy-within/main.bundle.min.js
+   9840 variants/core-js-2-only-polyfill/es6-reflect-set-prototype-of/main.bundle.min.js
+   9726 variants/core-js-2-only-polyfill/es6-array-fill/main.bundle.min.js
+   9706 variants/core-js-2-only-polyfill/es6-array-reduce-right/main.bundle.min.js
+   9682 variants/core-js-2-only-polyfill/es6-object-set-prototype-of/main.bundle.min.js
+   9680 variants/core-js-2-only-polyfill/es6-array-reduce/main.bundle.min.js
+   9561 variants/core-js-2-only-polyfill/es7-string-pad-start/main.bundle.min.js
+   9554 variants/core-js-2-only-polyfill/es7-string-pad-end/main.bundle.min.js
+   9532 variants/core-js-2-only-polyfill/es6-object-get-own-property-descriptor/main.bundle.min.js
+   9458 variants/core-js-2-only-polyfill/es6-array-last-index-of/main.bundle.min.js
+   9315 variants/core-js-2-only-polyfill/es6-string-includes/main.bundle.min.js
+   9196 variants/core-js-2-only-polyfill/es6-reflect-delete-property/main.bundle.min.js
+   9173 variants/core-js-2-only-polyfill/es6-reflect-get-own-property-descriptor/main.bundle.min.js
+   9120 variants/core-js-2-only-polyfill/es6-object-prevent-extensions/main.bundle.min.js
+   9093 variants/core-js-2-only-polyfill/es6-number-parse-int/main.bundle.min.js
+   9087 variants/core-js-2-only-polyfill/es6-number-parse-float/main.bundle.min.js
+   9041 variants/core-js-2-only-polyfill/es6-object-freeze/main.bundle.min.js
+   9027 variants/core-js-2-only-polyfill/es6-object-seal/main.bundle.min.js
+   9009 variants/core-js-2-only-polyfill/es6-object-get-prototype-of/main.bundle.min.js
+   8843 variants/core-js-2-only-polyfill/es6-string-raw/main.bundle.min.js
+   8792 variants/core-js-2-only-polyfill/es6-function-bind/main.bundle.min.js
+   8667 variants/core-js-2-only-polyfill/es6-reflect-get-prototype-of/main.bundle.min.js
+   8593 variants/core-js-2-only-polyfill/es6-string-trim/main.bundle.min.js
+   8445 variants/core-js-2-only-polyfill/es6-string-from-code-point/main.bundle.min.js
+   8423 variants/core-js-2-only-polyfill/es6-date-to-iso-string/main.bundle.min.js
+   8413 variants/core-js-2-only-polyfill/es6-string-code-point-at/main.bundle.min.js
+   8219 variants/core-js-2-only-polyfill/es6-math-fround/main.bundle.min.js
+   8217 variants/core-js-2-only-polyfill/es6-string-repeat/main.bundle.min.js
+   8193 variants/core-js-2-only-polyfill/es6-array-of/main.bundle.min.js
+   8191 variants/core-js-2-only-polyfill/es6-date-to-json/main.bundle.min.js
+   8004 variants/core-js-2-only-polyfill/es6-reflect-define-property/main.bundle.min.js
+   7998 variants/core-js-2-only-polyfill/es6-object-is-extensible/main.bundle.min.js
+   7984 variants/core-js-2-only-polyfill/es6-math-sinh/main.bundle.min.js
+   7970 variants/core-js-2-only-polyfill/es6-object-is-sealed/main.bundle.min.js
+   7970 variants/core-js-2-only-polyfill/es6-object-is-frozen/main.bundle.min.js
+   7942 variants/core-js-2-only-polyfill/es6-reflect-apply/main.bundle.min.js
+   7907 variants/core-js-2-only-polyfill/es6-math-tanh/main.bundle.min.js
+   7906 variants/core-js-2-only-polyfill/es6-math-acosh/main.bundle.min.js
+   7876 variants/core-js-2-only-polyfill/es6-number-is-safe-integer/main.bundle.min.js
+   7821 variants/core-js-2-only-polyfill/es6-math-expm1/main.bundle.min.js
+   7814 variants/core-js-2-only-polyfill/es6-array-is-array/main.bundle.min.js
+   7786 variants/core-js-2-only-polyfill/es6-math-hypot/main.bundle.min.js
+   7763 variants/core-js-2-only-polyfill/es6-reflect-prevent-extensions/main.bundle.min.js
+   7752 variants/core-js-2-only-polyfill/es6-math-imul/main.bundle.min.js
+   7743 variants/core-js-2-only-polyfill/es6-number-is-integer/main.bundle.min.js
+   7702 variants/core-js-2-only-polyfill/es6-math-cbrt/main.bundle.min.js
+   7693 variants/core-js-2-only-polyfill/es6-reflect-is-extensible/main.bundle.min.js
+   7654 variants/core-js-2-only-polyfill/es6-math-log1p/main.bundle.min.js
+   7631 variants/core-js-2-only-polyfill/es6-math-sign/main.bundle.min.js
+   7628 variants/core-js-2-only-polyfill/es6-math-asinh/main.bundle.min.js
+   7608 variants/core-js-2-only-polyfill/es6-object-define-property/main.bundle.min.js
+   7592 variants/core-js-2-only-polyfill/es6-math-atanh/main.bundle.min.js
+   7551 variants/core-js-2-only-polyfill/es6-math-clz32/main.bundle.min.js
+   7539 variants/core-js-2-only-polyfill/es6-reflect-has/main.bundle.min.js
+   7533 variants/core-js-2-only-polyfill/es6-math-cosh/main.bundle.min.js
+   7531 variants/core-js-2-only-polyfill/es6-math-trunc/main.bundle.min.js
+   7530 variants/core-js-2-only-polyfill/es6-number-min-safe-integer/main.bundle.min.js
+   7529 variants/core-js-2-only-polyfill/es6-number-max-safe-integer/main.bundle.min.js
+   7523 variants/core-js-2-only-polyfill/es6-math-log10/main.bundle.min.js
+   7516 variants/core-js-2-only-polyfill/es6-math-log2/main.bundle.min.js
+   7506 variants/core-js-2-only-polyfill/es6-date-now/main.bundle.min.js
+   7501 variants/core-js-2-only-polyfill/es6-number-epsilon/main.bundle.min.js
+   6915 variants/core-js-2-only-polyfill/es6-object-to-string/main.bundle.min.js
+   5994 variants/core-js-2-only-polyfill/es6-function-has-instance/main.bundle.min.js
+   5993 variants/core-js-2-only-polyfill/es6-date-to-string/main.bundle.min.js
+   5422 variants/core-js-2-only-polyfill/es6-date-to-primitive/main.bundle.min.js
+   4774 variants/core-js-2-only-polyfill/es6-array-species/main.bundle.min.js
+   3410 variants/core-js-2-only-polyfill/es6-function-name/main.bundle.min.js
+
+variants/core-js-2-preset-env-esmodules
+3567 total
+2259 variants/core-js-2-preset-env-esmodules/false/main.bundle.min.js
+1308 variants/core-js-2-preset-env-esmodules/true/main.bundle.min.js
+
+variants/core-js-3-only-polyfill
+1482721 total
+  47737 variants/core-js-3-only-polyfill/es6-typed-uint8-clamped-array/main.bundle.min.js
+  47717 variants/core-js-3-only-polyfill/es6-typed-float64-array/main.bundle.min.js
+  47717 variants/core-js-3-only-polyfill/es6-typed-float32-array/main.bundle.min.js
+  47713 variants/core-js-3-only-polyfill/es6-typed-uint32-array/main.bundle.min.js
+  47713 variants/core-js-3-only-polyfill/es6-typed-uint16-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-uint8-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-int32-array/main.bundle.min.js
+  47709 variants/core-js-3-only-polyfill/es6-typed-int16-array/main.bundle.min.js
+  47705 variants/core-js-3-only-polyfill/es6-typed-int8-array/main.bundle.min.js
+  29516 variants/core-js-3-only-polyfill/es6-map/main.bundle.min.js
+  29422 variants/core-js-3-only-polyfill/es6-set/main.bundle.min.js
+  27278 variants/core-js-3-only-polyfill/es6-weak-map/main.bundle.min.js
+  24645 variants/core-js-3-only-polyfill/es6-promise/main.bundle.min.js
+  23127 variants/core-js-3-only-polyfill/es6-typed-array-buffer/main.bundle.min.js
+  22880 variants/core-js-3-only-polyfill/es6-weak-set/main.bundle.min.js
+  20885 variants/core-js-3-only-polyfill/es6-typed-data-view/main.bundle.min.js
+  17642 variants/core-js-3-only-polyfill/es6-array-iterator/main.bundle.min.js
+  17387 variants/core-js-3-only-polyfill/es6-number-constructor/main.bundle.min.js
+  17329 variants/core-js-3-only-polyfill/es6-string-iterator/main.bundle.min.js
+  14882 variants/core-js-3-only-polyfill/es6-reflect-construct/main.bundle.min.js
+  12740 variants/core-js-3-only-polyfill/es7-object-get-own-property-descriptors/main.bundle.min.js
+  12329 variants/core-js-3-only-polyfill/es6-object-create/main.bundle.min.js
+  12185 variants/core-js-3-only-polyfill/es6-array-from/main.bundle.min.js
+  11962 variants/core-js-3-only-polyfill/es6-object-assign/main.bundle.min.js
+  11574 variants/core-js-3-only-polyfill/es6-object-get-own-property-names/main.bundle.min.js
+  11412 variants/core-js-3-only-polyfill/es6-array-find-index/main.bundle.min.js
+  11385 variants/core-js-3-only-polyfill/es6-array-find/main.bundle.min.js
+  11294 variants/core-js-3-only-polyfill/es7-object-entries/main.bundle.min.js
+  11289 variants/core-js-3-only-polyfill/es7-object-values/main.bundle.min.js
+  11239 variants/core-js-3-only-polyfill/es6-array-for-each/main.bundle.min.js
+  11212 variants/core-js-3-only-polyfill/es6-array-filter/main.bundle.min.js
+  11205 variants/core-js-3-only-polyfill/es6-array-every/main.bundle.min.js
+  11198 variants/core-js-3-only-polyfill/es6-array-some/main.bundle.min.js
+  11191 variants/core-js-3-only-polyfill/es6-array-map/main.bundle.min.js
+  11186 variants/core-js-3-only-polyfill/es6-object-keys/main.bundle.min.js
+  11158 variants/core-js-3-only-polyfill/es6-reflect-own-keys/main.bundle.min.js
+  11145 variants/core-js-3-only-polyfill/es6-object-define-properties/main.bundle.min.js
+  10839 variants/core-js-3-only-polyfill/es6-reflect-set/main.bundle.min.js
+  10361 variants/core-js-3-only-polyfill/es6-reflect-get/main.bundle.min.js
+  10324 variants/core-js-3-only-polyfill/es7-array-includes/main.bundle.min.js
+   9991 variants/core-js-3-only-polyfill/es6-array-index-of/main.bundle.min.js
+   9973 variants/core-js-3-only-polyfill/es6-string-ends-with/main.bundle.min.js
+   9927 variants/core-js-3-only-polyfill/es6-string-starts-with/main.bundle.min.js
+   9915 variants/core-js-3-only-polyfill/es6-array-copy-within/main.bundle.min.js
+   9839 variants/core-js-3-only-polyfill/es6-reflect-set-prototype-of/main.bundle.min.js
+   9725 variants/core-js-3-only-polyfill/es6-array-fill/main.bundle.min.js
+   9705 variants/core-js-3-only-polyfill/es6-array-reduce-right/main.bundle.min.js
+   9681 variants/core-js-3-only-polyfill/es6-object-set-prototype-of/main.bundle.min.js
+   9679 variants/core-js-3-only-polyfill/es6-array-reduce/main.bundle.min.js
+   9561 variants/core-js-3-only-polyfill/es7-string-pad-start/main.bundle.min.js
+   9554 variants/core-js-3-only-polyfill/es7-string-pad-end/main.bundle.min.js
+   9531 variants/core-js-3-only-polyfill/es6-object-get-own-property-descriptor/main.bundle.min.js
+   9457 variants/core-js-3-only-polyfill/es6-array-last-index-of/main.bundle.min.js
+   9314 variants/core-js-3-only-polyfill/es6-string-includes/main.bundle.min.js
+   9195 variants/core-js-3-only-polyfill/es6-reflect-delete-property/main.bundle.min.js
+   9172 variants/core-js-3-only-polyfill/es6-reflect-get-own-property-descriptor/main.bundle.min.js
+   9119 variants/core-js-3-only-polyfill/es6-object-prevent-extensions/main.bundle.min.js
+   9094 variants/core-js-3-only-polyfill/es6-number-parse-int/main.bundle.min.js
+   9088 variants/core-js-3-only-polyfill/es6-number-parse-float/main.bundle.min.js
+   9040 variants/core-js-3-only-polyfill/es6-object-freeze/main.bundle.min.js
+   9026 variants/core-js-3-only-polyfill/es6-object-seal/main.bundle.min.js
+   9010 variants/core-js-3-only-polyfill/es6-object-get-prototype-of/main.bundle.min.js
+   8842 variants/core-js-3-only-polyfill/es6-string-raw/main.bundle.min.js
+   8792 variants/core-js-3-only-polyfill/es6-function-bind/main.bundle.min.js
+   8667 variants/core-js-3-only-polyfill/es6-reflect-get-prototype-of/main.bundle.min.js
+   8594 variants/core-js-3-only-polyfill/es6-string-trim/main.bundle.min.js
+   8444 variants/core-js-3-only-polyfill/es6-string-from-code-point/main.bundle.min.js
+   8424 variants/core-js-3-only-polyfill/es6-date-to-iso-string/main.bundle.min.js
+   8413 variants/core-js-3-only-polyfill/es6-string-code-point-at/main.bundle.min.js
+   8218 variants/core-js-3-only-polyfill/es6-math-fround/main.bundle.min.js
+   8217 variants/core-js-3-only-polyfill/es6-string-repeat/main.bundle.min.js
+   8194 variants/core-js-3-only-polyfill/es6-array-of/main.bundle.min.js
+   8192 variants/core-js-3-only-polyfill/es6-date-to-json/main.bundle.min.js
+   8003 variants/core-js-3-only-polyfill/es6-reflect-define-property/main.bundle.min.js
+   7997 variants/core-js-3-only-polyfill/es6-object-is-extensible/main.bundle.min.js
+   7983 variants/core-js-3-only-polyfill/es6-math-sinh/main.bundle.min.js
+   7969 variants/core-js-3-only-polyfill/es6-object-is-sealed/main.bundle.min.js
+   7969 variants/core-js-3-only-polyfill/es6-object-is-frozen/main.bundle.min.js
+   7941 variants/core-js-3-only-polyfill/es6-reflect-apply/main.bundle.min.js
+   7906 variants/core-js-3-only-polyfill/es6-math-tanh/main.bundle.min.js
+   7905 variants/core-js-3-only-polyfill/es6-math-acosh/main.bundle.min.js
+   7875 variants/core-js-3-only-polyfill/es6-number-is-safe-integer/main.bundle.min.js
+   7820 variants/core-js-3-only-polyfill/es6-math-expm1/main.bundle.min.js
+   7814 variants/core-js-3-only-polyfill/es6-array-is-array/main.bundle.min.js
+   7785 variants/core-js-3-only-polyfill/es6-math-hypot/main.bundle.min.js
+   7762 variants/core-js-3-only-polyfill/es6-reflect-prevent-extensions/main.bundle.min.js
+   7751 variants/core-js-3-only-polyfill/es6-math-imul/main.bundle.min.js
+   7742 variants/core-js-3-only-polyfill/es6-number-is-integer/main.bundle.min.js
+   7701 variants/core-js-3-only-polyfill/es6-math-cbrt/main.bundle.min.js
+   7692 variants/core-js-3-only-polyfill/es6-reflect-is-extensible/main.bundle.min.js
+   7653 variants/core-js-3-only-polyfill/es6-math-log1p/main.bundle.min.js
+   7630 variants/core-js-3-only-polyfill/es6-math-sign/main.bundle.min.js
+   7627 variants/core-js-3-only-polyfill/es6-math-asinh/main.bundle.min.js
+   7607 variants/core-js-3-only-polyfill/es6-object-define-property/main.bundle.min.js
+   7591 variants/core-js-3-only-polyfill/es6-math-atanh/main.bundle.min.js
+   7550 variants/core-js-3-only-polyfill/es6-math-clz32/main.bundle.min.js
+   7538 variants/core-js-3-only-polyfill/es6-reflect-has/main.bundle.min.js
+   7532 variants/core-js-3-only-polyfill/es6-math-cosh/main.bundle.min.js
+   7530 variants/core-js-3-only-polyfill/es6-math-trunc/main.bundle.min.js
+   7529 variants/core-js-3-only-polyfill/es6-number-min-safe-integer/main.bundle.min.js
+   7528 variants/core-js-3-only-polyfill/es6-number-max-safe-integer/main.bundle.min.js
+   7522 variants/core-js-3-only-polyfill/es6-math-log10/main.bundle.min.js
+   7515 variants/core-js-3-only-polyfill/es6-math-log2/main.bundle.min.js
+   7505 variants/core-js-3-only-polyfill/es6-date-now/main.bundle.min.js
+   7500 variants/core-js-3-only-polyfill/es6-number-epsilon/main.bundle.min.js
+   6917 variants/core-js-3-only-polyfill/es6-object-to-string/main.bundle.min.js
+   5993 variants/core-js-3-only-polyfill/es6-function-has-instance/main.bundle.min.js
+   5992 variants/core-js-3-only-polyfill/es6-date-to-string/main.bundle.min.js
+   5421 variants/core-js-3-only-polyfill/es6-date-to-primitive/main.bundle.min.js
+   4773 variants/core-js-3-only-polyfill/es6-array-species/main.bundle.min.js
+   3410 variants/core-js-3-only-polyfill/es6-function-name/main.bundle.min.js
+
+variants/core-js-3-preset-env-esmodules
+3567 total
+2259 variants/core-js-3-preset-env-esmodules/false/main.bundle.min.js
+1308 variants/core-js-3-preset-env-esmodules/true/main.bundle.min.js
+
+variants/only-plugin
+2770 total
+1152 variants/only-plugin/-babel-plugin-transform-spread/main.bundle.min.js
+ 814 variants/only-plugin/-babel-plugin-transform-classes/main.bundle.min.js
+ 804 variants/only-plugin/-babel-plugin-transform-regenerator/main.bundle.min.js
+
diff --git a/lighthouse-core/scripts/legacy-javascript/update-sizes.sh b/lighthouse-core/scripts/legacy-javascript/update-sizes.sh
new file mode 100644
index 000000000..e6b81c0fd
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/update-sizes.sh
@@ -0,0 +1,7 @@
+#!/usr/bin/env bash
+
+for d in variants/* ; do
+  echo "$d"
+  wc -c "$d"/*/main.bundle.min.js | sort -hr
+  printf "\n"
+done > summary-sizes.txt
diff --git a/lighthouse-core/scripts/release/prepare-package.sh b/lighthouse-core/scripts/release/prepare-package.sh
index ac175968e..2e3ffe3b8 100755
--- a/lighthouse-core/scripts/release/prepare-package.sh
+++ b/lighthouse-core/scripts/release/prepare-package.sh
@@ -28,9 +28,6 @@ yarn install
 # Build everything
 yarn build-all
 
-# Package the extension
-node build/build-extension.js package
-
 # Verify the npm package won't include unncessary files
 npm pack --dry-run
 npx pkgfiles
diff --git a/lighthouse-core/scripts/roll-to-devtools.sh b/lighthouse-core/scripts/roll-to-devtools.sh
index 00d9bfc3e..e0463d98b 100755
--- a/lighthouse-core/scripts/roll-to-devtools.sh
+++ b/lighthouse-core/scripts/roll-to-devtools.sh
@@ -56,4 +56,5 @@ echo -e "$check Locale JSON files copied."
 
 echo ""
 echo "Done. To rebase the test expectations, run: "
-echo "    yarn --cwd ~/chromium/src/third_party/blink/renderer/devtools test 'http/tests/devtools/audits/*.js' --reset-results"
+echo "    yarn --cwd ~/chromium/src/third_party/devtools-frontend/src test 'http/tests/devtools/lighthouse/*.js' --reset-results"
+echo " (you also need to do `autoninja -C out/Linux chrome blink_tests` in the chromium checkout)"
diff --git a/package.json b/package.json
index 1b36de5c5..608fc9b05 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,9 @@
     "build-all:task": "(yarn build-cdt-lib & yarn build-extension & yarn build-devtools & yarn build-lr & yarn build-viewer & wait) && yarn build-pack",
     "build-all:task:windows": "yarn build-cdt-lib && yarn build-extension && yarn build-devtools && yarn build-lr && yarn build-viewer",
     "build-cdt-lib": "node ./build/build-cdt-lib.js",
-    "build-extension": "node ./build/build-extension.js",
+    "build-extension": "yarn build-extension-chrome && yarn build-extension-firefox",
+    "build-extension-chrome": "node ./build/build-extension.js chrome",
+    "build-extension-firefox": "node ./build/build-extension.js firefox",
     "build-devtools": "yarn link && yarn link lighthouse && node ./build/build-bundle.js clients/devtools-entry.js dist/lighthouse-dt-bundle.js && node ./build/build-dt-report-resources.js",
     "build-lr": "node ./build/build-lightrider-bundles.js",
     "build-pack": "bash build/build-pack.sh",
@@ -30,6 +32,7 @@
     "test-clients": "jest \"clients/\"",
     "test-viewer": "yarn unit-viewer && jest lighthouse-viewer/test/viewer-test-pptr.js",
     "test-lantern": "bash lighthouse-core/scripts/test-lantern.sh",
+    "test-legacy-javascript": "bash lighthouse-core/scripts/test-legacy-javascript.sh",
     "test-docs": "yarn --cwd docs/recipes/auth && jest docs/recipes/integration-test && yarn --cwd docs/recipes/custom-gatherer-puppeteer test",
     "unit-core": "jest \"lighthouse-core/\"",
     "unit-cli": "jest \"lighthouse-cli/\"",
@@ -179,7 +182,7 @@
   },
   "bundlesize": [
     {
-      "path": "./dist/extension/scripts/popup-bundle.js",
+      "path": "./dist/extension-chrome/scripts/popup-bundle.js",
       "maxSize": "15 kB"
     },
     {
diff --git a/tsconfig.json b/tsconfig.json
index 70f307899..2017a56e7 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -25,6 +25,7 @@
     "lighthouse-core/test/**/*.js",
     "clients/test/**/*.js",
     "lighthouse-cli/test/fixtures/**/*.js",
+    "lighthouse-core/scripts/legacy-javascript/variants",
   ],
   "files": [
     // Opt-in to typechecking for some core tests.
diff --git a/types/audit-details.d.ts b/types/audit-details.d.ts
index b5dfef7de..9e8810971 100644
--- a/types/audit-details.d.ts
+++ b/types/audit-details.d.ts
@@ -88,8 +88,11 @@ declare global {
       // TODO(bckenny): unify Table/Opportunity headings and items on next breaking change.
 
       export interface TableColumnHeading {
-        /** The name of the property within items being described. */
-        key: string;
+        /**
+         * The name of the property within items being described.
+         * If null, subRows must be defined, and the first sub-row will be empty.
+         */
+        key: string|null;
         /** Readable text label of the field. */
         text: string;
         /**
@@ -114,8 +117,11 @@ declare global {
       }
 
       export interface OpportunityColumnHeading {
-        /** The name of the property within items being described. */
-        key: string;
+        /**
+         * The name of the property within items being described.
+         * If null, subRows must be defined, and the first sub-row will be empty.
+         */
+        key: string|null;
         /** Readable text label of the field. */
         label: string;
         /**


Test patch:
diff --git a/clients/test/extension/popup-test.js b/clients/test/extension/popup-test.js
index c7ad3d468..a1f3c3c4d 100644
--- a/clients/test/extension/popup-test.js
+++ b/clients/test/extension/popup-test.js
@@ -12,7 +12,7 @@ const puppeteer = require('../../../node_modules/puppeteer/index.js');
 const {DEFAULT_CATEGORIES, STORAGE_KEYS} =
   require('../../extension/scripts/settings-controller.js');
 
-const lighthouseExtensionPath = path.resolve(__dirname, '../../../dist/extension');
+const lighthouseExtensionPath = path.resolve(__dirname, '../../../dist/extension-chrome');
 
 const mockStorage = {
   [STORAGE_KEYS.Categories]: {
@@ -68,6 +68,11 @@ describe('Lighthouse chrome popup', function() {
           getManifest: () => ({}),
         }),
       });
+      Object.defineProperty(chrome, 'i18n', {
+        get: () => ({
+          getMessage: () => '__LOCALIZED_STRING__',
+        }),
+      });
     }, mockStorage);
 
     await page.goto('file://' + path.join(lighthouseExtensionPath, 'popup.html'), {waitUntil: 'networkidle2'});
diff --git a/lighthouse-cli/test/fixtures/legacy-javascript.html b/lighthouse-cli/test/fixtures/legacy-javascript.html
new file mode 100644
index 000000000..e4a6c8fa7
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/legacy-javascript.html
@@ -0,0 +1,14 @@
+<!--
+ * Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+-->
+<html>
+  <body>
+    wanna cracker?
+    <script src="/legacy-javascript.js"></script>
+    <script>
+      String.prototype.includes = function() {};
+    </script>
+  </body>
+</html>
diff --git a/lighthouse-cli/test/fixtures/legacy-javascript.js b/lighthouse-cli/test/fixtures/legacy-javascript.js
new file mode 100644
index 000000000..6fed4379d
--- /dev/null
+++ b/lighthouse-cli/test/fixtures/legacy-javascript.js
@@ -0,0 +1,11 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+
+/*eslint-disable*/
+
+// This is `./lighthouse-core/scripts/legacy-javascript/variants/core-js-3-preset-env-esmodules/false/main.bundle.min.js`.
+
+(function(){function r(e,n,t){function o(i,f){if(!n[i]){if(!e[i]){var c="function"==typeof require&&require;if(!f&&c)return c(i,!0);if(u)return u(i,!0);var a=new Error("Cannot find module '"+i+"'");throw a.code="MODULE_NOT_FOUND",a}var p=n[i]={exports:{}};e[i][0].call(p.exports,(function(r){var n=e[i][1][r];return o(n||r)}),p,p.exports,r,e,n,t)}return n[i].exports}for(var u="function"==typeof require&&require,i=0;i<t.length;i++)o(t[i]);return o}return r})()({1:[function(require,module,exports){"use strict";function _toConsumableArray(arr){return _arrayWithoutHoles(arr)||_iterableToArray(arr)||_nonIterableSpread()}function _nonIterableSpread(){throw new TypeError("Invalid attempt to spread non-iterable instance")}function _iterableToArray(iter){if(Symbol.iterator in Object(iter)||Object.prototype.toString.call(iter)==="[object Arguments]")return Array.from(iter)}function _arrayWithoutHoles(arr){if(Array.isArray(arr)){for(var i=0,arr2=new Array(arr.length);i<arr.length;i++){arr2[i]=arr[i]}return arr2}}function asyncGeneratorStep(gen,resolve,reject,_next,_throw,key,arg){try{var info=gen[key](arg);var value=info.value}catch(error){reject(error);return}if(info.done){resolve(value)}else{Promise.resolve(value).then(_next,_throw)}}function _asyncToGenerator(fn){return function(){var self=this,args=arguments;return new Promise((function(resolve,reject){var gen=fn.apply(self,args);function _next(value){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"next",value)}function _throw(err){asyncGeneratorStep(gen,resolve,reject,_next,_throw,"throw",err)}_next(undefined)}))}}function _classCallCheck(instance,Constructor){if(!(instance instanceof Constructor)){throw new TypeError("Cannot call a class as a function")}}var MyTestClass=function MyTestClass(){_classCallCheck(this,MyTestClass)};function reg(){return _reg.apply(this,arguments)}function _reg(){_reg=_asyncToGenerator(regeneratorRuntime.mark((function _callee(){return regeneratorRuntime.wrap((function _callee$(_context){while(1){switch(_context.prev=_context.next){case 0:_context.next=2;return 1;case 2:case"end":return _context.stop()}}}),_callee)})));return _reg.apply(this,arguments)}var spread=[1,2,3].concat([3,2,1]);reg.apply(void 0,_toConsumableArray(spread))},{}]},{},[1]);
\ No newline at end of file
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
index b6115e8b0..98231b87f 100644
--- a/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
+++ b/lighthouse-cli/test/smokehouse/test-definitions/core-tests.js
@@ -70,6 +70,11 @@ const smokeTests = [{
   id: 'mixed-content',
   expectations: require('./mixed-content/mixed-content-expectations.js'),
   config: require('../../../../lighthouse-core/config/mixed-content-config.js'),
+},
+{
+  id: 'legacy-javascript',
+  expectations: require('./legacy-javascript/expectations.js'),
+  config: require('./legacy-javascript/legacy-javascript-config.js'),
 }];
 
 module.exports = smokeTests;
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js
new file mode 100644
index 000000000..63c375958
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/expectations.js
@@ -0,0 +1,40 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * Expected Lighthouse audit values for sites with polyfills.
+ */
+module.exports = [
+  {
+    lhr: {
+      requestedUrl: 'http://localhost:10200/legacy-javascript.html',
+      finalUrl: 'http://localhost:10200/legacy-javascript.html',
+      audits: {
+        'legacy-javascript': {
+          details: {
+            items: [
+              {
+                url: 'http://localhost:10200/legacy-javascript.js',
+                signals: [
+                  '@babel/plugin-transform-classes',
+                  '@babel/plugin-transform-regenerator',
+                  '@babel/plugin-transform-spread',
+                ],
+              },
+              {
+                url: 'http://localhost:10200/legacy-javascript.html',
+                signals: [
+                  'String.prototype.includes',
+                ],
+              },
+            ],
+          },
+        },
+      },
+    },
+  },
+];
diff --git a/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js
new file mode 100644
index 000000000..f593c177e
--- /dev/null
+++ b/lighthouse-cli/test/smokehouse/test-definitions/legacy-javascript/legacy-javascript-config.js
@@ -0,0 +1,23 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/**
+ * Config file for running PWA smokehouse audits.
+ */
+module.exports = {
+  extends: 'lighthouse:default',
+  settings: {
+    onlyCategories: [
+      'performance',
+    ],
+    onlyAudits: [
+      'legacy-javascript',
+    ],
+  },
+  // Not in default yet.
+  audits: ['legacy-javascript'],
+};
diff --git a/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
new file mode 100644
index 000000000..c6c05cfb1
--- /dev/null
+++ b/lighthouse-core/scripts/legacy-javascript/examine-latest-run.js
@@ -0,0 +1,103 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an 'AS IS' BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+/* eslint-disable no-console */
+
+/**
+ * @fileoverview - Used to manually examine the polyfills/transforms used on a page.
+ *
+ * USAGE:
+ *   1. Run `yarn start <url to examine> -G
+ *   2. Run `node ./lighthouse-core/scripts/legacy-javascript/examine-latest-run.js`
+ *   3. Inspect output for fishy looking polyfills.
+ */
+
+const path = require('path');
+// @ts-ignore - We don't really need types for this
+const colors = require('colors');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+
+const LH_ROOT_DIR = path.join(__dirname, '../../../');
+const LATEST_RUN_DIR = path.join(LH_ROOT_DIR, 'latest-run');
+
+/** @param {LH.DevtoolsLog} log */
+function requestUrlToId(log) {
+  return log.reduce(
+    (map, entry) => {
+      if (entry.method === 'Network.requestWillBeSent') {
+        map[entry.params.request.url] = entry.params.requestId;
+      }
+      return map;
+    },
+    /** @type {Record<string, string>} */ ({})
+  );
+}
+
+async function main() {
+  /** @type {LH.Artifacts} */
+  const artifacts = require(`${LATEST_RUN_DIR}/artifacts.json`);
+  const devtoolsLog = require(`${LATEST_RUN_DIR}/defaultPass.devtoolslog.json`);
+  const scripts = artifacts.ScriptElements;
+  const requestUrlMap = requestUrlToId(devtoolsLog);
+  artifacts.devtoolsLogs = {defaultPass: devtoolsLog};
+
+  const auditResults = await LegacyJavascript.audit(artifacts, {
+    computedCache: new Map(),
+    options: {},
+    settings: /** @type {any} */ ({}),
+    LighthouseRunWarnings: [],
+  });
+
+  const items =
+    auditResults.details &&
+    auditResults.details.type === 'table' &&
+    auditResults.details.items;
+
+  if (!items) {
+    console.log('No signals found!');
+    return;
+  }
+
+  console.log(colors.bold(`${items.length} signals found!`));
+  for (const item of items) {
+    if (typeof item.url !== 'string') continue;
+    const requestId = requestUrlMap[item.url];
+    const script = scripts.find(s => s.requestId === requestId);
+    const signals = Array.isArray(item.signals) ? item.signals : [];
+    const locations = Array.isArray(item.locations) ? item.locations : [];
+
+    console.log('---------------------------------');
+    console.log(`URL: ${item.url}`);
+    console.log(`Signals: ${signals.length}`);
+    if (!script || !script.content) {
+      console.log('\nFailed to find script content! :/');
+      console.log('---------------------------------\n\n');
+      continue;
+    }
+
+    const lines = script.content.split('\n');
+    for (let i = 0; i < signals.length; i++) {
+      const signal = signals[i];
+      const location = locations[i];
+      if (typeof location !== 'object' || location.type !== 'source-location') {
+        continue;
+      }
+
+      const line = lines[location.line || 0] || '';
+      const locationString = `at ${location.line}:${location.column}`;
+      console.log('');
+      console.log(`${signal} ${colors.dim(locationString)}`);
+      const contentToShow = line.slice(location.column - 10, location.column + 80);
+      const unimportant = contentToShow.split(signal.toString());
+      console.log(unimportant.map(s => colors.dim(s)).join(signal.toString()));
+    }
+
+    console.log('---------------------------------\n\n');
+  }
+}
+
+main();
diff --git a/lighthouse-core/scripts/test-legacy-javascript.sh b/lighthouse-core/scripts/test-legacy-javascript.sh
new file mode 100644
index 000000000..aafe9f8ee
--- /dev/null
+++ b/lighthouse-core/scripts/test-legacy-javascript.sh
@@ -0,0 +1,27 @@
+#!/usr/bin/env bash
+
+DIRNAME="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+LH_ROOT="$DIRNAME/../.."
+cd $LH_ROOT
+
+set -e
+
+# This test can be expensive, we'll only run the tests if we touched files that affect the simulations.
+CHANGED_FILES=""
+if [[ "$CI" ]]; then
+  CHANGED_FILES=$(git --no-pager diff --name-only $TRAVIS_COMMIT_RANGE)
+else
+  CHANGED_FILES=$(git --no-pager diff --name-only master)
+fi
+
+printf "Determined the following files have been touched:\n\n$CHANGED_FILES\n\n"
+
+if ! echo $CHANGED_FILES | grep -E 'legacy-javascript' > /dev/null; then
+  echo "No legacy-javascript files affected, skipping test."
+  exit 0
+fi
+
+printf "\n\nRunning test...\n"
+cd "$LH_ROOT/lighthouse-core/scripts/legacy-javascript"
+yarn
+node run.js
diff --git a/lighthouse-core/test/audits/legacy-javascript-test.js b/lighthouse-core/test/audits/legacy-javascript-test.js
new file mode 100644
index 000000000..80d291c48
--- /dev/null
+++ b/lighthouse-core/test/audits/legacy-javascript-test.js
@@ -0,0 +1,167 @@
+/**
+ * @license Copyright 2020 Google Inc. All Rights Reserved.
+ * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
+ */
+'use strict';
+
+const assert = require('assert');
+const LegacyJavascript = require('../../audits/legacy-javascript.js');
+const networkRecordsToDevtoolsLog = require('../network-records-to-devtools-log.js');
+
+/**
+ * @param {Array<{url: string, code: string}>} scripts
+ * @return {LH.Artifacts}
+ */
+const createArtifacts = (scripts) => {
+  const networkRecords = scripts.map(({url}, index) => ({
+    requestId: String(index),
+    url,
+  }));
+  return {
+    URL: {finalUrl: '', requestedUrl: ''},
+    devtoolsLogs: {defaultPass: networkRecordsToDevtoolsLog(networkRecords)},
+    ScriptElements: scripts.reduce((acc, {code}, index) => {
+      acc[String(index)] = {
+        content: code,
+        requestId: String(index),
+      };
+      return acc;
+    }, {}),
+  };
+};
+
+/**
+ * @param {string[]} codeSnippets
+ * @return {string[]}
+ */
+const createVariants = (codeSnippets) => {
+  const variants = [];
+
+  for (const codeSnippet of codeSnippets) {
+    // Explicitly don't create a variant for just `codeSnippet`,
+    // because making the patterns work with a starting anchor (^)
+    // complicates the expressions more than its worth.
+    variants.push(`;${codeSnippet}`);
+    variants.push(` ${codeSnippet}`);
+  }
+
+  return variants;
+};
+
+/* eslint-env jest */
+describe('LegacyJavaScript audit', () => {
+  it('passes code with no polyfills', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'var message = "hello world"; console.log(message);',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'SomeGlobal = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'SomeClass.prototype.someFn = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+      {
+        code: 'Object.defineProperty(SomeClass.prototype, "someFn", function() {})',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 1);
+    assert.equal(result.extendedInfo.signalCount, 0);
+  });
+
+  it('fails code with a legacy polyfill', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'String.prototype.repeat = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 1);
+    expect(result.details.items[0].signals).toEqual(['String.prototype.repeat']);
+  });
+
+  it('fails code with multiple legacy polyfills', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: 'String.prototype.repeat = function() {}; String.prototype.includes = function() {}',
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 2);
+  });
+
+  it('counts multiple of the same polyfill from the same script only once', async () => {
+    const artifacts = createArtifacts([
+      {
+        code: (() => {
+          // eslint-disable-next-line no-extend-native
+          String.prototype.repeat = function() {};
+          // eslint-disable-next-line no-extend-native
+          Object.defineProperty(String.prototype, 'repeat', function() {});
+        }),
+        url: 'https://www.example.com/a.js',
+      },
+    ]);
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    assert.equal(result.score, 0);
+    assert.equal(result.extendedInfo.signalCount, 1);
+  });
+
+  it('should identify polyfills in multiple patterns', async () => {
+    const codeSnippets = [
+      'String.prototype.repeat = function() {}',
+      'String.prototype["repeat"] = function() {}',
+      'String.prototype[\'repeat\'] = function() {}',
+      'Object.defineProperty(String.prototype, "repeat", function() {})',
+      'Object.defineProperty(String.prototype, \'repeat\', function() {})',
+      'Object.defineProperty(window, \'WeakMap\', function() {})',
+      '$export($export.S,"Object",{values:function values(t){return i(t)}})',
+      'WeakMap = function() {}',
+      'window.WeakMap = function() {}',
+      'function WeakMap() {}',
+      'String.raw = function() {}',
+    ];
+    const variants = createVariants(codeSnippets);
+    const scripts = variants.map((code, i) => {
+      return {code, url: `https://www.example.com/${i}.js`};
+    });
+    const getCodeForUrl = url => scripts.find(script => script.url === url).code;
+    const artifacts = createArtifacts(scripts);
+
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    expect(result.details.items.map(item => getCodeForUrl(item.url)))
+      .toEqual(scripts.map(script => getCodeForUrl(script.url)));
+    assert.equal(result.score, 0);
+  });
+
+  it('should not misidentify legacy code', async () => {
+    const codeSnippets = [
+      'i.prototype.toArrayBuffer = blah',
+      'this.childListChangeMap=void 0',
+      't.toPromise=u,t.makePromise=u,t.fromPromise=function(e){return new o.default',
+      'var n=new Error(h.apply(void 0,[d].concat(f)));n.name="Invariant Violation";',
+      'var b=typeof Map==="function"?new Map():void 0',
+      'd.Promise=s;var y,g,v,b=function(n,o,t){if(function(t){if("function"!=typeof t)th',
+    ];
+    const variants = createVariants(codeSnippets);
+    const scripts = variants.map((code, i) => {
+      return {code, url: `https://www.example.com/${i}.js`};
+    });
+    const getCodeForUrl = url => scripts.find(script => script.url === url).code;
+    const artifacts = createArtifacts(scripts);
+
+    const result = await LegacyJavascript.audit(artifacts, {computedCache: new Map()});
+    expect(result.details.items.map(item => getCodeForUrl(item.url))).toEqual([]);
+    assert.equal(result.score, 1);
+  });
+});