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;
+ }
+ }
+}