diff --git a/crates/codestory-cli/src/app/tests/test_support.rs b/crates/codestory-cli/src/app/tests/test_support.rs index e8ef34935..7c7af96fd 100644 --- a/crates/codestory-cli/src/app/tests/test_support.rs +++ b/crates/codestory-cli/src/app/tests/test_support.rs @@ -28,6 +28,7 @@ pub(super) fn sample_retrieval() -> RetrievalStateDto { pub(super) fn sample_agent_answer_with_graph(graph: GraphArtifactDto) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "context-test".to_string(), prompt: "capped_bundle".to_string(), summary: "Bundle summary".to_string(), @@ -88,6 +89,7 @@ pub(super) fn sample_task_brief_packet() -> AgentPacketDto { trace: Vec::new(), }, answer: AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-task-brief".to_string(), prompt: "Add `$env:SECRET $(Get-ChildItem) 'literal' task brief".to_string(), summary: "Use the packet command path.".to_string(), diff --git a/crates/codestory-cli/src/output.rs b/crates/codestory-cli/src/output.rs index 4bf8073b5..2bf0183c7 100644 --- a/crates/codestory-cli/src/output.rs +++ b/crates/codestory-cli/src/output.rs @@ -4981,6 +4981,7 @@ mod tests { #[test] fn context_markdown_contract_includes_evidence_packet_shape() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-1".to_string(), prompt: "build_packet".to_string(), summary: "Packet output is assembled from retrieved CLI evidence.".to_string(), @@ -5126,6 +5127,7 @@ mod tests { fn well_grounded_packet_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-telemetry".to_string(), prompt: "Explain how the installer dispatches commands.".to_string(), summary: "The installer dispatches install, download, and use commands.".to_string(), @@ -5913,6 +5915,7 @@ mod tests { #[test] fn context_markdown_surfaces_low_confidence_trace_gaps() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "answer-1".to_string(), prompt: "weak_hit".to_string(), summary: "Retrieval was incomplete.".to_string(), diff --git a/crates/codestory-cli/src/stdio_catalog.rs b/crates/codestory-cli/src/stdio_catalog.rs index ac5861aa7..5d2d552c3 100644 --- a/crates/codestory-cli/src/stdio_catalog.rs +++ b/crates/codestory-cli/src/stdio_catalog.rs @@ -1571,6 +1571,19 @@ static CONTEXT_PACKET_SCHEMA: SchemaObject = SchemaObject::object( SchemaProperty::string("retrieval_version", "Retrieval version."), SchemaProperty::array("graphs", "Graph artifacts.", &GENERIC_OBJECT_SCHEMA), SchemaProperty::object("retrieval_trace", "Retrieval trace and summary."), + // Both are optional on the wire and both were already being emitted: + // this schema is `additionalProperties: false`, so leaving them + // undeclared published a contract the tool itself violates. `freshness` + // has been emitted undeclared since EV-78. + SchemaProperty::object( + "freshness", + "Index freshness observation, when one was made.", + ), + SchemaProperty::array( + "source_coverage", + "Coverage for the files this packet rested on, when any were checked.", + &GENERIC_OBJECT_SCHEMA, + ), ], &[ "packet_id", @@ -2479,4 +2492,60 @@ mod tests { assert_eq!(budget["minimum"], 1_000); assert_eq!(budget["maximum"], 120_000); } + + /// The context packet must not emit a field its own published schema + /// forbids. + /// + /// `CONTEXT_PACKET_SCHEMA` is `additionalProperties: false`, and + /// `context_packet_json` serializes the whole `AgentAnswerDto`, so every + /// optional field added to that DTO silently becomes a schema violation. It + /// happened twice before anyone noticed — `freshness` since EV-78 and + /// `source_coverage` — because nothing compared the two. + #[test] + fn the_context_packet_emits_only_fields_its_schema_declares() { + let declared = CONTEXT_PACKET_SCHEMA.declared_property_names(); + let mut answer = codestory_contracts::api::AgentAnswerDto { + answer_id: "packet".to_string(), + prompt: "question".to_string(), + summary: "summary".to_string(), + freshness: None, + source_coverage: Vec::new(), + sections: Vec::new(), + citations: Vec::new(), + subgraph_ids: Vec::new(), + retrieval_version: "test".to_string(), + graphs: Vec::new(), + retrieval_trace: serde_json::from_value(serde_json::json!({ + "request_id": "r", + "resolved_profile": "architecture", + "policy_mode": "latency_first", + "total_latency_ms": 0, + "steps": [], + })) + .expect("minimal retrieval trace"), + }; + // Populate the optional fields: they are `skip_serializing_if`, so an + // empty fixture would pass while a real packet failed. + answer.source_coverage = vec![codestory_contracts::api::SourceCoverageObservationDto { + path: "data/big.json".to_string(), + status: codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(2), + byte_cap: Some(1), + }]; + + let packet = crate::output::context_packet_json(&answer); + let emitted = packet.as_object().expect("packet object"); + let undeclared = emitted + .keys() + .filter(|key| !declared.contains(&key.as_str())) + .cloned() + .collect::>(); + assert!( + undeclared.is_empty(), + "the packet emits {undeclared:?}, which its published output schema \ + forbids: declared = {declared:?}" + ); + } } diff --git a/crates/codestory-contracts/src/api.rs b/crates/codestory-contracts/src/api.rs index 736b77d36..bc350b075 100644 --- a/crates/codestory-contracts/src/api.rs +++ b/crates/codestory-contracts/src/api.rs @@ -69,12 +69,14 @@ pub use dto::{ SearchPlanTermsDto, SearchQueryAssessmentDto, SearchRepoTextMode, SearchRequest, SearchResultsDto, SearchTargetDto, SearchVerificationTargetDto, SemanticFallbackRecordDto, SemanticModeDto, SetUiLayoutRequest, SnippetContextDto, SnippetScopeDto, - SourceFreshnessTelemetryDto, SourceOccurrenceDto, SourcePolicyExclusionDto, - StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto, SummaryGenerationDto, - SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto, TrailContextDto, - TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto, UpdateBookmarkCategoryRequest, - UpdateBookmarkRequest, WorkspaceMemberIndexDto, WriteFileDataUrlRequest, WriteFileResponse, - WriteFileTextRequest, validate_packet_probe, validate_packet_probe_request, + SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, + SourceCoverageUnprovableCauseDto, SourceFreshnessTelemetryDto, SourceOccurrenceDto, + SourcePolicyExclusionDto, StartIndexingRequest, StorageStatsDto, StoredSemanticDocsContractDto, + SummaryGenerationDto, SymbolContextDto, SymbolSummaryDto, SystemActionResponse, TrailConfigDto, + TrailContextDto, TrailFilterOptionsDto, TrailStoryDto, TrailStoryStepDto, + UpdateBookmarkCategoryRequest, UpdateBookmarkRequest, WorkspaceMemberIndexDto, + WriteFileDataUrlRequest, WriteFileResponse, WriteFileTextRequest, validate_packet_probe, + validate_packet_probe_request, }; pub use errors::{ ApiError, ApiErrorDetails, COMMAND_FAILURE_SCHEMA_VERSION, CommandFailureEnvelope, diff --git a/crates/codestory-contracts/src/api/dto.rs b/crates/codestory-contracts/src/api/dto.rs index 1683bc7f4..c5a6f4448 100644 --- a/crates/codestory-contracts/src/api/dto.rs +++ b/crates/codestory-contracts/src/api/dto.rs @@ -673,6 +673,126 @@ pub struct IndexFreshnessDto { pub samples: Vec, } +/// What the index knows about one source file it was asked about. +/// +/// Deliberately inert, like [`IndexFreshnessDto`]: it carries no verdict about whether the file is +/// usable and none about whether evidence over it is provable. Both are computed downstream by +/// consumers reading this same observation. +#[derive(Debug, Clone, Serialize, Deserialize, Type, PartialEq, Eq)] +pub struct SourceCoverageObservationDto { + /// The path as the consumer asked about it, so a caller can match its own request. + pub path: String, + pub status: SourceCoverageStatusDto, + /// Set only when `status` is `Incomplete`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, + /// Set only when `status` is `NotEstablished`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub not_established_cause: Option, + /// Observed size and the cap that refused it, when the file was policy-excluded. + /// + /// Reported so a gap sentence can name numbers instead of a word. No verdict reads these. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub observed_size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub byte_cap: Option, +} + +/// What the index established about one file. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageStatusDto { + /// The file is in the published core with no recorded coverage defect. + Indexed, + /// A published policy exclusion refused the file before scheduling. + PolicyExcluded, + /// The file was indexed but a coverage reason was recorded against it. + Incomplete, + /// Coverage could not be determined; see `not_established_cause`. + NotEstablished, +} + +/// Why a coverage lookup could not reach a verdict. +/// +/// The same split [`IndexFreshnessNotCheckedCauseDto`] makes: `PublicationIncomplete` means there +/// is no complete core to ask, which is a deliberate and recoverable state; `LookupUnavailable` +/// means the query itself failed and proves nothing. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageNotEstablishedCauseDto { + PublicationIncomplete, + LookupUnavailable, +} + +/// Why a consumer must treat evidence over one file as unprovable. +/// +/// [`SourceCoverageStatusDto`] describes what the *index* did. This describes what a consumer may +/// conclude, and as with freshness those are different judgements — so this carries variants the +/// producer cannot emit. +/// +/// One deliberate divergence from [`FreshnessUnknownCauseDto`], and it is the whole asymmetry: +/// that mapping takes an `Option` because *no freshness observation at all* is itself unknown and +/// must cap. Coverage's takes a value, because an empty observation list means no path was +/// checked, which is legitimate and must cap nothing. A failed lookup is carried per path as +/// `NotEstablished` rather than by absence. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum SourceCoverageUnprovableCauseDto { + /// A published policy exclusion refused the file, so the index never read it. + PolicyExcluded, + /// The file carries a recorded coverage defect. + IncompleteIndex, + /// There is no complete publication to ask about this file. + PublicationIncomplete, + /// The coverage lookup failed. + LookupUnavailable, + /// The observation reported `Incomplete` without naming a reason. + ReasonUnreported, + /// The observation reported `NotEstablished` without naming a cause. + CauseUnreported, +} + +impl SourceCoverageUnprovableCauseDto { + /// Stable machine-readable identity for gap text and typed output fields. + pub fn id(self) -> &'static str { + match self { + Self::PolicyExcluded => "policy_excluded", + Self::IncompleteIndex => "incomplete_index", + Self::PublicationIncomplete => "publication_incomplete", + Self::LookupUnavailable => "lookup_unavailable", + Self::ReasonUnreported => "reason_unreported", + Self::CauseUnreported => "cause_unreported", + } + } + + /// The unprovable cause an observation carries, or `None` when the file is covered. + /// + /// `Indexed` is the only verdict that establishes coverage. Everything else — including an + /// `Incomplete` that names no reason — is unprovable, because defaulting an unnamed defect to + /// covered is the exposure this type exists to close. + pub fn for_observation(observation: &SourceCoverageObservationDto) -> Option { + match observation.status { + SourceCoverageStatusDto::Indexed => None, + SourceCoverageStatusDto::PolicyExcluded => Some(Self::PolicyExcluded), + SourceCoverageStatusDto::Incomplete => Some(match observation.reason { + Some(_) => Self::IncompleteIndex, + None => Self::ReasonUnreported, + }), + SourceCoverageStatusDto::NotEstablished => { + Some(match observation.not_established_cause { + Some(SourceCoverageNotEstablishedCauseDto::PublicationIncomplete) => { + Self::PublicationIncomplete + } + Some(SourceCoverageNotEstablishedCauseDto::LookupUnavailable) => { + Self::LookupUnavailable + } + None => Self::CauseUnreported, + }) + } + } + } +} + /// Readiness goal being evaluated. #[derive(Debug, Clone, Copy, Serialize, Deserialize, Type, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -2649,6 +2769,13 @@ pub struct AgentAnswerDto { pub summary: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub freshness: Option, + /// Coverage for the files this answer actually touched. + /// + /// Empty means no path was checked, which is legitimate and caps nothing — the opposite of + /// `freshness`, where absence is itself an unknown. A failed lookup arrives here as a + /// `NotEstablished` observation rather than as an absent list. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub source_coverage: Vec, pub sections: Vec, pub citations: Vec, pub subgraph_ids: Vec, diff --git a/crates/codestory-runtime/src/agent/mod.rs b/crates/codestory-runtime/src/agent/mod.rs index ee5bb3009..8468f16bf 100644 --- a/crates/codestory-runtime/src/agent/mod.rs +++ b/crates/codestory-runtime/src/agent/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod packet_claim_profile_registry; pub(crate) mod packet_claim_profiles; pub(crate) mod packet_claims; pub(crate) mod packet_command_profiles; +pub(crate) mod packet_coverage; pub(crate) mod packet_degradation; pub(crate) mod packet_evidence; pub(crate) mod packet_freshness; diff --git a/crates/codestory-runtime/src/agent/orchestrator.rs b/crates/codestory-runtime/src/agent/orchestrator.rs index b522ec350..ba6de523d 100644 --- a/crates/codestory-runtime/src/agent/orchestrator.rs +++ b/crates/codestory-runtime/src/agent/orchestrator.rs @@ -361,6 +361,7 @@ pub(crate) fn agent_ask( let summary = summarize_response(&resolved_profile, &bundle); Ok(AgentAnswerDto { + source_coverage: Vec::new(), answer_id: request_id, prompt, summary, @@ -513,6 +514,30 @@ pub(crate) fn agent_packet( let sufficiency_extra_probes = packet_plan_sufficiency_extra_probes(&plan, &extra_probes); let exact_probe_paths = exact_packet_probe_paths(&plan.probe_resolutions); + + // Observed here, after every filesystem citation appender has run, because + // the uncapped route is a *cited* file rather than a probed one: a required + // file-scoped citation is minted `eligible_for_sufficiency`, so a packet + // could rest a proof-bearing claim on a file the index refused. Driving + // this off probe paths alone would leave exactly that route uncovered. + let mut covered_paths = exact_probe_paths.clone(); + covered_paths.extend( + answer + .citations + .iter() + // Only citations that could carry a proof-bearing claim. A citation + // minted `eligible_for_sufficiency: false` — the SQL-schema and + // generic-shape appenders mint several — cannot make a packet + // Sufficient, so letting it cap would degrade ordinary answers for + // evidence they never rested on. Route B, the hole this closes, is + // eligible by construction. + .filter(|citation| { + crate::agent::packet_evidence::citation_sufficiency_eligible(citation) + }) + .filter_map(|citation| citation.file_path.clone()), + ); + answer.source_coverage = + crate::source_coverage::observe_source_coverage(controller, &covered_paths); let phase_started = Instant::now(); let budget = apply_packet_budget_with_extra( &project_root, @@ -5173,6 +5198,7 @@ mod tests { fn packet_answer_fixture(question: &str, citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-fixture".to_string(), prompt: question.to_string(), summary: "Fixture packet is covered by cited anchors.".to_string(), @@ -8472,6 +8498,7 @@ mod tests { fn packet_supported_claims_use_generic_evidence_roles() { let limits = packet_budget_limits(PacketBudgetModeDto::Compact); let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "generic-fixture".to_string(), prompt: "Explain the packet evidence roles.".to_string(), summary: "Generic evidence roles are covered.".to_string(), @@ -8887,6 +8914,7 @@ mod tests { let exec_lib_path = exec_lib.to_string_lossy().to_string(); let event_jsonl_path = event_jsonl.to_string_lossy().to_string(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "exec-fixture".to_string(), prompt: "Explain how `codex exec --json` flows from the top-level CLI into the exec runtime, app-server thread and turn start requests, and JSONL event output.".to_string(), summary: "Exec flow evidence is covered.".to_string(), @@ -9001,6 +9029,7 @@ mod tests { .expect("write temp exec lib"); let exec_lib_path = exec_lib.to_string_lossy().to_string(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "source-definition-fixture".to_string(), prompt: "Explain how `codex exec --json` flows from the exec runtime into app-server thread start requests and JSONL event output.".to_string(), summary: "Exec flow evidence is covered.".to_string(), @@ -9047,6 +9076,7 @@ mod tests { fn packet_supported_claims_include_indexing_storage_flow_specific_claims() { let _eval_probes = EvalProbesGuard::enabled(); let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "indexing-storage-fixture".to_string(), prompt: "Explain project source-group indexing into storage.".to_string(), summary: "Indexing and storage evidence is covered.".to_string(), @@ -9286,6 +9316,7 @@ mod tests { #[test] fn production_packet_claims_do_not_synthesize_local_real_template_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "indexing-storage-production-fixture".to_string(), prompt: "Explain project source-group indexing into storage.".to_string(), summary: "Indexing and storage evidence is covered.".to_string(), @@ -9356,6 +9387,7 @@ mod tests { #[test] fn packet_supported_claims_include_vscode_workbench_extension_host_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "vscode-fixture".to_string(), prompt: "Explain VS Code workbench extension-host command execution.".to_string(), summary: "VS Code workbench flow evidence is covered.".to_string(), @@ -9432,6 +9464,7 @@ mod tests { #[test] fn packet_supported_claims_include_payload_public_content_flow_claims() { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "payload-fixture".to_string(), prompt: "Explain Payload posts comments RSS and Elsewhere feed.".to_string(), summary: "Payload public content flow evidence is covered.".to_string(), @@ -9504,6 +9537,7 @@ mod tests { fn packet_ranking_prefers_payload_collections_over_component_and_preview_fillers() { let question = "Explain how Payload collections, post rendering, comment submission, RSS, and the Elsewhere feed connect."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "payload-rank-fixture".to_string(), prompt: question.to_string(), summary: "Payload public content flow evidence is covered.".to_string(), @@ -9558,6 +9592,7 @@ mod tests { fn packet_ranking_demotes_test_paths_without_fixture_specific_boosts() { let question = "Trace route dispatch through a handler."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-fixture".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), @@ -9599,6 +9634,7 @@ mod tests { fn packet_ranking_demotes_test_named_source_helpers_for_production_prompts() { let question = "Explain runtime orchestration and search projection in the indexing flow."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-test-symbols".to_string(), prompt: question.to_string(), summary: "Runtime evidence is covered by cited anchors.".to_string(), @@ -9665,6 +9701,7 @@ mod tests { fn packet_ranking_demotes_non_primary_roles_for_production_prompts() { let question = "Trace production route dispatch through the handler."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-roles".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), @@ -9709,6 +9746,7 @@ mod tests { fn packet_ranking_keeps_requested_docs_role_eligible() { let question = "Trace the docs route dispatch example."; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "rank-docs".to_string(), prompt: question.to_string(), summary: "Route evidence is covered by cited anchors.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_batch.rs b/crates/codestory-runtime/src/agent/packet_batch.rs index f939616b1..e67045f0d 100644 --- a/crates/codestory-runtime/src/agent/packet_batch.rs +++ b/crates/codestory-runtime/src/agent/packet_batch.rs @@ -757,6 +757,7 @@ mod tests { /// producer stamped. Nothing here hand-builds an annotation. fn empty_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "ev6c".to_string(), prompt: "ev6c packet".to_string(), summary: String::new(), diff --git a/crates/codestory-runtime/src/agent/packet_budget.rs b/crates/codestory-runtime/src/agent/packet_budget.rs index 5d1e815d8..ebc513ba3 100644 --- a/crates/codestory-runtime/src/agent/packet_budget.rs +++ b/crates/codestory-runtime/src/agent/packet_budget.rs @@ -1073,6 +1073,7 @@ mod tests { fn test_packet(question: &str, max_output_bytes: u32) -> AgentPacketDto { let answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-budget-test".to_string(), prompt: question.to_string(), summary: "Packet budget test answer.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_capping.rs b/crates/codestory-runtime/src/agent/packet_capping.rs index 8ecabe084..2e5938741 100644 --- a/crates/codestory-runtime/src/agent/packet_capping.rs +++ b/crates/codestory-runtime/src/agent/packet_capping.rs @@ -1304,6 +1304,7 @@ mod tests { fn answer_fixture(citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-capping-test".to_string(), prompt: "Trace the generic flow.".to_string(), summary: "Covered by cited anchors.".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_claims.rs b/crates/codestory-runtime/src/agent/packet_claims.rs index d14374bfa..c129e7c8c 100644 --- a/crates/codestory-runtime/src/agent/packet_claims.rs +++ b/crates/codestory-runtime/src/agent/packet_claims.rs @@ -1159,6 +1159,7 @@ mod tests { fn test_answer(prompt: &str, citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-claims-test".to_string(), prompt: prompt.to_string(), summary: "test answer".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_coverage.rs b/crates/codestory-runtime/src/agent/packet_coverage.rs new file mode 100644 index 000000000..f12c5c460 --- /dev/null +++ b/crates/codestory-runtime/src/agent/packet_coverage.rs @@ -0,0 +1,221 @@ +//! Whether the files a packet rested on were actually covered by the index. +//! +//! The companion to [`super::packet_freshness`], with one deliberate +//! difference. Freshness treats a *missing* observation as unknown, because a +//! freshness check that did not run proves nothing. Coverage must not: an empty +//! observation list means no path was checked, which is legitimate and caps +//! nothing. A lookup that failed arrives here as a per-path `NotEstablished` +//! observation instead of as an absence. +//! +//! Getting that backwards in either direction is the whole risk. Cap on absence +//! and every packet that cites nothing becomes `Partial`; fail to cap on a +//! failed lookup and the gap this exists to close reopens. + +use codestory_contracts::api::{ + SourceCoverageObservationDto, SourceCoverageStatusDto, SourceCoverageUnprovableCauseDto, +}; + +/// Prefix every coverage gap sentence shares, so callers can partition them. +pub(crate) const PACKET_COVERAGE_GAP_PREFIX: &str = "source coverage"; + +/// One file this packet rested on that the index could not prove it covered. +#[derive(Debug, Clone)] +struct UnprovableFile { + path: String, + cause: SourceCoverageUnprovableCauseDto, + /// Observed size and the cap that refused it, when the index recorded them. + sizes: Option<(u64, u64)>, +} + +/// The coverage facts for the files one packet touched. +#[derive(Debug, Clone, Default)] +pub(crate) struct PacketCoverageInput { + unprovable: Vec, +} + +impl PacketCoverageInput { + pub(crate) fn from_observations(observations: &[SourceCoverageObservationDto]) -> Self { + let unprovable = observations + .iter() + .filter_map(|observation| { + // Matched exhaustively on purpose. `packet_freshness` can afford + // a `_` arm because its mapping is total over `Option` and runs + // first; here a wildcard would silently default a newly added + // status to "proven", which is the failure this type prevents. + match observation.status { + SourceCoverageStatusDto::Indexed => None, + SourceCoverageStatusDto::PolicyExcluded + | SourceCoverageStatusDto::Incomplete + | SourceCoverageStatusDto::NotEstablished => { + SourceCoverageUnprovableCauseDto::for_observation(observation).map( + |cause| UnprovableFile { + path: observation.path.clone(), + cause, + sizes: observation.observed_size.zip(observation.byte_cap), + }, + ) + } + } + }) + .collect(); + Self { unprovable } + } + + /// Whether any file this packet rested on could not be proven covered. + pub(crate) fn caps_sufficiency(&self) -> bool { + !self.unprovable.is_empty() + } + + /// One sentence per unprovable file, naming the cause and, where the index + /// recorded them, the numbers that produced it. + pub(crate) fn gaps(&self) -> Vec { + self.unprovable + .iter() + .map(|file| match file.sizes { + Some((observed_size, byte_cap)) => format!( + "{PACKET_COVERAGE_GAP_PREFIX} ({}) is unproven for {path}: \ + {observed_size} bytes exceeds the {byte_cap} byte cap, so the index \ + never read it.", + file.cause.id(), + path = file.path + ), + None => format!( + "{PACKET_COVERAGE_GAP_PREFIX} ({}) is unproven for {path}.", + file.cause.id(), + path = file.path + ), + }) + .collect() + } + + /// The paths that could not be proven covered. + pub(crate) fn unprovable_paths(&self) -> Vec<&str> { + self.unprovable + .iter() + .map(|file| file.path.as_str()) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codestory_contracts::api::SourceCoverageNotEstablishedCauseDto; + + fn observation(path: &str, status: SourceCoverageStatusDto) -> SourceCoverageObservationDto { + SourceCoverageObservationDto { + path: path.to_string(), + status, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + } + } + + #[test] + fn an_excluded_observation_caps_sufficiency() { + let input = PacketCoverageInput::from_observations(&[observation( + "data/big.json", + SourceCoverageStatusDto::PolicyExcluded, + )]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].starts_with(PACKET_COVERAGE_GAP_PREFIX)); + assert_eq!(input.unprovable_paths(), vec!["data/big.json"]); + } + + /// The EV-78 asymmetry, and the reason coverage cannot copy freshness + /// wholesale: no path checked must cap nothing. This fails the moment + /// someone swaps the per-path producer for a repository-wide exclusion + /// list, which is the natural-looking simplification. + #[test] + fn no_observations_cap_nothing() { + let input = PacketCoverageInput::from_observations(&[]); + assert!(!input.caps_sufficiency()); + assert!(input.gaps().is_empty()); + } + + #[test] + fn an_indexed_observation_caps_nothing() { + let input = PacketCoverageInput::from_observations(&[observation( + "src/main.rs", + SourceCoverageStatusDto::Indexed, + )]); + assert!(!input.caps_sufficiency()); + } + + /// Every status must map to a definite answer. Fails if a variant is added + /// and absorbed into a permissive arm. + #[test] + fn every_status_reaches_a_definite_verdict() { + for status in [ + SourceCoverageStatusDto::Indexed, + SourceCoverageStatusDto::PolicyExcluded, + SourceCoverageStatusDto::Incomplete, + SourceCoverageStatusDto::NotEstablished, + ] { + let caps = PacketCoverageInput::from_observations(&[observation("f.rs", status)]) + .caps_sufficiency(); + assert_eq!( + caps, + status != SourceCoverageStatusDto::Indexed, + "{status:?} must cap unless it is Indexed" + ); + } + } + + /// An unnamed defect must stay typed rather than defaulting to covered — + /// the direct analog of freshness's unlabelled `NotChecked`. + #[test] + fn an_unnamed_defect_is_still_unprovable() { + let incomplete = observation("src/odd.rs", SourceCoverageStatusDto::Incomplete); + let input = PacketCoverageInput::from_observations(&[incomplete]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("reason_unreported")); + + let unestablished = observation("src/odd.rs", SourceCoverageStatusDto::NotEstablished); + let input = PacketCoverageInput::from_observations(&[unestablished]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("cause_unreported")); + } + + #[test] + fn a_failed_lookup_caps_and_names_itself() { + let mut observation = observation("src/main.rs", SourceCoverageStatusDto::NotEstablished); + observation.not_established_cause = + Some(SourceCoverageNotEstablishedCauseDto::LookupUnavailable); + let input = PacketCoverageInput::from_observations(&[observation]); + assert!(input.caps_sufficiency()); + assert!(input.gaps()[0].contains("lookup_unavailable")); + } + + /// A structural source refused for its *unit* count has + /// `observed_size <= byte_cap`, so a byte-overrun sentence would state + /// something false about a file the index did read. The producer withholds + /// the sizes for those rows; this pins that the renderer then says nothing + /// numeric rather than something wrong. + #[test] + fn a_gap_without_sizes_makes_no_claim_about_bytes() { + let input = PacketCoverageInput::from_observations(&[observation( + "db/structure.sql", + SourceCoverageStatusDto::PolicyExcluded, + )]); + let gap = &input.gaps()[0]; + assert!(gap.contains("db/structure.sql"), "{gap}"); + assert!( + !gap.contains("bytes exceeds"), + "a unit-bound exclusion must not claim a byte overrun: {gap}" + ); + } + + #[test] + fn an_exclusion_gap_names_the_size_and_the_cap() { + let mut observation = observation("data/big.json", SourceCoverageStatusDto::PolicyExcluded); + observation.observed_size = Some(1_500_000); + observation.byte_cap = Some(1_048_576); + let input = PacketCoverageInput::from_observations(&[observation]); + let gap = &input.gaps()[0]; + assert!(gap.contains("1500000"), "{gap}"); + assert!(gap.contains("1048576"), "{gap}"); + } +} diff --git a/crates/codestory-runtime/src/agent/packet_degradation.rs b/crates/codestory-runtime/src/agent/packet_degradation.rs index a3b9f79d2..f85f16ce5 100644 --- a/crates/codestory-runtime/src/agent/packet_degradation.rs +++ b/crates/codestory-runtime/src/agent/packet_degradation.rs @@ -355,6 +355,7 @@ mod tests { fn counter_answer() -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-degradation-test".to_string(), prompt: "how does activation admit a lease".to_string(), summary: String::new(), diff --git a/crates/codestory-runtime/src/agent/packet_obligations.rs b/crates/codestory-runtime/src/agent/packet_obligations.rs index ff695c2bb..0e1451f27 100644 --- a/crates/codestory-runtime/src/agent/packet_obligations.rs +++ b/crates/codestory-runtime/src/agent/packet_obligations.rs @@ -1587,6 +1587,7 @@ mod tests { fn answer(citations: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "obligation-test".to_string(), prompt: INDEXING_QUESTION.to_string(), summary: "test".to_string(), diff --git a/crates/codestory-runtime/src/agent/packet_sufficiency.rs b/crates/codestory-runtime/src/agent/packet_sufficiency.rs index 307315944..09475ef98 100644 --- a/crates/codestory-runtime/src/agent/packet_sufficiency.rs +++ b/crates/codestory-runtime/src/agent/packet_sufficiency.rs @@ -1,5 +1,6 @@ use crate::agent::packet_budget::next_deeper_packet_argv; use crate::agent::packet_claims::{decorate_packet_claims_proof_metadata, packet_supported_claims}; +use crate::agent::packet_coverage::PacketCoverageInput; use crate::agent::packet_degradation::packet_primary_retrieval_truncated; use crate::agent::packet_evidence::citation_sufficiency_eligible; use crate::agent::packet_evidence_roles::packet_evidence_role; @@ -309,6 +310,7 @@ fn assemble_packet_sufficiency_with_probe_context( // EV-7/EV-8: two facts about how this packet was collected, both of which bound what its // evidence can be reported as regardless of how well the claims themselves scored. let freshness = PacketFreshnessInput::from_observation(answer.freshness.as_ref()); + let coverage = PacketCoverageInput::from_observations(&answer.source_coverage); let primary_retrieval_truncated = packet_primary_retrieval_truncated(answer); let status = packet_sufficiency_status(PacketSufficiencyStatusInput { budget, @@ -324,6 +326,7 @@ fn assemble_packet_sufficiency_with_probe_context( missing_required_probe_queries: &blocking_missing_probe_queries, unresolved_sidecar_queries: &blocking_unresolved_sidecar_queries, freshness, + coverage: &coverage, primary_retrieval_truncated, }); @@ -350,6 +353,7 @@ fn assemble_packet_sufficiency_with_probe_context( &missing_required_flow_requirements, &blocking_unresolved_sidecar_queries, freshness, + &coverage, primary_retrieval_truncated, ); if let Some(obligations) = obligations { @@ -439,6 +443,37 @@ fn assemble_packet_sufficiency_with_probe_context( for path in &reported_claim_open_next_paths { push_unique_term(&mut open_next_paths, path); } + // Filtered once, after every source has contributed, because leads arrive + // from three of them. Capping on coverage flips `terminally_sufficient` + // false, which is exactly what opens follow-up generation — so without + // this the cap turns a packet that answered and stopped into one that + // re-probes a permanently unindexable file every round. A path the index + // can never cover is not a lead. + let unprovable_paths = coverage.unprovable_paths(); + if !unprovable_paths.is_empty() { + // Leads arrive as `packet_display_path` output, which strips a named + // repository root: a path under a cached checkout keeps only its + // in-repository suffix. Joining the project root back onto that suffix + // yields a path that does not exist, so a path-identity comparison + // reports "different file" and the lead survives — leaving this filter + // inert for exactly the cached-repository packets where the re-probe + // loop it prevents actually bites. Comparing display form to display + // form keeps both sides in one vocabulary; the identity comparison + // stays as the fallback for leads that were never stripped. + let unprovable_display = unprovable_paths + .iter() + .map(|path| packet_display_path(path)) + .collect::>(); + open_next_paths.retain(|path| { + let display = packet_display_path(path); + !unprovable_display + .iter() + .any(|unprovable| unprovable == &display) + && !unprovable_paths.iter().any(|unprovable| { + packet_paths_match_exact_probe(project_root, unprovable, path) + }) + }); + } let blocking_follow_up_probe_queries = packet_interleave_follow_up_queries( &open_next_paths, &blocking_follow_up_probe_query_seeds, @@ -606,6 +641,11 @@ struct PacketSufficiencyStatusInput<'a> { unresolved_sidecar_queries: &'a [String], /// EV-7: how well this packet's publication was known to match the working tree. freshness: PacketFreshnessInput, + /// CAP-1: whether the index actually covered the files this packet rested on. + /// + /// Distinct from `has_minimum_coverage`, which is about how many *claims* carry evidence. + /// This is about whether the underlying files were indexed at all. + coverage: &'a PacketCoverageInput, /// EV-8: whether the primary retrieval run lost evidence it planned to collect. primary_retrieval_truncated: bool, } @@ -625,10 +665,11 @@ fn packet_sufficiency_status( || !input.missing_required_probe_queries.is_empty() || !input.unresolved_sidecar_queries.is_empty() || input.has_sufficiency_blocking_budget_omission - // Both of these are caps, not floors: they can only stop a packet that would otherwise be + // These three are caps, not floors: they can only stop a packet that would otherwise be // Sufficient from claiming it. A packet with no eligible citation stays Insufficient // above, and everything that was already Partial stays Partial. || input.freshness.caps_sufficiency() + || input.coverage.caps_sufficiency() || input.primary_retrieval_truncated || packet_budget_exceeded_hard_output_cap(input.budget) { @@ -1290,6 +1331,7 @@ fn packet_sufficiency_gaps( missing_required_flow_requirements: &[FlowRequirement], unresolved_sidecar_queries: &[String], freshness: PacketFreshnessInput, + coverage: &PacketCoverageInput, primary_retrieval_truncated: bool, ) -> Vec { let mut gaps = Vec::new(); @@ -1298,6 +1340,7 @@ fn packet_sufficiency_gaps( if let Some(gap) = freshness.gap() { gaps.push(gap); } + gaps.extend(coverage.gaps()); if primary_retrieval_truncated { gaps.push( "primary retrieval truncated: the primary retrieval run ended before collecting the \ @@ -2558,6 +2601,7 @@ mod tests { PacketEvidenceTierDto, PacketProofStatusDto, PacketSidecarQueryDiagnosticDto, RetrievalScoreBreakdownDto, RetrievalShadowDto, RetrievalStageTimingDto, SearchHitOrigin, }; + use codestory_contracts::api::{SourceCoverageObservationDto, SourceCoverageStatusDto}; use std::path::Path; #[test] @@ -2668,6 +2712,7 @@ mod tests { fn answer_fixture(question: &str) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "packet-sufficiency-test".to_string(), prompt: question.to_string(), summary: "Covered by cited anchors.".to_string(), @@ -2957,8 +3002,19 @@ mod tests { names: &[&str], edges: &[(&str, &str)], extra_probes: &[String], + ) -> (PacketSufficiencyDto, Vec) { + production_route_sufficiency_with_coverage(question, names, edges, extra_probes, Vec::new()) + } + + fn production_route_sufficiency_with_coverage( + question: &str, + names: &[&str], + edges: &[(&str, &str)], + extra_probes: &[String], + source_coverage: Vec, ) -> (PacketSufficiencyDto, Vec) { let mut answer = route_answer(question, names, edges); + answer.source_coverage = source_coverage; for citation in &mut answer.citations { citation.file_path = Some(format!("src/router/{}.rs", citation.display_name)); } @@ -3026,6 +3082,124 @@ mod tests { (sufficiency, claims) } + /// CAP-1: a packet resting on a file the index refused must not report + /// `Sufficient`. + /// + /// This is the Route B hole. A *required* file-scoped citation is minted + /// `eligible_for_sufficiency`, unlike an explicitly probed one, so before + /// this cap a packet could carry a proof-bearing claim over a file the + /// index deliberately never read and still claim sufficiency. The + /// probe-side route already capped, which is exactly why this one was + /// invisible. + #[test] + fn a_packet_resting_on_an_excluded_file_cannot_be_sufficient() { + let question = "alpha -> omega"; + let names = ["alpha", "omega", "RouteSupport"]; + let edges = [("alpha", "omega")]; + + let (baseline, _) = production_route_sufficiency(question, &names, &edges); + assert_eq!( + baseline.status, + PacketSufficiencyStatusDto::Sufficient, + "the control must be Sufficient or this test proves nothing: {baseline:?}" + ); + + let (capped, _) = production_route_sufficiency_with_coverage( + question, + &names, + &edges, + &[], + vec![SourceCoverageObservationDto { + path: "src/router/alpha.rs".to_string(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(1_500_000), + byte_cap: Some(1_048_576), + }], + ); + assert_eq!( + capped.status, + PacketSufficiencyStatusDto::Partial, + "{capped:?}" + ); + assert!( + capped + .gaps + .iter() + .any(|gap| gap.contains("source coverage") && gap.contains("alpha.rs")), + "the gap must name the file and say it is a coverage problem: {capped:?}" + ); + assert!( + capped + .gaps + .iter() + .any(|gap| gap.contains("1500000") && gap.contains("1048576")), + "the gap must name the numbers, not just the word: {capped:?}" + ); + } + + /// An empty observation list must cap nothing. + /// + /// The asymmetry with freshness at the level that matters: freshness treats + /// a missing observation as unknown-and-capping, so copying it wholesale + /// would turn every packet that cites nothing into `Partial`. + #[test] + fn a_packet_with_no_coverage_observations_is_unaffected() { + let (sufficiency, _) = production_route_sufficiency( + "alpha -> omega", + &["alpha", "omega", "RouteSupport"], + &[("alpha", "omega")], + ); + assert_eq!( + sufficiency.status, + PacketSufficiencyStatusDto::Sufficient, + "{sufficiency:?}" + ); + assert!( + !sufficiency + .gaps + .iter() + .any(|gap| gap.contains("source coverage")), + "{sufficiency:?}" + ); + } + + /// Step 7 without step 8 turns a packet that answered and stopped into one + /// that re-probes a permanently unindexable file forever: capping flips + /// `terminally_sufficient` false, which is what opens follow-up generation. + #[test] + fn an_excluded_file_is_never_offered_as_a_follow_up_lead() { + let (capped, _) = production_route_sufficiency_with_coverage( + "alpha -> omega", + &["alpha", "omega", "RouteSupport"], + &[("alpha", "omega")], + &[], + vec![SourceCoverageObservationDto { + path: "src/router/alpha.rs".to_string(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: Some(1_500_000), + byte_cap: Some(1_048_576), + }], + ); + assert!( + !capped + .open_next + .iter() + .any(|lead| lead.contains("src/router/alpha.rs")), + "a file the index can never cover is not a lead: {capped:?}" + ); + assert!( + !capped + .follow_up_commands + .iter() + .any(|command| command.contains("src/router/alpha.rs")), + "{capped:?}" + ); + } + fn assert_unresolved_route_order(sufficiency: &PacketSufficiencyDto) { assert_eq!( sufficiency.status, diff --git a/crates/codestory-runtime/src/agent/packet_trace.rs b/crates/codestory-runtime/src/agent/packet_trace.rs index ef77f08e9..7014a5d19 100644 --- a/crates/codestory-runtime/src/agent/packet_trace.rs +++ b/crates/codestory-runtime/src/agent/packet_trace.rs @@ -241,6 +241,7 @@ mod golden_tests { }]; let rank_terms = vec!["exec".to_string(), "events".to_string()]; let mut answer = AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "golden".to_string(), prompt: "trace exec flow".to_string(), summary: "summary".to_string(), diff --git a/crates/codestory-runtime/src/agent/trace_export.rs b/crates/codestory-runtime/src/agent/trace_export.rs index f28f69f4d..15bc8c247 100644 --- a/crates/codestory-runtime/src/agent/trace_export.rs +++ b/crates/codestory-runtime/src/agent/trace_export.rs @@ -261,6 +261,7 @@ mod tests { fn sample_answer(steps: Vec) -> AgentAnswerDto { AgentAnswerDto { + source_coverage: Vec::new(), answer_id: "a1".to_string(), prompt: "q".to_string(), summary: "s".to_string(), diff --git a/crates/codestory-runtime/src/lib.rs b/crates/codestory-runtime/src/lib.rs index aae9495dc..c4b7c87fc 100644 --- a/crates/codestory-runtime/src/lib.rs +++ b/crates/codestory-runtime/src/lib.rs @@ -89,6 +89,7 @@ mod search_terms; mod semantic_projection; mod semantic_republish; mod snippets; +mod source_coverage; mod workspace_state; use affected::{AffectedOperationIdentityIndex, IndexFreshnessObservation}; pub use agent::{packet_step_trace_json, plan_packet}; diff --git a/crates/codestory-runtime/src/source_coverage.rs b/crates/codestory-runtime/src/source_coverage.rs new file mode 100644 index 000000000..7d857c77b --- /dev/null +++ b/crates/codestory-runtime/src/source_coverage.rs @@ -0,0 +1,185 @@ +//! What the published index knows about specific source files. +//! +//! This exists because an incompletely indexed file was invisible to the agent: +//! nothing under `agent/` referenced a coverage reason or an exclusion, so a +//! packet could rest a proof-bearing claim on a file the index had deliberately +//! refused and still report `Sufficient`. +//! +//! The one contract worth stating up front: **this maps, it never filters.** +//! Every requested path gets exactly one observation, including when the lookup +//! fails. A producer that dropped unknown paths would be indistinguishable from +//! one reporting them as covered, which is the defect itself moved one layer up. + +use crate::AppController; +use crate::index_coverage::stored_file_coverage_diagnostics; +use codestory_contracts::api::{ + SourceCoverageNotEstablishedCauseDto, SourceCoverageObservationDto, SourceCoverageStatusDto, +}; +use codestory_workspace::same_workspace_path; +use std::path::{Path, PathBuf}; + +/// Observe coverage for `paths`, one observation each, in the order given. +/// +/// Paths may be absolute or workspace-relative; both are resolved against the +/// project root before comparison. +pub(crate) fn observe_source_coverage( + controller: &AppController, + paths: &[String], +) -> Vec { + if paths.is_empty() { + return Vec::new(); + } + + // Before the project root is known there is nothing to resolve against, so + // this one branch falls back to a raw-string dedup. + let Ok(project_root) = controller.require_project_root() else { + let mut unique: Vec<&String> = Vec::new(); + for path in paths { + if !unique.iter().any(|seen| seen.as_str() == path.as_str()) { + unique.push(path); + } + } + return not_established( + &unique, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + }; + + // Deduped by resolved identity, not by string: `exact_packet_probe_paths` + // yields a project-relative spelling while a citation carries an absolute + // one, so a string comparison lets two spellings of one file through and + // the packet ships the same gap twice. + let mut deduped: Vec<&String> = Vec::new(); + for path in paths { + let absolute = absolute_against(&project_root, path); + if !deduped + .iter() + .any(|seen| same_workspace_path(&absolute_against(&project_root, seen), &absolute)) + { + deduped.push(path); + } + } + + // These two are not the same judgement, and EV-78's split is the reason to + // keep them apart: no published core yet is a deliberate, recoverable state, + // while a failed query establishes nothing about anything. + let storage = match controller.open_storage_read_only() { + Ok(storage) => storage, + Err(error) if error.code == "project_unavailable" => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::PublicationIncomplete, + ); + } + Err(_) => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + } + }; + let exclusions = match storage.get_source_policy_exclusions() { + Ok(exclusions) => exclusions, + Err(_) => { + return not_established( + &deduped, + SourceCoverageNotEstablishedCauseDto::LookupUnavailable, + ); + } + }; + + // `ParserPartial` is the one coverage reason that survives publication — + // both refresh gates refuse to commit on any other — so it is exactly the + // defect a served packet can rest on, and reporting such a file `Indexed` + // would contradict this contract's own definition of the word. + let diagnostics = stored_file_coverage_diagnostics(&project_root, &storage).unwrap_or_default(); + let incomplete: Vec<(PathBuf, codestory_contracts::graph::FileCoverageReason)> = diagnostics + .iter() + .map(|diagnostic| (project_root.join(&diagnostic.path), diagnostic.reason)) + .collect(); + + // Resolved once, not per requested path: the comparison is by path identity + // rather than by string, so each side has to be made absolute first. + // The sizes are carried only for a byte-bound exclusion. A structural + // source refused for its *unit* count has `observed_size <= byte_cap`, so + // rendering "N bytes exceeds the M byte cap" for it would state something + // false about a file the index did read. Those rows get the plain sentence. + let excluded: Vec<(PathBuf, Option<(u64, u64)>)> = exclusions + .iter() + .map(|record| { + let byte_bound = + record.observed_size > record.byte_cap && record.observed_unit_count == 0; + ( + project_root.join(&record.normalized_path), + byte_bound.then_some((record.observed_size, record.byte_cap)), + ) + }) + .collect(); + + deduped + .into_iter() + .map(|path| { + let absolute = absolute_against(&project_root, path); + match excluded + .iter() + .find(|(excluded_path, _)| same_workspace_path(excluded_path, &absolute)) + { + Some((_, sizes)) => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::PolicyExcluded, + reason: None, + not_established_cause: None, + observed_size: sizes.map(|(observed_size, _)| observed_size), + byte_cap: sizes.map(|(_, byte_cap)| byte_cap), + }, + None => match incomplete + .iter() + .find(|(defect_path, _)| same_workspace_path(defect_path, &absolute)) + { + Some((_, reason)) => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::Incomplete, + reason: Some(*reason), + not_established_cause: None, + observed_size: None, + byte_cap: None, + }, + None => SourceCoverageObservationDto { + path: path.clone(), + status: SourceCoverageStatusDto::Indexed, + reason: None, + not_established_cause: None, + observed_size: None, + byte_cap: None, + }, + }, + } + }) + .collect() +} + +fn absolute_against(project_root: &Path, path: &str) -> PathBuf { + let candidate = Path::new(path); + if candidate.is_absolute() { + candidate.to_path_buf() + } else { + project_root.join(candidate) + } +} + +fn not_established( + paths: &[&String], + cause: SourceCoverageNotEstablishedCauseDto, +) -> Vec { + paths + .iter() + .map(|path| SourceCoverageObservationDto { + path: (*path).clone(), + status: SourceCoverageStatusDto::NotEstablished, + reason: None, + not_established_cause: Some(cause), + observed_size: None, + byte_cap: None, + }) + .collect() +} diff --git a/crates/codestory-runtime/src/tests.rs b/crates/codestory-runtime/src/tests.rs index 268f550b8..dc77f67c5 100644 --- a/crates/codestory-runtime/src/tests.rs +++ b/crates/codestory-runtime/src/tests.rs @@ -3637,6 +3637,151 @@ fn changed_source_is_reevaluated_into_a_new_verified_exclusion() { assert!(changed.observed_size > first_exclusion.observed_size); } +/// CAP-1b: a partially parsed file is not "covered". +/// +/// `ParserPartial` is the one coverage reason that survives publication — both +/// refresh gates refuse to commit on any other — so it is precisely the defect +/// a *served* packet can rest on. Reporting such a file `Indexed` would +/// contradict this contract's own definition of the word, and would leave the +/// half of the coverage surface that can actually occur doing nothing. +#[test] +fn a_partially_parsed_file_is_reported_incomplete_not_indexed() { + let _env = hybrid_test_env(); + let workspace = tempdir().expect("workspace"); + let partial = workspace.path().join("job-store.ts"); + fs::write( + &partial, + "declare function sql(parts: TemplateStringsArray): T;\n\ + export const row = sql`SELECT 1`;\n", + ) + .expect("write a source the parser only partly understands"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path( + workspace.path().to_path_buf(), + storage_path.clone(), + ) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("a parser-partial file must not block publication"); + + let observations = + crate::source_coverage::observe_source_coverage(&controller, &["job-store.ts".to_string()]); + assert_eq!(observations.len(), 1); + assert_eq!( + observations[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Incomplete, + "a file with a recorded coverage defect is not covered: {observations:?}" + ); + assert_eq!( + observations[0].reason, + Some(codestory_contracts::graph::FileCoverageReason::ParserPartial), + "{observations:?}" + ); + + let input = + crate::agent::packet_coverage::PacketCoverageInput::from_observations(&observations); + assert!( + input.caps_sufficiency(), + "an incompletely parsed file must stop a packet claiming sufficiency" + ); +} + +/// CAP-1b: the production path from a citation's path to an exclusion row. +/// +/// The unit tests for the cap set `source_coverage` directly, so they never +/// exercise the matching — which is where this change could most easily do +/// nothing at all. Exclusion rows store a workspace-relative `normalized_path` +/// while citations carry whatever the retrieval layer produced, so a string +/// comparison would match on some platforms and silently never match on +/// others: plumbing complete, tests green, nothing ever capped. +#[test] +fn coverage_observation_matches_an_exclusion_by_path_identity() { + let _env = hybrid_test_env(); + let workspace = copy_tictactoe_workspace(); + let structural = workspace.path().join("docs").join("api.json"); + fs::create_dir_all(structural.parent().expect("docs dir")).expect("create docs dir"); + fs::write(&structural, vec![b'x'; 1_300_010]).expect("write oversized structural source"); + + let storage_path = workspace.path().join(".cache").join("codestory.db"); + let controller = AppController::new_with_config(test_sidecar_runtime_from_env()); + controller + .open_project_summary_with_storage_path( + workspace.path().to_path_buf(), + storage_path.clone(), + ) + .expect("open project"); + controller + .run_indexing_blocking_without_runtime_refresh(IndexMode::Full) + .expect("publish complete core"); + + // Every spelling a citation might carry for the same file must resolve to + // the one exclusion row. + for spelling in [ + "docs/api.json".to_string(), + structural.to_string_lossy().to_string(), + format!( + ".{}docs{}api.json", + std::path::MAIN_SEPARATOR, + std::path::MAIN_SEPARATOR + ), + ] { + let observations = crate::source_coverage::observe_source_coverage( + &controller, + std::slice::from_ref(&spelling), + ); + assert_eq!(observations.len(), 1, "{spelling}: {observations:?}"); + assert_eq!( + observations[0].status, + codestory_contracts::api::SourceCoverageStatusDto::PolicyExcluded, + "spelling {spelling} must resolve to the exclusion row: {observations:?}" + ); + assert_eq!( + observations[0].byte_cap, + Some(codestory_contracts::workspace::DEFAULT_STRUCTURAL_SOURCE_BYTE_CAP), + "the observation must carry the cap that refused the file" + ); + } + + // And two spellings of one file are one file: the packet must not ship the + // same gap twice. This is why the dedup compares path identity rather than + // strings, like everything else here. + let duplicated = crate::source_coverage::observe_source_coverage( + &controller, + &[ + "docs/api.json".to_string(), + structural.to_string_lossy().to_string(), + ], + ); + assert_eq!( + duplicated.len(), + 1, + "two spellings of one file must dedup: {duplicated:?}" + ); + + // Distinct files still get one observation each — the map-not-filter + // contract, which the dedup must not quietly break. + let distinct = crate::source_coverage::observe_source_coverage( + &controller, + &["docs/api.json".to_string(), "game.kt".to_string()], + ); + assert_eq!(distinct.len(), 2, "{distinct:?}"); + + // A file the index did cover must not be reported as excluded, or the cap + // would fire on every packet in the repository. + let covered = + crate::source_coverage::observe_source_coverage(&controller, &["game.kt".to_string()]); + assert_eq!(covered.len(), 1); + assert_eq!( + covered[0].status, + codestory_contracts::api::SourceCoverageStatusDto::Indexed, + "{covered:?}" + ); +} + #[test] fn republishing_projections_keeps_a_structural_exclusion_publishable() { // `codestory retrieval republish-projections` is the documented no-reindex diff --git a/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs b/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs deleted file mode 100644 index 0d5c9438a..000000000 --- a/crates/codestory-workspace/tests/zz_tmp_structural_cap_probe.rs +++ /dev/null @@ -1,208 +0,0 @@ -//! TEMPORARY probe (delete after). Tests the claim that a stale structural -//! byte-bound exclusion row survives a structural-cap change. - -use codestory_contracts::workspace::{ - DEFAULT_STRUCTURAL_UNIT_CAP, OVERSIZED_SOURCE_POLICY_VERSION, - OversizedSourceExclusionCandidate, RefreshInputs, SourceIndexPolicy, WorkspaceInventory, -}; -use codestory_workspace::{WorkspaceDiscovery, WorkspaceManifest}; -use std::fs; -use tempfile::tempdir; - -fn policy(byte_cap: u64, structural_byte_cap: u64) -> SourceIndexPolicy { - SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap, - structural_byte_cap, - structural_unit_cap: DEFAULT_STRUCTURAL_UNIT_CAP, - } -} - -/// Simulate the claim exactly: a core published under structural cap 1 MiB, -/// then the structural cap drops to 512 KiB with the headroom unchanged at -/// 2,000,000. Does the stale row (byte_cap = 1_048_576) survive? -#[test] -fn probe_stale_structural_row_under_a_lowered_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("data"))?; - let json = root.join("data").join("config.json"); - fs::write(&json, vec![b'x'; 1_500_000])?; - let manifest = WorkspaceManifest::open(root.clone())?; - - // Step 1: publish-time classification under the OLD structural cap. - let old = policy(2_000_000, 1_048_576); - let old_inventory = manifest.source_inventory_with_policy(&old)?; - let stale_row = old_inventory - .policy_exclusions - .iter() - .find(|c| c.normalized_path == "data/config.json") - .expect("stale row") - .clone(); - assert_eq!(stale_row.byte_cap, 1_048_576); - assert_eq!(stale_row.observed_unit_count, 0, "byte-bound"); - println!("STALE ROW: {stale_row:?}"); - - // Step 2: the future release. Structural cap 512 KiB, headroom unchanged. - let new = policy(2_000_000, 512 * 1024); - - // 2a. Does planning carry the stale byte-bound row forward? - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![stale_row.clone()], - inventory: WorkspaceInventory::default(), - }; - let outcome = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("REPLANNED EXCLUSIONS: {:?}", outcome.policy_exclusions); - assert_eq!( - outcome.policy_exclusions.len(), - 1, - "the file is still over the new cap, so it is re-derived" - ); - assert_eq!( - outcome.policy_exclusions[0].byte_cap, - 512 * 1024, - "the re-derived row must name the NEW cap, not the stale 1 MiB" - ); - - // 2b. Does the publication fence accept the stale row? - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&stale_row), - &new, - ); - println!("FENCE on stale row: {fence:?}"); - assert!( - fence.is_err(), - "the publication fence must reject a row naming a superseded structural cap" - ); - - // 2c. The re-derived row passes the same fence. - let verified = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - &outcome.policy_exclusions, - &new, - )?; - assert_eq!(verified.len(), 1); - Ok(()) -} - -/// The other direction: the structural cap RISES. A stale row excluding a -/// 1.5 MB JSON at 1 MiB should be re-admitted for indexing. -#[test] -fn probe_stale_structural_row_under_a_raised_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(root.join("data"))?; - let json = root.join("data").join("config.json"); - fs::write(&json, vec![b'x'; 1_500_000])?; - let manifest = WorkspaceManifest::open(root.clone())?; - - let old = policy(2_000_000, 1_048_576); - let stale_row = manifest - .source_inventory_with_policy(&old)? - .policy_exclusions - .into_iter() - .find(|c| c.normalized_path == "data/config.json") - .expect("stale row"); - - let new = policy(2_000_000, 1_800_000); - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![stale_row.clone()], - inventory: WorkspaceInventory::default(), - }; - let outcome = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("RAISED: exclusions={:?}", outcome.policy_exclusions); - println!("RAISED: to_index={:?}", outcome.refresh.plan.files_to_index); - assert!( - outcome.policy_exclusions.is_empty(), - "the stale byte-bound row must not be carried forward" - ); - assert!( - outcome.refresh.plan.files_to_index.contains(&json), - "the file must be scheduled now that it fits the raised structural cap" - ); - - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&stale_row), - &new, - ); - println!("RAISED fence on stale row: {fence:?}"); - assert!(fence.is_err()); - Ok(()) -} - -/// A unit-bound row IS carried forward. Does it dodge the cap check? -#[test] -fn probe_unit_bound_row_carry_forward_under_a_lowered_structural_cap() -> anyhow::Result<()> { - let temp = tempdir()?; - let root = temp.path().join("repo"); - fs::create_dir_all(&root)?; - let json = root.join("evidence.json"); - fs::write(&json, "{\"one\":1,\"two\":2,\"three\":3}\n")?; - let manifest = WorkspaceManifest::open(root.clone())?; - - let old = SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap: 2_000_000, - structural_byte_cap: 1_048_576, - structural_unit_cap: 2, - }; - // Borrow the crate's own content hash by classifying the file byte-bound - // under a tiny cap first. - let tiny = SourceIndexPolicy { - policy_version: OVERSIZED_SOURCE_POLICY_VERSION.to_string(), - byte_cap: 4, - structural_byte_cap: 4, - structural_unit_cap: 2, - }; - let seed = manifest - .source_inventory_with_policy(&tiny)? - .policy_exclusions - .into_iter() - .find(|c| c.normalized_path == "evidence.json") - .expect("seed row"); - let retained = OversizedSourceExclusionCandidate { - normalized_path: "evidence.json".to_string(), - content_hash: seed.content_hash, - observed_size: seed.observed_size, - observed_unit_count: 3, - policy_version: old.policy_version.clone(), - // Under the OLD policy a unit-bound structural row names the OLD - // structural cap. - byte_cap: 1_048_576, - structural_unit_cap: 2, - }; - - let inputs = RefreshInputs { - stored_files: Vec::new(), - policy_exclusions: vec![retained.clone()], - inventory: WorkspaceInventory::default(), - }; - - // Same policy: carried forward. - let same = manifest.build_execution_outcome_with_policy(&inputs, &old)?; - println!("UNIT same-policy exclusions: {:?}", same.policy_exclusions); - - // Lowered structural cap: is the stale unit-bound row still carried? - let new = SourceIndexPolicy { - structural_byte_cap: 512 * 1024, - ..old.clone() - }; - let lowered = manifest.build_execution_outcome_with_policy(&inputs, &new)?; - println!("UNIT lowered exclusions: {:?}", lowered.policy_exclusions); - println!( - "UNIT lowered to_index: {:?}", - lowered.refresh.plan.files_to_index - ); - - let fence = WorkspaceDiscovery.revalidate_source_policy_exclusions( - &manifest, - std::slice::from_ref(&retained), - &new, - ); - println!("UNIT fence under lowered cap: {fence:?}"); - Ok(()) -} diff --git a/plugins/codestory/generated-mcp-catalog.json b/plugins/codestory/generated-mcp-catalog.json index c98f6aad8..952d6cc0a 100644 --- a/plugins/codestory/generated-mcp-catalog.json +++ b/plugins/codestory/generated-mcp-catalog.json @@ -4198,6 +4198,10 @@ }, "type": "array" }, + "freshness": { + "description": "Index freshness observation, when one was made.", + "type": "object" + }, "graphs": { "description": "Graph artifacts.", "items": { @@ -4232,6 +4236,17 @@ }, "type": "array" }, + "source_coverage": { + "description": "Coverage for the files this packet rested on, when any were checked.", + "items": { + "additionalProperties": true, + "description": "Generic JSON object.", + "properties": {}, + "required": [], + "type": "object" + }, + "type": "array" + }, "subgraph_ids": { "description": "Related graph ids.", "items": {