@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTRIBUTING.md +87 -0
  3. package/LICENSE +21 -0
  4. package/README.md +303 -0
  5. package/SECURITY.md +45 -0
  6. package/bin/gruff-ts +25 -0
  7. package/docs/CONFIGURATION.md +220 -0
  8. package/docs/RELEASING.md +103 -0
  9. package/docs/REPORTS_AND_CI.md +156 -0
  10. package/fixtures/sample.ts +21 -0
  11. package/package.json +56 -0
  12. package/scripts/bump-version.sh +145 -0
  13. package/scripts/check.sh +4 -0
  14. package/scripts/npm-publish.sh +258 -0
  15. package/scripts/preflight-checks.sh +357 -0
  16. package/scripts/start-dev.sh +8 -0
  17. package/scripts/test-performance.sh +695 -0
  18. package/src/analyser.ts +461 -0
  19. package/src/baseline.ts +90 -0
  20. package/src/blocks.ts +687 -0
  21. package/src/class-rules.ts +326 -0
  22. package/src/cli-program.ts +326 -0
  23. package/src/cli.ts +19 -0
  24. package/src/comment-rules.ts +605 -0
  25. package/src/comment-scanner.ts +357 -0
  26. package/src/config.ts +622 -0
  27. package/src/constants.ts +4 -0
  28. package/src/context-doc-rules.ts +241 -0
  29. package/src/dashboard.ts +114 -0
  30. package/src/dead-code-rules.ts +183 -0
  31. package/src/discovery.ts +508 -0
  32. package/src/doc-rules.ts +368 -0
  33. package/src/findings-helpers.ts +108 -0
  34. package/src/findings.ts +45 -0
  35. package/src/fixture-purpose-rules.ts +334 -0
  36. package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
  37. package/src/github-actions-rules.ts +413 -0
  38. package/src/line-rules.ts +538 -0
  39. package/src/naming-pushers.ts +191 -0
  40. package/src/project-config-rules.ts +555 -0
  41. package/src/project-rules.ts +545 -0
  42. package/src/report-renderers.ts +691 -0
  43. package/src/rule-list.ts +179 -0
  44. package/src/rules.ts +135 -0
  45. package/src/safety-rules.ts +355 -0
  46. package/src/scoring.ts +74 -0
  47. package/src/security-flow-rules.ts +112 -0
  48. package/src/sensitive-data-rules.ts +288 -0
  49. package/src/source-text.ts +722 -0
  50. package/src/test-block-rules.ts +347 -0
  51. package/src/test-fixtures.ts +621 -0
  52. package/src/text-scans.ts +193 -0
  53. package/src/types.ts +113 -0
  54. 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
+ }