Add a ConformancePattern rule to Tsetse, a configurable pattern-based rule.

PiperOrigin-RevId: 249029529
diff --git a/internal/tsetse/error_code.ts b/internal/tsetse/error_code.ts
index 1e0ea3a..71ca712 100644
--- a/internal/tsetse/error_code.ts
+++ b/internal/tsetse/error_code.ts
@@ -12,4 +12,5 @@
   MUST_USE_PROMISES = 21225,
   BAN_PROMISE_AS_CONDITION = 21226,
   PROPERTY_RENAMING_SAFE = 21227,
+  CONFORMANCE_PATTERN = 21228,
 }
diff --git a/internal/tsetse/rules/conformance_pattern_rule.ts b/internal/tsetse/rules/conformance_pattern_rule.ts
new file mode 100644
index 0000000..ec3c8bf
--- /dev/null
+++ b/internal/tsetse/rules/conformance_pattern_rule.ts
@@ -0,0 +1,52 @@
+import {Checker} from '../checker';
+import {ErrorCode} from '../error_code';
+import {AbstractRule} from '../rule';
+import {Fixer} from '../util/fixer';
+import {Config, MatchedNodeTypes, PatternKind} from '../util/pattern_config';
+import {PatternEngine} from '../util/pattern_engines/pattern_engine';
+import {PropertyWriteEngine} from '../util/pattern_engines/property_write_engine';
+
+/**
+ * Builds a Rule that matches a certain pattern, given as parameter, and
+ * that can additionally run a suggested fix generator on the matches.
+ *
+ * This is templated, mostly to ensure the nodes that have been matched
+ * correspond to what the Fixer expects.
+ */
+export class ConformancePatternRule<P extends PatternKind> implements
+    AbstractRule {
+  readonly ruleName: string;
+  readonly code = ErrorCode.CONFORMANCE_PATTERN;
+
+  private readonly engine: PatternEngine<P>;
+
+  constructor(
+      config: Config<P>, fixer?: Fixer<MatchedNodeTypes[P]>,
+      verbose?: boolean) {
+    // TODO(rjamet): This cheats a bit with the typing, as TS doesn't realize
+    // that P is Config.kind.
+    // tslint:disable-next-line:no-any See above.
+    let engine: PatternEngine<any>;
+    switch (config.kind) {
+      case PatternKind.BANNED_PROPERTY_WRITE:
+        engine = new PropertyWriteEngine(config, fixer, verbose);
+        break;
+      default:
+        throw new Error('Config type not recognized, or not implemented yet.');
+    }
+    this.ruleName = `conformance-pattern-${config.kind}`;
+    this.engine = engine as PatternEngine<P>;
+  }
+
+  register(checker: Checker) {
+    this.engine.register(checker);
+  }
+}
+
+// Re-exported for convenience when instantiating rules.
+/**
+ * The list of supported patterns useable in ConformancePatternRule. The
+ * patterns whose name match JSConformance patterns should behave similarly (see
+ * https://github.com/google/closure-compiler/wiki/JS-Conformance-Framework).
+ */
+export {PatternKind};
diff --git a/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts b/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts
new file mode 100644
index 0000000..45f0c3a
--- /dev/null
+++ b/internal/tsetse/tests/ban_conformance_pattern/property_write_test.ts
@@ -0,0 +1,86 @@
+import 'jasmine';
+import {ConformancePatternRule, PatternKind} from '../../rules/conformance_pattern_rule';
+import {compileAndCheck, customMatchers} from '../../util/testing/test_support';
+
+describe('BANNED_PROPERTY_WRITE', () => {
+  it('matches simple examples', () => {
+    const source = `const q = document.createElement('q');\n` +
+        `q.cite = 'some example string';\n`;
+    const rule = new ConformancePatternRule({
+      errorMessage: 'do not cite',
+      kind: PatternKind.BANNED_PROPERTY_WRITE,
+      values: ['HTMLQuoteElement.prototype.cite']
+    });
+    const results = compileAndCheck(rule, source);
+
+    expect(results.length).toBe(1);
+    expect(results[0])
+        .toBeFailureMatching({start: 39, end: 69, errorMessage: 'do not cite'});
+  });
+
+  it('understands imported symbols', () => {
+    const sources = [
+      `const q = document.createElement('q'); export {q};`,
+      `import {q} from './file_0'; q.cite = window.name;`
+    ];
+    const rule = new ConformancePatternRule({
+      errorMessage: 'do not cite',
+      kind: PatternKind.BANNED_PROPERTY_WRITE,
+      values: ['HTMLQuoteElement.prototype.cite']
+    });
+    const results = compileAndCheck(rule, ...sources);
+
+    expect(results.length).toBe(1);
+    expect(results[0]).toBeFailureMatching({
+      start: 28,
+      end: 48,
+      errorMessage: 'do not cite',
+      // fileName: 'file_0.ts'
+      // TODO(rjamet): why is there no source file in the finding?
+    });
+  });
+
+
+  describe('with inheritance', () => {
+    const source = [
+      `class Parent {x:number}`,
+      `class Child extends Parent {}`,
+      `const c:Child = new Child();`,
+      `c.x = 1;`,
+    ].join('\n');
+
+    // Both of these should have the same results: in `c.x`, `x` matches,
+    // and `c` is both a Parent and a Child.
+    const expectedFailure = {
+      start: 83,
+      end: 90,
+      errorMessage: 'found write to x',
+    };
+
+    it('banning Parent.x matches (instance of Child).x', () => {
+      const ruleOnParent = new ConformancePatternRule({
+        errorMessage: 'found write to x',
+        kind: PatternKind.BANNED_PROPERTY_WRITE,
+        values: ['Parent.prototype.x']
+      });
+      const r = compileAndCheck(ruleOnParent, source);
+      expect(r.length).toBe(1);
+      expect(r[0]).toBeFailureMatching(expectedFailure);
+    });
+
+    it('banning Child.x matches x defined on Parent', () => {
+      const ruleOnChild = new ConformancePatternRule({
+        errorMessage: 'found write to x',
+        kind: PatternKind.BANNED_PROPERTY_WRITE,
+        values: ['Child.prototype.x']
+      });
+      const r = compileAndCheck(ruleOnChild, source);
+      expect(r.length).toBe(1);
+      expect(r[0]).toBeFailureMatching(expectedFailure);
+    });
+  });
+});
+
+beforeEach(() => {
+  jasmine.addMatchers(customMatchers);
+});
diff --git a/internal/tsetse/util/pattern_config.ts b/internal/tsetse/util/pattern_config.ts
new file mode 100644
index 0000000..c2748d9
--- /dev/null
+++ b/internal/tsetse/util/pattern_config.ts
@@ -0,0 +1,33 @@
+import * as ts from 'typescript';
+
+/**
+ * The list of supported patterns useable in ConformancePatternRule. The
+ * patterns whose name match JSConformance patterns should behave similarly (see
+ * https://github.com/google/closure-compiler/wiki/JS-Conformance-Framework)
+ */
+export enum PatternKind {
+  BANNED_PROPERTY_WRITE = 'banned-property-write',
+}
+
+/**
+ * A config for ConformancePatternRule.
+ */
+export interface Config<P extends PatternKind> {
+  kind: P;
+  /**
+   * Values have a pattern-specific syntax.
+   *
+   * TODO(rjamet): We'll document them, but for now see each patternKind's tests
+   * for examples.
+   */
+  values: string[];
+  /** The error message this pattern will create. */
+  errorMessage: string;
+}
+
+/** Maps the type of nodes that each `PatternType` produces. */
+export interface MatchedNodeTypes {
+  [PatternKind.BANNED_PROPERTY_WRITE]: ts.BinaryExpression&{
+    left: ts.PropertyAccessExpression;
+  };
+}
diff --git a/internal/tsetse/util/pattern_engines/pattern_engine.ts b/internal/tsetse/util/pattern_engines/pattern_engine.ts
new file mode 100644
index 0000000..4d315b5
--- /dev/null
+++ b/internal/tsetse/util/pattern_engines/pattern_engine.ts
@@ -0,0 +1,15 @@
+import {Checker} from '../../checker';
+import {Fixer} from '../../util/fixer';
+import {Config, MatchedNodeTypes, PatternKind} from '../../util/pattern_config';
+
+/**
+ * A patternEngine is the logic that handles a specific PatternKind.
+ */
+export abstract class PatternEngine<P extends PatternKind> {
+  constructor(
+      protected readonly config: Config<P>,
+      protected readonly fixer?: Fixer<MatchedNodeTypes[P]>,
+      protected readonly verbose?: boolean) {}
+
+  abstract register(checker: Checker): void;
+}
diff --git a/internal/tsetse/util/pattern_engines/property_write_engine.ts b/internal/tsetse/util/pattern_engines/property_write_engine.ts
new file mode 100644
index 0000000..ea8fe35
--- /dev/null
+++ b/internal/tsetse/util/pattern_engines/property_write_engine.ts
@@ -0,0 +1,49 @@
+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 {Fixer} from '../fixer';
+import {PropertyMatcher} from '../match_symbol';
+import {Config, MatchedNodeTypes, PatternKind} from '../pattern_config';
+import {PatternEngine} from '../pattern_engines/pattern_engine';
+
+/**
+ * The engine for BANNED_PROPERTY_WRITE.
+ */
+export class PropertyWriteEngine extends
+    PatternEngine<PatternKind.BANNED_PROPERTY_WRITE> {
+  private readonly matcher: PropertyMatcher;
+  constructor(
+      config: Config<PatternKind.BANNED_PROPERTY_WRITE>,
+      fixer?: Fixer<MatchedNodeTypes[PatternKind.BANNED_PROPERTY_WRITE]>,
+      verbose?: boolean) {
+    super(config, fixer, verbose);
+    // TODO: Support more than one single value here, or even build a
+    // multi-pattern engine. This would help for performance.
+    if (this.config.values.length !== 1) {
+      throw new Error(`BANNED_PROPERTY_WRITE expects one value, got(${
+          this.config.values.join(',')})`);
+    }
+    this.matcher = PropertyMatcher.fromSpec(this.config.values[0]);
+  }
+
+  register(checker: Checker) {
+    checker.on(
+        ts.SyntaxKind.BinaryExpression, this.check.bind(this),
+        ErrorCode.CONFORMANCE_PATTERN);
+  }
+
+  check(c: Checker, n: ts.BinaryExpression) {
+    if (!shouldExamineNode(n) || n.getSourceFile().isDeclarationFile ||
+        !isPropertyWriteExpression(n)) {
+      return;
+    }
+    debugLog(this.verbose, `inspecting ${n.getFullText().trim()}`);
+    if (this.matcher.matches(n.left, c.typeChecker, this.verbose)) {
+      const fix: Fix|undefined =
+          this.fixer ? this.fixer.getFixForFlaggedNode(n) : undefined;
+      c.addFailureAtNode(n, this.config.errorMessage, fix);
+    }
+  }
+}