* 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
96 lines
3.5 KiB
JavaScript
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,
|
|
};
|