@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
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
|
+
}
|