@ohlori/gherkin-lint 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +13 -0
- package/README.md +299 -0
- package/package.json +157 -0
- package/src/config-parser.js +38 -0
- package/src/config-verifier.js +54 -0
- package/src/feature-finder.js +74 -0
- package/src/formatters/json.js +9 -0
- package/src/formatters/stylish.js +81 -0
- package/src/formatters/xunit.js +36 -0
- package/src/linter.js +140 -0
- package/src/logger.js +14 -0
- package/src/main.js +59 -0
- package/src/rules/allowed-tags.js +55 -0
- package/src/rules/file-name.js +40 -0
- package/src/rules/indentation.js +106 -0
- package/src/rules/keywords-in-logical-order.js +65 -0
- package/src/rules/max-scenarios-per-file.js +46 -0
- package/src/rules/name-length.js +57 -0
- package/src/rules/new-line-at-eof.js +40 -0
- package/src/rules/no-background-only-scenario.js +33 -0
- package/src/rules/no-dupe-feature-names.js +27 -0
- package/src/rules/no-dupe-scenario-names.js +60 -0
- package/src/rules/no-duplicate-tags.js +38 -0
- package/src/rules/no-empty-background.js +31 -0
- package/src/rules/no-empty-file.js +19 -0
- package/src/rules/no-examples-in-scenarios.js +28 -0
- package/src/rules/no-files-without-scenarios.js +29 -0
- package/src/rules/no-homogenous-tags.js +60 -0
- package/src/rules/no-multiple-empty-lines.js +18 -0
- package/src/rules/no-partially-commented-tag-lines.js +39 -0
- package/src/rules/no-restricted-patterns.js +125 -0
- package/src/rules/no-restricted-tags.js +58 -0
- package/src/rules/no-scenario-outlines-without-examples.js +30 -0
- package/src/rules/no-superfluous-tags.js +44 -0
- package/src/rules/no-trailing-spaces.js +22 -0
- package/src/rules/no-unnamed-features.js +20 -0
- package/src/rules/no-unnamed-scenarios.js +23 -0
- package/src/rules/no-unused-variables.js +108 -0
- package/src/rules/one-space-between-tags.js +41 -0
- package/src/rules/only-one-when.js +68 -0
- package/src/rules/required-tags.js +56 -0
- package/src/rules/scenario-size.js +46 -0
- package/src/rules/use-and.js +41 -0
- package/src/rules/utils/gherkin.js +91 -0
- package/src/rules.js +60 -0
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/*eslint no-console: "off"*/
|
|
2
|
+
require('core-js/stable/string');
|
|
3
|
+
|
|
4
|
+
const style = {
|
|
5
|
+
gray: function(text) {
|
|
6
|
+
return '\x1b[38;5;243m' + text + '\x1b[0m';
|
|
7
|
+
},
|
|
8
|
+
|
|
9
|
+
underline: function(text) {
|
|
10
|
+
return '\x1b[0;4m' + text + '\x1b[24m';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
function stylizeFilePath(filePath) {
|
|
16
|
+
return style.underline(filePath);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function stylizeError(error, maxLineLength, maxMessageLength, addColors) {
|
|
20
|
+
const indent = ' '; // indent 2 spaces so it looks pretty
|
|
21
|
+
const padding = ' '; //padding of 4 spaces, will be used between line numbers, error msgs and rule names, for readability
|
|
22
|
+
const errorLinePadded = error.line.toString().padEnd(maxLineLength);
|
|
23
|
+
const errorLineStylized = addColors ? style.gray(errorLinePadded) : errorLinePadded;
|
|
24
|
+
|
|
25
|
+
const errorRuleStylized = addColors ? style.gray(error.rule) : error.rule;
|
|
26
|
+
return indent + errorLineStylized + padding + error.message.padEnd(maxMessageLength) + padding + errorRuleStylized;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getMaxLineLength(result) {
|
|
30
|
+
let length = 0;
|
|
31
|
+
result.errors.forEach(error => {
|
|
32
|
+
const errorStr = error.line.toString();
|
|
33
|
+
if (errorStr.length > length) {
|
|
34
|
+
length = errorStr.length;
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getMaxMessageLength(result, maxLineLength, consoleWidth) {
|
|
41
|
+
let length = 0;
|
|
42
|
+
result.errors.forEach(error => {
|
|
43
|
+
const errorStr = error.message.toString();
|
|
44
|
+
|
|
45
|
+
// Get the length of the formatted error message when no extra padding is applied
|
|
46
|
+
// If the formatted message is longer than the console width, we will ignore its length
|
|
47
|
+
const expandedErrorStrLength = stylizeError(error, maxLineLength, 0, false).length;
|
|
48
|
+
|
|
49
|
+
if (errorStr.length > length && expandedErrorStrLength < consoleWidth) {
|
|
50
|
+
length = errorStr.length;
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function printResults(results) {
|
|
58
|
+
// If the console is tty, get its width and use it to ensure we don't try to write messages longer
|
|
59
|
+
// than the console width when possible
|
|
60
|
+
let consoleWidth = Infinity;
|
|
61
|
+
if (process.stdout.isTTY) {
|
|
62
|
+
consoleWidth = process.stdout.columns;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
results.forEach(result => {
|
|
66
|
+
if (result.errors.length > 0) {
|
|
67
|
+
const maxLineLength = getMaxLineLength(result);
|
|
68
|
+
const maxMessageLength = getMaxMessageLength(result, maxLineLength, consoleWidth);
|
|
69
|
+
console.error(stylizeFilePath(result.filePath));
|
|
70
|
+
|
|
71
|
+
result.errors.forEach(error => {
|
|
72
|
+
console.error(stylizeError(error, maxLineLength, maxMessageLength, true));
|
|
73
|
+
});
|
|
74
|
+
console.error('\n');
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
module.exports = {
|
|
80
|
+
printResults: printResults
|
|
81
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
function printResults(results) {
|
|
2
|
+
const testCases = results.map(result => ({
|
|
3
|
+
_attributes: {
|
|
4
|
+
name : result.filePath
|
|
5
|
+
},
|
|
6
|
+
error: result.errors.map(error => ({
|
|
7
|
+
_attributes: {
|
|
8
|
+
message: error.message,
|
|
9
|
+
type: 'gherkin-lint-error'
|
|
10
|
+
},
|
|
11
|
+
_cdata: `${result.filePath}:${error.line} (${error.rule}) ${error.message}`
|
|
12
|
+
}))
|
|
13
|
+
}));
|
|
14
|
+
const testSuiteReport = {
|
|
15
|
+
_declaration:{
|
|
16
|
+
_attributes: {
|
|
17
|
+
version: '1.0',
|
|
18
|
+
encoding: 'utf-8'
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
testsuite : {
|
|
22
|
+
_attributes: {
|
|
23
|
+
name: 'gherkin-lint',
|
|
24
|
+
},
|
|
25
|
+
testcase : testCases
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
let convert = require('xml-js');
|
|
29
|
+
const xunitXml = convert.js2xml(testSuiteReport, {compact: true, spaces: 4});
|
|
30
|
+
/*eslint no-console: "off"*/
|
|
31
|
+
console.error(xunitXml);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
printResults: printResults
|
|
36
|
+
};
|
package/src/linter.js
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const {generateMessages} = require('@cucumber/gherkin');
|
|
3
|
+
const {IdGenerator, SourceMediaType} = require('@cucumber/messages');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const rules = require('./rules.js');
|
|
6
|
+
const logger = require('./logger.js');
|
|
7
|
+
|
|
8
|
+
function readAndParseFile(filePath) {
|
|
9
|
+
return new Promise((resolve, reject) => {
|
|
10
|
+
const source = fs.readFileSync(filePath, 'utf8');
|
|
11
|
+
const options = {
|
|
12
|
+
includeGherkinDocument: true,
|
|
13
|
+
includePickles: false,
|
|
14
|
+
includeSource: false,
|
|
15
|
+
newId: IdGenerator.incrementing(),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
const envelopes = generateMessages(
|
|
20
|
+
source,
|
|
21
|
+
filePath,
|
|
22
|
+
SourceMediaType.TEXT_X_CUCUMBER_GHERKIN_PLAIN,
|
|
23
|
+
options
|
|
24
|
+
);
|
|
25
|
+
const parsingErrors = envelopes
|
|
26
|
+
.filter(envelope => envelope.parseError)
|
|
27
|
+
.map(envelope => envelope.parseError);
|
|
28
|
+
|
|
29
|
+
if (parsingErrors.length) {
|
|
30
|
+
reject(processFatalErrors(parsingErrors));
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const documentEnvelope = envelopes.find(envelope => envelope.gherkinDocument);
|
|
35
|
+
const file = {
|
|
36
|
+
relativePath: filePath,
|
|
37
|
+
lines: source.split(/\r\n|\r|\n/),
|
|
38
|
+
};
|
|
39
|
+
resolve({
|
|
40
|
+
feature: documentEnvelope ? documentEnvelope.gherkinDocument.feature : undefined,
|
|
41
|
+
file,
|
|
42
|
+
});
|
|
43
|
+
} catch (error) {
|
|
44
|
+
logger.error(`Gherkin emitted an error while parsing ${filePath}: ${error}`);
|
|
45
|
+
reject(processFatalErrors([error]));
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
function lint(files, configuration, additionalRulesDirs) {
|
|
52
|
+
let results = [];
|
|
53
|
+
|
|
54
|
+
return Promise.all(files.map((f) => {
|
|
55
|
+
let perFileErrors = [];
|
|
56
|
+
|
|
57
|
+
return readAndParseFile(f)
|
|
58
|
+
.then(
|
|
59
|
+
// Handle Promise.resolve
|
|
60
|
+
({feature, file}) => {
|
|
61
|
+
perFileErrors = rules.runAllEnabledRules(feature, file, configuration, additionalRulesDirs);
|
|
62
|
+
},
|
|
63
|
+
// Handle Promise.reject
|
|
64
|
+
(parsingErrors) => {
|
|
65
|
+
perFileErrors = parsingErrors;
|
|
66
|
+
})
|
|
67
|
+
.finally(()=> {
|
|
68
|
+
let fileBlob = {
|
|
69
|
+
filePath: fs.realpathSync(f),
|
|
70
|
+
errors: _.sortBy(perFileErrors, 'line')
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
results.push(fileBlob);
|
|
74
|
+
});
|
|
75
|
+
})).then(() => results);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function processFatalErrors(errors) {
|
|
79
|
+
const firstError = errors[0];
|
|
80
|
+
const firstMessage = getErrorMessage(firstError);
|
|
81
|
+
|
|
82
|
+
// A tag before a Background causes cascading parser errors. Report the root
|
|
83
|
+
// cause once instead of emitting every recovery error that follows it.
|
|
84
|
+
if (firstMessage.includes('got \'Background') &&
|
|
85
|
+
firstMessage.includes('expected: #TagLine, #RuleLine')) {
|
|
86
|
+
return [{
|
|
87
|
+
message: 'Tags on Backgrounds are disallowed',
|
|
88
|
+
rule: 'no-tags-on-backgrounds',
|
|
89
|
+
line: getErrorLine(firstError)
|
|
90
|
+
}];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return errors.map(getFormattedFatalError);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
/*eslint no-console: "off"*/
|
|
98
|
+
function getFormattedFatalError(error) {
|
|
99
|
+
const errorData = getErrorMessage(error);
|
|
100
|
+
const errorLine = getErrorLine(error);
|
|
101
|
+
let errorMsg;
|
|
102
|
+
let rule;
|
|
103
|
+
if (errorData.includes('got \'Background')) {
|
|
104
|
+
errorMsg = 'Multiple "Background" definitions in the same Feature or Rule are disallowed';
|
|
105
|
+
rule = 'up-to-one-background-per-scope';
|
|
106
|
+
} else if(errorData.includes('got \'Feature')) {
|
|
107
|
+
errorMsg = 'Multiple "Feature" definitions in the same file are disallowed';
|
|
108
|
+
rule = 'one-feature-per-file';
|
|
109
|
+
} else if (
|
|
110
|
+
errorData.includes('expected: #EOF, #TableRow, #DocStringSeparator, #StepLine, #TagLine, #ScenarioLine, #RuleLine, #Comment, #Empty, got') ||
|
|
111
|
+
errorData.includes('expected: #EOF, #TableRow, #DocStringSeparator, #StepLine, #TagLine, #ExamplesLine, #ScenarioLine, #RuleLine, #Comment, #Empty, got')
|
|
112
|
+
) {
|
|
113
|
+
errorMsg = 'Steps should begin with "Given", "When", "Then", "And" or "But". Multiline steps are disallowed';
|
|
114
|
+
rule = 'no-multiline-steps';
|
|
115
|
+
|
|
116
|
+
} else {
|
|
117
|
+
errorMsg = errorData;
|
|
118
|
+
rule = 'unexpected-error';
|
|
119
|
+
}
|
|
120
|
+
return {message: errorMsg,
|
|
121
|
+
rule : rule,
|
|
122
|
+
line : errorLine};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function getErrorMessage(error) {
|
|
126
|
+
return error.message || error.data || String(error);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function getErrorLine(error) {
|
|
130
|
+
if (error.source && error.source.location) {
|
|
131
|
+
return error.source.location.line;
|
|
132
|
+
}
|
|
133
|
+
const match = getErrorMessage(error).match(/\((\d+):/);
|
|
134
|
+
return match ? Number(match[1]) : 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
module.exports = {
|
|
138
|
+
lint: lint,
|
|
139
|
+
readAndParseFile: readAndParseFile
|
|
140
|
+
};
|
package/src/logger.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
|
|
3
|
+
function boldError(msg) {
|
|
4
|
+
console.error(`\x1b[31m\x1b[1m${msg}\x1b[0m`);
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function error(msg) {
|
|
8
|
+
console.error(`\x1b[31m${msg}\x1b[0m`);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
error: error,
|
|
13
|
+
boldError: boldError
|
|
14
|
+
};
|
package/src/main.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const program = require('commander');
|
|
4
|
+
const linter = require('./linter.js');
|
|
5
|
+
const featureFinder = require('./feature-finder.js');
|
|
6
|
+
const configParser = require('./config-parser.js');
|
|
7
|
+
const logger = require('./logger.js');
|
|
8
|
+
|
|
9
|
+
function list(val) {
|
|
10
|
+
return val.split(',');
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function collect(val, memo) {
|
|
14
|
+
memo.push(val);
|
|
15
|
+
return memo;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
program
|
|
19
|
+
.usage('[options] <feature-files>')
|
|
20
|
+
.option('-f, --format [format]', 'output format. Possible values: json, stylish, xunit. Defaults to stylish')
|
|
21
|
+
.option('-i, --ignore <...>', 'comma seperated list of files/glob patterns that the linter should ignore, overrides ' + featureFinder.defaultIgnoreFileName + ' file', list)
|
|
22
|
+
.option('-c, --config [config]', 'configuration file, defaults to ' + configParser.defaultConfigFileName)
|
|
23
|
+
.option('-r, --rulesdir <...>', 'additional rule directories', collect, [])
|
|
24
|
+
.parse(process.argv);
|
|
25
|
+
|
|
26
|
+
const options = program.opts();
|
|
27
|
+
const additionalRulesDirs = options.rulesdir;
|
|
28
|
+
const files = featureFinder.getFeatureFiles(program.args, options.ignore);
|
|
29
|
+
const config = configParser.getConfiguration(options.config, additionalRulesDirs);
|
|
30
|
+
linter.lint(files, config, additionalRulesDirs)
|
|
31
|
+
.then((results) => {
|
|
32
|
+
printResults(results, options.format);
|
|
33
|
+
process.exit(getExitCode(results));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
function getExitCode(results) {
|
|
37
|
+
let exitCode = 0;
|
|
38
|
+
results.forEach(result => {
|
|
39
|
+
if (result.errors.length > 0) {
|
|
40
|
+
exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return exitCode;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function printResults(results, format) {
|
|
47
|
+
let formatter;
|
|
48
|
+
if (format === 'json') {
|
|
49
|
+
formatter = require('./formatters/json.js');
|
|
50
|
+
} else if (format === 'xunit') {
|
|
51
|
+
formatter = require('./formatters/xunit.js');
|
|
52
|
+
} else if (!format || format === 'stylish') {
|
|
53
|
+
formatter = require('./formatters/stylish.js');
|
|
54
|
+
} else {
|
|
55
|
+
logger.boldError('Unsupported format. The supported formats are json, xunit and stylish.');
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
formatter.printResults(results);
|
|
59
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const gherkinUtils = require('./utils/gherkin.js');
|
|
3
|
+
const rule = 'allowed-tags';
|
|
4
|
+
|
|
5
|
+
const availableConfigs = {
|
|
6
|
+
'tags': [],
|
|
7
|
+
'patterns': []
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
function run(feature, unused, configuration) {
|
|
11
|
+
if (!feature) {
|
|
12
|
+
return [];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let errors = [];
|
|
16
|
+
const allowedTags = configuration.tags;
|
|
17
|
+
const allowedPatterns = getAllowedPatterns(configuration);
|
|
18
|
+
|
|
19
|
+
gherkinUtils.getTaggableNodes(feature).forEach(node => {
|
|
20
|
+
checkTags(node, allowedTags, allowedPatterns, errors);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
return errors;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function getAllowedPatterns(configuration) {
|
|
27
|
+
return (configuration.patterns || []).map((pattern) => new RegExp(pattern));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function checkTags(node, allowedTags, allowedPatterns, errors) {
|
|
31
|
+
return (node.tags || [])
|
|
32
|
+
.filter(tag => !isAllowed(tag, allowedTags, allowedPatterns))
|
|
33
|
+
.forEach(tag => {
|
|
34
|
+
errors.push(createError(node, tag));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isAllowed(tag, allowedTags, allowedPatterns) {
|
|
39
|
+
return _.includes(allowedTags, tag.name)
|
|
40
|
+
|| allowedPatterns.some((pattern) => pattern.test(tag.name));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createError(node, tag) {
|
|
44
|
+
return {
|
|
45
|
+
message: 'Not allowed tag ' + tag.name + ' on ' + node.keyword,
|
|
46
|
+
rule : rule,
|
|
47
|
+
line : tag.location.line
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
module.exports = {
|
|
52
|
+
name: rule,
|
|
53
|
+
run: run,
|
|
54
|
+
availableConfigs: availableConfigs
|
|
55
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const _ = require('lodash');
|
|
3
|
+
|
|
4
|
+
const rule = 'file-name';
|
|
5
|
+
const availableConfigs = {
|
|
6
|
+
'style': 'PascalCase'
|
|
7
|
+
};
|
|
8
|
+
const checkers = {
|
|
9
|
+
'PascalCase': filename => _.startCase(filename).replace(/ /g, ''),
|
|
10
|
+
'Title Case': filename => _.startCase(filename),
|
|
11
|
+
'camelCase': filename => _.camelCase(filename),
|
|
12
|
+
'kebab-case': filename => _.kebabCase(filename),
|
|
13
|
+
'snake_case': filename => _.snakeCase(filename)
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function run(feature, file, configuration) {
|
|
17
|
+
if (!file) {
|
|
18
|
+
return [];
|
|
19
|
+
}
|
|
20
|
+
const {style} = _.merge(availableConfigs, configuration);
|
|
21
|
+
const filename = path.basename(file.relativePath, '.feature');
|
|
22
|
+
if (!checkers[style]) {
|
|
23
|
+
throw new Error('style "' + style + '" not supported for file-name rule');
|
|
24
|
+
}
|
|
25
|
+
const expected = checkers[style](filename);
|
|
26
|
+
if (filename === expected) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return [{
|
|
30
|
+
message: `File names should be written in ${style} e.g. "${expected}.feature"`,
|
|
31
|
+
rule: rule,
|
|
32
|
+
line: 0
|
|
33
|
+
}];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
module.exports = {
|
|
37
|
+
name: rule,
|
|
38
|
+
run: run,
|
|
39
|
+
availableConfigs: availableConfigs
|
|
40
|
+
};
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const gherkinUtils = require('./utils/gherkin.js');
|
|
3
|
+
|
|
4
|
+
const rule = 'indentation';
|
|
5
|
+
const defaultConfig = {
|
|
6
|
+
'Feature': 0,
|
|
7
|
+
'Background': 0,
|
|
8
|
+
'Rule': 0,
|
|
9
|
+
'Scenario': 0,
|
|
10
|
+
'Step': 2,
|
|
11
|
+
'Examples': 0,
|
|
12
|
+
'example': 2,
|
|
13
|
+
'given': 2,
|
|
14
|
+
'when': 2,
|
|
15
|
+
'then': 2,
|
|
16
|
+
'and': 2,
|
|
17
|
+
'but': 2
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const availableConfigs = _.merge({}, defaultConfig, {
|
|
21
|
+
// The values here are unused by the config parsing logic.
|
|
22
|
+
'feature tag': -1,
|
|
23
|
+
'scenario tag': -1
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
function mergeConfiguration(configuration) {
|
|
27
|
+
let mergedConfiguration = _.merge({}, defaultConfig, configuration);
|
|
28
|
+
if (!Object.prototype.hasOwnProperty.call(mergedConfiguration, 'feature tag')) {
|
|
29
|
+
mergedConfiguration['feature tag'] = mergedConfiguration['Feature'];
|
|
30
|
+
}
|
|
31
|
+
if (!Object.prototype.hasOwnProperty.call(mergedConfiguration, 'scenario tag')) {
|
|
32
|
+
mergedConfiguration['scenario tag'] = mergedConfiguration['Scenario'];
|
|
33
|
+
}
|
|
34
|
+
return mergedConfiguration;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function run(feature, unused, configuration) {
|
|
38
|
+
if (!feature) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let errors = [];
|
|
43
|
+
const mergedConfiguration = mergeConfiguration(configuration);
|
|
44
|
+
|
|
45
|
+
function test(parsedLocation, type) {
|
|
46
|
+
// location.column is 1 index based so, when we compare with the expected
|
|
47
|
+
// indentation we need to subtract 1
|
|
48
|
+
if (parsedLocation.column - 1 !== mergedConfiguration[type]) {
|
|
49
|
+
errors.push({
|
|
50
|
+
message: 'Wrong indentation for "' + type +
|
|
51
|
+
'", expected indentation level of ' + mergedConfiguration[type] +
|
|
52
|
+
', but got ' + (parsedLocation.column - 1),
|
|
53
|
+
rule : rule,
|
|
54
|
+
line : parsedLocation.line
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function testStep(step) {
|
|
60
|
+
let stepType = gherkinUtils.getLanguageInsitiveKeyword(step, feature.language);
|
|
61
|
+
stepType = stepType in configuration ? stepType : 'Step';
|
|
62
|
+
test(step.location, stepType);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function testTags(tags, type) {
|
|
66
|
+
_(tags).groupBy('location.line').forEach(tagLocationGroup => {
|
|
67
|
+
const firstTag = _(tagLocationGroup).sortBy('location.column').head();
|
|
68
|
+
test(firstTag.location, type);
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
test(feature.location, 'Feature');
|
|
73
|
+
testTags(feature.tags, 'feature tag');
|
|
74
|
+
|
|
75
|
+
feature.children.forEach(child => {
|
|
76
|
+
if (child.rule) {
|
|
77
|
+
test(child.rule.location, 'Rule');
|
|
78
|
+
} else if (child.background) {
|
|
79
|
+
test(child.background.location, 'Background');
|
|
80
|
+
child.background.steps.forEach(testStep);
|
|
81
|
+
} else {
|
|
82
|
+
test(child.scenario.location, 'Scenario');
|
|
83
|
+
testTags(child.scenario.tags, 'scenario tag');
|
|
84
|
+
child.scenario.steps.forEach(testStep);
|
|
85
|
+
|
|
86
|
+
child.scenario.examples.forEach(examples => {
|
|
87
|
+
test(examples.location, 'Examples');
|
|
88
|
+
|
|
89
|
+
if (examples.tableHeader) {
|
|
90
|
+
test(examples.tableHeader.location, 'example');
|
|
91
|
+
examples.tableBody.forEach(row => {
|
|
92
|
+
test(row.location, 'example');
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
return errors;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
module.exports = {
|
|
103
|
+
name: rule,
|
|
104
|
+
run: run,
|
|
105
|
+
availableConfigs: availableConfigs
|
|
106
|
+
};
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
const gherkinUtils = require('./utils/gherkin.js');
|
|
2
|
+
|
|
3
|
+
const rule = 'keywords-in-logical-order';
|
|
4
|
+
|
|
5
|
+
function run(feature) {
|
|
6
|
+
if (!feature) {
|
|
7
|
+
return [];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
let errors = [];
|
|
11
|
+
|
|
12
|
+
let simpleStepContainers = feature.children
|
|
13
|
+
.filter(child => child.background || child.scenario);
|
|
14
|
+
|
|
15
|
+
let ruleStepContainers = feature.children
|
|
16
|
+
.filter(child => child.rule)
|
|
17
|
+
.map(child => child.rule.children)
|
|
18
|
+
// .flat(); NOTE: flat not available until nodejs 11, which this project does not require
|
|
19
|
+
// using reduce() equivalent instead
|
|
20
|
+
// When revisiting wth nodejs 11, consider using flatmap()
|
|
21
|
+
.reduce((flattened, element) => flattened.concat(element), []);
|
|
22
|
+
|
|
23
|
+
let allStepContainers = simpleStepContainers.concat(ruleStepContainers);
|
|
24
|
+
|
|
25
|
+
allStepContainers.forEach(child => {
|
|
26
|
+
const node = child.background || child.scenario;
|
|
27
|
+
|
|
28
|
+
const keywordList = ['given', 'when', 'then'];
|
|
29
|
+
|
|
30
|
+
let maxKeywordPosition = undefined;
|
|
31
|
+
|
|
32
|
+
node.steps.forEach((step) => {
|
|
33
|
+
const keyword = gherkinUtils.getLanguageInsitiveKeyword(step, feature.language);
|
|
34
|
+
let keywordPosition = keywordList.indexOf(keyword);
|
|
35
|
+
|
|
36
|
+
if (keywordPosition === -1) {
|
|
37
|
+
// not found
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (keywordPosition < maxKeywordPosition) {
|
|
42
|
+
let maxKeyword = keywordList[maxKeywordPosition];
|
|
43
|
+
errors.push(createError(step, maxKeyword));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
maxKeywordPosition =
|
|
47
|
+
Math.max(maxKeywordPosition, keywordPosition) || keywordPosition;
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function createError(step, maxKeyword) {
|
|
55
|
+
return {
|
|
56
|
+
message: `Step "${step.keyword}${step.text}" should not appear after step using keyword ${maxKeyword}`,
|
|
57
|
+
rule: rule,
|
|
58
|
+
line: step.location.line,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
module.exports = {
|
|
63
|
+
name: rule,
|
|
64
|
+
run: run,
|
|
65
|
+
};
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const _ = require('lodash');
|
|
2
|
+
const rule = 'max-scenarios-per-file';
|
|
3
|
+
|
|
4
|
+
const defaultConfig = {
|
|
5
|
+
'maxScenarios': 10,
|
|
6
|
+
'countOutlineExamples': true
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
function run(feature, unused, config) {
|
|
10
|
+
if (!feature) {
|
|
11
|
+
return [];
|
|
12
|
+
}
|
|
13
|
+
let errors = [];
|
|
14
|
+
const mergedConfiguration = _.merge({}, defaultConfig, config);
|
|
15
|
+
const maxScenarios = mergedConfiguration.maxScenarios;
|
|
16
|
+
let count = feature.children.length;
|
|
17
|
+
|
|
18
|
+
feature.children.forEach(child => {
|
|
19
|
+
if (child.background) {
|
|
20
|
+
count = count - 1;
|
|
21
|
+
} else if (child.scenario.examples.length && mergedConfiguration.countOutlineExamples) {
|
|
22
|
+
count = count - 1;
|
|
23
|
+
child.scenario.examples.forEach(example => {
|
|
24
|
+
if (example.tableBody) {
|
|
25
|
+
count = count + example.tableBody.length;
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
if (count > maxScenarios) {
|
|
32
|
+
errors.push({
|
|
33
|
+
message: 'Number of scenarios exceeds maximum: ' + count + '/' + maxScenarios,
|
|
34
|
+
rule,
|
|
35
|
+
line: 0
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
module.exports = {
|
|
43
|
+
name: rule,
|
|
44
|
+
run: run,
|
|
45
|
+
availableConfigs: defaultConfig
|
|
46
|
+
};
|