Files
iptvnator/tools/performance/check-journey-ratchet.mjs
T
4grayandClaude Opus 5.5 af44e2368b ci(performance): give the initial-bytes ratchet slack and a labelled override (#1744)
* ci(performance): give the initial-bytes ratchet slack and a labelled override

The exact renderer.initialBytes counter failed PRs for reasons outside
their diff: two concurrent merges left master 108 bytes over the baseline
for hours, and bundler identifier renaming moves the counter by hundreds of
bytes. PRs growing it by 243 and 302 bytes had no way to pass at all,
because the direction check refuses any raised baseline.

- Counter entries accept `slack` (integer, entry unit): the ratchet enforces
  `value + slack`, reports how much slack a measurement uses, and still
  prints the tighten hint below `value`. renderer.initialBytes gets 4096
  bytes, so growth can accumulate at most 4 KiB past the last lowered
  baseline while regressions such as +35 KB still fail.
- check-baseline-direction.mjs compares `value + slack`, treats widened
  slack like a widened tolerance, and takes `--allow-increase`, which
  reports weakened entries as ALLOWED instead of failing.
- CI passes `--allow-increase` only when the pull request (or, for a master
  push, the pull request merged as the pushed commit) carries the
  perf-baseline-increase label, read from the API so a job re-run picks up
  a label added later.

This change widens the slack itself, so its own PR needs the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PWobUjpoiWt8E9bBdsCs

* fix(performance): report slack usage only for entries that have slack

A wall-clock measurement above `value` but within `value × toleranceRatio`
fell into the slack branch and was reported as using "slack", conflating
timing tolerance with counter slack. Only entries with `slack` report it now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PWobUjpoiWt8E9bBdsCs

* fix(performance): scope the push-time label and refuse raised counter values

- A master push compares the whole push, but the label was read from the
  head commit's PR only, so one labelled PR could cover another commit's
  increase in the same push. The label now counts only when the push added
  exactly one first-parent commit (a squash or merge of one PR); any other
  push that weakens a baseline fails.
- Raising a counter's `value` while narrowing its `slack` lowered the
  enforced limit and was reported as "lowered". A counter's value is the
  measured evidence and only moves down, so that raise is now a weakening
  that needs the label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PWobUjpoiWt8E9bBdsCs

* fix(performance): treat a counter/wall-clock type switch as a weakening

Turning `{ value: 100, slack: 10 }` into `{ value: 105, toleranceRatio: 1 }`
skipped the raised-counter-value rule and was reported as a lowered limit.
Switching an entry between counter and wall-clock now needs the
perf-baseline-increase label.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PWobUjpoiWt8E9bBdsCs

* ci(performance): paginate the label lookups of the direction check

The labels endpoint returns 30 entries per page by default, so a PR with
more labels could miss perf-baseline-increase. Both lookups now request
100 per page and paginate, like the release-note gate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0166PWobUjpoiWt8E9bBdsCs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-09-28 14:55:15 +02:00

336 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Compares a journey summary (written by the measurement scripts) against the
* committed baselines in tools/performance/journey-baselines.json.
*
* Rules, from docs/architecture/performance-journeys.md:
* - A counter above `value + slack` fails. Counters are exact; `slack`
* (default 0, in the entry's unit) absorbs bundler noise and concurrent
* merges without letting growth accumulate past it.
* - An entry with `toleranceRatio` (wall-clock) fails above
* `value * toleranceRatio`.
* - A baseline without a measurement fails, so removing a measurement can
* never silently disable the ratchet.
* - A measurement below its baseline prints a "can tighten" hint. Baselines
* are lowered by hand, with the measured output as evidence, never raised.
* - Checking nothing is a failure: an empty baselines file, or `--only`
* naming an entry that does not exist, must not exit 0.
*
* `--only <journey>/<counter>` (repeatable) restricts the check to the named
* baselines, so a script that measures one counter can check that counter
* without every other baseline failing as unmeasured.
*
* Usage:
* node tools/performance/check-journey-ratchet.mjs --summary <summary.json>
* [--baselines tools/performance/journey-baselines.json]
* [--only launch/renderer.initialBytes ...]
*/
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
export const DEFAULT_BASELINES_PATH =
'tools/performance/journey-baselines.json';
/** The PR label that lets check-baseline-direction.mjs accept a weakening. */
export const BASELINE_INCREASE_LABEL = 'perf-baseline-increase';
function formatNumber(value) {
return Number.isInteger(value)
? value.toLocaleString('en-US')
: value.toLocaleString('en-US', { maximumFractionDigits: 2 });
}
function isPlainObject(value) {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export function validateBaselines(baselines) {
if (!isPlainObject(baselines) || !isPlainObject(baselines.journeys)) {
throw new Error(
'Baselines file must be an object with a "journeys" map.'
);
}
for (const [journey, entries] of Object.entries(baselines.journeys)) {
if (!isPlainObject(entries)) {
throw new Error(
`Baseline journey "${journey}" must map counter names to entries.`
);
}
for (const [name, entry] of Object.entries(entries)) {
const label = `${journey}/${name}`;
if (!isPlainObject(entry) || !Number.isFinite(entry.value)) {
throw new Error(
`Baseline ${label} needs a finite numeric "value".`
);
}
if (
entry.toleranceRatio !== undefined &&
!(
Number.isFinite(entry.toleranceRatio) &&
entry.toleranceRatio >= 1
)
) {
throw new Error(
`Baseline ${label} has an invalid "toleranceRatio" (must be >= 1).`
);
}
if (
entry.slack !== undefined &&
!(Number.isInteger(entry.slack) && entry.slack >= 0)
) {
throw new Error(
`Baseline ${label} has an invalid "slack" (must be an integer >= 0).`
);
}
if (
entry.slack !== undefined &&
entry.toleranceRatio !== undefined
) {
throw new Error(
`Baseline ${label} sets both "slack" and "toleranceRatio"; slack is for counters, toleranceRatio for wall-clock entries.`
);
}
}
}
return baselines;
}
/**
* A baseline entry is either a counter (exact) or a wall-clock entry (carries
* `toleranceRatio`), and each is read only from its own summary section. A
* value that moved to the other section is treated as missing, so a summary
* schema regression fails the ratchet instead of slipping through it.
*/
function summarySection(entry) {
return entry.toleranceRatio !== undefined ? 'wallClock' : 'counters';
}
/**
* What the ratchet enforces: `value × toleranceRatio` for a wall-clock entry,
* `value + slack` for a counter. check-baseline-direction.mjs compares this,
* not the bare value, so a PR cannot lower a value while widening a tolerance.
*/
export function enforcedLimit(entry) {
return entry.value * (entry.toleranceRatio ?? 1) + (entry.slack ?? 0);
}
function describeLimit(entry, unit) {
if (entry.toleranceRatio !== undefined) {
return `${formatNumber(enforcedLimit(entry))} (baseline ${formatNumber(entry.value)} × ${entry.toleranceRatio})`;
}
if (entry.slack) {
return `${formatNumber(enforcedLimit(entry))} (baseline ${formatNumber(entry.value)} + slack ${formatNumber(entry.slack)}${unit})`;
}
return `baseline ${formatNumber(entry.value)}`;
}
function measuredValue(summaryJourney, name, entry) {
if (!isPlainObject(summaryJourney)) return undefined;
return summaryJourney[summarySection(entry)]?.[name];
}
/**
* Pure comparison. Returns every outcome so the CLI and tests can render it;
* `failures` non-empty means the ratchet is broken.
*/
export function compareToBaselines({ baselines, summary, only = [] }) {
validateBaselines(baselines);
const summaryJourneys = isPlainObject(summary?.journeys)
? summary.journeys
: {};
const result = {
failures: [],
tightenable: [],
passed: [],
unbaselined: [],
};
const selected = new Set(only);
let evaluated = 0;
for (const target of only) {
const [journey, ...rest] = target.split('/');
if (baselines.journeys[journey]?.[rest.join('/')] === undefined) {
result.failures.push(
`${target}: --only names a baseline that does not exist in the baselines file.`
);
}
}
for (const [journey, entries] of Object.entries(baselines.journeys)) {
for (const [name, entry] of Object.entries(entries)) {
const label = `${journey}/${name}`;
if (selected.size > 0 && !selected.has(label)) continue;
evaluated += 1;
const unit = entry.unit ? ` ${entry.unit}` : '';
const measured = measuredValue(
summaryJourneys[journey],
name,
entry
);
if (measured === undefined) {
result.failures.push(
`${label}: baseline ${formatNumber(entry.value)}${unit} has no measurement under journeys.${journey}.${summarySection(entry)} in the summary. The ratchet cannot be bypassed by dropping a measurement; restore it.`
);
continue;
}
if (typeof measured !== 'number' || !Number.isFinite(measured)) {
result.failures.push(
`${label}: measured value ${JSON.stringify(measured)} is not a finite number.`
);
continue;
}
const limit = enforcedLimit(entry);
const limitText = describeLimit(entry, unit);
if (measured > limit) {
result.failures.push(
`${label}: ${formatNumber(measured)}${unit} exceeds ${limitText} by ${formatNumber(measured - limit)}${unit}. Bring the value back down; baselines only move down. If the growth is a deliberate trade-off, raise the baseline in ${DEFAULT_BASELINES_PATH}, make the case in the PR, and ask a maintainer to add the ${BASELINE_INCREASE_LABEL} label.`
);
} else if (entry.slack && measured > entry.value) {
result.passed.push(
`${label}: ${formatNumber(measured)}${unit} within ${limitText}; uses ${formatNumber(measured - entry.value)} of ${formatNumber(limit - entry.value)}${unit} slack.`
);
} else if (measured < entry.value) {
result.tightenable.push(
`${label}: ${formatNumber(measured)}${unit} is below baseline ${formatNumber(entry.value)} by ${formatNumber(entry.value - measured)}${unit}. Lower the baseline in ${DEFAULT_BASELINES_PATH} with this run as evidence.`
);
} else {
result.passed.push(
`${label}: ${formatNumber(measured)}${unit} within ${limitText}.`
);
}
}
}
for (const [journey, summaryJourney] of Object.entries(summaryJourneys)) {
const measuredNames = [
...Object.keys(summaryJourney?.counters ?? {}),
...Object.keys(summaryJourney?.wallClock ?? {}),
];
for (const name of measuredNames) {
if (baselines.journeys[journey]?.[name] === undefined) {
result.unbaselined.push(
`${journey}/${name}: measured but has no baseline yet. Add one to ${DEFAULT_BASELINES_PATH} once the counter is validated.`
);
}
}
}
if (evaluated === 0) {
result.failures.push(
`No baselines were checked. ${DEFAULT_BASELINES_PATH} must keep at least one entry; a ratchet that guards nothing must not pass.`
);
}
return result;
}
export function formatResult(result) {
const lines = [];
for (const line of result.passed) lines.push(`ok ${line}`);
for (const line of result.tightenable) lines.push(`tighten ${line}`);
for (const line of result.unbaselined) lines.push(`note ${line}`);
for (const line of result.failures) lines.push(`FAIL ${line}`);
const checked =
result.passed.length +
result.tightenable.length +
result.failures.length;
lines.push(
result.failures.length > 0
? `Journey ratchet failed: ${result.failures.length} of ${checked} baselines exceeded.`
: `Journey ratchet OK: ${checked} baselines checked, ${result.tightenable.length} can be tightened.`
);
return lines.join('\n');
}
export function parseArgs(argv) {
const options = {
summary: null,
baselines: DEFAULT_BASELINES_PATH,
only: [],
};
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === '--') continue;
if (argument === '--summary') {
options.summary = argv[++index];
} else if (argument.startsWith('--summary=')) {
options.summary = argument.slice('--summary='.length);
} else if (argument === '--baselines') {
options.baselines = argv[++index];
} else if (argument.startsWith('--baselines=')) {
options.baselines = argument.slice('--baselines='.length);
} else if (argument === '--only') {
options.only.push(argv[++index]);
} else if (argument.startsWith('--only=')) {
options.only.push(argument.slice('--only='.length));
} else {
throw new Error(`Unknown argument: ${argument}`);
}
if (
options.summary === undefined ||
options.baselines === undefined ||
options.only.includes(undefined)
) {
throw new Error(`Missing value for ${argument}`);
}
}
for (const target of options.only) {
if (!/^[^/]+\/.+$/.test(target)) {
throw new Error(
`--only expects <journey>/<counter>, received "${target}".`
);
}
}
if (!options.summary) {
throw new Error('--summary <journey-summary.json> is required.');
}
return options;
}
async function readJson(filePath, description) {
try {
return JSON.parse(await readFile(filePath, 'utf8'));
} catch (error) {
throw new Error(
`Cannot read ${description} at ${filePath}: ${error.message}`
);
}
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) ===
path.resolve(fileURLToPath(import.meta.url));
if (isMain) {
try {
const options = parseArgs(process.argv.slice(2));
const baselines = await readJson(
path.resolve(options.baselines),
'baselines'
);
const summary = await readJson(
path.resolve(options.summary),
'journey summary'
);
const result = compareToBaselines({
baselines,
summary,
only: options.only,
});
const output = formatResult(result);
if (result.failures.length > 0) {
console.error(output);
process.exitCode = 1;
} else {
console.log(output);
}
} catch (error) {
console.error(`check-journey-ratchet: ${error.message}`);
process.exitCode = 1;
}
}