Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.6.1] - 2026-06-17

### Fixed — Scan

- **`cora scan` no longer aborts on non-JSON LLM responses (#316)**
- Detect non-JSON responses early (provider error pages, rate-limit bodies, empty responses, prose wrappers) and surface the raw response prefix (first 512 bytes) in the error message so users can diagnose the cause.
- Per-batch parse failures are now **non-fatal by default**: the failing batch is skipped with a `warn`-level log and a stderr warning listing the affected files, and the scan continues with the remaining batches. Set `--no-continue-on-batch-error` to restore the old abort behavior.
- Added `--batch-files <N>` flag (default: 20) to control the maximum number of files per LLM batch — lower it to work around provider token limits or rate-limit errors on large scans.
- Truncated-JSON and general parse errors now include the raw response prefix for easier debugging without `--verbose`.

## [0.6.0] - 2026-06-14

### Added — Code Intelligence
Expand Down Expand Up @@ -502,7 +512,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Cross-platform** — Linux (x86_64, ARM64), macOS (Apple Silicon), Windows (x86_64)
- **MIT License** — fully open source

[Unreleased]: https://github.com/codecoradev/cora-cli/compare/v0.5.0...develop
[Unreleased]: https://github.com/codecoradev/cora-cli/compare/v0.6.1...develop
[0.6.1]: https://github.com/codecoradev/cora-cli/compare/v0.6.0...v0.6.1
[0.6.0]: https://github.com/codecoradev/cora-cli/compare/v0.5.0...v0.6.0
[0.5.0]: https://github.com/codecoradev/cora-cli/compare/v0.4.6...v0.5.0
[0.4.6]: https://github.com/codecoradev/cora-cli/compare/v0.4.5...v0.4.6
[0.4.5]: https://github.com/codecoradev/cora-cli/compare/v0.4.4...v0.4.5
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "cora-cli"
version = "0.6.0"
version = "0.6.1"
edition = "2024"
description = "CLI-first AI code review — BYOK, diff/scan/branch, pre-commit hooks"
license = "MIT"
Expand Down
77 changes: 70 additions & 7 deletions src/commands/scan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pub struct ScanOptions {
pub incremental: bool,
/// Focus areas for review (overrides config).
pub focus: Vec<String>,
/// Maximum files per LLM batch (0 = use default 20).
pub batch_files: usize,
/// Whether to continue scanning when a batch fails to parse.
/// When true (default), a failed batch is skipped with a warning and the
/// rest of the scan continues. When false, a failed batch aborts the run.
pub continue_on_batch_error: bool,
}

/// Execute the scan command.
Expand Down Expand Up @@ -99,12 +105,22 @@ pub async fn execute_scan(
let total_lines: usize = files.iter().map(|f| f.lines).sum();

// 3. Batch files
let batches = batch_files(&files, 60_000, 20);
debug!(batches = batches.len(), "batched files");
let max_files_per_batch = if opts.batch_files > 0 {
opts.batch_files
} else {
20
};
let batches = batch_files(&files, 60_000, max_files_per_batch);
debug!(
batches = batches.len(),
max_files = max_files_per_batch,
"batched files"
);

// 4. Process batches and collect issues
let mut all_issues = Vec::new();
let mut total_tokens = None;
let mut skipped_batches: Vec<(usize, Vec<String>, String)> = Vec::new();

for (batch_idx, batch) in batches.iter().enumerate() {
let files_content = format_batch_for_prompt(batch);
Expand All @@ -116,22 +132,69 @@ pub async fn execute_scan(

println!(" Reviewing{batch_label}…");

let (issues, _summary, tokens) = crate::engine::llm::scan_files(
match crate::engine::llm::scan_files(
llm_config,
&files_content,
&effective_focus,
&config.rules,
&config.response_format,
None,
)
.await?;
.await
{
Ok((issues, _summary, tokens)) => {
all_issues.extend(issues);
if tokens.is_some() {
total_tokens = tokens;
}
}
Err(err) => {
let file_list: Vec<String> =
batch.iter().map(|f| f.path.clone()).collect::<Vec<_>>();
let err_string = err.to_string();

// Always log the failure at warn level so it shows even without --verbose.
tracing::warn!(
batch = batch_idx + 1,
total_batches = batches.len(),
files = ?file_list,
error = %err_string,
"batch scan failed"
);

all_issues.extend(issues);
if tokens.is_some() {
total_tokens = tokens;
if !opts.continue_on_batch_error {
eprintln!(
" {} batch {}/{}: {}",
"failed".red().bold(),
batch_idx + 1,
batches.len(),
err_string
);
return Err(err.into());
}

eprintln!(
" {} batch {}/{} — skipping ({} files): {}",
"warn".yellow().bold(),
batch_idx + 1,
batches.len(),
file_list.len(),
err_string
);
skipped_batches.push((batch_idx + 1, file_list, err_string));
}
}
}

if !skipped_batches.is_empty() {
eprintln!(
" {} {} of {} batches skipped due to parse failures.",
skipped_batches.len().to_string().yellow(),
skipped_batches.len(),
batches.len()
);
}

// 5. Build response and format
let issue_count = all_issues.len();
let min_severity = config.hook.min_severity_level();
Expand Down
143 changes: 141 additions & 2 deletions src/engine/llm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,14 @@ pub(crate) fn parse_review_response(
pub(crate) fn parse_scan_response(
raw: &str,
) -> std::result::Result<(Vec<ReviewIssue>, Option<String>, Option<TokenUsage>), CoraError> {
// Fast-fail when the response is clearly not JSON (e.g. provider error page,
// empty body, rate-limit message, or prose wrapper). Surfacing the raw
// prefix lets users diagnose whether it's truncation, a provider error,
// or HTML.
if !looks_like_json_array(raw) {
return Err(CoraError::LlmParse(non_json_error_message(raw)));
}

let (json_str, summary) = extract_json_and_summary(raw);
let json_str = strip_code_fences(&json_str);

Expand All @@ -741,12 +749,16 @@ pub(crate) fn parse_scan_response(
}
Err(repair_err) => {
return Err(CoraError::LlmParse(format!(
"parse failed (original: {err_msg}, after repair: {repair_err})"
"parse failed (original: {err_msg}, after repair: {repair_err}). Raw response prefix: {}",
preview_raw(raw)
)));
}
}
} else {
return Err(CoraError::LlmParse(e.to_string()));
return Err(CoraError::LlmParse(format!(
"{err_msg}. Raw response prefix: {}",
preview_raw(raw)
)));
}
}
};
Expand All @@ -760,6 +772,53 @@ pub(crate) fn parse_scan_response(
Ok((issues, summary, None))
}

/// Check whether a raw LLM response plausibly contains a JSON payload.
///
/// Accepts responses that (after trimming leading whitespace and optional
/// markdown fences) begin with `[` or `{`. Rejects obvious non-JSON bodies
/// such as HTML error pages, empty strings, or pure prose.
pub(crate) fn looks_like_json_array(raw: &str) -> bool {
let trimmed = raw.trim_start();
if trimmed.is_empty() {
return false;
}
// Strip a leading ```json or ``` fence if present
let stripped = trimmed
.strip_prefix("```json")
.or_else(|| trimmed.strip_prefix("```"))
.map(str::trim_start)
.unwrap_or(trimmed);
matches!(stripped.chars().next(), Some('[') | Some('{'))
}

/// Build a human-readable diagnostic for a non-JSON LLM response, including a
/// truncated preview of the raw body (first 512 bytes) so users can tell
/// whether the provider returned an error page, rate-limit message, or prose.
pub(crate) fn non_json_error_message(raw: &str) -> String {
let len = raw.len();
format!(
"LLM response is not valid JSON (length={len}). This usually means the provider returned an error body, rate-limit page, or truncated output. Raw response prefix: {}",
preview_raw(raw)
)
}

/// Return a single-line, length-capped preview of a raw LLM response for logs
/// and error messages. Collapses whitespace and caps at 512 bytes.
pub(crate) fn preview_raw(raw: &str) -> String {
const MAX_BYTES: usize = 512;
let collapsed: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.len() <= MAX_BYTES {
collapsed
} else {
// Split at a char boundary <= MAX_BYTES to avoid slicing mid-codepoint.
let mut end = MAX_BYTES;
while end > 0 && !collapsed.is_char_boundary(end) {
end -= 1;
}
format!("{}… [truncated]", &collapsed[..end])
}
}

/// Extract JSON and optional summary (after ||| separator).
fn extract_json_and_summary(raw: &str) -> (String, String) {
if let Some(idx) = raw.find("|||") {
Expand Down Expand Up @@ -1423,4 +1482,84 @@ mod tests {
assert_eq!(result.0.len(), 1);
assert_eq!(result.0[0].file, "config.rs");
}

// ─── looks_like_json_array / non-JSON guard (#316) ───

#[test]
fn looks_like_json_array_accepts_plain_array() {
assert!(looks_like_json_array(EMPTY_ARRAY));
assert!(looks_like_json_array(SINGLE_ISSUE_JSON));
}

#[test]
fn looks_like_json_array_accepts_fenced_json() {
let fenced = format!("```json\n{SINGLE_ISSUE_JSON}\n```");
assert!(looks_like_json_array(&fenced));
let plain_fence = format!("```\n{EMPTY_ARRAY}\n```");
assert!(looks_like_json_array(&plain_fence));
}

#[test]
fn looks_like_json_array_accepts_leading_whitespace() {
let padded = format!("\n \t {SINGLE_ISSUE_JSON}");
assert!(looks_like_json_array(&padded));
}

#[test]
fn looks_like_json_array_rejects_empty() {
assert!(!looks_like_json_array(""));
assert!(!looks_like_json_array(" \n\t\n"));
}

#[test]
fn looks_like_json_array_rejects_html_error_page() {
let html = "<html><body><h1>503 Service Unavailable</h1></body></html>";
assert!(!looks_like_json_array(html));
}

#[test]
fn looks_like_json_array_rejects_prose() {
let prose = "Sure, here are the issues I found in your code: first, ...";
assert!(!looks_like_json_array(prose));
}

#[test]
fn parse_scan_response_rejects_non_json_with_preview() {
let html = "<html><body>Rate limited</body></html>";
let err = parse_scan_response(html).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not valid JSON"), "msg = {msg}");
assert!(msg.contains("Rate limited"), "msg = {msg}");
assert!(msg.contains("length="), "msg = {msg}");
}

#[test]
fn parse_scan_response_rejects_empty_body() {
let err = parse_scan_response("").unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not valid JSON"), "msg = {msg}");
assert!(msg.contains("length=0"), "msg = {msg}");
}

#[test]
fn preview_raw_is_truncated_to_max_bytes() {
// 2000-char prose should be collapsed and capped at 512 bytes.
let long = "word ".repeat(500);
let preview = preview_raw(&long);
assert!(preview.ends_with("… [truncated]"));
// Hard cap (512 + suffix length).
assert!(preview.len() < 600);
}

#[test]
fn preview_raw_preserves_short_input() {
let short = "hello world";
assert_eq!(preview_raw(short), short);
}

#[test]
fn preview_raw_collapses_whitespace() {
let messy = "hello\n\t world\n\n";
assert_eq!(preview_raw(messy), "hello world");
}
}
18 changes: 18 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,16 @@ enum Command {
/// Focus areas for review (overrides config)
#[clap(long)]
focus: Vec<String>,

/// Maximum files per LLM batch (default: 20). Lower this to work around
/// provider token limits or rate-limit errors on large scans.
#[clap(long, value_name = "N", default_value_t = 20)]
batch_files: usize,

/// Abort the entire scan when a batch fails to parse instead of
/// skipping it and continuing (default: skip and continue).
#[clap(long)]
no_continue_on_batch_error: bool,
},

/// Manage quality profiles (preset rule sets)
Expand Down Expand Up @@ -991,6 +1001,8 @@ async fn main() -> Result<()> {
extensions,
incremental,
focus,
batch_files,
no_continue_on_batch_error,
} => {
cmd_scan(
&cli.global,
Expand All @@ -1001,6 +1013,8 @@ async fn main() -> Result<()> {
extensions,
incremental,
focus,
batch_files,
continue_on_batch_error: !no_continue_on_batch_error,
},
)
.await?
Expand Down Expand Up @@ -1147,6 +1161,8 @@ struct ScanOpts {
extensions: Vec<String>,
incremental: bool,
focus: Vec<String>,
batch_files: usize,
continue_on_batch_error: bool,
}

/// Handle the `review` subcommand.
Expand Down Expand Up @@ -1375,6 +1391,8 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result<i32> {
extensions: opts.extensions,
incremental: opts.incremental,
focus: opts.focus,
batch_files: opts.batch_files,
continue_on_batch_error: opts.continue_on_batch_error,
};

scan::execute_scan(&config, &llm_config, &scan_opts, format).await
Expand Down
Loading