Define an interface for a TypeScript compiler plugin that may only contribute diagnostics.

Switch the strict deps and tsetse plugins to use this API. The only user-visible change is that diagnostics from those plugins are now tagged with [tsetse] / [strictDeps].

The string ids for tracking the performance of these plugins have also changed, and have become more granular, tracking both file by file and the total time contributed by each plugin as a whole.

PiperOrigin-RevId: 259985050
diff --git a/internal/tsc_wrapped/plugin_api.ts b/internal/tsc_wrapped/plugin_api.ts
index f778c0f..e09954d 100644
--- a/internal/tsc_wrapped/plugin_api.ts
+++ b/internal/tsc_wrapped/plugin_api.ts
@@ -96,3 +96,34 @@
   }
   return proxy;
 }
+
+/**
+ * A plugin that contributes additional diagnostics during compilation.
+ *
+ * This is a more limited API than Plugin, which can overwrite any features of
+ * the Program. A DiagnosticPlugin can't affect the output, and can only reject
+ * otherwise valid programs.
+ *
+ * This means that disabling a DiagnosticPlugin is always safe. It will not
+ * break any downstream projects, either at build time or in production.
+ *
+ * It also lets us instrument the plugin to track performance, and tag the
+ * diagnostics it emits with the plugin name.
+ */
+export interface DiagnosticPlugin {
+  /**
+   * A brief descriptive name for the plugin.
+   *
+   * Should not include 'ts', 'typescript', or 'plugin'.
+   */
+  readonly name: string;
+
+  /**
+   * Return diagnostics for the given file.
+   *
+   * Should only include new diagnostics that your plugin is contributing.
+   * Should not include diagnostics from program.
+   */
+  getDiagnostics(sourceFile: ts.SourceFile):
+      ReadonlyArray<Readonly<ts.Diagnostic>>;
+}
diff --git a/internal/tsc_wrapped/strict_deps.ts b/internal/tsc_wrapped/strict_deps.ts
index 7afa4d9..c2cb723 100644
--- a/internal/tsc_wrapped/strict_deps.ts
+++ b/internal/tsc_wrapped/strict_deps.ts
@@ -47,21 +47,19 @@
  *
  * strict_deps currently does not check ambient/global definitions.
  */
-export const PLUGIN: pluginApi.Plugin = {
-  wrap: (program: ts.Program, config: StrictDepsPluginConfig): ts.Program => {
-    const proxy = pluginApi.createProxy(program);
-    proxy.getSemanticDiagnostics = function(sourceFile: ts.SourceFile) {
-      const result = [...program.getSemanticDiagnostics(sourceFile)];
-      perfTrace.wrap('checkModuleDeps', () => {
-        result.push(...checkModuleDeps(
-            sourceFile, program.getTypeChecker(), config.allowedStrictDeps,
-            config.rootDir));
-      });
-      return result;
-    };
-    return proxy;
+export class Plugin implements pluginApi.DiagnosticPlugin {
+  constructor(
+      private readonly program: ts.Program,
+      private readonly config: StrictDepsPluginConfig) {}
+
+  readonly name = 'strictDeps';
+
+  getDiagnostics(sourceFile: ts.SourceFile) {
+    return checkModuleDeps(
+        sourceFile, this.program.getTypeChecker(),
+        this.config.allowedStrictDeps, this.config.rootDir);
   }
-};
+}
 
 // Exported for testing
 export function checkModuleDeps(
diff --git a/internal/tsc_wrapped/tsc_wrapped.ts b/internal/tsc_wrapped/tsc_wrapped.ts
index 02d5040..ad7c379 100644
--- a/internal/tsc_wrapped/tsc_wrapped.ts
+++ b/internal/tsc_wrapped/tsc_wrapped.ts
@@ -3,15 +3,15 @@
 import * as tsickle from 'tsickle';
 import * as ts from 'typescript';
 
-import {PLUGIN as bazelConformancePlugin} from '../tsetse/runner';
+import {Plugin as BazelConformancePlugin} from '../tsetse/runner';
 
 import {CachedFileLoader, FileLoader, ProgramAndFileCache, UncachedFileLoader} from './cache';
 import {CompilerHost} from './compiler_host';
 import * as bazelDiagnostics from './diagnostics';
 import {constructManifest} from './manifest';
 import * as perfTrace from './perf_trace';
-import {PluginCompilerHost, TscPlugin} from './plugin_api';
-import {PLUGIN as strictDepsPlugin} from './strict_deps';
+import {DiagnosticPlugin, PluginCompilerHost, TscPlugin} from './plugin_api';
+import {Plugin as StrictDepsPlugin} from './strict_deps';
 import {BazelOptions, parseTsconfig, resolveNormalizedPath} from './tsconfig';
 import {debug, log, runAsWorker, runWorkerLoop} from './worker';
 
@@ -62,18 +62,11 @@
  */
 export function gatherDiagnostics(
     options: ts.CompilerOptions, bazelOpts: BazelOptions, program: ts.Program,
-    disabledTsetseRules: string[], angularPlugin?: TscPlugin): ts.Diagnostic[] {
+    disabledTsetseRules: string[], angularPlugin?: TscPlugin,
+    plugins: DiagnosticPlugin[] = []): ts.Diagnostic[] {
   // Install extra diagnostic plugins
-  if (!bazelOpts.disableStrictDeps) {
-    program = strictDepsPlugin.wrap(program, {
-      ...bazelOpts,
-      rootDir: options.rootDir,
-    });
-  }
-  if (!bazelOpts.isJsTranspilation) {
-    let selectedTsetsePlugin = bazelConformancePlugin;
-    program = selectedTsetsePlugin.wrap(program, disabledTsetseRules);
-  }
+  plugins.push(
+      ...getCommonPlugins(options, bazelOpts, program, disabledTsetseRules));
   if (angularPlugin) {
     program = angularPlugin.wrap(program);
   }
@@ -101,12 +94,84 @@
       });
       perfTrace.snapshotMemoryUsage();
     }
+    for (const plugin of plugins) {
+      perfTrace.wrap(`${plugin.name} diagnostics`, () => {
+        for (const sf of sourceFilesToCheck) {
+          perfTrace.wrap(`${plugin.name} checking ${sf.fileName}`, () => {
+            const pluginDiagnostics = plugin.getDiagnostics(sf).map((d) => {
+              return tagDiagnosticWithPlugin(plugin.name, d);
+            });
+            diagnostics.push(...pluginDiagnostics);
+          });
+          perfTrace.snapshotMemoryUsage();
+        }
+      });
+    }
   });
 
   return diagnostics;
 }
 
 /**
+ * Construct diagnostic plugins that we always want included.
+ *
+ * TODO: Call sites of getDiagnostics should initialize plugins themselves,
+ *   including these, and the arguments to getDiagnostics should be simplified.
+ */
+export function*
+    getCommonPlugins(
+        options: ts.CompilerOptions, bazelOpts: BazelOptions,
+        program: ts.Program,
+        disabledTsetseRules: string[]): Iterable<DiagnosticPlugin> {
+  if (!bazelOpts.disableStrictDeps) {
+    if (options.rootDir == null) {
+      throw new Error(`StrictDepsPlugin requires that rootDir be specified`);
+    }
+    yield new StrictDepsPlugin(program, {
+      ...bazelOpts,
+      rootDir: options.rootDir,
+    });
+  }
+  if (!bazelOpts.isJsTranspilation) {
+    let tsetsePluginConstructor:
+        {new (program: ts.Program, disabledRules: string[]): DiagnosticPlugin} =
+            BazelConformancePlugin;
+    yield new tsetsePluginConstructor(program, disabledTsetseRules);
+  }
+}
+
+/**
+ * Returns a copy of diagnostic with one whose text has been prepended with
+ * an indication of what plugin contributed that diagnostic.
+ *
+ * This is slightly complicated because a diagnostic's message text can be
+ * split up into a chain of diagnostics, e.g. when there's supplementary info
+ * about a diagnostic.
+ */
+function tagDiagnosticWithPlugin(
+    pluginName: string, diagnostic: Readonly<ts.Diagnostic>): ts.Diagnostic {
+  const tagMessageWithPluginName = (text: string) => `[${pluginName}] ${text}`;
+
+  let messageText;
+  if (typeof diagnostic.messageText === 'string') {
+    // The simple case, where a diagnostic's message is just a string.
+    messageText = tagMessageWithPluginName(diagnostic.messageText);
+  } else {
+    // In the case of a chain of messages we only want to tag the head of the
+    //   chain, as that's the first line of message on the CLI.
+    const chain: ts.DiagnosticMessageChain = diagnostic.messageText;
+    messageText = {
+      ...chain,
+      messageText: tagMessageWithPluginName(chain.messageText)
+    };
+  }
+  return {
+    ...diagnostic,
+    messageText,
+  };
+}
+
+/**
  * expandSourcesFromDirectories finds any directories under filePath and expands
  * them to their .js or .ts contents.
  */
@@ -232,6 +297,7 @@
       files, options, bazelOpts, compilerHostDelegate, fileLoader,
       moduleResolver);
   let compilerHost: PluginCompilerHost = tsickleCompilerHost;
+  const diagnosticPlugins: DiagnosticPlugin[] = [];
 
   let angularPlugin: TscPlugin|undefined;
   if (bazelOpts.compileAngularTemplates) {
@@ -271,7 +337,8 @@
     // messages refer to the original source.  After any subsequent passes
     // (decorator downleveling or tsickle) we do not type check.
     let diagnostics = gatherDiagnostics(
-        options, bazelOpts, program, disabledTsetseRules, angularPlugin);
+        options, bazelOpts, program, disabledTsetseRules, angularPlugin,
+        diagnosticPlugins);
     if (!expectDiagnosticsWhitelist.length ||
         expectDiagnosticsWhitelist.some(p => bazelOpts.target.startsWith(p))) {
       diagnostics = bazelDiagnostics.filterExpected(
diff --git a/internal/tsetse/runner.ts b/internal/tsetse/runner.ts
index d61a707..826131e 100644
--- a/internal/tsetse/runner.ts
+++ b/internal/tsetse/runner.ts
@@ -31,22 +31,19 @@
  * The Tsetse check plugin performs compile-time static analysis for TypeScript
  * code.
  */
-export const PLUGIN: pluginApi.Plugin = {
-  wrap(program: ts.Program, disabledTsetseRules: string[] = []): ts.Program {
-    const checker = new Checker(program);
-    registerRules(checker, disabledTsetseRules);
-    const proxy = pluginApi.createProxy(program);
-    proxy.getSemanticDiagnostics = (sourceFile: ts.SourceFile) => {
-      const result = [...program.getSemanticDiagnostics(sourceFile)];
-      perfTrace.wrap('checkConformance', () => {
-        result.push(...checker.execute(sourceFile)
-                        .map(failure => failure.toDiagnostic()));
-      });
-      return result;
-    };
-    return proxy;
-  },
-};
+export class Plugin implements pluginApi.DiagnosticPlugin {
+  readonly name = 'tsetse';
+  private readonly checker: Checker;
+  constructor(program: ts.Program, disabledTsetseRules: string[] = []) {
+    this.checker = new Checker(program);
+    registerRules(this.checker, disabledTsetseRules);
+  }
+
+  getDiagnostics(sourceFile: ts.SourceFile) {
+    return this.checker.execute(sourceFile)
+        .map(failure => failure.toDiagnostic());
+  }
+}
 
 export function registerRules(checker: Checker, disabledTsetseRules: string[]) {
   for (const rule of ENABLED_RULES) {