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,85 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { readExemption, standingExemptions } from '../exemptions.mjs';
4
+
5
+ const block = (until, scope = 'checklist') =>
6
+ `## Summary\n\nx\n\n## Redline exemption\n\n- reason: a reason long enough to be useful to a later reader\n- until: ${until}\n- scope: ${scope}\n`;
7
+
8
+ test('reads a structured exemption out of a pull request body', () => {
9
+ const exemption = readExemption(block('2026-10-01'));
10
+
11
+ assert.equal(exemption?.until, '2026-10-01');
12
+ assert.deepEqual(exemption?.scope, ['checklist']);
13
+ });
14
+
15
+ test('a body with no block reads as no exemption, not as an empty one', () => {
16
+ assert.equal(readExemption('## Summary\n\nnothing\n'), null);
17
+ assert.equal(readExemption(''), null);
18
+ assert.equal(readExemption(null), null);
19
+ });
20
+
21
+ test('a block missing a required field is not an exemption', () => {
22
+ assert.equal(readExemption('## Redline exemption\n- reason: something long enough to count\n'), null);
23
+ assert.equal(readExemption('## Redline exemption\n- until: 2026-10-01\n'), null);
24
+ });
25
+
26
+ test('an omitted scope reads as everything, matching the CLI', () => {
27
+ const exemption = readExemption(
28
+ '## Redline exemption\n- reason: a reason long enough to be useful\n- until: 2026-10-01\n'
29
+ );
30
+
31
+ assert.deepEqual(exemption?.scope, ['*']);
32
+ });
33
+
34
+ test('the reason stops at the next heading', () => {
35
+ const exemption = readExemption(
36
+ '## Redline exemption\n- reason: a reason long enough to be useful\n- until: 2026-10-01\n\n## Rollback\n- until: 2099-01-01\n'
37
+ );
38
+
39
+ assert.equal(exemption?.until, '2026-10-01');
40
+ });
41
+
42
+ const record = (repo, until, scope = ['checklist']) => ({
43
+ repo,
44
+ exemption: { reason: 'r', until, scope },
45
+ });
46
+
47
+ test('standing exemptions group by scope and count the repositories using each', () => {
48
+ const standing = standingExemptions(
49
+ [
50
+ record('acme/web', '2026-10-01'),
51
+ record('acme/api', '2026-10-05'),
52
+ record('acme/web', '2026-10-09'),
53
+ record('acme/infra', '2026-10-02', ['adr']),
54
+ ],
55
+ new Date('2026-09-04T00:00:00.000Z')
56
+ );
57
+
58
+ assert.deepEqual(standing[0], { scope: 'checklist', count: 3, repos: 2, soonest: '2026-10-01' });
59
+ assert.deepEqual(standing[1], { scope: 'adr', count: 1, repos: 1, soonest: '2026-10-02' });
60
+ });
61
+
62
+ test('an expired exemption is history, not a standing one', () => {
63
+ // Counting it would make a resolved problem look permanent.
64
+ const standing = standingExemptions(
65
+ [record('acme/web', '2026-01-01'), record('acme/api', '2026-10-01')],
66
+ new Date('2026-09-04T00:00:00.000Z')
67
+ );
68
+
69
+ assert.equal(standing.length, 1);
70
+ assert.equal(standing[0]?.count, 1);
71
+ });
72
+
73
+ test('a pull request with no exemption contributes nothing', () => {
74
+ const standing = standingExemptions([{ repo: 'acme/web' }], new Date('2026-09-04T00:00:00.000Z'));
75
+
76
+ assert.deepEqual(standing, []);
77
+ });
78
+
79
+ test('a wildcard scope is reported as itself, not expanded', () => {
80
+ // Expanding it would invent checks the author never named and make the trend
81
+ // unreadable.
82
+ const standing = standingExemptions([record('acme/web', '2026-10-01', ['*'])], new Date('2026-09-04'));
83
+
84
+ assert.equal(standing[0]?.scope, '*');
85
+ });
@@ -0,0 +1,65 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { aggregate } from '../metrics.mjs';
4
+
5
+ const record = (over = {}) => ({
6
+ repo: 'acme/web',
7
+ merged_at: '2026-08-01T00:00:00Z',
8
+ findings: { total: 4, blocker: 1, high: 2, suggestion: 1 },
9
+ outcomes: { resolved: 3, stale_by_severity: {} },
10
+ rules: { 'react/effect-derived-state': { severity: 'BLOCKER', fired: 1, resolved: 1, stale: 0 } },
11
+ reviewers: ['copilot'],
12
+ ...over,
13
+ });
14
+
15
+ test('scanner findings are aggregated alongside Redline’s, never into them', () => {
16
+ // Folding another tool's rule ids into byRule would tune Redline's rules on
17
+ // that tool's noise — the one way ingestion makes things worse.
18
+ const agg = aggregate([
19
+ record({ scanner: { findings: 3, by_tool: { CodeQL: 3 }, by_severity: { blocker: 2, high: 1 } } }),
20
+ ]);
21
+
22
+ assert.equal(agg.findings, 4, "Redline's own total is untouched");
23
+ assert.deepEqual(Object.keys(Object.fromEntries(agg.byRule.map((r) => [r.id, r]))), [
24
+ 'react/effect-derived-state',
25
+ ]);
26
+ assert.equal(agg.scanner.findings, 3);
27
+ assert.deepEqual(agg.scanner.bySeverity, { blocker: 2, high: 1, suggestion: 0 });
28
+ });
29
+
30
+ test('per-tool totals identify one noisy scanner across the estate', () => {
31
+ const agg = aggregate([
32
+ record({ scanner: { findings: 3, by_tool: { CodeQL: 3 }, by_severity: {} } }),
33
+ record({ repo: 'acme/api', scanner: { findings: 5, by_tool: { Semgrep: 5 }, by_severity: {} } }),
34
+ ]);
35
+
36
+ assert.deepEqual(agg.scanner.byTool, [
37
+ { tool: 'Semgrep', findings: 5 },
38
+ { tool: 'CodeQL', findings: 3 },
39
+ ]);
40
+ });
41
+
42
+ test('the count of repositories emitting anything answers the roadmap’s open question 1', () => {
43
+ const agg = aggregate([
44
+ record({ scanner: { findings: 3, by_tool: {}, by_severity: {} } }),
45
+ record({ repo: 'acme/api', scanner: { findings: 0, by_tool: {}, by_severity: {} } }),
46
+ record({ repo: 'acme/infra' }),
47
+ ]);
48
+
49
+ assert.equal(agg.scanner.repos, 1);
50
+ });
51
+
52
+ test('records from before ingestion existed aggregate to zero, not to NaN', () => {
53
+ const agg = aggregate([record()]);
54
+
55
+ assert.equal(agg.scanner.findings, 0);
56
+ assert.equal(agg.scanner.repos, 0);
57
+ });
58
+
59
+ test('an unknown severity from a scanner is ignored rather than inventing a bucket', () => {
60
+ const agg = aggregate([
61
+ record({ scanner: { findings: 1, by_tool: {}, by_severity: { catastrophic: 1 } } }),
62
+ ]);
63
+
64
+ assert.deepEqual(agg.scanner.bySeverity, { blocker: 0, high: 0, suggestion: 0 });
65
+ });
@@ -0,0 +1,125 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { aggregateBySource, ingestAlerts, mapSeverity } from '../sarif.mjs';
4
+
5
+ const alert = (over = {}) => ({
6
+ rule: { id: 'js/sql-injection', security_severity_level: 'critical' },
7
+ tool: { name: 'CodeQL' },
8
+ state: 'open',
9
+ most_recent_instance: { location: { path: 'src/db.ts', start_line: 42 } },
10
+ ...over,
11
+ });
12
+
13
+ test('an alert becomes a finding carrying its tool and its own rule id', () => {
14
+ const { findings, problems } = ingestAlerts([alert()]);
15
+
16
+ assert.deepEqual(problems, []);
17
+ assert.deepEqual(findings[0], {
18
+ source: 'sarif',
19
+ tool: 'CodeQL',
20
+ ruleId: 'js/sql-injection',
21
+ severity: 'BLOCKER',
22
+ nativeSeverity: 'critical',
23
+ state: 'open',
24
+ file: 'src/db.ts',
25
+ line: 42,
26
+ });
27
+ });
28
+
29
+ test('security severity wins over the SARIF level when both are present', () => {
30
+ const { findings } = ingestAlerts([
31
+ alert({ rule: { id: 'r', security_severity_level: 'low', severity: 'error' } }),
32
+ ]);
33
+
34
+ assert.equal(findings[0]?.severity, 'SUGGESTION');
35
+ });
36
+
37
+ test('the SARIF level is used when there is no security severity', () => {
38
+ const { findings } = ingestAlerts([alert({ rule: { id: 'r', severity: 'warning' } })]);
39
+
40
+ assert.equal(findings[0]?.severity, 'HIGH');
41
+ });
42
+
43
+ test('an alert with no rule id is reported and skipped', () => {
44
+ const { findings, problems } = ingestAlerts([alert({ rule: {} })]);
45
+
46
+ assert.deepEqual(findings, []);
47
+ assert.match(problems[0] ?? '', /no rule id/);
48
+ });
49
+
50
+ test('an unknown severity falls back to SUGGESTION and says so', () => {
51
+ const { findings, problems } = ingestAlerts([
52
+ alert({ rule: { id: 'r', security_severity_level: 'apocalyptic' } }),
53
+ ]);
54
+
55
+ assert.equal(findings[0]?.severity, 'SUGGESTION');
56
+ assert.match(problems[0] ?? '', /not in the severity map/);
57
+ });
58
+
59
+ test('a repository override changes the mapping', () => {
60
+ assert.equal(mapSeverity('warning', { warning: 'BLOCKER' }).severity, 'BLOCKER');
61
+ });
62
+
63
+ test('an alert with no location is still a finding', () => {
64
+ const { findings } = ingestAlerts([alert({ most_recent_instance: undefined })]);
65
+
66
+ assert.equal(findings[0]?.file, null);
67
+ assert.equal(findings[0]?.line, null);
68
+ });
69
+
70
+ const finding = (over = {}) => ({
71
+ source: 'sarif',
72
+ tool: 'CodeQL',
73
+ ruleId: 'js/x',
74
+ severity: 'BLOCKER',
75
+ acted: false,
76
+ ...over,
77
+ });
78
+
79
+ test('the two sources are aggregated apart and never merged', () => {
80
+ // The single way this piece can make things worse: tuning Redline's rules on
81
+ // another tool's noise.
82
+ const agg = aggregateBySource([
83
+ finding(),
84
+ finding({ acted: true }),
85
+ finding({ source: 'redline', tool: 'redline', ruleId: 'core/hardcoded-secrets', acted: true }),
86
+ ]);
87
+
88
+ assert.equal(agg.sarif.total, 2);
89
+ assert.equal(agg.redline.total, 1);
90
+ assert.deepEqual(Object.keys(agg.redline.byRule), ['core/hardcoded-secrets']);
91
+ assert.deepEqual(Object.keys(agg.sarif.byRule), ['js/x']);
92
+ });
93
+
94
+ test('acted-on rate is computed within each source, never across them', () => {
95
+ // Redline's "acted on" is a resolved review thread; a scanner's is a closed
96
+ // alert. Averaging two different definitions describes neither.
97
+ const agg = aggregateBySource([
98
+ finding({ acted: true }),
99
+ finding({ acted: false }),
100
+ finding({ source: 'redline', acted: true }),
101
+ ]);
102
+
103
+ assert.equal(agg.sarif.actedRate, 0.5);
104
+ assert.equal(agg.redline.actedRate, 1);
105
+ });
106
+
107
+ test('a source with nothing in it has a null rate, not a zero one', () => {
108
+ const agg = aggregateBySource([finding()]);
109
+
110
+ assert.equal(agg.redline.total, 0);
111
+ assert.equal(agg.redline.actedRate, null);
112
+ });
113
+
114
+ test('a finding with an unknown source is ignored rather than miscounted', () => {
115
+ const agg = aggregateBySource([finding({ source: 'somewhere-else' })]);
116
+
117
+ assert.equal(agg.sarif.total, 0);
118
+ assert.equal(agg.redline.total, 0);
119
+ });
120
+
121
+ test('per-tool counts survive aggregation, so one noisy scanner is identifiable', () => {
122
+ const agg = aggregateBySource([finding(), finding({ tool: 'Semgrep' }), finding({ tool: 'Semgrep' })]);
123
+
124
+ assert.deepEqual(agg.sarif.byTool, { CodeQL: 1, Semgrep: 2 });
125
+ });
@@ -0,0 +1,64 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { costPerBlocker, readSpend } from '../spend.mjs';
4
+
5
+ test('a supplied figure carries its grain and its source', () => {
6
+ const spend = readSpend({ total: 1200, currency: 'GBP', grain: 'org', source: 'vendor console' });
7
+
8
+ assert.equal(spend.available, true);
9
+ assert.equal(spend.grain, 'org');
10
+ assert.equal(spend.currency, 'GBP');
11
+ assert.equal(spend.source, 'vendor console');
12
+ });
13
+
14
+ test('an unrecognised grain becomes "unknown" rather than being trusted', () => {
15
+ assert.equal(readSpend({ total: 10, grain: 'per-seat' }).grain, 'unknown');
16
+ });
17
+
18
+ test('no figure is unavailable with a reason, never zero', () => {
19
+ const spend = readSpend(null);
20
+
21
+ assert.equal(spend.available, false);
22
+ assert.match(spend.reason, /usage reporting/);
23
+ });
24
+
25
+ test('a negative figure is refused', () => {
26
+ assert.equal(readSpend({ total: -5 }).available, false);
27
+ });
28
+
29
+ test('cost per BLOCKER divides spend by what was actually caught', () => {
30
+ const result = costPerBlocker(readSpend({ total: 400, grain: 'org' }), 20);
31
+
32
+ assert.equal(result.value, 20);
33
+ assert.equal(result.blockersCaught, 20);
34
+ });
35
+
36
+ test('no BLOCKERs is a refusal, not an infinity', () => {
37
+ const result = costPerBlocker(readSpend({ total: 400, grain: 'org' }), 0);
38
+
39
+ assert.equal(result.value, null);
40
+ assert.match(result.reason, /nothing to divide by/);
41
+ });
42
+
43
+ test('an org-level figure cannot answer a per-repository question', () => {
44
+ // The failure this prevents: org spend divided by repo count, presented as
45
+ // per-repository cost. It looks precise, is invented, and is the number a
46
+ // stakeholder will act on.
47
+ const result = costPerBlocker(readSpend({ total: 400, grain: 'org' }), 20, { scope: 'repo' });
48
+
49
+ assert.equal(result.value, null);
50
+ assert.match(result.reason, /cannot be attributed per repository/);
51
+ assert.match(result.reason, /org-level figure against org-level value/);
52
+ });
53
+
54
+ test('a per-repo figure answers a per-repo question', () => {
55
+ const result = costPerBlocker(readSpend({ total: 40, grain: 'repo' }), 4, { scope: 'repo' });
56
+
57
+ assert.equal(result.value, 10);
58
+ });
59
+
60
+ test('missing spend propagates its own reason rather than a generic one', () => {
61
+ const result = costPerBlocker(readSpend(null), 20);
62
+
63
+ assert.match(result.reason, /usage reporting/);
64
+ });
@@ -0,0 +1,156 @@
1
+ // Computes the Phase 0 baseline the roadmap's acceptance criteria name.
2
+ //
3
+ // The rule this module exists to enforce: an unavailable figure is `null` with a
4
+ // stated reason, never `0`. A baseline is the number every later phase is judged
5
+ // against, and a zero that actually means "nobody measured this" is worse than a
6
+ // gap — it reads as a finding, and it makes every later comparison look like
7
+ // progress that did not happen.
8
+
9
+ /** A measured figure, or an honest absence. */
10
+ export const measured = (value) => ({ value, available: true });
11
+ export const unavailable = (reason) => ({ value: null, available: false, reason });
12
+
13
+ const rate = (numerator, denominator) =>
14
+ denominator > 0 ? measured(numerator / denominator) : unavailable('nothing to divide — no findings in the window');
15
+
16
+ /**
17
+ * The baseline, from data already collected plus whatever the caller could read.
18
+ *
19
+ * @param {object} input
20
+ * @param {object} input.aggregate output of metrics.aggregate over the window
21
+ * @param {number} input.windowDays how many days the window covers
22
+ * @param {object|null} input.registry the derived register, or null if unreadable
23
+ * @param {object[]|null} input.ownPullRequests Redline's own PRs on the estate, or null
24
+ * @param {object|null} input.sarifProducers {repo: [tool]} or null if not surveyed
25
+ * @param {object|null} input.spend {total, currency, grain} or null
26
+ */
27
+ export function buildBaseline(input) {
28
+ const {
29
+ aggregate: agg,
30
+ windowDays,
31
+ registry = null,
32
+ ownPullRequests = null,
33
+ sarifProducers = null,
34
+ spend = null,
35
+ } = input;
36
+
37
+ const findings = agg.findings ?? 0;
38
+ const resolved = agg.resolved ?? 0;
39
+ const blocker = agg.blocker ?? 0;
40
+
41
+ // The primary metric. Acted-on is resolved over fired — the same definition the
42
+ // dashboard and the digest use, deliberately: three numbers called "acted-on
43
+ // rate" that disagree is how a programme loses an argument it was winning.
44
+ const actedOnRate = rate(resolved, findings);
45
+
46
+ const coverage = registry
47
+ ? measured(registry.entries?.length ?? 0)
48
+ : unavailable('registry.json could not be read — run the Redline Registry workflow');
49
+
50
+ const instrumented = agg.repos ?? 0;
51
+ const onboarded = coverage.available ? coverage.value : null;
52
+
53
+ const ownMergeRate =
54
+ ownPullRequests === null
55
+ ? unavailable('Redline\'s own pull requests were not surveyed — needs org read access')
56
+ : ownPullRequests.length === 0
57
+ ? unavailable('Redline has opened no pull requests on the estate yet')
58
+ : measured(ownPullRequests.filter((pr) => pr.merged).length / ownPullRequests.length);
59
+
60
+ const sarif =
61
+ sarifProducers === null
62
+ ? unavailable('no SARIF survey was run — open question 1 in the roadmap is unanswered')
63
+ : measured(
64
+ Object.entries(sarifProducers).reduce((acc, [, tools]) => {
65
+ for (const tool of tools) acc[tool] = (acc[tool] ?? 0) + 1;
66
+ return acc;
67
+ }, {})
68
+ );
69
+
70
+ const tokenSpend =
71
+ spend === null
72
+ ? unavailable('AI vendor usage reporting was not read — open question 2 is unanswered')
73
+ : measured(spend);
74
+
75
+ return {
76
+ generatedAt: new Date().toISOString(),
77
+ windowDays,
78
+
79
+ // --- the primary metric and its guardrails, per roadmap §3 ---------------
80
+ primary: { actedOnRate },
81
+ guardrails: {
82
+ coverage: {
83
+ onboarded: coverage,
84
+ instrumented: measured(instrumented),
85
+ // Instrumented-against-onboarded, so partial coverage cannot read as
86
+ // health. Absent rather than 1.0 when the register is unreadable.
87
+ ratio:
88
+ onboarded === null
89
+ ? unavailable('onboarded count unavailable, so the ratio has no denominator')
90
+ : onboarded === 0
91
+ ? unavailable('no repositories are onboarded — the ratio has no meaning yet')
92
+ : measured(instrumented / onboarded),
93
+ },
94
+ ownPullRequestMergeRate: ownMergeRate,
95
+ falsePositivesOnCleanCorpus: unavailable(
96
+ 'read from data/seed-scores.jsonl by the canary — not derivable from merged-PR telemetry'
97
+ ),
98
+ standingExemptions: measured(agg.exempted ?? 0),
99
+ },
100
+
101
+ // --- the rest of Phase 0's acceptance list -------------------------------
102
+ findings: {
103
+ total: measured(findings),
104
+ blocker: measured(blocker),
105
+ perWeek: measured(windowDays > 0 ? (findings / windowDays) * 7 : 0),
106
+ untagged: measured(agg.untagged ?? 0),
107
+ },
108
+ sarifProducers: sarif,
109
+ tokenSpend,
110
+
111
+ // The number D exists to produce. It needs both halves, and says which one
112
+ // is missing rather than dividing by an assumption.
113
+ costPerBlockerCaught:
114
+ !tokenSpend.available
115
+ ? unavailable(`spend unavailable: ${tokenSpend.reason}`)
116
+ : blocker === 0
117
+ ? unavailable('no BLOCKER findings in the window — nothing to divide by')
118
+ : measured(tokenSpend.value.total / blocker),
119
+ };
120
+ }
121
+
122
+ /** A readable summary. Every absent figure prints its reason, not a dash. */
123
+ export function formatBaseline(baseline) {
124
+ const lines = [`Redline baseline — ${baseline.generatedAt} (${baseline.windowDays}-day window)`, ''];
125
+ const show = (label, figure, format = (v) => String(v)) => {
126
+ lines.push(
127
+ figure.available
128
+ ? ` ${label.padEnd(34)} ${format(figure.value)}`
129
+ : ` ${label.padEnd(34)} not available — ${figure.reason}`
130
+ );
131
+ };
132
+ const pct = (v) => `${(v * 100).toFixed(1)}%`;
133
+
134
+ lines.push('PRIMARY');
135
+ show('BLOCKER acted-on rate', baseline.primary.actedOnRate, pct);
136
+ lines.push('', 'GUARDRAILS');
137
+ show('repositories onboarded', baseline.guardrails.coverage.onboarded);
138
+ show('repositories instrumented', baseline.guardrails.coverage.instrumented);
139
+ show('coverage', baseline.guardrails.coverage.ratio, pct);
140
+ show('merge rate on Redline\'s own PRs', baseline.guardrails.ownPullRequestMergeRate, pct);
141
+ show('false positives (clean corpus)', baseline.guardrails.falsePositivesOnCleanCorpus);
142
+ show('standing exemptions', baseline.guardrails.standingExemptions);
143
+ lines.push('', 'VOLUME');
144
+ show('findings in window', baseline.findings.total);
145
+ show('BLOCKER findings', baseline.findings.blocker);
146
+ show('findings per week', baseline.findings.perWeek, (v) => v.toFixed(1));
147
+ show('untagged findings', baseline.findings.untagged);
148
+ lines.push('', 'COST');
149
+ show('SARIF producers in use', baseline.sarifProducers, (v) =>
150
+ Object.entries(v).map(([tool, n]) => `${tool} (${n})`).join(', ') || 'none found'
151
+ );
152
+ show('AI spend', baseline.tokenSpend, (v) => `${v.total} ${v.currency} (${v.grain})`);
153
+ show('cost per BLOCKER caught', baseline.costPerBlockerCaught, (v) => v.toFixed(2));
154
+
155
+ return lines.join('\n');
156
+ }
@@ -0,0 +1,125 @@
1
+ // Does ignoring a finding cost anything?
2
+ //
3
+ // Redline can already say a rule was ignored. It cannot say ignoring it mattered.
4
+ // Where a finding was left unresolved and the same file later attracted a revert
5
+ // or a hotfix, that is *evidence* the rule earns its place — computable from
6
+ // merged-pull-request history alone, with no incident feed, which is what keeps
7
+ // it inside the non-goals.
8
+ //
9
+ // THIS IS RESEARCH, and the roadmap says to cut it without regret if the signal
10
+ // is too weak. So the design decision that matters here is not the correlation —
11
+ // it is the refusal. This module reports a rate only when the sample supports one,
12
+ // and returns `reportable: false` with the reason otherwise. A correlation quoted
13
+ // without its sample size is how a plausible story becomes a policy nobody can
14
+ // unwind, and the honest output of a weak experiment is "we cannot say".
15
+
16
+ const DAY = 86400000;
17
+
18
+ // Below this many ignored findings for a rule, no rate is reported for it. Ten is
19
+ // not a statistical threshold — it is the point below which a single coincidence
20
+ // moves the number by ten percentage points, which is enough to mislead.
21
+ export const MIN_SAMPLE = 10;
22
+
23
+ const REVERT = /^revert[\s:"']|^revert\b/i;
24
+ const HOTFIX = /\b(hotfix|hot-fix)\b/i;
25
+
26
+ const isRemediation = (record) => REVERT.test(record.title ?? '') || HOTFIX.test(record.title ?? '');
27
+
28
+ /**
29
+ * Files a pull request's ignored findings touched, per rule.
30
+ *
31
+ * A record carries per-rule counts, not per-file findings, so file attribution is
32
+ * only available where the collector recorded it. Where it is not, the pull
33
+ * request contributes to the totals and not to the correlation — counted as
34
+ * unattributable rather than quietly dropped, because a correlation computed over
35
+ * whichever records happened to have the field is not a correlation.
36
+ */
37
+ export function correlate(records, { windowDays = 30, minSample = MIN_SAMPLE } = {}) {
38
+ const byRepo = new Map();
39
+ for (const record of records ?? []) {
40
+ if (!record.merged_at) continue;
41
+ if (!byRepo.has(record.repo)) byRepo.set(record.repo, []);
42
+ byRepo.get(record.repo).push(record);
43
+ }
44
+ for (const list of byRepo.values()) {
45
+ list.sort((a, b) => Date.parse(a.merged_at) - Date.parse(b.merged_at));
46
+ }
47
+
48
+ const perRule = new Map();
49
+ let unattributable = 0;
50
+
51
+ for (const [, list] of byRepo) {
52
+ for (let i = 0; i < list.length; i += 1) {
53
+ const record = list[i];
54
+ const at = Date.parse(record.merged_at);
55
+ const followedByRemediation = list
56
+ .slice(i + 1)
57
+ .some((later) => {
58
+ const gap = Date.parse(later.merged_at) - at;
59
+ return gap >= 0 && gap <= windowDays * DAY && isRemediation(later);
60
+ });
61
+
62
+ for (const [ruleId, stats] of Object.entries(record.rules ?? {})) {
63
+ const ignored = stats.stale ?? 0;
64
+ if (ignored === 0) continue;
65
+ // A remediation's own findings are not evidence about the change it
66
+ // remedied. Counting them would let one incident inflate every rule that
67
+ // happened to fire on the fix.
68
+ if (isRemediation(record)) {
69
+ unattributable += ignored;
70
+ continue;
71
+ }
72
+ const entry = perRule.get(ruleId) ?? { ruleId, ignored: 0, followedByRemediation: 0, severity: stats.severity };
73
+ entry.ignored += ignored;
74
+ if (followedByRemediation) entry.followedByRemediation += ignored;
75
+ perRule.set(ruleId, entry);
76
+ }
77
+ }
78
+ }
79
+
80
+ const rules = [...perRule.values()]
81
+ .map((entry) => ({
82
+ ...entry,
83
+ // Reported ONLY where the sample supports it. Below the threshold the rate
84
+ // exists arithmetically and means nothing, and publishing it anyway is how
85
+ // a coincidence becomes a rule nobody can argue with.
86
+ rate: entry.ignored >= minSample ? entry.followedByRemediation / entry.ignored : null,
87
+ reportable: entry.ignored >= minSample,
88
+ reason:
89
+ entry.ignored >= minSample
90
+ ? null
91
+ : `${entry.ignored} ignored finding(s); at least ${minSample} are needed before a rate means anything`,
92
+ }))
93
+ .sort((a, b) => (b.rate ?? -1) - (a.rate ?? -1) || b.ignored - a.ignored);
94
+
95
+ const reportable = rules.filter((r) => r.reportable);
96
+ const totalIgnored = rules.reduce((n, r) => n + r.ignored, 0);
97
+
98
+ return {
99
+ windowDays,
100
+ minSample,
101
+ rules,
102
+ unattributable,
103
+ // The verdict on the experiment itself, not on any one rule. The roadmap
104
+ // asks for the correlation "with its confidence, or the work abandoned and
105
+ // said so" — this is that sentence, computed rather than written.
106
+ verdict:
107
+ reportable.length === 0
108
+ ? {
109
+ reportable: false,
110
+ reason:
111
+ totalIgnored === 0
112
+ ? 'no findings were ignored in this window, so there is nothing to correlate — which is a good result, not a failed experiment'
113
+ : `no rule reached ${minSample} ignored findings. The signal is too weak to report, and reporting it anyway would turn a coincidence into a policy nobody can unwind.`,
114
+ }
115
+ : {
116
+ reportable: true,
117
+ rulesReported: reportable.length,
118
+ rulesWithheld: rules.length - reportable.length,
119
+ caveat:
120
+ 'Correlation, not causation, and a weak one: a revert near an ignored finding is ' +
121
+ 'evidence the rule earns its place, never proof the finding caused the revert. ' +
122
+ 'Use it to prioritise which rules to examine, never to justify a rule on its own.',
123
+ },
124
+ };
125
+ }