Skip to content
Merged
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
4 changes: 3 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,9 @@ jobs:
set -euo pipefail
INSTALL_ROOT="$(mktemp -d)"
cargo install --path cli --root "$INSTALL_ROOT"
"$INSTALL_ROOT/bin/code2graph" --help
for CLI_BIN in "$INSTALL_ROOT"/bin/*; do
"$CLI_BIN" --help
done

bindings:
if: ${{ !inputs.skip_bindings }}
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,11 @@ c2g callers helper
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.
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. Each reported omission list is capped at 256 entries. Project metadata retains the full count in `omittedFiles` and full reason totals in `omissionReasons`. Index results use `omitted_files` and `omission_reasons`. Status inventory retains the full count in `inventory.omitted_files`. The `omissionsTruncated` project flag and `omissions_truncated` index flag identify capped lists.

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
18 changes: 11 additions & 7 deletions cli/src/execution/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1878,13 +1878,15 @@ fn graph_from_snapshot(
})
}

fn project_output(
pub(super) fn project_output(
selection: &crate::ProjectSelection,
snapshot: &LoadedSnapshot,
tier: crate::ResolverTier,
freshness: Freshness,
cache: CacheDisposition,
) -> ProjectOutput {
let omission_reasons = crate::result::cache_omission_reasons(&snapshot.omissions);
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 +1895,9 @@ fn project_output(
cache,
completeness: snapshot.completeness.into(),
omitted_files: snapshot.omissions.len(),
omissions: snapshot.omissions.iter().map(Into::into).collect(),
omissions,
omission_reasons,
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 Expand Up @@ -2675,11 +2679,11 @@ mod tests {

// One project root disappears; one cache is left on an older schema.
fs::remove_dir_all(temp.path().join("deleted")).expect("remove project");
let stale_key = crate::cache::CacheLocation::for_project(
Some(cache.as_path()),
&temp.path().join("stale"),
)
.expect("stale location");
let stale_root =
fs::canonicalize(temp.path().join(".").join("stale")).expect("canonical stale project");
let stale_key =
crate::cache::CacheLocation::for_project(Some(cache.as_path()), &stale_root)
.expect("stale location");
let connection =
rusqlite::Connection::open(&stale_key.database_path).expect("open stale cache");
connection
Expand Down
175 changes: 175 additions & 0 deletions cli/src/execution/omission_output_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
// SPDX-License-Identifier: Apache-2.0

use crate::cache::{
CacheCompleteness, CacheOmission, CandidateId, CompatibilityFingerprint, CompatibilityRecord,
LanguageFeatureFingerprint, LoadedSnapshot, PackageFingerprint, ProjectInputDigest,
};
use crate::config::{DEFAULT_MAX_OMISSIONS, ResourceLimits};
use crate::result::{CacheReasonCountOutput, IndexOutput, PlanDecisionCountsOutput};
use crate::{
CacheDisposition, Freshness, OutputEnvelope, OutputStatus, ProjectSelection, ResolverTier,
SelectionProvenance, StatusOutput,
};

use super::super::lifecycle::{CommandOutput, project_output};
use super::render_human;

fn mixed_snapshot() -> LoadedSnapshot {
let omissions = (0..265)
.rev()
.map(|index| CacheOmission {
path: format!("src/file{index:04}.rs"),
reason: match index {
0..250 => "file-count-limit",
250..260 => "file-too-large",
_ => "read-error:other",
}
.into(),
detail: "resource limit".into(),
})
.collect::<Vec<_>>();
let language = LanguageFeatureFingerprint::current();
let package = PackageFingerprint::from_normalized(["test"]);
let compatibility = CompatibilityFingerprint::new(language, package);
let digest = ProjectInputDigest::from_inputs([] as [(&str, &str, [u8; 32]); 0]);
LoadedSnapshot {
candidate_id: CandidateId::new(
compatibility,
digest,
CacheCompleteness::Partial,
&omissions,
),
compatibility: CompatibilityRecord {
id: compatibility,
language_fingerprint: language,
package_fingerprint: package,
created_at_ns: 1,
},
input_digest: digest,
completeness: CacheCompleteness::Partial,
omissions,
created_at_ns: 2,
inventory_file_count: 3,
inventory_total_bytes: 42,
files: Vec::new(),
tier_graphs: Vec::new(),
}
}

#[test]
fn index_and_cached_status_count_reasons_outside_capped_entries() {
let snapshot = mixed_snapshot();
let expected = vec![
CacheReasonCountOutput {
reason: "file-count-limit".into(),
count: 250,
},
CacheReasonCountOutput {
reason: "file-too-large".into(),
count: 10,
},
CacheReasonCountOutput {
reason: "read-error:other".into(),
count: 5,
},
];
let index = IndexOutput::from_loaded_snapshot(
&snapshot,
ResolverTier::Scope,
0,
0,
0,
1,
PlanDecisionCountsOutput::default(),
);
let selection = ProjectSelection {
canonical_root: "/project".into(),
canonical_source: None,
provenance: SelectionProvenance::RootArgument,
};
let project = project_output(
&selection,
&snapshot,
ResolverTier::Scope,
Freshness::Frozen,
CacheDisposition::Hit,
);
assert_eq!(index.omission_reasons, expected);
assert_eq!(project.omission_reasons, expected);
assert_eq!(index.omissions.len(), DEFAULT_MAX_OMISSIONS);
assert_eq!(index.omissions, project.omissions);
assert_eq!(index.omitted_files, 265);
assert_eq!(project.omitted_files, 265);
assert!(index.omissions_truncated);
assert!(project.omissions_truncated);
assert!(
index
.omissions
.iter()
.all(|entry| entry.reason != "read-error:other")
);
assert_eq!(index.omissions[0].path, "src/file0000.rs");
assert_eq!(index.omissions[255].path, "src/file0255.rs");

let status = StatusOutput::from_loaded_snapshot(project, &snapshot, &ResourceLimits::default());
assert_eq!(status.cached_omissions, index.omissions);
assert_eq!(status.project.omission_reasons, expected);
assert_eq!(status.inventory.omitted_files, 265);
let expected_json = serde_json::json!([
{"reason": "file-count-limit", "count": 250},
{"reason": "file-too-large", "count": 10},
{"reason": "read-error:other", "count": 5},
]);
let mut index_json = serde_json::to_value(&index).expect("index JSON");
let mut project_json = serde_json::to_value(&status.project).expect("project JSON");
assert_eq!(index_json["omission_reasons"], expected_json);
assert_eq!(project_json["omissionReasons"], expected_json);
index_json
.as_object_mut()
.expect("index object")
.remove("omission_reasons");
project_json
.as_object_mut()
.expect("project object")
.remove("omissionReasons");
assert!(
serde_json::from_value::<IndexOutput>(index_json)
.expect("older index contract")
.omission_reasons
.is_empty()
);
assert!(
serde_json::from_value::<crate::ProjectOutput>(project_json)
.expect("older project contract")
.omission_reasons
.is_empty()
);

for output in [
CommandOutput::Index(OutputEnvelope::new(OutputStatus::Partial, index)),
CommandOutput::Status(OutputEnvelope::new(OutputStatus::Partial, status)),
] {
let rendered = render_human(&output);
assert!(rendered.contains("warning: omission entries truncated; listing 256 of 265\n"));
let reasons = rendered
.lines()
.filter(|line| line.starts_with("omission reason="))
.collect::<Vec<_>>();
assert_eq!(
reasons,
[
"omission reason=file-count-limit count=250",
"omission reason=file-too-large count=10",
"omission reason=read-error:other count=5",
]
);
assert_eq!(
rendered
.lines()
.filter(|line| line.starts_with("omitted src/"))
.count(),
DEFAULT_MAX_OMISSIONS
);
assert!(!rendered.contains("omitted src/file0260.rs"));
}
}
68 changes: 55 additions & 13 deletions 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,15 +114,21 @@ fn render_index(envelope: &crate::OutputEnvelope<crate::IndexOutput>) -> String
}
output.push_str(&format!(
"omitted files={}\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 {
*counts.entry(&omission.reason).or_default() += 1;
if envelope.results.omissions_truncated {
output.push_str(&format!(
"warning: omission entries truncated; listing {} of {}\n",
envelope.results.omissions.len(),
envelope.results.omitted_files
));
}
for (reason, count) in counts {
output.push_str(&format!("omission reason={} count={}\n", reason, count));
let omissions = sorted_omissions(&envelope.results.omissions);
Comment thread
farhan-syah marked this conversation as resolved.
for reason in &envelope.results.omission_reasons {
output.push_str(&format!(
"omission reason={} count={}\n",
reason.reason, reason.count
));
}
for omission in omissions {
output.push_str(&format!(
Expand Down Expand Up @@ -153,13 +166,19 @@ fn render_status(status: &crate::StatusOutput) -> String {
.timeout_millis
.map_or_else(|| "none".into(), |value| value.to_string()),
);
let mut counts = std::collections::BTreeMap::<&str, usize>::new();
let omissions = sorted_omissions(&status.project.omissions);
for omission in &omissions {
*counts.entry(&omission.reason).or_default() += 1;
if status.project.omissions_truncated {
output.push_str(&format!(
"warning: omission entries truncated; listing {} of {}\n",
status.project.omissions.len(),
status.project.omitted_files
));
}
for (reason, count) in counts {
output.push_str(&format!("omission reason={} count={}\n", reason, count));
let omissions = sorted_omissions(&status.project.omissions);
for reason in &status.project.omission_reasons {
output.push_str(&format!(
"omission reason={} count={}\n",
reason.reason, reason.count
));
}
for omission in omissions {
output.push_str(&format!(
Expand Down Expand Up @@ -547,6 +566,17 @@ mod tests {
detail: "limit=12".into(),
},
],
omission_reasons: vec![
crate::result::CacheReasonCountOutput {
reason: "file-too-large".into(),
count: 1,
},
crate::result::CacheReasonCountOutput {
reason: "read-error:other".into(),
count: 1,
},
],
omissions_truncated: false,
cache_recovery: None,
}
}
Expand Down Expand Up @@ -595,6 +625,10 @@ mod tests {
inventory_file_count: 3,
inventory_total_bytes: 42,
omissions: project(Freshness::Fresh, CacheCompletenessOutput::Partial).omissions,
omitted_files: 2,
omission_reasons: project(Freshness::Fresh, CacheCompletenessOutput::Partial)
.omission_reasons,
omissions_truncated: false,
changed: 2,
deleted: 1,
ignored_omissions: 0,
Expand All @@ -617,6 +651,7 @@ mod tests {
let mut project = project(Freshness::Fresh, CacheCompletenessOutput::Complete);
project.omitted_files = 0;
project.omissions = Vec::new();
project.omission_reasons = Vec::new();
project.cache_recovery = Some(detail.into());

let mut envelope = OutputEnvelope::new(
Expand All @@ -629,6 +664,9 @@ mod tests {
inventory_file_count: 1,
inventory_total_bytes: 42,
omissions: Vec::new(),
omitted_files: 0,
omission_reasons: Vec::new(),
omissions_truncated: false,
changed: 1,
deleted: 0,
ignored_omissions: 0,
Expand Down Expand Up @@ -703,3 +741,7 @@ mod tests {
assert_eq!(render_human(&CommandOutput::Impact(impact)), expected);
}
}

#[cfg(test)]
#[path = "omission_output_tests.rs"]
mod omission_output_tests;
Loading
Loading