├── .gitignore ├── .clang-format ├── typings.json ├── tsconfig.json ├── package.json ├── .vscode └── launch.json ├── test_files └── fallthrough.ts ├── src ├── fallthrough.ts ├── matchers.ts ├── match.ts └── main.ts ├── LICENSE ├── test └── test_golden_files.ts └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | built 4 | typings/ 5 | -------------------------------------------------------------------------------- /.clang-format: -------------------------------------------------------------------------------- 1 | Language: JavaScript 2 | BasedOnStyle: Google 3 | ColumnLimit: 100 4 | -------------------------------------------------------------------------------- /typings.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsoops", 3 | "version": false, 4 | "dependencies": {}, 5 | "ambientDependencies": {}, 6 | "devDependencies": { 7 | "chai": "registry:npm/chai#3.5.0+20160402210230" 8 | }, 9 | "ambientDevDependencies": { 10 | "mocha": "registry:dt/mocha#2.2.5+20160317120654", 11 | "node": "registry:dt/node#4.0.0+20160330064709" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es5", 5 | 6 | "sourceMap": true, 7 | "preserveConstEnums": true, 8 | "mapRoot": "built", 9 | "outDir": "built", 10 | 11 | // Static analysis options 12 | "noImplicitAny": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "test_files", 19 | "typings/browser", 20 | "typings/browser.d.ts" 21 | ] 22 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "tsetse", 3 | "version": "0.0.1", 4 | "description": "Static analysis and refactoring driver for TypeScript", 5 | "main": "built/src/main.js", 6 | "scripts": { 7 | "format": "clang-format -style file -i src/*.ts test/*.ts", 8 | "tsc": "tsc", 9 | "tsc:w": "tsc --watch", 10 | "test": "tsc && mocha built/test/*.js", 11 | "typings": "typings install" 12 | }, 13 | "keywords": [ 14 | "typescript" 15 | ], 16 | "author": "Alex Eagle (https://angular.io/)", 17 | "license": "MIT", 18 | "peerDependencies": { 19 | "typescript": "^1.8 || ^1.9.0-dev" 20 | }, 21 | "devDependencies": { 22 | "chai": "^3.5.0", 23 | "clang-format": "^1.0.36", 24 | "mocha": "^2.4.5", 25 | "typescript": "^1.9.0-dev", 26 | "typings": "^0.7.9" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug tests", 6 | "type": "node", 7 | "request": "launch", 8 | "program": "${workspaceRoot}/node_modules/.bin/mocha", 9 | "stopOnEntry": false, 10 | "args": ["built/test/*.js"], 11 | "cwd": "${workspaceRoot}", 12 | "preLaunchTask": null, 13 | "runtimeExecutable": null, 14 | "runtimeArgs": [ 15 | "--nolazy" 16 | ], 17 | "env": { 18 | "NODE_ENV": "development" 19 | }, 20 | "externalConsole": false, 21 | "sourceMaps": false, 22 | "outDir": null 23 | }, 24 | { 25 | "name": "Attach", 26 | "type": "node", 27 | "request": "attach", 28 | "port": 5858, 29 | "address": "localhost", 30 | "restart": false, 31 | "sourceMaps": false, 32 | "outDir": null, 33 | "localRoot": "${workspaceRoot}", 34 | "remoteRoot": null 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /test_files/fallthrough.ts: -------------------------------------------------------------------------------- 1 | let a:number; 2 | switch(a) { 3 | // BUG: missing break statement 4 | case 1: 5 | console.log("1"); 6 | // BUG: missing break statement 7 | case 2: 8 | console.log("2"); 9 | // BUG: missing break statement 10 | default: 11 | console.log("default"); 12 | case 4: 13 | break; 14 | } 15 | 16 | switch(a) { 17 | case 2: 18 | case 3: 19 | console.log("3"); 20 | break; 21 | // FIXME(alexeagle): handle this one too 22 | // case 4: { 23 | // console.log("4"); 24 | // break; 25 | // } 26 | // It's okay in the last clause 27 | default: 28 | console.log("default"); 29 | } 30 | 31 | let p = () => { 32 | switch(1) { 33 | case 1: 34 | return true; 35 | case 2: 36 | return false; 37 | // FIXME: handle this case 38 | //case 3: 39 | // if (1 === 2) { 40 | // return true; 41 | // } else { 42 | // return false; 43 | // } 44 | case 4: 45 | } 46 | } -------------------------------------------------------------------------------- /src/fallthrough.ts: -------------------------------------------------------------------------------- 1 | import * as match from './match'; 2 | import {not, lastStatement, kindIs, anyOf, Matcher} from './matchers'; 3 | import * as ts from 'typescript'; 4 | 5 | type Clause = ts.CaseClause | ts.DefaultClause; 6 | 7 | const last = (l: any[]) => l[l.length - 1]; 8 | 9 | const empty = (c: Clause) => !c.statements.length; 10 | const isLastClause = (c: Clause) => c === last((c.parent as ts.CaseBlock).clauses); 11 | const hasBreakOrReturn = lastStatement( 12 | anyOf(kindIs(ts.SyntaxKind.BreakStatement), kindIs(ts.SyntaxKind.ReturnStatement))); 13 | export const matcher: Matcher = not(anyOf(empty, isLastClause, hasBreakOrReturn)); 14 | 15 | export function describeMatch(t: Clause): match.Match { 16 | if (matcher(t)) { 17 | let result = new match.MatchBuilder(t); 18 | result.diagnosticMsg = 19 | 'Case clause missing break statement\nSee http://tsetse.info/fallthrough'; 20 | result.addFix().appendText('break;'); 21 | return result.build(); 22 | } 23 | return match.NO_MATCH; 24 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2014-2016 Google, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/matchers.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | /* 4 | * Define a predicate on AST nodes. 5 | */ 6 | export type Matcher = (t: T) => boolean; 7 | 8 | export function allOf(...matchers: Matcher[]): Matcher { 9 | return (t: T) => { 10 | for (let m of matchers) { 11 | if (!m(t)) { 12 | return false; 13 | } 14 | } 15 | return true; 16 | }; 17 | } 18 | 19 | export function anyOf(...matchers: Matcher[]): Matcher { 20 | return (t: T) => { 21 | for (let m of matchers) { 22 | if (m(t)) { 23 | return true; 24 | } 25 | } 26 | return false; 27 | }; 28 | } 29 | 30 | export function not(matcher: Matcher): Matcher { 31 | return (t: T) => !matcher(t); 32 | } 33 | 34 | export function kindIs(kind: ts.SyntaxKind): Matcher { 35 | return (t: T) => t && t.kind === kind; 36 | } 37 | 38 | export function lastStatement}>( 39 | matcher: Matcher): Matcher { 40 | return (t: T) => matcher(t.statements[t.statements.length - 1]); 41 | } 42 | -------------------------------------------------------------------------------- /test/test_golden_files.ts: -------------------------------------------------------------------------------- 1 | import {expect} from 'chai'; 2 | import {createReadStream, readdirSync} from 'fs'; 3 | import {createInterface} from 'readline'; 4 | import {checkOneFile} from '../src/main'; 5 | import * as ts from 'typescript'; 6 | 7 | let r = /\s*\/\/\s*BUG:/; 8 | 9 | for (let file of readdirSync('test_files')) { 10 | describe(file, () => { 11 | it('should match "// BUG" lines', (done) => { 12 | let path = `test_files/${file}`; 13 | var lineReader = createInterface({input: createReadStream(path, {encoding: 'utf-8'})}); 14 | 15 | let actual: number[] = [], expected: number[] = []; 16 | let lineno = 1; 17 | lineReader 18 | .on('line', 19 | (line: string) => { 20 | if (r.test(line)) { 21 | expected.push(lineno + 1); 22 | } 23 | lineno++; 24 | }) 25 | .on('close', () => { 26 | let diagnostics = checkOneFile(path); 27 | for (let d of diagnostics) { 28 | let line = ts.getLineAndCharacterOfPosition(d.file, d.start).line + 1; 29 | actual.push(line) 30 | } 31 | expect(actual).to.deep.equal(expected); 32 | done(); 33 | }); 34 | }); 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/match.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | 3 | export const NO_MATCH: Match = { 4 | diagnostic: undefined 5 | }; 6 | export interface Match { 7 | diagnostic: ts.Diagnostic; 8 | // First fix is applied by default 9 | // Other fixes may be shown in an interactive context 10 | fixes?: Fix[]; 11 | } 12 | export interface Replacement { 13 | start: number; 14 | end: number; 15 | replaceWith: string; 16 | } 17 | export class Fix { 18 | replacements: Replacement[] = []; 19 | constructor(private nodeStart: number, private nodeEnd: number) {} 20 | appendText(text: string): this { 21 | this.replacements.push({start: this.nodeEnd, end: this.nodeEnd, replaceWith: text}); 22 | return this; 23 | } 24 | } 25 | 26 | export class MatchBuilder { 27 | private diagnostic: ts.Diagnostic; 28 | private fixes: Fix[] = []; 29 | constructor(private node: ts.Node) { 30 | this.diagnostic = { 31 | messageText: '', 32 | category: 1, 33 | code: 1, 34 | file: node.getSourceFile(), 35 | start: node.getStart(), 36 | length: node.getEnd() - node.getStart(), 37 | }; 38 | } 39 | public set diagnosticMsg(msg: string) { this.diagnostic.messageText = msg; } 40 | public addFix(fix: Fix = new Fix(this.node.getStart(), this.node.getEnd())) { 41 | this.fixes.push(fix); 42 | return fix; 43 | } 44 | public build(): Match { return {diagnostic: this.diagnostic, fixes: this.fixes}; } 45 | } -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as ts from 'typescript'; 2 | import * as fallthrough from './fallthrough'; 3 | import {Match, NO_MATCH, Fix, Replacement} from './match'; 4 | 5 | let matches: Match[] = []; 6 | 7 | let visit = (node: ts.Node) => { 8 | switch (node.kind) { 9 | case ts.SyntaxKind.CaseClause: 10 | case ts.SyntaxKind.DefaultClause: 11 | let n = node; 12 | if (fallthrough.matcher(n)) { 13 | let match = fallthrough.describeMatch(n); 14 | if (match !== NO_MATCH) { 15 | matches.push(match); 16 | } 17 | } 18 | break; 19 | } 20 | ts.forEachChild(node, visit); 21 | }; 22 | 23 | const defaultOptions: ts.CompilerOptions = { 24 | target: ts.ScriptTarget.ES6, 25 | noImplicitAny: true, 26 | skipDefaultLibCheck: true, 27 | noEmit: true, 28 | experimentalDecorators: true, 29 | emitDecoratorMetadata: true, 30 | }; 31 | export function checkOneFile( 32 | path: string, compilerOptions: ts.CompilerOptions = defaultOptions): ts.Diagnostic[] { 33 | let program = ts.createProgram([path], compilerOptions); 34 | let diagnostics = ts.getPreEmitDiagnostics(program); 35 | if (diagnostics.length > 0) { 36 | return diagnostics; 37 | } 38 | let sf = program.getSourceFile(path); 39 | if (!sf) { 40 | throw 'SourceFile not found: ' + sf; 41 | } 42 | visit(sf); 43 | 44 | let replacements: Replacement[] = []; 45 | matches.forEach(m => m.fixes.forEach(f => replacements = replacements.concat(f.replacements))); 46 | let fixed = sf.getFullText(); 47 | replacements.sort((a, b) => b.start - a.start).forEach(r => { 48 | fixed = fixed.slice(0, r.start) + r.replaceWith + fixed.slice(r.end); 49 | }); 50 | console.log('fixed source', fixed); 51 | return matches.map(m => m.diagnostic); 52 | } 53 | 54 | export class Extension { 55 | check(sf: ts.SourceFile): {errors: ts.Diagnostic[], data: Object} { 56 | visit(sf); 57 | const errors = matches.map(m => m.diagnostic); 58 | // TODO: return suggested replacements as data 59 | return {errors, data: null}; 60 | } 61 | } 62 | const extension = new Extension(); 63 | export default extension; 64 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # A static analysis and refactoring driver for TypeScript 2 | 3 | This project is modelled on similar work in Java, see 4 | http://ErrorProne.info 5 | 6 | In very early design, do not use. 7 | 8 | ## Errors, not warnings (and definitely not formatting) 9 | To comply with syntax style, users should use a formatter, not a linter. 10 | 11 | Warnings are nice to have, but ought to appear in code review, not on the command line. 12 | Developers should only fix warnings in code they are nearly ready to commit. 13 | 14 | On the other hand, things which are almost always wrong should be reported as errors 15 | by the compiler (both command line and in the editor). This gives us the most early 16 | impact, since our tool catches real defects. 17 | 18 | See my longer post: https://medium.com/@Jakeherringbone/why-linting-makes-me-yawn-cadbd9a51ca9 19 | 20 | ## Matchers use a convenient predicate DSL 21 | When a developer is bitten by a bug, and thinks the compiler might have caught it, 22 | we want to lower the bar for that developer to contribute a checker that catches it. 23 | This biases our checks to those which save developers time. 24 | 25 | A checker is a boolean predicate composition, like this one to detect fallthrough 26 | (same condition as `--noImplicitFallthrough`) 27 | 28 | ``` 29 | const empty = (c: Clause) => !c.statements.length; 30 | const isLastClause = (c: Clause) => c === last((c.parent as ts.CaseBlock).clauses); 31 | const hasBreakOrReturn = lastStatement( 32 | anyOf(kindIs(ts.SyntaxKind.BreakStatement), kindIs(ts.SyntaxKind.ReturnStatement))); 33 | export const matcher: Matcher = not(anyOf(empty, isLastClause, hasBreakOrReturn)); 34 | ``` 35 | 36 | ## Producing a fix 37 | Since we want to focus on errors, we need a way to turn them on for a large codebase without 38 | breaking users builds or asking engineers to take an interrupt to fix things for us. 39 | (Imagine rolling out --noImplicitAny on the whole Angular 2 codebase, or fixing --noImplicitReturns 40 | in Google or Microsoft's entire TS corpus) 41 | 42 | At Google, we apply the fix using a mapreduce across the whole codebase, then use a tool 43 | to automatically send out code reviews to each team and submit. 44 | *See H. Wright, D. Jasper, M. Klimek, C. Carruth, and Z. Wan. Large-scale automated refactoring using clangmr. 45 | In Proceedings of the 29th International Conference on Software Maintenance, 2013.* 46 | 47 | A fix is also nice-to-have in interactive UIs that can prompt the user and update the code. 48 | 49 | Fixes also use a convenient DSL, such as the fix for fallthrough, where we assume most developers 50 | meant to have a break statement: 51 | ``` 52 | let result = new match.MatchBuilder(t); 53 | result.diagnosticMsg = 54 | 'Case clause missing break statement\nSee http://tsetse.info/fallthrough'; 55 | result.addFix().appendText('break;'); 56 | return result.build(); 57 | ``` 58 | --------------------------------------------------------------------------------