@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,691 @@
|
|
|
1
|
+
// Text, HTML, SARIF, hotspot, and dashboard renderers for the stable analysis report.
|
|
2
|
+
import type { AnalysisReport, Finding, OutputFormat, Severity } from "./types.ts";
|
|
3
|
+
import { ruleDescriptors } from "./rules.ts";
|
|
4
|
+
|
|
5
|
+
const CYCLOMATIC_BUCKETS = [
|
|
6
|
+
{ label: "21+", minimum: 21 },
|
|
7
|
+
{ label: "16-20", minimum: 16 },
|
|
8
|
+
{ label: "11-15", minimum: 11 },
|
|
9
|
+
{ label: "6-10", minimum: 6 },
|
|
10
|
+
{ label: "1-5", minimum: 1 },
|
|
11
|
+
] as const;
|
|
12
|
+
|
|
13
|
+
/*
|
|
14
|
+
* Format dispatcher. `hotspot` is emitted inline (smallest schema) while every other format has a
|
|
15
|
+
* dedicated renderer. The stable schema string `gruff.hotspot.v1` is part of the public contract;
|
|
16
|
+
* bump it only when changing the hotspot payload shape.
|
|
17
|
+
*/
|
|
18
|
+
function renderReport(report: AnalysisReport, format: OutputFormat): string {
|
|
19
|
+
switch (format) {
|
|
20
|
+
case "json":
|
|
21
|
+
return JSON.stringify(report, null, 2);
|
|
22
|
+
case "html":
|
|
23
|
+
return renderHtml(report);
|
|
24
|
+
case "markdown":
|
|
25
|
+
return renderMarkdown(report);
|
|
26
|
+
case "github":
|
|
27
|
+
return renderGithub(report);
|
|
28
|
+
case "hotspot":
|
|
29
|
+
return JSON.stringify({ schemaVersion: "gruff.hotspot.v1", tool: report.tool, score: report.score.composite, files: report.score.topOffenders }, null, 2);
|
|
30
|
+
case "sarif":
|
|
31
|
+
return renderSarif(report);
|
|
32
|
+
case "text":
|
|
33
|
+
return renderText(report);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/*
|
|
38
|
+
* SARIF 2.1.0 output for GitHub code-scanning uploads. Kept as one large object literal because
|
|
39
|
+
* the SARIF schema demands a specific shape - splitting it up obscures which fields are required.
|
|
40
|
+
* `partialFingerprints.gruffFingerprint` is the cross-tool stable identifier; GitHub uses it to
|
|
41
|
+
* dedupe alerts across re-uploads, so it must match the Finding fingerprint exactly.
|
|
42
|
+
*/
|
|
43
|
+
function renderSarif(report: AnalysisReport): string {
|
|
44
|
+
const rules = ruleDescriptors().map((descriptor) => ({
|
|
45
|
+
id: descriptor.ruleId,
|
|
46
|
+
name: descriptor.ruleId,
|
|
47
|
+
shortDescription: { text: descriptor.description },
|
|
48
|
+
fullDescription: { text: descriptor.description },
|
|
49
|
+
help: { text: descriptor.remediation },
|
|
50
|
+
properties: {
|
|
51
|
+
pillar: descriptor.pillar,
|
|
52
|
+
tier: "v0.1",
|
|
53
|
+
defaultSeverity: descriptor.severity,
|
|
54
|
+
confidence: descriptor.confidence,
|
|
55
|
+
defaultEnabled: true,
|
|
56
|
+
...(typeof descriptor.threshold === "number" ? { threshold: descriptor.threshold } : {}),
|
|
57
|
+
...(descriptor.optionKeys ? { optionKeys: descriptor.optionKeys } : {}),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
const ruleIndices = new Map(rules.map((rule, index) => [rule.id, index]));
|
|
61
|
+
const sarif = {
|
|
62
|
+
$schema: "https://json.schemastore.org/sarif-2.1.0.json",
|
|
63
|
+
version: "2.1.0",
|
|
64
|
+
runs: [
|
|
65
|
+
{
|
|
66
|
+
tool: {
|
|
67
|
+
driver: {
|
|
68
|
+
name: report.tool.name,
|
|
69
|
+
semanticVersion: report.tool.version,
|
|
70
|
+
rules,
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
results: report.findings.map((finding) => sarifResult(finding, ruleIndices)),
|
|
74
|
+
properties: {
|
|
75
|
+
gruffSchemaVersion: report.schemaVersion,
|
|
76
|
+
generatedAt: report.run.generatedAt,
|
|
77
|
+
score: report.score.composite,
|
|
78
|
+
grade: report.score.grade,
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
return `${JSON.stringify(sarif, null, 2)}\n`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/*
|
|
87
|
+
* Maps one Finding into a SARIF result row. The stable, deterministic fingerprint in
|
|
88
|
+
* `partialFingerprints` is the public contract - GitHub code-scanning keys alerts off it.
|
|
89
|
+
*/
|
|
90
|
+
function sarifResult(finding: Finding, ruleIndices: Map<string, number>): Record<string, unknown> {
|
|
91
|
+
const result: Record<string, unknown> = {
|
|
92
|
+
ruleId: finding.ruleId,
|
|
93
|
+
level: sarifLevel(finding.severity),
|
|
94
|
+
message: { text: finding.message },
|
|
95
|
+
locations: [
|
|
96
|
+
{
|
|
97
|
+
physicalLocation: sarifPhysicalLocation(finding),
|
|
98
|
+
},
|
|
99
|
+
],
|
|
100
|
+
partialFingerprints: {
|
|
101
|
+
gruffFingerprint: finding.fingerprint,
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
const ruleIndex = ruleIndices.get(finding.ruleId);
|
|
105
|
+
if (ruleIndex !== undefined) {
|
|
106
|
+
result.ruleIndex = ruleIndex;
|
|
107
|
+
}
|
|
108
|
+
const properties: Record<string, unknown> = {
|
|
109
|
+
severity: finding.severity,
|
|
110
|
+
pillar: finding.pillar,
|
|
111
|
+
tier: finding.tier,
|
|
112
|
+
confidence: finding.confidence,
|
|
113
|
+
metadata: finding.metadata,
|
|
114
|
+
};
|
|
115
|
+
if (finding.secondaryPillars.length > 0) {
|
|
116
|
+
properties.secondaryPillars = finding.secondaryPillars;
|
|
117
|
+
}
|
|
118
|
+
if (finding.symbol) {
|
|
119
|
+
properties.symbol = finding.symbol;
|
|
120
|
+
}
|
|
121
|
+
if (finding.remediation) {
|
|
122
|
+
properties.remediation = finding.remediation;
|
|
123
|
+
}
|
|
124
|
+
result.properties = properties;
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/*
|
|
129
|
+
* Constructs the stable SARIF `physicalLocation` object. `startLine` and column/endLine are only
|
|
130
|
+
* populated when the Finding carries them - SARIF requires `region` to be omitted (not empty)
|
|
131
|
+
* when there is no line context.
|
|
132
|
+
*/
|
|
133
|
+
function sarifPhysicalLocation(finding: Finding): Record<string, unknown> {
|
|
134
|
+
const location: Record<string, unknown> = {
|
|
135
|
+
artifactLocation: {
|
|
136
|
+
uri: sarifUri(finding.filePath),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
if (finding.line !== undefined) {
|
|
140
|
+
const region: Record<string, unknown> = {
|
|
141
|
+
startLine: finding.line,
|
|
142
|
+
};
|
|
143
|
+
if (finding.column !== undefined) {
|
|
144
|
+
region.startColumn = finding.column;
|
|
145
|
+
}
|
|
146
|
+
if (finding.endLine !== undefined) {
|
|
147
|
+
region.endLine = finding.endLine;
|
|
148
|
+
}
|
|
149
|
+
location.region = region;
|
|
150
|
+
}
|
|
151
|
+
return location;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// SARIF artifact URIs are POSIX-style relative paths. Strips leading `./` (which SARIF consumers
|
|
155
|
+
// treat as absolute or as a different path) and converts Windows-style separators.
|
|
156
|
+
function sarifUri(filePath: string): string {
|
|
157
|
+
return filePath
|
|
158
|
+
.replaceAll("\\", "/")
|
|
159
|
+
.replace(/^(?:\.\/)+/, "")
|
|
160
|
+
.split("/")
|
|
161
|
+
.map(encodeURIComponent)
|
|
162
|
+
.join("/");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// SARIF has three levels; gruff's "advisory" maps to "note" because that's the documented soft-warning level.
|
|
166
|
+
function sarifLevel(severity: Severity): "error" | "warning" | "note" {
|
|
167
|
+
switch (severity) {
|
|
168
|
+
case "error":
|
|
169
|
+
return "error";
|
|
170
|
+
case "warning":
|
|
171
|
+
return "warning";
|
|
172
|
+
case "advisory":
|
|
173
|
+
return "note";
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/*
|
|
178
|
+
* Compact digest for humans in terminals. It intentionally stays outside the JSON schema contract
|
|
179
|
+
* because the CLI should be able to improve wording/layout without a schema bump; callers that need
|
|
180
|
+
* durable machine output should use `analyse --format=json` instead.
|
|
181
|
+
*/
|
|
182
|
+
function renderSummary(report: AnalysisReport, elapsedMs?: number, pathLabel?: string): string {
|
|
183
|
+
const pillarCounts = countBy(report.findings, (finding) => finding.pillar);
|
|
184
|
+
const ruleCounts = countBy(report.findings, (finding) => finding.ruleId);
|
|
185
|
+
const lines = [
|
|
186
|
+
`gruff-ts ${report.tool.version} summary`,
|
|
187
|
+
`Path: ${pathLabel ?? report.run.projectRoot}`,
|
|
188
|
+
...(typeof elapsedMs === "number" ? [`Duration: ${formatSummaryDuration(elapsedMs)}`] : []),
|
|
189
|
+
`Score: ${report.score.composite.toFixed(1)} (${report.score.grade})`,
|
|
190
|
+
`Findings: ${report.summary.total} total, ${report.summary.error} error, ${report.summary.warning} warning, ${report.summary.advisory} advisory`,
|
|
191
|
+
`Analysed files: ${report.paths.analysedFiles}`,
|
|
192
|
+
];
|
|
193
|
+
if (report.diagnostics.length > 0) {
|
|
194
|
+
lines.push("", "Diagnostics:", ...report.diagnostics.map(summaryDiagnosticLine));
|
|
195
|
+
}
|
|
196
|
+
lines.push("", "Per-pillar counts:");
|
|
197
|
+
lines.push(...renderRankedCounts(pillarCounts, "No findings by pillar."));
|
|
198
|
+
lines.push("", "Top rules:");
|
|
199
|
+
lines.push(...renderRankedCounts(ruleCounts, "No rule findings."));
|
|
200
|
+
lines.push("", "Top file offenders:");
|
|
201
|
+
lines.push(
|
|
202
|
+
...(
|
|
203
|
+
report.score.topOffenders.length === 0
|
|
204
|
+
? ["- No file offenders."]
|
|
205
|
+
: report.score.topOffenders.map((offender) => `- ${offender.filePath}: ${offender.findings} findings, score ${offender.score.toFixed(1)}`)
|
|
206
|
+
),
|
|
207
|
+
);
|
|
208
|
+
return `${lines.join("\n")}\n`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Shared text formatter for diagnostic rows in plain-text summaries and the `text` format. Stable
|
|
212
|
+
// "- {type}: {message} (path)" shape is part of the contract that scripts grepping the text output
|
|
213
|
+
// rely on, so the format must stay deterministic across both call sites.
|
|
214
|
+
function summaryDiagnosticLine(diagnostic: AnalysisReport["diagnostics"][number]): string {
|
|
215
|
+
const location = diagnostic.filePath ? ` (${diagnostic.filePath})` : "";
|
|
216
|
+
return `- ${diagnostic.diagnosticType}: ${diagnostic.message}${location}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Human-sized summary runtime without pretending sub-millisecond precision is useful.
|
|
220
|
+
function formatSummaryDuration(elapsedMs: number): string {
|
|
221
|
+
const bounded = Math.max(0, elapsedMs);
|
|
222
|
+
if (bounded < 1000) {
|
|
223
|
+
return `${Math.round(bounded)}ms`;
|
|
224
|
+
}
|
|
225
|
+
return `${(bounded / 1000).toFixed(2)}s`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function countBy<T extends string>(findings: Finding[], keyFor: (finding: Finding) => T): Map<T, number> {
|
|
229
|
+
const counts = new Map<T, number>();
|
|
230
|
+
for (const finding of findings) {
|
|
231
|
+
const key = keyFor(finding);
|
|
232
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
233
|
+
}
|
|
234
|
+
return counts;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function renderRankedCounts<T extends string>(counts: Map<T, number>, emptyText: string): string[] {
|
|
238
|
+
if (counts.size === 0) {
|
|
239
|
+
return [`- ${emptyText}`];
|
|
240
|
+
}
|
|
241
|
+
return [...counts.entries()]
|
|
242
|
+
.sort(([leftKey, leftCount], [rightKey, rightCount]) => rightCount - leftCount || leftKey.localeCompare(rightKey))
|
|
243
|
+
.slice(0, 10)
|
|
244
|
+
.map(([key, count]) => `- ${key}: ${count}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/*
|
|
248
|
+
* Default terminal output. Findings are listed verbatim (no truncation) - the analyser keeps them
|
|
249
|
+
* sorted into the stable order, so piping into `grep` produces deterministic results.
|
|
250
|
+
*/
|
|
251
|
+
function renderText(report: AnalysisReport): string {
|
|
252
|
+
const lines = [
|
|
253
|
+
`gruff-ts ${report.tool.version}`,
|
|
254
|
+
`Score: ${report.score.composite.toFixed(1)} (${report.score.grade}) | Findings: ${report.summary.advisory} advisory, ${report.summary.warning} warning, ${report.summary.error} error`,
|
|
255
|
+
`Analysed files: ${report.paths.analysedFiles}`,
|
|
256
|
+
];
|
|
257
|
+
if (report.diagnostics.length > 0) {
|
|
258
|
+
lines.push("", "Diagnostics:", ...report.diagnostics.map(summaryDiagnosticLine));
|
|
259
|
+
}
|
|
260
|
+
if (report.findings.length > 0) {
|
|
261
|
+
lines.push("", "Findings:", ...report.findings.map((finding) => `- [${finding.severity}] ${finding.filePath}:${finding.line ?? 1} ${finding.ruleId} - ${finding.message}`));
|
|
262
|
+
}
|
|
263
|
+
return `${lines.join("\n")}\n`;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Truncates to 50 findings because Markdown previews (PR comments, READMEs) start mangling longer
|
|
267
|
+
// tables. The stable JSON or HTML renderers stay the canonical full-fidelity output.
|
|
268
|
+
function renderMarkdown(report: AnalysisReport): string {
|
|
269
|
+
return [
|
|
270
|
+
"# gruff-ts report",
|
|
271
|
+
"",
|
|
272
|
+
`Score: **${report.score.composite.toFixed(1)} (${report.score.grade})**`,
|
|
273
|
+
"",
|
|
274
|
+
`Findings: ${report.summary.advisory} advisory, ${report.summary.warning} warning, ${report.summary.error} error.`,
|
|
275
|
+
...report.findings.slice(0, 50).map((finding) => `- \`${finding.ruleId}\` \`${finding.filePath}\`:${finding.line ?? 1} - ${finding.message}`),
|
|
276
|
+
].join("\n");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// GitHub Actions `::workflow command` syntax. Public contract invariant: file/title properties
|
|
280
|
+
// must be normalized and command-escaped before interpolation because commas and colons delimit the property list.
|
|
281
|
+
function renderGithub(report: AnalysisReport): string {
|
|
282
|
+
return report.findings
|
|
283
|
+
.map((finding) => `::${githubLevel(finding.severity)} file=${escapeCommandProperty(githubAnnotationPath(finding.filePath))},line=${finding.line ?? 1},title=${escapeCommandProperty(finding.ruleId)}::${escapeCommand(finding.message)}`)
|
|
284
|
+
.join("\n");
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// GitHub annotation paths are repository-relative POSIX paths. Leading `./` and Windows
|
|
288
|
+
// separators produce duplicate annotations for the same file, so normalize them once here.
|
|
289
|
+
function githubAnnotationPath(filePath: string): string {
|
|
290
|
+
return filePath.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Extra metadata appended to dashboard-served HTML. Static-file consumers never see it because the
|
|
294
|
+
// CLI calls `renderHtml` without this argument; only the dashboard route passes it in.
|
|
295
|
+
interface DashboardRenderContext {
|
|
296
|
+
projectRoot: string;
|
|
297
|
+
scanPath: string;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/*
|
|
301
|
+
* Self-contained HTML output (CSS inlined, no external assets) so reports can be archived or
|
|
302
|
+
* emailed. The schema invariant: every Finding listed in the report must be reachable through
|
|
303
|
+
* deep links - the on-page JS in `dashboardJs` relies on stable `data-path` attributes.
|
|
304
|
+
*/
|
|
305
|
+
function renderHtml(report: AnalysisReport, dashboardContext?: DashboardRenderContext): string {
|
|
306
|
+
const bodySections = [
|
|
307
|
+
htmlMasthead(report),
|
|
308
|
+
htmlDiagnostics(report),
|
|
309
|
+
dashboardContext ? htmlDashboardContext(dashboardContext) : "",
|
|
310
|
+
htmlVerdict(report),
|
|
311
|
+
htmlPillars(report),
|
|
312
|
+
htmlOffenders(report),
|
|
313
|
+
htmlDistribution(report),
|
|
314
|
+
htmlFindings(report),
|
|
315
|
+
htmlFooter(report),
|
|
316
|
+
].join("\n");
|
|
317
|
+
return `<!doctype html>
|
|
318
|
+
<html lang="en-NZ">
|
|
319
|
+
<head>
|
|
320
|
+
<meta charset="utf-8">
|
|
321
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
322
|
+
<title>gruff-ts report - ${escapeHtml(report.score.grade)}</title>
|
|
323
|
+
<style>${htmlReportCss(report.diagnostics.length > 0)}</style>
|
|
324
|
+
</head>
|
|
325
|
+
<body>
|
|
326
|
+
<main class="paper"><span class="corner-tr"></span><span class="corner-bl"></span>
|
|
327
|
+
${bodySections}
|
|
328
|
+
</main>
|
|
329
|
+
</body>
|
|
330
|
+
</html>`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Top banner: path count, format, fail-on, schema version. The schemaVersion text is the
|
|
334
|
+
// contract surface - operators eyeball it to confirm the report shape they expect.
|
|
335
|
+
function htmlMasthead(report: AnalysisReport): string {
|
|
336
|
+
const paths = report.paths.analysedFiles === 0 ? "." : `${report.paths.analysedFiles} analysed ${report.paths.analysedFiles === 1 ? "file" : "files"}`;
|
|
337
|
+
return `<header class="masthead"><div class="brand"><div class="wordmark">gruff</div><div class="tagline">ts/js code quality - inspection report</div></div><div class="meta">${htmlMetaRow("paths", paths)}${htmlMetaRow("format", report.run.format)}${htmlMetaRow("fail", report.run.failOn)}${htmlMetaRow("schema", report.schemaVersion)}<div class="inspection-id">gruff-ts ${escapeHtml(report.tool.version)}</div></div></header>`;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// Single key/value row. Both inputs are escaped - they reach this function as user-influenced
|
|
341
|
+
// strings (paths, format names) and would otherwise enable injection in archived reports.
|
|
342
|
+
function htmlMetaRow(label: string, metaValue: string): string {
|
|
343
|
+
const escapedLabel = escapeHtml(label);
|
|
344
|
+
const escapedValue = escapeHtml(metaValue);
|
|
345
|
+
return `<div><span class="label">${escapedLabel}</span><span class="val">${escapedValue}</span></div>`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Diagnostics are parse/IO failures, not normal findings. HTML renders them before score sections
|
|
349
|
+
// because partial scans must remain visibly incomplete; omitting empty diagnostics is the stable contract.
|
|
350
|
+
function htmlDiagnostics(report: AnalysisReport): string {
|
|
351
|
+
if (report.diagnostics.length === 0) {
|
|
352
|
+
return "";
|
|
353
|
+
}
|
|
354
|
+
const diagnostics = report.diagnostics.map(htmlDiagnosticEntry).join("");
|
|
355
|
+
return `<section class="diagnostics"><h2 class="section-head">diagnostics <span class="aside">run messages</span></h2><div class="diagnostic-list">${diagnostics}</div></section>`;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// HTML markup for one diagnostic entry. The `diagnostic-type`/`diagnostic-message`/`diagnostic-location`
|
|
359
|
+
// span classes are part of the stable HTML report contract - the dashboard CSS and any downstream
|
|
360
|
+
// scraping keys off them, so the structure here must stay invariant across renderer changes.
|
|
361
|
+
function htmlDiagnosticEntry(diagnostic: AnalysisReport["diagnostics"][number]): string {
|
|
362
|
+
const lineSuffix = diagnostic.line ? `:${diagnostic.line}` : "";
|
|
363
|
+
const location = diagnostic.filePath
|
|
364
|
+
? `<span class="diagnostic-location">${escapeHtml(diagnostic.filePath)}${lineSuffix}</span>`
|
|
365
|
+
: "";
|
|
366
|
+
return `<div class="diagnostic"><span class="diagnostic-type">${escapeHtml(diagnostic.diagnosticType)}</span><span class="diagnostic-message">${escapeHtml(diagnostic.message)}</span>${location}</div>`;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Dashboard-only banner that names the project root and scan path. Static reports never include
|
|
370
|
+
// this; both inputs are user-controlled query parameters and are escaped before reaching HTML.
|
|
371
|
+
function htmlDashboardContext(context: DashboardRenderContext): string {
|
|
372
|
+
const escapedProjectRoot = escapeHtml(context.projectRoot);
|
|
373
|
+
const escapedScanPath = escapeHtml(context.scanPath);
|
|
374
|
+
return `<section class="dashboard-context"><h2 class="section-head">dashboard scan <span class="aside">local run</span></h2><div class="dashboard-context-grid"><div><span class="label">Project root</span><span class="val">${escapedProjectRoot}</span></div><div><span class="label">Path</span><span class="val">${escapedScanPath}</span></div></div></section>`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/*
|
|
378
|
+
* The grade stamp + four stat cards. The verdict shape is part of the stable visual contract that
|
|
379
|
+
* the dashboard relies on for parity with static reports.
|
|
380
|
+
*/
|
|
381
|
+
function htmlVerdict(report: AnalysisReport): string {
|
|
382
|
+
const gradeCssClass = gradeClass(report.score.grade);
|
|
383
|
+
const escapedGrade = escapeHtml(report.score.grade);
|
|
384
|
+
const scoreText = report.score.composite.toFixed(1);
|
|
385
|
+
const escapedSummary = escapeHtml(verdictSummary(report));
|
|
386
|
+
const stats = `${htmlStat(String(report.summary.total), "findings", "")}${htmlStat(String(report.summary.error), "errors", "fail")}${htmlStat(String(report.summary.warning), "warnings", "warn")}${htmlStat(String(report.summary.advisory), "advisories", "note")}`;
|
|
387
|
+
return `<section class="verdict"><div class="grade-stamp ${gradeCssClass}"><div class="grade-letter">${escapedGrade}</div><div class="grade-score">${scoreText} / 100</div></div><div class="verdict-body"><div class="verdict-headline">Inspection complete.<br><em>${escapedSummary}</em></div><div class="verdict-stats">${stats}</div></div></section>`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Headline sentence. Counts only warning/error findings - advisories are intentionally excluded
|
|
391
|
+
// from the verdict text because they are below the stable "needs attention" threshold.
|
|
392
|
+
function verdictSummary(report: AnalysisReport): string {
|
|
393
|
+
const thresholdFindings = report.summary.warning + report.summary.error;
|
|
394
|
+
if (thresholdFindings === 0) {
|
|
395
|
+
return "No warning or error findings flagged.";
|
|
396
|
+
}
|
|
397
|
+
const pillars = new Set(report.findings.filter((finding) => finding.severity === "warning" || finding.severity === "error").map((finding) => finding.pillar));
|
|
398
|
+
return `${thresholdFindings} ${thresholdFindings === 1 ? "finding" : "findings"} at warning or error severity across ${pillars.size} ${pillars.size === 1 ? "pillar" : "pillars"}.`;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// One stat card (number + label). `className` is a curated, non-escaped CSS class - callers must
|
|
402
|
+
// not pass user input here or it would break the layout in archived reports.
|
|
403
|
+
function htmlStat(number: string, label: string, className: string): string {
|
|
404
|
+
const escapedClassName = escapeHtml(className);
|
|
405
|
+
const escapedNumber = escapeHtml(number);
|
|
406
|
+
const escapedLabel = escapeHtml(label);
|
|
407
|
+
return `<div class="stat"><div class="num ${escapedClassName}">${escapedNumber}</div><div class="lbl">${escapedLabel}</div></div>`;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/*
|
|
411
|
+
* Pillar score grid in the same order the analyser produced - the stable composite score is the
|
|
412
|
+
* mean of these pillars (see `scoreReport`), so visual order matches the headline-grade derivation.
|
|
413
|
+
*/
|
|
414
|
+
function htmlPillars(report: AnalysisReport): string {
|
|
415
|
+
const items =
|
|
416
|
+
report.score.pillars.length === 0
|
|
417
|
+
? '<div class="empty">No pillar findings.</div>'
|
|
418
|
+
: report.score.pillars
|
|
419
|
+
.map((pillar) => {
|
|
420
|
+
const letter = grade(pillar.score);
|
|
421
|
+
return `<div class="pillar"><div class="name">${escapeHtml(pillar.pillar)}</div><div class="grade ${gradeClass(letter)}">${letter}</div><div class="breakdown"><div class="row"><span class="key">score</span><span class="val">${pillar.score.toFixed(1)}</span></div><div class="row"><span class="key">findings</span><span class="val">${pillar.findings}</span></div></div></div>`;
|
|
422
|
+
})
|
|
423
|
+
.join("");
|
|
424
|
+
return `<section class="pillars"><h2 class="section-head">pillar grades <span class="aside">weighted composite</span></h2><div class="pillar-grid">${items}</div></section>`;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Top-10 offender table, ordered by ascending score (worst first). `scoreReport` already truncated
|
|
428
|
+
// the list to 10 - this stable, deterministic limit is part of the public report contract.
|
|
429
|
+
function htmlOffenders(report: AnalysisReport): string {
|
|
430
|
+
const rows =
|
|
431
|
+
report.score.topOffenders.length === 0
|
|
432
|
+
? '<tr><td colspan="4">No offenders found.</td></tr>'
|
|
433
|
+
: report.score.topOffenders
|
|
434
|
+
.map((file) => {
|
|
435
|
+
const letter = grade(file.score);
|
|
436
|
+
return `<tr><td class="file-path">${htmlLocation(file.filePath)}</td><td class="num">${file.score.toFixed(1)}</td><td class="num">${file.findings}</td><td class="num"><span class="grade-pill ${gradeClass(letter)}">${letter}</span></td></tr>`;
|
|
437
|
+
})
|
|
438
|
+
.join("");
|
|
439
|
+
return `<section class="offenders"><h2 class="section-head">top offenders <span class="aside">sorted by score</span></h2><table class="offender-list"><thead><tr><th scope="col">file</th><th scope="col" class="num">score</th><th scope="col" class="num">findings</th><th scope="col" class="num">grade</th></tr></thead><tbody>${rows}</tbody></table></section>`;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/*
|
|
443
|
+
* Histogram of cyclomatic-complexity buckets. The 5 fixed buckets are part of the stable visual
|
|
444
|
+
* vocabulary maintainers learn over time - adding a new bucket would invalidate that mental model.
|
|
445
|
+
*/
|
|
446
|
+
function htmlDistribution(report: AnalysisReport): string {
|
|
447
|
+
const distribution = cyclomaticDistribution(report);
|
|
448
|
+
const max = Math.max(1, ...Object.values(distribution));
|
|
449
|
+
const bars = Object.entries(distribution)
|
|
450
|
+
.map(([label, count]) => {
|
|
451
|
+
const height = Math.max(4, Math.round((count / max) * 100));
|
|
452
|
+
const className = label === "16-20" || label === "21+" ? " fail" : label === "11-15" ? " warn" : "";
|
|
453
|
+
return `<div class="bar${className}" style="height:${height}%;"><span class="count">${count}</span></div>`;
|
|
454
|
+
})
|
|
455
|
+
.join("");
|
|
456
|
+
const axis = Object.keys(distribution)
|
|
457
|
+
.map((label) => `<span>${escapeHtml(label)}</span>`)
|
|
458
|
+
.join("");
|
|
459
|
+
return `<section class="chart-section"><h2 class="section-head">distribution <span class="aside">cyclomatic complexity</span></h2><p class="chart-summary">${escapeHtml(cyclomaticSummary(distribution))}</p><div class="chart-card"><div class="title">cyclomatic complexity - flagged functions</div><div class="histogram">${bars}</div><div class="histogram-axis">${axis}</div></div></section>`;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/*
|
|
463
|
+
* Pre-seeded with zero counts for every bucket so the resulting record has a stable key order
|
|
464
|
+
* in JSON dumps. Findings outside `complexity.cyclomatic` are ignored - see `cyclomaticFindingBucket`.
|
|
465
|
+
*/
|
|
466
|
+
function cyclomaticDistribution(report: AnalysisReport): Record<string, number> {
|
|
467
|
+
const distribution: Record<string, number> = { "1-5": 0, "6-10": 0, "11-15": 0, "16-20": 0, "21+": 0 };
|
|
468
|
+
for (const finding of report.findings) {
|
|
469
|
+
const bucket = cyclomaticFindingBucket(finding);
|
|
470
|
+
if (bucket) {
|
|
471
|
+
distribution[bucket] = (distribution[bucket] ?? 0) + 1;
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return distribution;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Extracts the cyclomatic number from the finding message because the metadata schema doesn't
|
|
478
|
+
// expose it as a top-level field - coupling the renderer to the message text is acceptable here
|
|
479
|
+
// because the message format is itself part of the stable rule contract.
|
|
480
|
+
function cyclomaticFindingBucket(finding: Finding): string | undefined {
|
|
481
|
+
if (finding.ruleId !== "complexity.cyclomatic") {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
const match = finding.message.match(/cyclomatic complexity (\d+)/);
|
|
485
|
+
const complexityValue = match?.[1] ? Number(match[1]) : undefined;
|
|
486
|
+
return complexityValue === undefined ? undefined : cyclomaticBucket(complexityValue);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// Linear walk in descending-minimum order so the first match wins - required because `21+` and
|
|
490
|
+
// `16-20` would otherwise both apply to a value of 25.
|
|
491
|
+
function cyclomaticBucket(complexityValue: number): string | undefined {
|
|
492
|
+
for (const bucket of CYCLOMATIC_BUCKETS) {
|
|
493
|
+
if (complexityValue >= bucket.minimum) {
|
|
494
|
+
return bucket.label;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return undefined;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// One-line caption that quantifies how many functions exceed CC=10. The pluralisation handling
|
|
501
|
+
// matters - "1 functions exceed" reads poorly enough that a reviewer would file it as a regression.
|
|
502
|
+
function cyclomaticSummary(distribution: Record<string, number>): string {
|
|
503
|
+
const moderate = distribution["11-15"] ?? 0;
|
|
504
|
+
const high = distribution["16-20"] ?? 0;
|
|
505
|
+
const severe = distribution["21+"] ?? 0;
|
|
506
|
+
const exceeds = moderate + high + severe;
|
|
507
|
+
return `${exceeds} ${exceeds === 1 ? "function" : "functions"} ${exceeds === 1 ? "exceeds" : "exceed"} CC 10 (${moderate} in 11-15, ${high} in 16-20, ${severe} at 21+).`;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// Caps the list at 250 entries because the inline-CSS report becomes painful to scroll past that.
|
|
511
|
+
// The footer caveat shows "first 250 of N" so the truncation stable and visible.
|
|
512
|
+
function htmlFindings(report: AnalysisReport): string {
|
|
513
|
+
const findings =
|
|
514
|
+
report.findings.length === 0
|
|
515
|
+
? '<div class="empty">No findings.</div>'
|
|
516
|
+
: report.findings
|
|
517
|
+
.slice(0, 250)
|
|
518
|
+
.map(
|
|
519
|
+
(finding) =>
|
|
520
|
+
`<div class="finding"><div class="severity ${severityClass(finding.severity)}">${escapeHtml(finding.severity)}</div><div class="finding-body"><h3 class="rule">${escapeHtml(finding.ruleId)}</h3><div class="msg">${escapeHtml(finding.message)}</div><div class="loc"><code>${htmlLocation(finding.filePath, finding.line)}</code></div></div><div class="points"><b>${escapeHtml(finding.pillar)}</b></div></div>`,
|
|
521
|
+
)
|
|
522
|
+
.join("");
|
|
523
|
+
const capped = report.findings.length > 250 ? ` <span class="aside">first 250 of ${report.findings.length}</span>` : ` <span class="aside">${report.findings.length} shown</span>`;
|
|
524
|
+
return `<section class="findings"><h2 class="section-head">flagged findings${capped}</h2><div class="findings-list">${findings}</div></section>`;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/*
|
|
528
|
+
* Page-bottom strip with tool version and schema string - those are the two stable identifiers a
|
|
529
|
+
* reviewer can use to reproduce a historical report.
|
|
530
|
+
*/
|
|
531
|
+
function htmlFooter(report: AnalysisReport): string {
|
|
532
|
+
const escapedVersion = escapeHtml(report.tool.version);
|
|
533
|
+
const escapedSchemaVersion = escapeHtml(report.schemaVersion);
|
|
534
|
+
return `<footer class="footer"><div class="left">gruff-ts - v${escapedVersion}</div><div class="center">strong opinions, opinionated defaults</div><div class="right">schema - ${escapedSchemaVersion}</div></footer>`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// Renders a keyboard-focusable location pill. `data-path` is what the dashboard JS keys off when
|
|
538
|
+
// the user clicks to deep-link; both attributes go through `escapeHtml` because filePath is user-influenced.
|
|
539
|
+
function htmlLocation(filePath: string, line?: number): string {
|
|
540
|
+
const text = line === undefined ? filePath : `${filePath}:${line}`;
|
|
541
|
+
return `<span class="loc-link" tabindex="0" data-path="${escapeHtml(text)}">${escapeHtml(text)}</span>`;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// CSS class suffix used by severity badges and grade letters. Returning a curated literal (never
|
|
545
|
+
// arbitrary user text) is what keeps `htmlStat`/`htmlFinding` safe to interpolate without escaping.
|
|
546
|
+
function severityClass(severity: Severity): string {
|
|
547
|
+
return severity === "error" ? "fail" : severity === "warning" ? "warn" : "note";
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// Constrains grade text to known CSS suffixes before interpolation. Unknown future grades degrade
|
|
551
|
+
// to neutral styling instead of creating user-influenced class names.
|
|
552
|
+
function gradeClass(gradeValue: string): string {
|
|
553
|
+
const letter = gradeValue[0]?.toLowerCase() ?? "n";
|
|
554
|
+
return ["a", "b", "c", "d", "f"].includes(letter) ? letter : "n";
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Single inline stylesheet for both static reports and dashboard-served pages. `shouldIncludeDiagnostics`
|
|
558
|
+
// appends the diagnostic-section rules only when the report actually has them, keeping clean
|
|
559
|
+
// reports leaner. Hand-maintained CSS - no preprocessor.
|
|
560
|
+
function htmlReportCss(shouldIncludeDiagnostics: boolean): string {
|
|
561
|
+
const baseCss = `:root{--ink:#0d0c0a;--ink-2:#161412;--ink-3:#1f1c19;--paper:#f3e9d2;--paper-dim:#b5ab94;--paper-mute:#7d735f;--rule:#2a2622;--forge:#e85d04;--grade-a:#7fa15a;--grade-b:#b8b450;--grade-c:#d08c36;--grade-d:#c2552b;--grade-f:#8b2828;--advisory:#b5ab94;--serif:Georgia,'Iowan Old Style',serif;--mono:'JetBrains Mono','IBM Plex Mono',ui-monospace,monospace}*{box-sizing:border-box;margin:0;padding:0}html{background:var(--ink);scrollbar-gutter:stable}body{font-family:var(--mono);color:var(--paper);background:var(--ink);min-height:100vh;line-height:1.5;font-size:14px;padding:48px 32px}.paper{max-width:1180px;margin:0 auto 24px;background:var(--ink-2);border:1px solid var(--rule);position:relative;padding:56px 64px 48px;scrollbar-gutter:stable}.corner-tr,.corner-bl,.paper:before,.paper:after{content:'';position:absolute;width:22px;height:22px;border:1px solid var(--forge)}.paper:before{top:12px;left:12px;border-right:0;border-bottom:0}.paper:after{bottom:12px;right:12px;border-left:0;border-top:0}.corner-tr{top:12px;right:12px;border-left:0;border-bottom:0}.corner-bl{bottom:12px;left:12px;border-right:0;border-top:0}.masthead{display:grid;grid-template-columns:1fr auto;gap:32px;padding-bottom:28px;border-bottom:1px solid var(--rule);align-items:end}.wordmark{font-family:var(--serif);font-weight:900;font-size:96px;line-height:.85;color:var(--paper);font-style:italic}.wordmark:after{content:'-ts';color:var(--forge);font-style:normal;font-size:.45em;margin-left:.15em;vertical-align:super}.tagline{margin-top:12px;font-size:11px;letter-spacing:0;color:var(--paper-mute);text-transform:uppercase}.meta{text-align:right;font-size:11px;color:var(--paper-dim);line-height:1.9}.label{color:var(--paper-mute);text-transform:uppercase;letter-spacing:0;margin-right:8px}.val{color:var(--paper)}.inspection-id{margin-top:10px;color:var(--forge);font-weight:700;font-size:12px;letter-spacing:0}.section-head{font-size:11px;letter-spacing:0;color:var(--paper-mute);text-transform:uppercase;padding-bottom:16px;margin-bottom:20px;border-bottom:1px solid var(--rule);display:flex;justify-content:space-between;align-items:baseline;font-family:var(--mono);font-weight:500;line-height:1.5}.section-head:before{content:'>';margin-right:10px;color:var(--forge);font-family:var(--serif);font-size:14px;font-style:italic}.aside{color:var(--paper-mute);font-size:10px;letter-spacing:0}.verdict{display:grid;grid-template-columns:auto 1fr;gap:56px;padding:48px 0;border-bottom:1px solid var(--rule);align-items:center}.grade-stamp{width:220px;height:220px;border:3px solid currentColor;color:var(--grade-b);display:flex;flex-direction:column;align-items:center;justify-content:center;transform:rotate(-4deg)}.grade-stamp.a,.grade.a,.grade-pill.a{color:var(--grade-a)}.grade-stamp.b,.grade.b,.grade-pill.b{color:var(--grade-b)}.grade-stamp.c,.grade.c,.grade-pill.c{color:var(--grade-c)}.grade-stamp.d,.grade.d,.grade-pill.d{color:var(--grade-d)}.grade-stamp.f,.grade.f,.grade-pill.f{color:var(--grade-f)}.grade-letter{font-family:var(--serif);font-style:italic;font-weight:900;font-size:112px;line-height:1}.grade-score{font-size:13px;letter-spacing:0}.verdict-body{display:flex;flex-direction:column;gap:18px}.verdict-headline{font-family:var(--serif);font-style:italic;font-weight:600;font-size:38px;line-height:1.15}.verdict-headline em{color:var(--forge)}.verdict-stats{display:grid;grid-template-columns:repeat(4,1fr);border-top:1px solid var(--rule);padding-top:20px}.stat{border-right:1px solid var(--rule);padding:0 18px}.stat:first-child{padding-left:0}.stat:last-child{border-right:0}.verdict-stats .num{font-family:var(--serif);font-weight:800;font-size:32px;line-height:1}.verdict-stats .num.warn{color:var(--grade-c)}.verdict-stats .num.fail{color:var(--grade-f)}.verdict-stats .num.note{color:var(--advisory)}.lbl{font-size:10px;text-transform:uppercase;letter-spacing:0;color:var(--paper-mute);margin-top:8px}.pillars,.offenders,.chart-section{padding:48px 0;border-bottom:1px solid var(--rule)}.pillar-grid{display:grid;grid-template-columns:repeat(4,1fr);gap:1px;background:var(--rule);border:1px solid var(--rule)}.pillar{background:var(--ink-2);padding:24px 20px;display:flex;flex-direction:column;gap:14px}.pillar .name{font-size:10px;text-transform:uppercase;letter-spacing:0;color:var(--paper-mute)}.pillar .grade{font-family:var(--serif);font-weight:800;font-style:italic;font-size:52px;line-height:.9}.breakdown{font-size:11px;color:var(--paper-dim);line-height:1.7}.row{display:flex;justify-content:space-between;gap:8px}.key{color:var(--paper-mute)}table{width:100%;border-collapse:collapse;font-size:13px;table-layout:auto;font-family:var(--mono)}th{text-align:left;font-size:10px;text-transform:uppercase;letter-spacing:0;color:var(--paper-mute);font-weight:500;padding:12px 14px 12px 0;border-bottom:1px solid var(--rule)}th:last-child,td:last-child{padding-right:0}th.num,td.num{text-align:right;padding-left:18px}td{padding:14px 14px 14px 0;border-bottom:1px solid var(--ink-3);color:var(--paper-dim);font-size:13px;font-family:var(--mono);font-weight:500;line-height:1.4}td.num{color:var(--paper);font-variant-numeric:tabular-nums}.file-path{color:var(--paper);font-weight:500}.grade-pill{display:inline-block;font-family:var(--serif);font-style:italic;font-weight:800;font-size:18px;line-height:1;padding:4px 10px;border:1.5px solid currentColor;min-width:36px;text-align:center}.chart-summary{color:var(--paper-dim);font-size:12px;margin:-6px 0 18px}.chart-card{border:1px solid var(--rule);padding:24px;background:var(--ink-3)}.title{font-size:10px;text-transform:uppercase;letter-spacing:0;color:var(--paper-mute);margin-bottom:24px}.histogram{display:flex;align-items:flex-end;gap:6px;height:180px;padding-bottom:20px;border-bottom:1px solid var(--rule)}.bar{flex:1;background:var(--forge);position:relative;min-height:4px}.bar.warn{background:var(--grade-c)}.bar.fail{background:var(--grade-f)}.bar .count{position:absolute;top:-22px;left:50%;transform:translateX(-50%);font-size:11px}.histogram-axis{display:flex;gap:6px;margin-top:8px;font-size:10px;color:var(--paper-mute)}.histogram-axis span{flex:1;text-align:center}.findings{padding:48px 0}.finding{display:grid;grid-template-columns:auto 1fr auto;gap:24px;padding:18px 0;border-bottom:1px solid var(--ink-3);align-items:start}.severity{font-size:9px;text-transform:uppercase;letter-spacing:0;padding:4px 10px;border:1px solid currentColor;margin-top:2px;min-width:76px;text-align:center}.severity.fail{color:var(--grade-f)}.severity.warn{color:var(--grade-c)}.severity.note{color:var(--paper-mute)}.rule{font-size:10px;color:var(--forge);text-transform:uppercase;letter-spacing:0;margin-bottom:6px;font-family:var(--mono);font-weight:700;line-height:1.5}.msg{font-family:var(--serif);font-weight:500;font-size:17px;color:var(--paper);line-height:1.4}.loc{font-size:11px;color:var(--paper-mute);margin-top:8px}.loc code{color:var(--paper-dim);background:var(--ink-3);padding:1px 6px;border:1px solid var(--rule)}.loc-link{color:inherit;text-decoration:none}.loc-link:focus-visible{outline:2px solid var(--forge);outline-offset:3px}.points{font-size:10px;color:var(--paper-mute);text-align:right;letter-spacing:0;min-width:96px;padding-left:12px}.empty{color:var(--paper-dim);font-size:12px}.footer{margin-top:48px;padding-top:24px;border-top:1px solid var(--rule);display:grid;grid-template-columns:1fr auto 1fr;gap:24px;align-items:center;font-size:10px;color:var(--paper-mute);letter-spacing:0;text-transform:uppercase}.center{font-family:var(--serif);font-style:italic;font-size:13px;color:var(--paper-dim);text-transform:none;letter-spacing:0}.right{text-align:right}@media(max-width:900px){body{padding:16px}.paper{padding:28px 20px}.wordmark{font-size:64px}.masthead,.verdict{grid-template-columns:1fr}.meta{text-align:left}.grade-stamp{margin:0 auto}.pillar-grid{grid-template-columns:repeat(2,1fr)}.verdict-stats{grid-template-columns:repeat(2,1fr);gap:16px}.stat{border-right:0;padding:0}.verdict-headline{font-size:28px}.footer{grid-template-columns:1fr}.center,.right{text-align:left}}@media(max-width:560px){.pillar-grid{grid-template-columns:1fr}.finding{grid-template-columns:1fr}.points{text-align:left;padding-left:0}.verdict-stats{grid-template-columns:1fr}.histogram{height:140px}}`;
|
|
562
|
+
const reportCss = `${baseCss}.dashboard-context{padding:28px 0;border-bottom:1px solid var(--rule)}.dashboard-context-grid{display:grid;grid-template-columns:1fr 1fr;gap:12px}.dashboard-context-grid>div{border:1px solid var(--rule);background:var(--ink-3);padding:12px 14px}.dashboard-context .label{display:block;margin:0 0 6px}.dashboard-context .val{overflow-wrap:anywhere}@media(max-width:700px){.dashboard-context-grid{grid-template-columns:1fr}}@media(max-width:560px){.offender-list thead{display:none}.offender-list,.offender-list tbody,.offender-list tr,.offender-list td{display:block;width:100%}.offender-list tr{border-bottom:1px solid var(--ink-3);padding:10px 0}.offender-list td{border-bottom:0;padding:6px 0}.offender-list td.num{text-align:left;padding-left:0}}`;
|
|
563
|
+
if (!shouldIncludeDiagnostics) {
|
|
564
|
+
return reportCss;
|
|
565
|
+
}
|
|
566
|
+
return `${reportCss}.diagnostics{padding:28px 0 0}.diagnostic-list{display:grid;gap:10px}.diagnostic{display:grid;grid-template-columns:auto 1fr;gap:10px 14px;border:1px solid var(--rule);background:var(--ink-3);padding:12px 14px;color:var(--paper-dim);font-size:12px}.diagnostic-type{text-transform:uppercase;letter-spacing:0;color:var(--forge);font-size:10px}.diagnostic-location{grid-column:2;color:var(--paper-mute);font-size:11px}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
// Top-level dashboard chrome: iframe that loads the scan + a slide-out controls panel. The iframe
|
|
570
|
+
// arrangement is what lets the dashboard reload scans without losing the control form state.
|
|
571
|
+
function dashboardHomeHtml(projectRoot: string, scanPath: string): string {
|
|
572
|
+
const initialScan = dashboardScanUrl(projectRoot, scanPath);
|
|
573
|
+
return `<!doctype html>
|
|
574
|
+
<html lang="en">
|
|
575
|
+
<head>
|
|
576
|
+
<meta charset="utf-8">
|
|
577
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
578
|
+
<title>gruff-ts dashboard</title>
|
|
579
|
+
<style>${dashboardCss()}</style>
|
|
580
|
+
</head>
|
|
581
|
+
<body>
|
|
582
|
+
<iframe class="report-frame" name="report-frame" title="gruff-ts report" src="${escapeHtml(initialScan)}"></iframe>
|
|
583
|
+
<button class="controls-toggle" type="button" aria-expanded="false" aria-controls="controls-panel" title="Dashboard controls">⚙</button>
|
|
584
|
+
<aside class="controls-panel" id="controls-panel" hidden>
|
|
585
|
+
<header class="controls-head">
|
|
586
|
+
<h1>Dashboard controls</h1>
|
|
587
|
+
<p>local scan settings</p>
|
|
588
|
+
</header>
|
|
589
|
+
<form class="scan-form" data-scan-form action="/scan" method="get" target="report-frame">
|
|
590
|
+
<label>Project root <input name="projectRoot" value="${escapeHtml(projectRoot)}" autocomplete="off"></label>
|
|
591
|
+
<label>Paths <input name="path" value="${escapeHtml(scanPath)}" autocomplete="off"></label>
|
|
592
|
+
<div class="scan-state"><span>Status</span><strong data-scan-status>Loading report</strong></div>
|
|
593
|
+
<div class="actions">
|
|
594
|
+
<button class="secondary" type="button" data-refresh>Refresh</button>
|
|
595
|
+
<button type="submit">Run scan</button>
|
|
596
|
+
</div>
|
|
597
|
+
</form>
|
|
598
|
+
</aside>
|
|
599
|
+
<script>${dashboardJs()}</script>
|
|
600
|
+
</body>
|
|
601
|
+
</html>`;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
// URLSearchParams handles encoding so the projectRoot/scanPath values are safe to embed in `src`.
|
|
605
|
+
function dashboardScanUrl(projectRoot: string, scanPath: string): string {
|
|
606
|
+
const params = new URLSearchParams({ projectRoot, path: scanPath });
|
|
607
|
+
return `/scan?${params.toString()}`;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Friendly failure page served when `renderDashboardScan` catches an analyser throw. All inputs
|
|
611
|
+
// are escaped - `message` is the error text and could contain attacker-controlled file path fragments.
|
|
612
|
+
function dashboardErrorHtml(message: string, projectRoot: string, scanPath: string): string {
|
|
613
|
+
const escapedMessage = escapeHtml(message);
|
|
614
|
+
const escapedProjectRoot = escapeHtml(projectRoot);
|
|
615
|
+
const escapedScanPath = escapeHtml(scanPath);
|
|
616
|
+
return `<!doctype html>
|
|
617
|
+
<html lang="en-NZ">
|
|
618
|
+
<head>
|
|
619
|
+
<meta charset="utf-8">
|
|
620
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
621
|
+
<title>gruff-ts dashboard scan failed</title>
|
|
622
|
+
<style>${dashboardCss()}</style>
|
|
623
|
+
</head>
|
|
624
|
+
<body class="error-page">
|
|
625
|
+
<main class="scan-error">
|
|
626
|
+
<h1>Scan failed</h1>
|
|
627
|
+
<p>${escapedMessage}</p>
|
|
628
|
+
<dl>
|
|
629
|
+
<dt>Project root</dt><dd>${escapedProjectRoot}</dd>
|
|
630
|
+
<dt>Paths</dt><dd>${escapedScanPath}</dd>
|
|
631
|
+
</dl>
|
|
632
|
+
</main>
|
|
633
|
+
</body>
|
|
634
|
+
</html>`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
// Inline stylesheet shared by the dashboard shell and its error page. Inlined (not linked) so the
|
|
638
|
+
// dashboard works without separate asset routes - keeping it self-contained matters for offline use.
|
|
639
|
+
function dashboardCss(): string {
|
|
640
|
+
return `:root{color-scheme:dark;--ink:#0d0c0a;--ink-2:#161412;--panel:#1f1c19;--paper:#f3e9d2;--paper-dim:#b5ab94;--paper-mute:#7d735f;--rule:#2a2622;--forge:#e85d04;--forge-dark:#b94402;--mono:'JetBrains Mono','IBM Plex Mono',ui-monospace,monospace}*{box-sizing:border-box}html,body{height:100%;margin:0;background:var(--ink);color:var(--paper);font-family:var(--mono);font-size:14px;line-height:1.5}.report-frame{position:fixed;inset:0;width:100%;height:100%;border:0;background:var(--ink)}.controls-toggle{position:fixed;top:18px;right:18px;z-index:3;width:44px;height:44px;border:1px solid rgba(232,93,4,.75);border-radius:8px;background:var(--forge);color:#170b05;font:700 22px/1 var(--mono);display:grid;place-items:center;cursor:pointer;box-shadow:0 16px 36px rgba(0,0,0,.38)}.controls-toggle:hover,.controls-toggle:focus-visible{background:#ff7a1a;outline:2px solid rgba(243,233,210,.75);outline-offset:3px}.controls-panel{position:fixed;z-index:2;top:74px;right:18px;width:min(420px,calc(100vw - 36px));max-height:calc(100vh - 92px);overflow:auto;background:rgba(31,28,25,.98);border:1px solid var(--rule);border-radius:8px;padding:20px;box-shadow:0 24px 70px rgba(0,0,0,.5)}[hidden]{display:none!important}.controls-head{border-bottom:1px solid var(--rule);padding-bottom:14px;margin-bottom:16px}.controls-head h1{margin:0;font-size:18px;font-weight:800}.controls-head p{margin:4px 0 0;color:var(--paper-mute);font-size:12px;text-transform:uppercase}.scan-form{display:grid;gap:14px}.scan-form label{display:grid;gap:6px;color:var(--paper-dim);font-size:12px;text-transform:uppercase}.scan-form input{width:100%;font:inherit;color:var(--paper);background:var(--ink-2);border:1px solid var(--rule);border-radius:6px;padding:10px 11px;min-width:0}.scan-form input:focus{outline:2px solid var(--forge);outline-offset:2px}.scan-state{display:flex;justify-content:space-between;gap:12px;border:1px solid var(--rule);background:var(--ink-2);border-radius:6px;padding:10px 11px;color:var(--paper-mute)}.scan-state strong{color:var(--paper);font-weight:700;text-align:right}.actions{display:grid;grid-template-columns:1fr 1fr;gap:10px}.actions button{font:inherit;border:1px solid var(--forge);border-radius:6px;padding:10px 12px;background:var(--forge);color:#170b05;font-weight:800;cursor:pointer}.actions button.secondary{background:transparent;color:var(--paper);border-color:var(--rule)}.actions button:disabled{opacity:.6;cursor:wait}.scan-error{max-width:720px;margin:8vh auto;padding:48px;background:var(--panel);border:1px solid var(--rule);color:var(--paper)}.scan-error h1{margin:0 0 16px;font-size:28px}.scan-error p{color:var(--paper-dim);overflow-wrap:anywhere}.scan-error dl{display:grid;grid-template-columns:auto 1fr;gap:8px 16px;margin:24px 0 0}.scan-error dt{color:var(--paper-mute);text-transform:uppercase}.scan-error dd{margin:0;overflow-wrap:anywhere}@media(max-width:560px){.controls-toggle{top:12px;right:12px}.controls-panel{top:64px;right:12px;width:calc(100vw - 24px);max-height:calc(100vh - 76px);padding:16px}.actions{grid-template-columns:1fr}.scan-error{margin:0;min-height:100vh;padding:28px 20px}.scan-error dl{grid-template-columns:1fr}}`;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Inline `<script>` body served with the dashboard shell. Updates the URL via history.replaceState
|
|
644
|
+
// so a maintainer can copy the current scan's URL and reproduce it later without re-typing controls.
|
|
645
|
+
function dashboardJs(): string {
|
|
646
|
+
return `const form=document.querySelector("[data-scan-form]");const frame=document.querySelector(".report-frame");const toggle=document.querySelector(".controls-toggle");const panel=document.querySelector(".controls-panel");const refresh=document.querySelector("[data-refresh]");const status=document.querySelector("[data-scan-status]");function setOpen(open){panel.hidden=!open;toggle.setAttribute("aria-expanded",String(open));if(open){const input=form.querySelector("input");if(input){input.focus();}}}function params(){return new URLSearchParams(new FormData(form));}function runScan(){const query=params();status.textContent="Scanning";refresh.disabled=true;form.querySelector("button[type=submit]").disabled=true;frame.src="/scan?"+query.toString();history.replaceState(null,"","/?"+query.toString());}toggle.addEventListener("click",()=>setOpen(panel.hidden));document.addEventListener("keydown",(event)=>{if(event.key==="Escape"){setOpen(false);}});form.addEventListener("submit",(event)=>{event.preventDefault();runScan();});refresh.addEventListener("click",runScan);frame.addEventListener("load",()=>{status.textContent="Ready";refresh.disabled=false;form.querySelector("button[type=submit]").disabled=false;});`;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Composite-score → letter grade conversion. 90/80/70/60 boundaries are part of the stable rule
|
|
650
|
+
// surface; changing them would shift every historical report grade and the headline on the dashboard.
|
|
651
|
+
function grade(score: number): string {
|
|
652
|
+
if (score >= 90) {
|
|
653
|
+
return "A";
|
|
654
|
+
}
|
|
655
|
+
if (score >= 80) {
|
|
656
|
+
return "B";
|
|
657
|
+
}
|
|
658
|
+
if (score >= 70) {
|
|
659
|
+
return "C";
|
|
660
|
+
}
|
|
661
|
+
if (score >= 60) {
|
|
662
|
+
return "D";
|
|
663
|
+
}
|
|
664
|
+
return "F";
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// Three GitHub annotation levels - gruff's "advisory" collapses to "notice" because Actions has
|
|
668
|
+
// no fourth tier and "notice" is the documented soft-warning level.
|
|
669
|
+
function githubLevel(severity: Severity): "notice" | "warning" | "error" {
|
|
670
|
+
return severity === "error" ? "error" : severity === "warning" ? "warning" : "notice";
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Actions workflow-command escaping per the documented spec. `%` must be first - otherwise the
|
|
674
|
+
// `%0A` replacement would itself be re-encoded.
|
|
675
|
+
function escapeCommand(commandText: string): string {
|
|
676
|
+
return commandText.replaceAll("%", "%25").replaceAll("\n", "%0A").replaceAll("\r", "%0D");
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
// Property-list variant of `escapeCommand`: also escapes `:` and `,` because GitHub workflow
|
|
680
|
+
// commands use them as the property-list delimiters between `file`, `line`, `title`, etc.
|
|
681
|
+
function escapeCommandProperty(propertyText: string): string {
|
|
682
|
+
return escapeCommand(propertyText).replaceAll(":", "%3A").replaceAll(",", "%2C");
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Four-character HTML entity escape (`&`, `<`, `>`, `"`). `&` must be first so the other entities
|
|
686
|
+
// don't get double-encoded; missing escapes here would enable script injection in archived reports.
|
|
687
|
+
function escapeHtml(htmlText: string): string {
|
|
688
|
+
return htmlText.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export { dashboardErrorHtml, dashboardHomeHtml, grade, renderHtml, renderReport, renderSummary };
|