@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/CONTRIBUTING.md +87 -0
  3. package/LICENSE +21 -0
  4. package/README.md +303 -0
  5. package/SECURITY.md +45 -0
  6. package/bin/gruff-ts +25 -0
  7. package/docs/CONFIGURATION.md +220 -0
  8. package/docs/RELEASING.md +103 -0
  9. package/docs/REPORTS_AND_CI.md +156 -0
  10. package/fixtures/sample.ts +21 -0
  11. package/package.json +56 -0
  12. package/scripts/bump-version.sh +145 -0
  13. package/scripts/check.sh +4 -0
  14. package/scripts/npm-publish.sh +258 -0
  15. package/scripts/preflight-checks.sh +357 -0
  16. package/scripts/start-dev.sh +8 -0
  17. package/scripts/test-performance.sh +695 -0
  18. package/src/analyser.ts +461 -0
  19. package/src/baseline.ts +90 -0
  20. package/src/blocks.ts +687 -0
  21. package/src/class-rules.ts +326 -0
  22. package/src/cli-program.ts +326 -0
  23. package/src/cli.ts +19 -0
  24. package/src/comment-rules.ts +605 -0
  25. package/src/comment-scanner.ts +357 -0
  26. package/src/config.ts +622 -0
  27. package/src/constants.ts +4 -0
  28. package/src/context-doc-rules.ts +241 -0
  29. package/src/dashboard.ts +114 -0
  30. package/src/dead-code-rules.ts +183 -0
  31. package/src/discovery.ts +508 -0
  32. package/src/doc-rules.ts +368 -0
  33. package/src/findings-helpers.ts +108 -0
  34. package/src/findings.ts +45 -0
  35. package/src/fixture-purpose-rules.ts +334 -0
  36. package/src/fixtures/rule-catalogue-security-doctrine.ts +132 -0
  37. package/src/github-actions-rules.ts +413 -0
  38. package/src/line-rules.ts +538 -0
  39. package/src/naming-pushers.ts +191 -0
  40. package/src/project-config-rules.ts +555 -0
  41. package/src/project-rules.ts +545 -0
  42. package/src/report-renderers.ts +691 -0
  43. package/src/rule-list.ts +179 -0
  44. package/src/rules.ts +135 -0
  45. package/src/safety-rules.ts +355 -0
  46. package/src/scoring.ts +74 -0
  47. package/src/security-flow-rules.ts +112 -0
  48. package/src/sensitive-data-rules.ts +288 -0
  49. package/src/source-text.ts +722 -0
  50. package/src/test-block-rules.ts +347 -0
  51. package/src/test-fixtures.ts +621 -0
  52. package/src/text-scans.ts +193 -0
  53. package/src/types.ts +113 -0
  54. package/tsconfig.json +15 -0
@@ -0,0 +1,357 @@
1
+ // Source-text comment lexer. Walks a file once and emits one CommentRecord per `//` or `/* */`
2
+ // block. Kept as a leaf module (no gruff imports) so block, line, and comment-rule modules can
3
+ // depend on it without forming a cycle.
4
+
5
+ // One comment block extracted by `commentRecords`. `endLine` differs from `line` for block
6
+ // comments; documentation rules need both to compute the gap between comment and declaration.
7
+ export interface CommentRecord {
8
+ kind: "line" | "block";
9
+ text: string;
10
+ line: number;
11
+ endLine: number;
12
+ startIndex: number;
13
+ endIndex: number;
14
+ }
15
+
16
+ /** Tracks comment lexer state so string and regex contents are not treated as comments. */
17
+ interface CommentScanState {
18
+ quote: string | undefined;
19
+ isEscaped: boolean;
20
+ isRegex: boolean;
21
+ isRegexEscaped: boolean;
22
+ isRegexCharClass: boolean;
23
+ previousCode: string;
24
+ line: number;
25
+ }
26
+
27
+ type CommentScanHandler = (source: string, index: number, state: CommentScanState, records: CommentRecord[]) => number | undefined;
28
+
29
+ // Source-text comment lexer. Produces the stable list of CommentRecords every documentation rule
30
+ // consumes. Walks the file once via the prioritised handler chain (`COMMENT_SCAN_HANDLERS`).
31
+ export function commentRecords(source: string): CommentRecord[] {
32
+ const records: CommentRecord[] = [];
33
+ const state = initialCommentScanState();
34
+
35
+ for (let index = 0; index < source.length; index += 1) {
36
+ index = advanceCommentRecordScan(source, index, state, records);
37
+ }
38
+ return records;
39
+ }
40
+
41
+ /** Ordered comment lexer steps keep comment detection deterministic and branch-light. */
42
+ const COMMENT_SCAN_HANDLERS: CommentScanHandler[] = [
43
+ scanCommentLineBreak,
44
+ scanQuotedCommentStep,
45
+ scanRegexCommentStep,
46
+ scanCommentRecordStep,
47
+ scanQuoteStartStep,
48
+ scanRegexStartStep,
49
+ ];
50
+
51
+ /** Runs the first lexer step that can consume the current source character. */
52
+ function advanceCommentRecordScan(source: string, index: number, state: CommentScanState, records: CommentRecord[]): number {
53
+ for (const handler of COMMENT_SCAN_HANDLERS) {
54
+ const nextIndex = handler(source, index, state, records);
55
+ if (typeof nextIndex === "number") {
56
+ return nextIndex;
57
+ }
58
+ }
59
+ updateCommentScanPreviousCode(state, source[index] ?? "");
60
+ return index;
61
+ }
62
+
63
+ /** Creates a fresh comment lexer state for a single source file scan. */
64
+ function initialCommentScanState(): CommentScanState {
65
+ return {
66
+ quote: undefined,
67
+ isEscaped: false,
68
+ isRegex: false,
69
+ isRegexEscaped: false,
70
+ isRegexCharClass: false,
71
+ previousCode: "",
72
+ line: 1,
73
+ };
74
+ }
75
+
76
+ /** Consumes newlines and resets quote/regex state that cannot cross lines. */
77
+ function scanCommentLineBreak(source: string, index: number, state: CommentScanState): number | undefined {
78
+ if ((source[index] ?? "") !== "\n") {
79
+ return undefined;
80
+ }
81
+ advanceCommentScanLine(state);
82
+ return index;
83
+ }
84
+
85
+ /** Advances the lexer after a newline has been consumed. */
86
+ function advanceCommentScanLine(state: CommentScanState): void {
87
+ state.line += 1;
88
+ if (state.quote !== "`") {
89
+ state.quote = undefined;
90
+ }
91
+ state.isRegex = false;
92
+ state.isRegexEscaped = false;
93
+ state.isRegexCharClass = false;
94
+ }
95
+
96
+ /** Keeps quoted text from being reported as a real source comment. */
97
+ function scanQuotedCommentStep(source: string, index: number, state: CommentScanState): number | undefined {
98
+ return scanActiveQuotedCommentState(state, source[index] ?? "") ? index : undefined;
99
+ }
100
+
101
+ /** Updates quote escape/close state when the lexer is inside a string. */
102
+ function scanActiveQuotedCommentState(state: CommentScanState, character: string): boolean {
103
+ if (!state.quote) {
104
+ return false;
105
+ }
106
+ const nextState = scanQuotedCommentCharacter(character, state.quote, state.isEscaped);
107
+ state.quote = nextState.quote;
108
+ state.isEscaped = nextState.isEscaped;
109
+ return true;
110
+ }
111
+
112
+ /** Keeps regex literal bodies from being reported as real source comments. */
113
+ function scanRegexCommentStep(source: string, index: number, state: CommentScanState): number | undefined {
114
+ return scanActiveRegexCommentState(state, source[index] ?? "") ? index : undefined;
115
+ }
116
+
117
+ /** Updates regex escape, character-class, and close state. */
118
+ function scanActiveRegexCommentState(state: CommentScanState, character: string): boolean {
119
+ if (!state.isRegex) {
120
+ return false;
121
+ }
122
+ const nextState = scanRegexCommentCharacter(character, state.isRegexEscaped, state.isRegexCharClass);
123
+ state.isRegex = nextState.isRegex;
124
+ state.isRegexEscaped = nextState.isEscaped;
125
+ state.isRegexCharClass = nextState.isCharClass;
126
+ return true;
127
+ }
128
+
129
+ /** Records a line or block comment and returns the consumed source index. */
130
+ function scanCommentRecordStep(source: string, index: number, state: CommentScanState, records: CommentRecord[]): number | undefined {
131
+ const record = commentRecordAt(source, index, state.line);
132
+ if (!record) {
133
+ return undefined;
134
+ }
135
+ records.push(record);
136
+ state.line = record.endLine;
137
+ return record.kind === "line" ? record.endIndex - 1 : record.endIndex;
138
+ }
139
+
140
+ /** Detects whether the current slash pair begins a source comment. */
141
+ function commentRecordAt(source: string, index: number, line: number): CommentRecord | undefined {
142
+ const character = source[index] ?? "";
143
+ const next = source[index + 1] ?? "";
144
+ if (character === "/" && next === "/") {
145
+ return lineCommentRecord(source, index, line);
146
+ }
147
+ if (character === "/" && next === "*") {
148
+ return blockCommentRecord(source, index, line);
149
+ }
150
+ return undefined;
151
+ }
152
+
153
+ /** Opens string/template quote state when the current character starts a literal. */
154
+ function scanQuoteStartStep(source: string, index: number, state: CommentScanState): number | undefined {
155
+ return openCommentScanQuote(state, source[index] ?? "") ? index : undefined;
156
+ }
157
+
158
+ /** Mutates quote state after a quote-start character is found. */
159
+ function openCommentScanQuote(state: CommentScanState, character: string): boolean {
160
+ if (character !== "\"" && character !== "'" && character !== "`") {
161
+ return false;
162
+ }
163
+ state.quote = character;
164
+ state.isEscaped = false;
165
+ state.previousCode = character;
166
+ return true;
167
+ }
168
+
169
+ /** Opens regex state for slash tokens that follow expression-start syntax. */
170
+ function scanRegexStartStep(source: string, index: number, state: CommentScanState): number | undefined {
171
+ return openCommentScanRegex(state, source, index, source[index] ?? "") ? index : undefined;
172
+ }
173
+
174
+ /** Mutates regex state after a regex literal start is found. */
175
+ function openCommentScanRegex(state: CommentScanState, source: string, index: number, character: string): boolean {
176
+ if (character !== "/" || !isCommentRegexStart(state.previousCode, source.slice(Math.max(0, index - 40), index))) {
177
+ return false;
178
+ }
179
+ state.isRegex = true;
180
+ state.isRegexEscaped = false;
181
+ state.isRegexCharClass = false;
182
+ state.previousCode = character;
183
+ return true;
184
+ }
185
+
186
+ /** Remembers the last non-whitespace code token for regex/comment disambiguation. */
187
+ function updateCommentScanPreviousCode(state: CommentScanState, character: string): void {
188
+ if (/\S/.test(character)) {
189
+ state.previousCode = character;
190
+ }
191
+ }
192
+
193
+ // Pure function returning the next quote state. Pure because callers thread the state explicitly,
194
+ // which keeps the comment-lexer testable in isolation from the surrounding mutation.
195
+ function scanQuotedCommentCharacter(character: string, quote: string, isEscaped: boolean): { quote: string | undefined; isEscaped: boolean } {
196
+ if (isEscaped) {
197
+ return { quote, isEscaped: false };
198
+ }
199
+ if (character === "\\") {
200
+ return { quote, isEscaped: true };
201
+ }
202
+ if (character === quote) {
203
+ return { quote: undefined, isEscaped: false };
204
+ }
205
+ return { quote, isEscaped: false };
206
+ }
207
+
208
+ // Pure step that yields the next regex state. Same isolation pattern as `scanQuotedCommentCharacter`.
209
+ function scanRegexCommentCharacter(character: string, isEscaped: boolean, isCharClass: boolean): { isRegex: boolean; isEscaped: boolean; isCharClass: boolean } {
210
+ if (isEscaped) {
211
+ return { isRegex: true, isEscaped: false, isCharClass };
212
+ }
213
+ if (character === "\\") {
214
+ return { isRegex: true, isEscaped: true, isCharClass };
215
+ }
216
+ if (character === "[") {
217
+ return { isRegex: true, isEscaped: false, isCharClass: true };
218
+ }
219
+ if (character === "]") {
220
+ return { isRegex: true, isEscaped: false, isCharClass: false };
221
+ }
222
+ if (character === "/" && !isCharClass) {
223
+ return { isRegex: false, isEscaped: false, isCharClass: false };
224
+ }
225
+ return { isRegex: true, isEscaped: false, isCharClass };
226
+ }
227
+
228
+ // Same regex-vs-division heuristic as `source-text.ts:isRegexLiteralStart`. Duplicated here so the
229
+ // comment lexer stays a leaf with no cross-module dependency on the masking pass.
230
+ function isCommentRegexStart(previousCode: string, beforeSlash: string): boolean {
231
+ return previousCode === "" || "([{=,:!&|?;".includes(previousCode) || /\breturn$/.test(beforeSlash.trimEnd());
232
+ }
233
+
234
+ // Line-comment record. `line === endLine` because line comments cannot span newlines. Text is
235
+ // captured trimmed so leading/trailing whitespace doesn't enter rule comparisons.
236
+ function lineCommentRecord(source: string, startIndex: number, line: number): CommentRecord {
237
+ const newline = source.indexOf("\n", startIndex + 2);
238
+ const endIndex = newline === -1 ? source.length : newline;
239
+ return {
240
+ kind: "line",
241
+ text: source.slice(startIndex + 2, endIndex).trim(),
242
+ line,
243
+ endLine: line,
244
+ startIndex,
245
+ endIndex,
246
+ };
247
+ }
248
+
249
+ // Block-comment record. `endLine > line` is normal for multi-line blocks. `text` is normalised
250
+ // (leading-asterisk decoration stripped) so JSDoc-style and plain-block comments compare equally.
251
+ function blockCommentRecord(source: string, startIndex: number, line: number): CommentRecord {
252
+ let endIndex = source.length - 1;
253
+ let endLine = line;
254
+ for (let index = startIndex + 2; index < source.length; index += 1) {
255
+ if (source[index] === "\n") {
256
+ endLine += 1;
257
+ }
258
+ if (source[index] === "*" && source[index + 1] === "/") {
259
+ endIndex = index + 1;
260
+ break;
261
+ }
262
+ }
263
+ return {
264
+ kind: "block",
265
+ text: normalizedBlockCommentText(source.slice(startIndex + 2, Math.max(startIndex + 2, endIndex - 1))),
266
+ line,
267
+ endLine,
268
+ startIndex,
269
+ endIndex,
270
+ };
271
+ }
272
+
273
+ // Strips the `* ` JSDoc-style line decoration so `/** foo */`, `/* foo */`, and `// foo` all
274
+ // produce the same `text` payload for rule comparison.
275
+ function normalizedBlockCommentText(text: string): string {
276
+ return text
277
+ .split(/\r?\n/)
278
+ .map((line) => line.replace(/^[ \t]*\*[ \t]?/, "").trim())
279
+ .filter((line) => line !== "")
280
+ .join(" ")
281
+ .trim();
282
+ }
283
+
284
+ // String-input wrapper around `hasLeadingCommentBeforeLines`. Keeps call sites that already hold
285
+ // a split line array from re-splitting on every lookup.
286
+ export function hasLeadingCommentBeforeLine(source: string, line: number): boolean {
287
+ return hasLeadingCommentBeforeLines(source.split(/\r?\n/), line);
288
+ }
289
+
290
+ // Skips blank padding above the declaration and asks whether the immediately preceding non-blank
291
+ // line is any comment shape (`//`, `/*`, or `*/`). Underlies the missing-comment rules across
292
+ // functions, interfaces, and exported declarations.
293
+ export function hasLeadingCommentBeforeLines(lines: string[], line: number): boolean {
294
+ let index = line - 2;
295
+ while (index >= 0 && (lines[index] ?? "").trim() === "") {
296
+ index -= 1;
297
+ }
298
+ return index >= 0 && commentTextAtLine(lines, index) !== undefined;
299
+ }
300
+
301
+ // Three comment shapes resolved here: `//` line comments, `/* … */` opener lines, and lines that
302
+ // only contain the `*/` closer (call delegates upward to find the opener). Returns undefined for
303
+ // non-comment lines and for empty comments, so callers can use truthiness as the "has text" gate.
304
+ export function commentTextAtLine(lines: string[], index: number): string | undefined {
305
+ const trimmedLine = (lines[index] ?? "").trim();
306
+ if (trimmedLine.startsWith("//")) {
307
+ const text = trimmedLine.slice(2).trim();
308
+ return text === "" ? undefined : text;
309
+ }
310
+ if (trimmedLine.startsWith("/*")) {
311
+ return blockCommentText(lines, index);
312
+ }
313
+ if (trimmedLine.endsWith("*/")) {
314
+ return blockCommentTextEndingAt(lines, index);
315
+ }
316
+ return undefined;
317
+ }
318
+
319
+ // Walks upward from a `*/` closer to its matching `/*` opener, then delegates to
320
+ // `blockCommentText` to extract the joined body. Used when a declaration's leading comment is a
321
+ // block comment whose closer sits on the line above the declaration.
322
+ function blockCommentTextEndingAt(lines: string[], endIndex: number): string | undefined {
323
+ for (let index = endIndex; index >= 0; index -= 1) {
324
+ if ((lines[index] ?? "").trim().startsWith("/*")) {
325
+ return blockCommentText(lines, index, endIndex);
326
+ }
327
+ }
328
+ return undefined;
329
+ }
330
+
331
+ // Joins a block comment's lines into one normalised text run: strips `/*` / `*/` / leading `*`,
332
+ // drops `@tag` lines, and collapses whitespace. The `knownEndIndex` parameter lets
333
+ // `blockCommentTextEndingAt` skip the scan when the closer line is already known.
334
+ function blockCommentText(lines: string[], startIndex: number, knownEndIndex?: number): string | undefined {
335
+ const endIndex = knownEndIndex ?? blockCommentEndIndex(lines, startIndex);
336
+ if (endIndex === undefined) {
337
+ return undefined;
338
+ }
339
+ const text = lines
340
+ .slice(startIndex, endIndex + 1)
341
+ .map((line) => line.replace(/^\s*\/\*\*?/, "").replace(/\*\/\s*$/, "").replace(/^\s*\*\s?/, "").trim())
342
+ .filter((line) => line !== "" && !line.startsWith("@"))
343
+ .join(" ")
344
+ .trim();
345
+ return text === "" ? undefined : text;
346
+ }
347
+
348
+ // Forward scan for the next line containing `*/`. Returns undefined for unterminated comments,
349
+ // which the caller treats as "no useful text" rather than throwing - partial-scan robustness.
350
+ function blockCommentEndIndex(lines: string[], startIndex: number): number | undefined {
351
+ for (let index = startIndex; index < lines.length; index += 1) {
352
+ if ((lines[index] ?? "").includes("*/")) {
353
+ return index;
354
+ }
355
+ }
356
+ return undefined;
357
+ }