From f9580d21a82856900a91a9f5c592a79533d4f88b Mon Sep 17 00:00:00 2001 From: EnRaiha <15997552+EnRaiha@users.noreply.github.com> Date: Fri, 18 Sep 2026 00:06:55 +0800 Subject: [PATCH] fix(cli): refuse an implicit home root and cap reported omissions An agent session runs with the working directory set to the user's home, so selecting the current directory as the project walked ~2.4M files, ran past any subprocess timeout, and produced no output at all. Refuse an implicit root that is a home directory or the filesystem root, naming the fix in the error. An explicit --root keeps selecting whatever the caller asks for. A wide root also omits one entry per skipped file: a status envelope for /tmp carried 22,933 entries and 9 MB of JSON, and both the project and cached lists duplicated it. Cap each reported omission list at 256 entries, keep the full count in omittedFiles / omitted_files (new on IndexOutput, so the index report no longer implies its total by list length), and mark a capped list with omissionsTruncated. Human output names the truncation and reports the index total from the count instead of the list. --- README.md | 8 +++- cli/src/config.rs | 7 ++++ cli/src/execution/lifecycle.rs | 4 +- cli/src/execution/output.rs | 28 ++++++++++++- cli/src/project/select.rs | 42 ++++++++++++++++++- cli/src/result.rs | 75 ++++++++++++++++++++++++++++++++-- docs/agent-integration.md | 3 ++ 7 files changed, 159 insertions(+), 8 deletions(-) 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`