diff --git a/README.md b/README.md index 1e2239e..ad16fda 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,13 @@ c2g impact helper --depth 3 By default the CLI rejects an incomplete index. `--allow-partial` explicitly permits publishing and querying a partial source set; inspect the reported omissions before -relying on its results. +relying on its results. Each reported omission list is capped at 256 entries to keep an +envelope small; `omittedFiles` (and `inventory.omitted_files`) still carries the full +count, and `omissionsTruncated` marks a list that was capped. + +Without `--root`, the selected project is the working directory. A working directory +that is a home directory or the filesystem root is refused: walking one costs minutes +and describes no project. Name the project (`--root `) to proceed. Driving the CLI from a coding agent: [`docs/agent-integration.md`](docs/agent-integration.md) carries a copy-pasteable rule block for `CLAUDE.md` / `AGENTS.md` and explains why a mechanical trigger is the only kind an agent reliably follows. diff --git a/cli/src/config.rs b/cli/src/config.rs index 302b636..c81b979 100644 --- a/cli/src/config.rs +++ b/cli/src/config.rs @@ -95,6 +95,13 @@ pub const DEFAULT_MAX_TOTAL_BYTES: usize = 256 * 1_024 * 1_024; pub const DEFAULT_MAX_DEPTH: u32 = 32; /// Default number of rows rendered by a command. pub const DEFAULT_LIMIT: usize = 50; +/// Default maximum number of individual omission entries reported in any one +/// list. An over-broad root (a home directory, a parent of many repositories) +/// omits tens of thousands of files, and a JSON envelope carrying one entry per +/// omitted file grows to megabytes. The entry lists are diagnostics: each list +/// is capped to this many entries while the totals (`omittedFiles`, +/// `inventory.omitted_files`) and the rendered reason counts stay complete. +pub const DEFAULT_MAX_OMISSIONS: usize = 256; /// Default reverse-reachability depth for `impact`. pub const DEFAULT_IMPACT_DEPTH: u32 = 2; diff --git a/cli/src/execution/lifecycle.rs b/cli/src/execution/lifecycle.rs index 190af01..028a13a 100644 --- a/cli/src/execution/lifecycle.rs +++ b/cli/src/execution/lifecycle.rs @@ -1885,6 +1885,7 @@ fn project_output( freshness: Freshness, cache: CacheDisposition, ) -> ProjectOutput { + let (omissions, omissions_truncated) = crate::result::capped_omissions(&snapshot.omissions); ProjectOutput { root: selection.canonical_root.to_string_lossy().into_owned(), snapshot: snapshot.candidate_id.to_string(), @@ -1893,7 +1894,8 @@ fn project_output( cache, completeness: snapshot.completeness.into(), omitted_files: snapshot.omissions.len(), - omissions: snapshot.omissions.iter().map(Into::into).collect(), + omissions, + omissions_truncated, // Only the paths that actually refreshed against a store can observe a // recovery; they fill this in from the store afterwards. cache_recovery: None, diff --git a/cli/src/execution/output.rs b/cli/src/execution/output.rs index c7727f7..a840a43 100644 --- a/cli/src/execution/output.rs +++ b/cli/src/execution/output.rs @@ -70,6 +70,13 @@ fn query_warning(project: Option<&ProjectOutput>) -> String { "warning: partial snapshot; {} source files omitted\n", project.omitted_files )); + if project.omissions_truncated { + output.push_str(&format!( + "warning: omission entries truncated; listing {} of {}\n", + project.omissions.len(), + project.omitted_files + )); + } for omission in sorted_omissions(&project.omissions) { output.push_str(&format!( "warning: omitted {} reason={} detail={}\n", @@ -107,8 +114,15 @@ fn render_index(envelope: &crate::OutputEnvelope) -> String } output.push_str(&format!( "omitted files={}\n", - envelope.results.omissions.len() + envelope.results.omitted_files )); + if envelope.results.omissions_truncated { + output.push_str(&format!( + "warning: omission entries truncated; listing {} of {}\n", + envelope.results.omissions.len(), + envelope.results.omitted_files + )); + } let omissions = sorted_omissions(&envelope.results.omissions); let mut counts = std::collections::BTreeMap::<&str, usize>::new(); for omission in &omissions { @@ -153,6 +167,13 @@ fn render_status(status: &crate::StatusOutput) -> String { .timeout_millis .map_or_else(|| "none".into(), |value| value.to_string()), ); + if status.project.omissions_truncated { + output.push_str(&format!( + "warning: omission entries truncated; listing {} of {}; reason counts cover the listed entries only\n", + status.project.omissions.len(), + status.project.omitted_files + )); + } let mut counts = std::collections::BTreeMap::<&str, usize>::new(); let omissions = sorted_omissions(&status.project.omissions); for omission in &omissions { @@ -547,6 +568,7 @@ mod tests { detail: "limit=12".into(), }, ], + omissions_truncated: false, cache_recovery: None, } } @@ -595,6 +617,8 @@ mod tests { inventory_file_count: 3, inventory_total_bytes: 42, omissions: project(Freshness::Fresh, CacheCompletenessOutput::Partial).omissions, + omitted_files: 2, + omissions_truncated: false, changed: 2, deleted: 1, ignored_omissions: 0, @@ -629,6 +653,8 @@ mod tests { inventory_file_count: 1, inventory_total_bytes: 42, omissions: Vec::new(), + omitted_files: 0, + omissions_truncated: false, changed: 1, deleted: 0, ignored_omissions: 0, diff --git a/cli/src/project/select.rs b/cli/src/project/select.rs index 5fc885c..350be23 100644 --- a/cli/src/project/select.rs +++ b/cli/src/project/select.rs @@ -68,7 +68,32 @@ pub fn select_project(request: &CliRequest, cwd: &Path) -> Result) -> bool { + path.parent().is_none() || home.is_some_and(|home| path == home) +} + +fn home_directory() -> Option { + directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf()) +} + +fn select_implicit_directory(cwd: &ValidatedCwd) -> Result { + if is_forbidden_default_root(&cwd.canonical, home_directory().as_deref()) { + return Err(CliError::ProjectPath { + path: cwd.canonical.clone(), + reason: "refusing the current directory as an implicit project root \ + (home or filesystem root); pass --root " + .into(), + }); + } + select_directory(&cwd.canonical, cwd, SelectionProvenance::CurrentDirectory) } struct ValidatedCwd { @@ -222,7 +247,7 @@ mod tests { #[cfg(target_os = "macos")] use super::is_trusted_system_ancestor; - use super::{SelectionProvenance, select_project}; + use super::{SelectionProvenance, is_forbidden_default_root, select_project}; use crate::config::GlobalOptions; use crate::error::CliError; use crate::request::{CliRequest, CommandRequest}; @@ -355,6 +380,19 @@ mod tests { ); } + #[test] + fn implicit_cwd_root_refuses_home_and_filesystem_root_only() { + let home = Path::new("/home/example"); + assert!(is_forbidden_default_root(Path::new("/"), Some(home))); + assert!(is_forbidden_default_root(Path::new("/"), None)); + assert!(is_forbidden_default_root(home, Some(home))); + assert!(!is_forbidden_default_root( + Path::new("/home/example/project"), + Some(home) + )); + assert!(!is_forbidden_default_root(home, None)); + } + #[test] fn rejects_invalid_cwd_before_other_selection_inputs() { let directory = tempdir().expect("temporary directory"); diff --git a/cli/src/result.rs b/cli/src/result.rs index 17fc6c5..0b23939 100644 --- a/cli/src/result.rs +++ b/cli/src/result.rs @@ -4,7 +4,7 @@ use code2graph::{Confidence, Provenance, RefRole, SymbolId, SymbolKind, TypeRefC use serde::{Deserialize, Serialize}; use crate::cache::{CacheCompleteness, CacheOmission, LoadedSnapshot}; -use crate::config::{ResolverTier, ResourceLimits}; +use crate::config::{DEFAULT_MAX_OMISSIONS, ResolverTier, ResourceLimits}; use crate::exit::ExitCode; use crate::inventory::{ InventoryCompleteness, InventorySummary, OmissionReason, StableIoErrorKind, @@ -92,7 +92,16 @@ pub struct ProjectOutput { pub completeness: CacheCompletenessOutput, #[serde(rename = "omittedFiles")] pub omitted_files: usize, + /// Capped to [`DEFAULT_MAX_OMISSIONS`] entries; `omittedFiles` carries the total. pub omissions: Vec, + /// Present only when `omissions` was capped, so a consumer can tell a short + /// list from a complete one. + #[serde( + rename = "omissionsTruncated", + default, + skip_serializing_if = "is_false" + )] + pub omissions_truncated: bool, /// Why a previously cached snapshot was discarded and rebuilt, when that /// happened during this run. A cache whose stored facts no longer satisfy /// their validation contract — after an upgrade changes that contract, say @@ -509,6 +518,27 @@ impl From<&CacheOmission> for CacheOmissionOutput { } } +/// Deterministically ordered, capped view of an omission list. +/// +/// Returns the reported entries (at most [`DEFAULT_MAX_OMISSIONS`]) and whether +/// entries were held back. Callers keep the full total in their own count field, +/// so capping the entry list never hides how many files were omitted. +pub fn capped_omissions(omissions: &[CacheOmission]) -> (Vec, bool) { + let mut sorted = omissions.iter().collect::>(); + sorted.sort_by(|left, right| { + (&left.path, &left.reason, &left.detail).cmp(&(&right.path, &right.reason, &right.detail)) + }); + let truncated = sorted.len() > DEFAULT_MAX_OMISSIONS; + sorted.truncate(DEFAULT_MAX_OMISSIONS); + (sorted.into_iter().map(Into::into).collect(), truncated) +} + +/// `skip_serializing_if` for the additive truncation flags: an untruncated +/// envelope keeps the exact spelling it had before the flag existed. +const fn is_false(value: &bool) -> bool { + !*value +} + /// Counts of decisions made by the refresh planner. #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct PlanDecisionCountsOutput { @@ -544,7 +574,14 @@ pub struct IndexOutput { pub completeness: CacheCompletenessOutput, pub inventory_file_count: u64, pub inventory_total_bytes: u64, + /// Total extracted-and-omitted files, independent of `omissions` being capped. + #[serde(default)] + pub omitted_files: usize, + /// Capped to [`DEFAULT_MAX_OMISSIONS`] entries; `omitted_files` carries the total. pub omissions: Vec, + /// Present only when `omissions` was capped. + #[serde(default, skip_serializing_if = "is_false")] + pub omissions_truncated: bool, pub changed: usize, pub deleted: usize, pub ignored_omissions: usize, @@ -563,6 +600,7 @@ impl IndexOutput { attempts: u8, plan_decisions: PlanDecisionCountsOutput, ) -> Self { + let (omissions, omissions_truncated) = capped_omissions(&snapshot.omissions); Self { candidate: snapshot.candidate_id.to_string(), snapshot: snapshot.candidate_id.to_string(), @@ -570,7 +608,9 @@ impl IndexOutput { completeness: snapshot.completeness.into(), inventory_file_count: snapshot.inventory_file_count, inventory_total_bytes: snapshot.inventory_total_bytes, - omissions: snapshot.omissions.iter().map(Into::into).collect(), + omitted_files: snapshot.omissions.len(), + omissions, + omissions_truncated, changed, deleted, ignored_omissions, @@ -618,7 +658,10 @@ impl StatusOutput { omitted_files: snapshot.omissions.len(), omission_reasons: Vec::new(), }, - cached_omissions: snapshot.omissions.iter().map(Into::into).collect(), + // The cached entries mirror `project.omissions` and are capped the + // same way; `project.omitted_files` carries the full count and + // `project.omissions_truncated` says whether either list is short. + cached_omissions: capped_omissions(&snapshot.omissions).0, max_files: limits.max_files, max_file_bytes: limits.max_file_bytes, max_total_bytes: limits.max_total_bytes, @@ -909,10 +952,31 @@ mod tests { completeness: snapshot.completeness.into(), omitted_files: snapshot.omissions.len(), omissions: snapshot.omissions.iter().map(Into::into).collect(), + omissions_truncated: false, cache_recovery: None, } } + #[test] + fn omission_entry_lists_are_capped_while_the_truncation_is_reported() { + let omissions = (0..(DEFAULT_MAX_OMISSIONS + 5)) + .map(|index| CacheOmission { + path: format!("src/file{index:04}.rs"), + reason: "file-count-limit".into(), + detail: "limit=10000".into(), + }) + .collect::>(); + + let (reported, truncated) = capped_omissions(&omissions); + assert_eq!(reported.len(), DEFAULT_MAX_OMISSIONS); + assert!(truncated); + assert_eq!(reported[0].path, "src/file0000.rs"); + + let (short, truncated) = capped_omissions(&omissions[..3]); + assert_eq!(short.len(), 3); + assert!(!truncated); + } + #[test] fn index_output_and_cached_status_are_owned_stable_contracts() { let snapshot = loaded_snapshot(CacheCompleteness::Partial); @@ -962,6 +1026,8 @@ mod tests { reason: "file-too-large".into(), detail: "limit=1024".into(), }], + omitted_files: 1, + omissions_truncated: false, changed: 2, deleted: 1, ignored_omissions: 4, @@ -986,6 +1052,7 @@ mod tests { "omissions": [{ "path": "src/large.rs", "reason": "file-too-large", "detail": "limit=1024" }], + "omitted_files": 1, "changed": 2, "deleted": 1, "ignored_omissions": 4, @@ -1060,6 +1127,7 @@ mod tests { completeness: CacheCompletenessOutput::Complete, omitted_files: 0, omissions: Vec::new(), + omissions_truncated: false, cache_recovery: None, }; assert_eq!( @@ -1132,6 +1200,7 @@ mod tests { completeness: snapshot.completeness.into(), omitted_files: snapshot.omissions.len(), omissions: snapshot.omissions.iter().map(Into::into).collect(), + omissions_truncated: false, cache_recovery: None, }, &snapshot, diff --git a/docs/agent-integration.md b/docs/agent-integration.md index 28d3893..2035616 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -32,6 +32,9 @@ strings, config values, comments, error text, non-source files, unsupported lang - ALWAYS pass `--allow-partial`: real codebases have files that fail extraction, and without it any such file aborts the command. +- ALWAYS pass `--root`: the implicit root is the working directory, and a home directory + or filesystem root is refused because walking one costs minutes and describes no + project. - `--root` a single package for tight results, or the workspace root for cross-package questions. `--json` for machine-readable output. - `--tier scope` (default) is precise; `--tier name` is recall-first; `--tier dense`