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.
Files changed (198) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +188 -0
  3. package/commands/redline-init.md +54 -0
  4. package/commands/redline-verify.md +69 -0
  5. package/dist/bin/redline.js +605 -0
  6. package/dist/bin/redline.js.map +1 -0
  7. package/dist/commands/exempt.js +45 -0
  8. package/dist/commands/exempt.js.map +1 -0
  9. package/dist/commands/init.js +615 -0
  10. package/dist/commands/init.js.map +1 -0
  11. package/dist/commands/policy.js +25 -0
  12. package/dist/commands/policy.js.map +1 -0
  13. package/dist/commands/remove.js +427 -0
  14. package/dist/commands/remove.js.map +1 -0
  15. package/dist/commands/review.js +72 -0
  16. package/dist/commands/review.js.map +1 -0
  17. package/dist/commands/sync.js +42 -0
  18. package/dist/commands/sync.js.map +1 -0
  19. package/dist/commands/verify.js +478 -0
  20. package/dist/commands/verify.js.map +1 -0
  21. package/dist/config/redline-json.js +187 -0
  22. package/dist/config/redline-json.js.map +1 -0
  23. package/dist/core/errors.js +25 -0
  24. package/dist/core/errors.js.map +1 -0
  25. package/dist/core/git.js +152 -0
  26. package/dist/core/git.js.map +1 -0
  27. package/dist/core/log.js +22 -0
  28. package/dist/core/log.js.map +1 -0
  29. package/dist/core/severity.js +16 -0
  30. package/dist/core/severity.js.map +1 -0
  31. package/dist/core/version.js +15 -0
  32. package/dist/core/version.js.map +1 -0
  33. package/dist/detect/scan.js +79 -0
  34. package/dist/detect/scan.js.map +1 -0
  35. package/dist/detect/stack.js +71 -0
  36. package/dist/detect/stack.js.map +1 -0
  37. package/dist/enforce/ladder.js +148 -0
  38. package/dist/enforce/ladder.js.map +1 -0
  39. package/dist/exempt/parse.js +86 -0
  40. package/dist/exempt/parse.js.map +1 -0
  41. package/dist/metrics/options.js +257 -0
  42. package/dist/metrics/options.js.map +1 -0
  43. package/dist/metrics/run.js +72 -0
  44. package/dist/metrics/run.js.map +1 -0
  45. package/dist/platforms/azure/client.js +53 -0
  46. package/dist/platforms/azure/client.js.map +1 -0
  47. package/dist/platforms/azure/index.js +69 -0
  48. package/dist/platforms/azure/index.js.map +1 -0
  49. package/dist/platforms/azure/install.js +917 -0
  50. package/dist/platforms/azure/install.js.map +1 -0
  51. package/dist/platforms/azure/policy-types.js +70 -0
  52. package/dist/platforms/azure/policy-types.js.map +1 -0
  53. package/dist/platforms/azure/verify.js +309 -0
  54. package/dist/platforms/azure/verify.js.map +1 -0
  55. package/dist/platforms/detect.js +40 -0
  56. package/dist/platforms/detect.js.map +1 -0
  57. package/dist/platforms/github/client.js +49 -0
  58. package/dist/platforms/github/client.js.map +1 -0
  59. package/dist/platforms/github/index.js +54 -0
  60. package/dist/platforms/github/index.js.map +1 -0
  61. package/dist/platforms/github/install.js +558 -0
  62. package/dist/platforms/github/install.js.map +1 -0
  63. package/dist/platforms/github/push.js +102 -0
  64. package/dist/platforms/github/push.js.map +1 -0
  65. package/dist/platforms/github/remote.js +36 -0
  66. package/dist/platforms/github/remote.js.map +1 -0
  67. package/dist/platforms/github/verify.js +366 -0
  68. package/dist/platforms/github/verify.js.map +1 -0
  69. package/dist/platforms/http.js +58 -0
  70. package/dist/platforms/http.js.map +1 -0
  71. package/dist/platforms/pull-request-templates.js +157 -0
  72. package/dist/platforms/pull-request-templates.js.map +1 -0
  73. package/dist/platforms/remote.js +21 -0
  74. package/dist/platforms/remote.js.map +1 -0
  75. package/dist/platforms/resolve.js +69 -0
  76. package/dist/platforms/resolve.js.map +1 -0
  77. package/dist/platforms/shape.js +19 -0
  78. package/dist/platforms/shape.js.map +1 -0
  79. package/dist/platforms/types.js +24 -0
  80. package/dist/platforms/types.js.map +1 -0
  81. package/dist/policy/checks.js +91 -0
  82. package/dist/policy/checks.js.map +1 -0
  83. package/dist/policy/diff.js +50 -0
  84. package/dist/policy/diff.js.map +1 -0
  85. package/dist/registry/discover.js +74 -0
  86. package/dist/registry/discover.js.map +1 -0
  87. package/dist/registry/serialize.js +41 -0
  88. package/dist/registry/serialize.js.map +1 -0
  89. package/dist/registry/types.js +2 -0
  90. package/dist/registry/types.js.map +1 -0
  91. package/dist/remove/host.js +294 -0
  92. package/dist/remove/host.js.map +1 -0
  93. package/dist/render/commands.js +139 -0
  94. package/dist/render/commands.js.map +1 -0
  95. package/dist/render/manifest.js +82 -0
  96. package/dist/render/manifest.js.map +1 -0
  97. package/dist/render/markers.js +166 -0
  98. package/dist/render/markers.js.map +1 -0
  99. package/dist/render/profile.js +23 -0
  100. package/dist/render/profile.js.map +1 -0
  101. package/dist/render/standards.js +169 -0
  102. package/dist/render/standards.js.map +1 -0
  103. package/dist/render/vendors.js +199 -0
  104. package/dist/render/vendors.js.map +1 -0
  105. package/dist/review/engines/api.js +95 -0
  106. package/dist/review/engines/api.js.map +1 -0
  107. package/dist/review/engines/embedded.js +20 -0
  108. package/dist/review/engines/embedded.js.map +1 -0
  109. package/dist/review/engines/types.js +2 -0
  110. package/dist/review/engines/types.js.map +1 -0
  111. package/dist/review/glob.js +29 -0
  112. package/dist/review/glob.js.map +1 -0
  113. package/dist/review/prompt.js +45 -0
  114. package/dist/review/prompt.js.map +1 -0
  115. package/dist/review/rules.js +25 -0
  116. package/dist/review/rules.js.map +1 -0
  117. package/dist/review/schema.js +84 -0
  118. package/dist/review/schema.js.map +1 -0
  119. package/dist/review/scope.js +27 -0
  120. package/dist/review/scope.js.map +1 -0
  121. package/dist/sarif/map.js +58 -0
  122. package/dist/sarif/map.js.map +1 -0
  123. package/dist/sarif/parse.js +88 -0
  124. package/dist/sarif/parse.js.map +1 -0
  125. package/dist/sarif/types.js +2 -0
  126. package/dist/sarif/types.js.map +1 -0
  127. package/dist/sync/host.js +21 -0
  128. package/dist/sync/host.js.map +1 -0
  129. package/dist/sync/plan.js +47 -0
  130. package/dist/sync/plan.js.map +1 -0
  131. package/dist/sync/render.js +57 -0
  132. package/dist/sync/render.js.map +1 -0
  133. package/dist/sync/run.js +118 -0
  134. package/dist/sync/run.js.map +1 -0
  135. package/dist/verify/host.js +52 -0
  136. package/dist/verify/host.js.map +1 -0
  137. package/dist/verify/remote.js +146 -0
  138. package/dist/verify/remote.js.map +1 -0
  139. package/package.json +52 -0
  140. package/platforms/azure/gate-template.yml +105 -0
  141. package/rulesets/redline-org-ruleset.json +46 -0
  142. package/rulesets/redline-ruleset.json +49 -0
  143. package/scripts/assign-rule-ids.mjs +129 -0
  144. package/scripts/build-baseline.mjs +124 -0
  145. package/scripts/build-correlation.mjs +74 -0
  146. package/scripts/build-dashboard.mjs +584 -0
  147. package/scripts/build-digest.mjs +164 -0
  148. package/scripts/build-inbox.mjs +133 -0
  149. package/scripts/build-registry.mjs +42 -0
  150. package/scripts/build-roi.mjs +181 -0
  151. package/scripts/check-pins.mjs +88 -0
  152. package/scripts/collect-telemetry.mjs +300 -0
  153. package/scripts/lib/__tests__/baseline.test.mjs +132 -0
  154. package/scripts/lib/__tests__/correlate.test.mjs +131 -0
  155. package/scripts/lib/__tests__/dora.test.mjs +110 -0
  156. package/scripts/lib/__tests__/exemptions.test.mjs +85 -0
  157. package/scripts/lib/__tests__/metrics-scanner.test.mjs +65 -0
  158. package/scripts/lib/__tests__/sarif.test.mjs +125 -0
  159. package/scripts/lib/__tests__/spend.test.mjs +64 -0
  160. package/scripts/lib/baseline.mjs +156 -0
  161. package/scripts/lib/correlate.mjs +125 -0
  162. package/scripts/lib/dora.mjs +133 -0
  163. package/scripts/lib/exemptions.mjs +79 -0
  164. package/scripts/lib/metrics.mjs +172 -0
  165. package/scripts/lib/rules.mjs +107 -0
  166. package/scripts/lib/sarif.mjs +108 -0
  167. package/scripts/lib/spend.mjs +69 -0
  168. package/scripts/measure-context.mjs +101 -0
  169. package/scripts/render-self.mjs +18 -0
  170. package/scripts/score-seeds.mjs +294 -0
  171. package/scripts/validate.mjs +394 -0
  172. package/standards/core.md +124 -0
  173. package/standards/manifest.json +214 -0
  174. package/standards/stacks/csharp.md +29 -0
  175. package/standards/stacks/go.md +30 -0
  176. package/standards/stacks/java.md +30 -0
  177. package/standards/stacks/javascript.md +42 -0
  178. package/standards/stacks/kotlin.md +27 -0
  179. package/standards/stacks/microservices.md +37 -0
  180. package/standards/stacks/nodejs.md +34 -0
  181. package/standards/stacks/python.md +29 -0
  182. package/standards/stacks/react-native.md +29 -0
  183. package/standards/stacks/react.md +46 -0
  184. package/standards/stacks/swift.md +26 -0
  185. package/standards/stacks/terraform.md +27 -0
  186. package/templates/CODEOWNERS +29 -0
  187. package/templates/azure/pull_request_template.md +72 -0
  188. package/templates/github/pull_request_template.md +72 -0
  189. package/templates/redline.yml +35 -0
  190. package/templates/repo-context.md +64 -0
  191. package/workflows/dashboard.yml +119 -0
  192. package/workflows/inbox.yml +97 -0
  193. package/workflows/redline-collect.yml +61 -0
  194. package/workflows/redline-gate.yml +301 -0
  195. package/workflows/redline-sync.yml +79 -0
  196. package/workflows/seed-canary.yml +231 -0
  197. package/workflows/verify-onboarding.yml +115 -0
  198. package/workflows/weekly-digest.yml +78 -0
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env node
2
+ // Builds the weekly stakeholder digest from collected telemetry and writes an Adaptive
3
+ // Card payload to stdout (or --out <file>).
4
+ //
5
+ // Microsoft retired Office 365 connectors, so the old `{"text": "..."}` webhook shape no
6
+ // longer delivers. The target is a Power Automate "When a Teams webhook request is
7
+ // received" flow, which expects an Adaptive Card.
8
+ //
9
+ // Env: DATA_DIR (default data), DAYS (default 7), ORG, [OPEN_PRS], [STALE_PRS], [CAPPED],
10
+ // [DASHBOARD_URL], [SEED_SCORES=data/seed-scores.jsonl]
11
+
12
+ import { writeFileSync, readFileSync, existsSync } from 'node:fs';
13
+ import { loadRecords, aggregate, noisiestRules, pct } from './lib/metrics.mjs';
14
+
15
+ const {
16
+ DATA_DIR = 'data',
17
+ DAYS = '7',
18
+ ORG = 'org',
19
+ OPEN_PRS = '',
20
+ STALE_PRS = '',
21
+ CAPPED = '',
22
+ DASHBOARD_URL = '',
23
+ SEED_SCORES = 'data/seed-scores.jsonl',
24
+ } = process.env;
25
+
26
+ const outIndex = process.argv.indexOf('--out');
27
+ const outFile = outIndex === -1 ? null : process.argv[outIndex + 1];
28
+
29
+ const since = new Date(Date.now() - Number(DAYS) * 86400000).toISOString();
30
+ const { records, problems } = loadRecords(DATA_DIR, since);
31
+ const agg = aggregate(records);
32
+ const noisy = noisiestRules(agg);
33
+
34
+ // Latest seed score per repo, so the digest reports whether review quality is verified
35
+ // rather than assumed.
36
+ const seedScores = [];
37
+ if (existsSync(SEED_SCORES)) {
38
+ const latest = new Map();
39
+ for (const line of readFileSync(SEED_SCORES, 'utf8').split('\n')) {
40
+ if (!line.trim()) continue;
41
+ try {
42
+ const score = JSON.parse(line);
43
+ latest.set(score.repo, score);
44
+ } catch {
45
+ problems.push('seed-scores.jsonl has an unparseable line');
46
+ }
47
+ }
48
+ seedScores.push(...latest.values());
49
+ }
50
+
51
+ const facts = [
52
+ { title: 'PRs merged (instrumented)', value: `${agg.prs} across ${agg.repos} repo(s)` },
53
+ { title: 'PRs with findings', value: `${agg.prsWithFindings} (${pct(agg.prsWithFindings, agg.prs)})` },
54
+ { title: 'Findings', value: `${agg.findings} — ${agg.blocker} BLOCKER, ${agg.high} HIGH` },
55
+ { title: 'Acted on (thread resolved)', value: `${agg.resolved} (${pct(agg.resolved, agg.findings)})` },
56
+ { title: 'Ignored (stale + outdated)', value: `${agg.stale} (${pct(agg.stale, agg.findings)})` },
57
+ { title: 'Gate exemptions used', value: `${agg.exempted} (${pct(agg.exempted, agg.prs)})` },
58
+ ];
59
+ if (OPEN_PRS) {
60
+ facts.push({
61
+ title: 'Open PRs',
62
+ value: OPEN_PRS + (STALE_PRS ? ` — ${STALE_PRS} idle over 7 days` : ''),
63
+ });
64
+ }
65
+ if (seedScores.length) {
66
+ const worst = seedScores.reduce((a, b) =>
67
+ a.totals.blocker_recall <= b.totals.blocker_recall ? a : b
68
+ );
69
+ facts.push({
70
+ title: 'Seed validation (worst repo)',
71
+ value: `${worst.repo}: BLOCKER recall ${Math.round(worst.totals.blocker_recall * 100)}%, ${worst.totals.false_positives_on_clean} false positive(s)`,
72
+ });
73
+ }
74
+
75
+ const topRepos = agg.byRepo.slice(0, 5);
76
+
77
+ const warnings = [];
78
+ if (agg.prs === 0) warnings.push('No telemetry for this window. The collector may be failing — check redline-collect.');
79
+ if (agg.withoutRuleId > 0) {
80
+ warnings.push(
81
+ `${agg.withoutRuleId} finding(s) carried no rule id — per-rule tuning is blind for those repos until the current standards land.`
82
+ );
83
+ }
84
+ if (agg.unknownRuleIds > 0) warnings.push(`${agg.unknownRuleIds} finding(s) cited a rule id that is not in the catalogue.`);
85
+ if (agg.untagged > 0) warnings.push(`${agg.untagged} finding(s) ignored the output contract entirely — severity is a guess for those.`);
86
+ if (agg.findings > 0 && agg.stale / agg.findings > 0.3) {
87
+ warnings.push('Over 30% of findings are being ignored. Tune the noisiest rules below before adding new ones.');
88
+ }
89
+ if (seedScores.some((s) => s.totals.blocker_recall < 1)) {
90
+ warnings.push('At least one pilot repo is below 100% BLOCKER recall on the seed corpus. Do not widen the rollout.');
91
+ }
92
+ if (CAPPED) warnings.push('Search result cap was hit during collection — counts are a lower bound.');
93
+ warnings.push(...problems);
94
+
95
+ const bullets = (rows) => rows.join('\n');
96
+
97
+ const card = {
98
+ type: 'message',
99
+ attachments: [
100
+ {
101
+ contentType: 'application/vnd.microsoft.card.adaptive',
102
+ contentUrl: null,
103
+ content: {
104
+ $schema: 'http://adaptivecards.io/schemas/adaptive-card.json',
105
+ type: 'AdaptiveCard',
106
+ version: '1.4',
107
+ body: [
108
+ { type: 'TextBlock', size: 'Large', weight: 'Bolder', text: `Redline weekly — ${ORG}` },
109
+ {
110
+ type: 'TextBlock',
111
+ isSubtle: true,
112
+ spacing: 'None',
113
+ text: `Last ${DAYS} days · generated ${new Date().toISOString().slice(0, 16)}Z`,
114
+ },
115
+ { type: 'FactSet', facts },
116
+ ...(noisy.length
117
+ ? [
118
+ { type: 'TextBlock', weight: 'Bolder', text: 'Noisiest rules (tuning queue)', spacing: 'Medium' },
119
+ {
120
+ type: 'TextBlock',
121
+ wrap: true,
122
+ text: bullets(
123
+ noisy.map(
124
+ (r) =>
125
+ `- \`${r.id}\` — fired ${r.fired}× across ${r.repos} repo(s), ${Math.round(r.ignoredRate * 100)}% ignored`
126
+ )
127
+ ),
128
+ },
129
+ ]
130
+ : []),
131
+ ...(topRepos.length
132
+ ? [
133
+ { type: 'TextBlock', weight: 'Bolder', text: 'Most findings', spacing: 'Medium' },
134
+ {
135
+ type: 'TextBlock',
136
+ wrap: true,
137
+ text: bullets(
138
+ topRepos.map((r) => `- ${r.repo}: ${r.findings} (${r.blocker} BLOCKER, ${r.stale} ignored)`)
139
+ ),
140
+ },
141
+ ]
142
+ : []),
143
+ ...(warnings.length
144
+ ? [
145
+ { type: 'TextBlock', weight: 'Bolder', color: 'Attention', text: 'Needs attention', spacing: 'Medium' },
146
+ { type: 'TextBlock', wrap: true, color: 'Attention', text: bullets(warnings.map((w) => `- ${w}`)) },
147
+ ]
148
+ : []),
149
+ ],
150
+ ...(DASHBOARD_URL
151
+ ? { actions: [{ type: 'Action.OpenUrl', title: 'Open dashboard', url: DASHBOARD_URL }] }
152
+ : {}),
153
+ },
154
+ },
155
+ ],
156
+ };
157
+
158
+ const payload = JSON.stringify(card);
159
+ if (outFile) {
160
+ writeFileSync(outFile, payload);
161
+ console.error(`digest written to ${outFile} (${agg.prs} PRs, ${agg.findings} findings, ${warnings.length} warning(s))`);
162
+ } else {
163
+ process.stdout.write(payload);
164
+ }
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ // Zero-infra engineering inbox: aggregates org PRs into one prioritised static page.
3
+ // Run by workflows/inbox.yml on a schedule, deployed to GitHub Pages.
4
+ //
5
+ // NOTE: this page lists PR titles, authors and repo names. Publish it to a PRIVATE or
6
+ // INTERNAL Pages site only. workflows/inbox.yml refuses to build unless the visibility
7
+ // has been acknowledged.
8
+ //
9
+ // Env: GH_TOKEN (org read), ORG, [OUT=dist], [MAX_PAGES=10]
10
+
11
+ import { mkdirSync, writeFileSync } from 'node:fs';
12
+
13
+ const { GH_TOKEN, ORG, OUT = 'dist', MAX_PAGES = '10' } = process.env;
14
+ if (!GH_TOKEN || !ORG) throw new Error('GH_TOKEN and ORG are required');
15
+
16
+ const api = async (path) => {
17
+ const res = await fetch(`https://api.github.com${path}`, {
18
+ headers: { Authorization: `Bearer ${GH_TOKEN}`, Accept: 'application/vnd.github+json' },
19
+ });
20
+ if (res.status === 403 && res.headers.get('x-ratelimit-remaining') === '0') {
21
+ const reset = new Date(Number(res.headers.get('x-ratelimit-reset')) * 1000).toISOString();
22
+ throw new Error(`rate limited until ${reset}`);
23
+ }
24
+ if (!res.ok) throw new Error(`${path}: ${res.status} ${await res.text()}`);
25
+ return res.json();
26
+ };
27
+
28
+ // The search API caps at 1000 results (10 pages of 100). Paginate to that ceiling and
29
+ // report when a bucket is truncated rather than silently under-reporting.
30
+ async function search(q) {
31
+ const items = [];
32
+ let total = 0;
33
+ for (let page = 1; page <= Number(MAX_PAGES); page++) {
34
+ const result = await api(
35
+ `/search/issues?q=${encodeURIComponent(q)}&per_page=100&page=${page}&sort=updated&order=desc`
36
+ );
37
+ total = result.total_count ?? 0;
38
+ items.push(...(result.items ?? []));
39
+ if (!result.items?.length || items.length >= total) break;
40
+ }
41
+ return { items, total, truncated: total > items.length };
42
+ }
43
+
44
+ const days = (iso) => Math.floor((Date.now() - new Date(iso)) / 86400000);
45
+ const isoDay = (offsetDays) =>
46
+ new Date(Date.now() - offsetDays * 86400000).toISOString().slice(0, 10);
47
+
48
+ const base = `org:${ORG} is:pr is:open draft:false`;
49
+ const buckets = [
50
+ { label: 'Gate failing', priority: 1, q: `${base} status:failure` },
51
+ { label: 'Changes requested', priority: 2, q: `${base} review:changes_requested` },
52
+ { label: 'Awaiting review', priority: 3, q: `${base} review:required` },
53
+ { label: 'Stale (>7d idle)', priority: 4, q: `${base} updated:<${isoDay(7)}` },
54
+ ];
55
+
56
+ const results = await Promise.all(buckets.map((b) => search(b.q)));
57
+
58
+ const seen = new Set();
59
+ const rows = [];
60
+ const truncated = [];
61
+ buckets.forEach((bucket, i) => {
62
+ const { items, total, truncated: cut } = results[i];
63
+ if (cut) truncated.push(`${bucket.label} (${items.length} of ${total})`);
64
+ for (const item of items) {
65
+ if (seen.has(item.id)) continue;
66
+ seen.add(item.id);
67
+ rows.push({
68
+ priority: bucket.priority,
69
+ label: bucket.label,
70
+ repo: item.repository_url.split('/').slice(-1)[0],
71
+ number: item.number,
72
+ title: item.title,
73
+ author: item.user?.login ?? '?',
74
+ age: days(item.created_at),
75
+ idle: days(item.updated_at),
76
+ url: item.html_url,
77
+ });
78
+ }
79
+ });
80
+ rows.sort((a, b) => a.priority - b.priority || b.idle - a.idle);
81
+
82
+ const esc = (s) =>
83
+ String(s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
84
+ const badge = { 1: '#b91c1c', 2: '#c2410c', 3: '#1d4ed8', 4: '#6b7280' };
85
+ const counts = buckets.map((b) => `${b.label}: ${rows.filter((r) => r.label === b.label).length}`).join(' · ');
86
+
87
+ const html = `<!doctype html><meta charset="utf-8">
88
+ <meta name="viewport" content="width=device-width,initial-scale=1">
89
+ <title>Redline Inbox — ${esc(ORG)}</title>
90
+ <style>
91
+ :root{color-scheme:dark}
92
+ body{font:14px/1.5 system-ui,-apple-system,sans-serif;margin:0;background:#0f1115;color:#e5e7eb;padding:2rem}
93
+ h1{font-size:1.3rem;margin:0 0 .25rem}h1 span{color:#ef4444}
94
+ table{border-collapse:collapse;width:100%;max-width:1200px}
95
+ td,th{padding:.5rem .75rem;border-bottom:1px solid #1f2430;text-align:left;vertical-align:top}
96
+ th{color:#9ca3af;font-weight:600;font-size:.8rem;text-transform:uppercase;letter-spacing:.04em}
97
+ a{color:#93c5fd;text-decoration:none}a:hover{text-decoration:underline}
98
+ .b{border-radius:4px;padding:.1rem .5rem;font-size:.75rem;color:#fff;white-space:nowrap;display:inline-block}
99
+ .meta{color:#6b7280;font-size:.8rem;margin:.25rem 0 1rem}
100
+ .warn{background:#7f1d1d;color:#fee2e2;padding:.5rem .75rem;border-radius:6px;max-width:1200px;margin:0 0 1rem}
101
+ input{background:#151922;border:1px solid #2a3140;color:#e5e7eb;border-radius:6px;padding:.4rem .6rem;width:22rem;margin-bottom:1rem}
102
+ tr[hidden]{display:none}
103
+ </style>
104
+ <h1><span>Redline</span> Inbox — ${esc(ORG)}</h1>
105
+ <p class="meta">Generated ${new Date().toISOString()} · ${rows.length} PRs need attention · ${esc(counts)}</p>
106
+ ${truncated.length ? `<p class="warn">Result cap reached — counts are a lower bound for: ${esc(truncated.join(', '))}</p>` : ''}
107
+ ${rows.length === 0 ? '<p class="warn">No rows. Either the org has a clean board, or the token lost access — check the workflow run.</p>' : ''}
108
+ <input id="f" placeholder="Filter by repo, author, or title" autocomplete="off">
109
+ <table>
110
+ <thead><tr><th>State</th><th>PR</th><th>Repo</th><th>Author</th><th>Age</th><th>Idle</th></tr></thead>
111
+ <tbody id="rows">
112
+ ${rows
113
+ .map(
114
+ (r) => `<tr data-k="${esc(`${r.repo} ${r.author} ${r.title}`.toLowerCase())}">
115
+ <td><span class="b" style="background:${badge[r.priority]}">${esc(r.label)}</span></td>
116
+ <td><a href="${esc(r.url)}">#${r.number} ${esc(r.title)}</a></td>
117
+ <td>${esc(r.repo)}</td><td>${esc(r.author)}</td><td>${r.age}d</td><td>${r.idle}d</td></tr>`
118
+ )
119
+ .join('\n')}
120
+ </tbody>
121
+ </table>
122
+ <script>
123
+ const input = document.getElementById('f');
124
+ const rows = [...document.querySelectorAll('#rows tr')];
125
+ input.addEventListener('input', () => {
126
+ const q = input.value.trim().toLowerCase();
127
+ for (const row of rows) row.hidden = q !== '' && !row.dataset.k.includes(q);
128
+ });
129
+ </script>`;
130
+
131
+ mkdirSync(OUT, { recursive: true });
132
+ writeFileSync(`${OUT}/index.html`, html);
133
+ console.log(`inbox: ${rows.length} rows${truncated.length ? ` (truncated: ${truncated.join(', ')})` : ''}`);
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env node
2
+ // Builds registry.json: the derived register of onboarded repositories.
3
+ //
4
+ // Runs in the Redline source repo on a schedule. The register is DERIVED — it is
5
+ // discovered from .redline.json across the org, never hand-edited, and never written
6
+ // by `redline init`. An entry exists exactly as long as the repository's own file
7
+ // does, so a repository that removes Redline leaves the register on its next run.
8
+ //
9
+ // Requires `npm run build` first: it imports the compiled CLI from dist/.
10
+ //
11
+ // Env: GH_TOKEN (read access to org repos), ORG, SOURCE (owner/name of this repo),
12
+ // [OUT=registry.json]
13
+
14
+ import { writeFileSync } from 'node:fs';
15
+ import { createGitHubClient } from '../dist/platforms/github/client.js';
16
+ import { discoverGitHub } from '../dist/registry/discover.js';
17
+ import { serializeRegistry } from '../dist/registry/serialize.js';
18
+
19
+ const { GH_TOKEN, ORG, SOURCE, OUT = 'registry.json' } = process.env;
20
+ if (!GH_TOKEN || !ORG || !SOURCE) throw new Error('GH_TOKEN, ORG and SOURCE are required');
21
+
22
+ const client = createGitHubClient({ token: GH_TOKEN });
23
+ const { entries, problems } = await discoverGitHub(client, ORG);
24
+
25
+ for (const problem of problems) console.warn(` ${problem}`);
26
+
27
+ // An empty register is indistinguishable from a token that lost access, and
28
+ // publishing it would erase the dashboard's coverage figure and every sync
29
+ // target at once. Refuse rather than overwrite.
30
+ if (entries.length === 0) {
31
+ console.error(
32
+ `No onboarded repositories discovered in ${ORG}. Refusing to write an empty register.`
33
+ );
34
+ process.exit(1);
35
+ }
36
+
37
+ writeFileSync(
38
+ OUT,
39
+ serializeRegistry({ generatedAt: new Date().toISOString(), source: SOURCE, entries })
40
+ );
41
+
42
+ console.log(`${OUT}: ${entries.length} onboarded repositories, ${problems.length} problem(s)`);
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env node
2
+ // Builds the one page a finance stakeholder can read: what AI review cost, and
3
+ // what it caught. Sourced entirely from collected data — nothing on it is typed
4
+ // by hand except the spend figure, which no script can read from a vendor for you.
5
+ //
6
+ // The number that matters is cost per BLOCKER caught. A cost-management tool
7
+ // knows spend and has no findings, so it cannot compute value. A DORA tool has
8
+ // neither. Redline knows which findings were acted on, and joining the two is the
9
+ // entire argument — which is also why every figure here refuses rather than
10
+ // approximates: a made-up number in a finance conversation is not a small error.
11
+ //
12
+ // Env: [DATA_DIR=data], [DAYS=90], [OUT=roi.json], [HTML=roi.html],
13
+ // [SPEND_TOTAL, SPEND_CURRENCY=USD, SPEND_GRAIN=org, SPEND_SOURCE, SPEND_PERIOD]
14
+
15
+ import { writeFileSync } from 'node:fs';
16
+ import { aggregate, loadRecords } from './lib/metrics.mjs';
17
+ import { dora } from './lib/dora.mjs';
18
+ import { costPerBlocker, readSpend } from './lib/spend.mjs';
19
+
20
+ const {
21
+ DATA_DIR = 'data',
22
+ DAYS = '90',
23
+ OUT = 'roi.json',
24
+ HTML = 'roi.html',
25
+ SPEND_TOTAL,
26
+ SPEND_CURRENCY = 'USD',
27
+ SPEND_GRAIN = 'org',
28
+ SPEND_SOURCE,
29
+ SPEND_PERIOD,
30
+ } = process.env;
31
+
32
+ const windowDays = Number(DAYS);
33
+ const since = new Date(Date.now() - windowDays * 86400000).toISOString();
34
+
35
+ const { records, problems } = loadRecords(DATA_DIR, since);
36
+ for (const problem of problems) console.warn(` ${problem}`);
37
+
38
+ const agg = aggregate(records);
39
+ const delivery = dora(records, { windowDays });
40
+ const spend = readSpend(
41
+ SPEND_TOTAL
42
+ ? {
43
+ total: Number(SPEND_TOTAL),
44
+ currency: SPEND_CURRENCY,
45
+ grain: SPEND_GRAIN,
46
+ source: SPEND_SOURCE,
47
+ period: SPEND_PERIOD,
48
+ }
49
+ : null
50
+ );
51
+
52
+ // Value is what was ACTED ON, not what was reported. A finding nobody acted on
53
+ // caught nothing, and counting it would let the return be inflated by producing
54
+ // more noise — the exact behaviour the guardrails exist to prevent.
55
+ const blockersActedOn = agg.byRule
56
+ .filter((r) => r.severity === 'BLOCKER')
57
+ .reduce((n, r) => n + r.resolved, 0);
58
+
59
+ const cost = costPerBlocker(spend, blockersActedOn, { scope: spend.grain === 'repo' ? 'repo' : 'org' });
60
+
61
+ const roi = {
62
+ generatedAt: new Date().toISOString(),
63
+ windowDays,
64
+ review: {
65
+ pullRequests: agg.prs,
66
+ repositories: agg.repos,
67
+ findings: agg.findings,
68
+ blockerFindings: agg.blocker,
69
+ blockersActedOn,
70
+ actedOnRate: agg.findings > 0 ? agg.resolved / agg.findings : null,
71
+ },
72
+ delivery,
73
+ spend,
74
+ costPerBlockerCaught: cost,
75
+ };
76
+
77
+ writeFileSync(OUT, `${JSON.stringify(roi, null, 2)}\n`);
78
+
79
+ const show = (label, value, reason) =>
80
+ console.log(value === null || value === undefined
81
+ ? ` ${label.padEnd(32)} not available — ${reason}`
82
+ : ` ${label.padEnd(32)} ${value}`);
83
+ const pct = (v) => (v === null ? null : `${(v * 100).toFixed(1)}%`);
84
+
85
+ console.log(`Redline ROI — ${windowDays}-day window\n`);
86
+ console.log('WHAT REVIEW CAUGHT');
87
+ show('pull requests reviewed', agg.prs);
88
+ show('BLOCKER findings raised', agg.blocker);
89
+ show('BLOCKER findings acted on', blockersActedOn);
90
+ show('acted-on rate, all severities', pct(roi.review.actedOnRate), 'no findings in the window');
91
+
92
+ console.log('\nWHAT IT COST');
93
+ show('AI spend', spend.available ? `${spend.total} ${spend.currency} (${spend.grain} grain, ${spend.source})` : null, spend.reason);
94
+ show('cost per BLOCKER caught', cost.value === null ? null : `${cost.value.toFixed(2)} ${cost.currency}`, cost.reason);
95
+
96
+ console.log('\nDELIVERY (DORA)');
97
+ show('lead time, median hours', delivery.leadTimeHours.median?.toFixed(1) ?? null, delivery.leadTimeHours.reason);
98
+ show('change failure rate', pct(delivery.changeFailureRate.rate), delivery.changeFailureRate.reason);
99
+ show('deployment frequency, per day', delivery.deploymentFrequency.perDay?.toFixed(2) ?? null, delivery.deploymentFrequency.reason);
100
+ show('mean time to restore', null, delivery.meanTimeToRestore.reason);
101
+
102
+ if (delivery.changeFailureRate.caveat) console.log(`\n note: ${delivery.changeFailureRate.caveat}`);
103
+
104
+ // --- the page -----------------------------------------------------------------
105
+ // The roadmap's exit condition for this piece is a single page a finance
106
+ // stakeholder can read, sourced entirely from collected data. Every unavailable
107
+ // figure prints its reason in place of the number: a dash invites the reader to
108
+ // assume zero, and zero is a claim.
109
+ const esc = (v) =>
110
+ String(v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
111
+
112
+ const row = (label, value, reason, note) =>
113
+ `<tr><th scope="row">${esc(label)}</th><td>${
114
+ value === null || value === undefined
115
+ ? `<span class="absent">not available</span><span class="why">${esc(reason ?? '')}</span>`
116
+ : `<b>${esc(value)}</b>${note ? `<span class="why">${esc(note)}</span>` : ''}`
117
+ }</td></tr>`;
118
+
119
+ const html = `<!doctype html><meta charset="utf-8">
120
+ <meta name="viewport" content="width=device-width,initial-scale=1">
121
+ <title>Redline — what AI review cost, and what it caught</title>
122
+ <style>
123
+ :root { color-scheme: light dark; --ink:#111; --muted:#666; --line:#e4e4e0; --bg:#fbfbf9; --card:#fff; }
124
+ @media (prefers-color-scheme: dark) { :root { --ink:#eee; --muted:#999; --line:#2a2a2a; --bg:#121211; --card:#1a1a19; } }
125
+ * { box-sizing: border-box; }
126
+ body { margin:0; background:var(--bg); color:var(--ink); font:15px/1.6 ui-sans-serif,system-ui,-apple-system,sans-serif; padding:2.5rem 1.25rem 4rem; }
127
+ main { max-width: 46rem; margin: 0 auto; }
128
+ h1 { font-size:1.5rem; letter-spacing:-.02em; margin:0 0 .3rem; }
129
+ .sub { color:var(--muted); margin:0 0 2rem; }
130
+ h2 { font-size:.78rem; text-transform:uppercase; letter-spacing:.09em; color:var(--muted); margin:2.2rem 0 .6rem; }
131
+ section { background:var(--card); border:1px solid var(--line); border-radius:10px; padding:.4rem 1.1rem; }
132
+ table { width:100%; border-collapse:collapse; }
133
+ th[scope=row] { text-align:left; font-weight:400; color:var(--muted); padding:.62rem 0; vertical-align:top; }
134
+ td { text-align:right; padding:.62rem 0; border-bottom:1px solid var(--line); }
135
+ tr:last-child td, tr:last-child th { border-bottom:0; }
136
+ th[scope=row] { border-bottom:1px solid var(--line); }
137
+ b { font-variant-numeric: tabular-nums; font-weight:600; }
138
+ .absent { color:var(--muted); font-style:italic; }
139
+ .why { display:block; color:var(--muted); font-size:.76rem; font-style:normal; max-width:26rem; margin-left:auto; line-height:1.45; }
140
+ .lede { border-left:3px solid var(--ink); padding:.1rem 0 .1rem 1rem; margin:0 0 1.6rem; }
141
+ footer { color:var(--muted); font-size:.78rem; margin-top:2.5rem; border-top:1px solid var(--line); padding-top:1rem; }
142
+ </style>
143
+ <main>
144
+ <h1>What AI review cost, and what it caught</h1>
145
+ <p class="sub">Redline · ${esc(windowDays)}-day window · generated ${esc(roi.generatedAt.slice(0, 10))}</p>
146
+
147
+ <p class="lede">Every figure below is computed from collected review outcomes. Where a number
148
+ is not available it says so and why, rather than showing a zero — a zero is a claim, and an
149
+ absence is not.</p>
150
+
151
+ <h2>What review caught</h2>
152
+ <section><table>
153
+ ${row('Pull requests reviewed', agg.prs)}
154
+ ${row('BLOCKER findings raised', agg.blocker)}
155
+ ${row('BLOCKER findings acted on', blockersActedOn, null, 'Value is what was acted on, not what was reported. A finding nobody acted on caught nothing.')}
156
+ ${row('Acted-on rate, all severities', pct(roi.review.actedOnRate), 'no findings in the window')}
157
+ ${row('Repositories measured', agg.repos)}
158
+ </table></section>
159
+
160
+ <h2>What it cost</h2>
161
+ <section><table>
162
+ ${row('AI spend', spend.available ? `${spend.total} ${spend.currency}` : null, spend.reason, spend.available ? `${spend.grain} grain — ${spend.source}` : null)}
163
+ ${row('Cost per BLOCKER caught', cost.value === null ? null : `${cost.value.toFixed(2)} ${cost.currency}`, cost.reason, cost.value === null ? null : 'Spend divided by BLOCKERs acted on. No cost tool can compute this: it has spend and no findings.')}
164
+ </table></section>
165
+
166
+ <h2>Delivery</h2>
167
+ <section><table>
168
+ ${row('Lead time, median hours', delivery.leadTimeHours.median?.toFixed(1) ?? null, delivery.leadTimeHours.reason)}
169
+ ${row('Change failure rate', pct(delivery.changeFailureRate.rate), delivery.changeFailureRate.reason, delivery.changeFailureRate.caveat)}
170
+ ${row('Deployment frequency, per day', delivery.deploymentFrequency.perDay?.toFixed(2) ?? null, delivery.deploymentFrequency.reason)}
171
+ ${row('Mean time to restore', null, delivery.meanTimeToRestore.reason)}
172
+ </table></section>
173
+
174
+ <footer>Sourced from <code>${esc(DATA_DIR)}</code>. The spend figure is supplied by the
175
+ operator from the assistant vendor's own usage reporting — no script here can read a
176
+ vendor's billing, and none of it is estimated.</footer>
177
+ </main>
178
+ `;
179
+
180
+ writeFileSync(HTML, html);
181
+ console.log(`\nWritten to ${OUT} and ${HTML}.`);
@@ -0,0 +1,88 @@
1
+ #!/usr/bin/env node
2
+ // Verifies that every SHA-pinned third-party action still resolves to the tag its
3
+ // trailing comment claims, and reports when a newer release exists.
4
+ //
5
+ // node scripts/check-pins.mjs # verify pins, warn on updates available
6
+ // node scripts/check-pins.mjs --strict # also fail when an update is available
7
+ //
8
+ // A pin without verification rots: the comment says v3.97.1, the SHA says something
9
+ // else, and nobody notices. This is the only check in the bundle that needs network,
10
+ // so it runs as its own CI job and is allowed to be skipped offline.
11
+
12
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
13
+ import { join, dirname, resolve } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+
16
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
17
+ const STRICT = process.argv.includes('--strict');
18
+ const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
19
+
20
+ const PINNED = /^\s*-?\s*uses:\s*([\w.-]+\/[\w.-]+)@([0-9a-f]{40})\s*#\s*(\S+)/gm;
21
+
22
+ const api = async (path) => {
23
+ const res = await fetch(`https://api.github.com${path}`, {
24
+ headers: {
25
+ Accept: 'application/vnd.github+json',
26
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
27
+ },
28
+ });
29
+ if (res.status === 403 || res.status === 429) {
30
+ throw new Error(`rate limited on ${path} — set GH_TOKEN`);
31
+ }
32
+ if (!res.ok) throw new Error(`${path}: ${res.status}`);
33
+ return res.json();
34
+ };
35
+
36
+ const files = [
37
+ ...readdirSync(join(ROOT, 'workflows')).map((f) => `workflows/${f}`),
38
+ ...(existsSync(join(ROOT, '.github/workflows'))
39
+ ? readdirSync(join(ROOT, '.github/workflows')).map((f) => `.github/workflows/${f}`)
40
+ : []),
41
+ 'templates/redline.yml',
42
+ ].filter((f) => f.endsWith('.yml') || f.endsWith('.yaml'));
43
+
44
+ const pins = [];
45
+ for (const file of files) {
46
+ const body = readFileSync(join(ROOT, file), 'utf8');
47
+ for (const [, repo, sha, tag] of body.matchAll(PINNED)) pins.push({ file, repo, sha, tag });
48
+ }
49
+
50
+ if (!pins.length) {
51
+ console.log('no SHA-pinned third-party actions found');
52
+ process.exit(0);
53
+ }
54
+
55
+ let errors = 0;
56
+ let updates = 0;
57
+
58
+ for (const pin of pins) {
59
+ try {
60
+ const ref = await api(`/repos/${pin.repo}/git/ref/tags/${pin.tag}`);
61
+ // An annotated tag points at a tag object; dereference it to the commit.
62
+ const resolved =
63
+ ref.object.type === 'tag'
64
+ ? (await api(`/repos/${pin.repo}/git/tags/${ref.object.sha}`)).object.sha
65
+ : ref.object.sha;
66
+
67
+ if (resolved !== pin.sha) {
68
+ console.error(
69
+ `FAIL ${pin.file}: ${pin.repo}@${pin.sha} is commented as ${pin.tag}, but ${pin.tag} resolves to ${resolved}`
70
+ );
71
+ errors += 1;
72
+ continue;
73
+ }
74
+ console.log(`ok ${pin.repo}@${pin.tag} → ${pin.sha.slice(0, 12)}`);
75
+
76
+ const latest = await api(`/repos/${pin.repo}/releases/latest`).catch(() => null);
77
+ if (latest?.tag_name && latest.tag_name !== pin.tag) {
78
+ console.warn(` update available: ${pin.tag} → ${latest.tag_name} (${latest.published_at?.slice(0, 10)})`);
79
+ updates += 1;
80
+ }
81
+ } catch (err) {
82
+ console.error(`FAIL ${pin.file}: could not verify ${pin.repo}@${pin.tag} — ${err.message}`);
83
+ errors += 1;
84
+ }
85
+ }
86
+
87
+ console.log(`\n${pins.length} pin(s), ${errors} error(s), ${updates} update(s) available`);
88
+ process.exit(errors || (STRICT && updates) ? 1 : 0);