One command, `node scripts/yardstick.mjs`, prints use, trust and
interrupts. Each row gives countable YES/NO, the value, the denominator and
the source it was read from. A fourth row asks whether the end-state gate can
be fooled by a mutant that keeps the module's signature. Exit is non-zero
while any number is not countable, the mutant is not felled, or the verdict
over the first five deliveries is not judged. Nothing counts a delivery yet,
so today it is RED for the right reasons:
use NO no order in the queue carries work-class
trust YES current ALLOW streak 0 of 37 (BLOCK 14, WARN 12, ALLOW 11,
last 2026-09-05 BLOCK, ordered by ts, read as JSON)
interrupts NO 0 user_input records of ~141k lines in 5 files, 0 emitter
call sites outside lib/stats/ and tests/
mutants 0 of 1 signature-preserving mutants felled: on M9 (every export
returns {status:'PASSED'}, every named test only checks typeof)
the gate closes both D-03 and D-04
It only measures. The gate, the emitter and the order writer are untouched.
The mutant row is meant to stay red until the gate learns to fell M9.
Choices, and why:
- Use counts over the order queue (pending, archive/ and claimed/), because
an order is the unit of work. The denominator is orders dated on or after a
cutoff (default 2026-09-21) that carry `work-class: new`. They count as
having gone through Voyage when a Voyage run in the stats has the order's
`slug:`. Both fields are the contract the order writer must meet. No order
carries them today, so the number is NOT COUNTABLE, not 0 %.
- Mailboxes whose names start with a dot are mailboxes too. A shell `*`
glob skips them, which is why a hand count of `coord/*/orders` comes out
lower. The row names them so a re-measure can reconcile.
- M9 must be LIVE before its row can count. Its typeof tests have to pass on
the M9 tree, checked by a runner independent of the gate, so a broken
fixture can never read as "felled".
Also: tests/fixtures/red-first/02243c6-always-allow-shim.mjs, the shim that
makes the red-first claim of 02243c6 reproducible (6 of 55). The recipe is in
its header.
Red first: the new test file failed to load before scripts/yardstick.mjs
existed, and a dot-mailbox test was red before the census learned about
dot-mailboxes. Suite 1161 -> 1183 (1181/0/2), also green on a clean tree
exported from the index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
21 KiB
JavaScript
472 lines
21 KiB
JavaScript
#!/usr/bin/env node
|
|
// scripts/yardstick.mjs
|
|
// The yardstick - Voyage's three numbers, each with a denominator read from its SOURCE:
|
|
//
|
|
// use new work that went through Voyage (the order queue: orders dated on or after
|
|
// the cutoff that carry `work-class: new`;
|
|
// counted = those with a Voyage run whose
|
|
// slug equals the order's `slug:`)
|
|
// trust reviews in a row with nothing that (trekreview-stats.jsonl, read as JSON and
|
|
// stops a release ordered by `ts` - file order is not
|
|
// chronological; value = the current ALLOW streak)
|
|
// interrupts operator interrupts per delivery (every *-stats.jsonl: `user_input` records
|
|
// that carry a slug or run id)
|
|
//
|
|
// plus a fourth row that asks whether the end-state gate can itself be fooled:
|
|
//
|
|
// mutants signature-preserving mutants the (M9: every export of the module a behaviour
|
|
// gate fells probe binds keeps its name and returns
|
|
// {status:'PASSED'}; every named test only
|
|
// checks `typeof`. The gate fells it when it
|
|
// refuses to close the probe on that tree.)
|
|
//
|
|
// A number the source cannot give is NOT COUNTABLE - never 0. The verdict over the first five
|
|
// deliveries is not judged by anything yet, and says so. Exit 0 = every number countable, every
|
|
// mutant felled and the verdict judged and met; 1 = anything short of that; 2 = usage error.
|
|
// It only READS: the queue, the stats and the repo. The mutant is built in a temp directory.
|
|
//
|
|
// Usage: node scripts/yardstick.mjs [--json] [--root <repo>] [--coord <dir>] [--data <dir>]
|
|
// [--cutoff YYYY-MM-DD]
|
|
|
|
import {
|
|
readFileSync, writeFileSync, existsSync, readdirSync, statSync, mkdirSync, mkdtempSync,
|
|
realpathSync, rmSync,
|
|
} from 'node:fs';
|
|
import { homedir, tmpdir } from 'node:os';
|
|
import { join, resolve, dirname, relative, sep } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { evaluateCheck, loadRegistry, TEST_TIMEOUT_MS } from './end-state-gate.mjs';
|
|
|
|
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
|
|
export const DEFAULT_CUTOFF = '2026-09-21';
|
|
export const DEFAULT_COORD = join(homedir(), '.claude', 'coord');
|
|
export const DEFAULT_DATA = process.env.CLAUDE_PLUGIN_DATA
|
|
|| join(homedir(), '.claude', 'plugins', 'data', 'voyage-ktg-plugin-marketplace');
|
|
|
|
const REVIEW_FILE = 'trekreview-stats.jsonl';
|
|
const toPosix = (p) => p.split(sep).join('/');
|
|
|
|
function walk(dir) {
|
|
const out = [];
|
|
for (const name of readdirSync(dir)) {
|
|
const p = join(dir, name);
|
|
if (statSync(p).isDirectory()) out.push(...walk(p));
|
|
else out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// --- the stats files -----------------------------------------------------------
|
|
|
|
/**
|
|
* Every `*-stats.jsonl` in the data directory, parsed line by line. A blank line
|
|
* is not a line; a line that is not JSON is a line, and is reported by number.
|
|
*/
|
|
export function loadStats(dataDir) {
|
|
if (!existsSync(dataDir) || !statSync(dataDir).isDirectory()) {
|
|
return { dir: dataDir, missing: true, files: [] };
|
|
}
|
|
const files = readdirSync(dataDir)
|
|
.filter((n) => n.endsWith('-stats.jsonl'))
|
|
.sort()
|
|
.map((name) => {
|
|
const path = join(dataDir, name);
|
|
const records = [];
|
|
const invalid = [];
|
|
let lines = 0;
|
|
readFileSync(path, 'utf8').split('\n').forEach((line, i) => {
|
|
if (line.trim() === '') return;
|
|
lines++;
|
|
try {
|
|
records.push(JSON.parse(line));
|
|
} catch {
|
|
invalid.push(i + 1);
|
|
}
|
|
});
|
|
return { name, path, lines, records, invalid };
|
|
});
|
|
return { dir: dataDir, missing: false, files };
|
|
}
|
|
|
|
// The slugs of every Voyage run the stats know about.
|
|
function voyageSlugs(stats) {
|
|
const slugs = new Set();
|
|
for (const f of stats.files) {
|
|
for (const r of f.records) {
|
|
if (r && typeof r.slug === 'string' && r.slug !== '') slugs.add(r.slug);
|
|
}
|
|
}
|
|
return slugs;
|
|
}
|
|
|
|
const notCountable = (detail, extra = {}) => ({ countable: false, value: null, detail, ...extra });
|
|
|
|
// --- 2 · TRUST -------------------------------------------------------------------
|
|
|
|
export function measureTrust(stats) {
|
|
const base = { id: 'trust', label: 'reviews in a row with nothing that stops a release' };
|
|
const file = stats.files.find((f) => f.name === REVIEW_FILE);
|
|
if (!file) {
|
|
return { ...base, source: join(stats.dir, REVIEW_FILE), denominator: 0, facts: {},
|
|
...notCountable(stats.missing ? `data directory ${stats.dir} not found` : `${REVIEW_FILE} not found`) };
|
|
}
|
|
const reviews = file.records.filter((r) => r && typeof r.verdict === 'string');
|
|
const undated = reviews.filter((r) => Number.isNaN(Date.parse(r.ts))).length;
|
|
// Stable sort on ts: the file is appended by whoever ran a review, in no fixed order.
|
|
const ordered = reviews
|
|
.map((r, i) => ({ r, i, t: Date.parse(r.ts) }))
|
|
.filter((x) => !Number.isNaN(x.t))
|
|
.sort((a, b) => a.t - b.t || a.i - b.i)
|
|
.map((x) => x.r);
|
|
const verdicts = {};
|
|
for (const r of reviews) verdicts[r.verdict] = (verdicts[r.verdict] ?? 0) + 1;
|
|
let longest = 0;
|
|
let run = 0;
|
|
for (const r of ordered) {
|
|
run = r.verdict === 'ALLOW' ? run + 1 : 0;
|
|
longest = Math.max(longest, run);
|
|
}
|
|
const last = ordered.at(-1) ?? null;
|
|
const facts = {
|
|
verdicts, longestStreak: longest, invalid: file.invalid.length, undated,
|
|
last: last ? { ts: last.ts, slug: last.slug, verdict: last.verdict } : null,
|
|
};
|
|
const notes = [];
|
|
if (file.invalid.length) notes.push(`${file.invalid.length} invalid line(s) left out (line ${file.invalid.join(', ')})`);
|
|
if (undated) notes.push(`${undated} review(s) without a readable ts left out of the streak`);
|
|
if (reviews.length === 0) {
|
|
return { ...base, source: file.path, denominator: 0, facts, ...notCountable(['no review rows', ...notes].join('; ')) };
|
|
}
|
|
const counts = Object.entries(verdicts).map(([k, v]) => `${k} ${v}`).join(' · ');
|
|
const detail = [
|
|
`${counts}; longest ALLOW streak ${longest}; last review ${last ? `${last.ts} ${last.verdict} (${last.slug})` : 'n/a'}`,
|
|
'ordered by ts, read as JSON',
|
|
...notes,
|
|
].join('; ');
|
|
return { ...base, source: file.path, countable: true, value: run, denominator: reviews.length, facts, detail };
|
|
}
|
|
|
|
// --- 1 · USE ---------------------------------------------------------------------
|
|
|
|
// A minimal frontmatter reader: `key: value` lines between the first two `---`.
|
|
function frontmatter(text) {
|
|
const m = String(text).match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
if (!m) return null;
|
|
const fm = {};
|
|
for (const line of m[1].split(/\r?\n/)) {
|
|
const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*?)\s*$/);
|
|
if (kv) fm[kv[1]] = kv[2];
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
/** Every order file under `<coord>/<repo>/orders/` - pending, `archive/` and `claimed/`. */
|
|
export function readOrders(coordDir) {
|
|
const orders = [];
|
|
const unreadable = [];
|
|
let files = 0;
|
|
if (existsSync(coordDir)) {
|
|
for (const repo of readdirSync(coordDir).sort()) {
|
|
const dir = join(coordDir, repo, 'orders');
|
|
if (!existsSync(dir) || !statSync(dir).isDirectory()) continue;
|
|
for (const path of walk(dir).filter((p) => p.endsWith('.md')).sort()) {
|
|
files++;
|
|
const fm = frontmatter(readFileSync(path, 'utf8'));
|
|
if (fm && fm['order-id']) orders.push({ path, repo, fm });
|
|
else unreadable.push(path);
|
|
}
|
|
}
|
|
}
|
|
return { dir: coordDir, files, orders, unreadable };
|
|
}
|
|
|
|
export function measureUse({ queue, slugs, cutoff }) {
|
|
const base = {
|
|
id: 'use', label: 'new work that went through Voyage',
|
|
source: `${join(queue.dir, '*', 'orders')}/**/*.md (cutoff ${cutoff})`,
|
|
};
|
|
const withClass = queue.orders.filter((o) => o.fm['work-class'] !== undefined);
|
|
const newWork = withClass.filter((o) => o.fm['work-class'] === 'new' && String(o.fm.date ?? '').slice(0, 10) >= cutoff);
|
|
const through = newWork.filter((o) => o.fm.slug && slugs.has(o.fm.slug));
|
|
const facts = {
|
|
scanned: queue.orders.length, files: queue.files, unreadable: queue.unreadable.length,
|
|
withWorkClass: withClass.length, newSinceCutoff: newWork.length, withVoyageRun: through.length,
|
|
};
|
|
const perMailbox = {};
|
|
for (const o of queue.orders) perMailbox[o.repo] = (perMailbox[o.repo] ?? 0) + 1;
|
|
const dot = Object.entries(perMailbox).filter(([k]) => k.startsWith('.'));
|
|
const census = `${queue.files} order files in ${Object.keys(perMailbox).length} mailboxes`
|
|
+ (dot.length ? ` (dot-mailboxes ${dot.map(([k, v]) => `${k} ${v}`).join(' · ')} - a shell \`*\` skips them)` : '')
|
|
+ `; ${withClass.length} of ${queue.orders.length} orders carry work-class`
|
|
+ (queue.unreadable.length ? `; ${queue.unreadable.length} order file(s) without frontmatter` : '');
|
|
if (newWork.length === 0) {
|
|
return { ...base, denominator: 0, facts, ...notCountable(
|
|
`${census}; 0 orders dated >= ${cutoff} with work-class: new - the denominator is empty`) };
|
|
}
|
|
const unmatched = newWork.filter((o) => !o.fm.slug).length;
|
|
return {
|
|
...base, countable: true, value: through.length, denominator: newWork.length, facts,
|
|
detail: `${census}; counted = a Voyage run in the stats with the order's slug`
|
|
+ (unmatched ? `; ${unmatched} new-work order(s) carry no slug and cannot match` : ''),
|
|
};
|
|
}
|
|
|
|
// --- 3 · INTERRUPTS --------------------------------------------------------------
|
|
|
|
// A call site EMITS user_input: the CLI shim with `--event user_input`, or a
|
|
// call `emit…('user_input'`. A name in prose is not a call.
|
|
const CALL_SITE = /--event[\s'",=\\]+user_input\b|\bemit\w*\(\s*['"]user_input['"]/;
|
|
const SCAN = [
|
|
{ dir: 'commands', ext: '.md' },
|
|
{ dir: 'agents', ext: '.md' },
|
|
{ dir: 'hooks', ext: '' },
|
|
{ dir: 'lib', ext: '.mjs' },
|
|
];
|
|
|
|
/** Emitter call sites for user_input outside lib/stats/ (the emitter) and tests/. */
|
|
export function emitterCallSites(root) {
|
|
const sites = [];
|
|
let scanned = 0;
|
|
for (const { dir, ext } of SCAN) {
|
|
const abs = join(root, dir);
|
|
if (!existsSync(abs)) continue;
|
|
for (const file of walk(abs).sort()) {
|
|
const rel = toPosix(relative(root, file));
|
|
if (rel.startsWith('lib/stats/') || (ext && !rel.endsWith(ext))) continue;
|
|
scanned++;
|
|
readFileSync(file, 'utf8').split('\n').forEach((line, i) => {
|
|
if (CALL_SITE.test(line)) sites.push({ file: rel, line: i + 1 });
|
|
});
|
|
}
|
|
}
|
|
return { sites, scanned };
|
|
}
|
|
|
|
const deliveryOf = (r) => {
|
|
const p = r.payload && typeof r.payload === 'object' ? r.payload : {};
|
|
return p.slug || p.run_id || r.slug || r.run_id || null;
|
|
};
|
|
|
|
export function measureInterrupts({ stats, callSites }) {
|
|
const base = { id: 'interrupts', label: 'operator interrupts per delivery', source: join(stats.dir, '*-stats.jsonl') };
|
|
const lines = stats.files.reduce((n, f) => n + f.lines, 0);
|
|
const records = stats.files.flatMap((f) => f.records);
|
|
const userInput = records.filter((r) => r && r.event === 'user_input');
|
|
const perDelivery = {};
|
|
for (const r of userInput) {
|
|
const d = deliveryOf(r);
|
|
if (d) perDelivery[d] = (perDelivery[d] ?? 0) + 1;
|
|
}
|
|
const withDelivery = Object.values(perDelivery).reduce((a, b) => a + b, 0);
|
|
const facts = { lines, files: stats.files.length, userInput: userInput.length, withDelivery, perDelivery,
|
|
callSites: callSites.sites.length, scannedForCallSites: callSites.scanned };
|
|
const census = `${userInput.length} user_input records of ${lines} lines in ${stats.files.length} files`
|
|
+ `; ${withDelivery} carry a slug or run id`
|
|
+ `; ${callSites.sites.length} emitter call sites outside lib/stats/ and tests/ (${callSites.scanned} files scanned)`;
|
|
if (withDelivery === 0) {
|
|
return { ...base, denominator: 0, facts, ...notCountable(`${census} - an interrupt that names no delivery cannot be divided by one`) };
|
|
}
|
|
return {
|
|
...base, countable: true, value: withDelivery, denominator: Object.keys(perDelivery).length, facts,
|
|
detail: `${census}; per delivery: ${Object.entries(perDelivery).map(([k, v]) => `${k} ${v}`).join(' · ')}`,
|
|
};
|
|
}
|
|
|
|
// --- 4 · the signature-preserving mutant (M9) --------------------------------------
|
|
|
|
const EXPORT_NAME = /^\s*export\s+(?:async\s+)?(?:function\*?|const|let|var|class)\s+([A-Za-z_$][\w$]*)/gm;
|
|
|
|
// Every entry whose check RUNS a named test and names the module it binds.
|
|
function behaviourConditions(registry) {
|
|
const out = [];
|
|
for (const e of [...(registry.defects ?? []), ...(registry.experiments ?? [])]) {
|
|
for (const c of Array.isArray(e.check) ? e.check : []) {
|
|
if (c && c.test !== undefined && Array.isArray(c.stubs)) out.push({ id: e.id, entry: e, cond: c });
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const relImport = (fromFile, toFile) => {
|
|
const r = toPosix(relative(dirname(fromFile), toFile));
|
|
return r.startsWith('.') ? r : `./${r}`;
|
|
};
|
|
|
|
/**
|
|
* Build M9 in a fresh temp directory: every module a behaviour probe binds is
|
|
* replaced by one whose exports keep their names and always return
|
|
* {status:'PASSED'}, and every named test is replaced by one that only checks
|
|
* that each export is a function. Returns the directory. `root` is only read.
|
|
*/
|
|
export function buildM9Tree(root, registry) {
|
|
const dir = mkdtempSync(join(tmpdir(), 'yardstick-m9-'));
|
|
const tests = new Map();
|
|
const modules = new Set();
|
|
for (const { cond } of behaviourConditions(registry)) {
|
|
if (!tests.has(cond.test)) tests.set(cond.test, { names: new Set(), stubs: new Set() });
|
|
tests.get(cond.test).names.add(cond.name);
|
|
for (const s of cond.stubs) { tests.get(cond.test).stubs.add(s); modules.add(s); }
|
|
}
|
|
const write = (rel, body) => {
|
|
mkdirSync(dirname(join(dir, rel)), { recursive: true });
|
|
writeFileSync(join(dir, rel), body);
|
|
};
|
|
for (const mod of modules) {
|
|
const source = readFileSync(join(root, mod), 'utf8');
|
|
const names = new Set([...source.matchAll(EXPORT_NAME)].map((m) => m[1]));
|
|
const lines = [...names].map((n) => `export function ${n}() { return { status: 'PASSED' }; }`);
|
|
if (/^\s*export\s+default\b/m.test(source)) lines.push("export default function () { return { status: 'PASSED' }; }");
|
|
write(mod, `${lines.join('\n')}\n`);
|
|
}
|
|
for (const [testRel, { names, stubs }] of tests) {
|
|
const lines = ["import { test } from 'node:test';", "import { strict as assert } from 'node:assert';"];
|
|
[...stubs].forEach((s, i) => lines.push(`import * as m${i} from '${relImport(testRel, s)}';`));
|
|
for (const name of names) {
|
|
const checks = [...stubs].map((_, i) => `for (const v of Object.values(m${i})) assert.equal(typeof v, 'function');`);
|
|
lines.push(`test(${JSON.stringify(name)}, () => { ${checks.join(' ')} });`);
|
|
}
|
|
write(testRel, `${lines.join('\n')}\n`);
|
|
}
|
|
return dir;
|
|
}
|
|
|
|
// Run ONE named test; true = passed, false = failed, null = it did not run.
|
|
function runNamedTest(root, testRel, name) {
|
|
const env = { ...process.env };
|
|
for (const key of Object.keys(env)) if (key.startsWith('NODE_TEST_')) delete env[key];
|
|
const pattern = `^${String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`;
|
|
const r = spawnSync(process.execPath, ['--test', '--test-reporter=tap', `--test-name-pattern=${pattern}`, testRel],
|
|
{ cwd: root, encoding: 'utf8', timeout: TEST_TIMEOUT_MS, env });
|
|
for (const line of String(r.stdout ?? '').split('\n')) {
|
|
const m = line.match(/^\s*(not )?ok \d+ - (.*)$/);
|
|
if (m && m[2].replace(/\\(.)/g, '$1').trim() === name) return m[1] === undefined;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function measureMutants(root, { registry, evaluate = evaluateCheck, runTest = runNamedTest } = {}) {
|
|
const reg = registry ?? loadRegistry(root);
|
|
const base = {
|
|
id: 'mutants', label: 'signature-preserving mutants the end-state gate fells',
|
|
source: 'scripts/end-state-gate.mjs behaviour probes vs M9 (exports return {status:"PASSED"}, tests check typeof)',
|
|
denominator: 1,
|
|
};
|
|
const probes = behaviourConditions(reg);
|
|
const entryIds = [...new Set(probes.map((p) => p.id))];
|
|
if (probes.length === 0) {
|
|
return { ...base, facts: { entries: [] }, ...notCountable('the registry has no behaviour probe to aim M9 at') };
|
|
}
|
|
let dir;
|
|
try {
|
|
dir = buildM9Tree(root, reg);
|
|
} catch (err) {
|
|
return { ...base, facts: { entries: entryIds }, ...notCountable(`M9 could not be built: ${err.message}`) };
|
|
}
|
|
try {
|
|
return judgeM9(base, dir, probes, entryIds, evaluate, runTest);
|
|
} finally {
|
|
// Only ever the directory buildM9Tree made, under the system temp dir.
|
|
if (realpathSync(dir).startsWith(realpathSync(tmpdir()))) rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function judgeM9(base, dir, probes, entryIds, evaluate, runTest) {
|
|
// Liveness: M9's own tests must PASS on the M9 tree, or "felled" would mean "broken fixture".
|
|
const dead = probes.filter((p) => runTest(dir, p.cond.test, p.cond.name) !== true).map((p) => p.cond.name);
|
|
if (dead.length > 0) {
|
|
return { ...base, facts: { entries: entryIds },
|
|
...notCountable(`M9 is not live: ${dead.length} of its named tests do not pass on the M9 tree`) };
|
|
}
|
|
const results = [...new Map(probes.map((p) => [p.id, p.entry])).values()]
|
|
.map((e) => ({ id: e.id, ...evaluate(dir, e.check) }));
|
|
const closed = results.filter((r) => r.status === 'closed');
|
|
const felled = closed.length === 0 ? 1 : 0;
|
|
const per = results.map((r) => `${r.id} ${r.status === 'closed' ? 'closed' : `not closed (${r.status})`}`).join(', ');
|
|
return {
|
|
...base, countable: true, value: felled, facts: { entries: entryIds },
|
|
detail: `M9 on a temp tree: ${per} - ${felled ? 'the gate refuses to count a typeof-only test as behaviour'
|
|
: 'a typeof-only test closes the probe, so the gate counts a signature as behaviour'}`,
|
|
};
|
|
}
|
|
|
|
// --- the whole yardstick ---------------------------------------------------------
|
|
|
|
const VERDICT = {
|
|
judged: false,
|
|
met: false,
|
|
detail: 'the verdict needs the first five deliveries through the new Voyage; nothing counts a delivery yet',
|
|
};
|
|
|
|
export function measureAll({ root = REPO_ROOT, coordDir = DEFAULT_COORD, dataDir = DEFAULT_DATA, cutoff = DEFAULT_CUTOFF, mutantOptions = {} } = {}) {
|
|
const stats = loadStats(dataDir);
|
|
const numbers = [
|
|
measureUse({ queue: readOrders(coordDir), slugs: voyageSlugs(stats), cutoff }),
|
|
measureTrust(stats),
|
|
measureInterrupts({ stats, callSites: emitterCallSites(root) }),
|
|
];
|
|
const mutants = measureMutants(root, mutantOptions);
|
|
const countable = numbers.filter((r) => r.countable).length;
|
|
const felled = mutants.countable && mutants.value === mutants.denominator;
|
|
return {
|
|
green: countable === numbers.length && felled && VERDICT.judged && VERDICT.met,
|
|
countable,
|
|
rows: [...numbers, mutants],
|
|
verdict: VERDICT,
|
|
};
|
|
}
|
|
|
|
const fmt = (v) => (v === null || v === undefined ? 'n/a' : String(v));
|
|
|
|
export function render(result) {
|
|
const m = result.rows.find((r) => r.id === 'mutants');
|
|
const out = [];
|
|
out.push(`Voyage yardstick: ${result.green ? 'GREEN' : 'RED'} (${result.countable} of 3 numbers countable)`);
|
|
out.push(`signature-preserving mutants felled: ${m.countable ? `${m.value} of ${m.denominator}` : 'n/a (not measurable)'}`);
|
|
out.push(`verdict: ${result.verdict.judged ? (result.verdict.met ? 'MET' : 'NOT MET') : 'NOT JUDGED'} - ${result.verdict.detail}`);
|
|
out.push('');
|
|
out.push('| number | countable | value | denominator | source |');
|
|
out.push('|---|---|---|---|---|');
|
|
for (const r of result.rows) {
|
|
out.push(`| ${r.id} | ${r.countable ? 'YES' : 'NO'} | ${fmt(r.value)} | ${fmt(r.denominator)} | ${r.source} |`);
|
|
}
|
|
for (const r of result.rows) {
|
|
out.push('');
|
|
out.push(`${r.id} - ${r.label}: ${r.detail}`);
|
|
}
|
|
return out.join('\n');
|
|
}
|
|
|
|
export function main(argv) {
|
|
const opts = {};
|
|
let json = false;
|
|
const usage = 'usage: yardstick.mjs [--json] [--root <repo>] [--coord <dir>] [--data <dir>] [--cutoff YYYY-MM-DD]';
|
|
for (let i = 0; i < argv.length; i++) {
|
|
const a = argv[i];
|
|
const value = argv[i + 1];
|
|
if (a === '--json') { json = true; continue; }
|
|
if (['--root', '--coord', '--data', '--cutoff'].includes(a) && value !== undefined) {
|
|
i++;
|
|
if (a === '--cutoff') {
|
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
|
|
process.stderr.write(`yardstick: --cutoff must be YYYY-MM-DD, got ${value}\n${usage}\n`);
|
|
return 2;
|
|
}
|
|
opts.cutoff = value;
|
|
} else {
|
|
opts[{ '--root': 'root', '--coord': 'coordDir', '--data': 'dataDir' }[a]] = resolve(value);
|
|
}
|
|
continue;
|
|
}
|
|
process.stderr.write(`yardstick: unknown argument ${a}\n${usage}\n`);
|
|
return 2;
|
|
}
|
|
const result = measureAll(opts);
|
|
process.stdout.write(`${json ? JSON.stringify(result, null, 2) : render(result)}\n`);
|
|
return result.green ? 0 : 1;
|
|
}
|
|
|
|
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
process.exitCode = main(process.argv.slice(2));
|
|
}
|