Skip to main content

lean_ctx_protocol/hive/
gate.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! G6, the test gate of process v1 (leanctx-hive `docs/process-v1.md` §5,
4//! `contracts/process-v1/gate-record.schema.json`): what the Instance checks
5//! and records on submit (E47) and again before verify (E48).
6//!
7//! - [`LockedPathsV1`]: every locked path is byte-identical to `spec_commit`
8//!   in the judged commit; `ok` exactly when `changed` is empty, each path
9//!   once, each under a checked path ([`under_locked`]).
10//! - [`RerunV1`]: the gate's own run of the declared command at the judged
11//!   commit (`rerun.commit == artifact.ref`), never a worker's report.
12//! - [`G6RecordV1`]: both, as the Instance sends them, with
13//!   [`G6RecordV1::validate`] keeping the contract's rules
14//!   (`locked_paths_changed`, `locked_paths_unique`,
15//!   `locked_paths_changed_unchecked`, `locked_paths_spec_commit`,
16//!   `rerun_commit`, S: nothing checked and no feature results; M/H: a
17//!   non-empty check). The Instance does not read CI (§5.1), so its record
18//!   is never a `pass`: its best is `blocked` on `ci_pipeline`, which Hive
19//!   completes with `ci`.
20//! - [`HiveG6RecordV1`]: the G6 record Hive stores and serves (lenient: the
21//!   whole contract record), green only with `ci` for the judged commit and
22//!   every required job green (`ci_commit`, `ci_required_jobs`).
23//!
24//! The checks themselves run in the Instance (lean-ctx-hive `tasks::gate`),
25//! never through an agent's shell.
26
27use std::collections::BTreeSet;
28
29use serde::{Deserialize, Serialize};
30
31use crate::ValidationError;
32
33/// The gate record's schema id (process v1).
34pub const GATE_RECORD_SCHEMA: &str = "leanctx.hive.process.gate/v1";
35/// The process contract version a record names.
36pub const PROCESS_VERSION: &str = "1.0";
37/// The one rule a locked-path failure names.
38pub const RULE_LOCKED_PATHS_CHANGED: &str = "locked_paths_changed";
39/// At most this many checked or changed paths in one record.
40pub const MAX_LOCKED_PATHS: usize = 500;
41/// The evidence kind a G6 record without a pipeline misses (§5.1).
42pub const MISSING_CI_PIPELINE: &str = "ci_pipeline";
43/// The Postgres job every Hive Cloud (relay project 37) record requires.
44pub const HIVE_CLOUD_PROJECT: u64 = 37;
45pub const HIVE_CLOUD_PG_JOB: &str = "test-pg";
46
47/// A process task's risk level (§2).
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
49pub enum TaskRisk {
50    S,
51    M,
52    H,
53}
54
55impl TaskRisk {
56    /// M and H passed G2: they have a feature list and locked paths.
57    pub fn has_spec(self) -> bool {
58        matches!(self, Self::M | Self::H)
59    }
60}
61
62/// How a locked path differs from `spec_commit` (§5).
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
64#[serde(rename_all = "snake_case")]
65pub enum LockedChange {
66    /// Content or mode differs.
67    Modified,
68    /// A tracked file that `spec_commit` does not have.
69    Added,
70    /// Gone from the worktree.
71    Deleted,
72    /// A new file not in git.
73    Untracked,
74    /// Any type change: file ↔ symlink ↔ directory.
75    TypeChanged,
76    /// A path that was a symlink at `spec_commit` and now resolves outside
77    /// the repository.
78    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/// One differing locked path.
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct ChangedPathV1 {
97    pub path: String,
98    pub change: LockedChange,
99}
100
101/// `gate_record.locked_paths` (G6, required on every G6 record).
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct LockedPathsV1 {
104    pub spec_commit: String,
105    /// The locked paths compared: the feature list's at `spec_commit`
106    /// (M/H), `[]` for a task without G2 (S).
107    pub checked: Vec<String>,
108    pub ok: bool,
109    pub changed: Vec<ChangedPathV1>,
110}
111
112impl LockedPathsV1 {
113    /// `ok` exactly when nothing changed; each path once and under a
114    /// checked path; nothing checked means nothing changed.
115    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/// One feature's result from the gate's own run.
151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
152pub struct FeatureResultV1 {
153    pub id: String,
154    pub passes: bool,
155}
156
157/// One locked test with the status the harness report gives it (§5,
158/// bb1a533e): `passed`, `failed`, `ignored`, or `not_run` (absent).
159#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct TestStatusV1 {
161    pub test: String,
162    pub status: String,
163}
164
165/// `gate_record.rerun.report`: what the test harness itself reported, so a
166/// green `exit_code` from code that ended the test binary early does not
167/// count (§5, the contract follow-up). `format` is `libtest-json` or
168/// `libtest-text`; `names_checked` lists every locked test the feature
169/// list names with its reported status (empty for an S task).
170#[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/// `gate_record.rerun`: the gate's own run of the locked tests.
181#[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    /// The judged commit (rule `rerun_commit`).
188    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    /// The harness's own result report (§5): required for a pass.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub report: Option<RerunReportV1>,
198}
199
200/// A gate's result.
201#[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/// The G6 part of a gate record as the Instance builds and sends it: the
210/// judged commit, the locked-path check and the gate's own rerun. Hive adds
211/// what only it knows (id, task, author, checker, `ci`) when it stores the
212/// record.
213///
214/// The Instance does not read CI (§5.1: "a pass needs `status: success`
215/// and every `required_jobs` entry green"), so this record is never a
216/// `pass`: a clean check with a green rerun is `blocked` with `missing:
217/// [ci_pipeline]` ([`G6RecordV1::ready_for_hive`]), and only Hive's record
218/// with `ci` ([`HiveG6RecordV1`]) can pass.
219#[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    /// `artifact.ref`: the judged commit (40 lowercase hex).
227    pub artifact_commit: String,
228    pub result: GateResult,
229    /// For `blocked`: the evidence kinds that were required and absent.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub missing: Vec<String>,
232    pub locked_paths: LockedPathsV1,
233    /// Absent only on a locked-path failure: the gate does not run tests
234    /// against changed locked paths.
235    #[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    /// Whether everything the Instance checks holds, so the record goes to
245    /// Hive with the result: a valid record with a clean locked-path check
246    /// and a green rerun of the judged commit, blocked only on the CI
247    /// pipeline Hive reads. Never a pass by itself.
248    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    /// Whether this record judged `commit`.
257    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/// The rules every G6 record keeps, the Instance's and Hive's alike.
291#[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            // An M/H pass or blocked-for-CI record carries the harness's
371            // own report (§5): an exit code alone is no evidence there. An
372            // S task's declared command may have none (not a harness).
373            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            // When the harness reported, a pass or a blocked-for-CI record
379            // needs nothing failed (§5).
380            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/// `artifact` of a stored gate record: what was judged.
391#[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/// One CI job of a pipeline (§5.1).
399#[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/// `gate_record.ci` (§5.1, `gate-record.schema.json#/$defs/ci`) in its
408/// three forms: a pipeline, no pipeline yet (`status: missing`), or no CI
409/// (`provider: none`). Response type: unknown fields are accepted.
410#[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    /// Whether this CI part lets a pass through for `artifact`: no CI by
433    /// the repo's declaration (with a reason), or a successful pipeline for
434    /// exactly `artifact` with every required job present and successful
435    /// (`ci_commit`, `ci_required_jobs`; project 37 always requires
436    /// `test-pg`).
437    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/// The G6 record Hive stores and serves for a task (`process.g6`): the
482/// whole contract record, read leniently (unknown fields accepted), so a
483/// contract-complete record with `id`, `task`, `author`, `checker`,
484/// `evidence` and `at` parses. Only [`HiveG6RecordV1::green`] lets a
485/// verify on.
486#[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/// Why a stored G6 record does not let a verify on.
508#[derive(Debug, Clone, PartialEq, Eq)]
509pub enum NotGreen {
510    /// The record carries no `ci`: this Hive does not do §5.1 yet.
511    NoCi,
512    /// Anything else, with the reason.
513    Red(String),
514}
515
516impl HiveG6RecordV1 {
517    /// The judged commit.
518    pub fn artifact_commit(&self) -> &str {
519        &self.artifact.reference
520    }
521
522    /// Whether this record judged `commit`.
523    pub fn judges(&self, commit: &str) -> bool {
524        self.artifact.kind == "commit" && self.artifact.reference == commit
525    }
526
527    /// A pass of a valid G6 record: clean locked paths, a green rerun of
528    /// the judged commit, nothing missing, and a green `ci` for it.
529    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/// The process part of a task view, as far as G6 needs it (process v1
565/// `task-process.schema.json`, its feature list and the repo's CI
566/// declaration). A task view without it is not a process task. Response
567/// type: unknown fields are accepted.
568///
569/// No Hive serves this yet; an engine meeting a process task whose view
570/// lacks what G6 needs refuses the submit or verify ("not supported by Hive
571/// yet"), it never judges with less.
572#[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    /// M/H: the commit G2 pinned the feature list at.
578    #[serde(default, skip_serializing_if = "Option::is_none")]
579    pub spec_commit: Option<String>,
580    /// The commit G4 passed (the one G6 and G7 judge).
581    #[serde(default, skip_serializing_if = "Option::is_none")]
582    pub candidate_commit: Option<String>,
583    /// M/H: the feature list's `locked_paths` at `spec_commit`.
584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
585    pub locked_paths: Vec<String>,
586    /// M/H: the feature list's `test_command`.
587    #[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    /// M/H: the feature list's feature ids.
592    #[serde(default, skip_serializing_if = "Vec::is_empty")]
593    pub features: Vec<String>,
594    /// S: the repo's declared `test_command` (`repo-ci.schema.json`).
595    #[serde(default, skip_serializing_if = "Option::is_none")]
596    pub repo_test_command: Option<String>,
597    /// The newest G6 record Hive stored for the task.
598    #[serde(default, skip_serializing_if = "Option::is_none")]
599    pub g6: Option<HiveG6RecordV1>,
600}
601
602/// `path` lies under the locked path `pattern`: the path itself, a file
603/// below it, or a glob match (`*` within one segment; `**/` zero or more
604/// whole segments; a trailing `**` everything below). The same matcher as
605/// leanctx-hive `under_locked` (routes/tests_process_v1.rs, process v1 §5).
606pub fn under_locked(path: &str, pattern: &str) -> bool {
607    fn glob(p: &[u8], s: &[u8]) -> bool {
608        match p {
609            [] => s.is_empty(),
610            // `**/`: zero or more whole segments, so `rest` starts only at the
611            // beginning or right after a `/` (a/**/b: a/b, a/c/b, never a/xb).
612            [b'*', b'*', b'/', rest @ ..] => {
613                (0..=s.len()).any(|i| (i == 0 || s[i - 1] == b'/') && glob(rest, &s[i..]))
614            }
615            // A trailing `**` (or `**` inside a segment): anything, `/` included.
616            [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
630/// A locked path is repository-relative: not absolute, no `..` segment
631/// (`feature-list.schema.json#/$defs/repo_path`).
632pub 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    /// The cases of leanctx-hive `process_v1_locked_path_matcher`, one for
665    /// one (leanctx-hive main 0c5a4291), plus the trace cases of its review.
666    #[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            // Review r4 traces.
693            ("**/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    /// What the Instance sends for a clean check with a green rerun:
741    /// blocked on the CI pipeline only Hive reads.
742    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        // The Instance never writes a pass: it does not read CI (§5.1).
773        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        // Blocked lists what is missing.
780        let mut nothing_missing = ready_m();
781        nothing_missing.missing.clear();
782        assert!(nothing_missing.validate().is_err());
783
784        // rerun_commit: a run of another commit counts for nothing.
785        let mut other = ready_m();
786        other.rerun = Some(rerun(SPEC));
787        assert!(other.validate().is_err() && !other.ready_for_hive());
788
789        // M with nothing checked is refused (review r2 finding 1).
790        let mut empty = ready_m();
791        empty.locked_paths.checked.clear();
792        assert!(empty.validate().is_err());
793
794        // A red rerun is a fail, never blocked or a pass.
795        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        // An M/H record that is not a fail carries the harness report
805        // (review r5, R5-3); a fail may lack it.
806        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        // A report with failed tests is never a pass or blocked (§5).
811        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        // The rule appears only on a locked-path failure.
823        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    /// leanctx-hive `fixtures/valid/gate.G6.pass.json` at leanctx-hive main 0c5a4291: the
887    /// whole contract record, as Hive stores it.
888    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        // No ci: this Hive does not do §5.1 yet.
938        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        // ci_commit: a green pipeline for the parent counts for nothing.
948        assert!(red(&|v| v["ci"]["commit"] = SPEC.into()));
949        // A red or running pipeline.
950        assert!(red(&|v| v["ci"]["status"] = "failed".into()));
951        assert!(red(&|v| v["ci"]["status"] = "running".into()));
952        // ci_required_jobs: a required job failed, skipped or absent.
953        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        // Project 37 always requires test-pg.
959        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        // No pipeline yet: blocked, never a pass.
964        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        // The Instance's own record is no Hive pass either.
975        assert!(red(&|v| v["rerun"]["exit_code"] = 101.into()));
976        assert!(red(&|v| v["rerun"]["commit"] = SPEC.into()));
977        // A repo without CI by declaration: the rerun is the evidence.
978        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}