* enhance(#4261): report size-cap headroom on every run, with a reserved margin The tier caps are red lines and none of them moves here. What was missing is everything below the red line: a passing run said nothing, so a contributor at 99.6% of a cap and one at 60% got identical feedback, and the density that produces merge-time collisions was invisible to the people creating it. Two levels, matching the shape execute-phase.md already carries by hand (a hard ceiling plus a lower margin "so minor future edits don't re-trip the gate") and which was until now the only capped file with one: 1. a headroom census printed every run, green included, sorted least-headroom-first, and appended to the GitHub job summary 2. a 95% reserved margin that names the files inside it and REPORTS rather than fails The margin deliberately does not fail. A cap breach is a red line; a file at 96% is not broken, it is a file whose next contributor should extract before adding. Failing there would create a second red line and force exactly the +N bumps the policy forbids. Neither level asserts a count, so this adds no snapshot to regenerate — the per-file size baseline was deleted by #2724 for conflicting on 7 of 7 PRs that touched it. Also deletes rather than refreshes the per-tier high-water comments in both guard files. They were measured once and then diverged from the tree: the LARGE line still claimed "gsd-executor 42,342 -> ~6.8 KB headroom" while the real high-water sat at 99.6% of that cap, so the comment documenting the margin was itself why nobody noticed the margin was gone. As measured on next by the new census, the pressure has grown since the issue was filed: gsd-plan-checker.md has 9 bytes of headroom, gsd-verifier.md 21, and plan-phase.md 14. * chore(#4261): add changeset for the size-cap headroom census * test(#4261): exercise reserved-margin boundaries --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
238 lines
9.1 KiB
JavaScript
238 lines
9.1 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* @file workflow-size.cjs
|
|
*
|
|
* Single source of truth for measuring workflow `.md` file sizes in bytes.
|
|
*
|
|
* Shared by `tests/workflow-size-budget.test.cjs` / `tests/agent-size-budget.test.cjs`
|
|
* (the tier hard-cap guards), `tests/helpers/emitted-runtime.cjs`'s `currentSizes()`
|
|
* (the differential attribution check's size ratchet, ADR-2719 Phase 4), and
|
|
* `scripts/gen-emitted-baseline.cjs` (the baseline publisher) so none of them can
|
|
* disagree on HOW a file is measured (issue #1074). `scripts/update-size-baseline.cjs`,
|
|
* the original third consumer, was removed by #2724 along with the per-file baseline
|
|
* it generated.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows');
|
|
|
|
/**
|
|
* Byte size of a file, counted as on an LF (Unix) checkout.
|
|
*
|
|
* The size budget is calibrated against `wc -c` on a Unix (LF) checkout.
|
|
* Counting raw on-disk bytes on a CRLF checkout adds one byte per line, a
|
|
* Windows-only false positive that diverges from the LF calibration basis
|
|
* (issue #683). Stripping CR yields the same LF byte count on every platform.
|
|
*
|
|
* `.gitattributes:2` (`* text=auto eol=lf`, added in #1088) now normalizes these
|
|
* files to LF on checkout everywhere, so the CRLF case should not arise from a
|
|
* normal clone — but this stays unconditional because it also covers a working
|
|
* tree produced some other way (an unpacked archive, an editor that rewrites
|
|
* line endings, a checkout predating that attribute).
|
|
* This is still a raw byte count (not a trailing-newline-stripping line count).
|
|
*
|
|
* @param {string} filePath - Absolute or relative path to the file.
|
|
* @returns {number} LF-normalized byte length.
|
|
*/
|
|
function lfByteCount(filePath) {
|
|
const content = fs.readFileSync(filePath, 'utf-8');
|
|
return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* List top-level workflow stems (filenames without the `.md` extension), sorted.
|
|
* Non-recursive by design: per-mode bodies under `workflows/<name>/modes/` and
|
|
* templates are NOT measured — only the always-loaded top-level workflows.
|
|
*
|
|
* @param {string} [dir] - Workflows directory (defaults to the canonical one).
|
|
* @returns {string[]} Sorted stems, e.g. `['autonomous', 'plan-phase', ...]`.
|
|
*/
|
|
function listWorkflowStems(dir = WORKFLOWS_DIR) {
|
|
return fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith('.md'))
|
|
.map((f) => f.replace(/\.md$/, ''))
|
|
.sort();
|
|
}
|
|
|
|
/**
|
|
* Measure every top-level `.md` file in `dir`, keyed by filename, byte sizes.
|
|
* Generic over directory and an optional filename predicate — used for both
|
|
* workflows (`gsd-core/workflows/*.md`) and agents (`agents/gsd-*.md`) so the
|
|
* size guards and the baseline generator share one measurement path (#1074).
|
|
* Non-recursive by design.
|
|
*
|
|
* @param {string} dir - Directory to scan.
|
|
* @param {function(string): boolean} [predicate] - Filename filter (default: all `.md`).
|
|
* @returns {Object<string, number>} Map of filename → LF byte size, keys sorted.
|
|
*/
|
|
function measureMdFiles(dir, predicate = () => true) {
|
|
const out = {};
|
|
const names = fs
|
|
.readdirSync(dir)
|
|
.filter((f) => f.endsWith('.md') && predicate(f))
|
|
.sort();
|
|
for (const name of names) out[name] = lfByteCount(path.join(dir, name));
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Measure every top-level workflow file, keyed by filename (`<stem>.md`).
|
|
*
|
|
* @param {string} [dir] - Workflows directory (defaults to the canonical one).
|
|
* @returns {Object<string, number>} Map of `<stem>.md` → LF byte size, sorted.
|
|
*/
|
|
function measureWorkflows(dir = WORKFLOWS_DIR) {
|
|
return measureMdFiles(dir);
|
|
}
|
|
|
|
/**
|
|
* #4261: the reserved margin, as a fraction of a tier's hard cap.
|
|
*
|
|
* The hard caps are red lines and stay exactly where they are. This is the
|
|
* second, softer level the caps never had: `execute-phase.md` already carries
|
|
* a hand-rolled version of this shape (a hard `< 93600` plus a `<= 93400`
|
|
* margin whose own message says it exists "so minor future edits don't
|
|
* re-trip the gate"), and it is the only capped file that does. Everywhere
|
|
* else a file is either fine or already over, with nothing in between — so
|
|
* the first signal a contributor gets is a failure, and by then the cheap
|
|
* moment to extract has passed.
|
|
*
|
|
* 95% is deliberately not derived from anything. It is the round number the
|
|
* issue proposed, and the census below is what makes it reviewable: if it
|
|
* turns out to name too many or too few files, that is visible in one table
|
|
* rather than argued from first principles.
|
|
*/
|
|
const MARGIN_RATIO = 0.95;
|
|
|
|
/**
|
|
* The reserved-margin threshold for a hard cap, in bytes.
|
|
*
|
|
* Floor, not round: the margin must never land ON or above the cap it is
|
|
* meant to sit under, however small the cap.
|
|
*
|
|
* @param {number} cap - Tier hard cap in bytes.
|
|
* @returns {number} Margin threshold in bytes.
|
|
*/
|
|
function marginFor(cap) {
|
|
return Math.floor(cap * MARGIN_RATIO);
|
|
}
|
|
|
|
/**
|
|
* Build the headroom census for a set of measured files.
|
|
*
|
|
* Sorted by pressure (least headroom first) because that is the reading
|
|
* order that matters: the top row is the file that will break next.
|
|
*
|
|
* @param {Object<string, number>} sizes - Map of filename -> LF byte size.
|
|
* @param {function(string): {tier: string, cap: number}} capFor - Tier lookup, keyed by stem.
|
|
* @returns {Array<{name: string, tier: string, bytes: number, cap: number, margin: number, headroom: number, usedPct: number, overMargin: boolean}>}
|
|
*/
|
|
function buildHeadroomRows(sizes, capFor) {
|
|
return Object.entries(sizes)
|
|
.map(([file, bytes]) => {
|
|
const name = file.replace(/\.md$/, '');
|
|
const { tier, cap } = capFor(name);
|
|
const margin = marginFor(cap);
|
|
return {
|
|
name,
|
|
tier,
|
|
bytes,
|
|
cap,
|
|
margin,
|
|
headroom: cap - bytes,
|
|
usedPct: (bytes / cap) * 100,
|
|
overMargin: bytes > margin,
|
|
};
|
|
})
|
|
.sort((a, b) => a.headroom - b.headroom || a.name.localeCompare(b.name));
|
|
}
|
|
|
|
/**
|
|
* Render the census as a fixed-width text table for the test diagnostics.
|
|
*
|
|
* @param {ReturnType<typeof buildHeadroomRows>} rows
|
|
* @param {{limit?: number}} [opts] - How many rows to render (default: all).
|
|
* @returns {string[]} Lines, ready to hand to `t.diagnostic` one at a time.
|
|
*/
|
|
function formatHeadroomTable(rows, { limit = Infinity } = {}) {
|
|
const shown = rows.slice(0, limit);
|
|
const width = Math.max(4, ...shown.map((r) => r.name.length));
|
|
const head = `${'file'.padEnd(width)} ${'tier'.padEnd(7)} ${'bytes'.padStart(7)} ${'cap'.padStart(7)} ${'margin'.padStart(7)} ${'headroom'.padStart(8)} ${'used'.padStart(6)}`;
|
|
const body = shown.map(
|
|
(r) =>
|
|
`${r.name.padEnd(width)} ${r.tier.padEnd(7)} ${String(r.bytes).padStart(7)} ${String(r.cap).padStart(7)} ${String(r.margin).padStart(7)} ${String(r.headroom).padStart(8)} ${r.usedPct.toFixed(1).padStart(5)}%`,
|
|
);
|
|
return [head, ...body];
|
|
}
|
|
|
|
/**
|
|
* Render the census as a GitHub job-summary markdown table.
|
|
*
|
|
* Only the rows over the reserved margin: a job summary that lists all 124
|
|
* capped files is a log dump nobody reads, and every row below the margin is
|
|
* by definition not the problem. The count of the rest is still reported so
|
|
* an empty table cannot be mistaken for an unmeasured one.
|
|
*
|
|
* @param {string} title - Section heading (e.g. `'Agent size headroom'`).
|
|
* @param {ReturnType<typeof buildHeadroomRows>} rows
|
|
* @returns {string} Markdown block.
|
|
*/
|
|
function buildHeadroomSummaryMarkdown(title, rows) {
|
|
const pressured = rows.filter((r) => r.overMargin);
|
|
const lines = [`### ${title}`, ''];
|
|
if (pressured.length === 0) {
|
|
lines.push(`All ${rows.length} files are under the ${Math.round(MARGIN_RATIO * 100)}% reserved margin.`);
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
lines.push(
|
|
`**${pressured.length} of ${rows.length}** files are over the ${Math.round(MARGIN_RATIO * 100)}% reserved margin.`,
|
|
'',
|
|
'| file | tier | bytes | cap | headroom | used |',
|
|
'| --- | --- | ---: | ---: | ---: | ---: |',
|
|
);
|
|
for (const r of pressured) {
|
|
lines.push(`| \`${r.name}\` | ${r.tier} | ${r.bytes} | ${r.cap} | ${r.headroom} | ${r.usedPct.toFixed(1)}% |`);
|
|
}
|
|
return `${lines.join('\n')}\n`;
|
|
}
|
|
|
|
/**
|
|
* Append a census block to `$GITHUB_STEP_SUMMARY` when running in CI.
|
|
*
|
|
* No-op off CI, and never throws: this is reporting, and a test suite must
|
|
* not go red because a summary file was not writable.
|
|
*
|
|
* @param {string} title - Section heading.
|
|
* @param {ReturnType<typeof buildHeadroomRows>} rows
|
|
* @param {NodeJS.ProcessEnv} [env]
|
|
* @returns {boolean} Whether anything was written.
|
|
*/
|
|
function appendHeadroomStepSummary(title, rows, env = process.env) {
|
|
if (!env.GITHUB_STEP_SUMMARY) return false;
|
|
try {
|
|
fs.appendFileSync(env.GITHUB_STEP_SUMMARY, `${buildHeadroomSummaryMarkdown(title, rows)}\n`);
|
|
return true;
|
|
} catch (err) {
|
|
process.stderr.write(`workflow-size: could not write GITHUB_STEP_SUMMARY: ${err.message}\n`);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
WORKFLOWS_DIR,
|
|
MARGIN_RATIO,
|
|
lfByteCount,
|
|
listWorkflowStems,
|
|
measureMdFiles,
|
|
measureWorkflows,
|
|
marginFor,
|
|
buildHeadroomRows,
|
|
formatHeadroomTable,
|
|
buildHeadroomSummaryMarkdown,
|
|
appendHeadroomStepSummary,
|
|
};
|