@blundergoat/gruff-ts 0.1.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/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
// Per-test-block rule pass: assertion quality (no-assertions, trivial, snapshot-only,
|
|
2
|
+
// no-throw-only, exception-type-only, magic-number), mock quality (unused-mock, mock-only-test),
|
|
3
|
+
// setup bloat + global-state-mutation, and structural checks (sleep/loop/conditional/only-skip).
|
|
4
|
+
// Invoked from the analyseBlocks orchestrator when `block.isTest` is true.
|
|
5
|
+
import { blockFinding, blockFindingWithMetadata, type FunctionBlock, hasAssertion, setupLineCount } from "./blocks.ts";
|
|
6
|
+
import { ruleSeverity, threshold } from "./config.ts";
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { escapeRegex } from "./findings-helpers.ts";
|
|
9
|
+
import { countMatches } from "./text-scans.ts";
|
|
10
|
+
import type { Config, Finding, Severity } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// Provisional rule output gathered during a test-block walk. Built before the surrounding context
|
|
13
|
+
// (file, line) is known, then promoted into a real Finding by the caller.
|
|
14
|
+
interface TestBlockCheck {
|
|
15
|
+
ruleId: string;
|
|
16
|
+
message: string;
|
|
17
|
+
severity: Severity;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Captures one assertion matcher plus the capture indexes that hold actual expression and literal.
|
|
21
|
+
interface MagicNumberAssertionPattern {
|
|
22
|
+
pattern: RegExp;
|
|
23
|
+
expressionIndex: number;
|
|
24
|
+
valueIndex: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/*
|
|
28
|
+
* Reached when `block.isTest` is true. Four sub-passes in a stable, deterministic order: assertion
|
|
29
|
+
* quality, mock quality, setup bloat, structural rules.
|
|
30
|
+
*/
|
|
31
|
+
export function analyseTestBlock(file: SourceFile, block: FunctionBlock, config: Config, findings: Finding[]): void {
|
|
32
|
+
const body = block.codeBody;
|
|
33
|
+
analyseAssertionQuality(file, block, body, findings);
|
|
34
|
+
analyseMockQuality(file, block, body, findings);
|
|
35
|
+
analyseSetupBloat(file, block, body, config, findings);
|
|
36
|
+
analyseTestStructureChecks(file, block, body, findings);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Five assertion-shape checks (no-assertions, trivial, snapshot-only, no-throw-only, exception-type-only)
|
|
40
|
+
// plus the magic-number sub-pass. Reports findings with stable test-block metadata.
|
|
41
|
+
function analyseAssertionQuality(file: SourceFile, block: FunctionBlock, body: string, findings: Finding[]): void {
|
|
42
|
+
for (const check of assertionQualityChecks(block, body)) {
|
|
43
|
+
findings.push(blockFinding({ ruleId: check.ruleId, message: check.message, file, block, severity: check.severity, pillar: "test-quality" }));
|
|
44
|
+
}
|
|
45
|
+
pushMagicNumberAssertionFindings(file, block, body, findings);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Lazy evaluation: only checks whose `active` predicate fired are returned. The five rule IDs are
|
|
49
|
+
// part of the public test-quality pillar; their ordering here is the stable emission order.
|
|
50
|
+
function assertionQualityChecks(block: FunctionBlock, body: string): TestBlockCheck[] {
|
|
51
|
+
const testName = block.name;
|
|
52
|
+
const checks: Array<TestBlockCheck & { active: boolean }> = [
|
|
53
|
+
{ active: !hasAssertion(body), ruleId: "test-quality.no-assertions", message: `Test \`${testName}\` does not appear to make an assertion.`, severity: "warning" },
|
|
54
|
+
{ active: hasTrivialAssertion(body), ruleId: "test-quality.trivial-assertion", message: `Test \`${testName}\` contains an assertion that compares a value to itself.`, severity: "warning" },
|
|
55
|
+
{ active: isSnapshotOnlyTest(body), ruleId: "test-quality.snapshot-only-test", message: `Test \`${testName}\` relies only on snapshot assertions.`, severity: "advisory" },
|
|
56
|
+
{ active: isNoThrowOnlyTest(body), ruleId: "test-quality.no-throw-only-test", message: `Test \`${testName}\` only verifies that code does not throw.`, severity: "advisory" },
|
|
57
|
+
{ active: hasExceptionTypeOnlyAssertion(body), ruleId: "test-quality.exception-type-only", message: `Test \`${testName}\` checks only the exception type.`, severity: "advisory" },
|
|
58
|
+
];
|
|
59
|
+
return checks.filter((check) => check.active).map(({ active: _active, ...check }) => check);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/*
|
|
63
|
+
* Targets `expect(x).toBe(42)` / `assert.equal(x, 42)` patterns where 42 has no name. Reports
|
|
64
|
+
* `test-quality.magic-number-assertion` with stable literal metadata for downstream review tools.
|
|
65
|
+
*/
|
|
66
|
+
function pushMagicNumberAssertionFindings(file: SourceFile, block: FunctionBlock, body: string, findings: Finding[]): void {
|
|
67
|
+
for (const assertion of magicNumberAssertions(body)) {
|
|
68
|
+
findings.push(
|
|
69
|
+
blockFindingWithMetadata({
|
|
70
|
+
ruleId: "test-quality.magic-number-assertion",
|
|
71
|
+
message: `Test \`${block.name}\` asserts against unexplained numeric literal ${assertion.value}.`,
|
|
72
|
+
file,
|
|
73
|
+
block,
|
|
74
|
+
severity: "advisory",
|
|
75
|
+
pillar: "test-quality",
|
|
76
|
+
metadata: { value: assertion.value },
|
|
77
|
+
}),
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Two distinct findings emitted from one walk: per-unused-mock and a single mock-only flag.
|
|
83
|
+
// Reports `test-quality.unused-mock` / `test-quality.mock-only-test` with stable test-block metadata.
|
|
84
|
+
function analyseMockQuality(file: SourceFile, block: FunctionBlock, body: string, findings: Finding[]): void {
|
|
85
|
+
const unusedMocks = unusedMockVariables(body);
|
|
86
|
+
for (const mock of unusedMocks) {
|
|
87
|
+
findings.push(
|
|
88
|
+
blockFindingWithMetadata({
|
|
89
|
+
ruleId: "test-quality.unused-mock",
|
|
90
|
+
message: `Mock \`${mock}\` is created but not used.`,
|
|
91
|
+
file,
|
|
92
|
+
block,
|
|
93
|
+
severity: "advisory",
|
|
94
|
+
pillar: "test-quality",
|
|
95
|
+
metadata: { mockName: mock },
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
if (isMockOnlyTest(body)) {
|
|
100
|
+
findings.push(blockFinding({ ruleId: "test-quality.mock-only-test", message: `Test \`${block.name}\` only verifies mock interaction.`, file, block, severity: "advisory", pillar: "test-quality" }));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/*
|
|
105
|
+
* Two rules off one pass: `test-quality.global-state-mutation` for tests that touch process state,
|
|
106
|
+
* and `test-quality.setup-bloat` (threshold 12) for excessive arrange before the first assertion.
|
|
107
|
+
* Reports both with stable metadata so downstream tooling can track the setup-line counts.
|
|
108
|
+
*/
|
|
109
|
+
function analyseSetupBloat(file: SourceFile, block: FunctionBlock, body: string, config: Config, findings: Finding[]): void {
|
|
110
|
+
if (hasGlobalStateMutation(body)) {
|
|
111
|
+
findings.push(blockFinding({ ruleId: "test-quality.global-state-mutation", message: `Test \`${block.name}\` mutates global process or runtime state.`, file, block, severity: "warning", pillar: "test-quality" }));
|
|
112
|
+
}
|
|
113
|
+
const setupLines = setupLineCount(body);
|
|
114
|
+
const maxSetupLines = setupBloatThreshold(file, config);
|
|
115
|
+
if (setupLines > maxSetupLines) {
|
|
116
|
+
findings.push(
|
|
117
|
+
blockFindingWithMetadata({
|
|
118
|
+
ruleId: "test-quality.setup-bloat",
|
|
119
|
+
message: `Test \`${block.name}\` has ${setupLines} setup lines before its first assertion.`,
|
|
120
|
+
file,
|
|
121
|
+
block,
|
|
122
|
+
severity: ruleSeverity(config, "test-quality.setup-bloat", "advisory"),
|
|
123
|
+
pillar: "test-quality",
|
|
124
|
+
metadata: { setupLines, maxSetupLines },
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Pattern-driven checks for sleep/loop/conditional logic plus the `.only`/`.skip` commit gate.
|
|
131
|
+
// Reports each detected structural issue as a stable test-quality finding.
|
|
132
|
+
function analyseTestStructureChecks(file: SourceFile, block: FunctionBlock, body: string, findings: Finding[]): void {
|
|
133
|
+
const checks: Array<[string, boolean, string]> = [
|
|
134
|
+
["test-quality.sleep-in-test", /\b(setTimeout|sleep|waitForTimeout)\s*\(/.test(body), "Test sleeps instead of synchronising on behaviour."],
|
|
135
|
+
["test-quality.loop-in-test", controlFlowContainsAssertion(body, /\b(?:for|while)\b/g), "Test contains loop logic around assertions."],
|
|
136
|
+
["test-quality.conditional-logic", controlFlowContainsAssertion(body, /\b(?:if|switch)\b/g), "Test contains conditional logic around assertions."],
|
|
137
|
+
["test-quality.only-skip", /\.(only|skip)\s*\(/.test(body), "Focused or skipped test is committed."],
|
|
138
|
+
];
|
|
139
|
+
for (const [ruleId, active, message] of checks) {
|
|
140
|
+
if (active) {
|
|
141
|
+
findings.push(blockFinding({ ruleId, message, file, block, severity: "advisory", pillar: "test-quality" }));
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Integration, contract, smoke, and performance tests naturally need more environment setup than
|
|
147
|
+
// focused unit tests; keep the default strict for unit tests and double it for broad-flow suites.
|
|
148
|
+
function setupBloatThreshold(file: SourceFile, config: Config): number {
|
|
149
|
+
const baseThreshold = threshold(config, "test-quality.setup-bloat", 12);
|
|
150
|
+
return isBroadFlowTestPath(file.displayPath) ? baseThreshold * 2 : baseThreshold;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Broad-flow tests exercise systems rather than one unit, so longer setup stays below the bloat line.
|
|
154
|
+
function isBroadFlowTestPath(path: string): boolean {
|
|
155
|
+
return /(?:^|\/)test\/(?:integration|contract|smoke|performance)\//.test(path);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Structural loop/branch findings now require the control flow to wrap an assertion; setup-only
|
|
159
|
+
// conditionals and fixture-building loops are noisy but not direct test-quality failures.
|
|
160
|
+
function controlFlowContainsAssertion(source: string, pattern: RegExp): boolean {
|
|
161
|
+
for (const match of source.matchAll(pattern)) {
|
|
162
|
+
const start = match.index ?? 0;
|
|
163
|
+
const segment = controlFlowSegment(source, start);
|
|
164
|
+
if (hasAssertion(segment)) {
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Captures the smallest control-flow segment so assertion detection does not scan the whole test.
|
|
172
|
+
function controlFlowSegment(source: string, start: number): string {
|
|
173
|
+
const lineEnd = source.indexOf("\n", start);
|
|
174
|
+
const openBrace = source.indexOf("{", start);
|
|
175
|
+
if (openBrace === -1 || (lineEnd !== -1 && openBrace > lineEnd)) {
|
|
176
|
+
return source.slice(start, lineEnd === -1 ? source.length : lineEnd);
|
|
177
|
+
}
|
|
178
|
+
const closeBrace = matchingCloseBrace(source, openBrace);
|
|
179
|
+
return source.slice(start, closeBrace === undefined ? openBrace + 1 : closeBrace + 1);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Lightweight brace matcher for already-isolated test block text; enough to bound loop/if bodies.
|
|
183
|
+
function matchingCloseBrace(source: string, openBrace: number): number | undefined {
|
|
184
|
+
let depth = 0;
|
|
185
|
+
for (let index = openBrace; index < source.length; index += 1) {
|
|
186
|
+
const character = source[index];
|
|
187
|
+
if (character === "{") {
|
|
188
|
+
depth += 1;
|
|
189
|
+
} else if (character === "}") {
|
|
190
|
+
depth -= 1;
|
|
191
|
+
if (depth === 0) {
|
|
192
|
+
return index;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return undefined;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Aggregator over the three trivial-assertion shapes - literal-comparison, mirrored-`assert`
|
|
200
|
+
// arguments, mirrored-`expect` arguments. Splitting the checks keeps each regex focused and
|
|
201
|
+
// debuggable while this top-level keeps the call site for `test-quality.trivial-assertion` short.
|
|
202
|
+
function hasTrivialAssertion(source: string): boolean {
|
|
203
|
+
return hasLiteralTrivialAssertion(source) || hasRepeatedAssertArgument(source) || hasRepeatedExpectArgument(source);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Targets `assert.ok(true)` and `assert.equal(literal, sameLiteral)` shapes - both prove nothing
|
|
207
|
+
// at runtime. The backreference `\1` is what makes the second pattern detect mirrored literals
|
|
208
|
+
// across the supported `equal` / `strictEqual` / `deepEqual` variants.
|
|
209
|
+
function hasLiteralTrivialAssertion(source: string): boolean {
|
|
210
|
+
return (
|
|
211
|
+
/\bassert\.ok\s*\(\s*true\s*\)/.test(source) ||
|
|
212
|
+
/\bassert\.(?:equal|strictEqual|deepEqual)\s*\(\s*(true|false|null|undefined|\d+|["'][^"']*["'])\s*,\s*\1\s*\)/.test(source)
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Walks every `assert.equal(a, b)` call and normalises both arguments before comparison so that
|
|
217
|
+
// `foo;` and `foo` collapse to the same key. Mirrored expressions indicate the assertion would
|
|
218
|
+
// pass regardless of behaviour - reports as a trivial assertion.
|
|
219
|
+
function hasRepeatedAssertArgument(source: string): boolean {
|
|
220
|
+
for (const match of source.matchAll(/\bassert\.(?:equal|strictEqual|deepEqual)\s*\(\s*([^,\n]+?)\s*,\s*([^,\n)]+?)(?:\s*,|\s*\))/g)) {
|
|
221
|
+
if (normalizeAssertionExpression(match[1] ?? "") === normalizeAssertionExpression(match[2] ?? "")) {
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return false;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Jest/Vitest counterpart to `hasRepeatedAssertArgument`. Targets `expect(a).toBe(b)` and the
|
|
229
|
+
// equality variants; the matcher set is intentionally narrow so async / negation forms don't
|
|
230
|
+
// produce false positives on argument equality.
|
|
231
|
+
function hasRepeatedExpectArgument(source: string): boolean {
|
|
232
|
+
for (const match of source.matchAll(/\bexpect\s*\(\s*([^)]+?)\s*\)\s*\.\s*to(?:Be|Equal|StrictEqual)\s*\(\s*([^)]+?)\s*\)/g)) {
|
|
233
|
+
if (normalizeAssertionExpression(match[1] ?? "") === normalizeAssertionExpression(match[2] ?? "")) {
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Trims whitespace and strips a trailing semicolon so that `foo;` and `foo` compare as equal -
|
|
241
|
+
// preserves the deterministic mirrored-argument detection across whitespace variations.
|
|
242
|
+
function normalizeAssertionExpression(expression: string): string {
|
|
243
|
+
return expression.trim().replace(/;$/, "");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Strips every snapshot-shaped assertion plus `expect.assertions(...)` and re-checks whether any
|
|
247
|
+
// assertion remains. A body that empties out is flagged for `test-quality.snapshot-only-test`,
|
|
248
|
+
// since snapshot fixtures alone don't constrain behaviour.
|
|
249
|
+
function isSnapshotOnlyTest(source: string): boolean {
|
|
250
|
+
if (!/\.\s*toMatch(?:Inline)?Snapshot\s*\(/.test(source)) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
const withoutSnapshots = source
|
|
254
|
+
.replace(/\bexpect\s*\([\s\S]*?\)\s*\.\s*toMatch(?:Inline)?Snapshot\s*\([^)]*\)\s*;?/g, "")
|
|
255
|
+
.replace(/\bexpect\.(?:assertions|hasAssertions)\s*\([^)]*\)\s*;?/g, "");
|
|
256
|
+
return !hasAssertion(withoutSnapshots);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Same shape as `isSnapshotOnlyTest` but for `doesNotThrow` / `not.toThrow`. A test that asserts
|
|
260
|
+
// only the absence of an exception is weak - `test-quality.no-throw-only-test` reports it so
|
|
261
|
+
// authors can add a real behaviour assertion alongside.
|
|
262
|
+
function isNoThrowOnlyTest(source: string): boolean {
|
|
263
|
+
if (!/\bassert\.doesNotThrow\s*\(|\.\s*not\s*\.\s*toThrow\s*\(/.test(source)) {
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
const withoutNoThrow = source
|
|
267
|
+
.replace(/\bassert\.doesNotThrow\s*\([\s\S]*?\)\s*;?/g, "")
|
|
268
|
+
.replace(/\bexpect\s*\([\s\S]*?\)\s*\.\s*not\s*\.\s*toThrow\s*\([^)]*\)\s*;?/g, "")
|
|
269
|
+
.replace(/\bexpect\.(?:assertions|hasAssertions)\s*\([^)]*\)\s*;?/g, "");
|
|
270
|
+
return !hasAssertion(withoutNoThrow);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Pulls every numeric expected value out of `expect(...).toBe(n)` and `assert.equal(actual, n)`
|
|
274
|
+
// shapes. `-1`, `0`, `1` are excluded because they're universally idiomatic - the intent is to
|
|
275
|
+
// avoid noise on neutral values and surface only literals whose meaning a maintainer must look up.
|
|
276
|
+
function magicNumberAssertions(source: string): Array<{ value: number }> {
|
|
277
|
+
return MAGIC_NUMBER_ASSERTION_PATTERNS.flatMap((candidate) => magicNumberAssertionMatches(source, candidate));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const MAGIC_NUMBER_ASSERTION_PATTERNS: MagicNumberAssertionPattern[] = [
|
|
281
|
+
{ pattern: /\bexpect\s*\(\s*([^)]+?)\s*\)\s*\.\s*to(?:Be|Equal|HaveLength|HaveCount)\s*\(\s*(-?\d+(?:\.\d+)?)\s*\)/g, expressionIndex: 1, valueIndex: 2 },
|
|
282
|
+
{ pattern: /\bassert\.(?:equal|strictEqual|deepEqual)\s*\(\s*([^,\n]+?)\s*,\s*(-?\d+(?:\.\d+)?)(?:\s*,|\s*\))/g, expressionIndex: 1, valueIndex: 2 },
|
|
283
|
+
];
|
|
284
|
+
|
|
285
|
+
// Extracts reportable numeric literals for one assertion grammar while keeping HTTP statuses quiet.
|
|
286
|
+
function magicNumberAssertionMatches(source: string, candidate: MagicNumberAssertionPattern): Array<{ value: number }> {
|
|
287
|
+
const ignored = new Set([-1, 0, 1]);
|
|
288
|
+
const results: Array<{ value: number }> = [];
|
|
289
|
+
for (const match of source.matchAll(candidate.pattern)) {
|
|
290
|
+
const expression = match[candidate.expressionIndex] ?? "";
|
|
291
|
+
const expectedNumber = Number(match[candidate.valueIndex] ?? "0");
|
|
292
|
+
if (!ignored.has(expectedNumber) && !isHttpStatusAssertion(expression, expectedNumber)) {
|
|
293
|
+
results.push({ value: expectedNumber });
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return results;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// HTTP response status codes are intentionally numeric at assertion sites; naming every 200/404
|
|
300
|
+
// would add ceremony without making the test clearer.
|
|
301
|
+
function isHttpStatusAssertion(expression: string, expectedNumber: number): boolean {
|
|
302
|
+
return Number.isInteger(expectedNumber) && expectedNumber >= 100 && expectedNumber <= 599 && /\b(?:status|statusCode)\b/.test(expression);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// `const mockX = vi.fn(...)` declarations whose binding appears only once in the body - that one
|
|
306
|
+
// occurrence is the declaration itself, so the mock is created but never wired in. Reports the
|
|
307
|
+
// names for `test-quality.unused-mock` to anchor on.
|
|
308
|
+
function unusedMockVariables(source: string): string[] {
|
|
309
|
+
const names: string[] = [];
|
|
310
|
+
for (const match of source.matchAll(/\bconst\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:(?:vi|jest)\.fn|sinon\.stub|createMock|mock)\s*\(/g)) {
|
|
311
|
+
const name = match[1] ?? "";
|
|
312
|
+
if (name) {
|
|
313
|
+
const escaped = escapeRegex(name);
|
|
314
|
+
if (countMatches(source, new RegExp(`\\b${escaped}\\b`, "g")) <= 1) {
|
|
315
|
+
names.push(name);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return names;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Three gates in order: a mock factory must exist, a mock-call matcher must be asserted, and
|
|
323
|
+
// *every* `expect(target)` argument must look like a mock/stub/spy name. All three together
|
|
324
|
+
// signal a test that only verifies its own scaffolding - flagged for `test-quality.mock-only-test`.
|
|
325
|
+
function isMockOnlyTest(source: string): boolean {
|
|
326
|
+
if (!/\b(?:vi|jest)\.fn\s*\(|\b(?:createMock|mock|sinon\.stub)\s*\(/.test(source)) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
if (!/\.(?:toHaveBeenCalled|toHaveBeenCalledWith|toHaveBeenNthCalledWith|toBeCalled|toBeCalledWith)\s*\(/.test(source)) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
const targets = [...source.matchAll(/\bexpect\s*\(\s*([A-Za-z_$][A-Za-z0-9_$]*)\s*\)/g)].map((match) => match[1] ?? "");
|
|
333
|
+
return targets.length > 0 && targets.every((target) => /(?:mock|stub|spy)$/i.test(target));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// `toThrow(Error)` / `assert.throws(fn, Error)` constrain only the constructor, not the message
|
|
337
|
+
// or properties. Reports `test-quality.exception-type-only` so authors tighten the assertion.
|
|
338
|
+
function hasExceptionTypeOnlyAssertion(source: string): boolean {
|
|
339
|
+
return /\.toThrow\s*\(\s*(?:Error|[A-Z][A-Za-z0-9_$]*Error)\s*\)/.test(source) || /\bassert\.throws\s*\([^,\n]+,\s*(?:Error|[A-Z][A-Za-z0-9_$]*Error)\s*\)/.test(source);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Three known anti-patterns: writing to `process.env`, writing to `globalThis.*`, or reassigning
|
|
343
|
+
// `Date.now` / `Math.random`. Each leaks state across tests; reports
|
|
344
|
+
// `test-quality.global-state-mutation` so the author isolates the fixture.
|
|
345
|
+
function hasGlobalStateMutation(source: string): boolean {
|
|
346
|
+
return /\bprocess\.env\.[A-Za-z0-9_]+\s*=/.test(source) || /\bglobalThis\.[A-Za-z0-9_$]+\s*=/.test(source) || /\b(?:Date\.now|Math\.random)\s*=/.test(source);
|
|
347
|
+
}
|