Add an in-config ConformanceRule whitelist.

This whitelist is file-granular, based on the jscompiler Conformance configs.

PiperOrigin-RevId: 252021771
diff --git a/internal/tsetse/failure.ts b/internal/tsetse/failure.ts
index 20126ac..7db67ef 100644
--- a/internal/tsetse/failure.ts
+++ b/internal/tsetse/failure.ts
@@ -36,9 +36,9 @@
   }
 
   toString(): string {
-    return `Failure{sourceFile:${
+    return `{ sourceFile:${
         this.sourceFile ? this.sourceFile.fileName :
-                          'unknown'}, start:${this.start}, end:${this.end}}`;
+                          'unknown'}, start:${this.start}, end:${this.end} }`;
   }
 }
 
diff --git a/internal/tsetse/tests/ban_conformance_pattern/whitelist_test.ts b/internal/tsetse/tests/ban_conformance_pattern/whitelist_test.ts
new file mode 100644
index 0000000..a1c7342
--- /dev/null
+++ b/internal/tsetse/tests/ban_conformance_pattern/whitelist_test.ts
@@ -0,0 +1,122 @@
+import 'jasmine';
+import {ConformancePatternRule, PatternKind} from '../../rules/conformance_pattern_rule';
+import {WhitelistReason} from '../../util/pattern_config';
+import {compileAndCheck, customMatchers, getTempDirForWhitelist} from '../../util/testing/test_support';
+
+const tmpPrefixForWhitelist = getTempDirForWhitelist();
+const tmpRegexpForWhitelist =
+    `^(?:${getTempDirForWhitelist().replace(/\\/g, '\\\\')})`;
+
+
+describe('ConformancePatternRule', () => {
+  describe('whitelist handling', () => {
+    const source = `export {};\n` +
+        `const q = document.createElement('q');\n` +
+        `q.cite = 'some example string';\n`;
+
+    // The initial config off which we run those checks.
+    const baseConfig = {
+      errorMessage: 'do not cite',
+      kind: PatternKind.BANNED_PROPERTY_WRITE,
+      values: ['HTMLQuoteElement.prototype.cite'],
+    };
+
+    it('matches if no whitelist (sanity check)', () => {
+      const config = {...baseConfig, whitelistEntries: []};
+      const rule = new ConformancePatternRule(config);
+      const results = compileAndCheck(rule, source);
+
+      expect(results).toHaveNFailures(1, config);
+    });
+
+    it('matches if there is an empty whitelist group', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+        }]
+      };
+      const rule = new ConformancePatternRule(config);
+      const results = compileAndCheck(rule, source);
+
+      expect(results).toHaveNFailures(1, config);
+    });
+
+    it('respects prefix-based whitelists (matching test)', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+          prefix: [tmpPrefixForWhitelist],
+        }]
+      };
+      const rule = new ConformancePatternRule(config);
+      const results = compileAndCheck(rule, source);
+
+      expect(results).toHaveNFailures(0, config);
+    });
+
+    it('respects prefix-based whitelists (non-matching test)', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+          prefix: ['/nowhere in particular/'],
+        }]
+      };
+      const rule = new ConformancePatternRule(config);
+      const results = compileAndCheck(rule, source);
+
+      expect(results).toHaveNFailures(1, config);
+    });
+
+    it('respects regex-based whitelists', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+          regexp: [`${tmpRegexpForWhitelist}.+/file_0\\.ts`]
+        }]
+      };
+      const rule = new ConformancePatternRule(config);
+      const results = compileAndCheck(rule, source);
+
+      expect(results).toHaveNFailures(0, config);
+    });
+
+    it('accepts several regex-based whitelists', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+          regexp: [
+            `${tmpRegexpForWhitelist}.+/file_0\\.ts`,
+            `${tmpRegexpForWhitelist}.+/file_1\\.ts`
+          ]
+        }]
+      };
+      const rule = new ConformancePatternRule(config);
+      // Testing two times the same file so that both regexps match.
+      const results = compileAndCheck(rule, source, source);
+
+      expect(results).toHaveNFailures(0, config);
+    });
+
+    it('throws on creation of invalid regexps', () => {
+      const config = {
+        ...baseConfig,
+        whitelistEntries: [{
+          reason: WhitelistReason.UNSPECIFIED,
+          regexp: ['(', '/tmp/', 'foo'],
+        }]
+      };
+      expect(() => {
+        new ConformancePatternRule(config);
+      }).toThrowError(/Invalid regular expression/);
+    });
+  });
+});
+
+beforeEach(() => {
+  jasmine.addMatchers(customMatchers);
+});
diff --git a/internal/tsetse/util/pattern_config.ts b/internal/tsetse/util/pattern_config.ts
index 76e2d71..c8c0927 100644
--- a/internal/tsetse/util/pattern_config.ts
+++ b/internal/tsetse/util/pattern_config.ts
@@ -15,6 +15,7 @@
  */
 export interface Config<P extends PatternKind> {
   kind: P;
+
   /**
    * Values have a pattern-specific syntax.
    *
@@ -22,8 +23,60 @@
    * for examples.
    */
   values: string[];
+
   /** The error message this pattern will create. */
   errorMessage: string;
+
+  /** A list of whitelist blocks. */
+  whitelistEntries?: WhitelistEntry[];
+}
+
+/**
+ * A whitelist entry, corresponding to a logical whitelisting rule. Use these to
+ * distinguish between various logical reasons for whitelisting something: for
+ * instance, tie these to particular bugs that needed whitelisting, per legacy
+ * project, manually reviewed entries, and so on.
+ *
+ * Whitelists are based on the file paths provided by the TS compiler, with both
+ * regexp-based checks and prefix-based checks.
+ *
+ *
+ * Follows the logic in
+ * https://github.com/google/closure-compiler/blob/master/src/com/google/javascript/jscomp/conformance.proto.
+ */
+export interface WhitelistEntry {
+  /** The category corresponding to this entry. */
+  reason: WhitelistReason;
+  /** Why is this okay to whitelist. */
+  explanation?: string;
+
+  /**
+   * Regexps for the paths of files that will be ignored by the
+   * ConformancePattern. Beware, escaping can be tricky.
+   */
+  regexp?: string[];
+  /**
+   * Prefixes for the paths of files that will be ignored by the
+   * ConformancePattern.
+   */
+  prefix?: string[];
+}
+
+/**
+ * The categories of whitelist entries.
+ */
+export enum WhitelistReason {
+  /** No reason. */
+  UNSPECIFIED,
+  /** Code that has to be grandfathered in (no guarantees). */
+  LEGACY,
+  /**
+   * Code that does not enter the scope of this particular check  (no
+   * guarantees).
+   */
+  OUT_OF_SCOPE,
+  /** Manually reviewed exceptions (supposedly okay). */
+  MANUALLY_REVIEWED
 }
 
 /** Maps the type of nodes that each `PatternType` produces. */
diff --git a/internal/tsetse/util/pattern_engines/pattern_engine.ts b/internal/tsetse/util/pattern_engines/pattern_engine.ts
index 5dbbddc..6e01420 100644
--- a/internal/tsetse/util/pattern_engines/pattern_engine.ts
+++ b/internal/tsetse/util/pattern_engines/pattern_engine.ts
@@ -1,14 +1,85 @@
+import * as ts from 'typescript';
 import {Checker} from '../../checker';
+import {Fix} from '../../failure';
 import {Fixer} from '../../util/fixer';
 import {Config, MatchedNodeTypes, PatternKind} from '../../util/pattern_config';
+import {shouldExamineNode} from '../ast_tools';
 
 /**
  * A patternEngine is the logic that handles a specific PatternKind.
  */
 export abstract class PatternEngine<P extends PatternKind> {
+  private readonly whitelistedPrefixes: string[] = [];
+  private readonly whitelistedRegExps: RegExp[] = [];
+  private readonly whitelistMemoizer: Map<string, boolean> = new Map();
+
   constructor(
       protected readonly config: Config<P>,
-      protected readonly fixer?: Fixer<MatchedNodeTypes[P]>) {}
+      protected readonly fixer?: Fixer<MatchedNodeTypes[P]>) {
+    if (config.whitelistEntries) {
+      for (const e of config.whitelistEntries) {
+        if (e.prefix) {
+          this.whitelistedPrefixes =
+              this.whitelistedPrefixes.concat(...e.prefix);
+        }
+        if (e.regexp) {
+          this.whitelistedRegExps = this.whitelistedRegExps.concat(
+              ...e.regexp.map(r => new RegExp(r)));
+        }
+      }
+    }
+  }
 
+  /**
+   * `register` will be called by the ConformanceRule to tell Tsetse the
+   * PatternEngine will handle matching. Implementations should use
+   *`checkAndFilterResults` as a wrapper for `check`.
+   **/
   abstract register(checker: Checker): void;
+
+  /**
+   * `check` is the PatternEngine subclass-specific matching logic. Overwrite
+   * with what the engine looks for, i.e., AST matching. The whitelisting logic
+   * and fix generation are handled in `checkAndFilterResults`.
+   */
+  abstract check(tc: ts.TypeChecker, n: MatchedNodeTypes[P]):
+      MatchedNodeTypes[P]|undefined;
+
+  /**
+   * A wrapper for `check` that handles aspects of the analysis that are not
+   * engine-specific, and which defers to the subclass-specific logic
+   * afterwards.
+   */
+  checkAndFilterResults(c: Checker, n: MatchedNodeTypes[P]) {
+    if (!shouldExamineNode(n) || n.getSourceFile().isDeclarationFile) {
+      return;
+    }
+    const matchedNode = this.check(c.typeChecker, n);
+    if (matchedNode && !this.isWhitelisted(matchedNode)) {
+      const fix: Fix|undefined =
+          this.fixer ? this.fixer.getFixForFlaggedNode(matchedNode) : undefined;
+      c.addFailureAtNode(matchedNode, this.config.errorMessage, fix);
+    }
+  }
+
+  isWhitelisted(n: ts.Node): boolean {
+    const name: string = n.getSourceFile().fileName;
+    if (this.whitelistMemoizer.has(name)) {
+      return this.whitelistMemoizer.get(name)!;
+    }
+    for (const p of this.whitelistedPrefixes) {
+      if (name.indexOf(p) == 0) {
+        this.whitelistMemoizer.set(name, true);
+        return true;
+      }
+    }
+    for (const re of this.whitelistedRegExps) {
+      if (re.test(name)) {
+        this.whitelistMemoizer.set(name, true);
+        return true;
+      }
+    }
+    this.whitelistMemoizer.set(name, false);
+    return false;
+  }
 }
diff --git a/internal/tsetse/util/pattern_engines/property_non_constant_write_engine.ts b/internal/tsetse/util/pattern_engines/property_non_constant_write_engine.ts
index 9e26cd7..d9d3a97 100644
--- a/internal/tsetse/util/pattern_engines/property_non_constant_write_engine.ts
+++ b/internal/tsetse/util/pattern_engines/property_non_constant_write_engine.ts
@@ -1,8 +1,7 @@
 import * as ts from 'typescript';
 import {Checker} from '../../checker';
 import {ErrorCode} from '../../error_code';
-import {Fix} from '../../failure';
-import {debugLog, isPropertyWriteExpression, shouldExamineNode} from '../ast_tools';
+import {debugLog, isPropertyWriteExpression} from '../ast_tools';
 import {Fixer} from '../fixer';
 import {isLiteral} from '../is_literal';
 import {PropertyMatcher} from '../match_symbol';
@@ -32,27 +31,25 @@
 
   register(checker: Checker) {
     checker.on(
-        ts.SyntaxKind.BinaryExpression, this.check.bind(this),
+        ts.SyntaxKind.BinaryExpression, this.checkAndFilterResults.bind(this),
         ErrorCode.CONFORMANCE_PATTERN);
   }
 
-  check(c: Checker, n: ts.BinaryExpression) {
-    if (!shouldExamineNode(n) || n.getSourceFile().isDeclarationFile ||
-        !isPropertyWriteExpression(n)) {
+  check(tc: ts.TypeChecker, n: MatchedNodeTypes[BanKind]):
+      MatchedNodeTypes[BanKind]|undefined {
+    if (!isPropertyWriteExpression(n)) {
       return;
     }
     debugLog(`inspecting ${n.getFullText().trim()}`);
-    if (!this.matcher.matches(n.left, c.typeChecker)) {
+    if (!this.matcher.matches(n.left, tc)) {
       debugLog('Not an assignment to the right property');
       return;
     }
-    if (isLiteral(c.typeChecker, n.right)) {
+    if (isLiteral(tc, n.right)) {
       debugLog(`Assigned value (${
           n.right.getFullText()}) is a compile-time constant.`);
       return;
     }
-    const fix: Fix|undefined =
-        this.fixer ? this.fixer.getFixForFlaggedNode(n) : undefined;
-    c.addFailureAtNode(n, this.config.errorMessage, fix);
+    return n;
   }
 }
diff --git a/internal/tsetse/util/pattern_engines/property_write_engine.ts b/internal/tsetse/util/pattern_engines/property_write_engine.ts
index ea90e60..3b92f2d 100644
--- a/internal/tsetse/util/pattern_engines/property_write_engine.ts
+++ b/internal/tsetse/util/pattern_engines/property_write_engine.ts
@@ -1,22 +1,22 @@
 import * as ts from 'typescript';
 import {Checker} from '../../checker';
 import {ErrorCode} from '../../error_code';
-import {Fix} from '../../failure';
-import {debugLog, isPropertyWriteExpression, shouldExamineNode} from '../ast_tools';
+import {debugLog, isPropertyWriteExpression} from '../ast_tools';
 import {Fixer} from '../fixer';
 import {PropertyMatcher} from '../match_symbol';
 import {Config, MatchedNodeTypes, PatternKind} from '../pattern_config';
 import {PatternEngine} from '../pattern_engines/pattern_engine';
 
+// Just for conciseness.
+type BanKind = PatternKind.BANNED_PROPERTY_WRITE;
+
 /**
  * The engine for BANNED_PROPERTY_WRITE.
  */
-export class PropertyWriteEngine extends
-    PatternEngine<PatternKind.BANNED_PROPERTY_WRITE> {
+export class PropertyWriteEngine extends PatternEngine<BanKind> {
   private readonly matcher: PropertyMatcher;
   constructor(
-      config: Config<PatternKind.BANNED_PROPERTY_WRITE>,
-      fixer?: Fixer<MatchedNodeTypes[PatternKind.BANNED_PROPERTY_WRITE]>) {
+      config: Config<BanKind>, fixer?: Fixer<MatchedNodeTypes[BanKind]>) {
     super(config, fixer);
     // TODO: Support more than one single value here, or even build a
     // multi-pattern engine. This would help for performance.
@@ -29,22 +29,21 @@
 
   register(checker: Checker) {
     checker.on(
-        ts.SyntaxKind.BinaryExpression, this.check.bind(this),
+        ts.SyntaxKind.BinaryExpression, this.checkAndFilterResults.bind(this),
         ErrorCode.CONFORMANCE_PATTERN);
   }
 
-  check(c: Checker, n: ts.BinaryExpression) {
-    if (!shouldExamineNode(n) || n.getSourceFile().isDeclarationFile ||
-        !isPropertyWriteExpression(n)) {
+  check(tc: ts.TypeChecker, n: MatchedNodeTypes[BanKind]):
+      MatchedNodeTypes[BanKind]|undefined {
+    if (!isPropertyWriteExpression(n)) {
       return;
     }
     debugLog(`inspecting ${n.getText().trim()}`);
-    if (this.matcher.matches(n.left, c.typeChecker)) {
-      const fix: Fix|undefined =
-          this.fixer ? this.fixer.getFixForFlaggedNode(n) : undefined;
-      debugLog(`Match. Reporting failure (boundaries: ${n.getStart()}, ${
-          n.getEnd()}] on node [${n.getText()}]`);
-      c.addFailureAtNode(n, this.config.errorMessage, fix);
+    if (!this.matcher.matches(n.left, tc)) {
+      return;
     }
+    debugLog(`Match. Reporting failure (boundaries: ${n.getStart()}, ${
+        n.getEnd()}] on node [${n.getText()}]`);
+    return n;
   }
 }
diff --git a/internal/tsetse/util/testing/test_support.ts b/internal/tsetse/util/testing/test_support.ts
index 8e3d6d0..7b93b7f 100644
--- a/internal/tsetse/util/testing/test_support.ts
+++ b/internal/tsetse/util/testing/test_support.ts
@@ -3,11 +3,13 @@
 import * as crypto from 'crypto';
 import * as fs from 'fs';
 import * as os from 'os';
+import * as path from 'path';
 import * as ts from 'typescript';
 
 import {Checker} from '../../checker';
 import {Failure} from '../../failure';
 import {AbstractRule} from '../../rule';
+import {Config} from '../pattern_config';
 
 
 
@@ -17,11 +19,11 @@
  * the `sourceCode` array.
  */
 export function compile(...sourceCode: string[]): ts.Program {
-  const temporaryFolder = os.tmpdir() +
-      `/tslint_test_input_${crypto.randomBytes(16).toString('hex')}`;
+  const temporaryFolder = os.tmpdir() + path.sep +
+      `tslint_test_input_${crypto.randomBytes(16).toString('hex')}`;
   const fullPaths: string[] = [];
   sourceCode.forEach((s, i) => {
-    fullPaths.push(`${temporaryFolder}/file_${i}.ts`);
+    fullPaths.push(`${temporaryFolder}${path.sep}file_${i}.ts`);
   });
 
   let error: Error|undefined = undefined;
@@ -66,8 +68,43 @@
   return check(rule, program);
 }
 
+/** Turns a Failure to a fileName. */
+export function toFileName(f: Failure) {
+  const file = f.toDiagnostic().file;
+  return file ? file.fileName : 'unknown';
+}
+
+export function getTempDirForWhitelist() {
+  // TS uses forward slashes on Windows ¯\_(ツ)_/¯
+  return os.platform() == 'win32' ? os.tmpdir().replace(/\\/g, '/') :
+                                    os.tmpdir();
+}
+
 // Custom matcher for Jasmine, for a better experience matching fixes.
 export const customMatchers: jasmine.CustomMatcherFactories = {
+
+  toHaveNFailures(): jasmine.CustomMatcher {
+    return {
+      compare: (actual: Failure[], expected: Number, config?: Config<any>) => {
+        if (actual.length === expected) {
+          return {pass: true};
+        } else {
+          let message =
+              `Expected ${expected} Failures, but found ${actual.length}.`;
+          if (actual.length) {
+            message += '\n' + actual.map(f => f.toString()).join('\n');
+          }
+          if (config) {
+            message += `\nConfig: {kind:${config.kind}, values:${
+                JSON.stringify(config.values)}, whitelist:${
+                JSON.stringify(config.whitelistEntries)} }`;
+          }
+          return {pass: false, message};
+        }
+      }
+    };
+  },
+
   toBeFailureMatching(): jasmine.CustomMatcher {
     return {
       compare: (actualFailure: Failure, exp: {
@@ -135,6 +172,8 @@
         end?: number,
         matchedCode?: string,
       }): void;
+
+      toHaveNFailures(expected: Number, config?: Config<any>): void;
     }
   }
 }