| 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. |
| * |
| * Note that this isn't smart about subclasses and types: to write a check, we |
| * strongly suggest finding the expected symbol in externs to find the object |
| * name on which the symbol was initially defined. |
| * |
| * TODO(rjamet): add a file-based optional filter, since FQNs tell you where |
| * your imported symbols were initially defined. That would let us be more |
| * specific in matches (say, you want to ban the fromLiteral in foo.ts but not |
| * the one from bar.ts). |
| */ |
| 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.match('.prototype.')) { |
| throw new Error( |
| 'Your pattern includes a .prototype, but the AbsoluteMatcher is ' + |
| 'meant for non-object matches. Use the PropertyMatcher instead, or ' + |
| 'the Property-based PatternKinds.'); |
| } |
| } |
| |
| matches(n: ts.Node, tc: ts.TypeChecker): 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(`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(`got FQN ${fqn}`); |
| |
| // Name-based check |
| if (!(fqn.endsWith('.' + this.bannedName) || fqn === this.bannedName)) { |
| debugLog(`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(`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(`Symbol never declared?`); |
| return false; |
| } |
| if (!declarations.some(isAmbientDeclaration) && |
| !declarations.some(isInStockLibraries)) { |
| debugLog(`Symbol neither ambient nor from the stock libraries`); |
| return false; |
| } |
| } |
| |
| debugLog(`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) { |
| 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)); |
| } |
| } |