lex_vcs/gate.rs
1//! Write-time type-check gate (#130).
2//!
3//! Wraps [`apply`](crate::apply::apply) with a type-checker pass.
4//! When this gate is the only path that advances a branch head,
5//! the store's invariant becomes "every accepted operation
6//! produces a program that typechecks." The cascading-breakage
7//! failure mode that breaks agentic workflows — agent A commits a
8//! typing-broken stage, agent B reads it and builds work
9//! assuming the broken stage, hours pass, CI catches the bug —
10//! becomes impossible by construction.
11//!
12//! Effect violations surface here too: `lex-types::check_program`
13//! reports an undeclared-effect call as a `TypeError` variant, so
14//! a single rejection envelope covers both type and effect bugs.
15//!
16//! ## Performance
17//!
18//! The gate runs `lex_types::check_program` against the *candidate*
19//! program — the sequence of `Stage`s that would exist after the
20//! op is applied. Computing that sequence is the caller's job
21//! (typically `Store::publish_program`, which already has it in
22//! memory). The gate itself does not load anything from disk; it
23//! just runs the type checker.
24//!
25//! Performance budget from #130: <50 ms p99 for a single-function
26//! op on a 1000-stage store. This module doesn't validate that —
27//! the budget belongs to the caller's candidate-assembly path
28//! plus `lex_types::check_program`. We'll measure once the gate
29//! is wired into a real `Store::publish_program` flow.
30
31use lex_ast::Stage;
32use lex_types::{check_program, TypeError};
33
34use crate::apply::{apply, ApplyError, NewHead};
35use crate::op_log::OpLog;
36use crate::operation::{OpId, Operation, StageTransition};
37
38#[derive(Debug, thiserror::Error)]
39pub enum GateError {
40 /// The operation's parent or merge structure is wrong.
41 /// Pass-through from [`ApplyError`]; same shape so callers
42 /// already handling stale-parent / unknown-merge-parent on the
43 /// raw apply path can keep their existing match arms.
44 #[error(transparent)]
45 Apply(#[from] ApplyError),
46 /// The candidate program — i.e. the state that would exist
47 /// after applying the op — doesn't typecheck. The op is *not*
48 /// persisted; the branch head is unchanged.
49 ///
50 /// `op_id` is the would-be op_id (computed before the apply
51 /// path persisted anything). Lets callers correlate the
52 /// rejection with the op they submitted, even though no
53 /// `<root>/ops/<op_id>.json` file exists.
54 ///
55 /// `errors` is the structured envelope `lex check` already
56 /// emits. Effect violations show up here as a `TypeError`
57 /// variant; the gate doesn't model them as a separate kind
58 /// because the type checker doesn't either.
59 #[error("type errors after applying op {op_id}: {} error(s)", errors.len())]
60 TypeError {
61 op_id: OpId,
62 errors: Vec<TypeError>,
63 },
64}
65
66/// Apply an operation only if the resulting candidate program
67/// typechecks. Otherwise return [`GateError::TypeError`] with the
68/// structured error envelope; nothing is persisted.
69///
70/// `candidate` is the sequence of `Stage`s that would exist after
71/// the op is applied. The caller computes it — typically by
72/// applying the op's [`StageTransition`] to the program it just
73/// loaded from source. The gate does not assemble it from the
74/// store; the cost of "load every stage" is on the caller, where
75/// it can be amortized across the full publish flow.
76pub fn check_and_apply(
77 op_log: &OpLog,
78 head_op: Option<&OpId>,
79 op: Operation,
80 transition: StageTransition,
81 candidate: &[Stage],
82) -> Result<NewHead, GateError> {
83 if let Err(errors) = check_program(candidate) {
84 return Err(GateError::TypeError {
85 op_id: op.op_id(),
86 errors,
87 });
88 }
89 Ok(apply(op_log, head_op, op, transition)?)
90}
91
92#[cfg(test)]
93mod tests {
94 use super::*;
95 use crate::operation::OperationKind;
96 use std::collections::BTreeSet;
97
98 fn fac_op_and_transition() -> (Operation, StageTransition) {
99 let op = Operation::new(
100 OperationKind::AddFunction {
101 sig_id: "fac".into(),
102 stage_id: "s1".into(),
103 effects: BTreeSet::new(),
104 budget_cost: None,
105 },
106 [],
107 );
108 let t = StageTransition::Create {
109 sig_id: "fac".into(),
110 stage_id: "s1".into(),
111 };
112 (op, t)
113 }
114
115 fn parse(src: &str) -> Vec<Stage> {
116 let prog = lex_syntax::parse_source(src).expect("parse");
117 lex_ast::canonicalize_program(&prog)
118 }
119
120 #[test]
121 fn clean_program_is_accepted_and_persisted() {
122 let tmp = tempfile::tempdir().unwrap();
123 let log = OpLog::open(tmp.path()).unwrap();
124 let candidate = parse(
125 "fn factorial(n :: Int) -> Int { match n { 0 => 1, _ => n * factorial(n - 1) } }\n",
126 );
127 let (op, t) = fac_op_and_transition();
128 let head = check_and_apply(&log, None, op, t, &candidate).unwrap();
129 assert!(log.get(&head.op_id).unwrap().is_some());
130 }
131
132 #[test]
133 fn type_error_is_rejected_and_nothing_persisted() {
134 let tmp = tempfile::tempdir().unwrap();
135 let log = OpLog::open(tmp.path()).unwrap();
136 // `not_defined` is referenced but never declared — the type
137 // checker emits an `UnknownIdentifier` error.
138 let candidate =
139 parse("fn broken(x :: Int) -> Int { not_defined(x) }\n");
140 let (op, t) = fac_op_and_transition();
141 let expected_op_id = op.op_id();
142 let err = check_and_apply(&log, None, op, t, &candidate)
143 .expect_err("expected TypeError");
144 match err {
145 GateError::TypeError { op_id, errors } => {
146 assert_eq!(op_id, expected_op_id);
147 assert!(!errors.is_empty(), "expected at least one TypeError");
148 }
149 other => panic!("expected TypeError, got {other:?}"),
150 }
151 // The op record was NOT persisted on the rejection path —
152 // the store's "always-valid HEAD" invariant holds.
153 assert!(log.get(&expected_op_id).unwrap().is_none());
154 }
155
156 #[test]
157 fn arity_mismatch_is_rejected() {
158 // Calling `add` with one arg when it takes two should
159 // produce an `ArityMismatch`. Verifies that several
160 // `TypeError` variants flow through the gate, not just
161 // unknown-identifier.
162 let tmp = tempfile::tempdir().unwrap();
163 let log = OpLog::open(tmp.path()).unwrap();
164 let candidate = parse(
165 "fn add(x :: Int, y :: Int) -> Int { x + y }\nfn caller() -> Int { add(1) }\n",
166 );
167 let (op, t) = fac_op_and_transition();
168 let err = check_and_apply(&log, None, op, t, &candidate)
169 .expect_err("expected TypeError");
170 assert!(matches!(err, GateError::TypeError { .. }));
171 }
172
173 #[test]
174 fn parent_check_still_runs_when_program_is_clean() {
175 // Pass a clean candidate but a stale-parent op. The gate
176 // shouldn't accept it just because typechecking passed —
177 // structural rejection still wins.
178 let tmp = tempfile::tempdir().unwrap();
179 let log = OpLog::open(tmp.path()).unwrap();
180 let candidate = parse(
181 "fn factorial(n :: Int) -> Int { match n { 0 => 1, _ => n * factorial(n - 1) } }\n",
182 );
183 // First op lands cleanly so head_op is set.
184 let (op1, t1) = fac_op_and_transition();
185 let head1 = check_and_apply(&log, None, op1, t1, &candidate).unwrap();
186 // Second op declares a wrong parent.
187 let bogus = Operation::new(
188 OperationKind::ModifyBody {
189 sig_id: "fac".into(),
190 from_stage_id: "s1".into(),
191 to_stage_id: "s2".into(),
192 from_budget: None,
193 to_budget: None,
194 },
195 ["someone-else".into()],
196 );
197 let t = StageTransition::Replace {
198 sig_id: "fac".into(),
199 from: "s1".into(),
200 to: "s2".into(),
201 };
202 let err = check_and_apply(&log, Some(&head1.op_id), bogus, t, &candidate)
203 .expect_err("expected Apply(StaleParent)");
204 match err {
205 GateError::Apply(ApplyError::StaleParent { .. }) => {}
206 other => panic!("expected Apply(StaleParent), got {other:?}"),
207 }
208 }
209}