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