├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── ci.yml ├── .eslintignore ├── .prettier.json ├── renovate.json ├── .npmignore ├── .gitignore ├── jest.config.js ├── tsconfig.json ├── .eslint.js ├── src ├── after_date.test.ts ├── on_branch.test.ts ├── on_branch.ts ├── getStats.test.ts ├── getStats.ts ├── printDiagnostics.ts ├── after_date.ts ├── when.test.ts ├── types.ts ├── getFilesFromInput.ts ├── analyzeFile.ts ├── index.ts ├── analyzeFile.test.ts ├── utils.ts ├── cli.ts ├── utils.test.ts └── when.ts ├── LICENSE ├── package.json └── README.md /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: ngnijland 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | lib 2 | e2e 3 | .eslintrc.js 4 | -------------------------------------------------------------------------------- /.prettier.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 90 4 | } 5 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | *.swp 4 | *.swo 5 | .DS_Store 6 | *.map 7 | log.txt 8 | 9 | test-workspace 10 | e2e 11 | *.test.js 12 | coverage 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | *.log 3 | *.swp 4 | *.swo 5 | .DS_Store 6 | *.log 7 | log.txt 8 | .vscode 9 | .idea 10 | 11 | test-workspace 12 | lib 13 | coverage 14 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | module.exports = { 3 | preset: "ts-jest", 4 | testEnvironment: "node", 5 | collectCoverageFrom: ["**/*.ts", "!**/*.d.ts", "!/node_modules"], 6 | }; 7 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2019", 5 | "outDir": "lib", 6 | "rootDir": "src", 7 | "strict": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "skipLibCheck": true, 11 | "resolveJsonModule": true, 12 | "esModuleInterop": true 13 | }, 14 | "exclude": ["lib", "e2e", "node_modules", "test-workspace", "**/*.test.ts"] 15 | } 16 | -------------------------------------------------------------------------------- /.eslint.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es6: true, 4 | node: true, 5 | }, 6 | extends: ['plugin:@typescript-eslint/recommended'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | project: 'tsconfig.json', 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | '@typescript-eslint/no-explicit-any': 'off', 15 | '@typescript-eslint/explicit-module-boundary-types': 'off', 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/after_date.test.ts: -------------------------------------------------------------------------------- 1 | import { after_date } from "./after_date"; 2 | 3 | const mockOptions = { 4 | after_date: { 5 | warn: "4d", 6 | }, 7 | }; 8 | 9 | test("after_date works", () => { 10 | const date = "2021-08-01"; 11 | const res = after_date(date, mockOptions); 12 | 13 | expect(res.error).toBe(true); 14 | }); 15 | 16 | test("after_date works without options", () => { 17 | const date = "2021-08-01"; 18 | const res = after_date(date); 19 | 20 | expect(res.error).toBe(true); 21 | }); 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - uses: actions/setup-node@v2 13 | with: 14 | node-version: '16.x' 15 | registry-url: 'https://registry.npmjs.org' 16 | - run: npm ci 17 | - run: npm run compile 18 | - run: npm publish 19 | env: 20 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 21 | -------------------------------------------------------------------------------- /src/on_branch.test.ts: -------------------------------------------------------------------------------- 1 | import { on_branch } from "./on_branch"; 2 | 3 | jest.mock('./utils', () => ({ 4 | __esModule: true, 5 | getGitBranch: () => 'master', 6 | })) 7 | 8 | test("on_branch is triggered on matching branch", () => { 9 | const branch = "master"; 10 | const res = on_branch(branch); 11 | 12 | expect(res.error).toBe(true); 13 | }); 14 | 15 | test("on_branch is not triggered on not matching branch", () => { 16 | const branch = "feature/branch"; 17 | const res = on_branch(branch); 18 | 19 | expect(res.error).toBe(false); 20 | }); 21 | -------------------------------------------------------------------------------- /src/on_branch.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | import { Validation } from "./types"; 3 | import { getGitBranch } from "./utils"; 4 | 5 | 6 | const getOnBranchIssue = ( 7 | branchName: string, 8 | ): Validation => { 9 | const currentGitBranch = getGitBranch(); 10 | 11 | if (branchName === currentGitBranch) { 12 | return { 13 | error: true, 14 | message: "It's time to do it!", 15 | category: DiagnosticCategory.Error, 16 | }; 17 | } 18 | 19 | return { 20 | error: false, 21 | }; 22 | }; 23 | 24 | export function on_branch(branchName: string): Validation { 25 | return getOnBranchIssue(branchName.replace(/"|'/g, "").trim()); 26 | } 27 | -------------------------------------------------------------------------------- /src/getStats.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | 3 | import { getStats } from "./getStats"; 4 | 5 | test("getStats returns stats correctly", () => { 6 | expect(getStats([])).toEqual({ errors: 0, warnings: 0 }); 7 | expect( 8 | getStats([ 9 | { 10 | category: DiagnosticCategory.Error, 11 | error: true, 12 | message: "Foo", 13 | condition: "Baz", 14 | line: "Bar", 15 | lineNumber: 0, 16 | }, 17 | { 18 | category: DiagnosticCategory.Warning, 19 | error: true, 20 | message: "Foo", 21 | condition: "Baz", 22 | line: "Bar", 23 | lineNumber: 0, 24 | }, 25 | ]) 26 | ).toEqual({ errors: 1, warnings: 1 }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/getStats.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | 3 | import { DiagnosticError } from "./types"; 4 | 5 | export type Stats = { 6 | errors: number; 7 | warnings: number; 8 | }; 9 | 10 | export function getStats(diagnostics: DiagnosticError[]): Stats { 11 | return diagnostics.reduce( 12 | (acc, { category }) => { 13 | if (category === DiagnosticCategory.Error) { 14 | return { 15 | ...acc, 16 | errors: acc.errors + 1, 17 | }; 18 | } 19 | 20 | if (category === DiagnosticCategory.Warning) { 21 | return { 22 | ...acc, 23 | warnings: acc.warnings + 1, 24 | }; 25 | } 26 | 27 | return acc; 28 | }, 29 | { errors: 0, warnings: 0 } 30 | ); 31 | } 32 | -------------------------------------------------------------------------------- /src/printDiagnostics.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import Table from "easy-table"; 3 | import { DiagnosticCategory } from "typescript"; 4 | 5 | import { DiagnosticError } from "./types"; 6 | 7 | function getCategory(category: DiagnosticCategory) { 8 | switch (category) { 9 | case DiagnosticCategory.Warning: { 10 | return chalk.yellow("warning"); 11 | } 12 | case DiagnosticCategory.Error: { 13 | return chalk.red("error"); 14 | } 15 | default: { 16 | return chalk.grey("unknown"); 17 | } 18 | } 19 | } 20 | 21 | export function printDiagnostics( 22 | diagnostics: DiagnosticError[], 23 | file: string 24 | ): void { 25 | console.log(`${chalk.underline(file)}`); 26 | 27 | const t = new Table(); 28 | 29 | diagnostics.forEach(({ category, condition, lineNumber, message }) => { 30 | t.cell("Line number", ` ${chalk.grey(lineNumber)}`); 31 | t.cell("Category", getCategory(category)); 32 | t.cell("Message", message); 33 | t.cell("Condition", chalk.grey(condition)); 34 | t.newRow(); 35 | }); 36 | 37 | console.log(t.print()); 38 | } 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Niek 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/after_date.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | import { ConfigOptions, Validation } from "./types"; 3 | import { getWarningPeriod, isValidDate } from "./utils"; 4 | 5 | const getAfterDateIssue = ( 6 | date: string, 7 | options?: ConfigOptions 8 | ): Validation => { 9 | const dateParam = new Date(date).getTime() / 1000; 10 | const now = new Date().getTime() / 1000; 11 | const warningOption = options?.after_date?.warn; 12 | 13 | if (now > dateParam) { 14 | return { 15 | error: true, 16 | message: "It's time to do it!", 17 | category: DiagnosticCategory.Error, 18 | }; 19 | } 20 | 21 | if (warningOption && dateParam - now < getWarningPeriod(warningOption)) { 22 | return { 23 | error: true, 24 | message: "Get ready, time is short!", 25 | category: DiagnosticCategory.Warning, 26 | }; 27 | } 28 | 29 | return { 30 | error: false, 31 | }; 32 | }; 33 | 34 | export function after_date(param: string, options?: ConfigOptions): Validation { 35 | const date = param.replace(/"|'/g, "").trim(); 36 | 37 | if (isValidDate(date)) { 38 | return getAfterDateIssue(date, options); 39 | } 40 | 41 | return { 42 | error: false, 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | branches: [ main ] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [12.x, 14.x, 16.x] 16 | # Based on supported versions: https://nodejs.org/en/about/releases/ 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Use Node.js ${{ matrix.node-version }} 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: ${{ matrix.node-version }} 24 | cache: 'npm' 25 | - run: npm ci 26 | - run: npm run compile 27 | 28 | quality: 29 | runs-on: ubuntu-latest 30 | 31 | strategy: 32 | matrix: 33 | node-version: [12.x, 14.x, 16.x] 34 | # Based on supported versions: https://nodejs.org/en/about/releases/ 35 | 36 | steps: 37 | - uses: actions/checkout@v2 38 | - name: Use Node.js ${{ matrix.node-version }} 39 | uses: actions/setup-node@v2 40 | with: 41 | node-version: ${{ matrix.node-version }} 42 | cache: 'npm' 43 | - run: npm ci 44 | - run: npm run lint 45 | - run: npm run test 46 | -------------------------------------------------------------------------------- /src/when.test.ts: -------------------------------------------------------------------------------- 1 | import { when } from "./when"; 2 | 3 | const mockOptions = { 4 | when: { 5 | warn: "2p", 6 | }, 7 | }; 8 | 9 | const mockPackageJson = { 10 | dependencies: { 11 | typescript: "^4.4.3", 12 | }, 13 | }; 14 | 15 | test("when works", () => { 16 | const param = '"typescript", ">4.0.0"'; 17 | 18 | const res = when(param, { 19 | pjson: mockPackageJson, 20 | options: mockOptions.when, 21 | }); 22 | 23 | expect(res.error).toBe(true) 24 | }); 25 | 26 | test("when does not error", () => { 27 | const param = '"typescript", ">5.0.0"'; 28 | 29 | const res = when(param, { 30 | pjson: mockPackageJson, 31 | options: mockOptions.when, 32 | }); 33 | 34 | expect(res.error).toBe(false) 35 | }); 36 | 37 | test("when does warn", () => { 38 | const param = '"typescript", ">4.4.5"'; 39 | 40 | const res = when(param, { 41 | pjson: mockPackageJson, 42 | options: mockOptions.when, 43 | }); 44 | 45 | expect(res.error).toBe(true) 46 | }); 47 | 48 | test("when works without options", () => { 49 | const param = '"typescript", ">4.4.1"'; 50 | 51 | const res = when(param, { 52 | pjson: mockPackageJson, 53 | options: undefined 54 | }); 55 | 56 | expect(res.error).toBe(true) 57 | }); 58 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | 3 | export type Conditions = { 4 | after_date: (param: string, options?: ConfigOptions) => Validation; 5 | when: (param: string, config: WhenConfig) => Validation; 6 | on_branch: (param: string) => Validation; 7 | }; 8 | 9 | export type ValidationError = { 10 | category: DiagnosticCategory; 11 | error: true; 12 | message: string; 13 | }; 14 | 15 | export type ValidationApproval = { 16 | error: false; 17 | }; 18 | 19 | export type DiagnosticError = ValidationError & { 20 | condition: string; 21 | line: string; 22 | lineNumber: number; 23 | }; 24 | 25 | export type DiagnosticApproval = { 26 | error: false; 27 | }; 28 | 29 | export type ConfigOptions = { 30 | after_date?: { 31 | warn?: string | boolean; 32 | }; 33 | when?: { 34 | warn?: string | boolean; 35 | }; 36 | }; 37 | 38 | export type WhenConfig = { 39 | pjson?: Record | null; 40 | options?: ConfigOptions["when"]; 41 | }; 42 | 43 | export type ValidateContext = { 44 | options?: ConfigOptions; 45 | packageJson: Record | null; 46 | keywords: string[]; 47 | }; 48 | 49 | export type Diagnostic = DiagnosticError | DiagnosticApproval; 50 | 51 | export type Validation = ValidationError | ValidationApproval; 52 | 53 | export type Periods = { w: number; d: number; h: number }; 54 | 55 | export type ValidateTodo = { 56 | additionalKeywords?: string[]; 57 | options?: ConfigOptions; 58 | todo: string; 59 | }; 60 | 61 | export type Levels = { 62 | M: number; 63 | m: number; 64 | p: number; 65 | }; 66 | -------------------------------------------------------------------------------- /src/getFilesFromInput.ts: -------------------------------------------------------------------------------- 1 | import glob from "fast-glob"; 2 | import fs from "fs"; 3 | 4 | type Invalid = [Error, undefined]; 5 | type Files = [undefined, string[]]; 6 | 7 | type Response = Invalid | Files; 8 | 9 | export async function getFilesFromInput( 10 | inputs: string[] = ["."] 11 | ): Promise { 12 | const paths: string[] = typeof inputs === "string" ? [inputs] : inputs; 13 | 14 | if (paths.length === 0) { 15 | return [ 16 | new Error( 17 | "No path(s) provided. Run `tod --help` to see usage information." 18 | ), 19 | undefined, 20 | ]; 21 | } 22 | 23 | const uniqueFiles = new Set(); 24 | 25 | for (const path of paths) { 26 | if (glob.isDynamicPattern(path)) { 27 | const files = await glob(path, { dot: true }); 28 | 29 | for (const file of files) { 30 | uniqueFiles.add(file); 31 | } 32 | 33 | continue; 34 | } 35 | 36 | if (!fs.existsSync(path)) { 37 | return [new Error(`Path "${path}" does not exist.`), undefined]; 38 | } 39 | 40 | if (fs.lstatSync(path).isDirectory()) { 41 | const files = await glob(`${path}/**/*`, { dot: true }); 42 | 43 | for (const file of files) { 44 | uniqueFiles.add(file); 45 | } 46 | 47 | continue; 48 | } 49 | 50 | if (fs.lstatSync(path).isFile()) { 51 | uniqueFiles.add(path); 52 | 53 | continue; 54 | } 55 | 56 | if (path === ".") { 57 | const files = await glob("**/*", { dot: true }); 58 | 59 | for (const file of files) { 60 | uniqueFiles.add(file); 61 | } 62 | 63 | continue; 64 | } 65 | 66 | throw new Error(`Invalid path: ${path}`); 67 | } 68 | 69 | return [undefined, Array.from(uniqueFiles)]; 70 | } 71 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "typescript-todo-or-die-plugin", 3 | "version": "0.6.0", 4 | "description": "", 5 | "main": "lib/index.js", 6 | "bin": { 7 | "tod": "lib/cli.js" 8 | }, 9 | "files": [ 10 | "lib" 11 | ], 12 | "contributors": [ 13 | { 14 | "name": "Dylan Carver", 15 | "email": "dylc2770@gmail.com", 16 | "url": "http://dylancarver.dev/" 17 | }, 18 | { 19 | "name": "Niek Nijland", 20 | "email": "ngnijland@gmail.com", 21 | "url": "http://nieknijland.nl/" 22 | }, 23 | { 24 | "name": "Bogdan Nenu", 25 | "email": "bogdan.nenu@gmail.com", 26 | "url": "http://bogdannenu.com/" 27 | } 28 | ], 29 | "scripts": { 30 | "compile": "tsc -p .", 31 | "watch:compile": "tsc --watch -p .", 32 | "lint": "eslint .", 33 | "test": "jest .", 34 | "test:watch": "jest . --watch" 35 | }, 36 | "repository": { 37 | "type": "git", 38 | "url": "git+https://github.com/ngnijland/typescript-todo-or-die-plugin.git" 39 | }, 40 | "keywords": [ 41 | "typescript", 42 | "plugin", 43 | "todo", 44 | "comments" 45 | ], 46 | "author": "", 47 | "license": "MIT", 48 | "bugs": { 49 | "url": "https://github.com/ngnijland/typescript-todo-or-die-plugin/issues" 50 | }, 51 | "homepage": "https://github.com/ngnijland/typescript-todo-or-die-plugin#readme", 52 | "dependencies": { 53 | "chalk": "^4.1.2", 54 | "commander": "^8.2.0", 55 | "easy-table": "^1.2.0", 56 | "fast-glob": "^3.2.7", 57 | "typescript": "^4.4.3" 58 | }, 59 | "devDependencies": { 60 | "@types/jest": "27.0.2", 61 | "@types/node": "^16.10.2", 62 | "@typescript-eslint/eslint-plugin": "^4.32.0", 63 | "@typescript-eslint/parser": "^4.32.0", 64 | "eslint": "^7.32.0", 65 | "eslint-plugin-prettier": "^4.0.0", 66 | "jest": "27.4.7", 67 | "prettier": "^2.4.1", 68 | "ts-jest": "^27.0.5" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/analyzeFile.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Conditions, 3 | Diagnostic, 4 | DiagnosticError, 5 | ValidateContext, 6 | } from "./types"; 7 | import { startsWithKeyword } from "./utils"; 8 | import { after_date } from "./after_date"; 9 | import { on_branch } from "./on_branch"; 10 | import { when } from "./when"; 11 | 12 | type Line = [string, number]; 13 | 14 | const conditions: Conditions = { 15 | after_date, 16 | on_branch, 17 | when, 18 | }; 19 | 20 | function isDiagnosticError( 21 | validation: Diagnostic 22 | ): validation is DiagnosticError { 23 | return validation.error === true; 24 | } 25 | 26 | export function analyzeFile( 27 | file: string, 28 | { keywords, options, packageJson }: ValidateContext 29 | ): DiagnosticError[] { 30 | const lines = file.split("\n").reduce((acc, line, index) => { 31 | if (startsWithKeyword(line.trim(), keywords)) { 32 | return [...acc, [line, index]]; 33 | } 34 | 35 | return acc; 36 | }, []); 37 | 38 | return lines.reduce((acc, [line, lineNumber]) => { 39 | const condition = line.substring( 40 | line.indexOf("::") + 2, 41 | line.lastIndexOf(":") 42 | ); 43 | const param = line.substring(line.indexOf("(") + 1, line.lastIndexOf(")")); 44 | 45 | if (condition.startsWith("after_date")) { 46 | const validation = conditions.after_date(param, options); 47 | 48 | if (!validation.error) { 49 | return acc; 50 | } 51 | 52 | return [ 53 | ...acc, 54 | { 55 | ...validation, 56 | condition, 57 | line, 58 | lineNumber, 59 | }, 60 | ]; 61 | } 62 | 63 | if (condition.startsWith("when")) { 64 | const validation = conditions.when(param, { 65 | pjson: packageJson, 66 | options: options?.when, 67 | }); 68 | 69 | if (!validation.error) { 70 | return acc; 71 | } 72 | 73 | return [ 74 | ...acc, 75 | { 76 | ...validation, 77 | condition, 78 | line, 79 | lineNumber, 80 | }, 81 | ]; 82 | } 83 | 84 | if (condition.startsWith("on_branch")) { 85 | const validation = conditions.on_branch(param); 86 | 87 | if (!validation.error) { 88 | return acc; 89 | } 90 | 91 | return [ 92 | ...acc, 93 | { 94 | ...validation, 95 | condition, 96 | line, 97 | lineNumber, 98 | }, 99 | ]; 100 | } 101 | 102 | return acc; 103 | }, []); 104 | } 105 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { getJsonFromFile } from "./utils"; 3 | import { analyzeFile } from "./analyzeFile"; 4 | import { ConfigOptions } from "./types"; 5 | 6 | type Config = { 7 | options?: ConfigOptions; 8 | additionalKeywords?: string[]; 9 | }; 10 | 11 | interface PluginCreateInfo extends ts.server.PluginCreateInfo { 12 | config: Config; 13 | } 14 | 15 | function init(modules: { 16 | typescript: typeof import("typescript/lib/tsserverlibrary"); 17 | }) { 18 | const ts = modules.typescript; 19 | 20 | function create({ config, languageService, project }: PluginCreateInfo) { 21 | // Diagnostic logging 22 | project.projectService.logger.info( 23 | "WELCOME TO TODO OR DIE, BE PREPARED TO SOLVE YOUR TODO COMMENTS!" 24 | ); 25 | 26 | // Set up decorator object 27 | const proxy: ts.LanguageService = Object.create(null); 28 | for (let k of Object.keys(languageService) as Array< 29 | keyof ts.LanguageService 30 | >) { 31 | const x = languageService[k]!; 32 | // @ts-expect-error - JS runtime trickery which is tricky to type tersely 33 | proxy[k] = (...args: Array<{}>) => x.apply(languageService, args); 34 | } 35 | 36 | proxy.getSemanticDiagnostics = (filename) => { 37 | const prior = languageService.getSemanticDiagnostics(filename); 38 | const doc = languageService.getProgram()?.getSourceFile(filename); 39 | 40 | const rootDir = project.getCurrentDirectory(); 41 | const packageJson = getJsonFromFile( 42 | `${path.normalize(rootDir)}/package.json` 43 | ); 44 | 45 | if (!doc) { 46 | return prior; 47 | } 48 | 49 | const additionalKeywords = 50 | config?.additionalKeywords && Array.isArray(config.additionalKeywords) 51 | ? config.additionalKeywords.filter( 52 | (keyword) => typeof keyword === "string" 53 | ) 54 | : []; 55 | 56 | const keywords = ["TODO", "FIXME", ...additionalKeywords]; 57 | 58 | const context = { 59 | options: config?.options, 60 | packageJson, 61 | keywords, 62 | }; 63 | 64 | const validations = analyzeFile(doc.text, context); 65 | 66 | return [ 67 | ...prior, 68 | ...validations.map(({ category, line, lineNumber, message }) => ({ 69 | file: doc, 70 | start: doc.getPositionOfLineAndCharacter(lineNumber, 0), 71 | length: line.length, 72 | messageText: message, 73 | category: category, 74 | source: "TOD", 75 | code: 666, 76 | })), 77 | ]; 78 | }; 79 | 80 | return proxy; 81 | } 82 | 83 | return { create }; 84 | } 85 | 86 | export = init; 87 | -------------------------------------------------------------------------------- /src/analyzeFile.test.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | 3 | import { analyzeFile } from "./analyzeFile"; 4 | import { DiagnosticError } from "./types"; 5 | 6 | jest.mock("./utils", () => { 7 | const originalModule = jest.requireActual("./utils"); 8 | 9 | return { 10 | __esModule: true, 11 | ...originalModule, 12 | getGitBranch: () => "main", 13 | }; 14 | }); 15 | 16 | test("analyzeFile handles after_date comments correctly", () => { 17 | const file = ` 18 | // TODO::after_date("2021-01-01"): fix me!\n 19 | // TODO::after_date("2999-01-01"): fix me! 20 | `; 21 | const keywords = ["TODO"]; 22 | const packageJson = {}; 23 | 24 | const expected = [ 25 | { 26 | category: DiagnosticCategory.Error, 27 | condition: `after_date("2021-01-01")`, 28 | error: true, 29 | line: ' // TODO::after_date("2021-01-01"): fix me!', 30 | lineNumber: 1, 31 | message: "It's time to do it!", 32 | }, 33 | ]; 34 | 35 | expect(analyzeFile(file, { keywords, packageJson })).toEqual(expected); 36 | }); 37 | 38 | test("analyzeFile handles when comments correctly", () => { 39 | const file = ` 40 | // TODO::when("foo", ">0.1.0"): fix me!\n 41 | // TODO::when("foo", ">2.0.0"): fix me! 42 | `; 43 | const keywords = ["TODO"]; 44 | const options = { 45 | when: { 46 | warn: "1p", 47 | }, 48 | }; 49 | const packageJson = { 50 | dependencies: { 51 | foo: "^1.0.0", 52 | }, 53 | }; 54 | 55 | const expected = [ 56 | { 57 | category: DiagnosticCategory.Error, 58 | condition: `when("foo", ">0.1.0")`, 59 | error: true, 60 | line: ' // TODO::when("foo", ">0.1.0"): fix me!', 61 | lineNumber: 1, 62 | message: "Your package has arrived! Now on 1.0.0", 63 | }, 64 | ]; 65 | 66 | expect(analyzeFile(file, { keywords, options, packageJson })).toEqual( 67 | expected 68 | ); 69 | expect(analyzeFile(file, { keywords, packageJson })).toEqual(expected); 70 | }); 71 | 72 | test("analyzeFile handles on_branch comments correctly", () => { 73 | const file = ` 74 | // TODO::on_branch("main"): fix me!\n 75 | // TODO::on_branch("feat/story"): fix me! 76 | `; 77 | const keywords = ["TODO"]; 78 | const options = {}; 79 | const packageJson = {}; 80 | 81 | const expected = [ 82 | { 83 | category: DiagnosticCategory.Error, 84 | condition: `on_branch("main")`, 85 | error: true, 86 | line: ' // TODO::on_branch("main"): fix me!', 87 | lineNumber: 1, 88 | message: "It's time to do it!", 89 | }, 90 | ]; 91 | 92 | expect(analyzeFile(file, { keywords, options, packageJson })).toEqual( 93 | expected 94 | ); 95 | }); 96 | 97 | test("analyzeFile ignores comments with unknown conditions", () => { 98 | const file = ` 99 | // TODO::non_existent("foo"): fix me!\n 100 | `; 101 | const keywords = ["TODO"]; 102 | const packageJson = {}; 103 | 104 | const expected = [] as DiagnosticError[]; 105 | 106 | expect(analyzeFile(file, { keywords, packageJson })).toEqual(expected); 107 | }); 108 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { Periods, Levels } from "./types"; 3 | import { spawnSync } from 'child_process'; 4 | 5 | const periods: Periods = { 6 | w: 604800, 7 | d: 86400, 8 | h: 3600, 9 | }; 10 | 11 | const levels: Levels = { 12 | M: 0, 13 | m: 1, 14 | p: 2, 15 | }; 16 | 17 | export const isValidDate = (date: string): boolean => { 18 | //const regex = new RegExp(/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[01])$/) 19 | 20 | //return regex.test(date) 21 | const [year] = date.split("-"); 22 | return year.length === 4; 23 | }; 24 | 25 | export const isValidWarnOption = (option: string): string | boolean => { 26 | return option.match(/^\d+[w|d|h]$/) ? option : false; 27 | }; 28 | 29 | export const isValidWarnWhenOption = (option: string): boolean => { 30 | return option.match(/^\d+[M|m|p]$/) ? true : false; 31 | }; 32 | 33 | export const parseOption = (option: string): [number, string] => [ 34 | parseInt(option.substring(0, option.length - 1), 10), 35 | option.charAt(option.length - 1), 36 | ]; 37 | 38 | export const parseWarnOption = (option: string): [number, keyof Periods] => { 39 | if (!isValidWarnOption(option)) { 40 | return [1, "w"]; 41 | } 42 | 43 | const [multipler, interval] = parseOption(option); 44 | 45 | return [multipler, interval as keyof Periods]; 46 | }; 47 | 48 | export const parseWarnWhenOption = (option: string): [number, number] => { 49 | if (!isValidWarnWhenOption(option)) { 50 | return [1, levels["p"]]; 51 | } 52 | 53 | const [versionAhead, level] = parseOption(option); 54 | 55 | return [versionAhead, levels[level as keyof Levels]]; 56 | }; 57 | 58 | export const getWarningPeriod = (warnOption: string | true): number => { 59 | if (warnOption === true) { 60 | return periods["w"]; 61 | } 62 | 63 | const [multipler, interval] = parseWarnOption(warnOption); 64 | 65 | return multipler * periods[interval]; 66 | }; 67 | 68 | export const startsWithKeyword = ( 69 | todo: string, 70 | keywords: string[] 71 | ): boolean => { 72 | return keywords.some((keyword) => { 73 | return todo.startsWith(`// ${keyword}::`); 74 | }); 75 | }; 76 | 77 | export const getWarningWhen = (warnOption: string | true): [number, number] => { 78 | if (warnOption === true) { 79 | return [1, levels["p"]]; 80 | } 81 | 82 | return parseWarnWhenOption(warnOption); 83 | }; 84 | 85 | export const getSplitPackageVersion = (version: string): number[] => { 86 | return version.split(".").map((n) => parseInt(n, 10)); 87 | }; 88 | 89 | export const getJsonFromFile = ( 90 | filepath: string 91 | ): Record | null => { 92 | try { 93 | const file = fs.readFileSync(filepath); 94 | // @ts-ignore 95 | const data = JSON.parse(file); 96 | 97 | return data; 98 | } catch (_e) { 99 | return null; 100 | } 101 | }; 102 | 103 | export const pipe = ( 104 | fn: (...args: T) => any, 105 | ...fns: Array<(a: any) => any> 106 | ) => { 107 | const piped = fns.reduce( 108 | (prevFn, nextFn) => (value: any) => nextFn(prevFn(value)), 109 | (value) => value 110 | ); 111 | return (...args: T) => piped(fn(...args)); 112 | }; 113 | 114 | export const getGitBranch = (): string | undefined => { 115 | try { 116 | const callResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {cwd: __dirname}); 117 | const { stdout, stderr } = callResult; 118 | 119 | if (stderr.byteLength) { 120 | return; 121 | } 122 | 123 | return stdout.byteLength ? stdout.toString().trim() : undefined; 124 | } catch { 125 | return undefined; 126 | } 127 | } -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { program } from "commander"; 4 | import fs from "fs"; 5 | import chalk from "chalk"; 6 | 7 | import { getFilesFromInput } from "./getFilesFromInput"; 8 | import { analyzeFile } from "./analyzeFile"; 9 | import { printDiagnostics } from "./printDiagnostics"; 10 | import { Stats, getStats } from "./getStats"; 11 | 12 | type PackageJSON = { 13 | version: `${number}.${number}.${number}`; 14 | }; 15 | 16 | type Plugin = { 17 | name: string; 18 | [key: string]: unknown; 19 | }; 20 | 21 | const { version }: PackageJSON = require("../package.json"); 22 | 23 | program 24 | .description("An application to lint your TODO comments") 25 | .version(version) 26 | .argument("", "Files to check for comments") 27 | .action(async (input) => { 28 | const [error, files] = await getFilesFromInput(input); 29 | 30 | if (error) { 31 | console.error(error.message); 32 | process.exit(1); 33 | } 34 | 35 | if (!files) { 36 | console.error("No files found."); 37 | process.exit(1); 38 | } 39 | 40 | const stats: Stats = { 41 | errors: 0, 42 | warnings: 0, 43 | }; 44 | 45 | const currentDirectory = process.cwd(); 46 | 47 | // Read project package.json 48 | const packageJsonPath = `${currentDirectory}/package.json`; 49 | 50 | if (!fs.existsSync(packageJsonPath)) { 51 | console.error( 52 | `"package.json" not found. Make sure to run this command from the root folder of your project.` 53 | ); 54 | process.exit(1); 55 | } 56 | 57 | const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")); 58 | 59 | // Read project tsconfig.json 60 | const tsconfigJsonPath = `${currentDirectory}/tsconfig.json`; 61 | 62 | if (!fs.existsSync(tsconfigJsonPath)) { 63 | console.error( 64 | `"tsconfig.json" not found. Make sure to run this command from the root folder of a TypeScript project.` 65 | ); 66 | process.exit(1); 67 | } 68 | 69 | const tsconfigJson = JSON.parse(fs.readFileSync(tsconfigJsonPath, "utf8")); 70 | const config = tsconfigJson?.compilerOptions?.plugins?.find( 71 | (plugin: Plugin) => plugin.name === "typescript-todo-or-die-plugin" 72 | ); 73 | 74 | const options = config?.options; 75 | const additionalKeywords = config?.additionalKeywords || []; 76 | 77 | files.forEach((file) => { 78 | const doc = fs.readFileSync(file, { encoding: "utf-8" }); 79 | 80 | const diagnostics = analyzeFile(doc, { 81 | keywords: ["TODO", "FIXME", ...additionalKeywords], 82 | packageJson, 83 | options, 84 | }); 85 | 86 | if (diagnostics.length === 0) { 87 | return; 88 | } 89 | 90 | const { errors, warnings } = getStats(diagnostics); 91 | 92 | stats.errors += errors; 93 | stats.warnings += warnings; 94 | 95 | printDiagnostics(diagnostics, file); 96 | }); 97 | 98 | const statsText = `${stats.errors + stats.warnings} problems (${ 99 | stats.errors 100 | } errors, ${stats.warnings} warnings)`; 101 | 102 | if (stats.errors > 0) { 103 | console.log(chalk.red.bold(statsText)); 104 | } else if (stats.warnings > 0) { 105 | console.log(chalk.yellow.bold(statsText)); 106 | } else { 107 | console.log(chalk.green.bold("No unfinished TODO's. Well done!")); 108 | } 109 | 110 | if (stats.errors > 0) { 111 | process.exit(1); 112 | } 113 | 114 | if (process.env.CI && stats.warnings > 0) { 115 | process.exit(1); 116 | } 117 | }) 118 | .parse(process.argv); 119 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # typescript-todo-or-die-plugin 2 | 3 | `TODO`'s that speak up for themselves via the TypeScript Language Server. 4 | 5 | ## Examples 6 | 7 | ```typescript 8 | // Will result in your editor showing an error: "It's time to do it!" 9 | // TODO::after_date("2021-04-02"): remove april fools code 10 | ``` 11 | 12 | ```typescript 13 | // Will result in your editor showing an error: 14 | // "Your package has arrived! now on 4.5.1" 15 | // FIXME::when("typescript", ">4.5.0"): check your types 16 | ``` 17 | 18 | ## Usage 19 | 20 | ### Run as language server plugin 21 | 22 | This plugin requires a project with TypeScript setup. 23 | 24 | 1. Install dependency 25 | 26 | ```bash 27 | npm install --save-dev typescript-todo-or-die-plugin 28 | ``` 29 | 30 | or 31 | 32 | ```bash 33 | yarn add typescript-todo-or-die-plugin --dev 34 | ``` 35 | 36 | 2. Add a plugins section to your tsconfig.json. 37 | 38 | ```json 39 | { 40 | "compilerOptions": { 41 | "plugins": [ 42 | { 43 | "name": "typescript-todo-or-die-plugin", 44 | "options": { 45 | "after_date": { 46 | "warn": "1w" 47 | }, 48 | "when": { 49 | "warn": "1p" 50 | } 51 | }, 52 | "additionalKeywords": ["FIX", "TODO_OR_DIE"] 53 | } 54 | ] 55 | } 56 | } 57 | ``` 58 | 59 | 3. Add `TODO`'s with conditions to your codebase 60 | 61 | **Note**: If you're using Visual Studio Code, you'll have to run the "TypeScript: Select TypeScript Version" command and choose "Use Workspace Version", or click the version number next to "TypeScript" in the lower-right corner. Otherwise, VS Code will not be able to find your plugin. 62 | 63 | ### Run in command line 64 | 65 | 1. Follow steps 1 and 2 from ["Run as language server plugin"](#run-as-language-server-plugin) 66 | 67 | 2. Run in command line: 68 | 69 | ```bash 70 | tod 71 | ``` 72 | 73 | 3. All errors and warnings in the given range of files are outputted to the terminal. When there is at least one error the process will exit with code 1. In the ci the process will exit with code 1 when there is at least one warning. 74 | 75 | ## Conditions 76 | 77 | The following conditions are available to use inside your `TODO` comments 78 | 79 | ### `after_date(date)` 80 | 81 | | Param | Type | Description | 82 | | ----- | ------------ | ---------------------------------------- | 83 | | date | `yyyy-mm-dd` | Date after which an error will be shown. | 84 | 85 | Show an error if today is after the given date 86 | 87 | ##### Configuration options: 88 | 89 | - **warn?**: string | boolean (Ex: '1w'/'2d'/'30h'/true) 90 | 91 | Show a warning before the given date 92 | 93 | ### `when(package, version)` 94 | 95 | | Param | Type | Description | 96 | | ------- | -------- | --------------------------------------------------------------- | 97 | | package | string | Package name to be tracked as defined in the package.json file. | 98 | | version | `>1.0.0` | A comparator (`>` or `=`) followed by the version to be matched | 99 | 100 | Show an error when version is compared with the current version as defined in the 101 | `package.json` file. 102 | 103 | ##### Configuration options: 104 | 105 | - **warn?**: string | boolean (Ex: '1M'/'2m'/'4p'/true) 106 | 107 | Show a warning before the given version matching on M - major versions, m - 108 | minor versions, p - patches. Defaults to 1 patch ahead when `warn` option is present. 109 | 110 | ### `on_branch(branch_name)` 111 | 112 | | Param | Type | Description | 113 | | ------------ | ------------ | ------------------------------------------- | 114 | | branch_name | string | Git branch, on which an error will be shown | 115 | 116 | Show an error if current git branch matches specified one 117 | 118 | ## Additional keywords 119 | 120 | By default `TODO` & `FIXME` are valid keywords to use for your todo comments. Additional keywords can be added as shown here: 121 | 122 | ```json 123 | "plugins": [ 124 | { 125 | "name": "typescript-todo-or-die-plugin", 126 | "additionalKeywords": ["FIX", "TODO_OR_DIE"] 127 | } 128 | ] 129 | ``` 130 | 131 | ## Contributors 132 | 133 | 134 | 135 | 136 | 137 | Made with [contributors-img](https://contrib.rocks). 138 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getSplitPackageVersion, 3 | getWarningPeriod, 4 | getWarningWhen, 5 | isValidDate, 6 | isValidWarnOption, 7 | isValidWarnWhenOption, 8 | parseOption, 9 | parseWarnOption, 10 | parseWarnWhenOption, 11 | pipe, 12 | startsWithKeyword, 13 | } from "./utils"; 14 | 15 | test("isValidDate", () => { 16 | const date = "2021-12-05"; 17 | const res = isValidDate(date); 18 | 19 | expect(res).toBe(true); 20 | }); 21 | 22 | test("isValidDate invalidates wrong format", () => { 23 | const date = "20-06-2021"; 24 | const res = isValidDate(date); 25 | 26 | expect(res).toBe(false); 27 | }); 28 | 29 | test("isValidDate invalidates no format", () => { 30 | const date = "20062021"; 31 | const res = isValidDate(date); 32 | 33 | expect(res).toBe(false); 34 | }); 35 | 36 | test("isValidDate invalidates empty", () => { 37 | const date = ""; 38 | const res = isValidDate(date); 39 | 40 | expect(res).toBe(false); 41 | }); 42 | 43 | test("isValidWarnOption validates", () => { 44 | const validWeeks = "1w"; 45 | const validDays = "20d"; 46 | const validHours = "24h"; 47 | const resultW = isValidWarnOption(validWeeks); 48 | const resultD = isValidWarnOption(validDays); 49 | const resultH = isValidWarnOption(validHours); 50 | 51 | expect(resultW).toBe(validWeeks); 52 | expect(resultD).toBe(validDays); 53 | expect(resultH).toBe(validHours); 54 | }); 55 | 56 | test("isValidWarnOption invalidates", () => { 57 | const invalid1 = "rew"; 58 | const invalid2 = ""; 59 | const invalid3 = "20g"; 60 | const result1 = isValidWarnOption(invalid1); 61 | const result2 = isValidWarnOption(invalid2); 62 | const result3 = isValidWarnOption(invalid3); 63 | 64 | expect(result1).toBe(false); 65 | expect(result2).toBe(false); 66 | expect(result3).toBe(false); 67 | }); 68 | 69 | test("isValidWarnWhenOption validates", () => { 70 | const option1 = "1M"; 71 | const option2 = "2m"; 72 | const option3 = "3p"; 73 | 74 | expect(isValidWarnWhenOption(option1)).toBe(true); 75 | expect(isValidWarnWhenOption(option2)).toBe(true); 76 | expect(isValidWarnWhenOption(option3)).toBe(true); 77 | }); 78 | 79 | test("isValidWarnWhenOption invalidates", () => { 80 | const option1 = "reg"; 81 | const option2 = ""; 82 | const option3 = "3g"; 83 | 84 | expect(isValidWarnWhenOption(option1)).toBe(false); 85 | expect(isValidWarnWhenOption(option2)).toBe(false); 86 | expect(isValidWarnWhenOption(option3)).toBe(false); 87 | }); 88 | 89 | test("parseOption", () => { 90 | const dateOption = "20d"; 91 | const versionOption = "2M"; 92 | expect(parseOption(dateOption)).toStrictEqual([20, "d"]); 93 | expect(parseOption(versionOption)).toStrictEqual([2, "M"]); 94 | }); 95 | 96 | test("parseWarnOption", () => { 97 | const opt = "23h"; 98 | const [multipler, interval] = parseWarnOption(opt); 99 | 100 | expect(multipler).toBe(23); 101 | expect(interval).toBe("h"); 102 | }); 103 | 104 | test("parseWarnOption with wrong option", () => { 105 | const opt = "5x"; 106 | const [multipler, interval] = parseWarnOption(opt); 107 | 108 | expect(multipler).toBe(1); 109 | expect(interval).toBe("w"); 110 | }); 111 | 112 | test("parseWarnWhenOption", () => { 113 | const option1 = "1M"; 114 | const option2 = "2m"; 115 | const option3 = "10p"; 116 | 117 | expect(parseWarnWhenOption(option1)).toStrictEqual([1, 0]); 118 | expect(parseWarnWhenOption(option2)).toStrictEqual([2, 1]); 119 | expect(parseWarnWhenOption(option3)).toStrictEqual([10, 2]); 120 | }); 121 | 122 | test("getWarningPeriod with true", () => { 123 | const option = true; 124 | const res = getWarningPeriod(option); 125 | 126 | expect(res).toBe(604800); 127 | }); 128 | 129 | test("getWarningPeriod with interval option", () => { 130 | const option = "1h"; 131 | const res = getWarningPeriod(option); 132 | 133 | expect(res).toBe(3600); 134 | }); 135 | 136 | test("startsWithKeyword returns true on lines that start with keywords", () => { 137 | const keywords = ["TODO", "FIXME"]; 138 | 139 | const validLine1 = "// TODO::after_date('2020-01-01'): fix me"; 140 | const validLine2 = "// FIXME::after_date('2020-01-01'): fix me"; 141 | 142 | const res1 = startsWithKeyword(validLine1, keywords); 143 | const res2 = startsWithKeyword(validLine2, keywords); 144 | 145 | expect(res1).toBe(true); 146 | expect(res2).toBe(true); 147 | }); 148 | 149 | test("startsWithKeyword returns false on lines that don't start with keywords", () => { 150 | const keywords = ["TODO", "FIXME"]; 151 | const invalidLine = "// NOTAVALIDKEYWORD::after_date('2020-01-01'): fix me"; 152 | const res = startsWithKeyword(invalidLine, keywords); 153 | 154 | expect(res).toBe(false); 155 | }); 156 | 157 | test("getWarningWhen", () => { 158 | const option1 = true; 159 | const option2 = "2m"; 160 | 161 | expect(getWarningWhen(option1)).toStrictEqual([1, 2]); 162 | expect(getWarningWhen(option2)).toStrictEqual([2, 1]); 163 | }); 164 | 165 | test("getSplitPackageVersion", () => { 166 | const p = "1.2.3"; 167 | expect(getSplitPackageVersion(p)).toStrictEqual([1, 2, 3]); 168 | }); 169 | 170 | test("pipe", () => { 171 | const fn1 = (a: number, b: number) => a + b; 172 | const fn2 = (s: number) => s * 2; 173 | 174 | const pipeline = pipe(fn1, fn2); 175 | 176 | expect(pipeline(1, 2)).toStrictEqual(6); 177 | }); 178 | -------------------------------------------------------------------------------- /src/when.ts: -------------------------------------------------------------------------------- 1 | import { DiagnosticCategory } from "typescript"; 2 | import { 3 | ConfigOptions, 4 | Validation, 5 | WhenConfig, 6 | ValidationApproval, 7 | } from "./types"; 8 | import { getWarningWhen, getSplitPackageVersion, pipe } from "./utils"; 9 | 10 | const noerror: ValidationApproval = { 11 | error: false, 12 | }; 13 | 14 | const clean = (version: string): string => { 15 | const first = version.charAt(0); 16 | 17 | if (["~", "^", "=", ">"].includes(first)) { 18 | return version.substring(1); 19 | } 20 | 21 | return version; 22 | }; 23 | 24 | type State = { 25 | skip: boolean; 26 | validation: Validation; 27 | }; 28 | 29 | type ValidationState = { 30 | currentNumbers: number[]; 31 | matchNumbers: number[]; 32 | state: State["validation"]; 33 | }; 34 | 35 | const compareForWarning = ( 36 | curr: number, 37 | match: number, 38 | versionAhead: number 39 | ): boolean => { 40 | return match - curr <= versionAhead; 41 | }; 42 | 43 | const checkEquality = ( 44 | currentNumbers: number[], 45 | matchNumbers: number[] 46 | ): ValidationState => { 47 | if (currentNumbers.join("") === matchNumbers.join("")) { 48 | return { 49 | currentNumbers, 50 | matchNumbers, 51 | state: { 52 | error: true, 53 | message: `It's a match, fix it'! Now on ${currentNumbers.join(".")}`, 54 | category: DiagnosticCategory.Error, 55 | }, 56 | }; 57 | } 58 | 59 | return { 60 | currentNumbers, 61 | matchNumbers, 62 | state: noerror, 63 | }; 64 | }; 65 | 66 | const checkForErrors = ( 67 | currentNumbers: number[], 68 | matchNumbers: number[] 69 | ): ValidationState => { 70 | const state = currentNumbers.reduce( 71 | (acc: State, next: number, idx: number): State => { 72 | if (acc.skip) { 73 | return acc; 74 | } 75 | 76 | const isIssue = next > matchNumbers[idx] || 0; 77 | 78 | if (isIssue) { 79 | return { 80 | skip: true, 81 | validation: { 82 | error: true, 83 | message: `Your package has arrived! Now on ${currentNumbers.join( 84 | "." 85 | )}`, 86 | category: DiagnosticCategory.Error, 87 | }, 88 | }; 89 | } 90 | 91 | return next < matchNumbers[idx] || 0 92 | ? { 93 | skip: true, 94 | validation: noerror, 95 | } 96 | : acc; 97 | }, 98 | { 99 | skip: false, 100 | validation: noerror, 101 | } 102 | ); 103 | 104 | return { 105 | currentNumbers, 106 | matchNumbers, 107 | state: state.validation, 108 | }; 109 | }; 110 | 111 | const checkForWarnings = 112 | (warningOption: string | boolean | undefined) => 113 | ({ currentNumbers, matchNumbers, state }: ValidationState): Validation => { 114 | if (state.error) { 115 | return state; 116 | } 117 | 118 | if (warningOption) { 119 | const [versionAhead, level] = getWarningWhen(warningOption); 120 | 121 | const { validation } = currentNumbers.reduce( 122 | (acc: State, next: number, idx: number): State => { 123 | if (acc.skip) { 124 | return acc; 125 | } 126 | 127 | const shouldWarn = 128 | parseInt(currentNumbers.join(""), 10) < 129 | parseInt(matchNumbers.join("")); 130 | 131 | const isWarning = 132 | shouldWarn && 133 | idx === level && 134 | compareForWarning(next, matchNumbers[idx] || 0, versionAhead); 135 | 136 | if (isWarning) { 137 | return { 138 | skip: true, 139 | validation: { 140 | error: true, 141 | message: `Get ready, your package is on the way!`, 142 | category: DiagnosticCategory.Warning, 143 | }, 144 | }; 145 | } 146 | 147 | return next < matchNumbers[idx] || 0 148 | ? { 149 | skip: true, 150 | validation: noerror, 151 | } 152 | : acc; 153 | }, 154 | { skip: false, validation: noerror } 155 | ); 156 | 157 | return validation; 158 | } 159 | 160 | return noerror; 161 | }; 162 | 163 | const getWhenIssue = ( 164 | current: string, 165 | match: string, 166 | options: ConfigOptions["when"] 167 | ): Validation => { 168 | const comparator = match.charAt(0); 169 | const warningOption = options?.warn; 170 | const currentNumbers = getSplitPackageVersion(clean(current)); 171 | const matchNumbers = getSplitPackageVersion(clean(match)); 172 | 173 | if (comparator === "=") { 174 | const validation = pipe(checkEquality, checkForWarnings(warningOption))( 175 | currentNumbers, 176 | matchNumbers 177 | ); 178 | 179 | return validation; 180 | } 181 | 182 | if (comparator === ">") { 183 | const validation = pipe(checkForErrors, checkForWarnings(warningOption))( 184 | currentNumbers, 185 | matchNumbers 186 | ); 187 | 188 | return validation; 189 | } 190 | 191 | return noerror; 192 | }; 193 | 194 | export const when = ( 195 | conditionParam: string, 196 | config: WhenConfig 197 | ): Validation => { 198 | if (!config || !conditionParam) { 199 | return noerror; 200 | } 201 | 202 | const [dependency, matchVersion] = conditionParam 203 | .split(",") 204 | .map((s) => s.replace(/"|'/g, "").trim()); 205 | 206 | const packages = { 207 | ...config.pjson?.devDependecies, 208 | ...config.pjson?.dependencies, 209 | }; 210 | 211 | if (!packages[dependency]) { 212 | return noerror; 213 | } 214 | 215 | return getWhenIssue(packages[dependency], matchVersion, config.options); 216 | }; 217 | --------------------------------------------------------------------------------