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`