Skip to content

Commit ad0b3e7

Browse files
os-litantclaude
andauthored
fix(cli): route os diff's missing-paths usage error to stderr, in both faces (#15875)
* fix(cli): route `os diff`'s missing-paths usage error to stderr The four writes sat ABOVE the command's first `if (!flags.json)`, so the face was still undecided when they ran and they fired in BOTH. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and streams captured separately, `os diff --json` and bare `os diff` both answered exit 1 with 141 bytes of prose on stdout and an empty stderr, so `JSON.parse(stdout)` threw on the stream `--json` reserves for the machine. Moving the bytes is the whole change: exit code and wording are untouched and no payload is invented, the envelope question being open in #15549. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(cli): pin os diff's usage-error streams in both faces Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f37912 commit ad0b3e7

3 files changed

Lines changed: 304 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
`os diff` with no path arguments no longer prints its usage error on stdout — in either face.
6+
7+
The refusal sat **above** the command's first `if (!flags.json)`, so the face was still undecided when it ran and it fired in **both**. `printError` plus three `console.log` calls — all four writing to stdout — then `process.exit(1)`. Measured on the published entry `bin/run.js` with `NO_COLOR=1` and the streams captured separately, `os diff --json` and bare `os diff` answered byte-identically: exit 1, **141 bytes of prose on stdout, an empty stderr**, and `JSON.parse(stdout)` throwing on the one stream `--json` reserves for the machine.
8+
9+
The diagnostic now goes to stderr, where the rest of this CLI's diagnostics already go. The 141 bytes moved intact — stdout 141 → 0, stderr 0 → 141. Nothing else moves:
10+
11+
- **the exit code is still 1**, so a consumer branching on exit status sees no change at all;
12+
- **the wording is unchanged**, both usage hints included, so a human reading a terminal sees the same four lines;
13+
- **nothing is accepted or rejected differently** — no invocation that worked before fails now.
14+
15+
⚠️ **No error payload is invented on this path.** What a `--json` consumer should *receive* on a refusal is an open envelope question, entangled with `os lint --eval --json`'s bare `{ error }` (no `code`, no `httpStatus`), and it is deliberately left open here — this change settles only that the machine's channel no longer carries prose. `--json` on this path emits nothing on stdout; a consumer must still read the exit status, exactly as it must today.
16+
17+
This is the sibling of the `resolveConfigPath` repair, and a genuinely different site: that one is reached through `loadConfig()`, this one is `diff.ts`'s own usage error, raised before any config work happens. The existing pin drives `os diff` with two paths precisely so the run gets *past* this check, so it could not see this path. A new pin (`diff-usage-error-stream.e2e.test.ts`) drives the bare form in both faces, and carries a structural tripwire: across 62 command modules, 27 of which offer `--json`, `diff` was the only one with a stdout write above its guard, and the tripwire goes red if another arrives.

packages/cli/src/commands/diff.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
printSuccess,
99
printWarning,
1010
printError,
11+
printErrorToStderr,
1112
printInfo,
1213
printStep,
1314
createTimer,
@@ -177,11 +178,32 @@ export default class Diff extends Command {
177178
const beforePath: string | undefined = args.before || flags.before;
178179
const afterPath: string | undefined = args.after || flags.after;
179180

181+
// This refusal goes to STDERR, and it is the one write in this file that
182+
// has to (#15697).
183+
//
184+
// It sits ABOVE the first `if (!flags.json)` below, so it is reached with
185+
// the face still undecided and fires in BOTH — the text face and the
186+
// machine face alike. Measured on the published entry `bin/run.js` with
187+
// `NO_COLOR=1` and the streams captured separately, it used to answer
188+
// `os diff --json` with **exit 1, 141 bytes of prose on stdout and an empty
189+
// stderr**: `JSON.parse(stdout)` threw, on the one stream `--json` reserves
190+
// for the machine (`utils/json-stdout.ts`). Both faces measured identically,
191+
// because there is no branch here to tell them apart.
192+
//
193+
// ⚠️ Every other diagnostic in this file stays on stdout deliberately: they
194+
// sit INSIDE a `!flags.json` branch, i.e. the command has already decided it
195+
// is rendering its text face, which is exactly the case `printError` is for
196+
// (see the note on {@link printErrorToStderr}).
197+
//
198+
// ⛔ Moving the bytes is the whole change. The exit code stays 1, the
199+
// wording stays identical, and no payload is invented: what `--json` should
200+
// emit on a refusal is an open envelope question (#15549) touching this
201+
// command family at once, and settling it is above this fix's authority.
180202
if (!beforePath || !afterPath) {
181-
printError('Two config file paths are required.');
182-
console.log('');
183-
console.log(chalk.dim(' Usage: objectstack diff <before> <after>'));
184-
console.log(chalk.dim(' or: objectstack diff --before path1 --after path2'));
203+
printErrorToStderr('Two config file paths are required.');
204+
console.error('');
205+
console.error(chalk.dim(' Usage: objectstack diff <before> <after>'));
206+
console.error(chalk.dim(' or: objectstack diff --before path1 --after path2'));
185207
process.exit(1);
186208
}
187209

Lines changed: 261 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,261 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os diff` with NO path arguments ⇒ nothing unparseable on stdout, in EITHER
5+
* face (#15697).
6+
*
7+
* ## Why this is a second file rather than a case in the sibling pin
8+
*
9+
* `config-miss-stdout-purity.e2e.test.ts` (#15547) drives the same command, but
10+
* a different branch of it: its `FAMILY` entry is
11+
* `diff: { explicit: [MISSING, MISSING], auto: null }` — two paths, supplied so
12+
* the run gets *past* the usage check and down into `resolveConfigPath()`. It
13+
* says so in place: "`os diff` requires two config paths, so there is no bare
14+
* form that reaches the helper without one."
15+
*
16+
* That is precisely the branch this file drives — the bare form. The refusal
17+
* here is `diff.ts`'s own usage error, reached BEFORE any config work happens,
18+
* so the sibling pin structurally cannot see it and stayed green through it.
19+
*
20+
* ## What was measured
21+
*
22+
* The four writes sat ABOVE the command's first `if (!flags.json)`, so the face
23+
* was still undecided when they ran and they fired in BOTH. On the published
24+
* entry `bin/run.js`, `NO_COLOR=1`, streams captured separately:
25+
*
26+
* os diff --json → exit 1 · stdout 141 bytes of prose · stderr 0 bytes
27+
* os diff → exit 1 · stdout 141 bytes of prose · stderr 0 bytes
28+
*
29+
* Byte-identical, because there was no branch to tell the faces apart. And
30+
* `JSON.parse(stdout)` threw on the one stream `--json` reserves for the
31+
* machine.
32+
*
33+
* ## What is asserted — and what is deliberately NOT
34+
*
35+
* ⛔ No error-payload shape is pinned here. Whether `--json` should emit an
36+
* envelope on a refusal is an open question entangled with #15549
37+
* (`os lint --eval --json`'s bare `{ error }`, no `code`, no `httpStatus`), and
38+
* settling it is above this pin's authority.
39+
*
40+
* So the assertion is the half that needs no ruling, and it is a PROPERTY, not
41+
* a string: **stdout carries nothing a machine cannot read.** Empty passes, one
42+
* JSON document passes, prose fails. A pin on "141 bytes" would rot on the next
43+
* wording change; this one survives it, and survives whoever settles the
44+
* envelope question without their having to touch this file.
45+
*
46+
* The other assertions are the ones a "just silence it" regression would break:
47+
* the diagnostic must still reach the operator on **stderr**, and the exit
48+
* status must still be 1.
49+
*
50+
* ## Anti-vacuity
51+
*
52+
* Every assertion below is green if the command dies early for an unrelated
53+
* reason — an empty stdout is "machine-readable" and a missing string is "not
54+
* on stdout". So the suite refuses to report until a CONTROL has shown that
55+
* this argv actually reaches this command: `os diff --help` must exit 0 and
56+
* render the command's own description. If the binary were broken, the control
57+
* goes red and the rest is known to be worthless rather than quietly green.
58+
*/
59+
60+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
61+
import { execFile } from 'node:child_process';
62+
import { mkdtempSync, rmSync, existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
63+
import { tmpdir } from 'node:os';
64+
import { join, resolve, relative, sep } from 'node:path';
65+
import { fileURLToPath } from 'node:url';
66+
import { childEnv } from './helpers/serve-process.js';
67+
68+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
69+
const CLI = resolve(HERE, '../bin/run-dev.js');
70+
const TSX = resolve(HERE, '../../../node_modules/.bin/tsx');
71+
const COMMANDS_DIR = resolve(HERE, '../src/commands');
72+
73+
/** The refusal and its two hint lines — what must be on stderr, never stdout. */
74+
const REFUSAL = 'Two config file paths are required.';
75+
const HINTS = ['Usage: objectstack diff', 'or: objectstack diff --before'] as const;
76+
77+
interface Run {
78+
code: number;
79+
stdout: string;
80+
stderr: string;
81+
}
82+
83+
function runCli(argv: string[], cwd: string): Promise<Run> {
84+
return new Promise((resolvePromise) => {
85+
execFile(
86+
TSX,
87+
[CLI, ...argv],
88+
{ cwd, maxBuffer: 32 * 1024 * 1024, env: childEnv({ NO_COLOR: '1' }) },
89+
(err, stdout, stderr) => {
90+
resolvePromise({
91+
code: err
92+
? (typeof (err as { code?: unknown }).code === 'number'
93+
? (err as unknown as { code: number }).code
94+
: 1)
95+
: 0,
96+
stdout: String(stdout),
97+
stderr: String(stderr),
98+
});
99+
},
100+
);
101+
});
102+
}
103+
104+
/**
105+
* Whether stdout is something a program can read: nothing at all, or exactly
106+
* one JSON document. Prose is the failure — see the header for why the choice
107+
* between the two passing shapes is deliberately left open.
108+
*/
109+
function stdoutIsMachineReadable(stdout: string): boolean {
110+
if (stdout.trim() === '') return true;
111+
try {
112+
JSON.parse(stdout);
113+
return true;
114+
} catch {
115+
return false;
116+
}
117+
}
118+
119+
/**
120+
* The two faces, both driven with NO path arguments — the argv that reaches the
121+
* usage error. `--json` is the machine face; bare is the text face. The defect
122+
* fired in both, so both are pinned.
123+
*/
124+
const FACES: Record<string, string[]> = {
125+
'machine face (--json)': ['diff', '--json'],
126+
'text face (bare)': ['diff'],
127+
};
128+
129+
let dir: string;
130+
let control: Run;
131+
let runs: Record<string, Run>;
132+
133+
beforeAll(async () => {
134+
// Deliberately EMPTY. The usage error is raised before any config work, so
135+
// the cwd cannot influence it — an empty dir keeps that true rather than
136+
// assumed, by making sure no stray `objectstack.config.*` is in reach.
137+
dir = mkdtempSync(join(tmpdir(), 'os-diff-usage-e2e-'));
138+
139+
// Sequential: concurrent `tsx` starts are the kind of load that makes a
140+
// shared box report timeouts instead of verdicts.
141+
control = await runCli(['diff', '--help'], dir);
142+
runs = {};
143+
for (const [name, argv] of Object.entries(FACES)) {
144+
runs[name] = await runCli(argv, dir);
145+
}
146+
}, 900_000);
147+
148+
afterAll(() => {
149+
try { rmSync(dir, { recursive: true, force: true }); } catch { /* ignore */ }
150+
});
151+
152+
describe('control — the reading below is worthless without this', () => {
153+
it('resolves the dev entry it drives', () => {
154+
// A run against a path that does not exist fails exactly like a true
155+
// negative: empty streams, non-zero exit. Rule it out before reading
156+
// anything off the runs.
157+
expect(existsSync(CLI)).toBe(true);
158+
expect(existsSync(TSX)).toBe(true);
159+
});
160+
161+
it('actually reaches `os diff` with this argv', () => {
162+
// If this is red, every assertion below is green for the wrong reason.
163+
expect(control.code).toBe(0);
164+
expect(control.stdout).toContain('Compare two ObjectStack configurations');
165+
});
166+
});
167+
168+
describe.each(Object.keys(FACES))('os diff, no path arguments — %s', (face) => {
169+
const runOf = (): Run => runs[face];
170+
171+
it('leaves nothing on stdout that a machine cannot read', () => {
172+
// Under the defect this was 141 bytes of ` ✗ Two config file paths are
173+
// required.` plus a blank line and two usage hints — on the one stream
174+
// `--json` reserves for the machine, with stderr completely empty.
175+
expect(stdoutIsMachineReadable(runOf().stdout)).toBe(true);
176+
});
177+
178+
it('keeps the human refusal off stdout entirely', () => {
179+
// Asserted separately from the parse so a regression names its cause
180+
// rather than only `Unexpected token`.
181+
const { stdout } = runOf();
182+
expect(stdout).not.toContain(REFUSAL);
183+
for (const hint of HINTS) expect(stdout).not.toContain(hint);
184+
});
185+
186+
it('still shows the operator the refusal and both hints — on stderr', () => {
187+
// Diagnostics are MOVED, never destroyed: a regression toward silencing
188+
// this path goes red here. This is also the pair that makes the assertion
189+
// above non-vacuous — an early death would leave stderr without these.
190+
const { stderr } = runOf();
191+
expect(stderr).toContain(REFUSAL);
192+
for (const hint of HINTS) expect(stderr).toContain(hint);
193+
});
194+
195+
it('still exits 1', () => {
196+
expect(runOf().code).toBe(1);
197+
});
198+
});
199+
200+
/**
201+
* The structural tripwire: `diff` was the ONLY command shaped this way, and a
202+
* new one must not arrive unnoticed.
203+
*
204+
* ⚠️ Its bound, stated rather than implied: this is a source-text scan, so it
205+
* sees only the print helpers it names, called syntactically inside `run()`
206+
* above the first `flags.json` read. It does NOT follow calls into helpers —
207+
* that is how #15547 was reached, through `loadConfig()` — and a command that
208+
* reads `flags.json` into a local on its first line hides everything below from
209+
* this check. It is a tripwire for the shape that was measured, not a proof
210+
* that no other shape exists.
211+
*/
212+
describe('no new command has grown a stdout write above its --json guard', () => {
213+
function commandFiles(d: string): string[] {
214+
const out: string[] = [];
215+
for (const entry of readdirSync(d)) {
216+
const abs = join(d, entry);
217+
if (statSync(abs).isDirectory()) {
218+
out.push(...commandFiles(abs));
219+
continue;
220+
}
221+
if (!entry.endsWith('.ts') || entry.endsWith('.test.ts')) continue;
222+
out.push(abs);
223+
}
224+
return out;
225+
}
226+
227+
const WRITES_TO_STDOUT =
228+
/\b(?:console\.log|printHeader|printStep|printInfo|printSuccess|printWarning|printError)\(/;
229+
230+
function offenders(): string[] {
231+
const found: string[] = [];
232+
for (const abs of commandFiles(COMMANDS_DIR)) {
233+
const lines = readFileSync(abs, 'utf-8').split('\n');
234+
if (!lines.some((l) => /\bjson:\s*Flags\.boolean\(/.test(l))) continue;
235+
const runIdx = lines.findIndex((l) => /async run\s*\(/.test(l));
236+
if (runIdx < 0) continue;
237+
const guardIdx = lines.findIndex(
238+
(l, i) => i >= runIdx && /flags\.json|flags\[['"]json['"]\]/.test(l),
239+
);
240+
if (guardIdx < 0) continue;
241+
if (lines.slice(runIdx, guardIdx).some((l) => WRITES_TO_STDOUT.test(l))) {
242+
found.push(relative(COMMANDS_DIR, abs).split(sep).join('/'));
243+
}
244+
}
245+
return found.sort();
246+
}
247+
248+
it('scans a non-empty family — refuse to reason from zero', () => {
249+
// The scan is only evidence if it looked at something. A refactor that
250+
// moved or renamed the commands directory would otherwise report "no
251+
// offenders" from an empty sweep.
252+
const withJson = commandFiles(COMMANDS_DIR).filter((abs) =>
253+
/\bjson:\s*Flags\.boolean\(/.test(readFileSync(abs, 'utf-8')),
254+
);
255+
expect(withJson.length).toBeGreaterThan(10);
256+
});
257+
258+
it('finds none', () => {
259+
expect(offenders()).toEqual([]);
260+
});
261+
});

0 commit comments

Comments
 (0)