Add utilities to Tsetse in prevision of the future rule to ban conformance patterns.

PiperOrigin-RevId: 247593544
diff --git a/internal/BUILD.bazel b/internal/BUILD.bazel
index 9cb5167..8b24d69 100644
--- a/internal/BUILD.bazel
+++ b/internal/BUILD.bazel
@@ -45,8 +45,7 @@
     srcs = glob(
         [
             "tsc_wrapped/*.ts",
-            "tsetse/*.ts",
-            "tsetse/rules/*.ts",
+            "tsetse/**/*.ts",
         ],
         exclude = [
             "**/test_support.ts",
@@ -93,9 +92,15 @@
 
 ts_library(
     name = "test_lib",
-    srcs = glob(["tsc_wrapped/*_test.ts"]) + ["tsc_wrapped/test_support.ts"],
-    tsconfig = "//internal:tsc_wrapped/tsconfig.json",
+    srcs = glob([
+        "tsc_wrapped/*_test.ts",
+        "tsetse/**/*_test.ts",
+    ]) + [
+        "tsc_wrapped/test_support.ts",
+        "tsetse/util/testing/test_support.ts",
+    ],
     compiler = "@build_bazel_rules_typescript//internal:tsc_wrapped_bin",
+    tsconfig = "//internal:tsc_wrapped/tsconfig.json",
     deps = [
         ":tsc_wrapped",
         "@npm//@types/jasmine",
diff --git a/internal/tsetse/util/ast_tools.ts b/internal/tsetse/util/ast_tools.ts
new file mode 100644
index 0000000..44d26ee
--- /dev/null
+++ b/internal/tsetse/util/ast_tools.ts
@@ -0,0 +1,169 @@
+/**
+ * @fileoverview This is a collection of smaller utility functions to operate on
+ * a TypeScript AST, used by JSConformance rules and elsewhere.
+ */
+
+import * as ts from 'typescript';
+
+/**
+ * Returns `n`'s parents in order.
+ */
+export function parents(n: ts.Node): ts.Node[] {
+  const p = [];
+  while (n.parent) {
+    n = n.parent;
+    p.push(n);
+  }
+  return p;
+}
+
+/**
+ * Searches for something satisfying the given test in `n` or its children.
+ */
+export function findInChildren(
+    n: ts.Node, test: (n: ts.Node) => boolean): boolean {
+  let toExplore: ts.Node[] = [n];
+  let cur: ts.Node|undefined;
+  while (cur = toExplore.pop()) {
+    if (test(cur)) {
+      return true;
+    }
+    // Recurse
+    toExplore = toExplore.concat(cur.getChildren());
+  }
+  return false;
+}
+
+/**
+ * Returns true if the pattern-based Rule should look at that node and consider
+ * warning there. The goal is to make it easy to exclude on source files,
+ * blocks, module declarations, JSDoc, lib.d.ts nodes, that kind of things.
+ */
+export function shouldExamineNode(n: ts.Node) {
+  return !(
+      ts.isBlock(n) || ts.isModuleBlock(n) || ts.isModuleDeclaration(n) ||
+      ts.isSourceFile(n) || (n.parent && ts.isTypeNode(n.parent)) ||
+      ts.isJSDoc(n) || isInStockLibraries(n));
+}
+
+/**
+ * Return whether the given declaration is ambient.
+ */
+export function isAmbientDeclaration(d: ts.Declaration): boolean {
+  return Boolean(
+      d.modifiers &&
+      d.modifiers.some(m => m.kind === ts.SyntaxKind.DeclareKeyword));
+}
+
+/**
+ * Return whether the given Node is (or is in) a library included as default.
+ * We currently look for a node_modules/typescript/ prefix, but this could
+ * be expanded if needed.
+ */
+export function isInStockLibraries(n: ts.Node|ts.SourceFile): boolean {
+  const sourceFile = ts.isSourceFile(n) ? n : n.getSourceFile();
+  if (sourceFile) {
+    return sourceFile.fileName.indexOf('node_modules/typescript/') !== -1;
+  } else {
+    // the node is nowhere? Consider it as part of the core libs: we can't do
+    // anything with it anyways, and it was likely included as default.
+    return true;
+  }
+}
+
+/**
+ * Turns the given Symbol into its non-aliased version (which could be itself).
+ * Returns undefined if given an undefined Symbol (so you can call
+ * `dealias(typeChecker.getSymbolAtLocation(node))`).
+ */
+export function dealias(
+    symbol: ts.Symbol|undefined, tc: ts.TypeChecker): ts.Symbol|undefined {
+  if (!symbol) {
+    return undefined;
+  }
+  if (symbol.getFlags() & (ts.SymbolFlags.Alias | ts.SymbolFlags.TypeAlias)) {
+    return dealias(tc.getAliasedSymbol(symbol), tc);
+  }
+  return symbol;
+}
+
+/**
+ * Returns whether `n`'s parents are something indicating a type.
+ */
+export function isPartOfTypeDeclaration(n: ts.Node) {
+  return [n, ...parents(n)].some(
+      p => p.kind === ts.SyntaxKind.TypeReference ||
+          p.kind === ts.SyntaxKind.TypeLiteral);
+}
+
+/**
+ * Returns whether `n` is under an import statement.
+ */
+export function isPartOfImportStatement(n: ts.Node) {
+  return [n, ...parents(n)].some(
+      p => p.kind === ts.SyntaxKind.ImportDeclaration);
+}
+
+/**
+ * Returns whether `n` is a declaration.
+ */
+export function isDeclaration(n: ts.Node): n is ts.VariableDeclaration|
+    ts.ClassDeclaration|ts.FunctionDeclaration|ts.MethodDeclaration|
+    ts.PropertyDeclaration|ts.VariableDeclarationList|ts.InterfaceDeclaration|
+    ts.TypeAliasDeclaration|ts.EnumDeclaration|ts.ModuleDeclaration|
+    ts.ImportDeclaration|ts.ImportEqualsDeclaration|ts.ExportDeclaration|
+    ts.MissingDeclaration {
+  return ts.isVariableDeclaration(n) || ts.isClassDeclaration(n) ||
+      ts.isFunctionDeclaration(n) || ts.isMethodDeclaration(n) ||
+      ts.isPropertyDeclaration(n) || ts.isVariableDeclarationList(n) ||
+      ts.isInterfaceDeclaration(n) || ts.isTypeAliasDeclaration(n) ||
+      ts.isEnumDeclaration(n) || ts.isModuleDeclaration(n) ||
+      ts.isImportDeclaration(n) || ts.isImportEqualsDeclaration(n) ||
+      ts.isExportDeclaration(n) || ts.isMissingDeclaration(n);
+}
+
+/** Type guard for expressions that looks like property writes. */
+export function isPropertyWriteExpression(node: ts.Node):
+    node is(ts.BinaryExpression & {
+      left: ts.PropertyAccessExpression;
+    }) {
+  if (!ts.isBinaryExpression(node)) {
+    return false;
+  }
+  if (node.operatorToken.getText().trim() !== '=') {
+    return false;
+  }
+  if (!ts.isPropertyAccessExpression(node.left) ||
+      node.left.expression.getFullText().trim() === '') {
+    return false;
+  }
+
+  // TODO: Destructuring assigments aren't covered. This would be a potential
+  // bypass, but I doubt we'd catch bugs, so fixing it seems low priority
+  // overall.
+
+  return true;
+}
+
+/**
+ * Debug helper.
+ */
+export function debugLog(verbose: boolean|undefined, msg: string) {
+  if (verbose) console.info(msg);
+}
+
+/**
+ * If verbose, logs the given error that happened while walking n, with a
+ * stacktrace.
+ */
+export function logASTWalkError(verbose: boolean, n: ts.Node, e: Error) {
+  let nodeText = `[error getting name for ${JSON.stringify(n)}]`;
+  try {
+    nodeText = '"' + n.getFullText().trim() + '"';
+  } catch {
+  }
+  debugLog(
+      verbose,
+      `Walking node ${nodeText} failed with error ${e}.\n` +
+          `Stacktrace:\n${e.stack}`);
+}
diff --git a/internal/tsetse/util/fixer.ts b/internal/tsetse/util/fixer.ts
new file mode 100644
index 0000000..19de12a
--- /dev/null
+++ b/internal/tsetse/util/fixer.ts
@@ -0,0 +1,191 @@
+import * as ts from 'typescript';
+import {Fix, IndividualChange} from '../failure';
+import {debugLog} from './ast_tools';
+
+/**
+ * A Fixer turns Nodes (that are supposed to have been matched before) into a
+ * Fix. This is meant to be implemented by Rule implementers (or
+ * ban-preset-pattern users). See also `buildReplacementFixer` for a simpler way
+ * of implementing a Fixer.
+ */
+export interface Fixer<NodeType extends ts.Node = ts.Node> {
+  getFixForFlaggedNode(node: NodeType, v?: boolean): Fix|undefined;
+}
+
+/**
+ * A simple Fixer builder based on a function that looks at a node, and
+ * output either nothing, or a replacement. If this is too limiting, implement
+ * Fixer instead.
+ */
+export function buildReplacementFixer(
+    potentialReplacementGenerator: (node: ts.Node, v?: boolean) =>
+        ({replaceWith: string} | undefined)): Fixer {
+  return {
+    getFixForFlaggedNode: (n: ts.Node, v?: boolean): Fix | undefined => {
+      const partialFix = potentialReplacementGenerator(n, v);
+      if (!partialFix) {
+        return;
+      }
+      return {
+        changes: [{
+          sourceFile: n.getSourceFile(),
+          start: n.getStart(),
+          end: n.getEnd(),
+          replacement: partialFix.replaceWith,
+        }],
+      };
+    }
+  };
+}
+
+// TODO(rjamet): Both maybeAddNamedImport and maybeAddNamespacedImport are too
+// hard to read to my taste. This could probably be improved upon by being more
+// functionnal, to show the filter passes and get rid of the continues and
+// returns (which are confusing).
+
+/**
+ * Builds an IndividualChange that imports the required symbol from the given
+ * file under the given name. This might reimport the same thing twice in some
+ * cases, but it will always make it available under the right name (though
+ * its name might collide with other imports, as we don't currently check for
+ * that).
+ */
+export function maybeAddNamedImport(
+    source: ts.SourceFile, importWhat: string, fromFile: string,
+    importAs?: string, tazeComment?: string, v?: boolean): IndividualChange|
+    undefined {
+  const importStatements = source.statements.filter(ts.isImportDeclaration);
+  const importSpecifier =
+      importAs ? `${importWhat} as ${importAs}` : importWhat;
+
+  for (const iDecl of importStatements) {
+    const parsedDecl = maybeParseImportNode(iDecl, v);
+    if (!parsedDecl || parsedDecl.fromFile !== fromFile) {
+      // Not an import from the right file, or couldn't understand the import.
+      continue;  // Jump to the next import.
+    }
+    if (ts.isNamespaceImport(parsedDecl.namedBindings)) {
+      debugLog(v, `... but it's a wildcard import`);
+      continue;  // Jump to the next import.
+    }
+
+    // Else, bindings is a NamedImports. We can now search whether the right
+    // symbol is there under the right name.
+    const foundRightImport = parsedDecl.namedBindings.elements.some(
+        iSpec => iSpec.propertyName ?
+            iSpec.name.getText() === importAs &&  // import {foo as bar}
+                iSpec.propertyName.getText() === importWhat :
+            iSpec.name.getText() === importWhat);  // import {foo}
+
+    if (foundRightImport) {
+      debugLog(v, `"${iDecl.getFullText()}" imports ${importWhat} as we want.`);
+      return;  // Our request is already imported under the right name.
+    }
+
+    // Else, insert our symbol in the list of imports from that file.
+    debugLog(v, `No named imports from that file, generating new fix`);
+    return {
+      start: parsedDecl.namedBindings.elements[0].getStart(),
+      end: parsedDecl.namedBindings.elements[0].getStart(),
+      sourceFile: source,
+      replacement: `${importSpecifier}, `,
+    };
+  }
+
+  // If we get here, we didn't find anything imported from the wanted file, so
+  // we'll need the full import string. Add it after the last import,
+  // and let clang-format handle the rest.
+  const newImportStatement = `import {${importSpecifier}} from '${fromFile}';` +
+      (tazeComment ? `  ${tazeComment}\n` : `\n`);
+  const insertionPosition = importStatements.length ?
+      importStatements[importStatements.length - 1].getEnd() + 1 :
+      0;
+  return {
+    start: insertionPosition,
+    end: insertionPosition,
+    sourceFile: source,
+    replacement: newImportStatement,
+  };
+}
+
+/**
+ * Builds an IndividualChange that imports the required namespace from the given
+ * file under the given name. This might reimport the same thing twice in some
+ * cases, but it will always make it available under the right name (though
+ * its name might collide with other imports, as we don't currently check for
+ * that).
+ */
+export function maybeAddNamespaceImport(
+    source: ts.SourceFile, fromFile: string, importAs: string,
+    tazeComment?: string, v?: boolean): IndividualChange|undefined {
+  const importStatements = source.statements.filter(ts.isImportDeclaration);
+
+  const hasTheRightImport = importStatements.some(iDecl => {
+    const parsedDecl = maybeParseImportNode(iDecl, v);
+    if (!parsedDecl || parsedDecl.fromFile !== fromFile) {
+      // Not an import from the right file, or couldn't understand the import.
+      return false;
+    }
+    debugLog(v, `"${iDecl.getFullText()}" is an import from the right file`);
+
+    if (ts.isNamedImports(parsedDecl.namedBindings)) {
+      debugLog(v, `... but it's a named import`);
+      return false;  // irrelevant to our namespace imports
+    }
+    // Else, bindings is a NamespaceImport.
+    if (parsedDecl.namedBindings.name.getText() !== importAs) {
+      debugLog(v, `... but not the right name, we need to reimport`);
+      return false;
+    }
+    debugLog(v, `... and the right name, no need to reimport`);
+    return true;
+  });
+
+  if (!hasTheRightImport) {
+    const insertionPosition = importStatements.length ?
+        importStatements[importStatements.length - 1].getEnd() + 1 :
+        0;
+    return {
+      start: insertionPosition,
+      end: insertionPosition,
+      sourceFile: source,
+      replacement: tazeComment ?
+          `import * as ${importAs} from '${fromFile}';  ${tazeComment}\n` :
+          `import * as ${importAs} from '${fromFile}';\n`,
+    };
+  }
+  return;
+}
+
+/**
+ * This tries to make sense of an ImportDeclaration, and returns the interesting
+ * parts, undefined if the import declaration is valid but not understandable by
+ * the checker.
+ */
+function maybeParseImportNode(iDecl: ts.ImportDeclaration, v?: boolean): {
+  namedBindings: ts.NamedImportBindings|ts.NamespaceImport,
+  fromFile: string
+}|undefined {
+  if (!iDecl.importClause) {
+    // something like import "./file";
+    debugLog(
+        v, `Ignoring import without imported symbol: ${iDecl.getFullText()}`);
+    return;
+  }
+  if (iDecl.importClause.name || !iDecl.importClause.namedBindings) {
+    // Seems to happen in defaults imports like import Foo from 'Bar'.
+    // Not much we can do with that when trying to get a hold of some symbols,
+    // so just ignore that line (worst case, we'll suggest another import
+    // style).
+    debugLog(v, `Ignoring import: ${iDecl.getFullText()}`);
+    return;
+  }
+  if (!ts.isStringLiteral(iDecl.moduleSpecifier)) {
+    debugLog(v, `Ignoring import whose module specifier is not literal`);
+    return;
+  }
+  return {
+    namedBindings: iDecl.importClause.namedBindings,
+    fromFile: iDecl.moduleSpecifier.text
+  };
+}
diff --git a/internal/tsetse/util/is_literal.ts b/internal/tsetse/util/is_literal.ts
new file mode 100644
index 0000000..8b7209c
--- /dev/null
+++ b/internal/tsetse/util/is_literal.ts
@@ -0,0 +1,94 @@
+import * as ts from 'typescript';
+import {findInChildren} from './ast_tools';
+
+/**
+ * Determines if the given ts.Node is literal enough for security purposes.
+ */
+export function isLiteral(typeChecker: ts.TypeChecker, node: ts.Node): boolean {
+  if (ts.isBinaryExpression(node) &&
+      node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
+    // Concatenation is fine, if the parts are literals.
+    return (
+        isLiteral(typeChecker, node.left) &&
+        isLiteral(typeChecker, node.right));
+  } else if (ts.isTemplateExpression(node)) {
+    // Same for template expressions.
+    return node.templateSpans.every(span => {
+      return isLiteral(typeChecker, span.expression);
+    });
+  } else if (ts.isTemplateLiteral(node)) {
+    // and literals (in that order).
+    return true;
+  } else if (ts.isConditionalExpression(node)) {
+    return isLiteral(typeChecker, node.whenTrue) &&
+        isLiteral(typeChecker, node.whenFalse);
+  } else if (ts.isIdentifier(node)) {
+    return isUnderlyingValueAStringLiteral(node, typeChecker);
+  }
+
+  const hasCasts = findInChildren(node, ts.isAsExpression);
+
+  return !hasCasts && isLiteralAccordingToItsType(typeChecker, node);
+}
+
+/**
+ * Given an identifier, this function goes around the AST to determine
+ * whether we should consider it a string literal, on a best-effort basis. It
+ * is an approximation, but should never have false positives.
+ */
+function isUnderlyingValueAStringLiteral(
+    identifier: ts.Identifier, tc: ts.TypeChecker) {
+  // The identifier references a value, and we try to follow the trail: if we
+  // find a variable declaration for the identifier, and it was declared as a
+  // const (so we know it wasn't altered along the way), then the value used
+  // in the declaration is the value our identifier references. That means we
+  // should look at the value used in its initialization (by applying the same
+  // rules as before).
+  // Since we're best-effort, if a part of that operation failed due to lack
+  // of support (for instance, the identifier was imported), then we fail
+  // closed and don't consider the value a literal.
+
+  // TODO(rjamet): This doesn't follow imports, which is a feature that we need
+  // in a fair amount of cases.
+  return getVariableDeclarationsInSameFile(identifier, tc)
+      .filter(isConst)
+      .some(d => d.initializer !== undefined && isLiteral(tc, d.initializer));
+}
+
+/**
+ * Returns whether this thing is a literal based on TS's understanding. This is
+ * only looking at the local type, so there's no magic in that function.
+ */
+function isLiteralAccordingToItsType(
+    typeChecker: ts.TypeChecker, node: ts.Node): boolean {
+  const nodeType = typeChecker.getTypeAtLocation(node);
+  return (nodeType.flags &
+          (ts.TypeFlags.StringLiteral | ts.TypeFlags.NumberLiteral |
+           ts.TypeFlags.BooleanLiteral | ts.TypeFlags.EnumLiteral)) !== 0;
+}
+
+/**
+ * Follows the symbol behind the given identifier, assuming it is a variable,
+ * and return all the variable declarations we can find that match it in the
+ * same file.
+ */
+function getVariableDeclarationsInSameFile(
+    node: ts.Identifier, tc: ts.TypeChecker): ts.VariableDeclaration[] {
+  const symbol = tc.getSymbolAtLocation(node);
+  if (!symbol) {
+    return [];
+  }
+  const decls = symbol.getDeclarations();
+  if (!decls) {
+    return [];
+  }
+  return decls.filter(ts.isVariableDeclaration);
+}
+
+// Tests whether the given variable declaration is Const.
+function isConst(varDecl: ts.VariableDeclaration): boolean {
+  return Boolean(
+      varDecl && varDecl.parent &&
+      ts.isVariableDeclarationList(varDecl.parent) &&
+      varDecl.parent.flags & ts.NodeFlags.Const);
+}
diff --git a/internal/tsetse/util/match_symbol.ts b/internal/tsetse/util/match_symbol.ts
new file mode 100644
index 0000000..1a05c72
--- /dev/null
+++ b/internal/tsetse/util/match_symbol.ts
@@ -0,0 +1,146 @@
+
+
+import * as ts from 'typescript';
+import {dealias, debugLog, isAmbientDeclaration, isDeclaration, isInStockLibraries, isPartOfImportStatement} from './ast_tools';
+
+const JS_IDENTIFIER_FORMAT = '[\\w\\d_-]+';
+const FQN_FORMAT = `(${JS_IDENTIFIER_FORMAT}\.)*${JS_IDENTIFIER_FORMAT}`;
+// A fqn made out of a dot-separated chain of JS identifiers.
+const ABSOLUTE_RE = new RegExp(`^${FQN_FORMAT}$`);
+
+/**
+ * This class matches symbols given a "foo.bar.baz" name, where none of the
+ * steps are instances of classes.
+ */
+export class AbsoluteMatcher {
+  /**
+   * From a "path/to/file.ts:foo.bar.baz" or "foo.bar.baz" matcher
+   * specification, builds a Matcher.
+   */
+  constructor(readonly bannedName: string) {
+    if (!bannedName.match(ABSOLUTE_RE)) {
+      throw new Error('Malformed matcher selector.');
+    }
+
+    // JSConformance used to use a Foo.prototype.bar syntax for bar on
+    // instances of Foo. TS doesn't surface the prototype part in the FQN, and
+    // so you can't tell static `bar` on `foo` from the `bar` property/method
+    // on `foo`. To avoid any confusion, throw there if we see `prototype` in
+    // the spec: that way, it's obvious that you're not trying to match
+    // properties.
+    if (this.bannedName.includes('.prototype')) {
+      throw new Error(
+          'Your pattern includes a .prototype, but the AbsoluteMatcher is ' +
+          'meant for non-object matches. Use the PropertyMatcher instead.');
+    }
+  }
+
+  matches(n: ts.Node, tc: ts.TypeChecker, verbose?: boolean): boolean {
+    // Get the symbol (or the one at the other end of this alias) that we're
+    // looking at.
+    const s = dealias(tc.getSymbolAtLocation(n), tc);
+    if (!s) {
+      debugLog(verbose, `cannot get symbol`);
+      return false;
+    }
+
+    // The TS-provided FQN tells us the full identifier, and the origin file
+    // in some circumstances.
+    const fqn = tc.getFullyQualifiedName(s);
+    debugLog(verbose, `got FQN ${fqn}`);
+
+    // Name-based check
+    if (!(fqn.endsWith('.' + this.bannedName) || fqn === this.bannedName)) {
+      debugLog(verbose, `FQN ${fqn} doesn't match name ${this.bannedName}`);
+      return false;  // not a use of the symbols we want
+    }
+
+    // Check if it's part of a declaration or import. The check is cheap. If
+    // we're looking for the uses of a symbol, we don't alert on the imports, to
+    // avoid flooding users with warnings (as the actual use will be alerted)
+    // and bad fixes.
+    const p = n.parent;
+    if (p && (isDeclaration(p) || isPartOfImportStatement(p))) {
+      debugLog(verbose, `We don't flag symbol declarations`);
+      return false;
+    }
+
+    // No file info in the FQN means it's not explicitly imported.
+    // That must therefore be a local variable, or an ambient symbol
+    // (and we only care about ambients here). Those could come from
+    // either a declare somewhere, or one of the core libraries that
+    // are loaded by default.
+    if (!fqn.startsWith('"')) {
+      // We need to trace things back, so get declarations of the symbol.
+      const declarations = s.getDeclarations();
+      if (!declarations) {
+        debugLog(verbose, `Symbol never declared?`);
+        return false;
+      }
+      if (!declarations.some(isAmbientDeclaration) &&
+          !declarations.some(isInStockLibraries)) {
+        debugLog(
+            verbose, `Symbol neither ambient nor from the stock libraries`);
+        return false;
+      }
+    }
+
+    debugLog(verbose, `all clear, report finding`);
+    return true;
+  }
+}
+
+// TODO: Export the matched node kinds here.
+/**
+ * This class matches a property access node, based on a property holder type
+ * (through its name), i.e. a class, and a property name.
+ *
+ * The logic is voluntarily simple: if a matcher for `a.b` tests a `x.y` node,
+ * it will return true if:
+ * - `x` is of type `a` either directly (name-based) or through inheritance
+ *   (ditto),
+ * - and, textually, `y` === `b`.
+ *
+ * Note that the logic is different from TS's type system: this matcher doesn't
+ * have any knowledge of structural typing.
+ */
+export class PropertyMatcher {
+  static fromSpec(spec: string): PropertyMatcher {
+    if (spec.indexOf('.prototype.') === -1) {
+      throw new Error(`BANNED_PROPERTY expects a .prototype in your query.`);
+    }
+    const requestParser = /^([\w\d_.-]+)\.prototype\.([\w\d_.-]+)$/;
+    const matches = requestParser.exec(spec);
+    if (!matches) {
+      throw new Error('Cannot understand the BannedProperty spec' + spec);
+    }
+    const [bannedType, bannedProperty] = matches.slice(1);
+    return new PropertyMatcher(bannedType, bannedProperty);
+  }
+
+  constructor(readonly bannedType: string, readonly bannedProperty: string) {}
+
+  /**
+   * @param n The PropertyAccessExpression we're looking at.
+   */
+  matches(
+      n: ts.PropertyAccessExpression, tc: ts.TypeChecker, verbose?: boolean) {
+    return n.name.text === this.bannedProperty &&
+        this.typeMatches(tc.getTypeAtLocation(n.expression));
+  }
+
+  private exactTypeMatches(inspectedType: ts.Type): boolean {
+    const typeSymbol = inspectedType.getSymbol() || false;
+    return typeSymbol && typeSymbol.getName() === this.bannedType;
+  }
+
+  // TODO: Account for unknown types/ '?', and 'loose type matches', i.e. if the
+  // actual type is a supertype of the prohibited type.
+  private typeMatches(inspectedType: ts.Type): boolean {
+    if (this.exactTypeMatches(inspectedType)) {
+      return true;
+    }
+    const baseTypes = inspectedType.getBaseTypes() || [];
+    return baseTypes.some(base => this.exactTypeMatches(base));
+  }
+}
diff --git a/internal/tsetse/util/testing/test_support.ts b/internal/tsetse/util/testing/test_support.ts
new file mode 100644
index 0000000..010ad8c
--- /dev/null
+++ b/internal/tsetse/util/testing/test_support.ts
@@ -0,0 +1,110 @@
+import 'jasmine';
+
+import * as crypto from 'crypto';
+import * as fs from 'fs';
+import * as os from 'os';
+import * as ts from 'typescript';
+
+import {Checker} from '../../checker';
+import {Failure, Fix} from '../../failure';
+import {AbstractRule} from '../../rule';
+
+
+function compile(...sourceCode: string[]): ts.Program {
+  const temporaryFolder = os.tmpdir() +
+      `/tslint_test_input_${crypto.randomBytes(16).toString('hex')}`;
+  const fullPaths: string[] = [];
+  sourceCode.forEach((s, i) => {
+    fullPaths.push(`${temporaryFolder}/file_${i}.ts`);
+  });
+
+  let error: Error|undefined = undefined;
+  let program: ts.Program|undefined = undefined;
+  try {  // Wrap it all in a try/finally to clean up the temp files afterwards
+    fs.mkdirSync(temporaryFolder);
+    sourceCode.forEach((s, i) => {
+      fs.writeFileSync(fullPaths[i], s);
+    });
+    program = ts.createProgram(fullPaths, {});
+    if (ts.getPreEmitDiagnostics(program).length !== 0) {
+      throw new Error(
+          'Your program does not compile cleanly. Diagnostics:\n' +
+          ts.formatDiagnostics(
+              ts.getPreEmitDiagnostics(program), ts.createCompilerHost({})));
+    }
+  } catch (e) {
+    error = e;
+  } finally {
+    fullPaths.forEach(p => fs.unlinkSync(p));
+    fs.rmdirSync(temporaryFolder);
+  }
+  if (program && !error) {
+    return program;
+  } else {
+    throw error;
+  }
+}
+
+function check(rule: AbstractRule, program: ts.Program): Failure[] {
+  const checker = new Checker(program);
+  rule.register(checker);
+  return program.getSourceFiles()
+      .map(s => checker.execute(s))
+      .reduce((prev, cur) => prev.concat(cur));
+}
+
+/** Builds and run the given Rule upon the source files that were provided. */
+export function compileAndCheck(
+    rule: AbstractRule, ...sourceCode: string[]): Failure[] {
+  const program = compile(...sourceCode);
+  return check(rule, program);
+}
+
+// Custom matcher for Jasmine, for a better experience matching fixes.
+export const customMatchers: jasmine.CustomMatcherFactories = {
+  toBeFailureMatching(): jasmine.CustomMatcher {
+    return {
+      compare: (actual: ts.Diagnostic&{end: number, fix?: Fix}, exp: {
+        fileName?: string, start: number, end: number,
+      }) => {
+        let regrets = '';
+        if (exp === undefined) {
+          regrets += 'The rule requires two arguments. ';
+        }
+        if (exp.fileName) {
+          if (!actual.file) {
+            regrets += 'Expected diagnostic to have a source file. ';
+          } else if (!actual.file.fileName.endsWith(exp.fileName)) {
+            regrets += `Expected ${actual.file.fileName} to end with ${
+                exp.fileName}. `;
+          }
+        }
+        if (exp.start && actual.start !== exp.start) {
+          regrets += expectation('start', exp.start, actual.start);
+        }
+        if (exp.end && actual.end !== exp.end) {
+          regrets += expectation('end', exp.end, actual.end);
+        }
+        return {pass: regrets === '', message: regrets};
+      }
+    };
+  }
+};
+
+function expectation(fieldname: string, expectation: any, actual: any) {
+  return `Expected .${fieldname} to be ${expectation}, was ${actual}. `;
+}
+
+// And the matching type
+declare global {
+  namespace jasmine {
+    interface Matchers<T> {
+      toBeFailureMatching(expected: {
+        fileName?: string,
+                start: number,
+                end: number,
+                [i: string]: any  // the rest
+      }): void;
+    }
+  }
+}