@blundergoat/gruff-ts 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +16 -0
- package/CONTRIBUTING.md +87 -0
- package/LICENSE +21 -0
- package/README.md +303 -0
- package/SECURITY.md +45 -0
- package/bin/gruff-ts +25 -0
- package/docs/CONFIGURATION.md +220 -0
- package/docs/RELEASING.md +103 -0
- package/docs/REPORTS_AND_CI.md +156 -0
- package/fixtures/sample.ts +21 -0
- package/package.json +56 -0
- package/scripts/bump-version.sh +145 -0
- package/scripts/check.sh +4 -0
- package/scripts/npm-publish.sh +258 -0
- package/scripts/preflight-checks.sh +357 -0
- package/scripts/start-dev.sh +8 -0
- package/scripts/test-performance.sh +695 -0
- package/src/analyser.ts +461 -0
- package/src/baseline.ts +90 -0
- package/src/blocks.ts +687 -0
- package/src/class-rules.ts +326 -0
- package/src/cli-program.ts +326 -0
- package/src/cli.ts +19 -0
- package/src/comment-rules.ts +605 -0
- package/src/comment-scanner.ts +357 -0
- package/src/config.ts +622 -0
- package/src/constants.ts +4 -0
- package/src/context-doc-rules.ts +241 -0
- package/src/dashboard.ts +114 -0
- package/src/dead-code-rules.ts +183 -0
- package/src/discovery.ts +508 -0
- package/src/doc-rules.ts +368 -0
- package/src/findings-helpers.ts +108 -0
- package/src/findings.ts +45 -0
- package/src/fixture-purpose-rules.ts +334 -0
- package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
- package/src/github-actions-rules.ts +413 -0
- package/src/line-rules.ts +538 -0
- package/src/naming-pushers.ts +191 -0
- package/src/project-config-rules.ts +555 -0
- package/src/project-rules.ts +545 -0
- package/src/report-renderers.ts +691 -0
- package/src/rule-list.ts +179 -0
- package/src/rules.ts +135 -0
- package/src/safety-rules.ts +355 -0
- package/src/scoring.ts +74 -0
- package/src/security-flow-rules.ts +112 -0
- package/src/sensitive-data-rules.ts +288 -0
- package/src/source-text.ts +722 -0
- package/src/test-block-rules.ts +347 -0
- package/src/test-fixtures.ts +621 -0
- package/src/text-scans.ts +193 -0
- package/src/types.ts +113 -0
- package/tsconfig.json +15 -0
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Class + naming-inventory rules: exported-declaration docs, class/file-name mismatch,
|
|
2
|
+
// public-property + readonly candidates, inconsistent casing, acronym case, interface fields.
|
|
3
|
+
// Pulls the declaration walkers and casing helpers out of cli.ts so the orchestrator just calls
|
|
4
|
+
// the entry points.
|
|
5
|
+
import { type FunctionBlock, parameterNames } from "./blocks.ts";
|
|
6
|
+
import { type ExportedDeclaration, exportedDeclarations, pushMissingPublicDocFinding } from "./doc-rules.ts";
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { makeFinding } from "./findings.ts";
|
|
9
|
+
import { fileBaseName, finding, normalizedIdentifier } from "./findings-helpers.ts";
|
|
10
|
+
import { pushAbbreviationAt, pushBooleanPrefixAt, pushNegativeBooleanAt } from "./naming-pushers.ts";
|
|
11
|
+
import { byteLine } from "./text-scans.ts";
|
|
12
|
+
import type { Config, Finding } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
// One identifier observation. `line` is the declaration line in the original source so the casing
|
|
15
|
+
// and acronym rules can report a stable, reproducible location.
|
|
16
|
+
interface DeclaredIdentifier {
|
|
17
|
+
name: string;
|
|
18
|
+
line: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Aggregates `const`/`let`/`var` declarations, callable parameters, and interface fields into a
|
|
22
|
+
// single de-duplicated list. The naming rules walk this once instead of re-parsing the file.
|
|
23
|
+
export function collectDeclaredIdentifiers(source: string, codeSource: string, blocks: FunctionBlock[]): DeclaredIdentifier[] {
|
|
24
|
+
const inventory: DeclaredIdentifier[] = [];
|
|
25
|
+
const seen = new Set<string>();
|
|
26
|
+
const push = (name: string, line: number): void => {
|
|
27
|
+
if (!name) return;
|
|
28
|
+
const key = `${name}@${line}`;
|
|
29
|
+
if (seen.has(key)) return;
|
|
30
|
+
seen.add(key);
|
|
31
|
+
inventory.push({ name, line });
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
for (const match of codeSource.matchAll(/\b(?:export\s+)?(?:const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)) {
|
|
35
|
+
push(match[1] ?? "", byteLine(source, match.index ?? 0));
|
|
36
|
+
}
|
|
37
|
+
for (const block of blocks) {
|
|
38
|
+
for (const parameter of parameterNames(block.params)) {
|
|
39
|
+
push(parameter.name, block.declarationLine);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
for (const fieldMatch of collectInterfaceFieldDeclarations(source, codeSource)) {
|
|
43
|
+
push(fieldMatch.name, fieldMatch.line);
|
|
44
|
+
}
|
|
45
|
+
return inventory;
|
|
46
|
+
}
|
|
47
|
+
|
|
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).
|
|
50
|
+
function collectInterfaceFieldDeclarations(source: string, codeSource: string): DeclaredIdentifier[] {
|
|
51
|
+
const fieldRegex = /^[ \t]*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:/;
|
|
52
|
+
const out: DeclaredIdentifier[] = [];
|
|
53
|
+
for (const { lineIndex, sourceLine } of walkInterfaceBodyLines(source, codeSource)) {
|
|
54
|
+
const name = sourceLine.match(fieldRegex)?.[1] ?? "";
|
|
55
|
+
if (name) out.push({ name, line: lineIndex + 1 });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Strips separators and digits so `userId`, `user_id`, and `userID` all collapse to `userid`.
|
|
61
|
+
// Two names sharing this key but differing in original form are the casing-drift signal.
|
|
62
|
+
function casingCanonicalKey(name: string): string {
|
|
63
|
+
return name.toLowerCase().replace(/[_\-0-9]/g, "");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/*
|
|
67
|
+
* Groups identifiers by their canonical key and reports the second-seen variant whenever two or
|
|
68
|
+
* more spellings exist. The "second variant" anchor keeps the stable fingerprint on the diverging
|
|
69
|
+
* identifier rather than the original - useful when the original form is the project convention.
|
|
70
|
+
*/
|
|
71
|
+
export function analyseInconsistentCasing(file: SourceFile, inventory: DeclaredIdentifier[], findings: Finding[]): void {
|
|
72
|
+
const groups = new Map<string, DeclaredIdentifier[]>();
|
|
73
|
+
for (const entry of inventory) {
|
|
74
|
+
const key = casingCanonicalKey(entry.name);
|
|
75
|
+
if (!key) continue;
|
|
76
|
+
const list = groups.get(key) ?? [];
|
|
77
|
+
list.push(entry);
|
|
78
|
+
groups.set(key, list);
|
|
79
|
+
}
|
|
80
|
+
for (const [, entries] of groups) {
|
|
81
|
+
const surfaces = new Set(entries.map((entry) => entry.name));
|
|
82
|
+
if (surfaces.size < 2) continue;
|
|
83
|
+
const sorted = [...entries].sort((a, b) => a.line - b.line);
|
|
84
|
+
const second = sorted.find((entry, index) => index > 0 && entry.name !== sorted[0]?.name);
|
|
85
|
+
if (!second) continue;
|
|
86
|
+
findings.push(
|
|
87
|
+
makeFinding({
|
|
88
|
+
ruleId: "naming.inconsistent-casing",
|
|
89
|
+
message: `Identifier \`${second.name}\` shares a canonical key with \`${sorted[0]?.name}\` in the same file.`,
|
|
90
|
+
filePath: file.displayPath,
|
|
91
|
+
line: second.line,
|
|
92
|
+
severity: "advisory",
|
|
93
|
+
pillar: "naming",
|
|
94
|
+
confidence: "medium",
|
|
95
|
+
symbol: second.name,
|
|
96
|
+
remediation: "Choose one form and use it consistently within the file.",
|
|
97
|
+
metadata: { variants: [...surfaces].sort() },
|
|
98
|
+
}),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Splits camelCase, PascalCase, snake_case, and kebab-case into tokens. The regex preserves
|
|
104
|
+
// uppercase runs as a single token so the acronym detector sees `URL` and `url` as the same word.
|
|
105
|
+
function tokensForAcronymCheck(name: string): string[] {
|
|
106
|
+
const split = name.split(/[_\-]+/).filter(Boolean);
|
|
107
|
+
const tokens: string[] = [];
|
|
108
|
+
for (const part of split) {
|
|
109
|
+
const matches = part.match(/[A-Z]+(?=[A-Z][a-z])|[A-Z]?[a-z0-9]+|[A-Z]+/g);
|
|
110
|
+
if (matches) tokens.push(...matches);
|
|
111
|
+
else tokens.push(part);
|
|
112
|
+
}
|
|
113
|
+
return tokens;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Three-bucket classification used to detect when one project uses both `URL` and `Url` styles -
|
|
117
|
+
// the rule flags drift when two of the three buckets are seen for the same acronym in one file.
|
|
118
|
+
function acronymCaseClass(token: string): "upper" | "lower" | "title" {
|
|
119
|
+
if (token === token.toUpperCase()) return "upper";
|
|
120
|
+
if (token === token.toLowerCase()) return "lower";
|
|
121
|
+
return "title";
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/*
|
|
125
|
+
* Reports when an acronym from `config.knownAcronyms` appears as all-caps plus another case form.
|
|
126
|
+
* The all-caps gate exists because lower/title-only forms like `apiToken` beside `googleApiKey`
|
|
127
|
+
* are idiomatic enough to avoid noisy findings. Like `analyseInconsistentCasing`, the finding
|
|
128
|
+
* anchors on the second occurrence so the stable fingerprint sticks to the divergence.
|
|
129
|
+
*/
|
|
130
|
+
export function analyseAcronymCase(file: SourceFile, inventory: DeclaredIdentifier[], config: Config, findings: Finding[]): void {
|
|
131
|
+
const observed = new Map<string, Map<string, { name: string; line: number }>>();
|
|
132
|
+
for (const entry of inventory) {
|
|
133
|
+
recordAcronymCases(observed, config, entry);
|
|
134
|
+
}
|
|
135
|
+
for (const [acronym, cases] of observed) {
|
|
136
|
+
pushAcronymCaseFinding(file, acronym, cases, findings);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Adds one identifier's acronym tokens to the observed case map, skipping fixture-only constants.
|
|
141
|
+
function recordAcronymCases(observed: Map<string, Map<string, { name: string; line: number }>>, config: Config, entry: DeclaredIdentifier): void {
|
|
142
|
+
if (isFixtureIdentifier(entry.name)) {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const token of tokensForAcronymCheck(entry.name)) {
|
|
146
|
+
const lower = token.toLowerCase();
|
|
147
|
+
if (!config.knownAcronyms.has(lower)) continue;
|
|
148
|
+
const cases = observed.get(lower) ?? new Map();
|
|
149
|
+
const caseKey = acronymCaseClass(token);
|
|
150
|
+
if (!cases.has(caseKey)) cases.set(caseKey, { name: entry.name, line: entry.line });
|
|
151
|
+
observed.set(lower, cases);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Reports the stable acronym-case finding only after the all-caps-vs-other drift gate passes.
|
|
156
|
+
function pushAcronymCaseFinding(file: SourceFile, acronym: string, cases: Map<string, { name: string; line: number }>, findings: Finding[]): void {
|
|
157
|
+
if (!shouldReportAcronymCase(cases)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const occurrences = [...cases.values()].sort((a, b) => a.line - b.line);
|
|
161
|
+
const second = occurrences[1];
|
|
162
|
+
if (!second) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
findings.push(
|
|
166
|
+
makeFinding({
|
|
167
|
+
ruleId: "naming.acronym-case",
|
|
168
|
+
message: `Acronym \`${acronym.toUpperCase()}\` appears in multiple cases in this file.`,
|
|
169
|
+
filePath: file.displayPath,
|
|
170
|
+
line: second.line,
|
|
171
|
+
severity: "advisory",
|
|
172
|
+
pillar: "naming",
|
|
173
|
+
confidence: "medium",
|
|
174
|
+
symbol: second.name,
|
|
175
|
+
remediation: "Use one casing for each acronym throughout the file.",
|
|
176
|
+
metadata: { acronym: acronym.toUpperCase(), variants: [...cases.keys()].sort() },
|
|
177
|
+
}),
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Requires an upper-case acronym plus a different case; lower/title-only mixes stay quiet.
|
|
182
|
+
function shouldReportAcronymCase(cases: Map<string, { name: string; line: number }>): boolean {
|
|
183
|
+
return cases.has("upper") && cases.size >= 2;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Fixture constants often use SCREAMING_SNAKE vendor token names that should not force local
|
|
187
|
+
// camelCase variables to adopt the same acronym style.
|
|
188
|
+
function isFixtureIdentifier(name: string): boolean {
|
|
189
|
+
return /(?:^|[_-])fixture(?:[_-]|$)/i.test(name);
|
|
190
|
+
}
|
|
191
|
+
|
|
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.
|
|
196
|
+
*/
|
|
197
|
+
export function analyseInterfaceFields(file: SourceFile, source: string, codeSource: string, config: Config, findings: Finding[]): void {
|
|
198
|
+
const fieldRegex = /^[ \t]*(?:readonly\s+)?([A-Za-z_$][A-Za-z0-9_$]*)\??\s*:\s*([^;]+)/;
|
|
199
|
+
for (const { lineIndex, sourceLine } of walkInterfaceBodyLines(source, codeSource)) {
|
|
200
|
+
const match = sourceLine.match(fieldRegex);
|
|
201
|
+
const name = match?.[1] ?? "";
|
|
202
|
+
if (!name) continue;
|
|
203
|
+
pushAbbreviationAt(file, lineIndex + 1, name, config, findings, "interface-field");
|
|
204
|
+
if (/^\s*boolean\b/.test(match?.[2] ?? "")) {
|
|
205
|
+
pushBooleanPrefixAt(file, lineIndex + 1, name, config, findings, "interface-field");
|
|
206
|
+
pushNegativeBooleanAt(file, lineIndex + 1, name, config, findings, "interface-field");
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const INTERFACE_HEADER_REGEX = /\b(?:export\s+)?(?:interface\s+[A-Za-z_$][A-Za-z0-9_$]*(?:\s*<[^>]*>)?(?:\s+extends\s+[^{]+)?|type\s+[A-Za-z_$][A-Za-z0-9_$]*(?:\s*<[^>]*>)?\s*=\s*)\s*\{/g;
|
|
212
|
+
|
|
213
|
+
function* walkInterfaceBodyLines(source: string, codeSource: string): Generator<{ lineIndex: number; sourceLine: string }> {
|
|
214
|
+
const codeLines = codeSource.split(/\r?\n/);
|
|
215
|
+
const sourceLines = source.split(/\r?\n/);
|
|
216
|
+
for (const header of codeSource.matchAll(INTERFACE_HEADER_REGEX)) {
|
|
217
|
+
const headerEnd = (header.index ?? 0) + header[0].length;
|
|
218
|
+
if (codeSource.slice(headerEnd, headerEnd + 30).trimStart().startsWith("[")) {
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const headerLineIndex = byteLine(source, headerEnd - 1) - 1;
|
|
222
|
+
const headerLine = codeLines[headerLineIndex] ?? "";
|
|
223
|
+
let depth = 1 + countBraceChange(headerLine.slice(headerLine.lastIndexOf("{") + 1));
|
|
224
|
+
for (let lineIndex = headerLineIndex + 1; depth > 0 && lineIndex < codeLines.length; lineIndex += 1) {
|
|
225
|
+
const codeLine = codeLines[lineIndex] ?? "";
|
|
226
|
+
if (depth === 1) {
|
|
227
|
+
yield { lineIndex, sourceLine: sourceLines[lineIndex] ?? "" };
|
|
228
|
+
}
|
|
229
|
+
depth += countBraceChange(codeLine);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Net brace delta (`{` minus `}`) for a slice of text. Used by the interface-body walker to track
|
|
235
|
+
// nesting depth without parsing the source twice.
|
|
236
|
+
function countBraceChange(text: string): number {
|
|
237
|
+
let delta = 0;
|
|
238
|
+
for (const character of text) {
|
|
239
|
+
if (character === "{") {
|
|
240
|
+
delta += 1;
|
|
241
|
+
} else if (character === "}") {
|
|
242
|
+
delta -= 1;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return delta;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/*
|
|
249
|
+
* Three class-pillar rules in their stable, deterministic emission order: exported-declaration
|
|
250
|
+
* docs and file-name mismatch, public-property, readonly candidates.
|
|
251
|
+
*/
|
|
252
|
+
export function analyseClassRules(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
253
|
+
analyseExportedDeclarations(file, source, codeSource, findings);
|
|
254
|
+
analysePublicProperties(file, source, codeSource, findings);
|
|
255
|
+
analyseReadonlyCandidates(file, source, codeSource, findings);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/*
|
|
259
|
+
* Two rules per exported declaration. Both fire from one walk so the file isn't re-scanned for
|
|
260
|
+
* each rule. Reports the stable `docs.missing-public-doc` and `naming.class-file-mismatch`
|
|
261
|
+
* findings per declaration.
|
|
262
|
+
*/
|
|
263
|
+
function analyseExportedDeclarations(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
264
|
+
for (const declaration of exportedDeclarations(source, codeSource)) {
|
|
265
|
+
pushMissingPublicDocFinding(file, source, declaration, findings);
|
|
266
|
+
pushClassFileMismatchFinding(file, declaration, findings);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/*
|
|
271
|
+
* Compares normalised forms (lowercased, no underscores) so `UserProfile` and `user-profile.ts`
|
|
272
|
+
* match. Reports the stable `naming.class-file-mismatch` finding when the exported class diverges
|
|
273
|
+
* from the file name.
|
|
274
|
+
*/
|
|
275
|
+
function pushClassFileMismatchFinding(file: SourceFile, declaration: ExportedDeclaration, findings: Finding[]): void {
|
|
276
|
+
const fileName = fileBaseName(file.displayPath);
|
|
277
|
+
if (declaration.kind !== "class" || normalizedIdentifier(declaration.name) === normalizedIdentifier(fileName)) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
findings.push(
|
|
281
|
+
makeFinding({
|
|
282
|
+
ruleId: "naming.class-file-mismatch",
|
|
283
|
+
message: `Exported class \`${declaration.name}\` does not match file name \`${fileName}\`.`,
|
|
284
|
+
filePath: file.displayPath,
|
|
285
|
+
line: declaration.line,
|
|
286
|
+
severity: "advisory",
|
|
287
|
+
pillar: "naming",
|
|
288
|
+
confidence: "medium",
|
|
289
|
+
symbol: declaration.name,
|
|
290
|
+
remediation: "Rename the class or file so the primary export is easy to locate.",
|
|
291
|
+
metadata: { className: declaration.name, fileName },
|
|
292
|
+
}),
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Targets `public foo =` and `public foo:` patterns. The rule message recommends `readonly` or
|
|
297
|
+
// accessors because both preserve the field's invariant better than a raw public field, and
|
|
298
|
+
// reports each match as a stable `modernisation.public-property` finding.
|
|
299
|
+
function analysePublicProperties(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
300
|
+
const publicProperty = /\bpublic\s+[A-Za-z_$][A-Za-z0-9_$]*\s*[=:]/g;
|
|
301
|
+
for (const match of codeSource.matchAll(publicProperty)) {
|
|
302
|
+
findings.push(finding({ ruleId: "modernisation.public-property", message: "Public class property exposes representation; prefer readonly or accessors when invariants matter.", file, line: byteLine(source, match.index ?? 0), severity: "advisory", pillar: "modernisation" }));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Visibility-modifier fields without `readonly`. The negative lookahead skips already-readonly
|
|
307
|
+
// properties; each remaining match reports a stable `modernisation.readonly-property-candidate`.
|
|
308
|
+
function analyseReadonlyCandidates(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
309
|
+
const readonlyCandidate = /\b(?:public|private|protected)\s+(?!readonly\b)([A-Za-z_$][A-Za-z0-9_$]*)\s*:\s*[^;=\n]+;/g;
|
|
310
|
+
for (const match of codeSource.matchAll(readonlyCandidate)) {
|
|
311
|
+
const name = match[1] ?? "";
|
|
312
|
+
findings.push(
|
|
313
|
+
makeFinding({
|
|
314
|
+
ruleId: "modernisation.readonly-property-candidate",
|
|
315
|
+
message: `Property \`${name}\` can be marked readonly if it is only assigned during construction.`,
|
|
316
|
+
filePath: file.displayPath,
|
|
317
|
+
line: byteLine(source, match.index ?? 0),
|
|
318
|
+
severity: "advisory",
|
|
319
|
+
pillar: "modernisation",
|
|
320
|
+
confidence: "medium",
|
|
321
|
+
symbol: name,
|
|
322
|
+
remediation: "Mark the property readonly when mutation is not part of the type contract.",
|
|
323
|
+
}),
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
// Commander CLI shell wiring that keeps option normalization and stdout behavior outside the analyzer.
|
|
2
|
+
import { Command, Help } from "commander";
|
|
3
|
+
import { writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { performance } from "node:perf_hooks";
|
|
6
|
+
import { DEFAULT_BASELINE } from "./baseline.ts";
|
|
7
|
+
import { VERSION } from "./constants.ts";
|
|
8
|
+
import { startDashboard } from "./dashboard.ts";
|
|
9
|
+
import { renderReport, renderSummary } from "./report-renderers.ts";
|
|
10
|
+
import { completionShell, renderCompletionScript, renderConsoleList, renderRuleList, type RuleListFormat } from "./rule-list.ts";
|
|
11
|
+
import { exitFor } from "./scoring.ts";
|
|
12
|
+
import type { AnalysisOptions, AnalysisReport } from "./types.ts";
|
|
13
|
+
|
|
14
|
+
type AnalyseRunner = (options: AnalysisOptions) => AnalysisReport;
|
|
15
|
+
|
|
16
|
+
// The `report` command intentionally rejects `--baseline` because its output is meant to reflect
|
|
17
|
+
// raw findings; this flag lets `normalizeOptions` enforce that without per-command branching.
|
|
18
|
+
interface NormalizeContext {
|
|
19
|
+
shouldAllowBaselineFlag: boolean;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Honours `--silent`/`--quiet` before writing to stdout. Always appends a trailing newline so piped
|
|
23
|
+
// callers (e.g., `gruff-ts analyse | jq`) see a complete line even when a renderer forgot one.
|
|
24
|
+
function writeCommandOutput(program: Command, output: string): void {
|
|
25
|
+
if (outputSuppressed(program)) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
process.stdout.write(output.endsWith("\n") ? output : `${output}\n`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// `--silent` and `--quiet` both suppress non-error stdout - they are intentionally treated the same
|
|
32
|
+
// here because Commander exposes them as independent flags but the CLI's contract is uniform.
|
|
33
|
+
function outputSuppressed(program: Command): boolean {
|
|
34
|
+
const options = program.opts() as { quiet?: boolean; silent?: boolean };
|
|
35
|
+
return options.quiet === true || options.silent === true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Three-state ANSI resolution: explicit `--ansi` forces colour, explicit `--no-ansi` forbids it,
|
|
39
|
+
// otherwise autodetect from TTY. Required because pipelines and CI logs would otherwise eat colour codes.
|
|
40
|
+
function ansiEnabled(program: Command): boolean {
|
|
41
|
+
const options = program.opts() as { ansi?: boolean };
|
|
42
|
+
if (options.ansi === true) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
if (options.ansi === false) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
return process.stdout.isTTY === true;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `runAnalyse` is injected rather than imported to keep `cli-program.ts` off the analyser's
|
|
52
|
+
// dependency graph; see `.goat-flow/lessons/verification.md` on the original cli.ts ↔ cli-program.ts cycle.
|
|
53
|
+
export function buildProgram(runAnalyse: AnalyseRunner): Command {
|
|
54
|
+
const program = new Command();
|
|
55
|
+
configureRootProgram(program);
|
|
56
|
+
registerAnalyseCommand(program, runAnalyse);
|
|
57
|
+
registerCompletionCommand(program);
|
|
58
|
+
registerDashboardCommand(program, runAnalyse);
|
|
59
|
+
registerListCommand(program);
|
|
60
|
+
registerListRulesCommand(program);
|
|
61
|
+
registerReportCommand(program, runAnalyse);
|
|
62
|
+
registerSummaryCommand(program, runAnalyse);
|
|
63
|
+
return program;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Adds the Symfony-style global flags (`--silent`, `--quiet`, `--ansi`, `--no-interaction`, `-v`)
|
|
67
|
+
// and replaces the default help formatter so the bare root command lists the catalogue instead of
|
|
68
|
+
// Commander's auto-generated usage block.
|
|
69
|
+
function configureRootProgram(program: Command): void {
|
|
70
|
+
program
|
|
71
|
+
.name("gruff-ts")
|
|
72
|
+
.usage("command [options] [arguments]")
|
|
73
|
+
.helpOption("-h, --help", "Display help for the given command. When no command is given display help for the list command")
|
|
74
|
+
.version(VERSION, "-V, --version", "Display this application version")
|
|
75
|
+
.option("--silent", "Do not output any message")
|
|
76
|
+
.option("-q, --quiet", "Only errors are displayed. All other output is suppressed")
|
|
77
|
+
.option("--ansi", "Force ANSI output")
|
|
78
|
+
.option("--no-ansi", "Disable ANSI output")
|
|
79
|
+
.option("-n, --no-interaction", "Do not ask any interactive question")
|
|
80
|
+
.option("-v, --verbose", "Increase the verbosity of messages: 1 for normal output, 2 for more verbose output and 3 for debug", (_value, previous: number) => previous + 1, 0)
|
|
81
|
+
.addHelpCommand("help [command]", "Display help for a command")
|
|
82
|
+
.showHelpAfterError()
|
|
83
|
+
.configureHelp({
|
|
84
|
+
|
|
85
|
+
// Custom root help: the bare `gruff-ts` invocation prints the Symfony-style command catalogue;
|
|
86
|
+
// subcommand help still uses Commander's default formatter via `rootHelpText`.
|
|
87
|
+
formatHelp(command, helper) {
|
|
88
|
+
return rootHelpText(program, command, helper);
|
|
89
|
+
},
|
|
90
|
+
})
|
|
91
|
+
.action(() => {
|
|
92
|
+
writeCommandOutput(program, renderConsoleList(ansiEnabled(program)));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Returns the catalogue view when the user asked for root help; defers to Commander's default
|
|
97
|
+
// formatter for subcommand help. `showGlobalOptions = true` so the global flags surface there too.
|
|
98
|
+
function rootHelpText(program: Command, command: Command, helper: Help): string {
|
|
99
|
+
if (command === program) {
|
|
100
|
+
return renderConsoleList(ansiEnabled(program));
|
|
101
|
+
}
|
|
102
|
+
const defaultHelp = new Help();
|
|
103
|
+
defaultHelp.showGlobalOptions = true;
|
|
104
|
+
if (helper.helpWidth !== undefined) {
|
|
105
|
+
defaultHelp.helpWidth = helper.helpWidth;
|
|
106
|
+
}
|
|
107
|
+
if (helper.minWidthToWrap !== undefined) {
|
|
108
|
+
defaultHelp.minWidthToWrap = helper.minWidthToWrap;
|
|
109
|
+
}
|
|
110
|
+
return defaultHelp.formatHelp(command, defaultHelp);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The primary entry point. Sets `process.exitCode` (not `process.exit`) so async writers in the
|
|
114
|
+
// renderer get a chance to flush before Node tears down. Default fail-on is `error`, matching CI.
|
|
115
|
+
function registerAnalyseCommand(program: Command, runAnalyse: AnalyseRunner): void {
|
|
116
|
+
program
|
|
117
|
+
.command("analyse")
|
|
118
|
+
.description("Run gruff analysis.")
|
|
119
|
+
.argument("[paths...]", "Files or directories to analyse.")
|
|
120
|
+
.option("--config <path>", "Path to a gruff YAML config file.")
|
|
121
|
+
.option("--no-config", "Skip auto-applying the default .gruff-ts.yaml file for this run.")
|
|
122
|
+
.option("--format <format>", "Output format: text, json, html, markdown, github, hotspot, or sarif.", "text")
|
|
123
|
+
.option("--fail-on <severity>", "Finding severity that fails the run: advisory, warning, error, or none.", "error")
|
|
124
|
+
.option("--include-ignored", "Include files under default and Git ignored paths; config ignores still apply.")
|
|
125
|
+
.option("--diff [mode]", "Filter findings to changed files. Use working-tree, staged, unstaged, or a base ref.")
|
|
126
|
+
.option("--history-file <path>", "Append score trend history to this JSON file.")
|
|
127
|
+
.option("--baseline [path]", "Suppress findings that match a gruff baseline JSON file.")
|
|
128
|
+
.option("--generate-baseline [path]", "Write current findings to a gruff baseline JSON file.")
|
|
129
|
+
.option("--no-baseline", "Skip auto-applying the default baseline file for this run.")
|
|
130
|
+
.action((paths: string[], rawOptions: Record<string, unknown>) => {
|
|
131
|
+
const options = normalizeOptions(paths, rawOptions, { shouldAllowBaselineFlag: true });
|
|
132
|
+
const report = runAnalyse(options);
|
|
133
|
+
writeCommandOutput(program, renderReport(report, options.format));
|
|
134
|
+
process.exitCode = exitFor(report, options.failOn);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Emits the static completion script for the requested shell. Does not touch the filesystem or
|
|
139
|
+
// run a scan - callers pipe the output into their shell config themselves.
|
|
140
|
+
function registerCompletionCommand(program: Command): void {
|
|
141
|
+
program
|
|
142
|
+
.command("completion")
|
|
143
|
+
.description("Dump the shell completion script")
|
|
144
|
+
.argument("[shell]", "Shell to generate completion for: bash, zsh, or fish.", "bash")
|
|
145
|
+
.action((shell: string) => {
|
|
146
|
+
writeCommandOutput(program, renderCompletionScript(completionShell(shell)));
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Wires the `dashboard` subcommand to `startDashboard`. Defaults bind to loopback (127.0.0.1:8767)
|
|
151
|
+
// because the dashboard accepts a `projectRoot` query parameter and would otherwise be unauthenticated.
|
|
152
|
+
function registerDashboardCommand(program: Command, runAnalyse: AnalyseRunner): void {
|
|
153
|
+
program
|
|
154
|
+
.command("dashboard")
|
|
155
|
+
.description("Serve the local gruff dashboard.")
|
|
156
|
+
.option("--host <host>", "Host to bind.", "127.0.0.1")
|
|
157
|
+
.option("--port <port>", "Port to bind.", "8767")
|
|
158
|
+
.option("--project-root <path>", "Default project root.", ".")
|
|
159
|
+
.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));
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Mirrors the bare-root help output. Exists so users coming from Symfony's console conventions
|
|
165
|
+
// can run `gruff-ts list` the way they expect.
|
|
166
|
+
function registerListCommand(program: Command): void {
|
|
167
|
+
program
|
|
168
|
+
.command("list")
|
|
169
|
+
.description("List commands")
|
|
170
|
+
.action(() => {
|
|
171
|
+
writeCommandOutput(program, renderConsoleList(ansiEnabled(program)));
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 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.
|
|
177
|
+
function registerListRulesCommand(program: Command): void {
|
|
178
|
+
program
|
|
179
|
+
.command("list-rules")
|
|
180
|
+
.description("List gruff rule metadata.")
|
|
181
|
+
.option("--format <format>", "Output format: text or json.", "text")
|
|
182
|
+
.action((rawOptions: Record<string, unknown>) => {
|
|
183
|
+
const format: RuleListFormat = rawOptions.format === "json" ? "json" : "text";
|
|
184
|
+
writeCommandOutput(program, renderRuleList(format));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `report` differs from `analyse` in two ways: default format is `html`, and baseline suppression is
|
|
189
|
+
// disallowed (`shouldAllowBaselineFlag: false`) because reports are meant to capture the raw scan, not a
|
|
190
|
+
// filtered view. Writes the rendered output to `--output` when provided; otherwise to stdout.
|
|
191
|
+
function registerReportCommand(program: Command, runAnalyse: AnalyseRunner): void {
|
|
192
|
+
program
|
|
193
|
+
.command("report")
|
|
194
|
+
.description("Render a gruff report to stdout or a file.")
|
|
195
|
+
.argument("[paths...]", "Files or directories to analyse.")
|
|
196
|
+
.option("--format <format>", "Report format: html or json.", "html")
|
|
197
|
+
.option("--output <path>", "Write report to a file.")
|
|
198
|
+
.option("--config <path>", "Path to a gruff YAML config file.")
|
|
199
|
+
.option("--no-config", "Skip auto-applying the default .gruff-ts.yaml file for this run.")
|
|
200
|
+
.option("--fail-on <severity>", "Finding severity that fails the run.", "none")
|
|
201
|
+
.option("--include-ignored", "Include files under default and Git ignored paths; config ignores still apply.")
|
|
202
|
+
.option("--no-baseline", "Skip auto-applying the default baseline file for this run.")
|
|
203
|
+
.action((paths: string[], rawOptions: Record<string, unknown>) => {
|
|
204
|
+
const format = rawOptions.format === "json" ? "json" : "html";
|
|
205
|
+
const options = normalizeOptions(paths, { ...rawOptions, format }, { shouldAllowBaselineFlag: false });
|
|
206
|
+
const report = runAnalyse(options);
|
|
207
|
+
const rendered = renderReport(report, format);
|
|
208
|
+
if (typeof rawOptions.output === "string") {
|
|
209
|
+
writeFileSync(rawOptions.output, rendered);
|
|
210
|
+
} else {
|
|
211
|
+
writeCommandOutput(program, rendered);
|
|
212
|
+
}
|
|
213
|
+
process.exitCode = exitFor(report, options.failOn);
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
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.
|
|
219
|
+
function registerSummaryCommand(program: Command, runAnalyse: AnalyseRunner): void {
|
|
220
|
+
program
|
|
221
|
+
.command("summary")
|
|
222
|
+
.description(
|
|
223
|
+
"Print a compact digest of a scan: per-pillar finding counts, top rules, and top file offenders. Runs the analyser once and renders only the summary; no per-finding spam.",
|
|
224
|
+
)
|
|
225
|
+
.argument("[paths...]", "Files or directories to analyse.")
|
|
226
|
+
.option("--config <path>", "Path to a gruff YAML config file.")
|
|
227
|
+
.option("--no-config", "Skip auto-applying the default .gruff-ts.yaml file for this run.")
|
|
228
|
+
.option("--fail-on <severity>", "Finding severity that fails the run: advisory, warning, error, or none.", "error")
|
|
229
|
+
.option("--include-ignored", "Include files under default and Git ignored paths; config ignores still apply.")
|
|
230
|
+
.option("--diff [mode]", "Filter findings to changed files. Use working-tree, staged, unstaged, or a base ref.")
|
|
231
|
+
.option("--history-file <path>", "Append score trend history to this JSON file.")
|
|
232
|
+
.option("--baseline [path]", "Suppress findings that match a gruff baseline JSON file.")
|
|
233
|
+
.option("--generate-baseline [path]", "Write current findings to a gruff baseline JSON file.")
|
|
234
|
+
.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();
|
|
237
|
+
const options = normalizeOptions(paths, { ...rawOptions, format: "text" }, { shouldAllowBaselineFlag: true });
|
|
238
|
+
const report = runAnalyse(options);
|
|
239
|
+
const elapsedMs = performance.now() - startedAt;
|
|
240
|
+
writeCommandOutput(program, renderSummary(report, elapsedMs, summaryPathLabel(options.paths, report.run.projectRoot)));
|
|
241
|
+
process.exitCode = exitFor(report, options.failOn);
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Summary output should name the scanned operand, not merely the process cwd used to run gruff-ts.
|
|
246
|
+
function summaryPathLabel(paths: string[], projectRoot: string): string {
|
|
247
|
+
if (paths.length === 0) {
|
|
248
|
+
return projectRoot;
|
|
249
|
+
}
|
|
250
|
+
return paths.map((path) => resolve(path)).join(", ");
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Single source of truth for translating Commander's loose option bag into the strict
|
|
254
|
+
// `AnalysisOptions` shape that drives baseline matching. The fingerprint contract is the invariant:
|
|
255
|
+
// two CLI invocations producing identical AnalysisOptions must produce identical, stable findings -
|
|
256
|
+
// adding new fields here without folding them into that hash is a deterministic-output regression.
|
|
257
|
+
function normalizeOptions(paths: string[], rawOptions: Record<string, unknown>, context: NormalizeContext): AnalysisOptions {
|
|
258
|
+
const format = stringChoice(rawOptions.format, ["text", "json", "html", "markdown", "github", "hotspot", "sarif"], "text");
|
|
259
|
+
const failOn = stringChoice(rawOptions.failOn, ["none", "advisory", "warning", "error"], "error");
|
|
260
|
+
const baselineValue = rawOptions.baseline;
|
|
261
|
+
const shouldSkipBaseline =
|
|
262
|
+
!context.shouldAllowBaselineFlag ||
|
|
263
|
+
baselineValue === false ||
|
|
264
|
+
rawOptions.noBaseline === true;
|
|
265
|
+
return {
|
|
266
|
+
paths,
|
|
267
|
+
...configOption(rawOptions),
|
|
268
|
+
shouldSkipConfig:
|
|
269
|
+
rawOptions.config === false ||
|
|
270
|
+
rawOptions.noConfig === true,
|
|
271
|
+
format,
|
|
272
|
+
failOn,
|
|
273
|
+
shouldIncludeIgnored: rawOptions.includeIgnored === true,
|
|
274
|
+
...diffOption(rawOptions),
|
|
275
|
+
...historyFileOption(rawOptions),
|
|
276
|
+
...baselineOption(baselineValue, context),
|
|
277
|
+
...generateBaselineOption(rawOptions),
|
|
278
|
+
shouldSkipBaseline,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Conditional spreads (not `config: undefined`) because `AnalysisOptions` runs under
|
|
283
|
+
// `exactOptionalPropertyTypes` - the absent and `undefined` cases are not interchangeable.
|
|
284
|
+
function configOption(rawOptions: Record<string, unknown>): Partial<Pick<AnalysisOptions, "config">> {
|
|
285
|
+
return typeof rawOptions.config === "string" ? { config: rawOptions.config } : {};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// `--diff` without an argument means "working-tree". A boolean `true` arrives when Commander parsed
|
|
289
|
+
// the flag standalone; an explicit string keeps whatever ref the user passed (e.g., `main`).
|
|
290
|
+
function diffOption(rawOptions: Record<string, unknown>): Partial<Pick<AnalysisOptions, "diff">> {
|
|
291
|
+
if (typeof rawOptions.diff === "string") {
|
|
292
|
+
return { diff: rawOptions.diff };
|
|
293
|
+
}
|
|
294
|
+
return rawOptions.diff === true ? { diff: "working-tree" } : {};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// Conditional spread keeps `historyFile` absent rather than `undefined` under exactOptionalPropertyTypes.
|
|
298
|
+
function historyFileOption(rawOptions: Record<string, unknown>): Partial<Pick<AnalysisOptions, "historyFile">> {
|
|
299
|
+
return typeof rawOptions.historyFile === "string" ? { historyFile: rawOptions.historyFile } : {};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// `--baseline` with no value implies the conventional default file (`gruff-baseline.json`); commands
|
|
303
|
+
// that disallow baseline input (e.g., `report`) short-circuit so the option is never set. The fingerprint
|
|
304
|
+
// contract requires that two scans with the same baseline file produce identical suppression behaviour.
|
|
305
|
+
function baselineOption(baselineValue: unknown, context: NormalizeContext): Partial<Pick<AnalysisOptions, "baseline">> {
|
|
306
|
+
if (!context.shouldAllowBaselineFlag) {
|
|
307
|
+
return {};
|
|
308
|
+
}
|
|
309
|
+
if (typeof baselineValue === "string") {
|
|
310
|
+
return { baseline: baselineValue };
|
|
311
|
+
}
|
|
312
|
+
return baselineValue === true ? { baseline: DEFAULT_BASELINE } : {};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
// Mirrors `--baseline`: a bare `--generate-baseline` writes to the default path; a string value
|
|
316
|
+
// overrides it. Absent (not present) and absent-because-undefined are distinct here.
|
|
317
|
+
function generateBaselineOption(rawOptions: Record<string, unknown>): Partial<Pick<AnalysisOptions, "generateBaseline">> {
|
|
318
|
+
if (typeof rawOptions.generateBaseline === "string") {
|
|
319
|
+
return { generateBaseline: rawOptions.generateBaseline };
|
|
320
|
+
}
|
|
321
|
+
return rawOptions.generateBaseline === true ? { generateBaseline: DEFAULT_BASELINE } : {};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function stringChoice<T extends string>(value: unknown, choices: readonly T[], fallback: T): T {
|
|
325
|
+
return typeof value === "string" && choices.includes(value as T) ? (value as T) : fallback;
|
|
326
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI shell: thin entrypoint that wires the analyser into the commander-based CLI program. The
|
|
3
|
+
// analyse pipeline itself lives in `./analyser.ts`; this file is just bootstrap plus re-exports.
|
|
4
|
+
import { argv } from "node:process";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { analyse } from "./analyser.ts";
|
|
7
|
+
import { buildProgram as buildCliProgram } from "./cli-program.ts";
|
|
8
|
+
import { absolutize, displayPath } from "./discovery.ts";
|
|
9
|
+
import { renderReport } from "./report-renderers.ts";
|
|
10
|
+
import { ruleDescriptors } from "./rules.ts";
|
|
11
|
+
export type { AnalysisReport, Finding, OutputFormat, Pillar, RuleDescriptor, Severity } from "./types.ts";
|
|
12
|
+
|
|
13
|
+
const buildProgram = (): ReturnType<typeof buildCliProgram> => buildCliProgram(analyse);
|
|
14
|
+
|
|
15
|
+
if (import.meta.url === pathToFileURL(argv[1] ?? "").href) {
|
|
16
|
+
buildProgram().parse(argv);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export { absolutize, analyse, buildProgram, displayPath, renderReport, ruleDescriptors };
|