feat(xatu): add JSON debug output for parsed files #92

Closed
alexion wants to merge 2 commits from feat/xatu-debug into main
12 changed files with 351 additions and 40 deletions

View File

@@ -120,3 +120,9 @@
Do not use `git subtree add` or `git subtree pull` for Pi PR branches. Do not use `git subtree add` or `git subtree pull` for Pi PR branches.
Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix. Gitea rebases the synthetic upstream root commit without the `third_party/pi` prefix.
Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits. Preserve `git-subtree-dir` and `git-subtree-split` trailers on Pi vendor commits.
- Xatu file discovery skips symlinks, including Bazel runfiles.
Copy Nix fixtures into `TEST_TMPDIR` before passing their paths to CLI tests.
- The vendored `rustversion-1.0.23/build/` files are currently ignored and untracked.
Fresh worktrees cannot build Xatu tests until those upstream files are restored.
- `status::ResultExt` requires errors that implement `Into<Status>`, not arbitrary `std::error::Error` values.
Map other errors explicitly with a status code and `with_source`.

2
Cargo.lock generated
View File

@@ -402,6 +402,8 @@ dependencies = [
"monorepo", "monorepo",
"rnix", "rnix",
"rowan", "rowan",
"serde",
"serde_json",
"status", "status",
] ]

View File

@@ -16,6 +16,7 @@ rust_library(
deps = [ deps = [
"//src/status:status", "//src/status:status",
"//third_party/crates:rnix", "//third_party/crates:rnix",
"//third_party/crates:serde",
"//third_party/crates/rowan-0.16.1:rowan", "//third_party/crates/rowan-0.16.1:rowan",
], ],
) )
@@ -41,6 +42,23 @@ rust_test(
], ],
) )
rust_test(
name = "debug_test",
srcs = ["debug_test.rs"],
data = [
":xatu",
"testdata/clean.json",
"testdata/clean.nix",
"testdata/invalid.nix",
"testdata/lint_findings.json",
"testdata/lint_findings.nix",
],
deps = [
"//third_party/crates:googletest",
"//third_party/crates:serde_json",
],
)
rust_binary( rust_binary(
name = "xatu", name = "xatu",
crate_name = "xatu_cli", crate_name = "xatu_cli",
@@ -50,5 +68,6 @@ rust_binary(
"//src/monorepo:monorepo", "//src/monorepo:monorepo",
"//src/status:status", "//src/status:status",
"//third_party/crates:clap", "//third_party/crates:clap",
"//third_party/crates:serde_json",
], ],
) )

View File

@@ -16,6 +16,8 @@ monorepo = { workspace = true }
rnix = { workspace = true } rnix = { workspace = true }
rowan = { workspace = true } rowan = { workspace = true }
status = { workspace = true } status = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
[dev-dependencies] [dev-dependencies]
googletest = { workspace = true } googletest = { workspace = true }

40
src/xatu/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Xatu debug output
Run `bazel run //src/xatu:xatu -- --debug src/dotfiles/registry.nix` to inspect parser output.
The command writes JSON containing file paths and complete `ParsedFile` values:
- File IDs.
- Lowered rule nodes and tokens.
- Parse errors.
It skips lint rules and leaves the normal analysis report unchanged.
It sorts files by path and removes duplicate paths before assigning file IDs.
Parse errors remain in JSON and produce exit code 2.
Read errors go to stderr and produce exit code 2 without JSON output.
Successful parsing produces exit code 0, including files with potential lint findings.
The debug format is an inspection interface, not a stable interchange schema.
## Tests
Run `bazel test //src/xatu:all`.
Existing black-box tests check lint findings.
`debug_test.rs` invokes the CLI and compares parsed JSON against expected fixture output.
The test also checks parse errors, read errors, and deterministic file ordering.
It copies Nix fixtures into `TEST_TMPDIR` because discovery skips symlinks, including Bazel runfiles.
Regenerate expected JSON after intentional parser or fixture changes:
```bash
bazel build //src/xatu:xatu
binary="$(pwd)/bazel-bin/src/xatu/xatu"
(
cd src/xatu
for fixture in clean lint_findings; do
"$binary" --debug "testdata/$fixture.nix" > "testdata/$fixture.json"
done
)
```
Review regenerated output before accepting it.
Expected JSON files are generated test artifacts; do not edit them manually.

View File

@@ -1,6 +1,8 @@
use serde::Serialize;
use crate::FileId; use crate::FileId;
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct ParsedFile { pub struct ParsedFile {
pub file_id: FileId, pub file_id: FileId,
pub nodes: Vec<Node>, pub nodes: Vec<Node>,
@@ -8,13 +10,13 @@ pub struct ParsedFile {
pub errors: Vec<ParseError>, pub errors: Vec<ParseError>,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Node { pub struct Node {
pub range: SourceRange, pub range: SourceRange,
pub data: NodeData, pub data: NodeData,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct SourceRange { pub struct SourceRange {
pub start: usize, pub start: usize,
pub end: usize, pub end: usize,
@@ -26,46 +28,46 @@ impl std::fmt::Display for SourceRange {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum NodeData { pub enum NodeData {
Binding(Binding), Binding(Binding),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Binding { pub struct Binding {
pub key: Ident, pub key: Ident,
pub value: Expr, pub value: Expr,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Ident { pub struct Ident {
pub name: String, pub name: String,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum Expr { pub enum Expr {
Ident(Ident), Ident(Ident),
Select(Select), Select(Select),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Select { pub struct Select {
pub receiver: String, pub receiver: String,
pub attr: Ident, pub attr: Ident,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Token { pub struct Token {
pub range: SourceRange, pub range: SourceRange,
pub data: TokenData, pub data: TokenData,
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub enum TokenData { pub enum TokenData {
UnquotedUri(String), UnquotedUri(String),
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct ParseError { pub struct ParseError {
pub range: SourceRange, pub range: SourceRange,
pub message: String, pub message: String,

89
src/xatu/debug_test.rs Normal file
View File

@@ -0,0 +1,89 @@
use std::path::PathBuf;
use std::process::{Command, Output};
use googletest::matchers::eq;
use googletest::{OrFail, expect_that, gtest};
use serde_json::Value;
#[gtest]
fn debug_matches_fixture_representations() -> googletest::Result<()> {
for name in ["clean", "lint_findings"] {
let output = run_debug(&[&format!("testdata/{name}.nix")])?;
expect_that!(output.status.code(), eq(Some(0)));
expect_that!(output.stderr.is_empty(), eq(true));
let actual: Value = serde_json::from_slice(&output.stdout).or_fail()?;
let expected: Value = serde_json::from_str(
&std::fs::read_to_string(fixture_dir()?.join(format!("testdata/{name}.json")))
.or_fail()?,
)
.or_fail()?;
expect_that!(actual, eq(&expected));
}
Ok(())
}
#[gtest]
fn debug_retains_parse_errors() -> googletest::Result<()> {
let output = run_debug(&["testdata/invalid.nix"])?;
expect_that!(output.status.code(), eq(Some(2)));
expect_that!(output.stderr.is_empty(), eq(true));
let actual: Value = serde_json::from_slice(&output.stdout).or_fail()?;
expect_that!(
actual["files"][0]["parsed"]["errors"]
.as_array()
.or_fail()?
.is_empty(),
eq(false)
);
Ok(())
}
#[gtest]
fn debug_orders_and_deduplicates_files() -> googletest::Result<()> {
let first = run_debug(&[
"testdata/lint_findings.nix",
"testdata/clean.nix",
"testdata/clean.nix",
])?;
let second = run_debug(&["testdata/clean.nix", "testdata/lint_findings.nix"])?;
expect_that!(first.status.code(), eq(Some(0)));
expect_that!(second.status.code(), eq(Some(0)));
expect_that!(first.stdout, eq(&second.stdout));
Ok(())
}
#[gtest]
fn debug_reports_io_errors_on_stderr() -> googletest::Result<()> {
let output = run_debug(&["testdata/missing.nix"])?;
expect_that!(output.status.code(), eq(Some(2)));
expect_that!(output.stdout.is_empty(), eq(true));
expect_that!(output.stderr.is_empty(), eq(false));
Ok(())
}
fn run_debug(paths: &[&str]) -> googletest::Result<Output> {
let directory = fixture_dir()?;
static NEXT_ID: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
let id = NEXT_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let working = PathBuf::from(std::env::var("TEST_TMPDIR").or_fail()?).join(id.to_string());
std::fs::create_dir_all(working.join("testdata")).or_fail()?;
// Discovery skips symlinks, including Bazel's fixture runfiles.
for name in ["clean", "lint_findings", "invalid"] {
let path = format!("testdata/{name}.nix");
std::fs::copy(directory.join(&path), working.join(&path)).or_fail()?;
}
let output = Command::new(directory.join("xatu"))
.current_dir(&working)
.env_remove("BUILD_WORKSPACE_DIRECTORY")
.arg("--debug")
.args(paths)
.output();
std::fs::remove_dir_all(working).or_fail()?;
output.or_fail()
}
fn fixture_dir() -> googletest::Result<PathBuf> {
Ok(PathBuf::from(std::env::var("TEST_SRCDIR").or_fail()?)
.join(std::env::var("TEST_WORKSPACE").or_fail()?)
.join("src/xatu"))
}

View File

@@ -29,5 +29,5 @@ pub struct SourceFile {
pub source: String, pub source: String,
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize)]
pub struct FileId(usize); pub struct FileId(usize);

32
src/xatu/testdata/clean.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
"files": [
{
"path": "testdata/clean.nix",
"parsed": {
"file_id": 0,
"nodes": [
{
"range": {
"start": 85,
"end": 103
},
"data": {
"Binding": {
"key": {
"name": "different"
},
"value": {
"Ident": {
"name": "other"
}
}
}
}
}
],
"tokens": [],
"errors": []
}
}
]
}

63
src/xatu/testdata/lint_findings.json vendored Normal file
View File

@@ -0,0 +1,63 @@
{
"files": [
{
"path": "testdata/lint_findings.nix",
"parsed": {
"file_id": 0,
"nodes": [
{
"range": {
"start": 4,
"end": 22
},
"data": {
"Binding": {
"key": {
"name": "package"
},
"value": {
"Ident": {
"name": "package"
}
}
}
}
},
{
"range": {
"start": 25,
"end": 42
},
"data": {
"Binding": {
"key": {
"name": "from"
},
"value": {
"Select": {
"receiver": "pkgs",
"attr": {
"name": "from"
}
}
}
}
}
}
],
"tokens": [
{
"range": {
"start": 51,
"end": 70
},
"data": {
"UnquotedUri": "https://example.com"
}
}
],
"errors": []
}
}
]
}

View File

@@ -29,6 +29,43 @@ impl std::fmt::Display for Report {
} }
} }
/// Contains parser output without lint diagnostics.
#[derive(Debug, serde::Serialize)]
pub struct DebugReport {
files: Vec<DebugFile>,
}
impl DebugReport {
pub fn has_parse_errors(&self) -> bool {
self.files.iter().any(|file| !file.parsed.errors.is_empty())
}
}
#[derive(Debug, serde::Serialize)]
struct DebugFile {
path: std::path::PathBuf,
parsed: ast::ParsedFile,
}
/// Collects parser output without running lint rules.
pub fn debug(mut files: Vec<SourceFile>) -> DebugReport {
files.sort_by(|left, right| left.path.cmp(&right.path));
files.dedup_by(|left, right| left.path == right.path);
let mut store = FileStore::default();
let files = files
.into_iter()
.map(|file| {
let file_id = store.add(file);
let file = store.get(file_id);
DebugFile {
path: file.path.clone(),
parsed: parser::parse_file(file_id, &file.source),
}
})
.collect();
DebugReport { files }
}
/// Analyzes source files and returns the diagnostics Xatu finds. /// Analyzes source files and returns the diagnostics Xatu finds.
pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> { pub fn future_sight(files: Vec<SourceFile>) -> status::Result<Report> {
let mut store = FileStore::default(); let mut store = FileStore::default();

View File

@@ -2,20 +2,11 @@ use clap::Parser;
use status::ResultExt; use status::ResultExt;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::ExitCode; use std::process::ExitCode;
use xatu::{Report, SourceFile}; use xatu::SourceFile;
fn main() -> ExitCode { fn main() -> ExitCode {
let args = Args::parse(); match run(Args::parse()) {
Ok(exit_code) => exit_code,
match analyze(args) {
Ok(report) if report.is_clean() => {
println!("No findings.");
ExitCode::SUCCESS
}
Ok(report) => {
print!("{report}");
ExitCode::from(1)
}
Err(error) => { Err(error) => {
eprintln!("{error}"); eprintln!("{error}");
ExitCode::from(2) ExitCode::from(2)
@@ -23,28 +14,56 @@ fn main() -> ExitCode {
} }
} }
fn run(args: Args) -> status::Result<ExitCode> {
let files = collect_files(args.paths)?;
if args.debug {
dump_parsed_files(files)
} else {
check(files)
}
}
fn dump_parsed_files(files: Vec<SourceFile>) -> status::Result<ExitCode> {
let report = xatu::debug(files);
let json = serde_json::to_string_pretty(&report).map_err(|error| {
status::Status::internal("failed to serialize parser output").with_source(error)
})?;
println!("{json}");
Ok(if report.has_parse_errors() {
ExitCode::from(2)
} else {
ExitCode::SUCCESS
})
}
fn check(files: Vec<SourceFile>) -> status::Result<ExitCode> {
let report = xatu::future_sight(files)?;
if report.is_clean() {
println!("No findings.");
Ok(ExitCode::SUCCESS)
} else {
print!("{report}");
Ok(ExitCode::from(1))
}
}
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
struct Args { struct Args {
/// Dumps parser representations as JSON instead of running lint rules.
#[arg(long)]
debug: bool,
#[arg(value_name = "PATH", default_value = ".")] #[arg(value_name = "PATH", default_value = ".")]
paths: Vec<PathBuf>, paths: Vec<PathBuf>,
} }
fn analyze(args: Args) -> status::Result<Report> { fn collect_files(paths: Vec<PathBuf>) -> status::Result<Vec<SourceFile>> {
let paths: Vec<PathBuf> = args let mut files = Vec::new();
.paths for path in paths {
.into_iter() let path = monorepo::workspace::resolve_workspace_path(path);
.map(monorepo::workspace::resolve_workspace_path) files.extend(collect_source_files(&path)?);
.collect(); }
Ok(files)
let files: Vec<SourceFile> = paths
.iter()
.map(|path| collect_source_files(path))
.collect::<status::Result<Vec<_>>>()?
.into_iter()
.flatten()
.collect();
xatu::future_sight(files)
} }
fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> { fn collect_source_files(path: &Path) -> status::Result<Vec<SourceFile>> {