From 6b9f74b74a94956349b886de43d9ec484a519295 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 14 Jun 2026 21:01:53 +0700 Subject: [PATCH 1/7] fix: remove --branch=main from deploy (sync with main) --- .github/workflows/deploy-website.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 862e081..6ba571a 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -52,4 +52,4 @@ jobs: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} preCommands: npx wrangler pages project create cora-cli --production-branch=main || true - command: pages deploy docs/.vitepress/dist/ --project-name=cora-cli --commit-dirty=true --branch=main + command: pages deploy docs/.vitepress/dist/ --project-name=cora-cli --commit-dirty=true From 130c0834a5b58eb9d68623d60089368018dbec08 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Sun, 14 Jun 2026 22:18:45 +0700 Subject: [PATCH 2/7] =?UTF-8?q?sync:=20main=20=E2=86=92=20develop=20(after?= =?UTF-8?q?=20trim=20fix)=20(#311)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: remove --branch=main * fix: remove VitePress base (routing handled by Worker) * fix: restore VitePress base for proper asset routing * fix: add VitePress base '/docs/cora/' for asset routing * fix: restore docs/.vitepress/config.mts from develop * fix: remove VitePress base — routing via Worker with injection * fix: add CF Pages _redirects * fix: add VitePress base '/docs/cora/' to cora config * fix: remove base prefix from head assets (VitePress prepends base automatically) * fix: trim trailing whitespace in deploy workflow (#310) --- docs/public/_redirects | 1 + 1 file changed, 1 insertion(+) create mode 100644 docs/public/_redirects diff --git a/docs/public/_redirects b/docs/public/_redirects new file mode 100644 index 0000000..788ad2f --- /dev/null +++ b/docs/public/_redirects @@ -0,0 +1 @@ +/docs/cora/* /:splat 200 From 447398b065837bb1b3d2218b36c15fa1e52b067e Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 17 Jun 2026 12:50:40 +0700 Subject: [PATCH 3/7] fix(scan): non-fatal batch errors + non-JSON response diagnostics (#316) (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add early non-JSON response guard in parse_scan_response: detect HTML error pages, rate-limit bodies, empty responses, and prose wrappers before attempting strict parse. Surface raw response prefix (first 512 bytes, whitespace-collapsed) in the error so users can diagnose truncation vs provider error vs prose. - Make per-batch parse failures non-fatal by default: skip the failing batch with a stderr warning and warn-level log (with file list), continue with remaining batches. --no-continue-on-batch-error restores old abort behavior. - Add --batch-files flag (default 20) to cap files per LLM batch — lower it to work around provider token limits on large scans. - Truncated-JSON repair path now also includes raw response prefix. - Add 14 unit tests covering looks_like_json_array, preview_raw, and parse_scan_response non-JSON rejection. - Bump version 0.6.0 → 0.6.1 (patch: bug fix, no API breaking changes). --- CHANGELOG.md | 14 ++++- Cargo.lock | 2 +- Cargo.toml | 2 +- src/commands/scan.rs | 77 ++++++++++++++++++++--- src/engine/llm.rs | 143 ++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 18 ++++++ 6 files changed, 244 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b23e2e..b4f53a0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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 @@ -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 diff --git a/Cargo.lock b/Cargo.lock index f1b00de..9295742 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,7 +275,7 @@ dependencies = [ [[package]] name = "cora-cli" -version = "0.6.0" +version = "0.6.1" dependencies = [ "anyhow", "assert_cmd", diff --git a/Cargo.toml b/Cargo.toml index fb3c6e9..44056cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/src/commands/scan.rs b/src/commands/scan.rs index c153538..95de3e8 100644 --- a/src/commands/scan.rs +++ b/src/commands/scan.rs @@ -22,6 +22,12 @@ pub struct ScanOptions { pub incremental: bool, /// Focus areas for review (overrides config). pub focus: Vec, + /// 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. @@ -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)> = Vec::new(); for (batch_idx, batch) in batches.iter().enumerate() { let files_content = format_batch_for_prompt(batch); @@ -116,7 +132,7 @@ 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, @@ -124,14 +140,61 @@ pub async fn execute_scan( &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 = + batch.iter().map(|f| f.path.clone()).collect::>(); + 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(); diff --git a/src/engine/llm.rs b/src/engine/llm.rs index d2b1fa6..e6ec431 100644 --- a/src/engine/llm.rs +++ b/src/engine/llm.rs @@ -720,6 +720,14 @@ pub(crate) fn parse_review_response( pub(crate) fn parse_scan_response( raw: &str, ) -> std::result::Result<(Vec, Option, Option), 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); @@ -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) + ))); } } }; @@ -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::>().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("|||") { @@ -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 = "

503 Service Unavailable

"; + 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 = "Rate limited"; + 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"); + } } diff --git a/src/main.rs b/src/main.rs index d7963df..3190f8e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -316,6 +316,16 @@ enum Command { /// Focus areas for review (overrides config) #[clap(long)] focus: Vec, + + /// 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) @@ -991,6 +1001,8 @@ async fn main() -> Result<()> { extensions, incremental, focus, + batch_files, + no_continue_on_batch_error, } => { cmd_scan( &cli.global, @@ -1001,6 +1013,8 @@ async fn main() -> Result<()> { extensions, incremental, focus, + batch_files, + continue_on_batch_error: !no_continue_on_batch_error, }, ) .await? @@ -1147,6 +1161,8 @@ struct ScanOpts { extensions: Vec, incremental: bool, focus: Vec, + batch_files: usize, + continue_on_batch_error: bool, } /// Handle the `review` subcommand. @@ -1375,6 +1391,8 @@ async fn cmd_scan(globals: &GlobalOptions, opts: ScanOpts) -> Result { 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 From 22374c4db19d84465e3987eb101e1c06a1e55aa4 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 17 Jun 2026 13:20:16 +0700 Subject: [PATCH 4/7] fix(review): exit code reflects filtered findings (#312) (#318) Previously `cora review` could exit 2 (EXIT_BLOCKED) even when the `--severity` filter removed all blocking findings and the SARIF output contained 0 error-level results. This blocked PR merges in CI when Cora Review was configured as a required status check. Root cause: `should_block` was computed by the engine using `config.hook.min_severity`, but the exit-code logic used the same `should_block` flag without re-checking against the **filtered** issue list (after CLI `--severity` filtering). So a Major finding with `--severity critical` would still cause exit 2 even though the filtered output shown to the user had no findings. Fix: - Recompute `should_block` on `filtered_response` after severity filtering, so exit code matches what the user sees (both in the single-chunk and `--auto-chunk` paths). - Extract exit-code logic into `compute_exit_code()` pure helper with 8 unit tests (gate pass/fail, CI mode, hook block vs non-block). Bump 0.6.0 -> 0.6.1 (patch). --- CHANGELOG.md | 7 ++ src/commands/review.rs | 161 ++++++++++++++++++++++++++++++++++++----- 2 files changed, 151 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f53a0..c2b4c7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `--batch-files ` 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`. +### Fixed — Review + +- **`cora review` no longer exits 2 when severity filtering removes all blocking findings (#312)** + - Recompute `should_block` against the **filtered** issue list (after `--severity` filtering) so the exit code matches the SARIF/pretty output the user sees. + - Extracted exit-code logic into `compute_exit_code()` helper (pure function) with 8 unit tests covering gate pass/fail, CI mode, and hook `block` vs non-`block` modes. + - Applies to both the single-chunk and auto-chunked (`--auto-chunk`) review paths. + ## [0.6.0] - 2026-06-14 ### Added — Code Intelligence diff --git a/src/commands/review.rs b/src/commands/review.rs index 53a9fc9..2bc1dfe 100644 --- a/src/commands/review.rs +++ b/src/commands/review.rs @@ -267,6 +267,13 @@ pub async fn execute_review( filtered_response .issues .retain(|i| i.severity <= min_severity); + // Recompute should_block against the filtered issue list so the exit code + // matches the output the user actually sees (e.g. when `--severity critical` + // filters out all Major/Minor issues, we must not block). See #312. + filtered_response.should_block = filtered_response + .issues + .iter() + .any(|i| i.severity <= min_severity); // 7. Format output let formatter = formatter_for(format); @@ -309,7 +316,7 @@ pub async fn execute_review( } else { EXIT_OK } - } else if response.should_block && config.hook.mode == "block" { + } else if filtered_response.should_block && config.hook.mode == "block" { EXIT_BLOCKED } else { EXIT_OK @@ -592,6 +599,12 @@ async fn execute_chunked_review( filtered_response .issues .retain(|i| i.severity <= min_severity); + // Recompute should_block against the filtered issue list so the exit code + // matches the output the user actually sees (see #312). + filtered_response.should_block = filtered_response + .issues + .iter() + .any(|i| i.severity <= min_severity); // 7. Format output let formatter = formatter_for(format); @@ -632,22 +645,12 @@ async fn execute_chunked_review( } // 9. Return exit code - let exit_code = if gate_result - .as_ref() - .is_some_and(|g| g.status == quality_gate::GateStatus::Fail) - { - EXIT_BLOCKED - } else if opts.ci { - if !filtered_response.issues.is_empty() { - EXIT_BLOCKED - } else { - EXIT_OK - } - } else if merged_response.should_block && config.hook.mode == "block" { - EXIT_BLOCKED - } else { - EXIT_OK - }; + let exit_code = compute_exit_code( + gate_result.as_ref().map(|g| g.status), + opts.ci, + &filtered_response, + config.hook.mode.as_str(), + ); // 10. Emit complete event if progress.is_enabled() { @@ -668,3 +671,127 @@ async fn execute_chunked_review( &filtered_response, )) } + +/// Compute the review exit code from the gate status, CI flag, and the +/// **filtered** review response (issues after severity filtering). +/// +/// Exit code semantics (see #312): +/// +/// | Code | Meaning | +/// |------|---------| +/// | 0 | Review completed; no findings at or above the severity threshold | +/// | 2 | Review completed but findings are blocking (gate fail, CI with any +/// issue, or hook in block mode with blocking severity) | +/// +/// `should_block` must be computed against the **filtered** issue list so that +/// the exit code matches the SARIF/pretty output the user sees. +fn compute_exit_code( + gate_status: Option, + ci: bool, + filtered_response: &ReviewResponse, + hook_mode: &str, +) -> i32 { + if gate_status == Some(quality_gate::GateStatus::Fail) { + return EXIT_BLOCKED; + } + if ci { + return if filtered_response.issues.is_empty() { + EXIT_OK + } else { + EXIT_BLOCKED + }; + } + if filtered_response.should_block && hook_mode == "block" { + EXIT_BLOCKED + } else { + EXIT_OK + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::engine::ReviewResponse; + use crate::engine::Severity; + use crate::engine::types::ReviewIssue; + + fn issue(severity: Severity) -> ReviewIssue { + ReviewIssue { + file: "src/main.rs".to_string(), + line: Some(1), + severity, + issue_type: Some("bug".to_string()), + title: "test".to_string(), + body: "body".to_string(), + suggested_fix: None, + } + } + + fn response(issues: Vec, should_block: bool) -> ReviewResponse { + ReviewResponse { + issues, + summary: String::new(), + tokens_used: None, + should_block, + } + } + + // ─── #312: exit code must match filtered output ─── + + #[test] + fn exit_code_zero_when_no_findings_after_filter() { + let resp = response(vec![], false); + assert_eq!(compute_exit_code(None, false, &resp, "block"), EXIT_OK); + } + + #[test] + fn exit_code_zero_when_filtered_should_block_false() { + // Even if the unfiltered response would have blocked, after filtering + // should_block is false → exit 0. This is the core regression in #312. + let resp = response(vec![], false); + assert_eq!(compute_exit_code(None, false, &resp, "block"), EXIT_OK); + } + + #[test] + fn exit_code_blocked_when_filtered_should_block_true_and_hook_blocks() { + let resp = response(vec![issue(Severity::Critical)], true); + assert_eq!(compute_exit_code(None, false, &resp, "block"), EXIT_BLOCKED); + } + + #[test] + fn exit_code_zero_when_hook_mode_not_block() { + let resp = response(vec![issue(Severity::Critical)], true); + assert_eq!(compute_exit_code(None, false, &resp, "warn"), EXIT_OK); + assert_eq!(compute_exit_code(None, false, &resp, ""), EXIT_OK); + } + + #[test] + fn exit_code_ci_zero_when_no_findings() { + let resp = response(vec![], false); + assert_eq!(compute_exit_code(None, true, &resp, "block"), EXIT_OK); + } + + #[test] + fn exit_code_ci_blocked_when_any_finding() { + let resp = response(vec![issue(Severity::Minor)], false); + assert_eq!(compute_exit_code(None, true, &resp, "block"), EXIT_BLOCKED); + } + + #[test] + fn exit_code_gate_fail_overrides_everything() { + let resp = response(vec![], false); + assert_eq!( + compute_exit_code(Some(quality_gate::GateStatus::Fail), false, &resp, "block"), + EXIT_BLOCKED + ); + } + + #[test] + fn exit_code_gate_pass_then_falls_through() { + let resp = response(vec![], false); + assert_eq!( + compute_exit_code(Some(quality_gate::GateStatus::Pass), false, &resp, "block"), + EXIT_OK + ); + } +} From dc4a667cca23f4e91074c73506e06200a99a9226 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 17 Jun 2026 13:27:38 +0700 Subject: [PATCH 5/7] fix(install): strip macOS Gatekeeper quarantine xattrs (#313) (#319) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prebuilt macOS binaries are not Apple-notarized. When a user downloads the `aarch64-apple-darwin` tarball directly (via `gh release download`, browser, or curl), macOS attaches `com.apple.quarantine` and `com.apple.provenance` extended attributes and then kills the binary on first launch with signal 9 — producing `Killed: 9` and **no error output**. This makes the failure look like a cora crash rather than a distribution gap. Changes - install.sh: after `chmod +x`, on macOS run best-effort `xattr -dr com.apple.quarantine` and `xattr -dr com.apple.provenance` on the installed binary. Non-fatal on failure (binary may already be clean). - README: add a prominent
block under the Install section explaining the `Killed: 9` symptom, the manual `xattr -dr` workaround for direct-download users, and the `cargo` / Homebrew alternatives. - CHANGELOG entry under `## [0.6.1] -> Fixed — Install (macOS)`. Note: the real fix is Apple notarization (codesign + notarytool), which requires an Apple Developer ID certificate. This PR is the minimum-cost stopgap documented in the issue. A follow-up can add notarization to `.github/workflows/release.yml` once credentials are available. Bump 0.6.0 -> 0.6.1 (patch). --- CHANGELOG.md | 7 +++++++ README.md | 19 +++++++++++++++++++ install.sh | 10 ++++++++++ 3 files changed, 36 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b4c7f..1aeb318 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Extracted exit-code logic into `compute_exit_code()` helper (pure function) with 8 unit tests covering gate pass/fail, CI mode, and hook `block` vs non-`block` modes. - Applies to both the single-chunk and auto-chunked (`--auto-chunk`) review paths. +### Fixed — Install (macOS) + +- **macOS installer now strips Gatekeeper quarantine attributes (#313)** + - Prebuilt macOS binaries (`aarch64-apple-darwin`) are not Apple-notarized. When downloaded directly, macOS attaches `com.apple.quarantine` / `com.apple.provenance` xattrs and kills the binary with `Killed: 9` on first launch. + - `install.sh` now runs `xattr -dr` for both attributes on the installed binary on macOS (best-effort, non-fatal). + - Added a prominent `
` block in the README install section explaining the symptom, the manual `xattr` workaround for users who download the binary directly, and the `cargo` / Homebrew alternatives. + ## [0.6.0] - 2026-06-14 ### Added — Code Intelligence diff --git a/README.md b/README.md index 68f7d0d..b1868b3 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,25 @@ curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-b > Or: `cargo install --git https://github.com/codecoradev/cora-cli` > Pre-built binaries: [GitHub Releases](https://github.com/codecoradev/cora-cli/releases) +
+macOS note — binary killed on launch (Killed: 9)? + +The prebuilt `aarch64-apple-darwin` binary is not Apple-notarized. On macOS, downloaded +binaries may be tagged with `com.apple.quarantine` / `com.apple.provenance` and killed by +Gatekeeper with **no error message**. + +The `install.sh` installer strips these attributes automatically. If you downloaded the +binary manually (e.g. `gh release download`), strip them yourself: + +```bash +xattr -dr com.apple.quarantine /path/to/cora +xattr -dr com.apple.provenance /path/to/cora +``` + +Or install via `cargo` / Homebrew to sidestep Gatekeeper entirely. + +
+ ### Authenticate ```bash diff --git a/install.sh b/install.sh index 63f95a8..ef624f6 100755 --- a/install.sh +++ b/install.sh @@ -141,6 +141,16 @@ install() { chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + # macOS Gatekeeper workaround (#313): strip quarantine/provenance xattrs + # that the browser/curl attaches to downloaded binaries. Without this, + # macOS kills the unsigned binary with `Killed: 9` and no error message. + if [ "$OS" = "darwin" ] && command -v xattr >/dev/null 2>&1; then + # Best-effort — failures here are non-fatal (binary may already be clean). + xattr -dr com.apple.quarantine "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + xattr -dr com.apple.provenance "${INSTALL_DIR}/${BINARY_NAME}" 2>/dev/null || true + info "Stripped macOS quarantine attributes (Gatekeeper workaround)" + fi + # Cleanup rm -rf "$TEMP_DIR" From d026db65d91fb189834e292ca8a956f12b31ed68 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 17 Jun 2026 13:36:01 +0700 Subject: [PATCH 6/7] docs(install): warn about multiple distribution channels (#314) (#320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The install docs listed several methods without warning that having more\nthan one on $PATH silently resolves to whichever appears first. On a real\ndeveloper machine three copies were present simultaneously\n(~/.local/bin, ~/.cargo/bin, ~/.bun/bin) and the stale npm v0.2.4 was\nbeing invoked, causing confusing behavior.\n\nChanges\n- README: pick one recommended method, list all supported channels in a\n table, and add a `which -a cora && cora --version` check snippet with\n guidance for pruning stale copies.\n- Cross-link issue #314 for background.\n- CHANGELOG entry under ## [0.6.1] -> Changed — Docs.\n\nAlso closes #315 as a duplicate of #314 (same title/body/author, opened\n3 seconds apart).\n\nBump 0.6.0 -> 0.6.1 (patch; docs only). --- CHANGELOG.md | 7 +++++++ README.md | 26 ++++++++++++++++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1aeb318..3f8ec54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `install.sh` now runs `xattr -dr` for both attributes on the installed binary on macOS (best-effort, non-fatal). - Added a prominent `
` block in the README install section explaining the symptom, the manual `xattr` workaround for users who download the binary directly, and the `cargo` / Homebrew alternatives. +### Changed — Docs + +- **Install section now warns about multiple distribution channels (#314)** + - Recommends a single install method per platform and lists the supported channels (installer script, `cargo`, pre-built binaries). + - Adds a `which -a cora && cora --version` check snippet and guidance for removing stale copies when more than one `cora` is on `PATH` (e.g. `~/.local/bin` vs `~/.cargo/bin` vs npm global). + - Cross-links the original issue for background. + ## [0.6.0] - 2026-06-14 ### Added — Code Intelligence diff --git a/README.md b/README.md index b1868b3..b3f4758 100644 --- a/README.md +++ b/README.md @@ -36,17 +36,35 @@ ### Install +Pick **one** install method — mixing channels can leave stale binaries on your `PATH`. + +| Method | When to use | +|---|---| +| **`curl … install.sh`** (recommended) | Quick standalone install; fetches the latest GitHub release binary | +| **`cargo install --git …`** | You already have a Rust toolchain; builds from source | +| **Pre-built binaries** | Manual download from [Releases](https://github.com/codecoradev/cora-cli/releases) | + ```bash -# Cora only (standalone) +# Recommended: Cora only (standalone) curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh # Or install both Cora + Uteke (code review with memory) curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-bundle.sh | sh + +# Or build from source with cargo +cargo install --git https://github.com/codecoradev/cora-cli +``` + +> Pin a version: `CORA_VERSION=v0.6.0 curl -fsSL ... | sh` + +**Verify which `cora` you're running** — `which -a cora` will reveal stale copies from other channels: + +```bash +which -a cora # list every `cora` on your PATH (one entry = healthy) +cora --version # should match the latest release ``` -> Pin a version: `CORA_VERSION=v0.6.0 curl -fsSL ... | sh` -> Or: `cargo install --git https://github.com/codecoradev/cora-cli` -> Pre-built binaries: [GitHub Releases](https://github.com/codecoradev/cora-cli/releases) +If `which -a cora` shows more than one path (e.g. `~/.local/bin/cora` and `~/.cargo/bin/cora`), remove the one you don't want or reorder your `PATH`. See [Issue #314](https://github.com/codecoradev/cora-cli/issues/314) for background.
macOS note — binary killed on launch (Killed: 9)? From f1027ba6eef1d576c0d093470a71cc7442b73ef9 Mon Sep 17 00:00:00 2001 From: "Anaz S. Aji" Date: Wed, 17 Jun 2026 14:00:54 +0700 Subject: [PATCH 7/7] docs: sync website docs for v0.6.1 release (#321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/changelog.md: sync from root CHANGELOG.md (was stuck at v0.2.0 — now includes v0.4.x, v0.5.x, v0.6.0, and v0.6.1 with all compare links). - docs/installation.md: - Update pinned-version example to v0.6.1. - Add 'Check for stale copies on PATH' subsection (which -a cora) cross-linking #314. - Add 'macOS: Killed: 9 on launch?' subsection documenting the Gatekeeper quarantine workaround (xattr -dr) from #313. - Replace placeholder 'cora 0.x.x' in verify snippet with 'cora 0.6.1'. - docs/cli-reference.md: document new `cora scan` flags --batch-files and --no-continue-on-batch-error (#316). - docs/usage.md: add tip about lowering --batch-files for large scans and the non-fatal batch behavior (#316). - README.md: update pinned-version example to v0.6.1. No code changes. Part of the v0.6.1 patch release bundle. --- README.md | 2 +- docs/changelog.md | 35 ++++++++++++++++++++++++++++++++++- docs/cli-reference.md | 2 ++ docs/installation.md | 33 +++++++++++++++++++++++++++++++-- docs/usage.md | 1 + 5 files changed, 69 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index b3f4758..2a79834 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install-b cargo install --git https://github.com/codecoradev/cora-cli ``` -> Pin a version: `CORA_VERSION=v0.6.0 curl -fsSL ... | sh` +> Pin a version: `CORA_VERSION=v0.6.1 curl -fsSL ... | sh` **Verify which `cora` you're running** — `which -a cora` will reveal stale copies from other channels: diff --git a/docs/changelog.md b/docs/changelog.md index 7b23e2e..3f8ec54 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -7,6 +7,37 @@ 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 ` 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`. + +### Fixed — Review + +- **`cora review` no longer exits 2 when severity filtering removes all blocking findings (#312)** + - Recompute `should_block` against the **filtered** issue list (after `--severity` filtering) so the exit code matches the SARIF/pretty output the user sees. + - Extracted exit-code logic into `compute_exit_code()` helper (pure function) with 8 unit tests covering gate pass/fail, CI mode, and hook `block` vs non-`block` modes. + - Applies to both the single-chunk and auto-chunked (`--auto-chunk`) review paths. + +### Fixed — Install (macOS) + +- **macOS installer now strips Gatekeeper quarantine attributes (#313)** + - Prebuilt macOS binaries (`aarch64-apple-darwin`) are not Apple-notarized. When downloaded directly, macOS attaches `com.apple.quarantine` / `com.apple.provenance` xattrs and kills the binary with `Killed: 9` on first launch. + - `install.sh` now runs `xattr -dr` for both attributes on the installed binary on macOS (best-effort, non-fatal). + - Added a prominent `
` block in the README install section explaining the symptom, the manual `xattr` workaround for users who download the binary directly, and the `cargo` / Homebrew alternatives. + +### Changed — Docs + +- **Install section now warns about multiple distribution channels (#314)** + - Recommends a single install method per platform and lists the supported channels (installer script, `cargo`, pre-built binaries). + - Adds a `which -a cora && cora --version` check snippet and guidance for removing stale copies when more than one `cora` is on `PATH` (e.g. `~/.local/bin` vs `~/.cargo/bin` vs npm global). + - Cross-links the original issue for background. + ## [0.6.0] - 2026-06-14 ### Added — Code Intelligence @@ -502,7 +533,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 diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 9638cfc..d7e3459 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -41,6 +41,8 @@ Complete command reference for the cora CLI. | `cora review --upload` | Review and upload SARIF to GitHub Code Scanning | | `cora scan` `` | Scan files for issues | | `cora scan .` `[--incremental]` | Scan only changed files | +| `cora scan .` `[--batch-files N]` | Max files per LLM batch (default: 20). Lower to work around provider token limits | +| `cora scan .` `[--no-continue-on-batch-error]` | Abort the scan when a batch fails to parse (default: skip and continue) | | `cora config show` | Show resolved configuration | | `cora config show --global` | Show global config (`~/.cora/config.yaml`) | | `cora config show --project` | Show project config (`.cora.yaml`) | diff --git a/docs/installation.md b/docs/installation.md index 99b4ec0..c6299d4 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -21,7 +21,7 @@ echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc Pin a specific version: ```bash -$ CORA_VERSION=v0.6.0 curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh +$ CORA_VERSION=v0.6.1 curl -fsSL https://raw.githubusercontent.com/codecoradev/cora-cli/main/install.sh | sh ``` ## Install via Cargo @@ -85,13 +85,42 @@ Confirm cora is installed correctly: ```bash $ cora --version -cora 0.x.x +cora 0.6.1 $ cora auth status Provider: openai API key: configured ``` +### Check for stale copies on PATH + +cora is distributed through multiple channels (installer script, `cargo`, pre-built binaries). If you have more than one installed, `which cora` resolves to whichever appears first in `$PATH` — which may silently be a stale version. + +```bash +# List every `cora` on your PATH (one entry = healthy) +$ which -a cora +/Users/you/.local/bin/cora + +# Should match the latest release +$ cora --version +cora 0.6.1 +``` + +If `which -a cora` shows more than one path (e.g. `~/.local/bin/cora` and `~/.cargo/bin/cora`), remove the one you don't want or reorder your `PATH`. See [Issue #314](https://github.com/codecoradev/cora-cli/issues/314) for background. + +## macOS: `Killed: 9` on launch? + +Prebuilt macOS binaries (`aarch64-apple-darwin`) are not Apple-notarized. When downloaded directly (via browser, `curl`, or `gh release download`), macOS attaches `com.apple.quarantine` / `com.apple.provenance` extended attributes and kills the binary on first launch with `Killed: 9` and **no error message**. + +The `install.sh` installer strips these attributes automatically. If you downloaded the binary manually, strip them yourself: + +```bash +$ xattr -dr com.apple.quarantine /path/to/cora +$ xattr -dr com.apple.provenance /path/to/cora +``` + +Or install via `cargo` / Homebrew to sidestep Gatekeeper entirely. + ## Updating To update cora to the latest version: diff --git a/docs/usage.md b/docs/usage.md index 0cc01ac..1027710 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -288,5 +288,6 @@ Trend indicators: ▼ improving (fewer findings), ► stable, ▲ worsening (mor - Use `cora review` with no flags for the fastest pre-commit feedback - Combine `--format json` with `--base main` in CI pipelines - Use `cora scan . --incremental` for large codebases — only changed files are analyzed +- For large scans that hit provider token limits or rate-limits, lower the batch size with `--batch-files 10` (default: 20). Failed batches are skipped by default — pass `--no-continue-on-batch-error` to abort instead - Use `--quiet` for minimal output and `--severity` to filter by severity level - Use `cora auth login` to store API keys securely instead of environment variables