@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,605 @@
1
+ // Every documentation, comment-quality, fixture-purpose, stale-reference, context-doc,
2
+ // magic-threshold, and restating-signature rule. The biggest of the rule-pack modules; the
3
+ // orchestrator `analyseCommentQualityRules` fans out per-comment / per-declaration / per-block
4
+ // passes that all share a stable, deterministic emission order.
5
+ import { existsSync } from "node:fs";
6
+ import { dirname as dirnamePath, resolve } from "node:path";
7
+ import { cwd } from "node:process";
8
+ import { type FunctionBlock } from "./blocks.ts";
9
+ import { type CommentRecord } from "./comment-scanner.ts";
10
+ import { type SourceFile } from "./discovery.ts";
11
+ import { type ExportedDeclaration, interfaceDeclarations } from "./doc-rules.ts";
12
+ import { type CommentedDeclaration, pushDeclarationContextFindings, pushFunctionContextFindings } from "./context-doc-rules.ts";
13
+ import { makeFinding } from "./findings.ts";
14
+ import { splitIdentifierWords } from "./findings-helpers.ts";
15
+ import { pushFixturePurposeFindings } from "./fixture-purpose-rules.ts";
16
+ import { isTestPath } from "./project-rules.ts";
17
+ import { ruleDescriptors } from "./rules.ts";
18
+ import type { Config, Finding } from "./types.ts";
19
+
20
+ type CommentQualityRuleInput = {
21
+ file: SourceFile;
22
+ source: string;
23
+ codeSource: string;
24
+ blocks: FunctionBlock[];
25
+ comments: CommentRecord[];
26
+ config: Config;
27
+ findings: Finding[];
28
+ };
29
+
30
+ type FunctionContextCommentQualityInput = {
31
+ file: SourceFile;
32
+ lines: string[];
33
+ comments: CommentRecord[];
34
+ blocks: FunctionBlock[];
35
+ config: Config;
36
+ findings: Finding[];
37
+ };
38
+
39
+ type MagicThresholdCandidate = {
40
+ label: string;
41
+ value: string;
42
+ kind: string;
43
+ };
44
+
45
+ const DESCRIPTOR_IDS = new Set(ruleDescriptors().map((descriptor) => descriptor.ruleId));
46
+ const CLI_FLAGS = knownCliFlags();
47
+
48
+
49
+ /*
50
+ * Coordinator for every comment-quality rule. Comments and declarations are parsed once and the
51
+ * rule descriptor + CLI flag sets are computed once, so every sub-rule sees the same stable inputs.
52
+ */
53
+ export function analyseCommentQualityRules(input: CommentQualityRuleInput): void {
54
+ const { file, source, codeSource, blocks, comments, config, findings } = input;
55
+ const lines = source.split(/\r?\n/);
56
+ const declarations = commentedDeclarations(blocks, interfaceDeclarations(source, codeSource));
57
+
58
+ analyseStandaloneCommentQuality(file, comments, DESCRIPTOR_IDS, CLI_FLAGS, findings);
59
+ analyseCommentedDeclarationQuality(file, lines, comments, declarations, findings);
60
+ analyseFunctionContextCommentQuality({ file, lines, comments, blocks, config, findings });
61
+ pushMagicThresholdFindings(file, lines, codeSource, comments, findings);
62
+ pushFixturePurposeFindings({ file, source, codeSource, lines, comments, blocks, config, findings });
63
+ }
64
+
65
+ /*
66
+ * Five per-comment rules (task tracking, suppression rationale, stale file refs, stale rule refs,
67
+ * stale CLI flag refs) that run on every comment regardless of whether it documents a declaration.
68
+ * Stable, deterministic emission order across the five sub-checks.
69
+ */
70
+ function analyseStandaloneCommentQuality(file: SourceFile, comments: CommentRecord[], ruleIdSet: Set<string>, optionFlagSet: Set<string>, findings: Finding[]): void {
71
+ for (const comment of comments) {
72
+ pushTodoWithoutTrackingFinding(file, comment, findings);
73
+ pushSuppressionWithoutRationaleFinding(file, comment, findings);
74
+ pushStaleFileReferenceFindings(file, comment, findings);
75
+ pushStaleRuleReferenceFindings(file, comment, ruleIdSet, findings);
76
+ pushStaleCliFlagReferenceFindings(file, comment, optionFlagSet, findings);
77
+ }
78
+ }
79
+
80
+ /*
81
+ * Per-declaration rules that need both the declaration metadata and its leading comment. Each
82
+ * declaration is checked against three rules (stale reference, restating signature, context-doc)
83
+ * in their stable, deterministic order.
84
+ */
85
+ function analyseCommentedDeclarationQuality(file: SourceFile, lines: string[], comments: CommentRecord[], declarations: CommentedDeclaration[], findings: Finding[]): void {
86
+ for (const declaration of declarations) {
87
+ const comment = leadingCommentForLine(lines, comments, declaration.line);
88
+ if (!comment) {
89
+ continue;
90
+ }
91
+ pushStaleDeclarationCommentFinding(file, comment, declaration, findings);
92
+ pushRestatingSignatureCommentFinding(file, comment, declaration, findings);
93
+ if (!isRestatingSignatureComment(comment.text, declaration.name, declaration.kind)) {
94
+ pushDeclarationContextFindings(file, lines, declaration, comment, findings);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Function-only context-doc rule. Restating-signature comments are skipped first because the
100
+ // useless-docblock rule has already flagged them; running context checks on top would be redundant noise.
101
+ function analyseFunctionContextCommentQuality(input: FunctionContextCommentQualityInput): void {
102
+ const { file, lines, comments, blocks, config, findings } = input;
103
+ for (const block of blocks) {
104
+ const comment = leadingCommentForLine(lines, comments, block.declarationLine);
105
+ if (!comment || isRestatingSignatureComment(comment.text, block.name, "function")) {
106
+ continue;
107
+ }
108
+ pushFunctionContextFindings(file, block, comment, config, findings);
109
+ }
110
+ }
111
+
112
+ // Combines callable blocks and exported interfaces into one homogeneous list for the comment-quality
113
+ // rules. Test blocks are excluded because their `test("…")` description is the documentation.
114
+ function commentedDeclarations(blocks: FunctionBlock[], interfaces: ExportedDeclaration[]): CommentedDeclaration[] {
115
+ return [
116
+ ...blocks
117
+ .filter((block) => !block.isTest)
118
+ .map((block) => ({ kind: "function" as const, name: block.name, line: block.declarationLine, isPublic: block.isPublic })),
119
+ ...interfaces.map((declaration) => ({ kind: "interface" as const, name: declaration.name, line: declaration.line, isPublic: true })),
120
+ ];
121
+ }
122
+
123
+ /*
124
+ * One finding per comment containing an untracked task marker. The reported marker keyword is
125
+ * preserved in stable metadata so consumers can group by marker kind. Reports the stable
126
+ * untracked-task-marker finding when no tracking reference is attached.
127
+ */
128
+ function pushTodoWithoutTrackingFinding(file: SourceFile, comment: CommentRecord, findings: Finding[]): void {
129
+ const marker = todoMarker(comment.text);
130
+ if (!marker || hasTodoTracking(comment.text)) {
131
+ return;
132
+ }
133
+ findings.push(
134
+ makeFinding({
135
+ ruleId: "docs.todo-without-tracking",
136
+ message: `${marker} comment is missing an issue, owner, date, ADR, or task reference.`,
137
+ filePath: file.displayPath,
138
+ line: comment.line,
139
+ severity: "advisory",
140
+ pillar: "documentation",
141
+ confidence: "high",
142
+ remediation: "Attach a tracking URL, issue id, owner, date, ADR, or .goat-flow task reference.",
143
+ metadata: { marker },
144
+ }),
145
+ );
146
+ }
147
+
148
+ // Four canonical task-marker words, returned in uppercase so the finding message reads consistently
149
+ // regardless of how the maintainer wrote them.
150
+ function todoMarker(text: string): string | undefined {
151
+ return text.match(/\b(TODO|FIXME|HACK|XXX)\b/i)?.[1]?.toUpperCase();
152
+ }
153
+
154
+ const TODO_TRACKING_PATTERNS = [
155
+ /https?:\/\//i,
156
+ /(?:^|\s)#\d+\b/,
157
+ /\bGH-\d+\b/i,
158
+ /\bM\d{1,3}\b/,
159
+ /\.goat-flow\/tasks\//,
160
+ /\bADR-\d{3}\b/i,
161
+ /\b\d{4}-\d{2}-\d{2}\b/,
162
+ /\bowner\s*:/i,
163
+ ] as const;
164
+
165
+ // Eight accepted tracking forms (URL, #123, GH-123, M123, .goat-flow/tasks, ADR-001, ISO date,
166
+ // `owner:`). The stable set is intentionally generous so projects with different ticketing systems
167
+ // can comply without changing their conventions.
168
+ function hasTodoTracking(text: string): boolean {
169
+ return TODO_TRACKING_PATTERNS.some((pattern) => pattern.test(text));
170
+ }
171
+
172
+ /*
173
+ * Targets `eslint-disable`, `biome-ignore`, coverage `istanbul ignore`, etc. when no maintainer
174
+ * rationale is attached - the false-positive escape hatch is explicit because TS suppression
175
+ * directives have their own dedicated rule. Reports the stable `docs.suppression-without-rationale` finding.
176
+ */
177
+ function pushSuppressionWithoutRationaleFinding(file: SourceFile, comment: CommentRecord, findings: Finding[]): void {
178
+ const suppression = suppressionDirective(comment.text);
179
+ if (!suppression || hasSuppressionRationale(comment.text)) {
180
+ return;
181
+ }
182
+ findings.push(
183
+ makeFinding({
184
+ ruleId: "docs.suppression-without-rationale",
185
+ message: `${suppression} suppression is missing a maintainer rationale.`,
186
+ filePath: file.displayPath,
187
+ line: comment.line,
188
+ severity: "advisory",
189
+ pillar: "documentation",
190
+ confidence: "medium",
191
+ remediation: "Explain why the suppression is intentional, a false positive, or tracked elsewhere.",
192
+ metadata: { suppression },
193
+ }),
194
+ );
195
+ }
196
+
197
+ // Returns the suppression keyword that triggered the rule. `@ts-*` directives are explicitly
198
+ // excluded - they have their own dedicated rule (`pushTsDirectiveFinding`).
199
+ function suppressionDirective(text: string): string | undefined {
200
+ if (/@ts-(?:ignore|expect-error|nocheck|check)\b/.test(text)) {
201
+ return undefined;
202
+ }
203
+ const match = text.match(/\b(eslint-disable(?:-next-line|-line)?|biome-ignore|oxlint-disable|istanbul ignore|c8 ignore|v8 ignore|prettier-ignore)\b/i);
204
+ return match?.[1];
205
+ }
206
+
207
+ // Accepted rationale forms: explanatory keywords (because, intentional, false positive, tracked in),
208
+ // project task markers (M123, ADR-XXX, GH-123), explicit `reason:`, a tracking URL, or a #issue.
209
+ export function hasSuppressionRationale(text: string): boolean {
210
+ return /\b(?:because|intentional|false positive|tracked in|M\d{1,3}|ADR-\d{3}|GH-\d+)\b/i.test(text) || /\breason\s*:/i.test(text) || /(?:^|\s)#\d+\b/.test(text) || /https?:\/\//i.test(text) || /\.goat-flow\/tasks\//.test(text);
211
+ }
212
+
213
+ /*
214
+ * Scans the comment text for path-shaped references and reports any that resolve to nothing on
215
+ * disk. Historical-context comments (migration notes, legacy markers) are exempted on purpose
216
+ * because they intentionally name removed files. Reports the stable `docs.stale-comment` finding.
217
+ */
218
+ function pushStaleFileReferenceFindings(file: SourceFile, comment: CommentRecord, findings: Finding[]): void {
219
+ if (isHistoricalContextComment(comment.text)) {
220
+ return;
221
+ }
222
+ for (const match of comment.text.matchAll(/[`'"]((?:\.{1,2}\/|src\/|bin\/|scripts\/|docs\/|fixtures\/|\.goat-flow\/)[A-Za-z0-9_./-]+\.(?:ts|tsx|js|json|ya?ml|toml|md|sh))[`'"]/g)) {
223
+ const referencedPath = match[1] ?? "";
224
+ if (referencedPathExists(file, referencedPath)) {
225
+ continue;
226
+ }
227
+ findings.push(staleCommentFinding(file, comment, `Comment references missing path \`${referencedPath}\`.`, { staleReference: referencedPath, referenceType: "path" }));
228
+ }
229
+ }
230
+
231
+ // Tries both the project-root and same-directory interpretations because comments are inconsistent
232
+ // about which they imply. Either match is enough to consider the reference live.
233
+ function referencedPathExists(file: SourceFile, referencedPath: string): boolean {
234
+ const fromProject = resolve(cwd(), referencedPath);
235
+ const fromFile = resolve(dirnamePath(file.absolutePath), referencedPath);
236
+ return existsSync(fromProject) || existsSync(fromFile);
237
+ }
238
+
239
+ /*
240
+ * Walks every `pillar.rule-id` shape in the comment and reports those not in the descriptor set.
241
+ * Historical-context comments stay exempt so removed rules can remain referenced in lessons text.
242
+ * Reports the stable `docs.stale-comment` finding for each stale rule id.
243
+ */
244
+ function pushStaleRuleReferenceFindings(file: SourceFile, comment: CommentRecord, ruleIdSet: Set<string>, findings: Finding[]): void {
245
+ if (isHistoricalContextComment(comment.text)) {
246
+ return;
247
+ }
248
+ for (const match of comment.text.matchAll(/\b((?:complexity|dead-code|design|docs|modernisation|naming|security|sensitive-data|size|test-quality|waste)\.[a-z0-9-]+)\b/g)) {
249
+ const ruleId = match[1] ?? "";
250
+ if (ruleIdSet.has(ruleId)) {
251
+ continue;
252
+ }
253
+ findings.push(staleCommentFinding(file, comment, `Comment references unknown rule id \`${ruleId}\`.`, { staleReference: ruleId, referenceType: "ruleId" }));
254
+ }
255
+ }
256
+
257
+ /*
258
+ * Each double-dash option in a comment must appear in `cliFlags` (parsed from the CLI source) or
259
+ * it counts as a stale reference. The rule's value is catching gruff-ts maintainers who rename a
260
+ * flag and forget to update its references - so the check only fires when the comment also names
261
+ * gruff-ts (or invokes the gruff/gruff-ts binary). In any other project, flag references belong to
262
+ * that project's CLI, not gruff-ts, and validating them against gruff-ts's own option surface
263
+ * produces only noise. Reports the stable `docs.stale-comment` finding for each unknown option.
264
+ */
265
+ function pushStaleCliFlagReferenceFindings(file: SourceFile, comment: CommentRecord, optionFlagSet: Set<string>, findings: Finding[]): void {
266
+ if (isHistoricalContextComment(comment.text)) {
267
+ return;
268
+ }
269
+ if (!mentionsGruffCli(comment.text)) {
270
+ return;
271
+ }
272
+ for (const match of comment.text.matchAll(/(?<![A-Za-z0-9])--[a-z][a-z0-9-]*/g)) {
273
+ const flag = match[0] ?? "";
274
+ if (optionFlagSet.has(flag)) {
275
+ continue;
276
+ }
277
+ findings.push(staleCommentFinding(file, comment, `Comment references unknown CLI flag \`${flag}\`.`, { staleReference: flag, referenceType: "cliFlag" }));
278
+ }
279
+ }
280
+
281
+ // True when the comment names the gruff-ts CLI by binary or product name. Acts as the activation
282
+ // gate for the unknown-CLI-flag check; comments that talk about other tools' flags never trip it.
283
+ function mentionsGruffCli(text: string): boolean {
284
+ return /\bgruff(?:-ts)?\b/i.test(text);
285
+ }
286
+
287
+ // Static list of valid CLI options. Hand-curated rather than parsed from the Commander definition
288
+ // at runtime because the stale-CLI-flag rule must not depend on import order - both files would
289
+ // otherwise have to load the analyser to power their checks.
290
+ function knownCliFlags(): Set<string> {
291
+ return new Set([
292
+ "--ansi",
293
+ "--baseline",
294
+ "--config",
295
+ "--diff",
296
+ "--fail-on",
297
+ "--format",
298
+ "--generate-baseline",
299
+ "--help",
300
+ "--history-file",
301
+ "--host",
302
+ "--include-ignored",
303
+ "--no-ansi",
304
+ "--no-baseline",
305
+ "--no-config",
306
+ "--no-interaction",
307
+ "--output",
308
+ "--port",
309
+ "--project-root",
310
+ "--quiet",
311
+ "--silent",
312
+ "--verbose",
313
+ "--version",
314
+ ]);
315
+ }
316
+
317
+ // A comment whose prose names a different symbol than the declaration directly below it. The
318
+ // historical-context escape hatch keeps migration notes (which intentionally name removed symbols)
319
+ // quiet. Reports `docs.stale-comment` with stable metadata.
320
+ function pushStaleDeclarationCommentFinding(file: SourceFile, comment: CommentRecord, declaration: CommentedDeclaration, findings: Finding[]): void {
321
+ if (isHistoricalContextComment(comment.text)) {
322
+ return;
323
+ }
324
+ const referencedName = referencedDeclarationName(comment.text, declaration.kind);
325
+ if (!referencedName || referencedName === declaration.name) {
326
+ return;
327
+ }
328
+ findings.push(staleCommentFinding(file, comment, `Comment names \`${referencedName}\` but documents \`${declaration.name}\`.`, { staleReference: referencedName, referenceType: declaration.kind, symbol: declaration.name }));
329
+ }
330
+
331
+ // Two-pass match: either `<kind> name` (e.g., "function fooBar") or leading `name <kind|helper|
332
+ // method|contract|type>`. Both forms appear in real comments and either is sufficient evidence
333
+ // that the comment intends to name a specific symbol.
334
+ function referencedDeclarationName(text: string, kind: CommentedDeclaration["kind"]): string | undefined {
335
+ const directBackticked = text.match(new RegExp(["\\b", kind, "\\s+`([A-Za-z_$][A-Za-z0-9_$]*)`"].join(""), "i"));
336
+ if (directBackticked?.[1]) {
337
+ return directBackticked[1];
338
+ }
339
+ const directCodeLike = text.match(new RegExp(["\\b", kind, "\\s+([A-Za-z_$][A-Za-z0-9_$]*)"].join(""), "i"));
340
+ if (directCodeLike?.[1] && isCodeLikeIdentifier(directCodeLike[1])) {
341
+ return directCodeLike[1];
342
+ }
343
+ return leadingReferencedDeclarationName(text, kind);
344
+ }
345
+
346
+ // The leading form is intentionally stricter than `<kind> name`: ordinary English such as
347
+ // "Read-only filesystem interface" or "Symlink helper" must not become a stale-symbol finding.
348
+ function leadingReferencedDeclarationName(text: string, kind: CommentedDeclaration["kind"]): string | undefined {
349
+ const backticked = text.match(new RegExp(["^`([A-Za-z_$][A-Za-z0-9_$]*)`\\s+(?:", kind, "|helper|method|contract|type)\\b"].join(""), "i"));
350
+ if (backticked?.[1]) {
351
+ return backticked[1];
352
+ }
353
+ const codeLike = text.match(new RegExp(["^([A-Za-z_$][A-Za-z0-9_$]*)\\s+(?:", kind, "|helper|method|contract|type)\\b"].join(""), "i"));
354
+ if (codeLike?.[1] && isCodeLikeIdentifier(codeLike[1])) {
355
+ return codeLike[1];
356
+ }
357
+ return undefined;
358
+ }
359
+
360
+ // Requires a real identifier marker beyond normal prose capitalization.
361
+ function isCodeLikeIdentifier(name: string): boolean {
362
+ return /[A-Z].*[A-Z]|[a-z][A-Z]|[_$]|\d/.test(name);
363
+ }
364
+
365
+ /*
366
+ * Public block-doc comments are exempted on purpose because their first words usually mirror the
367
+ * API surface - that's the documented JSDoc convention, not a useless docblock. Reports the stable
368
+ * `docs.useless-docblock` finding otherwise.
369
+ */
370
+ function pushRestatingSignatureCommentFinding(file: SourceFile, comment: CommentRecord, declaration: CommentedDeclaration, findings: Finding[]): void {
371
+ if (declaration.kind === "function" && declaration.isPublic && comment.kind === "block") {
372
+ return;
373
+ }
374
+ if (!isRestatingSignatureComment(comment.text, declaration.name, declaration.kind)) {
375
+ return;
376
+ }
377
+ findings.push(
378
+ makeFinding({
379
+ ruleId: "docs.useless-docblock",
380
+ message: `Comment for \`${declaration.name}\` only restates the signature.`,
381
+ filePath: file.displayPath,
382
+ line: comment.line,
383
+ severity: "advisory",
384
+ pillar: "documentation",
385
+ confidence: "medium",
386
+ symbol: declaration.name,
387
+ remediation: "Update the JSDoc so it documents the current signature and return value.",
388
+ metadata: {},
389
+ }),
390
+ );
391
+ }
392
+
393
+ /*
394
+ * Test files are exempt because their `expect(x).toBe(42)` patterns legitimately contain
395
+ * unexplained numbers. For every production source line, the rule looks for either a named
396
+ * threshold/limit/cap or a `threshold(config, …)` default call, then checks that a nearby comment
397
+ * explains it. Reports the stable `docs.magic-threshold-without-rationale` finding.
398
+ */
399
+ function pushMagicThresholdFindings(file: SourceFile, lines: string[], codeSource: string, comments: CommentRecord[], findings: Finding[]): void {
400
+ if (isTestPath(file.displayPath) || !hasMagicThresholdSignal(codeSource)) {
401
+ return;
402
+ }
403
+ const codeLines = codeSource.split(/\r?\n/);
404
+ codeLines.forEach((codeLine, index) => {
405
+ const candidate = magicThresholdCandidate(lines[index] ?? "", codeLine);
406
+ if (!candidate || hasNearbyThresholdRationale(lines, comments, index + 1)) {
407
+ return;
408
+ }
409
+ findings.push(
410
+ makeFinding({
411
+ ruleId: "docs.magic-threshold-without-rationale",
412
+ message: `Threshold-like value \`${candidate.label}\` lacks a nearby rationale comment.`,
413
+ filePath: file.displayPath,
414
+ line: index + 1,
415
+ severity: "advisory",
416
+ pillar: "documentation",
417
+ confidence: "medium",
418
+ symbol: candidate.label,
419
+ remediation: "Add a nearby comment explaining the threshold, limit, budget, or default.",
420
+ metadata: { value: candidate.value, thresholdKind: candidate.kind },
421
+ }),
422
+ );
423
+ });
424
+ }
425
+
426
+ // Cheap whole-file preflight for the two shapes `magicThresholdCandidate` can report.
427
+ function hasMagicThresholdSignal(codeSource: string): boolean {
428
+ return /\bthreshold\s*\(/.test(codeSource) || /\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:Threshold|Limit|Cap|Budget|Timeout|Tolerance|Weight|Score|Max|Min|Default|Entropy|Length)[A-Za-z0-9_$]*\b/i.test(codeSource);
429
+ }
430
+
431
+ // Two candidate sources: a named constant (`const maxThings = N`) or a `threshold()` default call.
432
+ // Either is treated as policy-shaped numeric - ordinary arithmetic constants stay quiet.
433
+ function magicThresholdCandidate(rawLine: string, codeLine: string): MagicThresholdCandidate | undefined {
434
+ return namedThresholdCandidate(rawLine, codeLine) ?? configDefaultThresholdCandidate(rawLine, codeLine);
435
+ }
436
+
437
+ // Identifiers ending in Threshold/Limit/Cap/Budget/Timeout/Tolerance/Weight/Score/Max/Min/Default/
438
+ // Entropy/Length signal "policy number". `-1`, `0`, `1`, `2` are exempt because they're usually sentinels.
439
+ // Gates on the masked `codeLine` so template-literal fixture content (where the same line appears as
440
+ // source text but inside a backtick) does not trip the rule.
441
+ function namedThresholdCandidate(rawLine: string, codeLine: string): MagicThresholdCandidate | undefined {
442
+ if (!/\b(?:const|let|var)\s+[A-Za-z_$][A-Za-z0-9_$]*(?:Threshold|Limit|Cap|Budget|Timeout|Tolerance|Weight|Score|Max|Min|Default|Entropy|Length)[A-Za-z0-9_$]*\b/i.test(codeLine)) {
443
+ return undefined;
444
+ }
445
+ const named = rawLine.match(/\b(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*(?:Threshold|Limit|Cap|Budget|Timeout|Tolerance|Weight|Score|Max|Min|Default|Entropy|Length)[A-Za-z0-9_$]*)\b[^=\n]*=\s*(-?\d+(?:\.\d+)?)/i);
446
+ const label = named?.[1];
447
+ const thresholdValue = named?.[2];
448
+ if (!label) {
449
+ return undefined;
450
+ }
451
+ if (!thresholdValue || isCommonSafeNumber(thresholdValue)) {
452
+ return undefined;
453
+ }
454
+ return { label, value: thresholdValue, kind: "named-threshold" };
455
+ }
456
+
457
+ // Cheap gate first: only look for the four-arg `threshold(config, "rule", "key", N)` form when the
458
+ // masked code actually contains `threshold(`. Required because labels come from raw text but the
459
+ // call shape must originate in executable code - masked code is the only stable signal for that.
460
+ function configDefaultThresholdCandidate(rawLine: string, codeLine: string): MagicThresholdCandidate | undefined {
461
+ if (!/\bthreshold\s*\(/.test(codeLine)) {
462
+ return undefined;
463
+ }
464
+ const thresholdDefault = rawLine.match(/\bthreshold\s*\([^)]*,\s*["']([^"']+)["']\s*,\s*["']([^"']+)["']\s*,\s*(-?\d+(?:\.\d+)?)\s*\)/);
465
+ const ruleId = thresholdDefault?.[1];
466
+ const key = thresholdDefault?.[2];
467
+ const thresholdValue = thresholdDefault?.[3];
468
+ if (!ruleId || !key) {
469
+ return undefined;
470
+ }
471
+ if (!thresholdValue || isCommonSafeNumber(thresholdValue)) {
472
+ return undefined;
473
+ }
474
+ return { label: `${ruleId}.${key}`, value: thresholdValue, kind: "config-default" };
475
+ }
476
+
477
+ // Four sentinel values that recur as "counter starts", "boolean toggle as int", and "default
478
+ // count" without being policy decisions. Anything outside this set is treated as a threshold.
479
+ function isCommonSafeNumber(numericLiteral: string): boolean {
480
+ return ["-1", "0", "1", "2"].includes(numericLiteral);
481
+ }
482
+
483
+ // Two acceptable positions for the explanatory comment: same line as the constant, or directly
484
+ // above with a blank-line gap. Mirrors `hasFixturePurposeComment` adjacency rules.
485
+ function hasNearbyThresholdRationale(lines: string[], comments: CommentRecord[], line: number): boolean {
486
+ const sameLine = comments.find((comment) => comment.line <= line && comment.endLine >= line);
487
+ if (sameLine && hasThresholdRationaleMarker(sameLine.text)) {
488
+ return true;
489
+ }
490
+ const leading = leadingCommentForLine(lines, comments, line);
491
+ return Boolean(leading && hasThresholdRationaleMarker(leading.text));
492
+ }
493
+
494
+ // Vocabulary used by the magic-threshold rule. A numeric constant followed by a comment containing
495
+ // any of these words is treated as "explained".
496
+ function hasThresholdRationaleMarker(text: string): boolean {
497
+ return /\b(?:threshold|limit|cap|budget|tuned|default|because|empirical)\b/i.test(text);
498
+ }
499
+
500
+ // Three-tier test: useful-context vocabulary short-circuits as "not restating"; identical
501
+ // word sequences are restating; near-identical sequences (one extra trailing word) are restating.
502
+ // The result drives the `docs.useless-docblock` rule.
503
+ function isRestatingSignatureComment(text: string, name: string, kind: CommentedDeclaration["kind"]): boolean {
504
+ if (hasUsefulCommentContext(text)) {
505
+ return false;
506
+ }
507
+ const words = normalizedCommentWords(text).filter((word) => !restatementStopWords(kind).has(word)).map(stemCommentWord);
508
+ const nameWords = splitIdentifierWords(name).map(stemCommentWord);
509
+ if (words.length === 0) {
510
+ return true;
511
+ }
512
+ if (sameWords(words, nameWords)) {
513
+ return true;
514
+ }
515
+ return words.length <= nameWords.length + 1 && sameWords(words.slice(0, nameWords.length), nameWords);
516
+ }
517
+
518
+ // Strips backticks, splits on non-identifier characters, then further splits each fragment into
519
+ // identifier-style words. The flat list lets the comparator compare on a per-word basis.
520
+ function normalizedCommentWords(text: string): string[] {
521
+ return text
522
+ .replace(/`/g, " ")
523
+ .replace(/[^A-Za-z0-9_$]+/g, " ")
524
+ .trim()
525
+ .split(/\s+/)
526
+ .flatMap(splitIdentifierWords)
527
+ .filter(Boolean);
528
+ }
529
+
530
+ // Stop-word list that filters out grammatical scaffolding before name comparison. Includes the
531
+ // declaration kind itself so a kind-and-name pair is judged on the name alone.
532
+ function restatementStopWords(kind: CommentedDeclaration["kind"]): Set<string> {
533
+ return new Set(["a", "an", "the", "this", "that", "function", "method", "helper", "type", "declaration", kind]);
534
+ }
535
+
536
+ // Trailing-`s` stripping for words longer than 3 characters. Crude but adequate for restating-
537
+ // signature detection - covers `findings`/`finding`, `imports`/`import`, etc.
538
+ function stemCommentWord(word: string): string {
539
+ return word.length > 3 && word.endsWith("s") ? word.slice(0, -1) : word;
540
+ }
541
+
542
+ // Pointwise equality between two stemmed word arrays. Used by `isRestatingSignatureComment` to
543
+ // compare the comment's first words against the declaration name's words.
544
+ function sameWords(left: string[], right: string[]): boolean {
545
+ return left.length === right.length && left.every((word, index) => word === right[index]);
546
+ }
547
+
548
+ // The shared "this comment carries real signal" vocabulary. Any match here exempts the comment
549
+ // from the useless-docblock and stale-reference rules.
550
+ function hasUsefulCommentContext(text: string): boolean {
551
+ return /\b(?:because|why|intentional|tradeoff|compat|avoid|preserve|invariant|contract|side effect|throws|writes|reads|persists|fallback|recover|stable|deterministic|schema|fingerprint)\b/i.test(text);
552
+ }
553
+
554
+ // Five vocabulary markers signal "this comment is intentionally about removed/old code". The
555
+ // stale-reference rules all consult this so legacy notes can keep naming removed paths/symbols.
556
+ function isHistoricalContextComment(text: string): boolean {
557
+ return /\b(?:previously|legacy|compat|migration|ADR)\b/i.test(text);
558
+ }
559
+
560
+ // Single makeFinding factory for every stale-comment variant. `symbol` is omitted (not set to
561
+ // undefined) via conditional spread because exactOptionalPropertyTypes treats the two as different
562
+ // shapes - the omission keeps stable fingerprints round-tripping across baseline reads and writes.
563
+ function staleCommentFinding(file: SourceFile, comment: CommentRecord, message: string, metadata: Record<string, string>): Finding {
564
+ const symbol = metadata["symbol"];
565
+ return makeFinding({
566
+ ruleId: "docs.stale-comment",
567
+ message,
568
+ filePath: file.displayPath,
569
+ line: comment.line,
570
+ severity: "advisory",
571
+ pillar: "documentation",
572
+ confidence: "medium",
573
+ ...(symbol ? { symbol } : {}),
574
+ remediation: "Update the comment reference or add historical context that explains why it remains useful.",
575
+ metadata,
576
+ });
577
+ }
578
+
579
+ // Reverse scan through the comment list - the closest comment whose `endLine < line` wins, but
580
+ // only when nothing but blank lines sits between them. Anything else means the comment documents
581
+ // a different declaration.
582
+ function leadingCommentForLine(lines: string[], comments: CommentRecord[], line: number): CommentRecord | undefined {
583
+ for (let index = comments.length - 1; index >= 0; index -= 1) {
584
+ const comment = comments[index];
585
+ if (!comment || comment.endLine >= line) {
586
+ continue;
587
+ }
588
+ if (hasOnlyBlankLines(lines, comment.endLine + 1, line - 1)) {
589
+ return comment;
590
+ }
591
+ return undefined;
592
+ }
593
+ return undefined;
594
+ }
595
+
596
+ // Tighter sibling of `hasOnlyBlankFixturePurposeGap` - exclusive upper bound. Used by
597
+ // `leadingCommentForLine` to confirm no executable token sits between a comment and its declaration.
598
+ function hasOnlyBlankLines(lines: string[], startLine: number, endLine: number): boolean {
599
+ for (let line = startLine; line < endLine; line += 1) {
600
+ if ((lines[line - 1] ?? "").trim() !== "") {
601
+ return false;
602
+ }
603
+ }
604
+ return true;
605
+ }