Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <DIR>`) 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.

Expand Down
7 changes: 7 additions & 0 deletions cli/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
4 changes: 3 additions & 1 deletion cli/src/execution/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand Down
28 changes: 27 additions & 1 deletion cli/src/execution/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -107,8 +114,15 @@ fn render_index(envelope: &crate::OutputEnvelope<crate::IndexOutput>) -> 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -547,6 +568,7 @@ mod tests {
detail: "limit=12".into(),
},
],
omissions_truncated: false,
cache_recovery: None,
}
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
42 changes: 40 additions & 2 deletions cli/src/project/select.rs
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,32 @@ pub fn select_project(request: &CliRequest, cwd: &Path) -> Result<ProjectSelecti
});
}

select_directory(&cwd.canonical, &cwd, SelectionProvenance::CurrentDirectory)
select_implicit_directory(&cwd)
}

/// The implicit root is the current directory, and an agent session usually runs
/// with the working directory set to the user's home. Walking that (or `/`)
/// costs minutes, omits tens of thousands of files, and describes no project, so
/// it is refused with a message naming the fix. An explicit `--root` still
/// selects whatever the caller asks for.
fn is_forbidden_default_root(path: &Path, home: Option<&Path>) -> bool {
path.parent().is_none() || home.is_some_and(|home| path == home)
}

fn home_directory() -> Option<PathBuf> {
directories::BaseDirs::new().map(|dirs| dirs.home_dir().to_path_buf())
}

fn select_implicit_directory(cwd: &ValidatedCwd) -> Result<ProjectSelection> {
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 <DIR>"
.into(),
});
}
select_directory(&cwd.canonical, cwd, SelectionProvenance::CurrentDirectory)
}

struct ValidatedCwd {
Expand Down Expand Up @@ -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};
Expand Down Expand Up @@ -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");
Expand Down
75 changes: 72 additions & 3 deletions cli/src/result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<CacheOmissionOutput>,
/// 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
Expand Down Expand Up @@ -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<CacheOmissionOutput>, bool) {
let mut sorted = omissions.iter().collect::<Vec<_>>();
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 {
Expand Down Expand Up @@ -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<CacheOmissionOutput>,
/// 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,
Expand All @@ -563,14 +600,17 @@ 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(),
tier,
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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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::<Vec<_>>();

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);
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -1060,6 +1127,7 @@ mod tests {
completeness: CacheCompletenessOutput::Complete,
omitted_files: 0,
omissions: Vec::new(),
omissions_truncated: false,
cache_recovery: None,
};
assert_eq!(
Expand Down Expand Up @@ -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,
Expand Down
3 changes: 3 additions & 0 deletions docs/agent-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
Loading