build-baseline.mjs
Computes the Phase 0 baseline every later roadmap phase is judged against, with every unavailable figure stating why.
What this is
Computes the Phase 0 baseline the roadmap's acceptance criteria name: acted-on rate, coverage, findings per week, the merge rate on Redline's own pull requests, which SARIF producers the estate already runs, and AI spend. It is the set of numbers every later phase is judged against.
How to onboard it
Nothing to onboard, and nothing an onboarded repository ever runs. This is maintainer tooling: it ships in this repository and runs where it already has an environment.
- Runs in: A maintainer's terminal, once, with org credentials — alongside a checkout of the metrics repo's data/.
- Trigger: Run by hand. It is a measurement, not a loop.
To run it yourself you need a checkout of this repository and Node 22 or newer. There are no runtime dependencies to install — every script uses only Node builtins — so a clone and the environment below is the whole setup.
How to use it
$ npx redlinegate metrics baseline --data data --registry registry.json --days 90 $ GH_TOKEN=... npx redlinegate metrics baseline --org acme --spend-total 850 # the full picture
Environment:
DATA_DIR — collected telemetry, default data.REGISTRY — the derived register, default registry.json.DAYS — window in days, default 90.GH_TOKEN + ORG — needed to survey Redline's own pull requests and scan for SARIF producers.SPEND_TOTAL, SPEND_CURRENCY, SPEND_GRAIN — AI spend from the vendor's own usage reporting, which no script here can read for you.
Run it before starting any roadmap phase past 0. The roadmap says plainly that the ordering of Phases 1-4 is a hypothesis until this exists, and that the baseline is allowed to reorder them.
Expected output
baseline.json plus a readable summary. Every figure it cannot source is reported as unavailable WITH ITS REASON rather than as zero — a zero that means "nobody measured this" reads as a finding, and would make every later comparison look like progress that did not happen. Cost per BLOCKER caught is computed only when both halves exist, and names the missing half when they do not.
How to edit it
- Edit the .mjs file directlyNothing generates these — scripts/ is hand-written maintainer tooling with no runtime dependencies. Keep it that way: package.json declares none, and these run in CI with only Node's builtins available.
- node --check scripts/<file>.mjsCI's lint job parses every script in scripts/. A syntax error there fails the build without running anything.
- Run it locally with the same env CI gives itEach script is env-configured with no argument parsing, so a local run is the CI run. The environment variables it needs are listed above.
The full file
#!/usr/bin/env node
// Computes the Phase 0 baseline: the numbers every later phase in the roadmap is
// judged against.
//
// Run once, by the owner, with org credentials. Every figure it cannot source is
// reported as unavailable WITH ITS REASON rather than as zero — a zero that means
// "nobody measured this" reads as a finding, and makes every later comparison
// look like progress that did not happen.
//
// The roadmap's Phase 0 acceptance list, and where each figure comes from:
// repositories onboarded ......... registry.json (the derived register)
// today's acted-on rate .......... collected telemetry under data/
// findings per week .............. collected telemetry
// merge rate on Redline's own PRs the GitHub search API (needs ORG + token)
// SARIF producers in use ......... a scan of each repo's workflows (needs token)
// token spend per review ......... the AI vendor's own usage reporting, which
// this script cannot read — supply it with
// SPEND_TOTAL/SPEND_CURRENCY/SPEND_GRAIN
//
// Env: [DATA_DIR=data], [REGISTRY=registry.json], [DAYS=90], [OUT=baseline.json],
// [GH_TOKEN + ORG] to survey Redline's own PRs and SARIF producers,
// [SPEND_TOTAL, SPEND_CURRENCY=USD, SPEND_GRAIN=org]
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { loadRecords, aggregate } from './lib/metrics.mjs';
import { buildBaseline, formatBaseline } from './lib/baseline.mjs';
const {
DATA_DIR = 'data',
REGISTRY = 'registry.json',
DAYS = '90',
OUT = 'baseline.json',
GH_TOKEN,
ORG,
SPEND_TOTAL,
SPEND_CURRENCY = 'USD',
SPEND_GRAIN = 'org',
} = process.env;
const windowDays = Number(DAYS);
const since = new Date(Date.now() - windowDays * 86400000).toISOString();
const { records, problems } = loadRecords(DATA_DIR, since);
for (const problem of problems) console.warn(` ${problem}`);
const registry = existsSync(REGISTRY)
? JSON.parse(readFileSync(REGISTRY, 'utf8'))
: null;
async function gh(path) {
const response = await fetch(`https://api.github.com${path}`, {
headers: {
authorization: `Bearer ${GH_TOKEN}`,
accept: 'application/vnd.github+json',
'user-agent': 'redlinegate',
},
});
if (!response.ok) throw new Error(`GitHub ${path} returned ${response.status}`);
return response.json();
}
// Redline's own pull requests across the estate — the guardrail that catches the
// estate quietly declining while every other number looks fine.
let ownPullRequests = null;
if (GH_TOKEN && ORG) {
try {
const query = encodeURIComponent(`org:${ORG} is:pr head:redline/ created:>${since.slice(0, 10)}`);
const result = await gh(`/search/issues?q=${query}&per_page=100`);
ownPullRequests = (result.items ?? []).map((pr) => ({ merged: Boolean(pr.pull_request?.merged_at) }));
} catch (error) {
console.warn(` could not survey Redline's own pull requests: ${error.message}`);
}
}
// Which SARIF producers the estate already runs — roadmap open question 1, and
// the input that decides whether Phase 1 is the right first move at all.
let sarifProducers = null;
if (GH_TOKEN && ORG && registry) {
sarifProducers = {};
const KNOWN = [
['codeql-action', 'CodeQL'],
['snyk', 'Snyk'],
['semgrep', 'Semgrep'],
['sonarsource', 'SonarQube'],
['trivy', 'Trivy'],
['checkmarx', 'Checkmarx'],
['upload-sarif', 'other (uploads SARIF)'],
];
for (const entry of registry.entries ?? []) {
const repo = `${entry.org}/${entry.repo}`;
try {
const listing = await gh(`/repos/${repo}/contents/.github/workflows`);
const tools = new Set();
for (const file of listing) {
if (!/\.ya?ml$/.test(file.name)) continue;
const body = await gh(`/repos/${repo}/contents/${file.path}`);
const text = Buffer.from(body.content ?? '', 'base64').toString('utf8').toLowerCase();
for (const [needle, name] of KNOWN) if (text.includes(needle)) tools.add(name);
}
if (tools.size > 0) sarifProducers[repo] = [...tools];
} catch {
// A repository whose workflows this token cannot list is not evidence that
// it runs no scanner. Skipping it undercounts; claiming zero would be a
// stronger and wronger statement.
}
}
}
const spend = SPEND_TOTAL
? { total: Number(SPEND_TOTAL), currency: SPEND_CURRENCY, grain: SPEND_GRAIN }
: null;
const baseline = buildBaseline({
aggregate: aggregate(records),
windowDays,
registry,
ownPullRequests,
sarifProducers,
spend,
});
writeFileSync(OUT, `${JSON.stringify(baseline, null, 2)}\n`);
console.log(formatBaseline(baseline));
console.log(`\nWritten to ${OUT}.`);