feat: SARIF 2.1.0 and Checkstyle XML reports for check/fix
Some checks are pending
CI / lint (push) Waiting to run
CI / test (ubuntu-latest) (push) Waiting to run
CI / test (windows-latest) (push) Waiting to run

--report FILE on check and fix writes the findings a run already
computed into CI-consumable formats chosen by extension: .sarif
(single run, %SRCROOT%-relative URIs, properties.autoFixable, rule
registry with descriptions) and .xml (Checkstyle, source=jmove.<rule>).
Unknown suffixes fail fast with INVALID_ARGUMENT; stdout and exit codes
are untouched, and clean runs write valid empty documents.

The check handler moved into cli::report (its format siblings); fix
emits the candidate list in dry-run and apply modes. Marketing angle
from the plan: auto-fix for what Checkstyle only reports.
This commit is contained in:
loki5512344 2026-09-15 20:23:20 +02:00
parent 47395069cb
commit ad1ad4f999
Signed by: boba
GPG key ID: 253067914055423B
10 changed files with 726 additions and 56 deletions

View file

@ -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) # Find broken imports (exit code 2 if any)
jmove check 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 # Auto-repair import problems (unused + missing imports today) — same dry-run/atomic engine
jmove fix --dry-run jmove fix --dry-run
jmove fix jmove fix

View file

@ -21,9 +21,7 @@ $ jmove mv lib/sum.ts utils/sum.ts --dry-run
@@ -1,4 +1,4 @@ @@ -1,4 +1,4 @@
-import { sum } from "./lib/sum"; -import { sum } from "./lib/sum";
+import { sum } from "./utils/sum"; +import { sum } from "./utils/sum";
...
export function main(): number {
return sum(1, 2);
move lib/sum.ts -> utils/sum.ts move lib/sum.ts -> utils/sum.ts
``` ```
@ -57,8 +55,7 @@ $ echo $?
### Operating on another project ### Operating on another project
`--root` points jmove at a project other than the current directory; all `--root` points jmove at another project; path arguments stay relative to it:
path arguments stay relative to that root:
```console ```console
$ jmove --root ~/code/frontend mv src/old.ts src/new.ts --dry-run $ 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 jmove never overwrites: free the destination (or pick another name) and
retry. A missing source reports `SOURCE_NOT_FOUND` the same way, and a retry. A missing source reports `SOURCE_NOT_FOUND`; an unimported file
file nobody imports simply moves with zero rewrites. simply moves with zero rewrites.
### Java: package + imports + move in one step ### 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 $ 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
+++ src/main/java/com/example/app/App.java +++ src/main/java/com/example/app/App.java
@@ -1,7 +1,7 @@
package com.example.app; package com.example.app;
-import com.example.util.Text; -import com.example.util.Text;
@ -112,8 +108,6 @@ dry-run/atomic engine. Preview first, then apply:
```console ```console
$ cat src/main/java/com/example/app/App.java $ cat src/main/java/com/example/app/App.java
package com.example.app;
import com.example.Text; import com.example.Text;
import com.example.unused.Ghost; // never referenced import com.example.unused.Ghost; // never referenced
@ -121,7 +115,6 @@ import com.example.unused.Ghost; // never referenced
$ jmove fix --dry-run $ jmove fix --dry-run
--- src/main/java/com/example/app/App.java --- src/main/java/com/example/app/App.java
+++ src/main/java/com/example/app/App.java +++ src/main/java/com/example/app/App.java
@@ -1,6 +1,5 @@
package com.example.app; package com.example.app;
import com.example.Text; import com.example.Text;
@ -226,10 +219,18 @@ $ jmove check --json
} }
``` ```
Note: this response keeps `status: "ok"` (the command itself succeeded) Note: the response keeps `status: "ok"` while the process exits `2`; treat a
while the process exits `2`; treat a non-zero `total` — or exit code `2` — non-zero `total` (or exit `2`) as a failed refactor; clean returns exit `0`.
as a failed refactor. A clean project returns `"broken_imports": [], "total": 0`
and exit code `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 ### Error shape

View file

@ -57,7 +57,7 @@ Exit code never changes; surface the list to the user.
### check — find broken imports and Java layout errors ### 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 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 differently from the file (each finding carries the exact `jmove mv` that
renames it; exit code 2 covers both kinds). 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.<rule>"` 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 ### 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 Runs the deterministic rules over the whole project and applies the

View file

@ -15,6 +15,7 @@ use crate::core::{JmoveError, JmoveResult, rel_str};
use crate::parser; use crate::parser;
use super::json::Envelope; use super::json::Envelope;
use super::report::{self, Report};
use super::{Flow, exit, fail, flow, json, output}; use super::{Flow, exit, fail, flow, json, output};
/// One reported candidate (JSON element). `applied` is false for /// One reported candidate (JSON element). `applied` is false for
@ -77,7 +78,9 @@ pub fn fix(
rule: Option<&str>, rule: Option<&str>,
dry_run: bool, dry_run: bool,
json: bool, json: bool,
report: Option<&Path>,
) -> Flow<i32> { ) -> Flow<i32> {
let sink = flow(json, "fix", Report::parse(report))?;
let root = flow(json, "fix", root.canonicalize().map_err(JmoveError::from))?; let root = flow(json, "fix", root.canonicalize().map_err(JmoveError::from))?;
let source_root = flow(json, "fix", Index::normalize_scope(&root, source_root))?; let source_root = flow(json, "fix", Index::normalize_scope(&root, source_root))?;
if let Some(rejected) = fix_reject(rule) { 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 index = flow(json, "fix", Index::build_scoped(&root, scope))?;
let plan = plan_fix(&index, rule); let plan = plan_fix(&index, rule);
if plan.is_empty() { if plan.is_empty() {
flow(json, "fix", Report::emit(&sink, &[]))?;
if json { if json {
json::print(&Envelope::ok("fix", empty_data())); json::print(&Envelope::ok("fix", empty_data()));
} else { } else {
@ -95,13 +99,14 @@ pub fn fix(
return Ok(exit::OK); return Ok(exit::OK);
} }
if dry_run { 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 // Line numbers use spans against the original contents, so the JSON
// payload is assembled before any edit reaches the disk. // payload is assembled before any edit reaches the disk.
let detail = flow(json, "fix", describe(&root, &plan))?; let detail = flow(json, "fix", describe(&root, &plan))?;
let edits = plan.auto_edits(); let edits = plan.auto_edits();
let files_changed = flow(json, "fix", apply::apply_edits(&root, &edits))?; let files_changed = flow(json, "fix", apply::apply_edits(&root, &edits))?;
flow(json, "fix", Report::emit(&sink, &report::from_fix(&detail)))?;
if json { if json {
let fixes = edits.values().map(Vec::len).sum(); let fixes = edits.values().map(Vec::len).sum();
json::print(&Envelope::ok( json::print(&Envelope::ok(
@ -151,11 +156,16 @@ fn fix_reject(rule: Option<&str>) -> Option<super::json::ErrorData> {
} }
/// Dry-run branch: unified diff for humans, structured preview for agents. /// Dry-run branch: unified diff for humans, structured preview for agents.
fn fix_dry_run(root: &Path, json: bool, plan: &FixPlan) -> Flow<i32> { fn fix_dry_run(root: &Path, json: bool, plan: &FixPlan, sink: &Option<Report>) -> Flow<i32> {
let edits = plan.auto_edits(); let edits = plan.auto_edits();
let diff = flow(json, "fix", render_edits_diff(root, &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 { if json {
let detail = flow(json, "fix", describe(root, plan))?;
let affected = edits.keys().map(|p| rel_str(p)).collect(); let affected = edits.keys().map(|p| rel_str(p)).collect();
json::print(&Envelope::dry_run( json::print(&Envelope::dry_run(
"fix", "fix",

View file

@ -6,6 +6,7 @@
pub mod fix; pub mod fix;
pub mod json; pub mod json;
pub mod output; pub mod output;
pub mod report;
use std::convert::identity; use std::convert::identity;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@ -60,6 +61,9 @@ pub enum Command {
/// Machine-readable JSON output. /// Machine-readable JSON output.
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Write findings to a report file: `.sarif` or `.xml` (checkstyle).
#[arg(long)]
report: Option<PathBuf>,
}, },
/// Auto-fix small import problems (same engine as `mv`). /// Auto-fix small import problems (same engine as `mv`).
Fix { Fix {
@ -72,6 +76,9 @@ pub enum Command {
/// Machine-readable JSON output (for AI agents). /// Machine-readable JSON output (for AI agents).
#[arg(long)] #[arg(long)]
json: bool, json: bool,
/// Write the candidate list to a report file: `.sarif` or `.xml`.
#[arg(long)]
report: Option<PathBuf>,
}, },
} }
@ -109,17 +116,24 @@ pub fn run() -> anyhow::Result<i32> {
json, json,
apply::GitMode::from_no_git(no_git), 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 { Command::Fix {
rule, rule,
dry_run, dry_run,
json, json,
report,
} => fix::fix( } => fix::fix(
&args.root, &args.root,
args.source_root.as_deref(), args.source_root.as_deref(),
rule.as_deref(), rule.as_deref(),
dry_run, dry_run,
json, json,
report.as_deref(),
), ),
}; };
// Handlers report their own failures; both arms carry an exit code. // Handlers report their own failures; both arms carry an exit code.
@ -189,38 +203,6 @@ fn mv_dry_run(
Ok(exit::OK) 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<i32> {
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. /// Unwrap a core result, routing failures through the CLI error channel.
fn flow<T>(json: bool, operation: &'static str, result: JmoveResult<T>) -> Flow<T> { fn flow<T>(json: bool, operation: &'static str, result: JmoveResult<T>) -> Flow<T> {
result.map_err(|err| fail(json, operation, ErrorData::from_core(&err))) result.map_err(|err| fail(json, operation, ErrorData::from_core(&err)))

View file

@ -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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<checkstyle version=\"10.0\">\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, " <file name=\"{}\">", escape(file));
for v in entries {
let _ = writeln!(
out,
" <error line=\"{}\" severity=\"{}\" message=\"{}\" source=\"{}\"/>",
v.line,
v.severity,
escape(&v.message),
source(v.rule)
);
}
out.push_str(" </file>\n");
}
out.push_str("</checkstyle>\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("&amp;"),
'<' => out.push_str("&lt;"),
'>' => out.push_str("&gt;"),
'"' => out.push_str("&quot;"),
_ => 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 <this> & 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("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<checkstyle"),
"{xml}"
);
assert_eq!(xml.matches("<file ").count(), 2);
assert_eq!(xml.matches("<error ").count(), 3);
assert!(
xml.contains("<error line=\"4\" severity=\"warning\""),
"{xml}"
);
assert!(xml.contains("source=\"jmove.java.unused-import\""), "{xml}");
assert!(xml.contains("source=\"jmove.broken-import\""), "{xml}");
}
#[test]
fn attributes_are_escaped() {
let xml = build(&[violation("broken-import", "error", "a&b/c.ts", 1)]);
assert!(
xml.contains("message=\"he said &quot;fix &lt;this&gt; &amp; now&quot;\""),
"{xml}"
);
assert!(xml.contains("<file name=\"a&amp;b/c.ts\">"), "{xml}");
}
#[test]
fn clean_run_is_a_valid_empty_document() {
let xml = build(&[]);
assert_eq!(
xml,
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<checkstyle version=\"10.0\">\n</checkstyle>\n"
);
}
}

178
src/cli/report/mod.rs Normal file
View file

@ -0,0 +1,178 @@
//! Machine-readable report files, and the `check` command that produces
//! their primary findings.
//!
//! `--report <FILE>` (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<Option<Self>> {
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<Self>, 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<Violation> {
let mut out: Vec<Violation> = 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<Violation> {
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<i32> {
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)
}

247
src/cli/report/sarif.rs Normal file
View file

@ -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<Rule> {
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<Run>,
}
#[derive(Serialize)]
struct Tool {
driver: Driver,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct Driver {
name: &'static str,
version: &'static str,
rules: Vec<Rule>,
}
#[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<SarifResult>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SarifResult {
rule_id: &'static str,
level: &'static str,
message: Text,
locations: Vec<Location>,
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"));
}
}

122
tests/cli_report.rs Normal file
View file

@ -0,0 +1,122 @@
//! `--report <FILE>` 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 version=\"1.0\""), "{xml}");
assert!(xml.contains("<file name=\"src/broken.ts\">"), "{xml}");
assert!(
xml.contains(
"<error line=\"4\" severity=\"error\" message=\"cannot resolve &#39;./gone&#39;\""
) || xml.contains("message=\"cannot resolve './gone'\""),
"{xml}"
);
assert!(xml.contains("source=\"jmove.broken-import\""), "{xml}");
}
#[test]
fn clean_projects_still_get_valid_empty_reports() {
let tmp = copy_fixture("typescript", "basic");
let sarif_name = report_path(&tmp, "empty.sarif");
let xml_name = report_path(&tmp, "empty.xml");
jmove(&tmp, &["check", "--report", &sarif_name]).success();
jmove(&tmp, &["check", "--report", &xml_name]).success();
let doc = sarif(&tmp, "empty.sarif");
assert_eq!(doc["version"], "2.1.0");
assert!(doc["runs"][0]["results"].as_array().unwrap().is_empty());
assert!(
doc["runs"][0]["tool"]["driver"]["rules"]
.as_array()
.unwrap()
.is_empty()
);
let xml = read(&in_root(tmp.path(), "empty.xml"));
assert!(!xml.contains("<file"), "{xml}");
assert!(xml.ends_with("</checkstyle>\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());
}

View file

@ -66,9 +66,9 @@ AI оставляем СНАРУЖИ: при неоднозначности jmov
- [ ] Форматирование: свой cargo-fmt НЕ строим (вечный long-tail). Только «import formatting» - [ ] Форматирование: свой cargo-fmt НЕ строим (вечный long-tail). Только «import formatting»
(порядок/группировка — у нас уже есть spans). Опционально `--format-after <cmd>` (prettier / (порядок/группировка — у нас уже есть spans). Опционально `--format-after <cmd>` (prettier /
google-java-format), не зависимость google-java-format), не зависимость
- [ ] Интероп PMD/Checkstyle/eslint (фаза 2.5): `jmove fix --report checkstyle.xml` маппит - [x] Интероп Checkstyle/eslint (фаза 2.5): `check --report f.sarif|.xml` и `fix --report f.sarif|.xml` —
violation(file,line,rule) на паттерны; на выход SARIF для CI/IDE. SARIF 2.1.0 (GitHub/CodeQL, autoFixable) и Checkstyle XML (source=jmove.<rule>); формат по расширению,
Маркетинг: «auto-fix for what Checkstyle only reports» чистый прогон = валидный пустой файл, stdout/exit не меняются. Маркетинг: «auto-fix for what Checkstyle only reports»
## Guava real-world smoke test (google/guava @ main, JDK21, mvnw) — ПРОВЕРЕНО ## Guava real-world smoke test (google/guava @ main, JDK21, mvnw) — ПРОВЕРЕНО
- [x] mv Primitives primitives→util: 5 правок (4 imports + package), `mvn -pl guava compile` - [x] mv Primitives primitives→util: 5 правок (4 imports + package), `mvn -pl guava compile`