Skip to content

Commit 8fe68ae

Browse files
committed
fix(review): address fourth review pass — extra args error, guard tests, docs, fallback test
1 parent d8fa15d commit 8fe68ae

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
@@ -214,8 +214,6 @@ async function main(): Promise<void> {
214214
// Those packages pull ~thousand+ modules; --acp / --plain / auth must stay
215215
// on the lightweight path. Ink + App load only in the final TUI branch.
216216

217-
const {parseReviewCliArgs} = await import('./commands/review-cli');
218-
219217
const vscodeMode = args.includes('--vscode');
220218

221219
// Extract VS Code port if specified
@@ -339,56 +337,34 @@ async function main(): Promise<void> {
339337
const isRunCommand = runCommandIndex !== -1;
340338
const afterRunArgs = isRunCommand ? args.slice(runCommandIndex + 1) : [];
341339
if (isRunCommand && afterRunArgs.length > 0) {
342-
// Filter out known flags when constructing the prompt
343-
const promptArgs: string[] = [];
344-
for (let i = 0; i < afterRunArgs.length; i++) {
345-
const arg = afterRunArgs[i];
346-
if (arg === '--vscode') {
347-
continue; // skip this flag
348-
} else if (arg === '--vscode-port') {
349-
i++; // skip this flag and its value
350-
continue;
351-
} else if (arg === '--provider') {
352-
i++; // skip this flag and its value
353-
continue;
354-
} else if (arg === '--model') {
355-
i++; // skip this flag and its value
356-
continue;
357-
} else if (arg === '--context-max') {
358-
i++; // skip this flag and its value
359-
continue;
360-
} else if (arg === '--mode') {
361-
i++; // skip this flag and its value
362-
continue;
363-
} else if (arg.startsWith('--mode=')) {
364-
continue; // skip fused form
365-
} else if (arg === '--json') {
366-
continue; // skip this flag
367-
} else if (arg === '--output-format') {
368-
i++; // skip this flag and its value
369-
continue;
370-
} else if (arg.startsWith('--output-format=')) {
371-
continue; // skip fused form
372-
} else if (arg === '--trust-directory') {
373-
continue; // skip this flag
374-
} else if (arg === '--plain' || arg === '--no-plain') {
375-
continue; // skip this flag
376-
} else if (arg === '--no-alt-screen' || arg === '--alt-screen') {
377-
continue; // skip this flag
378-
} else {
379-
promptArgs.push(arg);
380-
}
381-
}
382-
nonInteractivePrompt = promptArgs.join(' ');
340+
const {filterCliFlags} = await import('@/utils/cli-flags');
341+
nonInteractivePrompt = filterCliFlags(afterRunArgs).join(' ');
383342
}
384343

385344
let nonInteractiveMode = isRunCommand;
386345

387346
// Check for `nanocoder review <target>` — syntactic sugar for
388347
// `nanocoder run /review <target>`. The target is the branch or PR number
389348
// to review. Flags between `review` and the target are filtered the same
390-
// way as `run`.
391-
const {isReviewCommand, prompt: reviewPrompt} = parseReviewCliArgs(args);
349+
// way as `run`. Lazy-loaded to keep it off the lightweight path.
350+
let isReviewCommand = false;
351+
let reviewPrompt: string | undefined;
352+
if (args[0] === 'review') {
353+
const {parseReviewCliArgs} = await import('./commands/review-cli');
354+
const result = parseReviewCliArgs(args);
355+
isReviewCommand = result.isReviewCommand;
356+
reviewPrompt = result.prompt;
357+
if (result.error) {
358+
console.error(`Error: ${result.error}`);
359+
process.exit(1);
360+
}
361+
}
362+
363+
if (isRunCommand && isReviewCommand) {
364+
console.error('Cannot use both `run` and `review` in the same invocation.');
365+
process.exit(1);
366+
}
367+
392368
if (isReviewCommand) {
393369
nonInteractivePrompt = reviewPrompt;
394370
nonInteractiveMode = true;
@@ -466,6 +442,13 @@ async function main(): Promise<void> {
466442
process.exit(1);
467443
}
468444

445+
if (outputFormat === 'json' && isReviewCommand) {
446+
console.error(
447+
'Error: --json cannot be used with `nanocoder review`. Review output is displayed in the interactive terminal.',
448+
);
449+
process.exit(1);
450+
}
451+
469452
const ciDetected =
470453
process.env.CI === 'true' ||
471454
Boolean(

0 commit comments

Comments
 (0)