diff --git a/crates/wright-cli/src/cli.rs b/crates/wright-cli/src/cli.rs index 1811af63..9ed55dcd 100644 --- a/crates/wright-cli/src/cli.rs +++ b/crates/wright-cli/src/cli.rs @@ -1,6 +1,9 @@ use std::path::PathBuf; -use crate::serve::{LintPolicyArgs, ServeArgs}; +mod lint; +pub(crate) use lint::*; + +use crate::serve::ServeArgs; use crate::ui::UiArgs; use crate::update::UpdateArgs; use clap::{Args, Parser, Subcommand, ValueEnum}; @@ -23,6 +26,13 @@ pub(crate) struct Cli { pub(crate) version: bool, } +/// A command whose clean verdict is its own name — the read-only query +/// commands, not a judging word (ADR-0030 decision 4). +pub(crate) fn is_query_command(command: &str) -> bool { + const QUERIES: &str = "inspect rules symbols refs cfg callgraph cost lookup package-search package-list package-outdated"; + QUERIES.split(' ').any(|query| query == command) +} + pub(crate) const LONG_ABOUT: &str = "Wright compiler and Workshop tooling CLI. Commands check correctness, summarize semantic hotspots, lint, compile, @@ -91,7 +101,12 @@ pub(crate) enum Command { /// Summarize Workshop cost, complexity hotspots, risk indicators, and /// cross-cutting state. Analyze(AnalyzeArgs), - /// Parse, lower, and report lint findings. + /// Parse, lower, and report lint findings. The `package` subcommand + /// manages GitHub lint rule packages (#844). + #[command( + args_conflicts_with_subcommands = true, + subcommand_precedence_over_arg = true + )] Lint(LintArgs), /// Parse, lower, and inspect semantic facts: the bare command prints the /// summary, and its query subcommands expose each detail area (#429). @@ -533,28 +548,6 @@ pub(crate) struct CallgraphArgs { pub(crate) max: Option, } -#[derive(Debug, Args)] -pub(crate) struct LintArgs { - #[command(flatten)] - pub(crate) common: CommonArgs, - #[command(flatten)] - pub(crate) select: SelectArgs, - /// Return every finding. - #[arg(long)] - pub(crate) all: bool, - /// Show the automated fixes lint offers for fixable findings as a - /// validated diff, without writing (#556). - #[arg(long, conflicts_with = "all")] - pub(crate) fix: bool, - /// Apply the fixes `--fix` previews: each fix is re-validated against - /// the current source before writing; a stale source refuses and - /// writes nothing (#556). - #[arg(long, requires = "fix")] - pub(crate) write: bool, - #[command(flatten)] - pub(crate) policy: LintPolicyArgs, -} - #[derive(Debug, Args)] pub(crate) struct CompletionArgs { #[command(subcommand)] diff --git a/crates/wright-cli/src/cli/lint.rs b/crates/wright-cli/src/cli/lint.rs new file mode 100644 index 00000000..ef8e28b9 --- /dev/null +++ b/crates/wright-cli/src/cli/lint.rs @@ -0,0 +1,140 @@ +//! The `lint` command tree: the finding-report arguments plus the +//! `lint package` subcommand tree (#844) — discovery and lifecycle +//! management for GitHub lint rule packages over the wright-driver store +//! (#842). Installing a package never activates it; project configuration +//! pins packages by commit SHA (#843). + +use clap::{Args, Subcommand}; + +use super::{ColorArg, CommonArgs, OutputFormatArg, RendererArg, SelectArgs}; +use crate::serve::LintPolicyArgs; + +/// Arguments of `wright lint`: the shared workflow options, the +/// finding-selection and policy flags, `--fix`/`--write`, and the +/// `package` subcommand. +#[derive(Debug, Args)] +pub(crate) struct LintArgs { + #[command(flatten)] + pub(crate) common: CommonArgs, + #[command(flatten)] + pub(crate) select: SelectArgs, + /// Return every finding. + #[arg(long)] + pub(crate) all: bool, + /// Show the automated fixes lint offers for fixable findings as a + /// validated diff, without writing (#556). + #[arg(long, conflicts_with = "all")] + pub(crate) fix: bool, + /// Apply the fixes `--fix` previews: each fix is re-validated against + /// the current source before writing; a stale source refuses and + /// writes nothing (#556). + #[arg(long, requires = "fix")] + pub(crate) write: bool, + #[command(flatten)] + pub(crate) policy: LintPolicyArgs, + #[command(subcommand)] + pub(crate) command: Option, +} + +/// The `lint` subcommands; only `package` exists today. +#[derive(Debug, Subcommand)] +pub(crate) enum LintSubcommand { + /// Manage GitHub lint rule packages: search, install, list, outdated, + /// update, remove (#844). + Package(PackageArgs), +} + +/// Arguments of `lint package`: the package lifecycle subcommand. +#[derive(Debug, Args)] +pub(crate) struct PackageArgs { + #[command(subcommand)] + pub(crate) command: LintPackageCommand, +} + +/// The `lint package` subcommands. +#[derive(Debug, Subcommand)] +pub(crate) enum LintPackageCommand { + /// Search GitHub repositories carrying the `wrightkit-lint` topic. + Search(PackageSearchArgs), + /// Download a GitHub lint rule package into the store, keyed by commit SHA. + Install(PackageInstallArgs), + /// List the package entries currently in the store. + List(PackageOutputArgs), + /// Check installed packages against the remote default-branch head + /// without changing the store. + Outdated(PackageOutputArgs), + /// Install the current default-branch head for installed packages. + Update(PackageUpdateArgs), + /// Remove a package's store entries; only Wright-managed content is + /// deleted. + Remove(PackageRemoveArgs), +} + +/// The output flags every `lint package` subcommand shares. +#[derive(Debug, Args)] +pub(crate) struct PackageOutputArgs { + /// Output format. + #[arg(short = 'f', long, value_enum, default_value_t = OutputFormatArg::Text)] + pub(crate) format: OutputFormatArg, + /// Renderer environment; `auto` detects terminal, CI, and GitHub Actions. + #[arg(long, value_enum, default_value_t = RendererArg::Auto)] + pub(crate) renderer: RendererArg, + /// ANSI color policy. + #[arg(long, value_enum, default_value_t = ColorArg::Auto)] + pub(crate) color: ColorArg, + /// Return every row instead of the default bounded report. + #[arg(long)] + pub(crate) all: bool, +} + +/// Arguments of `lint package search`: optional terms narrowing the +/// `topic:wrightkit-lint` repository query. +#[derive(Debug, Args)] +pub(crate) struct PackageSearchArgs { + /// Terms narrowing the repository search (for example `wait` or + /// `org:myteam`); the `wrightkit-lint` topic filter always applies. + #[arg(value_name = "QUERY")] + pub(crate) query: Option, + #[command(flatten)] + pub(crate) output: PackageOutputArgs, +} + +/// Arguments of `lint package install`: a GitHub `owner/repo` source — +/// URL spellings are accepted — plus an optional branch, tag, or commit. +#[derive(Debug, Args)] +pub(crate) struct PackageInstallArgs { + /// The package source: `owner/repo`, a github.com URL, or + /// `owner/repo@`; `@` and URL spellings select the revision. + #[arg(value_name = "SOURCE")] + pub(crate) source: String, + /// The branch, tag, or commit SHA to install; without it the remote + /// default-branch head is used. A full SHA installs offline. + #[arg(long = "ref", value_name = "REF")] + pub(crate) reference: Option, + #[command(flatten)] + pub(crate) output: PackageOutputArgs, +} + +/// Arguments of `lint package update`: an optional `owner/repo` or +/// `owner/repo@` restricting which store entries are updated. +#[derive(Debug, Args)] +pub(crate) struct PackageUpdateArgs { + /// Restrict the update to one repository, or to the one store entry a + /// `@` resolves to; without it every installed package is checked. + #[arg(value_name = "SOURCE")] + pub(crate) source: Option, + #[command(flatten)] + pub(crate) output: PackageOutputArgs, +} + +/// Arguments of `lint package remove`: the `owner/repo` or +/// `owner/repo@` store entry to remove. +#[derive(Debug, Args)] +pub(crate) struct PackageRemoveArgs { + /// The package to remove: `owner/repo` removes every installed entry, + /// `owner/repo@` removes only the entry the ref resolves to. + #[arg(value_name = "SOURCE")] + pub(crate) source: String, + #[command(flatten)] + pub(crate) output: PackageOutputArgs, +} diff --git a/crates/wright-cli/src/lint.rs b/crates/wright-cli/src/lint.rs new file mode 100644 index 00000000..1142b9c3 --- /dev/null +++ b/crates/wright-cli/src/lint.rs @@ -0,0 +1,60 @@ +//! The `lint` command's dispatch: `lint package ` manages +//! GitHub lint rule packages over the wright-driver store (#844) — no +//! project session is involved — while bare `lint` runs the finding +//! workflow through the shared configured runner like the other workflow +//! commands. + +mod package; +mod report; + +use std::process::ExitCode; + +use wright_driver::result::exit; + +use crate::present::{self, Presentation}; +use crate::{cli, essential_envelope, fail_on, run_configured, selection_from_args}; +use crate::{config_from_common, present::terminal}; + +/// Run `wright lint`: the `package` subcommand goes to the store commands, +/// otherwise the lint finding workflow (plain or `--fix`) runs. +pub(crate) fn run(args: cli::LintArgs) -> ExitCode { + if let Some(cli::LintSubcommand::Package(package)) = args.command { + return package::run(&package); + } + let mut config = config_from_common(&args.common); + config.selection = selection_from_args(&args.select); + if let Err(message) = args.policy.apply_to(&mut config) { + terminal::report_error( + Presentation::from_common(&args.common), + "USAGE", + "lint", + &message, + ); + return ExitCode::from(exit::USAGE); + } + let presentation = present::Presentation::from_common(&args.common); + if args.fix { + return run_configured( + "lint", + config, + presentation, + fail_on(&args.common), + None, + move |session| session.lint_fix(args.write), + ); + } + run_configured( + "lint", + config, + presentation.with_all(args.all), + fail_on(&args.common), + None, + |session| { + essential_envelope( + session.lint(), + |value| wright_driver::essential::lint(value, args.all), + |result| result.selection.clone(), + ) + }, + ) +} diff --git a/crates/wright-cli/src/lint/package.rs b/crates/wright-cli/src/lint/package.rs new file mode 100644 index 00000000..01d0ad6e --- /dev/null +++ b/crates/wright-cli/src/lint/package.rs @@ -0,0 +1,509 @@ +//! `wright lint package ` (#844): GitHub lint rule package +//! discovery and lifecycle over the wright-driver store (#842). Each +//! subcommand builds a [`LintPackageResult`] and renders it through the +//! shared `wright-result/v1` envelope (ADR-0030), so the plain records, +//! terminal report, GitHub Actions output, and JSON all read the same rows. +//! +//! `search`/`list`/`outdated` are read-only queries — their verdict word is +//! the command's own name. `install`/`update`/`remove` are lifecycle +//! operations: a row-level failure turns the envelope into a gated FAIL — +//! the verdict fails, the per-package report still renders, and every failed +//! package reports its own `lint-package-*` diagnostic. + +use std::path::Path; +use std::process::ExitCode; +use std::time::Instant; + +use wright_driver::result::{Envelope, LintPackageResult, LintPackageRow, exit, version_info}; +use wright_driver::{ + Diagnostic, LintPackageError, LintPackageSource, LintPackageStore, RemoteCheck, Stage, + StoreEntry, +}; + +use crate::cli; +use crate::present::{self, Presentation}; + +/// The GitHub API base override for recorded-response tests; +/// `WRIGHT_LINT_PACKAGE_DIR` already relocates the store root itself. +const API_BASE_ENV: &str = "WRIGHT_LINT_PACKAGE_API_BASE"; +/// The codeload tarball base override for recorded-response tests. +const CODELOAD_BASE_ENV: &str = "WRIGHT_LINT_PACKAGE_CODELOAD_BASE"; + +/// The store the command operates on: the default root (or +/// `WRIGHT_LINT_PACKAGE_DIR`) plus the test endpoint overrides. +fn store() -> LintPackageStore { + let mut store = LintPackageStore::new(LintPackageStore::default_root()); + if let Some(base) = env_nonempty(API_BASE_ENV) { + store = store.with_api_base(base); + } + if let Some(base) = env_nonempty(CODELOAD_BASE_ENV) { + store = store.with_codeload_base(base); + } + store +} + +fn env_nonempty(name: &str) -> Option { + std::env::var(name).ok().filter(|value| !value.is_empty()) +} + +/// A subcommand's report: the result rows plus each failed row's diagnostic +/// and exit code — `update`/`remove` use it for per-package failures. +type Built = Result<(LintPackageResult, Vec<(Diagnostic, u8)>), LintPackageError>; + +/// Dispatch one `lint package` subcommand. +pub(crate) fn run(args: &cli::PackageArgs) -> ExitCode { + match &args.command { + cli::LintPackageCommand::Search(args) => { + run_report("package-search", &args.output, |store| search(store, args)) + } + cli::LintPackageCommand::Install(args) => { + run_report("package-install", &args.output, |store| { + install(store, args) + }) + } + cli::LintPackageCommand::List(args) => run_report("package-list", args, list), + cli::LintPackageCommand::Outdated(args) => run_report("package-outdated", args, outdated), + cli::LintPackageCommand::Update(args) => { + run_report("package-update", &args.output, |store| update(store, args)) + } + cli::LintPackageCommand::Remove(args) => { + run_report("package-remove", &args.output, |store| remove(store, args)) + } + } +} + +/// Run one subcommand against the store, wrap its rows in the shared +/// envelope, and render it. A `LintPackageError` becomes an error envelope — +/// the verdict word follows the error's exit code (`USAGE` for a bad source, +/// `ERROR` for the rest) — and per-row failures become a gated FAIL whose +/// diagnostics name the packages that failed. +fn run_report( + command: &'static str, + output: &cli::PackageOutputArgs, + build: impl FnOnce(&LintPackageStore) -> Built, +) -> ExitCode { + let pres = Presentation::from_output(output.format, output.renderer, output.color) + .with_all(output.all); + let store = store(); + let started = Instant::now(); + let envelope = match build(&store) { + Ok((result, failures)) => { + let ok = failures.is_empty(); + Envelope { + wright: version_info(), + command: command.to_string(), + ok, + exit: failures.first().map_or(exit::SUCCESS, |(_, code)| *code), + diagnostics: failures.into_iter().map(|(d, _)| d).collect(), + selection: None, + gated: !ok, + result, + comparison: None, + } + } + Err(error) => error_envelope(command, &store, &error), + }; + let code = envelope.exit; + present::render(&envelope, pres, started.elapsed(), None, None); + ExitCode::from(code) +} + +/// A command-level failure: the envelope carries the error as its one +/// diagnostic and an empty result — the store root still reports so JSON +/// shows which store the command ran against. +fn error_envelope( + command: &str, + store: &LintPackageStore, + error: &LintPackageError, +) -> Envelope { + Envelope { + wright: version_info(), + command: command.to_string(), + ok: false, + exit: error.exit_code(), + diagnostics: vec![Diagnostic::error( + error.code(), + stage_of(error), + error.to_string(), + )], + selection: None, + gated: false, + result: LintPackageResult { + store: root(store), + ..LintPackageResult::default() + }, + comparison: None, + } +} + +fn root(store: &LintPackageStore) -> String { + store.root().display().to_string() +} + +/// The pipeline stage a package error reports: source/ref spellings and +/// not-installed lookups are discovery, package-content validation is +/// validation, and everything else — network, rate limit, store I/O — is +/// internal/environment. +fn stage_of(error: &LintPackageError) -> Stage { + match error { + LintPackageError::Source(_) | LintPackageError::NotFound(_) => Stage::Discovery, + LintPackageError::Invalid { .. } => Stage::Validation, + _ => Stage::Internal, + } +} + +/// A report with no row-level failures. +fn ok(result: LintPackageResult) -> Built { + Ok((result, Vec::new())) +} + +/// `package search`: repositories carrying the `wrightkit-lint` topic. +fn search(store: &LintPackageStore, args: &cli::PackageSearchArgs) -> Built { + let page = store.search(args.query.as_deref())?; + ok(LintPackageResult { + store: root(store), + total: Some(page.total), + incomplete: Some(page.incomplete), + packages: page + .hits + .iter() + .map(|hit| LintPackageRow { + source: hit.source.slug(), + status: "found".to_string(), + description: hit.description.clone(), + ..LintPackageRow::default() + }) + .collect(), + next: vec!["wright lint package install ".to_string()], + }) +} + +/// `package install`: fetch `SOURCE` at its resolved SHA into the store. +/// Installing never activates the package — project configuration pins it. +fn install(store: &LintPackageStore, args: &cli::PackageInstallArgs) -> Built { + let (source, reference) = parse_source(&args.source, args.reference.as_deref())?; + let package = store.install(&source, reference.as_deref())?; + ok(LintPackageResult { + store: root(store), + packages: vec![installed_row( + "installed", + &source, + package.sha, + &package.dir, + package.rules, + package.downloaded, + )], + next: vec![ + "wright lint package list".to_string(), + "wright lint package outdated".to_string(), + ], + ..LintPackageResult::default() + }) +} + +/// `package list`: every store entry — intact packages report their rules; +/// a damaged entry reports `damaged` with the reason it does not validate. +fn list(store: &LintPackageStore) -> Built { + let mut packages = Vec::new(); + for entry in store.entries()? { + packages.push(match &entry.damage { + Some(damage) => LintPackageRow { + source: entry.source.slug(), + status: "damaged".to_string(), + sha: Some(entry.sha.clone()), + dir: Some(dir_string(&entry.dir)), + code: Some(damage.code().to_string()), + message: Some(damage.to_string()), + ..LintPackageRow::default() + }, + None => installed_row( + "installed", + &entry.source, + entry.sha, + &entry.dir, + entry.rules, + false, + ), + }); + } + ok(LintPackageResult { + store: root(store), + packages, + next: vec!["wright lint package outdated".to_string()], + ..LintPackageResult::default() + }) +} + +/// `package outdated`: check each entry against the remote default-branch +/// head — read-only; the rows carry `unchanged`/`updated`/`unavailable`/ +/// `error` and nothing applies to the store. A damaged entry is an `error` +/// row and is never checked. +fn outdated(store: &LintPackageStore) -> Built { + let packages = store + .entries()? + .iter() + .map(|entry| check_row(store, entry)) + .collect(); + ok(LintPackageResult { + store: root(store), + packages, + next: vec!["wright lint package update".to_string()], + ..LintPackageResult::default() + }) +} + +/// `package update [SOURCE]`: install the current default-branch head for +/// each selected entry. An `unchanged` entry is not re-fetched; an `updated` +/// row keeps the previous `sha` next to the new `remote` head and `dir`. +/// `unavailable`/`error` rows fail the command. +fn update(store: &LintPackageStore, args: &cli::PackageUpdateArgs) -> Built { + let entries = select_entries(store, args.source.as_deref())?; + let mut packages = Vec::new(); + let mut failures = Vec::new(); + for entry in &entries { + let row = update_row(store, entry); + if let Some(failure) = row_failure(&row) { + failures.push(failure); + } + packages.push(row); + } + Ok(( + LintPackageResult { + store: root(store), + packages, + next: vec!["wright lint package list".to_string()], + ..LintPackageResult::default() + }, + failures, + )) +} + +/// `package remove SOURCE`: delete the selected entries' managed `rules/` +/// trees. `removed` means the entry directory went too; `pruned` means +/// unrelated files beside `rules/` keep it. Failures fail the command. +fn remove(store: &LintPackageStore, args: &cli::PackageRemoveArgs) -> Built { + let entries = select_entries(store, Some(args.source.as_str()))?; + let mut packages = Vec::new(); + let mut failures = Vec::new(); + for entry in &entries { + let row = match store.remove(&entry.source, &entry.sha) { + Ok(removed) => LintPackageRow { + source: entry.source.slug(), + status: if removed.dir_removed { + "removed" + } else { + "pruned" + } + .to_string(), + sha: Some(entry.sha.clone()), + dir: Some(dir_string(&entry.dir)), + ..LintPackageRow::default() + }, + Err(error) => LintPackageRow { + source: entry.source.slug(), + status: "error".to_string(), + sha: Some(entry.sha.clone()), + dir: Some(dir_string(&entry.dir)), + code: Some(error.code().to_string()), + message: Some(error.to_string()), + ..LintPackageRow::default() + }, + }; + if let Some(failure) = row_failure(&row) { + failures.push(failure); + } + packages.push(row); + } + Ok(( + LintPackageResult { + store: root(store), + packages, + next: vec!["wright lint package list".to_string()], + ..LintPackageResult::default() + }, + failures, + )) +} + +fn installed_row( + status: &str, + source: &LintPackageSource, + sha: String, + dir: &Path, + rules: Vec, + downloaded: bool, +) -> LintPackageRow { + LintPackageRow { + source: source.slug(), + status: status.to_string(), + sha: Some(sha), + dir: Some(dir_string(dir)), + rules, + downloaded: Some(downloaded), + ..LintPackageRow::default() + } +} + +/// The remote-check row `outdated` and `update` share before `update` +/// applies its `updated` case. A damaged entry reports `error` and is never +/// checked against the remote. +fn check_row(store: &LintPackageStore, entry: &StoreEntry) -> LintPackageRow { + let mut row = LintPackageRow { + source: entry.source.slug(), + sha: Some(entry.sha.clone()), + dir: Some(dir_string(&entry.dir)), + ..LintPackageRow::default() + }; + if let Some(damage) = &entry.damage { + row.status = "error".to_string(); + row.code = Some(damage.code().to_string()); + row.message = Some(damage.to_string()); + return row; + } + match store.check_remote(&entry.source, None, &entry.sha) { + RemoteCheck::Unchanged { sha } => { + row.status = "unchanged".to_string(); + row.remote = Some(sha); + } + RemoteCheck::Updated { sha } => { + row.status = "updated".to_string(); + row.remote = Some(sha); + } + RemoteCheck::Unavailable { reason } => { + row.status = "unavailable".to_string(); + row.code = Some("lint-package-not-found".to_string()); + row.message = Some(reason); + } + RemoteCheck::Error(error) => { + row.status = "error".to_string(); + row.code = Some(error.code().to_string()); + row.message = Some(error.to_string()); + } + } + row +} + +/// One `update` row: an `updated` check installs the new head into the +/// store; everything else reports like `outdated`. +fn update_row(store: &LintPackageStore, entry: &StoreEntry) -> LintPackageRow { + let row = check_row(store, entry); + if row.status != "updated" { + return row; + } + let remote = row.remote.clone().expect("an updated row carries remote"); + match store.install(&entry.source, Some(&remote)) { + Ok(package) => LintPackageRow { + status: "updated".to_string(), + dir: Some(dir_string(&package.dir)), + rules: package.rules, + downloaded: Some(package.downloaded), + ..row + }, + Err(error) => LintPackageRow { + status: "error".to_string(), + code: Some(error.code().to_string()), + message: Some(error.to_string()), + ..row + }, + } +} + +/// The entries `update`/`remove` act on: every installed entry, one +/// repository's entries for `owner/repo`, or the single entry +/// `owner/repo@` resolves to. No match is a `lint-package-not-found` +/// command error. +fn select_entries( + store: &LintPackageStore, + source: Option<&str>, +) -> Result, LintPackageError> { + let entries = store.entries()?; + let Some(input) = source else { + return Ok(entries); + }; + let (source, reference) = parse_source(input, None)?; + let mut selected: Vec = entries + .into_iter() + .filter(|entry| entry.source == source) + .collect(); + if let Some(reference) = &reference { + let sha = store.resolve(&source, Some(reference))?; + selected.retain(|entry| entry.sha == sha); + } + if selected.is_empty() { + let mut message = format!("lint package '{source}' is not installed"); + if let Some(reference) = reference { + message.push_str(&format!(" at ref '{reference}'")); + } + return Err(LintPackageError::NotFound(message)); + } + Ok(selected) +} + +/// A failed `update`/`remove` row as its diagnostic and exit code: the +/// message names the package so a multi-package failure stays attributable. +fn row_failure(row: &LintPackageRow) -> Option<(Diagnostic, u8)> { + if !matches!(row.status.as_str(), "unavailable" | "error") { + return None; + } + let code = row.code.as_deref().unwrap_or("lint-package-remote"); + let message = format!( + "{}: {}", + row.source, + row.message.as_deref().unwrap_or("operation failed") + ); + let stage = match code { + "lint-package-source" | "lint-package-not-found" => Stage::Discovery, + "lint-package-invalid" => Stage::Validation, + _ => Stage::Internal, + }; + let exit = match code { + "lint-package-source" => exit::USAGE, + "lint-package-invalid" => exit::SOURCE_ERROR, + _ => exit::INTERNAL, + }; + Some((Diagnostic::error(code, stage, message), exit)) +} + +/// Parse a `SOURCE` argument: `owner/repo`, a github.com URL, or +/// `owner/repo@`, plus an optional `--ref` flag. When more than one +/// spelling carries a ref they must agree — a conflict is a usage error. +fn parse_source( + input: &str, + flag: Option<&str>, +) -> Result<(LintPackageSource, Option), LintPackageError> { + let (base, at_ref) = split_at_ref(input); + let (source, url_ref) = LintPackageSource::parse(base)?; + let mut refs: Vec = Vec::new(); + for candidate in [ + url_ref, + at_ref.map(str::to_string), + flag.map(str::to_string), + ] { + if let Some(candidate) = candidate.filter(|r| !r.is_empty()) { + refs.push(candidate); + } + } + if let Some(conflict) = refs.iter().find(|r| **r != refs[0]) { + return Err(LintPackageError::Source(format!( + "conflicting refs for '{source}': '{}' and '{conflict}'", + refs[0] + ))); + } + Ok((source, refs.into_iter().next())) +} + +/// Split the `owner/repo@` spelling: only a bare `owner/repo` base +/// takes an `@` suffix — URL and `git@` spellings keep their `@`. +fn split_at_ref(input: &str) -> (&str, Option<&str>) { + let Some((base, reference)) = input.rsplit_once('@') else { + return (input, None); + }; + if reference.is_empty() || base.matches('/').count() != 1 || base.contains(':') { + return (input, None); + } + (base, Some(reference)) +} + +fn dir_string(dir: &Path) -> String { + dir.display().to_string() +} diff --git a/crates/wright-cli/src/lint/report.rs b/crates/wright-cli/src/lint/report.rs new file mode 100644 index 00000000..be6ef997 --- /dev/null +++ b/crates/wright-cli/src/lint/report.rs @@ -0,0 +1,220 @@ +//! The `LintPackageResult` renderer impls (#844): `ResultPresentation`, +//! `PlainReport`, and `TerminalReport` for the `lint package` commands. +//! They live beside the package runner rather than in the shared renderer +//! modules, which are at their size budget; the records, tags, and layout +//! they produce follow ADR-0030 identically. + +use wright_driver::result::{Envelope, LintPackageResult, LintPackageRow}; + +use crate::plain::{Line, PlainReport, Stream, expand_all, field, truncated_record}; +use crate::present::terminal::{TerminalReport, heading, line, next, note, plural}; +use crate::present::{RenderContext, ResultPresentation}; + +/// The status-count breakdown `summary:`/metadata/headline share: the row +/// count leads — `packages=N` — then each present status word reports its +/// own count in the record contract's order. A report whose rows all share +/// one status adds no breakdown (`5 installed` repeats `installed` five +/// times' worth of nothing); `search` appends the API's `total_count`. +fn status_counts(result: &LintPackageResult) -> Vec<(String, usize)> { + const ORDER: &[&str] = &[ + "found", + "installed", + "unchanged", + "updated", + "removed", + "pruned", + "damaged", + "unavailable", + "error", + ]; + let mut counts: Vec<(String, usize)> = Vec::new(); + for status in ORDER { + let n = result + .packages + .iter() + .filter(|row| row.status == *status) + .count(); + if n > 0 { + counts.push(((*status).to_string(), n)); + } + } + if counts.len() == 1 { + counts.clear(); + } + counts +} + +/// The `lint package` metadata line: `N package(s)` plus the status +/// breakdown when the rows are mixed, and the search API's total. +fn metadata(result: &LintPackageResult) -> String { + let mut metadata = format!("{} package(s)", result.packages.len()); + if let Some(total) = result.total.filter(|total| { + *total as usize != result.packages.len() || result.incomplete.unwrap_or(false) + }) { + metadata.push_str(&format!(" of {total} on GitHub")); + } + let counts = status_counts(result); + if !counts.is_empty() { + metadata.push_str(&format!( + " ({})", + counts + .iter() + .map(|(status, n)| format!("{n} {status}")) + .collect::>() + .join(", ") + )); + } + metadata +} + +impl ResultPresentation for LintPackageResult { + fn metadata(&self) -> Option { + Some(metadata(self)) + } + // Not an artifact stream: the plain renderer emits `package:` records + // and the terminal renderer its own layout — `render_body` never runs. + fn render_body(&self, _ctx: &RenderContext<'_>) {} +} + +/// One `package:` record per row: `source` and `status` lead, then the +/// row's own facts in a fixed order — the installed `sha`, the remote head +/// `remote`, the entry `dir`, the rule ids `rules`, the `downloaded` flag, +/// and `code`/`message`/`description` when the row carries them. +fn package_record(row: &LintPackageRow) -> String { + let mut items = vec![field("source", &row.source), field("status", &row.status)]; + if let Some(sha) = &row.sha { + items.push(field("sha", sha)); + } + if let Some(remote) = &row.remote { + items.push(field("remote", remote)); + } + if let Some(dir) = &row.dir { + items.push(field("dir", dir)); + } + if !row.rules.is_empty() { + items.push(field("rules", &row.rules.join(","))); + } + if let Some(downloaded) = row.downloaded { + items.push(format!("downloaded={downloaded}")); + } + if let Some(code) = &row.code { + items.push(field("code", code)); + } + if let Some(message) = &row.message { + items.push(field("message", message)); + } + if let Some(description) = &row.description { + items.push(field("description", description)); + } + format!("package: {}", items.join(" ")) +} + +/// `summary:` counts the rows and names the store root — status counts +/// appear only when the rows are mixed — then one `package:` record per +/// row, capped at the default page unless `--all` lifted it. +impl PlainReport for LintPackageResult { + fn plain_report( + &self, + envelope: &Envelope, + ctx: &RenderContext<'_>, + out: &mut Vec, + ) { + let mut fields = vec![format!("packages={}", self.packages.len())]; + if let Some(total) = self.total.filter(|t| *t as usize != self.packages.len()) { + fields.push(format!("total={total}")); + } + fields.extend( + status_counts(self) + .into_iter() + .map(|(status, n)| format!("{status}={n}")), + ); + fields.push(field("store", &self.store)); + out.push((Stream::Out, format!("summary: {}", fields.join(" ")))); + let limit = if ctx.presentation.all { usize::MAX } else { 20 }; + for row in self.packages.iter().take(limit) { + out.push((Stream::Out, package_record(row))); + } + if self.packages.len() > limit { + out.push(( + Stream::Out, + truncated_record( + &[("packages", limit, self.packages.len())], + &expand_all(envelope), + ), + )); + } + } +} + +/// One item per row — `owner/repo@ — status` — with the row's +/// message, rules, and entry directory as secondary notes, capped at +/// twenty unless `--all` lifted it. +impl TerminalReport for LintPackageResult { + fn headline(&self) -> Option { + let mut parts = vec![plural(self.packages.len(), "package", "packages")]; + if let Some(total) = self.total.filter(|t| *t as usize != self.packages.len()) { + parts.push(format!("{total} on GitHub")); + } + Some( + parts + .into_iter() + .chain( + status_counts(self) + .into_iter() + .map(|(status, n)| format!("{n} {status}")), + ) + .collect::>() + .join(" · "), + ) + } + + fn body(&self, ctx: &RenderContext<'_>) { + heading(ctx, "Lint packages"); + if self.packages.is_empty() { + line(ctx, 1, "none"); + } + let limit = if ctx.presentation.all { usize::MAX } else { 20 }; + for row in self.packages.iter().take(limit) { + let mut head = row.source.clone(); + if let Some(sha) = &row.sha { + head.push('@'); + head.push_str(&sha[..sha.len().min(7)]); + } + head.push_str(" — "); + head.push_str(&row.status); + if row.downloaded == Some(false) { + head.push_str(" (reused)"); + } + if let Some(remote) = &row.remote + && row.status == "updated" + { + head.push_str(&format!(" → {}", &remote[..remote.len().min(7)])); + } + line(ctx, 1, &head); + if let Some(description) = &row.description { + note(ctx, 2, description); + } + if let Some(message) = &row.message { + note(ctx, 2, message); + } + if !row.rules.is_empty() { + note(ctx, 2, &format!("rules: {}", row.rules.join(", "))); + } + if let Some(dir) = &row.dir { + note(ctx, 2, dir); + } + } + if self.packages.len() > limit { + line( + ctx, + 1, + &format!( + "... {} more (--all to see every package)", + self.packages.len() - limit + ), + ); + } + let value = serde_json::to_value(self).unwrap_or_default(); + next(ctx, &value); + } +} diff --git a/crates/wright-cli/src/main.rs b/crates/wright-cli/src/main.rs index 177209b6..9aa7a14a 100644 --- a/crates/wright-cli/src/main.rs +++ b/crates/wright-cli/src/main.rs @@ -2,6 +2,7 @@ mod agent; mod agenttools; mod cli; mod completion; +mod lint; mod mcp; mod plain; mod present; @@ -215,44 +216,7 @@ fn run_workflow(command: Command) -> ExitCode { }, ) } - Command::Lint(args) => { - let mut config = config_from_common(&args.common); - config.selection = selection_from_args(&args.select); - if let Err(message) = args.policy.apply_to(&mut config) { - present::terminal::report_error( - present::Presentation::from_common(&args.common), - "USAGE", - "lint", - &message, - ); - return ExitCode::from(exit::USAGE); - } - let presentation = present::Presentation::from_common(&args.common); - if args.fix { - return run_configured( - "lint", - config, - presentation, - fail_on(&args.common), - None, - move |session| session.lint_fix(args.write), - ); - } - run_configured( - "lint", - config, - presentation.with_all(args.all), - fail_on(&args.common), - None, - |session| { - essential_envelope( - session.lint(), - |value| wright_driver::essential::lint(value, args.all), - |result| result.selection.clone(), - ) - }, - ) - } + Command::Lint(args) => lint::run(args), Command::Inspect(args) => match args.query { None => run_configured( "inspect", @@ -424,7 +388,7 @@ fn run_workflow(command: Command) -> ExitCode { } /// Apply the same essential result projection as the agent service. -fn essential_envelope( +pub(crate) fn essential_envelope( envelope: wright_driver::Envelope, form: impl FnOnce(&serde_json::Value) -> serde_json::Value, outcome: impl FnOnce(&T) -> Option, @@ -435,7 +399,7 @@ fn essential_envelope( }) } -fn run_configured< +pub(crate) fn run_configured< T: serde::Serialize + present::ResultPresentation + plain::PlainReport @@ -777,7 +741,7 @@ fn run_semantic_compare(args: cli::SemanticCompareArgs) -> ExitCode { } } -fn config_from_common(common: &CommonArgs) -> SessionConfig { +pub(crate) fn config_from_common(common: &CommonArgs) -> SessionConfig { let input = match &common.input { Some(path) if path.as_os_str() != "-" => InputSpec::Path(path.clone()), Some(_) => InputSpec::Stdin, @@ -820,13 +784,13 @@ fn config_from_common(common: &CommonArgs) -> SessionConfig { /// `--fail-on` (ADR-0030 decision 13): the CLI gating threshold applied to /// the finished envelope — `None` leaves the default exit codes untouched. -fn fail_on(common: &CommonArgs) -> Option { +pub(crate) fn fail_on(common: &CommonArgs) -> Option { common.fail_on.map(cli::SeverityArg::as_severity) } /// Map the CLI finding-selection flags onto the shared driver selection /// model; the driver applies it to diagnostics and lint findings alike (#430). -fn selection_from_args(select: &cli::SelectArgs) -> wright_driver::FindingSelection { +pub(crate) fn selection_from_args(select: &cli::SelectArgs) -> wright_driver::FindingSelection { wright_driver::FindingSelection { severity: select.severity.map(cli::SeverityArg::as_severity), rule: select.rule_id.clone(), diff --git a/crates/wright-cli/src/plain.rs b/crates/wright-cli/src/plain.rs index 72701407..c69f5c56 100644 --- a/crates/wright-cli/src/plain.rs +++ b/crates/wright-cli/src/plain.rs @@ -141,12 +141,8 @@ fn verdict_word(envelope: &Envelope } match summary_status(envelope) { "WARN" => "WARN".to_string(), - _ => match envelope.command.as_str() { - "rules" | "symbols" | "refs" | "cfg" | "callgraph" | "cost" | "inspect" | "lookup" => { - envelope.command.to_uppercase() - } - _ => "PASS".to_string(), - }, + _ if crate::cli::is_query_command(&envelope.command) => envelope.command.to_uppercase(), + _ => "PASS".to_string(), } } @@ -415,7 +411,7 @@ fn scalar(value: &str) -> String { /// `truncated: =/ … expand=""` — the record a /// capped list is followed by. -fn truncated_record(lists: &[(&str, usize, usize)], expand: &str) -> String { +pub(crate) fn truncated_record(lists: &[(&str, usize, usize)], expand: &str) -> String { let mut parts: Vec = lists .iter() .map(|(name, shown, total)| format!("{name}={shown}/{total}")) @@ -429,7 +425,7 @@ fn truncated_record(lists: &[(&str, usize, usize)], expand: &str) -> String { /// The default expansion command for a capped list of this command's /// output: `--all` lifts the default cap; a `--max` selection is raised by /// `selected:`'s own expand field. -fn expand_all(envelope: &Envelope) -> String { +pub(crate) fn expand_all(envelope: &Envelope) -> String { format!("wright {} --all", cli_command(envelope)) } @@ -440,6 +436,7 @@ fn cli_command(envelope: &Envelope) -> String { name @ ("rules" | "symbols" | "refs" | "cfg" | "callgraph" | "cost") => { format!("inspect {name}") } + name if name.starts_with("package-") => format!("lint {}", name.replacen('-', " ", 1)), name => name.to_string(), } } diff --git a/crates/wright-cli/src/present/terminal.rs b/crates/wright-cli/src/present/terminal.rs index 115bb580..3817fdee 100644 --- a/crates/wright-cli/src/present/terminal.rs +++ b/crates/wright-cli/src/present/terminal.rs @@ -61,10 +61,7 @@ fn tag_word(envelope: &Envelope) -> /// Commands that report facts rather than judge a program: their tag is /// their name, drawn in the neutral hue. fn is_query(command: &str) -> bool { - matches!( - command, - "inspect" | "rules" | "symbols" | "refs" | "cfg" | "callgraph" | "cost" | "lookup" - ) + crate::cli::is_query_command(command) } /// The tag cell — ` WORD ` filled with the semantic hue under colour. The @@ -133,7 +130,7 @@ pub(crate) fn exit_word(code: u8) -> &'static str { } /// `{n} {singular}` or `{n} {plural}` — counts never carry `(s)` suffixes. -fn plural(n: usize, singular: &str, plural: &str) -> String { +pub(crate) fn plural(n: usize, singular: &str, plural: &str) -> String { if n == 1 { format!("{n} {singular}") } else { @@ -154,17 +151,17 @@ fn pad(ctx: &RenderContext<'_>, depth: usize) -> String { } } -fn line(ctx: &RenderContext<'_>, depth: usize, text: &str) { +pub(crate) fn line(ctx: &RenderContext<'_>, depth: usize, text: &str) { println!("{}{text}", pad(ctx, depth)); } -fn note(ctx: &RenderContext<'_>, depth: usize, text: &str) { +pub(crate) fn note(ctx: &RenderContext<'_>, depth: usize, text: &str) { println!("{}{}", pad(ctx, depth), dim(text, ctx.presentation.color)); } /// A named section at the report's top level, one blank line before it. /// Headings are bold under colour — a text style, not a filled element. -fn heading(ctx: &RenderContext<'_>, text: &str) { +pub(crate) fn heading(ctx: &RenderContext<'_>, text: &str) { if ctx.presentation.color { println!("\n\x1b[1m{text}\x1b[0m"); } else { @@ -644,7 +641,7 @@ fn withheld_note(ctx: &RenderContext<'_>, value: &Value, noun: &str) { /// `Next` — the follow-up commands a result recommends (ADR-0030): last in /// the report, one command per line. -fn next(ctx: &RenderContext<'_>, value: &Value) { +pub(crate) fn next(ctx: &RenderContext<'_>, value: &Value) { let Some(commands) = value["next"] .as_array() .filter(|commands| !commands.is_empty()) diff --git a/crates/wright-cli/tests/lint_package.rs b/crates/wright-cli/tests/lint_package.rs new file mode 100644 index 00000000..afc93218 --- /dev/null +++ b/crates/wright-cli/tests/lint_package.rs @@ -0,0 +1,353 @@ +//! `wright lint package` integration tests (#844): discovery, install, +//! and list. Every remote answer is a recorded response from a localhost +//! HTTP server — the `WRIGHT_LINT_PACKAGE_*` endpoint overrides point the +//! command at it — and every store lives in a per-test temp dir. No test +//! touches real network or real GitHub. + +mod support; + +use support::*; + +#[test] +fn search_lists_the_topic_packages_with_stable_records() { + let dirs = TestDirs::new("search"); + let server = TestServer::serve(|request| { + if request.path.starts_with("/search/repositories?") { + Response::json(&search_page( + &[ + ("community/rules", Some("minimum wait rules")), + ("team/extra-rules", None), + ], + 2, + )) + } else { + Response::not_found() + } + }); + let output = run( + &dirs, + Some(&server), + &["lint", "package", "search", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert_eq!( + text, + "exit 0\n== stdout\n\ + PACKAGE-SEARCH package-search\n\ + summary: packages=2 store=\n\ + package: source=community/rules status=found description=\"minimum wait rules\"\n\ + package: source=team/extra-rules status=found\n\ + next: wright lint package install \n\ + \n\ + == stderr\n", + "{text}" + ); + let requests = server.paths(); + assert_eq!(requests.len(), 1); + assert!( + requests[0].starts_with( + "/search/repositories?q=topic:wrightkit-lint%20archived:false%20fork:false" + ), + "the topic filter applies: {}", + requests[0] + ); +} + +#[test] +fn search_terms_narrow_the_query_and_search_errors_report_their_code() { + let dirs = TestDirs::new("search-fail"); + let server = TestServer::serve(|request| { + if request.path.starts_with("/search/repositories?") { + Response::json(&search_page(&[("community/rules", None)], 1)) + } else { + Response::not_found() + } + }); + let output = run( + &dirs, + Some(&server), + &["lint", "package", "search", "wait", "--renderer", "plain"], + ); + assert_eq!(output.status.code(), Some(0)); + assert!( + server.paths()[0].contains("fork:false%20wait&per_page=100"), + "terms append to the topic query: {}", + server.paths()[0] + ); + + let limited = TestServer::serve(|_| Response { + status: "403 Forbidden", + headers: vec![ + ("x-ratelimit-remaining".to_string(), "0".to_string()), + ("x-ratelimit-reset".to_string(), "1893456000".to_string()), + ], + body: b"API rate limit exceeded".to_vec(), + }); + let output = run( + &dirs, + Some(&limited), + &["lint", "package", "search", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("ERROR package-search") + && text.contains("diagnostic: error lint-package-rate-limited"), + "{text}" + ); + assert_eq!(output.status.code(), Some(4)); +} + +#[test] +fn install_fetches_into_the_store_and_never_activates() { + let dirs = TestDirs::new("install"); + let server = package_server("community", "rules", SHA_A, package_tarball(RULE)); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert_eq!( + text, + "exit 0\n== stdout\n\ + PASS package-install\n\ + summary: packages=1 store=\n\ + package: source=community/rules status=installed sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa dir=/community/rules/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa rules=community/minimum-wait downloaded=true\n\ + next: wright lint package list\n\ + next: wright lint package outdated\n\ + \n\ + == stderr\n", + "{text}" + ); + // The package landed under /// and nothing else was + // written — no project files were created or modified. + assert_eq!( + std::fs::read_to_string( + dirs.root + .join("community/rules") + .join(SHA_A) + .join("rules/rule.yaml") + ) + .unwrap(), + RULE + ); + assert!( + std::fs::read_dir(&dirs.cwd).unwrap().next().is_none(), + "install writes only to the store" + ); + + // A second install reuses the intact entry — no second download. + let again = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &again); + assert!(text.contains("downloaded=false"), "{text}"); + assert!( + !server + .paths() + .iter() + .any(|p| p.contains("tar.gz") && server.paths().iter().filter(|q| *q == p).count() > 1), + "one archive fetch total: {:?}", + server.paths() + ); +} + +#[test] +fn install_accepts_ref_spellings_and_a_full_sha() { + let dirs = TestDirs::new("install-refs"); + let server = package_server("community", "rules", SHA_A, package_tarball(RULE)); + + // `owner/repo@` resolves through the commits API. + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "community/rules@v1", + "--renderer", + "plain", + ], + ); + assert_eq!( + output.status.code(), + Some(0), + "{}", + transcript(&dirs, &output) + ); + assert!( + server + .paths() + .contains(&"/repos/community/rules/commits/v1".to_string()) + ); + + // A full SHA needs no resolution at all. + let dirs = TestDirs::new("install-refs-sha"); + let server = package_server("community", "rules", SHA_B, package_tarball(RULE)); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + &format!("community/rules@{SHA_B}"), + "--renderer", + "plain", + ], + ); + assert_eq!( + output.status.code(), + Some(0), + "{}", + transcript(&dirs, &output) + ); + assert!( + !server.paths().iter().any(|p| p.starts_with("/repos/")), + "a full SHA skips the commits API: {:?}", + server.paths() + ); + + // A URL source selects the repository; --ref selects the revision. + let dirs = TestDirs::new("install-refs-url"); + let server = package_server("community", "rules", SHA_A, package_tarball(RULE)); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "https://github.com/community/rules", + "--ref", + "v1", + "--renderer", + "plain", + ], + ); + assert_eq!( + output.status.code(), + Some(0), + "{}", + transcript(&dirs, &output) + ); +} + +#[test] +fn install_failures_report_stable_error_codes() { + let dirs = TestDirs::new("install-errors"); + let gone = TestServer::serve(|_| Response::not_found()); + let output = run( + &dirs, + Some(&gone), + &[ + "lint", + "package", + "install", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("ERROR package-install") + && text.contains("diagnostic: error lint-package-not-found"), + "{text}" + ); + assert_eq!(output.status.code(), Some(4)); + + let malformed = tarball(&[("o-r-bad/rules/bad.yaml", b"id: bad\nmetadata: []\n")]); + let server = package_server("community", "rules", SHA_A, malformed); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("ERROR package-install") + && text.contains("diagnostic: error lint-package-invalid"), + "{text}" + ); + assert_eq!(output.status.code(), Some(1)); + assert!( + !entry(&dirs.root, "community", "rules", SHA_A).exists(), + "a refused package leaves no partial store entry" + ); + + // A malformed source is a usage error, not a remote failure. + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "gitlab.com/community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("USAGE package-install") + && text.contains("diagnostic: error lint-package-source"), + "{text}" + ); + assert_eq!(output.status.code(), Some(2)); +} + +#[test] +fn list_reports_installed_and_damaged_entries() { + let dirs = TestDirs::new("list"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + // A damaged entry reports `damaged` with the reason it fails validation. + let damaged = entry(&dirs.root, "team", "pkg", SHA_B).join("rules"); + std::fs::create_dir_all(&damaged).unwrap(); + std::fs::write(damaged.join("bad.yaml"), b"id: bad\nmetadata: []\n").unwrap(); + let output = run( + &dirs, + None, + &["lint", "package", "list", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("PACKAGE-LIST package-list"), "{text}"); + assert!( + text.contains("summary: packages=2 installed=1 damaged=1 store="), + "{text}" + ); + assert!( + text.contains("package: source=community/rules status=installed sha=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa dir=/community/rules/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa rules=community/minimum-wait downloaded=false"), + "{text}" + ); + assert!( + text.contains("package: source=team/pkg status=damaged") + && text.contains("code=lint-package-invalid"), + "{text}" + ); +} diff --git a/crates/wright-cli/tests/lint_package_lifecycle.rs b/crates/wright-cli/tests/lint_package_lifecycle.rs new file mode 100644 index 00000000..ea8910e7 --- /dev/null +++ b/crates/wright-cli/tests/lint_package_lifecycle.rs @@ -0,0 +1,355 @@ +//! `wright lint package` integration tests (#844): the remote-check +//! commands — outdated, update, remove — plus the JSON envelope and +//! usage contract. Remote answers come from the recorded-response +//! localhost server; no test touches real network or real GitHub. + +mod support; + +use support::*; + +#[test] +fn outdated_reports_every_state_without_mutating_the_store() { + let dirs = TestDirs::new("outdated"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + write_entry(&dirs.root, "team", "moved", SHA_A, RULE); + write_entry(&dirs.root, "gone", "pkg", SHA_A, RULE); + let damaged = entry(&dirs.root, "bad", "entry", SHA_A).join("rules"); + std::fs::create_dir_all(&damaged).unwrap(); + let server = TestServer::serve(|request| match request.path.as_str() { + "/repos/community/rules/commits?per_page=1" => { + Response::json(&format!("[{{\"sha\":\"{SHA_A}\"}}]")) + } + "/repos/team/moved/commits?per_page=1" => { + Response::json(&format!("[{{\"sha\":\"{SHA_B}\"}}]")) + } + _ => Response::not_found(), + }); + let output = run( + &dirs, + Some(&server), + &["lint", "package", "outdated", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("PACKAGE-OUTDATED package-outdated"), "{text}"); + assert!( + text.contains( + "summary: packages=4 unchanged=1 updated=1 unavailable=1 error=1 store=" + ), + "{text}" + ); + assert!( + text.contains("package: source=bad/entry status=error"), + "the damaged entry is an error row, unchecked: {text}" + ); + assert!( + text.contains("package: source=community/rules status=unchanged") + && text.contains(&format!("remote={SHA_A}")), + "{text}" + ); + assert!( + text.contains("package: source=gone/pkg status=unavailable"), + "{text}" + ); + assert!( + text.contains("package: source=team/moved status=updated") + && text.contains(&format!("remote={SHA_B}")), + "{text}" + ); + // Nothing was applied — the entries are untouched and no archive was + // ever fetched. + assert!( + !server.paths().iter().any(|p| p.contains("tar.gz")), + "outdated never fetches: {:?}", + server.paths() + ); + assert!( + entry(&dirs.root, "team", "moved", SHA_A) + .join("rules/rule.yaml") + .exists() + ); + assert!(!entry(&dirs.root, "team", "moved", SHA_B).exists()); +} + +#[test] +fn update_installs_the_new_head_and_keeps_the_old_entry() { + let dirs = TestDirs::new("update"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + write_entry(&dirs.root, "team", "current", SHA_A, RULE); + let archive = package_tarball(RULE); + let server = TestServer::serve(move |request| match request.path.as_str() { + "/repos/community/rules/commits?per_page=1" => { + Response::json(&format!("[{{\"sha\":\"{SHA_B}\"}}]")) + } + "/repos/team/current/commits?per_page=1" => { + Response::json(&format!("[{{\"sha\":\"{SHA_A}\"}}]")) + } + p if p == format!("/community/rules/tar.gz/{SHA_B}") => Response::ok(archive.clone()), + _ => Response::not_found(), + }); + let output = run( + &dirs, + Some(&server), + &["lint", "package", "update", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("PASS package-update"), "{text}"); + assert!( + text.contains("package: source=community/rules status=updated") + && text.contains(&format!("sha={SHA_A}")) + && text.contains(&format!("remote={SHA_B}")) + && text.contains(&format!("dir=/community/rules/{SHA_B}")), + "{text}" + ); + assert!( + text.contains("package: source=team/current status=unchanged"), + "{text}" + ); + // The old entry is immutable — both revisions remain installed. + assert!( + entry(&dirs.root, "community", "rules", SHA_A) + .join("rules/rule.yaml") + .exists() + ); + assert!( + entry(&dirs.root, "community", "rules", SHA_B) + .join("rules/rule.yaml") + .exists() + ); + assert_eq!( + server + .paths() + .iter() + .filter(|p| p.contains("tar.gz")) + .count(), + 1, + "only the moved package was fetched: {:?}", + server.paths() + ); +} + +#[test] +fn update_of_one_source_updates_only_that_repository() { + let dirs = TestDirs::new("update-source"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + write_entry(&dirs.root, "team", "other", SHA_A, RULE); + let archive = package_tarball(RULE); + let server = TestServer::serve(move |request| match request.path.as_str() { + "/repos/community/rules/commits?per_page=1" => { + Response::json(&format!("[{{\"sha\":\"{SHA_B}\"}}]")) + } + p if p == format!("/community/rules/tar.gz/{SHA_B}") => Response::ok(archive.clone()), + _ => Response::not_found(), + }); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "update", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("packages=1"), "{text}"); + assert!( + !server.paths().iter().any(|p| p.contains("/repos/team/")), + "{text}" + ); + // A repository that is not installed is a not-found error, not a no-op. + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "update", + "missing/pkg", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("ERROR package-update") + && text.contains("diagnostic: error lint-package-not-found"), + "{text}" + ); + assert_eq!(output.status.code(), Some(4)); +} + +#[test] +fn update_fails_on_row_errors_and_refuses_damaged_entries() { + let dirs = TestDirs::new("update-fail"); + write_entry(&dirs.root, "gone", "pkg", SHA_A, RULE); + // A locally edited managed file fails store-entry validation — the + // damaged entry is refused, never replaced. + let damaged = write_entry(&dirs.root, "bad", "entry", SHA_A, RULE); + std::fs::write(damaged.join("rules/rule.yaml"), b"locally edited").unwrap(); + let server = TestServer::serve(|_| Response::not_found()); + let output = run( + &dirs, + Some(&server), + &["lint", "package", "update", "--renderer", "plain"], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("FAIL package-update"), "{text}"); + assert!( + text.contains("package: source=bad/entry status=error") + && text.contains("code=lint-package-invalid"), + "{text}" + ); + assert!( + text.contains("package: source=gone/pkg status=unavailable"), + "{text}" + ); + assert!( + text.contains("diagnostic: error lint-package-invalid") + && text.contains("diagnostic: error lint-package-not-found"), + "{text}" + ); + // The first failure — the damaged entry's invalid content — is a + // source error, like a failing rule file during `lint`. + assert_eq!(output.status.code(), Some(1)); + // The locally edited file was never replaced. + assert_eq!( + std::fs::read_to_string(damaged.join("rules/rule.yaml")).unwrap(), + "locally edited" + ); +} + +#[test] +fn remove_deletes_managed_content_and_keeps_user_files() { + let dirs = TestDirs::new("remove"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + let pruned = write_entry(&dirs.root, "community", "rules", SHA_B, RULE); + std::fs::write(pruned.join("notes.txt"), b"mine").unwrap(); + let output = run( + &dirs, + None, + &[ + "lint", + "package", + "remove", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("PASS package-remove"), "{text}"); + assert!( + text.contains(&format!( + "package: source=community/rules status=removed sha={SHA_A}" + )), + "{text}" + ); + assert!( + text.contains(&format!( + "package: source=community/rules status=pruned sha={SHA_B}" + )), + "{text}" + ); + assert!(!entry(&dirs.root, "community", "rules", SHA_A).exists()); + assert!(!pruned.join("rules").exists()); + assert_eq!( + std::fs::read_to_string(pruned.join("notes.txt")).unwrap(), + "mine" + ); + + // Removing nothing is a not-found error. + let output = run( + &dirs, + None, + &[ + "lint", + "package", + "remove", + "community/rules", + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!( + text.contains("ERROR package-remove") + && text.contains("diagnostic: error lint-package-not-found"), + "{text}" + ); + assert_eq!(output.status.code(), Some(4)); +} + +#[test] +fn remove_at_ref_removes_only_the_matching_entry() { + let dirs = TestDirs::new("remove-ref"); + write_entry(&dirs.root, "community", "rules", SHA_A, RULE); + write_entry(&dirs.root, "community", "rules", SHA_B, RULE); + let output = run( + &dirs, + None, + &[ + "lint", + "package", + "remove", + &format!("community/rules@{SHA_A}"), + "--renderer", + "plain", + ], + ); + let text = transcript(&dirs, &output); + assert!(text.contains("packages=1"), "{text}"); + assert!(!entry(&dirs.root, "community", "rules", SHA_A).exists()); + assert!(entry(&dirs.root, "community", "rules", SHA_B).exists()); +} + +#[test] +fn the_json_envelope_is_machine_readable() { + let dirs = TestDirs::new("json"); + let server = package_server("community", "rules", SHA_A, package_tarball(RULE)); + let output = run( + &dirs, + Some(&server), + &[ + "lint", + "package", + "install", + "community/rules", + "-f", + "json", + ], + ); + assert_eq!(output.status.code(), Some(0)); + let value: serde_json::Value = serde_json::from_slice(&output.stdout).expect("JSON envelope"); + assert_eq!(value["command"], "package-install"); + assert_eq!(value["ok"], true); + assert_eq!(value["exit"], 0); + assert_eq!(value["wright"]["contract"], "wright-result/v1"); + let row = &value["result"]["packages"][0]; + assert_eq!(row["source"], "community/rules"); + assert_eq!(row["status"], "installed"); + assert_eq!(row["sha"], SHA_A); + assert_eq!(row["downloaded"], true); + assert_eq!(row["rules"], serde_json::json!(["community/minimum-wait"])); + assert!(row["dir"].as_str().unwrap().contains("community/rules/")); + assert_eq!( + value["result"]["store"], + dirs.root.display().to_string().as_str() + ); + // No token or endpoint plumbing leaks into the wire form. + let text = String::from_utf8_lossy(&output.stdout); + assert!(!text.contains("WRIGHT_LINT_PACKAGE_API_BASE")); +} + +#[test] +fn a_bare_lint_package_is_a_usage_error() { + let dirs = TestDirs::new("bare"); + let output = run(&dirs, None, &["lint", "package"]); + assert_eq!(output.status.code(), Some(2)); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("search") || stderr.contains("subcommand"), + "{stderr}" + ); +} diff --git a/crates/wright-cli/tests/support/mod.rs b/crates/wright-cli/tests/support/mod.rs new file mode 100644 index 00000000..44495ec3 --- /dev/null +++ b/crates/wright-cli/tests/support/mod.rs @@ -0,0 +1,297 @@ +//! Test support for the `lint package` integration suites: the +//! recorded-response localhost server, tarball builder, temp dirs, and +//! `wright` process runner shared by `lint_package.rs` and +//! `lint_package_lifecycle.rs`. + +#![allow(dead_code)] + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::process::{Command, Output, Stdio}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +pub(crate) const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +pub(crate) const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +pub(crate) const RULE: &str = "\ +id: community/minimum-wait +metadata: + summary: loop contains a minimum wait + rationale: minimum waits can create high-frequency loops + documentation: Finds a minimum wait inside a while scope. + known-limits: This is structural and does not measure runtime cost. + tags: [performance] +matcher: + scope: while + actions: + - kind: call +"; + +pub(crate) fn wright() -> &'static str { + env!("CARGO_BIN_EXE_wright") +} + +pub(crate) fn tarball(files: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (path, body) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, path, &body[..]).unwrap(); + } + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&builder.into_inner().unwrap()).unwrap(); + encoder.finish().unwrap() +} + +pub(crate) fn package_tarball(rule: &str) -> Vec { + tarball(&[ + ("o-r-aaaaaaaa/rules/rule.yaml", rule.as_bytes()), + ("o-r-aaaaaaaa/README.md", b"# package"), + ("o-r-aaaaaaaa/src/tool.sh", b"#!/bin/sh\n"), + ]) +} + +#[derive(Clone)] +pub(crate) struct Request { + pub(crate) path: String, +} + +pub(crate) struct Response { + pub(crate) status: &'static str, + pub(crate) headers: Vec<(String, String)>, + pub(crate) body: Vec, +} + +impl Response { + pub(crate) fn ok(body: Vec) -> Self { + Self { + status: "200 OK", + headers: Vec::new(), + body, + } + } + pub(crate) fn json(body: &str) -> Self { + Self::ok(body.as_bytes().to_vec()) + } + pub(crate) fn not_found() -> Self { + Self { + status: "404 Not Found", + headers: Vec::new(), + body: b"not found".to_vec(), + } + } +} + +/// A localhost HTTP server replaying the handler's recorded responses. +pub(crate) struct TestServer { + pub(crate) addr: SocketAddr, + pub(crate) stop: Arc, + pub(crate) requests: Arc>>, + pub(crate) thread: Option>, +} + +impl TestServer { + pub(crate) fn serve(handler: impl Fn(&Request) -> Response + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (stop_flag, seen) = (Arc::clone(&stop), Arc::clone(&requests)); + let thread = thread::spawn(move || { + loop { + match listener.accept() { + Ok((mut stream, _)) => { + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); + let request = read_request(&mut stream); + let response = handler(&request); + write_response(&mut stream, &response); + seen.lock().unwrap().push(request); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if stop_flag.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + }); + Self { + addr, + stop, + requests, + thread: Some(thread), + } + } + + pub(crate) fn url(&self) -> String { + format!("http://{}", self.addr) + } + + pub(crate) fn paths(&self) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .map(|r| r.path.clone()) + .collect() + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub(crate) fn read_request(stream: &mut TcpStream) -> Request { + let mut raw = Vec::new(); + let mut buffer = [0; 4096]; + while !raw.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => raw.extend_from_slice(&buffer[..n]), + } + } + let text = String::from_utf8_lossy(&raw); + let mut request_line = text.lines().next().unwrap_or_default().split_whitespace(); + let path = request_line.nth(1).unwrap_or_default().to_string(); + Request { path } +} + +pub(crate) fn write_response(stream: &mut TcpStream, response: &Response) { + let mut head = format!("HTTP/1.1 {}\r\n", response.status); + for (name, value) in &response.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n", + response.body.len() + )); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&response.body); +} + +/// A temp dir as the store root plus a scratch cwd, both per-test. +pub(crate) struct TestDirs { + pub(crate) root: PathBuf, + pub(crate) cwd: PathBuf, +} + +impl TestDirs { + pub(crate) fn new(name: &str) -> Self { + let root = + std::env::temp_dir().join(format!("wright-pkg-test-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let cwd = root.join("cwd"); + std::fs::create_dir_all(&cwd).unwrap(); + Self { + root: root.join("store"), + cwd, + } + } +} + +impl Drop for TestDirs { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(self.root.parent().unwrap_or(&self.root)); + } +} + +/// Run the CLI in the test cwd with the store and the server's endpoints +/// wired through the `WRIGHT_LINT_PACKAGE_*` environment overrides. +pub(crate) fn run(dirs: &TestDirs, server: Option<&TestServer>, args: &[&str]) -> Output { + let mut command = Command::new(wright()); + command + .args(args) + .current_dir(&dirs.cwd) + .stdin(Stdio::null()) + .env_remove("CI") + .env_remove("GITHUB_ACTIONS") + .env_remove("GITHUB_STEP_SUMMARY") + .env_remove("NO_COLOR") + .env_remove("FORCE_COLOR") + .env_remove("GITHUB_TOKEN") + .env("WRIGHT_LINT_PACKAGE_DIR", &dirs.root); + if let Some(server) = server { + command + .env("WRIGHT_LINT_PACKAGE_API_BASE", server.url()) + .env("WRIGHT_LINT_PACKAGE_CODELOAD_BASE", server.url()); + } + command.output().expect("wright runs") +} + +/// The transcript with the temp store root normalized to `` so the +/// records stay byte-deterministic across runs. +pub(crate) fn transcript(dirs: &TestDirs, output: &Output) -> String { + let text = format!( + "exit {}\n== stdout\n{}\n== stderr\n{}", + output.status.code().unwrap_or(-1), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr), + ); + text.replace(&dirs.root.display().to_string(), "") +} + +/// Write a store entry directly: `////rules/`. +pub(crate) fn write_entry(root: &Path, owner: &str, repo: &str, sha: &str, rule: &str) -> PathBuf { + let dir = root.join(owner).join(repo).join(sha); + std::fs::create_dir_all(dir.join("rules")).unwrap(); + std::fs::write(dir.join("rules").join("rule.yaml"), rule).unwrap(); + dir +} + +pub(crate) fn entry(root: &Path, owner: &str, repo: &str, sha: &str) -> PathBuf { + root.join(owner).join(repo).join(sha) +} + +pub(crate) fn search_page(items: &[(&str, Option<&str>)], total: u64) -> String { + let items: Vec = items + .iter() + .map(|(full_name, description)| { + let description = description + .map(|d| format!("\"{d}\"")) + .unwrap_or_else(|| "null".to_string()); + format!("{{\"full_name\":\"{full_name}\",\"description\":{description}}}") + }) + .collect(); + format!( + "{{\"total_count\":{total},\"incomplete_results\":false,\"items\":[{}]}}", + items.join(",") + ) +} + +/// The commits-API + codeload answers one package needs: `sha` as the +/// default head and as `v1`'s target, `archive` at ``. +pub(crate) fn package_server( + owner: &'static str, + repo: &'static str, + sha: &'static str, + archive: Vec, +) -> TestServer { + let owner = owner.to_string(); + let repo = repo.to_string(); + TestServer::serve(move |request| { + if request.path == format!("/repos/{owner}/{repo}/commits?per_page=1") { + Response::json(&format!("[{{\"sha\":\"{sha}\"}}]")) + } else if request.path == format!("/repos/{owner}/{repo}/commits/v1") { + Response::json(&format!("{{\"sha\":\"{sha}\"}}")) + } else if request.path == format!("/{owner}/{repo}/tar.gz/{sha}") { + Response::ok(archive.clone()) + } else { + Response::not_found() + } + }) +} diff --git a/crates/wright-driver/src/lib.rs b/crates/wright-driver/src/lib.rs index 37c4456b..0a9193d1 100644 --- a/crates/wright-driver/src/lib.rs +++ b/crates/wright-driver/src/lib.rs @@ -29,7 +29,8 @@ pub use config::{ pub use diag::{Diagnostic, Origin, Position, Severity, SourceSpan, Stage, Status}; pub use input::{InputTarget, ResolvedInput, sha256_hex}; pub use lint_package::{ - InstalledPackage, LintPackageError, LintPackageSource, LintPackageStore, RemoteCheck, + InstalledPackage, LintPackageError, LintPackageSource, LintPackageStore, PackageSearch, + PackageSearchHit, RemoteCheck, RemovedPackage, StoreEntry, }; pub use opy_provider::{ OpyProviderConfig, OpyProviderError, OpyProviderResolver, ResolvedOpyProvider, @@ -38,7 +39,8 @@ pub use output::{CompileWrite, OutputDest, Overwrite}; pub use progress::{ProgressEvent, ProgressObserver, ProgressPhase, ProgressUnit}; pub use result::{ AnalyzeResult, CheckResult, CompileResult, CompiledOutput, ConvertResult, ConvertTarget, - Envelope, EssentialResult, InspectResult, LintResult, RESULT_CONTRACT, + Envelope, EssentialResult, InspectResult, LintPackageResult, LintPackageRow, LintResult, + RESULT_CONTRACT, }; pub use select::{FindingSelection, SelectionOutcome}; pub use session::{CompilerSession, Loaded, Provenance, WORKSHOP_CLIENT_ELEMENT_LIMIT}; diff --git a/crates/wright-driver/src/lint_package.rs b/crates/wright-driver/src/lint_package.rs index 7dc2c25f..714edcaa 100644 --- a/crates/wright-driver/src/lint_package.rs +++ b/crates/wright-driver/src/lint_package.rs @@ -43,6 +43,64 @@ const STORE_DIR_ENV: &str = "WRIGHT_LINT_PACKAGE_DIR"; /// action tracked by wrightkit/wright#845. pub const FIRST_PARTY_PACKAGE: &str = "wrightkit/lint-rules"; +/// One store entry directory discovered under `////`. +/// The directory names carry the source identity and the immutable revision; +/// an intact entry validates as a package, a `damaged` entry carries the +/// reason it does not (`lint-package-install`). +#[derive(Debug)] +pub struct StoreEntry { + /// The package source, from the `/` directory names. + pub source: LintPackageSource, + /// The full commit SHA the entry is keyed by. + pub sha: String, + /// The entry directory `////`. + pub dir: PathBuf, + /// The validated SPEC-309 rule IDs of an intact entry; empty for a + /// damaged one. + pub rules: Vec, + /// Why the entry does not validate as a package, when it is damaged — + /// missing `rules/`, no rule files, or locally edited managed content + /// that no longer loads. + pub damage: Option, +} + +/// The outcome of `LintPackageStore::remove`: the entry's managed `rules/` +/// tree is gone, and `dir_removed` reports whether the entry directory was +/// deleted too — it survives when unrelated, non-managed files remain. +#[derive(Debug)] +pub struct RemovedPackage { + /// The package source. + pub source: LintPackageSource, + /// The removed entry's full commit SHA. + pub sha: String, + /// The entry directory. + pub dir: PathBuf, + /// Whether the entry directory was removed. `false` means unrelated + /// files beside `rules/` keep it — managed content is still gone. + pub dir_removed: bool, +} + +/// One repository found by `LintPackageStore::search` on the +/// `wrightkit-lint` topic. +#[derive(Debug)] +pub struct PackageSearchHit { + /// The `owner/repo` source identity. + pub source: LintPackageSource, + /// The repository description, when GitHub returns one. + pub description: Option, +} + +/// The paged result of a `topic:wrightkit-lint` repository search. +#[derive(Debug)] +pub struct PackageSearch { + /// The `total_count` GitHub reported across all pages. + pub total: u64, + /// Whether GitHub reported incomplete results. + pub incomplete: bool, + /// The repositories found, in API order. + pub hits: Vec, +} + /// One installed package directory in the store. #[derive(Debug, Clone, PartialEq, Eq)] pub struct InstalledPackage { @@ -234,6 +292,179 @@ impl LintPackageStore { }) } + /// Every store entry — `////` directories + /// still carrying a `rules/` tree — in stable `owner/repo/sha` order. + /// Damaged entries (an unloadable or empty `rules/`) are included and + /// carry their refusal reason in `damage`; directories that cannot be + /// package entries (invalid names, non-SHA names, leftover staging, or + /// an entry dir whose `rules/` is already gone) are skipped. A missing + /// store root reports an empty store. + pub fn entries(&self) -> Result, LintPackageError> { + let read = |dir: &Path| -> Result, LintPackageError> { + match std::fs::read_dir(dir) { + Ok(d) => Ok(d + .flatten() + .map(|e| e.path()) + .filter(|p| p.is_dir()) + .collect()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Vec::new()), + Err(e) => Err(LintPackageError::install(format!( + "cannot read the lint package store '{}': {e}", + dir.display() + ))), + } + }; + let mut entries = Vec::new(); + for owner in read(&self.root)? { + let Some(owner_name) = owner.file_name().map(|n| n.to_string_lossy()) else { + continue; + }; + for repo in read(&owner)? { + let Some(repo_name) = repo.file_name().map(|n| n.to_string_lossy()) else { + continue; + }; + let Ok(source) = LintPackageSource::new(&owner_name, &repo_name) else { + continue; + }; + for dir in read(&repo)? { + let Some(sha) = dir.file_name().map(|n| n.to_string_lossy()) else { + continue; + }; + // An entry without its managed `rules/` tree is not a + // package — leftover debris from `remove` (the entry dir + // a user-owned file kept) or an abandoned partial write. + if !is_full_sha(&sha) || !dir.join("rules").is_dir() { + continue; + } + let sha = sha.to_lowercase(); + let (rules, damage) = + match rules::validate_stored(&dir, source.is_first_party()) { + Ok(rules) => (rules, None), + Err(error) => (Vec::new(), Some(error)), + }; + entries.push(StoreEntry { + source: source.clone(), + sha, + dir, + rules, + damage, + }); + } + } + } + entries.sort_by(|a, b| { + a.source + .slug() + .cmp(&b.source.slug()) + .then_with(|| a.sha.cmp(&b.sha)) + }); + Ok(entries) + } + + /// Remove one store entry's managed content: its `rules/` tree, then + /// the entry directory itself only when nothing else remains — a + /// user-owned file beside `rules/` keeps the directory. Empty + /// `/` parent directories are pruned. + pub fn remove( + &self, + source: &LintPackageSource, + sha: &str, + ) -> Result { + let dir = self.package_dir(source, sha); + if !dir.is_dir() { + return Err(LintPackageError::NotFound(format!( + "lint package '{source}' has no store entry at commit {sha}" + ))); + } + let rules_dir = dir.join("rules"); + if rules_dir.is_dir() { + std::fs::remove_dir_all(&rules_dir).map_err(|e| { + LintPackageError::install(format!( + "cannot remove managed lint rules '{}': {e}", + rules_dir.display() + )) + })?; + } + let remaining = std::fs::read_dir(&dir) + .map_err(|e| { + LintPackageError::install(format!( + "cannot inspect lint package entry '{}': {e}", + dir.display() + )) + })? + .next(); + let dir_removed = remaining.is_none(); + if dir_removed { + std::fs::remove_dir(&dir).map_err(|e| { + LintPackageError::install(format!( + "cannot remove lint package entry '{}': {e}", + dir.display() + )) + })?; + // Prune the `/` parents once they are empty. + let mut parent = dir.parent(); + while let Some(path) = parent { + if path == self.root || std::fs::remove_dir(path).is_err() { + break; + } + parent = path.parent(); + } + } + Ok(RemovedPackage { + source: source.clone(), + sha: sha.to_lowercase(), + dir, + dir_removed, + }) + } + + /// Search GitHub for lint rule packages: the + /// `topic:wrightkit-lint archived:false fork:false` repository query, + /// paged at `per_page=100`. `terms`, when given, narrow the query + /// further. Repositories whose `full_name` is not a valid package + /// source are skipped. + pub fn search(&self, terms: Option<&str>) -> Result { + let client = remote::package_client()?; + let mut hits = Vec::new(); + let mut total = 0u64; + let mut incomplete = false; + // GitHub's search API caps at 1000 results: 10 pages of 100. + for page in 1..=10u32 { + let url = remote::search_url(&self.api_base, terms, page); + let body = remote::api_get(&client, &url, self.token.as_deref(), &self.resolved)?; + let page_data = remote::search_page(&body)?; + total = page_data.total; + incomplete |= page_data.incomplete; + let count = page_data.items.len(); + for item in page_data.items { + let Some(full_name) = item.get("full_name").and_then(|v| v.as_str()) else { + continue; + }; + let Some((owner, repo)) = full_name.split_once('/') else { + continue; + }; + let Ok(source) = LintPackageSource::new(owner, repo) else { + continue; + }; + hits.push(PackageSearchHit { + source, + description: item + .get("description") + .and_then(|v| v.as_str()) + .map(str::to_string), + }); + } + if count < 100 || hits.len() as u64 >= total { + break; + } + } + Ok(PackageSearch { + total, + incomplete, + hits, + }) + } + /// Read-only remote check: whether `reference` still resolves to /// `installed_sha`. Applies nothing to the store. pub fn check_remote( diff --git a/crates/wright-driver/src/lint_package/remote.rs b/crates/wright-driver/src/lint_package/remote.rs index 5f4e91a0..9903a08e 100644 --- a/crates/wright-driver/src/lint_package/remote.rs +++ b/crates/wright-driver/src/lint_package/remote.rs @@ -23,6 +23,14 @@ const REF_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUME .remove(b'-') .remove(b'_'); +/// Query-string spellings keep the search qualifier colon plus `.`, `-`, +/// and `_` readable; spaces and other characters are percent-encoded. +const QUERY_ENCODE: &percent_encoding::AsciiSet = &percent_encoding::NON_ALPHANUMERIC + .remove(b':') + .remove(b'.') + .remove(b'-') + .remove(b'_'); + /// An ETag-conditional resolution cache: request URL → the entity tag GitHub /// issued and the response body it was issued for. pub(super) type ResolutionCache = Mutex>; @@ -64,6 +72,44 @@ pub(super) fn archive_url(codeload_base: &str, source: &LintPackageSource, sha: ) } +/// The repository Search API endpoint: the `wrightkit-lint` topic filter +/// plus optional caller terms, paged at `per_page=100` (1-based `page`). +pub(super) fn search_url(api_base: &str, terms: Option<&str>, page: u32) -> String { + let mut query = "topic:wrightkit-lint archived:false fork:false".to_string(); + if let Some(terms) = terms.map(str::trim).filter(|t| !t.is_empty()) { + query.push(' '); + query.push_str(terms); + } + format!( + "{}/search/repositories?q={}&per_page=100&page={page}", + api_base.trim_end_matches('/'), + percent_encoding::utf8_percent_encode(&query, QUERY_ENCODE) + ) +} + +/// One parsed search page: `total_count`, `incomplete_results`, and the +/// raw `items` array. +pub(super) struct SearchPage { + pub(super) total: u64, + pub(super) incomplete: bool, + pub(super) items: Vec, +} + +/// Parse one Search API page; a malformed or non-object answer is a +/// `lint-package-remote` error, matching `resolved_sha`. +pub(super) fn search_page(body: &[u8]) -> Result { + let unexpected = + || LintPackageError::remote("GitHub returned an unexpected repository search response"); + let value: serde_json::Value = serde_json::from_slice(body).map_err(|_| unexpected())?; + let total = value["total_count"].as_u64().ok_or_else(unexpected)?; + let items = value["items"].as_array().ok_or_else(unexpected)?.clone(); + Ok(SearchPage { + total, + incomplete: value["incomplete_results"].as_bool().unwrap_or(false), + items, + }) +} + pub(super) fn package_client() -> Result { reqwest::blocking::Client::builder() .user_agent(concat!("wright-lint-packages/", env!("CARGO_PKG_VERSION"))) diff --git a/crates/wright-driver/src/result.rs b/crates/wright-driver/src/result.rs index 02f92af1..215b0c52 100644 --- a/crates/wright-driver/src/result.rs +++ b/crates/wright-driver/src/result.rs @@ -397,6 +397,72 @@ pub struct ConvertResult { pub sha256: String, } +/// `lint package` result (#844): the outcome of one package lifecycle +/// command — a repository search, an install/list/outdated/update/remove — +/// as one row per repository or store entry, plus the store root the +/// command ran against. +#[derive(Debug, Clone, Default, Serialize)] +pub struct LintPackageResult { + /// The lint package store root (`WRIGHT_LINT_PACKAGE_DIR` or the + /// platform application-data dir) the command ran against. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub store: String, + /// One row per package or repository the command reported on. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub packages: Vec, + /// `package search`: the `total_count` GitHub reported across pages. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + /// `package search`: GitHub's `incomplete_results` flag. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub incomplete: Option, + /// The shared follow-up commands (#792). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub next: Vec, +} + +/// One package row in a `lint package` result: `source` + `status` are +/// always present; every other member appears only when the command that +/// produced the row establishes it. +/// +/// `status` is one of: `found` (search hits), `installed` (install/list +/// rows and reused store entries), `unchanged`, `updated`, `unavailable`, +/// `error` (outdated/update remote checks), `damaged` (list rows that fail +/// store-entry validation), `removed`, or `pruned` (remove rows whose +/// entry directory survives because unrelated files remain). +#[derive(Debug, Clone, Default, Serialize)] +pub struct LintPackageRow { + /// The `owner/repo` source identity. + pub source: String, + /// The row's outcome status. + pub status: String, + /// The store entry's full commit SHA. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sha: Option, + /// The resolved default-branch head on `outdated`/`update` rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub remote: Option, + /// The entry's directory in the store. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub dir: Option, + /// The entry's validated SPEC-309 rule IDs. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub rules: Vec, + /// Whether the archive was downloaded by this command (`false` when an + /// intact store entry was reused). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub downloaded: Option, + /// The repository description on `found` rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The stable error code on `error`/`unavailable`/`damaged` rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub code: Option, + /// The failure detail on `error`/`unavailable`/`damaged` rows. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub message: Option, +} + pub fn version_info() -> VersionInfo { VersionInfo { version: DRIVER_VERSION.to_string(), diff --git a/crates/wright-driver/tests/lint_package.rs b/crates/wright-driver/tests/lint_package.rs index f9636a06..91a8d5af 100644 --- a/crates/wright-driver/tests/lint_package.rs +++ b/crates/wright-driver/tests/lint_package.rs @@ -1,292 +1,14 @@ //! Offline lint package store tests: every remote answer is served by a //! recorded-response HTTP server on localhost; no test touches real network. +mod support; + use std::collections::HashMap; -use std::io::{Read, Write}; -use std::net::{SocketAddr, TcpListener, TcpStream}; -use std::path::{Path, PathBuf}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::{Arc, Mutex}; -use std::thread::{self, JoinHandle}; -use std::time::Duration; +use std::net::TcpListener; use wright_driver::{LintPackageSource, LintPackageStore, RemoteCheck}; -const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; -const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - -const RULE: &str = "\ -id: community/minimum-wait -metadata: - summary: loop contains a minimum wait - rationale: minimum waits can create high-frequency loops - documentation: Finds a minimum wait inside a while scope. - known-limits: This is structural and does not measure runtime cost. - tags: [performance] -matcher: - scope: while - actions: - - kind: call -"; - -const SECOND_RULE: &str = "\ -id: community/has-wait -metadata: - summary: rule contains a wait - rationale: waits yield the scheduler - documentation: Finds a wait call at rule scope. - known-limits: Structural only. - tags: [performance] -matcher: - scope: rule - actions: - - kind: call -"; - -fn tarball(files: &[(&str, &[u8])]) -> Vec { - let mut builder = tar::Builder::new(Vec::new()); - for (path, body) in files { - let mut header = tar::Header::new_gnu(); - header.set_size(body.len() as u64); - header.set_mode(0o644); - header.set_cksum(); - builder.append_data(&mut header, path, &body[..]).unwrap(); - } - let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); - encoder.write_all(&builder.into_inner().unwrap()).unwrap(); - encoder.finish().unwrap() -} - -fn package_tarball(rule: &str) -> Vec { - tarball(&[ - ("o-r-aaaaaaaa/rules/rule.yaml", rule.as_bytes()), - ("o-r-aaaaaaaa/README.md", b"# package"), - ("o-r-aaaaaaaa/src/tool.sh", b"#!/bin/sh\n"), - ]) -} - -#[derive(Clone)] -struct Request { - method: String, - path: String, - authorization: Option, - if_none_match: Option, -} - -struct Response { - status: &'static str, - headers: Vec<(String, String)>, - body: Vec, -} - -impl Response { - fn ok(body: Vec) -> Self { - Self { - status: "200 OK", - headers: Vec::new(), - body, - } - } - fn json(body: &str) -> Self { - Self::ok(body.as_bytes().to_vec()) - } - fn not_found() -> Self { - Self { - status: "404 Not Found", - headers: Vec::new(), - body: b"not found".to_vec(), - } - } -} - -/// A localhost HTTP server replaying the handler's recorded responses. -struct TestServer { - addr: SocketAddr, - stop: Arc, - requests: Arc>>, - thread: Option>, -} - -impl TestServer { - fn serve(handler: impl Fn(&Request) -> Response + Send + Sync + 'static) -> Self { - let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); - listener.set_nonblocking(true).unwrap(); - let addr = listener.local_addr().unwrap(); - let stop = Arc::new(AtomicBool::new(false)); - let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); - let (stop_flag, seen) = (Arc::clone(&stop), Arc::clone(&requests)); - let thread = thread::spawn(move || { - loop { - match listener.accept() { - Ok((mut stream, _)) => { - // Accepted sockets inherit the listener's nonblocking - // flag on some platforms; request reads must block. - let _ = stream.set_nonblocking(false); - let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); - let request = read_request(&mut stream); - let response = handler(&request); - write_response(&mut stream, &response); - seen.lock().unwrap().push(request); - } - Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { - if stop_flag.load(Ordering::Relaxed) { - break; - } - thread::sleep(Duration::from_millis(2)); - } - Err(_) => break, - } - } - }); - Self { - addr, - stop, - requests, - thread: Some(thread), - } - } - - fn url(&self) -> String { - format!("http://{}", self.addr) - } - - fn requests(&self) -> Vec<(String, String)> { - self.requests - .lock() - .unwrap() - .iter() - .map(|r| (r.method.clone(), r.path.clone())) - .collect() - } - - fn find(&self, path_prefix: &str) -> Vec { - self.requests - .lock() - .unwrap() - .iter() - .filter(|r| r.path.starts_with(path_prefix)) - .cloned() - .collect() - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - self.stop.store(true, Ordering::Relaxed); - if let Some(thread) = self.thread.take() { - let _ = thread.join(); - } - } -} - -fn read_request(stream: &mut TcpStream) -> Request { - let mut raw = Vec::new(); - let mut buffer = [0; 4096]; - while !raw.windows(4).any(|w| w == b"\r\n\r\n") { - match stream.read(&mut buffer) { - Ok(0) | Err(_) => break, - Ok(n) => raw.extend_from_slice(&buffer[..n]), - } - } - let text = String::from_utf8_lossy(&raw); - let mut lines = text.lines(); - let mut request_line = lines.next().unwrap_or_default().split_whitespace(); - let (method, path) = ( - request_line.next().unwrap_or_default().to_string(), - request_line.next().unwrap_or_default().to_string(), - ); - let mut authorization = None; - let mut if_none_match = None; - for line in lines { - let Some((name, value)) = line.split_once(':') else { - continue; - }; - match name.trim().to_ascii_lowercase().as_str() { - "authorization" => authorization = Some(value.trim().to_string()), - "if-none-match" => if_none_match = Some(value.trim().to_string()), - _ => {} - } - } - Request { - method, - path, - authorization, - if_none_match, - } -} - -fn write_response(stream: &mut TcpStream, response: &Response) { - let mut head = format!("HTTP/1.1 {}\r\n", response.status); - for (name, value) in &response.headers { - head.push_str(&format!("{name}: {value}\r\n")); - } - head.push_str(&format!( - "Content-Length: {}\r\nConnection: close\r\n\r\n", - response.body.len() - )); - let _ = stream.write_all(head.as_bytes()); - let _ = stream.write_all(&response.body); -} - -struct TestDir(PathBuf); - -impl TestDir { - fn new(name: &str) -> Self { - let root = Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../../target") - .join(format!("wright-lint-package-{name}-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&root); - std::fs::create_dir_all(&root).unwrap(); - Self(root) - } - fn path(&self) -> &Path { - &self.0 - } -} - -impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } -} - -fn source() -> LintPackageSource { - LintPackageSource::new("community", "rules").unwrap() -} - -fn store(dir: &Path, server: &TestServer) -> LintPackageStore { - LintPackageStore::new(dir) - .with_api_base(server.url()) - .with_codeload_base(server.url()) - .with_token(None) -} - -fn store_entries(root: &Path) -> Vec { - let mut entries = Vec::new(); - for owner in std::fs::read_dir(root).into_iter().flatten().flatten() { - for repo in std::fs::read_dir(owner.path()) - .into_iter() - .flatten() - .flatten() - { - for sha in std::fs::read_dir(repo.path()) - .into_iter() - .flatten() - .flatten() - { - entries.push(format!( - "{}/{}/{}", - owner.file_name().to_string_lossy(), - repo.file_name().to_string_lossy(), - sha.file_name().to_string_lossy() - )); - } - } - } - entries.sort(); - entries -} - +use support::*; #[test] fn full_sha_install_stores_only_rule_files() { let dir = TestDir::new("full-sha"); diff --git a/crates/wright-driver/tests/lint_package_store.rs b/crates/wright-driver/tests/lint_package_store.rs new file mode 100644 index 00000000..23284625 --- /dev/null +++ b/crates/wright-driver/tests/lint_package_store.rs @@ -0,0 +1,237 @@ +//! Lint package store lifecycle tests (#844): entry enumeration with +//! damaged entries and leftovers, removal that preserves user files, +//! offline installs by full SHA, and search paging. Every remote answer +//! comes from the shared recorded-response server; nothing touches the +//! real network. + +mod support; + +use std::net::TcpListener; +use std::path::{Path, PathBuf}; + +use wright_driver::LintPackageStore; + +use support::*; + +/// Write a store entry directly: `////rules/` +/// — the same layout `install` promotes — plus any extra files. +fn write_entry(root: &Path, sha: &str, extra: &[(&str, &[u8])]) -> PathBuf { + let dir = root.join("community/rules").join(sha); + std::fs::create_dir_all(dir.join("rules")).unwrap(); + std::fs::write(dir.join("rules").join("rule.yaml"), RULE).unwrap(); + for (name, body) in extra { + std::fs::write(dir.join(name), body).unwrap(); + } + dir +} + +#[test] +fn entries_enumerates_intact_and_damaged_entries_in_order() { + let dir = TestDir::new("entries"); + write_entry(dir.path(), SHA_A, &[]); + write_entry(dir.path(), SHA_B, &[]); + // A damaged entry — no rule files — is reported with its reason. + let damaged = dir.path().join("damaged/pkg").join(SHA_A).join("rules"); + std::fs::create_dir_all(&damaged).unwrap(); + // Non-entry directories are skipped: leftovers, non-SHA names, files. + std::fs::create_dir_all(dir.path().join(".lint-package-staging")).unwrap(); + std::fs::create_dir_all(dir.path().join("community/rules/latest")).unwrap(); + std::fs::write(dir.path().join("README.md"), b"notes").unwrap(); + let server = TestServer::serve(|_| Response::not_found()); + let store = store(dir.path(), &server); + let entries = store.entries().unwrap(); + assert_eq!(entries.len(), 3); + let slugs: Vec = entries + .iter() + .map(|e| format!("{}@{}", e.source.slug(), &e.sha[..7])) + .collect(); + assert_eq!( + slugs, + [ + format!("community/rules@{}", &SHA_A[..7]), + format!("community/rules@{}", &SHA_B[..7]), + format!("damaged/pkg@{}", &SHA_A[..7]), + ] + ); + assert_eq!(entries[0].rules, ["community/minimum-wait"]); + assert!(entries[0].damage.is_none()); + let damaged = &entries[2]; + assert_eq!( + damaged.damage.as_ref().map(|e| e.code()), + Some("lint-package-install") + ); + assert!(damaged.rules.is_empty()); + // Damaged entries load no rules and still enumerate; no remote calls. + assert!(server.requests().is_empty()); +} + +#[test] +fn remove_deletes_the_entry_and_prunes_empty_parents() { + let dir = TestDir::new("remove"); + write_entry(dir.path(), SHA_A, &[]); + write_entry(dir.path(), SHA_B, &[]); + let server = TestServer::serve(|_| Response::not_found()); + let store = store(dir.path(), &server); + let removed = store.remove(&source(), SHA_A).unwrap(); + assert!(removed.dir_removed); + assert!(!dir.path().join("community/rules").join(SHA_A).exists()); + assert_eq!( + store_entries(dir.path()), + [format!("community/rules/{SHA_B}")] + ); + // Removing the last entry prunes / back to the root. + store.remove(&source(), SHA_B).unwrap(); + assert_eq!(store_entries(dir.path()), Vec::::new()); + assert!(!dir.path().join("community").exists()); +} + +#[test] +fn remove_preserves_a_user_owned_file_and_prunes_the_rules_tree() { + let dir = TestDir::new("remove-pruned"); + let entry = write_entry(dir.path(), SHA_A, &[("notes.txt", b"mine")]); + let server = TestServer::serve(|_| Response::not_found()); + let store = store(dir.path(), &server); + let removed = store.remove(&source(), SHA_A).unwrap(); + assert!(!removed.dir_removed, "unrelated files keep the entry dir"); + assert!(!entry.join("rules").exists(), "managed rules are gone"); + assert_eq!( + std::fs::read_to_string(entry.join("notes.txt")).unwrap(), + "mine", + "the user-owned file survives" + ); +} + +#[test] +fn remove_of_a_missing_entry_is_not_found() { + let dir = TestDir::new("remove-missing"); + let server = TestServer::serve(|_| Response::not_found()); + let store = store(dir.path(), &server); + let error = store.remove(&source(), SHA_A).unwrap_err(); + assert_eq!(error.code(), "lint-package-not-found"); +} + +fn search_page(hits: &[(&str, Option<&str>)], total: u64) -> String { + let items: Vec = hits + .iter() + .map(|(full_name, description)| { + let description = description + .map(|d| format!("\"{d}\"")) + .unwrap_or_else(|| "null".to_string()); + format!("{{\"full_name\":\"{full_name}\",\"description\":{description}}}") + }) + .collect(); + format!( + "{{\"total_count\":{total},\"incomplete_results\":false,\"items\":[{}]}}", + items.join(",") + ) +} + +#[test] +fn search_pages_the_wrightkit_lint_topic_query() { + let dir = TestDir::new("search"); + // 101 results over two pages — the second page carries one item. + let mut first: Vec<(&str, Option<&str>)> = (0..100) + .map(|i| { + ( + Box::leak(format!("owner-{i:03}/repo").into_boxed_str()) as &str, + None, + ) + }) + .collect(); + first.push(("bad full_name", None)); + let server = TestServer::serve(move |request| { + if request.path.starts_with("/search/repositories?") && request.path.ends_with("page=1") { + Response::json(&search_page(&first, 101)) + } else if request.path.ends_with("page=2") { + Response::json(&search_page( + &[("community/rules", Some("wait rules"))], + 101, + )) + } else { + Response::not_found() + } + }); + let store = store(dir.path(), &server); + let page = store.search(None).unwrap(); + assert_eq!(page.total, 101); + assert!(!page.incomplete); + assert_eq!(page.hits.len(), 101, "invalid full_names are skipped"); + assert_eq!(page.hits[0].source.slug(), "owner-000/repo"); + assert_eq!(page.hits[100].source.slug(), "community/rules"); + assert_eq!(page.hits[100].description.as_deref(), Some("wait rules")); + let requests = server.find("/search/repositories"); + assert_eq!(requests.len(), 2); + assert!( + requests[0] + .path + .contains("q=topic:wrightkit-lint%20archived:false%20fork:false&per_page=100&page=1"), + "the topic filter excludes archives and forks: {}", + requests[0].path + ); +} + +#[test] +fn search_appends_terms_to_the_topic_query() { + let dir = TestDir::new("search-terms"); + let server = TestServer::serve(move |request| { + if request.path.starts_with("/search/repositories?") { + Response::json(&search_page(&[("community/rules", None)], 1)) + } else { + Response::not_found() + } + }); + let store = store(dir.path(), &server); + let page = store.search(Some("wait loops")).unwrap(); + assert_eq!(page.hits.len(), 1); + let requests = server.find("/search/repositories"); + assert_eq!( + requests[0].path, + "/search/repositories?q=topic:wrightkit-lint%20archived:false%20fork:false%20wait%20loops&per_page=100&page=1" + ); +} + +#[test] +fn search_surfaces_remote_error_classes() { + let dir = TestDir::new("search-errors"); + let server = TestServer::serve(|_| Response { + status: "403 Forbidden", + headers: vec![ + ("x-ratelimit-remaining".to_string(), "0".to_string()), + ("x-ratelimit-reset".to_string(), "1893456000".to_string()), + ], + body: b"API rate limit exceeded".to_vec(), + }); + let store = store(dir.path(), &server); + assert_eq!( + store.search(None).unwrap_err().code(), + "lint-package-rate-limited" + ); + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + let dead = format!("http://{}", listener.local_addr().unwrap()); + drop(listener); + let offline = LintPackageStore::new(dir.path()) + .with_api_base(&dead) + .with_token(None); + assert_eq!( + offline.search(None).unwrap_err().code(), + "lint-package-offline" + ); + let malformed = TestServer::serve(|_| Response::json("{\"unexpected\":true}")); + let store = LintPackageStore::new(dir.path()) + .with_api_base(malformed.url()) + .with_token(None); + assert_eq!( + store.search(None).unwrap_err().code(), + "lint-package-remote" + ); +} + +#[test] +fn entries_of_an_empty_or_missing_store_is_empty() { + let dir = TestDir::new("entries-empty"); + let server = TestServer::serve(|_| Response::not_found()); + let store = LintPackageStore::new(dir.path().join("absent")) + .with_api_base(server.url()) + .with_token(None); + assert!(store.entries().unwrap().is_empty()); +} diff --git a/crates/wright-driver/tests/support/mod.rs b/crates/wright-driver/tests/support/mod.rs new file mode 100644 index 00000000..de82196b --- /dev/null +++ b/crates/wright-driver/tests/support/mod.rs @@ -0,0 +1,290 @@ +//! Test support for the lint package suites: the recorded-response +//! localhost server, tarball builder, and temp store dir shared by +//! `lint_package.rs` and `lint_package_lifecycle.rs`. + +#![allow(dead_code)] + +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::thread::{self, JoinHandle}; +use std::time::Duration; + +use wright_driver::{LintPackageSource, LintPackageStore}; + +pub(crate) const SHA_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +pub(crate) const SHA_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +pub(crate) const RULE: &str = "\ +id: community/minimum-wait +metadata: + summary: loop contains a minimum wait + rationale: minimum waits can create high-frequency loops + documentation: Finds a minimum wait inside a while scope. + known-limits: This is structural and does not measure runtime cost. + tags: [performance] +matcher: + scope: while + actions: + - kind: call +"; + +pub(crate) const SECOND_RULE: &str = "\ +id: community/has-wait +metadata: + summary: rule contains a wait + rationale: waits yield the scheduler + documentation: Finds a wait call at rule scope. + known-limits: Structural only. + tags: [performance] +matcher: + scope: rule + actions: + - kind: call +"; + +pub(crate) fn tarball(files: &[(&str, &[u8])]) -> Vec { + let mut builder = tar::Builder::new(Vec::new()); + for (path, body) in files { + let mut header = tar::Header::new_gnu(); + header.set_size(body.len() as u64); + header.set_mode(0o644); + header.set_cksum(); + builder.append_data(&mut header, path, &body[..]).unwrap(); + } + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(&builder.into_inner().unwrap()).unwrap(); + encoder.finish().unwrap() +} + +pub(crate) fn package_tarball(rule: &str) -> Vec { + tarball(&[ + ("o-r-aaaaaaaa/rules/rule.yaml", rule.as_bytes()), + ("o-r-aaaaaaaa/README.md", b"# package"), + ("o-r-aaaaaaaa/src/tool.sh", b"#!/bin/sh\n"), + ]) +} + +#[derive(Clone)] +pub(crate) struct Request { + pub(crate) method: String, + pub(crate) path: String, + pub(crate) authorization: Option, + pub(crate) if_none_match: Option, +} + +pub(crate) struct Response { + pub(crate) status: &'static str, + pub(crate) headers: Vec<(String, String)>, + pub(crate) body: Vec, +} + +impl Response { + pub(crate) fn ok(body: Vec) -> Self { + Self { + status: "200 OK", + headers: Vec::new(), + body, + } + } + pub(crate) fn json(body: &str) -> Self { + Self::ok(body.as_bytes().to_vec()) + } + pub(crate) fn not_found() -> Self { + Self { + status: "404 Not Found", + headers: Vec::new(), + body: b"not found".to_vec(), + } + } +} + +/// A localhost HTTP server replaying the handler's recorded responses. +pub(crate) struct TestServer { + pub(crate) addr: SocketAddr, + pub(crate) stop: Arc, + pub(crate) requests: Arc>>, + pub(crate) thread: Option>, +} + +impl TestServer { + pub(crate) fn serve(handler: impl Fn(&Request) -> Response + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind((std::net::Ipv4Addr::LOCALHOST, 0)).unwrap(); + listener.set_nonblocking(true).unwrap(); + let addr = listener.local_addr().unwrap(); + let stop = Arc::new(AtomicBool::new(false)); + let requests: Arc>> = Arc::new(Mutex::new(Vec::new())); + let (stop_flag, seen) = (Arc::clone(&stop), Arc::clone(&requests)); + let thread = thread::spawn(move || { + loop { + match listener.accept() { + Ok((mut stream, _)) => { + // Accepted sockets inherit the listener's nonblocking + // flag on some platforms; request reads must block. + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(Duration::from_secs(10))); + let request = read_request(&mut stream); + let response = handler(&request); + write_response(&mut stream, &response); + seen.lock().unwrap().push(request); + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => { + if stop_flag.load(Ordering::Relaxed) { + break; + } + thread::sleep(Duration::from_millis(2)); + } + Err(_) => break, + } + } + }); + Self { + addr, + stop, + requests, + thread: Some(thread), + } + } + + pub(crate) fn url(&self) -> String { + format!("http://{}", self.addr) + } + + pub(crate) fn requests(&self) -> Vec<(String, String)> { + self.requests + .lock() + .unwrap() + .iter() + .map(|r| (r.method.clone(), r.path.clone())) + .collect() + } + + pub(crate) fn find(&self, path_prefix: &str) -> Vec { + self.requests + .lock() + .unwrap() + .iter() + .filter(|r| r.path.starts_with(path_prefix)) + .cloned() + .collect() + } +} + +impl Drop for TestServer { + fn drop(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(thread) = self.thread.take() { + let _ = thread.join(); + } + } +} + +pub(crate) fn read_request(stream: &mut TcpStream) -> Request { + let mut raw = Vec::new(); + let mut buffer = [0; 4096]; + while !raw.windows(4).any(|w| w == b"\r\n\r\n") { + match stream.read(&mut buffer) { + Ok(0) | Err(_) => break, + Ok(n) => raw.extend_from_slice(&buffer[..n]), + } + } + let text = String::from_utf8_lossy(&raw); + let mut lines = text.lines(); + let mut request_line = lines.next().unwrap_or_default().split_whitespace(); + let (method, path) = ( + request_line.next().unwrap_or_default().to_string(), + request_line.next().unwrap_or_default().to_string(), + ); + let mut authorization = None; + let mut if_none_match = None; + for line in lines { + let Some((name, value)) = line.split_once(':') else { + continue; + }; + match name.trim().to_ascii_lowercase().as_str() { + "authorization" => authorization = Some(value.trim().to_string()), + "if-none-match" => if_none_match = Some(value.trim().to_string()), + _ => {} + } + } + Request { + method, + path, + authorization, + if_none_match, + } +} + +pub(crate) fn write_response(stream: &mut TcpStream, response: &Response) { + let mut head = format!("HTTP/1.1 {}\r\n", response.status); + for (name, value) in &response.headers { + head.push_str(&format!("{name}: {value}\r\n")); + } + head.push_str(&format!( + "Content-Length: {}\r\nConnection: close\r\n\r\n", + response.body.len() + )); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(&response.body); +} + +pub(crate) struct TestDir(PathBuf); + +impl TestDir { + pub(crate) fn new(name: &str) -> Self { + let root = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target") + .join(format!("wright-lint-package-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + Self(root) + } + pub(crate) fn path(&self) -> &Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +pub(crate) fn source() -> LintPackageSource { + LintPackageSource::new("community", "rules").unwrap() +} + +pub(crate) fn store(dir: &Path, server: &TestServer) -> LintPackageStore { + LintPackageStore::new(dir) + .with_api_base(server.url()) + .with_codeload_base(server.url()) + .with_token(None) +} + +pub(crate) fn store_entries(root: &Path) -> Vec { + let mut entries = Vec::new(); + for owner in std::fs::read_dir(root).into_iter().flatten().flatten() { + for repo in std::fs::read_dir(owner.path()) + .into_iter() + .flatten() + .flatten() + { + for sha in std::fs::read_dir(repo.path()) + .into_iter() + .flatten() + .flatten() + { + entries.push(format!( + "{}/{}/{}", + owner.file_name().to_string_lossy(), + repo.file_name().to_string_lossy(), + sha.file_name().to_string_lossy() + )); + } + } + } + entries.sort(); + entries +} diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 1b6ea85f..1a166f1c 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -36,6 +36,7 @@ result. | `wright check [INPUT]` | Parse, lower, validate, and report correctness diagnostics | verdict and validation diagnostics | | `wright analyze [INPUT] [--all]` | Summarize Workshop cost, ranked complexity hotspots, performance/stability risk indicators, and cross-cutting state | overview, cost, top hotspots, complexity, risks, and top variables with exact/static/heuristic evidence labels; `--all` lifts list caps | | `wright lint [INPUT] [--all] [--fix [--write]]` | Parse, lower, lint; report findings | finding counts by severity and actionable findings; `--all` lifts list caps; `--fix` previews each offered fix's validated diff, `--write` applies them | +| `wright lint package ` | Discover, install, and manage GitHub lint rule packages in the Wright-owned store keyed by commit SHA (#844); takes no `INPUT` and never activates a package | per-package rows: source, status, SHA, entry directory, rule ids, and error detail ([lint configuration and findings](lint.md#lint-rule-packages-wright-lint-package-844)) | | `wright inspect [INPUT] [--all]` | Parse, lower, and inspect the program overview | overview, rule names, and the detail commands; `--all` lifts the rule-list cap | | `wright inspect rules [INPUT] [--content] [--rule RULE] [--only NAME] [--file PATH] [--max N]` | List rules; `--content` adds each selected rule's conditions and actions as canonical call trees (ADR-0024) | the rule list, with content when requested | | `wright inspect symbols [INPUT] [--only KIND] [--file PATH] [--max N]` | List semantic symbols, optionally narrowed to one kind | the symbol list with resolved locations | @@ -59,8 +60,8 @@ every `wright-result/v1` envelope. `wright --help` is the canonical help surface. Commands that take input accept a file path, a project directory, or `-` for -stdin (`wright lookup`, `wright completion`, `wright update`, and `wright -agent install` take none). An omitted input uses the current directory. Input kind is detected from the +stdin (`wright lookup`, `wright lint package`, `wright completion`, +`wright update`, and `wright agent install` take none). An omitted input uses the current directory. Input kind is detected from the extension (`.opy`, `.ostw`/`.del`, `.json`, `.txt`/`.ws`) or, for a directory, from the source files it contains; mixed source kinds fail with structured ambiguity guidance. Stdin content is auto-detected (protocol JSON starts with diff --git a/docs/cli/lint.md b/docs/cli/lint.md index a859700b..f3a91c9e 100644 --- a/docs/cli/lint.md +++ b/docs/cli/lint.md @@ -265,3 +265,85 @@ writes nothing. Provider-backed findings carry a `fix` when their source map records authored positions. A mapped `duplicate-condition` edits the authored `elif`/`else` chain directly; unmapped provider artifacts have spans in generated text and carry no `fix`. + +## Lint rule packages: `wright lint package` (#844) + +`wright lint package` discovers, installs, and manages community lint rule +packages hosted on GitHub. One repository carrying the `wrightkit-lint` +topic is one package: the `rules/*.yaml|yml` files directly inside its +`rules/` directory at one commit, each loading through the same SPEC-309 +declarative rule contract as a `--rule` file. Nested rule files, package +code, and anything else in the archive are never extracted or executed. + +The Wright-owned store keeps one immutable directory per +`///` under Wright's application-data directory; +`WRIGHT_LINT_PACKAGE_DIR` relocates it. An intact store entry is never +downloaded or overwritten again — only the full commit SHA is stored, never +the ref it resolved from — and a failed fetch or validation leaves no +partial entry. `GITHUB_TOKEN`, when set, raises the GitHub API limit and is +sent to the API host only, never to the archive host. + +Installing a package never activates it: project configuration pins a +package by commit SHA and opts it into the registry (#843), so a lint run +is reproducible without re-resolving refs. + +```text +wright lint package search [QUERY] repositories carrying the wrightkit-lint topic +wright lint package install SOURCE [--ref REF] +wright lint package list every store entry, intact and damaged +wright lint package outdated check each entry against the remote head +wright lint package update [SOURCE] install the current remote head +wright lint package remove SOURCE delete the entry's managed rules +``` + +A `SOURCE` is `owner/repo`, a github.com URL (`https://github.com/owner/repo`, +`git@github.com:owner/repo`, including `/tree/` and +`/releases/tag/` spellings), or `owner/repo@`; `--ref` selects a +branch, tag, or commit explicitly. Ref spellings that disagree are a usage +error. A full 40-hex SHA needs no resolution and installs offline. + +* `search` runs the `topic:wrightkit-lint archived:false fork:false` + repository query paged at `per_page=100`; `QUERY` narrows it further. The + result reports GitHub's `total` and `incomplete` flags beside the found + rows. +* `install` resolves the ref to a full SHA — the repository's + default-branch head when no ref is given — fetches the archive for that + SHA, validates it, and stores it. Reinstalling an intact entry makes no + download (`downloaded=false`). +* `list` reports every store entry in `owner/repo/sha` order. An intact + entry reports `installed` with its rule ids; a damaged entry reports + `damaged` with the `lint-package-install` reason it does not validate — + local edits to managed content are damage, never silently replaced. +* `outdated` checks each entry's installed SHA against the remote + default-branch head and reports `unchanged`, `updated`, `unavailable` + (the repository or ref is gone), or `error` — read-only; nothing applies + to the store and a damaged entry reports `error` without being checked. +* `update` installs the current remote head for every installed entry, or + for the one repository `SOURCE` selects (`owner/repo@` updates only + the entry the ref resolves to). An `unchanged` entry is not re-fetched; + an `updated` row keeps the installed `sha` beside the new `remote` head, + and the previous entry stays in the store. `unavailable`/`error` rows + fail the command. +* `remove` deletes each selected entry's managed `rules/` tree + (`owner/repo` removes every installed entry of the repository, + `owner/repo@` removes the one the ref resolves to). The entry + directory is deleted only when nothing else remains: unrelated files + beside `rules/` keep it, reported `pruned` instead of `removed`, and + empty `/` parents are pruned. A source with no matching + entry is a `lint-package-not-found` error. + +Every subcommand renders the shared `wright-result/v1` envelope — +`package-search`, `package-install`, `package-list`, `package-outdated`, +`package-update`, `package-remove` — through the same presentation options +(`--format`, `--renderer`, `--color`, `--all`) as the workflow commands. +`search`/`list`/`outdated` are read-only queries and lead with their own +command-name tag; `install`/`update`/`remove` are lifecycle operations +whose per-package failures produce a gated FAIL with one +`lint-package-*` diagnostic per failed package. Package diagnostics use +the `lint-package-*` codes: `lint-package-source` (a bad source/ref +spelling, exit 2), `lint-package-invalid` (content the SPEC-309 loader +refuses, exit 1), `lint-package-offline`, `lint-package-not-found`, +`lint-package-rate-limited`, `lint-package-remote`, and +`lint-package-install` (store damage or I/O, exit 4). Lint-policy flags, +finding-selection flags, and `INPUT` belong to `wright lint` and are +usage errors on `lint package`. diff --git a/docs/cli/machine-contract.md b/docs/cli/machine-contract.md index d3afb285..bf4b6fec 100644 --- a/docs/cli/machine-contract.md +++ b/docs/cli/machine-contract.md @@ -7,10 +7,10 @@ | Code | Meaning | Examples | | --- | --- | --- | | 0 | success | clean check, compiled program written, reconstructed source produced | -| 1 | source/user error | parse error, validation error, ambiguous input, unknown input kind, unreadable input, refused downgrade, non-Workshop `convert` input, a deliberate LPP provider refusal (`provider-refusal-`), an owner-side project load failure (`project-load-failed`) | -| 2 | usage error | unknown command/flag, missing option value, missing/unknown `convert --target`, `compile -o -` with `--format json`, the undeclared `compile --to` locale refusal `invalid-locale`, and the `compile` output refusals `output-exists` (existing file without a terminal or `--force`), `output-is-input`, and `output-unnamed` (carried in the envelope); `ui` on `-` input and `ui` on a build without the `ui` feature | +| 1 | source/user error | parse error, validation error, ambiguous input, unknown input kind, unreadable input, refused downgrade, non-Workshop `convert` input, a deliberate LPP provider refusal (`provider-refusal-`), an owner-side project load failure (`project-load-failed`), a lint package the SPEC-309 loader refuses (`lint-package-invalid`) | +| 2 | usage error | unknown command/flag, missing option value, missing/unknown `convert --target`, `compile -o -` with `--format json`, the undeclared `compile --to` locale refusal `invalid-locale`, and the `compile` output refusals `output-exists` (existing file without a terminal or `--force`), `output-is-input`, and `output-unnamed` (carried in the envelope); `ui` on `-` input and `ui` on a build without the `ui` feature; an invalid `lint package` source or conflicting refs (`lint-package-source`) | | 3 | recognized but unsupported | `.opy` stdin via the explicit adapter fallback (default path is native), package-manager-managed installation for `update self`, unsupported platform for `update self`, a `convert` reconstruction rejection (a construct outside the declared OPY/OSTW reconstruction surface), an LPP capability the provider did not negotiate (`capability-unavailable`), `ui` on OPY or OSTW input | -| 4 | internal/environment failure | catalog corruption, adapter bridge missing, I/O failure writing output, `update` network/checksum/extraction failure, provider transport/process failures (`provider-spawn`, `provider-io`, `provider-timeout`, `provider-exited`, `provider-malformed`, `jsonrpc-error`), provider resolution failures (`provider-missing`, `provider-offline`, `provider-download`, `provider-integrity`, `provider-install`, `provider-unsupported-platform`) | +| 4 | internal/environment failure | catalog corruption, adapter bridge missing, I/O failure writing output, `update` network/checksum/extraction failure, provider transport/process failures (`provider-spawn`, `provider-io`, `provider-timeout`, `provider-exited`, `provider-malformed`, `jsonrpc-error`), provider resolution failures (`provider-missing`, `provider-offline`, `provider-download`, `provider-integrity`, `provider-install`, `provider-unsupported-platform`), `lint package` remote and store failures (`lint-package-offline`, `lint-package-not-found`, `lint-package-rate-limited`, `lint-package-remote`, `lint-package-install`) | Exit codes are deterministic for identical inputs and configuration and are also carried inside the JSON envelope (`exit` field), so agents never need to diff --git a/docs/cli/presentation.md b/docs/cli/presentation.md index 7bbdc167..8c9fb16f 100644 --- a/docs/cli/presentation.md +++ b/docs/cli/presentation.md @@ -104,18 +104,22 @@ except the leading verdict line, which is ` [key=value …]`. The status shares the renderers' tag set: `PASS`, `WARN`, `FAIL` (a raised `--fail-on` verdict), `ERROR` (a genuine failure), `USAGE` and `UNSUPPORTED` for exit 2/3 envelopes; a clean query command (`inspect`, -`rules`, `symbols`, `refs`, `cfg`, `callgraph`, `cost`, `lookup`) shows its +`rules`, `symbols`, `refs`, `cfg`, `callgraph`, `cost`, `lookup`, and the +read-only `package-search`, `package-list`, `package-outdated`) shows its uppercase name in place of a vacuous `PASS`. Verdict fields carry the input `root=` plus program `kind=`/`locale=` when the result reports an origin. Records lead with the verdict, then `summary:` — the headline counts named after their JSON fields (`errors=`, `warnings=`, `notes=`/`findings=`, `affected=`, `emittedBytes=`, `rules=`, `symbols=`, `reads=`, `writes=`, -`calls=`, `blocks=`, `edges=`, …) — then the command's section records: +`calls=`, `blocks=`, `edges=`, `packages=`, …) — then the command's section records: `program:`, `rule:`, `symbol:`, `ref:`, `block:`, `edge:`, `fan-in:`, `fan-out:`, `share:`, `statement:`, `byNode:`, `hotspot:`, `tree:`, `complexity:`, `variable:`, `objects:`, `cost:`, `output:` (written-path -plus `sha256=`), `fix:`/`patch:`/`refusal:` for `lint --fix`, and `entry:` +plus `sha256=`), `fix:`/`patch:`/`refusal:` for `lint --fix`, +`package:` records for `lint package` (`source=`/`status=` lead, then +`sha=`/`remote=`/`dir=`/`rules=`/`downloaded=`/`code=`/`message=`/ +`description=` as the row carries them), and `entry:` records for `lookup`. `diagnostic:` records render envelope diagnostics on stderr; result records stay on stdout, and artifacts keep byte-exact stdout ownership. A `diagnostic:` carries ` ` then @@ -204,7 +208,8 @@ the verdict tag (#795, ADR-0030 decision 4). The tag word maps to the exit code: `PASS` or `WARN` for clean results, `FAIL` for a `--fail-on`-gated result, `USAGE` for exit 2, `UNSUPPORTED` for exit 3, and `ERROR` for other failures; a successful query command shows its own name (`INSPECT`, -`SYMBOLS`, `CFG`, `COST`, `LOOKUP`, …) rather than a judging word. A +`SYMBOLS`, `CFG`, `COST`, `LOOKUP`, `PACKAGE-SEARCH`, `PACKAGE-LIST`, +`PACKAGE-OUTDATED`, …) rather than a judging word. A status word names the command after it (` WARN lint`); a query tag stands alone. Colour is semantic and drawn from the eight ANSI hues only — green pass, yellow warn, red error class, cyan neutral — but the word