* fix(#4447): classify mixed structural+transient planning commits as a 5th arm pr-branch.md's analyze_commits step computed only NON_PLANNING and STRUCTURAL per commit -- never a total planning-file count -- so its four classification arms assumed every planning-only commit was either wholly structural or wholly non-structural. A commit touching both a structural .planning/ path and a transient/other one matched no arm, and the ambiguous prose let an LLM executing the workflow silently drop it, breaking STATE.md's per-commit revision chain in default mode. Adds an explicit PLANNING_COUNT variable and rewrites the four arms into five, each with an exact computable condition. The new "mixed planning commit" arm (structural + transient/other, no code) gets the same treatment mixed code+planning commits already get: INCLUDE, relying on create_pr_branch's existing universal per-commit filter to strip the transient/other paths -- no new filtering logic needed. tests/helpers/pr-branch-filter.cjs's classifyCommit already returned 'include' for this shape (no upper bound on its structural check); the defect was entirely in the workflow's own prose spec, which is what an executing agent actually reads. New tests pin both: classifyCommit's already-correct behavior (tests 49-50), and a failing-first assertion that analyze_commits computes an explicit planning-total signal (test 51, fails against the pre-fix text). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix(#4447): address code-review findings on the mixed-planning arm - Correct the mixed-planning arm's prose: create_pr_branch's universal filter only strips the TRANSIENT_DIRS subset, not the "other" bucket (config.json, intel/, etc.) -- that subset is preserved, not filtered, same as default mode already does for it on any commit. - Fix the "Mixed planning commits" display line to use the same mode-conditional bracket form as "Structural planning commits" -- it was hardcoding "included" even though the arm is EXCLUDE in strict mode, which would have misled a strict-mode user. - Tighten test 51's regex from unanchored /PLANNING_COUNT=/ to /^PLANNING_COUNT=\$\(/m so it requires the real shell-assignment shape, not just the substring appearing anywhere in prose. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Emitted-Drift-Ack-Growth: pr-branch.md — growth is this PR's own #4447 fix (5th classification arm with explicit computable conditions), not incidental drift * fix(#4447): bound test 51's regex quantifier (local/no-unbounded-quantifier) lint:ci flagged the unbounded [\s\S]*? over readFileSync content as a catastrophic-backtracking risk (CWE-1333 class). Bounded to {0,20000}, comfortably larger than the analyze_commits step's actual size. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4447): add changeset for the pr-branch mixed-planning classification fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: eliminate SIGPIPE race in gsd-validate-commit.sh subject/config extraction Discovered while verifying an unrelated PR (#4447): tests/hooks-opt-in.test.cjs's "a git-GENERATED subject is never measured against the supplied message (round 7)" test intermittently got r.status===141 instead of the expected 2 for the --fixup=HEAD case, on a run where the identical code had passed cleanly moments earlier -- confirming a timing race, not a deterministic bug in the test's own assertions. Root cause: gsd-validate-commit.sh runs under `set -euo pipefail` and extracted the commit subject via `SUBJECT=$(echo "$MSG" | head -1)` (two call sites) and the opt-in ENABLED flag via `$(printf '%s\n' "$CONFIG_OUT" | head -1)`. `head -1` closes its read end as soon as it has one line; a real commit message or multi-command-type CONFIG_OUT is multi-line, so the writer can receive SIGPIPE (exit 128+13=141) if its write lands after that close. Under pipefail this is NOT suppressed -- it aborts the whole hook instead of the intended exit-2 rejection. Fix: replace both patterns with pure bash parameter expansion (`${VAR%%$'\n'*}`) -- zero subprocesses, zero pipe/race surface, and behaviorally identical to `head -1` for single-line, multi-line, and trailing-newline input (verified directly). The third similar pipe (`tail -n +2` feeding a `while read` loop that drains to EOF) is a different, race-free shape and was left alone. Regression test is a static, by-construction assertion (per this repo's policy against forcing scheduling races to reproduce deterministically): the vulnerable pipe patterns must be absent from the shipped script, and the parameter-expansion forms must be present. This overrides one-concern-per-PR per CLAUDE.md's Defects & Warnings policy -- a genuine defect discovered mid-work is fixed inline, not deferred to a separate issue. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs: add changeset for the gsd-validate-commit.sh SIGPIPE race fix Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: harden SIGPIPE-race regression test against reformatted reintroduction Code review found the test's original exact-string regexes would miss a cosmetically-reworded reintroduction of the same dangerous head-1 pipe (extra whitespace, an appended 2>/dev/null). Broadened to content-tolerant but still $(...)-wrapped regexes (bounded quantifiers per local/no-unbounded-quantifier) -- verified against both the current file (no false positive, including the fix's own explanatory comments that quote the bare unwrapped pattern in prose) and a synthetic reformatted reintroduction (correctly caught). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4447): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1356,6 +1356,48 @@ EOF
|
||||
assert.strictEqual(runHookCmd(`git commit -m ${HD_OK}`).status, 0, 'non-vacuity: canonical form still resolves');
|
||||
});
|
||||
|
||||
test('subject extraction uses no pipe-to-head (SIGPIPE race, #4447 follow-on)', () => {
|
||||
// `echo "$MSG" | head -1` (and the equivalent `printf ... | head -1` for
|
||||
// the opt-in ENABLED flag) is a SIGPIPE race under `set -euo pipefail`:
|
||||
// `head -1` can close its read end as soon as it has one line, and a real
|
||||
// commit message/config output is multi-line, so `echo`/`printf` can die
|
||||
// with signal 13 (exit 141) if its write lands after that close — which
|
||||
// aborts the whole hook instead of the expected exit 2. This is a static,
|
||||
// by-construction pin (not a timing repro, per this repo's policy against
|
||||
// forcing scheduling races to reproduce deterministically): the fix
|
||||
// (`${VAR%%$'\n'*}`, a pure parameter expansion with zero subprocesses and
|
||||
// therefore zero pipe/race surface) must be present, and the vulnerable
|
||||
// pipe pattern must be gone.
|
||||
const hookSrc = fs.readFileSync(path.join(HOOKS_DIR, 'gsd-validate-commit.sh'), 'utf8');
|
||||
// Regexes require the `$(...)` command-substitution wrapper so this only
|
||||
// matches the actual dangerous CODE pattern, never the explanatory prose
|
||||
// comments left at the fix site (which quote the bare pipeline, without
|
||||
// the `$(...)` wrapper, for documentation purposes). `\s+`/`\s*` tolerate
|
||||
// incidental reformatting (extra spaces, an appended `2>/dev/null`, etc.)
|
||||
// so a cosmetically-reworded reintroduction of the SAME dangerous shape
|
||||
// does not silently escape this check (review finding: an exact-string
|
||||
// match would).
|
||||
// Requires the `$(...)` command-substitution wrapper (real CODE, never
|
||||
// the explanatory prose comments left at the fix site, which quote the
|
||||
// bare "$MSG" | head -1 / "$CONFIG_OUT" | head -1 shape WITHOUT a `$(`
|
||||
// in front — an earlier, unwrapped version of this same regex matched
|
||||
// those comments and false-failed). Content between `$(` and `"$MSG"`/
|
||||
// `"$CONFIG_OUT"` and between the quote and `head -1` is a tolerant
|
||||
// `[^)]*`, so a cosmetically-reworded reintroduction of the SAME
|
||||
// dangerous shape (extra spaces, an appended `2>/dev/null`, a different
|
||||
// command before the pipe) does not silently escape this check — only
|
||||
// the presence of a real `$( ... "$MSG" ... | head -1 ... )` /
|
||||
// `$( ... "$CONFIG_OUT" ... | head -1 ... )` substitution matters.
|
||||
assert.ok(!/\$\([^)]{0,200}"\$MSG"[^)]{0,200}\|\s*head\s+-1[^)]{0,200}\)/.test(hookSrc),
|
||||
'no $(...) command substitution may pipe "$MSG" into head -1 (SIGPIPE race under set -o pipefail)');
|
||||
assert.ok(!/\$\([^)]{0,200}"\$CONFIG_OUT"[^)]{0,200}\|\s*head\s+-1[^)]{0,200}\)/.test(hookSrc),
|
||||
'no $(...) command substitution may pipe "$CONFIG_OUT" into head -1 (SIGPIPE race under set -o pipefail)');
|
||||
assert.ok(hookSrc.includes('SUBJECT="${MSG%%$\'\\n\'*}"'),
|
||||
'subject extraction must use the pure parameter-expansion form');
|
||||
assert.ok(hookSrc.includes('ENABLED="${CONFIG_OUT%%$\'\\n\'*}"'),
|
||||
'ENABLED extraction must use the pure parameter-expansion form');
|
||||
});
|
||||
|
||||
test('validate-commit does not trust a relative path ending in cat (round-4 Codex MAJOR)', () => {
|
||||
// Recognition accepted any path ending in `/cat`, so a planted `./cat` or
|
||||
// `../evil/cat` was trusted to echo its stdin. With such an executable
|
||||
|
||||
@@ -164,6 +164,26 @@ describe('#2971 — pr-branch.md planning.pr_strict filter (failing-first)', ()
|
||||
assert.strictEqual(classifyCommit([], strictOpts), 'exclude');
|
||||
});
|
||||
|
||||
// #4447: a `.planning/`-only commit that mixes a structural path with a
|
||||
// non-structural planning path (transient-dir or the "other" bucket) is
|
||||
// the exact shape the workflow's prose could not classify — the four
|
||||
// arms as written never compute a total planning-file count, so they
|
||||
// cannot tell "only structural" from "structural plus something else".
|
||||
// The JS model here already resolves it correctly (`classifyCommit`'s
|
||||
// structural check has no upper bound against the total), so these pin
|
||||
// that behavior; the actual defect is fixed in the workflow prose itself
|
||||
// (see test 51).
|
||||
test('49: #4447 [.planning/STATE.md, .planning/phases/PLAN.md] (structural + transient, no code) includes default, excludes strict', () => {
|
||||
const files = ['.planning/STATE.md', '.planning/phases/PLAN.md'];
|
||||
assert.strictEqual(classifyCommit(files, defaultOpts), 'include');
|
||||
assert.strictEqual(classifyCommit(files, strictOpts), 'exclude');
|
||||
});
|
||||
|
||||
test('50: #4447 [.planning/STATE.md, .planning/config.json] (structural + third-bucket, no code) includes default', () => {
|
||||
const files = ['.planning/STATE.md', '.planning/config.json'];
|
||||
assert.strictEqual(classifyCommit(files, defaultOpts), 'include');
|
||||
});
|
||||
|
||||
test('8: [.planning/STATE.md, src/a.ts] — forbiddenPaths [] default, [.planning/STATE.md] strict', () => {
|
||||
const files = ['.planning/STATE.md', 'src/a.ts'];
|
||||
assert.deepStrictEqual(forbiddenPaths(files, defaultOpts), []);
|
||||
@@ -842,5 +862,28 @@ describe('#2971 — pr-branch.md planning.pr_strict filter (failing-first)', ()
|
||||
'the unconditional "No .planning/ files in PR branch diff" success line must be removed/replaced',
|
||||
);
|
||||
});
|
||||
|
||||
// #4447: this pins the actual documented defect — ambiguous prose read by
|
||||
// an LLM executing the workflow, not the already-correct JS model in
|
||||
// pr-branch-filter.cjs (see tests 49/50). Before the fix, `analyze_commits`
|
||||
// computed FILES/NON_PLANING/STRUCTURAL but never a total planning-file
|
||||
// count, so its four classification arms had no way to distinguish
|
||||
// "only structural" `.planning/` commits from "structural plus a
|
||||
// transient/other `.planning/` path" — that second shape matched none of
|
||||
// the four arms and was silently dropped, breaking STATE.md's per-commit
|
||||
// revision chain in default mode. This must FAIL against the original
|
||||
// (unedited) step text, which had no `PLANNING_COUNT=` assignment at all.
|
||||
test('51: #4447 analyze_commits computes an explicit total .planning/ file count (PLANNING_COUNT), closing the gap that let a structural+transient/other planning commit match none of the four classification arms', () => {
|
||||
const text = fs.readFileSync(WORKFLOW_PATH, 'utf-8');
|
||||
const stepMatch = text.match(/<step name="analyze_commits">([\s\S]{0,20000}?)<\/step>/);
|
||||
assert.ok(stepMatch, 'analyze_commits step not found in pr-branch.md');
|
||||
assert.match(
|
||||
stepMatch[1],
|
||||
/^PLANNING_COUNT=\$\(/m,
|
||||
'analyze_commits must compute an explicit total planning-file count via a real shell '
|
||||
+ 'assignment (PLANNING_COUNT=$(...)) so the classification arms can distinguish '
|
||||
+ '"only structural" planning commits from "structural plus transient/other" ones',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user