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
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:
parent
47395069cb
commit
ad1ad4f999
10 changed files with 726 additions and 56 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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)))
|
||||||
|
|
|
||||||
115
src/cli/report/checkstyle.rs
Normal file
115
src/cli/report/checkstyle.rs
Normal 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("&"),
|
||||||
|
'<' => 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 <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 "fix <this> & now"\""),
|
||||||
|
"{xml}"
|
||||||
|
);
|
||||||
|
assert!(xml.contains("<file name=\"a&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
178
src/cli/report/mod.rs
Normal 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
247
src/cli/report/sarif.rs
Normal 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
122
tests/cli_report.rs
Normal 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 './gone'\""
|
||||||
|
) || 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());
|
||||||
|
}
|
||||||
6
todo.md
6
todo.md
|
|
@ -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`
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue