@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,241 @@
|
|
|
1
|
+
// Context-doc rules: emit findings when a function or interface comment exists but does not
|
|
2
|
+
// describe the WHY (complex control flow), side effects, error behavior, or public-contract
|
|
3
|
+
// invariants the implementation carries. Each rule reports a stable, deterministic finding.
|
|
4
|
+
import { approximateNpath, functionBodyContent, type FunctionBlock, maxNestingDepth } from "./blocks.ts";
|
|
5
|
+
import { type CommentRecord } from "./comment-scanner.ts";
|
|
6
|
+
import { threshold } from "./config.ts";
|
|
7
|
+
import { type SourceFile } from "./discovery.ts";
|
|
8
|
+
import { makeFinding } from "./findings.ts";
|
|
9
|
+
import { countMatches } from "./text-scans.ts";
|
|
10
|
+
import type { Config, Finding } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
// Generic declaration shape used by both function and interface comment-quality rules so they can
|
|
13
|
+
// share `pushStaleDeclarationCommentFinding` and `pushRestatingSignatureCommentFinding` logic.
|
|
14
|
+
export interface CommentedDeclaration {
|
|
15
|
+
kind: "function" | "interface";
|
|
16
|
+
name: string;
|
|
17
|
+
line: number;
|
|
18
|
+
isPublic: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type ContextDocFindingDetails = {
|
|
22
|
+
symbol: string;
|
|
23
|
+
ruleId: string;
|
|
24
|
+
message: string;
|
|
25
|
+
remediation: string;
|
|
26
|
+
metadata: Record<string, string>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
type ContextDocFindingInput = ContextDocFindingDetails & {
|
|
30
|
+
file: SourceFile;
|
|
31
|
+
comment: CommentRecord;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
// Materialises one finding per missing context class. Each callable can theoretically produce
|
|
35
|
+
// four (complex, side-effect, error-behavior, invariant) - the four classes are independent signals.
|
|
36
|
+
// Reports each detected gap as a stable doc-context finding.
|
|
37
|
+
export function pushFunctionContextFindings(file: SourceFile, block: FunctionBlock, comment: CommentRecord, config: Config, findings: Finding[]): void {
|
|
38
|
+
for (const detail of functionContextDocFindings(block, comment.text, config)) {
|
|
39
|
+
findings.push(contextDocFinding({ file, comment, ...detail }));
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/*
|
|
44
|
+
* Interface-only context-doc rule. The caller is responsible for skipping signature-restatement
|
|
45
|
+
* comments so the useless-docblock rule's stable finding doesn't get duplicated by this one.
|
|
46
|
+
* Reports `docs.missing-invariant-doc` for interfaces that carry public-contract signals.
|
|
47
|
+
*/
|
|
48
|
+
export function pushDeclarationContextFindings(file: SourceFile, lines: string[], declaration: CommentedDeclaration, comment: CommentRecord, findings: Finding[]): void {
|
|
49
|
+
if (declaration.kind !== "interface") {
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (!hasInvariantInterfaceSignal(lines, declaration) || hasInvariantMarker(comment.text)) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
findings.push(
|
|
56
|
+
contextDocFinding({
|
|
57
|
+
file,
|
|
58
|
+
comment,
|
|
59
|
+
...contextDocDetails(declaration.name, "docs.missing-invariant-doc", `Interface \`${declaration.name}\` defines a public contract that its comment does not describe.`, "Document the schema, fingerprint, baseline, report, or determinism invariant.", "invariant"),
|
|
60
|
+
}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Evaluates four context classes independently - collected into a list rather than emitted directly
|
|
65
|
+
// so the caller can apply a single stable mapping over them.
|
|
66
|
+
function functionContextDocFindings(block: FunctionBlock, commentText: string, config: Config): ContextDocFindingDetails[] {
|
|
67
|
+
const details: ContextDocFindingDetails[] = [];
|
|
68
|
+
const body = block.codeBody;
|
|
69
|
+
const complex = complexFunctionContextDocFinding(block, commentText, config);
|
|
70
|
+
const sideEffect = sideEffectContextDocFinding(block, body, commentText);
|
|
71
|
+
const errorBehavior = errorBehaviorContextDocFinding(block, body, commentText);
|
|
72
|
+
const invariant = invariantContextDocFinding(block, commentText);
|
|
73
|
+
if (complex) {
|
|
74
|
+
details.push(complex);
|
|
75
|
+
}
|
|
76
|
+
if (sideEffect) {
|
|
77
|
+
details.push(sideEffect);
|
|
78
|
+
}
|
|
79
|
+
if (errorBehavior) {
|
|
80
|
+
details.push(errorBehavior);
|
|
81
|
+
}
|
|
82
|
+
if (invariant) {
|
|
83
|
+
details.push(invariant);
|
|
84
|
+
}
|
|
85
|
+
return details;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Requires "why" context only after callable complexity crosses the configured threshold.
|
|
89
|
+
function complexFunctionContextDocFinding(block: FunctionBlock, commentText: string, config: Config): ContextDocFindingDetails | undefined {
|
|
90
|
+
if (!isComplexContextCandidate(block, config) || hasComplexWhyMarker(commentText)) {
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
return contextDocDetails(block.name, "docs.missing-why-for-complex-code", `Complex function \`${block.name}\` has a comment, but it does not explain why the control flow exists.`, "Explain the tradeoff, compatibility reason, or invariant behind the complex control flow.", "complex-code");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Documents externally observable mutations when the comment does not mention them.
|
|
97
|
+
function sideEffectContextDocFinding(block: FunctionBlock, body: string, commentText: string): ContextDocFindingDetails | undefined {
|
|
98
|
+
if (!hasSideEffectSignal(block.name, body) || hasSideEffectMarker(commentText)) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
return contextDocDetails(block.name, "docs.missing-side-effect-doc", `Function \`${block.name}\` performs side effects that its comment does not describe.`, "Name the observable side effect such as filesystem, process, environment, or network mutation.", "side-effect");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Keeps thrown, diagnostic, and recovery behavior visible in maintainer comments.
|
|
105
|
+
function errorBehaviorContextDocFinding(block: FunctionBlock, body: string, commentText: string): ContextDocFindingDetails | undefined {
|
|
106
|
+
if (!hasErrorBehaviorSignal(body) || hasErrorBehaviorMarker(commentText)) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return contextDocDetails(block.name, "docs.missing-error-behavior-doc", `Function \`${block.name}\` has error behavior that its comment does not describe.`, "Document thrown errors, diagnostics, exits, reports, or recovery behavior.", "error-behavior");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Protects schema and fingerprint invariants from becoming implicit tribal knowledge.
|
|
113
|
+
function invariantContextDocFinding(block: FunctionBlock, commentText: string): ContextDocFindingDetails | undefined {
|
|
114
|
+
if (!hasInvariantFunctionSignal(block) || hasInvariantMarker(commentText)) {
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
return contextDocDetails(block.name, "docs.missing-invariant-doc", `Function \`${block.name}\` maintains a public contract that its comment does not describe.`, "Document the schema, fingerprint, baseline, sorting, or determinism invariant.", "invariant");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Bundles the per-context-class metadata so the four detector helpers share one stable shape.
|
|
121
|
+
// `contextClass` is the discriminator surfaced in finding metadata.
|
|
122
|
+
function contextDocDetails(symbol: string, ruleId: string, message: string, remediation: string, contextClass: string): ContextDocFindingDetails {
|
|
123
|
+
return {
|
|
124
|
+
symbol,
|
|
125
|
+
ruleId,
|
|
126
|
+
message,
|
|
127
|
+
remediation,
|
|
128
|
+
metadata: { contextClass },
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Anchors every context-doc finding at the comment line (not the declaration line) so the
|
|
133
|
+
// reviewer's eye lands on the documentation that needs editing. Reports a stable finding.
|
|
134
|
+
function contextDocFinding(input: ContextDocFindingInput): Finding {
|
|
135
|
+
const { file, comment, symbol, ruleId, message, remediation, metadata } = input;
|
|
136
|
+
return makeFinding({
|
|
137
|
+
ruleId,
|
|
138
|
+
message,
|
|
139
|
+
filePath: file.displayPath,
|
|
140
|
+
line: comment.line,
|
|
141
|
+
severity: "advisory",
|
|
142
|
+
pillar: "documentation",
|
|
143
|
+
confidence: "medium",
|
|
144
|
+
symbol,
|
|
145
|
+
remediation,
|
|
146
|
+
metadata,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Composite gate: any one of size, cyclomatic, cognitive, NPath, or nesting depth crossing the
|
|
151
|
+
// configured stable threshold qualifies a callable as "complex enough to need WHY context".
|
|
152
|
+
function isComplexContextCandidate(block: FunctionBlock, config: Config): boolean {
|
|
153
|
+
const cyclomatic = countMatches(block.codeBody, /\b(if|else if|switch|case|for|while|catch)\b|\?|&&|\|\|/g) + 1;
|
|
154
|
+
const cognitive = cyclomatic + maxNestingDepth(block.codeBody);
|
|
155
|
+
const npath = approximateNpath(functionBodyContent(block.codeBody));
|
|
156
|
+
return (
|
|
157
|
+
block.lineCount > threshold(config, "size.function-length", 200) ||
|
|
158
|
+
cyclomatic > threshold(config, "complexity.cyclomatic", 15) ||
|
|
159
|
+
cognitive > threshold(config, "complexity.cognitive", 15) ||
|
|
160
|
+
npath.value > threshold(config, "complexity.npath", 200) ||
|
|
161
|
+
maxNestingDepth(block.codeBody) > 3
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Vocabulary list signalling "the comment explains why" - the missing-why rule passes when any
|
|
166
|
+
// listed word appears. Adding entries here loosens the rule; removing them tightens it.
|
|
167
|
+
function hasComplexWhyMarker(text: string): boolean {
|
|
168
|
+
return /\b(?:because|why|intentional|tradeoff|compat|avoid|preserve)\b/i.test(text);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Vocabulary for "comment names a side effect". Pairs with `SIDE_EFFECT_BODY_PATTERNS` - if the
|
|
172
|
+
// body matches and none of these words appear, the missing-side-effect rule fires.
|
|
173
|
+
function hasSideEffectMarker(text: string): boolean {
|
|
174
|
+
return /\b(?:writes|reads|persists|mutates|starts|spawns|network|filesystem|environment)\b/i.test(text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Vocabulary for "comment names error behaviour". Matches throws/reports/exits/swallows/fallback/
|
|
178
|
+
// recover and the multi-word "returns diagnostic". Pairs with `hasErrorBehaviorSignal`.
|
|
179
|
+
function hasErrorBehaviorMarker(text: string): boolean {
|
|
180
|
+
return /\b(?:throws|returns diagnostic|reports|exits|swallows|fallback|recover)\b/i.test(text);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Vocabulary for "comment names a public contract". Seven canonical words; the rule fires when
|
|
184
|
+
// the callable carries invariant signals (Finding/baseline/fingerprint references) but none of these.
|
|
185
|
+
function hasInvariantMarker(text: string): boolean {
|
|
186
|
+
return /\b(?:invariant|contract|must|stable|deterministic|schema|fingerprint)\b/i.test(text);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const SIDE_EFFECT_BODY_PATTERNS = [
|
|
190
|
+
/\b(?:writeFile(?:Sync)?|appendFile(?:Sync)?|mkdir(?:Sync)?|rm(?:Sync)?|rename(?:Sync)?|createWriteStream)\s*\(/,
|
|
191
|
+
/\bprocess\.chdir\s*\(/,
|
|
192
|
+
/\bprocess\.env\.[A-Za-z0-9_]+\s*=/,
|
|
193
|
+
/\b(?:exec|execFile|spawn)(?:Sync)?\s*\(/,
|
|
194
|
+
/\b(?:response|res)\.(?:write|end|setHeader|writeHead)\s*\(/,
|
|
195
|
+
/\bcreateServer\s*\(|\.listen\s*\(/,
|
|
196
|
+
] as const;
|
|
197
|
+
|
|
198
|
+
// Two pathways: a body pattern match (writeFile, exec, listen, response.write, …) or a name
|
|
199
|
+
// pattern (functions starting with write/recordHistory/startDashboard). Either is sufficient
|
|
200
|
+
// evidence that the callable has externally observable effects.
|
|
201
|
+
function hasSideEffectSignal(name: string, body: string): boolean {
|
|
202
|
+
return SIDE_EFFECT_BODY_PATTERNS.some((pattern) => pattern.test(body)) || /^(?:write|recordHistory|startDashboard)\b/.test(name);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Detects throw, catch, process.exit, diagnostic emission, or finding/diagnostic push patterns -
|
|
206
|
+
// the five places error behaviour can hide inside a callable body.
|
|
207
|
+
function hasErrorBehaviorSignal(body: string): boolean {
|
|
208
|
+
return /\bthrow\b|\bcatch\b|\bprocess\.exit\s*\(|\bdiagnosticType\s*:|\b(?:findings|diagnostics)\.push\s*\(/.test(body);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Searches both the callable name and body for vocabulary tied to the analyser's stable contracts
|
|
212
|
+
// (fingerprint, schemaVersion, baseline, AnalysisReport, Finding, dedupe, sort). Any match
|
|
213
|
+
// triggers the invariant-doc rule's expectation that the comment will name an invariant.
|
|
214
|
+
function hasInvariantFunctionSignal(block: FunctionBlock): boolean {
|
|
215
|
+
const signalText = [block.name, block.codeBody].join("\n");
|
|
216
|
+
return /\b(?:fingerprint|schemaVersion|baseline|AnalysisReport|Finding|stable sort|deterministic|dedupe|sort)\b/i.test(signalText);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Same idea as `hasInvariantFunctionSignal` but reads the interface body via `declarationBlockText`.
|
|
220
|
+
// The contract vocabulary is slightly wider (`Baseline`, `report`) because interfaces often shape
|
|
221
|
+
// public report types.
|
|
222
|
+
function hasInvariantInterfaceSignal(lines: string[], declaration: CommentedDeclaration): boolean {
|
|
223
|
+
const blockText = declarationBlockText(lines, declaration.line);
|
|
224
|
+
const signalText = `${declaration.name}\n${blockText}`;
|
|
225
|
+
return /\b(?:fingerprint|schemaVersion|baseline|report|Finding|AnalysisReport|Baseline|stable|deterministic)\b/i.test(signalText);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Collects lines from the declaration until the first `}` at column 0 - used to feed the interface
|
|
229
|
+
// body to vocabulary detectors. A more precise parser would help but is unnecessary for word matches.
|
|
230
|
+
function declarationBlockText(lines: string[], line: number): string {
|
|
231
|
+
const start = Math.max(0, line - 1);
|
|
232
|
+
const collected: string[] = [];
|
|
233
|
+
for (let index = start; index < lines.length; index += 1) {
|
|
234
|
+
const current = lines[index] ?? "";
|
|
235
|
+
collected.push(current);
|
|
236
|
+
if (current.trim() === "}") {
|
|
237
|
+
break;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return collected.join("\n");
|
|
241
|
+
}
|
package/src/dashboard.ts
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Loopback dashboard HTTP surface that renders live analyzer reports without exposing remote scans.
|
|
2
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
3
|
+
import { chdir, cwd, stdout } from "node:process";
|
|
4
|
+
import { dashboardErrorHtml, dashboardHomeHtml, renderHtml } from "./report-renderers.ts";
|
|
5
|
+
import type { AnalysisOptions, AnalysisReport } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
type DashboardAnalyse = (options: AnalysisOptions) => AnalysisReport;
|
|
8
|
+
|
|
9
|
+
// Host/port/projectRoot frozen at server start. The dashboard binds to a loopback host only -
|
|
10
|
+
// `startDashboard` callers must not relax this without auditing for unauthenticated remote scans.
|
|
11
|
+
interface DashboardContext {
|
|
12
|
+
host: string;
|
|
13
|
+
port: number;
|
|
14
|
+
projectRoot: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Per-request projectRoot + scanPath. Sourced from `?projectRoot` and `?path` query parameters and
|
|
18
|
+
// fed straight to `chdir`/`analyse`, so untrusted values would let a caller pivot the analyser to
|
|
19
|
+
// arbitrary directories - only acceptable because the server is loopback-only.
|
|
20
|
+
interface DashboardRouteInput {
|
|
21
|
+
root: string;
|
|
22
|
+
scanPath: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Starts a loopback HTTP server. `analyse` is injected (not imported) to avoid a circular import
|
|
26
|
+
// back into `cli.ts`; see `.goat-flow/lessons/verification.md` on the dashboard import cycle.
|
|
27
|
+
// Side effect: opens a listening socket and writes the URL to stdout unless `shouldWriteOutput` is false.
|
|
28
|
+
function startDashboard(host: string, port: number, projectRoot: string, analyse: DashboardAnalyse, shouldWriteOutput = true): void {
|
|
29
|
+
assertLoopbackHost(host);
|
|
30
|
+
const context: DashboardContext = { host, port, projectRoot };
|
|
31
|
+
const server = createServer((request, response) => handleDashboardRequest(context, analyse, request, response));
|
|
32
|
+
server.listen(port, host, () => {
|
|
33
|
+
if (shouldWriteOutput) {
|
|
34
|
+
stdout.write(`gruff-ts dashboard listening at http://${host}:${port}\n`);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The dashboard accepts filesystem paths from query strings, so loopback binding is its safety
|
|
40
|
+
// boundary. Throws before opening the listener when a caller asks for a public host.
|
|
41
|
+
function assertLoopbackHost(host: string): void {
|
|
42
|
+
if (host !== "127.0.0.1" && host !== "localhost") {
|
|
43
|
+
throw new Error("Dashboard host must be 127.0.0.1 or localhost.");
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Four endpoints: `/health` (uptime probes), `/scan` (runs the analyser and renders HTML),
|
|
48
|
+
// `/` (control page), anything else → 404. `/health` is the only response that survives proxies
|
|
49
|
+
// uncached - the scan and home responses set `no-store` so the dashboard always sees fresh output.
|
|
50
|
+
function handleDashboardRequest(context: DashboardContext, analyse: DashboardAnalyse, request: IncomingMessage, response: ServerResponse): void {
|
|
51
|
+
const url = new URL(request.url ?? "/", `http://${context.host}:${context.port}`);
|
|
52
|
+
if (url.pathname === "/health") {
|
|
53
|
+
writeTextResponse(response, 200, "ok", true);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (url.pathname === "/scan") {
|
|
57
|
+
renderDashboardScan(response, dashboardRouteInput(url, context.projectRoot), analyse);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (url.pathname !== "/") {
|
|
61
|
+
writeTextResponse(response, 404, "not found", false);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const input = dashboardRouteInput(url, context.projectRoot);
|
|
65
|
+
writeHtmlResponse(response, 200, dashboardHomeHtml(input.root, input.scanPath));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Reads `?projectRoot` and `?path`, falling back to the server's launch context. Both values flow
|
|
69
|
+
// straight into `chdir` and `analyse`; see DashboardRouteInput for the loopback trust assumption.
|
|
70
|
+
function dashboardRouteInput(url: URL, projectRoot: string): DashboardRouteInput {
|
|
71
|
+
return {
|
|
72
|
+
root: url.searchParams.get("projectRoot") ?? projectRoot,
|
|
73
|
+
scanPath: url.searchParams.get("path") ?? ".",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// chdirs into the caller-requested project root, runs `analyse`, and always restores the previous
|
|
78
|
+
// cwd in `finally` - leaking the chdir would corrupt subsequent requests. The loopback server must
|
|
79
|
+
// keep serving on analyser failure, so the catch reports the error as a rendered fallback page.
|
|
80
|
+
function renderDashboardScan(response: ServerResponse, input: DashboardRouteInput, analyse: DashboardAnalyse): void {
|
|
81
|
+
const previous = cwd();
|
|
82
|
+
try {
|
|
83
|
+
chdir(input.root);
|
|
84
|
+
const report = analyse({
|
|
85
|
+
paths: [input.scanPath],
|
|
86
|
+
shouldSkipConfig: false,
|
|
87
|
+
format: "html",
|
|
88
|
+
failOn: "none",
|
|
89
|
+
shouldIncludeIgnored: false,
|
|
90
|
+
shouldSkipBaseline: false,
|
|
91
|
+
});
|
|
92
|
+
writeHtmlResponse(response, 200, renderHtml(report, { projectRoot: input.root, scanPath: input.scanPath }));
|
|
93
|
+
} catch (error) {
|
|
94
|
+
writeHtmlResponse(response, 500, dashboardErrorHtml(String(error), input.root, input.scanPath));
|
|
95
|
+
} finally {
|
|
96
|
+
chdir(previous);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// `no-store` is non-negotiable for dashboard responses: stale findings cached by a proxy would
|
|
101
|
+
// mislead a maintainer reading the report. Writes the HTTP response body and closes the connection.
|
|
102
|
+
function writeHtmlResponse(response: ServerResponse, statusCode: number, body: string): void {
|
|
103
|
+
response.writeHead(statusCode, { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" });
|
|
104
|
+
response.end(body);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// `/health` opts in to `no-store` so uptime probes never read a cached "ok"; 404 responses do not,
|
|
108
|
+
// because their content is constant and proxies can safely keep them. Writes the response and closes it.
|
|
109
|
+
function writeTextResponse(response: ServerResponse, statusCode: number, body: string, shouldUseNoStore: boolean): void {
|
|
110
|
+
response.writeHead(statusCode, { "content-type": "text/plain; charset=utf-8", ...(shouldUseNoStore ? { "cache-control": "no-store" } : {}) });
|
|
111
|
+
response.end(body);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export { startDashboard };
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// Dead-code rules: unused private methods, unreachable statements after terminators, unused
|
|
2
|
+
// named imports. Invoked from the analyseTypeScriptRules orchestrator alongside the line-rules
|
|
3
|
+
// pass; they share the same `codeSource` mask and emit findings on a stable per-file order.
|
|
4
|
+
import { type SourceFile } from "./discovery.ts";
|
|
5
|
+
import { makeFinding } from "./findings.ts";
|
|
6
|
+
import { escapeRegex, finding } from "./findings-helpers.ts";
|
|
7
|
+
import { byteLine, countMatches } from "./text-scans.ts";
|
|
8
|
+
import type { Finding } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
// Deliberately single-file and low confidence because private methods can still be reached by
|
|
11
|
+
// tests, decorators, framework hooks, or string-based reflection; reports stable advisory findings because removal still needs human confirmation.
|
|
12
|
+
export function analyseDeadCode(file: SourceFile, source: string, findings: Finding[]): void {
|
|
13
|
+
for (const match of source.matchAll(/\bprivate\s+([A-Za-z_$][A-Za-z0-9_$]*)\s*\(/g)) {
|
|
14
|
+
const name = match[1] ?? "";
|
|
15
|
+
const escaped = escapeRegex(name);
|
|
16
|
+
if (countMatches(source, new RegExp(`${escaped}\\s*\\(`, "g")) <= 1) {
|
|
17
|
+
findings.push(
|
|
18
|
+
makeFinding({
|
|
19
|
+
ruleId: "dead-code.unused-private-method",
|
|
20
|
+
message: `Private method \`${name}\` appears to be unused in this file.`,
|
|
21
|
+
filePath: file.displayPath,
|
|
22
|
+
line: byteLine(source, match.index ?? 0),
|
|
23
|
+
severity: "advisory",
|
|
24
|
+
pillar: "dead-code",
|
|
25
|
+
confidence: "low",
|
|
26
|
+
symbol: name,
|
|
27
|
+
remediation: "Remove the method or add a real call site.",
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Line-by-line walker that resets the terminator flag at every `case:` / `default:` so dead-code
|
|
35
|
+
// detection respects control-flow boundaries. Each line is walked once in source order, then
|
|
36
|
+
// reports `waste.unreachable-code` with stable, deterministic fingerprint anchors.
|
|
37
|
+
export function analyseUnreachable(file: SourceFile, source: string, findings: Finding[]): void {
|
|
38
|
+
let didPreviousTerminate = false;
|
|
39
|
+
let isInConditionalBranch = false;
|
|
40
|
+
source.split(/\r?\n/).forEach((line, index) => {
|
|
41
|
+
const trimmed = line.trim();
|
|
42
|
+
const branchLabel = isBranchLabel(trimmed);
|
|
43
|
+
if (branchLabel) {
|
|
44
|
+
didPreviousTerminate = false;
|
|
45
|
+
}
|
|
46
|
+
if (isUnreachableStatement(trimmed, didPreviousTerminate, branchLabel)) {
|
|
47
|
+
findings.push(finding({ ruleId: "waste.unreachable-code", message: "Statement appears after a terminating statement.", file, line: index + 1, severity: "warning", pillar: "waste" }));
|
|
48
|
+
}
|
|
49
|
+
// A terminating statement inside a braceless conditional body does not unconditionally exit:
|
|
50
|
+
// `if (x)\n return y;\nnextLine` - `nextLine` runs when `x` is falsy. Tracking the prior line's
|
|
51
|
+
// conditional-opener shape suppresses the false positive on compact guard clauses.
|
|
52
|
+
didPreviousTerminate = isTerminatingStatement(trimmed) && !isInConditionalBranch;
|
|
53
|
+
isInConditionalBranch = isBracelessConditionalOpener(trimmed);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Detects single-line conditional/loop openers that begin a one-statement body (no `{`). The next
|
|
58
|
+
// line is the conditional body, so any terminator there is conditional rather than unconditional.
|
|
59
|
+
function isBracelessConditionalOpener(trimmed: string): boolean {
|
|
60
|
+
if (trimmed.endsWith("{") || trimmed.endsWith("}")) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
return /^(?:if|else\s+if|for|while)\s*\(/.test(trimmed) || /^else\b/.test(trimmed) || /^do\b/.test(trimmed);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// `case X:` / `default:` open a new control path, so the unreachable walker must reset its
|
|
67
|
+
// terminator flag here - otherwise the first statement in a fallthrough case looks dead.
|
|
68
|
+
function isBranchLabel(trimmedLine: string): boolean {
|
|
69
|
+
return /^(?:case\b.*:|default\s*:)$/.test(trimmedLine);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Three conditions must hold to flag a line: the prior statement terminated, this line has real
|
|
73
|
+
// content, and it's not a `}` closer or a branch label. The `}` exclusion matters because the
|
|
74
|
+
// closing brace of the terminating block looks like a statement to a naive walker.
|
|
75
|
+
function isUnreachableStatement(trimmedLine: string, didPreviousTerminate: boolean, isBranchLabel: boolean): boolean {
|
|
76
|
+
return didPreviousTerminate && /\S/.test(trimmedLine) && !trimmedLine.startsWith(String.fromCharCode(125)) && !isBranchLabel;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `return`, `throw`, and `process.exit(...)` exit the current control path. The trailing `;`
|
|
80
|
+
// requirement filters out expressions like `return foo()` split across lines - without it the
|
|
81
|
+
// walker would falsely flag the continuation as unreachable.
|
|
82
|
+
function isTerminatingStatement(trimmedLine: string): boolean {
|
|
83
|
+
return /^(?:return|throw|process\.exit)\b/.test(trimmedLine) && trimmedLine.endsWith(";");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* Reports `waste.unused-import` for every named specifier whose local name appears nowhere else
|
|
88
|
+
* in the file. Default imports and namespace imports are out of scope because the regex anchors
|
|
89
|
+
* on `{ … }`; walking lines in source order keeps the reports stable and deterministic. Receives
|
|
90
|
+
* both the masked code and the raw source so identifiers referenced inside template-literal
|
|
91
|
+
* `${...}` interpolations (which the mask would otherwise blank out) still count as used. Never
|
|
92
|
+
* throws - every regex is anchored and the input shape is validated upstream by the analyser; the
|
|
93
|
+
* helper writes to `findings` and returns void. Part of the public per-file rule contract that
|
|
94
|
+
* baselines depend on, so finding ordering and message shape are intentionally stable across releases.
|
|
95
|
+
*/
|
|
96
|
+
export function analyseUnusedImports(file: SourceFile, source: string, rawSource: string, findings: Finding[]): void {
|
|
97
|
+
for (const statement of namedImportStatements(source)) {
|
|
98
|
+
for (const specifier of namedImportSpecifiers(statement.source)) {
|
|
99
|
+
const name = unusedImportName(source, rawSource, specifier);
|
|
100
|
+
if (!name) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
findings.push(unusedImportFinding(file, name, statement.line));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// One complete named-import declaration, including multiline `{ ... }` bodies, plus the anchor
|
|
109
|
+
// line where the `import` keyword began. Keeping the original source preserves alias parsing.
|
|
110
|
+
interface NamedImportStatement {
|
|
111
|
+
source: string;
|
|
112
|
+
line: number;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Finds only named import declarations and spans across newlines until the matching `from` source.
|
|
116
|
+
// The non-greedy body keeps adjacent imports from merging into one statement.
|
|
117
|
+
function namedImportStatements(source: string): NamedImportStatement[] {
|
|
118
|
+
return [...source.matchAll(/\bimport\s+(?:type\s+)?\{[\s\S]*?\}\s+from\s*["'][^"']+["']/g)].map((match) => ({ source: match[0] ?? "", line: byteLine(source, match.index ?? 0) }));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Slices the `{ a, b as c }` body out of a named-import statement and splits on commas. No AST: a
|
|
122
|
+
// regex-light approach is sufficient because `analyseUnusedImports` runs after the comment mask,
|
|
123
|
+
// so commas inside string defaults never reach this function.
|
|
124
|
+
function namedImportSpecifiers(source: string): string[] {
|
|
125
|
+
const trimmed = source.trim();
|
|
126
|
+
const openBrace = trimmed.indexOf(String.fromCharCode(123));
|
|
127
|
+
const closeBrace = trimmed.indexOf(String.fromCharCode(125), openBrace + 1);
|
|
128
|
+
if (!hasNamedImportBraces(openBrace, closeBrace)) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return trimmed.slice(openBrace + 1, closeBrace).split(",");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Both braces present and well-ordered. Indexes come from raw `indexOf` calls, so this guards
|
|
135
|
+
// against the malformed slice that would otherwise feed an empty or reversed specifier list.
|
|
136
|
+
function hasNamedImportBraces(openBrace: number, closeBrace: number): boolean {
|
|
137
|
+
return openBrace !== -1 && closeBrace !== -1 && closeBrace > openBrace;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The local binding (after `as`, if present) must appear in the source exactly once - the
|
|
141
|
+
// declaration itself. More than one match in the masked code, OR a `${...name...}` template-literal
|
|
142
|
+
// interpolation in the raw source, counts as a real reference. Returning undefined suppresses the finding.
|
|
143
|
+
function unusedImportName(source: string, rawSource: string, specifier: string): string | undefined {
|
|
144
|
+
const name = localImportName(specifier);
|
|
145
|
+
if (!name) {
|
|
146
|
+
return undefined;
|
|
147
|
+
}
|
|
148
|
+
const escaped = escapeRegex(name);
|
|
149
|
+
if (countMatches(source, new RegExp(`\\b${escaped}\\b`, "g")) > 1) {
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
if (new RegExp(`\\$\\{[^}]*\\b${escaped}\\b[^}]*\\}`).test(rawSource)) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return name;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Single makeFinding factory for `waste.unused-import`. The local binding name lands in both the
|
|
159
|
+
// message and `metadata.importName` so downstream tooling can group by symbol while the stable
|
|
160
|
+
// fingerprint identity remains (ruleId, filePath, line).
|
|
161
|
+
function unusedImportFinding(file: SourceFile, name: string, line: number): Finding {
|
|
162
|
+
return makeFinding({
|
|
163
|
+
ruleId: "waste.unused-import",
|
|
164
|
+
message: `Imported symbol \`${name}\` does not appear to be used.`,
|
|
165
|
+
filePath: file.displayPath,
|
|
166
|
+
line,
|
|
167
|
+
severity: "advisory",
|
|
168
|
+
pillar: "waste",
|
|
169
|
+
confidence: "medium",
|
|
170
|
+
symbol: name,
|
|
171
|
+
remediation: "Remove the unused import.",
|
|
172
|
+
metadata: { importName: name },
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Returns the right-hand side of `as` when present, otherwise the specifier itself. The trailing
|
|
177
|
+
// identifier regex protects against type-only specifiers that include extra tokens.
|
|
178
|
+
function localImportName(specifier: string): string | undefined {
|
|
179
|
+
const parts = specifier.trim().split(/\s+as\s+/);
|
|
180
|
+
const candidate = parts[1] ?? parts[0] ?? "";
|
|
181
|
+
const match = candidate.trim().match(/^[A-Za-z_$][A-Za-z0-9_$]*/);
|
|
182
|
+
return match?.[0];
|
|
183
|
+
}
|