redlinegate 0.0.1
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/LICENSE +21 -0
- package/README.md +188 -0
- package/commands/redline-init.md +54 -0
- package/commands/redline-verify.md +69 -0
- package/dist/bin/redline.js +605 -0
- package/dist/bin/redline.js.map +1 -0
- package/dist/commands/exempt.js +45 -0
- package/dist/commands/exempt.js.map +1 -0
- package/dist/commands/init.js +615 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/policy.js +25 -0
- package/dist/commands/policy.js.map +1 -0
- package/dist/commands/remove.js +427 -0
- package/dist/commands/remove.js.map +1 -0
- package/dist/commands/review.js +72 -0
- package/dist/commands/review.js.map +1 -0
- package/dist/commands/sync.js +42 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/verify.js +478 -0
- package/dist/commands/verify.js.map +1 -0
- package/dist/config/redline-json.js +187 -0
- package/dist/config/redline-json.js.map +1 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/errors.js.map +1 -0
- package/dist/core/git.js +152 -0
- package/dist/core/git.js.map +1 -0
- package/dist/core/log.js +22 -0
- package/dist/core/log.js.map +1 -0
- package/dist/core/severity.js +16 -0
- package/dist/core/severity.js.map +1 -0
- package/dist/core/version.js +15 -0
- package/dist/core/version.js.map +1 -0
- package/dist/detect/scan.js +79 -0
- package/dist/detect/scan.js.map +1 -0
- package/dist/detect/stack.js +71 -0
- package/dist/detect/stack.js.map +1 -0
- package/dist/enforce/ladder.js +148 -0
- package/dist/enforce/ladder.js.map +1 -0
- package/dist/exempt/parse.js +86 -0
- package/dist/exempt/parse.js.map +1 -0
- package/dist/metrics/options.js +257 -0
- package/dist/metrics/options.js.map +1 -0
- package/dist/metrics/run.js +72 -0
- package/dist/metrics/run.js.map +1 -0
- package/dist/platforms/azure/client.js +53 -0
- package/dist/platforms/azure/client.js.map +1 -0
- package/dist/platforms/azure/index.js +69 -0
- package/dist/platforms/azure/index.js.map +1 -0
- package/dist/platforms/azure/install.js +917 -0
- package/dist/platforms/azure/install.js.map +1 -0
- package/dist/platforms/azure/policy-types.js +70 -0
- package/dist/platforms/azure/policy-types.js.map +1 -0
- package/dist/platforms/azure/verify.js +309 -0
- package/dist/platforms/azure/verify.js.map +1 -0
- package/dist/platforms/detect.js +40 -0
- package/dist/platforms/detect.js.map +1 -0
- package/dist/platforms/github/client.js +49 -0
- package/dist/platforms/github/client.js.map +1 -0
- package/dist/platforms/github/index.js +54 -0
- package/dist/platforms/github/index.js.map +1 -0
- package/dist/platforms/github/install.js +558 -0
- package/dist/platforms/github/install.js.map +1 -0
- package/dist/platforms/github/push.js +102 -0
- package/dist/platforms/github/push.js.map +1 -0
- package/dist/platforms/github/remote.js +36 -0
- package/dist/platforms/github/remote.js.map +1 -0
- package/dist/platforms/github/verify.js +366 -0
- package/dist/platforms/github/verify.js.map +1 -0
- package/dist/platforms/http.js +58 -0
- package/dist/platforms/http.js.map +1 -0
- package/dist/platforms/pull-request-templates.js +157 -0
- package/dist/platforms/pull-request-templates.js.map +1 -0
- package/dist/platforms/remote.js +21 -0
- package/dist/platforms/remote.js.map +1 -0
- package/dist/platforms/resolve.js +69 -0
- package/dist/platforms/resolve.js.map +1 -0
- package/dist/platforms/shape.js +19 -0
- package/dist/platforms/shape.js.map +1 -0
- package/dist/platforms/types.js +24 -0
- package/dist/platforms/types.js.map +1 -0
- package/dist/policy/checks.js +91 -0
- package/dist/policy/checks.js.map +1 -0
- package/dist/policy/diff.js +50 -0
- package/dist/policy/diff.js.map +1 -0
- package/dist/registry/discover.js +74 -0
- package/dist/registry/discover.js.map +1 -0
- package/dist/registry/serialize.js +41 -0
- package/dist/registry/serialize.js.map +1 -0
- package/dist/registry/types.js +2 -0
- package/dist/registry/types.js.map +1 -0
- package/dist/remove/host.js +294 -0
- package/dist/remove/host.js.map +1 -0
- package/dist/render/commands.js +139 -0
- package/dist/render/commands.js.map +1 -0
- package/dist/render/manifest.js +82 -0
- package/dist/render/manifest.js.map +1 -0
- package/dist/render/markers.js +166 -0
- package/dist/render/markers.js.map +1 -0
- package/dist/render/profile.js +23 -0
- package/dist/render/profile.js.map +1 -0
- package/dist/render/standards.js +169 -0
- package/dist/render/standards.js.map +1 -0
- package/dist/render/vendors.js +199 -0
- package/dist/render/vendors.js.map +1 -0
- package/dist/review/engines/api.js +95 -0
- package/dist/review/engines/api.js.map +1 -0
- package/dist/review/engines/embedded.js +20 -0
- package/dist/review/engines/embedded.js.map +1 -0
- package/dist/review/engines/types.js +2 -0
- package/dist/review/engines/types.js.map +1 -0
- package/dist/review/glob.js +29 -0
- package/dist/review/glob.js.map +1 -0
- package/dist/review/prompt.js +45 -0
- package/dist/review/prompt.js.map +1 -0
- package/dist/review/rules.js +25 -0
- package/dist/review/rules.js.map +1 -0
- package/dist/review/schema.js +84 -0
- package/dist/review/schema.js.map +1 -0
- package/dist/review/scope.js +27 -0
- package/dist/review/scope.js.map +1 -0
- package/dist/sarif/map.js +58 -0
- package/dist/sarif/map.js.map +1 -0
- package/dist/sarif/parse.js +88 -0
- package/dist/sarif/parse.js.map +1 -0
- package/dist/sarif/types.js +2 -0
- package/dist/sarif/types.js.map +1 -0
- package/dist/sync/host.js +21 -0
- package/dist/sync/host.js.map +1 -0
- package/dist/sync/plan.js +47 -0
- package/dist/sync/plan.js.map +1 -0
- package/dist/sync/render.js +57 -0
- package/dist/sync/render.js.map +1 -0
- package/dist/sync/run.js +118 -0
- package/dist/sync/run.js.map +1 -0
- package/dist/verify/host.js +52 -0
- package/dist/verify/host.js.map +1 -0
- package/dist/verify/remote.js +146 -0
- package/dist/verify/remote.js.map +1 -0
- package/package.json +52 -0
- package/platforms/azure/gate-template.yml +105 -0
- package/rulesets/redline-org-ruleset.json +46 -0
- package/rulesets/redline-ruleset.json +49 -0
- package/scripts/assign-rule-ids.mjs +129 -0
- package/scripts/build-baseline.mjs +124 -0
- package/scripts/build-correlation.mjs +74 -0
- package/scripts/build-dashboard.mjs +584 -0
- package/scripts/build-digest.mjs +164 -0
- package/scripts/build-inbox.mjs +133 -0
- package/scripts/build-registry.mjs +42 -0
- package/scripts/build-roi.mjs +181 -0
- package/scripts/check-pins.mjs +88 -0
- package/scripts/collect-telemetry.mjs +300 -0
- package/scripts/lib/__tests__/baseline.test.mjs +132 -0
- package/scripts/lib/__tests__/correlate.test.mjs +131 -0
- package/scripts/lib/__tests__/dora.test.mjs +110 -0
- package/scripts/lib/__tests__/exemptions.test.mjs +85 -0
- package/scripts/lib/__tests__/metrics-scanner.test.mjs +65 -0
- package/scripts/lib/__tests__/sarif.test.mjs +125 -0
- package/scripts/lib/__tests__/spend.test.mjs +64 -0
- package/scripts/lib/baseline.mjs +156 -0
- package/scripts/lib/correlate.mjs +125 -0
- package/scripts/lib/dora.mjs +133 -0
- package/scripts/lib/exemptions.mjs +79 -0
- package/scripts/lib/metrics.mjs +172 -0
- package/scripts/lib/rules.mjs +107 -0
- package/scripts/lib/sarif.mjs +108 -0
- package/scripts/lib/spend.mjs +69 -0
- package/scripts/measure-context.mjs +101 -0
- package/scripts/render-self.mjs +18 -0
- package/scripts/score-seeds.mjs +294 -0
- package/scripts/validate.mjs +394 -0
- package/standards/core.md +124 -0
- package/standards/manifest.json +214 -0
- package/standards/stacks/csharp.md +29 -0
- package/standards/stacks/go.md +30 -0
- package/standards/stacks/java.md +30 -0
- package/standards/stacks/javascript.md +42 -0
- package/standards/stacks/kotlin.md +27 -0
- package/standards/stacks/microservices.md +37 -0
- package/standards/stacks/nodejs.md +34 -0
- package/standards/stacks/python.md +29 -0
- package/standards/stacks/react-native.md +29 -0
- package/standards/stacks/react.md +46 -0
- package/standards/stacks/swift.md +26 -0
- package/standards/stacks/terraform.md +27 -0
- package/templates/CODEOWNERS +29 -0
- package/templates/azure/pull_request_template.md +72 -0
- package/templates/github/pull_request_template.md +72 -0
- package/templates/redline.yml +35 -0
- package/templates/repo-context.md +64 -0
- package/workflows/dashboard.yml +119 -0
- package/workflows/inbox.yml +97 -0
- package/workflows/redline-collect.yml +61 -0
- package/workflows/redline-gate.yml +301 -0
- package/workflows/redline-sync.yml +79 -0
- package/workflows/seed-canary.yml +231 -0
- package/workflows/verify-onboarding.yml +115 -0
- package/workflows/weekly-digest.yml +78 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Builds the Redline dashboard: one static, dependency-free page summarising whether the
|
|
3
|
+
// review layer is actually working. Deployed to GitHub Pages from the metrics repo.
|
|
4
|
+
//
|
|
5
|
+
// The page answers four questions, in order:
|
|
6
|
+
// 1. Is review being acted on, or ignored? (the hero number)
|
|
7
|
+
// 2. Is that trending the right way? (weekly lines)
|
|
8
|
+
// 3. Does review still catch known defects? (seed recall history)
|
|
9
|
+
// 4. Which rules should be tuned or cut? (noisiest rules)
|
|
10
|
+
//
|
|
11
|
+
// Colour follows the validated categorical palette; both modes are selected steps, and
|
|
12
|
+
// every charted value is also present in a table because three light-mode slots sit
|
|
13
|
+
// below 3:1 contrast (the relief rule).
|
|
14
|
+
//
|
|
15
|
+
// Env: DATA_DIR (default data), ORG, [DAYS=90], [OUT=dist], [SEED_SCORES=data/seed-scores.jsonl],
|
|
16
|
+
// [ONBOARDED] (repo count, for coverage), [STANDARDS_VERSION], [REGISTRY=registry.json]
|
|
17
|
+
|
|
18
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { loadRecords, aggregate, weekly, noisiestRules, mostValuableRules, pct } from './lib/metrics.mjs';
|
|
21
|
+
|
|
22
|
+
const {
|
|
23
|
+
DATA_DIR = 'data',
|
|
24
|
+
ORG = 'org',
|
|
25
|
+
DAYS = '90',
|
|
26
|
+
OUT = 'dist',
|
|
27
|
+
SEED_SCORES = 'data/seed-scores.jsonl',
|
|
28
|
+
ONBOARDED = '',
|
|
29
|
+
STANDARDS_VERSION = '',
|
|
30
|
+
} = process.env;
|
|
31
|
+
|
|
32
|
+
const since = new Date(Date.now() - Number(DAYS) * 86400000).toISOString();
|
|
33
|
+
const { records, problems } = loadRecords(DATA_DIR, since);
|
|
34
|
+
const agg = aggregate(records);
|
|
35
|
+
const weeks = weekly(records);
|
|
36
|
+
const noisy = noisiestRules(agg, { minFired: 3, minIgnoredRate: 0.25, limit: 8 });
|
|
37
|
+
const valuable = mostValuableRules(agg, { minFired: 3, limit: 5 });
|
|
38
|
+
|
|
39
|
+
// --- seed score history -------------------------------------------------------
|
|
40
|
+
const seedRuns = [];
|
|
41
|
+
if (existsSync(SEED_SCORES)) {
|
|
42
|
+
for (const line of readFileSync(SEED_SCORES, 'utf8').split('\n')) {
|
|
43
|
+
if (!line.trim()) continue;
|
|
44
|
+
try {
|
|
45
|
+
seedRuns.push(JSON.parse(line));
|
|
46
|
+
} catch {
|
|
47
|
+
problems.push('seed-scores.jsonl has an unparseable line');
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
seedRuns.sort((a, b) => (a.scored_at ?? '').localeCompare(b.scored_at ?? ''));
|
|
52
|
+
|
|
53
|
+
const seedRepos = [...new Set(seedRuns.map((r) => r.repo))].slice(0, 5);
|
|
54
|
+
const seedSeries = seedRepos.map((repo) => ({
|
|
55
|
+
name: repo.split('/').pop(),
|
|
56
|
+
points: seedRuns
|
|
57
|
+
.filter((r) => r.repo === repo)
|
|
58
|
+
.map((r) => ({ x: r.scored_at.slice(0, 10), y: r.totals.blocker_recall * 100 })),
|
|
59
|
+
}));
|
|
60
|
+
const latestByRepo = new Map();
|
|
61
|
+
for (const run of seedRuns) latestByRepo.set(run.repo, run);
|
|
62
|
+
const latestSeeds = [...latestByRepo.values()];
|
|
63
|
+
const worstRecall = latestSeeds.length
|
|
64
|
+
? Math.min(...latestSeeds.map((s) => s.totals.blocker_recall))
|
|
65
|
+
: null;
|
|
66
|
+
const totalFalsePositives = latestSeeds.reduce((n, s) => n + s.totals.false_positives_on_clean, 0);
|
|
67
|
+
|
|
68
|
+
// --- freshness ----------------------------------------------------------------
|
|
69
|
+
const newest = records.at(-1)?.collected_at ?? records.at(-1)?.merged_at ?? null;
|
|
70
|
+
const staleHours = newest ? Math.floor((Date.now() - new Date(newest)) / 3600000) : null;
|
|
71
|
+
|
|
72
|
+
const warnings = [...problems];
|
|
73
|
+
if (!records.length) warnings.push('No telemetry in this window — the collector is not running, or the token lost access.');
|
|
74
|
+
if (staleHours !== null && staleHours > 48) warnings.push(`Newest record is ${staleHours}h old. This page is stale.`);
|
|
75
|
+
if (agg.withoutRuleId > 0) warnings.push(`${agg.withoutRuleId} finding(s) carried no rule id — those repos are invisible to per-rule tuning.`);
|
|
76
|
+
if (agg.unknownRuleIds > 0) warnings.push(`${agg.unknownRuleIds} finding(s) cited a rule id outside the catalogue.`);
|
|
77
|
+
if (agg.untagged > 0) warnings.push(`${agg.untagged} finding(s) ignored the output contract; their severity is inferred.`);
|
|
78
|
+
if (worstRecall !== null && worstRecall < 1) warnings.push(`Seed BLOCKER recall is below 100% on at least one pilot repo (${Math.round(worstRecall * 100)}%). Do not widen the rollout.`);
|
|
79
|
+
if (totalFalsePositives > 0) warnings.push(`${totalFalsePositives} false positive(s) on the clean corpus at the last scoring.`);
|
|
80
|
+
|
|
81
|
+
// --- rendering ----------------------------------------------------------------
|
|
82
|
+
const esc = (s) =>
|
|
83
|
+
String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
84
|
+
const json = (value) => JSON.stringify(value).replace(/</g, '\\u003c');
|
|
85
|
+
const compact = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}K` : String(n));
|
|
86
|
+
|
|
87
|
+
// The enforcement ladder, read from the register. This is the question the ladder
|
|
88
|
+
// exists to answer and no per-repository view can: how much of the estate is
|
|
89
|
+
// actually enforcing anything, as opposed to watching.
|
|
90
|
+
let ladder = null;
|
|
91
|
+
try {
|
|
92
|
+
const registryPath = process.env.REGISTRY ?? 'registry.json';
|
|
93
|
+
if (existsSync(registryPath)) {
|
|
94
|
+
const entries = JSON.parse(readFileSync(registryPath, 'utf8')).entries ?? [];
|
|
95
|
+
const counts = { observe: 0, warn: 0, 'block-blocker': 0, 'block-high': 0 };
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const rung = entry.rung ?? 'observe';
|
|
98
|
+
if (rung in counts) counts[rung] += 1;
|
|
99
|
+
}
|
|
100
|
+
const total = entries.length;
|
|
101
|
+
ladder = { counts, total, blocking: counts['block-blocker'] + counts['block-high'] };
|
|
102
|
+
}
|
|
103
|
+
} catch {
|
|
104
|
+
// A register that cannot be read leaves the ladder absent, not zeroed. Zero
|
|
105
|
+
// blocking repositories and an unreadable register look nothing alike to
|
|
106
|
+
// whoever has to act on the number.
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const tiles = [
|
|
110
|
+
{ label: 'PRs merged', value: compact(agg.prs), sub: `${agg.repos} repo(s)${ONBOARDED ? ` of ${ONBOARDED} onboarded` : ''}` },
|
|
111
|
+
{ label: 'Findings', value: compact(agg.findings), sub: `${agg.blocker} BLOCKER · ${agg.high} HIGH · Redline only` },
|
|
112
|
+
{ label: 'PRs with findings', value: pct(agg.prsWithFindings, agg.prs), sub: `${agg.prsWithFindings} of ${agg.prs}` },
|
|
113
|
+
{ label: 'Ignored', value: pct(agg.stale, agg.findings), sub: `${agg.stale} left stale and outdated` },
|
|
114
|
+
{ label: 'Gate exemptions', value: pct(agg.exempted, agg.prs), sub: `${agg.exempted} PR(s) used a soft-fail label` },
|
|
115
|
+
{
|
|
116
|
+
// Ingested, not produced. The label says so, because a tile reading
|
|
117
|
+
// "Findings 900" that silently included another tool's output would make
|
|
118
|
+
// Redline look nine times more productive than it is.
|
|
119
|
+
label: 'Scanner findings (ingested)',
|
|
120
|
+
value: compact(agg.scanner.findings),
|
|
121
|
+
sub:
|
|
122
|
+
agg.scanner.findings === 0
|
|
123
|
+
? 'no repository in this window emits any'
|
|
124
|
+
: `${agg.scanner.repos} repo(s) · ${agg.scanner.byTool.map((t) => t.tool).join(', ')}`,
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
label: 'Enforcing',
|
|
128
|
+
value: ladder === null ? '—' : pct(ladder.blocking, ladder.total),
|
|
129
|
+
sub:
|
|
130
|
+
ladder === null
|
|
131
|
+
? 'register unreadable — not zero, unknown'
|
|
132
|
+
: `${ladder.blocking} of ${ladder.total} repo(s) block a merge on a finding`,
|
|
133
|
+
status: ladder === null ? 'unknown' : undefined,
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
label: 'Seed BLOCKER recall',
|
|
137
|
+
value: worstRecall === null ? '—' : `${Math.round(worstRecall * 100)}%`,
|
|
138
|
+
sub: worstRecall === null ? 'never scored' : `worst of ${latestSeeds.length} pilot repo(s)`,
|
|
139
|
+
status: worstRecall === null ? 'unknown' : worstRecall < 1 ? 'critical' : 'good',
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
|
|
143
|
+
const trendFindings = weeks.map((w) => ({ x: w.week, y: w.findings }));
|
|
144
|
+
const trendActed = weeks.map((w) => ({ x: w.week, y: w.findings ? Math.round((w.resolved / w.findings) * 100) : 0 }));
|
|
145
|
+
|
|
146
|
+
const html = `<!doctype html><meta charset="utf-8">
|
|
147
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
148
|
+
<title>Redline Dashboard — ${esc(ORG)}</title>
|
|
149
|
+
<style>
|
|
150
|
+
:root {
|
|
151
|
+
color-scheme: light;
|
|
152
|
+
--plane: #f9f9f7;
|
|
153
|
+
--surface: #fcfcfb;
|
|
154
|
+
--ink: #0b0b0b;
|
|
155
|
+
--ink-2: #52514e;
|
|
156
|
+
--muted: #898781;
|
|
157
|
+
--grid: #e1e0d9;
|
|
158
|
+
--axis: #c3c2b7;
|
|
159
|
+
--ring: rgba(11,11,11,0.10);
|
|
160
|
+
--s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4;
|
|
161
|
+
--good: #0ca30c; --warning: #fab219; --serious: #ec835a; --critical: #d03b3b;
|
|
162
|
+
}
|
|
163
|
+
@media (prefers-color-scheme: dark) {
|
|
164
|
+
:root:not([data-theme="light"]) {
|
|
165
|
+
color-scheme: dark;
|
|
166
|
+
--plane: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7;
|
|
167
|
+
--muted: #898781; --grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
|
|
168
|
+
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
:root[data-theme="dark"] {
|
|
172
|
+
color-scheme: dark;
|
|
173
|
+
--plane: #0d0d0d; --surface: #1a1a19; --ink: #ffffff; --ink-2: #c3c2b7;
|
|
174
|
+
--muted: #898781; --grid: #2c2c2a; --axis: #383835; --ring: rgba(255,255,255,0.10);
|
|
175
|
+
--s1: #3987e5; --s2: #d95926; --s3: #199e70; --s4: #c98500; --s5: #d55181;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
* { box-sizing: border-box; }
|
|
179
|
+
body {
|
|
180
|
+
margin: 0; padding: 2rem 1.5rem 4rem;
|
|
181
|
+
background: var(--plane); color: var(--ink);
|
|
182
|
+
font: 14px/1.55 system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
183
|
+
}
|
|
184
|
+
.wrap { max-width: 1180px; margin: 0 auto; }
|
|
185
|
+
header { display: flex; flex-wrap: wrap; align-items: baseline; gap: .75rem 1.25rem; margin-bottom: .35rem; }
|
|
186
|
+
h1 { font-size: 1.35rem; margin: 0; font-weight: 600; }
|
|
187
|
+
h1 span { color: var(--critical); }
|
|
188
|
+
h2 { font-size: .95rem; font-weight: 600; margin: 0 0 .9rem; }
|
|
189
|
+
.meta { color: var(--muted); font-size: .82rem; margin: 0 0 1.5rem; }
|
|
190
|
+
|
|
191
|
+
.hero { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px;
|
|
192
|
+
padding: 1.4rem 1.5rem; margin-bottom: 1rem; }
|
|
193
|
+
.hero .label { color: var(--ink-2); font-size: .85rem; }
|
|
194
|
+
.hero .figure { font-size: 3.4rem; line-height: 1.05; font-weight: 600; margin: .1rem 0 .25rem; }
|
|
195
|
+
.hero .sub { color: var(--muted); font-size: .85rem; max-width: 62ch; }
|
|
196
|
+
|
|
197
|
+
.tiles { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); margin-bottom: 1.5rem; }
|
|
198
|
+
.tile { background: var(--surface); border: 1px solid var(--ring); border-radius: 10px; padding: .85rem 1rem; }
|
|
199
|
+
.tile .label { color: var(--ink-2); font-size: .78rem; }
|
|
200
|
+
.tile .value { font-size: 1.75rem; font-weight: 600; line-height: 1.2; }
|
|
201
|
+
.tile .sub { color: var(--muted); font-size: .76rem; }
|
|
202
|
+
.tile.good .value { color: var(--good); }
|
|
203
|
+
.tile.critical .value { color: var(--critical); }
|
|
204
|
+
|
|
205
|
+
.grid { display: grid; gap: 1rem; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); margin-bottom: 1.5rem; }
|
|
206
|
+
.card { background: var(--surface); border: 1px solid var(--ring); border-radius: 12px; padding: 1.1rem 1.2rem 1.2rem; }
|
|
207
|
+
.card.full { grid-column: 1 / -1; }
|
|
208
|
+
figure { margin: 0; }
|
|
209
|
+
figcaption { color: var(--muted); font-size: .78rem; margin-top: .55rem; }
|
|
210
|
+
.note { color: var(--ink-2); font-size: .82rem; margin: -.2rem 0 .9rem; max-width: 62ch; line-height: 1.55; }
|
|
211
|
+
td.muted { color: var(--muted); }
|
|
212
|
+
svg { display: block; width: 100%; height: auto; overflow: visible; }
|
|
213
|
+
|
|
214
|
+
.legend { display: flex; flex-wrap: wrap; gap: .35rem .9rem; margin: .1rem 0 .6rem; font-size: .78rem; color: var(--ink-2); }
|
|
215
|
+
.legend i { width: 10px; height: 10px; border-radius: 2px; display: inline-block; margin-right: .35rem; vertical-align: -1px; }
|
|
216
|
+
|
|
217
|
+
.scroll { overflow-x: auto; }
|
|
218
|
+
table { border-collapse: collapse; width: 100%; font-size: .82rem; }
|
|
219
|
+
th, td { padding: .42rem .6rem; text-align: left; border-bottom: 1px solid var(--grid); white-space: nowrap; }
|
|
220
|
+
th { color: var(--muted); font-weight: 600; font-size: .74rem; text-transform: uppercase; letter-spacing: .04em; }
|
|
221
|
+
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
|
222
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .95em; }
|
|
223
|
+
|
|
224
|
+
.warn { background: color-mix(in srgb, var(--critical) 14%, var(--surface));
|
|
225
|
+
border: 1px solid color-mix(in srgb, var(--critical) 40%, transparent);
|
|
226
|
+
border-radius: 10px; padding: .85rem 1.1rem; margin-bottom: 1.5rem; }
|
|
227
|
+
.warn h2 { color: var(--critical); }
|
|
228
|
+
.warn ul { margin: 0; padding-left: 1.1rem; }
|
|
229
|
+
.warn li { margin: .2rem 0; }
|
|
230
|
+
|
|
231
|
+
.tip { position: fixed; pointer-events: none; opacity: 0; transition: opacity .1s;
|
|
232
|
+
background: var(--surface); color: var(--ink); border: 1px solid var(--ring);
|
|
233
|
+
border-radius: 8px; padding: .4rem .6rem; font-size: .78rem; box-shadow: 0 6px 20px rgba(0,0,0,.25);
|
|
234
|
+
z-index: 10; white-space: nowrap; }
|
|
235
|
+
details { margin-top: .8rem; }
|
|
236
|
+
summary { cursor: pointer; color: var(--ink-2); font-size: .8rem; }
|
|
237
|
+
</style>
|
|
238
|
+
|
|
239
|
+
<div class="wrap">
|
|
240
|
+
<header>
|
|
241
|
+
<h1><span>Redline</span> Dashboard — ${esc(ORG)}</h1>
|
|
242
|
+
</header>
|
|
243
|
+
<p class="meta">
|
|
244
|
+
Last ${esc(DAYS)} days · generated ${new Date().toISOString().slice(0, 16)}Z${
|
|
245
|
+
STANDARDS_VERSION ? ` · standards v${esc(STANDARDS_VERSION)}` : ''
|
|
246
|
+
}${staleHours !== null ? ` · newest record ${staleHours}h old` : ''}
|
|
247
|
+
</p>
|
|
248
|
+
|
|
249
|
+
${
|
|
250
|
+
warnings.length
|
|
251
|
+
? `<section class="warn"><h2>Needs attention</h2><ul>${warnings.map((w) => `<li>${esc(w)}</li>`).join('')}</ul></section>`
|
|
252
|
+
: ''
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
<section class="hero">
|
|
256
|
+
<div class="label">Findings acted on</div>
|
|
257
|
+
<div class="figure">${pct(agg.resolved, agg.findings)}</div>
|
|
258
|
+
<div class="sub">${agg.resolved} of ${agg.findings} automated findings ended in a resolved review thread.
|
|
259
|
+
This is the number the standards are tuned against: review nobody acts on is noise, however much of it there is.</div>
|
|
260
|
+
</section>
|
|
261
|
+
|
|
262
|
+
<section class="tiles">
|
|
263
|
+
${tiles
|
|
264
|
+
.map(
|
|
265
|
+
(t) => ` <div class="tile${t.status ? ` ${esc(t.status)}` : ''}">
|
|
266
|
+
<div class="label">${esc(t.label)}</div>
|
|
267
|
+
<div class="value">${esc(t.value)}</div>
|
|
268
|
+
<div class="sub">${esc(t.sub)}</div>
|
|
269
|
+
</div>`
|
|
270
|
+
)
|
|
271
|
+
.join('\n')}
|
|
272
|
+
</section>
|
|
273
|
+
|
|
274
|
+
<section class="grid">
|
|
275
|
+
<div class="card">
|
|
276
|
+
<h2>Findings per week</h2>
|
|
277
|
+
<figure><svg id="c-findings" role="img" aria-label="Automated findings per week"></svg>
|
|
278
|
+
<figcaption>Volume alone says nothing about value — read it beside the panel on the right.</figcaption></figure>
|
|
279
|
+
</div>
|
|
280
|
+
<div class="card">
|
|
281
|
+
<h2>Acted-on rate per week</h2>
|
|
282
|
+
<figure><svg id="c-acted" role="img" aria-label="Share of findings resolved, per week"></svg>
|
|
283
|
+
<figcaption>Share of that week's findings that ended in a resolved thread.</figcaption></figure>
|
|
284
|
+
</div>
|
|
285
|
+
|
|
286
|
+
<div class="card full">
|
|
287
|
+
<h2>Seed BLOCKER recall over time</h2>
|
|
288
|
+
<div class="legend" id="l-seeds"></div>
|
|
289
|
+
<figure><svg id="c-seeds" role="img" aria-label="Seed corpus BLOCKER recall per pilot repo over time"></svg>
|
|
290
|
+
<figcaption>Percentage of seeded BLOCKER defects the reviewer flagged. Anything under 100% blocks a wider rollout.</figcaption></figure>
|
|
291
|
+
</div>
|
|
292
|
+
|
|
293
|
+
<div class="card full">
|
|
294
|
+
<h2>Noisiest rules — the tuning queue</h2>
|
|
295
|
+
<figure><svg id="c-noisy" role="img" aria-label="Rules by share of findings ignored"></svg>
|
|
296
|
+
<figcaption>Rules that fire often and are rarely acted on. Cut, narrow, or downgrade these before adding new rules.</figcaption></figure>
|
|
297
|
+
</div>
|
|
298
|
+
</section>
|
|
299
|
+
|
|
300
|
+
${
|
|
301
|
+
ladder === null
|
|
302
|
+
? ''
|
|
303
|
+
: `<section class="card full" style="margin-bottom:1rem">
|
|
304
|
+
<h2>Enforcement ladder</h2>
|
|
305
|
+
<p class="note">A repository climbs on recorded evidence, not on assertion, and steps back
|
|
306
|
+
whenever it wants — the safe direction never needs permission. The security floor is not on
|
|
307
|
+
this ladder: dependency review and the secret scan block at every rung, including observe.</p>
|
|
308
|
+
<div class="scroll">
|
|
309
|
+
<table>
|
|
310
|
+
<thead><tr><th>Rung</th><th>Blocks on</th><th class="num">Repositories</th><th class="num">Share</th></tr></thead>
|
|
311
|
+
<tbody>
|
|
312
|
+
${[
|
|
313
|
+
['observe', 'nothing — reported and recorded'],
|
|
314
|
+
['warn', 'nothing — reported in the merge box'],
|
|
315
|
+
['block-blocker', 'a BLOCKER finding'],
|
|
316
|
+
['block-high', 'a BLOCKER or a HIGH'],
|
|
317
|
+
]
|
|
318
|
+
.map(
|
|
319
|
+
([rung, blocks]) =>
|
|
320
|
+
` <tr><td><code>${rung}</code></td><td>${blocks}</td><td class="num">${ladder.counts[rung]}</td><td class="num">${pct(ladder.counts[rung], ladder.total)}</td></tr>`
|
|
321
|
+
)
|
|
322
|
+
.join('\n')}
|
|
323
|
+
</tbody>
|
|
324
|
+
</table>
|
|
325
|
+
</div>
|
|
326
|
+
</section>
|
|
327
|
+
|
|
328
|
+
`
|
|
329
|
+
}<section class="card full" style="margin-bottom:1rem">
|
|
330
|
+
<h2>Finding sources</h2>
|
|
331
|
+
<p class="note">Two catalogues, deliberately not merged. Redline's findings drive rule tuning;
|
|
332
|
+
a scanner's rule ids belong to that scanner, and folding them together would tune Redline's
|
|
333
|
+
rules on another tool's noise. Ingested findings are measured only — they never gate a merge,
|
|
334
|
+
because gating on another tool's output makes Redline responsible for its false positives.</p>
|
|
335
|
+
<div class="scroll">
|
|
336
|
+
<table>
|
|
337
|
+
<thead><tr><th>Source</th><th>Tool</th><th class="num">Findings</th><th class="num">BLOCKER</th><th class="num">HIGH</th></tr></thead>
|
|
338
|
+
<tbody>
|
|
339
|
+
<tr><td>Redline</td><td>LLM review</td><td class="num">${agg.findings}</td><td class="num">${agg.blocker}</td><td class="num">${agg.high}</td></tr>
|
|
340
|
+
${
|
|
341
|
+
agg.scanner.byTool.length === 0
|
|
342
|
+
? ' <tr><td colspan="5" class="muted">No repository in this window emits code-scanning alerts. Roadmap open question 1 is answered by this row: if it stays empty, SARIF ingestion is not where the next effort belongs.</td></tr>'
|
|
343
|
+
: agg.scanner.byTool
|
|
344
|
+
.map(
|
|
345
|
+
(t) =>
|
|
346
|
+
` <tr><td>Ingested</td><td>${esc(t.tool)}</td><td class="num">${t.findings}</td><td class="num">—</td><td class="num">—</td></tr>`
|
|
347
|
+
)
|
|
348
|
+
.join('\n')
|
|
349
|
+
}
|
|
350
|
+
</tbody>
|
|
351
|
+
</table>
|
|
352
|
+
</div>
|
|
353
|
+
</section>
|
|
354
|
+
|
|
355
|
+
<section class="card full" style="margin-bottom:1rem">
|
|
356
|
+
<h2>Rules by volume</h2>
|
|
357
|
+
<div class="scroll">
|
|
358
|
+
<table>
|
|
359
|
+
<thead><tr><th>Rule</th><th>Severity</th><th class="num">Fired</th><th class="num">Repos</th><th class="num">Acted on</th><th class="num">Ignored</th></tr></thead>
|
|
360
|
+
<tbody>
|
|
361
|
+
${agg.byRule
|
|
362
|
+
.slice(0, 30)
|
|
363
|
+
.map(
|
|
364
|
+
(r) => ` <tr><td><code>${esc(r.id)}</code></td><td>${esc(r.severity ?? '—')}</td>
|
|
365
|
+
<td class="num">${r.fired}</td><td class="num">${r.repos}</td>
|
|
366
|
+
<td class="num">${Math.round(r.actedRate * 100)}%</td><td class="num">${Math.round(r.ignoredRate * 100)}%</td></tr>`
|
|
367
|
+
)
|
|
368
|
+
.join('\n')}
|
|
369
|
+
</tbody>
|
|
370
|
+
</table>
|
|
371
|
+
</div>
|
|
372
|
+
${
|
|
373
|
+
valuable.length
|
|
374
|
+
? `<details><summary>Rules earning their place (highest acted-on rate)</summary>
|
|
375
|
+
<div class="scroll"><table><thead><tr><th>Rule</th><th class="num">Fired</th><th class="num">Acted on</th></tr></thead><tbody>
|
|
376
|
+
${valuable.map((r) => ` <tr><td><code>${esc(r.id)}</code></td><td class="num">${r.fired}</td><td class="num">${Math.round(r.actedRate * 100)}%</td></tr>`).join('\n')}
|
|
377
|
+
</tbody></table></div></details>`
|
|
378
|
+
: ''
|
|
379
|
+
}
|
|
380
|
+
</section>
|
|
381
|
+
|
|
382
|
+
<section class="card full">
|
|
383
|
+
<h2>Repositories</h2>
|
|
384
|
+
<div class="scroll">
|
|
385
|
+
<table>
|
|
386
|
+
<thead><tr><th>Repo</th><th class="num">PRs</th><th class="num">Findings</th><th class="num">BLOCKER</th><th class="num">Ignored</th><th class="num">Exemptions</th></tr></thead>
|
|
387
|
+
<tbody>
|
|
388
|
+
${agg.byRepo
|
|
389
|
+
.map(
|
|
390
|
+
(r) => ` <tr><td>${esc(r.repo)}</td><td class="num">${r.prs}</td><td class="num">${r.findings}</td>
|
|
391
|
+
<td class="num">${r.blocker}</td><td class="num">${r.stale}</td><td class="num">${r.exempted}</td></tr>`
|
|
392
|
+
)
|
|
393
|
+
.join('\n')}
|
|
394
|
+
</tbody>
|
|
395
|
+
</table>
|
|
396
|
+
</div>
|
|
397
|
+
</section>
|
|
398
|
+
</div>
|
|
399
|
+
|
|
400
|
+
<div class="tip" id="tip"></div>
|
|
401
|
+
|
|
402
|
+
<script type="application/json" id="data">${json({
|
|
403
|
+
findings: trendFindings,
|
|
404
|
+
acted: trendActed,
|
|
405
|
+
seeds: seedSeries,
|
|
406
|
+
noisy: noisy.map((r) => ({ id: r.id, fired: r.fired, ignored: Math.round(r.ignoredRate * 100) })),
|
|
407
|
+
})}</script>
|
|
408
|
+
<script>
|
|
409
|
+
(() => {
|
|
410
|
+
const DATA = JSON.parse(document.getElementById('data').textContent);
|
|
411
|
+
const tip = document.getElementById('tip');
|
|
412
|
+
const SERIES = ['--s1', '--s2', '--s3', '--s4', '--s5'];
|
|
413
|
+
const ink = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
414
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
415
|
+
const el = (name, attrs = {}) => {
|
|
416
|
+
const node = document.createElementNS(NS, name);
|
|
417
|
+
for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
|
|
418
|
+
return node;
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const showTip = (evt, text) => {
|
|
422
|
+
tip.textContent = text;
|
|
423
|
+
tip.style.opacity = '1';
|
|
424
|
+
tip.style.left = Math.min(evt.clientX + 14, window.innerWidth - tip.offsetWidth - 8) + 'px';
|
|
425
|
+
tip.style.top = (evt.clientY - 34) + 'px';
|
|
426
|
+
};
|
|
427
|
+
const hideTip = () => { tip.style.opacity = '0'; };
|
|
428
|
+
|
|
429
|
+
const niceMax = (v) => {
|
|
430
|
+
if (v <= 0) return 1;
|
|
431
|
+
const mag = Math.pow(10, Math.floor(Math.log10(v)));
|
|
432
|
+
return Math.ceil(v / mag) * mag;
|
|
433
|
+
};
|
|
434
|
+
|
|
435
|
+
// Line chart. One or more series; x is a date string, y a number.
|
|
436
|
+
function line(id, series, { unit = '', yMax = null, labelSeries = true, w = 460, h = 230 } = {}) {
|
|
437
|
+
const svg = document.getElementById(id);
|
|
438
|
+
if (!svg) return;
|
|
439
|
+
const W = w, H = h, P = { t: 12, r: labelSeries && series.length > 1 ? 118 : 26, b: 30, l: 40 };
|
|
440
|
+
svg.setAttribute('viewBox', \`0 0 \${W} \${H}\`);
|
|
441
|
+
svg.replaceChildren();
|
|
442
|
+
|
|
443
|
+
const points = series.flatMap((s) => s.points);
|
|
444
|
+
if (!points.length) {
|
|
445
|
+
svg.append(el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: ink('--muted'), 'font-size': 12 }));
|
|
446
|
+
svg.lastChild.textContent = 'No data yet';
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const xs = [...new Set(points.map((p) => p.x))].sort();
|
|
450
|
+
const max = yMax ?? niceMax(Math.max(...points.map((p) => p.y)));
|
|
451
|
+
const px = (x) => P.l + (xs.length < 2 ? (W - P.l - P.r) / 2 : (xs.indexOf(x) / (xs.length - 1)) * (W - P.l - P.r));
|
|
452
|
+
const py = (y) => H - P.b - (y / max) * (H - P.t - P.b);
|
|
453
|
+
|
|
454
|
+
// Recessive solid hairline grid, four bands.
|
|
455
|
+
for (let i = 0; i <= 4; i++) {
|
|
456
|
+
const v = (max / 4) * i;
|
|
457
|
+
svg.append(el('line', { x1: P.l, x2: W - P.r, y1: py(v), y2: py(v), stroke: ink(i ? '--grid' : '--axis'), 'stroke-width': 1 }));
|
|
458
|
+
const t = el('text', { x: P.l - 8, y: py(v) + 4, 'text-anchor': 'end', fill: ink('--muted'), 'font-size': 10, style: 'font-variant-numeric:tabular-nums' });
|
|
459
|
+
t.textContent = Math.round(v) + unit;
|
|
460
|
+
svg.append(t);
|
|
461
|
+
}
|
|
462
|
+
const step = Math.max(1, Math.ceil(xs.length / 6));
|
|
463
|
+
xs.forEach((x, i) => {
|
|
464
|
+
if (i % step && i !== xs.length - 1) return;
|
|
465
|
+
const t = el('text', { x: px(x), y: H - P.b + 16, 'text-anchor': 'middle', fill: ink('--muted'), 'font-size': 10 });
|
|
466
|
+
t.textContent = x.slice(5);
|
|
467
|
+
svg.append(t);
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
const endLabels = [];
|
|
471
|
+
series.forEach((s, si) => {
|
|
472
|
+
const colour = ink(SERIES[si % SERIES.length]);
|
|
473
|
+
const pts = s.points.slice().sort((a, b) => a.x.localeCompare(b.x));
|
|
474
|
+
if (pts.length > 1) {
|
|
475
|
+
svg.append(el('polyline', {
|
|
476
|
+
points: pts.map((p) => \`\${px(p.x)},\${py(p.y)}\`).join(' '),
|
|
477
|
+
fill: 'none', stroke: colour, 'stroke-width': 2, 'stroke-linejoin': 'round', 'stroke-linecap': 'round',
|
|
478
|
+
}));
|
|
479
|
+
}
|
|
480
|
+
pts.forEach((p) => {
|
|
481
|
+
// 2px surface ring so overlapping markers stay separable.
|
|
482
|
+
svg.append(el('circle', { cx: px(p.x), cy: py(p.y), r: 4.5, fill: colour, stroke: ink('--surface'), 'stroke-width': 2 }));
|
|
483
|
+
const hit = el('circle', { cx: px(p.x), cy: py(p.y), r: 13, fill: 'transparent', tabindex: '0' });
|
|
484
|
+
const text = \`\${s.name ? s.name + ' · ' : ''}\${p.x}: \${p.y}\${unit}\`;
|
|
485
|
+
hit.addEventListener('mouseenter', (e) => showTip(e, text));
|
|
486
|
+
hit.addEventListener('mousemove', (e) => showTip(e, text));
|
|
487
|
+
hit.addEventListener('mouseleave', hideTip);
|
|
488
|
+
hit.addEventListener('focus', (e) => showTip({ clientX: hit.getBoundingClientRect().x, clientY: hit.getBoundingClientRect().y }, text));
|
|
489
|
+
hit.addEventListener('blur', hideTip);
|
|
490
|
+
svg.append(hit);
|
|
491
|
+
});
|
|
492
|
+
// Direct-label the endpoint only, and only while the series count stays readable.
|
|
493
|
+
if (labelSeries && series.length > 1 && series.length <= 4 && pts.length) {
|
|
494
|
+
const last = pts[pts.length - 1];
|
|
495
|
+
endLabels.push({ name: s.name, x: px(last.x) + 10, y: py(last.y) + 4, colour });
|
|
496
|
+
}
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
// Series that finish at the same value would otherwise print on top of each other.
|
|
500
|
+
endLabels.sort((a, b) => a.y - b.y);
|
|
501
|
+
const MIN_GAP = 13;
|
|
502
|
+
for (let i = 1; i < endLabels.length; i++) {
|
|
503
|
+
if (endLabels[i].y - endLabels[i - 1].y < MIN_GAP) endLabels[i].y = endLabels[i - 1].y + MIN_GAP;
|
|
504
|
+
}
|
|
505
|
+
for (const label of endLabels) {
|
|
506
|
+
const t = el('text', { x: label.x, y: label.y, fill: ink('--ink-2'), 'font-size': 10 });
|
|
507
|
+
t.textContent = label.name;
|
|
508
|
+
svg.append(t);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Horizontal bars, magnitude only. Values sit outside the bar end so nothing is clipped.
|
|
513
|
+
function bars(id, rows) {
|
|
514
|
+
const svg = document.getElementById(id);
|
|
515
|
+
if (!svg) return;
|
|
516
|
+
const rowH = 22, W = 1100, P = { t: 6, r: 60, b: 26, l: 300 };
|
|
517
|
+
const H = P.t + P.b + Math.max(rows.length, 1) * rowH;
|
|
518
|
+
svg.setAttribute('viewBox', \`0 0 \${W} \${H}\`);
|
|
519
|
+
svg.replaceChildren();
|
|
520
|
+
if (!rows.length) {
|
|
521
|
+
const t = el('text', { x: W / 2, y: H / 2, 'text-anchor': 'middle', fill: ink('--muted'), 'font-size': 12 });
|
|
522
|
+
t.textContent = 'No rule is being ignored often enough to flag. Nothing to tune.';
|
|
523
|
+
svg.append(t);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const max = 100;
|
|
527
|
+
const scale = (v) => (v / max) * (W - P.l - P.r);
|
|
528
|
+
svg.append(el('line', { x1: P.l, x2: P.l, y1: P.t, y2: H - P.b, stroke: ink('--axis'), 'stroke-width': 1 }));
|
|
529
|
+
|
|
530
|
+
rows.forEach((row, i) => {
|
|
531
|
+
// 2px surface gap between adjacent bars rather than a border around them.
|
|
532
|
+
const y = P.t + i * rowH + 3;
|
|
533
|
+
const h = rowH - 9;
|
|
534
|
+
const label = el('text', { x: P.l - 10, y: y + h / 2 + 4, 'text-anchor': 'end', fill: ink('--ink-2'), 'font-size': 11 });
|
|
535
|
+
label.textContent = row.id.length > 44 ? row.id.slice(0, 43) + '…' : row.id;
|
|
536
|
+
svg.append(label);
|
|
537
|
+
|
|
538
|
+
const bar = el('rect', { x: P.l, y, width: Math.max(scale(row.ignored), 2), height: h, rx: 4, fill: ink('--s2') });
|
|
539
|
+
const text = \`\${row.id} — fired \${row.fired}×, \${row.ignored}% ignored\`;
|
|
540
|
+
bar.addEventListener('mouseenter', (e) => showTip(e, text));
|
|
541
|
+
bar.addEventListener('mousemove', (e) => showTip(e, text));
|
|
542
|
+
bar.addEventListener('mouseleave', hideTip);
|
|
543
|
+
svg.append(bar);
|
|
544
|
+
|
|
545
|
+
const value = el('text', { x: P.l + scale(row.ignored) + 8, y: y + h / 2 + 4, fill: ink('--ink-2'), 'font-size': 11, style: 'font-variant-numeric:tabular-nums' });
|
|
546
|
+
value.textContent = row.ignored + '%';
|
|
547
|
+
svg.append(value);
|
|
548
|
+
});
|
|
549
|
+
|
|
550
|
+
const axis = el('text', { x: P.l, y: H - 6, fill: ink('--muted'), 'font-size': 10 });
|
|
551
|
+
axis.textContent = 'share of this rule\\u2019s findings left unresolved and outdated';
|
|
552
|
+
svg.append(axis);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
const legend = document.getElementById('l-seeds');
|
|
556
|
+
DATA.seeds.forEach((s, i) => {
|
|
557
|
+
const span = document.createElement('span');
|
|
558
|
+
const swatch = document.createElement('i');
|
|
559
|
+
swatch.style.background = ink(SERIES[i % SERIES.length]);
|
|
560
|
+
span.append(swatch, s.name);
|
|
561
|
+
legend.append(span);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const draw = () => {
|
|
565
|
+
line('c-findings', [{ name: 'Findings', points: DATA.findings }]);
|
|
566
|
+
line('c-acted', [{ name: 'Acted on', points: DATA.acted }], { unit: '%', yMax: 100 });
|
|
567
|
+
// Full-width card: a wider viewBox keeps the plot from stretching into a tall band
|
|
568
|
+
// and keeps tick text at its intended size.
|
|
569
|
+
line('c-seeds', DATA.seeds, { unit: '%', yMax: 100, w: 1100, h: 215 });
|
|
570
|
+
bars('c-noisy', DATA.noisy);
|
|
571
|
+
};
|
|
572
|
+
draw();
|
|
573
|
+
// Marks read their colour from CSS custom properties at draw time, so a theme change
|
|
574
|
+
// has to trigger a redraw — from the OS setting, or from a data-theme stamp.
|
|
575
|
+
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw);
|
|
576
|
+
new MutationObserver(draw).observe(document.documentElement, { attributeFilter: ['data-theme'] });
|
|
577
|
+
})();
|
|
578
|
+
</script>`;
|
|
579
|
+
|
|
580
|
+
mkdirSync(OUT, { recursive: true });
|
|
581
|
+
writeFileSync(join(OUT, 'index.html'), html);
|
|
582
|
+
console.log(
|
|
583
|
+
`dashboard: ${agg.prs} PRs, ${agg.findings} findings, ${agg.byRule.length} rules, ${warnings.length} warning(s) → ${join(OUT, 'index.html')}`
|
|
584
|
+
);
|