1use std::collections::BTreeSet;
28
29use serde::{Deserialize, Serialize};
30
31use crate::ValidationError;
32
33pub const GATE_RECORD_SCHEMA: &str = "leanctx.hive.process.gate/v1";
35pub const PROCESS_VERSION: &str = "1.0";
37pub const RULE_LOCKED_PATHS_CHANGED: &str = "locked_paths_changed";
39pub const MAX_LOCKED_PATHS: usize = 500;
41pub const MISSING_CI_PIPELINE: &str = "ci_pipeline";
43pub const HIVE_CLOUD_PROJECT: u64 = 37;
45pub const HIVE_CLOUD_PG_JOB: &str = "test-pg";
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub enum TaskRisk {
50 S,
51 M,
52 H,
53}
54
55impl TaskRisk {
56 pub fn has_spec(self) -> bool {
58 matches!(self, Self::M | Self::H)
59 }
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum LockedChange {
66 Modified,
68 Added,
70 Deleted,
72 Untracked,
74 TypeChanged,
76 OutsideRepo,
79}
80
81impl LockedChange {
82 pub fn as_str(self) -> &'static str {
83 match self {
84 Self::Modified => "modified",
85 Self::Added => "added",
86 Self::Deleted => "deleted",
87 Self::Untracked => "untracked",
88 Self::TypeChanged => "type_changed",
89 Self::OutsideRepo => "outside_repo",
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ChangedPathV1 {
97 pub path: String,
98 pub change: LockedChange,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct LockedPathsV1 {
104 pub spec_commit: String,
105 pub checked: Vec<String>,
108 pub ok: bool,
109 pub changed: Vec<ChangedPathV1>,
110}
111
112impl LockedPathsV1 {
113 pub fn validate(&self) -> Result<(), ValidationError> {
116 validate_sha(&self.spec_commit, "locked_paths.spec_commit")?;
117 if self.checked.len() > MAX_LOCKED_PATHS || self.changed.len() > MAX_LOCKED_PATHS {
118 return Err(ValidationError::new("too many locked paths in one record"));
119 }
120 let mut seen = BTreeSet::new();
121 for path in &self.checked {
122 validate_repo_path(path)?;
123 if !seen.insert(path.as_str()) {
124 return Err(ValidationError::new("locked_paths.checked repeats a path"));
125 }
126 }
127 if self.ok != self.changed.is_empty() {
128 return Err(ValidationError::new(
129 "locked_paths.ok is true exactly when changed is empty",
130 ));
131 }
132 let mut paths = BTreeSet::new();
133 for item in &self.changed {
134 validate_repo_path(&item.path)?;
135 if !paths.insert(item.path.as_str()) {
136 return Err(ValidationError::new(
137 "locked_paths.changed names each path once (locked_paths_unique)",
138 ));
139 }
140 if !self.checked.iter().any(|c| under_locked(&item.path, c)) {
141 return Err(ValidationError::new(
142 "every changed path lies under a checked path (locked_paths_changed_unchecked)",
143 ));
144 }
145 }
146 Ok(())
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct FeatureResultV1 {
153 pub id: String,
154 pub passes: bool,
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct TestStatusV1 {
161 pub test: String,
162 pub status: String,
163}
164
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
171pub struct RerunReportV1 {
172 pub format: String,
173 pub sha256: String,
174 pub passed: u64,
175 pub failed: u64,
176 pub ignored: u64,
177 pub names_checked: Vec<TestStatusV1>,
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
182pub struct RerunV1 {
183 pub command: String,
184 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub e2e_command: Option<String>,
186 pub host: String,
187 pub commit: String,
189 pub spec_commit: String,
190 pub exit_code: i32,
191 pub log_sha256: String,
192 pub started_at: String,
193 pub finished_at: String,
194 pub features: Vec<FeatureResultV1>,
195 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub report: Option<RerunReportV1>,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
202#[serde(rename_all = "snake_case")]
203pub enum GateResult {
204 Pass,
205 Fail,
206 Blocked,
207}
208
209#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
220#[serde(deny_unknown_fields)]
221pub struct G6RecordV1 {
222 pub schema: String,
223 pub process: String,
224 pub gate: String,
225 pub risk: TaskRisk,
226 pub artifact_commit: String,
228 pub result: GateResult,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub missing: Vec<String>,
232 pub locked_paths: LockedPathsV1,
233 #[serde(default, skip_serializing_if = "Option::is_none")]
236 pub rerun: Option<RerunV1>,
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub rule: Option<String>,
239 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub reason: Option<String>,
241}
242
243impl G6RecordV1 {
244 pub fn ready_for_hive(&self) -> bool {
249 self.validate().is_ok()
250 && self.result == GateResult::Blocked
251 && self.missing == [MISSING_CI_PIPELINE]
252 && self.locked_paths.ok
253 && self.rerun.as_ref().is_some_and(|r| r.exit_code == 0)
254 }
255
256 pub fn judges(&self, commit: &str) -> bool {
258 self.artifact_commit == commit
259 }
260
261 pub fn validate(&self) -> Result<(), ValidationError> {
262 if self.schema != GATE_RECORD_SCHEMA {
263 return Err(ValidationError::new(format!(
264 "schema must be {GATE_RECORD_SCHEMA}"
265 )));
266 }
267 if self.process != PROCESS_VERSION || self.gate != "G6" {
268 return Err(ValidationError::new("a G6 record of process 1.0"));
269 }
270 validate_sha(&self.artifact_commit, "artifact_commit")?;
271 validate_core(
272 self.risk,
273 self.result,
274 &self.missing,
275 &self.locked_paths,
276 self.rerun.as_ref(),
277 self.rule.as_deref(),
278 self.reason.as_deref(),
279 &self.artifact_commit,
280 )?;
281 if self.result == GateResult::Pass {
282 return Err(ValidationError::new(
283 "a pass needs ci (§5.1), which the Instance does not read: its record is blocked on ci_pipeline",
284 ));
285 }
286 Ok(())
287 }
288}
289
290#[allow(clippy::too_many_arguments)]
292fn validate_core(
293 risk: TaskRisk,
294 result: GateResult,
295 missing: &[String],
296 locked_paths: &LockedPathsV1,
297 rerun: Option<&RerunV1>,
298 rule: Option<&str>,
299 reason: Option<&str>,
300 artifact: &str,
301) -> Result<(), ValidationError> {
302 locked_paths.validate()?;
303 if risk.has_spec() == locked_paths.checked.is_empty() {
304 return Err(ValidationError::new(
305 "an M or H task checks its feature list's locked paths; an S task checks none",
306 ));
307 }
308 let rule_ok = match (rule, locked_paths.ok) {
309 (None, true) => true,
310 (Some(rule), false) => rule == RULE_LOCKED_PATHS_CHANGED,
311 _ => false,
312 };
313 if !rule_ok || (!locked_paths.ok && result != GateResult::Fail) {
314 return Err(ValidationError::new(
315 "a locked-path change is a fail with rule locked_paths_changed, and that rule appears on nothing else",
316 ));
317 }
318 match result {
319 GateResult::Blocked if missing.is_empty() => {
320 return Err(ValidationError::new(
321 "a blocked record lists what is missing",
322 ));
323 }
324 GateResult::Pass if !missing.is_empty() => {
325 return Err(ValidationError::new("a pass misses nothing"));
326 }
327 _ => {}
328 }
329 if result != GateResult::Pass {
330 let reason_ok = reason.is_some_and(|r| (1..=2000).contains(&r.chars().count()));
331 if !reason_ok {
332 return Err(ValidationError::new(
333 "a fail or blocked record gives a reason (1-2000 chars)",
334 ));
335 }
336 }
337 match rerun {
338 None if locked_paths.ok => {
339 return Err(ValidationError::new(
340 "a G6 record with clean locked paths carries the gate's rerun",
341 ));
342 }
343 None => {}
344 Some(rerun) => {
345 validate_sha(&rerun.commit, "rerun.commit")?;
346 validate_sha(&rerun.spec_commit, "rerun.spec_commit")?;
347 if rerun.commit != artifact {
348 return Err(ValidationError::new(
349 "rerun.commit is the judged commit (rerun_commit)",
350 ));
351 }
352 if rerun.spec_commit != locked_paths.spec_commit {
353 return Err(ValidationError::new(
354 "locked_paths.spec_commit is the rerun's (locked_paths_spec_commit)",
355 ));
356 }
357 if !risk.has_spec() && !rerun.features.is_empty() {
358 return Err(ValidationError::new(
359 "an S record carries no feature results",
360 ));
361 }
362 if rerun.command.trim().is_empty() || rerun.command.chars().count() > 2000 {
363 return Err(ValidationError::new("rerun.command is 1-2000 chars"));
364 }
365 if result != GateResult::Fail && rerun.exit_code != 0 {
366 return Err(ValidationError::new(
367 "a red rerun is a fail, never a pass or blocked",
368 ));
369 }
370 if result != GateResult::Fail && risk.has_spec() && rerun.report.is_none() {
374 return Err(ValidationError::new(
375 "an M/H rerun that is not a fail carries the harness report (rerun_report_missing)",
376 ));
377 }
378 if result != GateResult::Fail && rerun.report.as_ref().is_some_and(|r| r.failed != 0) {
381 return Err(ValidationError::new(
382 "the harness reported failed tests, so the rerun is not green",
383 ));
384 }
385 }
386 }
387 Ok(())
388}
389
390#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392pub struct ArtifactRefV1 {
393 pub kind: String,
394 #[serde(rename = "ref")]
395 pub reference: String,
396}
397
398#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
400pub struct CiJobV1 {
401 pub name: String,
402 pub status: String,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
404 pub url: Option<String>,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
411pub struct CiV1 {
412 pub provider: String,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 pub project: Option<u64>,
415 #[serde(default, skip_serializing_if = "Option::is_none")]
416 pub repo: Option<String>,
417 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub pipeline_id: Option<String>,
419 #[serde(default, skip_serializing_if = "Option::is_none")]
420 pub commit: Option<String>,
421 #[serde(default, skip_serializing_if = "Option::is_none")]
422 pub status: Option<String>,
423 #[serde(default, skip_serializing_if = "Vec::is_empty")]
424 pub required_jobs: Vec<String>,
425 #[serde(default, skip_serializing_if = "Vec::is_empty")]
426 pub jobs: Vec<CiJobV1>,
427 #[serde(default, skip_serializing_if = "Option::is_none")]
428 pub reason: Option<String>,
429}
430
431impl CiV1 {
432 pub fn green_for(&self, artifact: &str) -> Result<(), String> {
438 if self.provider == "none" {
439 return match self.reason.as_deref().map(str::trim) {
440 Some(reason) if !reason.is_empty() => Ok(()),
441 _ => Err("ci provider none without a reason".into()),
442 };
443 }
444 if self.commit.as_deref() != Some(artifact) {
445 return Err("ci is for another commit (ci_commit)".into());
446 }
447 if self.status.as_deref() != Some("success") || self.pipeline_id.is_none() {
448 return Err(format!(
449 "the pipeline is {} (a pass needs success)",
450 self.status.as_deref().unwrap_or("unknown")
451 ));
452 }
453 if self.required_jobs.is_empty() {
454 return Err("ci names no required jobs (ci_declared_jobs)".into());
455 }
456 if self.project == Some(HIVE_CLOUD_PROJECT)
457 && !self.required_jobs.iter().any(|j| j == HIVE_CLOUD_PG_JOB)
458 {
459 return Err("a Hive Cloud record always requires test-pg".into());
460 }
461 for required in &self.required_jobs {
462 match self.jobs.iter().find(|j| &j.name == required) {
463 Some(job) if job.status == "success" => {}
464 Some(job) => {
465 return Err(format!(
466 "required job {required} is {} (ci_required_jobs)",
467 job.status
468 ));
469 }
470 None => {
471 return Err(format!(
472 "required job {required} is missing (ci_required_jobs)"
473 ));
474 }
475 }
476 }
477 Ok(())
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
487pub struct HiveG6RecordV1 {
488 pub schema: String,
489 pub process: String,
490 pub gate: String,
491 pub risk: TaskRisk,
492 pub artifact: ArtifactRefV1,
493 pub result: GateResult,
494 #[serde(default)]
495 pub missing: Vec<String>,
496 pub locked_paths: LockedPathsV1,
497 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub rerun: Option<RerunV1>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub ci: Option<CiV1>,
501 #[serde(default, skip_serializing_if = "Option::is_none")]
502 pub rule: Option<String>,
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 pub reason: Option<String>,
505}
506
507#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum NotGreen {
510 NoCi,
512 Red(String),
514}
515
516impl HiveG6RecordV1 {
517 pub fn artifact_commit(&self) -> &str {
519 &self.artifact.reference
520 }
521
522 pub fn judges(&self, commit: &str) -> bool {
524 self.artifact.kind == "commit" && self.artifact.reference == commit
525 }
526
527 pub fn green(&self) -> Result<(), NotGreen> {
530 let red = |e: String| NotGreen::Red(e);
531 if self.schema != GATE_RECORD_SCHEMA || self.process != PROCESS_VERSION || self.gate != "G6"
532 {
533 return Err(red("not a G6 record of process 1.0".into()));
534 }
535 if self.artifact.kind != "commit" {
536 return Err(red("the record judged no commit".into()));
537 }
538 validate_sha(&self.artifact.reference, "artifact.ref").map_err(|e| red(e.to_string()))?;
539 validate_core(
540 self.risk,
541 self.result,
542 &self.missing,
543 &self.locked_paths,
544 self.rerun.as_ref(),
545 self.rule.as_deref(),
546 self.reason.as_deref(),
547 &self.artifact.reference,
548 )
549 .map_err(|e| red(e.to_string()))?;
550 if self.result != GateResult::Pass {
551 return Err(red(format!(
552 "the record is {}",
553 match self.result {
554 GateResult::Fail => "fail",
555 _ => "blocked",
556 }
557 )));
558 }
559 let ci = self.ci.as_ref().ok_or(NotGreen::NoCi)?;
560 ci.green_for(&self.artifact.reference).map_err(red)
561 }
562}
563
564#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub struct TaskProcessV1 {
574 pub risk: TaskRisk,
575 #[serde(default, skip_serializing_if = "Option::is_none")]
576 pub stage: Option<String>,
577 #[serde(default, skip_serializing_if = "Option::is_none")]
579 pub spec_commit: Option<String>,
580 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub candidate_commit: Option<String>,
583 #[serde(default, skip_serializing_if = "Vec::is_empty")]
585 pub locked_paths: Vec<String>,
586 #[serde(default, skip_serializing_if = "Option::is_none")]
588 pub test_command: Option<String>,
589 #[serde(default, skip_serializing_if = "Option::is_none")]
590 pub e2e_command: Option<String>,
591 #[serde(default, skip_serializing_if = "Vec::is_empty")]
593 pub features: Vec<String>,
594 #[serde(default, skip_serializing_if = "Option::is_none")]
596 pub repo_test_command: Option<String>,
597 #[serde(default, skip_serializing_if = "Option::is_none")]
599 pub g6: Option<HiveG6RecordV1>,
600}
601
602pub fn under_locked(path: &str, pattern: &str) -> bool {
607 fn glob(p: &[u8], s: &[u8]) -> bool {
608 match p {
609 [] => s.is_empty(),
610 [b'*', b'*', b'/', rest @ ..] => {
613 (0..=s.len()).any(|i| (i == 0 || s[i - 1] == b'/') && glob(rest, &s[i..]))
614 }
615 [b'*', b'*', rest @ ..] => (0..=s.len()).any(|i| glob(rest, &s[i..])),
617 [b'*', rest @ ..] => (0..=s.len())
618 .take_while(|&i| i == 0 || s[i - 1] != b'/')
619 .any(|i| glob(rest, &s[i..])),
620 [c, rest @ ..] => s.first() == Some(c) && glob(rest, &s[1..]),
621 }
622 }
623 if pattern.contains('*') {
624 glob(pattern.as_bytes(), path.as_bytes())
625 } else {
626 path == pattern || path.starts_with(&format!("{}/", pattern.trim_end_matches('/')))
627 }
628}
629
630pub fn validate_repo_path(path: &str) -> Result<(), ValidationError> {
633 let len = path.chars().count();
634 let escapes = path.starts_with('/') || path.split('/').any(|segment| segment == "..");
635 if !(1..=500).contains(&len) || escapes || path.contains('\0') || path.contains('\\') {
636 return Err(ValidationError::new(format!(
637 "locked path {path:?} is not a path inside the repository"
638 )));
639 }
640 Ok(())
641}
642
643fn validate_sha(value: &str, field: &str) -> Result<(), ValidationError> {
644 if value.len() == 40
645 && value
646 .bytes()
647 .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
648 {
649 Ok(())
650 } else {
651 Err(ValidationError::new(format!(
652 "{field} is a full commit id (40 lowercase hex)"
653 )))
654 }
655}
656
657#[cfg(test)]
658mod tests {
659 use super::*;
660
661 const SPEC: &str = "1a2b3c4d5e6f708192a3b4c5d6e7f8091a2b3c4d";
662 const HEAD: &str = "9f01cf32a3d4e5f60718293a4b5c6d7e8f901234";
663
664 #[test]
667 fn locked_path_matcher_matches_hive() {
668 for (pattern, path, under) in [
669 ("a/**/b", "a/b", true),
670 ("a/**/b", "a/c/b", true),
671 ("a/**/b", "a/c/d/b", true),
672 ("a/**/b", "a/xb", false),
673 ("a/**/b", "a/c/xb", false),
674 (
675 "crates/pay/tests/**/refund.rs",
676 "crates/pay/tests/xrefund.rs",
677 false,
678 ),
679 (
680 "crates/pay/tests/**/refund.rs",
681 "crates/pay/tests/refund.rs",
682 true,
683 ),
684 ("a/**", "a/b", true),
685 ("a/**", "a/b/c/d.rs", true),
686 ("a/**", "ab/c", false),
687 ("a/*.rs", "a/b.rs", true),
688 ("a/*.rs", "a/b/c.rs", false),
689 ("a/b.rs", "a/b.rs", true),
690 ("a/b", "a/b/c.rs", true),
691 ("a/b", "a/bc.rs", false),
692 ("**/x.rs", "x.rs", true),
694 ("**/x.rs", "a/x.rs", true),
695 ("**/x.rs", "ax.rs", false),
696 ("a/**", "a", false),
697 ("a/**/b/**/c", "a/b/c", true),
698 ("a/**/b/**/c", "a/x/b/y/z/c", true),
699 ("a/**/b/**/c", "a/xb/c", false),
700 ("a/**/b/**/c", "a/b/xc", false),
701 ("x/**/y.rs", "x/zy.rs", false),
702 ] {
703 assert_eq!(
704 under_locked(path, pattern),
705 under,
706 "{path:?} under {pattern:?}"
707 );
708 }
709 }
710
711 fn rerun(commit: &str) -> RerunV1 {
712 RerunV1 {
713 command: "cargo test -p pay refund".into(),
714 e2e_command: None,
715 host: "pounce".into(),
716 commit: commit.into(),
717 spec_commit: SPEC.into(),
718 exit_code: 0,
719 log_sha256: "fe".repeat(32),
720 started_at: "2026-11-05T09:50:00Z".into(),
721 finished_at: "2026-11-05T09:58:00Z".into(),
722 features: vec![FeatureResultV1 {
723 id: "F1".into(),
724 passes: true,
725 }],
726 report: Some(RerunReportV1 {
727 format: "libtest-text".into(),
728 sha256: "fe".repeat(32),
729 passed: 1,
730 failed: 0,
731 ignored: 0,
732 names_checked: vec![TestStatusV1 {
733 test: "refunds".into(),
734 status: "passed".into(),
735 }],
736 }),
737 }
738 }
739
740 fn ready_m() -> G6RecordV1 {
743 G6RecordV1 {
744 schema: GATE_RECORD_SCHEMA.into(),
745 process: PROCESS_VERSION.into(),
746 gate: "G6".into(),
747 risk: TaskRisk::M,
748 artifact_commit: HEAD.into(),
749 result: GateResult::Blocked,
750 missing: vec![MISSING_CI_PIPELINE.into()],
751 locked_paths: LockedPathsV1 {
752 spec_commit: SPEC.into(),
753 checked: vec![
754 "crates/pay/tests/refund.rs".into(),
755 "crates/pay/tests/refund/**".into(),
756 ],
757 ok: true,
758 changed: vec![],
759 },
760 rerun: Some(rerun(HEAD)),
761 rule: None,
762 reason: Some("the Instance does not read CI".into()),
763 }
764 }
765
766 #[test]
767 fn ready_m_record_and_its_rules() {
768 let record = ready_m();
769 record.validate().unwrap();
770 assert!(record.ready_for_hive() && record.judges(HEAD) && !record.judges(SPEC));
771
772 let mut pass = ready_m();
774 pass.result = GateResult::Pass;
775 pass.missing.clear();
776 pass.reason = None;
777 assert!(pass.validate().is_err() && !pass.ready_for_hive());
778
779 let mut nothing_missing = ready_m();
781 nothing_missing.missing.clear();
782 assert!(nothing_missing.validate().is_err());
783
784 let mut other = ready_m();
786 other.rerun = Some(rerun(SPEC));
787 assert!(other.validate().is_err() && !other.ready_for_hive());
788
789 let mut empty = ready_m();
791 empty.locked_paths.checked.clear();
792 assert!(empty.validate().is_err());
793
794 let mut red = ready_m();
796 red.rerun.as_mut().unwrap().exit_code = 101;
797 assert!(red.validate().is_err() && !red.ready_for_hive());
798 red.result = GateResult::Fail;
799 red.missing.clear();
800 red.reason = Some("cargo test exited 101".into());
801 red.validate().unwrap();
802 assert!(!red.ready_for_hive());
803
804 let mut unreported = ready_m();
807 unreported.rerun.as_mut().unwrap().report = None;
808 assert!(unreported.validate().is_err() && !unreported.ready_for_hive());
809
810 let mut some_failed = ready_m();
812 some_failed
813 .rerun
814 .as_mut()
815 .unwrap()
816 .report
817 .as_mut()
818 .unwrap()
819 .failed = 1;
820 assert!(some_failed.validate().is_err());
821
822 let mut ruled = ready_m();
824 ruled.rule = Some(RULE_LOCKED_PATHS_CHANGED.into());
825 assert!(ruled.validate().is_err());
826 }
827
828 #[test]
829 fn locked_path_failure_record() {
830 let mut record = ready_m();
831 record.result = GateResult::Fail;
832 record.missing.clear();
833 record.rerun = None;
834 record.rule = Some(RULE_LOCKED_PATHS_CHANGED.into());
835 record.reason = Some("crates/pay/tests/refund.rs modified".into());
836 record.locked_paths.ok = false;
837 record.locked_paths.changed = vec![ChangedPathV1 {
838 path: "crates/pay/tests/refund.rs".into(),
839 change: LockedChange::Modified,
840 }];
841 record.validate().unwrap();
842 assert!(!record.ready_for_hive());
843
844 let mut twice = record.clone();
845 twice.locked_paths.changed.push(ChangedPathV1 {
846 path: "crates/pay/tests/refund.rs".into(),
847 change: LockedChange::Deleted,
848 });
849 assert!(twice.validate().is_err(), "locked_paths_unique");
850
851 let mut unchecked = record.clone();
852 unchecked.locked_paths.changed[0].path = "src/other.rs".into();
853 assert!(
854 unchecked.validate().is_err(),
855 "locked_paths_changed_unchecked"
856 );
857
858 let mut not_ok = record.clone();
859 not_ok.locked_paths.ok = true;
860 assert!(not_ok.validate().is_err());
861
862 let mut blocked = record;
863 blocked.result = GateResult::Blocked;
864 blocked.missing = vec![MISSING_CI_PIPELINE.into()];
865 assert!(blocked.validate().is_err(), "a locked change is a fail");
866 }
867
868 #[test]
869 fn s_record_checks_nothing() {
870 let mut record = ready_m();
871 record.risk = TaskRisk::S;
872 record.locked_paths.checked.clear();
873 record.rerun.as_mut().unwrap().features.clear();
874 record.validate().unwrap();
875 assert!(record.ready_for_hive());
876
877 let mut with_features = record.clone();
878 with_features.rerun = Some(rerun(HEAD));
879 assert!(with_features.validate().is_err());
880
881 let mut with_check = record;
882 with_check.locked_paths.checked = vec!["tests/a.rs".into()];
883 assert!(with_check.validate().is_err());
884 }
885
886 fn hive_pass() -> serde_json::Value {
889 serde_json::json!({
890 "schema": "leanctx.hive.process.gate/v1", "process": "1.0",
891 "id": "gat_01JC0000000000000000000016", "gate": "G6",
892 "task": {"vault": "#acme/payments", "number": 42}, "risk": "M",
893 "stage_from": "test", "stage_to": "review",
894 "artifact": {"kind": "commit", "ref": HEAD},
895 "author": {"kind": "agent", "agent_id": "claude-cccccccccccccccccccc",
896 "worker_id": "wkr_cccccccccccccccccccc",
897 "instance_id": "6f2b9c4e-1a3d-4e5f-8a7b-0c1d2e3f4a5b", "role": "developer"},
898 "checker": {"kind": "hive", "check": "g6.rerun"},
899 "result": "pass",
900 "evidence": [{"kind": "rerun", "ref": "https://hive.example/ev/rerun/16",
901 "sha256": "fe".repeat(32)}],
902 "missing": [],
903 "rerun": {"command": "cargo test -p pay refund", "host": "pounce", "commit": HEAD,
904 "spec_commit": SPEC, "exit_code": 0, "log_sha256": "fe".repeat(32),
905 "started_at": "2026-11-05T09:50:00Z", "finished_at": "2026-11-05T09:58:00Z",
906 "features": [{"id": "F1", "passes": true}, {"id": "F2", "passes": true}],
907 "report": {"format": "libtest-text", "sha256": "fe".repeat(32), "passed": 2,
908 "failed": 0, "ignored": 0, "names_checked": [{"test": "refunds", "status": "passed"}]}},
909 "at": "2026-11-05T10:00:00Z",
910 "ci": {"provider": "gitlab", "project": 37, "repo": "leanctx/leanctx-hive",
911 "runner": "hive-ci", "pipeline_id": "4711",
912 "url": "https://gitlab.pounce.ch/leanctx/leanctx-hive/-/pipelines/4711",
913 "commit": HEAD, "status": "success",
914 "required_jobs": ["fmt", "clippy", "test-pg", "c0v2-fixtures", "cargo-deny", "web"],
915 "jobs": [{"name": "fmt", "status": "success"}, {"name": "clippy", "status": "success"},
916 {"name": "test-pg", "status": "success", "url": "https://x.example/j/1"},
917 {"name": "c0v2-fixtures", "status": "success"},
918 {"name": "cargo-deny", "status": "success"}, {"name": "web", "status": "success"},
919 {"name": "e2e", "status": "manual"}]},
920 "locked_paths": {"spec_commit": SPEC,
921 "checked": ["crates/pay/tests/refund.rs", "crates/pay/tests/refund/**",
922 "specs/42/features.json"],
923 "ok": true, "changed": []}
924 })
925 }
926
927 fn hive(value: serde_json::Value) -> HiveG6RecordV1 {
928 serde_json::from_value(value).expect("a contract-complete record parses")
929 }
930
931 #[test]
932 fn hive_record_is_green_only_with_green_ci_for_the_judged_commit() {
933 let record = hive(hive_pass());
934 assert_eq!(record.green(), Ok(()));
935 assert!(record.judges(HEAD) && !record.judges(SPEC));
936
937 let mut v = hive_pass();
939 v.as_object_mut().unwrap().remove("ci");
940 assert_eq!(hive(v).green(), Err(NotGreen::NoCi));
941
942 let red = |edit: &dyn Fn(&mut serde_json::Value)| {
943 let mut v = hive_pass();
944 edit(&mut v);
945 matches!(hive(v).green(), Err(NotGreen::Red(_)))
946 };
947 assert!(red(&|v| v["ci"]["commit"] = SPEC.into()));
949 assert!(red(&|v| v["ci"]["status"] = "failed".into()));
951 assert!(red(&|v| v["ci"]["status"] = "running".into()));
952 assert!(red(&|v| v["ci"]["jobs"][2]["status"] = "failed".into()));
954 assert!(red(&|v| v["ci"]["jobs"][0]["status"] = "skipped".into()));
955 assert!(red(&|v| {
956 v["ci"]["jobs"].as_array_mut().unwrap().remove(2);
957 }));
958 assert!(red(&|v| {
960 v["ci"]["required_jobs"] = serde_json::json!(["fmt"]);
961 v["ci"]["jobs"] = serde_json::json!([{"name": "fmt", "status": "success"}]);
962 }));
963 assert!(red(&|v| {
965 v["ci"] = serde_json::json!({"provider": "gitlab", "project": 37,
966 "repo": "leanctx/leanctx-hive", "commit": HEAD, "status": "missing",
967 "reason": "relay returned []"});
968 }));
969 assert!(red(&|v| {
970 v["result"] = "blocked".into();
971 v["missing"] = serde_json::json!(["ci_pipeline"]);
972 v["reason"] = "no pipeline".into();
973 }));
974 assert!(red(&|v| v["rerun"]["exit_code"] = 101.into()));
976 assert!(red(&|v| v["rerun"]["commit"] = SPEC.into()));
977 let mut none = hive_pass();
979 none["ci"] = serde_json::json!({"provider": "none", "repo": "leanctx/x",
980 "reason": "the repo declares no CI"});
981 assert_eq!(hive(none).green(), Ok(()));
982 }
983
984 #[test]
985 fn locked_paths_stay_in_the_repo() {
986 for bad in ["/etc/passwd", "../x", "a/../../b", "", "a\\b"] {
987 assert!(validate_repo_path(bad).is_err(), "{bad:?}");
988 }
989 for good in ["a", "a/b.rs", "a/**", "..a/b", "a/b../c"] {
990 validate_repo_path(good).unwrap();
991 }
992 let json = serde_json::to_value(LockedChange::TypeChanged).unwrap();
993 assert_eq!(json, "type_changed");
994 assert_eq!(LockedChange::OutsideRepo.as_str(), "outside_repo");
995 }
996}