Files
msd-core/scripts/workflow-size.cjs
Tom Boucher a613caaeef enhance(#2721): regenerating merge driver, regen:derived, and a name for the emitted-artifact family (#2730)
* test(#2721): failing-first suite for the gsd-regen driver and CONTEXT.md parity

Tests precede the implementation per the TDD gate. The driver module does not
exist yet, so tests/git-merge-regen-driver.test.cjs fails at require time; the
contributor-standards parity assertions fail against next as it stands today,
where the standards doc names two CONTEXT.md headings that have never existed.

Refs #2721

* feat(#2721): add the gsd-regen merge driver and regen:derived

The golden parity manifests and the two size baselines are pure functions of
the source tree, so their only correct merge is "recompute" -- something git's
ours/theirs interface cannot express. 140 of 143 conflicted-file instances
across the open PR queue are these files.

The driver deliberately does NOT regenerate. Four probes established that at
merge-driver time neither the working tree nor the index reflects the merge:
both hold the ours side, a file added by theirs does not exist yet, and
MERGE_HEAD is unwritten. Git also invokes the driver once per conflicted path
(20 here). A regenerating driver would therefore read the ours-side tree and
emit a plausible-but-wrong hash manifest -- worse than a conflict, because a
conflict is visible. So it accepts %A, runs zero subprocesses, records the
resolved paths, and prints one notice pointing at npm run regen:derived.
Staleness stays caught where it already was, by golden-install-parity in CI.

Every failure path degrades toward today's behaviour (a normal conflict).
install-tree is deliberately excluded per ADR-2719 section 7.

Also folded in, per the no-defer rule: workflow-size.cjs claimed .md files have
no eol=lf in .gitattributes; git check-attr shows eol: lf, set by .gitattributes
line 2 since #1088.

Refs #2721

* docs(#2721): document regen:derived and the gsd-regen merge driver

Adds the how-to a contributor actually reaches for when the generated parity
manifests or size baselines conflict, in both places they would look: the
merge-conflict path in CONTRIBUTING.md and the full guide in TESTING-SUITES.md,
including what the driver deliberately does not do (it does not clear GitHub's
CONFLICTING label, and it does not regenerate mid-merge).

Also scopes the new contributor-standards parity assertion to the doc's own
CONTEXT.md section. Its first run flagged `## Decision`, `## Consequences` and
`## Standards followed`, which the doc attributes to an ADR body and a PR body
rather than to CONTEXT.md -- a doc-wide extractor would have demanded CONTEXT.md
grow headings that do not belong to it.

Refs #2721

* fix(#2721): stop passing %P to the merge driver — shell injection

The isolated adversarial review found, and I independently reproduced, local
arbitrary command execution.

Git does not invoke a merge driver with an argv array. It substitutes %O %A %B
%L %P textually into the configured string and runs the whole thing through a
shell, and $(...) executes inside POSIX double quotes -- so quoting the
placeholder does not neutralise it. %O/%A/%B are git-generated temp names and
%L is an integer, but %P is the file's own path, chosen freely by any
contributor. A branch renaming a covered fixture to
evil$(touch PWNED_SENTINEL).json executed that command on the machine of every
maintainer who merged it, and the merge still reported success.

Fix removes the input rather than filtering it: %P is no longer registered, so
the driver receives no attacker-controlled argument at all. The marker records
a count instead of path names. A metacharacter filter would have been a guess
about shell grammar; passing nothing is a property. Re-ran the identical
exploit against the fixed command: nothing executed, conflict still resolved.

Two regressions guard it -- a platform-independent assertion that the
registered command carries no %P, and a real merge driven by the actual
planInstall output with a $(...) filename.

Also from review: CLI dispatch had no coverage at all (CONTRIBUTING's
"CLI and command routing" matrix), which is why runInstall/runStatus now take
{repoRoot} -- hardcoding REPO_ROOT was what made them untestable. Renamed
planResolution to resolveAndRecord since the plan* prefix promised purity it
did not have. Reconciled the eleven-vs-twelve generator count across
CONTEXT.md, CONTRIBUTING.md and the changeset.

Refs #2721

* test(#2721): scope safe.directory for the check-attr helper

The 66f4d85a run failed 11 assertions, all in the .gitattributes scoping block,
with "fatal: detected dubious ownership in repository at '/work'". The test
container checks the repo out at a path its user does not own, so git refuses
check-attr outright. Everything else passed (27,185).

`check-attr` is a pure read of .gitattributes -- no hooks, no filters -- so the
exemption is scoped to that one invocation. It is deliberately NOT applied to
the driver's own production `git config` calls, which run in the user's own
clone and should keep the protection.

Refs #2721

* test(#2721): delete the stale assertion that the driver command carries %P

The plex2 run on bdfd0856 left exactly two failures, both this test: it still
asserted the pre-fix command string, i.e. the vulnerable behaviour. Deleted
rather than relaxed, per RULESET.TESTS.delete-bad-tests -- its useful half is
already covered, in both directions, by
registeredDriverCommandNeverPassesThePlaceholderForTheFilePath.

Refs #2721

* test(#2721): drive the end-to-end merges from the real planInstall output

The e2e helper hand-rolled its own driver registration, and still carried %P.
That meant the five real-git tests were not exercising the production command
string at all -- planInstall could drift and they would keep passing. They now
register exactly what a contributor gets from npm run setup:merge-driver.

Refs #2721

* chore(#2721): backfill changeset pr number to 2730
2026-07-27 19:55:37 -04:00

96 lines
3.5 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` (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.
* 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);
}
module.exports = {
WORKFLOWS_DIR,
lfByteCount,
listWorkflowStems,
measureMdFiles,
measureWorkflows,
};