From 9eab536972d53f94555b55c8c840993f501aa19d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 2 Sep 2026 23:43:36 +0000 Subject: [PATCH] fix(pm): refuse --tier against --commands/--json instead of silently preferring the tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--tier` is a third shape of stdout and was the one the "two spellings of stdout" rule did not name. `mode` was computed from --commands/--json and then discarded by the `--tier` branch, so the tier prose landed in a stream whose caption promises one runnable command per line, or one JSON document — at exit 0, with nothing on either stream saying the flag had been dropped. Both pairs now refuse with the shape the existing refusals use: exit 2, one `dispatch-gates: …` line on stderr naming both flags. Ordered after the `--commands --json` pair so all three together keep the message they had. `--tier` on its own is byte-identical to before. Fixes #14294 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01LraLgQVGq8egUwfYZpbYt1 --- scripts/pm/dispatch-gates.mjs | 50 +++++++++++++++++++++++++++++++++-- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/scripts/pm/dispatch-gates.mjs b/scripts/pm/dispatch-gates.mjs index d9c4fcf308..353abf8594 100644 --- a/scripts/pm/dispatch-gates.mjs +++ b/scripts/pm/dispatch-gates.mjs @@ -16752,6 +16752,25 @@ function selfTest() { // Two answers to "what shape is stdout" is no answer. const bothRun = runCli(['--commands', '--json', seamCard]); t('passing both stdout spellings refuses instead of silently preferring one', bothRun.status === 2 && (bothRun.stdout ?? '').trim() === ''); + // ⭐ The THIRD stdout shape (#14294). The pair above was enforced and this + // one was not: `mode` was computed and then discarded, so these two exited + // 0 with tier prose sitting in the file a consumer had redirected because + // `--commands` promises commands and nothing else. Driven on the real CLI + // for the same reason the pair above is — the defect was in the argv chain, + // which no pure half reaches. Both halves of the refusal are asserted: the + // STATUS, and the stdout a redirecting consumer would have kept, because a + // refusal that still prints an answer is the bug wearing an exit code. + const tierCmdRun = runCli(['--tier', '--commands', seamCard]); + t('⭐ --tier against --commands refuses instead of silently preferring the tier', tierCmdRun.status === 2 && (tierCmdRun.stdout ?? '').trim() === ''); + t('and its refusal names BOTH flags, so the dropped one is never left to be guessed', (tierCmdRun.stderr ?? '').includes('--tier') && (tierCmdRun.stderr ?? '').includes('--commands')); + const tierJsonRun = runCli(['--tier', '--json', seamCard]); + t('⭐ --tier against --json refuses the same way, on the other machine-readable shape', tierJsonRun.status === 2 && (tierJsonRun.stdout ?? '').trim() === ''); + t('and that refusal names --json, never the flag this run is not about', (tierJsonRun.stderr ?? '').includes('--json') && !(tierJsonRun.stderr ?? '').includes('--commands')); + // CONTROL: green before this fix and after it. The new branch is ordered + // after the pair, so all three flags together still answer with the pair's + // own message — the fix adds a refusal and rewords none. + const allThreeRun = runCli(['--tier', '--commands', '--json', seamCard]); + t('CONTROL: all three together keep the pair rule that was already enforced', allThreeRun.status === 2 && (allThreeRun.stderr ?? '').includes('--commands and --json')); } // ── END TO END: the CI-measured family, on the card it was measured on (#14004) @@ -17132,9 +17151,36 @@ if (invokedDirectly) { // Two answers to "what shape is stdout" is no answer. Blending them — or // silently preferring one — is the class of failure this whole file is // about, and it would be a poor place to commit it: these two flags exist - // because a consumer could not tell what it was reading. + // because a consumer could not tell what it was reading. The rule is about + // how many answers the stream carries, not about which two flags happened + // to be named when it was written: `--tier` is a THIRD shape of that same + // stdout, and it is refused against each of these two in the branch below. console.error('dispatch-gates: --commands and --json are two spellings of stdout — pass one.'); process.exit(2); + } else if (process.argv.includes('--tier') && (process.argv.includes('--commands') || process.argv.includes('--json'))) { + // The third shape, and the one the pair above did not reach: `mode` was + // computed from --commands/--json and then DISCARDED by the `--tier` branch + // at the bottom of this block, so the tier PROSE landed in a stream whose + // caption promises one runnable command per line, or one JSON document — + // at exit 0, with nothing on either stream saying the flag had been + // dropped. That is the silent preference the rule above forbids, committed + // one flag over from where it is enforced. + // + // REFUSED, not re-routed. Sending the tier verdict to stderr whenever a + // machine-readable mode is asked for is defensible — `--tier` answers a + // different question and reads no tree — but it changes what `--tier` + // MEANS when combined, and that is a design call for this file's owner, + // not a repair to a silent-drop bug. Same shape as the two refusals above + // it and the `--ran --tier` refusal above them: one line, exit 2, both + // flags named so the caller never has to guess which one was dropped. + // + // Ordered AFTER the pair deliberately: `--tier --commands --json` keeps + // the message it already had, so this branch adds a refusal and changes + // none. + console.error( + `dispatch-gates: --tier and ${process.argv.includes('--commands') ? '--commands' : '--json'} are two spellings of stdout — pass one.`, + ); + process.exit(2); } else if (wantsChanged && argvPaths.length > 0) { // The two input modes answer different questions and must never be blended: // silently preferring one would make the other's arguments vanish without a @@ -17202,7 +17248,7 @@ if (invokedDirectly) { derived = changedPathsFromGit(); } catch (err) { console.error(`dispatch-gates: could not derive the change set — ${err.message}`); - console.error('usage: node scripts/pm/dispatch-gates.mjs [--residue] [--tier] [--commands | --json | --ran ] [--repo owner/name] [ ...] | --changed | --self-test'); + console.error('usage: node scripts/pm/dispatch-gates.mjs [--residue] [--tier | --commands | --json | --ran ] [--repo owner/name] [ ...] | --changed | --self-test'); process.exit(2); } if (derived.paths.length === 0) {