use std::io::Write;
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::config::{BarePathOverride, Override, StaleReferenceOverride};
use crate::fm::{ExceptionLint, Exceptions};
use crate::structural::{self, SeverityCounts};
use crate::validation::{self, Diagnostic, Severity};
use crate::workspace::Workspace;
const MOVE_TEST_NOTE: &str = "note: a path is a link if moving the target would force you to update the mention; otherwise it's an example — write it as a link, or exempt it (see `lattice help config`).";
pub fn run(start: &Path, strict: bool, quiet: bool, out: &mut impl Write) -> Result<bool> {
let workspace = Workspace::scan(start).context("failed to scan workspace")?;
let scope = scope_relative_to_root(start, workspace.root());
let mut failed = false;
let mut diagnostics = Vec::new();
for (path, file_data) in workspace.files() {
diagnostics.extend(file_data.structural.iter().cloned());
for pd in &file_data.parse_diagnostics {
let severity = match pd.severity {
crate::fm::FmSeverity::Error => Severity::Error,
crate::fm::FmSeverity::Warning => Severity::Warning,
};
diagnostics.push(Diagnostic {
file: path.clone(),
line: pd.line,
severity,
message: format!("frontmatter: {}", pd.message),
span: None,
});
}
}
if workspace.has_config() {
if let Some(config_err) = workspace.config_error() {
let _ = writeln!(out, ".lattice.toml: error: {config_err}");
failed = true;
}
diagnostics.extend(validation::collect_all(&workspace));
} else {
writeln!(
out,
"note: no .lattice.toml found, graph validation disabled"
)?;
}
let override_outcome = apply_subtree_overrides(&workspace, &mut diagnostics);
diagnostics.sort_by(|a, b| a.file.cmp(&b.file).then(a.line.cmp(&b.line)));
let has_028_family = diagnostics.iter().any(|diag| {
in_scope(&diag.file, scope.as_deref())
&& structural::classify_028_lint(&diag.message).is_some()
});
if has_028_family {
writeln!(out, "{MOVE_TEST_NOTE}")?;
}
for diag in &diagnostics {
if !in_scope(&diag.file, scope.as_deref()) {
continue;
}
let gates =
diag.severity == Severity::Error || (strict && diag.severity == Severity::Warning);
if gates {
failed = true;
}
writeln!(out, "{}", format_diagnostic(diag))?;
}
for message in &override_outcome.messages {
if strict {
failed = true;
}
writeln!(out, ".lattice.toml: warning: {message}")?;
}
if !quiet {
let mut rows = collect_ledger_rows(&workspace, scope.as_deref());
rows.extend(override_outcome.rows);
write_ledger(&rows, out)?;
}
Ok(failed)
}
fn scope_relative_to_root(start: &Path, root: &Path) -> Option<PathBuf> {
let abs_start = std::fs::canonicalize(start).ok()?;
let abs_root = std::fs::canonicalize(root).ok()?;
let rel = abs_start.strip_prefix(&abs_root).ok()?;
if rel.as_os_str().is_empty() {
None
} else {
Some(rel.to_path_buf())
}
}
fn in_scope(file: &Path, scope: Option<&Path>) -> bool {
scope.is_none_or(|scope| file.starts_with(scope))
}
fn format_diagnostic(diag: &Diagnostic) -> String {
let severity = match diag.severity {
Severity::Error => "error",
Severity::Warning => "warning",
Severity::Info => "info",
Severity::Hint => "hint",
};
format!(
"{}:{}: {}: {}",
diag.file.display(),
diag.line,
severity,
diag.message
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LedgerSource {
Exceptions,
CountKey,
Override,
Artifact,
}
impl LedgerSource {
const fn header_name(self) -> &'static str {
match self {
Self::Exceptions => "exceptions",
Self::CountKey => "count-key",
Self::Override => "overrides",
Self::Artifact => "artifacts",
}
}
}
#[derive(Debug, Clone)]
struct LedgerRow {
source: LedgerSource,
label: String,
counts: SeverityCounts,
detail: String,
}
fn collect_ledger_rows(workspace: &Workspace, scope: Option<&Path>) -> Vec<LedgerRow> {
let mut rows = Vec::new();
let mut artifact_totals: std::collections::BTreeMap<String, SeverityCounts> =
std::collections::BTreeMap::new();
for (path, file_data) in workspace.files() {
if !in_scope(path, scope) {
continue;
}
let sup = &file_data.suppressions;
if sup.is_empty() {
continue;
}
if let Some(ex) = &sup.exceptions {
rows.push(LedgerRow {
source: LedgerSource::Exceptions,
label: path.display().to_string(),
counts: ex.counts,
detail: format!("exceptions ({})", ex.matched_entries),
});
}
for ck in &sup.count_keys {
rows.push(LedgerRow {
source: LedgerSource::CountKey,
label: ck.reason.clone(),
counts: ck.counts,
detail: format!("count-key ({})", ck.raw),
});
}
for (name, counts) in &sup.artifacts {
artifact_totals
.entry(name.clone())
.or_default()
.add(*counts);
}
}
for (name, counts) in artifact_totals {
rows.push(LedgerRow {
source: LedgerSource::Artifact,
label: name,
counts,
detail: "artifact".to_string(),
});
}
rows
}
struct OverrideOutcome {
rows: Vec<LedgerRow>,
messages: Vec<String>,
}
fn apply_subtree_overrides(
workspace: &Workspace,
diagnostics: &mut Vec<Diagnostic>,
) -> OverrideOutcome {
let overrides = &workspace.config().overrides;
let mut outcome = OverrideOutcome {
rows: Vec::new(),
messages: Vec::new(),
};
if overrides.is_empty() {
return outcome;
}
for ov in overrides {
let matches_any = workspace.files().keys().any(|p| ov.matches(p));
if !matches_any {
outcome.messages.push(format!(
"unused override: `{}` matches no files — remove it, or restore the path if the tree moved (see `lattice help config`){}",
ov.label(),
ov.hint_suffix()
));
}
}
for lint in [ExceptionLint::StaleReferences, ExceptionLint::BarePaths] {
apply_override_lint(workspace, overrides, lint, diagnostics, &mut outcome);
}
outcome
}
fn apply_override_lint(
workspace: &Workspace,
overrides: &[Override],
lint: ExceptionLint,
diagnostics: &mut Vec<Diagnostic>,
outcome: &mut OverrideOutcome,
) {
let mut expect_members: Vec<Vec<PathBuf>> = overrides.iter().map(|_| Vec::new()).collect();
let mut freeze_members: Vec<Vec<PathBuf>> = overrides.iter().map(|_| Vec::new()).collect();
for path in workspace.files().keys() {
match resolve_last_match(overrides, lint, path) {
Some((idx, OverrideResolution::Expect)) => expect_members[idx].push(path.clone()),
Some((idx, OverrideResolution::Freeze)) => freeze_members[idx].push(path.clone()),
Some((_, OverrideResolution::OtherLevel)) | None => {}
}
}
for (idx, members) in freeze_members.iter().enumerate() {
if members.is_empty() {
continue;
}
let mut counts = SeverityCounts::default();
for path in members {
for diag in base_level_lint_diagnostics(workspace, path, lint) {
counts.record(diag.severity);
}
}
if !counts.is_empty() {
outcome.rows.push(LedgerRow {
source: LedgerSource::Override,
label: overrides[idx].label(),
counts,
detail: format!("override (freeze){}", overrides[idx].hint_suffix()),
});
}
}
for (idx, members) in expect_members.iter().enumerate() {
if members.is_empty() {
continue;
}
let Some(expect) = expect_count(&overrides[idx], lint) else {
continue;
};
let member_set: std::collections::HashSet<&Path> =
members.iter().map(PathBuf::as_path).collect();
let is_member_lint = |d: &Diagnostic| {
member_set.contains(d.file.as_path())
&& structural::classify_028_lint(&d.message) == Some(lint)
};
let found = diagnostics.iter().filter(|d| is_member_lint(d)).count();
if found == expect {
let mut counts = SeverityCounts::default();
diagnostics.retain(|d| {
if is_member_lint(d) {
counts.record(d.severity);
false
} else {
true
}
});
outcome.rows.push(LedgerRow {
source: LedgerSource::Override,
label: overrides[idx].label(),
counts,
detail: format!("override (expect={expect}){}", overrides[idx].hint_suffix()),
});
} else {
outcome.messages.push(format!(
"override `{}` expects {expect} {} but found {found} — update the count or fix the drift (see `lattice help config`){}",
overrides[idx].label(),
lint.noun(),
overrides[idx].hint_suffix()
));
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OverrideResolution {
Expect,
Freeze,
OtherLevel,
}
fn resolve_last_match(
overrides: &[Override],
lint: ExceptionLint,
path: &Path,
) -> Option<(usize, OverrideResolution)> {
let mut winner = None;
for (idx, ov) in overrides.iter().enumerate() {
if !ov.matches(path) {
continue;
}
let resolution = match lint {
ExceptionLint::StaleReferences => ov.stale_references.map(|m| match m {
StaleReferenceOverride::Expect(_) => OverrideResolution::Expect,
StaleReferenceOverride::Level(crate::config::StaleReferencePolicy::Disabled) => {
OverrideResolution::Freeze
}
StaleReferenceOverride::Level(_) => OverrideResolution::OtherLevel,
}),
ExceptionLint::BarePaths => ov.bare_paths.map(|m| match m {
BarePathOverride::Expect(_) => OverrideResolution::Expect,
BarePathOverride::Level(crate::config::BarePathPolicy::Disabled) => {
OverrideResolution::Freeze
}
BarePathOverride::Level(_) => OverrideResolution::OtherLevel,
}),
};
if let Some(resolution) = resolution {
winner = Some((idx, resolution));
}
}
winner
}
fn expect_count(ov: &Override, lint: ExceptionLint) -> Option<usize> {
match lint {
ExceptionLint::StaleReferences => match ov.stale_references {
Some(StaleReferenceOverride::Expect(n)) => Some(n),
_ => None,
},
ExceptionLint::BarePaths => match ov.bare_paths {
Some(BarePathOverride::Expect(n)) => Some(n),
_ => None,
},
}
}
fn base_level_lint_diagnostics(
workspace: &Workspace,
rel_path: &Path,
lint: ExceptionLint,
) -> Vec<Diagnostic> {
let Some(file_data) = workspace.file(rel_path) else {
return Vec::new();
};
let file_exists = |target: &Path| workspace.file(target).is_some();
let external_exists = |path: &Path| path.exists();
let empty_exceptions = Exceptions::default();
let exceptions = file_data
.frontmatter
.as_ref()
.map_or(&empty_exceptions, |fm| &fm.exceptions);
let (diagnostics, _) = structural::collect_with_suppressions(
&file_data.tree,
rel_path,
workspace.config(),
&file_exists,
&external_exists,
exceptions,
);
diagnostics
.into_iter()
.filter(|d| structural::classify_028_lint(&d.message) == Some(lint))
.collect()
}
fn format_counts(counts: &SeverityCounts) -> String {
let mut parts = Vec::new();
for (n, singular) in [
(counts.errors, "error"),
(counts.warnings, "warning"),
(counts.info, "info"),
(counts.hints, "hint"),
] {
if n == 0 {
continue;
}
if n == 1 || singular == "info" {
parts.push(format!("{n} {singular}"));
} else {
parts.push(format!("{n} {singular}s"));
}
}
parts.join(", ")
}
fn write_ledger(rows: &[LedgerRow], out: &mut impl Write) -> Result<()> {
if rows.is_empty() {
return Ok(());
}
let mut totals = SeverityCounts::default();
for row in rows {
totals.add(row.counts);
}
let mut source_counts = Vec::new();
for source in [
LedgerSource::Override,
LedgerSource::CountKey,
LedgerSource::Exceptions,
LedgerSource::Artifact,
] {
let n = rows.iter().filter(|r| r.source == source).count();
if n > 0 {
source_counts.push(format!("{n} {}", source.header_name()));
}
}
writeln!(
out,
"suppressed: {} ({})",
format_counts(&totals),
source_counts.join(", ")
)?;
let label_width = rows.iter().map(|r| r.label.len()).max().unwrap_or(0);
let counts_width = rows
.iter()
.map(|r| format_counts(&r.counts).len())
.max()
.unwrap_or(0);
for row in rows {
let counts = format_counts(&row.counts);
writeln!(
out,
" {label:<label_width$} {counts:<counts_width$} {detail}",
label = row.label,
detail = row.detail,
)?;
}
Ok(())
}
#[cfg(test)]
#[allow(
clippy::expect_used,
clippy::panic,
reason = "tests use expect and panic for clarity"
)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use tempfile::TempDir;
use super::*;
static CWD_LOCK: Mutex<()> = Mutex::new(());
fn setup(files: &[(&str, &str)]) -> TempDir {
let dir = TempDir::new().expect("create temp dir");
fs::create_dir(dir.path().join(".git")).expect("create .git");
for (path, content) in files {
let full = dir.path().join(path);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).expect("create parent dirs");
}
fs::write(&full, content).expect("write file");
}
dir
}
fn run_lint(dir: &TempDir) -> (bool, String) {
run_lint_with(dir, false)
}
fn run_lint_with(dir: &TempDir, strict: bool) -> (bool, String) {
let mut buf = Vec::new();
let failed = run(dir.path(), strict, true, &mut buf).expect("run should succeed");
let output = String::from_utf8(buf).expect("output should be utf-8");
(failed, output)
}
fn run_lint_with_ledger(dir: &TempDir) -> (bool, String) {
let mut buf = Vec::new();
let failed = run(dir.path(), false, false, &mut buf).expect("run should succeed");
let output = String::from_utf8(buf).expect("output should be utf-8");
(failed, output)
}
#[test]
fn format_error_diagnostic() {
let diag = Diagnostic {
file: PathBuf::from("docs/foo.md"),
line: 5,
severity: Severity::Error,
message: "target does not exist".to_string(),
span: None,
};
assert_eq!(
format_diagnostic(&diag),
"docs/foo.md:5: error: target does not exist",
"error diagnostic should format as path:line: error: message"
);
}
#[test]
fn format_warning_diagnostic() {
let diag = Diagnostic {
file: PathBuf::from("index.md"),
line: 1,
severity: Severity::Warning,
message: "expected backlink".to_string(),
span: None,
};
assert_eq!(
format_diagnostic(&diag),
"index.md:1: warning: expected backlink",
"warning diagnostic should format as path:line: warning: message"
);
}
#[test]
fn format_info_diagnostic() {
let diag = Diagnostic {
file: PathBuf::from("notes.md"),
line: 12,
severity: Severity::Info,
message: "no explicit predicate".to_string(),
span: None,
};
assert_eq!(
format_diagnostic(&diag),
"notes.md:12: info: no explicit predicate",
"info diagnostic should format as path:line: info: message"
);
}
#[test]
fn clean_workspace_no_output() {
let dir = setup(&[
(".lattice.toml", ""),
(
"index.md",
"---\nbacklinks:\n referenced_by:\n - other.md\n---\n\n[other](other.md \"references\")\n",
),
(
"other.md",
"---\nbacklinks:\n referenced_by:\n - index.md\n---\n\n[index](index.md \"references\")\n",
),
]);
let (has_errors, output) = run_lint(&dir);
assert!(!has_errors, "clean workspace should have no errors");
assert!(
output.is_empty(),
"clean workspace should produce no output"
);
}
#[test]
fn broken_link_reports_error() {
let dir = setup(&[
(".lattice.toml", ""),
("index.md", "[missing](gone.md \"references\")\n"),
]);
let (has_errors, output) = run_lint(&dir);
assert!(has_errors, "broken link should produce errors");
assert!(
output.contains("error:"),
"output should contain an error diagnostic: {output}"
);
assert!(
output.contains("index.md:1:"),
"output should reference the source file and line: {output}"
);
}
#[test]
fn unknown_predicate_reports_error() {
let dir = setup(&[
(".lattice.toml", ""),
("a.md", "[b](b.md \"invented\")\n"),
("b.md", "# B\n"),
]);
let (has_errors, output) = run_lint(&dir);
assert!(has_errors, "unknown predicate should produce errors");
assert!(
output.contains("error:"),
"output should contain an error diagnostic: {output}"
);
}
#[test]
fn warnings_only_exit_zero() {
let dir = setup(&[
(".lattice.toml", ""),
("a.md", "[b](b.md \"references\")\n"),
("b.md", "# B\n"),
]);
let (has_errors, output) = run_lint(&dir);
assert!(
!has_errors,
"warnings-only workspace should not have errors"
);
assert!(
output.contains("warning:"),
"output should contain a warning diagnostic: {output}"
);
assert!(
!output.contains("error:"),
"output should not contain error diagnostics: {output}"
);
}
#[test]
fn strict_gates_warnings() {
let dir = setup(&[
(".lattice.toml", ""),
("a.md", "[b](b.md \"references\")\n"),
("b.md", "# B\n"),
]);
let (failed, output) = run_lint_with(&dir, true);
assert!(
failed,
"strict mode should fail the exit code on warnings: {output}"
);
assert!(
output.contains("warning:"),
"the gated diagnostic should still print as a warning: {output}"
);
assert!(
!output.contains("error:"),
"strict gating must not relabel warnings as errors: {output}"
);
}
#[test]
fn invalid_config_reports_error() {
let dir = setup(&[
(".lattice.toml", "[policy]\npredicates = \"bogus\"\n"),
("index.md", "# Hello\n"),
]);
let (has_errors, output) = run_lint(&dir);
assert!(has_errors, "invalid config should produce errors");
assert!(
output.contains(".lattice.toml: error:"),
"output should reference the config file: {output}"
);
}
#[test]
fn unknown_backlink_predicate_reports_error() {
let dir = setup(&[
(".lattice.toml", ""),
(
"a.md",
"---\nbacklinks:\n invented_by:\n - b.md\n---\n\n# A\n",
),
]);
let (has_errors, output) = run_lint(&dir);
assert!(
has_errors,
"predicate known in neither direction should produce errors"
);
assert!(
output.contains("unknown backlink predicate"),
"output should mention the unknown predicate: {output}"
);
}
#[test]
fn forward_label_backlink_key_is_known() {
let dir = setup(&[
(".lattice.toml", ""),
(
"a.md",
"---\nbacklinks:\n supersedes:\n - b.md\n---\n\n# A\n",
),
("b.md", "[a](a.md \"superseded_by\")\n"),
]);
let (has_errors, output) = run_lint(&dir);
assert!(
!has_errors,
"forward-label backlink key should not error: {output}"
);
}
#[test]
fn no_config_prints_note() {
let dir = setup(&[("a.md", "[b](b.md \"references\")\n"), ("b.md", "# B\n")]);
let (has_errors, output) = run_lint(&dir);
assert!(!has_errors, "no config should not produce errors");
assert!(
output.contains("no .lattice.toml found"),
"output should note that graph validation is disabled: {output}"
);
}
#[test]
fn move_test_note_leads_when_an_028_family_diagnostic_fires() {
let dir = setup(&[("a.md", "See `gone.md` for details.\n")]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("stale reference"),
"the fixture must produce the 028-family diagnostic: {output}"
);
let note_pos = output
.find("note: a path is a link if moving the target")
.expect("the move-test note must be present");
let diag_pos = output
.find("a.md:")
.expect("the diagnostic line must be present");
assert!(
note_pos < diag_pos,
"the move-test note must print before the diagnostics: {output}"
);
}
#[test]
fn move_test_note_absent_when_only_non_028_diagnostics_fire() {
let dir = setup(&[("a.md", "## \n\nbody\n")]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("empty heading"),
"the fixture must produce the non-028 heading diagnostic: {output}"
);
assert!(
!output.contains("note: a path is a link if moving the target"),
"the move-test note must not print for a non-028-only run: {output}"
);
}
#[test]
fn move_test_note_survives_quiet() {
let dir = setup(&[("a.md", "See `gone.md` for details.\n")]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("note: a path is a link if moving the target"),
"--quiet must keep the move-test preamble: {output}"
);
}
fn run_lint_start(start: &Path) -> (bool, String) {
let mut buf = Vec::new();
let failed = run(start, false, true, &mut buf).expect("run should succeed");
let output = String::from_utf8(buf).expect("output should be utf-8");
(failed, output)
}
fn scoped_fixture() -> TempDir {
setup(&[
(".lattice.toml", ""),
("sub/dir/file.md", "[broken](nope.md \"references\")\n"),
("other/sibling.md", "[gone](missing.md \"references\")\n"),
])
}
#[test]
fn scoped_lint_reports_in_scope_error_under_every_path_form() {
let dir = scoped_fixture();
let forms: [PathBuf; 5] = [
PathBuf::from("sub"),
PathBuf::from("sub/"),
PathBuf::from("./sub/"),
PathBuf::from("sub/dir/file.md"),
dir.path().join("sub"),
];
for form in &forms {
let start = if form.is_absolute() {
form.clone()
} else {
dir.path().join(form)
};
let (failed, output) = run_lint_start(&start);
assert!(
failed,
"scope `{}` contains an error and must exit non-zero: {output}",
form.display()
);
assert!(
output.contains("error:"),
"scope `{}` must surface the in-scope error: {output}",
form.display()
);
assert!(
output.contains("file.md:1:"),
"scope `{}` must anchor the diagnostic on sub/dir/file.md: {output}",
form.display()
);
assert!(
!output.contains("sibling.md"),
"scope `{}` must not leak the out-of-scope sibling's diagnostic: {output}",
form.display()
);
}
}
#[test]
fn scoped_lint_bare_relative_from_cwd_reports_and_fails() {
let dir = setup(&[
(".lattice.toml", ""),
("sub/dir/file.md", "[broken](nope.md \"references\")\n"),
("other/sibling.md", "[gone](missing.md \"references\")\n"),
("clean/ok.md", "# All good\n"),
]);
let _guard = CWD_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let original = std::env::current_dir().expect("read original cwd");
std::env::set_current_dir(dir.path()).expect("chdir to fixture root");
let forms = ["sub", "sub/", "sub/dir/file.md"];
let mut results = Vec::new();
for form in forms {
results.push((form, run_lint_start(Path::new(form))));
}
let clean = run_lint_start(Path::new("clean"));
std::env::set_current_dir(&original).expect("restore original cwd");
for (form, (failed, output)) in results {
assert!(
failed,
"bare-relative scope `{form}` contains an error and must exit non-zero: {output}"
);
assert!(
output.contains("file.md:1:"),
"bare-relative scope `{form}` must surface the in-scope error: {output}"
);
assert!(
!output.contains("sibling.md"),
"bare-relative scope `{form}` must not leak the out-of-scope sibling: {output}"
);
}
let (clean_failed, clean_output) = clean;
assert!(
!clean_failed,
"a clean bare-relative scope must exit zero, not false-clean nor spurious-fail: {clean_output}"
);
assert!(
!clean_output.contains("sibling.md"),
"a clean bare-relative scope must not leak the out-of-scope sibling: {clean_output}"
);
}
#[test]
fn scoped_lint_single_file_form_isolates_the_file() {
let dir = setup(&[
(".lattice.toml", ""),
("sub/dir/file.md", "[broken](nope.md \"references\")\n"),
("sub/dir/neighbor.md", "[also](absent.md \"references\")\n"),
]);
let (failed, output) = run_lint_start(&dir.path().join("sub/dir/file.md"));
assert!(
failed,
"single-file scope with an error must exit non-zero: {output}"
);
assert!(
output.contains("file.md:1:"),
"single-file scope must report the targeted file: {output}"
);
assert!(
!output.contains("neighbor.md"),
"single-file scope must not report a sibling in the same dir: {output}"
);
}
#[test]
fn scoped_lint_clean_subtree_exits_zero() {
let dir = setup(&[
(".lattice.toml", ""),
("clean/ok.md", "# All good\n"),
("other/sibling.md", "[gone](missing.md \"references\")\n"),
]);
let (failed, output) = run_lint_start(&dir.path().join("clean"));
assert!(
!failed,
"a clean scope must exit zero regardless of out-of-scope errors: {output}"
);
assert!(
!output.contains("error:"),
"a clean scope must not emit error diagnostics: {output}"
);
assert!(
!output.contains("sibling.md"),
"a clean scope must not leak the out-of-scope error: {output}"
);
}
#[test]
fn whole_workspace_still_reports_every_error() {
let dir = scoped_fixture();
let (failed, output) = run_lint(&dir);
assert!(
failed,
"whole-workspace lint with errors must exit non-zero"
);
assert!(
output.contains("file.md:1:"),
"whole-workspace lint must report the sub-tree error: {output}"
);
assert!(
output.contains("sibling.md:1:"),
"whole-workspace lint must report the sibling error too: {output}"
);
}
#[test]
fn scoped_lint_in_scope_error_depends_on_out_of_scope_file() {
let dir = setup(&[
(".lattice.toml", ""),
("sub/source.md", "[t](../target.md \"references\")\n"),
("target.md", "# Target\n"),
]);
let (_failed, output) = run_lint_start(&dir.path().join("sub"));
assert!(
output.contains("source.md:1:"),
"the in-scope source's graph diagnostic (computed from the whole-workspace graph) must survive scoping: {output}"
);
assert!(
!output.contains("target.md:"),
"the out-of-scope target must not appear in a scoped lint: {output}"
);
}
#[test]
fn scope_relative_to_root_normalizes_path_forms() {
let dir = setup(&[("sub/dir/file.md", "# X\n")]);
let root = dir.path();
let bare = scope_relative_to_root(&root.join("sub"), root);
let trailing = scope_relative_to_root(&root.join("sub/"), root);
let dotted = scope_relative_to_root(&root.join("./sub/"), root);
assert_eq!(
bare, trailing,
"`sub` and `sub/` must normalize to the same scope"
);
assert_eq!(
bare, dotted,
"`sub` and `./sub/` must normalize to the same scope"
);
assert_eq!(
bare,
Some(PathBuf::from("sub")),
"the scope must be the workspace-relative sub-tree"
);
assert_eq!(
scope_relative_to_root(root, root),
None,
"the root itself must mean whole-workspace (no scope)"
);
assert_eq!(
scope_relative_to_root(&root.join("."), root),
None,
"`.` must mean whole-workspace (no scope)"
);
}
#[test]
fn in_scope_matches_directory_and_file_but_not_sibling_prefix() {
let scope = PathBuf::from("archive");
assert!(
in_scope(Path::new("archive/cli-design.md"), Some(&scope)),
"a file under the scoped directory is in scope"
);
assert!(
in_scope(Path::new("archive"), Some(&scope)),
"the scoped directory itself is in scope"
);
assert!(
!in_scope(Path::new("archived/x.md"), Some(&scope)),
"a sibling sharing a name prefix must not be in scope (component-wise match)"
);
assert!(
!in_scope(Path::new("other/x.md"), Some(&scope)),
"an unrelated file must not be in scope"
);
assert!(
in_scope(Path::new("anything/at/all.md"), None),
"a None scope means the whole workspace is in scope"
);
}
#[test]
fn format_counts_omits_zero_buckets_and_pluralizes() {
let one = SeverityCounts {
warnings: 1,
..SeverityCounts::default()
};
assert_eq!(
format_counts(&one),
"1 warning",
"a count of one is singular and only the warning bucket shows"
);
let many = SeverityCounts {
warnings: 84,
hints: 12,
..SeverityCounts::default()
};
assert_eq!(
format_counts(&many),
"84 warnings, 12 hints",
"multiple non-zero buckets are comma-joined and pluralized"
);
}
#[test]
fn ledger_reports_exception_counts_by_severity() {
let dir = setup(&[(
"intro.md",
"---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"a worked example path, not a live reference\"\n\
---\n\
See `gone.md` for details.\n",
)]);
let (_failed, output) = run_lint_with_ledger(&dir);
assert!(
output.contains("suppressed: 1 warning (1 exceptions)"),
"the ledger header tallies one suppressed warning from one exception source: {output}"
);
assert!(
output.contains("intro.md") && output.contains("exceptions (1)"),
"the exception row is labeled by path with the matched-entry detail: {output}"
);
}
#[test]
fn ledger_reports_count_key_row_by_reason() {
let dir = setup(&[(
"table.md",
"---\n\
exceptions:\n \
stale_references:\n \
\"2\": \"the consolidation migration table\"\n\
---\n\
See `a.md` and `b.md`.\n",
)]);
let (_failed, output) = run_lint_with_ledger(&dir);
assert!(
output.contains("suppressed: 2 warnings (1 count-key)"),
"the header tallies the count-key's two suppressed warnings: {output}"
);
assert!(
output.contains("the consolidation migration table")
&& output.contains("count-key (2)"),
"the count-key row is labeled by reason, detailed by raw key: {output}"
);
}
#[test]
fn quiet_drops_the_ledger() {
let dir = setup(&[(
"intro.md",
"---\n\
exceptions:\n \
stale_references:\n \
\"gone.md\": \"a worked example path, not a live reference\"\n\
---\n\
See `gone.md` for details.\n",
)]);
let (_f1, with_ledger) = run_lint_with_ledger(&dir);
assert!(
with_ledger.contains("suppressed:"),
"the ledger prints by default: {with_ledger}"
);
let (_f2, quiet) = run_lint(&dir);
assert!(
!quiet.contains("suppressed:"),
"--quiet drops the ledger: {quiet}"
);
}
#[test]
fn ledger_absent_when_nothing_suppressed() {
let dir = setup(&[("clean.md", "# Title\n\nNo suppressions here.\n")]);
let (_failed, output) = run_lint_with_ledger(&dir);
assert!(
!output.contains("suppressed:"),
"a clean run prints no ledger: {output}"
);
}
#[test]
fn override_disables_lint_for_matching_files_only() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"archive/**\"]\nstale_references = \"disabled\"\nhint = \"frozen docs\"\n",
),
("archive/old.md", "See `gone.md` here.\n"),
("live/cur.md", "See `missing.md` here.\n"),
]);
let (_failed, output) = run_lint(&dir);
assert!(
!output.contains("archive/old.md"),
"the freeze override silences the matching file's stale reference: {output}"
);
assert!(
output.contains("live/cur.md") && output.contains("stale reference"),
"a non-matching file's stale reference is unaffected: {output}"
);
}
#[test]
fn override_raise_escalates_for_matching_files() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"strict/**\"]\nstale_references = \"deny\"\n",
),
("strict/a.md", "See `gone.md` here.\n"),
("lax/b.md", "See `gone.md` here.\n"),
]);
let (failed, output) = run_lint(&dir);
assert!(
failed,
"a raise to deny must fail the exit code on the matching file: {output}"
);
assert!(
output.contains("strict/a.md:1: error: stale reference"),
"the matching file's stale reference is escalated to an error: {output}"
);
assert!(
output.contains("lax/b.md:1: warning: stale reference"),
"a non-matching file keeps the repo-wide warning level: {output}"
);
}
#[test]
fn override_expect_match_suppresses_all() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 2 }\n",
),
("sweep/a.md", "See `gone.md` here.\n"),
("sweep/b.md", "See `missing.md` here.\n"),
]);
let (_failed, output) = run_lint(&dir);
assert!(
!output.contains("stale reference"),
"a matched expect aggregate suppresses every member's stale reference: {output}"
);
}
#[test]
fn override_expect_drift_resurfaces_and_flags() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 5 }\n",
),
("sweep/a.md", "See `gone.md` here.\n"),
("sweep/b.md", "See `missing.md` here.\n"),
]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("sweep/a.md") && output.contains("sweep/b.md"),
"on drift, every member diagnostic resurfaces: {output}"
);
assert!(
output.contains("expects 5 stale references but found 2")
&& output.contains("sweep/**"),
"the drift flag names the override and the expected/found counts: {output}"
);
}
#[test]
fn unused_override_flags() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"archive/**\"]\nstale_references = \"disabled\"\n",
),
("live/doc.md", "# Live\n"),
]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("unused override") && output.contains("archive/**"),
"a zero-match override is flagged, naming the glob: {output}"
);
}
#[test]
fn frontmatter_wins_over_override() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 1 }\n",
),
(
"sweep/a.md",
"---\nexceptions:\n stale_references:\n \"gone.md\": \"frontmatter wins\"\n---\nSee `gone.md` here.\n",
),
("sweep/b.md", "See `missing.md` here.\n"),
]);
let (_failed, output) = run_lint(&dir);
assert!(
!output.contains("stale reference"),
"frontmatter carves a.md's reference out first, then the expect = 1 aggregate suppresses b.md's: {output}"
);
assert!(
!output.contains("expects 1"),
"the aggregate counts only the frontmatter survivor (1), so it matches and does not drift: {output}"
);
}
#[test]
fn override_expect_drifts_when_frontmatter_carves_all() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 5 }\n",
),
(
"sweep/a.md",
"---\nexceptions:\n stale_references:\n \"gone.md\": \"frontmatter wins\"\n---\nSee `gone.md` here.\n",
),
]);
let (_failed, output) = run_lint(&dir);
assert!(
output.contains("expects 5 stale references but found 0"),
"with every reference carved out by frontmatter, the expect = 5 aggregate honestly drifts to 0: {output}"
);
}
#[test]
fn ledger_includes_override_rows() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"archive/**\"]\nstale_references = \"disabled\"\n\n[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 1 }\n",
),
("archive/old.md", "See `gone.md` here.\n"),
("sweep/a.md", "See `missing.md` here.\n"),
]);
let (_failed, output) = run_lint_with_ledger(&dir);
assert!(
output.contains("suppressed:") && output.contains("2 overrides"),
"the ledger header tallies both override rows: {output}"
);
assert!(
output.contains("archive/**") && output.contains("override (freeze)"),
"the freeze override contributes a labelled ledger row: {output}"
);
assert!(
output.contains("sweep/**") && output.contains("override (expect=1)"),
"the matched expect override contributes a labelled ledger row: {output}"
);
}
#[test]
fn ledger_reports_artifact_suppressions_by_severity() {
let dir = setup(&[
(
".lattice.toml",
"[graph]\nartifacts = [\"AGENTS.md\", \"CLAUDE.md\"]\n",
),
("intro.md", "Put hooks in `AGENTS.md`.\n"),
("docs/guide.md", "Also see `AGENTS.md`.\n"),
]);
let (_failed, output) = run_lint_with_ledger(&dir);
assert!(
output.contains("suppressed: 2 warnings (1 artifacts)"),
"the header tallies the two repo-wide artifact suppressions as one artifact source: {output}"
);
assert!(
output.contains("AGENTS.md") && output.contains("artifact"),
"the artifact row is labelled by the name with the `artifact` detail: {output}"
);
assert!(
!output.contains("stale reference"),
"no artifact mention surfaces as a diagnostic: {output}"
);
}
#[test]
fn quiet_drops_the_artifact_ledger() {
let dir = setup(&[
(".lattice.toml", "[graph]\nartifacts = [\"AGENTS.md\"]\n"),
("intro.md", "Put hooks in `AGENTS.md`.\n"),
]);
let (_f1, with_ledger) = run_lint_with_ledger(&dir);
assert!(
with_ledger.contains("suppressed:") && with_ledger.contains("artifact"),
"the artifact ledger prints by default: {with_ledger}"
);
let (_f2, quiet) = run_lint(&dir);
assert!(
!quiet.contains("suppressed:"),
"--quiet drops the artifact ledger: {quiet}"
);
}
#[test]
fn override_last_match_wins_freeze_then_raise() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"x/**\"]\nbare_paths = \"disabled\"\n\n[[override]]\npaths = [\"x/strict/**\"]\nbare_paths = \"deny\"\n",
),
("x/strict/a.md", "See \"target.md\" for details.\n"),
("x/other.md", "See \"target.md\" for details.\n"),
("target.md", "# Target\n"),
]);
let (failed, output) = run_lint(&dir);
assert!(
failed,
"the later deny entry wins for the overlapping file and fails the exit code: {output}"
);
assert!(
output.contains("x/strict/a.md:1: error:"),
"the file under the last-matching deny entry escalates to an error: {output}"
);
assert!(
!output.contains("x/other.md"),
"a file matched only by the first freeze entry is silenced: {output}"
);
}
#[test]
fn override_drift_gates_under_strict() {
let dir = setup(&[
(
".lattice.toml",
"[[override]]\npaths = [\"sweep/**\"]\nstale_references = { expect = 5 }\n",
),
("sweep/a.md", "See `gone.md` here.\n"),
]);
let (failed, output) = run_lint_with(&dir, true);
assert!(failed, "the drift flag gates under --strict: {output}");
assert!(
output.contains("expects 5 stale references but found 1"),
"the drift flag is present: {output}"
);
}
}