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,101 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Measures what the skills render target actually saves, per profile.
|
|
3
|
+
//
|
|
4
|
+
// The roadmap makes this piece conditional on the measurement: it is a cost
|
|
5
|
+
// optimisation, it is rated the highest-exposure item in the plan, and it dies if
|
|
6
|
+
// the reduction is marginal. So the number is produced by a script anyone can
|
|
7
|
+
// re-run rather than asserted once in a changelog.
|
|
8
|
+
//
|
|
9
|
+
// What is compared:
|
|
10
|
+
// composed — AGENTS.md, which a Claude session loads in full every turn for the
|
|
11
|
+
// life of the session, whatever file is being edited
|
|
12
|
+
// per-stack — the core skill plus the ONE stack whose files are in play, which
|
|
13
|
+
// is what a skills-rendered repository loads instead
|
|
14
|
+
//
|
|
15
|
+
// Env: [ROOT=.], [OUT] (write JSON here as well as printing)
|
|
16
|
+
|
|
17
|
+
import { cpSync, existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join, resolve } from 'node:path';
|
|
20
|
+
import { render } from '../dist/render/standards.js';
|
|
21
|
+
|
|
22
|
+
const { ROOT = process.cwd(), OUT } = process.env;
|
|
23
|
+
|
|
24
|
+
// This measures the standards package, so it needs one. Running it in a product
|
|
25
|
+
// repository is a reasonable mistake — the command surface does not hide that it
|
|
26
|
+
// exists — and an ENOENT stack trace is the worst possible answer to it.
|
|
27
|
+
if (!existsSync(join(ROOT, 'standards/manifest.json'))) {
|
|
28
|
+
console.error(`No standards/manifest.json under ${resolve(ROOT)}.`);
|
|
29
|
+
console.error('This measures the Redline standards themselves, so it runs in the source repo.');
|
|
30
|
+
console.error('Point it somewhere else with --root <path>.');
|
|
31
|
+
process.exit(2);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// A scratch root whose manifest has skills enabled, so the measurement does not
|
|
35
|
+
// depend on whether the org has switched it on yet.
|
|
36
|
+
const root = mkdtempSync(join(tmpdir(), 'redline-measure-'));
|
|
37
|
+
cpSync(join(ROOT, 'standards'), join(root, 'standards'), { recursive: true });
|
|
38
|
+
const manifestPath = join(root, 'standards/manifest.json');
|
|
39
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
40
|
+
manifest.vendors.skills.enabled = true;
|
|
41
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
|
|
42
|
+
|
|
43
|
+
const rows = [];
|
|
44
|
+
for (const profile of Object.keys(manifest.profiles)) {
|
|
45
|
+
const skillsOut = mkdtempSync(join(tmpdir(), 'redline-skills-'));
|
|
46
|
+
render({ root, profile, out: skillsOut, vendors: ['skills'] });
|
|
47
|
+
const dir = join(skillsOut, '.claude/skills');
|
|
48
|
+
const size = (name) => readFileSync(join(dir, name, 'SKILL.md'), 'utf8').length;
|
|
49
|
+
const names = readdirSync(dir);
|
|
50
|
+
const core = size('redline-core');
|
|
51
|
+
const stacks = names.filter((n) => n !== 'redline-core');
|
|
52
|
+
// The mean stack, not the smallest: quoting the best case would be marketing.
|
|
53
|
+
const perStack =
|
|
54
|
+
core + (stacks.length ? Math.round(stacks.reduce((a, n) => a + size(n), 0) / stacks.length) : 0);
|
|
55
|
+
|
|
56
|
+
const composedOut = mkdtempSync(join(tmpdir(), 'redline-composed-'));
|
|
57
|
+
render({ root, profile, out: composedOut, vendors: ['claude', 'agents'] });
|
|
58
|
+
const composed = readFileSync(join(composedOut, 'AGENTS.md'), 'utf8').length;
|
|
59
|
+
|
|
60
|
+
rows.push({
|
|
61
|
+
profile,
|
|
62
|
+
stacks: stacks.length,
|
|
63
|
+
composedBytes: composed,
|
|
64
|
+
perStackBytes: perStack,
|
|
65
|
+
reduction: 1 - perStack / composed,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
rmSync(skillsOut, { recursive: true, force: true });
|
|
69
|
+
rmSync(composedOut, { recursive: true, force: true });
|
|
70
|
+
}
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
|
|
73
|
+
rows.sort((a, b) => b.reduction - a.reduction);
|
|
74
|
+
|
|
75
|
+
const pct = (n) => `${(n * 100).toFixed(1)}%`;
|
|
76
|
+
console.log('profile stacks composed per-stack reduction');
|
|
77
|
+
for (const row of rows) {
|
|
78
|
+
console.log(
|
|
79
|
+
`${row.profile.padEnd(17)}${String(row.stacks).padStart(6)} ${String(row.composedBytes).padStart(8)} ${String(row.perStackBytes).padStart(9)} ${pct(row.reduction).padStart(9)}`
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// The finding, stated rather than left for a reader to notice: a single-stack
|
|
84
|
+
// profile pays the skill frontmatter and gains nothing, because there is no
|
|
85
|
+
// second stack to avoid loading. Selecting skills there makes the repository
|
|
86
|
+
// worse, and no amount of rollout enthusiasm changes that.
|
|
87
|
+
const helped = rows.filter((r) => r.reduction > 0);
|
|
88
|
+
const hurt = rows.filter((r) => r.reduction <= 0);
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(`Helps ${helped.length} profile(s): ${pct(Math.min(...helped.map((r) => r.reduction)))} to ${pct(Math.max(...helped.map((r) => r.reduction)))}.`);
|
|
91
|
+
if (hurt.length > 0) {
|
|
92
|
+
console.log(
|
|
93
|
+
`Costs ${hurt.length} profile(s) — all single-stack (${hurt.map((r) => r.profile).join(', ')}): ` +
|
|
94
|
+
'there is no second stack to avoid loading, so the frontmatter is pure overhead. Do not select skills there.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (OUT) {
|
|
99
|
+
writeFileSync(OUT, `${JSON.stringify({ generatedAt: new Date().toISOString(), rows }, null, 2)}\n`);
|
|
100
|
+
console.log(`\nWritten to ${OUT}.`);
|
|
101
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Renders this repository's own standards artifacts. The estate-wide renderer lives
|
|
3
|
+
// in cli/render/standards.ts; this is the two-line shim CI calls.
|
|
4
|
+
import { resolve, dirname } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { render } from '../cli/render/standards.ts';
|
|
7
|
+
|
|
8
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const check = process.argv.includes('--check');
|
|
10
|
+
const result = render({ root, profile: 'tooling', out: root, check });
|
|
11
|
+
|
|
12
|
+
if (result.stale.length) {
|
|
13
|
+
console.error(`Rendered output is stale. Run: node scripts/render-self.mjs\n ${result.stale.join('\n ')}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
result.written.forEach((f) => console.log(` write ${f}`));
|
|
17
|
+
result.removed.forEach((f) => console.log(` prune ${f}`));
|
|
18
|
+
if (!result.written.length && !result.removed.length) console.log(' up to date');
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Scores an automated reviewer against the seeded corpus.
|
|
3
|
+
//
|
|
4
|
+
// GH_TOKEN=... node scripts/score-seeds.mjs --repo acme/pilot-web --pr 12
|
|
5
|
+
// GH_TOKEN=... node scripts/score-seeds.mjs --repo acme/pilot-web --pr 12 --json
|
|
6
|
+
// GH_TOKEN=... node scripts/score-seeds.mjs --repo acme/pilot-web --pr 12 \
|
|
7
|
+
// --history data/seed-scores.jsonl --baseline
|
|
8
|
+
//
|
|
9
|
+
// Three numbers, because a review system fails in three ways:
|
|
10
|
+
// recall — seeded defects flagged at the expected severity or higher
|
|
11
|
+
// precision — comments on seeded/clean/**, which should never happen
|
|
12
|
+
// attribution— flagged defects that cited the correct rule id
|
|
13
|
+
//
|
|
14
|
+
// Vendor-neutral: findings are attributed by reviewer login, so the same command scores
|
|
15
|
+
// Copilot, Claude or Codex on identical input.
|
|
16
|
+
|
|
17
|
+
import { readFileSync, readdirSync, statSync, appendFileSync, existsSync } from 'node:fs';
|
|
18
|
+
import { join, relative } from 'node:path';
|
|
19
|
+
import { ROOT, RANK, loadRules, parseFinding, isReviewBot, RESERVED_RULE_IDS } from './lib/rules.mjs';
|
|
20
|
+
|
|
21
|
+
const args = process.argv.slice(2);
|
|
22
|
+
const arg = (name) => {
|
|
23
|
+
const i = args.indexOf(`--${name}`);
|
|
24
|
+
return i === -1 ? undefined : args[i + 1];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const REPO = arg('repo');
|
|
28
|
+
const PR = arg('pr');
|
|
29
|
+
const AS_JSON = args.includes('--json');
|
|
30
|
+
const HISTORY = arg('history');
|
|
31
|
+
const BASELINE = args.includes('--baseline');
|
|
32
|
+
// A finding rarely lands on the exact marker line; accept the surrounding block.
|
|
33
|
+
const WINDOW = Number(arg('window') ?? 4);
|
|
34
|
+
const { GH_TOKEN } = process.env;
|
|
35
|
+
|
|
36
|
+
if (!REPO || !PR || !GH_TOKEN) {
|
|
37
|
+
console.error(
|
|
38
|
+
'usage: GH_TOKEN=... score-seeds.mjs --repo <org>/<repo> --pr <number> [--json] [--window 4] [--history <file>] [--baseline]'
|
|
39
|
+
);
|
|
40
|
+
process.exit(2);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const rules = loadRules();
|
|
44
|
+
const SEED_MARKER = /SEED\s+(\d+)\s*\[(BLOCKER|HIGH|SUGGESTION)\]\s*(?:\(([^)]+)\))?\s*(.*)$/;
|
|
45
|
+
|
|
46
|
+
function walk(dir) {
|
|
47
|
+
const out = [];
|
|
48
|
+
for (const entry of readdirSync(dir)) {
|
|
49
|
+
const full = join(dir, entry);
|
|
50
|
+
if (statSync(full).isDirectory()) out.push(...walk(full));
|
|
51
|
+
else if (!entry.endsWith('.md')) out.push(full);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- expected findings, read straight out of the corpus ---------------------
|
|
57
|
+
const expected = [];
|
|
58
|
+
const cleanFiles = new Set();
|
|
59
|
+
for (const file of walk(join(ROOT, 'seeded'))) {
|
|
60
|
+
const rel = relative(ROOT, file);
|
|
61
|
+
if (rel.startsWith('seeded/clean/')) {
|
|
62
|
+
cleanFiles.add(rel);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
readFileSync(file, 'utf8')
|
|
66
|
+
.split('\n')
|
|
67
|
+
.forEach((line, i) => {
|
|
68
|
+
const m = SEED_MARKER.exec(line);
|
|
69
|
+
if (!m) return;
|
|
70
|
+
expected.push({
|
|
71
|
+
file: rel,
|
|
72
|
+
stack: rel.split('/')[1],
|
|
73
|
+
line: i + 1,
|
|
74
|
+
id: Number(m[1]),
|
|
75
|
+
severity: m[2],
|
|
76
|
+
ruleId: m[3] ?? null,
|
|
77
|
+
note: m[4].trim(),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
for (const seed of expected) {
|
|
83
|
+
if (seed.ruleId && !rules.has(seed.ruleId) && !RESERVED_RULE_IDS.has(seed.ruleId)) {
|
|
84
|
+
console.error(`FAIL ${seed.file}:${seed.line} cites unknown rule id "${seed.ruleId}"`);
|
|
85
|
+
process.exit(2);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- what the reviewer actually posted --------------------------------------
|
|
90
|
+
async function api(path) {
|
|
91
|
+
const results = [];
|
|
92
|
+
let url = `https://api.github.com${path}${path.includes('?') ? '&' : '?'}per_page=100`;
|
|
93
|
+
while (url) {
|
|
94
|
+
const res = await fetch(url, {
|
|
95
|
+
headers: { Authorization: `Bearer ${GH_TOKEN}`, Accept: 'application/vnd.github+json' },
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) throw new Error(`${url}: ${res.status} ${await res.text()}`);
|
|
98
|
+
results.push(...(await res.json()));
|
|
99
|
+
const next = /<([^>]+)>;\s*rel="next"/.exec(res.headers.get('link') ?? '');
|
|
100
|
+
url = next?.[1] ?? null;
|
|
101
|
+
}
|
|
102
|
+
return results;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const comments = (await api(`/repos/${REPO}/pulls/${PR}/comments`)).filter((c) =>
|
|
106
|
+
isReviewBot(c.user?.login ?? '')
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
const findings = comments.map((c) => {
|
|
110
|
+
const parsed = parseFinding(c.body ?? '');
|
|
111
|
+
return {
|
|
112
|
+
path: c.path,
|
|
113
|
+
line: c.line ?? c.original_line ?? 0,
|
|
114
|
+
...parsed,
|
|
115
|
+
reviewer: c.user?.login,
|
|
116
|
+
body: (c.body ?? '').split('\n')[0].slice(0, 120),
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// --- match ------------------------------------------------------------------
|
|
121
|
+
const used = new Set();
|
|
122
|
+
const caught = [];
|
|
123
|
+
const missed = [];
|
|
124
|
+
|
|
125
|
+
for (const seed of expected) {
|
|
126
|
+
// Prefer a finding that cites the right rule; fall back to position + severity, which
|
|
127
|
+
// still counts as caught. Attribution is scored separately so a reviewer that finds
|
|
128
|
+
// the bug but names the wrong rule is not marked as having missed it.
|
|
129
|
+
const candidates = findings
|
|
130
|
+
.map((f, i) => ({ f, i }))
|
|
131
|
+
.filter(
|
|
132
|
+
({ f, i }) =>
|
|
133
|
+
!used.has(i) &&
|
|
134
|
+
seed.file.endsWith(f.path) &&
|
|
135
|
+
Math.abs(f.line - seed.line) <= WINDOW &&
|
|
136
|
+
RANK[f.severity] >= RANK[seed.severity]
|
|
137
|
+
);
|
|
138
|
+
|
|
139
|
+
const exact = candidates.find(({ f }) => seed.ruleId && f.ruleId === seed.ruleId);
|
|
140
|
+
const hit = exact ?? candidates[0];
|
|
141
|
+
|
|
142
|
+
if (!hit) {
|
|
143
|
+
missed.push(seed);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
used.add(hit.i);
|
|
147
|
+
caught.push({
|
|
148
|
+
...seed,
|
|
149
|
+
got: hit.f.severity,
|
|
150
|
+
gotRuleId: hit.f.ruleId,
|
|
151
|
+
ruleMatch: Boolean(seed.ruleId && hit.f.ruleId === seed.ruleId),
|
|
152
|
+
reviewer: hit.f.reviewer,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const falsePositives = findings.filter(
|
|
157
|
+
(f, i) => !used.has(i) && [...cleanFiles].some((c) => c.endsWith(f.path))
|
|
158
|
+
);
|
|
159
|
+
const fpSet = new Set(falsePositives);
|
|
160
|
+
const unmatched = findings.filter((f, i) => !used.has(i) && !fpSet.has(f));
|
|
161
|
+
|
|
162
|
+
// Findings citing a rule id that is not in the catalogue: the reviewer invented one.
|
|
163
|
+
const invalidRuleIds = findings.filter(
|
|
164
|
+
(f) => f.hasRuleId && !rules.has(f.ruleId) && !RESERVED_RULE_IDS.has(f.ruleId)
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
const byStack = new Map();
|
|
168
|
+
for (const seed of expected) {
|
|
169
|
+
const entry = byStack.get(seed.stack) ?? { total: 0, caught: 0, ruleMatched: 0 };
|
|
170
|
+
entry.total += 1;
|
|
171
|
+
const hit = caught.find((c) => c.file === seed.file && c.id === seed.id);
|
|
172
|
+
if (hit) {
|
|
173
|
+
entry.caught += 1;
|
|
174
|
+
if (hit.ruleMatch) entry.ruleMatched += 1;
|
|
175
|
+
}
|
|
176
|
+
byStack.set(seed.stack, entry);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const blockers = expected.filter((s) => s.severity === 'BLOCKER');
|
|
180
|
+
const blockersCaught = caught.filter((s) => s.severity === 'BLOCKER');
|
|
181
|
+
const ruleMatched = caught.filter((c) => c.ruleMatch);
|
|
182
|
+
const withRuleId = findings.filter((f) => f.hasRuleId).length;
|
|
183
|
+
const untagged = findings.filter((f) => !f.tagged).length;
|
|
184
|
+
|
|
185
|
+
const ratio = (n, d) => (d ? +(n / d).toFixed(3) : 0);
|
|
186
|
+
|
|
187
|
+
const report = {
|
|
188
|
+
repo: REPO,
|
|
189
|
+
pr: Number(PR),
|
|
190
|
+
scored_at: new Date().toISOString(),
|
|
191
|
+
standards_version: JSON.parse(readFileSync(join(ROOT, 'standards/manifest.json'), 'utf8')).version,
|
|
192
|
+
reviewers: [...new Set(findings.map((f) => f.reviewer))],
|
|
193
|
+
totals: {
|
|
194
|
+
seeded: expected.length,
|
|
195
|
+
caught: caught.length,
|
|
196
|
+
recall: ratio(caught.length, expected.length),
|
|
197
|
+
blocker_seeded: blockers.length,
|
|
198
|
+
blocker_caught: blockersCaught.length,
|
|
199
|
+
blocker_recall: ratio(blockersCaught.length, blockers.length),
|
|
200
|
+
rule_attribution: ratio(ruleMatched.length, caught.length),
|
|
201
|
+
comments_posted: findings.length,
|
|
202
|
+
findings_with_rule_id: withRuleId,
|
|
203
|
+
invalid_rule_ids: invalidRuleIds.length,
|
|
204
|
+
false_positives_on_clean: falsePositives.length,
|
|
205
|
+
unmatched_findings: unmatched.length,
|
|
206
|
+
untagged_findings: untagged,
|
|
207
|
+
},
|
|
208
|
+
by_stack: Object.fromEntries(
|
|
209
|
+
[...byStack].map(([k, v]) => [
|
|
210
|
+
k,
|
|
211
|
+
{ ...v, recall: ratio(v.caught, v.total), rule_attribution: ratio(v.ruleMatched, v.caught) },
|
|
212
|
+
])
|
|
213
|
+
),
|
|
214
|
+
missed: missed.map((m) => ({ file: m.file, line: m.line, severity: m.severity, rule: m.ruleId, note: m.note })),
|
|
215
|
+
misattributed: caught
|
|
216
|
+
.filter((c) => c.ruleId && !c.ruleMatch)
|
|
217
|
+
.map((c) => ({ file: c.file, line: c.line, expected: c.ruleId, got: c.gotRuleId })),
|
|
218
|
+
false_positives: falsePositives.map((f) => ({ path: f.path, line: f.line, severity: f.severity, body: f.body })),
|
|
219
|
+
invalid_rule_ids: invalidRuleIds.map((f) => ({ path: f.path, line: f.line, ruleId: f.ruleId })),
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
if (HISTORY) {
|
|
223
|
+
appendFileSync(HISTORY, JSON.stringify(report) + '\n');
|
|
224
|
+
console.error(`appended to ${HISTORY}`);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- regression check against the previous run ------------------------------
|
|
228
|
+
let regression = null;
|
|
229
|
+
if (BASELINE && HISTORY && existsSync(HISTORY)) {
|
|
230
|
+
const previous = readFileSync(HISTORY, 'utf8')
|
|
231
|
+
.split('\n')
|
|
232
|
+
.filter(Boolean)
|
|
233
|
+
.map((l) => JSON.parse(l))
|
|
234
|
+
.filter((r) => r.repo === REPO && r.scored_at !== report.scored_at)
|
|
235
|
+
.pop();
|
|
236
|
+
if (previous) {
|
|
237
|
+
const drops = [];
|
|
238
|
+
if (report.totals.blocker_recall < previous.totals.blocker_recall)
|
|
239
|
+
drops.push(`BLOCKER recall ${previous.totals.blocker_recall} → ${report.totals.blocker_recall}`);
|
|
240
|
+
if (report.totals.false_positives_on_clean > previous.totals.false_positives_on_clean)
|
|
241
|
+
drops.push(`false positives ${previous.totals.false_positives_on_clean} → ${report.totals.false_positives_on_clean}`);
|
|
242
|
+
if (report.totals.rule_attribution < previous.totals.rule_attribution - 0.05)
|
|
243
|
+
drops.push(`rule attribution ${previous.totals.rule_attribution} → ${report.totals.rule_attribution}`);
|
|
244
|
+
regression = { since: previous.scored_at, drops };
|
|
245
|
+
report.regression = regression;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (AS_JSON) {
|
|
250
|
+
console.log(JSON.stringify(report, null, 2));
|
|
251
|
+
} else {
|
|
252
|
+
const t = report.totals;
|
|
253
|
+
const pct = (n) => `${Math.round(n * 100)}%`;
|
|
254
|
+
console.log(`\nRedline seed score — ${REPO}#${PR} (standards v${report.standards_version})`);
|
|
255
|
+
console.log(`reviewers: ${report.reviewers.join(', ') || 'none — no bot comments found'}\n`);
|
|
256
|
+
console.log(` BLOCKER recall ${t.blocker_caught}/${t.blocker_seeded} (${pct(t.blocker_recall)})`);
|
|
257
|
+
console.log(` overall recall ${t.caught}/${t.seeded} (${pct(t.recall)})`);
|
|
258
|
+
console.log(` rule attribution ${pct(t.rule_attribution)} of caught seeds cited the right rule`);
|
|
259
|
+
console.log(` comments posted ${t.comments_posted} (${t.findings_with_rule_id} carried a rule id)`);
|
|
260
|
+
console.log(` false positives ${t.false_positives_on_clean} on seeded/clean/**`);
|
|
261
|
+
console.log(` unmatched ${t.unmatched_findings} on a seed file but not near a marker`);
|
|
262
|
+
console.log(` untagged ${t.untagged_findings} missing the Redline/<SEVERITY> prefix`);
|
|
263
|
+
console.log(` invalid rule ids ${t.invalid_rule_ids}\n`);
|
|
264
|
+
console.log(' by stack:');
|
|
265
|
+
for (const [stack, s] of Object.entries(report.by_stack)) {
|
|
266
|
+
console.log(
|
|
267
|
+
` ${stack.padEnd(16)} ${String(s.caught).padStart(2)}/${String(s.total).padEnd(3)} recall ${pct(s.recall).padStart(4)} attribution ${pct(s.rule_attribution).padStart(4)}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
if (report.missed.length) {
|
|
271
|
+
console.log('\n missed:');
|
|
272
|
+
for (const m of report.missed) console.log(` ${m.file}:${m.line} [${m.severity}] ${m.rule ?? '?'} — ${m.note}`);
|
|
273
|
+
}
|
|
274
|
+
if (report.misattributed.length) {
|
|
275
|
+
console.log('\n found but wrong rule cited:');
|
|
276
|
+
for (const m of report.misattributed) console.log(` ${m.file}:${m.line} expected ${m.expected}, got ${m.got ?? 'none'}`);
|
|
277
|
+
}
|
|
278
|
+
if (report.false_positives.length) {
|
|
279
|
+
console.log('\n false positives on clean code:');
|
|
280
|
+
for (const f of report.false_positives) console.log(` ${f.path}:${f.line} [${f.severity}] ${f.body}`);
|
|
281
|
+
}
|
|
282
|
+
if (regression?.drops.length) {
|
|
283
|
+
console.log(`\n REGRESSION against ${regression.since}:`);
|
|
284
|
+
for (const d of regression.drops) console.log(` ${d}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// A pilot that cannot flag its own BLOCKER seeds is not ready to widen, and a regression
|
|
289
|
+
// against the last recorded run must stop the rollout.
|
|
290
|
+
const failed =
|
|
291
|
+
report.totals.blocker_recall < 1 ||
|
|
292
|
+
report.totals.false_positives_on_clean > 0 ||
|
|
293
|
+
Boolean(regression?.drops.length);
|
|
294
|
+
process.exit(failed ? 1 : 0);
|