@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.
@@ -0,0 +1,177 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ NPM_AUDIT_LEVEL="${NPM_AUDIT_LEVEL:-moderate}"
5
+ DRY_RUN=0
6
+ LATEST=0
7
+ RUN_AUDIT=1
8
+ RUN_CHECK=1
9
+
10
+ usage() {
11
+ cat <<'USAGE'
12
+ Usage:
13
+ scripts/dependency-update.sh [options]
14
+
15
+ Updates npm dependencies, then verifies the result.
16
+
17
+ Default behavior:
18
+ - npm update
19
+ - npm audit --audit-level=moderate
20
+ - npm run check
21
+
22
+ Options:
23
+ --dry-run Show npm outdated output without changing files
24
+ --latest Update direct dependencies/devDependencies to @latest
25
+ --audit-level LEVEL npm audit threshold (default: moderate)
26
+ --no-audit Skip npm audit after update
27
+ --no-check Skip npm run check after update
28
+ --help, -h Show this help
29
+
30
+ Environment:
31
+ NPM_AUDIT_LEVEL Default audit threshold when --audit-level is omitted
32
+ USAGE
33
+ }
34
+
35
+ die() {
36
+ printf 'dependency-update: %s\n' "$*" >&2
37
+ exit 1
38
+ }
39
+
40
+ repo_root() {
41
+ local script_dir
42
+ script_dir="$(CDPATH='' cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
43
+ CDPATH='' cd -- "$script_dir/.." && pwd
44
+ }
45
+
46
+ parse_args() {
47
+ while [[ "$#" -gt 0 ]]; do
48
+ case "$1" in
49
+ --dry-run)
50
+ DRY_RUN=1
51
+ ;;
52
+ --latest)
53
+ LATEST=1
54
+ ;;
55
+ --audit-level)
56
+ [[ "$#" -ge 2 ]] || die "--audit-level requires a value"
57
+ NPM_AUDIT_LEVEL="$2"
58
+ shift
59
+ ;;
60
+ --audit-level=*)
61
+ NPM_AUDIT_LEVEL="${1#*=}"
62
+ ;;
63
+ --no-audit)
64
+ RUN_AUDIT=0
65
+ ;;
66
+ --no-check)
67
+ RUN_CHECK=0
68
+ ;;
69
+ --help|-h)
70
+ usage
71
+ exit 0
72
+ ;;
73
+ *)
74
+ usage >&2
75
+ die "unknown option: $1"
76
+ ;;
77
+ esac
78
+ shift
79
+ done
80
+ }
81
+
82
+ require_tool() {
83
+ command -v "$1" >/dev/null 2>&1 || die "$1 is required"
84
+ }
85
+
86
+ print_outdated() {
87
+ local output
88
+ local status
89
+
90
+ if output=$(npm outdated --long 2>&1); then
91
+ printf '%s\n' "$output"
92
+ echo "All dependencies are current within the configured ranges."
93
+ return 0
94
+ else
95
+ status=$?
96
+ fi
97
+
98
+ # npm outdated exits non-zero when it finds outdated dependencies.
99
+ if [[ -n "$output" ]]; then
100
+ printf '%s\n' "$output"
101
+ fi
102
+ if ((status == 1)); then
103
+ return 0
104
+ fi
105
+
106
+ return "$status"
107
+ }
108
+
109
+ read_direct_dependencies() {
110
+ local field="$1"
111
+ # Print one key per line, nothing for an empty section. `console.log(keys.join("\n"))` would
112
+ # emit a lone newline when the section is empty, and `mapfile -t` would then read a single
113
+ # empty element, causing the install loop below to run `npm install --save-prod @latest`.
114
+ node -e "const pkg = require('./package.json'); for (const key of Object.keys(pkg['$field'] || {})) console.log(key);"
115
+ }
116
+
117
+ install_latest_dependencies() {
118
+ local -a dependencies=()
119
+ local -a dev_dependencies=()
120
+ local dependency
121
+
122
+ mapfile -t dependencies < <(read_direct_dependencies dependencies)
123
+ mapfile -t dev_dependencies < <(read_direct_dependencies devDependencies)
124
+
125
+ if [[ "${#dependencies[@]}" -gt 0 ]]; then
126
+ for dependency in "${dependencies[@]}"; do
127
+ dependency="${dependency}@latest"
128
+ echo "Updating production dependency: $dependency"
129
+ npm install --save-prod "$dependency"
130
+ done
131
+ fi
132
+
133
+ if [[ "${#dev_dependencies[@]}" -gt 0 ]]; then
134
+ for dependency in "${dev_dependencies[@]}"; do
135
+ dependency="${dependency}@latest"
136
+ echo "Updating development dependency: $dependency"
137
+ npm install --save-dev "$dependency"
138
+ done
139
+ fi
140
+ }
141
+
142
+ main() {
143
+ parse_args "$@"
144
+ cd "$(repo_root)"
145
+
146
+ require_tool node
147
+ require_tool npm
148
+ [[ -f package.json ]] || die "package.json not found"
149
+ [[ -f package-lock.json ]] || die "package-lock.json not found"
150
+
151
+ if [[ "$DRY_RUN" -eq 1 ]]; then
152
+ echo "--- Outdated dependencies ---"
153
+ print_outdated
154
+ exit 0
155
+ fi
156
+
157
+ echo "--- Update dependencies ---"
158
+ if [[ "$LATEST" -eq 1 ]]; then
159
+ install_latest_dependencies
160
+ else
161
+ npm update
162
+ fi
163
+
164
+ if [[ "$RUN_AUDIT" -eq 1 ]]; then
165
+ echo ""
166
+ echo "--- Dependency audit ---"
167
+ npm audit --audit-level="$NPM_AUDIT_LEVEL"
168
+ fi
169
+
170
+ if [[ "$RUN_CHECK" -eq 1 ]]; then
171
+ echo ""
172
+ echo "--- TypeScript + tests ---"
173
+ npm run check
174
+ fi
175
+ }
176
+
177
+ main "$@"
@@ -33,6 +33,8 @@ FAILED=0
33
33
  FAILURES=()
34
34
  TMP_FILES=()
35
35
  START_TIME=$(date +%s%N)
36
+ NPM_REGISTRY_URL="${NPM_REGISTRY_URL:-https://registry.npmjs.org/}"
37
+ NPM_AUDIT_LEVEL="${NPM_AUDIT_LEVEL:-moderate}"
36
38
 
37
39
  usage() {
38
40
  cat <<'USAGE'
@@ -40,12 +42,16 @@ Usage:
40
42
  scripts/preflight-checks.sh
41
43
 
42
44
  Runs the local preflight gate:
45
+ - release version is internally consistent and not already published to npm
46
+ - npm dependency audit
43
47
  - npm run check (TypeScript compile plus unit tests)
44
48
  - gruff-ts full-project scan
45
49
  - shellcheck for scripts/*.sh when shellcheck is installed
46
50
 
47
51
  Environment:
48
52
  GRUFF_TS_FAIL_ON gruff-ts severity that fails static analysis (default: advisory)
53
+ NPM_REGISTRY_URL npm registry used for the published-version check
54
+ NPM_AUDIT_LEVEL npm audit threshold (default: moderate)
49
55
  USAGE
50
56
  }
51
57
 
@@ -176,6 +182,71 @@ make_temp_file() {
176
182
  printf '%s\n' "$temp_file"
177
183
  }
178
184
 
185
+ read_package_name() {
186
+ node -p "require('./package.json').name"
187
+ }
188
+
189
+ read_package_version() {
190
+ node -p "require('./package.json').version"
191
+ }
192
+
193
+ npm_version_check() {
194
+ local package_name
195
+ local version
196
+ local output
197
+ local status
198
+
199
+ package_name="$(read_package_name)" || return 1
200
+ version="$(read_package_version)" || return 1
201
+ [[ -n "$package_name" ]] || {
202
+ printf 'package.json has no name field\n'
203
+ return 1
204
+ }
205
+ [[ -n "$version" ]] || {
206
+ printf 'package.json has no version field\n'
207
+ return 1
208
+ }
209
+
210
+ output=$(bash scripts/bump-version.sh --check 2>&1)
211
+ status=$?
212
+ if ((status != 0)); then
213
+ printf '%s\n' "$output"
214
+ return "$status"
215
+ fi
216
+
217
+ output=$(npm view "${package_name}@${version}" version --registry="$NPM_REGISTRY_URL" 2>&1)
218
+ status=$?
219
+ if ((status == 0)); then
220
+ printf '%s@%s is already published on %s; run scripts/bump-version.sh <next-version>\n' "$package_name" "$version" "$NPM_REGISTRY_URL"
221
+ return 1
222
+ fi
223
+
224
+ if printf '%s\n' "$output" | grep -Eq '(^|[[:space:]])(E404|404)([[:space:]]|$)'; then
225
+ printf 'lockstep ok; %s@%s is not published on %s' "$package_name" "$version" "$NPM_REGISTRY_URL"
226
+ return 0
227
+ fi
228
+
229
+ printf '%s\n' "$output"
230
+ return "$status"
231
+ }
232
+
233
+ npm_audit_check() {
234
+ local output
235
+ local status
236
+ local summary
237
+
238
+ output=$(npm audit --audit-level="$NPM_AUDIT_LEVEL" 2>&1)
239
+ status=$?
240
+ if ((status != 0)); then
241
+ printf '%s\n' "$output"
242
+ return "$status"
243
+ fi
244
+
245
+ summary=$(printf '%s\n' "$output" | awk '/found .* vulnerabilities|audited .* packages/ { line = $0 } END { print line }')
246
+ printf '%s' "${summary:-completed}"
247
+ return 0
248
+ }
249
+
179
250
  npm_check() {
180
251
  local output
181
252
  local status
@@ -340,6 +411,10 @@ main() {
340
411
  return 127
341
412
  fi
342
413
 
414
+ run_step "Release version" npm_version_check
415
+
416
+ run_step "Dependency audit" npm_audit_check
417
+
343
418
  run_step "TypeScript + tests" npm_check
344
419
 
345
420
  run_step "Gruff full-project scan" gruff_ts_check
package/src/analyser.ts CHANGED
@@ -1,5 +1,5 @@
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),
1
+ // Analyser pipeline: walks discovered sources, runs every rule pass (complexity, dead-code, design,
2
+ // documentation, maintainability, modernisation, naming, security, sensitive-data, size, test-quality),
3
3
  // aggregates findings into the `gruff.analysis.v1` schema, and exposes `analyse` to the CLI shell.
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { cwd } from "node:process";
@@ -18,14 +18,13 @@ import { analyseDeadCode, analyseUnreachable, analyseUnusedImports } from "./dea
18
18
  import { analyseCommentQualityRules } from "./comment-rules.ts";
19
19
  import { analyseDocRules, analyseFileOverviewDoc, analyseInterfaceDocs } from "./doc-rules.ts";
20
20
  import { analyseLineRules } from "./line-rules.ts";
21
- import { pushAbbreviationAt, pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
21
+ import { pushBooleanPrefixAt, pushIdentifierQualityAt, pushNegativeBooleanAt, pushShortVariableAt } from "./naming-pushers.ts";
22
22
  import { analyseTestBlock } from "./test-block-rules.ts";
23
23
  import { analyseGithubActionsRules } from "./github-actions-rules.ts";
24
24
  import { analyseProjectConfigRules } from "./project-config-rules.ts";
25
25
  import { scoreReport, summarize } from "./scoring.ts";
26
26
  import { analyseSensitiveData } from "./sensitive-data-rules.ts";
27
27
  import { maskNonCode, maskTemplateLiteralBodies, parseDiagnostics } from "./source-text.ts";
28
- import { todoMarkerSummary } from "./text-scans.ts";
29
28
  import type { AnalysisOptions, AnalysisReport, Config, Finding, RunDiagnostic } from "./types.ts";
30
29
 
31
30
  /**
@@ -308,18 +307,6 @@ function analyseTextRules(file: SourceFile, source: string, config: Config, find
308
307
  }
309
308
  }
310
309
 
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
310
  analyseSensitiveData(file, source, config, findings);
324
311
  analyseGithubActionsRules(file, source, findings);
325
312
  analyseProjectConfigRules(file, source, findings);
@@ -390,9 +377,8 @@ function analyseBlocks(file: SourceFile, blocks: FunctionBlock[], config: Config
390
377
  }
391
378
 
392
379
  /*
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.
380
+ * Per-parameter naming-rule fanout. Each parameter is checked for short-name / placeholder forms;
381
+ * typed booleans get the extra prefix and negative-name checks. Reports findings to the shared sink.
396
382
  */
397
383
  function pushParameterNamingFindings(context: BlockRuleContext): void {
398
384
  const line = context.block.declarationLine;
@@ -400,7 +386,6 @@ function pushParameterNamingFindings(context: BlockRuleContext): void {
400
386
  for (const parameter of params) {
401
387
  pushShortVariableAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
402
388
  pushIdentifierQualityAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
403
- pushAbbreviationAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
404
389
  if (isBooleanParameter(parameter.raw)) {
405
390
  pushBooleanPrefixAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
406
391
  pushNegativeBooleanAt(context.file, line, parameter.name, context.config, context.findings, "parameter");
package/src/blocks.ts CHANGED
@@ -223,7 +223,7 @@ function pushEmptyFunctionFinding(context: BlockRuleContext): void {
223
223
  return;
224
224
  }
225
225
  if (isEmptyFunctionBody(context.block.codeBody)) {
226
- context.findings.push(blockFinding({ ruleId: "waste.empty-function", message: `Function \`${context.block.name}\` has no executable body.`, file: context.file, block: context.block, severity: "advisory", pillar: "waste" }));
226
+ context.findings.push(blockFinding({ ruleId: "waste.empty-function", message: `Function \`${context.block.name}\` has no executable body.`, file: context.file, block: context.block, severity: "advisory", pillar: "maintainability" }));
227
227
  }
228
228
  }
229
229
 
@@ -287,7 +287,7 @@ function unusedParameterFinding(context: BlockRuleContext, parameterName: string
287
287
  filePath: context.file.displayPath,
288
288
  line: context.block.startLine,
289
289
  severity: "advisory",
290
- pillar: "waste",
290
+ pillar: "maintainability",
291
291
  confidence: "medium",
292
292
  symbol: context.block.name,
293
293
  remediation: "Remove the parameter or prefix it with _ if it is intentionally unused.",
@@ -306,7 +306,7 @@ function pushRedundantVariableFindings(context: BlockRuleContext): void {
306
306
  filePath: context.file.displayPath,
307
307
  line: context.block.startLine + redundant.lineOffset,
308
308
  severity: "advisory",
309
- pillar: "waste",
309
+ pillar: "maintainability",
310
310
  confidence: "medium",
311
311
  symbol: redundant.name,
312
312
  remediation: "Return the expression directly.",
@@ -327,7 +327,7 @@ function pushUselessReturnFindings(context: BlockRuleContext): void {
327
327
  filePath: context.file.displayPath,
328
328
  line: context.block.startLine + lineOffset,
329
329
  severity: "advisory",
330
- pillar: "waste",
330
+ pillar: "maintainability",
331
331
  confidence: "medium",
332
332
  symbol: context.block.name,
333
333
  remediation: "Remove the final return statement.",
@@ -7,7 +7,7 @@ import { type ExportedDeclaration, exportedDeclarations, pushMissingPublicDocFin
7
7
  import { type SourceFile } from "./discovery.ts";
8
8
  import { makeFinding } from "./findings.ts";
9
9
  import { fileBaseName, finding, normalizedIdentifier } from "./findings-helpers.ts";
10
- import { pushAbbreviationAt, pushBooleanPrefixAt, pushNegativeBooleanAt } from "./naming-pushers.ts";
10
+ import { pushBooleanPrefixAt, pushNegativeBooleanAt } from "./naming-pushers.ts";
11
11
  import { byteLine } from "./text-scans.ts";
12
12
  import type { Config, Finding } from "./types.ts";
13
13
 
@@ -46,7 +46,7 @@ export function collectDeclaredIdentifiers(source: string, codeSource: string, b
46
46
  }
47
47
 
48
48
  // Walks every interface body line and matches the field declaration regex. Used both for the
49
- // naming inventory (above) and for the per-field interface rules (boolean prefix, abbreviation).
49
+ // naming inventory (above) and for the per-field interface rules (boolean prefix, negative-boolean).
50
50
  function collectInterfaceFieldDeclarations(source: string, codeSource: string): DeclaredIdentifier[] {
51
51
  const fieldRegex = /^[ \t]*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:/;
52
52
  const out: DeclaredIdentifier[] = [];
@@ -190,9 +190,9 @@ function isFixtureIdentifier(name: string): boolean {
190
190
  }
191
191
 
192
192
  /*
193
- * Walks every interface field and runs three checks per field: abbreviation, boolean prefix,
194
- * negative boolean. The stable ordering matches `pushAbbreviationAt` → `pushBooleanPrefixAt`
195
- * `pushNegativeBooleanAt` so multiple findings on one field surface in a deterministic sequence.
193
+ * Walks every interface field and runs two checks per boolean field: boolean prefix and
194
+ * negative boolean. The stable ordering matches `pushBooleanPrefixAt` → `pushNegativeBooleanAt`
195
+ * so multiple findings on one field surface in a deterministic sequence.
196
196
  */
197
197
  export function analyseInterfaceFields(file: SourceFile, source: string, codeSource: string, config: Config, findings: Finding[]): void {
198
198
  const fieldRegex = /^[ \t]*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:\s*([^;]+)/;
@@ -200,7 +200,6 @@ export function analyseInterfaceFields(file: SourceFile, source: string, codeSou
200
200
  const match = sourceLine.match(fieldRegex);
201
201
  const name = match?.[1] ?? "";
202
202
  if (!name) continue;
203
- pushAbbreviationAt(file, lineIndex + 1, name, config, findings, "interface-field");
204
203
  if (/^\s*boolean\b/.test(match?.[2] ?? "")) {
205
204
  pushBooleanPrefixAt(file, lineIndex + 1, name, config, findings, "interface-field");
206
205
  pushNegativeBooleanAt(file, lineIndex + 1, name, config, findings, "interface-field");
@@ -1,12 +1,13 @@
1
1
  // Commander CLI shell wiring that keeps option normalization and stdout behavior outside the analyzer.
2
- import { Command, Help } from "commander";
2
+ import { Command, Help, InvalidArgumentError } from "commander";
3
3
  import { writeFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { performance } from "node:perf_hooks";
6
6
  import { DEFAULT_BASELINE } from "./baseline.ts";
7
7
  import { VERSION } from "./constants.ts";
8
8
  import { startDashboard } from "./dashboard.ts";
9
- import { renderReport, renderSummary } from "./report-renderers.ts";
9
+ import { promptYesNo, shouldPromptForInit, writeDefaultConfig } from "./init-config.ts";
10
+ import { renderReport, renderSummary, renderSummaryJson } from "./report-renderers.ts";
10
11
  import { completionShell, renderCompletionScript, renderConsoleList, renderRuleList, type RuleListFormat } from "./rule-list.ts";
11
12
  import { exitFor } from "./scoring.ts";
12
13
  import type { AnalysisOptions, AnalysisReport } from "./types.ts";
@@ -35,6 +36,49 @@ function outputSuppressed(program: Command): boolean {
35
36
  return options.quiet === true || options.silent === true;
36
37
  }
37
38
 
39
+ // Per-command config-loading state passed into maybePromptInitConfig. analyse/summary/report
40
+ // take both flags from normalizeOptions; dashboard has no equivalent flags so it passes
41
+ // `{ shouldSkipConfig: false, hasExplicitConfig: false }`.
42
+ interface InitPromptOptions {
43
+ shouldSkipConfig: boolean;
44
+ hasExplicitConfig: boolean;
45
+ }
46
+
47
+ // Asks the user whether to run `init` when no config exists, then writes the file if they agree.
48
+ // Called from analyse/summary/report/dashboard before kicking off their main work so the
49
+ // freshly-written file is picked up by loadConfig on the same invocation. Pure decision logic
50
+ // lives in shouldPromptForInit; this function only handles orchestration and side effects.
51
+ async function maybePromptInitConfig(program: Command, projectRoot: string, options: InitPromptOptions): Promise<void> {
52
+ const programOptions = program.opts() as { interaction?: boolean };
53
+ const context = {
54
+ projectRoot,
55
+ shouldSkipConfig: options.shouldSkipConfig,
56
+ hasExplicitConfig: options.hasExplicitConfig,
57
+ isInteractionAllowed: programOptions.interaction !== false,
58
+ isOutputSuppressed: outputSuppressed(program),
59
+ isStdinTty: process.stdin.isTTY === true,
60
+ isStdoutTty: process.stdout.isTTY === true,
61
+ isStderrTty: process.stderr.isTTY === true,
62
+ };
63
+ if (!shouldPromptForInit(context)) {
64
+ return;
65
+ }
66
+ const accepted = await promptYesNo(`No gruff config found at ${projectRoot}. Run 'gruff-ts init' to create .gruff-ts.yaml? [y/N] `);
67
+ if (!accepted) {
68
+ return;
69
+ }
70
+ const result = writeDefaultConfig(projectRoot, false);
71
+ if (result.status === "written") {
72
+ process.stderr.write(`Wrote ${result.path}\n`);
73
+ }
74
+ }
75
+
76
+ // AnalysisOptions → InitPromptOptions shim used by analyse/summary/report so they share one
77
+ // branch and shouldSkipConfig stays the single source of truth for opt-out behaviour.
78
+ function promptOptionsFromAnalysis(options: AnalysisOptions): InitPromptOptions {
79
+ return { shouldSkipConfig: options.shouldSkipConfig, hasExplicitConfig: typeof options.config === "string" };
80
+ }
81
+
38
82
  // Three-state ANSI resolution: explicit `--ansi` forces colour, explicit `--no-ansi` forbids it,
39
83
  // otherwise autodetect from TTY. Required because pipelines and CI logs would otherwise eat colour codes.
40
84
  function ansiEnabled(program: Command): boolean {
@@ -56,6 +100,7 @@ export function buildProgram(runAnalyse: AnalyseRunner): Command {
56
100
  registerAnalyseCommand(program, runAnalyse);
57
101
  registerCompletionCommand(program);
58
102
  registerDashboardCommand(program, runAnalyse);
103
+ registerInitCommand(program);
59
104
  registerListCommand(program);
60
105
  registerListRulesCommand(program);
61
106
  registerReportCommand(program, runAnalyse);
@@ -127,8 +172,9 @@ function registerAnalyseCommand(program: Command, runAnalyse: AnalyseRunner): vo
127
172
  .option("--baseline [path]", "Suppress findings that match a gruff baseline JSON file.")
128
173
  .option("--generate-baseline [path]", "Write current findings to a gruff baseline JSON file.")
129
174
  .option("--no-baseline", "Skip auto-applying the default baseline file for this run.")
130
- .action((paths: string[], rawOptions: Record<string, unknown>) => {
175
+ .action(async (paths: string[], rawOptions: Record<string, unknown>) => {
131
176
  const options = normalizeOptions(paths, rawOptions, { shouldAllowBaselineFlag: true });
177
+ await maybePromptInitConfig(program, process.cwd(), promptOptionsFromAnalysis(options));
132
178
  const report = runAnalyse(options);
133
179
  writeCommandOutput(program, renderReport(report, options.format));
134
180
  process.exitCode = exitFor(report, options.failOn);
@@ -156,11 +202,45 @@ function registerDashboardCommand(program: Command, runAnalyse: AnalyseRunner):
156
202
  .option("--host <host>", "Host to bind.", "127.0.0.1")
157
203
  .option("--port <port>", "Port to bind.", "8767")
158
204
  .option("--project-root <path>", "Default project root.", ".")
205
+ .action(async (rawOptions: Record<string, unknown>) => {
206
+ const projectRoot = resolve(String(rawOptions.projectRoot ?? "."));
207
+ await maybePromptInitConfig(program, projectRoot, { shouldSkipConfig: false, hasExplicitConfig: false });
208
+ startDashboard(String(rawOptions.host ?? "127.0.0.1"), Number(rawOptions.port ?? 8767), projectRoot, runAnalyse, !outputSuppressed(program));
209
+ });
210
+ }
211
+
212
+ // Writes the default `.gruff-ts.yaml` to the current working directory. Refuses to clobber an
213
+ // existing config unless `--force` is passed; sets process.exitCode=1 in that case so scripted
214
+ // callers can detect the refusal without parsing stdout.
215
+ function registerInitCommand(program: Command): void {
216
+ program
217
+ .command("init")
218
+ .description("Write the default .gruff-ts.yaml to the current directory.")
219
+ .option("--force", "Write .gruff-ts.yaml even when another supported config (.gruff.yaml/.yml/.json) is present; overwrites .gruff-ts.yaml if it exists.")
159
220
  .action((rawOptions: Record<string, unknown>) => {
160
- startDashboard(String(rawOptions.host ?? "127.0.0.1"), Number(rawOptions.port ?? 8767), resolve(String(rawOptions.projectRoot ?? ".")), runAnalyse, !outputSuppressed(program));
221
+ const result = writeDefaultConfig(process.cwd(), rawOptions.force === true);
222
+ if (result.status === "exists") {
223
+ process.stderr.write(`Refusing to overwrite existing config: ${result.path}. Re-run with --force to replace it.\n`);
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+ const verb = result.status === "overwritten" ? "Overwrote" : "Wrote";
228
+ writeCommandOutput(program, initSuccessMessage(verb, result.path));
161
229
  });
162
230
  }
163
231
 
232
+ // Keeps `init` as a config-only write while pointing existing projects at the adoption flow.
233
+ function initSuccessMessage(verb: "Wrote" | "Overwrote", configPath: string): string {
234
+ return [
235
+ `${verb} ${configPath}`,
236
+ "",
237
+ "Next: generate an adoption baseline with:",
238
+ " gruff-ts analyse . --generate-baseline gruff-baseline.json --fail-on=none",
239
+ "Then gate new findings with:",
240
+ " gruff-ts analyse . --baseline gruff-baseline.json --fail-on=warning",
241
+ ].join("\n");
242
+ }
243
+
164
244
  // Mirrors the bare-root help output. Exists so users coming from Symfony's console conventions
165
245
  // can run `gruff-ts list` the way they expect.
166
246
  function registerListCommand(program: Command): void {
@@ -173,12 +253,12 @@ function registerListCommand(program: Command): void {
173
253
  }
174
254
 
175
255
  // Read-only catalogue dump. JSON is the canonical form consumed by docs builds; text is for humans.
176
- // Anything other than `json` falls back to `text` rather than erroring so old aliases keep working.
256
+ // `--format` is validated by `parseSummaryFormat`; unsupported values fail fast as a usage error.
177
257
  function registerListRulesCommand(program: Command): void {
178
258
  program
179
259
  .command("list-rules")
180
260
  .description("List gruff rule metadata.")
181
- .option("--format <format>", "Output format: text or json.", "text")
261
+ .option("--format <format>", "Output format: text or json.", parseSummaryFormat, "text")
182
262
  .action((rawOptions: Record<string, unknown>) => {
183
263
  const format: RuleListFormat = rawOptions.format === "json" ? "json" : "text";
184
264
  writeCommandOutput(program, renderRuleList(format));
@@ -200,9 +280,10 @@ function registerReportCommand(program: Command, runAnalyse: AnalyseRunner): voi
200
280
  .option("--fail-on <severity>", "Finding severity that fails the run.", "none")
201
281
  .option("--include-ignored", "Include files under default and Git ignored paths; config ignores still apply.")
202
282
  .option("--no-baseline", "Skip auto-applying the default baseline file for this run.")
203
- .action((paths: string[], rawOptions: Record<string, unknown>) => {
283
+ .action(async (paths: string[], rawOptions: Record<string, unknown>) => {
204
284
  const format = rawOptions.format === "json" ? "json" : "html";
205
285
  const options = normalizeOptions(paths, { ...rawOptions, format }, { shouldAllowBaselineFlag: false });
286
+ await maybePromptInitConfig(program, process.cwd(), promptOptionsFromAnalysis(options));
206
287
  const report = runAnalyse(options);
207
288
  const rendered = renderReport(report, format);
208
289
  if (typeof rawOptions.output === "string") {
@@ -214,8 +295,7 @@ function registerReportCommand(program: Command, runAnalyse: AnalyseRunner): voi
214
295
  });
215
296
  }
216
297
 
217
- // Same analyser run as `analyse` but renders only the pillar/rule/offender digest. Format is locked
218
- // to `text` because the summary shape is intentionally not part of the JSON report contract.
298
+ // Same analyser run as `analyse` but renders only the pillar/rule/offender digest.
219
299
  function registerSummaryCommand(program: Command, runAnalyse: AnalyseRunner): void {
220
300
  program
221
301
  .command("summary")
@@ -225,6 +305,8 @@ function registerSummaryCommand(program: Command, runAnalyse: AnalyseRunner): vo
225
305
  .argument("[paths...]", "Files or directories to analyse.")
226
306
  .option("--config <path>", "Path to a gruff YAML config file.")
227
307
  .option("--no-config", "Skip auto-applying the default .gruff-ts.yaml file for this run.")
308
+ .option("--format <format>", "Output format: text or json.", parseSummaryFormat, "text")
309
+ .option("--top <n>", "How many top rules and file offenders to list.", parseNonNegativeInteger, 10)
228
310
  .option("--fail-on <severity>", "Finding severity that fails the run: advisory, warning, error, or none.", "error")
229
311
  .option("--include-ignored", "Include files under default and Git ignored paths; config ignores still apply.")
230
312
  .option("--diff [mode]", "Filter findings to changed files. Use working-tree, staged, unstaged, or a base ref.")
@@ -232,16 +314,47 @@ function registerSummaryCommand(program: Command, runAnalyse: AnalyseRunner): vo
232
314
  .option("--baseline [path]", "Suppress findings that match a gruff baseline JSON file.")
233
315
  .option("--generate-baseline [path]", "Write current findings to a gruff baseline JSON file.")
234
316
  .option("--no-baseline", "Skip auto-applying the default baseline file for this run.")
235
- .action((paths: string[], rawOptions: Record<string, unknown>) => {
236
- const startedAt = performance.now();
317
+ .action(async (paths: string[], rawOptions: Record<string, unknown>) => {
237
318
  const options = normalizeOptions(paths, { ...rawOptions, format: "text" }, { shouldAllowBaselineFlag: true });
319
+ const summaryFormat = rawOptions.format === "json" ? "json" : "text";
320
+ const top = typeof rawOptions.top === "number" ? rawOptions.top : 10;
321
+ await maybePromptInitConfig(program, process.cwd(), promptOptionsFromAnalysis(options));
322
+ const startedAt = performance.now();
238
323
  const report = runAnalyse(options);
239
324
  const elapsedMs = performance.now() - startedAt;
240
- writeCommandOutput(program, renderSummary(report, elapsedMs, summaryPathLabel(options.paths, report.run.projectRoot)));
325
+ const pathLabel = summaryPathLabel(options.paths, report.run.projectRoot);
326
+ const rendered = summaryFormat === "json"
327
+ ? renderSummaryJson(report, elapsedMs, pathLabel, top)
328
+ : renderSummary(report, elapsedMs, pathLabel, top);
329
+ writeCommandOutput(program, rendered);
241
330
  process.exitCode = exitFor(report, options.failOn);
242
331
  });
243
332
  }
244
333
 
334
+ /*
335
+ * Commander `--format` argParser for the summary command. Throws `InvalidArgumentError` when the
336
+ * input is neither `text` nor `json`; commander reports that as a usage error and exits non-zero
337
+ * before the command body runs.
338
+ */
339
+ function parseSummaryFormat(rawFormat: string): "text" | "json" {
340
+ if (rawFormat === "text" || rawFormat === "json") {
341
+ return rawFormat;
342
+ }
343
+ throw new InvalidArgumentError("must be text or json");
344
+ }
345
+
346
+ /*
347
+ * Commander argParser for `--top`-style numeric flags. Throws `InvalidArgumentError` on non-integer
348
+ * or negative input so commander reports a usage error and exits non-zero before the command runs.
349
+ */
350
+ function parseNonNegativeInteger(rawCount: string): number {
351
+ const parsed = Number(rawCount);
352
+ if (!Number.isInteger(parsed) || parsed < 0) {
353
+ throw new InvalidArgumentError("must be a non-negative integer");
354
+ }
355
+ return parsed;
356
+ }
357
+
245
358
  // Summary output should name the scanned operand, not merely the process cwd used to run gruff-ts.
246
359
  function summaryPathLabel(paths: string[], projectRoot: string): string {
247
360
  if (paths.length === 0) {
package/src/cli.ts CHANGED
@@ -13,7 +13,10 @@ export type { AnalysisReport, Finding, OutputFormat, Pillar, RuleDescriptor, Sev
13
13
  const buildProgram = (): ReturnType<typeof buildCliProgram> => buildCliProgram(analyse);
14
14
 
15
15
  if (import.meta.url === pathToFileURL(argv[1] ?? "").href) {
16
- buildProgram().parse(argv);
16
+ // Action handlers in cli-program.ts are async (await maybePromptInitConfig). parseAsync is
17
+ // required so rejections after the first await surface through Commander's error path instead
18
+ // of escaping as unhandled promise rejections.
19
+ await buildProgram().parseAsync(argv);
17
20
  }
18
21
 
19
22
  export { absolutize, analyse, buildProgram, displayPath, renderReport, ruleDescriptors };