├── .gitignore ├── .npmignore ├── .prettierignore ├── .prettierrc ├── .vscode └── settings.json ├── CONTRIBUTOR_LICENSE_AGREEMENT.md ├── LICENSE.md ├── README.md ├── jest.config.js ├── package-lock.json ├── package.json ├── src ├── constants.ts ├── defaultConfig.ts ├── index.ts ├── lintings.ts ├── scripts │ ├── lintAll.ts │ ├── lintOne.ts │ └── lintRef.ts ├── services │ ├── Collector.ts │ ├── ConfigManager.ts │ ├── Logger.ts │ ├── Navigator.ts │ ├── Presenter.ts │ ├── Traverser.ts │ ├── Validator.ts │ ├── index.ts │ └── subvalidators │ │ ├── DefaultValidator.ts │ │ ├── DisplayNameValidator.ts │ │ ├── LimitValidator.ts │ │ ├── MiscellaneousValidator.ts │ │ ├── NameValidator.ts │ │ ├── NodeDescriptionValidator.ts │ │ ├── OptionsValidator.ts │ │ ├── ParamDescriptionValidator.ts │ │ └── index.ts ├── tests │ ├── exceptions.test.ts │ ├── helpers │ │ └── testHelpers.ts │ ├── mocks │ │ ├── exceptions │ │ │ ├── all.ts │ │ │ ├── multiple.ts │ │ │ └── single.ts │ │ └── validators │ │ │ ├── default.ts │ │ │ ├── displayName.ts │ │ │ ├── limit.ts │ │ │ ├── miscellaneous.node.ts │ │ │ ├── miscellaneous.ts │ │ │ ├── name.ts │ │ │ ├── nodeDescription.node.ts │ │ │ ├── nodeDescription.ts │ │ │ ├── options.ts │ │ │ └── paramDescription.ts │ ├── validator.default.test.ts │ ├── validator.displayName.test.ts │ ├── validator.limit.test.ts │ ├── validator.miscellaneous.test.ts │ ├── validator.name.test.ts │ ├── validator.nodeDescription.test.ts │ ├── validator.options.test.ts │ └── validator.paramDescription.test.ts ├── types.d.ts └── utils.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .todo 3 | *_ 4 | .DS_Store 5 | dist 6 | nodelinter.config.json 7 | lintRef.json 8 | src/input/* 9 | lintOutput.json -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/n8n-io/nodelinter/c04f8128bb3bf553775286b4803112efa9aeea54/.npmignore -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | **/mocks 2 | **/input -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 2, 5 | "trailingComma": "es5" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "editor.formatOnPaste": false, 4 | "search.exclude": { 5 | "dist": true, 6 | "node_modules": true 7 | }, 8 | "files.exclude": { 9 | "node_modules": true 10 | }, 11 | "typescript.tsdk": "node_modules\\typescript\\lib", 12 | } -------------------------------------------------------------------------------- /CONTRIBUTOR_LICENSE_AGREEMENT.md: -------------------------------------------------------------------------------- 1 | # n8n Contributor License Agreement 2 | 3 | I give n8n permission to license my contributions on any terms they like. I am giving them this license in order to make it possible for them to accept my contributions into their project. 4 | 5 | **_As far as the law allows, my contributions come as is, without any warranty or condition, and I will not be liable to anyone for any damages related to this software or this license, under any kind of legal claim._** 6 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | © 2021 Iván Ovejero 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 4 | 5 |

6 |

Nodelinter

7 |

8 | 9 |

10 | Static code analyzer for n8n node files
11 | by Iván Ovejero 12 |

13 | 14 |

15 | 16 | 17 | 18 |

19 | 20 |
21 | 22 | 25 | 26 | **Nodelinter** is a static code analyzer for n8n node files, with ~70 linting rules for: 27 | 28 | - default values based on param type, 29 | - casing for display names and descriptions, 30 | - alphabetization for params and options, 31 | - required and optional key-value pairs, 32 | - expected values for specific params, 33 | - etc. 34 | 35 | See [full lintings list](./src/lintings.ts). 36 | 37 | ## Operation 38 | 39 | Run via npx: 40 | 41 | ```sh 42 | npx nodelinter --target=/Users/john/n8n/packages/nodes-base/nodes/Stripe/Stripe.node.ts 43 | ``` 44 | 45 | Or run locally: 46 | 47 | ```sh 48 | git clone https://github.com/n8n-io/nodelinter 49 | cd nodelinter; npm i 50 | npm run lint -- --target=/Users/john/n8n/packages/nodes-base/nodes/Stripe/Stripe.node.ts 51 | ``` 52 | 53 | ### Options 54 | 55 | | Option | Effect | 56 | | ----------------- | -------------------------------------------------- | 57 | | `--target` | Path of the file or directory to lint | 58 | | `--config` | Path of the [custom config](#custom-config) to use | 59 | | `--print` | Whether to print output to `lintOutput.json` | 60 | | `--patterns` | Lintable file patterns | 61 | | `--errors-only` | Enable error logs only | 62 | | `--warnings-only` | Enable warning logs only | 63 | | `--infos-only` | Enable info logs only | 64 | 65 | Examples: 66 | 67 | ```sh 68 | # lint a single file 69 | --target=./packages/nodes-base/nodes/Stripe/Stripe.node.ts 70 | 71 | # lint all files in a dir 72 | --target=./packages/nodes-base/nodes/Stripe 73 | 74 | # use a custom config 75 | --config=/Users/john/Documents/myConfig.json 76 | 77 | # print logs to lintOutput.json 78 | --print 79 | 80 | # lint files ending with these patterns 81 | --target=./src/input/MyNode --patterns:.node.ts,Description.ts 82 | 83 | # lint files ending with this pattern 84 | --target=./src/input/MyNode --patterns:.node.ts 85 | 86 | # lint only rules with error classification 87 | --target=./src/input/MyNode --errors-only 88 | ``` 89 | 90 | ### Custom config 91 | 92 | You can override the Nodelinter [default config](./src/defaultConfig.ts) with a custom config. 93 | 94 | To do so, create a JSON file containing any keys to overwrite: 95 | 96 | ```json 97 | { 98 | "target": "/Users/john/n8n/packages/nodes-base/nodes/Notion/Notion.node.ts", 99 | "patterns": [".node.ts"], 100 | "sortMethod": "lineNumber", 101 | "lintings": { 102 | "PARAM_DESCRIPTION_MISSING_WHERE_OPTIONAL": { 103 | "enabled": false 104 | }, 105 | "NAME_WITH_NO_CAMELCASE": { 106 | "enabled": false 107 | } 108 | } 109 | } 110 | ``` 111 | 112 | And use the `--config` option: 113 | 114 | ```sh 115 | npx nodelinter --config=/Users/john/Documents/myConfig.json 116 | ``` 117 | 118 | For convenience, when running locally, if you place a custom config file named `nodelinter.config.json` anywhere inside the nodelinter dir, the custom config file will be auto-detected. 119 | 120 | ### Lint exceptions 121 | 122 | Add `// nodelinter-ignore-next-line LINTING_NAME` to except the next line from one or more specific lintings: 123 | 124 | ``` 125 | // nodelinter-ignore-next-line PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE 126 | description: 'Time zone used in the response. The default is the time zone of the calendar.', 127 | 128 | // nodelinter-ignore-next-line PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE PARAM_DESCRIPTION_UNTRIMMED 129 | description: 'Time zone used in the response. The default is the time zone of the calendar. ', 130 | ``` 131 | 132 | Or add `// nodelinter-ignore-next-line` to except the next line from all lintings: 133 | 134 | ``` 135 | // nodelinter-ignore-next-line 136 | description: 'Time zone used in the response. The default is the time zone of the calendar.', 137 | ``` 138 | 139 | 146 | 147 | ## Author 148 | 149 | © 2021 [Iván Ovejero](https://github.com/ivov) 150 | 151 | ## License 152 | 153 | Distributed under the [MIT License](LICENSE.md). 154 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | rootDir: ".", 3 | testMatch: ["/src/tests/*.test.ts"], 4 | transform: { 5 | "^.+\\.ts$": "ts-jest" 6 | }, 7 | testEnvironment: "node", 8 | verbose: true, 9 | // silent: true, 10 | maxWorkers: 1 11 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nodelinter", 3 | "version": "0.1.19", 4 | "description": "Linter for n8n nodes", 5 | "main": "dist/index.js", 6 | "files": [ 7 | "dist/**/*" 8 | ], 9 | "bin": { 10 | "nodelinter": "dist/index.js" 11 | }, 12 | "scripts": { 13 | "prepare": "rm -rf dist && tsc", 14 | "lint": "tsc && node dist/index.js", 15 | "ref": "tsc && node dist/scripts/lintRef.js", 16 | "clear": "rm -rf dist", 17 | "test": "jest" 18 | }, 19 | "keywords": [ 20 | "n8n" 21 | ], 22 | "author": "Iván Ovejero", 23 | "license": "MIT", 24 | "devDependencies": { 25 | "@types/jest": "^26.0.23", 26 | "@types/minimist": "^1.2.2", 27 | "@types/node": "^15.6.0", 28 | "jest": "^27.0.4", 29 | "prettier": "^2.3.0", 30 | "ts-jest": "^27.0.3" 31 | }, 32 | "dependencies": { 33 | "@types/inquirer": "^7.3.3", 34 | "chalk": "^4.1.1", 35 | "inquirer": "^8.1.1", 36 | "minimist": "^1.2.5", 37 | "title-case": "^3.0.3", 38 | "typescript": "^4.3.4" 39 | } 40 | } -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const STANDARD_DESCRIPTIONS = { 2 | limit: "Max number of results to return", 3 | returnAll: "Whether to return all results or only up to a given limit", 4 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 5 | upsert: 6 | "Create a new record, or update the current one if it already exists (upsert)", 7 | simplifyResponse: 8 | "Whether to return a simplified version of the response instead of the raw data", 9 | }; 10 | 11 | export const STANDARD_NAMES = { 12 | simplifyResponse: "Simplify Response", 13 | upsert: "Create or Update", // option 14 | }; 15 | 16 | export const WEAK_DESCRIPTIONS = [ 17 | "The operation to perform", 18 | "Method of authentication", 19 | ]; 20 | 21 | export const SVG_ICON_SOURCES = [ 22 | "https://vecta.io/symbols", 23 | "https://github.com/gilbarbara/logos", 24 | ]; 25 | 26 | /** 27 | * Technical terms to be replaced with user-friendly versions. 28 | */ 29 | export const TECHNICAL_TERMS = ["string", "field"]; 30 | 31 | /** 32 | * British English suffixes to be replaced with their American equivalents. 33 | */ 34 | export const BRITISH_ENGLISH_SUFFIXES = ["yse", "ise", "our"]; 35 | 36 | export const ERRORS = { 37 | UNKNOWN_KEY: { 38 | title: "Invalid key", 39 | message: "Unknown key found in custom config file", 40 | }, 41 | UNKNOWN_OPTION: { 42 | title: "Invalid option", 43 | message: "Unknown option passed in", 44 | }, 45 | FAILED_TO_IMPORT_CUSTOM_CONFIG: { 46 | title: "Failed to import config file", 47 | message: 48 | "Ensure the path specified with --config is a valid JSON config file", 49 | }, 50 | OVERSPECIFIED_TARGET: { 51 | title: "Overspecified target", 52 | message: 53 | "Specify one target, either as a CLI flag with --target or as a key in the config file, not both", 54 | }, 55 | UNSPECIFIED_TARGET: { 56 | title: "Unspecified target", 57 | message: 58 | "Specify the target path (file or dir to lint) either as a CLI flag with --target or as a key in the config file", 59 | }, 60 | CONFIG_AUTODETECTION_FAILED: { 61 | title: "Autodetection of `nodelinter.config.json` failed", 62 | }, 63 | FAILED_TO_FIND_TARGET_AT_PATH: { 64 | title: "No such target", 65 | message: "There is no file or dir at the path specified by the target key", 66 | }, 67 | MULTIPLE_ONLY_ARGS: { 68 | title: "Multiple `--*-only` flags detected", 69 | message: 70 | "Specify one of: `--errors-only`, `--warnings-only`, `--infos-only`", 71 | }, 72 | INVALID_PATTERNS: { 73 | title: "One or more invalid patterns detected", 74 | message: 75 | "Specify: `.node.ts` or `Description.ts` or `.node.ts, Description.ts`", 76 | }, 77 | NOT_LINTABLE_TARGET: { 78 | title: "Non-lintable target", 79 | message: "Target a filepath that ends with `.node.ts` or `Description.ts`.", 80 | }, 81 | }; 82 | 83 | /** 84 | * How many items (inclusive) constitute a long listing, which requires alphabetization. 85 | */ 86 | export const LONG_LISTING_LIMIT = 5; 87 | 88 | /** 89 | * Filename end patterns that the nodelinter is able to lint. 90 | */ 91 | export const LINTABLE_FILE_PATTERNS = [".node.ts", "Description.ts"]; 92 | 93 | /** 94 | * Default filename for logs printed with the `--print` option when leaving the filename unspecified. 95 | */ 96 | export const DEFAULT_PRINT_FILENAME = "lintOutput"; 97 | 98 | /** 99 | * Default filename for autodetectable nodelinter config. 100 | */ 101 | export const DEFAULT_AUTODETECT_FILENAME = "nodelinter.config.json"; 102 | 103 | /** 104 | * Start text for the comment that creates a lint exception for the next line in the source. 105 | */ 106 | export const NEXT_LINE_EXCEPTION_TEXT = "// nodelinter-ignore-next-line"; 107 | -------------------------------------------------------------------------------- /src/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | import { LINTINGS } from "./lintings"; 2 | 3 | export const defaultConfig: Config = { 4 | /** 5 | * Path of file to lint or of the dir whose lintable files to lint. 6 | */ 7 | target: "", 8 | 9 | /** 10 | * Filename end patterns to determine which files is lintable when targeting a dir. 11 | * Only ".node.ts" and "Description.ts" allowed. 12 | */ 13 | patterns: [".node.ts", "Description.ts"], 14 | 15 | /** 16 | * Log sorting method, either `lineNumber` (ascending) or `importance` (error → warning → info). 17 | */ 18 | sortMethod: "lineNumber", 19 | 20 | /** 21 | * Whether to show details for lint messages, where available. 22 | */ 23 | showDetails: true, 24 | 25 | /** 26 | * Whether to extract all param descriptions instead of linting. 27 | */ 28 | extractDescriptions: false, 29 | 30 | /** 31 | * Hex values e.g. `#FFFFFF` to customize log level colors in stdout. Hash symbol `#` required. 32 | */ 33 | logLevelColors: { 34 | error: "", 35 | warning: "", 36 | info: "", 37 | }, 38 | 39 | /** 40 | * Number of characters allowed per line in the terminal screen. 41 | */ 42 | lineWrapChars: 60, 43 | 44 | /** 45 | * Whether to truncate source code excerpts and up to how many characters. 46 | */ 47 | truncateExcerpts: { 48 | enabled: true, 49 | charLimit: 60, 50 | }, 51 | 52 | /** 53 | * Toggle logs state based on log level, lint area, and lint issue. 54 | */ 55 | enable: { 56 | logLevels: { 57 | error: true, 58 | warning: true, 59 | info: true, 60 | }, 61 | lintAreas: { 62 | default: true, 63 | displayName: true, 64 | limit: true, 65 | miscellaneous: true, 66 | name: true, 67 | nodeDescription: true, 68 | options: true, 69 | paramDescription: true, 70 | }, 71 | lintIssues: { 72 | alphabetization: true, 73 | casing: true, 74 | icon: true, 75 | location: true, 76 | missing: true, 77 | naming: true, 78 | punctuation: true, 79 | unneeded: true, 80 | whitespace: true, 81 | wording: true, 82 | wrong: true, 83 | }, 84 | }, 85 | 86 | lintings: LINTINGS, 87 | }; 88 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from "fs"; 4 | import { lintAll } from "./scripts/lintAll"; 5 | import { lintOne } from "./scripts/lintOne"; 6 | import { terminate } from "./utils"; 7 | import { ERRORS } from "./constants"; 8 | import { ConfigManager } from "./services/ConfigManager"; 9 | 10 | export const isNotTestRun = process.argv[1].split("/").pop() !== "jest"; 11 | 12 | const configManager = new ConfigManager(process.argv.slice(2)); 13 | 14 | const { masterConfig, printLogs } = configManager; 15 | 16 | export { masterConfig }; 17 | 18 | if (isNotTestRun) { 19 | let symlink; 20 | 21 | try { 22 | symlink = fs.lstatSync(masterConfig.target); 23 | } catch (error) { 24 | terminate(ERRORS.FAILED_TO_FIND_TARGET_AT_PATH); 25 | } 26 | 27 | symlink.isDirectory() 28 | ? lintAll(masterConfig, { printLogs }) 29 | : lintOne(masterConfig, { printLogs }); 30 | } 31 | -------------------------------------------------------------------------------- /src/lintings.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import { 3 | STANDARD_DESCRIPTIONS, 4 | STANDARD_NAMES, 5 | SVG_ICON_SOURCES, 6 | } from "./constants"; 7 | 8 | export const LINTINGS: { 9 | [LintingName: string]: Linting; 10 | } = { 11 | WRONG_DEFAULT_FOR_STRING_TYPE_PARAM: { 12 | lintAreas: ["default"], 13 | lintIssue: "wrong", 14 | message: "Non-string default for `string`-type param", 15 | enabled: true, 16 | logLevel: "error", 17 | details: 18 | "The default value for a `string`-type param must be a string literal.", 19 | }, 20 | WRONG_DEFAULT_FOR_NUMBER_TYPE_PARAM: { 21 | lintAreas: ["default"], 22 | lintIssue: "wrong", 23 | message: "Non-numeric default for `number`-type param", 24 | enabled: true, 25 | logLevel: "error", 26 | details: 27 | "The default value for a `number`-type param must be a numeric literal.", 28 | }, 29 | WRONG_DEFAULT_FOR_BOOLEAN_TYPE_PARAM: { 30 | lintAreas: ["default"], 31 | lintIssue: "wrong", 32 | message: "Non-boolean default for `boolean`-type param", 33 | enabled: true, 34 | logLevel: "error", 35 | details: 36 | "The default value for a `boolean`-type param must be a boolean keyword.", 37 | }, 38 | WRONG_DEFAULT_FOR_COLLECTION_TYPE_PARAM: { 39 | lintAreas: ["default"], 40 | lintIssue: "wrong", 41 | message: "Non-object default for `collection`-type param", 42 | enabled: true, 43 | logLevel: "error", 44 | details: 45 | "The default value for a `collection`-type param must be an object literal.", 46 | }, 47 | WRONG_DEFAULT_FOR_MULTIOPTIONS_TYPE_PARAM: { 48 | lintAreas: ["default"], 49 | lintIssue: "wrong", 50 | message: "Non-array default for `multiOptions`-type param", 51 | enabled: true, 52 | logLevel: "error", 53 | details: 54 | "The default value for a `multiOptions`-type param must be an array literal.", 55 | }, 56 | WRONG_DEFAULT_FOR_OPTIONS_TYPE_PARAM: { 57 | lintAreas: ["default"], 58 | lintIssue: "wrong", 59 | message: "Non-option default for `options`-type param", 60 | enabled: true, 61 | logLevel: "error", 62 | details: 63 | "The default value for an `options`-type param must be one of the options.", 64 | }, 65 | WRONG_DEFAULT_FOR_SIMPLIFY_PARAM: { 66 | lintAreas: ["default"], 67 | lintIssue: "wrong", 68 | message: "Non-`true` default for Simplify Response param", 69 | enabled: true, 70 | logLevel: "error", 71 | details: "The default value for a Simplify Response param must be `true`.", 72 | }, 73 | DEFAULT_MISSING: { 74 | lintAreas: ["default"], 75 | lintIssue: "missing", 76 | message: "Default value missing for param", 77 | enabled: true, 78 | logLevel: "error", 79 | }, 80 | 81 | DISPLAYNAME_WITH_MISCASED_ID: { 82 | lintAreas: ["displayName"], 83 | lintIssue: "casing", 84 | message: "Miscased `ID` in `displayName` property", 85 | enabled: true, 86 | logLevel: "error", 87 | details: "`ID` must be all uppercase in every `displayName` property.", 88 | }, 89 | DISPLAYNAME_NOT_UPDATE_FIELDS: { 90 | lintAreas: ["displayName"], 91 | lintIssue: "wrong", 92 | message: "Collection param for update operation not named 'Update Fields'", 93 | enabled: true, 94 | logLevel: "error", 95 | }, 96 | DISPLAYNAME_WITH_NO_TITLECASE: { 97 | lintAreas: ["displayName"], 98 | lintIssue: "casing", 99 | message: "No title case in `displayName` property", 100 | enabled: true, 101 | logLevel: "error", 102 | }, 103 | DISPLAYNAME_UNTRIMMED: { 104 | lintAreas: ["displayName"], 105 | lintIssue: "whitespace", 106 | message: "Display name is untrimmed", 107 | enabled: true, 108 | logLevel: "error", 109 | }, 110 | NON_STANDARD_DISPLAY_NAME_FOR_SIMPLIFY_PARAM: { 111 | lintAreas: ["displayName"], 112 | lintIssue: "wording", 113 | message: "Non-standard name of Simplify Response param", 114 | enabled: true, 115 | logLevel: "info", 116 | details: `The standard display name of the simplify param is: ${chalk.bold( 117 | STANDARD_NAMES.simplifyResponse 118 | )}`, 119 | }, 120 | 121 | LIMIT_WITHOUT_TYPE_OPTIONS: { 122 | lintAreas: ["limit"], 123 | lintIssue: "missing", 124 | message: "Limit param without `typeOptions`", 125 | enabled: true, 126 | logLevel: "error", 127 | }, 128 | LIMIT_WITH_MIN_VALUE_LOWER_THAN_ONE: { 129 | lintAreas: ["limit"], 130 | lintIssue: "missing", 131 | message: "Limit param with min value lower than one", 132 | enabled: true, 133 | logLevel: "error", 134 | }, 135 | WRONG_DEFAULT_FOR_LIMIT_PARAM: { 136 | lintAreas: ["limit", "default"], 137 | lintIssue: "wrong", 138 | message: "Non-50 default for `limit` param", 139 | enabled: true, 140 | logLevel: "error", 141 | }, 142 | NON_STANDARD_LIMIT_DESCRIPTION: { 143 | lintAreas: ["limit"], 144 | lintIssue: "wording", 145 | message: "Non-standard description for `limit` param", 146 | enabled: true, 147 | logLevel: "info", 148 | details: `The standard description for \`limit\` is: ${chalk.bold( 149 | STANDARD_DESCRIPTIONS.limit 150 | )}`, 151 | }, 152 | 153 | TOP_LEVEL_OPTIONAL_FIXED_COLLECTION: { 154 | lintAreas: ["miscellaneous"], 155 | lintIssue: "wrong", 156 | message: "Top-level fixed collection without `required: true`", 157 | enabled: true, 158 | logLevel: "warning", 159 | details: 160 | 'A top-level fixed collection is required and therefore must be marked `required: true`. Otherwise it is optional and must be nested in "Additional Fields", "Options", "Filters", "Update Fields", etc.', 161 | }, 162 | RESOURCE_WITHOUT_NO_DATA_EXPRESSION: { 163 | lintAreas: ["miscellaneous"], 164 | lintIssue: "wrong", 165 | message: "Resource param without `noDataExpression: true`", 166 | enabled: true, 167 | logLevel: "error", 168 | details: 169 | "A resource param must have `noDataExpression: true` since it does not allow for expressions.", 170 | }, 171 | OPERATION_WITHOUT_NO_DATA_EXPRESSION: { 172 | lintAreas: ["miscellaneous"], 173 | lintIssue: "wrong", 174 | message: "Operation param without `noDataExpression: true`", 175 | enabled: true, 176 | logLevel: "error", 177 | details: 178 | "An operation param must have `noDataExpression: true` since it does not allow for expressions.", 179 | }, 180 | I_NODE_PROPERTIES_MISCASTING: { 181 | lintAreas: ["miscellaneous"], 182 | lintIssue: "wrong", 183 | message: "Casting instead of typing for `INodeProperties[]`", 184 | enabled: true, 185 | logLevel: "warning", 186 | details: 187 | "Type node params instead of casting them: `export const userOperations: INodeProperties[] = [ ... ]`", 188 | }, 189 | NON_EXISTENT_LOAD_OPTIONS_METHOD: { 190 | lintAreas: ["miscellaneous"], 191 | lintIssue: "wrong", 192 | message: "Param with non-existent `loadOptionsMethod`", 193 | enabled: true, 194 | logLevel: "error", 195 | details: 196 | "The method to load options specified for this param has not been defined in `methods.loadOptions` in the node.", 197 | }, 198 | TS_IGNORE: { 199 | lintAreas: ["miscellaneous"], 200 | lintIssue: "wrong", 201 | message: "@ts-ignore comment detected", 202 | enabled: true, 203 | logLevel: "warning", 204 | details: "@ts-ignore comments suppress compilation errors.", 205 | }, 206 | TODO: { 207 | lintAreas: ["miscellaneous"], 208 | lintIssue: "wrong", 209 | message: "TODO comment detected", 210 | enabled: true, 211 | logLevel: "warning", 212 | }, 213 | REQUIRED_FALSE: { 214 | lintAreas: ["miscellaneous"], 215 | lintIssue: "unneeded", 216 | message: "Unneeded `required: false` in param property", 217 | enabled: true, 218 | logLevel: "warning", 219 | details: 220 | "The property assignment `required: false` is the default, so it does not need to be specified.", 221 | }, 222 | NON_STANDARD_RETURNALL_DESCRIPTION: { 223 | lintAreas: ["miscellaneous"], 224 | lintIssue: "wording", 225 | message: "Non-standard description for `returnAll` param", 226 | enabled: true, 227 | logLevel: "info", 228 | details: `The standard description for \`returnAll\` is: ${chalk.bold( 229 | STANDARD_DESCRIPTIONS.returnAll 230 | )}`, 231 | }, 232 | MISSING_CONTINUE_ON_FAIL: { 233 | lintAreas: ["miscellaneous"], 234 | lintIssue: "missing", 235 | message: "Missing implementation of `continueOnFail`", 236 | enabled: true, 237 | logLevel: "error", 238 | details: 239 | "Without `continueOnFail`, the node will have to stop processing items on any error.", 240 | }, 241 | WRONG_ERROR_THROWN: { 242 | lintAreas: ["miscellaneous"], 243 | lintIssue: "wrong", 244 | message: "`Error` instead of `NodeApiError` or `NodeOperationError`", 245 | enabled: true, 246 | logLevel: "warning", 247 | details: 248 | "Use `NodeApiError` for unsuccessful API calls and `NodeOperationError` for functionality errors. Reference: n8n/packages/workflow/src/NodeErrors.ts", 249 | }, 250 | COLOR_TYPE_NOT_USED_FOR_COLOR_PARAM: { 251 | lintAreas: ["miscellaneous"], 252 | lintIssue: "wrong", 253 | message: "`color`-type not used for color-related parameter", 254 | enabled: true, 255 | logLevel: "warning", 256 | }, 257 | 258 | NAME_USING_STAR_INSTEAD_OF_ALL: { 259 | lintAreas: ["name"], 260 | lintIssue: "naming", 261 | message: "Name in options using `'*'` instead of `'[All]'`", 262 | enabled: true, 263 | logLevel: "warning", 264 | details: "Prefer `[All]` over the more technical `'*'`.", 265 | }, 266 | NAME_WITH_MISCASED_ID: { 267 | lintAreas: ["name"], 268 | lintIssue: "casing", 269 | message: "Miscased `id` in `name` property", 270 | enabled: true, 271 | logLevel: "error", 272 | details: "`id` must be lowercase in every `name` property", 273 | }, 274 | NAME_WITH_NO_CAMELCASE: { 275 | lintAreas: ["name"], 276 | lintIssue: "casing", 277 | message: "No camel case in `name` property", 278 | enabled: false, // TODO: Decide whether to remove linting 279 | logLevel: "error", 280 | }, 281 | NON_SUFFIXED_CREDENTIALS_NAME: { 282 | lintAreas: ["name"], 283 | lintIssue: "wording", 284 | message: "`name` in `credentials` property not suffixed with `Api`", 285 | enabled: true, 286 | logLevel: "error", 287 | details: 288 | "The `name` property in a `credentials` property in a node description must be suffixed with `Api`, e.g. `stripeApi` or `twilioOAuth2Api`", 289 | }, 290 | AUTHENTICATION_PARAM_NOT_IN_CREDENTIALS: { 291 | lintAreas: ["name"], 292 | lintIssue: "location", 293 | message: "Authentication param should be in credentials", 294 | enabled: true, 295 | logLevel: "info", 296 | details: 297 | "Having the authentication param (allowing the user to select API key, OAuth2, etc.) in a node description is legacy node design. Relocate it to the credentials file at `packages/nodes-base/credentials/*.credentials.ts`.", 298 | }, 299 | 300 | MISSING_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE: { 301 | lintAreas: ["nodeDescription"], 302 | lintIssue: "missing", 303 | message: "Missing test method reference in non-OAuth2 credentials", 304 | enabled: true, 305 | logLevel: "error", 306 | details: 307 | "Non-OAuth2 credentials must refer to a credentials test method using the `testedBy` key.", 308 | }, 309 | MISMATCHED_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE: { 310 | lintAreas: ["nodeDescription"], 311 | lintIssue: "missing", 312 | message: "Missing credentials test method in non-OAuth2 credentials", 313 | enabled: true, 314 | logLevel: "error", 315 | details: 316 | "Non-OAuth2 credentials must have a test method reference to a method defined in `methods.credentialTest`.", 317 | }, 318 | PNG_ICON_IN_NODE_DESCRIPTION: { 319 | lintAreas: ["nodeDescription"], 320 | lintIssue: "icon", 321 | message: "Icon is PNG instead of SVG in node description", 322 | enabled: true, 323 | logLevel: "warning", 324 | details: `Sources of SVG icons: ${chalk.bold( 325 | SVG_ICON_SOURCES.join(" | ") 326 | )}`, 327 | }, 328 | SUBTITLE_MISSING_IN_NODE_DESCRIPTION: { 329 | lintAreas: ["nodeDescription"], 330 | lintIssue: "missing", 331 | message: "Missing `subtitle` in node description", 332 | enabled: true, 333 | logLevel: "error", 334 | }, 335 | WRONG_NUMBER_OF_INPUTS_IN_REGULAR_NODE_DESCRIPTION: { 336 | lintAreas: ["nodeDescription"], 337 | lintIssue: "wrong", 338 | message: "Regular node with no input", 339 | enabled: true, 340 | logLevel: "error", 341 | details: "A regular node must have one or more inputs", 342 | }, 343 | WRONG_NUMBER_OF_INPUTS_IN_TRIGGER_NODE_DESCRIPTION: { 344 | lintAreas: ["nodeDescription"], 345 | lintIssue: "wrong", 346 | message: "Trigger node with non-zero input", 347 | enabled: true, 348 | logLevel: "error", 349 | details: "A trigger node must not have for any inputs", 350 | }, 351 | WRONG_NUMBER_OF_OUTPUTS_IN_NODE_DESCRIPTION: { 352 | lintAreas: ["nodeDescription"], 353 | lintIssue: "wrong", 354 | message: "Node with no output", 355 | enabled: true, 356 | logLevel: "error", 357 | details: "Every node must have one or more outputs", 358 | }, 359 | NON_STANDARD_SUBTITLE: { 360 | lintAreas: ["nodeDescription"], 361 | lintIssue: "wording", 362 | message: "Non-standard `subtitle` in node description", 363 | enabled: true, 364 | logLevel: "info", 365 | details: `The standard node description subtitle is: ${chalk.bold( 366 | STANDARD_DESCRIPTIONS.subtitle 367 | )}`, 368 | }, 369 | DISPLAYNAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION: { 370 | lintAreas: ["nodeDescription", "displayName"], 371 | lintIssue: "naming", 372 | message: 373 | "Display name in trigger node description not ending with ' Trigger'", 374 | enabled: true, 375 | logLevel: "error", 376 | }, 377 | NAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION: { 378 | lintAreas: ["nodeDescription", "name"], 379 | lintIssue: "naming", 380 | message: "Name in trigger node description not ending with 'Trigger'", 381 | enabled: true, 382 | logLevel: "error", 383 | }, 384 | 385 | NON_ALPHABETIZED_OPTIONS_IN_OPTIONS_TYPE_PARAM: { 386 | lintAreas: ["options"], 387 | lintIssue: "alphabetization", 388 | message: "Non-alphabetized `options` in `options`-type param", 389 | enabled: true, 390 | logLevel: "error", 391 | details: "Listings of >5 items must be alphabetized", 392 | }, 393 | NON_ALPHABETIZED_OPTIONS_IN_MULTIOPTIONS_TYPE_PARAM: { 394 | lintAreas: ["options"], 395 | lintIssue: "alphabetization", 396 | message: "Non-alphabetized `options` in `multiOptions`-type param", 397 | enabled: true, 398 | logLevel: "error", 399 | details: "Listings of >5 items must be alphabetized", 400 | }, 401 | NON_ALPHABETIZED_OPTIONS_IN_COLLECTION_TYPE_PARAM: { 402 | lintAreas: ["options"], 403 | lintIssue: "alphabetization", 404 | message: "Non-alphabetized `options` in `collection`-type param", 405 | enabled: true, 406 | logLevel: "error", 407 | details: "Listings of >5 items must be alphabetized", 408 | }, 409 | NON_ALPHABETIZED_VALUES_IN_FIXED_COLLECTION_TYPE_PARAM: { 410 | lintAreas: ["options"], // strictly `values`, but functionally same as `options` 411 | lintIssue: "alphabetization", 412 | message: "Non-alphabetized `values` in `fixedCollection`-type param", 413 | enabled: true, 414 | logLevel: "error", 415 | details: "Listings of >5 items must be alphabetized", 416 | }, 417 | FIXED_COLLECTION_VALUE_DISPLAY_NAME_WITH_NO_TITLECASE: { 418 | lintAreas: ["options", "displayName"], // strictly `values`, but functionally same as `options` 419 | lintIssue: "casing", 420 | message: "No title case in `fixedCollection` value display name", 421 | enabled: true, 422 | logLevel: "error", 423 | }, 424 | OPTIONS_NAME_WITH_NO_TITLECASE: { 425 | lintAreas: ["options"], 426 | lintIssue: "casing", 427 | message: "No title case in `options` name", 428 | enabled: true, 429 | logLevel: "error", 430 | }, 431 | OPTIONS_VALUE_WITH_NO_CAMELCASE: { 432 | lintAreas: ["options"], 433 | lintIssue: "casing", 434 | message: "No camel case in `options` value", 435 | enabled: false, // TODO: Decide whether to remove linting 436 | logLevel: "error", 437 | }, 438 | 439 | NON_STANDARD_NAME_FOR_UPSERT_OPTION: { 440 | lintAreas: ["options"], 441 | lintIssue: "wording", 442 | message: "Non-standard name of upsert option", 443 | enabled: true, 444 | logLevel: "info", 445 | details: `The standard upsert option name is: ${chalk.bold( 446 | STANDARD_NAMES.upsert 447 | )}`, 448 | }, 449 | NON_STANDARD_DESCRIPTION_FOR_UPSERT_OPTION: { 450 | lintAreas: ["options"], 451 | lintIssue: "wording", 452 | message: "Non-standard description of upsert option", 453 | enabled: true, 454 | logLevel: "info", 455 | details: `The standard upsert option description is: ${chalk.bold( 456 | STANDARD_DESCRIPTIONS.upsert 457 | )}`, 458 | }, 459 | 460 | NON_STANDARD_DESCRIPTION_FOR_SIMPLIFY_PARAM: { 461 | lintAreas: ["paramDescription"], 462 | lintIssue: "wording", 463 | message: "Non-standard description of simplify param", 464 | enabled: true, 465 | logLevel: "info", 466 | details: `The standard description of the simplify param is: ${chalk.bold( 467 | STANDARD_DESCRIPTIONS.simplifyResponse 468 | )}`, 469 | }, 470 | PARAM_DESCRIPTION_WITH_MISCASED_ID: { 471 | lintAreas: ["paramDescription"], 472 | lintIssue: "casing", 473 | message: "Miscased `ID` in param description", 474 | enabled: true, 475 | logLevel: "error", 476 | details: "`ID` must be all uppercase in any param description", 477 | }, 478 | PARAM_DESCRIPTION_WITH_UNCAPITALIZED_INITIAL: { 479 | lintAreas: ["paramDescription"], 480 | lintIssue: "casing", 481 | message: "Non-capital initial letter in param description", 482 | enabled: true, 483 | logLevel: "error", 484 | }, 485 | PARAM_DESCRIPTION_WITH_MISSING_FINAL_PERIOD: { 486 | lintAreas: ["paramDescription"], 487 | lintIssue: "punctuation", 488 | message: "Missing final period in multiple-sentence param description", 489 | enabled: true, 490 | logLevel: "warning", 491 | details: 492 | "A single-sentence description must have no final period. A multiple-sentence description must have final periods for all sentences, except if the last sentence ends with a `` element.", 493 | }, 494 | PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD: { 495 | lintAreas: ["paramDescription"], 496 | lintIssue: "punctuation", 497 | message: "Excess final period in single-sentence param description", 498 | enabled: true, 499 | logLevel: "warning", 500 | details: 501 | "A single-sentence description must have no final period. A multiple-sentence description must have final periods for all sentences, except if the last sentence ends with a `` element.", 502 | }, 503 | PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE: { 504 | lintAreas: ["paramDescription"], 505 | lintIssue: "punctuation", 506 | message: "Excess whitespace in param description", 507 | enabled: true, 508 | logLevel: "warning", 509 | }, 510 | PARAM_DESCRIPTION_MISSING_WHERE_REQUIRED: { 511 | lintAreas: ["paramDescription"], 512 | lintIssue: "missing", 513 | message: "Param description is missing where it is required", 514 | enabled: false, // TODO: Decide whether to remove linting 515 | logLevel: "error", 516 | details: 517 | "All param and options descriptions are required, except for resource option, credentials option, and defaults option", 518 | }, 519 | PARAM_DESCRIPTION_MISSING_WHERE_OPTIONAL: { 520 | lintAreas: ["paramDescription"], 521 | lintIssue: "missing", 522 | message: "Param description is missing where it is optional", 523 | enabled: true, // usually disabled 524 | logLevel: "info", 525 | details: 526 | "The only optional descriptions are resource option, credentials option, and defaults option", 527 | }, 528 | PARAM_DESCRIPTION_WITH_UNNEEDED_BACKTICKS: { 529 | lintAreas: ["paramDescription"], 530 | lintIssue: "unneeded", 531 | message: "Param description with unneeded backticks", 532 | enabled: true, 533 | logLevel: "warning", 534 | }, 535 | PARAM_DESCRIPTION_AS_EMPTY_STRING: { 536 | lintAreas: ["paramDescription"], 537 | lintIssue: "missing", 538 | message: "Param description as empty string", 539 | enabled: true, 540 | logLevel: "error", 541 | }, 542 | PARAM_DESCRIPTION_WITH_MISSING_PROTOCOL_LINK: { 543 | lintAreas: ["paramDescription"], 544 | lintIssue: "missing", 545 | message: "Param description has link without protocol", 546 | enabled: true, 547 | logLevel: "error", 548 | details: 549 | "A protocol `https://` or `http://` needs to be specified in the param description link.", 550 | }, 551 | PARAM_DESCRIPTION_WITH_BRITISH_SUFFIX: { 552 | lintAreas: ["paramDescription"], 553 | lintIssue: "wording", 554 | message: "Param description is using British English", 555 | enabled: false, // TODO: Decide whether to remove linting 556 | logLevel: "warning", 557 | details: 558 | "Prefer American English over British English suffixes, i.e. prefer '-ize' over '-ise', '-ize' over '-yse', and '-our' over '-our'", 559 | }, 560 | PARAM_DESCRIPTION_UNTRIMMED: { 561 | lintAreas: ["paramDescription"], 562 | lintIssue: "whitespace", 563 | message: "Param description is untrimmed", 564 | enabled: true, 565 | logLevel: "warning", 566 | }, 567 | BOOLEAN_DESCRIPTION_NOT_STARTING_WITH_WHETHER: { 568 | lintAreas: ["paramDescription"], 569 | lintIssue: "wording", 570 | message: "Boolean param description not starting with 'Whether'", 571 | enabled: true, 572 | logLevel: "warning", 573 | }, 574 | WEAK_PARAM_DESCRIPTION: { 575 | lintAreas: ["paramDescription"], 576 | lintIssue: "wording", 577 | message: "Weak param description to improve or omit", 578 | enabled: true, 579 | logLevel: "warning", 580 | }, 581 | PARAM_DESCRIPTION_IDENTICAL_TO_DISPLAY_NAME: { 582 | lintAreas: ["paramDescription", "displayName"], 583 | lintIssue: "unneeded", 584 | message: "Param description identical to `displayName`", 585 | enabled: true, 586 | logLevel: "warning", 587 | }, 588 | NON_STANDARD_HTML_LINE_BREAK: { 589 | lintAreas: ["paramDescription"], 590 | lintIssue: "unneeded", 591 | message: "Non-standard HTML element syntax for line break", 592 | enabled: true, 593 | logLevel: "info", 594 | }, 595 | TECHNICAL_TERM_IN_PARAM_DESCRIPTION: { 596 | lintAreas: ["paramDescription"], 597 | lintIssue: "wording", 598 | message: "Technical term in param description", 599 | enabled: false, // TODO: Decide whether to remove linting 600 | logLevel: "info", 601 | details: "Prefer 'text' over 'string' and 'field' over 'key'", 602 | }, 603 | }; 604 | -------------------------------------------------------------------------------- /src/scripts/lintAll.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { Presenter, Traverser, Validator } from "../services"; 5 | import { collect } from "../utils"; 6 | import chalk from "chalk"; 7 | 8 | export async function lintAll( 9 | config: Config, 10 | { printLogs }: { printLogs: boolean } 11 | ) { 12 | const isFileToLint = (fileName: string) => 13 | config.patterns.some((pattern) => fileName.endsWith(pattern)); 14 | 15 | const sourceFilePaths = collect(config.target, isFileToLint); 16 | 17 | if (config.extractDescriptions) { 18 | extract(sourceFilePaths); 19 | process.exit(0); 20 | } 21 | 22 | const executionStart = new Date().getTime(); 23 | 24 | const allFilesLogs: Log[] = []; 25 | const presenter = new Presenter(config); 26 | 27 | sourceFilePaths.forEach((sourceFilePath) => { 28 | Traverser.sourceFilePath = sourceFilePath; 29 | const validator = new Validator(); 30 | 31 | const sourceFileContents = fs.readFileSync(sourceFilePath, "utf8"); 32 | 33 | ts.transpileModule(sourceFileContents.toString(), { 34 | transformers: { before: [Traverser.traverse(validator)] }, 35 | }); 36 | 37 | if (validator.logs.length) allFilesLogs.push(...validator.logs); 38 | 39 | presenter.showLogs(validator.logs); 40 | }); 41 | 42 | const executionTimeMs = new Date().getTime() - executionStart; 43 | presenter.summarize(allFilesLogs, executionTimeMs); 44 | 45 | if (printLogs) { 46 | Presenter.printJson("lintOutput", allFilesLogs); 47 | console.log("Logs printed to:"); 48 | console.log(chalk.bold(`${path.join(process.cwd(), "lintOutput.json")}`)); 49 | } 50 | } 51 | 52 | const extract = (sourceFilePaths: string[]) => { 53 | sourceFilePaths.forEach((sourceFilePath) => { 54 | Traverser.sourceFilePath = sourceFilePath; 55 | 56 | const sourceFileContents = fs.readFileSync(sourceFilePath, "utf8"); 57 | 58 | ts.transpileModule(sourceFileContents.toString(), { 59 | transformers: { before: [Traverser.extract()] }, 60 | }); 61 | }); 62 | 63 | Presenter.printJson("extractedDescriptions", Traverser.extractedDescriptions); 64 | }; 65 | -------------------------------------------------------------------------------- /src/scripts/lintOne.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import fs from "fs"; 3 | import ts from "typescript"; 4 | import path from "path"; 5 | import { Presenter, Traverser, Validator } from "../services"; 6 | import { isLintable, terminate } from "../utils"; 7 | import { ERRORS } from "../constants"; 8 | 9 | export async function lintOne( 10 | config: Config, 11 | { printLogs }: { printLogs: boolean } 12 | ) { 13 | if (!isLintable(config.target)) { 14 | terminate(ERRORS.NOT_LINTABLE_TARGET); 15 | } 16 | 17 | const executionStart = new Date().getTime(); 18 | Traverser.sourceFilePath = config.target; 19 | const validator = new Validator(); 20 | const sourceFileContents = fs.readFileSync(config.target, "utf8"); 21 | 22 | ts.transpileModule(sourceFileContents, { 23 | transformers: { before: [Traverser.traverse(validator)] }, 24 | }); 25 | 26 | const executionTimeMs = new Date().getTime() - executionStart; 27 | 28 | const presenter = new Presenter(config); 29 | presenter.showLogs(validator.logs); 30 | presenter.summarize(validator.logs, executionTimeMs); 31 | 32 | if (printLogs) { 33 | Presenter.printJson("lintOutput", validator.logs); 34 | console.log("Logs printed to:"); 35 | console.log(chalk.bold(`${path.join(process.cwd(), "lintOutput.json")}`)); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/scripts/lintRef.ts: -------------------------------------------------------------------------------- 1 | import { LINTINGS } from "../lintings"; 2 | import { Presenter } from "../services"; 3 | 4 | Presenter.printJson("lintRef", Object.keys(LINTINGS)); 5 | -------------------------------------------------------------------------------- /src/services/Collector.ts: -------------------------------------------------------------------------------- 1 | import ts, { getLineAndCharacterOfPosition } from "typescript"; 2 | import { Traverser } from "."; 3 | import { NEXT_LINE_EXCEPTION_TEXT } from "../constants"; 4 | import { Navigator } from "./Navigator"; 5 | 6 | export class Collector { 7 | private static commentsMap = new Map(); 8 | 9 | public static loadOptionsMethods: string[] = []; 10 | public static credentialsTestMethods: string[] = []; 11 | public static sourceFileHasContinueOnFail = false; 12 | public static currentNode: ts.Node; 13 | public static isRegularNode = false; 14 | public static isTriggerNode = false; 15 | public static credentialsTestNamesSet: Set = new Set(); 16 | 17 | public static run(node: ts.Node) { 18 | Collector.identifyRegularOrTrigger(node); 19 | Collector.collectComments(node); 20 | Collector.collectContinueOnFail(node); 21 | Collector.collectCredentialsTestName(node); 22 | Collector.collectLoadOptionsMethods(node); 23 | Collector.collectCredentialsTestMethods(node); 24 | } 25 | 26 | static get credentialsTestNames() { 27 | return [...Collector.credentialsTestNamesSet]; 28 | } 29 | 30 | static get comments(): Comment[] { 31 | return Object.values(Object.fromEntries(Collector.commentsMap)); 32 | } 33 | 34 | static identifyRegularOrTrigger(node: ts.Node) { 35 | if (ts.isClassDeclaration(node)) { 36 | node.forEachChild((child) => { 37 | if (ts.isIdentifier(child)) { 38 | if (child.getText().endsWith("Trigger")) { 39 | Collector.isTriggerNode = true; 40 | } else { 41 | Collector.isRegularNode = true; 42 | } 43 | } 44 | }); 45 | } 46 | } 47 | 48 | static collectCredentialsTestName(node: ts.Node) { 49 | if (!Traverser.sourceFilePath.endsWith(".node.ts")) return; 50 | 51 | const found = Navigator.findDescendant(node, { text: "testedBy" }); 52 | 53 | if (found) { 54 | Collector.credentialsTestNamesSet.add( 55 | found.parent.getChildAt(2).getText().clean() 56 | ); 57 | } 58 | } 59 | 60 | static collectContinueOnFail(node: ts.Node) { 61 | if ( 62 | Traverser.sourceFilePath.endsWith(".node.ts") && 63 | ts.isPropertyAccessExpression(node) && 64 | node.getChildAt(2).getText() === "continueOnFail" 65 | ) { 66 | Collector.sourceFileHasContinueOnFail = true; 67 | } 68 | } 69 | 70 | static collectLoadOptionsMethods(node: ts.Node) { 71 | if ( 72 | Traverser.sourceFilePath.endsWith(".node.ts") && 73 | ts.isIdentifier(node) && 74 | node.getText() === "loadOptions" 75 | ) { 76 | const objectLiteralExpression = node.parent.getChildAt(2); 77 | if (!objectLiteralExpression) return; 78 | 79 | objectLiteralExpression.forEachChild((method) => { 80 | if (ts.isShorthandPropertyAssignment(method)) { 81 | Collector.loadOptionsMethods.push(method.getText()); 82 | } else { 83 | const identifier = method.getChildAt(1); 84 | Collector.loadOptionsMethods.push(identifier.getText()); 85 | } 86 | }); 87 | } 88 | } 89 | 90 | static collectCredentialsTestMethods(node: ts.Node) { 91 | if ( 92 | Traverser.sourceFilePath.endsWith(".node.ts") && 93 | ts.isIdentifier(node) && 94 | node.getText() === "credentialTest" 95 | ) { 96 | const objectLiteralExpression = node.parent.getChildAt(2); 97 | if (!objectLiteralExpression) return; 98 | 99 | objectLiteralExpression.forEachChild((method) => { 100 | if (ts.isShorthandPropertyAssignment(method)) { 101 | Collector.credentialsTestMethods.push(method.getText()); 102 | } else { 103 | const identifier = method.getChildAt(1); 104 | Collector.credentialsTestMethods.push(identifier.getText()); 105 | } 106 | }); 107 | } 108 | } 109 | 110 | /** 111 | * Retrieve the ending line number for the node. 112 | */ 113 | static getLineNumber(node: ts.Node) { 114 | const { line } = getLineAndCharacterOfPosition( 115 | Traverser.sourceFile, 116 | node?.getEnd() ?? 0 // TODO: Detect undefined node upstream 117 | ); 118 | return line; 119 | } 120 | 121 | static collectComments(node: ts.Node) { 122 | const commentRanges = 123 | ts.getLeadingCommentRanges( 124 | Traverser.sourceFile.getFullText(), 125 | node.getFullStart() 126 | ) ?? []; 127 | 128 | const comments = commentRanges.map((range) => ({ 129 | text: Traverser.sourceFile.getFullText().slice(range.pos, range.end), 130 | line: Collector.getLineNumber(node), 131 | pos: range.pos, 132 | end: range.end, 133 | })); 134 | 135 | // dedup with map because API may report 136 | // multiple comment ranges for a single comment 137 | 138 | comments.forEach((comment) => { 139 | const key = `${comment.pos}-${comment.end}`; 140 | Collector.commentsMap.set(key, comment); 141 | }); 142 | } 143 | 144 | // ---------------------------------- 145 | // getters 146 | // ---------------------------------- 147 | 148 | static get exceptions(): Exception[] { 149 | return Collector.comments 150 | .filter((comment) => comment.text.startsWith(NEXT_LINE_EXCEPTION_TEXT)) 151 | .map(({ line, text }) => { 152 | const parts = text.split(" "); 153 | let lintingsToExcept: string[] = []; 154 | 155 | if (parts.length === 2) { 156 | lintingsToExcept = ["*"]; 157 | } else if (parts.length === 3) { 158 | lintingsToExcept = [parts.pop()!]; 159 | } else if (parts.length > 3) { 160 | lintingsToExcept = parts.slice(2); 161 | } 162 | 163 | return { 164 | line, 165 | lintingsToExcept, 166 | exceptionType: "nextLine", 167 | }; 168 | }); 169 | } 170 | 171 | static get toDos(): ToDoComment[] { 172 | return Collector.comments 173 | .filter((comment) => comment.text.startsWith("// TODO")) 174 | .map(({ line, text }) => ({ line, text })); 175 | } 176 | 177 | static get tsIgnores() { 178 | return Collector.comments 179 | .filter((comment) => comment.text.startsWith("// @ts-ignore")) 180 | .map(({ line, text }) => ({ line, text })); 181 | } 182 | } 183 | -------------------------------------------------------------------------------- /src/services/ConfigManager.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import minimist from "minimist"; 3 | import { 4 | DEFAULT_AUTODETECT_FILENAME, 5 | ERRORS, 6 | LINTABLE_FILE_PATTERNS, 7 | } from "../constants"; 8 | import { collect, getLinting, getLintingName, terminate } from "../utils"; 9 | import { defaultConfig } from "../defaultConfig"; 10 | import { isNotTestRun } from ".."; 11 | 12 | export class ConfigManager { 13 | printLogs: boolean; 14 | only: LogLevel; 15 | patterns: LintableFilePattern[]; 16 | extractDescriptions = false; 17 | 18 | configPath: string; 19 | targetPath: string; 20 | 21 | defaultConfig = defaultConfig; 22 | customConfig: Config; 23 | masterConfig: Config; 24 | 25 | /** 26 | * `isNotTestRun` is needed so that tests use `defaultConfig` instead of `customConfig`. 27 | */ 28 | constructor(args: string[]) { 29 | this.parseArgs(args); 30 | 31 | if (!this.configPath && isNotTestRun) this.autoDetectConfigPath(); 32 | if (this.configPath) this.loadCustomConfig(); 33 | 34 | isNotTestRun && this.validateTargetKeyExists(); 35 | 36 | if (this.customConfig) { 37 | // @ts-ignore TODO 38 | this.validateNoUnknowns(this.customConfig, { type: "customConfig" }); 39 | this.validateNoTargetKeyConflict(); 40 | this.masterConfig = this.deepMerge(this.defaultConfig, this.customConfig); 41 | if (this.targetPath) this.masterConfig.target = this.targetPath; 42 | } else { 43 | this.masterConfig = { ...this.defaultConfig, target: this.targetPath }; 44 | } 45 | 46 | // overrides 47 | if (this.only) this.overrideLogLevels(); 48 | if (this.patterns) this.masterConfig.patterns = this.patterns; 49 | if (this.extractDescriptions) 50 | this.masterConfig.extractDescriptions = this.extractDescriptions; 51 | } 52 | 53 | // ---------------------------------- 54 | // CLI arg parsers 55 | // ---------------------------------- 56 | 57 | private parseArgs(args: string[]) { 58 | const { _, ...cliArgs } = minimist(args); 59 | 60 | this.parseOnlyArgs({ 61 | "errors-only": cliArgs["errors-only"], 62 | "warnings-only": cliArgs["warnings-only"], 63 | "infos-only": cliArgs["infos-only"], 64 | }); 65 | 66 | this.extractDescriptions = cliArgs["extract-descriptions"] !== undefined; 67 | 68 | this.validateNoUnknowns(cliArgs, { type: "cliArgs" }); 69 | 70 | this.parsePrintArgs(cliArgs.print); 71 | this.parsePatternsArgs(cliArgs.patterns); 72 | 73 | if (cliArgs.config) this.configPath = cliArgs.config; 74 | if (cliArgs.target) this.targetPath = cliArgs.target; 75 | } 76 | 77 | private parseOnlyArgs(multiWordArgs: MultiWordArgs) { 78 | const quantity = Object.values(multiWordArgs).filter(Boolean).length; 79 | 80 | if (quantity === 0) return; 81 | 82 | if (quantity > 1) terminate(ERRORS.MULTIPLE_ONLY_ARGS); 83 | 84 | if (quantity === 1) { 85 | if (multiWordArgs["errors-only"]) this.only = "error"; 86 | else if (multiWordArgs["warnings-only"]) this.only = "warning"; 87 | else if (multiWordArgs["infos-only"]) this.only = "info"; 88 | } 89 | } 90 | 91 | private parsePrintArgs(print?: boolean) { 92 | if (!print) return; 93 | 94 | this.printLogs = true; 95 | } 96 | 97 | private parsePatternsArgs(patterns?: string) { 98 | if (!patterns) return; 99 | 100 | const parsedPatterns = patterns.split(",").map((p) => p.trim()); 101 | 102 | this.adjustPattern(parsedPatterns, { from: "node.ts", to: ".node.ts" }); 103 | this.adjustPattern(parsedPatterns, { 104 | from: ".Description.ts", 105 | to: "Description.ts", 106 | }); 107 | 108 | if (!this.areValidPatterns(parsedPatterns)) 109 | terminate(ERRORS.INVALID_PATTERNS); 110 | 111 | this.patterns = parsedPatterns; 112 | } 113 | 114 | overrideLogLevels() { 115 | this.masterConfig.enable.logLevels = { 116 | error: false, 117 | warning: false, 118 | info: false, 119 | }; 120 | 121 | this.masterConfig.enable.logLevels[this.only] = true; 122 | } 123 | 124 | /** 125 | * Adjust lintable file patterns to tolerate mistypings. 126 | */ 127 | private adjustPattern(patterns: string[], { from, to }: AdjustPatternArg) { 128 | const mistypedPatternIndex = patterns.findIndex((p) => p === from); 129 | 130 | if (mistypedPatternIndex !== -1) { 131 | patterns[mistypedPatternIndex] = to; 132 | } 133 | 134 | return patterns; 135 | } 136 | 137 | private areValidPatterns(value: unknown): value is LintableFilePattern[] { 138 | return ( 139 | Array.isArray(value) && 140 | value.every((item) => LINTABLE_FILE_PATTERNS.includes(item)) 141 | ); 142 | } 143 | 144 | // ---------------------------------- 145 | // arg loaders 146 | // ---------------------------------- 147 | 148 | private loadCustomConfig() { 149 | try { 150 | this.customConfig = require(this.configPath); 151 | } catch (error) { 152 | terminate(ERRORS.FAILED_TO_IMPORT_CUSTOM_CONFIG); 153 | } 154 | } 155 | 156 | private autoDetectConfigPath() { 157 | console.log( 158 | chalk.dim("No --config option specified, attempting to autodetect...\n") 159 | ); 160 | 161 | const autodetected = collect( 162 | process.cwd(), 163 | (f) => f === DEFAULT_AUTODETECT_FILENAME 164 | ).pop(); 165 | 166 | if (autodetected) { 167 | console.log("Custom config autodetected:"); 168 | console.log(chalk.bold(autodetected + "\n")); 169 | this.configPath = autodetected; 170 | } 171 | } 172 | 173 | /** 174 | * Validate that the CLI options or the keys in custom config 175 | * are only those found in the default config. 176 | * 177 | * // TODO: Refactor this method 178 | */ 179 | private validateNoUnknowns( 180 | arg: MultiWordArgs, 181 | { type }: { type: "cliArgs" | "customConfig" } 182 | ) { 183 | delete arg["errors-only"]; 184 | delete arg["warnings-only"]; 185 | delete arg["infos-only"]; 186 | 187 | Object.keys(arg).forEach((keyOrOption) => { 188 | if ( 189 | ![ 190 | ...Object.keys(this.defaultConfig), 191 | "print", 192 | "extract-descriptions", 193 | ].includes(keyOrOption) 194 | ) { 195 | if (type === "cliArgs") { 196 | console.log( 197 | [ 198 | chalk.red.inverse( 199 | "error".padStart(7, " ").padEnd(9, " ").toUpperCase() 200 | ), 201 | `${chalk.bold(ERRORS.UNKNOWN_OPTION.title + ":")}`, 202 | `${ERRORS.UNKNOWN_OPTION.message + ":"} "--${keyOrOption}"`, 203 | "\n", 204 | ].join(" ") 205 | ); 206 | } else { 207 | console.log( 208 | [ 209 | chalk.red.inverse( 210 | "error".padStart(7, " ").padEnd(9, " ").toUpperCase() 211 | ), 212 | `${chalk.bold(ERRORS.UNKNOWN_KEY.title + ":")}`, 213 | `${ERRORS.UNKNOWN_KEY.message + ":"} ${keyOrOption}"`, 214 | "\n", 215 | ].join(" ") 216 | ); 217 | } 218 | 219 | const options = [ 220 | "--target\t\tPath to the file or dir to lint", 221 | "--config\t\tPath to the custom config to use", 222 | "--print\t\tWhether to print logs as JSON", 223 | "--patterns\t\tPatterns of filenames to lint", 224 | "--errors-only\t\tEnable error logs only", 225 | "--warnings-only\tEnable warning logs only", 226 | "--infos-only\t\tEnable info logs only", 227 | ]; 228 | 229 | console.log( 230 | [ 231 | `Available ${type === "cliArgs" ? "options" : "keys"}:`, 232 | ...(type === "cliArgs" 233 | ? options 234 | : options.map((option) => option.slice(2)) 235 | ).map((option, index) => { 236 | return type === "customConfig" && (index === 2 || index === 5) 237 | ? option.replace("\t", "\t\t") 238 | : option; 239 | }), 240 | ].join("\n ") 241 | ); 242 | 243 | process.exit(0); 244 | } 245 | }); 246 | } 247 | 248 | private validateTargetKeyExists() { 249 | if (!this.targetPath && !this.customConfig?.target) { 250 | terminate(ERRORS.UNSPECIFIED_TARGET); 251 | } 252 | } 253 | 254 | /** 255 | * Validate that a single target exists, either 256 | * - the `--target` CLI arg, i.e. `this.targetPath` or 257 | * - the `target` key in the custom config, i.e. `this.customConfig.target`. 258 | */ 259 | private validateNoTargetKeyConflict() { 260 | if (this.customConfig.target && this.targetPath) { 261 | terminate(ERRORS.OVERSPECIFIED_TARGET); 262 | } 263 | } 264 | 265 | // TODO: Type properly 266 | private deepMerge(...objects: any) { 267 | let result: any = {}; 268 | 269 | for (const o of objects) { 270 | for (let [key, value] of Object.entries(o)) { 271 | if (value instanceof Array) { 272 | result = { ...result, [key]: value }; 273 | continue; 274 | } 275 | 276 | if (value instanceof Object && key in result) { 277 | value = this.deepMerge(result[key], value); 278 | } 279 | 280 | result = { ...result, [key]: value }; 281 | } 282 | } 283 | 284 | return result; 285 | } 286 | 287 | // ---------------------------------- 288 | // state reporting 289 | // ---------------------------------- 290 | 291 | // TODO: Refactor to remove repetition 292 | 293 | static lintAreaIsDisabled(lintArea: LintArea, config: Config) { 294 | return !config.enable.lintAreas[lintArea]; 295 | } 296 | 297 | static lintIssueIsDisabled(lintIssue: LintIssue, config: Config) { 298 | return !config.enable.lintIssues[lintIssue]; 299 | } 300 | 301 | static logLevelIsDisabled(logLevel: LogLevel, config: Config) { 302 | return !config.enable.logLevels[logLevel]; 303 | } 304 | 305 | static lintingIsDisabled(linting: Linting, config: Config) { 306 | const configLinting = getLinting(linting, config.lintings); 307 | 308 | if (!configLinting) { 309 | throw new Error(`No config linting found for: ${linting.message}`); 310 | } 311 | 312 | return !configLinting.enabled; 313 | } 314 | 315 | /** 316 | * Report whether a linting at a line is disabled by an exception comment. 317 | */ 318 | static lintingIsExcepted( 319 | linting: Linting, 320 | lintingLine: number, 321 | exceptions: Exception[], 322 | masterConfig: Config 323 | ) { 324 | const found = exceptions.find(({ lintingsToExcept, line }) => { 325 | const lintingName = getLintingName(linting, masterConfig); 326 | const lintingsMatch = 327 | lintingsToExcept.includes(lintingName) || 328 | lintingsToExcept.includes("*"); 329 | 330 | return lintingsMatch && line + 1 === lintingLine; 331 | }); 332 | 333 | return found !== undefined; 334 | } 335 | } 336 | -------------------------------------------------------------------------------- /src/services/Logger.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { masterConfig } from "../"; 3 | import { Traverser } from "../services"; 4 | import { Collector } from "./Collector"; 5 | import { ConfigManager } from "./ConfigManager"; 6 | 7 | // type SubValidatorConstructor = new (...args: any[]) => T; 8 | 9 | export function Logger(Base: BaseClass) { 10 | return class extends Base { 11 | logs: Log[] = []; 12 | 13 | public log = (linting: Linting) => (node: ts.Node) => { 14 | let line = Collector.getLineNumber(node.getChildAt(2)); 15 | 16 | line += 1; // TODO: Find out why this offset is needed 17 | 18 | if ( 19 | ConfigManager.lintIssueIsDisabled(linting.lintIssue, masterConfig) || 20 | ConfigManager.logLevelIsDisabled(linting.logLevel, masterConfig) || 21 | ConfigManager.lintingIsDisabled(linting, masterConfig) || 22 | ConfigManager.lintingIsExcepted( 23 | linting, 24 | line, 25 | Collector.exceptions, 26 | masterConfig 27 | ) 28 | ) 29 | return; 30 | 31 | this.logs.push({ 32 | message: linting.message, 33 | lintAreas: linting.lintAreas, 34 | lintIssue: linting.lintIssue, 35 | line, 36 | excerpt: masterConfig.truncateExcerpts.enabled 37 | ? this.truncateExcerpt(node.getText()) 38 | : node.getText(), 39 | sourceFilePath: Traverser.sourceFilePath, 40 | logLevel: linting.logLevel, 41 | ...(linting.details && { details: linting.details }), 42 | }); 43 | }; 44 | 45 | truncateExcerpt(text: string) { 46 | if (text.includes("\t")) return ""; 47 | if (text.length <= masterConfig.truncateExcerpts.charLimit) return text; 48 | 49 | return text.slice(0, masterConfig.truncateExcerpts.charLimit - 3) + "..."; 50 | } 51 | }; 52 | } 53 | -------------------------------------------------------------------------------- /src/services/Navigator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | 3 | /** 4 | * Bundle of utility methods to navigate the AST. 5 | */ 6 | export class Navigator { 7 | /** 8 | * Find a matching descendant node. 9 | */ 10 | static findDescendant( 11 | node: ts.Node, 12 | testType: { [key in "text"]: string } 13 | ): ts.Node | undefined { 14 | if (node.getChildCount() === 0) return; 15 | 16 | return node.forEachChild((child) => { 17 | return Navigator.getTest(testType)(child) 18 | ? child 19 | : Navigator.findDescendant(child, testType); 20 | }); 21 | } 22 | 23 | static getTest = (testType: { [key in "text"]: string }) => { 24 | if (testType.text) 25 | return (node: ts.Node) => node.getText() === testType.text; 26 | 27 | throw new Error("Unknown test type"); 28 | }; 29 | 30 | static isBooleanKeyword(node: ts.Node) { 31 | return ( 32 | node.kind === ts.SyntaxKind.TrueKeyword || 33 | node.kind === ts.SyntaxKind.FalseKeyword 34 | ); 35 | } 36 | 37 | /** 38 | * Check if the node is a property assignment where 39 | * - the key-value pair matches, or 40 | * - the key matches. 41 | * 42 | * Note: The value to compare to needs to be twice-quoted (if a string) or stringified (if not a string). 43 | * `getText()` returns a string from the source, so 44 | * - a twice-quoted string for a string in the source (e.g. `'value'` → `"'value'"`), and 45 | * - a normal string for a non-string in the source (e.g. `false` → `'false'`). 46 | */ 47 | static isAssignment( 48 | node: ts.Node, 49 | { key, value }: { key?: string; value?: string | boolean } 50 | ) { 51 | if (key !== undefined && value !== undefined) { 52 | const isString = typeof value === "string"; 53 | 54 | return ( 55 | ts.isPropertyAssignment(node) && 56 | node.getChildAt(0).getText() === key && 57 | node.getChildAt(2).getText() === 58 | (isString ? `'${value}'` : value.toString()) 59 | ); 60 | } 61 | 62 | if (key !== undefined && value === undefined) { 63 | return ( 64 | ts.isPropertyAssignment(node) && node.getChildAt(0).getText() === key 65 | ); 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/services/Presenter.ts: -------------------------------------------------------------------------------- 1 | import chalk from "chalk"; 2 | import fs from "fs"; 3 | 4 | export class Presenter { 5 | config: Config; 6 | targethasAbsolutePath = false; 7 | 8 | logs: Log[]; 9 | log: Log; 10 | isLastLog: boolean; 11 | 12 | errorBaseColor: chalk.Chalk; 13 | warningBaseColor: chalk.Chalk; 14 | infoBaseColor: chalk.Chalk; 15 | 16 | constructor(config: Config) { 17 | this.config = config; 18 | 19 | this.targethasAbsolutePath = config.target[0] !== "."; 20 | 21 | this.errorBaseColor = this.config.logLevelColors.error 22 | ? chalk.hex(this.config.logLevelColors.error) 23 | : chalk.redBright; 24 | 25 | this.warningBaseColor = this.config.logLevelColors.warning 26 | ? chalk.hex(this.config.logLevelColors.warning) 27 | : chalk.yellowBright; 28 | 29 | this.infoBaseColor = this.config.logLevelColors.info 30 | ? chalk.hex(this.config.logLevelColors.info) 31 | : chalk.blueBright; 32 | } 33 | 34 | public showLogs(logs: Log[]) { 35 | this.showHeader(logs); 36 | 37 | // logs = this.removeRedundantLogs(logs); 38 | this.logs = this.sortLogs(logs); 39 | 40 | logs.forEach((log, index) => { 41 | this.log = log; 42 | this.isLastLog = index === this.logs.length - 1; 43 | 44 | this.showMainLine(); 45 | 46 | if (this.config.showDetails && log.details) this.showDetailsLine(); 47 | 48 | this.showExcerptLine(); 49 | this.showFinalLine(); 50 | }); 51 | } 52 | 53 | // ---------------------------------- 54 | // header 55 | // ---------------------------------- 56 | 57 | private showHeader(logs: Log[]) { 58 | if (!logs[0]) return; // file with no lint logs 59 | 60 | const filePath = this.targethasAbsolutePath 61 | ? logs[0].sourceFilePath.split("/").slice(-3).join("/") 62 | : logs[0].sourceFilePath; 63 | 64 | const parts = filePath.split("/"); 65 | const [fileName, ...basePath] = [parts.pop(), ...parts]; 66 | 67 | console.log( 68 | ` ` + 69 | chalk.inverse(` • `) + 70 | ` ` + 71 | chalk.grey(basePath.join("/")) + 72 | ` » ` + 73 | chalk.bold.inverse(` ${fileName} `) 74 | ); 75 | 76 | console.log(chalk.grey(" │")); 77 | } 78 | 79 | // ---------------------------------- 80 | // main line 81 | // ---------------------------------- 82 | 83 | private showMainLine() { 84 | const connector = this.isLastLog ? "└──" : "├──"; 85 | const indentation = " ".repeat(2); 86 | 87 | console.log( 88 | indentation + 89 | chalk.grey(connector) + 90 | this.formatLineNumber(this.log.line) + 91 | this.colorLogAndMessage(this.log.logLevel, this.log.message) 92 | ); 93 | } 94 | 95 | private formatLineNumber(line: number) { 96 | const zeroPadded = line.toString().padStart(3, "0"); 97 | const whitespacePadded = this.pad(zeroPadded, 5, " "); 98 | return chalk.white.inverse(whitespacePadded); 99 | } 100 | 101 | private pad(text: string, length = 11, padChar: string) { 102 | return text 103 | .padStart((text.length + length) / 2, padChar) 104 | .padEnd(length, padChar); 105 | } 106 | 107 | private colorLogAndMessage(logLevel: LogLevel, message: string) { 108 | const color = this.getColor(logLevel); 109 | const indentationForFirst = " ".repeat(1); 110 | const indentationForConnector = " ".repeat(2); 111 | const indentationForRest = " ".repeat(8); 112 | 113 | const logAndMessage = `${logLevel.toUpperCase()}: ${message}`; 114 | 115 | if (message.length > this.config.lineWrapChars) { 116 | const result: string[] = []; 117 | 118 | const [first, ...rest] = this.wrapLines(logAndMessage); 119 | result.push(indentationForFirst + color(first)); 120 | 121 | result.push( 122 | ...rest.map( 123 | (line) => 124 | indentationForConnector + 125 | chalk.grey(this.isLastLog ? " " : "│") + 126 | indentationForRest + 127 | color(line) 128 | ) 129 | ); 130 | return result.join("\n"); 131 | } 132 | 133 | return indentationForFirst + color(`${logLevel.toUpperCase()}: ${message}`); 134 | } 135 | 136 | // ---------------------------------- 137 | // details line 138 | // ---------------------------------- 139 | 140 | private showDetailsLine() { 141 | if (!this.log.details) throw new Error("Something went wrong"); // TODO: Rewrite as assert 142 | 143 | const connector = this.isLastLog ? " " : "│"; 144 | const connectorIndentation = " ".repeat(2); 145 | const detailsIndentation = " ".repeat(8); 146 | 147 | const showDetailsLine = (detailsLine: string) => 148 | console.log( 149 | connectorIndentation + 150 | chalk.grey(connector) + 151 | detailsIndentation + 152 | chalk.white(detailsLine) 153 | ); 154 | 155 | if (this.log.details.length > this.config.lineWrapChars) { 156 | const wrappedLines = this.wrapLines(this.log.details); 157 | wrappedLines.forEach(showDetailsLine); 158 | } else { 159 | showDetailsLine(this.log.details); 160 | } 161 | } 162 | 163 | // ---------------------------------- 164 | // excerpt line 165 | // ---------------------------------- 166 | 167 | private showExcerptLine() { 168 | const connector = this.isLastLog ? " " : "│"; 169 | const connectorIndentation = " ".repeat(2); 170 | const excerptIndentation = " ".repeat(8); 171 | 172 | console.log( 173 | chalk.grey( 174 | connectorIndentation + connector + excerptIndentation + this.log.excerpt 175 | ) 176 | ); 177 | } 178 | 179 | // ---------------------------------- 180 | // final line 181 | // ---------------------------------- 182 | 183 | private showFinalLine() { 184 | const connector = this.isLastLog ? " " : "│"; 185 | const indentation = " ".repeat(2); 186 | console.log(chalk.grey(indentation + connector)); 187 | } 188 | 189 | // ---------------------------------- 190 | // summary 191 | // ---------------------------------- 192 | 193 | public summarize(allFilesLogs: Log[], executionTimeMs: number) { 194 | let errors = 0; 195 | let warnings = 0; 196 | let infos = 0; 197 | 198 | allFilesLogs.forEach((log) => { 199 | if (log.logLevel === "error") errors++; 200 | if (log.logLevel === "warning") warnings++; 201 | if (log.logLevel === "info") infos++; 202 | }); 203 | 204 | this.showSummary({ 205 | errors, 206 | warnings, 207 | infos, 208 | total: allFilesLogs.length, 209 | executionTimeMs, 210 | }); 211 | } 212 | 213 | public showSummary({ 214 | total, 215 | errors, 216 | warnings, 217 | infos, 218 | executionTimeMs, 219 | }: LogSummary) { 220 | const indentation = " ".repeat(2); 221 | 222 | console.log(chalk.white.bold(`Total\t\t${total}`)); 223 | 224 | console.log(this.getColor("error")(indentation + `Errors\t${errors}`)); 225 | console.log( 226 | this.getColor("warning")(indentation + `Warnings\t${warnings}`) 227 | ); 228 | console.log(this.getColor("info")(indentation + `Infos\t\t${infos}`)); 229 | console.log(`Time\t\t${executionTimeMs} ms\n`); 230 | } 231 | 232 | // ---------------------------------- 233 | // line wrapping 234 | // ---------------------------------- 235 | 236 | /** 237 | * Wrap text into n-chars-long lines, at whitespace chars. 238 | */ 239 | private wrapLines(text: string, result: string[] = []): string[] { 240 | if (text.length === 0) return result; 241 | 242 | if (text.length < this.config.lineWrapChars || !text.includes(" ")) { 243 | result.push(text); 244 | return result; 245 | } 246 | 247 | const line = text.substring(0, this.config.lineWrapChars).split(" "); 248 | const remainder = line.pop(); // prevent wrapping mid-word 249 | result.push(line.join(" ")); 250 | 251 | return this.wrapLines( 252 | (remainder + text).substring(this.config.lineWrapChars), 253 | result 254 | ); 255 | } 256 | 257 | private getColor(logLevel: LogLevel, { thin } = { thin: false }) { 258 | return { 259 | error: thin ? this.errorBaseColor : this.errorBaseColor.bold, 260 | warning: thin ? this.warningBaseColor : this.warningBaseColor.bold, 261 | info: thin ? this.infoBaseColor : this.infoBaseColor.bold, 262 | }[logLevel]; 263 | } 264 | 265 | // ---------------------------------- 266 | // logs preprocessing 267 | // ---------------------------------- 268 | 269 | private sortLogs(logs: Log[]) { 270 | if (this.config.sortMethod === "importance") 271 | return this.sortByImportance(logs); 272 | 273 | if (this.config.sortMethod === "lineNumber") 274 | return this.sortByLineNumber(logs); 275 | 276 | throw new Error("Logs may only be sorted by line number or by importance."); 277 | } 278 | 279 | /** 280 | * Separate logs based on whether they pass a test. 281 | */ 282 | private separate(items: T[], test: (log: T) => boolean): [T[], T[]] { 283 | const pass: T[] = []; 284 | const fail: T[] = []; 285 | 286 | items.forEach((item) => (test(item) ? pass : fail).push(item)); 287 | 288 | return [pass, fail]; 289 | } 290 | 291 | /** 292 | * Separate an AB group of lintings into those that affect 293 | * the same line and those that do not. 294 | * 295 | * An "AB group of lintings" is an array of lintings that 296 | * may be of _one of two types only_, i.e. pre-filtered. 297 | * 298 | * TODO: Merge with `this.separate()` 299 | */ 300 | private separatePerSameLine(logs: Log[]) { 301 | const sameLine: Log[] = []; 302 | const differentLines: Log[] = []; 303 | 304 | logs.forEach((log, index) => { 305 | if ( 306 | logs.some( 307 | (sameNameItem, sameNameItemIndex) => 308 | sameNameItem.line === log.line && sameNameItemIndex !== index 309 | ) 310 | ) { 311 | sameLine.push(log); 312 | } else { 313 | differentLines.push(log); 314 | } 315 | }); 316 | 317 | return [sameLine, differentLines]; 318 | } 319 | 320 | /** 321 | * Remove logs that are logically covered by other logs on the same line. 322 | * 323 | * TODO: Systematize preferences and refactor this logic 324 | */ 325 | // private removeRedundantLogs(logs: Log[]) { 326 | // // prefer NON_STANDARD_RETURNALL_DESCRIPTION over BOOLEAN_DESCRIPTION_NOT_STARTING_WITH_WHETHER 327 | 328 | // const [returnAllOrWhether, others1] = this.separate(logs, (log) => 329 | // this.isReturnAllOrWhether(log) 330 | // ); 331 | 332 | // const [returnAllOrWhetherSameLine, returnAllOrWhetherDifferentLines] = 333 | // this.separatePerSameLine(returnAllOrWhether); 334 | 335 | // if (returnAllOrWhetherSameLine.length === 2) { 336 | // logs = [ 337 | // ...others1, 338 | // ...returnAllOrWhetherDifferentLines, 339 | // ...returnAllOrWhetherSameLine.filter((log) => this.isReturnAll(log)), 340 | // ]; 341 | // } 342 | 343 | // // prefer WEAK_PARAM_DESCRIPTION over PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD 344 | 345 | // const [weakOrExcess, others2] = this.separate(logs, (log) => 346 | // this.isWeakOrExcess(log) 347 | // ); 348 | 349 | // const [weakOrExcessSameLine, weakOrExcessDifferentLines] = 350 | // this.separatePerSameLine(weakOrExcess); 351 | 352 | // if (weakOrExcessSameLine.length === 2) { 353 | // logs = [ 354 | // ...others2, 355 | // ...weakOrExcessDifferentLines, 356 | // ...weakOrExcessSameLine.filter((log) => this.isWeak(log)), 357 | // ]; 358 | // } 359 | 360 | // return logs; 361 | // } 362 | 363 | private isReturnAllOrWhether(log: Log) { 364 | return ( 365 | log.message === 366 | this.config.lintings.BOOLEAN_DESCRIPTION_NOT_STARTING_WITH_WHETHER 367 | .message || 368 | log.message === 369 | this.config.lintings.NON_STANDARD_RETURNALL_DESCRIPTION.message 370 | ); 371 | } 372 | 373 | private isReturnAll(log: Log) { 374 | return ( 375 | log.message === 376 | this.config.lintings.NON_STANDARD_RETURNALL_DESCRIPTION.message 377 | ); 378 | } 379 | 380 | public isWeakOrExcess(log: Log) { 381 | return ( 382 | log.message === this.config.lintings.WEAK_PARAM_DESCRIPTION.message || 383 | log.message === 384 | this.config.lintings.PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD.message 385 | ); 386 | } 387 | 388 | private isWeak(log: Log) { 389 | return log.message === this.config.lintings.WEAK_PARAM_DESCRIPTION.message; 390 | } 391 | 392 | private sortByImportance(logs: Log[]) { 393 | const errors: Log[] = []; 394 | const warnings: Log[] = []; 395 | const infos: Log[] = []; 396 | 397 | logs.forEach((log) => { 398 | if (log.logLevel === "error") errors.push(log); 399 | if (log.logLevel === "warning") warnings.push(log); 400 | if (log.logLevel === "info") infos.push(log); 401 | }); 402 | 403 | return [...errors, ...warnings, ...infos]; 404 | } 405 | 406 | private sortByLineNumber(logs: Log[]) { 407 | return logs.sort((a, b) => a.line - b.line); 408 | } 409 | 410 | static printJson(fileName: string, content: object) { 411 | fs.writeFileSync(`${fileName}.json`, JSON.stringify(content, null, 2)); 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /src/services/Traverser.ts: -------------------------------------------------------------------------------- 1 | import ts, { getLineAndCharacterOfPosition as getLine } from "typescript"; 2 | import { Validator } from "../services"; 3 | import { Collector } from "./Collector"; 4 | import { Navigator } from "./Navigator"; 5 | 6 | export class Traverser { 7 | static sourceFile: ts.SourceFile; 8 | static sourceFilePath = ""; 9 | static extractedDescriptions: ExtractedDescription[] = []; 10 | 11 | static traverse(validator: Validator): ts.TransformerFactory { 12 | return (context) => { 13 | return (sourceFile) => { 14 | this.sourceFile = sourceFile; 15 | 16 | const collectorVisitor: ts.Visitor = (node) => { 17 | Collector.run(node); 18 | return ts.visitEachChild(node, collectorVisitor, context); 19 | }; 20 | 21 | const validatorVisitor: ts.Visitor = (node) => { 22 | validator.setNode(node).run(); 23 | return ts.visitEachChild(node, validatorVisitor, context); 24 | }; 25 | 26 | ts.visitNode(sourceFile, collectorVisitor); 27 | ts.visitNode(sourceFile, validatorVisitor); // main traversal 28 | validator.postTraversalChecks(sourceFile); 29 | 30 | return sourceFile; 31 | }; 32 | }; 33 | } 34 | 35 | /** 36 | * Extract all param descriptions. 37 | */ 38 | static extract(): ts.TransformerFactory { 39 | return (context) => { 40 | return (sourceFile) => { 41 | this.sourceFile = sourceFile; 42 | 43 | const extractorVisitor: ts.Visitor = (node) => { 44 | if (Navigator.isAssignment(node, { key: "description" })) { 45 | const description = node.getChildAt(2).getText().clean(); 46 | if (!description) return; 47 | 48 | let { line } = getLine(sourceFile, node.getEnd()); 49 | line += 1; 50 | 51 | Traverser.extractedDescriptions.push({ 52 | description, 53 | line, 54 | sourceFilePath: Traverser.sourceFilePath.split("n8n").pop()!, 55 | }); 56 | } 57 | 58 | return ts.visitEachChild(node, extractorVisitor, context); 59 | }; 60 | 61 | ts.visitNode(sourceFile, extractorVisitor); 62 | 63 | return sourceFile; 64 | }; 65 | }; 66 | } 67 | } 68 | 69 | String.prototype.unquote = function (this: string) { 70 | return this.replace(/(^'|'$)/g, ""); 71 | }; 72 | 73 | String.prototype.clean = function (this: string) { 74 | return this.trim().unquote(); 75 | }; 76 | -------------------------------------------------------------------------------- /src/services/Validator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { masterConfig } from ".."; 3 | import { LINTINGS } from "../lintings"; 4 | import { Logger, Traverser } from "../services"; 5 | import { isRegularNode } from "../utils"; 6 | import { Collector } from "./Collector"; 7 | import { ConfigManager } from "./ConfigManager"; 8 | import * as subvalidators from "./subvalidators"; 9 | 10 | export class Validator { 11 | public logs: Log[] = []; 12 | private currentNode: ts.Node; 13 | 14 | /** 15 | * Path to test file in `src/tests/input/`. 16 | */ 17 | public testSourceFilePath: string | undefined; 18 | 19 | constructor(testSourceFilePath?: string) { 20 | this.testSourceFilePath = testSourceFilePath; 21 | } 22 | 23 | public setNode(node: ts.Node) { 24 | this.currentNode = node; 25 | return this; 26 | } 27 | 28 | public run() { 29 | Object.values(subvalidators).forEach((sub) => { 30 | if (ConfigManager.lintAreaIsDisabled(sub.lintArea, masterConfig)) return; 31 | this.runSubValidator(sub); 32 | }); 33 | } 34 | 35 | private runSubValidator(constructor: SubValidatorConstructor) { 36 | const SubValidator = Logger(constructor); 37 | const logs = new SubValidator().run(this.currentNode); 38 | 39 | if (logs?.length) this.logs.push(...logs); 40 | } 41 | 42 | /** 43 | * Run checks _after_ the source file AST has been traversed. 44 | */ 45 | public postTraversalChecks(sourceFile: ts.SourceFile) { 46 | if ( 47 | Collector.credentialsTestNames.sort().join('') !== 48 | Collector.credentialsTestMethods.sort().join() 49 | ) { 50 | this.addToLogs( 51 | LINTINGS.MISMATCHED_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE, 52 | { line: 1, text: "" }, 53 | ) 54 | } 55 | 56 | if (Collector.tsIgnores.length) { 57 | Collector.tsIgnores.forEach(({ line, text }) => { 58 | this.addToLogs(LINTINGS.TS_IGNORE, { line, text }); 59 | }); 60 | } 61 | 62 | if (Collector.toDos.length) { 63 | Collector.toDos.forEach(({ line, text }) => { 64 | this.addToLogs(LINTINGS.TODO, { line, text }); 65 | }); 66 | } 67 | 68 | const { sourceFileHasContinueOnFail } = Collector; 69 | 70 | const nodeName = Traverser.sourceFilePath.split("/").pop(); 71 | 72 | if (isRegularNode(nodeName) && !sourceFileHasContinueOnFail) { 73 | let line = Collector.getLineNumber(sourceFile.getChildAt(0)); 74 | 75 | line += 1; // TODO: Find out why this offset is needed 76 | 77 | this.addToLogs(LINTINGS.MISSING_CONTINUE_ON_FAIL, { 78 | line, 79 | text: "", 80 | }); 81 | } 82 | } 83 | 84 | /** 85 | * Add logs during the final run, i.e. during the post-traversal checks. 86 | */ 87 | private addToLogs( 88 | linting: Linting, 89 | { line, text }: { line: number; text: string } 90 | ) { 91 | if ( 92 | ConfigManager.lintIssueIsDisabled(linting.lintIssue, masterConfig) || 93 | ConfigManager.logLevelIsDisabled(linting.logLevel, masterConfig) || 94 | ConfigManager.lintingIsDisabled(linting, masterConfig) || 95 | ConfigManager.lintingIsExcepted( 96 | linting, 97 | line, 98 | Collector.exceptions, 99 | masterConfig 100 | ) 101 | ) 102 | return; 103 | 104 | this.logs.push({ 105 | message: linting.message, 106 | lintAreas: linting.lintAreas, 107 | lintIssue: linting.lintIssue, 108 | line: line, 109 | excerpt: text, 110 | sourceFilePath: this.testSourceFilePath ?? Traverser.sourceFilePath, 111 | logLevel: linting.logLevel, 112 | ...(linting.details && { details: linting.details }), 113 | }); 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Logger"; 2 | export * from "./Presenter"; 3 | export * from "./Traverser"; 4 | export * from "./Validator"; 5 | -------------------------------------------------------------------------------- /src/services/subvalidators/DefaultValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { LINTINGS } from "../../lintings"; 3 | import { Navigator } from "../Navigator"; 4 | 5 | export class DefaultValidator implements SubValidator { 6 | static lintArea = "default" as const; 7 | 8 | logs: Log[]; 9 | log: LogFunction; 10 | 11 | public run(node: ts.Node) { 12 | this.validateDefaultExists(node); 13 | 14 | this.validateStringDefault(node); 15 | this.validateNumberDefault(node); 16 | this.validateBooleanDefault(node); 17 | this.validateCollectionDefault(node); 18 | this.validateMultiOptionsDefault(node); 19 | this.validateOptionsDefault(node); 20 | 21 | this.validateSimplifyDefault(node); 22 | 23 | return this.logs; 24 | } 25 | 26 | private validateDefaultExists(node: ts.Node) { 27 | if (Navigator.isAssignment(node, { key: "type" })) { 28 | let hasDefault = false; 29 | node.parent.forEachChild((node) => { 30 | if (Navigator.isAssignment(node, { key: "default" })) { 31 | hasDefault = true; 32 | } 33 | }); 34 | 35 | if (!hasDefault) { 36 | this.log(LINTINGS.DEFAULT_MISSING)(node); 37 | } 38 | } 39 | } 40 | 41 | private validateSimplifyDefault(node: ts.Node) { 42 | if ( 43 | Navigator.isAssignment(node, { 44 | key: "displayName", 45 | value: "Simplify Response", 46 | }) 47 | ) { 48 | node.parent.forEachChild((child) => { 49 | if (Navigator.isAssignment(child, { key: "default", value: false })) { 50 | this.log(LINTINGS.WRONG_DEFAULT_FOR_SIMPLIFY_PARAM)(child); 51 | } 52 | }); 53 | } 54 | } 55 | 56 | /** 57 | * Generate a function that validates if the value for a `default` conforms to the param `type`. 58 | * 59 | * Not applicable for default values that must conform to `displayName`, e.g. `default: true` for 60 | * `displayName: 'Simplify Response'`. 61 | */ 62 | private defaultValidatorGenerator = 63 | ( 64 | typeName: ParameterType, 65 | typeCheck: (node: ts.Node) => boolean, 66 | linting: Linting 67 | ) => 68 | (node: ts.Node) => { 69 | if (Navigator.isAssignment(node, { key: "type", value: typeName })) { 70 | node.parent.forEachChild((node) => { 71 | if ( 72 | node.getChildAt(0).getText() === "default" && 73 | !typeCheck(node.getChildAt(2)) 74 | ) 75 | this.log(linting)(node); 76 | }); 77 | } 78 | }; 79 | 80 | private validateStringDefault = this.defaultValidatorGenerator( 81 | "string", 82 | ts.isStringLiteral, 83 | LINTINGS.WRONG_DEFAULT_FOR_STRING_TYPE_PARAM 84 | ); 85 | 86 | private validateNumberDefault = this.defaultValidatorGenerator( 87 | "number", 88 | ts.isNumericLiteral, 89 | LINTINGS.WRONG_DEFAULT_FOR_NUMBER_TYPE_PARAM 90 | ); 91 | 92 | private validateBooleanDefault = this.defaultValidatorGenerator( 93 | "boolean", 94 | Navigator.isBooleanKeyword, 95 | LINTINGS.WRONG_DEFAULT_FOR_BOOLEAN_TYPE_PARAM 96 | ); 97 | 98 | private validateCollectionDefault = this.defaultValidatorGenerator( 99 | "collection", 100 | ts.isObjectLiteralExpression, 101 | LINTINGS.WRONG_DEFAULT_FOR_COLLECTION_TYPE_PARAM 102 | ); 103 | 104 | private validateMultiOptionsDefault = this.defaultValidatorGenerator( 105 | "multiOptions", 106 | ts.isArrayLiteralExpression, 107 | LINTINGS.WRONG_DEFAULT_FOR_MULTIOPTIONS_TYPE_PARAM 108 | ); 109 | 110 | private validateOptionsDefault = (node: ts.Node) => { 111 | if (Navigator.isAssignment(node, { key: "type", value: "options" })) { 112 | let hasTypeOptionsSibling = false; 113 | 114 | node?.parent?.forEachChild((child) => { 115 | if (Navigator.isAssignment(child, { key: "typeOptions" })) { 116 | hasTypeOptionsSibling = true; 117 | } 118 | }); 119 | 120 | if (hasTypeOptionsSibling) return; 121 | 122 | let defaultNodeToReport: ts.Node = node; // if the includes check fails 123 | let defaultOptionValue = ""; 124 | let optionValues: string[] = []; 125 | 126 | node.parent.forEachChild((node) => { 127 | if (Navigator.isAssignment(node, { key: "default" })) { 128 | defaultOptionValue = node.getChildAt(2).getText().replace(/'/g, ""); // remove single quotes 129 | defaultNodeToReport = node; 130 | } 131 | }); 132 | 133 | let hasOptionsInVariable = false; 134 | 135 | node.parent.forEachChild((node) => { 136 | if (node.getChildAt(0).getText() !== "options") return; 137 | 138 | // value of options is variable instead of array literal 139 | if (ts.isIdentifier(node.getChildAt(2))) { 140 | hasOptionsInVariable = true; 141 | } 142 | 143 | if (!ts.isArrayLiteralExpression(node.getChildAt(2))) return; 144 | 145 | node.getChildAt(2).forEachChild((node) => { 146 | if (!ts.isObjectLiteralExpression(node)) return; 147 | node.forEachChild((node) => { 148 | if (Navigator.isAssignment(node, { key: "value" })) { 149 | optionValues.push( 150 | node.getChildAt(2).getText().replace(/'/g, "") // remove single quotes 151 | ); 152 | } 153 | }); 154 | }); 155 | }); 156 | 157 | if (!optionValues.includes(defaultOptionValue) && !hasOptionsInVariable) { 158 | this.log(LINTINGS.WRONG_DEFAULT_FOR_OPTIONS_TYPE_PARAM)( 159 | defaultNodeToReport 160 | ); 161 | } 162 | } 163 | }; 164 | } 165 | -------------------------------------------------------------------------------- /src/services/subvalidators/DisplayNameValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { STANDARD_NAMES } from "../../constants"; 3 | import { LINTINGS } from "../../lintings"; 4 | import { isTitleCase } from "../../utils"; 5 | import { Navigator } from "../Navigator"; 6 | 7 | export class DisplayNameValidator implements SubValidator { 8 | static lintArea = "displayName" as const; 9 | logs: Log[]; 10 | log: LogFunction; 11 | 12 | public run(node: ts.Node) { 13 | if (Navigator.isAssignment(node, { key: "name", value: "simple" })) { 14 | node.parent.forEachChild((node) => { 15 | if ( 16 | node.getChildAt(0).getText() === "displayName" && 17 | node.getChildAt(2).getText() !== 18 | `'${STANDARD_NAMES.simplifyResponse}'` 19 | ) { 20 | this.log(LINTINGS.NON_STANDARD_DISPLAY_NAME_FOR_SIMPLIFY_PARAM)(node); 21 | } 22 | }); 23 | } 24 | 25 | if (Navigator.isAssignment(node, { key: "type", value: "collection" })) { 26 | node.parent.forEachChild((child) => { 27 | if (Navigator.isAssignment(child, { key: "displayOptions" })) { 28 | const operationNode = Navigator.findDescendant(child, { 29 | text: "operation", 30 | }); 31 | 32 | if (!operationNode) return; 33 | 34 | const updateNode = Navigator.findDescendant(operationNode.parent, { 35 | text: "'update'", 36 | }); 37 | 38 | if (!updateNode) return; 39 | 40 | node.parent.forEachChild((child) => { 41 | if (Navigator.isAssignment(child, { key: "displayName" })) { 42 | if (child.getChildAt(2).getText() !== "'Update Fields'") { 43 | this.log(LINTINGS.DISPLAYNAME_NOT_UPDATE_FIELDS)(child); 44 | } 45 | } 46 | }); 47 | } 48 | }); 49 | } 50 | 51 | if (Navigator.isAssignment(node, { key: "displayName" })) { 52 | const displayNameValue = node.getChildAt(2).getText().replace(/'/g, ""); // remove single quotes 53 | 54 | if (displayNameValue.match(/id$/) || displayNameValue.match(/Id$/)) { 55 | this.log(LINTINGS.DISPLAYNAME_WITH_MISCASED_ID)(node); 56 | } 57 | 58 | if (!isTitleCase(displayNameValue)) { 59 | this.log(LINTINGS.DISPLAYNAME_WITH_NO_TITLECASE)(node); 60 | } 61 | 62 | if ( 63 | node.getChildAt(2).getText().startsWith("' ") || 64 | node.getChildAt(2).getText().endsWith(" '") 65 | ) { 66 | this.log(LINTINGS.DISPLAYNAME_UNTRIMMED)(node); 67 | } 68 | } 69 | 70 | return this.logs; 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/services/subvalidators/LimitValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { STANDARD_DESCRIPTIONS } from "../../constants"; 3 | import { LINTINGS } from "../../lintings"; 4 | import { Navigator } from "../Navigator"; 5 | 6 | export class LimitValidator implements SubValidator { 7 | static lintArea = "limit" as const; 8 | logs: Log[]; 9 | log: LogFunction; 10 | 11 | public run(node: ts.Node) { 12 | if (Navigator.isAssignment(node, { key: "name" })) { 13 | const nameValue = node.getChildAt(2).getText().replace(/'/g, ""); // remove single quotes 14 | 15 | if (nameValue === "limit") { 16 | let hasTypeOptions = false; 17 | 18 | node.parent.forEachChild((node) => { 19 | if ( 20 | ts.isPropertyAssignment(node) && 21 | node.getChildAt(0).getText() === "default" && 22 | node.getChildAt(2).getText() !== "50" 23 | ) { 24 | this.log(LINTINGS.WRONG_DEFAULT_FOR_LIMIT_PARAM)(node); 25 | } 26 | 27 | if ( 28 | ts.isPropertyAssignment(node) && 29 | node.getChildAt(0).getText() === "description" && 30 | node.getChildAt(2).getText() !== `'${STANDARD_DESCRIPTIONS.limit}'` 31 | ) { 32 | this.log(LINTINGS.NON_STANDARD_LIMIT_DESCRIPTION)(node); 33 | } 34 | 35 | if (Navigator.isAssignment(node, { key: "typeOptions" })) { 36 | node.getChildAt(2).forEachChild((node) => { 37 | if (node.getChildAt(0).getText() === "minValue") { 38 | hasTypeOptions = true; 39 | const minValue = node.getChildAt(2).getText(); 40 | if (Number(minValue) < 1) { 41 | this.log(LINTINGS.LIMIT_WITH_MIN_VALUE_LOWER_THAN_ONE)(node); 42 | } 43 | } 44 | }); 45 | } 46 | }); 47 | 48 | if (!hasTypeOptions) { 49 | this.log(LINTINGS.LIMIT_WITHOUT_TYPE_OPTIONS)(node); 50 | } 51 | } 52 | } 53 | 54 | return this.logs; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/services/subvalidators/MiscellaneousValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { LINTINGS } from "../../lintings"; 3 | import { STANDARD_DESCRIPTIONS } from "../../constants"; 4 | import { Navigator } from "../Navigator"; 5 | import { Collector } from "../Collector"; 6 | 7 | export class MiscellaneousValidator implements SubValidator { 8 | static lintArea = "miscellaneous" as const; 9 | logs: Log[]; 10 | log: LogFunction; 11 | 12 | public run(node: ts.Node) { 13 | if ( 14 | Navigator.isAssignment(node, { key: "type", value: "fixedCollection" }) 15 | ) { 16 | let isOptionalFixedCollection = true; 17 | node.parent.forEachChild((propertyAssignment) => { 18 | if ( 19 | propertyAssignment.getChildAt(0).getText() === "required" && 20 | propertyAssignment.getChildAt(2).getText() === "true" 21 | ) { 22 | isOptionalFixedCollection = false; 23 | } 24 | }); 25 | 26 | if (isOptionalFixedCollection) { 27 | const paramsContainingArray = node.parent.parent; 28 | if (ts.isArrayLiteralExpression(paramsContainingArray)) { 29 | const arrayParentKind = ts.SyntaxKind[node.parent.parent.parent.kind]; 30 | const isTopLevelFixedCollection = 31 | arrayParentKind === "VariableDeclaration" || 32 | arrayParentKind === "AsExpression"; 33 | 34 | if (isTopLevelFixedCollection) { 35 | this.log(LINTINGS.TOP_LEVEL_OPTIONAL_FIXED_COLLECTION)(node); 36 | } 37 | } 38 | } 39 | } 40 | 41 | if (Navigator.isAssignment(node, { key: "displayName" })) { 42 | const value = node.getChildAt(2).getText().clean(); 43 | if (value.toLowerCase().match(/colo(u?)r/)) { 44 | let hasColorTypeParam = false; 45 | node.parent.forEachChild((propertyAssignment) => { 46 | if ( 47 | Navigator.isAssignment(propertyAssignment, { 48 | key: "type", 49 | value: "color", 50 | }) 51 | ) { 52 | hasColorTypeParam = true; 53 | } 54 | }); 55 | 56 | if (!hasColorTypeParam) { 57 | this.log(LINTINGS.COLOR_TYPE_NOT_USED_FOR_COLOR_PARAM)(node); 58 | } 59 | } 60 | } 61 | 62 | if ( 63 | ts.isAsExpression(node) && 64 | node.getChildAt(2).getText() === "INodeProperties[]" 65 | ) { 66 | this.log(LINTINGS.I_NODE_PROPERTIES_MISCASTING)(node); 67 | } 68 | 69 | if (Navigator.isAssignment(node, { key: "name", value: "resource" })) { 70 | let resourceHasNoDataExpression = false; 71 | 72 | node.parent.forEachChild((node) => { 73 | if ( 74 | node.getChildAt(0).getText() === "noDataExpression" && 75 | node.getChildAt(2).getText() === "true" 76 | ) { 77 | resourceHasNoDataExpression = true; 78 | } 79 | }); 80 | 81 | if (!resourceHasNoDataExpression) { 82 | this.log(LINTINGS.RESOURCE_WITHOUT_NO_DATA_EXPRESSION)(node); 83 | } 84 | } 85 | 86 | if (Navigator.isAssignment(node, { key: "name", value: "operation" })) { 87 | let operationHasNoDataExpression = false; 88 | 89 | node.parent.forEachChild((node) => { 90 | if ( 91 | node.getChildAt(0).getText() === "noDataExpression" && 92 | node.getChildAt(2).getText() === "true" 93 | ) { 94 | operationHasNoDataExpression = true; 95 | } 96 | }); 97 | 98 | if (!operationHasNoDataExpression) { 99 | this.log(LINTINGS.OPERATION_WITHOUT_NO_DATA_EXPRESSION)(node); 100 | } 101 | } 102 | 103 | if (Navigator.isAssignment(node, { key: "name", value: "returnAll" })) { 104 | node.parent.forEachChild((node) => { 105 | if ( 106 | node.getChildAt(0).getText() === "description" && 107 | node.getChildAt(2).getText() !== 108 | `'${STANDARD_DESCRIPTIONS.returnAll}'` 109 | ) 110 | this.log(LINTINGS.NON_STANDARD_RETURNALL_DESCRIPTION)(node); 111 | }); 112 | } 113 | 114 | if ( 115 | ts.isAsExpression(node) && 116 | ts.isArrayLiteralExpression(node.getChildAt(0)) 117 | ) { 118 | node.getChildAt(0).forEachChild((node) => 119 | node.forEachChild((node) => { 120 | if (Navigator.isAssignment(node, { key: "required", value: false })) { 121 | this.log(LINTINGS.REQUIRED_FALSE)(node); 122 | } 123 | }) 124 | ); 125 | } 126 | 127 | if ( 128 | ts.isIdentifier(node) && 129 | !ts.isTypeReferenceNode(node.parent) && 130 | node.getText() === "Error" 131 | ) { 132 | this.log(LINTINGS.WRONG_ERROR_THROWN)(node.parent); 133 | } 134 | 135 | if (ts.isIdentifier(node) && node.getText() === "loadOptionsMethod") { 136 | const loadOptionsMethod = node.parent 137 | .getText() 138 | .split(":") 139 | .map((i) => i.trim().replace(/'/g, "")) 140 | .pop(); 141 | 142 | if ( 143 | loadOptionsMethod && 144 | !Collector.loadOptionsMethods.includes(loadOptionsMethod) 145 | ) { 146 | this.log(LINTINGS.NON_EXISTENT_LOAD_OPTIONS_METHOD)(node.parent); 147 | } 148 | } 149 | 150 | return this.logs; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/services/subvalidators/NameValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { isCamelCase } from "../../utils"; 3 | import { LINTINGS } from "../../lintings"; 4 | import { Navigator } from "../Navigator"; 5 | 6 | export class NameValidator implements SubValidator { 7 | static lintArea = "name" as const; 8 | logs: Log[]; 9 | log: LogFunction; 10 | 11 | public run(node: ts.Node) { 12 | if (Navigator.isAssignment(node, { key: "name", value: "*" })) { 13 | this.log(LINTINGS.NAME_USING_STAR_INSTEAD_OF_ALL)(node); 14 | } 15 | 16 | if (Navigator.isAssignment(node, { key: "name" })) { 17 | const hasDefaultsParent = node?.parent?.parent 18 | ?.getText() 19 | .startsWith("defaults"); // skip check for defaults 20 | 21 | if (hasDefaultsParent) return; 22 | 23 | const hasCredentialsParent = node?.parent?.parent?.parent 24 | ?.getText() 25 | .startsWith("credentials"); 26 | 27 | const nameValue = node.getChildAt(2).getText().replace(/'/g, ""); // remove single quotes 28 | 29 | if (hasCredentialsParent) { 30 | if (!nameValue.endsWith("Api")) { 31 | this.log(LINTINGS.NON_SUFFIXED_CREDENTIALS_NAME)(node); 32 | } 33 | } 34 | 35 | if (nameValue === "authentication") { 36 | this.log(LINTINGS.AUTHENTICATION_PARAM_NOT_IN_CREDENTIALS)(node); 37 | } 38 | 39 | let isOption = false; 40 | node.parent.forEachChild((node) => { 41 | if (Navigator.isAssignment(node, { key: "value" })) { 42 | isOption = true; 43 | } 44 | }); 45 | 46 | if (!isOption) { 47 | if ( 48 | (nameValue.length > 2 && 49 | !nameValue.includes("_") && 50 | nameValue.match(/id$/)) || 51 | nameValue.match(/ID$/) 52 | ) { 53 | this.log(LINTINGS.NAME_WITH_MISCASED_ID)(node); 54 | } 55 | 56 | if (!isCamelCase(nameValue)) { 57 | this.log(LINTINGS.NAME_WITH_NO_CAMELCASE)(node); 58 | } 59 | } 60 | } 61 | 62 | return this.logs; 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/services/subvalidators/NodeDescriptionValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { LINTINGS } from "../../lintings"; 3 | import { STANDARD_DESCRIPTIONS } from "../../constants"; 4 | import { Navigator } from "../Navigator"; 5 | import { Collector } from "../Collector"; 6 | 7 | export class NodeDescriptionValidator implements SubValidator { 8 | static lintArea = "nodeDescription" as const; 9 | logs: Log[]; 10 | log: LogFunction; 11 | 12 | public run(node: ts.Node) { 13 | if ( 14 | ts.isPropertyAssignment(node) && 15 | (node.getChildAt(0).getText() === "displayName" || 16 | node.getChildAt(0).getText() === "name") 17 | ) { 18 | // TODO: Clean this up 19 | node.parent.parent.parent.parent.forEachChild((child) => { 20 | if ( 21 | ts.isClassDeclaration(child) && 22 | child.getChildAt(2).getText().endsWith("Trigger") && // class name is "*Trigger" 23 | !node.getChildAt(2).getText().endsWith(" Trigger'") && 24 | node.getChildAt(0).getText() === "displayName" // display name is not "* Trigger" 25 | ) { 26 | this.log( 27 | LINTINGS.DISPLAYNAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION 28 | )(node); 29 | } 30 | 31 | if ( 32 | ts.isClassDeclaration(child) && 33 | child.getChildAt(2).getText().endsWith("Trigger") && // class name is "*Trigger" 34 | !node.getChildAt(2).getText().endsWith("Trigger'") && 35 | node.getChildAt(0).getText() === "name" // name is not "*Trigger" 36 | ) { 37 | this.log(LINTINGS.NAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION)( 38 | node 39 | ); 40 | } 41 | }); 42 | } 43 | 44 | if (Navigator.isAssignment(node, { key: "icon" })) { 45 | const iconValue = node.getChildAt(2).getText(); 46 | 47 | if (iconValue.endsWith(".png'")) { 48 | this.log(LINTINGS.PNG_ICON_IN_NODE_DESCRIPTION)(node); 49 | } 50 | } 51 | 52 | if (Navigator.isAssignment(node, { key: "inputs" })) { 53 | const inputsContents = node.getChildAt(2).getChildAt(1).getText(); 54 | 55 | const numberOfInputs = 56 | inputsContents === "" ? 0 : inputsContents.split(",").length; 57 | 58 | if (Collector.isRegularNode && numberOfInputs === 0) { 59 | this.log(LINTINGS.WRONG_NUMBER_OF_INPUTS_IN_REGULAR_NODE_DESCRIPTION)( 60 | node 61 | ); 62 | } else if (Collector.isTriggerNode && numberOfInputs !== 0) { 63 | this.log(LINTINGS.WRONG_NUMBER_OF_INPUTS_IN_TRIGGER_NODE_DESCRIPTION)( 64 | node 65 | ); 66 | } 67 | } 68 | 69 | if (Navigator.isAssignment(node, { key: "outputs" })) { 70 | const outputsContents = node.getChildAt(2).getChildAt(1).getText(); 71 | 72 | const numberOfOutputs = 73 | outputsContents === "" ? 0 : outputsContents.split(",").length; 74 | 75 | if (Collector.isRegularNode && numberOfOutputs === 0) { 76 | this.log(LINTINGS.WRONG_NUMBER_OF_OUTPUTS_IN_NODE_DESCRIPTION)(node); 77 | } 78 | } 79 | 80 | if (Navigator.isAssignment(node, { key: "credentials" })) { 81 | const arrayLiteralExpression = node.getChildAt(2); 82 | 83 | arrayLiteralExpression.forEachChild(credentialsObject => { 84 | let isNonOAuth = false; 85 | let hasTestedBy = false; 86 | 87 | credentialsObject.forEachChild(propertyAssignment => { 88 | const key = propertyAssignment.getChildAt(0).getText(); 89 | const value = propertyAssignment.getChildAt(2).getText().clean(); 90 | 91 | if (key === "name" && value.endsWith("Api") && !value.endsWith("OAuth2Api")) { 92 | isNonOAuth = true; 93 | } else if (key === 'testedBy') { 94 | hasTestedBy = true; 95 | } 96 | }); 97 | 98 | if (isNonOAuth && !hasTestedBy) { 99 | this.log(LINTINGS.MISSING_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE)(credentialsObject) 100 | } 101 | }); 102 | } 103 | 104 | if ( 105 | ts.isObjectLiteralExpression(node) && 106 | ts.isPropertyDeclaration(node.parent) && 107 | node.parent.getChildAt(0).getText() === "description" 108 | ) { 109 | let hasSubtitle = false; 110 | 111 | node.forEachChild((child) => { 112 | if (child.getChildAt(0).getText() === "subtitle") { 113 | hasSubtitle = true; 114 | 115 | if ( 116 | child.getChildAt(2).getText() !== 117 | `'${STANDARD_DESCRIPTIONS.subtitle}'` 118 | ) { 119 | this.log(LINTINGS.NON_STANDARD_SUBTITLE)(child); 120 | } 121 | } 122 | }); 123 | 124 | if (!hasSubtitle) { 125 | this.log(LINTINGS.SUBTITLE_MISSING_IN_NODE_DESCRIPTION)(node); 126 | } 127 | } 128 | 129 | return this.logs; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/services/subvalidators/OptionsValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { 3 | areAlphabetized, 4 | areLongListing, 5 | isCamelCase, 6 | isTitleCase, 7 | } from "../../utils"; 8 | import { LINTINGS } from "../../lintings"; 9 | import { STANDARD_DESCRIPTIONS } from "../../constants"; 10 | import { Navigator } from "../Navigator"; 11 | 12 | export class OptionsValidator implements SubValidator { 13 | static lintArea = "options" as const; 14 | logs: Log[]; 15 | log: LogFunction; 16 | 17 | public run(node: ts.Node) { 18 | if ( 19 | Navigator.isAssignment(node, { key: "type", value: "fixedCollection" }) 20 | ) { 21 | let fixedCollectionValuesNames: string[] = []; 22 | let nodeToReport: ts.Node = node; 23 | 24 | node.parent.forEachChild((child) => { 25 | if (child.getChildAt(0).getText() === "options") { 26 | child 27 | .getChildAt(2) 28 | .getChildAt(1) 29 | .getChildAt(0) 30 | .forEachChild((child) => { 31 | if (child.getChildAt(0).getText() === "values") { 32 | nodeToReport = child.getChildAt(2); 33 | child.getChildAt(2).forEachChild((child) => { 34 | if (ts.isObjectLiteralExpression(child)) { 35 | child.forEachChild((child) => { 36 | if (child.getChildAt(0).getText() === "displayName") { 37 | if ( 38 | !isTitleCase( 39 | child.getChildAt(2).getText().replace(/'/g, "") 40 | ) 41 | ) { 42 | this.log( 43 | LINTINGS.FIXED_COLLECTION_VALUE_DISPLAY_NAME_WITH_NO_TITLECASE 44 | )(child); 45 | } 46 | } 47 | 48 | if (child.getChildAt(0).getText() === "displayName") { 49 | fixedCollectionValuesNames.push( 50 | child.getChildAt(2).getText().replace(/'/g, "") // remove single quotes from string 51 | ); 52 | } 53 | }); 54 | } 55 | }); 56 | } 57 | }); 58 | } 59 | }); 60 | 61 | if ( 62 | areLongListing(fixedCollectionValuesNames) && 63 | !areAlphabetized(fixedCollectionValuesNames) 64 | ) { 65 | this.log( 66 | LINTINGS.NON_ALPHABETIZED_VALUES_IN_FIXED_COLLECTION_TYPE_PARAM 67 | )(nodeToReport); 68 | } 69 | } 70 | 71 | if (Navigator.isAssignment(node, { key: "type", value: "collection" })) { 72 | let nodeToReport: ts.Node = node; 73 | let collectionOptionsNames: string[] = []; 74 | 75 | node.parent.forEachChild((node) => { 76 | if (node.getChildAt(0).getText() !== "options") return; 77 | if (!ts.isArrayLiteralExpression(node.getChildAt(2))) return; 78 | 79 | nodeToReport = node; 80 | node.getChildAt(2).forEachChild((node) => { 81 | if (!ts.isObjectLiteralExpression(node)) return; 82 | 83 | node.forEachChild((node) => { 84 | if ( 85 | ts.isPropertyAssignment(node) && 86 | node.getChildAt(0).getText() === "name" 87 | ) { 88 | collectionOptionsNames.push( 89 | node.getChildAt(2).getText().replace(/'/g, "") // remove single quotes from string 90 | ); 91 | } 92 | }); 93 | }); 94 | }); 95 | 96 | if ( 97 | areLongListing(collectionOptionsNames) && 98 | !areAlphabetized(collectionOptionsNames) 99 | ) { 100 | this.log(LINTINGS.NON_ALPHABETIZED_OPTIONS_IN_COLLECTION_TYPE_PARAM)( 101 | nodeToReport 102 | ); 103 | } 104 | } 105 | 106 | if ( 107 | ts.isPropertyAssignment(node) && 108 | node.getChildAt(0).getText() === "type" && 109 | (node.getChildAt(2).getText() === "'options'" || 110 | node.getChildAt(2).getText() === "'multiOptions'") 111 | ) { 112 | let isOptionsType = node.getChildAt(2).getText() === "'options'"; 113 | let isMultiOptionsType = 114 | node.getChildAt(2).getText() === "'multiOptions'"; 115 | 116 | let nodeToReport: ts.Node = node; 117 | let optionsNames: string[] = []; 118 | 119 | node.parent.forEachChild((node) => { 120 | if (node.getChildAt(0).getText() !== "options") return; 121 | if (!ts.isArrayLiteralExpression(node.getChildAt(2))) return; 122 | 123 | nodeToReport = node; 124 | node.getChildAt(2).forEachChild((node) => { 125 | if (!ts.isObjectLiteralExpression(node)) return; 126 | 127 | node.forEachChild((node) => { 128 | if (Navigator.isAssignment(node, { key: "name" })) { 129 | optionsNames.push( 130 | node.getChildAt(2).getText().replace(/'/g, "") // remove single quotes from string 131 | ); 132 | 133 | if ( 134 | !isTitleCase(node.getChildAt(2).getText().replace(/'/g, "")) 135 | ) { 136 | this.log(LINTINGS.OPTIONS_NAME_WITH_NO_TITLECASE)(node); 137 | } 138 | } 139 | 140 | if (Navigator.isAssignment(node, { key: "value" })) { 141 | if ( 142 | !isCamelCase(node.getChildAt(2).getText().replace(/'/g, "")) 143 | ) { 144 | this.log(LINTINGS.OPTIONS_VALUE_WITH_NO_CAMELCASE)(node); 145 | } 146 | 147 | if (node.getChildAt(2).getText() === "'upsert'") { 148 | node.parent.forEachChild((child) => { 149 | if (child.getChildAt(0).getText() === "name") { 150 | if ( 151 | child.getChildAt(2).getText() !== 152 | `'${STANDARD_DESCRIPTIONS.upsert}'` 153 | ) { 154 | this.log(LINTINGS.NON_STANDARD_NAME_FOR_UPSERT_OPTION)( 155 | child 156 | ); 157 | } 158 | } 159 | 160 | if (child.getChildAt(0).getText() === "description") { 161 | if ( 162 | child.getChildAt(2).getText() !== 163 | `'${STANDARD_DESCRIPTIONS.upsert}'` 164 | ) { 165 | this.log( 166 | LINTINGS.NON_STANDARD_DESCRIPTION_FOR_UPSERT_OPTION 167 | )(child); 168 | } 169 | } 170 | }); 171 | } 172 | } 173 | }); 174 | }); 175 | }); 176 | 177 | if ( 178 | isOptionsType && 179 | areLongListing(optionsNames) && 180 | !areAlphabetized(optionsNames) 181 | ) { 182 | this.log(LINTINGS.NON_ALPHABETIZED_OPTIONS_IN_OPTIONS_TYPE_PARAM)( 183 | nodeToReport 184 | ); 185 | } 186 | 187 | if ( 188 | isMultiOptionsType && 189 | areLongListing(optionsNames) && 190 | !areAlphabetized(optionsNames) 191 | ) { 192 | this.log(LINTINGS.NON_ALPHABETIZED_OPTIONS_IN_MULTIOPTIONS_TYPE_PARAM)( 193 | nodeToReport 194 | ); 195 | } 196 | } 197 | return this.logs; 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /src/services/subvalidators/ParamDescriptionValidator.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import { 3 | BRITISH_ENGLISH_SUFFIXES, 4 | STANDARD_DESCRIPTIONS, 5 | TECHNICAL_TERMS, 6 | WEAK_DESCRIPTIONS, 7 | } from "../../constants"; 8 | import { LINTINGS } from "../../lintings"; 9 | import { hasAnchorLink, hasProtocol, startsWithCapital } from "../../utils"; 10 | 11 | // TODO: Refactor for readability 12 | 13 | export class DescriptionValidator implements SubValidator { 14 | static lintArea = "paramDescription" as const; 15 | logs: Log[]; 16 | log: LogFunction; 17 | 18 | /** 19 | * Validate that a single-sentence description has no final period, and 20 | * that a multiple-sentence description has final periods for all sentences, 21 | * except if the sentence ends with a `` element. 22 | */ 23 | private checkFinalPeriod(description: string, node: ts.Node) { 24 | const sentences = description.split(". "); 25 | 26 | if (!sentences.length) return; 27 | 28 | if (sentences.length === 1 && !sentences[0].endsWith(".")) return; 29 | 30 | if (sentences.length === 1 && sentences[0].endsWith(".")) { 31 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD)(node); 32 | return; 33 | } 34 | 35 | const [last, ...allButLast] = [sentences.pop()!, ...sentences]; 36 | 37 | // restore periods removed by split() in all but last 38 | const restored = [...allButLast.map((s) => (s += ".")), last]; 39 | 40 | const lastSentence = restored[restored.length - 1]; 41 | 42 | if (lastSentence.endsWith("")) return; 43 | 44 | if (lastSentence.endsWith(".")) { 45 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD)(node); 46 | return; 47 | } 48 | 49 | if (!restored.every((sentence) => sentence.endsWith("."))) { 50 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_MISSING_FINAL_PERIOD)(node); 51 | } 52 | } 53 | 54 | public run(node: ts.Node) { 55 | // skip object inside arrow function that happens to have `name` property 56 | const isNameInArrowFunction = 57 | ts.isPropertyAssignment(node) && 58 | ts.isIdentifier(node.getChildAt(0)) && 59 | node.getChildAt(0).getText() === "name" && 60 | node?.parent?.parent?.parent?.kind === ts.SyntaxKind.ArrowFunction; 61 | 62 | // skip object inside `execute()` that happens to have `name` property 63 | const isNameWithVariableDeclarationParent = 64 | ts.isPropertyAssignment(node) && 65 | ts.isIdentifier(node.getChildAt(0)) && 66 | node.getChildAt(0).getText() === "name" && 67 | (node?.parent?.parent?.kind === ts.SyntaxKind.VariableDeclaration || 68 | node?.parent?.parent?.parent?.kind === 69 | ts.SyntaxKind.VariableDeclaration); 70 | 71 | // skip object in `credentials` in node description 72 | const hasCredentialsParent = node?.parent?.parent?.parent 73 | ?.getText() 74 | .startsWith("credentials"); 75 | 76 | // skip object in `defaults` in node description 77 | const hasDefaultsParent = node?.parent?.parent 78 | ?.getText() 79 | .startsWith("defaults"); 80 | 81 | if (!ts.isPropertyAssignment(node)) return; 82 | 83 | if ( 84 | node.getChildAt(0).getText() === "name" && 85 | node.getChildAt(2).getText() === "'simple'" 86 | ) { 87 | node.parent.forEachChild((node) => { 88 | if ( 89 | node.getChildAt(0).getText() === "description" && 90 | node.getChildAt(2).getText() !== 91 | `'${STANDARD_DESCRIPTIONS.simplifyResponse}'` 92 | ) 93 | this.log(LINTINGS.NON_STANDARD_DESCRIPTION_FOR_SIMPLIFY_PARAM)(node); 94 | }); 95 | } 96 | 97 | if (node.getChildAt(0).getText() === "description") { 98 | node.parent.forEachChild((child) => { 99 | if (child.getChildAt(0).getText() === "displayName") { 100 | if (node.getChildAt(2).getText() === child.getChildAt(2).getText()) { 101 | this.log(LINTINGS.PARAM_DESCRIPTION_IDENTICAL_TO_DISPLAY_NAME)( 102 | node 103 | ); 104 | } 105 | } 106 | }); 107 | 108 | if ( 109 | hasAnchorLink(node.getChildAt(2).getText()) && 110 | !hasProtocol(node.getChildAt(2).getText()) 111 | ) { 112 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_MISSING_PROTOCOL_LINK)(node); 113 | } 114 | 115 | if (node.getChildAt(2).getText().includes("
")) { 116 | this.log(LINTINGS.NON_STANDARD_HTML_LINE_BREAK)(node); 117 | } 118 | 119 | TECHNICAL_TERMS.forEach((technicalTerm) => { 120 | if (node.getChildAt(2).getText().includes(technicalTerm)) { 121 | this.log(LINTINGS.TECHNICAL_TERM_IN_PARAM_DESCRIPTION)(node); 122 | } 123 | }); 124 | 125 | const descriptionText = node.getChildAt(2).getText().replace(/'/g, ""); 126 | 127 | BRITISH_ENGLISH_SUFFIXES.forEach((suffix) => { 128 | descriptionText.split(" ").forEach((word) => { 129 | if (word.endsWith(suffix) && word !== "your") { 130 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_BRITISH_SUFFIX)(node); 131 | } 132 | }); 133 | }); 134 | 135 | WEAK_DESCRIPTIONS.forEach((weakDescription) => { 136 | if (node.getChildAt(2).getText().includes(weakDescription)) { 137 | this.log(LINTINGS.WEAK_PARAM_DESCRIPTION)(node); 138 | } 139 | }); 140 | 141 | if ( 142 | node.getChildAt(2).getText().startsWith("' ") || 143 | node.getChildAt(2).getText().endsWith(" '") 144 | ) { 145 | this.log(LINTINGS.PARAM_DESCRIPTION_UNTRIMMED)(node); 146 | } 147 | 148 | if ( 149 | node.getChildAt(2).getText().split(" ").includes("id") || 150 | node.getChildAt(2).getText().split(" ").includes("Id") 151 | ) { 152 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_MISCASED_ID)(node); 153 | } 154 | 155 | if ( 156 | ts.isNoSubstitutionTemplateLiteral(node.getChildAt(2)) && 157 | !node.getChildAt(2).getText().includes("'") && 158 | !node.getChildAt(2).getText().includes('"') 159 | ) { 160 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_UNNEEDED_BACKTICKS)(node); 161 | } 162 | 163 | const descriptionValue = node.getChildAt(2).getText().replace(/'/g, ""); // remove single quotes 164 | 165 | if (descriptionValue === "") { 166 | this.log(LINTINGS.PARAM_DESCRIPTION_AS_EMPTY_STRING)(node); 167 | } 168 | 169 | if (descriptionValue && !startsWithCapital(descriptionValue)) { 170 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_UNCAPITALIZED_INITIAL)(node); 171 | } 172 | 173 | if (/\s{2,}/.test(descriptionValue)) { 174 | this.log(LINTINGS.PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE)(node); 175 | } 176 | 177 | this.checkFinalPeriod(descriptionValue, node); 178 | } 179 | 180 | if ( 181 | node.getChildAt(0).getText() === "name" && 182 | node.getChildAt(2).getText() !== "'additionalFields'" 183 | ) { 184 | let hasDescription = false; 185 | let hasResourceParent = false; // skip check for resource options 186 | let isBooleanType = false; 187 | 188 | node.parent.forEachChild((node) => { 189 | if (node.getText() === "type: 'boolean'") { 190 | isBooleanType = true; 191 | } 192 | 193 | node.parent.parent.parent.parent.forEachChild((child) => { 194 | if (child.getText() === "name: 'resource'") { 195 | hasResourceParent = true; 196 | } 197 | }); 198 | 199 | if ( 200 | ts.isPropertyAssignment(node) && 201 | node.getChildAt(0).getText() === "description" 202 | ) { 203 | hasDescription = true; 204 | 205 | if ( 206 | isBooleanType && 207 | !node.getChildAt(2).getText().startsWith("'Whether") 208 | ) { 209 | this.log(LINTINGS.BOOLEAN_DESCRIPTION_NOT_STARTING_WITH_WHETHER)( 210 | node 211 | ); 212 | } 213 | } 214 | }); 215 | 216 | if ( 217 | !hasDescription && 218 | (hasResourceParent || hasCredentialsParent || hasDefaultsParent) 219 | ) { 220 | this.log(LINTINGS.PARAM_DESCRIPTION_MISSING_WHERE_OPTIONAL)(node); 221 | } 222 | 223 | if ( 224 | !hasDescription && 225 | !hasResourceParent && 226 | !hasCredentialsParent && 227 | !hasDefaultsParent && 228 | !isNameInArrowFunction && 229 | !isNameWithVariableDeclarationParent 230 | ) { 231 | let isFixedCollection = false; 232 | node.parent.parent.parent.parent.forEachChild((child) => { 233 | // skip required param description for middle container of fixed collection 234 | if ( 235 | child?.getChildAt(0)?.getText() === "type" && 236 | child?.getChildAt(2)?.getText() === "'fixedCollection'" 237 | ) { 238 | isFixedCollection = true; 239 | } 240 | }); 241 | 242 | !isFixedCollection && 243 | this.log(LINTINGS.PARAM_DESCRIPTION_MISSING_WHERE_REQUIRED)(node); 244 | } 245 | } 246 | 247 | return this.logs; 248 | } 249 | } 250 | -------------------------------------------------------------------------------- /src/services/subvalidators/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./DefaultValidator"; 2 | export * from "./ParamDescriptionValidator"; 3 | export * from "./DisplayNameValidator"; 4 | export * from "./LimitValidator"; 5 | export * from "./NameValidator"; 6 | export * from "./NodeDescriptionValidator"; 7 | export * from "./OptionsValidator"; 8 | export * from "./MiscellaneousValidator"; 9 | -------------------------------------------------------------------------------- /src/tests/exceptions.test.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { LINTINGS } from "../lintings"; 3 | import { Validator } from "../services"; 4 | import { exceptionMockFilePath, transpile } from "./helpers/testHelpers"; 5 | 6 | describe("Exceptions should disable lintings", () => { 7 | it("Single-linting exception for next line", () => { 8 | // PARAM_DESCRIPTION_UNTRIMMED 9 | const sourceFilePath = exceptionMockFilePath("single.ts"); 10 | const validator = new Validator(sourceFilePath); 11 | 12 | transpile(validator, sourceFilePath); 13 | 14 | const found = validator.logs.find( 15 | (log) => log.message === LINTINGS.PARAM_DESCRIPTION_UNTRIMMED.message 16 | ); 17 | 18 | expect(found).toBeUndefined(); 19 | }); 20 | 21 | it("Multiple-lintings exception for next line", () => { 22 | // PARAM_DESCRIPTION_UNTRIMMED 23 | // PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE 24 | 25 | const sourceFilePath = exceptionMockFilePath("multiple.ts"); 26 | const validator = new Validator(sourceFilePath); 27 | 28 | transpile(validator, sourceFilePath); 29 | 30 | const foundUntrimmed = validator.logs.find( 31 | (log) => log.message === LINTINGS.PARAM_DESCRIPTION_UNTRIMMED.message 32 | ); 33 | 34 | const foundExcess = validator.logs.find( 35 | (log) => 36 | log.message === 37 | LINTINGS.PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE.message 38 | ); 39 | 40 | expect(foundUntrimmed).toBeUndefined(); 41 | expect(foundExcess).toBeUndefined(); 42 | }); 43 | 44 | it("All-lintings exception for next line", () => { 45 | const sourceFilePath = exceptionMockFilePath("all.ts"); 46 | const validator = new Validator(sourceFilePath); 47 | 48 | transpile(validator, sourceFilePath); 49 | 50 | const found = validator.logs.find((log) => log.line === 11); 51 | 52 | expect(found).toBeUndefined(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/tests/helpers/testHelpers.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import { Traverser, Validator } from "../../services"; 5 | import { LINTINGS } from "../../lintings"; 6 | import { defaultConfig } from "../../defaultConfig"; 7 | import { masterConfig } from "../.."; 8 | import { ConfigManager } from "../../services/ConfigManager"; 9 | 10 | export const transpile = (validator: Validator, sourceFilePath: string) => { 11 | const source = fs.readFileSync(sourceFilePath, "utf8"); 12 | 13 | ts.transpileModule(source.toString(), { 14 | transformers: { before: [Traverser.traverse(validator)] }, 15 | }); 16 | }; 17 | 18 | export const runTest = (validator: Validator) => (linting: Linting) => { 19 | test(linting.message, () => { 20 | const found = validator.logs.find((log) => log.message === linting.message); 21 | 22 | if (ConfigManager.lintIssueIsDisabled(linting.lintIssue, defaultConfig)) 23 | return; 24 | 25 | if (ConfigManager.lintingIsDisabled(linting, defaultConfig)) return; 26 | 27 | expect(found).toBeDefined(); 28 | }); 29 | }; 30 | 31 | export const validatorMockFilePath = (fileName: string) => 32 | path.join("src", "tests", "mocks", "validators", fileName); 33 | 34 | export const exceptionMockFilePath = (fileName: string) => 35 | path.join("src", "tests", "mocks", "exceptions", fileName); 36 | 37 | const groupByLintArea = (list: Linting[]) => 38 | list.reduce<{ [key: string]: Linting[] }>((acc, linting) => { 39 | linting.lintAreas.forEach((lintArea) => { 40 | const accLintArea = acc[lintArea] ?? []; 41 | accLintArea.push(linting); 42 | acc[lintArea] = accLintArea; 43 | }); 44 | 45 | return acc; 46 | }, {}); 47 | 48 | export const lintingsByGroup = groupByLintArea(Object.values(LINTINGS)); 49 | 50 | /** 51 | * Separate one linting from others based on a test. 52 | * 53 | * Only _one_ linting is expected to pass. 54 | */ 55 | const partition = 56 | (test: (linting: Linting) => boolean) => 57 | (array: Linting[]): [Linting, Linting[]] => { 58 | const pass: Linting[] = []; 59 | const fail: Linting[] = []; 60 | array.forEach((item) => (test(item) ? pass : fail).push(item)); 61 | 62 | return [pass[0], fail]; 63 | }; 64 | 65 | export const separateContinueOnFail = partition( 66 | (linting: Linting) => 67 | linting.message === masterConfig.lintings.MISSING_CONTINUE_ON_FAIL.message 68 | ); 69 | 70 | export const separateCheckCredTestFunctions = partition( 71 | (linting: Linting) => 72 | linting.message === masterConfig.lintings.MISMATCHED_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE.message 73 | ); -------------------------------------------------------------------------------- /src/tests/mocks/exceptions/all.ts: -------------------------------------------------------------------------------- 1 | export const webinarOperations3 = [ 2 | { 3 | displayName: 'Timezone', 4 | name: 'timeZone', 5 | type: 'string', 6 | typeOptions: { 7 | loadOptionsMethod: 'getTimezones', 8 | }, 9 | default: '', 10 | // nodelinter-ignore-next-line 11 | description: 'Time zone used in the response. The default is the time zone of the calendar. ', 12 | }, 13 | ]; 14 | -------------------------------------------------------------------------------- /src/tests/mocks/exceptions/multiple.ts: -------------------------------------------------------------------------------- 1 | export const webinarOperations2 = [ 2 | { 3 | displayName: 'Timezone', 4 | name: 'timeZone', 5 | type: 'string', 6 | typeOptions: { 7 | loadOptionsMethod: 'getTimezones', 8 | }, 9 | default: '', 10 | // nodelinter-ignore-next-line PARAM_DESCRIPTION_UNTRIMMED PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE 11 | description: 'Time zone used in the response. The default is the time zone of the calendar. ', 12 | }, 13 | ]; 14 | -------------------------------------------------------------------------------- /src/tests/mocks/exceptions/single.ts: -------------------------------------------------------------------------------- 1 | export const webinarOperations1 = [ 2 | { 3 | displayName: 'Timezone', 4 | name: 'timeZone', 5 | type: 'string', 6 | typeOptions: { 7 | loadOptionsMethod: 'getTimezones', 8 | }, 9 | default: '', 10 | // nodelinter-ignore-next-line PARAM_DESCRIPTION_UNTRIMMED 11 | description: 'Time zone used in the response. The default is the time zone of the calendar. ', 12 | }, 13 | ]; -------------------------------------------------------------------------------- /src/tests/mocks/validators/default.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // WRONG_DEFAULT_FOR_STRING_TYPE_PARAM 3 | { 4 | displayName: 'User ID', 5 | name: 'userId', 6 | type: 'string', 7 | default: 3, 8 | }, 9 | 10 | // WRONG_DEFAULT_FOR_NUMBER_TYPE_PARAM 11 | { 12 | displayName: 'User ID', 13 | name: 'userId', 14 | type: 'number', 15 | default: '', 16 | }, 17 | 18 | // WRONG_DEFAULT_FOR_BOOLEAN_TYPE_PARAM 19 | { 20 | displayName: 'User ID', 21 | name: 'userId', 22 | type: 'boolean', 23 | default: '', 24 | }, 25 | 26 | // WRONG_DEFAULT_FOR_COLLECTION_TYPE_PARAM 27 | { 28 | displayName: 'User ID', 29 | name: 'userId', 30 | type: 'collection', 31 | placeholder: 'Add Field', 32 | default: 'b', 33 | options: [ 34 | { 35 | displayName: 'A', 36 | name: 'a', 37 | type: 'string', 38 | default: '', 39 | description: 'Some description.', 40 | }, 41 | ], 42 | }, 43 | 44 | // WRONG_DEFAULT_FOR_MULTIOPTIONS_TYPE_PARAM 45 | { 46 | displayName: 'User ID', 47 | name: 'userId', 48 | type: 'multiOptions', 49 | placeholder: 'Add Field', 50 | default: '', 51 | options: [ 52 | { 53 | displayName: 'A', 54 | name: 'a', 55 | type: 'string', 56 | default: '', 57 | description: 'Some description.', 58 | }, 59 | ], 60 | }, 61 | 62 | // WRONG_DEFAULT_FOR_OPTIONS_TYPE_PARAM 63 | { 64 | displayName: 'Non-Option Default', 65 | name: 'nonOptionDefault', 66 | type: 'options', 67 | options: [ 68 | { 69 | name: 'a', 70 | value: 'a', 71 | description: 'Some description.', 72 | }, 73 | { 74 | name: 'b', 75 | value: 'b', 76 | description: 'Some description.', 77 | }, 78 | ], 79 | default: 'c', 80 | }, 81 | 82 | // DEFAULT_MISSING 83 | { 84 | displayName: 'User ID', 85 | name: 'userId', 86 | type: 'string', 87 | description: 'Some description.', 88 | }, 89 | 90 | // WRONG_DEFAULT_FOR_LIMIT_PARAM 91 | { 92 | displayName: 'Limit', 93 | name: 'limit', 94 | type: 'number', 95 | default: 5, 96 | description: 'The number of results to return.', 97 | typeOptions: { 98 | minValue: 1, 99 | maxValue: 1000, 100 | }, 101 | }, 102 | 103 | // WRONG_DEFAULT_FOR_SIMPLIFY_PARAM 104 | { 105 | displayName: 'Simplify Response', 106 | name: 'simple', 107 | type: 'boolean', 108 | displayOptions: { 109 | show: { 110 | operation: [ 111 | 'get', 112 | 'getAll', 113 | ], 114 | resource: [ 115 | 'contact', 116 | ], 117 | }, 118 | }, 119 | default: false, 120 | description: 'Return a simplified version of the response instead of the raw data.', 121 | }, 122 | ]; 123 | -------------------------------------------------------------------------------- /src/tests/mocks/validators/displayName.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // DISPLAYNAME_WITH_MISCASED_ID 3 | { 4 | displayName: 'User Id', 5 | name: 'userId', 6 | type: 'string', 7 | default: '', 8 | }, 9 | 10 | // DISPLAYNAME_WITH_NO_TITLECASE 11 | { 12 | displayName: 'user ID', 13 | name: 'userId', 14 | type: 'string', 15 | default: '', 16 | }, 17 | 18 | // PARAM_DESCRIPTION_IDENTICAL_TO_DISPLAY_NAME 19 | { 20 | displayName: 'User ID', 21 | name: 'userId', 22 | type: 'string', 23 | default: '', 24 | description: 'User ID' 25 | }, 26 | ]; 27 | 28 | // DISPLAYNAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION 29 | export class BoxTrigger implements INodeType { 30 | description: INodeTypeDescription = { 31 | displayName: 'Box Traigger', // misspelled 32 | name: 'boxTraigger', // misspelled 33 | icon: 'file:box.svg', 34 | group: ['trigger'], 35 | version: 1, 36 | subtitle: 'Whatever', 37 | description: 'Starts the workflow when a Box events occurs', 38 | defaults: { 39 | name: 'Box Trigger', 40 | color: '#00aeef', 41 | }, 42 | inputs: [], 43 | outputs: ['main'], 44 | } 45 | } 46 | 47 | // FIXED_COLLECTION_VALUE_DISPLAY_NAME_WITH_NO_TITLECASE 48 | export const hello = [ 49 | { 50 | displayName: 'Template Data', 51 | name: 'templateDataUi', 52 | type: 'fixedCollection', 53 | placeholder: 'Add Data', 54 | typeOptions: { 55 | multipleValues: true, 56 | }, 57 | default: {}, 58 | options: [ 59 | { 60 | displayName: 'Data', 61 | name: 'templateDataValues', 62 | values: [ 63 | { 64 | displayName: 'alice', 65 | name: 'alice', 66 | type: 'string', 67 | default: '', 68 | }, 69 | { 70 | displayName: 'Bob', 71 | name: 'bob', 72 | type: 'string', 73 | default: '', 74 | }, 75 | ], 76 | }, 77 | ], 78 | } 79 | ]; 80 | 81 | // DISPLAYNAME_UNTRIMMED 82 | const whoa = [ 83 | { 84 | displayName: ' Additional Fields', 85 | name: 'additionalFieldsJson', 86 | type: 'json', 87 | typeOptions: { 88 | alwaysOpenEditWindow: true, 89 | }, 90 | default: '', 91 | displayOptions: { 92 | show: { 93 | resource: [ 94 | 'company', 95 | ], 96 | operation: [ 97 | 'create', 98 | ], 99 | jsonParameters: [ 100 | true, 101 | ], 102 | }, 103 | }, 104 | description: 'Object of values to set as described here.', 105 | }, 106 | ] 107 | 108 | // NON_STANDARD_DISPLAY_NAME_FOR_SIMPLIFY_PARAM 109 | export const a = [ 110 | { 111 | displayName: 'SimplifyResponse', 112 | name: 'simple', 113 | type: 'boolean', 114 | displayOptions: { 115 | show: { 116 | operation: [ 117 | 'get', 118 | 'getAll', 119 | ], 120 | resource: [ 121 | 'contact', 122 | ], 123 | }, 124 | }, 125 | default: true, 126 | description: 'Return a simplified version of the response instead of the raw data.', 127 | }, 128 | ]; 129 | 130 | const g = [ 131 | { 132 | displayName: 'Up1date Fields', 133 | name: 'updateFields', 134 | type: 'collection', 135 | placeholder: 'Add Field', 136 | default: {}, 137 | displayOptions: { 138 | show: { 139 | resource: [ 140 | 'person', 141 | ], 142 | operation: [ 143 | 'update', 144 | ], 145 | }, 146 | }, 147 | options: [ 148 | { 149 | name: 'Abc', 150 | value: 'abc', 151 | }, 152 | { 153 | name: 'Def', 154 | value: 'def', 155 | }, 156 | ], 157 | }, 158 | ] -------------------------------------------------------------------------------- /src/tests/mocks/validators/limit.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // LIMIT_WITHOUT_TYPE_OPTIONS 3 | { 4 | displayName: 'Limit', 5 | name: 'limit', 6 | type: 'number', 7 | XtypeOptions: { 8 | minValue: 1, 9 | maxValue: 100, 10 | }, 11 | default: 100, 12 | description: 'Number of results to return.', 13 | }, 14 | 15 | // LIMIT_WITH_MIN_VALUE_LOWER_THAN_ONE 16 | { 17 | displayName: 'Limit', 18 | name: 'limit', 19 | type: 'number', 20 | typeOptions: { 21 | minValue: 0, 22 | maxValue: 100, 23 | }, 24 | default: 100, 25 | description: 'Number of results to return.', 26 | }, 27 | 28 | // WRONG_DEFAULT_FOR_LIMIT_PARAM 29 | { 30 | displayName: 'Limit', 31 | name: 'limit', 32 | type: 'number', 33 | default: 5, 34 | description: 'This is wrong', 35 | typeOptions: { 36 | minValue: 1, 37 | maxValue: 1000, 38 | }, 39 | }, 40 | ]; 41 | 42 | // NON_STANDARD_LIMIT_DESCRIPTION 43 | export const accountContactOperations = [ 44 | { 45 | displayName: 'Limit', 46 | name: 'limit', 47 | type: 'number', 48 | displayOptions: { 49 | show: { 50 | operation: [ 51 | 'getAll', 52 | ], 53 | resource: [ 54 | 'project', 55 | ], 56 | returnAll: [ 57 | false, 58 | ], 59 | }, 60 | }, 61 | typeOptions: { 62 | minValue: 1, 63 | maxValue: 500, 64 | }, 65 | default: 100, 66 | description: 'Something is wrong here', 67 | }, 68 | ] -------------------------------------------------------------------------------- /src/tests/mocks/validators/miscellaneous.node.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Attention: Do _not_ use this file for testing miscellaneous lintings. 3 | * This file is only intended for the MISSING_CONTINUE_ON_FAIL linting 4 | */ 5 | 6 | // MISSING_CONTINUE_ON_FAIL 7 | // Separated from the main miscellaneous input file because the file name 8 | // is checked and the continueOnFail check runs after traversal of the entire AST 9 | 10 | export class ActionNetwork implements INodeType { 11 | 12 | async execute(this: IExecuteFunctions): Promise { 13 | const items = this.getInputData(); 14 | const returnData: IDataObject[] = []; 15 | 16 | const resource = this.getNodeParameter('resource', 0) as Resource; 17 | const operation = this.getNodeParameter('operation', 0) as Operation; 18 | 19 | let response; 20 | 21 | for (let i = 0; i < items.length; i++) { 22 | try { 23 | 24 | } catch (error) { 25 | // if (this.continueOnFail()) { 26 | // returnData.push({ error: error.message }); 27 | // continue; 28 | // } 29 | throw error; 30 | } 31 | } 32 | return [this.helpers.returnJsonArray(returnData)]; 33 | } 34 | } 35 | 36 | // NON_EXISTENT_LOAD_OPTIONS_METHOD 37 | 38 | export class Todoist implements INodeType { 39 | 40 | description: INodeTypeDescription = { 41 | displayName: 'Todoist', 42 | name: 'todoist', 43 | icon: 'file:todoist.svg', 44 | group: ['output'], 45 | version: 1, 46 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 47 | description: 'Consume Todoist API', 48 | defaults: { 49 | name: 'Todoist', 50 | color: '#c02428', 51 | }, 52 | inputs: ['main'], 53 | outputs: ['main'], 54 | credentials: [ 55 | { 56 | name: 'todoistApi', 57 | required: true, 58 | displayOptions: { 59 | show: { 60 | authentication: [ 61 | 'apiKey', 62 | ], 63 | }, 64 | }, 65 | }, 66 | { 67 | name: 'todoistOAuth2Api', 68 | required: true, 69 | displayOptions: { 70 | show: { 71 | authentication: [ 72 | 'oAuth2', 73 | ], 74 | }, 75 | }, 76 | }, 77 | ], 78 | properties: [ 79 | { 80 | displayName: 'Authentication', 81 | name: 'authentication', 82 | type: 'options', 83 | options: [ 84 | { 85 | name: 'API Key', 86 | value: 'apiKey', 87 | }, 88 | { 89 | name: 'OAuth2', 90 | value: 'oAuth2', 91 | }, 92 | ], 93 | default: 'apiKey', 94 | description: 'The resource to operate on.', 95 | }, 96 | { 97 | displayName: 'Resource', 98 | name: 'resource', 99 | type: 'options', 100 | options: [ 101 | { 102 | name: 'Task', 103 | value: 'task', 104 | description: 'Task resource.', 105 | }, 106 | ], 107 | default: 'task', 108 | required: true, 109 | description: 'Resource to consume.', 110 | }, 111 | { 112 | displayName: 'Operation', 113 | name: 'operation', 114 | type: 'options', 115 | required: true, 116 | displayOptions: { 117 | show: { 118 | resource: [ 119 | 'task', 120 | ], 121 | }, 122 | }, 123 | options: [ 124 | { 125 | name: 'Create', 126 | value: 'create', 127 | description: 'Create a new task', 128 | }, 129 | { 130 | name: 'Close', 131 | value: 'close', 132 | description: 'Close a task', 133 | }, 134 | { 135 | name: 'Delete', 136 | value: 'delete', 137 | description: 'Delete a task', 138 | }, 139 | { 140 | name: 'Get', 141 | value: 'get', 142 | description: 'Get a task', 143 | }, 144 | { 145 | name: 'Get All', 146 | value: 'getAll', 147 | description: 'Get all tasks', 148 | }, 149 | { 150 | name: 'Reopen', 151 | value: 'reopen', 152 | description: 'Reopen a task', 153 | }, 154 | ], 155 | default: 'create', 156 | description: 'The operation to perform.', 157 | }, 158 | { 159 | displayName: 'Project', 160 | name: 'project', 161 | type: 'options', 162 | typeOptions: { 163 | loadOptionsMethod: 'whoa', 164 | }, 165 | displayOptions: { 166 | show: { 167 | resource: [ 168 | 'task', 169 | ], 170 | operation: [ 171 | 'create', 172 | ], 173 | }, 174 | }, 175 | default: '', 176 | description: 'The project you want to operate on.', 177 | }, 178 | ], 179 | }; 180 | 181 | methods = { 182 | loadOptions: { 183 | // Get all the available projects to display them to user so that he can 184 | // select them easily 185 | async getProjects(this: ILoadOptionsFunctions): Promise { 186 | const returnData: INodePropertyOptions[] = []; 187 | const projects = await todoistApiRequest.call(this, 'GET', '/projects'); 188 | for (const project of projects) { 189 | const projectName = project.name; 190 | const projectId = project.id; 191 | 192 | returnData.push({ 193 | name: projectName, 194 | value: projectId, 195 | }); 196 | } 197 | 198 | return returnData; 199 | }, 200 | 201 | // Get all the available sections in the selected project, to display them 202 | // to user so that he can select one easily 203 | async getSections(this: ILoadOptionsFunctions): Promise { 204 | const returnData: INodePropertyOptions[] = []; 205 | 206 | const projectId = this.getCurrentNodeParameter('project') as number; 207 | if (projectId) { 208 | const qs: IDataObject = { project_id: projectId }; 209 | const sections = await todoistApiRequest.call(this, 'GET', '/sections', {}, qs); 210 | for (const section of sections) { 211 | const sectionName = section.name; 212 | const sectionId = section.id; 213 | 214 | returnData.push({ 215 | name: sectionName, 216 | value: sectionId, 217 | }); 218 | } 219 | } 220 | 221 | return returnData; 222 | }, 223 | 224 | // Get all the available labels to display them to user so that he can 225 | // select them easily 226 | async getLabels(this: ILoadOptionsFunctions): Promise { 227 | const returnData: INodePropertyOptions[] = []; 228 | const labels = await todoistApiRequest.call(this, 'GET', '/labels'); 229 | 230 | for (const label of labels) { 231 | const labelName = label.name; 232 | const labelId = label.id; 233 | 234 | returnData.push({ 235 | name: labelName, 236 | value: labelId, 237 | }); 238 | } 239 | 240 | return returnData; 241 | }, 242 | }, 243 | }; 244 | 245 | async execute(this: IExecuteFunctions): Promise { 246 | const items = this.getInputData(); 247 | const returnData: IDataObject[] = []; 248 | const length = items.length as unknown as number; 249 | const qs: IDataObject = {}; 250 | let responseData; 251 | 252 | const resource = this.getNodeParameter('resource', 0) as string; 253 | const operation = this.getNodeParameter('operation', 0) as string; 254 | 255 | for (let i = 0; i < length; i++) { } 256 | 257 | return [this.helpers.returnJsonArray(returnData)]; 258 | } 259 | } 260 | 261 | 262 | -------------------------------------------------------------------------------- /src/tests/mocks/validators/miscellaneous.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // REQUIRED_FALSE 3 | { 4 | displayName: 'Required False', 5 | name: 'requiredFalse', 6 | required: false, 7 | type: 'string', 8 | default: '', 9 | description: 'This has a required false.' 10 | }, 11 | ] as INodeProperties[]; // Removing casing causes test to fail, for unknown reason. 12 | 13 | // NON_STANDARD_RETURNALL_DESCRIPTION 14 | export const accountContactOperations = [ 15 | { 16 | displayName: 'Return All', 17 | name: 'returnAll', 18 | type: 'boolean', 19 | displayOptions: { 20 | show: { 21 | operation: [ 22 | 'getAll', 23 | ], 24 | resource: [ 25 | 'project', 26 | ], 27 | }, 28 | }, 29 | default: false, 30 | description: 'Whether all results should be returned', 31 | }, 32 | ] 33 | 34 | // TS_IGNORE 35 | export const webinarOperations = [ 36 | { 37 | displayName: 'Timezone', 38 | name: 'timeZone', 39 | // @ts-ignore 40 | type: 'string', 41 | typeOptions: { 42 | loadOptionsMethod: 'getTimezones', 43 | }, 44 | default: '', 45 | description: 'Time zone used in the response. The default is the time zone of the calendar.', 46 | }, 47 | ]; 48 | 49 | // TODO 50 | export const abc = [ 51 | { 52 | displayName: 'Return All', 53 | name: 'returnAll', 54 | // TODO 55 | type: 'boolean', 56 | displayOptions: { 57 | show: { 58 | operation: [ 59 | 'getAll', 60 | ], 61 | resource: [ 62 | 'project', 63 | ], 64 | }, 65 | }, 66 | default: false, 67 | description: 'Whether all results should be returned', 68 | }, 69 | ] 70 | 71 | // WRONG_ERROR_THROWN 72 | throw Error(); 73 | 74 | // RESOURCE_WITHOUT_NO_DATA_EXPRESSION 75 | export class Splunk implements INodeType { 76 | description: INodeTypeDescription = { 77 | displayName: 'Splunk', 78 | name: 'splunk', 79 | icon: 'file:splunk.svg', 80 | group: ['transform'], 81 | version: 1, 82 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 83 | description: 'Consume the Splunk Enterprise API', 84 | defaults: { 85 | name: 'Splunk', 86 | color: '#e20082', 87 | }, 88 | inputs: ['main'], 89 | properties: [ 90 | { 91 | displayName: 'Resource', 92 | name: 'resource', 93 | type: 'options', 94 | options: [ 95 | { 96 | name: 'Fired Alert', 97 | value: 'firedAlert', 98 | }, 99 | { 100 | name: 'Search Configuration', 101 | value: 'searchConfiguration', 102 | }, 103 | { 104 | name: 'Search Job', 105 | value: 'searchJob', 106 | }, 107 | { 108 | name: 'Search Result', 109 | value: 'searchResult', 110 | }, 111 | { 112 | name: 'User', 113 | value: 'user', 114 | }, 115 | ], 116 | default: 'searchJob', 117 | }, 118 | ] 119 | } 120 | }; 121 | 122 | // OPERATION_WITHOUT_NO_DATA_EXPRESSION 123 | export const firedAlertOperations: INodeProperties[] = [ 124 | { 125 | displayName: 'Operation', 126 | name: 'operation', 127 | type: 'options', 128 | displayOptions: { 129 | show: { 130 | resource: [ 131 | 'firedAlert', 132 | ], 133 | }, 134 | }, 135 | options: [ 136 | { 137 | name: 'Get Report', 138 | value: 'getReport', 139 | description: 'Retrieve a fired alerts report', 140 | }, 141 | ], 142 | default: 'getReport', 143 | }, 144 | ]; 145 | 146 | // I_NODE_PROPERTIES_MISCASTING 147 | export const firedAlertOperations = [ 148 | { 149 | displayName: 'Operation', 150 | name: 'operation', 151 | type: 'options', 152 | noDataExpression: true, 153 | displayOptions: { 154 | show: { 155 | resource: [ 156 | 'firedAlert', 157 | ], 158 | }, 159 | }, 160 | options: [ 161 | { 162 | name: 'Get Report', 163 | value: 'getReport', 164 | description: 'Retrieve a fired alerts report', 165 | }, 166 | ], 167 | default: 'getReport', 168 | }, 169 | ] as INodeProperties[]; 170 | 171 | // COLOR_TYPE_NOT_USED_FOR_COLOR_PARAM 172 | const abc = { 173 | displayName: 'Foreground Colour', 174 | name: 'foregroundColor', 175 | type: 'coXlor', 176 | default: '#000000', 177 | displayOptions: { 178 | show: { 179 | resource: [ 180 | 'spaceTag', 181 | ], 182 | operation: [ 183 | 'create', 184 | 'update', 185 | ], 186 | }, 187 | }, 188 | required: true, 189 | }; 190 | 191 | // TOP_LEVEL_OPTIONAL_FIXED_COLLECTION 192 | export const objectOperations = [ 193 | { 194 | displayName: 'Operation', 195 | name: 'operation', 196 | type: 'options', 197 | default: 'get', 198 | description: 'Operation to perform', 199 | options: [ 200 | { 201 | name: 'Create', 202 | value: 'create', 203 | }, 204 | { 205 | name: 'Delete', 206 | value: 'delete', 207 | }, 208 | { 209 | name: 'Get', 210 | value: 'get', 211 | }, 212 | { 213 | name: 'Get All', 214 | value: 'getAll', 215 | }, 216 | { 217 | name: 'Update', 218 | value: 'update', 219 | }, 220 | ], 221 | displayOptions: { 222 | show: { 223 | resource: [ 224 | 'object', 225 | ], 226 | }, 227 | }, 228 | }, 229 | ] as INodeProperties[]; 230 | 231 | export const objectFields = [ 232 | // ---------------------------------- 233 | // object: create 234 | // ---------------------------------- 235 | { 236 | displayName: 'Type Name', 237 | name: 'typeName', 238 | type: 'string', 239 | required: true, 240 | description: 'Name of data type of the object to create.', 241 | default: '', 242 | displayOptions: { 243 | show: { 244 | resource: [ 245 | 'object', 246 | ], 247 | operation: [ 248 | 'create', 249 | ], 250 | }, 251 | }, 252 | }, 253 | { 254 | displayName: 'Properties', 255 | name: 'properties', 256 | placeholder: 'Add Property', 257 | type: 'fixedCollection', 258 | typeOptions: { 259 | multipleValues: true, 260 | }, 261 | default: {}, 262 | displayOptions: { 263 | show: { 264 | resource: [ 265 | 'object', 266 | ], 267 | operation: [ 268 | 'create', 269 | ], 270 | }, 271 | }, 272 | options: [ 273 | { 274 | displayName: 'Property', 275 | name: 'property', 276 | values: [ 277 | { 278 | displayName: 'Key', 279 | name: 'key', 280 | type: 'string', 281 | default: '', 282 | description: 'Field to set for the object to create.', 283 | }, 284 | { 285 | displayName: 'Value', 286 | name: 'value', 287 | type: 'string', 288 | default: '', 289 | description: 'Value to set for the object to create.', 290 | }, 291 | { 292 | displayName: 'Elements', 293 | name: 'elementsUi', 294 | placeholder: 'Add Element', 295 | type: 'fixedCollection', 296 | typeOptions: { 297 | multipleValues: true, 298 | }, 299 | displayOptions: { 300 | show: { 301 | type: [ 302 | 'actions', 303 | ], 304 | }, 305 | }, 306 | default: {}, 307 | options: [ 308 | { 309 | name: 'elementsValues', 310 | displayName: 'Element', 311 | values: [ 312 | { 313 | displayName: 'Type', 314 | name: 'type', 315 | type: 'options', 316 | options: [ 317 | { 318 | name: 'Button', 319 | value: 'button', 320 | }, 321 | ], 322 | default: 'button', 323 | description: 'The type of element', 324 | }, 325 | { 326 | displayName: 'Text', 327 | name: 'text', 328 | type: 'string', 329 | displayOptions: { 330 | show: { 331 | type: [ 332 | 'button', 333 | ], 334 | }, 335 | }, 336 | default: '', 337 | description: 'The text for the block.', 338 | }, 339 | { 340 | displayName: 'Emoji', 341 | name: 'emoji', 342 | type: 'boolean', 343 | displayOptions: { 344 | show: { 345 | type: [ 346 | 'button', 347 | ], 348 | }, 349 | }, 350 | default: false, 351 | description: 'Indicates whether emojis in a text field should be escaped into the colon emoji format.', 352 | }, 353 | ], 354 | }, 355 | ], 356 | }, 357 | ], 358 | }, 359 | ], 360 | }, 361 | ] as INodeProperties[]; 362 | -------------------------------------------------------------------------------- /src/tests/mocks/validators/name.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // NAME_WITH_MISCASED_ID 3 | { 4 | displayName: 'User ID', 5 | name: 'user ID', 6 | type: 'string', 7 | default: '', 8 | }, 9 | 10 | // NAME_WITH_NO_CAMELCASE 11 | { 12 | displayName: 'User ID', 13 | name: 'User Id', 14 | type: 'string', 15 | default: '', 16 | }, 17 | ]; 18 | 19 | // AUTHENTICATION_PARAM_NOT_IN_CREDENTIALS 20 | export class Harvest implements INodeType { 21 | description: INodeTypeDescription = { 22 | displayName: 'Harvest', 23 | name: 'harvest', 24 | icon: 'file:harvest.png', 25 | group: ['input'], 26 | version: 1, 27 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 28 | description: 'Access data on Harvest', 29 | defaults: { 30 | name: 'Harvest', 31 | color: '#e7863f', 32 | }, 33 | inputs: ['main'], 34 | outputs: ['main'], 35 | properties: [ 36 | { 37 | displayName: 'Authentication', 38 | name: 'authentication', 39 | type: 'options', 40 | options: [ 41 | { 42 | name: 'Access Token', 43 | value: 'accessToken', 44 | }, 45 | { 46 | name: 'OAuth2', 47 | value: 'oAuth2', 48 | }, 49 | ], 50 | default: 'accessToken', 51 | description: 'Method of authentication.', 52 | }, 53 | ] 54 | } 55 | } 56 | 57 | // NAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION 58 | export class BoxTrigger implements INodeType { 59 | description: INodeTypeDescription = { 60 | displayName: 'Box Traigger', // misspelled 61 | name: 'boxTraigger', // misspelled 62 | icon: 'file:box.svg', 63 | group: ['trigger'], 64 | version: 1, 65 | subtitle: 'Whatever', 66 | description: 'Starts the workflow when a Box events occurs', 67 | defaults: { 68 | name: 'Box Trigger', 69 | color: '#00aeef', 70 | }, 71 | inputs: [], 72 | outputs: ['main'], 73 | } 74 | } 75 | 76 | // NON_SUFFIXED_CREDENTIALS_NAME 77 | export class Stripe implements INodeType { 78 | description: INodeTypeDescription = { 79 | displayName: 'Stripe', 80 | name: 'stripe', 81 | icon: 'file:stripe.svg', 82 | group: ['transform'], 83 | version: 1, 84 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 85 | description: 'Consume the Stripe API', 86 | defaults: { 87 | name: 'Stripe', 88 | color: '#6772e5', 89 | }, 90 | inputs: ['main'], 91 | outputs: ['main'], 92 | credentials: [ 93 | { 94 | name: 'stripeA2pi', 95 | required: true, 96 | }, 97 | ], 98 | } 99 | } 100 | 101 | // NAME_USING_STAR_INSTEAD_OF_ALL 102 | const abc = { 103 | displayName: 'Events', 104 | name: 'events', 105 | type: 'multiOptions', 106 | required: true, 107 | default: [], 108 | options: [ 109 | { 110 | name: '*', 111 | value: '*', 112 | }, 113 | { 114 | name: 'folder.created', 115 | value: 'folderCreated', 116 | }, 117 | { 118 | name: 'folder.deleted', 119 | value: 'folderDeleted', 120 | }, 121 | ], 122 | }; -------------------------------------------------------------------------------- /src/tests/mocks/validators/nodeDescription.node.ts: -------------------------------------------------------------------------------- 1 | // MISMATCHED_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE 2 | export class ElasticSecurity implements INodeType { 3 | description: INodeTypeDescription = { 4 | displayName: 'Elastic Security', 5 | name: 'elasticSecurity', 6 | icon: 'file:elasticSecurity.svg', 7 | group: ['transform'], 8 | version: 1, 9 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 10 | description: 'Consume the Elastic Security API', 11 | defaults: { 12 | name: 'Elastic Security', 13 | color: '#f3d337', 14 | }, 15 | inputs: ['main'], 16 | outputs: ['main'], 17 | credentials: [ 18 | { 19 | name: 'elasticSecurityApi', 20 | required: true, 21 | testedBy: 'elasticSecurityApiTest', 22 | }, 23 | ], 24 | }; 25 | 26 | methods = { 27 | credentialTest: { 28 | async XXXelasticSecurityApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { 29 | // ... 30 | }, 31 | }, 32 | }; 33 | } 34 | -------------------------------------------------------------------------------- /src/tests/mocks/validators/nodeDescription.ts: -------------------------------------------------------------------------------- 1 | // PNG_ICON_IN_NODE_DESCRIPTION 2 | // SUBTITLE_MISSING_IN_NODE_DESCRIPTION 3 | export class QuickBooks implements INodeType { 4 | description: INodeTypeDescription = { 5 | displayName: 'QuickBooks', 6 | name: 'quickbooks', 7 | icon: 'file:quickbooks.png', 8 | group: ['transform'], 9 | version: 1, 10 | Xsubtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 11 | description: 'Consume the QuickBooks API', 12 | defaults: { 13 | name: 'QuickBooks', 14 | color: '#2CA01C', 15 | }, 16 | inputs: ['main'], 17 | outputs: ['main'], 18 | credentials: [ 19 | { 20 | name: 'quickBooksOAuth2Api', 21 | required: true, 22 | }, 23 | ], 24 | properties: [ 25 | { 26 | displayName: 'Resource', 27 | name: 'resource', 28 | type: 'options', 29 | options: [ 30 | { 31 | name: 'Bill', 32 | value: 'bill', 33 | }, 34 | { 35 | name: 'Customer', 36 | value: 'customer', 37 | }, 38 | ], 39 | default: 'customer', 40 | description: 'Resource to consume', 41 | }, 42 | ], 43 | }; 44 | } 45 | 46 | // DISPLAYNAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION 47 | // NAME_NOT_ENDING_WITH_TRIGGER_IN_NODE_DESCRIPTION 48 | export class BoxTrigger implements INodeType { 49 | description: INodeTypeDescription = { 50 | displayName: 'Box Traigger', // misspelled 51 | name: 'boxTraigger', // misspelled 52 | icon: 'file:box.svg', 53 | group: ['trigger'], 54 | version: 1, 55 | subtitle: 'Whatever', 56 | description: 'Starts the workflow when a Box events occurs', 57 | defaults: { 58 | name: 'Box Trigger', 59 | color: '#00aeef', 60 | }, 61 | inputs: [], 62 | outputs: ['main'], 63 | } 64 | } 65 | 66 | // NON_STANDARD_SUBTITLE 67 | export class Drift implements INodeType { 68 | description: INodeTypeDescription = { 69 | displayName: 'Drift', 70 | name: 'drift', 71 | icon: 'file:drift.png', 72 | group: ['output'], 73 | version: 1, 74 | subtitle: '={{$parameter["operation"]', 75 | description: 'Consume Drift API', 76 | defaults: { 77 | name: 'Drift ', 78 | color: '#404040', 79 | }, 80 | inputs: ['main'], 81 | outputs: ['main'], 82 | }; 83 | } 84 | 85 | // WRONG_NUMBER_OF_INPUTS_IN_REGULAR_NODE_DESCRIPTION 86 | export class Misp1 implements INodeType { 87 | description: INodeTypeDescription = { 88 | displayName: 'MISP', 89 | name: 'misp', 90 | icon: 'file:misp.svg', 91 | group: ['transform'], 92 | version: 1, 93 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 94 | description: 'Consume the MISP API', 95 | defaults: { 96 | name: 'MISP', 97 | color: '#ffffff', 98 | }, 99 | inputs: [], 100 | outputs: ['main'], 101 | credentials: [ 102 | { 103 | name: 'mispApi', 104 | required: true, 105 | }, 106 | ], 107 | } 108 | } 109 | 110 | // WRONG_NUMBER_OF_INPUTS_IN_TRIGGER_NODE_DESCRIPTION 111 | export class MispTrigger implements INodeType { 112 | description: INodeTypeDescription = { 113 | displayName: 'MISP', 114 | name: 'misp', 115 | icon: 'file:misp.svg', 116 | group: ['transform'], 117 | version: 1, 118 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 119 | description: 'Consume the MISP API', 120 | defaults: { 121 | name: 'MISP', 122 | color: '#ffffff', 123 | }, 124 | inputs: ['main'], 125 | outputs: ['main'], 126 | credentials: [ 127 | { 128 | name: 'mispApi', 129 | required: true, 130 | }, 131 | ], 132 | } 133 | } 134 | 135 | // WRONG_NUMBER_OF_OUTPUTS_IN_NODE_DESCRIPTION 136 | export class Misp2 implements INodeType { 137 | description: INodeTypeDescription = { 138 | displayName: 'MISP', 139 | name: 'misp', 140 | icon: 'file:misp.svg', 141 | group: ['transform'], 142 | version: 1, 143 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 144 | description: 'Consume the MISP API', 145 | defaults: { 146 | name: 'MISP', 147 | color: '#ffffff', 148 | }, 149 | inputs: ['main'], 150 | outputs: [], 151 | credentials: [ 152 | { 153 | name: 'mispApi', 154 | required: true, 155 | }, 156 | ], 157 | } 158 | } 159 | 160 | export class ElasticSecurity implements INodeType { 161 | description: INodeTypeDescription = { 162 | displayName: 'Elastic Security', 163 | name: 'elasticSecurity', 164 | icon: 'file:elasticSecurity.svg', 165 | group: ['transform'], 166 | version: 1, 167 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 168 | description: 'Consume the Elastic Security API', 169 | defaults: { 170 | name: 'Elastic Security', 171 | color: '#f3d337', 172 | }, 173 | inputs: ['main'], 174 | outputs: ['main'], 175 | credentials: [ 176 | { 177 | name: 'elasticSecurityApi', 178 | required: true, 179 | // testedBy: 'elasticSecurityApiTest', // should trigger lint 180 | }, 181 | ], 182 | }; 183 | 184 | methods = { 185 | loadOptions: { 186 | async getTags(this: ILoadOptionsFunctions): Promise { 187 | const tags = await elasticSecurityApiRequest.call(this, 'GET', '/cases/tags') as string[]; 188 | return tags.map(tag => ({ name: tag, value: tag })); 189 | }, 190 | 191 | async getConnectors(this: ILoadOptionsFunctions): Promise { 192 | const endpoint = '/cases/configure/connectors/_find'; 193 | const connectors = await elasticSecurityApiRequest.call(this, 'GET', endpoint) as Connector[]; 194 | return connectors.map(({ name, id }) => ({ name, value: id })); 195 | }, 196 | }, 197 | credentialTest: { 198 | async elasticSecurityApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { 199 | // ... 200 | }, 201 | }, 202 | }; 203 | } 204 | 205 | // MISSING_NONOAUTH_CREDENTIALS_TEST_METHOD_REFERENCE 206 | export class ElasticSecurity implements INodeType { 207 | description: INodeTypeDescription = { 208 | displayName: 'Elastic Security', 209 | name: 'elasticSecurity', 210 | icon: 'file:elasticSecurity.svg', 211 | group: ['transform'], 212 | version: 1, 213 | subtitle: '={{$parameter["operation"] + ": " + $parameter["resource"]}}', 214 | description: 'Consume the Elastic Security API', 215 | defaults: { 216 | name: 'Elastic Security', 217 | color: '#f3d337', 218 | }, 219 | inputs: ['main'], 220 | outputs: ['main'], 221 | credentials: [ 222 | { 223 | name: 'elasticSecurityApi', 224 | required: true, 225 | testedBy: 'elasticSecurityApiTest', 226 | }, 227 | ], 228 | }; 229 | 230 | methods = { 231 | loadOptions: { 232 | async getTags(this: ILoadOptionsFunctions): Promise { 233 | const tags = await elasticSecurityApiRequest.call(this, 'GET', '/cases/tags') as string[]; 234 | return tags.map(tag => ({ name: tag, value: tag })); 235 | }, 236 | 237 | async getConnectors(this: ILoadOptionsFunctions): Promise { 238 | const endpoint = '/cases/configure/connectors/_find'; 239 | const connectors = await elasticSecurityApiRequest.call(this, 'GET', endpoint) as Connector[]; 240 | return connectors.map(({ name, id }) => ({ name, value: id })); 241 | }, 242 | }, 243 | credentialTest: { 244 | async XXXelasticSecurityApiTest(this: ICredentialTestFunctions, credential: ICredentialsDecrypted): Promise { 245 | // ... 246 | }, 247 | }, 248 | }; 249 | } 250 | -------------------------------------------------------------------------------- /src/tests/mocks/validators/options.ts: -------------------------------------------------------------------------------- 1 | export const properties = [ 2 | // NON_ALPHABETIZED_OPTIONS_IN_OPTIONS_TYPE_PARAM 3 | { 4 | displayName: 'Choices', 5 | name: 'choices', 6 | type: 'options', 7 | options: [ 8 | { 9 | name: 'b', 10 | value: 'b', 11 | }, 12 | { 13 | name: 'a', 14 | value: 'a', 15 | }, 16 | { 17 | name: 'ccc', 18 | value: 'ccc', 19 | }, 20 | { 21 | name: 'd', 22 | value: 'd', 23 | }, 24 | { 25 | name: 'e', 26 | value: 'e', 27 | }, 28 | { 29 | name: 'f', 30 | value: 'f', 31 | }, 32 | ], 33 | default: 'public', 34 | }, 35 | 36 | // NON_ALPHABETIZED_OPTIONS_IN_MULTIOPTIONS_TYPE_PARAM 37 | { 38 | displayName: 'Include', 39 | name: 'include', 40 | type: 'multiOptions', 41 | options: [ 42 | { 43 | name: 'b', 44 | value: 'b', 45 | }, 46 | { 47 | name: 'a', 48 | value: 'a', 49 | }, 50 | { 51 | name: 'ccc', 52 | value: 'ccc', 53 | }, 54 | { 55 | name: 'd', 56 | value: 'd', 57 | }, 58 | { 59 | name: 'e', 60 | value: 'e', 61 | }, 62 | { 63 | name: 'f', 64 | value: 'f', 65 | }, 66 | ], 67 | default: [], 68 | description: 'You may specify relations to include with your response.', 69 | }, 70 | ]; 71 | 72 | 73 | // NON_STANDARD_NAME_FOR_UPSERT_OPTION 74 | // NON_STANDARD_DESCRIPTION_FOR_UPSERT_OPTION 75 | export const contactOperations = [ 76 | { 77 | displayName: 'Operation', 78 | name: 'operation', 79 | type: 'options', 80 | displayOptions: { 81 | show: { 82 | resource: [ 83 | 'contact', 84 | ], 85 | }, 86 | }, 87 | options: [ 88 | { 89 | name: 'Create/Update', 90 | value: 'upsert', 91 | description: 'Create/Update a contact', 92 | }, 93 | { 94 | name: 'Delete', 95 | value: 'delete', 96 | description: 'Delete a contact', 97 | }, 98 | { 99 | name: 'Get', 100 | value: 'get', 101 | description: 'Get a contact', 102 | }, 103 | { 104 | name: 'Get All', 105 | value: 'getAll', 106 | description: 'Get all contacts', 107 | }, 108 | ], 109 | default: 'upsert', 110 | description: 'The operation to perform.', 111 | }, 112 | ] as INodeProperties[]; 113 | 114 | // NON_ALPHABETIZED_VALUES_IN_FIXED_COLLECTION_TYPE_PARAM 115 | export const otherProperties = [ 116 | { 117 | displayName: 'Template Data', 118 | name: 'templateDataUi', 119 | type: 'fixedCollection', 120 | placeholder: 'Add Data', 121 | typeOptions: { 122 | multipleValues: true, 123 | }, 124 | default: {}, 125 | options: [ 126 | { 127 | displayName: 'Data', 128 | name: 'templateDataValues', 129 | values: [ 130 | { 131 | displayName: 'b', 132 | name: 'b', 133 | type: 'string', 134 | default: '', 135 | }, 136 | { 137 | displayName: 'a', 138 | name: 'a', 139 | type: 'string', 140 | default: '', 141 | }, 142 | { 143 | displayName: 'c', 144 | name: 'c', 145 | type: 'string', 146 | default: '', 147 | }, 148 | { 149 | displayName: 'd', 150 | name: 'd', 151 | type: 'string', 152 | default: '', 153 | }, 154 | { 155 | displayName: 'e', 156 | name: 'e', 157 | type: 'string', 158 | default: '', 159 | }, 160 | { 161 | displayName: 'f', 162 | name: 'f', 163 | type: 'string', 164 | default: '', 165 | }, 166 | ], 167 | }, 168 | ], 169 | } 170 | ]; 171 | 172 | // NON_ALPHABETIZED_OPTIONS_IN_COLLECTION_TYPE_PARAM 173 | export const yetOtherProperties = [ 174 | { 175 | displayName: 'Additional Fields', 176 | name: 'additionalFields', 177 | type: 'collection', 178 | placeholder: 'Add Field', 179 | default: {}, 180 | options: [ 181 | { 182 | displayName: 'b', 183 | name: 'b', 184 | type: 'string', 185 | typeOptions: { 186 | multipleValues: true, 187 | multipleValueButtonText: 'Add Bcc Email', 188 | }, 189 | description: 'Bcc Recipients of the email.', 190 | default: [], 191 | }, 192 | { 193 | displayName: 'a', 194 | name: 'a', 195 | type: 'string', 196 | typeOptions: { 197 | multipleValues: true, 198 | multipleValueButtonText: 'Add Cc Email', 199 | }, 200 | description: 'Cc recipients of the email.', 201 | default: [], 202 | }, 203 | { 204 | displayName: 'c', 205 | name: 'c', 206 | type: 'string', 207 | typeOptions: { 208 | multipleValues: true, 209 | multipleValueButtonText: 'Add Cc Email', 210 | }, 211 | description: 'Cc recipients of the email.', 212 | default: [], 213 | }, 214 | { 215 | displayName: 'd', 216 | name: 'd', 217 | type: 'string', 218 | typeOptions: { 219 | multipleValues: true, 220 | multipleValueButtonText: 'Add Cc Email', 221 | }, 222 | description: 'Cc recipients of the email.', 223 | default: [], 224 | }, 225 | { 226 | displayName: 'e', 227 | name: 'e', 228 | type: 'string', 229 | typeOptions: { 230 | multipleValues: true, 231 | multipleValueButtonText: 'Add Cc Email', 232 | }, 233 | description: 'Cc recipients of the email.', 234 | default: [], 235 | }, 236 | { 237 | displayName: 'f', 238 | name: 'f', 239 | type: 'string', 240 | typeOptions: { 241 | multipleValues: true, 242 | multipleValueButtonText: 'Add Cc Email', 243 | }, 244 | description: 'Cc recipients of the email.', 245 | default: [], 246 | }, 247 | ], 248 | } 249 | ]; 250 | 251 | // FIXED_COLLECTION_VALUE_DISPLAY_NAME_WITH_NO_TITLECASE 252 | export const hello = [ 253 | { 254 | displayName: 'Template Data', 255 | name: 'templateDataUi', 256 | type: 'fixedCollection', 257 | placeholder: 'Add Data', 258 | typeOptions: { 259 | multipleValues: true, 260 | }, 261 | default: {}, 262 | options: [ 263 | { 264 | displayName: 'Data', 265 | name: 'templateDataValues', 266 | values: [ 267 | { 268 | displayName: 'alice', 269 | name: 'alice', 270 | type: 'string', 271 | default: '', 272 | }, 273 | { 274 | displayName: 'Bob', 275 | name: 'bob', 276 | type: 'string', 277 | default: '', 278 | }, 279 | ], 280 | }, 281 | ], 282 | } 283 | ]; 284 | 285 | // OPTIONS_NAME_WITH_NO_TITLECASE 286 | // OPTIONS_VALUE_WITH_NO_CAMELCASE 287 | export const hello2 = [ 288 | { 289 | displayName: 'Encoding', 290 | name: 'encoding', 291 | type: 'options', 292 | options: [ 293 | { 294 | name: 'alice', 295 | value: 'Alice', 296 | }, 297 | { 298 | name: 'bob', 299 | value: 'Bob', 300 | }, 301 | ], 302 | default: 'Bob', 303 | required: true, 304 | }, 305 | ]; -------------------------------------------------------------------------------- /src/tests/mocks/validators/paramDescription.ts: -------------------------------------------------------------------------------- 1 | [ 2 | // PARAM_DESCRIPTION_MISSING_WHERE_REQUIRED 3 | { 4 | displayName: 'User ID', 5 | name: 'userId', 6 | type: 'string', 7 | default: '', 8 | }, 9 | 10 | // PARAM_DESCRIPTION_WITH_UNCAPITALIZED_INITIAL 11 | { 12 | displayName: 'User ID', 13 | name: 'userId', 14 | type: 'string', 15 | default: '', 16 | description: 'description without initial capital.', 17 | }, 18 | 19 | // PARAM_DESCRIPTION_UNTRIMMED 20 | { 21 | displayName: 'User ID', 22 | name: 'userId', 23 | type: 'string', 24 | default: '', 25 | description: ' This is untrimmed' 26 | }, 27 | 28 | // PARAM_DESCRIPTION_WITH_MISCASED_ID 29 | { 30 | displayName: 'User ID', 31 | name: 'userId', 32 | type: 'string', 33 | default: '', 34 | description: 'This is a miscased id right here' 35 | }, 36 | 37 | // PARAM_DESCRIPTION_WITH_UNNEEDED_BACKTICKS 38 | { 39 | displayName: 'User ID', 40 | name: 'userId', 41 | type: 'string', 42 | default: '', 43 | description: `These backticks are unnecessary` 44 | }, 45 | 46 | // PARAM_DESCRIPTION_WITH_EXCESS_FINAL_PERIOD 47 | { 48 | displayName: 'User ID', 49 | name: 'userId', 50 | type: 'string', 51 | default: '', 52 | description: 'Sentence.', 53 | }, 54 | 55 | // PARAM_DESCRIPTION_WITH_MISSING_FINAL_PERIOD 56 | { 57 | displayName: 'User ID', 58 | name: 'userId', 59 | type: 'string', 60 | default: '', 61 | description: 'Sentence. Another sentence', 62 | }, 63 | 64 | // PARAM_DESCRIPTION_MISSING_WHERE_OPTIONAL 65 | { 66 | displayName: 'Resource', 67 | name: 'resource', 68 | type: 'options', 69 | options: [ 70 | { 71 | name: 'Bill', 72 | value: 'bill', 73 | }, 74 | { 75 | name: 'Customer', 76 | value: 'customer', 77 | }, 78 | { 79 | name: 'Employee', 80 | value: 'employee', 81 | }, 82 | ], 83 | default: 'customer', 84 | description: 'Resource to consume', 85 | }, 86 | 87 | // PARAM_DESCRIPTION_AS_EMPTY_STRING 88 | { 89 | displayName: 'User ID', 90 | name: 'userId', 91 | type: 'string', 92 | default: '', 93 | description: '', 94 | }, 95 | 96 | // BOOLEAN_DESCRIPTION_NOT_STARTING_WITH_WHETHER 97 | { 98 | displayName: 'Is Admin', 99 | name: 'isAdmin', 100 | type: 'boolean', 101 | default: true, 102 | description: 'This property determines if a user is an admin.', 103 | }, 104 | 105 | // PARAM_DESCRIPTION_IDENTICAL_TO_DISPLAY_NAME 106 | { 107 | displayName: 'User ID', 108 | name: 'userId', 109 | type: 'string', 110 | default: '', 111 | description: 'User ID' 112 | }, 113 | 114 | // TECHNICAL_TERM_IN_PARAM_DESCRIPTION 115 | { 116 | displayName: 'User ID', 117 | name: 'userId', 118 | type: 'string', 119 | default: '', 120 | description: 'A description containing the term string' 121 | }, 122 | 123 | // PARAM_DESCRIPTION_WITH_BRITISH_SUFFIX 124 | { 125 | displayName: 'User ID', 126 | name: 'userId', 127 | type: 'string', 128 | default: '', 129 | description: 'This is the colour of the object' 130 | }, 131 | ]; 132 | 133 | // PARAM_DESCRIPTION_WITH_MISSING_PROTOCOL_LINK 134 | const whoa = [ 135 | { 136 | displayName: ' Additional Fields', 137 | name: 'additionalFieldsJson', 138 | type: 'json', 139 | typeOptions: { 140 | alwaysOpenEditWindow: true, 141 | }, 142 | default: '', 143 | displayOptions: { 144 | show: { 145 | resource: [ 146 | 'company', 147 | ], 148 | operation: [ 149 | 'create', 150 | ], 151 | jsonParameters: [ 152 | true, 153 | ], 154 | }, 155 | }, 156 | description: 'Object of values to set as described here.', 157 | }, 158 | ] 159 | 160 | // WEAK_PARAM_DESCRIPTION 161 | export class OpenWeatherMap implements INodeType { 162 | description: INodeTypeDescription = { 163 | displayName: 'OpenWeatherMap', 164 | name: 'openWeatherMap', 165 | icon: 'fa:sun', 166 | group: ['input'], 167 | version: 1, 168 | description: 'Gets current and future weather information.', 169 | defaults: { 170 | name: 'OpenWeatherMap', 171 | color: '#554455', 172 | }, 173 | inputs: ['main'], 174 | outputs: ['main'], 175 | properties: [ 176 | { 177 | displayName: 'Operation', 178 | name: 'operation', 179 | type: 'options', 180 | options: [ 181 | { 182 | name: 'Current Weather', 183 | value: 'currentWeather', 184 | description: 'Returns the current weather data', 185 | }, 186 | { 187 | name: '5 day Forecast', 188 | value: '5DayForecast', 189 | description: 'Returns the weather data for the next 5 days', 190 | }, 191 | ], 192 | default: 'currentWeather', 193 | description: 'The operation to perform.', 194 | }, 195 | ] 196 | } 197 | } 198 | 199 | // NON_STANDARD_HTML_LINE_BREAK 200 | export const accountContactOperations = [ 201 | { 202 | displayName: 'Binary Property', 203 | name: 'binaryPropertyName', 204 | type: 'string', 205 | default: 'data', 206 | required: true, 207 | placeholder: '', 208 | description: 'Name of the binary property which contains
the data for the file(s) to be compress/decompress. Multiple can be used separated by ,', 209 | }, 210 | ] 211 | 212 | // NON_STANDARD_DESCRIPTION_FOR_SIMPLIFY_PARAM 213 | export const a = [ 214 | { 215 | displayName: 'SimplifyResponse', 216 | name: 'simple', 217 | type: 'boolean', 218 | displayOptions: { 219 | show: { 220 | operation: [ 221 | 'get', 222 | 'getAll', 223 | ], 224 | resource: [ 225 | 'contact', 226 | ], 227 | }, 228 | }, 229 | default: true, 230 | description: 'Return a simplified version of the response instead of the raw data.', 231 | }, 232 | ]; 233 | 234 | // PARAM_DESCRIPTION_WITH_EXCESS_WHITESPACE 235 | export const x = [ 236 | { 237 | displayName: 'Timezone', 238 | name: 'timeZone', 239 | type: 'string', 240 | typeOptions: { 241 | loadOptionsMethod: 'getTimezones', 242 | }, 243 | default: '', 244 | description: 'Time zone used in the response. The default is the time zone of the calendar.', 245 | }, 246 | ]; -------------------------------------------------------------------------------- /src/tests/validator.default.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate default values", () => { 12 | const lintArea = "default"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/validator.displayName.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate displayName values", () => { 12 | const lintArea = "displayName"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/validator.limit.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate limit values", () => { 12 | const lintArea = "limit"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/validator.miscellaneous.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Traverser, Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | separateContinueOnFail, 8 | transpile, 9 | } from "./helpers/testHelpers"; 10 | import { lintingsByGroup } from "./helpers/testHelpers"; 11 | 12 | describe("Validator should validate miscellaneous rules", () => { 13 | const lintArea = "miscellaneous"; 14 | 15 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 16 | 17 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 18 | const sourceFilePathNodeTs = validatorMockFilePath("miscellaneous.node.ts"); 19 | 20 | const validator = new Validator(sourceFilePath); 21 | const validatorNodeTs = new Validator(sourceFilePathNodeTs); 22 | 23 | transpile(validator, sourceFilePath); 24 | Traverser.sourceFilePath = sourceFilePathNodeTs; // required for Validator.runFinal() 25 | transpile(validatorNodeTs, sourceFilePathNodeTs); 26 | 27 | const [continueOnFail, others] = separateContinueOnFail( 28 | lintingsByGroup[lintArea] 29 | ); 30 | 31 | runTest(validatorNodeTs)(continueOnFail); 32 | others.forEach(runTest(validator)); 33 | }); 34 | -------------------------------------------------------------------------------- /src/tests/validator.name.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate name values", () => { 12 | const lintArea = "name"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/validator.nodeDescription.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Traverser, Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | separateCheckCredTestFunctions 9 | } from "./helpers/testHelpers"; 10 | import { lintingsByGroup } from "./helpers/testHelpers"; 11 | 12 | describe("Validator should validate node description", () => { 13 | const lintArea = "nodeDescription"; 14 | 15 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 16 | 17 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 18 | const sourceFilePathNodeTs = validatorMockFilePath("nodeDescription.node.ts"); 19 | const validator = new Validator(sourceFilePath); 20 | const validatorNodeTs = new Validator(sourceFilePathNodeTs); 21 | 22 | transpile(validator, sourceFilePath); 23 | Traverser.sourceFilePath = sourceFilePathNodeTs; // required for Validator.runFinal() 24 | transpile(validatorNodeTs, sourceFilePathNodeTs); 25 | 26 | const [checkCredTestFunctions, others] = separateCheckCredTestFunctions( 27 | lintingsByGroup[lintArea] 28 | ); 29 | 30 | runTest(validatorNodeTs)(checkCredTestFunctions); 31 | others.forEach(runTest(validator)); 32 | }); 33 | -------------------------------------------------------------------------------- /src/tests/validator.options.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate options", () => { 12 | const lintArea = "options"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/tests/validator.paramDescription.test.ts: -------------------------------------------------------------------------------- 1 | import { defaultConfig } from "../defaultConfig"; 2 | import { Validator } from "../services"; 3 | import { ConfigManager } from "../services/ConfigManager"; 4 | import { 5 | validatorMockFilePath, 6 | runTest, 7 | transpile, 8 | } from "./helpers/testHelpers"; 9 | import { lintingsByGroup } from "./helpers/testHelpers"; 10 | 11 | describe("Validator should validate param description values", () => { 12 | const lintArea = "paramDescription"; 13 | 14 | if (ConfigManager.lintAreaIsDisabled(lintArea, defaultConfig)) return; 15 | 16 | const sourceFilePath = validatorMockFilePath(`${lintArea}.ts`); 17 | const validator = new Validator(sourceFilePath); 18 | 19 | transpile(validator, sourceFilePath); 20 | 21 | lintingsByGroup[lintArea].forEach(runTest(validator)); 22 | }); 23 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | // ---------------------------------- 2 | // config 3 | // ---------------------------------- 4 | 5 | type Config = { 6 | target: string; 7 | patterns: LintableFilePattern[]; 8 | sortMethod: "lineNumber" | "importance"; 9 | showDetails: boolean; 10 | extractDescriptions: boolean; 11 | logLevelColors: { 12 | [key in LogLevel]: string; // hex color 13 | }; 14 | lineWrapChars: number; 15 | truncateExcerpts: { 16 | enabled: boolean; 17 | charLimit: number; 18 | }; 19 | enable: { 20 | logLevels: { [key in LogLevel]: boolean }; 21 | lintAreas: { [key in LintArea]: boolean }; 22 | lintIssues: { [key in LintIssue]: boolean }; 23 | }; 24 | lintings: { 25 | [LintingName: string]: Linting; 26 | }; 27 | }; 28 | 29 | type LintableFilePattern = ".node.ts" | "Description.ts"; 30 | 31 | // ---------------------------------- 32 | // description extraction 33 | // ---------------------------------- 34 | 35 | type ExtractedDescription = { 36 | description: string; 37 | line: number; 38 | sourceFilePath: string; 39 | }; 40 | 41 | // ---------------------------------- 42 | // lint 43 | // ---------------------------------- 44 | 45 | type Linting = { 46 | lintAreas: LintArea[]; 47 | lintIssue: LintIssue; 48 | message: string; 49 | enabled: boolean; 50 | logLevel: LogLevel; 51 | details?: string; 52 | }; 53 | 54 | type ParameterType = 55 | | "string" 56 | | "number" 57 | | "boolean" 58 | | "collection" 59 | | "multiOptions" 60 | | "options"; 61 | 62 | type LintArea = 63 | | "default" 64 | | "displayName" 65 | | "limit" 66 | | "miscellaneous" 67 | | "name" 68 | | "nodeDescription" 69 | | "options" 70 | | "paramDescription"; 71 | 72 | type LintIssue = 73 | | "casing" 74 | | "alphabetization" 75 | | "missing" 76 | | "wrong" 77 | | "unneeded" 78 | | "icon" 79 | | "punctuation" 80 | | "whitespace" 81 | | "wording" 82 | | "naming" 83 | | "location"; 84 | 85 | type Exception = { 86 | line: number; // one line before affected line 87 | lintingsToExcept: string[]; 88 | exceptionType: "nextLine"; // TODO: Add more 89 | }; 90 | 91 | type Comment = { 92 | text: string; 93 | line: number; 94 | pos: number; 95 | end: number; 96 | }; 97 | 98 | type CommentBasedItem = { 99 | line: number; 100 | text: string; 101 | }; 102 | 103 | type TsIgnoreComment = CommentBasedItem; 104 | 105 | type ToDoComment = CommentBasedItem; 106 | 107 | // ---------------------------------- 108 | // log 109 | // ---------------------------------- 110 | 111 | type Log = Omit & { 112 | line: number; 113 | excerpt: string; 114 | sourceFilePath: string; 115 | }; 116 | 117 | type LogLevel = "info" | "warning" | "error"; 118 | 119 | type LogFunction = (linting: Linting) => (node: ts.Node) => void; 120 | 121 | type LogSummary = { 122 | errors: number; 123 | warnings: number; 124 | infos: number; 125 | total: number; 126 | executionTimeMs: number; 127 | }; 128 | 129 | // ---------------------------------- 130 | // validation 131 | // ---------------------------------- 132 | 133 | interface SubValidator { 134 | lintArea?: LintArea; // TODO: Cannot type as static, so optional for now 135 | logs: Log[]; 136 | log: LogFunction; 137 | run: (node: ts.Node) => Log[] | undefined; 138 | } 139 | 140 | interface SubValidatorConstructor { 141 | new (): SubValidator; 142 | } 143 | 144 | // ---------------------------------- 145 | // CLI args 146 | // ---------------------------------- 147 | 148 | type CliArgs = { 149 | _?: []; 150 | target?: string; 151 | config?: string; 152 | print?: boolean; 153 | patterns?: string; 154 | } & MultiWordArgs; 155 | 156 | type MultiWordArgs = { 157 | "errors-only"?: boolean; 158 | "warnings-only"?: boolean; 159 | "infos-only"?: boolean; 160 | "extract-descriptions"?: boolean; 161 | }; 162 | 163 | type AdjustPatternArg = { 164 | from: string; 165 | to: LintableFilePattern; 166 | }; 167 | 168 | // ---------------------------------- 169 | // utils 170 | // ---------------------------------- 171 | 172 | /** 173 | * Extend ObjectConstructor with a stricter type definition for `Object.keys()` 174 | */ 175 | interface ObjectConstructor { 176 | keys(object: T): ObjectKeys; 177 | } 178 | 179 | type ObjectKeys = T extends object 180 | ? (keyof T)[] 181 | : T extends number 182 | ? [] 183 | : T extends Array | string 184 | ? string[] 185 | : never; 186 | 187 | type Constructor = new (...args: any[]) => {}; 188 | 189 | interface String { 190 | unquote(): string; 191 | clean(): string; 192 | } 193 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import ts from "typescript"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import chalk from "chalk"; 5 | import { titleCase } from "title-case"; 6 | import { LINTABLE_FILE_PATTERNS, LONG_LISTING_LIMIT } from "./constants"; 7 | 8 | // casing 9 | 10 | export const areAlphabetized = (items: string[]) => 11 | items.join() === items.sort().join(); 12 | 13 | export const areLongListing = (items: string[]) => 14 | items.length >= LONG_LISTING_LIMIT; 15 | 16 | export const startsWithCapital = (str: string) => 17 | str[0] === str[0].toUpperCase(); 18 | 19 | export const isTitleCase = (str: string) => str === titleCase(str); 20 | 21 | export const isCamelCase = (str: string) => 22 | /^([a-z]+[A-Z0-9]*)*$/.test(str) || /^[0-9]*$/.test(str); 23 | 24 | // selector helpers 25 | 26 | export const hasAnchorLink = (str: string) => / /href="https:\/\//.test(str); 29 | 30 | export const hasTargetBlank = (str: string) => /target="_blank"/.test(str); 31 | 32 | /** 33 | * Traverse a dir recursively and collect file paths that pass a test. 34 | */ 35 | export const collect = ( 36 | dir: string, 37 | test: (arg: string) => boolean, 38 | collection: string[] = [] 39 | ): string[] => { 40 | fs.readdirSync(dir).forEach((i) => { 41 | const iPath = path.join(dir, i); 42 | 43 | if (fs.lstatSync(iPath).isDirectory()) { 44 | collect(iPath, test, collection); 45 | } 46 | 47 | if (test(i)) collection.push(iPath); 48 | }); 49 | 50 | return collection; 51 | }; 52 | 53 | // TODO: stderr instead of stdout 54 | export function terminate(error: { title: string; message: string }): never { 55 | console.log( 56 | [ 57 | chalk.red.inverse("error".padStart(7, " ").padEnd(9, " ").toUpperCase()), 58 | `${chalk.bold(error.title + ":")}`, 59 | error.message, 60 | "\n", 61 | ].join(" ") 62 | ); 63 | 64 | process.exit(0); 65 | } 66 | 67 | export const isLintable = (target: string) => 68 | LINTABLE_FILE_PATTERNS.some((pattern) => target.endsWith(pattern)); 69 | 70 | // disabled state 71 | 72 | // TODO: Refactor 73 | export const getLintingName = (targetLinting: Linting, config: Config) => { 74 | return Object.entries(config.lintings).find( 75 | (configLinting) => targetLinting.message === configLinting[1].message 76 | )![0]; 77 | }; 78 | 79 | // TODO: Inefficient retrieval of linting name from linting message 80 | export const getLinting = ( 81 | targetLinting: Linting | Log, 82 | configLintings: Config["lintings"] 83 | ) => { 84 | return Object.values(configLintings).find((configLinting) => { 85 | return configLinting.message === targetLinting.message; 86 | }); 87 | }; 88 | 89 | export const isRegularNode = (nodeName: string | undefined) => 90 | nodeName?.endsWith(".node.ts") && !nodeName?.endsWith("Trigger.node.ts"); 91 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es5", 5 | "module": "commonjs", 6 | "outDir": "./dist", 7 | "lib": ["es2019"], 8 | "strictPropertyInitialization": false, 9 | "esModuleInterop": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "skipLibCheck": true, 12 | "downlevelIteration": true, 13 | "resolveJsonModule": true, 14 | "declaration": true 15 | }, 16 | 17 | "exclude": ["src/input/*", "src/tests/mocks/*", "./dist/**/*"] 18 | } 19 | --------------------------------------------------------------------------------