Skip to content

Commit 0506b03

Browse files
committed
fix(review): address fourth review pass — extra args error, guard tests, docs, fallback test
1 parent 3deffe9 commit 0506b03

10 files changed

Lines changed: 322 additions & 197 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,8 @@ nanocoder review main
5656
nanocoder review 42
5757
```
5858

59+
> **Note:** `nanocoder review` requires an interactive terminal (TTY). It cannot be used with pipes or redirection.
60+
5961
### Screen Modes
6062

6163
Nanocoder supports two rendering modes, mirroring what Claude Code and Codex ship:

docs/getting-started/index.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,8 @@ nanocoder review 42
194194

195195
This fetches the diff against the default branch and runs an architect-level review identifying bugs, security issues, and style violations. You can also use `/review <target>` inside the interactive TUI.
196196

197+
**Note:** `nanocoder review` requires an interactive terminal (TTY). It cannot be used with pipes or redirection (e.g. `nanocoder review main > review.md` will error). Use the `/review` slash command inside the interactive TUI for scripting workflows.
198+
197199
**Non-interactive mode behavior:**
198200

199201
- Automatically executes the given prompt

source/cli.spec.ts

Lines changed: 120 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import test from 'ava';
2+
import {filterCliFlags} from '@/utils/cli-flags';
23

34
// Test CLI argument parsing for non-interactive mode
45
// These tests verify that the CLI correctly parses the 'run' command
@@ -13,47 +14,8 @@ function parsePrompt(args: string[]): string | undefined {
1314
if (afterRunArgs.length === 0) {
1415
return undefined;
1516
}
16-
// Filter out known flags after 'run' when constructing the prompt
17-
const promptArgs: string[] = [];
18-
for (let i = 0; i < afterRunArgs.length; i++) {
19-
const arg = afterRunArgs[i];
20-
if (arg === '--vscode') {
21-
continue; // skip this flag
22-
} else if (arg === '--vscode-port') {
23-
i++; // skip this flag and its value
24-
continue;
25-
} else if (arg === '--provider') {
26-
i++; // skip this flag and its value
27-
continue;
28-
} else if (arg === '--model') {
29-
i++; // skip this flag and its value
30-
continue;
31-
} else if (arg === '--context-max') {
32-
i++; // skip this flag and its value
33-
continue;
34-
} else if (arg === '--mode') {
35-
i++; // skip this flag and its value
36-
continue;
37-
} else if (arg.startsWith('--mode=')) {
38-
continue; // skip fused form
39-
} else if (arg === '--json') {
40-
continue; // skip this flag
41-
} else if (arg === '--output-format') {
42-
i++; // skip this flag and its value
43-
continue;
44-
} else if (arg.startsWith('--output-format=')) {
45-
continue; // skip fused form
46-
} else if (arg === '--trust-directory') {
47-
continue; // skip this flag
48-
} else if (arg === '--plain' || arg === '--no-plain') {
49-
continue; // skip this flag
50-
} else if (arg === '--no-alt-screen' || arg === '--alt-screen') {
51-
continue; // skip this flag
52-
} else {
53-
promptArgs.push(arg);
54-
}
55-
}
56-
return promptArgs.length > 0 ? promptArgs.join(' ') : undefined;
17+
const positionals = filterCliFlags(afterRunArgs);
18+
return positionals.length > 0 ? positionals.join(' ') : undefined;
5719
}
5820

5921
test('CLI parsing: detects run command with single word prompt', t => {
@@ -593,3 +555,120 @@ test('CLI parsing: handles --mode before run command', t => {
593555
const prompt = parsePrompt(args);
594556
t.is(prompt, 'audit module');
595557
});
558+
559+
// Review guard tests — mirrors the guards in cli.tsx
560+
function resolveReviewGuards(opts: {
561+
args: string[];
562+
stdoutIsTTY: boolean;
563+
outputFormat: string;
564+
}): {ttyError: boolean; jsonError: boolean; collisionError: boolean} {
565+
const {args, stdoutIsTTY, outputFormat} = opts;
566+
const isRunCommand = args.findIndex(arg => arg === 'run') !== -1;
567+
const isReviewCommand = args[0] === 'review';
568+
const ttyError = isReviewCommand && !stdoutIsTTY;
569+
const jsonError = isReviewCommand && outputFormat === 'json';
570+
const collisionError = isRunCommand && isReviewCommand;
571+
return {ttyError, jsonError, collisionError};
572+
}
573+
574+
test('review guard: errors when stdout is not a TTY', t => {
575+
const {ttyError} = resolveReviewGuards({
576+
args: ['review', 'main'],
577+
stdoutIsTTY: false,
578+
outputFormat: 'text',
579+
});
580+
t.true(ttyError);
581+
});
582+
583+
test('review guard: passes on a TTY', t => {
584+
const {ttyError} = resolveReviewGuards({
585+
args: ['review', 'main'],
586+
stdoutIsTTY: true,
587+
outputFormat: 'text',
588+
});
589+
t.false(ttyError);
590+
});
591+
592+
test('review guard: --json is rejected with review', t => {
593+
const {jsonError} = resolveReviewGuards({
594+
args: ['review', 'main'],
595+
stdoutIsTTY: true,
596+
outputFormat: 'json',
597+
});
598+
t.true(jsonError);
599+
});
600+
601+
test('review guard: --json is not rejected with run', t => {
602+
const {jsonError} = resolveReviewGuards({
603+
args: ['run', 'hello'],
604+
stdoutIsTTY: true,
605+
outputFormat: 'json',
606+
});
607+
t.false(jsonError);
608+
});
609+
610+
test('review guard: run and review collision is detected', t => {
611+
const {collisionError} = resolveReviewGuards({
612+
args: ['review', 'run'],
613+
stdoutIsTTY: true,
614+
outputFormat: 'text',
615+
});
616+
t.true(collisionError);
617+
});
618+
619+
test('review guard: review alone has no collision', t => {
620+
const {collisionError} = resolveReviewGuards({
621+
args: ['review', 'main'],
622+
stdoutIsTTY: true,
623+
outputFormat: 'text',
624+
});
625+
t.false(collisionError);
626+
});
627+
628+
test('review guard: run alone has no collision', t => {
629+
const {collisionError} = resolveReviewGuards({
630+
args: ['run', 'hello'],
631+
stdoutIsTTY: true,
632+
outputFormat: 'text',
633+
});
634+
t.false(collisionError);
635+
});
636+
637+
// filterCliFlags: shared flag filter produces single source of truth
638+
test('filterCliFlags: filters all known flags', t => {
639+
const result = filterCliFlags([
640+
'--vscode',
641+
'--json',
642+
'--trust-directory',
643+
'--plain',
644+
'--no-plain',
645+
'--no-alt-screen',
646+
'--alt-screen',
647+
'--vscode-port',
648+
'3000',
649+
'--provider',
650+
'ollama',
651+
'--model',
652+
'llama3',
653+
'--context-max',
654+
'128k',
655+
'--output-format',
656+
'json',
657+
'--output-format=json',
658+
'--mode',
659+
'plan',
660+
'--mode=plan',
661+
'my-prompt',
662+
]);
663+
t.deepEqual(result, ['my-prompt']);
664+
});
665+
666+
test('filterCliFlags: returns all args when no flags present', t => {
667+
const result = filterCliFlags(['hello', 'world']);
668+
t.deepEqual(result, ['hello', 'world']);
669+
});
670+
671+
test('filterCliFlags: returns empty array for empty input', t => {
672+
const result = filterCliFlags([]);
673+
t.deepEqual(result, []);
674+
});

source/cli.tsx

Lines changed: 28 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -199,8 +199,6 @@ async function main(): Promise<void> {
199199
// Those packages pull ~thousand+ modules; --acp / --plain / auth must stay
200200
// on the lightweight path. Ink + App load only in the final TUI branch.
201201

202-
const {parseReviewCliArgs} = await import('./commands/review-cli');
203-
204202
const vscodeMode = args.includes('--vscode');
205203

206204
// Extract VS Code port if specified
@@ -324,56 +322,34 @@ async function main(): Promise<void> {
324322
const isRunCommand = runCommandIndex !== -1;
325323
const afterRunArgs = isRunCommand ? args.slice(runCommandIndex + 1) : [];
326324
if (isRunCommand && afterRunArgs.length > 0) {
327-
// Filter out known flags when constructing the prompt
328-
const promptArgs: string[] = [];
329-
for (let i = 0; i < afterRunArgs.length; i++) {
330-
const arg = afterRunArgs[i];
331-
if (arg === '--vscode') {
332-
continue; // skip this flag
333-
} else if (arg === '--vscode-port') {
334-
i++; // skip this flag and its value
335-
continue;
336-
} else if (arg === '--provider') {
337-
i++; // skip this flag and its value
338-
continue;
339-
} else if (arg === '--model') {
340-
i++; // skip this flag and its value
341-
continue;
342-
} else if (arg === '--context-max') {
343-
i++; // skip this flag and its value
344-
continue;
345-
} else if (arg === '--mode') {
346-
i++; // skip this flag and its value
347-
continue;
348-
} else if (arg.startsWith('--mode=')) {
349-
continue; // skip fused form
350-
} else if (arg === '--json') {
351-
continue; // skip this flag
352-
} else if (arg === '--output-format') {
353-
i++; // skip this flag and its value
354-
continue;
355-
} else if (arg.startsWith('--output-format=')) {
356-
continue; // skip fused form
357-
} else if (arg === '--trust-directory') {
358-
continue; // skip this flag
359-
} else if (arg === '--plain' || arg === '--no-plain') {
360-
continue; // skip this flag
361-
} else if (arg === '--no-alt-screen' || arg === '--alt-screen') {
362-
continue; // skip this flag
363-
} else {
364-
promptArgs.push(arg);
365-
}
366-
}
367-
nonInteractivePrompt = promptArgs.join(' ');
325+
const {filterCliFlags} = await import('@/utils/cli-flags');
326+
nonInteractivePrompt = filterCliFlags(afterRunArgs).join(' ');
368327
}
369328

370329
let nonInteractiveMode = isRunCommand;
371330

372331
// Check for `nanocoder review <target>` — syntactic sugar for
373332
// `nanocoder run /review <target>`. The target is the branch or PR number
374333
// to review. Flags between `review` and the target are filtered the same
375-
// way as `run`.
376-
const {isReviewCommand, prompt: reviewPrompt} = parseReviewCliArgs(args);
334+
// way as `run`. Lazy-loaded to keep it off the lightweight path.
335+
let isReviewCommand = false;
336+
let reviewPrompt: string | undefined;
337+
if (args[0] === 'review') {
338+
const {parseReviewCliArgs} = await import('./commands/review-cli');
339+
const result = parseReviewCliArgs(args);
340+
isReviewCommand = result.isReviewCommand;
341+
reviewPrompt = result.prompt;
342+
if (result.error) {
343+
console.error(`Error: ${result.error}`);
344+
process.exit(1);
345+
}
346+
}
347+
348+
if (isRunCommand && isReviewCommand) {
349+
console.error('Cannot use both `run` and `review` in the same invocation.');
350+
process.exit(1);
351+
}
352+
377353
if (isReviewCommand) {
378354
nonInteractivePrompt = reviewPrompt;
379355
nonInteractiveMode = true;
@@ -451,6 +427,13 @@ async function main(): Promise<void> {
451427
process.exit(1);
452428
}
453429

430+
if (outputFormat === 'json' && isReviewCommand) {
431+
console.error(
432+
'Error: --json cannot be used with `nanocoder review`. Review output is displayed in the interactive terminal.',
433+
);
434+
process.exit(1);
435+
}
436+
454437
const ciDetected =
455438
process.env.CI === 'true' ||
456439
Boolean(

0 commit comments

Comments
 (0)