@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,368 @@
1
+ // JSDoc and exported-declaration documentation rules. Covers `docs.missing-public-doc`,
2
+ // `docs.missing-file-overview`, `docs.missing-interface-doc`, and the docblock rule pack
3
+ // (stale/missing @param, missing @returns, useless docblock). Stable, deterministic emission order.
4
+ import { parameterNames } from "./blocks.ts";
5
+ import { commentTextAtLine, hasLeadingCommentBeforeLine } from "./comment-scanner.ts";
6
+ import { type SourceFile } from "./discovery.ts";
7
+ import { makeFinding } from "./findings.ts";
8
+ import { normalizedIdentifier, splitIdentifierWords } from "./findings-helpers.ts";
9
+ import { byteLine } from "./text-scans.ts";
10
+ import type { Finding } from "./types.ts";
11
+
12
+ // Lightweight shape used by both public-doc and class/file-mismatch rules. Holds the declaration
13
+ // keyword (`class`, `interface`, …), the symbol name, and the declaration line for finding anchors.
14
+ export interface ExportedDeclaration {
15
+ kind: string;
16
+ name: string;
17
+ line: number;
18
+ }
19
+
20
+ // Precomputed JSDoc + signature pair used by every docblock rule (stale-param, missing-param,
21
+ // missing-return, useless-docblock) so the parser runs only once per source file.
22
+ interface DocumentedExportBlock {
23
+ doc: string;
24
+ name: string;
25
+ params: string[];
26
+ paramTags: string[];
27
+ line: number;
28
+ returnType: string;
29
+ }
30
+
31
+ // Argument bundle shared by every docblock-related finding builder. `parameter` is optional
32
+ // because some docblock rules anchor on the symbol alone, not a specific parameter.
33
+ interface DocFindingInput {
34
+ ruleId: string;
35
+ message: string;
36
+ file: SourceFile;
37
+ line: number;
38
+ symbol: string;
39
+ parameter?: string;
40
+ }
41
+
42
+ // Scans the masked code for the five exportable kinds. The order returned matches source order
43
+ // because `matchAll` walks left-to-right, which is what downstream rules depend on.
44
+ export function exportedDeclarations(source: string, codeSource: string): ExportedDeclaration[] {
45
+ return [...codeSource.matchAll(/\bexport\s+(class|interface|type|enum|function)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((match) => ({
46
+ kind: match[1] ?? "",
47
+ name: match[2] ?? "",
48
+ line: byteLine(source, match.index ?? 0),
49
+ }));
50
+ }
51
+
52
+ /*
53
+ * Skips functions and interfaces - those have dedicated rules (`docs.missing-function-doc`,
54
+ * `docs.missing-interface-doc`). Reports the stable `docs.missing-public-doc` finding for
55
+ * classes/types/enums without a leading JSDoc-style block comment.
56
+ */
57
+ export function pushMissingPublicDocFinding(file: SourceFile, source: string, declaration: ExportedDeclaration, findings: Finding[]): void {
58
+ if (declaration.kind === "function" || declaration.kind === "interface") {
59
+ return;
60
+ }
61
+ if (hasDocCommentBeforeLine(source, declaration.line)) {
62
+ return;
63
+ }
64
+ findings.push(
65
+ makeFinding({
66
+ ruleId: "docs.missing-public-doc",
67
+ message: `Exported item \`${declaration.name}\` is missing a doc comment.`,
68
+ filePath: file.displayPath,
69
+ line: declaration.line,
70
+ severity: "advisory",
71
+ pillar: "documentation",
72
+ confidence: "medium",
73
+ symbol: declaration.name,
74
+ remediation: "Add a /** ... */ comment explaining the exported API.",
75
+ }),
76
+ );
77
+ }
78
+
79
+ // Anchors the finding at line 1 because the overview comment is expected at the very top of the
80
+ // file. Reports the stable `docs.missing-file-overview` finding when no top-of-file comment exists.
81
+ export function analyseFileOverviewDoc(file: SourceFile, source: string, findings: Finding[]): void {
82
+ if (hasFileOverviewComment(source)) {
83
+ return;
84
+ }
85
+ findings.push(
86
+ makeFinding({
87
+ ruleId: "docs.missing-file-overview",
88
+ message: `Source file \`${file.displayPath}\` is missing a top-of-file purpose comment.`,
89
+ filePath: file.displayPath,
90
+ line: 1,
91
+ severity: "advisory",
92
+ pillar: "documentation",
93
+ confidence: "medium",
94
+ remediation: "Add a brief /** ... */ overview before imports or declarations.",
95
+ metadata: {},
96
+ }),
97
+ );
98
+ }
99
+
100
+ /*
101
+ * Same shape as `pushMissingFunctionDocFinding` but for interfaces. The stable
102
+ * `docs.missing-interface-doc` rule reports any exported interface without a leading comment block.
103
+ */
104
+ export function analyseInterfaceDocs(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
105
+ for (const declaration of interfaceDeclarations(source, codeSource)) {
106
+ if (hasLeadingCommentBeforeLine(source, declaration.line)) {
107
+ continue;
108
+ }
109
+ findings.push(
110
+ makeFinding({
111
+ ruleId: "docs.missing-interface-doc",
112
+ message: `Interface \`${declaration.name}\` is missing a leading maintainer comment.`,
113
+ filePath: file.displayPath,
114
+ line: declaration.line,
115
+ severity: "advisory",
116
+ pillar: "documentation",
117
+ confidence: "medium",
118
+ symbol: declaration.name,
119
+ remediation: "Add a short /** ... */ or // comment explaining the interface contract.",
120
+ metadata: { interfaceName: declaration.name },
121
+ }),
122
+ );
123
+ }
124
+ }
125
+
126
+ // Line-anchored regex (`^[ \t]*` + `gm` flag) so the match start is the declaration line, not the
127
+ // first character of the keyword inside another construct. See lessons file for the indent-newline trap.
128
+ export function interfaceDeclarations(source: string, codeSource: string): ExportedDeclaration[] {
129
+ return [...codeSource.matchAll(/^[ \t]*(?:export[ \t]+)?interface[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)\b/gm)].map((match) => ({
130
+ kind: "interface",
131
+ name: match[1] ?? "",
132
+ line: byteLine(source, match.index ?? 0),
133
+ }));
134
+ }
135
+
136
+ /*
137
+ * Docblock rule pack. Walks every `/** … *\/ export function …` pair and fires four sub-rules per
138
+ * block in a stable, deterministic emission order (stale-param → missing-param → missing-return → useless-docblock).
139
+ */
140
+ export function analyseDocRules(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
141
+ for (const documentedExport of documentedExportBlocks(source, codeSource)) {
142
+ pushStaleParamFindings(file, documentedExport, findings);
143
+ pushMissingParamFindings(file, documentedExport, findings);
144
+ pushMissingReturnFinding(file, documentedExport, findings);
145
+ pushUselessDocblockFinding(file, documentedExport, findings);
146
+ }
147
+ }
148
+
149
+ // Walks every `/** … */ export function …` pair in the source. Skips matches whose `export`
150
+ // keyword is inside a string/regex by confirming it shows up in the masked code as well.
151
+ function documentedExportBlocks(source: string, codeSource: string): DocumentedExportBlock[] {
152
+ const blocks: DocumentedExportBlock[] = [];
153
+ const documentedExport = /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*export\s+function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)\s*(?::\s*([^\x7b\n]+))?/g;
154
+ for (const match of source.matchAll(documentedExport)) {
155
+ const block = documentedExportBlock(source, codeSource, match);
156
+ if (block) {
157
+ blocks.push(block);
158
+ }
159
+ }
160
+ return blocks;
161
+ }
162
+
163
+ // Promotes a regex match into the structured block consumed by docblock rules. Returns undefined
164
+ // when the matched `export` keyword is actually inside a string literal in the masked source.
165
+ function documentedExportBlock(source: string, codeSource: string, match: RegExpMatchArray): DocumentedExportBlock | undefined {
166
+ const matchStart = regexMatchStart(match);
167
+ const exportIndex = source.indexOf("export", matchStart);
168
+ if (!isDocumentedExportInCode(codeSource, exportIndex)) {
169
+ return undefined;
170
+ }
171
+ const doc = regexGroup(match, 1);
172
+ return {
173
+ doc,
174
+ name: regexGroup(match, 2),
175
+ params: parameterNames(regexGroup(match, 3)).map((parameter) => parameter.name),
176
+ paramTags: docParamTags(doc),
177
+ line: byteLine(source, matchStart),
178
+ returnType: regexGroup(match, 4).trim(),
179
+ };
180
+ }
181
+
182
+ // `index ?? 0` adapter for the standard regex API - match.index is technically optional under
183
+ // strict TypeScript even though every real match has it.
184
+ function regexMatchStart(match: RegExpMatchArray): number {
185
+ return match.index ?? 0;
186
+ }
187
+
188
+ // `match[index] ?? ""` adapter - keeps callers from sprinkling default-empty handling.
189
+ function regexGroup(match: RegExpMatchArray, index: number): string {
190
+ return match[index] ?? "";
191
+ }
192
+
193
+ // Confirms the captured `export` keyword is in real code, not inside a masked comment or string.
194
+ // The mask preserves the first letter of code tokens, so checking for `e` is sufficient.
195
+ function isDocumentedExportInCode(codeSource: string, exportIndex: number): boolean {
196
+ return exportIndex >= 0 && codeSource[exportIndex] === "e";
197
+ }
198
+
199
+ // Reports stale `@param` tags before missing ones because a rename should produce a stable pair:
200
+ // reports the "old name is stale" finding first because that order is the review contract.
201
+ function pushStaleParamFindings(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
202
+ for (const tag of block.paramTags) {
203
+ if (!block.params.includes(tag)) {
204
+ findings.push(docFinding({ ruleId: "docs.stale-param-tag", message: `Docblock for \`${block.name}\` has stale @param tag \`${tag}\`.`, file, line: block.line, symbol: block.name, parameter: tag }));
205
+ }
206
+ }
207
+ }
208
+
209
+ // Complements `pushStaleParamFindings`: reports stable one-argument findings because reviewers should not re-parse the signature mentally.
210
+ function pushMissingParamFindings(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
211
+ for (const param of block.params) {
212
+ if (!block.paramTags.includes(param)) {
213
+ findings.push(docFinding({ ruleId: "docs.missing-param-tag", message: `Docblock for \`${block.name}\` is missing @param for \`${param}\`.`, file, line: block.line, symbol: block.name, parameter: param }));
214
+ }
215
+ }
216
+ }
217
+
218
+ // `@returns` only required when the signature declares a non-void return type. `void` is exempt
219
+ // because documenting "returns nothing" is noise. Reports the stable `docs.missing-return-tag`.
220
+ function pushMissingReturnFinding(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
221
+ if (!needsReturnTag(block)) {
222
+ return;
223
+ }
224
+ findings.push(docFinding({ ruleId: "docs.missing-return-tag", message: `Docblock for \`${block.name}\` is missing @returns.`, file, line: block.line, symbol: block.name }));
225
+ }
226
+
227
+ // Three conditions: a declared return type exists, it isn't void, and the docblock doesn't already
228
+ // have a `@returns` tag. Annotation-less and void returns are exempt.
229
+ function needsReturnTag(block: DocumentedExportBlock): boolean {
230
+ return block.returnType !== "" && !/^void\b/.test(block.returnType) && !/@returns?\b/.test(block.doc);
231
+ }
232
+
233
+ // Docblock-flavoured useless-docblock rule. Targets `/** Foo */ export function foo` shapes
234
+ // that fail the same restate test as line-comment docs, then reports a stable `docs.useless-docblock`.
235
+ function pushUselessDocblockFinding(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
236
+ if (isUselessDocblock(block.doc, block.name)) {
237
+ findings.push(docFinding({ ruleId: "docs.useless-docblock", message: `Docblock for \`${block.name}\` only restates the signature.`, file, line: block.line, symbol: block.name }));
238
+ }
239
+ }
240
+
241
+ // Single makeFinding factory for every docblock-rule finding. `parameter` is omitted (not set to
242
+ // undefined) under exactOptionalPropertyTypes so the metadata shape stays stable and each
243
+ // baseline fingerprint round-trips cleanly across runs.
244
+ function docFinding(input: DocFindingInput): Finding {
245
+ return makeFinding({
246
+ ruleId: input.ruleId,
247
+ message: input.message,
248
+ filePath: input.file.displayPath,
249
+ line: input.line,
250
+ severity: "advisory",
251
+ pillar: "documentation",
252
+ confidence: "medium",
253
+ symbol: input.symbol,
254
+ remediation: "Update the JSDoc so it documents the current signature and return value.",
255
+ metadata: { ...(input.parameter ? { parameter: input.parameter } : {}) },
256
+ });
257
+ }
258
+
259
+ // Pulls every `@param name` tag's name from a docblock. Order is preserved so callers can spot
260
+ // duplicate tags by simple list comparison rather than set membership.
261
+ function docParamTags(doc: string): string[] {
262
+ const names: string[] = [];
263
+ for (const line of doc.split(/\r?\n/)) {
264
+ const name = docParamTagName(line);
265
+ if (name) {
266
+ names.push(name);
267
+ }
268
+ }
269
+ return names;
270
+ }
271
+
272
+ // Skips the `{Type}` braces before reading the identifier. The two-step approach lets the type
273
+ // portion be arbitrarily complex (unions, generics) without breaking the identifier extraction.
274
+ function docParamTagName(line: string): string | undefined {
275
+ const marker = line.indexOf("@param");
276
+ if (marker === -1) {
277
+ return undefined;
278
+ }
279
+ const rest = stripDocParamType(line.slice(marker + "@param".length).trim());
280
+ return rest.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/)?.[1];
281
+ }
282
+
283
+ // Removes a leading `{Type}` cluster from a `@param` tag tail. Returns the empty string when the
284
+ // braces are unbalanced - a malformed tag should not contribute a phantom parameter name.
285
+ function stripDocParamType(rest: string): string {
286
+ if (!rest.startsWith(String.fromCharCode(123))) {
287
+ return rest;
288
+ }
289
+ const end = rest.indexOf(String.fromCharCode(125));
290
+ return end === -1 ? "" : rest.slice(end + 1).trim();
291
+ }
292
+
293
+ // Normalises the docblock to its lowercase word run and compares against the symbol's expanded
294
+ // word list. Empty docblocks are considered useless; the equality fallback on
295
+ // `normalizedIdentifier` catches the case where punctuation alone separates the two.
296
+ function isUselessDocblock(doc: string, symbol: string): boolean {
297
+ const words = doc
298
+ .split(/\r?\n/)
299
+ .map((line) => line.replace(/^\s*\*\s?/, "").trim())
300
+ .filter((line) => line !== "" && !line.startsWith("@"))
301
+ .join(" ")
302
+ .toLowerCase()
303
+ .replace(/[^a-z0-9 ]/g, " ")
304
+ .replace(/\s+/g, " ")
305
+ .trim();
306
+ if (!words) {
307
+ return true;
308
+ }
309
+ return words === splitIdentifierWords(symbol).join(" ") || normalizedIdentifier(words) === normalizedIdentifier(symbol);
310
+ }
311
+
312
+ // Walks upward from the declaration looking for `/** */` or `*` continuation lines. The boundary
313
+ // predicate stops the search at the first real code or non-doc text, so docblocks from earlier
314
+ // declarations don't leak across.
315
+ function hasDocCommentBeforeLine(source: string, line: number): boolean {
316
+ const lines = source.split(/\r?\n/);
317
+ let index = line - 2;
318
+ while (index >= 0) {
319
+ const current = lines[index]?.trim() ?? "";
320
+ if (isDocCommentLine(current)) {
321
+ return true;
322
+ }
323
+ if (isDocCommentSearchBoundary(current)) {
324
+ return false;
325
+ }
326
+ index -= 1;
327
+ }
328
+ return false;
329
+ }
330
+
331
+ // `/**` openers and `*` continuation lines are both docblock material. Plain `//` comments are
332
+ // intentionally excluded - those are tracked separately by `hasLeadingCommentBeforeLine`.
333
+ function isDocCommentLine(trimmedLine: string): boolean {
334
+ return trimmedLine.startsWith("/**") || trimmedLine.startsWith("*");
335
+ }
336
+
337
+ // Halts the docblock walker when the upward search lands on real code. `@` lines are allowed
338
+ // through because JSDoc tags appear inside the docblock body and shouldn't terminate the scan.
339
+ function isDocCommentSearchBoundary(trimmedLine: string): boolean {
340
+ return trimmedLine !== "" && !trimmedLine.startsWith("@");
341
+ }
342
+
343
+ // File-overview presence check for `docs.missing-file-overview`. Skips a shebang first because
344
+ // scripts conventionally place `#!` on line 1, then asks whether the first real line is a
345
+ // comment - that is the contract the rule reports against.
346
+ function hasFileOverviewComment(source: string): boolean {
347
+ const lines = source.split(/\r?\n/);
348
+ let index = firstMeaningfulLineIndex(lines);
349
+ if (index === undefined) {
350
+ return false;
351
+ }
352
+ if (lines[index]?.startsWith("#!")) {
353
+ index = firstMeaningfulLineIndex(lines, index + 1);
354
+ }
355
+ return index !== undefined && commentTextAtLine(lines, index) !== undefined;
356
+ }
357
+
358
+ // Returns the index of the first non-blank line at or after `start`. The implementation does
359
+ // not skip comments - callers asking for "first meaningful line" treat comment text as a
360
+ // meaningful signal (file-overview detection wants to land on the comment itself).
361
+ function firstMeaningfulLineIndex(lines: string[], start = 0): number | undefined {
362
+ for (let index = start; index < lines.length; index += 1) {
363
+ if ((lines[index] ?? "").trim() !== "") {
364
+ return index;
365
+ }
366
+ }
367
+ return undefined;
368
+ }
@@ -0,0 +1,108 @@
1
+ // Shared low-level finding factories and string utilities used across every rule pass. Kept as
2
+ // a leaf module (no rule-specific imports) so block/line/comment/project rule modules can depend
3
+ // on it without forming a cycle.
4
+ import { execFileSync } from "node:child_process";
5
+ import { basename } from "node:path";
6
+ import type { SourceFile } from "./discovery.ts";
7
+ import { makeFinding } from "./findings.ts";
8
+ import type { Finding, Pillar, Severity } from "./types.ts";
9
+
10
+ // Input bundle for `finding()` - the lowest-cost finding factory. Captures everything the caller
11
+ // must supply for a line-anchored Finding; shared defaults (confidence "high", empty metadata)
12
+ // are added inside the builder so callers don't repeat them at every rule site.
13
+ export interface LineFindingArgs {
14
+ ruleId: string;
15
+ message: string;
16
+ file: SourceFile;
17
+ line: number;
18
+ severity: Severity;
19
+ pillar: Pillar;
20
+ }
21
+
22
+ // Cheapest finding factory: line-anchored, no symbol, confidence "high". Produces the
23
+ // (ruleId, filePath, line) tuple that every per-line emission relies on - this tuple is the
24
+ // stable fingerprint that drives baseline matching and report determinism.
25
+ export function finding(args: LineFindingArgs): Finding {
26
+ return makeFinding({ ruleId: args.ruleId, message: args.message, filePath: args.file.displayPath, line: args.line, severity: args.severity, pillar: args.pillar, confidence: "high" });
27
+ }
28
+
29
+ // Diff-aware discovery: uses `execFileSync` (not `execSync`) so the `mode` value is passed as
30
+ // an argv entry and a malicious value cannot inject shell metacharacters. Custom-mode values pass
31
+ // through `--end-of-options` so a leading `-` cannot be reinterpreted as a `git diff` flag
32
+ // (e.g., `--output=…`). Normalises path separators to `/` for clean display-path joins.
33
+ export function changedFiles(mode: string): Set<string> {
34
+ if (mode === "staged") {
35
+ return gitPathSet(["diff", "--name-only", "--cached"]);
36
+ }
37
+ if (mode === "unstaged") {
38
+ return gitPathSet(["diff", "--name-only"]);
39
+ }
40
+ if (mode === "working-tree") {
41
+ return new Set([...gitPathSet(["diff", "--name-only"]), ...gitPathSet(["diff", "--name-only", "--cached"]), ...gitPathSet(["ls-files", "--others", "--exclude-standard"])]);
42
+ }
43
+ return gitPathSet(["diff", "--name-only", "--end-of-options", mode]);
44
+ }
45
+
46
+ // Spawns `git` through execFileSync with one fixed argv vector and returns the normalized path set
47
+ // used by diff filtering; spawns no shell because callers choose argv arrays, never shell strings.
48
+ function gitPathSet(args: string[]): Set<string> {
49
+ return new Set(execFileSync("git", args, { encoding: "utf8" }).split(/\r?\n/).filter(Boolean).map((line) => line.replaceAll("\\", "/")));
50
+ }
51
+
52
+ // Strips directory and trailing extension. Used by `naming.class-file-mismatch` so the exported
53
+ // symbol name and the file stem normalise to the same shape - both sides must agree on this
54
+ // canonical form for the deterministic comparison to be meaningful.
55
+ export function fileBaseName(path: string): string {
56
+ return basename(path).replace(/\.[^.]+$/, "");
57
+ }
58
+
59
+ // Lowercase-and-strip-separators canonical form. Treats `FooBar`, `foo_bar`, and `foo-bar` as
60
+ // the same key so naming rules can compare across case styles without baking the convention in.
61
+ export function normalizedIdentifier(identifier: string): string {
62
+ return identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
63
+ }
64
+
65
+ // Inserts a space at every camelCase boundary, then splits on any non-alphanumeric run. Acronym
66
+ // runs (`HTTPServer`) stay intact because the inserted boundary is `lower → Upper`, not
67
+ // `Upper → Upper` - callers comparing word lists rely on this to keep tokens aligned.
68
+ export function splitIdentifierWords(identifier: string): string[] {
69
+ return identifier
70
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
71
+ .split(/[^A-Za-z0-9]+/)
72
+ .map((word) => word.toLowerCase())
73
+ .filter(Boolean);
74
+ }
75
+
76
+ // Counts newlines before the offset to get a 0-based line number. `Math.max(0, …)` guards against
77
+ // negative input - callers occasionally pass `match.index` which is typed as optional.
78
+ export function lineOffset(source: string, index: number): number {
79
+ return source.slice(0, Math.max(0, index)).split("\n").length - 1;
80
+ }
81
+
82
+ // Two-stage detector for `waste.commented-out-code`: first checks for a leading code keyword
83
+ // (`const`, `function`, `if`, etc.), then falls back to a `foo()` / `foo.bar()` call shape.
84
+ // The keyword list is intentionally conservative to avoid flagging prose that starts with `if`.
85
+ export function isCommentedOutCode(line: string): boolean {
86
+ const trimmed = line.trim();
87
+ if (!trimmed.startsWith("//")) {
88
+ return false;
89
+ }
90
+ const uncommented = trimmed.replace(/^\/\/+\s?/, "");
91
+ if (/^(const|let|var|function|class|interface|type|enum|import|export|if|for|while|switch|return|throw|await)\b/.test(uncommented)) {
92
+ return true;
93
+ }
94
+ return /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\);?$/.test(uncommented);
95
+ }
96
+
97
+ // Lowercase membership test against the configured banned-names set. Drives the
98
+ // `naming.identifier-quality` predicate so the rule stays a single Set lookup per identifier.
99
+ export function isGenericName(name: string, bannedNames: Set<string>): boolean {
100
+ return bannedNames.has(name.toLowerCase());
101
+ }
102
+
103
+ // Escapes the standard regex metacharacters so user-supplied strings (rule IDs, identifiers,
104
+ // paths) can be embedded in dynamic patterns without altering their meaning. Hot path -
105
+ // used by every rule that builds a per-source RegExp.
106
+ export function escapeRegex(source: string): string {
107
+ return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
108
+ }
@@ -0,0 +1,45 @@
1
+ // Finding factory helpers that centralize fingerprint generation and optional-field omission.
2
+ import { createHash } from "node:crypto";
3
+ import type { Confidence, Finding, Pillar, Severity } from "./types.ts";
4
+
5
+ // Caller-supplied fields. makeFinding hashes (ruleId, filePath, line, symbol) into the fingerprint,
6
+ // so any field added here that should affect baseline identity must also be folded into that hash.
7
+ interface FindingInput {
8
+ ruleId: string;
9
+ message: string;
10
+ filePath: string;
11
+ line?: number;
12
+ severity: Severity;
13
+ pillar: Pillar;
14
+ confidence: Confidence;
15
+ symbol?: string;
16
+ remediation?: string;
17
+ metadata?: Record<string, unknown>;
18
+ }
19
+
20
+ // Builds the canonical Finding shape. The 16-hex fingerprint is the baseline identity used by
21
+ // `gruff.baseline.v1`; changing the hashed fields or their join order is a baseline-breaking
22
+ // schema change. Optional fields are omitted (not set to undefined) so finding equality stays stable.
23
+ function makeFinding(input: FindingInput): Finding {
24
+ const fingerprint = createHash("sha256")
25
+ .update([input.ruleId, input.filePath, input.line ?? "", input.symbol ?? ""].join("\0"))
26
+ .digest("hex")
27
+ .slice(0, 16);
28
+ return {
29
+ ruleId: input.ruleId,
30
+ message: input.message,
31
+ filePath: input.filePath,
32
+ ...(input.line ? { line: input.line } : {}),
33
+ severity: input.severity,
34
+ pillar: input.pillar,
35
+ secondaryPillars: [],
36
+ tier: "v0.1",
37
+ confidence: input.confidence,
38
+ ...(input.symbol ? { symbol: input.symbol } : {}),
39
+ ...(input.remediation ? { remediation: input.remediation } : {}),
40
+ metadata: input.metadata ?? {},
41
+ fingerprint,
42
+ };
43
+ }
44
+
45
+ export { makeFinding };