diff --git a/README.md b/README.md index c9f9669..b34066d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,9 @@ jmove mv src/com/example/utils/Parser.java src/com/example/core/Parser.java # Find broken imports (exit code 2 if any) jmove check +# Feed CI/IDE: SARIF 2.1.0 or Checkstyle XML (never changes stdout/exit code) +jmove check --report build/jmove.sarif + # Auto-repair import problems (unused + missing imports today) — same dry-run/atomic engine jmove fix --dry-run jmove fix diff --git a/docs/EXAMPLES.md b/docs/EXAMPLES.md index 75117f3..84ad8df 100644 --- a/docs/EXAMPLES.md +++ b/docs/EXAMPLES.md @@ -21,9 +21,7 @@ $ jmove mv lib/sum.ts utils/sum.ts --dry-run @@ -1,4 +1,4 @@ -import { sum } from "./lib/sum"; +import { sum } from "./utils/sum"; - - export function main(): number { - return sum(1, 2); +... move lib/sum.ts -> utils/sum.ts ``` @@ -57,8 +55,7 @@ $ echo $? ### Operating on another project -`--root` points jmove at a project other than the current directory; all -path arguments stay relative to that root: +`--root` points jmove at another project; path arguments stay relative to it: ```console $ jmove --root ~/code/frontend mv src/old.ts src/new.ts --dry-run @@ -75,8 +72,8 @@ $ echo $? ``` jmove never overwrites: free the destination (or pick another name) and -retry. A missing source reports `SOURCE_NOT_FOUND` the same way, and a -file nobody imports simply moves with zero rewrites. +retry. A missing source reports `SOURCE_NOT_FOUND`; an unimported file +simply moves with zero rewrites. ### Java: package + imports + move in one step @@ -87,7 +84,6 @@ is three coordinated edits — jmove makes all of them: $ jmove mv src/main/java/com/example/util/Text.java src/main/java/com/example/core/Text.java --dry-run --- src/main/java/com/example/app/App.java +++ src/main/java/com/example/app/App.java -@@ -1,7 +1,7 @@ package com.example.app; -import com.example.util.Text; @@ -112,8 +108,6 @@ dry-run/atomic engine. Preview first, then apply: ```console $ cat src/main/java/com/example/app/App.java -package com.example.app; - import com.example.Text; import com.example.unused.Ghost; // never referenced @@ -121,7 +115,6 @@ import com.example.unused.Ghost; // never referenced $ jmove fix --dry-run --- src/main/java/com/example/app/App.java +++ src/main/java/com/example/app/App.java -@@ -1,6 +1,5 @@ package com.example.app; import com.example.Text; @@ -226,10 +219,18 @@ $ jmove check --json } ``` -Note: this response keeps `status: "ok"` (the command itself succeeded) -while the process exits `2`; treat a non-zero `total` — or exit code `2` — -as a failed refactor. A clean project returns `"broken_imports": [], "total": 0` -and exit code `0`. +Note: the response keeps `status: "ok"` while the process exits `2`; treat a +non-zero `total` (or exit `2`) as a failed refactor; clean returns exit `0`. + +### CI report (same findings, machine formats) + +```console +$ jmove check --report build/jmove.sarif # SARIF 2.1.0, GitHub/CodeQL +$ jmove fix --report build/jmove.xml # Checkstyle XML, IDEs/Jenkins +``` + +`--report` never changes stdout or the exit code; clean runs write valid +empty documents. Rule ids match the `--json` ones. ### Error shape diff --git a/docs/SKILL.md b/docs/SKILL.md index dbffb3a..d2d9462 100644 --- a/docs/SKILL.md +++ b/docs/SKILL.md @@ -57,7 +57,7 @@ Exit code never changes; surface the list to the user. ### check — find broken imports and Java layout errors ``` -jmove check [--root DIR] [--json] +jmove check [--root DIR] [--json] [--report FILE] ``` Run after any move (or any edit) to validate project consistency. Reports @@ -65,10 +65,22 @@ broken relative imports and Java files whose single public type is named differently from the file (each finding carries the exact `jmove mv` that renames it; exit code 2 covers both kinds). +### report files — SARIF and Checkstyle for CI/IDE + +`check` and `fix` accept `--report FILE`; the format is chosen by the file +name suffix: `.sarif` → SARIF 2.1.0 (single run, `%SRCROOT%`-relative +artifact URIs, `properties.autoFixable`), `.xml` → Checkstyle XML +(`source="jmove."` with `/` mapped to `.`). Unknown suffixes fail +before any work with `INVALID_ARGUMENT`. The report mirrors what the run +already computed (fix: candidates with their applied/manual status; dry-run +included), stdout and exit codes stay as without `--report`, and clean runs +write valid *empty* documents. Rule ids: the four fix rules above, plus +`broken-import` and `java/class-name-mismatch`. + ### fix — auto-repair import problems ``` -jmove fix [--root DIR] [--rule ID] [--dry-run] [--json] +jmove fix [--root DIR] [--rule ID] [--dry-run] [--json] [--report FILE] ``` Runs the deterministic rules over the whole project and applies the diff --git a/src/cli/fix.rs b/src/cli/fix.rs index fde7cdd..71f1d67 100644 --- a/src/cli/fix.rs +++ b/src/cli/fix.rs @@ -15,6 +15,7 @@ use crate::core::{JmoveError, JmoveResult, rel_str}; use crate::parser; use super::json::Envelope; +use super::report::{self, Report}; use super::{Flow, exit, fail, flow, json, output}; /// One reported candidate (JSON element). `applied` is false for @@ -77,7 +78,9 @@ pub fn fix( rule: Option<&str>, dry_run: bool, json: bool, + report: Option<&Path>, ) -> Flow { + let sink = flow(json, "fix", Report::parse(report))?; let root = flow(json, "fix", root.canonicalize().map_err(JmoveError::from))?; let source_root = flow(json, "fix", Index::normalize_scope(&root, source_root))?; if let Some(rejected) = fix_reject(rule) { @@ -87,6 +90,7 @@ pub fn fix( let index = flow(json, "fix", Index::build_scoped(&root, scope))?; let plan = plan_fix(&index, rule); if plan.is_empty() { + flow(json, "fix", Report::emit(&sink, &[]))?; if json { json::print(&Envelope::ok("fix", empty_data())); } else { @@ -95,13 +99,14 @@ pub fn fix( return Ok(exit::OK); } if dry_run { - return fix_dry_run(&root, json, &plan); + return fix_dry_run(&root, json, &plan, &sink); } // Line numbers use spans against the original contents, so the JSON // payload is assembled before any edit reaches the disk. let detail = flow(json, "fix", describe(&root, &plan))?; let edits = plan.auto_edits(); let files_changed = flow(json, "fix", apply::apply_edits(&root, &edits))?; + flow(json, "fix", Report::emit(&sink, &report::from_fix(&detail)))?; if json { let fixes = edits.values().map(Vec::len).sum(); json::print(&Envelope::ok( @@ -151,11 +156,16 @@ fn fix_reject(rule: Option<&str>) -> Option { } /// Dry-run branch: unified diff for humans, structured preview for agents. -fn fix_dry_run(root: &Path, json: bool, plan: &FixPlan) -> Flow { +fn fix_dry_run(root: &Path, json: bool, plan: &FixPlan, sink: &Option) -> Flow { let edits = plan.auto_edits(); let diff = flow(json, "fix", render_edits_diff(root, &edits))?; + let detail = if json || sink.is_some() { + flow(json, "fix", describe(root, plan))? + } else { + Vec::new() + }; + flow(json, "fix", Report::emit(sink, &report::from_fix(&detail)))?; if json { - let detail = flow(json, "fix", describe(root, plan))?; let affected = edits.keys().map(|p| rel_str(p)).collect(); json::print(&Envelope::dry_run( "fix", diff --git a/src/cli/mod.rs b/src/cli/mod.rs index c627d5b..25b4f43 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,6 +6,7 @@ pub mod fix; pub mod json; pub mod output; +pub mod report; use std::convert::identity; use std::path::{Path, PathBuf}; @@ -60,6 +61,9 @@ pub enum Command { /// Machine-readable JSON output. #[arg(long)] json: bool, + /// Write findings to a report file: `.sarif` or `.xml` (checkstyle). + #[arg(long)] + report: Option, }, /// Auto-fix small import problems (same engine as `mv`). Fix { @@ -72,6 +76,9 @@ pub enum Command { /// Machine-readable JSON output (for AI agents). #[arg(long)] json: bool, + /// Write the candidate list to a report file: `.sarif` or `.xml`. + #[arg(long)] + report: Option, }, } @@ -109,17 +116,24 @@ pub fn run() -> anyhow::Result { json, apply::GitMode::from_no_git(no_git), ), - Command::Check { json } => check(&args.root, args.source_root.as_deref(), json), + Command::Check { json, report } => report::check( + &args.root, + args.source_root.as_deref(), + json, + report.as_deref(), + ), Command::Fix { rule, dry_run, json, + report, } => fix::fix( &args.root, args.source_root.as_deref(), rule.as_deref(), dry_run, json, + report.as_deref(), ), }; // Handlers report their own failures; both arms carry an exit code. @@ -189,38 +203,6 @@ fn mv_dry_run( Ok(exit::OK) } -/// `check` handler: report relative imports that resolve to nothing. -/// -/// Exit code is `2` when at least one broken import was found, in both the -/// human and the `--json` mode (the JSON `status` stays `"ok"` — the -/// command itself succeeded; agents read `total` or the exit code). -fn check(root: &Path, source_root: Option<&Path>, json: bool) -> Flow { - let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?; - let source_root = flow(json, "check", Index::normalize_scope(&root, source_root))?; - let scope = source_root.as_deref(); - let index = flow(json, "check", Index::build_scoped(&root, scope))?; - let broken = flow(json, "check", output::broken_imports(&root, &index))?; - let mismatches = flow(json, "check", output::name_mismatches(&root, &index))?; - let code = if broken.is_empty() && mismatches.is_empty() { - exit::OK - } else { - exit::BROKEN - }; - - if json { - let total = broken.len(); - let data = output::CheckData { - broken_imports: broken, - total, - name_mismatches: mismatches, - }; - json::print(&Envelope::ok("check", data)); - } else { - output::report_check(&broken, &mismatches); - } - Ok(code) -} - /// Unwrap a core result, routing failures through the CLI error channel. fn flow(json: bool, operation: &'static str, result: JmoveResult) -> Flow { result.map_err(|err| fail(json, operation, ErrorData::from_core(&err))) diff --git a/src/cli/report/checkstyle.rs b/src/cli/report/checkstyle.rs new file mode 100644 index 0000000..c9ea088 --- /dev/null +++ b/src/cli/report/checkstyle.rs @@ -0,0 +1,115 @@ +//! Checkstyle XML emission: the de-facto report format for IDEs (IntelliJ, +//! VSCode), Jenkins warnings-ng and GitLab code-quality parsing. +//! +//! `source` carries the jmove rule id namespaced (`jmove.java.unused-import`) +//! so consumers can group by rule. Output is deterministic: files sorted +//! (input is already sorted), errors in source order. + +use std::collections::BTreeMap; +use std::fmt::Write as _; + +use super::Violation; + +/// Render `violations` as a Checkstyle XML document (always valid, even +/// with zero errors). +#[must_use] +pub fn build(violations: &[Violation]) -> String { + let mut out = + String::from("\n\n"); + let mut by_file: BTreeMap<&str, Vec<&Violation>> = BTreeMap::new(); + for v in violations { + by_file.entry(&v.file).or_default().push(v); + } + for (file, entries) in by_file { + let _ = writeln!(out, " ", escape(file)); + for v in entries { + let _ = writeln!( + out, + " ", + v.line, + v.severity, + escape(&v.message), + source(v.rule) + ); + } + out.push_str(" \n"); + } + out.push_str("\n"); + out +} + +// The rule as a dotted pseudo-class name, Checkstyle style. +fn source(rule: &str) -> String { + format!("jmove.{}", rule.replace('/', ".")) +} + +/// XML attribute escaping; `&` first so entities survive. +fn escape(text: &str) -> String { + let mut out = String::with_capacity(text.len()); + for c in text.chars() { + match c { + '&' => out.push_str("&"), + '<' => out.push_str("<"), + '>' => out.push_str(">"), + '"' => out.push_str("""), + _ => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + fn violation(rule: &'static str, severity: &'static str, file: &str, line: usize) -> Violation { + Violation { + rule, + severity, + message: "he said \"fix & now\"".to_owned(), + file: file.to_owned(), + line, + fixable: true, + } + } + + #[test] + fn groups_files_and_renders_error_attributes() { + let xml = build(&[ + violation("java/unused-import", "warning", "a.java", 1), + violation("java/unused-import", "warning", "a.java", 4), + violation("broken-import", "error", "b.ts", 2), + ]); + assert!( + xml.starts_with("\n"), "{xml}"); + } + + #[test] + fn clean_run_is_a_valid_empty_document() { + let xml = build(&[]); + assert_eq!( + xml, + "\n\n\n" + ); + } +} diff --git a/src/cli/report/mod.rs b/src/cli/report/mod.rs new file mode 100644 index 0000000..f8aa74a --- /dev/null +++ b/src/cli/report/mod.rs @@ -0,0 +1,178 @@ +//! Machine-readable report files, and the `check` command that produces +//! their primary findings. +//! +//! `--report ` (on `check` and `fix`) writes the same findings a run +//! already computes into a CI-consumable format chosen by file extension: +//! SARIF 2.1.0 (`.sarif` — GitHub code scanning, CodeQL upload) or +//! Checkstyle XML (`.xml` — IDEs, Jenkins, GitLab). Unknown extensions +//! fail early with `INVALID_ARGUMENT`, before any indexing. +//! +//! Reports never change stdout or exit codes: `check` still exits `2` when +//! it finds something, and a clean run still writes a *valid empty* report +//! (CI parsers must not choke on green builds). + +use std::path::{Path, PathBuf}; + +use crate::core::index::Index; +use crate::core::{JmoveError, JmoveResult}; + +use crate::cli::fix::FixedFile; +use crate::cli::json::Envelope; +use crate::cli::output::{BrokenImport, NameMismatch}; +use crate::cli::{Flow, exit, flow, json, output}; + +mod checkstyle; +mod sarif; + +/// One finding, detached from the format that renders it. +#[derive(Debug)] +pub struct Violation { + /// Stable rule id, e.g. `java/unused-import` or `broken-import`. + pub rule: &'static str, + /// `error` | `warning` | `info` (mirrors `--json`). + pub severity: &'static str, + /// Human-readable message, same text the terminal output shows. + pub message: String, + /// Project-relative file path, `/` separated. + pub file: String, + /// 1-based line. + pub line: usize, + /// Whether jmove can resolve it itself (fix engine, or the suggested + /// `jmove mv` for layout findings). + pub fixable: bool, +} + +/// A parsed `--report` destination. +#[derive(Debug)] +pub enum Report { + /// SARIF 2.1.0 document. + Sarif(PathBuf), + /// Checkstyle XML document. + Checkstyle(PathBuf), +} + +impl Report { + /// Validate a requested report path (by extension) without touching it. + pub fn parse(requested: Option<&Path>) -> JmoveResult> { + let Some(path) = requested else { + return Ok(None); + }; + let name = path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or_default(); + let report = if name.ends_with(".sarif") { + Self::Sarif(path.to_path_buf()) + } else if name.ends_with(".xml") { + Self::Checkstyle(path.to_path_buf()) + } else { + return Err(JmoveError::InvalidArgument(format!( + "--report '{name}': unsupported file name" + ))); + }; + Ok(Some(report)) + } + + /// Write the report when one was requested; always valid, also empty. + pub fn emit(sink: &Option, violations: &[Violation]) -> JmoveResult<()> { + let Some(report) = sink else { + return Ok(()); + }; + let (path, content) = match report { + Self::Sarif(path) => (path, sarif::build(violations)), + Self::Checkstyle(path) => (path, checkstyle::build(violations)), + }; + std::fs::write(path, content)?; + Ok(()) + } +} + +/// Findings of `check` as report violations. +#[must_use] +pub fn from_check(broken: &[BrokenImport], mismatches: &[NameMismatch]) -> Vec { + let mut out: Vec = broken + .iter() + .map(|b| Violation { + rule: "broken-import", + severity: "error", + message: format!("cannot resolve '{}'", b.import), + file: b.file.clone(), + line: b.line, + fixable: false, + }) + .collect(); + out.extend(mismatches.iter().map(|m| Violation { + rule: "java/class-name-mismatch", + severity: "error", + message: format!( + "public class '{}' must live in '{}'; {}", + m.public_class, m.expected_file, m.rename + ), + file: m.file.clone(), + line: m.line, + fixable: true, + })); + out +} + +/// Findings of `fix` (the per-file candidate detail) as report violations. +#[must_use] +pub fn from_fix(files: &[FixedFile]) -> Vec { + files + .iter() + .flat_map(|f| { + f.fixes.iter().map(|c| Violation { + rule: c.rule, + severity: c.severity, + message: c.message.clone(), + file: f.path.clone(), + line: c.line, + fixable: c.applied, + }) + }) + .collect() +} + +/// `check` handler: report relative imports that resolve to nothing. +/// +/// Exit code is `2` when at least one finding exists, in both the human +/// and the `--json` mode (the JSON `status` stays `"ok"` — the command +/// itself succeeded; agents read `total` or the exit code). A `--report` +/// file is written regardless of mode and does not alter the exit code. +pub fn check( + root: &Path, + source_root: Option<&Path>, + json: bool, + report: Option<&Path>, +) -> Flow { + let sink = flow(json, "check", Report::parse(report))?; + let root = flow(json, "check", root.canonicalize().map_err(JmoveError::from))?; + let source_root = flow(json, "check", Index::normalize_scope(&root, source_root))?; + let scope = source_root.as_deref(); + let index = flow(json, "check", Index::build_scoped(&root, scope))?; + let broken = flow(json, "check", output::broken_imports(&root, &index))?; + let mismatches = flow(json, "check", output::name_mismatches(&root, &index))?; + flow( + json, + "check", + Report::emit(&sink, &from_check(&broken, &mismatches)), + )?; + let code = if broken.is_empty() && mismatches.is_empty() { + exit::OK + } else { + exit::BROKEN + }; + + if json { + let total = broken.len(); + let data = output::CheckData { + broken_imports: broken, + total, + name_mismatches: mismatches, + }; + json::print(&Envelope::ok("check", data)); + } else { + output::report_check(&broken, &mismatches); + } + Ok(code) +} diff --git a/src/cli/report/sarif.rs b/src/cli/report/sarif.rs new file mode 100644 index 0000000..e742cdf --- /dev/null +++ b/src/cli/report/sarif.rs @@ -0,0 +1,247 @@ +//! SARIF 2.1.0 emission: single run, single tool driver, plain results. +//! +//! Only the subset consumed by GitHub code scanning / CodeQL upload / +//! VSCode is produced: `ruleId`, `level`, one physical `location` with a +//! start line, and a `properties.autoFixable` flag. `uriBaseId` is +//! `%SRCROOT%` so artifact URIs stay project-relative. + +use serde::Serialize; + +use super::Violation; + +const SCHEMA: &str = "https://raw.githubusercontent.com/oasis-tcs/sarif-spec/master/Schemata/sarif-schema-2.1.0.json"; + +/// Render `violations` as a pretty-printed SARIF document. +#[must_use] +pub fn build(violations: &[Violation]) -> String { + let results = violations + .iter() + .map(|v| SarifResult { + rule_id: v.rule, + level: level(v.severity), + message: Text { + text: v.message.clone(), + }, + locations: vec![Location { + physical_location: Physical { + artifact_location: Artifact { + uri: v.file.clone(), + uri_base_id: "%SRCROOT%", + }, + region: Region { start_line: v.line }, + }, + }], + properties: Props { + auto_fixable: v.fixable, + }, + }) + .collect(); + let doc = Sarif { + schema: SCHEMA, + version: "2.1.0", + runs: vec![Run { + tool: Tool { + driver: Driver { + name: "jmove", + version: env!("CARGO_PKG_VERSION"), + rules: driver_rules(violations), + }, + }, + results, + }], + }; + serde_json::to_string_pretty(&doc).expect("sarif shapes always serialize") +} + +/// SARIF `level`: the spec's vocabulary, mapped from jmove severities. +fn level(severity: &str) -> &'static str { + match severity { + "warning" => "warning", + "info" => "note", + _ => "error", + } +} + +// The driver rule registry: each distinct rule once, sorted, described. +fn driver_rules(violations: &[Violation]) -> Vec { + let mut ids: Vec<&'static str> = Vec::new(); + for v in violations { + if !ids.contains(&v.rule) { + ids.push(v.rule); + } + } + ids.sort_unstable(); + ids.into_iter() + .map(|id| Rule { + id, + short_description: Text { + text: description(id).to_owned(), + }, + }) + .collect() +} + +/// Stable one-line rule descriptions (`shortDescription.text`). +fn description(id: &str) -> &str { + match id { + "broken-import" => "Relative import specifier cannot be resolved", + "java/class-name-mismatch" => "File name must match the public Java type", + "java/unused-import" | "ts/unused-import" => "Import is never referenced", + "java/missing-import" => "Referenced type has no import", + "java/import-order" => "Imports violate the configured order", + other => other, + } +} + +#[derive(Serialize)] +struct Sarif { + #[serde(rename = "$schema")] + schema: &'static str, + version: &'static str, + runs: Vec, +} + +#[derive(Serialize)] +struct Tool { + driver: Driver, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Driver { + name: &'static str, + version: &'static str, + rules: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Rule { + id: &'static str, + short_description: Text, +} + +#[derive(Serialize)] +struct Text { + text: String, +} + +#[derive(Serialize)] +struct Run { + tool: Tool, + results: Vec, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct SarifResult { + rule_id: &'static str, + level: &'static str, + message: Text, + locations: Vec, + properties: Props, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Location { + physical_location: Physical, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Physical { + artifact_location: Artifact, + region: Region, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Artifact { + uri: String, + uri_base_id: &'static str, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Region { + start_line: usize, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct Props { + auto_fixable: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::parser; + + fn violation(rule: &'static str, severity: &'static str, file: &str, line: usize) -> Violation { + Violation { + rule, + severity, + message: format!("{rule} at {file}:{line}"), + file: file.to_owned(), + line, + fixable: severity != "error", + } + } + + fn parse(violations: &[Violation]) -> serde_json::Value { + serde_json::from_str(&build(violations)).expect("valid json") + } + + #[test] + fn envelope_and_result_shapes_follow_the_spec() { + let doc = parse(&[ + violation("java/unused-import", "warning", "a.java", 3), + violation("java/import-order", "info", "a.java", 9), + ]); + assert_eq!(doc["version"], "2.1.0"); + assert!(doc["$schema"].is_string()); + let run = &doc["runs"][0]; + assert_eq!(run["tool"]["driver"]["name"], "jmove"); + assert_eq!(run["results"][0]["ruleId"], "java/unused-import"); + assert_eq!(run["results"][0]["level"], "warning"); + let location = &run["results"][0]["locations"][0]["physicalLocation"]; + assert_eq!(location["artifactLocation"]["uri"], "a.java"); + assert_eq!(location["artifactLocation"]["uriBaseId"], "%SRCROOT%"); + assert_eq!(location["region"]["startLine"], 3); + assert_eq!(run["results"][0]["properties"]["autoFixable"], true); + assert_eq!(run["results"][1]["level"], "note"); + } + + #[test] + fn driver_lists_each_rule_once_sorted_with_descriptions() { + let doc = parse(&[ + violation("java/unused-import", "warning", "a.java", 1), + violation("broken-import", "error", "b.ts", 2), + violation("java/unused-import", "warning", "c.java", 3), + ]); + let ids: Vec<&str> = doc["runs"][0]["tool"]["driver"]["rules"] + .as_array() + .unwrap() + .iter() + .map(|r| r["id"].as_str().unwrap()) + .collect(); + assert_eq!(ids, ["broken-import", "java/unused-import"]); + assert_eq!(doc["runs"][0]["results"][1]["level"], "error"); + assert!( + doc["runs"][0]["tool"]["driver"]["rules"][0]["shortDescription"]["text"] + .as_str() + .unwrap() + .starts_with("Relative") + ); + } + + #[test] + fn every_shipped_rule_id_has_a_description() { + for id in parser::rule_ids() { + assert_ne!(description(id), *id, "missing description for {id}"); + } + assert!(!description("broken-import").starts_with("broken-import")); + } +} diff --git a/tests/cli_report.rs b/tests/cli_report.rs new file mode 100644 index 0000000..a710859 --- /dev/null +++ b/tests/cli_report.rs @@ -0,0 +1,122 @@ +//! `--report ` interop: SARIF 2.1.0 and Checkstyle XML for CI/IDE +//! consumers. The reports mirror the findings of `check`/`fix` without +//! changing stdout or exit codes. + +mod common; + +use common::{copy_fixture, in_root, jmove, read}; +use predicates::prelude::*; + +fn report_path(tmp: &tempfile::TempDir, name: &str) -> String { + tmp.path() + .join(name) + .to_str() + .expect("utf-8 path") + .to_owned() +} + +fn sarif(tmp: &tempfile::TempDir, name: &str) -> serde_json::Value { + serde_json::from_str(&read(&tmp.path().join(name))).expect("valid sarif json") +} + +#[test] +fn check_writes_sarif_and_keeps_the_findings_exit_code() { + let tmp = copy_fixture("java", "mismatch"); + let report = report_path(&tmp, "out.sarif"); + jmove(&tmp, &["check", "--report", &report]).code(2); + let doc = sarif(&tmp, "out.sarif"); + let result = &doc["runs"][0]["results"][0]; + assert_eq!(result["ruleId"], "java/class-name-mismatch"); + assert_eq!(result["level"], "error"); + assert!( + result["message"]["text"] + .as_str() + .unwrap() + .contains("jmove mv") + ); + let location = &result["locations"][0]["physicalLocation"]; + assert_eq!( + location["artifactLocation"]["uri"], + "src/main/java/com/example/Bad.java" + ); + assert_eq!(location["region"]["startLine"], 3); + assert_eq!(result["properties"]["autoFixable"], true); +} + +#[test] +fn check_writes_checkstyle_for_broken_imports() { + let tmp = copy_fixture("typescript", "complex"); + let report = report_path(&tmp, "checkstyle.xml"); + jmove(&tmp, &["check", "--report", &report]).code(2); + let xml = read(&in_root(tmp.path(), "checkstyle.xml")); + assert!(xml.starts_with(""), "{xml}"); + assert!( + xml.contains( + "\n"), "{xml}"); +} + +#[test] +fn unknown_report_extension_fails_fast() { + let tmp = copy_fixture("typescript", "basic"); + let report = report_path(&tmp, "out.txt"); + jmove(&tmp, &["check", "--report", &report]) + .code(1) + .stderr(predicate::str::contains("unsupported file name")); + assert!(!tmp.path().join("out.txt").exists()); +} + +#[test] +fn fix_reports_candidates_in_both_formats_without_touching_stdout() { + let tmp = copy_fixture("typescript", "unused"); + let sarif_name = report_path(&tmp, "fix.sarif"); + let xml_name = report_path(&tmp, "fix.xml"); + jmove(&tmp, &["fix", "--dry-run", "--report", &sarif_name]) + .success() + .stdout(predicate::str::contains("-import type { Ghost }")); + jmove(&tmp, &["fix", "--dry-run", "--report", &xml_name]).success(); + let doc = sarif(&tmp, "fix.sarif"); + let result = &doc["runs"][0]["results"][0]; + assert_eq!(result["ruleId"], "ts/unused-import"); + assert_eq!(result["level"], "warning"); + assert_eq!(result["properties"]["autoFixable"], true); + let xml = read(&in_root(tmp.path(), "fix.xml")); + assert!(xml.contains("source=\"jmove.ts.unused-import\""), "{xml}"); + assert!(xml.contains("severity=\"warning\""), "{xml}"); +} + +#[test] +fn applied_fix_writes_report_before_stdout_summary() { + let tmp = copy_fixture("typescript", "unused"); + let report = report_path(&tmp, "applied.sarif"); + jmove(&tmp, &["fix", "--report", &report]) + .success() + .stdout(predicate::str::contains("fixed")); + let doc = sarif(&tmp, "applied.sarif"); + assert!(!doc["runs"][0]["results"].as_array().unwrap().is_empty()); +} diff --git a/todo.md b/todo.md index 1a8d707..c4f7f18 100644 --- a/todo.md +++ b/todo.md @@ -66,9 +66,9 @@ AI оставляем СНАРУЖИ: при неоднозначности jmov - [ ] Форматирование: свой cargo-fmt НЕ строим (вечный long-tail). Только «import formatting» (порядок/группировка — у нас уже есть spans). Опционально `--format-after ` (prettier / google-java-format), не зависимость -- [ ] Интероп PMD/Checkstyle/eslint (фаза 2.5): `jmove fix --report checkstyle.xml` маппит - violation(file,line,rule) на паттерны; на выход SARIF для CI/IDE. - Маркетинг: «auto-fix for what Checkstyle only reports» +- [x] Интероп Checkstyle/eslint (фаза 2.5): `check --report f.sarif|.xml` и `fix --report f.sarif|.xml` — + SARIF 2.1.0 (GitHub/CodeQL, autoFixable) и Checkstyle XML (source=jmove.); формат по расширению, + чистый прогон = валидный пустой файл, stdout/exit не меняются. Маркетинг: «auto-fix for what Checkstyle only reports» ## Guava real-world smoke test (google/guava @ main, JDK21, mvnw) — ПРОВЕРЕНО - [x] mv Primitives primitives→util: 5 правок (4 imports + package), `mvn -pl guava compile`