1use std::collections::BTreeMap;
32
33use crate::metadata::{chrono_now_iso, json_to_vm, with_state};
34use crate::stdlib::macros::{harn_builtin, VmBuiltinDef};
35use crate::value::vm_to_storage_json as vm_to_json;
36use crate::value::{VmError, VmValue};
37
38const PROFILE_SCHEMA_VERSION: i64 = 1;
42
43const PROFILE_NAMESPACE: &str = "verification-profiles";
46
47const TIMING_WINDOW_CAP: usize = 64;
52
53pub(crate) const MODULE_BUILTINS: &[&VmBuiltinDef] = &[
54 &VERIFICATION_PROFILES_GET_IMPL_DEF,
55 &VERIFICATION_PROFILES_SET_IMPL_DEF,
56 &VERIFICATION_PROFILE_RESOLVE_IMPL_DEF,
57 &VERIFICATION_PROFILE_MATCHES_IMPL_DEF,
58 &VERIFICATION_PROFILE_RECORD_RUN_IMPL_DEF,
59 &VERIFICATION_DIAGNOSTIC_CLASSIFY_IMPL_DEF,
60];
61
62fn validate_record_set(value: &VmValue) -> Result<BTreeMap<String, serde_json::Value>, VmError> {
69 let VmValue::Dict(dict) = value else {
70 return Err(VmError::Runtime(
71 "verification_profiles_set: record_set must be a dict".to_string(),
72 ));
73 };
74 let mut fields = BTreeMap::new();
75 for (key, entry) in dict.iter() {
76 fields.insert(key.to_string(), vm_to_json(entry));
77 }
78 match fields.get("schemaVersion") {
79 None => {
80 fields.insert(
81 "schemaVersion".to_string(),
82 serde_json::json!(PROFILE_SCHEMA_VERSION),
83 );
84 }
85 Some(version) => {
86 let Some(version) = version.as_i64() else {
87 return Err(VmError::Runtime(
88 "verification_profiles_set: schemaVersion must be an int".to_string(),
89 ));
90 };
91 if version < 1 || version > PROFILE_SCHEMA_VERSION {
92 return Err(VmError::Runtime(format!(
93 "verification_profiles_set: unsupported schemaVersion {version} \
94 (this runtime writes schemaVersion <= {PROFILE_SCHEMA_VERSION})"
95 )));
96 }
97 }
98 }
99 match fields.get("rows") {
100 None => {
101 fields.insert("rows".to_string(), serde_json::json!([]));
102 }
103 Some(rows) => {
104 let Some(rows) = rows.as_array() else {
105 return Err(VmError::Runtime(
106 "verification_profiles_set: rows must be a list".to_string(),
107 ));
108 };
109 if rows.iter().any(|row| !row.is_object()) {
110 return Err(VmError::Runtime(
111 "verification_profiles_set: every row must be a dict".to_string(),
112 ));
113 }
114 }
115 }
116 fields.insert(
117 "updatedAt".to_string(),
118 serde_json::Value::String(chrono_now_iso()),
119 );
120 Ok(fields)
121}
122
123fn record_set_fields_to_vm(fields: &BTreeMap<String, serde_json::Value>) -> VmValue {
124 let mut map = BTreeMap::new();
125 for (key, value) in fields {
126 map.insert(key.clone(), json_to_vm(value));
127 }
128 VmValue::dict(map)
129}
130
131#[derive(Default)]
136struct ScopeQuery {
137 repo: Option<String>,
138 path: Option<String>,
139 language: Option<String>,
140 task: Option<String>,
141}
142
143fn optional_query_string(dict: &crate::value::DictMap, key: &str) -> Option<String> {
144 match dict.get(key) {
145 Some(VmValue::Nil) | None => None,
146 Some(value) => {
147 let text = value.display();
148 if text.trim().is_empty() {
149 None
150 } else {
151 Some(text)
152 }
153 }
154 }
155}
156
157fn scope_query_from_vm(value: Option<&VmValue>) -> ScopeQuery {
158 let Some(VmValue::Dict(dict)) = value else {
159 return ScopeQuery::default();
160 };
161 ScopeQuery {
162 repo: optional_query_string(dict, "repo"),
163 path: optional_query_string(dict, "path"),
164 language: optional_query_string(dict, "language"),
165 task: optional_query_string(dict, "task"),
166 }
167}
168
169fn scope_selector<'a>(row: &'a serde_json::Value, key: &str) -> Option<&'a str> {
170 row.get("scope")
171 .and_then(|scope| scope.get(key))
172 .and_then(|value| value.as_str())
173 .map(str::trim)
174 .filter(|text| !text.is_empty())
175}
176
177fn row_specificity(row: &serde_json::Value, query: &ScopeQuery) -> Option<u32> {
188 let mut specificity = 0u32;
189 if let Some(repo) = scope_selector(row, "repo") {
190 match &query.repo {
191 Some(candidate) if candidate == repo => specificity |= 1,
192 _ => return None,
193 }
194 }
195 if let Some(dir_glob) = scope_selector(row, "dir") {
196 match &query.path {
197 Some(path) if harn_glob::match_name(dir_glob, path) => specificity |= 2,
198 _ => return None,
199 }
200 }
201 if let Some(language) = scope_selector(row, "language") {
202 match &query.language {
203 Some(candidate) if candidate.eq_ignore_ascii_case(language) => specificity |= 4,
204 _ => return None,
205 }
206 }
207 if let Some(task) = scope_selector(row, "task") {
208 match &query.task {
209 Some(candidate) if candidate == task => specificity |= 8,
210 _ => return None,
211 }
212 }
213 Some(specificity)
214}
215
216fn matching_rows<'a>(
219 rows: &'a [serde_json::Value],
220 query: &ScopeQuery,
221) -> Vec<(u32, usize, &'a serde_json::Value)> {
222 let mut out = Vec::new();
223 for (index, row) in rows.iter().enumerate() {
224 let Some(specificity) = row_specificity(row, query) else {
225 continue;
226 };
227 out.push((specificity, index, row));
228 }
229 out.sort_by(|left, right| right.0.cmp(&left.0).then(left.1.cmp(&right.1)));
230 out
231}
232
233fn resolve_row<'a>(
234 rows: &'a [serde_json::Value],
235 query: &ScopeQuery,
236) -> Option<&'a serde_json::Value> {
237 matching_rows(rows, query)
238 .into_iter()
239 .next()
240 .map(|(_, _, row)| row)
241}
242
243fn percentile_ms(sorted: &[i64], percentile: usize) -> i64 {
249 if sorted.is_empty() {
250 return 0;
251 }
252 let rank = (percentile * sorted.len()).div_ceil(100).max(1);
253 sorted[rank - 1]
254}
255
256fn update_timings(
261 row: &mut serde_json::Map<String, serde_json::Value>,
262 duration_ms: i64,
263 warm: bool,
264) {
265 let timings = row
266 .entry("timings")
267 .or_insert_with(|| serde_json::json!({}));
268 if !timings.is_object() {
269 *timings = serde_json::json!({});
270 }
271 let timings = timings.as_object_mut().expect("timings coerced to object");
272
273 let (stats_key, window_key) = if warm {
274 ("warmMs", "recentWarmMs")
275 } else {
276 ("coldMs", "recentColdMs")
277 };
278 let mut window: Vec<i64> = timings
279 .get(window_key)
280 .and_then(|value| value.as_array())
281 .map(|values| values.iter().filter_map(|value| value.as_i64()).collect())
282 .unwrap_or_default();
283 window.push(duration_ms);
284 if window.len() > TIMING_WINDOW_CAP {
285 window.drain(0..window.len() - TIMING_WINDOW_CAP);
286 }
287 let mut sorted = window.clone();
288 sorted.sort_unstable();
289
290 let stats = timings
291 .entry(stats_key)
292 .or_insert_with(|| serde_json::json!({}));
293 if !stats.is_object() {
294 *stats = serde_json::json!({});
295 }
296 let stats = stats.as_object_mut().expect("stats coerced to object");
297 stats.insert(
298 "p50".to_string(),
299 serde_json::json!(percentile_ms(&sorted, 50)),
300 );
301 stats.insert(
302 "p95".to_string(),
303 serde_json::json!(percentile_ms(&sorted, 95)),
304 );
305 stats.insert(
306 "p99".to_string(),
307 serde_json::json!(percentile_ms(&sorted, 99)),
308 );
309
310 timings.insert(window_key.to_string(), serde_json::json!(window));
311 let samples = timings
312 .get("samples")
313 .and_then(|value| value.as_i64())
314 .unwrap_or(0);
315 timings.insert("samples".to_string(), serde_json::json!(samples + 1));
316}
317
318fn update_last_run(
323 row: &mut serde_json::Map<String, serde_json::Value>,
324 observation: &serde_json::Map<String, serde_json::Value>,
325) {
326 let mut last_run = serde_json::Map::new();
327 let at = observation
328 .get("at")
329 .and_then(|value| value.as_str())
330 .map(ToString::to_string)
331 .unwrap_or_else(chrono_now_iso);
332 last_run.insert("at".to_string(), serde_json::Value::String(at));
333 for key in ["exit", "failureSignature", "snapshot"] {
334 if let Some(value) = observation.get(key) {
335 if !value.is_null() {
336 last_run.insert(key.to_string(), value.clone());
337 }
338 }
339 }
340 row.insert("lastRun".to_string(), serde_json::Value::Object(last_run));
341}
342
343fn apply_observation(
347 row: &mut serde_json::Map<String, serde_json::Value>,
348 observation: &serde_json::Map<String, serde_json::Value>,
349) {
350 if let Some(duration_ms) = observation
351 .get("durationMs")
352 .and_then(|value| value.as_i64())
353 {
354 let warm = observation
355 .get("warm")
356 .and_then(|value| value.as_bool())
357 .unwrap_or(true);
358 update_timings(row, duration_ms, warm);
359 }
360 let has_outcome = ["exit", "failureSignature", "snapshot"].iter().any(|key| {
361 observation
362 .get(*key)
363 .map(|value| !value.is_null())
364 .unwrap_or(false)
365 });
366 if has_outcome {
367 update_last_run(row, observation);
368 }
369}
370
371fn classify_diagnostic(
387 envelope: &serde_json::Value,
388 current_hashes: &serde_json::Value,
389) -> serde_json::Value {
390 let mut result = serde_json::Map::new();
391 let envelope_obj = envelope.as_object();
392
393 let rung = envelope_obj
394 .and_then(|obj| obj.get("rung"))
395 .and_then(|value| value.as_str())
396 .map(str::trim)
397 .filter(|text| !text.is_empty());
398 let has_timestamp = envelope_obj
399 .and_then(|obj| obj.get("at"))
400 .map(|value| value.is_string() || value.is_number())
401 .unwrap_or(false);
402 let snapshot = envelope_obj
403 .and_then(|obj| obj.get("snapshot"))
404 .and_then(|value| value.as_object())
405 .filter(|snapshot| !snapshot.is_empty());
406
407 if let Some(rung) = rung {
408 result.insert("rung".to_string(), serde_json::json!(rung));
409 }
410 if let Some(row_id) = envelope_obj
411 .and_then(|obj| obj.get("rowId"))
412 .filter(|value| !value.is_null())
413 {
414 result.insert("rowId".to_string(), row_id.clone());
415 }
416
417 let (status, stale_files, reason) = match (rung, has_timestamp, snapshot) {
418 (Some(_), true, Some(snapshot)) => {
419 let current = current_hashes.as_object();
420 let mut stale_files: Vec<String> = snapshot
421 .iter()
422 .filter(|(file, recorded_hash)| {
423 current.and_then(|map| map.get(*file)) != Some(recorded_hash)
424 })
425 .map(|(file, _)| file.clone())
426 .collect();
427 stale_files.sort();
428 if stale_files.is_empty() {
429 (
430 "bound_fresh",
431 stale_files,
432 "snapshot matches current file hashes".to_string(),
433 )
434 } else {
435 let reason = format!(
436 "snapshot superseded for {} file(s); re-check before trusting",
437 stale_files.len()
438 );
439 ("bound_stale", stale_files, reason)
440 }
441 }
442 _ => (
443 "unbound",
444 Vec::new(),
445 "diagnostic carries no rung+timestamp+snapshot binding; advisory only".to_string(),
446 ),
447 };
448
449 result.insert("status".to_string(), serde_json::json!(status));
450 result.insert("staleFiles".to_string(), serde_json::json!(stale_files));
451 let feeds_gates = status == "bound_fresh";
452 result.insert("feedsGates".to_string(), serde_json::json!(feeds_gates));
453 result.insert("advisory".to_string(), serde_json::json!(!feeds_gates));
454 result.insert("reason".to_string(), serde_json::json!(reason));
455 serde_json::Value::Object(result)
456}
457
458fn optional_dir_arg(args: &[VmValue], index: usize) -> String {
463 match args.get(index) {
464 Some(VmValue::Nil) | None => ".".to_string(),
465 Some(value) => {
466 let text = value.display();
467 if text.trim().is_empty() {
468 ".".to_string()
469 } else {
470 text
471 }
472 }
473 }
474}
475
476#[harn_builtin(
479 sig = "verification_profiles_get(dir?: string|nil) -> dict|nil",
480 category = "verification"
481)]
482fn verification_profiles_get_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
483 let dir = optional_dir_arg(args, 0);
484 with_state("verification_profiles_get", |st| {
485 match st.get_namespace(&dir, PROFILE_NAMESPACE) {
486 Some(fields) => Ok(record_set_fields_to_vm(&fields)),
487 None => Ok(VmValue::Nil),
488 }
489 })
490}
491
492#[harn_builtin(
497 sig = "verification_profiles_set(record_set: dict, dir?: string|nil) -> nil",
498 category = "verification"
499)]
500fn verification_profiles_set_impl(args: &[VmValue], _out: &mut String) -> Result<VmValue, VmError> {
501 let record_set = args.first().cloned().unwrap_or(VmValue::Nil);
502 let dir = optional_dir_arg(args, 1);
503 let fields = validate_record_set(&record_set)?;
504 with_state("verification_profiles_set", |st| {
505 st.replace_namespace(&dir, PROFILE_NAMESPACE, fields);
506 st.save().map_err(VmError::Runtime)?;
507 Ok(VmValue::Nil)
508 })
509}
510
511#[harn_builtin(
515 sig = "verification_profile_resolve(query: dict, dir?: string|nil) -> dict|nil",
516 category = "verification"
517)]
518fn verification_profile_resolve_impl(
519 args: &[VmValue],
520 _out: &mut String,
521) -> Result<VmValue, VmError> {
522 let query = scope_query_from_vm(args.first());
523 let dir = optional_dir_arg(args, 1);
524 with_state("verification_profile_resolve", |st| {
525 let Some(fields) = st.get_namespace(&dir, PROFILE_NAMESPACE) else {
526 return Ok(VmValue::Nil);
527 };
528 let Some(rows) = fields.get("rows").and_then(|value| value.as_array()) else {
529 return Ok(VmValue::Nil);
530 };
531 match resolve_row(rows, &query) {
532 Some(row) => Ok(json_to_vm(row)),
533 None => Ok(VmValue::Nil),
534 }
535 })
536}
537
538#[harn_builtin(
544 sig = "verification_profile_matches(query: dict, dir?: string|nil) -> list",
545 category = "verification"
546)]
547fn verification_profile_matches_impl(
548 args: &[VmValue],
549 _out: &mut String,
550) -> Result<VmValue, VmError> {
551 let query = scope_query_from_vm(args.first());
552 let dir = optional_dir_arg(args, 1);
553 with_state("verification_profile_matches", |st| {
554 let Some(fields) = st.get_namespace(&dir, PROFILE_NAMESPACE) else {
555 return Ok(VmValue::List(std::sync::Arc::new(Vec::new())));
556 };
557 let Some(rows) = fields.get("rows").and_then(|value| value.as_array()) else {
558 return Ok(VmValue::List(std::sync::Arc::new(Vec::new())));
559 };
560 let matches = matching_rows(rows, &query)
561 .into_iter()
562 .map(|(specificity, index, row)| {
563 json_to_vm(&serde_json::json!({
564 "row": row,
565 "specificity": specificity,
566 "index": index,
567 }))
568 })
569 .collect();
570 Ok(VmValue::List(std::sync::Arc::new(matches)))
571 })
572}
573
574#[harn_builtin(
581 sig = "verification_profile_record_run(row_id: string, observation: dict, dir?: string|nil) -> dict|nil",
582 category = "verification"
583)]
584fn verification_profile_record_run_impl(
585 args: &[VmValue],
586 _out: &mut String,
587) -> Result<VmValue, VmError> {
588 let row_id = args
589 .first()
590 .map(|value| value.display())
591 .unwrap_or_default();
592 if row_id.trim().is_empty() {
593 return Err(VmError::Runtime(
594 "verification_profile_record_run: row_id must not be empty".to_string(),
595 ));
596 }
597 let observation = match args.get(1) {
598 Some(VmValue::Dict(_)) => vm_to_json(args.get(1).expect("checked")),
599 _ => {
600 return Err(VmError::Runtime(
601 "verification_profile_record_run: observation must be a dict".to_string(),
602 ))
603 }
604 };
605 let observation = observation.as_object().cloned().unwrap_or_default();
606 let dir = optional_dir_arg(args, 2);
607 with_state("verification_profile_record_run", |st| {
608 let origin = st
609 .origin_directory(&dir, PROFILE_NAMESPACE, Some("rows"))
610 .unwrap_or_else(|| dir.clone());
611 let Some(mut fields) = st
612 .local_directory(&origin)
613 .namespaces
614 .get(PROFILE_NAMESPACE)
615 .cloned()
616 else {
617 return Ok(VmValue::Nil);
618 };
619 let Some(rows) = fields
620 .get_mut("rows")
621 .and_then(|value| value.as_array_mut())
622 else {
623 return Ok(VmValue::Nil);
624 };
625 let mut updated_row = None;
626 for row in rows.iter_mut() {
627 let Some(row_obj) = row.as_object_mut() else {
628 continue;
629 };
630 let matches = row_obj
631 .get("id")
632 .and_then(|value| value.as_str())
633 .map(|id| id == row_id)
634 .unwrap_or(false);
635 if matches {
636 apply_observation(row_obj, &observation);
637 updated_row = Some(row.clone());
638 break;
639 }
640 }
641 let Some(updated_row) = updated_row else {
642 return Ok(VmValue::Nil);
643 };
644 fields.insert(
645 "updatedAt".to_string(),
646 serde_json::Value::String(chrono_now_iso()),
647 );
648 st.replace_namespace(&origin, PROFILE_NAMESPACE, fields);
649 st.save().map_err(VmError::Runtime)?;
650 Ok(json_to_vm(&updated_row))
651 })
652}
653
654#[harn_builtin(
659 sig = "verification_diagnostic_classify(envelope: dict|nil, current_hashes: dict) -> dict",
660 category = "verification"
661)]
662fn verification_diagnostic_classify_impl(
663 args: &[VmValue],
664 _out: &mut String,
665) -> Result<VmValue, VmError> {
666 let envelope = args
667 .first()
668 .map(vm_to_json)
669 .unwrap_or(serde_json::Value::Null);
670 let current_hashes = args
671 .get(1)
672 .map(vm_to_json)
673 .unwrap_or_else(|| serde_json::json!({}));
674 Ok(json_to_vm(&classify_diagnostic(&envelope, ¤t_hashes)))
675}
676
677#[cfg(test)]
678mod tests {
679 use super::*;
680 use crate::metadata::MetadataState;
681 use std::path::PathBuf;
682 use std::sync::atomic::{AtomicU64, Ordering};
683
684 static TEMP_PATH_COUNTER: AtomicU64 = AtomicU64::new(0);
685
686 fn temp_path(name: &str) -> PathBuf {
687 let unique = TEMP_PATH_COUNTER.fetch_add(1, Ordering::Relaxed);
688 let pid = std::process::id();
689 std::env::temp_dir().join(format!("harn-verification-{name}-{pid}-{unique}"))
690 }
691
692 fn query(path: &str, language: &str, task: &str) -> ScopeQuery {
693 let optional = |text: &str| {
694 if text.is_empty() {
695 None
696 } else {
697 Some(text.to_string())
698 }
699 };
700 ScopeQuery {
701 repo: None,
702 path: optional(path),
703 language: optional(language),
704 task: optional(task),
705 }
706 }
707
708 fn sample_rows() -> Vec<serde_json::Value> {
711 vec![
712 serde_json::json!({"id": "any", "scope": {}}),
713 serde_json::json!({"id": "by-dir", "scope": {"dir": "src/**"}}),
714 serde_json::json!({"id": "by-lang", "scope": {"language": "toolchainx"}}),
715 serde_json::json!({
716 "id": "by-dir-lang",
717 "scope": {"dir": "src/**", "language": "toolchainx"}
718 }),
719 serde_json::json!({
720 "id": "by-task",
721 "scope": {"language": "toolchainx", "task": "post_edit"}
722 }),
723 ]
724 }
725
726 fn resolved_id(rows: &[serde_json::Value], q: &ScopeQuery) -> Option<String> {
727 resolve_row(rows, q)
728 .and_then(|row| row.get("id"))
729 .and_then(|id| id.as_str())
730 .map(ToString::to_string)
731 }
732
733 #[test]
734 fn resolve_prefers_most_specific_row() {
735 let rows = sample_rows();
736 assert_eq!(
738 resolved_id(&rows, &query("src/a.tx", "toolchainx", "post_edit")),
739 Some("by-task".to_string())
740 );
741 assert_eq!(
743 resolved_id(&rows, &query("src/a.tx", "toolchainx", "")),
744 Some("by-dir-lang".to_string())
745 );
746 assert_eq!(
748 resolved_id(&rows, &query("docs/a.tx", "toolchainx", "")),
749 Some("by-lang".to_string())
750 );
751 assert_eq!(
753 resolved_id(&rows, &query("docs/readme.md", "otherlang", "")),
754 Some("any".to_string())
755 );
756 assert_eq!(
759 resolved_id(&rows, &query("src/a.tx", "", "")),
760 Some("by-dir".to_string())
761 );
762 assert_eq!(
764 resolved_id(&rows, &query("", "TOOLCHAINX", "")),
765 Some("by-lang".to_string())
766 );
767 assert_eq!(
768 resolved_id(&rows, &query("", "toolchainx", "other_task")),
769 Some("by-lang".to_string())
770 );
771 }
772
773 #[test]
774 fn resolve_ties_keep_the_earliest_row() {
775 let rows = vec![
776 serde_json::json!({"id": "first", "scope": {"language": "toolchainx"}}),
777 serde_json::json!({"id": "second", "scope": {"language": "toolchainx"}}),
778 ];
779 assert_eq!(
780 resolved_id(&rows, &query("", "toolchainx", "")),
781 Some("first".to_string())
782 );
783 }
784
785 #[test]
786 fn matching_rows_return_all_matches_by_specificity_then_order() {
787 let mut rows = sample_rows();
788 rows.insert(
789 3,
790 serde_json::json!({
791 "id": "by-lang-later",
792 "scope": {"language": "toolchainx"}
793 }),
794 );
795 let ids: Vec<String> = matching_rows(&rows, &query("src/a.tx", "toolchainx", ""))
796 .into_iter()
797 .map(|(_, _, row)| {
798 row.get("id")
799 .and_then(|id| id.as_str())
800 .expect("row id")
801 .to_string()
802 })
803 .collect();
804 assert_eq!(
805 ids,
806 vec![
807 "by-dir-lang".to_string(),
808 "by-lang".to_string(),
809 "by-lang-later".to_string(),
810 "by-dir".to_string(),
811 "any".to_string()
812 ]
813 );
814 }
815
816 #[test]
817 fn record_set_round_trips_unknown_fields_through_disk() {
818 let base = temp_path("roundtrip");
819 let record_set = VmValue::dict(BTreeMap::from([
820 ("schemaVersion".to_string(), VmValue::Int(1)),
821 (
822 "vendorExt".to_string(),
823 VmValue::dict(BTreeMap::from([("keep".to_string(), VmValue::Bool(true))])),
824 ),
825 (
826 "rows".to_string(),
827 VmValue::List(std::sync::Arc::new(vec![VmValue::dict(BTreeMap::from([
828 (
829 "id".to_string(),
830 VmValue::String(arcstr::ArcStr::from("r1")),
831 ),
832 (
833 "futureField".to_string(),
834 VmValue::String(arcstr::ArcStr::from("kept")),
835 ),
836 ]))])),
837 ),
838 ]));
839 let fields = validate_record_set(&record_set).expect("valid");
840 assert_eq!(fields.get("schemaVersion"), Some(&serde_json::json!(1)));
841 assert!(fields.contains_key("updatedAt"));
842
843 let mut state = MetadataState::new(&base);
844 state.replace_namespace(".", PROFILE_NAMESPACE, fields);
845 state.save().expect("save");
846
847 let mut reloaded = MetadataState::new(&base);
848 let fields = reloaded
849 .get_namespace(".", PROFILE_NAMESPACE)
850 .expect("stored");
851 assert_eq!(
852 fields.get("vendorExt"),
853 Some(&serde_json::json!({"keep": true}))
854 );
855 let rows = fields
856 .get("rows")
857 .and_then(|value| value.as_array())
858 .unwrap();
859 assert_eq!(rows[0].get("futureField"), Some(&serde_json::json!("kept")));
860 let _ = std::fs::remove_dir_all(base);
861 }
862
863 #[test]
864 fn replace_namespace_drops_keys_absent_from_the_new_set() {
865 let base = temp_path("replace");
866 let mut state = MetadataState::new(&base);
867 state.replace_namespace(
868 ".",
869 PROFILE_NAMESPACE,
870 BTreeMap::from([("stray".into(), serde_json::json!(true))]),
871 );
872 state.replace_namespace(
873 ".",
874 PROFILE_NAMESPACE,
875 BTreeMap::from([("rows".into(), serde_json::json!([]))]),
876 );
877 let fields = state.get_namespace(".", PROFILE_NAMESPACE).expect("stored");
878 assert!(!fields.contains_key("stray"));
879 assert!(fields.contains_key("rows"));
880 }
881
882 #[test]
883 fn profiles_set_rejects_newer_schema_versions() {
884 let record_set = VmValue::dict(BTreeMap::from([(
885 "schemaVersion".to_string(),
886 VmValue::Int(PROFILE_SCHEMA_VERSION + 1),
887 )]));
888 let error = validate_record_set(&record_set).expect_err("must reject");
889 let VmError::Runtime(message) = error else {
890 panic!("unexpected error kind");
891 };
892 assert!(message.contains("unsupported schemaVersion"));
893 }
894
895 #[test]
896 fn timing_percentiles_track_the_recent_window() {
897 let mut row = serde_json::json!({
898 "id": "r1",
899 "timings": {"customNote": "kept"}
900 });
901 let row_obj = row.as_object_mut().unwrap();
902 for duration in 1..=100 {
903 update_timings(row_obj, duration, true);
904 }
905 let timings = row_obj
906 .get("timings")
907 .and_then(|value| value.as_object())
908 .unwrap();
909 let window = timings
911 .get("recentWarmMs")
912 .and_then(|value| value.as_array())
913 .unwrap();
914 assert_eq!(window.len(), TIMING_WINDOW_CAP);
915 assert_eq!(window.first().and_then(|value| value.as_i64()), Some(37));
916 assert_eq!(window.last().and_then(|value| value.as_i64()), Some(100));
917 let warm = timings
918 .get("warmMs")
919 .and_then(|value| value.as_object())
920 .unwrap();
921 assert_eq!(warm.get("p50"), Some(&serde_json::json!(68)));
922 assert_eq!(warm.get("p95"), Some(&serde_json::json!(97)));
923 assert_eq!(warm.get("p99"), Some(&serde_json::json!(100)));
924 assert_eq!(timings.get("samples"), Some(&serde_json::json!(100)));
925 update_timings(row_obj, 5000, false);
927 let timings = row_obj
928 .get("timings")
929 .and_then(|value| value.as_object())
930 .unwrap();
931 let cold = timings
932 .get("coldMs")
933 .and_then(|value| value.as_object())
934 .unwrap();
935 assert_eq!(cold.get("p50"), Some(&serde_json::json!(5000)));
936 assert_eq!(timings.get("samples"), Some(&serde_json::json!(101)));
937 assert_eq!(timings.get("customNote"), Some(&serde_json::json!("kept")));
939 }
940
941 #[test]
942 fn observations_update_last_run_only_when_outcome_bearing() {
943 let mut row = serde_json::json!({"id": "r1"});
944 let row_obj = row.as_object_mut().unwrap();
945
946 let timing_only = serde_json::json!({"durationMs": 120})
947 .as_object()
948 .cloned()
949 .unwrap();
950 apply_observation(row_obj, &timing_only);
951 assert!(row_obj.get("lastRun").is_none());
952
953 let outcome = serde_json::json!({
954 "durationMs": 90,
955 "exit": 1,
956 "failureSignature": "expected ';' after statement",
957 "snapshot": {"src/a.tx": "h1"}
958 })
959 .as_object()
960 .cloned()
961 .unwrap();
962 apply_observation(row_obj, &outcome);
963 let last_run = row_obj
964 .get("lastRun")
965 .and_then(|value| value.as_object())
966 .unwrap();
967 assert_eq!(last_run.get("exit"), Some(&serde_json::json!(1)));
968 assert_eq!(
969 last_run.get("failureSignature"),
970 Some(&serde_json::json!("expected ';' after statement"))
971 );
972 assert_eq!(
973 last_run.get("snapshot"),
974 Some(&serde_json::json!({"src/a.tx": "h1"}))
975 );
976 assert!(last_run
977 .get("at")
978 .and_then(|value| value.as_str())
979 .is_some());
980
981 let green = serde_json::json!({"exit": 0, "snapshot": {"src/a.tx": "h2"}})
984 .as_object()
985 .cloned()
986 .unwrap();
987 apply_observation(row_obj, &green);
988 let last_run = row_obj
989 .get("lastRun")
990 .and_then(|value| value.as_object())
991 .unwrap();
992 assert_eq!(last_run.get("exit"), Some(&serde_json::json!(0)));
993 assert!(last_run.get("failureSignature").is_none());
994 }
995
996 #[test]
997 fn classify_bound_fresh_feeds_gates() {
998 let envelope = serde_json::json!({
999 "rung": "R2",
1000 "rowId": "r1",
1001 "at": "2026-07-02T00:00:00Z",
1002 "snapshot": {"src/a.tx": "h1", "src/b.tx": "h2"}
1003 });
1004 let current = serde_json::json!({"src/a.tx": "h1", "src/b.tx": "h2"});
1005 let result = classify_diagnostic(&envelope, ¤t);
1006 assert_eq!(
1007 result.get("status"),
1008 Some(&serde_json::json!("bound_fresh"))
1009 );
1010 assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(true)));
1011 assert_eq!(result.get("advisory"), Some(&serde_json::json!(false)));
1012 assert_eq!(result.get("staleFiles"), Some(&serde_json::json!([])));
1013 assert_eq!(result.get("rowId"), Some(&serde_json::json!("r1")));
1014 }
1015
1016 #[test]
1017 fn classify_bound_stale_on_hash_change_or_missing_hash() {
1018 let envelope = serde_json::json!({
1019 "rung": "R2",
1020 "at": "2026-07-02T00:00:00Z",
1021 "snapshot": {"src/a.tx": "h1", "src/b.tx": "h2"}
1022 });
1023 let result = classify_diagnostic(
1025 &envelope,
1026 &serde_json::json!({"src/a.tx": "h9", "src/b.tx": "h2"}),
1027 );
1028 assert_eq!(
1029 result.get("status"),
1030 Some(&serde_json::json!("bound_stale"))
1031 );
1032 assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(false)));
1033 assert_eq!(result.get("advisory"), Some(&serde_json::json!(true)));
1034 assert_eq!(
1035 result.get("staleFiles"),
1036 Some(&serde_json::json!(["src/a.tx"]))
1037 );
1038 let result = classify_diagnostic(&envelope, &serde_json::json!({"src/a.tx": "h1"}));
1040 assert_eq!(
1041 result.get("status"),
1042 Some(&serde_json::json!("bound_stale"))
1043 );
1044 assert_eq!(
1045 result.get("staleFiles"),
1046 Some(&serde_json::json!(["src/b.tx"]))
1047 );
1048 }
1049
1050 #[test]
1051 fn classify_unbound_diagnostics_never_feed_gates() {
1052 let current = serde_json::json!({"src/a.tx": "h1"});
1053 let unbound_cases = [
1054 serde_json::Value::Null,
1055 serde_json::json!({}),
1056 serde_json::json!({"rung": "R2", "at": "2026-07-02T00:00:00Z"}),
1058 serde_json::json!({"rung": "R2", "at": "2026-07-02T00:00:00Z", "snapshot": {}}),
1060 serde_json::json!({"at": "2026-07-02T00:00:00Z", "snapshot": {"src/a.tx": "h1"}}),
1062 serde_json::json!({"rung": "R2", "snapshot": {"src/a.tx": "h1"}}),
1064 ];
1065 for envelope in unbound_cases {
1066 let result = classify_diagnostic(&envelope, ¤t);
1067 assert_eq!(
1068 result.get("status"),
1069 Some(&serde_json::json!("unbound")),
1070 "envelope: {envelope}"
1071 );
1072 assert_eq!(result.get("feedsGates"), Some(&serde_json::json!(false)));
1073 assert_eq!(result.get("advisory"), Some(&serde_json::json!(true)));
1074 }
1075 }
1076}