@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.
- package/CHANGELOG.md +14 -2
- package/CONTRIBUTING.md +3 -3
- package/README.md +140 -182
- package/docs/README.md +23 -0
- package/docs/ci-integration.md +63 -0
- package/docs/{CONFIGURATION.md → configuration.md} +2 -19
- package/docs/dashboard.md +29 -0
- package/docs/output-formats.md +65 -0
- package/docs/{RELEASING.md → releasing.md} +12 -10
- package/docs/rules.md +177 -0
- package/package.json +2 -2
- package/scripts/bump-version.sh +67 -18
- package/scripts/dependency-install.sh +96 -0
- package/scripts/dependency-update.sh +177 -0
- package/scripts/preflight-checks.sh +75 -0
- package/src/analyser.ts +5 -20
- package/src/blocks.ts +4 -4
- package/src/class-rules.ts +5 -6
- package/src/cli-program.ts +125 -12
- package/src/cli.ts +4 -1
- package/src/comment-rules.ts +2 -0
- package/src/config.ts +2 -4
- package/src/constants.ts +1 -1
- package/src/dead-code-rules.ts +2 -2
- package/src/init-config.ts +248 -0
- package/src/line-rules.ts +6 -8
- package/src/naming-pushers.ts +0 -31
- package/src/project-config-rules.ts +1 -1
- package/src/report-renderers.ts +60 -11
- package/src/rule-list.ts +1 -0
- package/src/rules.ts +14 -16
- package/src/safety-rules.ts +3 -3
- package/src/scoring.ts +4 -4
- package/src/test-fixtures.ts +0 -2
- package/src/text-scans.ts +1 -152
- package/src/types.ts +1 -2
- /package/docs/{REPORTS_AND_CI.md → reports-and-ci.md} +0 -0
|
@@ -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 (
|
|
2
|
-
//
|
|
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 {
|
|
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 /
|
|
394
|
-
*
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
330
|
+
pillar: "maintainability",
|
|
331
331
|
confidence: "medium",
|
|
332
332
|
symbol: context.block.name,
|
|
333
333
|
remediation: "Remove the final return statement.",
|
package/src/class-rules.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
|
194
|
-
* negative boolean. The stable ordering matches `
|
|
195
|
-
*
|
|
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");
|
package/src/cli-program.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|