├── .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 |
--------------------------------------------------------------------------------