Skip to content

Commit 28a5b03

Browse files
committed
feat(cache): persist verified federated generations
Signed-off-by: Tom Ballard <tom@armytage.co>
1 parent 3ebb51c commit 28a5b03

23 files changed

Lines changed: 2713 additions & 192 deletions

rust/rac-engine/src/commands.rs

Lines changed: 162 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
44
use std::path::{Path, PathBuf};
55

6+
use crate::corpus::{ArtifactOrigin, CorpusLayer};
67
use crate::output;
78
use crate::parse::{parse_file, parse_text, Artifact, Issue};
89
use crate::relationships::{
@@ -59,6 +60,10 @@ pub struct FileValidation {
5960
pub artifact_type: String,
6061
pub status: &'static str,
6162
pub issues: Vec<Issue>,
63+
/// Stable source and layer identity for rows built from a composed corpus.
64+
/// Released single-corpus validation leaves this absent so its rendered
65+
/// output remains byte-identical.
66+
pub origin: Option<ArtifactOrigin>,
6267
}
6368

6469
pub struct DirectoryValidation {
@@ -126,6 +131,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati
126131
artifact_type,
127132
status: STATUS_SKIPPED,
128133
issues: Vec::new(),
134+
origin: None,
129135
};
130136
}
131137
let issues = apply_overrides(
@@ -143,6 +149,7 @@ pub fn validate_directory(directory: &str, recursive: bool) -> DirectoryValidati
143149
artifact_type,
144150
status,
145151
issues,
152+
origin: None,
146153
}
147154
})
148155
.collect();
@@ -191,6 +198,7 @@ pub(crate) fn validate_directory_from_items(
191198
artifact_type,
192199
status: STATUS_SKIPPED,
193200
issues: Vec::new(),
201+
origin: Some(item.origin.clone()),
194202
};
195203
}
196204
let issues = if item.origin.layer == crate::corpus::Layer::Inherited {
@@ -212,6 +220,7 @@ pub(crate) fn validate_directory_from_items(
212220
artifact_type,
213221
status,
214222
issues,
223+
origin: Some(item.origin.clone()),
215224
}
216225
})
217226
.collect();
@@ -448,6 +457,7 @@ pub fn validate_directory_incremental_in(
448457
line: i.line.map(i64::from),
449458
})
450459
.collect(),
460+
origin: None,
451461
});
452462
let file_name = entry
453463
.display
@@ -565,6 +575,25 @@ fn read_validate_input(target: &str) -> Result<Artifact, i32> {
565575
read_named_file(target)
566576
}
567577

578+
/// Recover the declared inherited identity for a composed-corpus load error.
579+
/// A malformed or unreadable manifest has no trustworthy provenance, while a
580+
/// successfully parsed declaration can still identify failures from later
581+
/// materialisation, pin, or composition checks.
582+
fn manifest_failure_origin(directory: &str) -> Option<ArtifactOrigin> {
583+
let repository_root = crate::validate::repository_root(directory);
584+
let manifest = crate::federation::load_manifest(&repository_root)
585+
.ok()
586+
.flatten()?;
587+
Some(
588+
CorpusLayer::inherited(
589+
manifest.inherits.source,
590+
manifest.inherits.alias,
591+
manifest.inherits.digest,
592+
)
593+
.origin(),
594+
)
595+
}
596+
568597
pub fn cmd_validate(args: &ValidateArgs) -> i32 {
569598
// Directory? Validate every recognized artifact beneath it.
570599
if args.file != "-" && Path::new(&args.file).is_dir() {
@@ -586,22 +615,26 @@ pub fn cmd_validate(args: &ValidateArgs) -> i32 {
586615
validate_directory_incremental(&args.file, !args.top_level, args.verify)
587616
}
588617
Ok(None) => validate_directory(&args.file, !args.top_level),
589-
Err(error) => DirectoryValidation {
590-
directory: args.file.clone(),
591-
recursive: !args.top_level,
592-
files: vec![FileValidation {
593-
path: crate::federation::MANIFEST_RELATIVE_PATH.to_string(),
594-
artifact_type: "corpus-manifest".to_string(),
595-
status: STATUS_INVALID,
596-
issues: vec![Issue::new(
597-
"error",
598-
error.stable_code(),
599-
error.to_string(),
600-
None,
601-
)],
602-
}],
603-
okf: None,
604-
},
618+
Err(error) => {
619+
let origin = manifest_failure_origin(&args.file);
620+
DirectoryValidation {
621+
directory: args.file.clone(),
622+
recursive: !args.top_level,
623+
files: vec![FileValidation {
624+
path: crate::federation::MANIFEST_RELATIVE_PATH.to_string(),
625+
artifact_type: "corpus-manifest".to_string(),
626+
status: STATUS_INVALID,
627+
issues: vec![Issue::new(
628+
"error",
629+
error.stable_code(),
630+
error.to_string(),
631+
None,
632+
)],
633+
origin,
634+
}],
635+
okf: None,
636+
}
637+
}
605638
};
606639
if args.sarif {
607640
emit(output::render_validate_sarif(&result));
@@ -2751,3 +2784,116 @@ pub fn cmd_telemetry(args: &TelemetryArgs) -> i32 {
27512784
}
27522785
EXIT_OK
27532786
}
2787+
2788+
#[cfg(test)]
2789+
mod validation_provenance_tests {
2790+
use std::fs;
2791+
use std::sync::atomic::{AtomicUsize, Ordering};
2792+
2793+
use super::*;
2794+
2795+
static COUNTER: AtomicUsize = AtomicUsize::new(0);
2796+
2797+
fn scratch() -> PathBuf {
2798+
let count = COUNTER.fetch_add(1, Ordering::SeqCst);
2799+
let root = std::env::temp_dir().join(format!(
2800+
"asdecided-validation-provenance-{}-{count}",
2801+
std::process::id()
2802+
));
2803+
fs::create_dir_all(root.join(".decided")).unwrap();
2804+
fs::create_dir_all(root.join("decisions")).unwrap();
2805+
root
2806+
}
2807+
2808+
fn validation(origin: Option<ArtifactOrigin>) -> DirectoryValidation {
2809+
DirectoryValidation {
2810+
directory: "decisions".to_string(),
2811+
recursive: true,
2812+
files: vec![FileValidation {
2813+
path: "decisions/example.md".to_string(),
2814+
artifact_type: "Decision".to_string(),
2815+
status: STATUS_INVALID,
2816+
issues: vec![Issue::new(
2817+
"error",
2818+
"missing-title",
2819+
"Title is required.".to_string(),
2820+
Some(3),
2821+
)],
2822+
origin,
2823+
}],
2824+
okf: None,
2825+
}
2826+
}
2827+
2828+
#[test]
2829+
fn composed_validation_adds_machine_provenance_only() {
2830+
let legacy = validation(None);
2831+
let composed = validation(Some(
2832+
CorpusLayer::inherited(
2833+
"acme/standards",
2834+
"standards",
2835+
"sha256:0123456789abcdef",
2836+
)
2837+
.origin(),
2838+
));
2839+
2840+
assert_eq!(
2841+
output::render_validate_dir_human(&legacy),
2842+
output::render_validate_dir_human(&composed)
2843+
);
2844+
2845+
let legacy_json: serde_json::Value =
2846+
serde_json::from_str(&output::render_validate_dir_json(&legacy)).unwrap();
2847+
assert!(legacy_json["files"][0].get("provenance").is_none());
2848+
let composed_json: serde_json::Value =
2849+
serde_json::from_str(&output::render_validate_dir_json(&composed)).unwrap();
2850+
assert_eq!(
2851+
composed_json["files"][0]["provenance"],
2852+
serde_json::json!({
2853+
"layer": "inherited",
2854+
"pin": "sha256:0123456789abcdef",
2855+
"source": "acme/standards",
2856+
})
2857+
);
2858+
2859+
let legacy_sarif: serde_json::Value =
2860+
serde_json::from_str(&output::render_validate_sarif(&legacy)).unwrap();
2861+
assert!(legacy_sarif["runs"][0]["results"][0]
2862+
.get("properties")
2863+
.is_none());
2864+
let composed_sarif: serde_json::Value =
2865+
serde_json::from_str(&output::render_validate_sarif(&composed)).unwrap();
2866+
assert_eq!(
2867+
composed_sarif["runs"][0]["results"][0]["properties"],
2868+
composed_json["files"][0]["provenance"]
2869+
);
2870+
}
2871+
2872+
#[test]
2873+
fn parsed_manifest_identity_provenances_later_load_failures() {
2874+
let root = scratch();
2875+
fs::write(
2876+
root.join(".decided/config.yaml"),
2877+
"repository_key: APP\ncorpus:\n source: acme/app\n",
2878+
)
2879+
.unwrap();
2880+
let pin = format!("sha256:{}", "0".repeat(64));
2881+
fs::write(
2882+
root.join(crate::federation::MANIFEST_RELATIVE_PATH),
2883+
format!(
2884+
"# Corpus\n\n## inherits\n\n```yaml\nversion: 1\nalias: standards\n\
2885+
source: acme/standards\nroot: vendor/standards\ncorpus: decisions\n\
2886+
digest: {pin}\n```\n"
2887+
),
2888+
)
2889+
.unwrap();
2890+
2891+
let origin = manifest_failure_origin(&root.join("decisions").to_string_lossy()).unwrap();
2892+
assert_eq!(origin.source, "acme/standards");
2893+
assert_eq!(origin.layer, crate::corpus::Layer::Inherited);
2894+
assert_eq!(origin.alias.as_deref(), Some("standards"));
2895+
assert_eq!(origin.pin.as_deref(), Some(pin.as_str()));
2896+
2897+
fs::remove_dir_all(root).unwrap();
2898+
}
2899+
}

rust/rac-engine/src/composition.rs

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ use crate::corpus::{ArtifactKey, ArtifactPath, Layer};
1313
use crate::pycompat::py_casefold;
1414
use crate::relationships::{
1515
resolution_index_from_rows, resolve_relationships, validation_from_rows_with_index,
16-
validation_row_from_item, CorpusItem, Relationship, RelationshipValidation,
17-
ResolutionCandidate, ResolutionIndex, ValidationRow,
16+
validation_row_from_item, CorpusItem, Relationship, RelationshipSummary,
17+
RelationshipValidation, ResolutionCandidate, ResolutionIndex, ValidationRow,
1818
};
1919
use crate::resolve::{entry_from_item, identity_entry_from_item, is_live_decision, IndexEntry};
2020

@@ -717,6 +717,27 @@ impl ComposedCorpus {
717717
resolve_relationships(&self.catalog_rows, &self.resolution_index)
718718
}
719719

720+
/// Portfolio relationship metrics through the same qualified/redirect
721+
/// index as lookup and graph construction.
722+
pub fn relationship_summary(&self) -> RelationshipSummary {
723+
let mut summary = crate::relationships::summary_from_rows_with_index(
724+
&self.effective_rows,
725+
&self.resolution_index,
726+
true,
727+
);
728+
let before = summary.issues.len();
729+
summary.issues.retain(|issue| {
730+
!issue.origin.as_ref().is_some_and(|origin| {
731+
origin.layer == Layer::Inherited
732+
&& crate::relationships::relationship_severity(&issue.code) != "error"
733+
})
734+
});
735+
let parent_owned = before - summary.issues.len();
736+
summary.broken -= parent_owned;
737+
summary.valid += parent_owned;
738+
summary
739+
}
740+
720741
/// Run the existing relationship validator over source-aware keys. The
721742
/// child repository root is intentionally supplied here so inherited
722743
/// filesystem scope is checked against child code.
@@ -725,14 +746,22 @@ impl ComposedCorpus {
725746
child_directory: &str,
726747
recursive: bool,
727748
) -> RelationshipValidation {
728-
validation_from_rows_with_index(
749+
let mut validation = validation_from_rows_with_index(
729750
child_directory,
730751
&self.effective_rows,
731752
&self.catalog_rows,
732753
recursive,
733754
&self.resolution_index,
734755
false,
735-
)
756+
true,
757+
);
758+
validation.issues.retain(|issue| {
759+
!issue.origin.as_ref().is_some_and(|origin| {
760+
origin.layer == Layer::Inherited
761+
&& crate::relationships::relationship_severity(&issue.code) != "error"
762+
})
763+
});
764+
validation
736765
}
737766
}
738767

rust/rac-engine/src/delta_generation.rs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1228,12 +1228,18 @@ impl DeltaGeneration {
12281228
pub fn materialize_derived(&self, directory: &str, recursive: bool) -> DerivedIndex {
12291229
let (index_entries, field_tokens) = self.search.entries_and_fields(&self.graph);
12301230
let compatibility_layer = crate::corpus::compatible_local_layer(directory);
1231+
let normalized_root = crate::walk::normalize_root(directory);
1232+
let display_prefix = format!("{normalized_root}/");
12311233
let source_artifacts: Vec<crate::derived::SourceAwareArtifact> = index_entries
12321234
.iter()
12331235
.map(|entry| {
1236+
let identity_path = entry
1237+
.path
1238+
.strip_prefix(&display_prefix)
1239+
.unwrap_or(&entry.path);
12341240
let path = self
12351241
.identity
1236-
.artifact_path_for_path(&entry.path)
1242+
.artifact_path_for_path(identity_path)
12371243
.cloned()
12381244
.unwrap_or_else(|| {
12391245
crate::corpus::ArtifactPath::new(
@@ -1243,7 +1249,7 @@ impl DeltaGeneration {
12431249
});
12441250
let origin = self
12451251
.identity
1246-
.origin_for_path(&entry.path)
1252+
.origin_for_path(identity_path)
12471253
.cloned()
12481254
.unwrap_or_else(|| compatibility_layer.origin());
12491255
crate::derived::SourceAwareArtifact {
@@ -1263,13 +1269,35 @@ impl DeltaGeneration {
12631269
if layers.is_empty() {
12641270
layers.push(compatibility_layer);
12651271
}
1272+
let identity_entries = index_entries
1273+
.iter()
1274+
.cloned()
1275+
.map(|mut entry| {
1276+
entry.search_sections.clear();
1277+
entry.inbound_count = 0;
1278+
entry
1279+
})
1280+
.collect();
1281+
let live_decision_paths = self.scope.live_paths();
1282+
let live_path_set: std::collections::HashSet<&str> =
1283+
live_decision_paths.iter().map(String::as_str).collect();
1284+
let live_decision_keys = source_artifacts
1285+
.iter()
1286+
.filter(|artifact| live_path_set.contains(artifact.display_path.as_str()))
1287+
.map(|artifact| artifact.key.clone())
1288+
.collect();
12661289
DerivedIndex {
12671290
layers,
12681291
source_artifacts,
1292+
resolution: Box::new(crate::derived::ResolutionProjection {
1293+
entries: identity_entries,
1294+
canonical_redirects: Vec::new(),
1295+
}),
12691296
index_entries,
12701297
field_tokens,
12711298
relationships: self.graph.relationships(),
1272-
live_decision_paths: self.scope.live_paths(),
1299+
live_decision_keys,
1300+
live_decision_paths,
12731301
portfolio_summary: self.summary.value(directory, recursive),
12741302
scope_rows: self.scope.rows(),
12751303
}

0 commit comments

Comments
 (0)