Skip to content

Commit f9580d2

Browse files
committed
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.
1 parent f803882 commit f9580d2

7 files changed

Lines changed: 159 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ c2g impact helper --depth 3
113113

114114
By default the CLI rejects an incomplete index. `--allow-partial` explicitly permits
115115
publishing and querying a partial source set; inspect the reported omissions before
116-
relying on its results.
116+
relying on its results. Each reported omission list is capped at 256 entries to keep an
117+
envelope small; `omittedFiles` (and `inventory.omitted_files`) still carries the full
118+
count, and `omissionsTruncated` marks a list that was capped.
119+
120+
Without `--root`, the selected project is the working directory. A working directory
121+
that is a home directory or the filesystem root is refused: walking one costs minutes
122+
and describes no project. Name the project (`--root <DIR>`) to proceed.
117123

118124
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.
119125

‎cli/src/config.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,13 @@ pub const DEFAULT_MAX_TOTAL_BYTES: usize = 256 * 1_024 * 1_024;
9595
pub const DEFAULT_MAX_DEPTH: u32 = 32;
9696
/// Default number of rows rendered by a command.
9797
pub const DEFAULT_LIMIT: usize = 50;
98+
/// Default maximum number of individual omission entries reported in any one
99+
/// list. An over-broad root (a home directory, a parent of many repositories)
100+
/// omits tens of thousands of files, and a JSON envelope carrying one entry per
101+
/// omitted file grows to megabytes. The entry lists are diagnostics: each list
102+
/// is capped to this many entries while the totals (`omittedFiles`,
103+
/// `inventory.omitted_files`) and the rendered reason counts stay complete.
104+
pub const DEFAULT_MAX_OMISSIONS: usize = 256;
98105
/// Default reverse-reachability depth for `impact`.
99106
pub const DEFAULT_IMPACT_DEPTH: u32 = 2;
100107

‎cli/src/execution/lifecycle.rs‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1885,6 +1885,7 @@ fn project_output(
18851885
freshness: Freshness,
18861886
cache: CacheDisposition,
18871887
) -> ProjectOutput {
1888+
let (omissions, omissions_truncated) = crate::result::capped_omissions(&snapshot.omissions);
18881889
ProjectOutput {
18891890
root: selection.canonical_root.to_string_lossy().into_owned(),
18901891
snapshot: snapshot.candidate_id.to_string(),
@@ -1893,7 +1894,8 @@ fn project_output(
18931894
cache,
18941895
completeness: snapshot.completeness.into(),
18951896
omitted_files: snapshot.omissions.len(),
1896-
omissions: snapshot.omissions.iter().map(Into::into).collect(),
1897+
omissions,
1898+
omissions_truncated,
18971899
// Only the paths that actually refreshed against a store can observe a
18981900
// recovery; they fill this in from the store afterwards.
18991901
cache_recovery: None,

‎cli/src/execution/output.rs‎

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,13 @@ fn query_warning(project: Option<&ProjectOutput>) -> String {
7070
"warning: partial snapshot; {} source files omitted\n",
7171
project.omitted_files
7272
));
73+
if project.omissions_truncated {
74+
output.push_str(&format!(
75+
"warning: omission entries truncated; listing {} of {}\n",
76+
project.omissions.len(),
77+
project.omitted_files
78+
));
79+
}
7380
for omission in sorted_omissions(&project.omissions) {
7481
output.push_str(&format!(
7582
"warning: omitted {} reason={} detail={}\n",
@@ -107,8 +114,15 @@ fn render_index(envelope: &crate::OutputEnvelope<crate::IndexOutput>) -> String
107114
}
108115
output.push_str(&format!(
109116
"omitted files={}\n",
110-
envelope.results.omissions.len()
117+
envelope.results.omitted_files
111118
));
119+
if envelope.results.omissions_truncated {
120+
output.push_str(&format!(
121+
"warning: omission entries truncated; listing {} of {}\n",
122+
envelope.results.omissions.len(),
123+
envelope.results.omitted_files
124+
));
125+
}
112126
let omissions = sorted_omissions(&envelope.results.omissions);
113127
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
114128
for omission in &omissions {
@@ -153,6 +167,13 @@ fn render_status(status: &crate::StatusOutput) -> String {
153167
.timeout_millis
154168
.map_or_else(|| "none".into(), |value| value.to_string()),
155169
);
170+
if status.project.omissions_truncated {
171+
output.push_str(&format!(
172+
"warning: omission entries truncated; listing {} of {}; reason counts cover the listed entries only\n",
173+
status.project.omissions.len(),
174+
status.project.omitted_files
175+
));
176+
}
156177
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
157178
let omissions = sorted_omissions(&status.project.omissions);
158179
for omission in &omissions {
@@ -547,6 +568,7 @@ mod tests {
547568
detail: "limit=12".into(),
548569
},
549570
],
571+
omissions_truncated: false,
550572
cache_recovery: None,
551573
}
552574
}
@@ -595,6 +617,8 @@ mod tests {
595617
inventory_file_count: 3,
596618
inventory_total_bytes: 42,
597619
omissions: project(Freshness::Fresh, CacheCompletenessOutput::Partial).omissions,
620+
omitted_files: 2,
621+
omissions_truncated: false,
598622
changed: 2,
599623
deleted: 1,
600624
ignored_omissions: 0,
@@ -629,6 +653,8 @@ mod tests {
629653
inventory_file_count: 1,
630654
inventory_total_bytes: 42,
631655
omissions: Vec::new(),
656+
omitted_files: 0,
657+
omissions_truncated: false,
632658
changed: 1,
633659
deleted: 0,
634660
ignored_omissions: 0,

‎cli/src/project/select.rs‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,32 @@ pub fn select_project(request: &CliRequest, cwd: &Path) -> Result<ProjectSelecti
6868
});
6969
}
7070

71-
select_directory(&cwd.canonical, &cwd, SelectionProvenance::CurrentDirectory)
71+
select_implicit_directory(&cwd)
72+
}
73+
74+
/// The implicit root is the current directory, and an agent session usually runs
75+
/// with the working directory set to the user's home. Walking that (or `/`)
76+
/// costs minutes, omits tens of thousands of files, and describes no project, so
77+
/// it is refused with a message naming the fix. An explicit `--root` still
78+
/// selects whatever the caller asks for.
79+
fn is_forbidden_default_root(path: &Path, home: Option<&Path>) -> bool {
80+
path.parent().is_none() || home.is_some_and(|home| path == home)
81+
}
82+
83+
fn home_directory() -> Option<PathBuf> {
84+
directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
85+
}
86+
87+
fn select_implicit_directory(cwd: &ValidatedCwd) -> Result<ProjectSelection> {
88+
if is_forbidden_default_root(&cwd.canonical, home_directory().as_deref()) {
89+
return Err(CliError::ProjectPath {
90+
path: cwd.canonical.clone(),
91+
reason: "refusing the current directory as an implicit project root \
92+
(home or filesystem root); pass --root <DIR>"
93+
.into(),
94+
});
95+
}
96+
select_directory(&cwd.canonical, cwd, SelectionProvenance::CurrentDirectory)
7297
}
7398

7499
struct ValidatedCwd {
@@ -222,7 +247,7 @@ mod tests {
222247

223248
#[cfg(target_os = "macos")]
224249
use super::is_trusted_system_ancestor;
225-
use super::{SelectionProvenance, select_project};
250+
use super::{SelectionProvenance, is_forbidden_default_root, select_project};
226251
use crate::config::GlobalOptions;
227252
use crate::error::CliError;
228253
use crate::request::{CliRequest, CommandRequest};
@@ -355,6 +380,19 @@ mod tests {
355380
);
356381
}
357382

383+
#[test]
384+
fn implicit_cwd_root_refuses_home_and_filesystem_root_only() {
385+
let home = Path::new("/home/example");
386+
assert!(is_forbidden_default_root(Path::new("/"), Some(home)));
387+
assert!(is_forbidden_default_root(Path::new("/"), None));
388+
assert!(is_forbidden_default_root(home, Some(home)));
389+
assert!(!is_forbidden_default_root(
390+
Path::new("/home/example/project"),
391+
Some(home)
392+
));
393+
assert!(!is_forbidden_default_root(home, None));
394+
}
395+
358396
#[test]
359397
fn rejects_invalid_cwd_before_other_selection_inputs() {
360398
let directory = tempdir().expect("temporary directory");

‎cli/src/result.rs‎

Lines changed: 72 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ use code2graph::{Confidence, Provenance, RefRole, SymbolId, SymbolKind, TypeRefC
44
use serde::{Deserialize, Serialize};
55

66
use crate::cache::{CacheCompleteness, CacheOmission, LoadedSnapshot};
7-
use crate::config::{ResolverTier, ResourceLimits};
7+
use crate::config::{DEFAULT_MAX_OMISSIONS, ResolverTier, ResourceLimits};
88
use crate::exit::ExitCode;
99
use crate::inventory::{
1010
InventoryCompleteness, InventorySummary, OmissionReason, StableIoErrorKind,
@@ -92,7 +92,16 @@ pub struct ProjectOutput {
9292
pub completeness: CacheCompletenessOutput,
9393
#[serde(rename = "omittedFiles")]
9494
pub omitted_files: usize,
95+
/// Capped to [`DEFAULT_MAX_OMISSIONS`] entries; `omittedFiles` carries the total.
9596
pub omissions: Vec<CacheOmissionOutput>,
97+
/// Present only when `omissions` was capped, so a consumer can tell a short
98+
/// list from a complete one.
99+
#[serde(
100+
rename = "omissionsTruncated",
101+
default,
102+
skip_serializing_if = "is_false"
103+
)]
104+
pub omissions_truncated: bool,
96105
/// Why a previously cached snapshot was discarded and rebuilt, when that
97106
/// happened during this run. A cache whose stored facts no longer satisfy
98107
/// their validation contract — after an upgrade changes that contract, say
@@ -509,6 +518,27 @@ impl From<&CacheOmission> for CacheOmissionOutput {
509518
}
510519
}
511520

521+
/// Deterministically ordered, capped view of an omission list.
522+
///
523+
/// Returns the reported entries (at most [`DEFAULT_MAX_OMISSIONS`]) and whether
524+
/// entries were held back. Callers keep the full total in their own count field,
525+
/// so capping the entry list never hides how many files were omitted.
526+
pub fn capped_omissions(omissions: &[CacheOmission]) -> (Vec<CacheOmissionOutput>, bool) {
527+
let mut sorted = omissions.iter().collect::<Vec<_>>();
528+
sorted.sort_by(|left, right| {
529+
(&left.path, &left.reason, &left.detail).cmp(&(&right.path, &right.reason, &right.detail))
530+
});
531+
let truncated = sorted.len() > DEFAULT_MAX_OMISSIONS;
532+
sorted.truncate(DEFAULT_MAX_OMISSIONS);
533+
(sorted.into_iter().map(Into::into).collect(), truncated)
534+
}
535+
536+
/// `skip_serializing_if` for the additive truncation flags: an untruncated
537+
/// envelope keeps the exact spelling it had before the flag existed.
538+
const fn is_false(value: &bool) -> bool {
539+
!*value
540+
}
541+
512542
/// Counts of decisions made by the refresh planner.
513543
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
514544
pub struct PlanDecisionCountsOutput {
@@ -544,7 +574,14 @@ pub struct IndexOutput {
544574
pub completeness: CacheCompletenessOutput,
545575
pub inventory_file_count: u64,
546576
pub inventory_total_bytes: u64,
577+
/// Total extracted-and-omitted files, independent of `omissions` being capped.
578+
#[serde(default)]
579+
pub omitted_files: usize,
580+
/// Capped to [`DEFAULT_MAX_OMISSIONS`] entries; `omitted_files` carries the total.
547581
pub omissions: Vec<CacheOmissionOutput>,
582+
/// Present only when `omissions` was capped.
583+
#[serde(default, skip_serializing_if = "is_false")]
584+
pub omissions_truncated: bool,
548585
pub changed: usize,
549586
pub deleted: usize,
550587
pub ignored_omissions: usize,
@@ -563,14 +600,17 @@ impl IndexOutput {
563600
attempts: u8,
564601
plan_decisions: PlanDecisionCountsOutput,
565602
) -> Self {
603+
let (omissions, omissions_truncated) = capped_omissions(&snapshot.omissions);
566604
Self {
567605
candidate: snapshot.candidate_id.to_string(),
568606
snapshot: snapshot.candidate_id.to_string(),
569607
tier,
570608
completeness: snapshot.completeness.into(),
571609
inventory_file_count: snapshot.inventory_file_count,
572610
inventory_total_bytes: snapshot.inventory_total_bytes,
573-
omissions: snapshot.omissions.iter().map(Into::into).collect(),
611+
omitted_files: snapshot.omissions.len(),
612+
omissions,
613+
omissions_truncated,
574614
changed,
575615
deleted,
576616
ignored_omissions,
@@ -618,7 +658,10 @@ impl StatusOutput {
618658
omitted_files: snapshot.omissions.len(),
619659
omission_reasons: Vec::new(),
620660
},
621-
cached_omissions: snapshot.omissions.iter().map(Into::into).collect(),
661+
// The cached entries mirror `project.omissions` and are capped the
662+
// same way; `project.omitted_files` carries the full count and
663+
// `project.omissions_truncated` says whether either list is short.
664+
cached_omissions: capped_omissions(&snapshot.omissions).0,
622665
max_files: limits.max_files,
623666
max_file_bytes: limits.max_file_bytes,
624667
max_total_bytes: limits.max_total_bytes,
@@ -909,10 +952,31 @@ mod tests {
909952
completeness: snapshot.completeness.into(),
910953
omitted_files: snapshot.omissions.len(),
911954
omissions: snapshot.omissions.iter().map(Into::into).collect(),
955+
omissions_truncated: false,
912956
cache_recovery: None,
913957
}
914958
}
915959

960+
#[test]
961+
fn omission_entry_lists_are_capped_while_the_truncation_is_reported() {
962+
let omissions = (0..(DEFAULT_MAX_OMISSIONS + 5))
963+
.map(|index| CacheOmission {
964+
path: format!("src/file{index:04}.rs"),
965+
reason: "file-count-limit".into(),
966+
detail: "limit=10000".into(),
967+
})
968+
.collect::<Vec<_>>();
969+
970+
let (reported, truncated) = capped_omissions(&omissions);
971+
assert_eq!(reported.len(), DEFAULT_MAX_OMISSIONS);
972+
assert!(truncated);
973+
assert_eq!(reported[0].path, "src/file0000.rs");
974+
975+
let (short, truncated) = capped_omissions(&omissions[..3]);
976+
assert_eq!(short.len(), 3);
977+
assert!(!truncated);
978+
}
979+
916980
#[test]
917981
fn index_output_and_cached_status_are_owned_stable_contracts() {
918982
let snapshot = loaded_snapshot(CacheCompleteness::Partial);
@@ -962,6 +1026,8 @@ mod tests {
9621026
reason: "file-too-large".into(),
9631027
detail: "limit=1024".into(),
9641028
}],
1029+
omitted_files: 1,
1030+
omissions_truncated: false,
9651031
changed: 2,
9661032
deleted: 1,
9671033
ignored_omissions: 4,
@@ -986,6 +1052,7 @@ mod tests {
9861052
"omissions": [{
9871053
"path": "src/large.rs", "reason": "file-too-large", "detail": "limit=1024"
9881054
}],
1055+
"omitted_files": 1,
9891056
"changed": 2,
9901057
"deleted": 1,
9911058
"ignored_omissions": 4,
@@ -1060,6 +1127,7 @@ mod tests {
10601127
completeness: CacheCompletenessOutput::Complete,
10611128
omitted_files: 0,
10621129
omissions: Vec::new(),
1130+
omissions_truncated: false,
10631131
cache_recovery: None,
10641132
};
10651133
assert_eq!(
@@ -1132,6 +1200,7 @@ mod tests {
11321200
completeness: snapshot.completeness.into(),
11331201
omitted_files: snapshot.omissions.len(),
11341202
omissions: snapshot.omissions.iter().map(Into::into).collect(),
1203+
omissions_truncated: false,
11351204
cache_recovery: None,
11361205
},
11371206
&snapshot,

‎docs/agent-integration.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ strings, config values, comments, error text, non-source files, unsupported lang
3232

3333
- ALWAYS pass `--allow-partial`: real codebases have files that fail extraction, and
3434
without it any such file aborts the command.
35+
- ALWAYS pass `--root`: the implicit root is the working directory, and a home directory
36+
or filesystem root is refused because walking one costs minutes and describes no
37+
project.
3538
- `--root` a single package for tight results, or the workspace root for cross-package
3639
questions. `--json` for machine-readable output.
3740
- `--tier scope` (default) is precise; `--tier name` is recall-first; `--tier dense`

0 commit comments

Comments
 (0)