@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
package/src/blocks.ts ADDED
@@ -0,0 +1,687 @@
1
+ // Function-block parsing + per-block rule pass (size, complexity, NPath, god-function, doc,
2
+ // empty-function, unused-parameter, redundant-variable, useless-return) and the block-anchored
3
+ // finding factories. Pulls the parser and the rules that operate on parsed blocks out of cli.ts.
4
+ import { ruleSeverity, threshold } from "./config.ts";
5
+ import { hasLeadingCommentBeforeLines } from "./comment-scanner.ts";
6
+ import { type SourceFile } from "./discovery.ts";
7
+ import { makeFinding } from "./findings.ts";
8
+ import { escapeRegex, isGenericName, lineOffset } from "./findings-helpers.ts";
9
+ import { countMatches } from "./text-scans.ts";
10
+ import type { Config, Finding, Pillar, Severity } from "./types.ts";
11
+
12
+ const NPATH_CAP = 1_000_000;
13
+
14
+ // Parsed callable body shared by every block-level rule (size, complexity, naming, docs). The
15
+ // `body` / `codeBody` split (raw text vs. comment-masked) lets rules choose between literal
16
+ // inspection and code-only matching without re-running the masker.
17
+ export interface FunctionBlock {
18
+ name: string;
19
+ params: string;
20
+ startLine: number;
21
+ lineCount: number;
22
+ body: string;
23
+ codeBody: string;
24
+ isPublic: boolean;
25
+ isTest: boolean;
26
+ hasLeadingComment: boolean;
27
+ declarationLine: number;
28
+ }
29
+
30
+ // Working state for the function-block parser. Patterns are precompiled once per file so each
31
+ // callable detection doesn't re-instantiate the same RegExp objects.
32
+ interface FunctionBlockScan {
33
+ lines: string[];
34
+ codeLines: string[];
35
+ patterns: RegExp[];
36
+ }
37
+
38
+ const FUNCTION_BLOCK_PATTERNS = functionBlockPatterns();
39
+
40
+ // Tiny lexer for finding the closing brace of a callable. `hasSeenOpen` matters because the depth
41
+ // counter would otherwise hit zero before the body ever opened (arrow functions with a default body).
42
+ interface FunctionBodyScanState {
43
+ depth: number;
44
+ hasSeenOpen: boolean;
45
+ }
46
+
47
+ // Precomputed inputs for every block-level rule. Computing cyclomatic / functionBody once and
48
+ // reusing the values keeps each rule's deterministic per-block work down to a single pattern test.
49
+ export interface BlockRuleContext {
50
+ file: SourceFile;
51
+ block: FunctionBlock;
52
+ config: Config;
53
+ findings: Finding[];
54
+ cyclomatic: number;
55
+ functionBody: string;
56
+ }
57
+
58
+ // NPath approximation result. `isCapped: true` signals the value hit `NPATH_CAP` and is a lower
59
+ // bound - the finding message uses this to mark capped values rather than implying precision.
60
+ export interface NpathResult {
61
+ value: number;
62
+ isCapped: boolean;
63
+ }
64
+
65
+ // Input bundle for `blockFinding()`. The block reference replaces the explicit line: the builder
66
+ // reads `block.startLine` and `block.name` so the stable finding anchor and symbol metadata stay
67
+ // in sync with the parsed callable across every block-level rule.
68
+ export interface BlockFindingArgs {
69
+ ruleId: string;
70
+ message: string;
71
+ file: SourceFile;
72
+ block: FunctionBlock;
73
+ severity: Severity;
74
+ pillar: Pillar;
75
+ }
76
+
77
+ // `BlockFindingArgs` plus a rule-specific metadata payload. Used by rules that need to encode
78
+ // numeric thresholds or measurements (size, complexity values) into the Finding so downstream
79
+ // consumers can filter without re-running the analyzer.
80
+ export interface BlockFindingWithMetadataArgs extends BlockFindingArgs {
81
+ metadata: Record<string, unknown>;
82
+ }
83
+
84
+ // Block-anchored finding factory: pulls line + symbol from the parsed callable so every
85
+ // block-level rule reports against the same anchor. Default confidence is "high"; callers
86
+ // needing metadata or lower confidence go through `blockFindingWithMetadata` to keep the
87
+ // per-rule fingerprint shape stable.
88
+ export function blockFinding(args: BlockFindingArgs): Finding {
89
+ return makeFinding({ ruleId: args.ruleId, message: args.message, filePath: args.file.displayPath, line: args.block.startLine, severity: args.severity, pillar: args.pillar, confidence: "high", symbol: args.block.name });
90
+ }
91
+
92
+ // Block-anchored variant that ships rule-specific metadata. Confidence defaults to "medium"
93
+ // because metadata-carrying rules (size, complexity, NPath) report measurements rather than
94
+ // definitive defects; the metadata payload is part of each rule's stable fingerprint contract.
95
+ export function blockFindingWithMetadata(args: BlockFindingWithMetadataArgs): Finding {
96
+ return makeFinding({ ruleId: args.ruleId, message: args.message, filePath: args.file.displayPath, line: args.block.startLine, severity: args.severity, pillar: args.pillar, confidence: "medium", symbol: args.block.name, metadata: args.metadata });
97
+ }
98
+
99
+ // Computes cyclomatic and function-body once and threads them through the per-block rule pipeline.
100
+ // Pre-computing here keeps each rule's per-block work to a single threshold comparison; the
101
+ // resulting struct is part of the stable rule-context contract every per-block helper consumes.
102
+ export function blockRuleContext(file: SourceFile, block: FunctionBlock, config: Config, findings: Finding[]): BlockRuleContext {
103
+ return {
104
+ file,
105
+ block,
106
+ config,
107
+ findings,
108
+ cyclomatic: countMatches(block.codeBody, /\b(if|else if|switch|case|for|while|catch)\b|\?|&&|\|\|/g) + 1,
109
+ functionBody: functionBodyContent(block.codeBody),
110
+ };
111
+ }
112
+
113
+ /*
114
+ * Per-block rule sequence. The ordering is the stable baseline contract - every block emits its
115
+ * findings in this exact deterministic order, so reshuffling the call list churns fingerprints
116
+ * even when no rule changes.
117
+ */
118
+ export function analyseBlockRules(context: BlockRuleContext): void {
119
+ pushFunctionLengthFinding(context);
120
+ pushParameterCountFinding(context);
121
+ pushCyclomaticFinding(context);
122
+ pushCognitiveFinding(context);
123
+ pushNpathFinding(context);
124
+ pushGodFunctionFinding(context);
125
+ pushGenericFunctionFinding(context);
126
+ pushMissingFunctionDocFinding(context);
127
+ pushEmptyFunctionFinding(context);
128
+ pushUnusedParameterFindings(context);
129
+ pushRedundantVariableFindings(context);
130
+ pushUselessReturnFindings(context);
131
+ }
132
+
133
+ // Default threshold 200, default severity `warning` - functions past that length are usually a
134
+ // maintenance signal, not a stylistic preference. Reports `size.function-length` when the block exceeds the limit.
135
+ function pushFunctionLengthFinding(context: BlockRuleContext): void {
136
+ const functionLengthThreshold = threshold(context.config, "size.function-length", 200);
137
+ if (context.block.lineCount > functionLengthThreshold) {
138
+ context.findings.push(blockFinding({ ruleId: "size.function-length", message: `Function \`${context.block.name}\` has ${context.block.lineCount} lines, above the threshold of ${functionLengthThreshold}.`, file: context.file, block: context.block, severity: ruleSeverity(context.config, "size.function-length", "warning"), pillar: "size" }));
139
+ }
140
+ }
141
+
142
+ // Default threshold 7. Comma-counts on `block.params` rather than parsing because the parser
143
+ // already validated the signature shape upstream. Reports `size.parameter-count` when exceeded.
144
+ function pushParameterCountFinding(context: BlockRuleContext): void {
145
+ const params = context.block.params.split(",").map((value) => value.trim()).filter(Boolean).length;
146
+ if (params > threshold(context.config, "size.parameter-count", 7)) {
147
+ context.findings.push(blockFinding({ ruleId: "size.parameter-count", message: `Function \`${context.block.name}\` declares ${params} parameters.`, file: context.file, block: context.block, severity: ruleSeverity(context.config, "size.parameter-count", "warning"), pillar: "size" }));
148
+ }
149
+ }
150
+
151
+ // Default threshold 15. Counts conditional keywords + boolean operators in the code body - see
152
+ // `blockRuleContext` for the pre-computed value. Reports `complexity.cyclomatic` when exceeded.
153
+ function pushCyclomaticFinding(context: BlockRuleContext): void {
154
+ if (context.cyclomatic > threshold(context.config, "complexity.cyclomatic", 15)) {
155
+ context.findings.push(blockFinding({ ruleId: "complexity.cyclomatic", message: `Function \`${context.block.name}\` has cyclomatic complexity ${context.cyclomatic}.`, file: context.file, block: context.block, severity: ruleSeverity(context.config, "complexity.cyclomatic", "warning"), pillar: "complexity" }));
156
+ }
157
+ }
158
+
159
+ // Default threshold 15. Cognitive complexity is cyclomatic + max nesting depth - captures the
160
+ // "deeply nested" intuition pure cyclomatic misses. Reports `complexity.cognitive` when exceeded.
161
+ function pushCognitiveFinding(context: BlockRuleContext): void {
162
+ const cognitive = context.cyclomatic + maxNestingDepth(context.block.codeBody);
163
+ if (cognitive > threshold(context.config, "complexity.cognitive", 15)) {
164
+ context.findings.push(blockFinding({ ruleId: "complexity.cognitive", message: `Function \`${context.block.name}\` has cognitive complexity ${cognitive}.`, file: context.file, block: context.block, severity: ruleSeverity(context.config, "complexity.cognitive", "warning"), pillar: "complexity" }));
165
+ }
166
+ }
167
+
168
+ // Default threshold 200. NPath is the number of possible execution paths; the message marks
169
+ // `capped` when the calculation hit `NPATH_CAP` so reviewers know it's a lower bound. Reports `complexity.npath`.
170
+ function pushNpathFinding(context: BlockRuleContext): void {
171
+ const npath = approximateNpath(context.functionBody);
172
+ const npathThreshold = threshold(context.config, "complexity.npath", 200);
173
+ if (npath.value > npathThreshold) {
174
+ context.findings.push(npathFinding(context, npath, npathThreshold, ruleSeverity(context.config, "complexity.npath", "warning")));
175
+ }
176
+ }
177
+
178
+ /*
179
+ * Stable `complexity.npath` finding factory. `capped` and `cap` are surfaced in metadata so
180
+ * downstream tooling can distinguish "5000" from "≥ NPATH_CAP" - both would render as the same
181
+ * value otherwise.
182
+ */
183
+ function npathFinding(context: BlockRuleContext, npath: NpathResult, thresholdValue: number, severity: Severity): Finding {
184
+ return blockFindingWithMetadata({
185
+ ruleId: "complexity.npath",
186
+ message: `Function \`${context.block.name}\` has approximate NPath complexity ${npath.value} above the threshold of ${thresholdValue} (capped at ${NPATH_CAP}).`,
187
+ file: context.file,
188
+ block: context.block,
189
+ severity,
190
+ pillar: "complexity",
191
+ metadata: { npath: npath.value, capped: npath.isCapped, cap: NPATH_CAP, threshold: thresholdValue },
192
+ });
193
+ }
194
+
195
+ // Combined-shape rule: long (>45 lines) AND complex (cyclomatic >10). Thresholds are hard-coded
196
+ // because "god function" is a design heuristic, not a per-rule tunable. Reports `design.god-function`.
197
+ function pushGodFunctionFinding(context: BlockRuleContext): void {
198
+ if (context.block.lineCount > 45 && context.cyclomatic > 10) {
199
+ context.findings.push(blockFinding({ ruleId: "design.god-function", message: `Function \`${context.block.name}\` is both long and complex.`, file: context.file, block: context.block, severity: "warning", pillar: "design" }));
200
+ }
201
+ }
202
+
203
+ // Names like `process`, `handle`, `run` from `config.bannedGenericNames`. The list is user-configurable;
204
+ // the rule body itself just consults the config. Reports `naming.generic-function`.
205
+ function pushGenericFunctionFinding(context: BlockRuleContext): void {
206
+ if (isGenericName(context.block.name, context.config.bannedGenericNames)) {
207
+ context.findings.push(blockFinding({ ruleId: "naming.generic-function", message: `Function \`${context.block.name}\` is too generic to explain intent.`, file: context.file, block: context.block, severity: "advisory", pillar: "naming" }));
208
+ }
209
+ }
210
+
211
+ // Every non-test function must carry a leading comment. Test blocks are exempted because their
212
+ // `test("name", …)` description already documents intent. Reports `docs.missing-function-doc`.
213
+ function pushMissingFunctionDocFinding(context: BlockRuleContext): void {
214
+ if (!context.block.isTest && !context.block.hasLeadingComment) {
215
+ context.findings.push(blockFinding({ ruleId: "docs.missing-function-doc", message: `Function \`${context.block.name}\` is missing a leading maintainer comment.`, file: context.file, block: context.block, severity: "advisory", pillar: "documentation" }));
216
+ }
217
+ }
218
+
219
+ // Empty bodies are sometimes intentional placeholders, hence the advisory severity rather than
220
+ // warning. Reports `waste.empty-function` when the body strips to whitespace/comments only.
221
+ function pushEmptyFunctionFinding(context: BlockRuleContext): void {
222
+ if (isBodyLessDeclaration(context.block) || isDeclarationFile(context.file)) {
223
+ return;
224
+ }
225
+ if (isEmptyFunctionBody(context.block.codeBody)) {
226
+ context.findings.push(blockFinding({ ruleId: "waste.empty-function", message: `Function \`${context.block.name}\` has no executable body.`, file: context.file, block: context.block, severity: "advisory", pillar: "waste" }));
227
+ }
228
+ }
229
+
230
+ // `_`-prefixed parameters are exempted (the standard "intentionally unused" convention).
231
+ // Reports `waste.unused-parameter` for parameter names that never appear in the callable body.
232
+ function pushUnusedParameterFindings(context: BlockRuleContext): void {
233
+ if (isBodyLessDeclaration(context.block) || isDeclarationFile(context.file)) {
234
+ return;
235
+ }
236
+ for (const parameter of parameterNames(context.block.params)) {
237
+ if (!isUnusedParameter(context, parameter.name)) {
238
+ continue;
239
+ }
240
+ context.findings.push(unusedParameterFinding(context, parameter.name));
241
+ }
242
+ }
243
+
244
+ // Skips interface methods, type-literal methods, function-type aliases, abstract members, and
245
+ // overload signatures - all of which look like a function declaration ending in `;` rather than `{`,
246
+ // and have no real body to check for emptiness or parameter usage.
247
+ function isBodyLessDeclaration(block: FunctionBlock): boolean {
248
+ for (const rawLine of block.codeBody.split("\n")) {
249
+ const trimmed = rawLine.trim();
250
+ if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("/*") || trimmed.startsWith("*")) {
251
+ continue;
252
+ }
253
+ return /\)[^{;]*;\s*$/.test(trimmed);
254
+ }
255
+ return false;
256
+ }
257
+
258
+ // TypeScript `.d.ts` files only declare types; every callable in them is a signature, never an
259
+ // implementation, so the empty/unused-parameter rules are categorically misapplied there.
260
+ function isDeclarationFile(file: SourceFile): boolean {
261
+ return file.displayPath.endsWith(".d.ts");
262
+ }
263
+
264
+ // Word-boundary regex against the masked function body. The body is masked so a parameter mentioned
265
+ // only in a string literal would still count as unused - that matches the intent of the rule. A
266
+ // loose `${...param...}` regex over the raw body catches parameters used only inside template
267
+ // interpolations, which the mask would otherwise hide.
268
+ function isUnusedParameter(context: BlockRuleContext, parameterName: string): boolean {
269
+ if (parameterName.startsWith("_")) {
270
+ return false;
271
+ }
272
+ const escaped = escapeRegex(parameterName);
273
+ if (new RegExp(`\\b${escaped}\\b`).test(context.functionBody)) {
274
+ return false;
275
+ }
276
+ return !new RegExp(`\\$\\{[^}]*\\b${escaped}\\b[^}]*\\}`).test(context.block.body);
277
+ }
278
+
279
+ /*
280
+ * Stable `waste.unused-parameter` finding shape. `block.startLine` is the anchor so the fingerprint
281
+ * stays at the callable's declaration line, not at the parameter's column position.
282
+ */
283
+ function unusedParameterFinding(context: BlockRuleContext, parameterName: string): Finding {
284
+ return makeFinding({
285
+ ruleId: "waste.unused-parameter",
286
+ message: `Parameter \`${parameterName}\` does not appear to be used.`,
287
+ filePath: context.file.displayPath,
288
+ line: context.block.startLine,
289
+ severity: "advisory",
290
+ pillar: "waste",
291
+ confidence: "medium",
292
+ symbol: context.block.name,
293
+ remediation: "Remove the parameter or prefix it with _ if it is intentionally unused.",
294
+ metadata: { parameter: parameterName },
295
+ });
296
+ }
297
+
298
+ // Targets `const x = expr; return x;` patterns. The detector walks the function source once and
299
+ // reports each variable whose only use is the trailing return as `waste.redundant-variable`.
300
+ function pushRedundantVariableFindings(context: BlockRuleContext): void {
301
+ for (const redundant of redundantVariableReturns(context.block.codeBody)) {
302
+ context.findings.push(
303
+ makeFinding({
304
+ ruleId: "waste.redundant-variable",
305
+ message: `Variable \`${redundant.name}\` is returned immediately after assignment.`,
306
+ filePath: context.file.displayPath,
307
+ line: context.block.startLine + redundant.lineOffset,
308
+ severity: "advisory",
309
+ pillar: "waste",
310
+ confidence: "medium",
311
+ symbol: redundant.name,
312
+ remediation: "Return the expression directly.",
313
+ metadata: { variable: redundant.name },
314
+ }),
315
+ );
316
+ }
317
+ }
318
+
319
+ // Caller adds the block's start line to the relative offset so the finding anchors at the actual
320
+ // trailing statement. Reports `waste.useless-return` when the final statement is a redundant bare exit.
321
+ function pushUselessReturnFindings(context: BlockRuleContext): void {
322
+ for (const lineOffset of terminalBareReturnLines(context.block.codeBody)) {
323
+ context.findings.push(
324
+ makeFinding({
325
+ ruleId: "waste.useless-return",
326
+ message: `Function \`${context.block.name}\` ends with a redundant bare return.`,
327
+ filePath: context.file.displayPath,
328
+ line: context.block.startLine + lineOffset,
329
+ severity: "advisory",
330
+ pillar: "waste",
331
+ confidence: "medium",
332
+ symbol: context.block.name,
333
+ remediation: "Remove the final return statement.",
334
+ }),
335
+ );
336
+ }
337
+ }
338
+
339
+ // Approximation: each decision keyword (`if`, `case`, `catch`, loops) and short-circuit operator
340
+ // doubles the count. Optional chaining is stripped first so `a?.b` and `??` don't inflate the
341
+ // signal. Capped at `NPATH_CAP` - the `capped` flag tells callers to report the value as ≥, not =.
342
+ export function approximateNpath(source: string): NpathResult {
343
+ let pathCount = 1;
344
+ let isCapped = false;
345
+ const normalized = source.replace(/\?\./g, "").replace(/\?\?/g, "");
346
+ const decisionCount = countMatches(normalized, /\b(if|else if|case|catch|for|while)\b|\?|&&|\|\|/g);
347
+ for (let index = 0; index < decisionCount; index += 1) {
348
+ pathCount *= 2;
349
+ if (pathCount >= NPATH_CAP) {
350
+ pathCount = NPATH_CAP;
351
+ isCapped = true;
352
+ break;
353
+ }
354
+ }
355
+ return { value: pathCount, isCapped };
356
+ }
357
+
358
+ // Strips line and block comments before measuring - a body containing only documentation is still
359
+ // considered empty for the `waste.empty-function` check, since no executable statements run.
360
+ function isEmptyFunctionBody(source: string): boolean {
361
+ const body = functionBodyContent(source)
362
+ .replace(/\/\/.*$/gm, "")
363
+ .replace(/\/\*[\s\S]*?\*\//g, "")
364
+ .trim();
365
+ return body === "";
366
+ }
367
+
368
+ // Two shapes: block-body callables return the text between the outermost `{` and `}`; expression
369
+ // arrow functions fall back to the slice after `=>`. The trailing-`;` strip keeps the arrow
370
+ // branch usable for downstream regex tests that anchor on statement boundaries.
371
+ export function functionBodyContent(source: string): string {
372
+ const start = source.indexOf("{");
373
+ const end = source.lastIndexOf("}");
374
+ if (start === -1 || end <= start) {
375
+ const arrow = source.indexOf("=>");
376
+ return arrow === -1 ? "" : source.slice(arrow + 2).replace(/;?\s*$/, "");
377
+ }
378
+ return source.slice(start + 1, end);
379
+ }
380
+
381
+ // Walks upward past blank lines and the closing `}` looking for a final `return;`. Returns the
382
+ // line offset of that statement (zero-based) or an empty list if the last real line is anything
383
+ // else - used by `waste.redundant-variable` so the finding anchors on the actual statement.
384
+ function terminalBareReturnLines(source: string): number[] {
385
+ const lines = source.split(/\r?\n/);
386
+ let current = lines.length - 1;
387
+ while (current >= 0) {
388
+ const trimmed = lines[current]?.trim() ?? "";
389
+ if (trimmed === "" || trimmed === "}") {
390
+ current -= 1;
391
+ continue;
392
+ }
393
+ return /^return\s*;?$/.test(trimmed) ? [current] : [];
394
+ }
395
+ return [];
396
+ }
397
+
398
+ // Splits on `,` then strips visibility modifiers, `...rest`, default values, and type annotations
399
+ // in that order. Final filter rejects entries whose name isn't a plain identifier - destructured
400
+ // parameters land in that bucket and are intentionally invisible to per-parameter rules.
401
+ export function parameterNames(params: string): Array<{ name: string; raw: string }> {
402
+ return params
403
+ .split(",")
404
+ .map((parameter) => parameter.trim())
405
+ .filter(Boolean)
406
+ .map((raw) => {
407
+ const stripped = raw.replace(/^(?:public|private|protected|readonly)\s+/, "").replace(/^\.\.\./, "");
408
+ const name = stripped.split(/[?:=]/)[0]?.trim() ?? "";
409
+ return { name, raw: stripped };
410
+ })
411
+ .filter((parameter) => /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(parameter.name));
412
+ }
413
+
414
+ // Detects the `const x = expr; return x;` pattern. The regex backreference `\1` enforces that the
415
+ // returned identifier matches the declared one - used by `waste.redundant-variable` to surface
416
+ // pointless temporaries with deterministic line offsets.
417
+ function redundantVariableReturns(source: string): Array<{ name: string; lineOffset: number }> {
418
+ const results: Array<{ name: string; lineOffset: number }> = [];
419
+ for (const match of source.matchAll(/\b(?:const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*[^;]+;\s*return\s+\1\s*;/g)) {
420
+ results.push({ name: match[1] ?? "", lineOffset: lineOffset(source, match.index ?? 0) });
421
+ }
422
+ return results.filter((result) => result.name !== "");
423
+ }
424
+
425
+ // Deepest `{` / `}` nesting reached across the body, minus one so the body's own outer braces
426
+ // don't count. The `Math.max(0, …)` clamp protects against unbalanced inputs - feeds the nesting
427
+ // component of `complexity.cognitive` and must stay deterministic across runs.
428
+ export function maxNestingDepth(source: string): number {
429
+ let depth = 0;
430
+ let maxDepth = 0;
431
+ for (const character of source) {
432
+ if (character === "{") {
433
+ depth += 1;
434
+ maxDepth = Math.max(maxDepth, depth);
435
+ } else if (character === "}") {
436
+ depth = Math.max(0, depth - 1);
437
+ }
438
+ }
439
+ return Math.max(0, maxDepth - 1);
440
+ }
441
+
442
+ // Matches `test("…", …)` and `it("…", …)` openers. Used both by the function-block parser to
443
+ // pick the right pattern and by setup detection to skip the test wrapper line itself.
444
+ function isTestInvocationLine(line: string): boolean {
445
+ return /^\s*(?:test|it)\s*\(/.test(line);
446
+ }
447
+
448
+ // Top-level driver: precompiles patterns once, then walks the masked code lines so commented-out
449
+ // declarations don't fire. The two-source split (`source` / `codeSource`) keeps raw line text
450
+ // available for body extraction while preserving stable, comment-masked matching.
451
+ export function functionBlocks(source: string, codeSource = source): FunctionBlock[] {
452
+ const scan: FunctionBlockScan = {
453
+ lines: source.split(/\r?\n/),
454
+ codeLines: codeSource.split(/\r?\n/),
455
+ patterns: FUNCTION_BLOCK_PATTERNS,
456
+ };
457
+ const blocks: FunctionBlock[] = [];
458
+ scan.codeLines.forEach((line, index) => {
459
+ const match = functionBlockMatch(scan, line, index);
460
+ if (!match) {
461
+ return;
462
+ }
463
+ blocks.push(functionBlockFromMatch(scan, match, index));
464
+ });
465
+ return blocks;
466
+ }
467
+
468
+ // Four callable shapes in the order `functionBlockMatch` tries them: `test()` / `it()` bodies,
469
+ // `function` declarations, class methods, and arrow assignments. Pattern[0] is intentionally
470
+ // first because test bodies must match before the generic arrow pattern claims them.
471
+ function functionBlockPatterns(): RegExp[] {
472
+ return [
473
+ /^\s*(?:test|it)\s*\(\s*["'`]([^"'`]+)["'`]\s*,\s*(?:async\s*)?\(([^)]*)\)\s*=>/,
474
+ /^\s*(?:export\s+)?(?:async\s+)?function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)/,
475
+ /^\s*(?:public|private|protected)?\s*(?:async\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)\s*[:{]/,
476
+ /^\s*(?:const|let)\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*(?:async\s*)?\(([^)]*)\)\s*=>/,
477
+ ];
478
+ }
479
+
480
+ // Tries each compiled pattern in order and returns the first hit. The loop bails when a pattern
481
+ // slot is missing (defensive guard for the precompiled list) and uses the patternIndex to let
482
+ // `functionPatternMatch` pick raw vs. masked text per pattern.
483
+ function functionBlockMatch(scan: FunctionBlockScan, line: string, index: number): RegExpMatchArray | undefined {
484
+ const rawLine = scan.lines[index] ?? "";
485
+ for (let patternIndex = 0; patternIndex < scan.patterns.length; patternIndex += 1) {
486
+ const pattern = scan.patterns[patternIndex];
487
+ if (!pattern) {
488
+ continue;
489
+ }
490
+ const match = functionPatternMatch(pattern, patternIndex, line, rawLine);
491
+ if (match) {
492
+ return match;
493
+ }
494
+ }
495
+ return undefined;
496
+ }
497
+
498
+ // Pattern[0] (`test`/`it`) needs the raw line because the test name lives inside a string
499
+ // literal that the masker would otherwise blank out. Everything else runs against the masked
500
+ // `line`. Filters out control-block keywords so `if(...) {` doesn't register as a callable.
501
+ function functionPatternMatch(pattern: RegExp, patternIndex: number, line: string, rawLine: string): RegExpMatchArray | undefined {
502
+ const candidate = patternIndex === 0 && isTestInvocationLine(line) ? rawLine : line;
503
+ const match = candidate.match(pattern);
504
+ if (!match?.[1] || isControlBlockName(match[1])) {
505
+ return undefined;
506
+ }
507
+ return match;
508
+ }
509
+
510
+ // Promotes a regex match into a `FunctionBlock`. `start` walks up to capture leading docblock /
511
+ // decorator lines so size and documentation rules see the full declaration footprint; the
512
+ // `isPublic` check scans that same range so `export` / `public` modifiers above the line count.
513
+ function functionBlockFromMatch(scan: FunctionBlockScan, match: RegExpMatchArray, index: number): FunctionBlock {
514
+ const start = functionStartIndex(scan.lines, index);
515
+ const end = functionEndIndex(scan, index);
516
+ const body = scan.lines.slice(start, end + 1).join("\n");
517
+ const codeBody = scan.codeLines.slice(start, end + 1).join("\n");
518
+ return {
519
+ name: match[1] ?? "",
520
+ params: match[2] ?? "",
521
+ startLine: start + 1,
522
+ lineCount: end - start + 1,
523
+ body,
524
+ codeBody,
525
+ isPublic: /\bexport\b|\bpublic\b/.test(scan.codeLines.slice(start, index + 1).join("\n")),
526
+ isTest: isTestInvocationLine(scan.codeLines[index] ?? ""),
527
+ hasLeadingComment: hasLeadingCommentBeforeLines(scan.lines, index + 1),
528
+ declarationLine: index + 1,
529
+ };
530
+ }
531
+
532
+ // Two callable shapes share one entry point: single-expression arrows return early via
533
+ // `expressionArrowEndIndex`, everything else falls into the brace-depth walker. Returning the
534
+ // wrong end index would slice the wrong body and silently corrupt every per-block rule's input.
535
+ function functionEndIndex(scan: FunctionBlockScan, index: number): number {
536
+ return expressionArrowEndIndex(scan.codeLines, index) ?? blockFunctionEndIndex(scan, index);
537
+ }
538
+
539
+ // Brace-depth walker over masked code lines. Operates on `codeLines` so braces inside strings or
540
+ // comments don't disturb the depth counter - the masker preserves brace positions in real code
541
+ // and neutralises them everywhere else, keeping the body slice stable.
542
+ function blockFunctionEndIndex(scan: FunctionBlockScan, index: number): number {
543
+ const state: FunctionBodyScanState = { depth: 0, hasSeenOpen: false };
544
+ let end = index;
545
+ for (let current = index; current < scan.lines.length; current += 1) {
546
+ for (const character of scan.codeLines[current] ?? "") {
547
+ applyFunctionBodyCharacter(state, character);
548
+ }
549
+ end = current;
550
+ if (isFunctionBodyClosed(state)) {
551
+ break;
552
+ }
553
+ }
554
+ return end;
555
+ }
556
+
557
+ // Per-character transition for the brace-depth walker. Setting `hasSeenOpen` on `{` is the
558
+ // invariant `isFunctionBodyClosed` relies on - without it, the walker would treat the
559
+ // pre-open state (depth 0) as already closed.
560
+ function applyFunctionBodyCharacter(state: FunctionBodyScanState, character: string): void {
561
+ if (character === "{") {
562
+ state.depth += 1;
563
+ state.hasSeenOpen = true;
564
+ } else if (character === "}") {
565
+ state.depth -= 1;
566
+ }
567
+ }
568
+
569
+ // Termination predicate for the brace-depth walker. Requires `hasSeenOpen` so the initial
570
+ // pre-body state doesn't read as already closed - the depth counter is only meaningful after the
571
+ // first `{`.
572
+ function isFunctionBodyClosed(state: FunctionBodyScanState): boolean {
573
+ return state.hasSeenOpen && state.depth <= 0;
574
+ }
575
+
576
+ // Returns the line index where a single-expression arrow body terminates. Detection bails when
577
+ // the line contains a `{` after `=>` (a block-bodied arrow); otherwise walks forward until a `;`
578
+ // closes the expression or a blank line ends it.
579
+ function expressionArrowEndIndex(codeLines: string[], index: number): number | undefined {
580
+ const line = codeLines[index] ?? "";
581
+ const arrowIndex = line.indexOf("=>");
582
+ if (!isExpressionArrowLine(line, arrowIndex)) {
583
+ return undefined;
584
+ }
585
+ for (let current = index; current < codeLines.length; current += 1) {
586
+ const endIndex = expressionArrowEndStep(codeLines, line, arrowIndex, index, current);
587
+ if (endIndex !== undefined) {
588
+ return endIndex;
589
+ }
590
+ }
591
+ return index;
592
+ }
593
+
594
+ // `=>` exists and no `{` follows it - that means the body is a bare expression, not a block.
595
+ // The distinction matters because expression bodies need a different end-of-block strategy.
596
+ function isExpressionArrowLine(line: string, arrowIndex: number): boolean {
597
+ return arrowIndex !== -1 && !line.slice(arrowIndex + 2).includes("{");
598
+ }
599
+
600
+ // One step of the arrow-expression walker. On the declaration line itself, a trailing `;` closes
601
+ // the body immediately. Otherwise: a blank line means we walked past the body (back up one
602
+ // index), and a `;`-terminated line is the actual end. Returning undefined means keep walking.
603
+ function expressionArrowEndStep(codeLines: string[], line: string, arrowIndex: number, start: number, current: number): number | undefined {
604
+ const trimmed = (codeLines[current] ?? "").trim();
605
+ if (current === start) {
606
+ return line.slice(arrowIndex + 2).trim().endsWith(";") ? current : undefined;
607
+ }
608
+ if (trimmed === "") {
609
+ return current - 1;
610
+ }
611
+ return trimmed.endsWith(";") ? current : undefined;
612
+ }
613
+
614
+ // `if (...) { … }`, `for (...)`, etc. all look like `<name>(` to the pattern walker. The fixed
615
+ // exclusion list prevents control-flow blocks from being treated as callables and reported by
616
+ // per-block rules.
617
+ function isControlBlockName(name: string): boolean {
618
+ return ["if", "for", "while", "switch", "catch"].includes(name);
619
+ }
620
+
621
+ // Walks upward from the declaration line absorbing decorator (`@`), docblock (`/**`, `*`), and
622
+ // blank lines so the function block includes its leading documentation. Stops at the first real
623
+ // code line above - that boundary becomes the block's start line.
624
+ function functionStartIndex(lines: string[], index: number): number {
625
+ let start = index;
626
+ while (start > 0) {
627
+ const previous = lines[start - 1]?.trim() ?? "";
628
+ if (isFunctionPrefixLine(previous)) {
629
+ start -= 1;
630
+ continue;
631
+ }
632
+ break;
633
+ }
634
+ return start;
635
+ }
636
+
637
+ // Predicate that decides whether `functionStartIndex` should keep walking upward. Decorators,
638
+ // docblock openers, docblock body lines (`*`), and blank lines all belong to the declaration's
639
+ // leading block; anything else marks the boundary.
640
+ function isFunctionPrefixLine(trimmedLine: string): boolean {
641
+ return trimmedLine.startsWith("@") || trimmedLine.startsWith("/**") || trimmedLine.startsWith("*") || trimmedLine === "";
642
+ }
643
+
644
+ // Generic "is there any assertion at all" probe used by missing-assertion rules. Accepts standard
645
+ // `assert(...)` / `assert.foo(...)` / `expect(...)` (including `expect.assertions()` / `expect.hasAssertions()`)
646
+ // PLUS project-local helpers shaped like `assertFoo(...)`, `expectFoo(...)`, `fooCheck(...)`, and
647
+ // promise-rejection patterns (`rejects.`, `doesNotReject(`). Custom helpers are common in mature
648
+ // test suites and missing them produced false positives in M38 false-positive triage.
649
+ export function hasAssertion(source: string): boolean {
650
+ if (/\bassert(?:\.[A-Za-z]+|[A-Z][A-Za-z0-9_$]*)?\s*\(/.test(source)) {
651
+ return true;
652
+ }
653
+ if (/\bexpect(?:\.(?:assertions|hasAssertions)|[A-Z][A-Za-z0-9_$]*)?\s*\(/.test(source)) {
654
+ return true;
655
+ }
656
+ if (/\b[A-Za-z_$][A-Za-z0-9_$]*Check\s*\(/.test(source)) {
657
+ return true;
658
+ }
659
+ if (/\.(?:rejects|resolves)\b/.test(source) || /\b(?:doesNotReject|rejects)\s*\(/.test(source)) {
660
+ return true;
661
+ }
662
+ return false;
663
+ }
664
+
665
+ // Setup-bloat metric: counts non-ignorable lines preceding the first assertion in the body. Stops
666
+ // as soon as an assertion appears, so the value never overshoots the actual prologue length used
667
+ // by `test-quality.setup-bloat`.
668
+ export function setupLineCount(source: string): number {
669
+ let count = 0;
670
+ for (const line of functionBodyContent(source).split(/\r?\n/)) {
671
+ const trimmed = line.trim();
672
+ if (isIgnorableSetupLine(trimmed)) {
673
+ continue;
674
+ }
675
+ if (hasAssertion(trimmed)) {
676
+ break;
677
+ }
678
+ count += 1;
679
+ }
680
+ return count;
681
+ }
682
+
683
+ // Filter for `setupLineCount`. Blank lines plus the two closer shapes (`}` and `});`) shouldn't
684
+ // inflate the count - those are syntax, not setup work.
685
+ function isIgnorableSetupLine(trimmedLine: string): boolean {
686
+ return trimmedLine.length === 0 || trimmedLine === "});" || trimmedLine === "}";
687
+ }