Files
pkh/src/lint/mod.rs
T
vhaudiquet a0e74073bf
CI / build (push) Successful in 2m55s
CI / test (push) Skipped
CI / snap (push) Successful in 4m32s
lint: add pkh lint, wrapping lintian for parity plus pkh-native checks
pkh covered the package lifecycle but never validated the packaging
itself: broken control stanzas, unparsable changelog versions or
uncommitted debian/ edits only surfaced at build or upload time. pkh
lint lints a source tree with day-one lintian parity plus a native Rust
engine for the checks lintian cannot have.

The wrapper reuses the pkh build output next to the tree when it matches
the current changelog entry and no tree content is newer (mtime walk,
skipping .git/.pc), else packs fresh with dpkg-source -b using weak gzip
compression (the artifact is ephemeral; xz dominated the run at 9.8 s
versus 2.7 s on a 111 MB tree) and symlinks quilt orig tarballs from the
tree's parent, which dpkg-source searches in cwd. Findings are parsed
from the installed lintian into a unified report, deduplicated by tag
name against the native engine, and rendered lintian-shaped
(<L>: <pkg> <type>: <tag> <details>) as text or JSON, colorized at
render time (--color auto/always/never). Exit codes follow lintian's
contract (0 clean, 1 findings at/above --fail-on, 2 runtime error);
lintian's own exit code is ignored because it uses 2 both for findings
and for runtime errors. -d/--dist maps to lintian --profile so the
target distro's rules apply even on a foreign host.

The native engine hosts the first workflow check lintian cannot know:
pkh-debian-changes-not-committed flags debian/ content that is not
committed to git, since the pkh flow builds and uploads the tree as-is.
Checks register in a static registry validated by a unit test, and the
wrapper's parser is pinned by golden tests captured from lintian 2.129
output. Strategies for lintian's Ubuntu blind spots (its vendor data
there is one file plus 14 disabled tags) are specced in
plans/pkh-lint.md, deliberately not implemented yet.
2026-09-19 23:45:31 +02:00

187 lines
6.3 KiB
Rust

//! `pkh lint`: lint a Debian source tree, with lintian feature parity.
//!
//! The strategy is *wrap first, port second* (see `plans/pkh-lint.md`): the
//! [wrapper](wrapper) runs the installed lintian over an ephemeral source
//! package for day-one parity with every lintian check, while the native
//! engine (check registry, collectors, emitter) hosts pkh-specific workflow
//! checks (`pkh-*` tags) and grows ported lintian checks incrementally.
//! Both findings merge into one report — deduplicated by tag name, which is
//! why native checks mirror lintian's tag names for equivalent checks —
//! rendered as lintian-shaped text or JSON, with lintian's exit-code
//! contract (0 clean, 1 findings at/above `--fail-on`, 2 runtime error).
pub mod check;
pub mod checks;
pub mod collect;
pub mod emit;
pub mod output;
pub mod tag;
pub mod wrapper;
use std::collections::HashSet;
use std::path::PathBuf;
use crate::lint::check::Check;
use crate::lint::collect::LintData;
use crate::lint::emit::{Finding, LintReport};
/// Knobs of one `pkh lint` run, built from the CLI in `main.rs`.
pub struct LintOptions {
/// Source tree to lint.
pub path: PathBuf,
/// Run the native engine only; never invoke the lintian wrapper.
pub native: bool,
/// Levels that make the exit code 1 (`--fail-on`, default: error).
pub fail_on: Vec<output::Level>,
/// Show tag explanations under each finding (`--info`, lintian's `-i`).
pub info: bool,
/// Also display info-level (`I:`) findings (lintian's `-I`).
pub display_info: bool,
/// Also display pedantic (`P:`) findings.
pub pedantic: bool,
/// Also display experimental (`X:`) findings.
pub experimental: bool,
/// Also display overridden (`O:`) findings.
pub show_overrides: bool,
/// Tag names to ignore for this run.
pub suppress_tags: Vec<String>,
/// Run only these native checks (`--check`, repeatable).
pub only_checks: Vec<String>,
/// Ignore any existing `pkh build` output and pack the tree fresh.
pub repack: bool,
/// Emit JSON instead of text.
pub json: bool,
/// Text colorization mode (`--color`, default auto: TTY without NO_COLOR).
pub color: output::ColorMode,
/// Target distribution (debian/ubuntu), when known.
pub dist: Option<String>,
/// Target series, when known.
pub series: Option<String>,
}
/// Run one lint: collect package information, run the native checks, wrap
/// lintian (unless `--native`), and merge everything into one report.
pub fn run(options: &LintOptions) -> Result<LintReport, String> {
let data = LintData::collect(
&options.path,
options.dist.as_deref(),
options.series.as_deref(),
);
let mut report = LintReport {
findings: Vec::new(),
source_name: data.source_name.clone(),
wrapper_unavailable: false,
notes: Vec::new(),
};
if !options.native {
match wrapper::run(&data.root, options)? {
Some(outcome) => {
report.findings.extend(outcome.findings);
report.notes = outcome.notes;
}
None => report.wrapper_unavailable = true,
}
}
let selected: Vec<&'static dyn Check> = if options.only_checks.is_empty() {
check::CHECKS.to_vec()
} else {
options
.only_checks
.iter()
.map(|id| check::find_check(id).ok_or_else(|| format!("Unknown --check '{id}'")))
.collect::<Result<_, _>>()?
};
let mut native = Vec::new();
{
let mut emitter = emit::Emitter::new(report.source_name.clone(), &mut native);
for check in selected {
check.run(&data, &mut emitter);
}
}
report.findings.extend(merge(&report.findings, native));
if !options.suppress_tags.is_empty() {
report
.findings
.retain(|finding| !options.suppress_tags.contains(&finding.tag_name));
}
Ok(report)
}
/// Append `native` findings to `lintian`'s, dropping native duplicates by
/// tag name (the wrapper's verdict wins for tags both engines produce).
fn merge(lintian: &[Finding], native: Vec<Finding>) -> Vec<Finding> {
let known: HashSet<&str> = lintian.iter().map(|f| f.tag_name.as_str()).collect();
native
.into_iter()
.filter(|finding| !known.contains(finding.tag_name.as_str()))
.collect()
}
/// The `--list-tags` catalog: one line per registered native tag.
pub fn list_tags() -> String {
let mut out = String::new();
for check in check::CHECKS {
for tag in check.tags() {
out.push_str(&format!(
"{} [{}] {}\n {}\n",
tag.letter(),
check.id(),
tag.name,
tag.description
));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lint::emit::Origin;
fn finding(tag: &str, origin: Origin) -> Finding {
Finding {
letter: 'W',
tag_name: tag.to_string(),
message: String::new(),
package: "hello".to_string(),
processable_type: Some("source".to_string()),
explanation: Vec::new(),
origin,
}
}
#[test]
fn merge_drops_native_duplicates_by_tag_name() {
let lintian = vec![finding("no-debian-copyright-in-source", Origin::Lintian)];
let native = vec![
finding("no-debian-copyright-in-source", Origin::Native),
finding("pkh-debian-changes-not-committed", Origin::Native),
];
let merged = merge(&lintian, native);
assert_eq!(merged.len(), 1);
assert_eq!(merged[0].tag_name, "pkh-debian-changes-not-committed");
assert_eq!(merged[0].origin, Origin::Native);
}
#[test]
fn native_tags_resolve_from_the_registry() {
let tag = check::find_tag("pkh-debian-changes-not-committed")
.expect("pkh check tags must resolve");
assert_eq!(tag.letter(), 'W');
}
#[test]
fn unknown_native_tags_still_produce_findings() {
let mut sink = Vec::new();
let mut emitter = emit::Emitter::new("hello".to_string(), &mut sink);
emitter.tag("no-such-tag-anywhere", "boom");
assert_eq!(sink.len(), 1);
assert_eq!(sink[0].tag_name, "no-such-tag-anywhere");
}
}