speccle 0.11.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 +21 -0
- package/README.md +178 -0
- package/dist/check.js +74 -0
- package/dist/claims.js +80 -0
- package/dist/cli.js +218 -0
- package/dist/coverage.js +48 -0
- package/dist/dialects.js +43 -0
- package/dist/discover.js +29 -0
- package/dist/init.js +164 -0
- package/dist/lint.js +20 -0
- package/dist/mutation.js +103 -0
- package/dist/render.js +209 -0
- package/dist/rules/src/index.js +10 -0
- package/dist/rules/src/quality.js +259 -0
- package/dist/rules/src/structural.js +119 -0
- package/dist/spec.js +88 -0
- package/dist/strength.js +187 -0
- package/dist/violation.js +13 -0
- package/package.json +50 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/** Quality rules judge the heading statement only — the body is never linted (ADR-0007). */
|
|
2
|
+
export function qualityRules(specs) {
|
|
3
|
+
const out = [];
|
|
4
|
+
for (const spec of specs) {
|
|
5
|
+
for (const c of spec.criteria) {
|
|
6
|
+
if (!c.wellFormed)
|
|
7
|
+
continue;
|
|
8
|
+
// Stryker disable next-line all: an empty statement yields no quality violation; the skip only saves work
|
|
9
|
+
if (c.statement === "")
|
|
10
|
+
continue;
|
|
11
|
+
out.push(...weaselWording(spec, c), ...compoundCriterion(spec, c), ...unmeasurable(spec, c), ...codeVoice(spec, c));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
/** Contract, not config: extending this list means changing docs/convention.md. */
|
|
17
|
+
export const WEASEL_TERMS = [
|
|
18
|
+
"should",
|
|
19
|
+
"appropriately",
|
|
20
|
+
"properly",
|
|
21
|
+
"correctly",
|
|
22
|
+
"gracefully",
|
|
23
|
+
"seamlessly",
|
|
24
|
+
"efficiently",
|
|
25
|
+
"effectively",
|
|
26
|
+
"robust",
|
|
27
|
+
"robustly",
|
|
28
|
+
"reasonable",
|
|
29
|
+
"reasonably",
|
|
30
|
+
"adequate",
|
|
31
|
+
"adequately",
|
|
32
|
+
"sufficient",
|
|
33
|
+
"sufficiently",
|
|
34
|
+
"fast",
|
|
35
|
+
"quickly",
|
|
36
|
+
"performant",
|
|
37
|
+
"user-friendly",
|
|
38
|
+
"intuitive",
|
|
39
|
+
"easy",
|
|
40
|
+
"easily",
|
|
41
|
+
"simple",
|
|
42
|
+
"simply",
|
|
43
|
+
"flexible",
|
|
44
|
+
"sane",
|
|
45
|
+
"sensible",
|
|
46
|
+
"as needed",
|
|
47
|
+
"as appropriate",
|
|
48
|
+
"as expected",
|
|
49
|
+
"if necessary",
|
|
50
|
+
"if needed",
|
|
51
|
+
"when possible",
|
|
52
|
+
"where applicable",
|
|
53
|
+
"etc",
|
|
54
|
+
"and so on",
|
|
55
|
+
"works well",
|
|
56
|
+
];
|
|
57
|
+
const WEASEL_MATCHERS = WEASEL_TERMS.map((term) => ({
|
|
58
|
+
term,
|
|
59
|
+
regex: wordBoundaryRegex(term),
|
|
60
|
+
}));
|
|
61
|
+
function weaselWording(spec, c) {
|
|
62
|
+
const target = stripCodeSpans(c.statement);
|
|
63
|
+
return WEASEL_MATCHERS.filter(({ regex }) => regex.test(target)).map(({ term }) => ({
|
|
64
|
+
rule: "weasel-wording",
|
|
65
|
+
file: spec.file,
|
|
66
|
+
line: c.line,
|
|
67
|
+
message: `weasel wording: "${term}"`,
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
/** One bare "and"/"or" never flags — a compound noun phrase names one thing. */
|
|
71
|
+
const COMPOUND_SIGNALS = [
|
|
72
|
+
{ regex: /;/, message: () => "a semicolon joins independent clauses" },
|
|
73
|
+
{
|
|
74
|
+
regex: /,\s+(and|or)\b/i,
|
|
75
|
+
message: (m) => `a comma before "${m[1].toLowerCase()}" joins independent clauses`,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
regex: /\b(and also|as well as)\b/i,
|
|
79
|
+
message: (m) => `"${m[1].toLowerCase()}" joins independent clauses`,
|
|
80
|
+
},
|
|
81
|
+
// \s, not \s+: .test() treats them identically and \s+ leaves an unkillable mutant
|
|
82
|
+
{ regex: /[.!?]\s/, message: () => "a second sentence starts mid-statement" },
|
|
83
|
+
];
|
|
84
|
+
const ABBREVIATIONS = /\b(e\.g\.|i\.e\.|vs\.|etc\.)/gi;
|
|
85
|
+
const BARE_CONJUNCTION = /\b(and|or)\b/gi;
|
|
86
|
+
function compoundCriterion(spec, c) {
|
|
87
|
+
const target = stripCodeSpans(c.statement).replace(ABBREVIATIONS, " ");
|
|
88
|
+
for (const signal of COMPOUND_SIGNALS) {
|
|
89
|
+
const match = signal.regex.exec(target);
|
|
90
|
+
if (match) {
|
|
91
|
+
return [
|
|
92
|
+
{
|
|
93
|
+
rule: "compound-criterion",
|
|
94
|
+
file: spec.file,
|
|
95
|
+
line: c.line,
|
|
96
|
+
message: `compound criterion: ${signal.message(match)}`,
|
|
97
|
+
},
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const second = secondBareConjunction(target);
|
|
102
|
+
if (second !== null) {
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
rule: "compound-criterion",
|
|
106
|
+
file: spec.file,
|
|
107
|
+
line: c.line,
|
|
108
|
+
message: `compound criterion: a second bare "${second}" joins another clause`,
|
|
109
|
+
},
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
return [];
|
|
113
|
+
}
|
|
114
|
+
/** Two bare conjunctions in the main clause read as a list of behaviours. Conjunctions
|
|
115
|
+
* inside a condition ("when the card is expired and the retry limit is reached")
|
|
116
|
+
* qualify one outcome and are not counted. */
|
|
117
|
+
function secondBareConjunction(target) {
|
|
118
|
+
const mainClause = target.split(SUBORDINATOR)[0] ?? "";
|
|
119
|
+
// Stryker disable next-line ArrayDeclaration: the fallback is read only for its length, and a planted element still leaves it under two
|
|
120
|
+
const conjunctions = mainClause.match(BARE_CONJUNCTION) ?? [];
|
|
121
|
+
return conjunctions.length >= 2 ? conjunctions[1].toLowerCase() : null;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Predicates that name activity without naming an outcome. Contract, not config: this is
|
|
125
|
+
* the whole of what `unmeasurable` knows, so the rule under-flags rather than gatekeeping
|
|
126
|
+
* a spec's vocabulary — an unrecognised domain verb ("a refund credits the customer") is
|
|
127
|
+
* measurable and passes.
|
|
128
|
+
*/
|
|
129
|
+
export const VACUOUS_PREDICATES = [
|
|
130
|
+
"is handled",
|
|
131
|
+
"are handled",
|
|
132
|
+
"is supported",
|
|
133
|
+
"are supported",
|
|
134
|
+
"is implemented",
|
|
135
|
+
"are implemented",
|
|
136
|
+
"is done",
|
|
137
|
+
"are done",
|
|
138
|
+
"is correct",
|
|
139
|
+
"are correct",
|
|
140
|
+
"is responsible for",
|
|
141
|
+
"are responsible for",
|
|
142
|
+
"works",
|
|
143
|
+
"working",
|
|
144
|
+
"behaves",
|
|
145
|
+
"happens",
|
|
146
|
+
"occurs",
|
|
147
|
+
"handles",
|
|
148
|
+
"supports",
|
|
149
|
+
"deals with",
|
|
150
|
+
"takes care of",
|
|
151
|
+
];
|
|
152
|
+
export const OUTCOME_COMPARATORS = [
|
|
153
|
+
"at least",
|
|
154
|
+
"at most",
|
|
155
|
+
"no more than",
|
|
156
|
+
"more than",
|
|
157
|
+
"fewer than",
|
|
158
|
+
"less than",
|
|
159
|
+
"exactly",
|
|
160
|
+
"only when",
|
|
161
|
+
"only if",
|
|
162
|
+
"never",
|
|
163
|
+
"always",
|
|
164
|
+
"within",
|
|
165
|
+
"empty",
|
|
166
|
+
"non-empty",
|
|
167
|
+
"null",
|
|
168
|
+
"true",
|
|
169
|
+
"false",
|
|
170
|
+
"zero",
|
|
171
|
+
"once",
|
|
172
|
+
"unchanged",
|
|
173
|
+
];
|
|
174
|
+
const VACUOUS_MATCHERS = VACUOUS_PREDICATES.map((predicate) => ({
|
|
175
|
+
predicate,
|
|
176
|
+
regex: wordBoundaryRegex(predicate),
|
|
177
|
+
}));
|
|
178
|
+
const COMPARATOR_MATCHERS = OUTCOME_COMPARATORS.map(wordBoundaryRegex);
|
|
179
|
+
const QUANTITY = /\d|%/;
|
|
180
|
+
const SUBORDINATOR = /\b(?:when|whenever|if|unless|after|before|while|until|once|given)\b/i;
|
|
181
|
+
/**
|
|
182
|
+
* A trailing `is <adjective>` names a property. Lowercase-only: a capitalised word is a
|
|
183
|
+
* literal ("the response is JSON"), as is a code span, which is stripped before matching.
|
|
184
|
+
* An `-ed` word is a participle naming an outcome ("the order is cancelled").
|
|
185
|
+
*/
|
|
186
|
+
const COPULA_PROPERTY = /\b(?:is|are)\s+(?:not\s+)?([a-z][a-z-]*)\s*$/;
|
|
187
|
+
function unmeasurable(spec, c) {
|
|
188
|
+
const target = stripCodeSpans(c.statement);
|
|
189
|
+
const vacuous = VACUOUS_MATCHERS.find(({ regex }) => regex.test(target));
|
|
190
|
+
const message = vacuous
|
|
191
|
+
? `no measurable outcome: "${vacuous.predicate}" asserts nothing observable`
|
|
192
|
+
: namesOnlyAProperty(target)
|
|
193
|
+
? "no measurable outcome: names a property, not an observable outcome"
|
|
194
|
+
: null;
|
|
195
|
+
if (message === null)
|
|
196
|
+
return [];
|
|
197
|
+
return [{ rule: "unmeasurable", file: spec.file, line: c.line, message }];
|
|
198
|
+
}
|
|
199
|
+
/** Only the main clause is judged — a copula inside a condition ("when the basket is large")
|
|
200
|
+
* qualifies an outcome asserted elsewhere in the statement. */
|
|
201
|
+
function namesOnlyAProperty(target) {
|
|
202
|
+
if (QUANTITY.test(target) || COMPARATOR_MATCHERS.some((regex) => regex.test(target))) {
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
const mainClause = target.split(SUBORDINATOR)[0] ?? target;
|
|
206
|
+
const match = COPULA_PROPERTY.exec(mainClause);
|
|
207
|
+
return match !== null && !match[1].endsWith("ed");
|
|
208
|
+
}
|
|
209
|
+
const CODE_SPAN = /`[^`]*`/;
|
|
210
|
+
/** Contract, not config: extending this list means changing docs/convention.md. */
|
|
211
|
+
export const FILE_EXTENSIONS = [
|
|
212
|
+
"cjs",
|
|
213
|
+
"css",
|
|
214
|
+
"html",
|
|
215
|
+
"js",
|
|
216
|
+
"json",
|
|
217
|
+
"jsx",
|
|
218
|
+
"md",
|
|
219
|
+
"mjs",
|
|
220
|
+
"sh",
|
|
221
|
+
"toml",
|
|
222
|
+
"ts",
|
|
223
|
+
"tsx",
|
|
224
|
+
"txt",
|
|
225
|
+
"yaml",
|
|
226
|
+
"yml",
|
|
227
|
+
];
|
|
228
|
+
const FILE_PATH = new RegExp(`\\b[\\w./-]*\\.(?:${FILE_EXTENSIONS.join("|")})\\b`);
|
|
229
|
+
/**
|
|
230
|
+
* camelCase (two or more leading lowercase letters, so brand names like iPhone pass),
|
|
231
|
+
* snake_case, or call parentheses. Plain acronyms (JSON, HTTP) pass — under-flagging
|
|
232
|
+
* by design, like `unmeasurable`.
|
|
233
|
+
*/
|
|
234
|
+
const IDENTIFIER = /\b(?:[a-z][a-z0-9]+[A-Z][A-Za-z0-9]*|\w+_\w+|[A-Za-z_$][\w$]*\([^()]*\))/;
|
|
235
|
+
/** Judges the raw statement: here a code span is the strongest signal, not a literal. */
|
|
236
|
+
function codeVoice(spec, c) {
|
|
237
|
+
const signal = codeVoiceSignal(c.statement);
|
|
238
|
+
if (signal === null)
|
|
239
|
+
return [];
|
|
240
|
+
return [{ rule: "code-voice", file: spec.file, line: c.line, message: `code voice: ${signal}` }];
|
|
241
|
+
}
|
|
242
|
+
function codeVoiceSignal(statement) {
|
|
243
|
+
const span = CODE_SPAN.exec(statement);
|
|
244
|
+
if (span)
|
|
245
|
+
return `code span ${span[0]} belongs in the criterion body`;
|
|
246
|
+
const path = FILE_PATH.exec(statement);
|
|
247
|
+
if (path)
|
|
248
|
+
return `file path "${path[0]}" belongs in the criterion body`;
|
|
249
|
+
const identifier = IDENTIFIER.exec(statement);
|
|
250
|
+
if (identifier)
|
|
251
|
+
return `identifier "${identifier[0]}" belongs in the criterion body`;
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
function stripCodeSpans(text) {
|
|
255
|
+
return text.replace(/`[^`]*`/g, " ");
|
|
256
|
+
}
|
|
257
|
+
function wordBoundaryRegex(term) {
|
|
258
|
+
return new RegExp(`\\b${term.replace(/[-\s]/g, "[-\\s]")}\\b`, "i");
|
|
259
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
export function structuralRules(specs) {
|
|
2
|
+
return [
|
|
3
|
+
...missingKey(specs),
|
|
4
|
+
...keyCollision(specs),
|
|
5
|
+
...keyMismatch(specs),
|
|
6
|
+
...malformedId(specs),
|
|
7
|
+
...duplicateId(specs),
|
|
8
|
+
...emptyStatement(specs),
|
|
9
|
+
];
|
|
10
|
+
}
|
|
11
|
+
function missingKey(specs) {
|
|
12
|
+
return specs.flatMap((spec) => {
|
|
13
|
+
if (spec.key === undefined) {
|
|
14
|
+
return [
|
|
15
|
+
{
|
|
16
|
+
rule: "missing-key",
|
|
17
|
+
file: spec.file,
|
|
18
|
+
line: 1,
|
|
19
|
+
message: 'frontmatter "key" is missing',
|
|
20
|
+
},
|
|
21
|
+
];
|
|
22
|
+
}
|
|
23
|
+
if (!spec.key.valid) {
|
|
24
|
+
return [
|
|
25
|
+
{
|
|
26
|
+
rule: "missing-key",
|
|
27
|
+
file: spec.file,
|
|
28
|
+
line: spec.key.line,
|
|
29
|
+
message: `frontmatter key "${spec.key.raw}" is malformed: expected [A-Z][A-Z0-9]{1,9}`,
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
return [];
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
function keyCollision(specs) {
|
|
37
|
+
const byKey = new Map();
|
|
38
|
+
for (const spec of specs) {
|
|
39
|
+
if (spec.key?.valid) {
|
|
40
|
+
const group = byKey.get(spec.key.raw) ?? [];
|
|
41
|
+
group.push(spec);
|
|
42
|
+
byKey.set(spec.key.raw, group);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [key, group] of byKey) {
|
|
47
|
+
if (group.length < 2)
|
|
48
|
+
continue;
|
|
49
|
+
for (const spec of group) {
|
|
50
|
+
const others = group.filter((s) => s !== spec).map((s) => s.file);
|
|
51
|
+
out.push({
|
|
52
|
+
rule: "key-collision",
|
|
53
|
+
file: spec.file,
|
|
54
|
+
line: spec.key.line,
|
|
55
|
+
message: `feature key "${key}" is also declared in ${others.join(", ")}`,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
function keyMismatch(specs) {
|
|
62
|
+
return specs.flatMap((spec) => {
|
|
63
|
+
if (!spec.key?.valid)
|
|
64
|
+
return [];
|
|
65
|
+
const declared = spec.key.raw;
|
|
66
|
+
return wellFormed(spec)
|
|
67
|
+
.filter((c) => c.key !== declared)
|
|
68
|
+
.map((c) => ({
|
|
69
|
+
rule: "key-mismatch",
|
|
70
|
+
file: spec.file,
|
|
71
|
+
line: c.line,
|
|
72
|
+
message: `criterion id [${c.id}] does not match the feature key "${declared}"`,
|
|
73
|
+
}));
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
function malformedId(specs) {
|
|
77
|
+
return specs.flatMap((spec) => spec.criteria
|
|
78
|
+
.filter((c) => !c.wellFormed)
|
|
79
|
+
.map((c) => ({
|
|
80
|
+
rule: "malformed-id",
|
|
81
|
+
file: spec.file,
|
|
82
|
+
line: c.line,
|
|
83
|
+
message: `H2 "${c.heading}" has no well-formed [KEY-n] token`,
|
|
84
|
+
})));
|
|
85
|
+
}
|
|
86
|
+
function duplicateId(specs) {
|
|
87
|
+
const firstSeen = new Map();
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const spec of specs) {
|
|
90
|
+
for (const c of wellFormed(spec)) {
|
|
91
|
+
const first = firstSeen.get(c.id);
|
|
92
|
+
if (first === undefined) {
|
|
93
|
+
firstSeen.set(c.id, { file: spec.file, line: c.line });
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
out.push({
|
|
97
|
+
rule: "duplicate-id",
|
|
98
|
+
file: spec.file,
|
|
99
|
+
line: c.line,
|
|
100
|
+
message: `criterion id [${c.id}] is already used at ${first.file}:${first.line}`,
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
function emptyStatement(specs) {
|
|
108
|
+
return specs.flatMap((spec) => wellFormed(spec)
|
|
109
|
+
.filter((c) => c.statement === "")
|
|
110
|
+
.map((c) => ({
|
|
111
|
+
rule: "empty-statement",
|
|
112
|
+
file: spec.file,
|
|
113
|
+
line: c.line,
|
|
114
|
+
message: `criterion [${c.id}] has a token but no statement`,
|
|
115
|
+
})));
|
|
116
|
+
}
|
|
117
|
+
function wellFormed(spec) {
|
|
118
|
+
return spec.criteria.filter((c) => c.wellFormed);
|
|
119
|
+
}
|
package/dist/spec.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const KEY_PATTERN = /^[A-Z][A-Z0-9]{1,9}$/;
|
|
2
|
+
const ID_TOKEN = /^\[([A-Z][A-Z0-9]{1,9})-([1-9][0-9]*)\]$/;
|
|
3
|
+
/** A `[KEY-n]` token anywhere in a test's full concatenated name (ADR-0004). */
|
|
4
|
+
export const CLAIM_TOKEN = /\[([A-Z][A-Z0-9]{1,9}-[1-9][0-9]*)\]/g;
|
|
5
|
+
/**
|
|
6
|
+
* `KEY_n`, bounded by underscores or non-alphanumerics: `test_CHECKOUT_1_taxRounds`
|
|
7
|
+
* claims CHECKOUT-1, while `testCHECKOUT_1` and `CHECKOUT_1Fixture` do not.
|
|
8
|
+
*/
|
|
9
|
+
const IDENTIFIER_CLAIM_TOKEN = /(?<![A-Za-z0-9])([A-Z][A-Z0-9]{1,9})_([1-9][0-9]*)(?![A-Za-z0-9])/g;
|
|
10
|
+
/** Every criterion id a test name claims, always in the bracketed `KEY-n` form. */
|
|
11
|
+
export function readClaimedIds(name, spelling) {
|
|
12
|
+
if (spelling === "bracketed")
|
|
13
|
+
return [...name.matchAll(CLAIM_TOKEN)].map((m) => m[1]);
|
|
14
|
+
return [...name.matchAll(IDENTIFIER_CLAIM_TOKEN)].map((m) => `${m[1]}-${m[2]}`);
|
|
15
|
+
}
|
|
16
|
+
/** Sorts `KEY-n` ids by key, then numerically by n. */
|
|
17
|
+
export function compareCriterionIds(a, b) {
|
|
18
|
+
const [aKey, aN] = splitId(a);
|
|
19
|
+
const [bKey, bN] = splitId(b);
|
|
20
|
+
return aKey === bKey ? aN - bN : aKey.localeCompare(bKey);
|
|
21
|
+
}
|
|
22
|
+
function splitId(id) {
|
|
23
|
+
const dash = id.lastIndexOf("-");
|
|
24
|
+
return [id.slice(0, dash), Number(id.slice(dash + 1))];
|
|
25
|
+
}
|
|
26
|
+
export function parseSpec(content, file) {
|
|
27
|
+
const lines = content.split(/\r?\n/);
|
|
28
|
+
let bodyStart = 0;
|
|
29
|
+
let key;
|
|
30
|
+
if (lines[0]?.trim() === "---") {
|
|
31
|
+
const close = lines.findIndex((line, i) => i > 0 && line.trim() === "---");
|
|
32
|
+
if (close > 0) {
|
|
33
|
+
bodyStart = close + 1;
|
|
34
|
+
for (let i = 1; i < close; i++) {
|
|
35
|
+
const match = /^key:\s*(.*)$/.exec(lines[i] ?? "");
|
|
36
|
+
if (match) {
|
|
37
|
+
const raw = unquote(match[1].trim());
|
|
38
|
+
key = { raw, line: i + 1, valid: KEY_PATTERN.test(raw) };
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
const criteria = [];
|
|
45
|
+
let fence;
|
|
46
|
+
for (let i = bodyStart; i < lines.length; i++) {
|
|
47
|
+
const line = lines[i] ?? "";
|
|
48
|
+
const fenceMatch = /^(`{3,}|~{3,})/.exec(line.trimStart());
|
|
49
|
+
if (fenceMatch) {
|
|
50
|
+
const marker = fenceMatch[1][0];
|
|
51
|
+
if (fence === undefined)
|
|
52
|
+
fence = marker;
|
|
53
|
+
else if (marker === fence)
|
|
54
|
+
fence = undefined;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
if (fence !== undefined)
|
|
58
|
+
continue;
|
|
59
|
+
const h2 = /^##(?!#)\s+(.*?)\s*$/.exec(line);
|
|
60
|
+
if (!h2)
|
|
61
|
+
continue;
|
|
62
|
+
const heading = h2[1].replace(/\s+#+$/, "");
|
|
63
|
+
criteria.push(parseHeading(heading, i + 1));
|
|
64
|
+
}
|
|
65
|
+
return { file, key, criteria };
|
|
66
|
+
}
|
|
67
|
+
function parseHeading(heading, line) {
|
|
68
|
+
const token = /^(\[[^\]]*\])\s*(.*)$/.exec(heading);
|
|
69
|
+
if (token) {
|
|
70
|
+
const id = ID_TOKEN.exec(token[1]);
|
|
71
|
+
if (id) {
|
|
72
|
+
return {
|
|
73
|
+
wellFormed: true,
|
|
74
|
+
id: `${id[1]}-${id[2]}`,
|
|
75
|
+
key: id[1],
|
|
76
|
+
n: Number(id[2]),
|
|
77
|
+
statement: token[2].trim(),
|
|
78
|
+
line,
|
|
79
|
+
heading,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return { wellFormed: false, line, heading };
|
|
84
|
+
}
|
|
85
|
+
function unquote(value) {
|
|
86
|
+
const quoted = /^(["'])(.*)\1$/.exec(value);
|
|
87
|
+
return quoted ? quoted[2] : value;
|
|
88
|
+
}
|
package/dist/strength.js
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { readFile, stat } from "node:fs/promises";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { coverageUnder, parseCoverageSummary } from "./coverage.js";
|
|
4
|
+
import { discoverSpecs } from "./discover.js";
|
|
5
|
+
import { isKill, isScored, parseMutationReport } from "./mutation.js";
|
|
6
|
+
import { CLAIM_TOKEN, compareCriterionIds, parseSpec } from "./spec.js";
|
|
7
|
+
export const DEFAULT_MUTATION_REPORT = "reports/mutation/mutation.json";
|
|
8
|
+
export const DEFAULT_COVERAGE_SUMMARY = "coverage/coverage-summary.json";
|
|
9
|
+
export async function strength(target, options = {}) {
|
|
10
|
+
const root = resolve(target);
|
|
11
|
+
if (!(await isDirectory(root)))
|
|
12
|
+
throw new Error(`path not found: ${target}`);
|
|
13
|
+
const mutationPath = resolve(root, options.mutationReport ?? DEFAULT_MUTATION_REPORT);
|
|
14
|
+
const report = parseMutationReport(await readJson(mutationPath), rel(root, mutationPath));
|
|
15
|
+
if (report.coverageAnalysis !== undefined && report.coverageAnalysis !== "perTest") {
|
|
16
|
+
throw new Error(`${rel(root, mutationPath)} was produced with coverageAnalysis "${report.coverageAnalysis}"; ` +
|
|
17
|
+
`oracle strength needs "perTest" to know which tests covered each mutant`);
|
|
18
|
+
}
|
|
19
|
+
const coveragePath = resolve(root, options.coverageSummary ?? DEFAULT_COVERAGE_SUMMARY);
|
|
20
|
+
const coverage = await readCoverage(coveragePath, root);
|
|
21
|
+
const specFiles = await discoverSpecs(root);
|
|
22
|
+
const specs = await Promise.all(specFiles.map(async (file) => parseSpec(await readFile(join(root, file), "utf8"), file)));
|
|
23
|
+
const criteria = new Map();
|
|
24
|
+
for (const spec of specs) {
|
|
25
|
+
for (const criterion of spec.criteria) {
|
|
26
|
+
if (criterion.wellFormed && !criteria.has(criterion.id)) {
|
|
27
|
+
criteria.set(criterion.id, { criterion, spec: spec.file });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const claimsByTest = new Map();
|
|
32
|
+
const claimedIds = new Set();
|
|
33
|
+
for (const [id, name] of report.testNames) {
|
|
34
|
+
const claims = [...name.matchAll(CLAIM_TOKEN)].map((m) => m[1]);
|
|
35
|
+
claimsByTest.set(id, claims);
|
|
36
|
+
for (const claim of claims)
|
|
37
|
+
claimedIds.add(claim);
|
|
38
|
+
}
|
|
39
|
+
const tally = new Map();
|
|
40
|
+
const featureTally = new Map();
|
|
41
|
+
let covered = 0;
|
|
42
|
+
let killed = 0;
|
|
43
|
+
const unclaimedMutants = [];
|
|
44
|
+
let staticKilled = 0;
|
|
45
|
+
const staticSurvivors = [];
|
|
46
|
+
for (const mutant of report.mutants) {
|
|
47
|
+
if (!isScored(mutant.status))
|
|
48
|
+
continue;
|
|
49
|
+
if (mutant.static) {
|
|
50
|
+
if (isKill(mutant.status))
|
|
51
|
+
staticKilled++;
|
|
52
|
+
else
|
|
53
|
+
staticSurvivors.push(toSite(mutant));
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
const claims = new Set();
|
|
57
|
+
for (const testId of mutant.coveredBy) {
|
|
58
|
+
for (const claim of claimsByTest.get(testId) ?? []) {
|
|
59
|
+
if (criteria.has(claim))
|
|
60
|
+
claims.add(claim);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (claims.size === 0) {
|
|
64
|
+
unclaimedMutants.push(toSite(mutant));
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
// ADR-0011: any test's kill counts for every criterion whose tests covered the mutant.
|
|
68
|
+
const kill = isKill(mutant.status);
|
|
69
|
+
covered++;
|
|
70
|
+
if (kill)
|
|
71
|
+
killed++;
|
|
72
|
+
for (const id of claims) {
|
|
73
|
+
const entry = tally.get(id) ?? { covered: 0, killed: 0, survivors: [] };
|
|
74
|
+
entry.covered++;
|
|
75
|
+
if (kill)
|
|
76
|
+
entry.killed++;
|
|
77
|
+
else
|
|
78
|
+
entry.survivors.push(toSite(mutant));
|
|
79
|
+
tally.set(id, entry);
|
|
80
|
+
}
|
|
81
|
+
const features = new Set([...claims].map((id) => criteria.get(id).spec));
|
|
82
|
+
for (const spec of features) {
|
|
83
|
+
const entry = featureTally.get(spec) ?? { covered: 0, killed: 0 };
|
|
84
|
+
entry.covered++;
|
|
85
|
+
if (kill)
|
|
86
|
+
entry.killed++;
|
|
87
|
+
featureTally.set(spec, entry);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const features = specs.map((spec) => {
|
|
91
|
+
const own = [...criteria.values()]
|
|
92
|
+
.filter((c) => c.spec === spec.file)
|
|
93
|
+
.map(({ criterion }) => {
|
|
94
|
+
const entry = tally.get(criterion.id);
|
|
95
|
+
return {
|
|
96
|
+
id: criterion.id,
|
|
97
|
+
statement: criterion.statement,
|
|
98
|
+
claimed: claimedIds.has(criterion.id),
|
|
99
|
+
covered: entry?.covered ?? 0,
|
|
100
|
+
killed: entry?.killed ?? 0,
|
|
101
|
+
strength: ratio(entry?.killed ?? 0, entry?.covered ?? 0),
|
|
102
|
+
survivors: (entry?.survivors ?? []).sort(bySourcePosition),
|
|
103
|
+
};
|
|
104
|
+
})
|
|
105
|
+
.sort(byCriterionId);
|
|
106
|
+
const totals = featureTally.get(spec.file) ?? { covered: 0, killed: 0 };
|
|
107
|
+
const lines = coverage && coverageUnder(coverage, dirname(spec.file));
|
|
108
|
+
return {
|
|
109
|
+
key: spec.key?.raw,
|
|
110
|
+
spec: spec.file,
|
|
111
|
+
covered: totals.covered,
|
|
112
|
+
killed: totals.killed,
|
|
113
|
+
strength: ratio(totals.killed, totals.covered),
|
|
114
|
+
lineCoverage: lines ? ratio(lines.covered, lines.total) : null,
|
|
115
|
+
criteria: own,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
const unknownClaims = [...claimedIds].filter((id) => !criteria.has(id)).sort();
|
|
119
|
+
const unclaimed = [...criteria.keys()].filter((id) => !claimedIds.has(id)).sort();
|
|
120
|
+
return {
|
|
121
|
+
root,
|
|
122
|
+
covered,
|
|
123
|
+
killed,
|
|
124
|
+
strength: ratio(killed, covered),
|
|
125
|
+
lineCoverage: coverage?.total ? ratio(coverage.total.covered, coverage.total.total) : null,
|
|
126
|
+
features,
|
|
127
|
+
unclaimed,
|
|
128
|
+
unknownClaims,
|
|
129
|
+
unclaimedMutants: unclaimedMutants.sort(bySourcePosition),
|
|
130
|
+
staticMutants: { killed: staticKilled, survivors: staticSurvivors.sort(bySourcePosition) },
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function toSite(mutant) {
|
|
134
|
+
return {
|
|
135
|
+
file: mutant.file,
|
|
136
|
+
line: mutant.line,
|
|
137
|
+
column: mutant.column,
|
|
138
|
+
mutator: mutant.mutator,
|
|
139
|
+
replacement: mutant.replacement,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
/** Stryker does not order mutants stably across runs; a Speccle tool's output must be. */
|
|
143
|
+
function bySourcePosition(a, b) {
|
|
144
|
+
return a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column;
|
|
145
|
+
}
|
|
146
|
+
function byCriterionId(a, b) {
|
|
147
|
+
return compareCriterionIds(a.id, b.id);
|
|
148
|
+
}
|
|
149
|
+
function ratio(numerator, denominator) {
|
|
150
|
+
return denominator === 0 ? null : numerator / denominator;
|
|
151
|
+
}
|
|
152
|
+
async function isDirectory(path) {
|
|
153
|
+
try {
|
|
154
|
+
return (await stat(path)).isDirectory();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function readJson(path) {
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
raw = await readFile(path, "utf8");
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
throw new Error(`report not found: ${path}`);
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return JSON.parse(raw);
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
throw new Error(`${path} is not valid JSON: ${err instanceof Error ? err.message : ""}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
/** Coverage is the naïve baseline, not the measurement: its absence is not fatal. */
|
|
176
|
+
async function readCoverage(path, root) {
|
|
177
|
+
try {
|
|
178
|
+
await stat(path);
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
return parseCoverageSummary(await readJson(path), root, rel(root, path));
|
|
184
|
+
}
|
|
185
|
+
function rel(root, path) {
|
|
186
|
+
return path.startsWith(`${root}/`) ? path.slice(root.length + 1) : path;
|
|
187
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Order matches the table in docs/convention.md and drives violation sorting. */
|
|
2
|
+
export const RULE_IDS = [
|
|
3
|
+
"missing-key",
|
|
4
|
+
"key-collision",
|
|
5
|
+
"key-mismatch",
|
|
6
|
+
"malformed-id",
|
|
7
|
+
"duplicate-id",
|
|
8
|
+
"empty-statement",
|
|
9
|
+
"weasel-wording",
|
|
10
|
+
"compound-criterion",
|
|
11
|
+
"unmeasurable",
|
|
12
|
+
"code-voice",
|
|
13
|
+
];
|