@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,193 @@
1
+ // Empty-match guard (`lastIndex += 1`) prevents zero-width patterns like /(?=)/g from looping forever.
2
+ // Caller's RegExp is never mutated - `globalRegExp` clones it when the global flag is missing.
3
+ function countMatches(source: string, pattern: RegExp): number {
4
+ const globalPattern = globalRegExp(pattern);
5
+ let count = 0;
6
+ let match: RegExpExecArray | null;
7
+ globalPattern.lastIndex = 0;
8
+ while ((match = globalPattern["exec"](source)) !== null) {
9
+ count += 1;
10
+ if (match[0] === "") {
11
+ globalPattern.lastIndex += 1;
12
+ }
13
+ }
14
+ return count;
15
+ }
16
+
17
+ // Clones into a new `g`-flagged RegExp when needed. Mutating the caller's pattern (via `lastIndex`)
18
+ // would silently break any further use on the calling side - rule descriptors share patterns at module scope.
19
+ function globalRegExp(pattern: RegExp): RegExp {
20
+ return new RegExp(pattern.source, pattern.flags.includes("g") ? pattern.flags : `${pattern.flags}g`);
21
+ }
22
+
23
+ // One-based line number of the first matching line, defaulting to 1 when no match exists so
24
+ // findings always have a valid anchor. Used by rules that need a stable file-level location.
25
+ function firstLine(source: string, pattern: RegExp): number {
26
+ return source.split(/\r?\n/).findIndex((line) => pattern.test(line)) + 1 || 1;
27
+ }
28
+
29
+ // Mutable lexer state threaded through the task-marker scanner. `quote === "\`"` survives across
30
+ // newlines because template literals can span lines; ordinary quotes reset at end of line.
31
+ interface TodoMarkerScanState {
32
+ isInBlockComment: boolean;
33
+ quote: string | undefined;
34
+ isEscaped: boolean;
35
+ }
36
+
37
+ // One scanner step yields the comment bytes for this position, how many extra characters to skip
38
+ // (e.g., to consume `*/`), and whether the rest of the line is comment text (no need to keep scanning).
39
+ interface CommentScanStep {
40
+ comment: string;
41
+ skip: number;
42
+ isDone: boolean;
43
+ }
44
+
45
+ // Task-marker counter consumed by the density rule. Skipping strings is mandatory - markers inside
46
+ // string literals must not inflate the count, or every fixture string containing a task keyword
47
+ // would trip. `isScript` flips `#` line-comment recognition for shell-like config files.
48
+ function todoMarkerSummary(source: string, isScript: boolean): { count: number; firstLine: number } {
49
+ if (!source.includes("TODO") && !source.includes("FIXME")) {
50
+ return { count: 0, firstLine: 1 };
51
+ }
52
+ let count = 0;
53
+ let firstLine = 0;
54
+ const state: TodoMarkerScanState = { isInBlockComment: false, quote: undefined, isEscaped: false };
55
+
56
+ source.split(/\r?\n/).forEach((line, index) => {
57
+ const markerCount = countMatches(commentTextForLine(line, state, isScript), /\b(?:TODO|FIXME)\b/g);
58
+ if (markerCount === 0) {
59
+ return;
60
+ }
61
+ count += markerCount;
62
+ if (firstLine === 0) {
63
+ firstLine = index + 1;
64
+ }
65
+ });
66
+
67
+ return { count, firstLine: firstLine || 1 };
68
+ }
69
+
70
+ // Returns only the comment portions of `line` - strings, regex literals, and code are dropped.
71
+ // Mutates `state` so block comments and template literals can carry across line boundaries.
72
+ function commentTextForLine(line: string, state: TodoMarkerScanState, isScript: boolean): string {
73
+ let comment = "";
74
+ for (let index = 0; index < line.length; index += 1) {
75
+ const step = commentScanStep(line, index, state, isScript);
76
+ comment += step.comment;
77
+ index += step.skip;
78
+ if (step.isDone) {
79
+ break;
80
+ }
81
+ }
82
+ if (state.quote !== "`") {
83
+ state.quote = undefined;
84
+ state.isEscaped = false;
85
+ }
86
+ return comment;
87
+ }
88
+
89
+ // Three-state dispatcher: in a block comment, in a quoted string, or in executable code. Each
90
+ // branch must be exhaustive - falling through would let a task keyword inside a string get counted.
91
+ function commentScanStep(line: string, index: number, state: TodoMarkerScanState, isScript: boolean): CommentScanStep {
92
+ const character = line[index] ?? "";
93
+ const next = line[index + 1] ?? "";
94
+ if (state.isInBlockComment) {
95
+ return blockCommentScanStep(character, next, state);
96
+ }
97
+ if (state.quote) {
98
+ return quotedScanStep(character, state);
99
+ }
100
+ return openCodeCommentScanStep(line, index, state, isScript);
101
+ }
102
+
103
+ // Priority order: quote → block opener → line comment. Quotes must come first because `/* ... */`
104
+ // inside a string is just text, and `// foo` inside a string would otherwise prematurely end scanning.
105
+ function openCodeCommentScanStep(line: string, index: number, state: TodoMarkerScanState, isScript: boolean): CommentScanStep {
106
+ const character = line[index] ?? "";
107
+ const next = line[index + 1] ?? "";
108
+ const quoteStep = quoteStartScanStep(character, state);
109
+ if (quoteStep) {
110
+ return quoteStep;
111
+ }
112
+ const blockStep = blockStartScanStep(character, next, state);
113
+ if (blockStep) {
114
+ return blockStep;
115
+ }
116
+ return lineCommentScanStep(line, index, character, next, isScript);
117
+ }
118
+
119
+ // Detects opening quote of a string or template literal and mutates `state.quote` so subsequent
120
+ // characters are routed through `quotedScanStep` rather than the code branch.
121
+ function quoteStartScanStep(character: string, state: TodoMarkerScanState): CommentScanStep | undefined {
122
+ if (character === "\"" || character === "'" || character === "`") {
123
+ state.quote = character;
124
+ return emptyCommentScanStep();
125
+ }
126
+ return undefined;
127
+ }
128
+
129
+ // Detects `/*` opener. Returns `skip: 1` so the caller advances past `*` and does not re-enter on
130
+ // the next character as if `*` were code.
131
+ function blockStartScanStep(character: string, next: string, state: TodoMarkerScanState): CommentScanStep | undefined {
132
+ if (character === "/" && next === "*") {
133
+ state.isInBlockComment = true;
134
+ return { comment: "", skip: 1, isDone: false };
135
+ }
136
+ return undefined;
137
+ }
138
+
139
+ // Slices the comment payload after `//` (or `#` in config files) and signals `isDone: true` so the
140
+ // caller stops walking the line - everything to the right is comment text.
141
+ function lineCommentScanStep(line: string, index: number, character: string, next: string, isScript: boolean): CommentScanStep {
142
+ if (character === "/" && next === "/") {
143
+ return { comment: line.slice(index + 2), skip: line.length, isDone: true };
144
+ }
145
+ if (!isScript && character === "#") {
146
+ return { comment: line.slice(index + 1), skip: line.length, isDone: true };
147
+ }
148
+ return emptyCommentScanStep();
149
+ }
150
+
151
+ // Inside a block comment until `*/` is seen. Each character is treated as comment text so task
152
+ // markers inside the block contribute to the count.
153
+ function blockCommentScanStep(character: string, next: string, state: TodoMarkerScanState): CommentScanStep {
154
+ if (character === "*" && next === "/") {
155
+ state.isInBlockComment = false;
156
+ return { comment: "", skip: 1, isDone: false };
157
+ }
158
+ return { comment: character, skip: 0, isDone: false };
159
+ }
160
+
161
+ // Consumes the body of a string/template literal. `\` toggles `isEscaped` so the next character
162
+ // is not interpreted as the closing quote - necessary for sequences like `"\""` and `"\\\""`.
163
+ function quotedScanStep(character: string, state: TodoMarkerScanState): CommentScanStep {
164
+ if (state.isEscaped) {
165
+ state.isEscaped = false;
166
+ } else if (character === "\\") {
167
+ state.isEscaped = true;
168
+ } else if (character === state.quote) {
169
+ state.quote = undefined;
170
+ }
171
+ return emptyCommentScanStep();
172
+ }
173
+
174
+ // No-op step used by branches that consumed a character but produced no comment text - keeps the
175
+ // caller loop branchless at the cost of one allocation per non-comment character.
176
+ function emptyCommentScanStep(): CommentScanStep {
177
+ return { comment: "", skip: 0, isDone: false };
178
+ }
179
+
180
+ // One-based line number containing byte offset `index`. Used to anchor findings extracted from
181
+ // regex match indices; off-by-one would shift every reported line in the resulting reports.
182
+ function byteLine(source: string, index: number): number {
183
+ const end = Math.max(0, index);
184
+ let line = 1;
185
+ for (let offset = 0; offset < end; offset += 1) {
186
+ if (source.charCodeAt(offset) === 10) {
187
+ line += 1;
188
+ }
189
+ }
190
+ return line;
191
+ }
192
+
193
+ export { byteLine, countMatches, firstLine, todoMarkerSummary };
package/src/types.ts ADDED
@@ -0,0 +1,113 @@
1
+ /** Finding impact level used for scoring, output, and fail-on thresholds. */
2
+ export type Severity = "advisory" | "warning" | "error";
3
+
4
+ /** High-level rubric category assigned to every finding. */
5
+ export type Pillar =
6
+ | "size"
7
+ | "complexity"
8
+ | "dead-code"
9
+ | "waste"
10
+ | "naming"
11
+ | "documentation"
12
+ | "modernisation"
13
+ | "security"
14
+ | "sensitive-data"
15
+ | "test-quality"
16
+ | "design";
17
+
18
+ /** Analyzer confidence attached to a finding or descriptor. */
19
+ export type Confidence = "low" | "medium" | "high";
20
+
21
+ /** Output renderer selected by CLI options or direct callers. */
22
+ export type OutputFormat = "text" | "json" | "html" | "markdown" | "github" | "hotspot" | "sarif";
23
+
24
+ /** Minimum severity that causes a non-zero CLI exit. */
25
+ export type FailThreshold = "none" | "advisory" | "warning" | "error";
26
+
27
+ /** Public options contract consumed by the analyzer core and CLI. */
28
+ export interface AnalysisOptions {
29
+ paths: string[];
30
+ config?: string;
31
+ shouldSkipConfig: boolean;
32
+ format: OutputFormat;
33
+ failOn: FailThreshold;
34
+ shouldIncludeIgnored: boolean;
35
+ diff?: string;
36
+ historyFile?: string;
37
+ baseline?: string;
38
+ generateBaseline?: string;
39
+ shouldSkipBaseline: boolean;
40
+ }
41
+
42
+ /** Loaded analyzer configuration derived from optional gruff config files. */
43
+ export interface Config {
44
+ ignoredPaths: string[];
45
+ acceptedAbbreviations: Set<string>;
46
+ secretPreviews: Set<string>;
47
+ bannedGenericNames: Set<string>;
48
+ booleanPrefixes: Set<string>;
49
+ hungarianPrefixes: Set<string>;
50
+ placeholderNames: Set<string>;
51
+ abbreviationDenylist: Set<string>;
52
+ negativeBooleanAllowed: Set<string>;
53
+ knownAcronyms: Set<string>;
54
+ rules: Map<string, { enabled?: boolean; threshold?: number; severity?: Severity; options: Map<string, number> }>;
55
+ }
56
+
57
+ /** Stable analysis finding emitted by a rule. */
58
+ export interface Finding {
59
+ ruleId: string;
60
+ message: string;
61
+ filePath: string;
62
+ line?: number;
63
+ endLine?: number;
64
+ column?: number;
65
+ severity: Severity;
66
+ pillar: Pillar;
67
+ secondaryPillars: Pillar[];
68
+ tier: "v0.1";
69
+ confidence: Confidence;
70
+ symbol?: string;
71
+ remediation?: string;
72
+ metadata: Record<string, unknown>;
73
+ fingerprint: string;
74
+ }
75
+
76
+ /** Non-finding runtime diagnostic emitted while preparing or reading inputs. */
77
+ export interface RunDiagnostic {
78
+ diagnosticType: string;
79
+ message: string;
80
+ filePath?: string;
81
+ line?: number;
82
+ }
83
+
84
+ /** Stable gruff.analysis.v1 report schema returned by analyse and JSON report commands. */
85
+ export interface AnalysisReport {
86
+ schemaVersion: "gruff.analysis.v1";
87
+ tool: { name: "gruff-ts"; version: string };
88
+ run: { projectRoot: string; format: OutputFormat; failOn: FailThreshold; generatedAt: string };
89
+ summary: { advisory: number; warning: number; error: number; total: number };
90
+ paths: { analysedFiles: number; ignoredPaths: string[]; missingPaths: string[] };
91
+ diagnostics: RunDiagnostic[];
92
+ findings: Finding[];
93
+ score: {
94
+ composite: number;
95
+ grade: string;
96
+ pillars: Array<{ pillar: Pillar; score: number; findings: number }>;
97
+ topOffenders: Array<{ filePath: string; score: number; findings: number }>;
98
+ };
99
+ baseline?: { path: string; source: string; suppressed: number; generated: boolean };
100
+ }
101
+
102
+ /** Static catalogue entry describing a rule's purpose and configuration knobs. */
103
+ export interface RuleDescriptor {
104
+ ruleId: string;
105
+ pillar: Pillar;
106
+ severity: Severity;
107
+ confidence: Confidence;
108
+ description: string;
109
+ remediation: string;
110
+ threshold?: number;
111
+ optionKeys?: readonly string[];
112
+ fixtureExemption?: string;
113
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,15 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "forceConsistentCasingInFileNames": true,
9
+ "skipLibCheck": true,
10
+ "noUncheckedIndexedAccess": true,
11
+ "exactOptionalPropertyTypes": true,
12
+ "allowImportingTsExtensions": true
13
+ },
14
+ "include": ["src/**/*.ts"]
15
+ }