@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,461 @@
1
+ // Analyser pipeline: walks discovered sources, runs every rule pass (size, complexity, dead-code,
2
+ // waste, naming, documentation, modernisation, security, sensitive-data, test-quality, design),
3
+ // aggregates findings into the `gruff.analysis.v1` schema, and exposes `analyse` to the CLI shell.
4
+ import { existsSync, readFileSync } from "node:fs";
5
+ import { cwd } from "node:process";
6
+ import { basename, join } from "node:path";
7
+ import { applyBaseline, dedupeFindings, DEFAULT_BASELINE, recordHistory, writeBaseline } from "./baseline.ts";
8
+ import { loadConfig, optionNumber, ruleEnabled, ruleSeverity, threshold } from "./config.ts";
9
+ import { VERSION } from "./constants.ts";
10
+ import { absolutize, discoverSources, displayPath, type SourceFile } from "./discovery.ts";
11
+ import { makeFinding } from "./findings.ts";
12
+ import { changedFiles, finding } from "./findings-helpers.ts";
13
+ import { commentRecords } from "./comment-scanner.ts";
14
+ import { analyseArchitectureRules, analyseTestAdequacyRules, buildProjectIndex, exportedSurface, isProductionSourcePath, isTestPath, type ProjectSource } from "./project-rules.ts";
15
+ import { analyseBlockRules, type BlockRuleContext, blockRuleContext, type FunctionBlock, functionBlocks, parameterNames } from "./blocks.ts";
16
+ import { analyseClassRules, analyseAcronymCase, analyseInconsistentCasing, analyseInterfaceFields, collectDeclaredIdentifiers } from "./class-rules.ts";
17
+ import { analyseDeadCode, analyseUnreachable, analyseUnusedImports } from "./dead-code-rules.ts";
18
+ import { analyseCommentQualityRules } from "./comment-rules.ts";
19
+ import { analyseDocRules, analyseFileOverviewDoc, analyseInterfaceDocs } from "./doc-rules.ts";
20
+ import { analyseLineRules } from "./line-rules.ts";
21
+ import { pushAbbreviationAt, pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
22
+ import { analyseTestBlock } from "./test-block-rules.ts";
23
+ import { analyseGithubActionsRules } from "./github-actions-rules.ts";
24
+ import { analyseProjectConfigRules } from "./project-config-rules.ts";
25
+ import { scoreReport, summarize } from "./scoring.ts";
26
+ import { analyseSensitiveData } from "./sensitive-data-rules.ts";
27
+ import { maskNonCode, maskTemplateLiteralBodies, parseDiagnostics } from "./source-text.ts";
28
+ import { todoMarkerSummary } from "./text-scans.ts";
29
+ import type { AnalysisOptions, AnalysisReport, Config, Finding, RunDiagnostic } from "./types.ts";
30
+
31
+ /**
32
+ * Analyse the configured paths and return the stable gruff.analysis.v1 report contract.
33
+ *
34
+ * @param options Normalised analysis options from the CLI or direct callers.
35
+ * @returns Versioned report with fingerprinted findings, diagnostics, paths, and score data.
36
+ */
37
+ export function analyse(options: AnalysisOptions): AnalysisReport {
38
+ const projectRoot = cwd();
39
+ const config = loadConfig(projectRoot, options);
40
+ const diagnostics: RunDiagnostic[] = [];
41
+ const discovery = discoverSources(projectRoot, options, config);
42
+ filterDiffSources(discovery, options);
43
+ pushMissingPathDiagnostics(discovery.missingPaths, diagnostics);
44
+
45
+ const scanned = scanDiscoveredSources(discovery.files, config, diagnostics);
46
+ const allFindings = sortedUniqueFindings([
47
+ ...scanned.findings,
48
+ ...analyseProjectIndex(scanned.projectSources, config).filter((finding) => ruleEnabled(config, finding.ruleId)),
49
+ ]);
50
+ const baselineResult = applyBaselineOptions(projectRoot, options, allFindings);
51
+
52
+ if (options.historyFile) {
53
+ recordHistory(projectRoot, options.historyFile, baselineResult.findings, diagnostics);
54
+ }
55
+
56
+ return buildAnalysisReport(projectRoot, options, discovery, diagnostics, baselineResult);
57
+ }
58
+
59
+ function buildAnalysisReport(
60
+ projectRoot: string,
61
+ options: AnalysisOptions,
62
+ discovery: DiscoverySummary,
63
+ diagnostics: RunDiagnostic[],
64
+ baselineResult: BaselineApplication,
65
+ ): AnalysisReport {
66
+ const findings = baselineResult.findings;
67
+ return {
68
+ schemaVersion: "gruff.analysis.v1",
69
+ tool: { name: "gruff-ts", version: VERSION },
70
+ run: {
71
+ projectRoot,
72
+ format: options.format,
73
+ failOn: options.failOn,
74
+ generatedAt: new Date().toISOString(),
75
+ },
76
+ summary: summarize(findings),
77
+ paths: {
78
+ analysedFiles: discovery.files.length,
79
+ ignoredPaths: discovery.ignoredPaths,
80
+ missingPaths: discovery.missingPaths,
81
+ },
82
+ diagnostics,
83
+ findings,
84
+ score: scoreReport(findings),
85
+ ...(baselineResult.baseline ? { baseline: baselineResult.baseline } : {}),
86
+ };
87
+ }
88
+
89
+ // Subset of discovery output that survives diff filtering. Held as its own type so the diff
90
+ // filter can mutate `files` in place without exposing the whole `SourceDiscoveryResult` shape.
91
+ interface DiscoverySummary {
92
+ files: SourceFile[];
93
+ ignoredPaths: string[];
94
+ missingPaths: string[];
95
+ }
96
+
97
+ // Output of the per-file scan pass - both the findings produced and the cached source bodies that
98
+ // later project-level rules need to operate against the deterministic stable shape used by baselines.
99
+ interface SourceScanResult {
100
+ findings: Finding[];
101
+ projectSources: ProjectSource[];
102
+ }
103
+
104
+ /*
105
+ * Result of applying a baseline (suppression) or generating a new one. The optional `baseline`
106
+ * matches the `gruff.analysis.v1` schema's baseline metadata - present only when a baseline file
107
+ * was actually used or generated, so the report stays stable across baseline-disabled runs.
108
+ */
109
+ interface BaselineApplication {
110
+ findings: Finding[];
111
+ baseline?: NonNullable<AnalysisReport["baseline"]>;
112
+ }
113
+
114
+ // Resolved baseline path plus the provenance string emitted in the report. `source` distinguishes
115
+ // "explicit" (--baseline flag) from "default" (auto-discovered gruff-baseline.json).
116
+ interface BaselineSelection {
117
+ path: string;
118
+ source: string;
119
+ }
120
+
121
+ // Mutates `discovery.files` in place to retain only paths that changed against the diff base.
122
+ // Required when `--diff` is set so the scan does not waste time on unchanged files; no-op otherwise.
123
+ function filterDiffSources(discovery: DiscoverySummary, options: AnalysisOptions): void {
124
+ if (!options.diff) {
125
+ return;
126
+ }
127
+ const changed = changedFiles(options.diff);
128
+ discovery.files = discovery.files.filter((file) => changed.has(file.displayPath));
129
+ }
130
+
131
+ // Emits a `missing-path` diagnostic per path that the user requested but discovery could not
132
+ // resolve. Diagnostics force a non-zero exit (see `exitFor`); never throws - partial scans should still report.
133
+ function pushMissingPathDiagnostics(missingPaths: string[], diagnostics: RunDiagnostic[]): void {
134
+ for (const missingPath of missingPaths) {
135
+ diagnostics.push({
136
+ diagnosticType: "missing-path",
137
+ message: `Input path does not exist: ${missingPath}`,
138
+ filePath: missingPath,
139
+ });
140
+ }
141
+ }
142
+
143
+ /*
144
+ * Per-file read + scan loop. Reports read failures as diagnostics because CLI users need partial
145
+ * results from the rest of the tree, but the stable discovered-file order still feeds project-index
146
+ * snapshots before the final canonical sort. Changing that contract can churn graph-rule anchors.
147
+ */
148
+ function scanDiscoveredSources(files: SourceFile[], config: Config, diagnostics: RunDiagnostic[]): SourceScanResult {
149
+ const findings: Finding[] = [];
150
+ const projectSources: ProjectSource[] = [];
151
+ for (const file of files) {
152
+ try {
153
+ const source = readFileSync(file.absolutePath, "utf8");
154
+ if (shouldRetainProjectSource(file, source)) {
155
+ projectSources.push(projectSource(file, source));
156
+ }
157
+ diagnostics.push(...parseDiagnostics(file, source));
158
+ findings.push(...analyseSource(file, source, config));
159
+ } catch (error) {
160
+ diagnostics.push({
161
+ diagnosticType: "read-error",
162
+ message: `Unable to read file: ${String(error)}`,
163
+ filePath: file.displayPath,
164
+ line: 1,
165
+ });
166
+ }
167
+ }
168
+ return { findings, projectSources };
169
+ }
170
+
171
+ // Retains production files for exported-surface checks, tests for central-suite import coverage,
172
+ // and import/export candidates for graph edges. Dropping any class here makes a project rule blind.
173
+ function shouldRetainProjectSource(file: SourceFile, source: string): boolean {
174
+ return file.isScript && (isProductionSourcePath(file.displayPath) || isTestPath(file.displayPath) || hasImportSyntaxCandidate(source));
175
+ }
176
+
177
+ // Stores the raw line view and, only when needed, a template-masked line view. The conditional mask
178
+ // avoids paying lexer cost for files that cannot affect import edges while keeping fixtures invisible.
179
+ function projectSource(file: SourceFile, source: string): ProjectSource {
180
+ const lines = source.split(/\r?\n/);
181
+ const templateMaskedLines = hasImportSyntaxCandidate(source) ? maskTemplateLiteralBodies(source).split(/\r?\n/) : lines;
182
+ const surface = isProductionSourcePath(file.displayPath) ? exportedSurface(source) : undefined;
183
+ return { file, lines, templateMaskedLines, ...(surface ? { exportedSurface: surface } : {}) };
184
+ }
185
+
186
+ // Cheap prefilter for files that might contain real import/export edges or fixture strings that
187
+ // mention them. False positives are acceptable; false negatives would drop graph edges.
188
+ function hasImportSyntaxCandidate(source: string): boolean {
189
+ return source.includes("import") || source.includes("from");
190
+ }
191
+
192
+ // Canonical finding ordering: (filePath, line, ruleId, message). The same tuple is part of the
193
+ // stable baseline matching contract, so changing the comparator would churn every existing baseline.
194
+ function sortedUniqueFindings(findings: Finding[]): Finding[] {
195
+ findings.sort(
196
+ (left, right) =>
197
+ left.filePath.localeCompare(right.filePath) ||
198
+ (left.line ?? 0) - (right.line ?? 0) ||
199
+ left.ruleId.localeCompare(right.ruleId) ||
200
+ left.message.localeCompare(right.message),
201
+ );
202
+ return dedupeFindings(findings);
203
+ }
204
+
205
+ /*
206
+ * Three-way baseline dispatcher. `--generate-baseline` wins (writes a new file, returns findings
207
+ * unchanged); `--no-baseline` skips entirely; otherwise look for an explicit or default baseline.
208
+ * The stable identity tuple (fingerprint, ruleId, filePath) drives suppression matching.
209
+ */
210
+ function applyBaselineOptions(projectRoot: string, options: AnalysisOptions, findings: Finding[]): BaselineApplication {
211
+ if (options.generateBaseline) {
212
+ return generateBaselineResult(projectRoot, options.generateBaseline, findings);
213
+ }
214
+
215
+ if (options.shouldSkipBaseline) {
216
+ return { findings };
217
+ }
218
+
219
+ const selected = selectedBaseline(projectRoot, options);
220
+ if (!selected) {
221
+ return { findings };
222
+ }
223
+
224
+ return applySelectedBaseline(projectRoot, selected, findings);
225
+ }
226
+
227
+ /*
228
+ * Writes the baseline file via writeBaseline and returns the report-shaped metadata. `suppressed: 0`
229
+ * because generation does not filter findings - every current finding is captured in the stable baseline.
230
+ */
231
+ function generateBaselineResult(projectRoot: string, baselineFile: string, findings: Finding[]): BaselineApplication {
232
+ const baselinePath = absolutize(projectRoot, baselineFile);
233
+ writeBaseline(baselinePath, findings);
234
+ return {
235
+ findings,
236
+ baseline: {
237
+ path: displayPath(projectRoot, baselinePath),
238
+ source: "generated",
239
+ suppressed: 0,
240
+ generated: true,
241
+ },
242
+ };
243
+ }
244
+
245
+ // Loads the baseline file and filters findings whose identity tuple matches. `suppressed` is
246
+ // computed from the size delta so the stable baseline report metadata stays accurate.
247
+ function applySelectedBaseline(projectRoot: string, selected: BaselineSelection, findings: Finding[]): BaselineApplication {
248
+ const before = findings.length;
249
+ const filteredFindings = applyBaseline(selected.path, findings);
250
+ return {
251
+ findings: filteredFindings,
252
+ baseline: {
253
+ path: displayPath(projectRoot, selected.path),
254
+ source: selected.source,
255
+ suppressed: before - filteredFindings.length,
256
+ generated: false,
257
+ },
258
+ };
259
+ }
260
+
261
+ // Picks an explicit `--baseline` path first, then the conventional `gruff-baseline.json` at the
262
+ // project root. Returning undefined means "no baseline" - the stable contract preserves report shape.
263
+ function selectedBaseline(projectRoot: string, options: AnalysisOptions): BaselineSelection | undefined {
264
+ if (options.baseline) {
265
+ return { path: absolutize(projectRoot, options.baseline), source: "explicit" };
266
+ }
267
+ const defaultBaseline = join(projectRoot, DEFAULT_BASELINE);
268
+ return existsSync(defaultBaseline) ? { path: defaultBaseline, source: "default" } : undefined;
269
+ }
270
+
271
+ // Per-file rule pipeline. Text rules run on every file (including config/yaml); TypeScript rules
272
+ // run only on scripts. Fixed order is part of the stable fingerprint contract.
273
+ function analyseSource(file: SourceFile, source: string, config: Config): Finding[] {
274
+ const findings: Finding[] = [];
275
+ analyseTextRules(file, source, config, findings);
276
+ if (file.isScript) {
277
+ analyseTypeScriptRules(file, source, config, findings);
278
+ }
279
+ return findings.filter((finding) => ruleEnabled(config, finding.ruleId));
280
+ }
281
+
282
+ // Cross-file rule pipeline that runs after every per-file scan completes. The index is built once
283
+ // and reused across architecture and test-adequacy rules to keep the stable, deterministic order.
284
+ function analyseProjectIndex(projectSources: ProjectSource[], config: Config): Finding[] {
285
+ const index = buildProjectIndex(projectSources);
286
+ const findings: Finding[] = [];
287
+ analyseArchitectureRules(index, config, findings);
288
+ analyseTestAdequacyRules(index, findings);
289
+ return findings;
290
+ }
291
+
292
+ /*
293
+ * Per-file text-pillar rules run before script-only rules because config, workflow, CSS, and
294
+ * secret surfaces are not TypeScript. The order is a stable baseline contract: reshuffling these
295
+ * checks changes same-line finding order for machine reports.
296
+ */
297
+ function analyseTextRules(file: SourceFile, source: string, config: Config, findings: Finding[]): void {
298
+ const lines = lineCount(source);
299
+ if (isCssPath(file.displayPath)) {
300
+ const stylesheetThreshold = threshold(config, "size.stylesheet-length", 1500);
301
+ if (lines > stylesheetThreshold) {
302
+ findings.push(finding({ ruleId: "size.stylesheet-length", message: `Stylesheet has ${lines} lines, above the threshold of ${stylesheetThreshold}.`, file, line: 1, severity: ruleSeverity(config, "size.stylesheet-length", "warning"), pillar: "size" }));
303
+ }
304
+ } else if (!isGeneratedLockfile(file.displayPath)) {
305
+ const fileLengthThreshold = threshold(config, "size.file-length", 750);
306
+ if (lines > fileLengthThreshold) {
307
+ findings.push(finding({ ruleId: "size.file-length", message: `File has ${lines} lines, above the threshold of ${fileLengthThreshold}.`, file, line: 1, severity: ruleSeverity(config, "size.file-length", "warning"), pillar: "size" }));
308
+ }
309
+ }
310
+
311
+ /*
312
+ * Opt-in by default per M38 (.goat-flow/tasks/0.1/M38-css-metrics-and-todo-density-calibration.md):
313
+ * raw marker density produced too many false positives in other gruff projects; prefer the
314
+ * context-aware docs.todo-without-tracking rule when task-marker scanning matters.
315
+ */
316
+ if (config.rules.get("docs.todo-density")?.enabled === true) {
317
+ const todoMarkers = todoMarkerSummary(source, file.isScript);
318
+ if (todoMarkers.count >= threshold(config, "docs.todo-density", 4)) {
319
+ findings.push(finding({ ruleId: "docs.todo-density", message: `File contains ${todoMarkers.count} TODO/FIXME markers.`, file, line: todoMarkers.firstLine, severity: ruleSeverity(config, "docs.todo-density", "advisory"), pillar: "documentation" }));
320
+ }
321
+ }
322
+
323
+ analyseSensitiveData(file, source, config, findings);
324
+ analyseGithubActionsRules(file, source, findings);
325
+ analyseProjectConfigRules(file, source, findings);
326
+ }
327
+
328
+ // CSS paths use a dedicated size rule (`size.stylesheet-length`) so stylesheets can have a
329
+ // different threshold and message from generic source files.
330
+ function isCssPath(displayPath: string): boolean {
331
+ return displayPath.toLowerCase().endsWith(".css");
332
+ }
333
+
334
+ // Counts the same logical lines as `source.split(/\r?\n/)` without allocating the full line array.
335
+ function lineCount(source: string): number {
336
+ let count = 1;
337
+ for (let index = 0; index < source.length; index += 1) {
338
+ if (source.charCodeAt(index) === 10) {
339
+ count += 1;
340
+ }
341
+ }
342
+ return count;
343
+ }
344
+
345
+ // Exact-name match against the five major package managers. Lockfiles routinely break size and
346
+ // sensitive-data thresholds without being meaningful project code, so they get excluded by file rules.
347
+ function isGeneratedLockfile(path: string): boolean {
348
+ const name = basename(path);
349
+ return name === "package-lock.json" || name === "npm-shrinkwrap.json" || name === "yarn.lock" || name === "pnpm-lock.yaml" || name === "bun.lockb";
350
+ }
351
+
352
+ /*
353
+ * TypeScript-only rule pipeline. Masks comments and literals once, parses callable blocks once,
354
+ * then walks every rule pack in a stable, deterministic order so reports and baselines remain reproducible.
355
+ */
356
+ function analyseTypeScriptRules(file: SourceFile, source: string, config: Config, findings: Finding[]): void {
357
+ const codeSource = maskNonCode(source);
358
+ const blocks = functionBlocks(source, codeSource);
359
+ const comments = commentRecords(source);
360
+ analyseFileOverviewDoc(file, source, findings);
361
+ analyseBlocks(file, blocks, config, findings);
362
+ analyseUnusedImports(file, codeSource, source, findings);
363
+ analyseLineRules(file, source, codeSource, config, findings);
364
+ analyseUnreachable(file, codeSource, findings);
365
+ analyseDocRules(file, source, codeSource, findings);
366
+ analyseInterfaceDocs(file, source, codeSource, findings);
367
+ analyseInterfaceFields(file, source, codeSource, config, findings);
368
+ analyseCommentQualityRules({ file, source, codeSource, blocks, comments, config, findings });
369
+ analyseClassRules(file, source, codeSource, findings);
370
+ analyseDeadCode(file, codeSource, findings);
371
+ const inventory = collectDeclaredIdentifiers(source, codeSource, blocks);
372
+ analyseInconsistentCasing(file, inventory, findings);
373
+ analyseAcronymCase(file, inventory, config, findings);
374
+ }
375
+
376
+
377
+ // One pass over the file's parsed callables. The naming and test-block fanouts are dispatched
378
+ // separately so blocks.ts can stay independent of the naming-pusher and test-block-rule modules;
379
+ // the per-rule emission order from `analyseBlockRules` is the stable fingerprint contract every
380
+ // Finding depends on for deterministic baseline matching.
381
+ function analyseBlocks(file: SourceFile, blocks: FunctionBlock[], config: Config, findings: Finding[]): void {
382
+ for (const block of blocks) {
383
+ const context = blockRuleContext(file, block, config, findings);
384
+ analyseBlockRules(context);
385
+ pushParameterNamingFindings(context);
386
+ if (block.isTest) {
387
+ analyseTestBlock(file, block, config, findings);
388
+ }
389
+ }
390
+ }
391
+
392
+ /*
393
+ * Per-parameter naming-rule fanout. Each parameter is checked for short-name / opaque-abbreviation
394
+ * / placeholder forms; typed booleans get the extra prefix and negative-name checks. Reports findings
395
+ * to the shared sink.
396
+ */
397
+ function pushParameterNamingFindings(context: BlockRuleContext): void {
398
+ const line = context.block.declarationLine;
399
+ const params = parameterNames(context.block.params);
400
+ for (const parameter of params) {
401
+ pushShortVariableAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
402
+ pushIdentifierQualityAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
403
+ pushAbbreviationAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
404
+ if (isBooleanParameter(parameter.raw)) {
405
+ pushBooleanPrefixAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
406
+ pushNegativeBooleanAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
407
+ }
408
+ if (isGenericParameterCandidate(context, params.length, parameter.name)) {
409
+ pushGenericParameterAt(context.file, line, parameter.name, context.findings);
410
+ }
411
+ }
412
+ }
413
+
414
+ // Generic-parameter rule is context-gated: only fires when the surrounding function is itself
415
+ // large enough to deserve attention (long, complex, or many parameters). Keeps noise down on
416
+ // trivial helpers that legitimately accept a `value` argument.
417
+ function isGenericParameterCandidate(context: BlockRuleContext, paramCount: number, name: string): boolean {
418
+ if (!context.config.placeholderNames.has(name.toLowerCase())) {
419
+ return false;
420
+ }
421
+ const minParameters = optionNumber(context.config, "naming.generic-parameter", "minParameters", 3);
422
+ const minLineCount = optionNumber(context.config, "naming.generic-parameter", "minLineCount", 30);
423
+ const minCyclomatic = optionNumber(context.config, "naming.generic-parameter", "minCyclomatic", 8);
424
+ return (
425
+ paramCount >= minParameters ||
426
+ context.block.lineCount >= minLineCount ||
427
+ context.cyclomatic >= minCyclomatic
428
+ );
429
+ }
430
+
431
+ // Only called after `isGenericParameterCandidate` has gated the placeholder check on the function's
432
+ // complexity and length - reaching this helper means the rule decided the finding is wanted.
433
+ // Reports the stable `naming.generic-parameter` finding.
434
+ function pushGenericParameterAt(file: SourceFile, line: number, name: string, findings: Finding[]): void {
435
+ findings.push(
436
+ makeFinding({
437
+ ruleId: "naming.generic-parameter",
438
+ message: `Parameter \`${name}\` uses a placeholder name in a function that meets context-gating thresholds.`,
439
+ filePath: file.displayPath,
440
+ line,
441
+ severity: "advisory",
442
+ pillar: "naming",
443
+ confidence: "medium",
444
+ symbol: name,
445
+ remediation: "Use a name that describes the parameter's role.",
446
+ metadata: { identifierName: name, surface: "parameter" },
447
+ }),
448
+ );
449
+ }
450
+
451
+ // Two positive cases: explicit `: boolean` annotation, or a default value of `true`/`false`.
452
+ // Explicit `as` casts are rejected so generic-call sites don't trip the boolean-name checks.
453
+ function isBooleanParameter(raw: string): boolean {
454
+ if (/:\s*boolean\b/.test(raw)) {
455
+ return true;
456
+ }
457
+ if (/\bas\b/.test(raw)) {
458
+ return false;
459
+ }
460
+ return /=\s*(?:true|false)\s*$/.test(raw);
461
+ }
@@ -0,0 +1,90 @@
1
+ // Baseline persistence helpers for stable suppression files and score history side effects.
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { isAbsolute, join, relative } from "node:path";
4
+ import { scoreReport } from "./scoring.ts";
5
+ import type { Finding, RunDiagnostic } from "./types.ts";
6
+
7
+ const DEFAULT_BASELINE = "gruff-baseline.json";
8
+
9
+ // Treats already-absolute paths as-is; otherwise anchors at the project root the CLI was launched against.
10
+ function absolutize(projectRoot: string, path: string): string {
11
+ return isAbsolute(path) ? path : join(projectRoot, path);
12
+ }
13
+
14
+ // Project-relative form with forward slashes - the report contract uses POSIX-style display paths on
15
+ // every platform. "" collapses to "." so the project root has a stable label in history entries.
16
+ function displayPath(projectRoot: string, path: string): string {
17
+ const relativePath = relative(projectRoot, path).replaceAll("\\", "/");
18
+ return relativePath === "" ? "." : relativePath;
19
+ }
20
+
21
+ // `gruff.baseline.v1`: writes the fingerprint plus the identity tuple used by `applyBaseline`.
22
+ // `message` is persisted for human review; `applyBaseline` ignores it so cosmetic message changes
23
+ // do not invalidate baselines. Bump schema before adding required fields; persists to disk via writeFileSync.
24
+ function writeBaseline(path: string, findings: Finding[]): void {
25
+ writeFileSync(
26
+ path,
27
+ JSON.stringify(
28
+ {
29
+ schemaVersion: "gruff.baseline.v1",
30
+ generatedAt: new Date().toISOString(),
31
+ entries: findings.map((finding) => ({
32
+ fingerprint: finding.fingerprint,
33
+ ruleId: finding.ruleId,
34
+ filePath: finding.filePath,
35
+ line: finding.line,
36
+ symbol: finding.symbol,
37
+ message: finding.message,
38
+ })),
39
+ },
40
+ null,
41
+ 2,
42
+ ),
43
+ );
44
+ }
45
+
46
+ // Suppresses any finding whose (fingerprint, ruleId, filePath) tuple appears in the baseline.
47
+ // Drift between versions would let stale suppressions leak in, so the operator must regenerate
48
+ // against the current CLI; reads the baseline file and throws on an unknown schema version.
49
+ function applyBaseline(path: string, findings: Finding[]): Finding[] {
50
+ const baselineFile = JSON.parse(readFileSync(path, "utf8")) as { schemaVersion?: string; entries?: Array<{ fingerprint: string; ruleId: string; filePath: string }> };
51
+ if (baselineFile.schemaVersion !== "gruff.baseline.v1") {
52
+ throw new Error(`unsupported baseline schema in ${path}`);
53
+ }
54
+ const keys = new Set((baselineFile.entries ?? []).map((entry) => [entry.fingerprint, entry.ruleId, entry.filePath].join("\0")));
55
+ return findings.filter((finding) => !keys.has([finding.fingerprint, finding.ruleId, finding.filePath].join("\0")));
56
+ }
57
+
58
+ /*
59
+ * Appends one row to the score-history JSON file and trims to the most recent 100 entries so the
60
+ * dashboard sparkline never grows unbounded. The stable contract: writes via writeFileSync, and on
61
+ * persistence failure it reports a `history-error` diagnostic and recovers - a flaky history file
62
+ * must not fail the analysis run.
63
+ */
64
+ function recordHistory(projectRoot: string, historyFile: string, findings: Finding[], diagnostics: RunDiagnostic[]): void {
65
+ const path = absolutize(projectRoot, historyFile);
66
+ try {
67
+ const entries = existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as unknown[]) : [];
68
+ entries.push({ recordedAt: new Date().toISOString(), findings: findings.length, score: scoreReport(findings).composite });
69
+ writeFileSync(path, JSON.stringify(entries.slice(-100), null, 2));
70
+ } catch (error) {
71
+ diagnostics.push({ diagnosticType: "history-error", message: `Unable to write history file: ${String(error)}`, filePath: displayPath(projectRoot, path) });
72
+ }
73
+ }
74
+
75
+ // `docs.missing-public-doc` is keyed by (ruleId, filePath, symbol) instead of fingerprint because one
76
+ // file can legitimately surface multiple undocumented public symbols and they must each survive dedupe.
77
+ // All other rules collapse on their fingerprint, which already encodes the unique anchor.
78
+ function dedupeFindings(findings: Finding[]): Finding[] {
79
+ const seen = new Set<string>();
80
+ return findings.filter((finding) => {
81
+ const key = finding.ruleId === "docs.missing-public-doc" && finding.symbol ? [finding.ruleId, finding.filePath, finding.symbol].join("\0") : finding.fingerprint;
82
+ if (seen.has(key)) {
83
+ return false;
84
+ }
85
+ seen.add(key);
86
+ return true;
87
+ });
88
+ }
89
+
90
+ export { DEFAULT_BASELINE, writeBaseline, applyBaseline, recordHistory, dedupeFindings };