@blundergoat/gruff-ts 0.1.0 → 0.1.1

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.
@@ -294,6 +294,7 @@ function knownCliFlags(): Set<string> {
294
294
  "--config",
295
295
  "--diff",
296
296
  "--fail-on",
297
+ "--force",
297
298
  "--format",
298
299
  "--generate-baseline",
299
300
  "--help",
@@ -309,6 +310,7 @@ function knownCliFlags(): Set<string> {
309
310
  "--project-root",
310
311
  "--quiet",
311
312
  "--silent",
313
+ "--top",
312
314
  "--verbose",
313
315
  "--version",
314
316
  ]);
package/src/config.ts CHANGED
@@ -35,7 +35,6 @@ function defaultConfig(): Config {
35
35
  booleanPrefixes: new Set(["is", "has", "can", "should", "does", "did", "was", "will", "may", "in", "scan", "supports", "requires"]),
36
36
  hungarianPrefixes: new Set(["str", "obj", "arr", "bool", "int", "num"]),
37
37
  placeholderNames: new Set(["foo", "bar", "baz", "tmp", "temp", "thing", "stuff", "data", "value", "item"]),
38
- abbreviationDenylist: new Set(["ctx", "pkg", "opts", "fn", "idx", "cb"]),
39
38
  negativeBooleanAllowed: new Set(["nostore", "nofollow", "noreferrer", "noscript", "noindex"]),
40
39
  knownAcronyms: new Set(["url", "http", "https", "id", "xml", "json", "html", "css", "api", "sql", "db", "io", "ui", "uuid", "ip", "tcp", "udp", "ast", "cli", "npm"]),
41
40
  rules: new Map(),
@@ -100,14 +99,13 @@ function applyAllowlistConfig(config: Config, raw: Record<string, unknown>): voi
100
99
  applyNamingAllowlist(config, allowlists, "booleanPrefixes");
101
100
  applyNamingAllowlist(config, allowlists, "hungarianPrefixes");
102
101
  applyNamingAllowlist(config, allowlists, "placeholderNames");
103
- applyNamingAllowlist(config, allowlists, "abbreviationDenylist");
104
102
  applyNamingAllowlist(config, allowlists, "negativeBooleanAllowed");
105
103
  applyNamingAllowlist(config, allowlists, "knownAcronyms");
106
104
  }
107
105
 
108
106
  // Replaces the entire list when the user provides that key - there is no merge with defaults.
109
107
  // The "set the whole list" semantic is intentional so users can deliberately empty a list.
110
- function applyNamingAllowlist(config: Config, allowlists: Record<string, unknown> | undefined, key: "bannedGenericNames" | "booleanPrefixes" | "hungarianPrefixes" | "placeholderNames" | "abbreviationDenylist" | "negativeBooleanAllowed" | "knownAcronyms"): void {
108
+ function applyNamingAllowlist(config: Config, allowlists: Record<string, unknown> | undefined, key: "bannedGenericNames" | "booleanPrefixes" | "hungarianPrefixes" | "placeholderNames" | "negativeBooleanAllowed" | "knownAcronyms"): void {
111
109
  if (!allowlists || !(key in allowlists)) {
112
110
  return;
113
111
  }
@@ -619,4 +617,4 @@ function isSeverity(configValue: unknown): configValue is Severity {
619
617
  return configValue === "advisory" || configValue === "warning" || configValue === "error";
620
618
  }
621
619
 
622
- export { isString, loadConfig, objectValue, optionNumber, ruleEnabled, ruleSeverity, threshold };
620
+ export { defaultConfigPath, isString, loadConfig, objectValue, optionNumber, ruleEnabled, ruleSeverity, threshold };
package/src/constants.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // Shared release constants that keep CLI, reports, and rule catalogue output in sync.
2
- const VERSION = "0.1.0";
2
+ const VERSION = "0.1.1";
3
3
 
4
4
  export { VERSION };
@@ -44,7 +44,7 @@ export function analyseUnreachable(file: SourceFile, source: string, findings: F
44
44
  didPreviousTerminate = false;
45
45
  }
46
46
  if (isUnreachableStatement(trimmed, didPreviousTerminate, branchLabel)) {
47
- findings.push(finding({ ruleId: "waste.unreachable-code", message: "Statement appears after a terminating statement.", file, line: index + 1, severity: "warning", pillar: "waste" }));
47
+ findings.push(finding({ ruleId: "waste.unreachable-code", message: "Statement appears after a terminating statement.", file, line: index + 1, severity: "warning", pillar: "maintainability" }));
48
48
  }
49
49
  // A terminating statement inside a braceless conditional body does not unconditionally exit:
50
50
  // `if (x)\n return y;\nnextLine` - `nextLine` runs when `x` is falsy. Tracking the prior line's
@@ -165,7 +165,7 @@ function unusedImportFinding(file: SourceFile, name: string, line: number): Find
165
165
  filePath: file.displayPath,
166
166
  line,
167
167
  severity: "advisory",
168
- pillar: "waste",
168
+ pillar: "maintainability",
169
169
  confidence: "medium",
170
170
  symbol: name,
171
171
  remediation: "Remove the unused import.",
@@ -0,0 +1,248 @@
1
+ // Renders the default .gruff-ts.yaml file from the rule descriptor registry so `gruff-ts init`
2
+ // can drop a config into a fresh project that mirrors the analyser's effective defaults.
3
+ import { existsSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { defaultConfigPath, loadConfig } from "./config.ts";
7
+ import { ruleDescriptors } from "./rules.ts";
8
+ import type { RuleDescriptor } from "./types.ts";
9
+
10
+ const DEFAULT_CONFIG_FILE_NAME = ".gruff-ts.yaml";
11
+
12
+ // Default option values for rules with `optionKeys`. The source of truth is the
13
+ // `optionNumber(config, ruleId, key, default)` call site in the rule implementation; mirroring
14
+ // them here keeps `gruff-ts init` self-contained. `init-config.test.ts` asserts the values
15
+ // here match the implementation defaults so drift fails the test suite, not user projects.
16
+ const RULE_OPTION_DEFAULTS: Readonly<Record<string, Readonly<Record<string, number>>>> = {
17
+ "design.large-module-concentration": { minFiles: 4, minLines: 80 },
18
+ "naming.generic-parameter": { minCyclomatic: 8, minLineCount: 30, minParameters: 3 },
19
+ };
20
+
21
+ // Default starter list copied from `defaultConfig()` in config.ts. Generated separately because
22
+ // the YAML form (a block sequence) is more reviewable than the inline `[...]` form a Set would emit.
23
+ const DEFAULT_ACCEPTED_ABBREVIATIONS: readonly string[] = ["id", "db", "fs", "io", "ui", "tx", "rx"];
24
+
25
+ // Result of an init write attempt, including the no-clobber branch for existing config files.
26
+ interface InitResult {
27
+ path: string;
28
+ status: "written" | "overwritten" | "exists";
29
+ }
30
+
31
+ // Inputs the analyse/summary actions must collect before deciding whether to ask the user about
32
+ // running `init`. Kept as an explicit shape so the predicate is testable without TTY mocking. All
33
+ // three stream TTY states matter: stdin so the answer can be typed, stderr so the prompt is seen,
34
+ // and stdout so a piped consumer (e.g. `... --format=json | jq`) does not block on hidden input.
35
+ interface InitPromptContext {
36
+ projectRoot: string;
37
+ shouldSkipConfig: boolean;
38
+ hasExplicitConfig: boolean;
39
+ isInteractionAllowed: boolean;
40
+ isOutputSuppressed: boolean;
41
+ isStdinTty: boolean;
42
+ isStdoutTty: boolean;
43
+ isStderrTty: boolean;
44
+ }
45
+
46
+ /**
47
+ * Render the default .gruff-ts.yaml content from the rule descriptor registry.
48
+ *
49
+ * @param ignoredPaths Optional `paths.ignore` entries to inject verbatim (block-sequence form). The
50
+ * `gruff-ts init --force` flow forwards the existing file's entries so user-curated exclusions
51
+ * survive regeneration; an empty list emits `ignore: []` for fresh projects.
52
+ * @returns A YAML document string terminated by a trailing newline.
53
+ */
54
+ function renderDefaultConfig(ignoredPaths: readonly string[] = []): string {
55
+ return [renderPathsSection(ignoredPaths), "", renderAllowlistsSection(), "", renderRulesSection()].join("\n") + "\n";
56
+ }
57
+
58
+ /**
59
+ * Write the default config to `<projectRoot>/.gruff-ts.yaml`. Performs a filesystem write side
60
+ * effect when no supported config exists, or when `shouldOverwrite` is true and `.gruff-ts.yaml`
61
+ * is the incumbent. Refuses to clobber (no side effect) when ANY supported config file is present
62
+ * (the four-name precedence list in `DEFAULT_CONFIG_FILES`), not just `.gruff-ts.yaml` -
63
+ * otherwise `init` would silently create a higher-precedence file alongside an existing
64
+ * `.gruff.yaml` / `.gruff.yml` / `.gruff.json` and quietly change the effective config. When
65
+ * overwriting an existing `.gruff-ts.yaml`, the file's `paths.ignore` entries are preserved so
66
+ * `init --force` does not erase project-specific recursive-scan exclusions.
67
+ *
68
+ * @param projectRoot Directory to write the config file into.
69
+ * @param shouldOverwrite Overwrite an existing config file when true.
70
+ * @returns The resolved path and whether a file was written, overwritten, or skipped. When the
71
+ * refusal is triggered by a non-canonical name, `path` points at that file so the caller's
72
+ * error message names the actual blocker.
73
+ */
74
+ function writeDefaultConfig(projectRoot: string, shouldOverwrite: boolean): InitResult {
75
+ const targetPath = join(projectRoot, DEFAULT_CONFIG_FILE_NAME);
76
+ const existingConfigPath = defaultConfigPath(projectRoot);
77
+ if (existingConfigPath !== undefined && !shouldOverwrite) {
78
+ return { path: existingConfigPath, status: "exists" };
79
+ }
80
+ const targetExists = existsSync(targetPath);
81
+ // Preserve paths.ignore from whichever supported config exists, not just `.gruff-ts.yaml` -
82
+ // otherwise `init --force` against a project with only `.gruff.yaml`/`.yml`/`.json` would
83
+ // silently drop user-curated ignore entries when generating the new canonical file.
84
+ const preservedIgnoredPaths = existingConfigPath !== undefined ? readExistingIgnoredPaths(projectRoot) : [];
85
+ writeFileSync(targetPath, renderDefaultConfig(preservedIgnoredPaths));
86
+ return { path: targetPath, status: targetExists ? "overwritten" : "written" };
87
+ }
88
+
89
+ /*
90
+ * Recover the existing file's `paths.ignore` block before `init --force` overwrites it. The
91
+ * try/catch swallows YAML-parse errors and the fallback returns an empty list so a malformed
92
+ * existing config does not block regeneration - the user is opting into clobbering, but the
93
+ * documented contract is that curated ignore entries survive when readable.
94
+ */
95
+ function readExistingIgnoredPaths(projectRoot: string): readonly string[] {
96
+ try {
97
+ const config = loadConfig(projectRoot, {
98
+ paths: [],
99
+ shouldSkipConfig: false,
100
+ format: "text",
101
+ failOn: "none",
102
+ shouldIncludeIgnored: false,
103
+ shouldSkipBaseline: true,
104
+ });
105
+ return config.ignoredPaths;
106
+ } catch {
107
+ return [];
108
+ }
109
+ }
110
+
111
+ // `paths.ignore` defaults to empty for fresh projects - discovery.ts already filters node_modules,
112
+ // .git, etc. regardless of config. When `init --force` regenerates an existing config, the caller
113
+ // forwards the existing entries so user-curated exclusions survive.
114
+ function renderPathsSection(ignoredPaths: readonly string[]): string {
115
+ const header = [
116
+ "paths:",
117
+ " # Recursive scans already respect .gitignore plus built-in default directories",
118
+ " # such as .git, node_modules, dist, coverage, generated, tmp, and vendor.",
119
+ " # Add project-specific generated or local outputs here when Git does not ignore them.",
120
+ " # Examples:",
121
+ " # - \"out/**\"",
122
+ " # - \".next/**\"",
123
+ " # - \"src/generated/**\"",
124
+ ];
125
+ if (ignoredPaths.length === 0) {
126
+ return [...header, " ignore: []"].join("\n");
127
+ }
128
+ return [
129
+ ...header,
130
+ " ignore:",
131
+ ...ignoredPaths.map((ignoredPath) => ` - ${JSON.stringify(ignoredPath)}`),
132
+ ].join("\n");
133
+ }
134
+
135
+ // `acceptedAbbreviations` is emitted as a block sequence for reviewability; the seven naming
136
+ // allowlists below it stay commented out so users see the override knobs without changing defaults.
137
+ function renderAllowlistsSection(): string {
138
+ return [
139
+ "allowlists:",
140
+ " acceptedAbbreviations:",
141
+ ...DEFAULT_ACCEPTED_ABBREVIATIONS.map((abbreviation) => ` - ${abbreviation}`),
142
+ " secretPreviews: []",
143
+ " # Names that trigger naming.generic-function. Each key replaces the built-in",
144
+ " # default when present; an empty list disables that rule's blacklist branch.",
145
+ " # Default: [process, handle, doit, run, execute, manage]",
146
+ " # bannedGenericNames: [process, handle, doit, run, execute, manage]",
147
+ " # Accepted prefixes for boolean identifiers. Names without one of these",
148
+ " # prefixes trigger naming.boolean-prefix.",
149
+ " # Default: [is, has, can, should, does, did, was, will, may, in, scan, supports, requires]",
150
+ " # booleanPrefixes: [is, has, can, should, does, did, was, will, may, in, scan, supports, requires]",
151
+ " # Hungarian type-style prefixes flagged by naming.hungarian-notation.",
152
+ " # Default: [str, obj, arr, bool, int, num]",
153
+ " # hungarianPrefixes: [str, obj, arr, bool, int, num]",
154
+ " # Placeholder words flagged as generic by naming.identifier-quality. The",
155
+ " # numbered-suffix branch (foo1, value2) stays active even when this is empty.",
156
+ " # Default: [foo, bar, baz, tmp, temp, thing, stuff, data, value, item]",
157
+ " # placeholderNames: [foo, bar, baz, tmp, temp, thing, stuff, data, value, item]",
158
+ " # Negative-framed boolean names that should NOT trigger naming.negative-boolean.",
159
+ " # Defaults are HTTP-header conventions; add project terms as needed.",
160
+ " # Default: [nostore, nofollow, noreferrer, noscript, noindex]",
161
+ " # negativeBooleanAllowed: [nostore, nofollow, noreferrer, noscript, noindex]",
162
+ " # Known acronyms whose mixed casings trigger naming.acronym-case. Stored",
163
+ " # case-insensitively; match is against canonical lowercase.",
164
+ " # Default: [url, http, https, id, xml, json, html, css, api, sql, db, io, ui, uuid, ip, tcp, udp, ast, cli, npm]",
165
+ " # knownAcronyms: [url, http, https, id, xml, json, html, css, api, sql, db, io, ui, uuid, ip, tcp, udp, ast, cli, npm]",
166
+ ].join("\n");
167
+ }
168
+
169
+ // Walks the registry in its canonical (sorted) order so the generated YAML is byte-stable.
170
+ function renderRulesSection(): string {
171
+ const lines = ["rules:"];
172
+ for (const descriptor of ruleDescriptors()) {
173
+ lines.push(...renderRuleEntry(descriptor));
174
+ }
175
+ return lines.join("\n");
176
+ }
177
+
178
+ // One rule entry: a `# pillar/severity: description` comment line, the rule id, `enabled`, then
179
+ // threshold/severity/options only when the descriptor declares them. Omitting absent keys keeps
180
+ // the generated file aligned with the descriptor's actual contract.
181
+ function renderRuleEntry(descriptor: RuleDescriptor): string[] {
182
+ const lines: string[] = [];
183
+ lines.push(` # ${descriptor.pillar}/${descriptor.severity}: ${descriptor.description}`);
184
+ lines.push(` ${descriptor.ruleId}:`);
185
+ lines.push(" enabled: true");
186
+ if (typeof descriptor.threshold === "number") {
187
+ lines.push(` threshold: ${descriptor.threshold}`);
188
+ lines.push(` severity: ${descriptor.severity}`);
189
+ }
190
+ const optionDefaults = RULE_OPTION_DEFAULTS[descriptor.ruleId];
191
+ if (optionDefaults) {
192
+ lines.push(" options:");
193
+ for (const [key, value] of Object.entries(optionDefaults)) {
194
+ lines.push(` ${key}: ${value}`);
195
+ }
196
+ }
197
+ return lines;
198
+ }
199
+
200
+ /**
201
+ * Decide whether the analyse/summary actions should prompt the user to run `init`.
202
+ *
203
+ * Returns true only when every gate passes: interaction is allowed, output isn't suppressed, the
204
+ * user hasn't already opted in (--config) or out (--no-config) of config loading, all three
205
+ * standard streams are TTYs (stdin to type, stderr to display, stdout to confirm the run is not
206
+ * piping machine output to a downstream consumer), and no supported config file is already
207
+ * present at the project root.
208
+ *
209
+ * @param context CLI-collected state needed to make the decision.
210
+ * @returns Whether the prompt should be shown.
211
+ */
212
+ function shouldPromptForInit(context: InitPromptContext): boolean {
213
+ if (!context.isInteractionAllowed) {
214
+ return false;
215
+ }
216
+ if (context.isOutputSuppressed) {
217
+ return false;
218
+ }
219
+ if (context.shouldSkipConfig || context.hasExplicitConfig) {
220
+ return false;
221
+ }
222
+ if (!context.isStdinTty || !context.isStdoutTty || !context.isStderrTty) {
223
+ return false;
224
+ }
225
+ return defaultConfigPath(context.projectRoot) === undefined;
226
+ }
227
+
228
+ /**
229
+ * Ask the user a yes/no question on stderr and return their answer.
230
+ *
231
+ * Defaults to "no" when the user just presses enter so the prompt is safe to dismiss. Reads from
232
+ * stdin; closes the readline interface in a finally so a Ctrl-C exits cleanly.
233
+ *
234
+ * @param question Prompt text written to stderr verbatim.
235
+ * @returns True when the user typed y or yes (case-insensitive); false otherwise.
236
+ */
237
+ async function promptYesNo(question: string): Promise<boolean> {
238
+ const readlineInterface = createInterface({ input: process.stdin, output: process.stderr });
239
+ try {
240
+ const answer = await readlineInterface.question(question);
241
+ return /^(y|yes)$/i.test(answer.trim());
242
+ } finally {
243
+ readlineInterface.close();
244
+ }
245
+ }
246
+
247
+ export type { InitPromptContext, InitResult };
248
+ export { DEFAULT_CONFIG_FILE_NAME, RULE_OPTION_DEFAULTS, promptYesNo, renderDefaultConfig, shouldPromptForInit, writeDefaultConfig };
package/src/line-rules.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  import { type SourceFile } from "./discovery.ts";
8
8
  import { makeFinding } from "./findings.ts";
9
9
  import { escapeRegex, finding, isCommentedOutCode } from "./findings-helpers.ts";
10
- import { type NamingSurface, pushAbbreviationAt, pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
10
+ import { type NamingSurface, pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
11
11
  import { analyseReliabilityLine, analyseSwallowedCatches, analyseTypeSafetyLine, analyseUselessCatches } from "./safety-rules.ts";
12
12
  import { analyseSecurityFlowLine } from "./security-flow-rules.ts";
13
13
  import { codeLineForMatching } from "./source-text.ts";
@@ -104,7 +104,7 @@ function codeLineChecks(): LineRuleCheck[] {
104
104
  { ruleId: "security.inner-html", pattern: /\.innerHTML\s*=(?!\s*(?:""|''))|\bdangerouslySetInnerHTML\b/, message: "HTML injection sink can introduce XSS.", severity: "warning", pillar: "security" },
105
105
  { ruleId: "security.proto-access", pattern: /\.__proto__\b/, message: "Direct __proto__ access can enable prototype pollution.", severity: "warning", pillar: "security" },
106
106
  { ruleId: "security.document-write", pattern: /\bdocument\.write\s*\(/, message: "document.write() can introduce injection risks.", severity: "warning", pillar: "security" },
107
- { ruleId: "waste.redundant-boolean-cast", pattern: /\b(?:if|while)\s*\(\s*(?:!!\s*[A-Za-z_$][A-Za-z0-9_$.]*|Boolean\s*\()/, message: "Condition contains a redundant boolean cast.", severity: "advisory", pillar: "waste" },
107
+ { ruleId: "waste.redundant-boolean-cast", pattern: /\b(?:if|while)\s*\(\s*(?:!!\s*[A-Za-z_$][A-Za-z0-9_$.]*|Boolean\s*\()/, message: "Condition contains a redundant boolean cast.", severity: "advisory", pillar: "maintainability" },
108
108
  ];
109
109
  }
110
110
 
@@ -120,8 +120,8 @@ function literalLineChecks(): LineRuleCheck[] {
120
120
  { ruleId: "security.sql-concatenation", pattern: /\b(?:query|execute|raw)\s*\(\s*(?:`[^`]*(?:SELECT|INSERT|UPDATE|DELETE)[^`]*\$\{|["'][^"']*(?:SELECT|INSERT|UPDATE|DELETE)[^"']*["']\s*\+)/i, message: "SQL text is composed with runtime string interpolation.", severity: "warning", pillar: "security" },
121
121
  { ruleId: "modernisation.date-now-candidate", pattern: /\bnew\s+Date\s*\(\s*\)\s*\.getTime\s*\(\s*\)|\bNumber\s*\(\s*new\s+Date\s*\(\s*\)\s*\)/, message: "Current-time expression can use Date.now().", severity: "advisory", pillar: "modernisation" },
122
122
  { ruleId: "modernisation.object-spread-candidate", pattern: /\bObject\.assign\s*\(\s*\{\s*\}\s*,/, message: "Object.assign clone can usually use object spread.", severity: "advisory", pillar: "modernisation" },
123
- { ruleId: "waste.console-log", pattern: /\bconsole\.(log|debug)\s*\(/, message: "console logging is committed in source.", severity: "advisory", pillar: "waste" },
124
- { ruleId: "waste.any-type", pattern: /:\s*any\b|as\s+any\b/, message: "any weakens TypeScript's type guarantees.", severity: "warning", pillar: "waste" },
123
+ { ruleId: "waste.console-log", pattern: /\bconsole\.(log|debug)\s*\(/, message: "console logging is committed in source.", severity: "advisory", pillar: "maintainability" },
124
+ { ruleId: "waste.any-type", pattern: /:\s*any\b|as\s+any\b/, message: "any weakens TypeScript's type guarantees.", severity: "warning", pillar: "maintainability" },
125
125
  { ruleId: "modernisation.var-declaration", pattern: /\bvar\s+[A-Za-z_$]/, message: "var declaration should usually be let or const.", severity: "advisory", pillar: "modernisation" },
126
126
  ];
127
127
  return checks.map(withGlobalPattern);
@@ -143,7 +143,7 @@ function withGlobalPattern(check: LineRuleCheck): LineRuleCheck {
143
143
  */
144
144
  function pushCommentedOutCodeFinding(context: LineRuleContext): void {
145
145
  if (isCommentedOutCode(context.line)) {
146
- context.findings.push(finding({ ruleId: "waste.commented-out-code", message: "Comment appears to contain disabled source code.", file: context.file, line: context.lineNumber, severity: "advisory", pillar: "waste" }));
146
+ context.findings.push(finding({ ruleId: "waste.commented-out-code", message: "Comment appears to contain disabled source code.", file: context.file, line: context.lineNumber, severity: "advisory", pillar: "maintainability" }));
147
147
  }
148
148
  }
149
149
 
@@ -279,19 +279,17 @@ function isSuppressedByPathContext(ruleId: string, displayPath: string): boolean
279
279
  return /(?:^|\/)(?:scripts|bin)\//.test(displayPath) || /(?:^|\/)cli[/.]/i.test(displayPath) || /(?:^|\/)server[/.]/i.test(displayPath);
280
280
  }
281
281
 
282
- // Per-line variable-name pass that runs short/identifier-quality/abbreviation checks on both
282
+ // Per-line variable-name pass that runs short/identifier-quality checks on both
283
283
  // regular `const`/`let` declarations and destructured names. Reports any findings produced.
284
284
  function pushVariableNameFindings(context: LineRuleContext): void {
285
285
  for (const match of context.codeLine.matchAll(context.variables)) {
286
286
  const name = match[1] ?? "";
287
287
  pushShortVariableFinding(context, name);
288
288
  pushIdentifierQualityFinding(context, name);
289
- pushAbbreviationAt(context.file, context.lineNumber, name, context.config, context.findings, "declaration");
290
289
  }
291
290
  for (const name of destructuredLocalNames(context.codeLine)) {
292
291
  pushShortVariableAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
293
292
  pushIdentifierQualityAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
294
- pushAbbreviationAt(context.file, context.lineNumber, name, context.config, context.findings, "destructure");
295
293
  }
296
294
  }
297
295
 
@@ -124,37 +124,6 @@ export function pushIdentifierQualityAt(file: SourceFile, line: number, name: st
124
124
  );
125
125
  }
126
126
 
127
- /*
128
- * Reports `naming.abbreviation` when the name is on `abbreviationDenylist` and not on the user's
129
- * `acceptedAbbreviations` allowlist. `surface` distinguishes parameter / variable / interface-field
130
- * - same stable rule contract, different metadata, so consumers can filter on origin.
131
- */
132
- export function pushAbbreviationAt(file: SourceFile, line: number, name: string, config: Config, findings: Finding[], surface: NamingSurface): void {
133
- if (config.rules.get("naming.abbreviation")?.enabled !== true) {
134
- return;
135
- }
136
- if (config.acceptedAbbreviations.has(name.toLowerCase())) {
137
- return;
138
- }
139
- if (!config.abbreviationDenylist.has(name.toLowerCase())) {
140
- return;
141
- }
142
- findings.push(
143
- makeFinding({
144
- ruleId: "naming.abbreviation",
145
- message: `Identifier \`${name}\` uses an opaque abbreviation.`,
146
- filePath: file.displayPath,
147
- line,
148
- severity: "advisory",
149
- pillar: "naming",
150
- confidence: "medium",
151
- symbol: name,
152
- remediation: "Use the full domain term or add the abbreviation to allowlists.acceptedAbbreviations.",
153
- metadata: { identifierName: name, surface },
154
- }),
155
- );
156
- }
157
-
158
127
  // Returns `"generic"` for low-information names from the configured set, `"numbered"` for
159
128
  // `foo1` / `bar2` style trailing-digit identifiers, or undefined when the name is acceptable.
160
129
  // The variant string lands in finding metadata so consumers can split the two failure modes.
@@ -198,7 +198,7 @@ function pushBroadRuntimeDependencyFinding(
198
198
  filePath: file.displayPath,
199
199
  line: jsonKeyLine(source, packageName),
200
200
  severity: "advisory",
201
- pillar: "waste",
201
+ pillar: "maintainability",
202
202
  confidence: "medium",
203
203
  symbol: packageName,
204
204
  remediation: "Use a bounded semver range and rely on the lockfile for repeatable installs.",
@@ -26,7 +26,7 @@ function renderReport(report: AnalysisReport, format: OutputFormat): string {
26
26
  case "github":
27
27
  return renderGithub(report);
28
28
  case "hotspot":
29
- return JSON.stringify({ schemaVersion: "gruff.hotspot.v1", tool: report.tool, score: report.score.composite, files: report.score.topOffenders }, null, 2);
29
+ return JSON.stringify({ schemaVersion: "gruff.hotspot.v1", tool: report.tool, score: report.score.composite, files: report.score.topOffenders.slice(0, 10) }, null, 2);
30
30
  case "sarif":
31
31
  return renderSarif(report);
32
32
  case "text":
@@ -179,7 +179,7 @@ function sarifLevel(severity: Severity): "error" | "warning" | "note" {
179
179
  * because the CLI should be able to improve wording/layout without a schema bump; callers that need
180
180
  * durable machine output should use `analyse --format=json` instead.
181
181
  */
182
- function renderSummary(report: AnalysisReport, elapsedMs?: number, pathLabel?: string): string {
182
+ function renderSummary(report: AnalysisReport, elapsedMs?: number, pathLabel?: string, top = 10): string {
183
183
  const pillarCounts = countBy(report.findings, (finding) => finding.pillar);
184
184
  const ruleCounts = countBy(report.findings, (finding) => finding.ruleId);
185
185
  const lines = [
@@ -189,25 +189,56 @@ function renderSummary(report: AnalysisReport, elapsedMs?: number, pathLabel?: s
189
189
  `Score: ${report.score.composite.toFixed(1)} (${report.score.grade})`,
190
190
  `Findings: ${report.summary.total} total, ${report.summary.error} error, ${report.summary.warning} warning, ${report.summary.advisory} advisory`,
191
191
  `Analysed files: ${report.paths.analysedFiles}`,
192
+ ...(report.baseline ? [summaryBaselineLine(report.baseline)] : []),
192
193
  ];
193
194
  if (report.diagnostics.length > 0) {
194
195
  lines.push("", "Diagnostics:", ...report.diagnostics.map(summaryDiagnosticLine));
195
196
  }
196
197
  lines.push("", "Per-pillar counts:");
197
198
  lines.push(...renderRankedCounts(pillarCounts, "No findings by pillar."));
198
- lines.push("", "Top rules:");
199
- lines.push(...renderRankedCounts(ruleCounts, "No rule findings."));
200
- lines.push("", "Top file offenders:");
199
+ lines.push("", `Top ${top} rules:`);
200
+ lines.push(...renderRankedCounts(ruleCounts, "No rule findings.", top));
201
+ lines.push("", `Top ${top} file offenders:`);
201
202
  lines.push(
202
203
  ...(
203
204
  report.score.topOffenders.length === 0
204
205
  ? ["- No file offenders."]
205
- : report.score.topOffenders.map((offender) => `- ${offender.filePath}: ${offender.findings} findings, score ${offender.score.toFixed(1)}`)
206
+ : report.score.topOffenders.slice(0, top).map((offender) => `- ${offender.filePath}: ${offender.findings} findings, score ${offender.score.toFixed(1)}`)
206
207
  ),
207
208
  );
208
209
  return `${lines.join("\n")}\n`;
209
210
  }
210
211
 
212
+ /*
213
+ * Renders the stable public `gruff.summary.v1` JSON contract for the `summary --format=json` flow.
214
+ * The schema shape (scope/score/findings/topRules/topOffenders) is part of that contract - downstream
215
+ * CI integrations parse it, so field renames or removals are breaking changes for users.
216
+ */
217
+ function renderSummaryJson(report: AnalysisReport, elapsedMs?: number, pathLabel?: string, top = 10): string {
218
+ const pillarCounts = countBy(report.findings, (finding) => finding.pillar);
219
+ const ruleCounts = countBy(report.findings, (finding) => finding.ruleId);
220
+ const payload = {
221
+ schemaVersion: "gruff.summary.v1",
222
+ tool: report.tool,
223
+ scope: {
224
+ paths: pathLabel ?? report.run.projectRoot,
225
+ projectRoot: report.run.projectRoot,
226
+ analysedFiles: report.paths.analysedFiles,
227
+ ignoredPaths: report.paths.ignoredPaths.length,
228
+ missingPaths: report.paths.missingPaths.length,
229
+ diagnostics: report.diagnostics.length,
230
+ elapsedSeconds: typeof elapsedMs === "number" ? Number((elapsedMs / 1000).toFixed(3)) : undefined,
231
+ },
232
+ score: report.score,
233
+ findings: report.summary,
234
+ baseline: report.baseline,
235
+ pillars: renderRankedCountRows(pillarCounts),
236
+ topRules: renderRankedCountRows(ruleCounts, top),
237
+ topOffenders: report.score.topOffenders.slice(0, top),
238
+ };
239
+ return `${JSON.stringify(payload, null, 2)}\n`;
240
+ }
241
+
211
242
  // Shared text formatter for diagnostic rows in plain-text summaries and the `text` format. Stable
212
243
  // "- {type}: {message} (path)" shape is part of the contract that scripts grepping the text output
213
244
  // rely on, so the format must stay deterministic across both call sites.
@@ -216,6 +247,15 @@ function summaryDiagnosticLine(diagnostic: AnalysisReport["diagnostics"][number]
216
247
  return `- ${diagnostic.diagnosticType}: ${diagnostic.message}${location}`;
217
248
  }
218
249
 
250
+ // Documents the summary baseline contract so suppressed findings are not mistaken for a clean scan.
251
+ function summaryBaselineLine(baseline: NonNullable<AnalysisReport["baseline"]>): string {
252
+ if (baseline.generated) {
253
+ return `Baseline: generated ${baseline.path}; current findings still shown`;
254
+ }
255
+ const findingNoun = baseline.suppressed === 1 ? "finding" : "findings";
256
+ return `Baseline: ${baseline.source} ${baseline.path}; suppressed ${baseline.suppressed} ${findingNoun}`;
257
+ }
258
+
219
259
  // Human-sized summary runtime without pretending sub-millisecond precision is useful.
220
260
  function formatSummaryDuration(elapsedMs: number): string {
221
261
  const bounded = Math.max(0, elapsedMs);
@@ -234,16 +274,23 @@ function countBy<T extends string>(findings: Finding[], keyFor: (finding: Findin
234
274
  return counts;
235
275
  }
236
276
 
237
- function renderRankedCounts<T extends string>(counts: Map<T, number>, emptyText: string): string[] {
277
+ function renderRankedCounts<T extends string>(counts: Map<T, number>, emptyText: string, limit?: number): string[] {
238
278
  if (counts.size === 0) {
239
279
  return [`- ${emptyText}`];
240
280
  }
241
281
  return [...counts.entries()]
242
282
  .sort(([leftKey, leftCount], [rightKey, rightCount]) => rightCount - leftCount || leftKey.localeCompare(rightKey))
243
- .slice(0, 10)
283
+ .slice(0, limit ?? counts.size)
244
284
  .map(([key, count]) => `- ${key}: ${count}`);
245
285
  }
246
286
 
287
+ function renderRankedCountRows<T extends string>(counts: Map<T, number>, limit?: number): Array<{ name: T; count: number }> {
288
+ return [...counts.entries()]
289
+ .sort(([leftKey, leftCount], [rightKey, rightCount]) => rightCount - leftCount || leftKey.localeCompare(rightKey))
290
+ .slice(0, limit ?? counts.size)
291
+ .map(([name, count]) => ({ name, count }));
292
+ }
293
+
247
294
  /*
248
295
  * Default terminal output. Findings are listed verbatim (no truncation) - the analyser keeps them
249
296
  * sorted into the stable order, so piping into `grep` produces deterministic results.
@@ -424,13 +471,15 @@ function htmlPillars(report: AnalysisReport): string {
424
471
  return `<section class="pillars"><h2 class="section-head">pillar grades <span class="aside">weighted composite</span></h2><div class="pillar-grid">${items}</div></section>`;
425
472
  }
426
473
 
427
- // Top-10 offender table, ordered by ascending score (worst first). `scoreReport` already truncated
428
- // the list to 10 - this stable, deterministic limit is part of the public report contract.
474
+ // Top-10 offender table, ordered by ascending score (worst first). `scoreReport` returns the full
475
+ // sorted list; summary and hotspot apply their own caps from the same source. The HTML report caps
476
+ // at 10 rows so the visual layout stays stable regardless of project size - that 10 is the contract.
429
477
  function htmlOffenders(report: AnalysisReport): string {
430
478
  const rows =
431
479
  report.score.topOffenders.length === 0
432
480
  ? '<tr><td colspan="4">No offenders found.</td></tr>'
433
481
  : report.score.topOffenders
482
+ .slice(0, 10)
434
483
  .map((file) => {
435
484
  const letter = grade(file.score);
436
485
  return `<tr><td class="file-path">${htmlLocation(file.filePath)}</td><td class="num">${file.score.toFixed(1)}</td><td class="num">${file.findings}</td><td class="num"><span class="grade-pill ${gradeClass(letter)}">${letter}</span></td></tr>`;
@@ -688,4 +737,4 @@ function escapeHtml(htmlText: string): string {
688
737
  return htmlText.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;").replaceAll('"', "&quot;");
689
738
  }
690
739
 
691
- export { dashboardErrorHtml, dashboardHomeHtml, grade, renderHtml, renderReport, renderSummary };
740
+ export { dashboardErrorHtml, dashboardHomeHtml, grade, renderHtml, renderReport, renderSummary, renderSummaryJson };
package/src/rule-list.ts CHANGED
@@ -21,6 +21,7 @@ const CONSOLE_COMMANDS = [
21
21
  { name: "completion", description: "Dump the shell completion script" },
22
22
  { name: "dashboard", description: "Serve the local gruff dashboard." },
23
23
  { name: "help", description: "Display help for a command" },
24
+ { name: "init", description: "Write the default .gruff-ts.yaml to the current directory." },
24
25
  { name: "list", description: "List commands" },
25
26
  { name: "list-rules", description: "List gruff rule metadata." },
26
27
  { name: "report", description: "Render a gruff report to stdout or a file." },