diff --git a/package.json b/package.json index 96a5245f4..fedf7a012 100644 --- a/package.json +++ b/package.json @@ -103,6 +103,7 @@ "lint:docs": "node scripts/lint-docs-required.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", "ci:test-scope": "node scripts/ci-test-scope.cjs", + "size:baseline": "node scripts/update-size-baseline.cjs", "changeset": "node scripts/changeset/new.cjs", "changelog:render": "node scripts/changeset/cli.cjs render", "test": "node scripts/run-tests.cjs", diff --git a/scripts/lib/allowlist-ratchet.cjs b/scripts/lib/allowlist-ratchet.cjs index d0d686e6d..54f570571 100644 --- a/scripts/lib/allowlist-ratchet.cjs +++ b/scripts/lib/allowlist-ratchet.cjs @@ -133,4 +133,104 @@ function assertTightCeiling({ label, actualMax, ceiling, grace, fail }) { return { ok, slack }; } -module.exports = { assertWithinAllowlist, assertTightCeiling }; +/** + * Assert that each artifact's measured size matches a committed per-file + * baseline snapshot. Growth, shrinkage, additions, and removals are each + * surfaced by name — there is no aggregate "max" that can mask one file's + * growth behind another file's size. + * + * Fails when, for the union of `current` and `baseline` keys: + * - `current[name] > baseline[name]` → GROWTH: the file grew past its recorded + * size. Regenerate the baseline and justify the growth in the PR (or extract + * the content lazily). This is the headline guard. + * - `current[name] < baseline[name]` → STALE: the file shrank but the baseline + * still records the old (larger) size. Regenerate to auto-tighten — the + * per-file analogue of `assertWithinAllowlist`'s stale-entry rule, so the + * snapshot can only ratchet downward. + * - name in `current` but not `baseline` → ADDED: a new artifact with no + * recorded baseline. Regenerate to record it. + * - name in `baseline` but not `current` → REMOVED: an orphaned baseline entry + * whose artifact no longer exists. Regenerate to drop it. + * + * ## Why per-file, not a tier max (issue #1074) + * + * A `max(group) within grace` ceiling only binds the single largest file in the + * group; every other file inherits that ceiling and can grow silently beneath + * it. Recording each file's exact size removes the masking blind spot — the + * same reason `assertWithinAllowlist` enforces on identity rather than a count + * (issue #597). + * + * @param {object} opts + * @param {string} opts.label - Human-readable guard name (used in messages). + * @param {Object} opts.current - Measured sizes by name. + * @param {Object} opts.baseline - Committed sizes by name. + * @param {function(string): void} opts.fail - Callback invoked once per + * non-empty violation category with a + * descriptive message. Injected so callers + * control the failure mode (assert.fail, a + * thrower, or a collector in unit tests). + * @param {string} [opts.updateHint] - Optional remediation hint appended to + * every failure message (e.g. the regen + * command). + * @returns {{ grown: Array<{name:string,from:number,to:number,delta:number}>, + * shrunk: Array<{name:string,from:number,to:number,delta:number}>, + * added: string[], removed: string[] }} + * Sorted-by-name breakdown of every difference. + */ +function assertFileBaseline({ label, current, baseline, fail, updateHint }) { + const currentNames = new Set(Object.keys(current)); + const baselineNames = new Set(Object.keys(baseline)); + + const added = [...currentNames].filter((n) => !baselineNames.has(n)).sort(); + const removed = [...baselineNames].filter((n) => !currentNames.has(n)).sort(); + + const grown = []; + const shrunk = []; + const shared = [...currentNames].filter((n) => baselineNames.has(n)).sort(); + for (const name of shared) { + const from = baseline[name]; + const to = current[name]; + if (to > from) grown.push({ name, from, to, delta: to - from }); + else if (to < from) shrunk.push({ name, from, to, delta: from - to }); + } + + const hint = updateHint ? `\n${updateHint}` : ''; + + if (grown.length > 0) { + const list = grown + .map((g) => ` - ${g.name}: ${g.from} → ${g.to} (+${g.delta})`) + .join('\n'); + fail( + `[${label}] ${grown.length} file(s) grew past the committed baseline. ` + + `Regenerate the baseline and justify the growth in your PR, or extract the content lazily.\n${list}${hint}` + ); + } + + if (shrunk.length > 0) { + const list = shrunk + .map((s) => ` - ${s.name}: ${s.from} → ${s.to} (-${s.delta})`) + .join('\n'); + fail( + `[${label}] ${shrunk.length} file(s) are SMALLER than the baseline — the snapshot is stale ` + + `and MUST be regenerated so the budget ratchets downward.\n${list}${hint}` + ); + } + + if (added.length > 0) { + const list = added.map((n) => ` - ${n}`).join('\n'); + fail( + `[${label}] ${added.length} file(s) are not in the baseline — regenerate to record them.\n${list}${hint}` + ); + } + + if (removed.length > 0) { + const list = removed.map((n) => ` - ${n}`).join('\n'); + fail( + `[${label}] ${removed.length} baseline entry(ies) no longer exist — regenerate to drop them.\n${list}${hint}` + ); + } + + return { grown, shrunk, added, removed }; +} + +module.exports = { assertWithinAllowlist, assertTightCeiling, assertFileBaseline }; diff --git a/scripts/update-size-baseline.cjs b/scripts/update-size-baseline.cjs new file mode 100644 index 000000000..6dd494d36 --- /dev/null +++ b/scripts/update-size-baseline.cjs @@ -0,0 +1,58 @@ +#!/usr/bin/env node +'use strict'; + +/** + * @file update-size-baseline.cjs + * + * Regenerates the committed per-file workflow size baseline + * (`tests/workflow-size-baseline.json`) from the current workflow files. + * + * Run via `npm run size:baseline` whenever a workflow file legitimately grows + * or shrinks. Growth must still be justified in the PR; this script only + * records the new reality so the CI guard (issue #1074) can diff against it. + * + * Idempotent: running it twice with no file changes produces no diff. + */ + +const fs = require('fs'); +const path = require('path'); +const { measureWorkflows, WORKFLOWS_DIR } = require('./workflow-size.cjs'); + +const BASELINE_PATH = path.join(__dirname, '..', 'tests', 'workflow-size-baseline.json'); + +/** + * Serialize a size map to the on-disk baseline format: keys sorted, 2-space + * indent, trailing newline (so the file is a stable, minimal-diff artifact). + * + * @param {Object} sizes + * @returns {string} + */ +function serializeBaseline(sizes) { + const sorted = {}; + for (const key of Object.keys(sizes).sort()) sorted[key] = sizes[key]; + return JSON.stringify(sorted, null, 2) + '\n'; +} + +/** + * Write the baseline file from the measured workflow sizes. + * + * @param {object} [opts] + * @param {string} [opts.dir] - Workflows dir to measure (default canonical). + * @param {string} [opts.outPath] - Baseline file to write (default canonical). + * @returns {{ outPath: string, count: number, content: string }} + */ +function generateBaseline({ dir = WORKFLOWS_DIR, outPath = BASELINE_PATH } = {}) { + const sizes = measureWorkflows(dir); + const content = serializeBaseline(sizes); + fs.writeFileSync(outPath, content); + return { outPath, count: Object.keys(sizes).length, content }; +} + +if (require.main === module) { + const { outPath, count } = generateBaseline(); + process.stdout.write( + `Wrote ${count} workflow sizes to ${path.relative(process.cwd(), outPath)}\n` + ); +} + +module.exports = { generateBaseline, serializeBaseline, BASELINE_PATH }; diff --git a/scripts/workflow-size.cjs b/scripts/workflow-size.cjs new file mode 100644 index 000000000..9a2f23e31 --- /dev/null +++ b/scripts/workflow-size.cjs @@ -0,0 +1,68 @@ +'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` (the CI guard) and + * `scripts/update-size-baseline.cjs` (the baseline generator) so the two can + * never disagree on HOW a file is measured. A divergence between the generator + * and the guard would silently mis-record the baseline (issue #1074). + */ + +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, but + * these `.md` files have no `eol=lf` in `.gitattributes`, so Windows checks + * them out as CRLF. Counting raw on-disk bytes there 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. + * 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//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 workflow file, keyed by filename (`.md`). + * + * @param {string} [dir] - Workflows directory (defaults to the canonical one). + * @returns {Object} Map of `.md` → LF byte size, with + * keys inserted in sorted order. + */ +function measureWorkflows(dir = WORKFLOWS_DIR) { + const out = {}; + for (const stem of listWorkflowStems(dir)) { + out[`${stem}.md`] = lfByteCount(path.join(dir, `${stem}.md`)); + } + return out; +} + +module.exports = { WORKFLOWS_DIR, lfByteCount, listWorkflowStems, measureWorkflows }; diff --git a/tests/allowlist-ratchet.test.cjs b/tests/allowlist-ratchet.test.cjs index ab2eecdc5..6aa8216aa 100644 --- a/tests/allowlist-ratchet.test.cjs +++ b/tests/allowlist-ratchet.test.cjs @@ -14,6 +14,7 @@ const assert = require('node:assert/strict'); const { assertWithinAllowlist, assertTightCeiling, + assertFileBaseline, } = require('../scripts/lib/allowlist-ratchet.cjs'); // ─── Fake fail helper ──────────────────────────────────────────────────────── @@ -295,3 +296,150 @@ describe('assertTightCeiling', () => { assert.ok(calls[0].includes('my-special-guard'), 'label should appear in message'); }); }); + +// ─── assertFileBaseline ────────────────────────────────────────────────────── + +describe('assertFileBaseline', () => { + test('exact match: current equals baseline — fail never called', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'a.md': 100, 'b.md': 200 }, + baseline: { 'a.md': 100, 'b.md': 200 }, + fail, + }); + assert.strictEqual(calls.length, 0, 'fail should not be called on an exact match'); + assert.deepStrictEqual(result.grown, []); + assert.deepStrictEqual(result.shrunk, []); + assert.deepStrictEqual(result.added, []); + assert.deepStrictEqual(result.removed, []); + }); + + test('growth: a file larger than baseline — fail called, delta reported', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'a.md': 154, 'b.md': 200 }, + baseline: { 'a.md': 100, 'b.md': 200 }, + fail, + }); + assert.strictEqual(calls.length, 1, 'fail should be called once for growth'); + assert.ok(calls[0].includes('grew') || calls[0].includes('grow'), 'message should describe growth'); + assert.ok(calls[0].includes('a.md'), 'message should name the grown file'); + assert.ok(calls[0].includes('100') && calls[0].includes('154'), 'message should show from → to'); + assert.ok(calls[0].includes('54'), 'message should show the +delta'); + assert.deepStrictEqual(result.grown.map((g) => g.name), ['a.md']); + assert.strictEqual(result.grown[0].delta, 54); + }); + + test('shrink: a file smaller than baseline — fail called as stale (auto-tighten)', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'a.md': 80, 'b.md': 200 }, + baseline: { 'a.md': 100, 'b.md': 200 }, + fail, + }); + assert.strictEqual(calls.length, 1, 'fail should be called once for a stale (shrunk) baseline'); + assert.ok(/stale|smaller|shrank|shrunk/i.test(calls[0]), 'message should flag a stale/shrunk baseline'); + assert.ok(calls[0].includes('a.md'), 'message should name the shrunk file'); + assert.deepStrictEqual(result.shrunk.map((s) => s.name), ['a.md']); + assert.strictEqual(result.shrunk[0].delta, 20); + }); + + test('added: a file absent from baseline — fail called', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'a.md': 100, 'new.md': 50 }, + baseline: { 'a.md': 100 }, + fail, + }); + assert.strictEqual(calls.length, 1, 'fail should be called once for an unbaselined new file'); + assert.ok(/not in the baseline|new|missing/i.test(calls[0]), 'message should flag the unbaselined file'); + assert.ok(calls[0].includes('new.md'), 'message should name the new file'); + assert.deepStrictEqual(result.added, ['new.md']); + }); + + test('removed: a baseline entry with no current file — fail called', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'a.md': 100 }, + baseline: { 'a.md': 100, 'gone.md': 70 }, + fail, + }); + assert.strictEqual(calls.length, 1, 'fail should be called once for an orphaned baseline entry'); + assert.ok(/no longer exist|removed|orphan/i.test(calls[0]), 'message should flag the orphaned entry'); + assert.ok(calls[0].includes('gone.md'), 'message should name the orphaned entry'); + assert.deepStrictEqual(result.removed, ['gone.md']); + }); + + test('multiple categories at once — one fail per non-empty category', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'grow.md': 150, 'shrink.md': 50, 'new.md': 10 }, + baseline: { 'grow.md': 100, 'shrink.md': 100, 'gone.md': 30 }, + fail, + }); + // grown(1) + shrunk(1) + added(1) + removed(1) = 4 categories + assert.strictEqual(calls.length, 4, 'one fail per non-empty category'); + assert.deepStrictEqual(result.grown.map((g) => g.name), ['grow.md']); + assert.deepStrictEqual(result.shrunk.map((s) => s.name), ['shrink.md']); + assert.deepStrictEqual(result.added, ['new.md']); + assert.deepStrictEqual(result.removed, ['gone.md']); + }); + + test('updateHint appears in every failure message', () => { + const { fail, calls } = makeFail(); + assertFileBaseline({ + label: 'workflow-size', + current: { 'grow.md': 150, 'new.md': 10 }, + baseline: { 'grow.md': 100, 'gone.md': 30 }, + fail, + updateHint: 'Run `npm run size:baseline`', + }); + assert.ok(calls.length >= 2, 'multiple categories should each fail'); + for (const msg of calls) { + assert.ok(msg.includes('Run `npm run size:baseline`'), 'every message should carry the updateHint'); + } + }); + + test('empty inputs — fail never called', () => { + const { fail, calls } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: {}, + baseline: {}, + fail, + }); + assert.strictEqual(calls.length, 0); + assert.deepStrictEqual(result.grown, []); + assert.deepStrictEqual(result.shrunk, []); + assert.deepStrictEqual(result.added, []); + assert.deepStrictEqual(result.removed, []); + }); + + test('returned category lists are sorted by name', () => { + const { fail } = makeFail(); + const result = assertFileBaseline({ + label: 'workflow-size', + current: { 'z.md': 10, 'a.md': 10, 'm.md': 10 }, + baseline: {}, + fail, + }); + assert.deepStrictEqual(result.added, ['a.md', 'm.md', 'z.md'], 'added should be sorted'); + }); + + test('label appears in failure messages', () => { + const { fail, calls } = makeFail(); + assertFileBaseline({ + label: 'my-size-guard', + current: { 'a.md': 200 }, + baseline: { 'a.md': 100 }, + fail, + }); + assert.ok(calls[0].includes('my-size-guard'), 'label should appear in message'); + }); +}); diff --git a/tests/update-size-baseline.test.cjs b/tests/update-size-baseline.test.cjs new file mode 100644 index 000000000..26feaacea --- /dev/null +++ b/tests/update-size-baseline.test.cjs @@ -0,0 +1,127 @@ +'use strict'; + +/** + * Tests for scripts/update-size-baseline.cjs — the per-file workflow size + * baseline generator (issue #1074). + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('node:os'); +const path = require('path'); + +const { + generateBaseline, + serializeBaseline, +} = require('../scripts/update-size-baseline.cjs'); +const { assertFileBaseline } = require('../scripts/lib/allowlist-ratchet.cjs'); +const { measureWorkflows } = require('../scripts/workflow-size.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('serializeBaseline', () => { + test('keys are sorted and output ends with a trailing newline', () => { + const out = serializeBaseline({ 'z.md': 3, 'a.md': 1, 'm.md': 2 }); + assert.ok(out.endsWith('\n'), 'must end with a trailing newline'); + const keys = Object.keys(JSON.parse(out)); + assert.deepStrictEqual(keys, ['a.md', 'm.md', 'z.md'], 'keys must be sorted'); + }); + + test('is stable: same input serializes identically (minimal-diff artifact)', () => { + const input = { 'b.md': 2, 'a.md': 1 }; + assert.strictEqual(serializeBaseline(input), serializeBaseline({ ...input })); + }); +}); + +describe('generateBaseline', () => { + let dir; + let outPath; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-gen-baseline-')); + outPath = path.join(dir, 'baseline.json'); + }); + afterEach(() => cleanup(dir)); + + test('writes a baseline matching the measured workflow sizes', () => { + const wfDir = path.join(dir, 'workflows'); + fs.mkdirSync(wfDir); + fs.writeFileSync(path.join(wfDir, 'one.md'), 'hello\n'); + fs.writeFileSync(path.join(wfDir, 'two.md'), 'a longer body here\n'); + + const result = generateBaseline({ dir: wfDir, outPath }); + assert.strictEqual(result.count, 2); + + const written = JSON.parse(fs.readFileSync(outPath, 'utf-8')); + assert.deepStrictEqual(written, measureWorkflows(wfDir)); + }); + + test('idempotent: a second run with no changes produces an identical file', () => { + const wfDir = path.join(dir, 'workflows'); + fs.mkdirSync(wfDir); + fs.writeFileSync(path.join(wfDir, 'one.md'), 'hello\n'); + + generateBaseline({ dir: wfDir, outPath }); + const first = fs.readFileSync(outPath, 'utf-8'); + generateBaseline({ dir: wfDir, outPath }); + const second = fs.readFileSync(outPath, 'utf-8'); + assert.strictEqual(first, second, 'a no-op regeneration must not churn the file'); + }); + + test('round-trip: a freshly generated baseline satisfies assertFileBaseline', () => { + const wfDir = path.join(dir, 'workflows'); + fs.mkdirSync(wfDir); + fs.writeFileSync(path.join(wfDir, 'one.md'), 'hello\n'); + fs.writeFileSync(path.join(wfDir, 'two.md'), 'world body\n'); + + generateBaseline({ dir: wfDir, outPath }); + const baseline = JSON.parse(fs.readFileSync(outPath, 'utf-8')); + + const calls = []; + assertFileBaseline({ + label: 'roundtrip', + current: measureWorkflows(wfDir), + baseline, + fail: (m) => calls.push(m), + }); + assert.deepStrictEqual(calls, [], 'a just-generated baseline must pass the guard with zero failures'); + }); + + test('regeneration records growth after a workflow file grows', () => { + const wfDir = path.join(dir, 'workflows'); + fs.mkdirSync(wfDir); + const wf = path.join(wfDir, 'one.md'); + fs.writeFileSync(wf, 'small\n'); + generateBaseline({ dir: wfDir, outPath }); + + // File grows; the OLD baseline should now flag growth... + fs.writeFileSync(wf, 'small\nplus several more bytes\n'); + const oldBaseline = JSON.parse(fs.readFileSync(outPath, 'utf-8')); + const beforeRegen = []; + assertFileBaseline({ + label: 'grow', + current: measureWorkflows(wfDir), + baseline: oldBaseline, + fail: (m) => beforeRegen.push(m), + }); + assert.strictEqual(beforeRegen.length, 1, 'old baseline must flag the growth'); + + // ...and regenerating clears it. + generateBaseline({ dir: wfDir, outPath }); + const newBaseline = JSON.parse(fs.readFileSync(outPath, 'utf-8')); + const afterRegen = []; + assertFileBaseline({ + label: 'grow', + current: measureWorkflows(wfDir), + baseline: newBaseline, + fail: (m) => afterRegen.push(m), + }); + assert.deepStrictEqual(afterRegen, [], 'regenerated baseline must pass'); + }); + + test('throws when the workflows directory does not exist', () => { + assert.throws( + () => generateBaseline({ dir: path.join(dir, 'missing'), outPath }), + /ENOENT/ + ); + }); +}); diff --git a/tests/workflow-size-baseline.json b/tests/workflow-size-baseline.json new file mode 100644 index 000000000..e8b235b3a --- /dev/null +++ b/tests/workflow-size-baseline.json @@ -0,0 +1,90 @@ +{ + "add-backlog.md": 7132, + "add-phase.md": 7203, + "add-tests.md": 16901, + "add-todo.md": 8952, + "ai-integration-phase.md": 14761, + "analyze-dependencies.md": 3887, + "audit-fix.md": 10988, + "audit-milestone.md": 17556, + "audit-uat.md": 7425, + "autonomous.md": 41838, + "check-todos.md": 9431, + "cleanup.md": 9897, + "code-review-fix.md": 23676, + "code-review.md": 31397, + "complete-milestone.md": 30228, + "debug.md": 13505, + "diagnose-issues.md": 12425, + "discovery-phase.md": 8651, + "discuss-phase-assumptions.md": 26984, + "discuss-phase-power.md": 11273, + "discuss-phase.md": 31423, + "do.md": 10068, + "docs-update.md": 54770, + "edit-phase.md": 12883, + "eval-review.md": 9923, + "execute-phase.md": 92885, + "execute-plan.md": 29980, + "explore.md": 10497, + "extract-learnings.md": 12849, + "fast.md": 4149, + "forensics.md": 12463, + "graduation.md": 11578, + "health.md": 11824, + "help.md": 1722, + "import.md": 14604, + "inbox.md": 14407, + "ingest-docs.md": 18336, + "insert-phase.md": 8943, + "list-phase-assumptions.md": 4305, + "list-workspaces.md": 5655, + "manager.md": 25937, + "map-codebase.md": 20360, + "milestone-summary.md": 11774, + "mvp-phase.md": 13582, + "new-milestone.md": 32422, + "new-project.md": 61690, + "new-workspace.md": 11254, + "next.md": 17868, + "node-repair.md": 4173, + "note.md": 6563, + "pause-work.md": 13654, + "plan-milestone-gaps.md": 11765, + "plan-phase.md": 93135, + "plan-review-convergence.md": 22949, + "plant-seed.md": 11741, + "pr-branch.md": 4994, + "profile-user.md": 20457, + "progress.md": 26647, + "quick.md": 46213, + "reapply-patches.md": 20393, + "remove-phase.md": 8469, + "remove-workspace.md": 7507, + "resume-project.md": 15288, + "review.md": 37079, + "scan.md": 7688, + "secure-phase.md": 12187, + "session-report.md": 4044, + "settings-advanced.md": 39330, + "settings-integrations.md": 15801, + "settings.md": 32133, + "ship.md": 20896, + "sketch-wrap-up.md": 14223, + "sketch.md": 19960, + "spec-phase.md": 15131, + "spike-wrap-up.md": 15092, + "spike.md": 24517, + "stats.md": 6718, + "sync-skills.md": 6125, + "thread.md": 12400, + "transition.md": 21787, + "ui-phase.md": 15477, + "ui-review.md": 11289, + "ultraplan-phase.md": 10468, + "undo.md": 10431, + "update.md": 21053, + "validate-phase.md": 10630, + "verify-phase.md": 28430, + "verify-work.md": 30614 +} diff --git a/tests/workflow-size-budget.test.cjs b/tests/workflow-size-budget.test.cjs index 84233e1e0..9ee837cc3 100644 --- a/tests/workflow-size-budget.test.cjs +++ b/tests/workflow-size-budget.test.cjs @@ -66,10 +66,12 @@ const assert = require('node:assert/strict'); const fs = require('fs'); const os = require('node:os'); const path = require('path'); -const { assertTightCeiling } = require('../scripts/lib/allowlist-ratchet.cjs'); +const { assertTightCeiling, assertFileBaseline } = require('../scripts/lib/allowlist-ratchet.cjs'); +const { lfByteCount: byteCount, measureWorkflows } = require('../scripts/workflow-size.cjs'); const { cleanup } = require('./helpers.cjs'); const WORKFLOWS_DIR = path.join(__dirname, '..', 'gsd-core', 'workflows'); +const BASELINE_PATH = path.join(__dirname, 'workflow-size-baseline.json'); // Grace band: maximum allowed slack (ceiling − actualMax) in BYTES before a // ceiling is considered too loose. 3000 bytes ≈ the prior 60-line grace @@ -127,18 +129,10 @@ function budgetFor(workflow) { return { tier: 'DEFAULT', limit: DEFAULT_BUDGET }; } -function byteCount(filePath) { - // Count bytes as on an LF checkout, so the budget is platform-independent. - // The tier ceilings are calibrated against `wc -c` on a Unix (LF) checkout, - // but these .md files have no `eol=lf` in .gitattributes, so Windows checks - // them out as CRLF. Counting raw on-disk bytes there adds one byte per line, - // which fails CI on the high-water-mark file (execute-phase.md) on Windows - // ONLY — a false positive that diverges from the LF calibration basis (#683). - // Stripping CR yields the same LF byte count on every platform. Still a raw - // byte count (not the old trailing-newline-stripping lineCount()). - const content = fs.readFileSync(filePath, 'utf-8'); - return Buffer.byteLength(content.replace(/\r\n/g, '\n'), 'utf-8'); -} +// byteCount (LF-normalized, #683) is imported as `lfByteCount` from +// scripts/workflow-size.cjs — the single source of truth shared with the +// baseline generator so the guard and the snapshot can never measure +// differently. See the #683 regression test at the bottom of this file. describe('SIZE: workflow byte-size budget', () => { for (const workflow of ALL_WORKFLOWS) { @@ -159,6 +153,31 @@ describe('SIZE: workflow byte-size budget', () => { } }); +describe('SIZE: per-file workflow baseline (issue #1074)', () => { + // Per-file exact-size ratchet. Unlike the tier anti-creep block below — which + // only binds the single largest file in each tier — this guards EVERY + // workflow file by name against a committed snapshot + // (tests/workflow-size-baseline.json). Growth fails with the file and delta; + // shrinkage fails as a stale snapshot (regenerate to ratchet down). The fix + // for any failure is `npm run size:baseline` plus a PR justification for + // genuine growth (or lazy extraction). Runs side-by-side with the tier tests + // during the #1074 migration; the tier anti-creep block is removed in a + // follow-up once this is established. + test('every workflow file matches its committed baseline', () => { + const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf-8')); + const current = measureWorkflows(); + assertFileBaseline({ + label: 'workflow-size', + current, + baseline, + fail: assert.fail, + updateHint: + 'Run `npm run size:baseline` to update tests/workflow-size-baseline.json, ' + + 'then justify any growth in your PR (or extract content lazily — see workflows/discuss-phase/).', + }); + }); +}); + describe('SIZE: tier anti-creep (tighten-only ceilings, issue #597)', () => { // For each tier, compute the high-water mark (in bytes) across all files in // that tier and assert the ceiling stays tight. Prevents budgets from diff --git a/tests/workflow-size.test.cjs b/tests/workflow-size.test.cjs new file mode 100644 index 000000000..cc6837338 --- /dev/null +++ b/tests/workflow-size.test.cjs @@ -0,0 +1,98 @@ +'use strict'; + +/** + * Tests for scripts/workflow-size.cjs — the shared LF byte counter and + * workflow enumeration used by both the size guard and the baseline generator. + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('node:os'); +const path = require('path'); + +const { + lfByteCount, + listWorkflowStems, + measureWorkflows, + WORKFLOWS_DIR, +} = require('../scripts/workflow-size.cjs'); +const { cleanup } = require('./helpers.cjs'); + +describe('lfByteCount', () => { + let dir; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wfsize-')); + }); + afterEach(() => cleanup(dir)); + + test('counts raw UTF-8 bytes of an LF file', () => { + const body = 'line one\nline two\n'; + const p = path.join(dir, 'a.md'); + fs.writeFileSync(p, body); + assert.strictEqual(lfByteCount(p), Buffer.byteLength(body, 'utf-8')); + }); + + test('CRLF and LF of the same logical content count identically (#683)', () => { + const body = 'alpha\nbeta\ngamma — multibyte dash\n'; + const lf = path.join(dir, 'lf.md'); + const crlf = path.join(dir, 'crlf.md'); + fs.writeFileSync(lf, body); + fs.writeFileSync(crlf, body.replace(/\n/g, '\r\n')); + assert.strictEqual(lfByteCount(crlf), lfByteCount(lf)); + }); + + test('multibyte characters count as their UTF-8 byte length', () => { + const body = '— 漢字 🚀\n'; // em-dash (3) + CJK (3 each) + emoji (4) + const p = path.join(dir, 'm.md'); + fs.writeFileSync(p, body); + assert.strictEqual(lfByteCount(p), Buffer.byteLength(body, 'utf-8')); + }); + + test('empty file counts as zero bytes', () => { + const p = path.join(dir, 'empty.md'); + fs.writeFileSync(p, ''); + assert.strictEqual(lfByteCount(p), 0); + }); + + test('throws on a missing file (no silent zero)', () => { + assert.throws(() => lfByteCount(path.join(dir, 'nope.md')), /ENOENT/); + }); +}); + +describe('listWorkflowStems / measureWorkflows', () => { + let dir; + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-wfmeasure-')); + }); + afterEach(() => cleanup(dir)); + + test('lists only .md stems, sorted, without extension', () => { + fs.writeFileSync(path.join(dir, 'zeta.md'), 'z'); + fs.writeFileSync(path.join(dir, 'alpha.md'), 'a'); + fs.writeFileSync(path.join(dir, 'notes.txt'), 'ignored'); + assert.deepStrictEqual(listWorkflowStems(dir), ['alpha', 'zeta']); + }); + + test('does not recurse into subdirectories (modes/templates excluded)', () => { + fs.writeFileSync(path.join(dir, 'top.md'), 'x'); + fs.mkdirSync(path.join(dir, 'modes')); + fs.writeFileSync(path.join(dir, 'modes', 'sub.md'), 'should not be counted'); + assert.deepStrictEqual(listWorkflowStems(dir), ['top']); + assert.deepStrictEqual(Object.keys(measureWorkflows(dir)), ['top.md']); + }); + + test('measureWorkflows keys by .md with LF byte sizes', () => { + const body = 'hello\nworld\n'; + fs.writeFileSync(path.join(dir, 'one.md'), body); + const sizes = measureWorkflows(dir); + assert.deepStrictEqual(sizes, { 'one.md': Buffer.byteLength(body, 'utf-8') }); + }); + + test('canonical WORKFLOWS_DIR resolves to a real directory with workflows', () => { + assert.ok(fs.existsSync(WORKFLOWS_DIR), 'canonical workflows dir should exist'); + const sizes = measureWorkflows(); + assert.ok(Object.keys(sizes).length > 0, 'should measure at least one workflow'); + assert.ok('plan-phase.md' in sizes, 'plan-phase.md should be among the measured workflows'); + }); +});