@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
package/src/doc-rules.ts
ADDED
|
@@ -0,0 +1,368 @@
|
|
|
1
|
+
// JSDoc and exported-declaration documentation rules. Covers `docs.missing-public-doc`,
|
|
2
|
+
// `docs.missing-file-overview`, `docs.missing-interface-doc`, and the docblock rule pack
|
|
3
|
+
// (stale/missing @param, missing @returns, useless docblock). Stable, deterministic emission order.
|
|
4
|
+
import { parameterNames } from "./blocks.ts";
|
|
5
|
+
import { commentTextAtLine, hasLeadingCommentBeforeLine } from "./comment-scanner.ts";
|
|
6
|
+
import { type SourceFile } from "./discovery.ts";
|
|
7
|
+
import { makeFinding } from "./findings.ts";
|
|
8
|
+
import { normalizedIdentifier, splitIdentifierWords } from "./findings-helpers.ts";
|
|
9
|
+
import { byteLine } from "./text-scans.ts";
|
|
10
|
+
import type { Finding } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// Lightweight shape used by both public-doc and class/file-mismatch rules. Holds the declaration
|
|
13
|
+
// keyword (`class`, `interface`, …), the symbol name, and the declaration line for finding anchors.
|
|
14
|
+
export interface ExportedDeclaration {
|
|
15
|
+
kind: string;
|
|
16
|
+
name: string;
|
|
17
|
+
line: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Precomputed JSDoc + signature pair used by every docblock rule (stale-param, missing-param,
|
|
21
|
+
// missing-return, useless-docblock) so the parser runs only once per source file.
|
|
22
|
+
interface DocumentedExportBlock {
|
|
23
|
+
doc: string;
|
|
24
|
+
name: string;
|
|
25
|
+
params: string[];
|
|
26
|
+
paramTags: string[];
|
|
27
|
+
line: number;
|
|
28
|
+
returnType: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Argument bundle shared by every docblock-related finding builder. `parameter` is optional
|
|
32
|
+
// because some docblock rules anchor on the symbol alone, not a specific parameter.
|
|
33
|
+
interface DocFindingInput {
|
|
34
|
+
ruleId: string;
|
|
35
|
+
message: string;
|
|
36
|
+
file: SourceFile;
|
|
37
|
+
line: number;
|
|
38
|
+
symbol: string;
|
|
39
|
+
parameter?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scans the masked code for the five exportable kinds. The order returned matches source order
|
|
43
|
+
// because `matchAll` walks left-to-right, which is what downstream rules depend on.
|
|
44
|
+
export function exportedDeclarations(source: string, codeSource: string): ExportedDeclaration[] {
|
|
45
|
+
return [...codeSource.matchAll(/\bexport\s+(class|interface|type|enum|function)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g)].map((match) => ({
|
|
46
|
+
kind: match[1] ?? "",
|
|
47
|
+
name: match[2] ?? "",
|
|
48
|
+
line: byteLine(source, match.index ?? 0),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/*
|
|
53
|
+
* Skips functions and interfaces - those have dedicated rules (`docs.missing-function-doc`,
|
|
54
|
+
* `docs.missing-interface-doc`). Reports the stable `docs.missing-public-doc` finding for
|
|
55
|
+
* classes/types/enums without a leading JSDoc-style block comment.
|
|
56
|
+
*/
|
|
57
|
+
export function pushMissingPublicDocFinding(file: SourceFile, source: string, declaration: ExportedDeclaration, findings: Finding[]): void {
|
|
58
|
+
if (declaration.kind === "function" || declaration.kind === "interface") {
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (hasDocCommentBeforeLine(source, declaration.line)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
findings.push(
|
|
65
|
+
makeFinding({
|
|
66
|
+
ruleId: "docs.missing-public-doc",
|
|
67
|
+
message: `Exported item \`${declaration.name}\` is missing a doc comment.`,
|
|
68
|
+
filePath: file.displayPath,
|
|
69
|
+
line: declaration.line,
|
|
70
|
+
severity: "advisory",
|
|
71
|
+
pillar: "documentation",
|
|
72
|
+
confidence: "medium",
|
|
73
|
+
symbol: declaration.name,
|
|
74
|
+
remediation: "Add a /** ... */ comment explaining the exported API.",
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Anchors the finding at line 1 because the overview comment is expected at the very top of the
|
|
80
|
+
// file. Reports the stable `docs.missing-file-overview` finding when no top-of-file comment exists.
|
|
81
|
+
export function analyseFileOverviewDoc(file: SourceFile, source: string, findings: Finding[]): void {
|
|
82
|
+
if (hasFileOverviewComment(source)) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
findings.push(
|
|
86
|
+
makeFinding({
|
|
87
|
+
ruleId: "docs.missing-file-overview",
|
|
88
|
+
message: `Source file \`${file.displayPath}\` is missing a top-of-file purpose comment.`,
|
|
89
|
+
filePath: file.displayPath,
|
|
90
|
+
line: 1,
|
|
91
|
+
severity: "advisory",
|
|
92
|
+
pillar: "documentation",
|
|
93
|
+
confidence: "medium",
|
|
94
|
+
remediation: "Add a brief /** ... */ overview before imports or declarations.",
|
|
95
|
+
metadata: {},
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/*
|
|
101
|
+
* Same shape as `pushMissingFunctionDocFinding` but for interfaces. The stable
|
|
102
|
+
* `docs.missing-interface-doc` rule reports any exported interface without a leading comment block.
|
|
103
|
+
*/
|
|
104
|
+
export function analyseInterfaceDocs(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
105
|
+
for (const declaration of interfaceDeclarations(source, codeSource)) {
|
|
106
|
+
if (hasLeadingCommentBeforeLine(source, declaration.line)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
findings.push(
|
|
110
|
+
makeFinding({
|
|
111
|
+
ruleId: "docs.missing-interface-doc",
|
|
112
|
+
message: `Interface \`${declaration.name}\` is missing a leading maintainer comment.`,
|
|
113
|
+
filePath: file.displayPath,
|
|
114
|
+
line: declaration.line,
|
|
115
|
+
severity: "advisory",
|
|
116
|
+
pillar: "documentation",
|
|
117
|
+
confidence: "medium",
|
|
118
|
+
symbol: declaration.name,
|
|
119
|
+
remediation: "Add a short /** ... */ or // comment explaining the interface contract.",
|
|
120
|
+
metadata: { interfaceName: declaration.name },
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Line-anchored regex (`^[ \t]*` + `gm` flag) so the match start is the declaration line, not the
|
|
127
|
+
// first character of the keyword inside another construct. See lessons file for the indent-newline trap.
|
|
128
|
+
export function interfaceDeclarations(source: string, codeSource: string): ExportedDeclaration[] {
|
|
129
|
+
return [...codeSource.matchAll(/^[ \t]*(?:export[ \t]+)?interface[ \t]+([A-Za-z_$][A-Za-z0-9_$]*)\b/gm)].map((match) => ({
|
|
130
|
+
kind: "interface",
|
|
131
|
+
name: match[1] ?? "",
|
|
132
|
+
line: byteLine(source, match.index ?? 0),
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/*
|
|
137
|
+
* Docblock rule pack. Walks every `/** … *\/ export function …` pair and fires four sub-rules per
|
|
138
|
+
* block in a stable, deterministic emission order (stale-param → missing-param → missing-return → useless-docblock).
|
|
139
|
+
*/
|
|
140
|
+
export function analyseDocRules(file: SourceFile, source: string, codeSource: string, findings: Finding[]): void {
|
|
141
|
+
for (const documentedExport of documentedExportBlocks(source, codeSource)) {
|
|
142
|
+
pushStaleParamFindings(file, documentedExport, findings);
|
|
143
|
+
pushMissingParamFindings(file, documentedExport, findings);
|
|
144
|
+
pushMissingReturnFinding(file, documentedExport, findings);
|
|
145
|
+
pushUselessDocblockFinding(file, documentedExport, findings);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Walks every `/** … */ export function …` pair in the source. Skips matches whose `export`
|
|
150
|
+
// keyword is inside a string/regex by confirming it shows up in the masked code as well.
|
|
151
|
+
function documentedExportBlocks(source: string, codeSource: string): DocumentedExportBlock[] {
|
|
152
|
+
const blocks: DocumentedExportBlock[] = [];
|
|
153
|
+
const documentedExport = /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*export\s+function\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(([^)]*)\)\s*(?::\s*([^\x7b\n]+))?/g;
|
|
154
|
+
for (const match of source.matchAll(documentedExport)) {
|
|
155
|
+
const block = documentedExportBlock(source, codeSource, match);
|
|
156
|
+
if (block) {
|
|
157
|
+
blocks.push(block);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return blocks;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Promotes a regex match into the structured block consumed by docblock rules. Returns undefined
|
|
164
|
+
// when the matched `export` keyword is actually inside a string literal in the masked source.
|
|
165
|
+
function documentedExportBlock(source: string, codeSource: string, match: RegExpMatchArray): DocumentedExportBlock | undefined {
|
|
166
|
+
const matchStart = regexMatchStart(match);
|
|
167
|
+
const exportIndex = source.indexOf("export", matchStart);
|
|
168
|
+
if (!isDocumentedExportInCode(codeSource, exportIndex)) {
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
const doc = regexGroup(match, 1);
|
|
172
|
+
return {
|
|
173
|
+
doc,
|
|
174
|
+
name: regexGroup(match, 2),
|
|
175
|
+
params: parameterNames(regexGroup(match, 3)).map((parameter) => parameter.name),
|
|
176
|
+
paramTags: docParamTags(doc),
|
|
177
|
+
line: byteLine(source, matchStart),
|
|
178
|
+
returnType: regexGroup(match, 4).trim(),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// `index ?? 0` adapter for the standard regex API - match.index is technically optional under
|
|
183
|
+
// strict TypeScript even though every real match has it.
|
|
184
|
+
function regexMatchStart(match: RegExpMatchArray): number {
|
|
185
|
+
return match.index ?? 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// `match[index] ?? ""` adapter - keeps callers from sprinkling default-empty handling.
|
|
189
|
+
function regexGroup(match: RegExpMatchArray, index: number): string {
|
|
190
|
+
return match[index] ?? "";
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// Confirms the captured `export` keyword is in real code, not inside a masked comment or string.
|
|
194
|
+
// The mask preserves the first letter of code tokens, so checking for `e` is sufficient.
|
|
195
|
+
function isDocumentedExportInCode(codeSource: string, exportIndex: number): boolean {
|
|
196
|
+
return exportIndex >= 0 && codeSource[exportIndex] === "e";
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Reports stale `@param` tags before missing ones because a rename should produce a stable pair:
|
|
200
|
+
// reports the "old name is stale" finding first because that order is the review contract.
|
|
201
|
+
function pushStaleParamFindings(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
|
|
202
|
+
for (const tag of block.paramTags) {
|
|
203
|
+
if (!block.params.includes(tag)) {
|
|
204
|
+
findings.push(docFinding({ ruleId: "docs.stale-param-tag", message: `Docblock for \`${block.name}\` has stale @param tag \`${tag}\`.`, file, line: block.line, symbol: block.name, parameter: tag }));
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Complements `pushStaleParamFindings`: reports stable one-argument findings because reviewers should not re-parse the signature mentally.
|
|
210
|
+
function pushMissingParamFindings(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
|
|
211
|
+
for (const param of block.params) {
|
|
212
|
+
if (!block.paramTags.includes(param)) {
|
|
213
|
+
findings.push(docFinding({ ruleId: "docs.missing-param-tag", message: `Docblock for \`${block.name}\` is missing @param for \`${param}\`.`, file, line: block.line, symbol: block.name, parameter: param }));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// `@returns` only required when the signature declares a non-void return type. `void` is exempt
|
|
219
|
+
// because documenting "returns nothing" is noise. Reports the stable `docs.missing-return-tag`.
|
|
220
|
+
function pushMissingReturnFinding(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
|
|
221
|
+
if (!needsReturnTag(block)) {
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
findings.push(docFinding({ ruleId: "docs.missing-return-tag", message: `Docblock for \`${block.name}\` is missing @returns.`, file, line: block.line, symbol: block.name }));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Three conditions: a declared return type exists, it isn't void, and the docblock doesn't already
|
|
228
|
+
// have a `@returns` tag. Annotation-less and void returns are exempt.
|
|
229
|
+
function needsReturnTag(block: DocumentedExportBlock): boolean {
|
|
230
|
+
return block.returnType !== "" && !/^void\b/.test(block.returnType) && !/@returns?\b/.test(block.doc);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Docblock-flavoured useless-docblock rule. Targets `/** Foo */ export function foo` shapes
|
|
234
|
+
// that fail the same restate test as line-comment docs, then reports a stable `docs.useless-docblock`.
|
|
235
|
+
function pushUselessDocblockFinding(file: SourceFile, block: DocumentedExportBlock, findings: Finding[]): void {
|
|
236
|
+
if (isUselessDocblock(block.doc, block.name)) {
|
|
237
|
+
findings.push(docFinding({ ruleId: "docs.useless-docblock", message: `Docblock for \`${block.name}\` only restates the signature.`, file, line: block.line, symbol: block.name }));
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Single makeFinding factory for every docblock-rule finding. `parameter` is omitted (not set to
|
|
242
|
+
// undefined) under exactOptionalPropertyTypes so the metadata shape stays stable and each
|
|
243
|
+
// baseline fingerprint round-trips cleanly across runs.
|
|
244
|
+
function docFinding(input: DocFindingInput): Finding {
|
|
245
|
+
return makeFinding({
|
|
246
|
+
ruleId: input.ruleId,
|
|
247
|
+
message: input.message,
|
|
248
|
+
filePath: input.file.displayPath,
|
|
249
|
+
line: input.line,
|
|
250
|
+
severity: "advisory",
|
|
251
|
+
pillar: "documentation",
|
|
252
|
+
confidence: "medium",
|
|
253
|
+
symbol: input.symbol,
|
|
254
|
+
remediation: "Update the JSDoc so it documents the current signature and return value.",
|
|
255
|
+
metadata: { ...(input.parameter ? { parameter: input.parameter } : {}) },
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Pulls every `@param name` tag's name from a docblock. Order is preserved so callers can spot
|
|
260
|
+
// duplicate tags by simple list comparison rather than set membership.
|
|
261
|
+
function docParamTags(doc: string): string[] {
|
|
262
|
+
const names: string[] = [];
|
|
263
|
+
for (const line of doc.split(/\r?\n/)) {
|
|
264
|
+
const name = docParamTagName(line);
|
|
265
|
+
if (name) {
|
|
266
|
+
names.push(name);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return names;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Skips the `{Type}` braces before reading the identifier. The two-step approach lets the type
|
|
273
|
+
// portion be arbitrarily complex (unions, generics) without breaking the identifier extraction.
|
|
274
|
+
function docParamTagName(line: string): string | undefined {
|
|
275
|
+
const marker = line.indexOf("@param");
|
|
276
|
+
if (marker === -1) {
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
const rest = stripDocParamType(line.slice(marker + "@param".length).trim());
|
|
280
|
+
return rest.match(/^([A-Za-z_$][A-Za-z0-9_$]*)/)?.[1];
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Removes a leading `{Type}` cluster from a `@param` tag tail. Returns the empty string when the
|
|
284
|
+
// braces are unbalanced - a malformed tag should not contribute a phantom parameter name.
|
|
285
|
+
function stripDocParamType(rest: string): string {
|
|
286
|
+
if (!rest.startsWith(String.fromCharCode(123))) {
|
|
287
|
+
return rest;
|
|
288
|
+
}
|
|
289
|
+
const end = rest.indexOf(String.fromCharCode(125));
|
|
290
|
+
return end === -1 ? "" : rest.slice(end + 1).trim();
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Normalises the docblock to its lowercase word run and compares against the symbol's expanded
|
|
294
|
+
// word list. Empty docblocks are considered useless; the equality fallback on
|
|
295
|
+
// `normalizedIdentifier` catches the case where punctuation alone separates the two.
|
|
296
|
+
function isUselessDocblock(doc: string, symbol: string): boolean {
|
|
297
|
+
const words = doc
|
|
298
|
+
.split(/\r?\n/)
|
|
299
|
+
.map((line) => line.replace(/^\s*\*\s?/, "").trim())
|
|
300
|
+
.filter((line) => line !== "" && !line.startsWith("@"))
|
|
301
|
+
.join(" ")
|
|
302
|
+
.toLowerCase()
|
|
303
|
+
.replace(/[^a-z0-9 ]/g, " ")
|
|
304
|
+
.replace(/\s+/g, " ")
|
|
305
|
+
.trim();
|
|
306
|
+
if (!words) {
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
return words === splitIdentifierWords(symbol).join(" ") || normalizedIdentifier(words) === normalizedIdentifier(symbol);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Walks upward from the declaration looking for `/** */` or `*` continuation lines. The boundary
|
|
313
|
+
// predicate stops the search at the first real code or non-doc text, so docblocks from earlier
|
|
314
|
+
// declarations don't leak across.
|
|
315
|
+
function hasDocCommentBeforeLine(source: string, line: number): boolean {
|
|
316
|
+
const lines = source.split(/\r?\n/);
|
|
317
|
+
let index = line - 2;
|
|
318
|
+
while (index >= 0) {
|
|
319
|
+
const current = lines[index]?.trim() ?? "";
|
|
320
|
+
if (isDocCommentLine(current)) {
|
|
321
|
+
return true;
|
|
322
|
+
}
|
|
323
|
+
if (isDocCommentSearchBoundary(current)) {
|
|
324
|
+
return false;
|
|
325
|
+
}
|
|
326
|
+
index -= 1;
|
|
327
|
+
}
|
|
328
|
+
return false;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// `/**` openers and `*` continuation lines are both docblock material. Plain `//` comments are
|
|
332
|
+
// intentionally excluded - those are tracked separately by `hasLeadingCommentBeforeLine`.
|
|
333
|
+
function isDocCommentLine(trimmedLine: string): boolean {
|
|
334
|
+
return trimmedLine.startsWith("/**") || trimmedLine.startsWith("*");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Halts the docblock walker when the upward search lands on real code. `@` lines are allowed
|
|
338
|
+
// through because JSDoc tags appear inside the docblock body and shouldn't terminate the scan.
|
|
339
|
+
function isDocCommentSearchBoundary(trimmedLine: string): boolean {
|
|
340
|
+
return trimmedLine !== "" && !trimmedLine.startsWith("@");
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// File-overview presence check for `docs.missing-file-overview`. Skips a shebang first because
|
|
344
|
+
// scripts conventionally place `#!` on line 1, then asks whether the first real line is a
|
|
345
|
+
// comment - that is the contract the rule reports against.
|
|
346
|
+
function hasFileOverviewComment(source: string): boolean {
|
|
347
|
+
const lines = source.split(/\r?\n/);
|
|
348
|
+
let index = firstMeaningfulLineIndex(lines);
|
|
349
|
+
if (index === undefined) {
|
|
350
|
+
return false;
|
|
351
|
+
}
|
|
352
|
+
if (lines[index]?.startsWith("#!")) {
|
|
353
|
+
index = firstMeaningfulLineIndex(lines, index + 1);
|
|
354
|
+
}
|
|
355
|
+
return index !== undefined && commentTextAtLine(lines, index) !== undefined;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Returns the index of the first non-blank line at or after `start`. The implementation does
|
|
359
|
+
// not skip comments - callers asking for "first meaningful line" treat comment text as a
|
|
360
|
+
// meaningful signal (file-overview detection wants to land on the comment itself).
|
|
361
|
+
function firstMeaningfulLineIndex(lines: string[], start = 0): number | undefined {
|
|
362
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
363
|
+
if ((lines[index] ?? "").trim() !== "") {
|
|
364
|
+
return index;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// Shared low-level finding factories and string utilities used across every rule pass. Kept as
|
|
2
|
+
// a leaf module (no rule-specific imports) so block/line/comment/project rule modules can depend
|
|
3
|
+
// on it without forming a cycle.
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import { basename } from "node:path";
|
|
6
|
+
import type { SourceFile } from "./discovery.ts";
|
|
7
|
+
import { makeFinding } from "./findings.ts";
|
|
8
|
+
import type { Finding, Pillar, Severity } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// Input bundle for `finding()` - the lowest-cost finding factory. Captures everything the caller
|
|
11
|
+
// must supply for a line-anchored Finding; shared defaults (confidence "high", empty metadata)
|
|
12
|
+
// are added inside the builder so callers don't repeat them at every rule site.
|
|
13
|
+
export interface LineFindingArgs {
|
|
14
|
+
ruleId: string;
|
|
15
|
+
message: string;
|
|
16
|
+
file: SourceFile;
|
|
17
|
+
line: number;
|
|
18
|
+
severity: Severity;
|
|
19
|
+
pillar: Pillar;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Cheapest finding factory: line-anchored, no symbol, confidence "high". Produces the
|
|
23
|
+
// (ruleId, filePath, line) tuple that every per-line emission relies on - this tuple is the
|
|
24
|
+
// stable fingerprint that drives baseline matching and report determinism.
|
|
25
|
+
export function finding(args: LineFindingArgs): Finding {
|
|
26
|
+
return makeFinding({ ruleId: args.ruleId, message: args.message, filePath: args.file.displayPath, line: args.line, severity: args.severity, pillar: args.pillar, confidence: "high" });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Diff-aware discovery: uses `execFileSync` (not `execSync`) so the `mode` value is passed as
|
|
30
|
+
// an argv entry and a malicious value cannot inject shell metacharacters. Custom-mode values pass
|
|
31
|
+
// through `--end-of-options` so a leading `-` cannot be reinterpreted as a `git diff` flag
|
|
32
|
+
// (e.g., `--output=…`). Normalises path separators to `/` for clean display-path joins.
|
|
33
|
+
export function changedFiles(mode: string): Set<string> {
|
|
34
|
+
if (mode === "staged") {
|
|
35
|
+
return gitPathSet(["diff", "--name-only", "--cached"]);
|
|
36
|
+
}
|
|
37
|
+
if (mode === "unstaged") {
|
|
38
|
+
return gitPathSet(["diff", "--name-only"]);
|
|
39
|
+
}
|
|
40
|
+
if (mode === "working-tree") {
|
|
41
|
+
return new Set([...gitPathSet(["diff", "--name-only"]), ...gitPathSet(["diff", "--name-only", "--cached"]), ...gitPathSet(["ls-files", "--others", "--exclude-standard"])]);
|
|
42
|
+
}
|
|
43
|
+
return gitPathSet(["diff", "--name-only", "--end-of-options", mode]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Spawns `git` through execFileSync with one fixed argv vector and returns the normalized path set
|
|
47
|
+
// used by diff filtering; spawns no shell because callers choose argv arrays, never shell strings.
|
|
48
|
+
function gitPathSet(args: string[]): Set<string> {
|
|
49
|
+
return new Set(execFileSync("git", args, { encoding: "utf8" }).split(/\r?\n/).filter(Boolean).map((line) => line.replaceAll("\\", "/")));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Strips directory and trailing extension. Used by `naming.class-file-mismatch` so the exported
|
|
53
|
+
// symbol name and the file stem normalise to the same shape - both sides must agree on this
|
|
54
|
+
// canonical form for the deterministic comparison to be meaningful.
|
|
55
|
+
export function fileBaseName(path: string): string {
|
|
56
|
+
return basename(path).replace(/\.[^.]+$/, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Lowercase-and-strip-separators canonical form. Treats `FooBar`, `foo_bar`, and `foo-bar` as
|
|
60
|
+
// the same key so naming rules can compare across case styles without baking the convention in.
|
|
61
|
+
export function normalizedIdentifier(identifier: string): string {
|
|
62
|
+
return identifier.replace(/[^A-Za-z0-9]/g, "").toLowerCase();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Inserts a space at every camelCase boundary, then splits on any non-alphanumeric run. Acronym
|
|
66
|
+
// runs (`HTTPServer`) stay intact because the inserted boundary is `lower → Upper`, not
|
|
67
|
+
// `Upper → Upper` - callers comparing word lists rely on this to keep tokens aligned.
|
|
68
|
+
export function splitIdentifierWords(identifier: string): string[] {
|
|
69
|
+
return identifier
|
|
70
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
71
|
+
.split(/[^A-Za-z0-9]+/)
|
|
72
|
+
.map((word) => word.toLowerCase())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Counts newlines before the offset to get a 0-based line number. `Math.max(0, …)` guards against
|
|
77
|
+
// negative input - callers occasionally pass `match.index` which is typed as optional.
|
|
78
|
+
export function lineOffset(source: string, index: number): number {
|
|
79
|
+
return source.slice(0, Math.max(0, index)).split("\n").length - 1;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Two-stage detector for `waste.commented-out-code`: first checks for a leading code keyword
|
|
83
|
+
// (`const`, `function`, `if`, etc.), then falls back to a `foo()` / `foo.bar()` call shape.
|
|
84
|
+
// The keyword list is intentionally conservative to avoid flagging prose that starts with `if`.
|
|
85
|
+
export function isCommentedOutCode(line: string): boolean {
|
|
86
|
+
const trimmed = line.trim();
|
|
87
|
+
if (!trimmed.startsWith("//")) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
const uncommented = trimmed.replace(/^\/\/+\s?/, "");
|
|
91
|
+
if (/^(const|let|var|function|class|interface|type|enum|import|export|if|for|while|switch|return|throw|await)\b/.test(uncommented)) {
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*(?:\.[A-Za-z_$][A-Za-z0-9_$]*)?\s*\([^)]*\);?$/.test(uncommented);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Lowercase membership test against the configured banned-names set. Drives the
|
|
98
|
+
// `naming.identifier-quality` predicate so the rule stays a single Set lookup per identifier.
|
|
99
|
+
export function isGenericName(name: string, bannedNames: Set<string>): boolean {
|
|
100
|
+
return bannedNames.has(name.toLowerCase());
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Escapes the standard regex metacharacters so user-supplied strings (rule IDs, identifiers,
|
|
104
|
+
// paths) can be embedded in dynamic patterns without altering their meaning. Hot path -
|
|
105
|
+
// used by every rule that builds a per-source RegExp.
|
|
106
|
+
export function escapeRegex(source: string): string {
|
|
107
|
+
return source.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
108
|
+
}
|
package/src/findings.ts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Finding factory helpers that centralize fingerprint generation and optional-field omission.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import type { Confidence, Finding, Pillar, Severity } from "./types.ts";
|
|
4
|
+
|
|
5
|
+
// Caller-supplied fields. makeFinding hashes (ruleId, filePath, line, symbol) into the fingerprint,
|
|
6
|
+
// so any field added here that should affect baseline identity must also be folded into that hash.
|
|
7
|
+
interface FindingInput {
|
|
8
|
+
ruleId: string;
|
|
9
|
+
message: string;
|
|
10
|
+
filePath: string;
|
|
11
|
+
line?: number;
|
|
12
|
+
severity: Severity;
|
|
13
|
+
pillar: Pillar;
|
|
14
|
+
confidence: Confidence;
|
|
15
|
+
symbol?: string;
|
|
16
|
+
remediation?: string;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Builds the canonical Finding shape. The 16-hex fingerprint is the baseline identity used by
|
|
21
|
+
// `gruff.baseline.v1`; changing the hashed fields or their join order is a baseline-breaking
|
|
22
|
+
// schema change. Optional fields are omitted (not set to undefined) so finding equality stays stable.
|
|
23
|
+
function makeFinding(input: FindingInput): Finding {
|
|
24
|
+
const fingerprint = createHash("sha256")
|
|
25
|
+
.update([input.ruleId, input.filePath, input.line ?? "", input.symbol ?? ""].join("\0"))
|
|
26
|
+
.digest("hex")
|
|
27
|
+
.slice(0, 16);
|
|
28
|
+
return {
|
|
29
|
+
ruleId: input.ruleId,
|
|
30
|
+
message: input.message,
|
|
31
|
+
filePath: input.filePath,
|
|
32
|
+
...(input.line ? { line: input.line } : {}),
|
|
33
|
+
severity: input.severity,
|
|
34
|
+
pillar: input.pillar,
|
|
35
|
+
secondaryPillars: [],
|
|
36
|
+
tier: "v0.1",
|
|
37
|
+
confidence: input.confidence,
|
|
38
|
+
...(input.symbol ? { symbol: input.symbol } : {}),
|
|
39
|
+
...(input.remediation ? { remediation: input.remediation } : {}),
|
|
40
|
+
metadata: input.metadata ?? {},
|
|
41
|
+
fingerprint,
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export { makeFinding };
|