1use std::collections::BTreeMap;
17use std::path::Path;
18
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22pub const SCHEMA: &str = "leanctx.hive.process.feature-list/v1";
24pub const MAX_FILE_BYTES: u64 = 512 * 1024;
26const MAX_FEATURES: usize = 200;
27
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(deny_unknown_fields)]
31pub struct Feature {
32 pub id: String,
33 pub description: String,
34 pub steps: Vec<String>,
35 pub tests: Vec<String>,
36 pub passes: bool,
37}
38
39#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41#[serde(deny_unknown_fields)]
42pub struct FeatureList {
43 pub schema: String,
44 pub process: String,
45 pub task: Value,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub spec_commit: Option<String>,
48 pub test_command: String,
49 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub e2e_command: Option<String>,
51 pub locked_paths: Vec<String>,
52 #[serde(default, skip_serializing_if = "Option::is_none")]
53 pub unlocks: Option<Vec<Value>>,
54 pub features: Vec<Feature>,
55}
56
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
59pub enum Writer<'a> {
60 Gate,
62 Role(&'a str),
64}
65
66impl FeatureList {
67 pub fn parse(text: &str) -> Result<Self, String> {
69 let list: Self =
70 serde_json::from_str(text).map_err(|e| format!("feature list is not valid: {e}"))?;
71 list.check()?;
72 Ok(list)
73 }
74
75 pub fn load(path: &Path) -> Result<Self, String> {
77 let meta = std::fs::metadata(path)
78 .map_err(|e| format!("feature list {} unreadable: {e}", path.display()))?;
79 if meta.len() > MAX_FILE_BYTES {
80 return Err(format!(
81 "feature list {} is larger than {MAX_FILE_BYTES} bytes",
82 path.display()
83 ));
84 }
85 let text = std::fs::read_to_string(path)
86 .map_err(|e| format!("feature list {} unreadable: {e}", path.display()))?;
87 Self::parse(&text)
88 }
89
90 pub fn is_locked(&self) -> bool {
92 self.spec_commit.is_some()
93 }
94
95 fn check(&self) -> Result<(), String> {
96 if self.schema != SCHEMA {
97 return Err(format!(
98 "feature list schema must be {SCHEMA} (got {})",
99 self.schema
100 ));
101 }
102 if !is_process_version(&self.process) {
103 return Err(format!(
104 "feature list process version must be 1.<minor> (got {})",
105 self.process
106 ));
107 }
108 check_task_ref(&self.task)?;
109 if let Some(sha) = &self.spec_commit
110 && !is_git_sha(sha)
111 {
112 return Err(format!(
113 "spec_commit must be a 40-character git sha (got {sha})"
114 ));
115 }
116 if self.test_command.trim().is_empty() || !len_in(&self.test_command, 1, 2000) {
117 return Err("feature list test_command must hold 1 to 2000 characters".into());
118 }
119 if let Some(e2e) = &self.e2e_command
120 && !len_in(e2e, 1, 2000)
121 {
122 return Err("feature list e2e_command must hold 1 to 2000 characters".into());
123 }
124 if self.locked_paths.is_empty() {
125 return Err("feature list names no locked_paths".into());
126 }
127 let mut unique = std::collections::BTreeSet::new();
128 for path in &self.locked_paths {
129 if !is_repo_relative(path) || !len_in(path, 1, 500) {
130 return Err(format!(
131 "locked path must be repo-relative without '..', at most 500 characters: {path}"
132 ));
133 }
134 if !unique.insert(path.as_str()) {
135 return Err(format!("locked path {path} appears twice"));
136 }
137 }
138 for unlock in self.unlocks.iter().flatten() {
139 check_unlock(unlock)?;
140 }
141 if self.features.is_empty() || self.features.len() > MAX_FEATURES {
142 return Err(format!(
143 "feature list must hold 1 to {MAX_FEATURES} features"
144 ));
145 }
146 let mut seen = std::collections::BTreeSet::new();
147 for feature in &self.features {
148 if !is_feature_id(&feature.id) {
149 return Err(format!("feature id must match F<digits>: {}", feature.id));
150 }
151 if !seen.insert(feature.id.as_str()) {
152 return Err(format!("feature id {} appears twice", feature.id));
153 }
154 if feature.steps.is_empty() || feature.tests.is_empty() {
155 return Err(format!("feature {} needs steps and tests", feature.id));
156 }
157 if !len_in(&feature.description, 1, 2000)
158 || feature.steps.iter().any(|s| !len_in(s, 1, 1000))
159 || feature.tests.iter().any(|t| !len_in(t, 1, 500))
160 {
161 return Err(format!(
162 "feature {}: description 1-2000, steps 1-1000, tests 1-500 characters",
163 feature.id
164 ));
165 }
166 }
167 Ok(())
168 }
169}
170
171fn len_in(text: &str, min: usize, max: usize) -> bool {
172 (min..=max).contains(&text.chars().count())
173}
174
175pub(crate) fn is_process_version(raw: &str) -> bool {
177 let Some(minor) = raw.strip_prefix("1.") else {
178 return false;
179 };
180 minor == "0"
181 || (!minor.is_empty()
182 && minor.len() <= 4
183 && !minor.starts_with('0')
184 && minor.bytes().all(|b| b.is_ascii_digit()))
185}
186
187fn is_git_sha(raw: &str) -> bool {
188 raw.len() == 40
189 && raw
190 .bytes()
191 .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
192}
193
194fn matches(pattern: &str, raw: &str) -> bool {
195 regex::Regex::new(pattern).is_ok_and(|re| re.is_match(raw))
196}
197
198fn only_keys(object: &serde_json::Map<String, Value>, allowed: &[&str]) -> bool {
199 object.keys().all(|k| allowed.contains(&k.as_str()))
200}
201
202fn check_task_ref(task: &Value) -> Result<(), String> {
204 let ok = task.as_object().is_some_and(|t| {
205 only_keys(t, &["vault", "number"])
206 && t.get("vault")
207 .and_then(Value::as_str)
208 .is_some_and(|v| matches(r"^#[a-z0-9_-]{1,64}/[a-z0-9-]{2,40}$", v))
209 && t.get("number")
210 .and_then(Value::as_i64)
211 .is_some_and(|n| (1..=2_147_483_647).contains(&n))
212 });
213 if ok {
214 Ok(())
215 } else {
216 Err("feature list task must be {vault: \"#workspace/vault\", number: 1..}".into())
217 }
218}
219
220const UUID: &str = r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
221
222fn is_actor(actor: &Value) -> bool {
224 let Some(a) = actor.as_object() else {
225 return false;
226 };
227 let text = |key: &str| a.get(key).and_then(Value::as_str);
228 match text("kind") {
229 Some("agent") => {
230 only_keys(
231 a,
232 &[
233 "kind",
234 "agent_id",
235 "worker_id",
236 "instance_id",
237 "role",
238 "queen",
239 ],
240 ) && text("agent_id")
241 .is_some_and(|v| matches(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$", v))
242 && text("instance_id").is_some_and(|v| matches(UUID, v))
243 && text("role").is_some_and(|r| crate::core::session_rights::ROLE_IDS.contains(&r))
244 && a.get("worker_id").is_none_or(|w| {
245 w.as_str()
246 .is_some_and(|v| matches(r"^wkr_[a-z2-7]{20}$", v))
247 })
248 && a.get("queen").is_none_or(Value::is_boolean)
249 }
250 Some("person") => {
251 only_keys(a, &["kind", "person_id", "session"])
252 && text("person_id").is_some_and(|v| matches(UUID, v))
253 && text("session") == Some("human")
254 }
255 _ => false,
256 }
257}
258
259fn check_unlock(unlock: &Value) -> Result<(), String> {
261 let ok = unlock.as_object().is_some_and(|u| {
262 only_keys(u, &["by", "decision", "at"])
263 && u.get("by").is_some_and(is_actor)
264 && u.get("decision")
265 .and_then(Value::as_str)
266 .is_some_and(|d| matches(r"^dec_[0-9A-HJKMNP-TV-Z]{26}$", d))
267 && u.get("at").and_then(Value::as_str).is_some_and(|t| {
268 matches(
269 r"^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]{1,6})?Z$",
270 t,
271 )
272 })
273 });
274 if ok {
275 Ok(())
276 } else {
277 Err("feature list unlocks[] must be {by: actor, decision: dec_…, at: UTC timestamp}".into())
278 }
279}
280
281pub fn is_repo_relative(path: &str) -> bool {
283 !path.is_empty()
284 && !path.starts_with('/')
285 && !path.starts_with('\\')
286 && !path.split(['/', '\\']).any(|part| part == "..")
287}
288
289fn is_feature_id(id: &str) -> bool {
290 let digits = id.strip_prefix('F').unwrap_or("");
291 (1..=4).contains(&digits.len()) && digits.bytes().all(|b| b.is_ascii_digit())
292}
293
294pub fn validate_transition(
300 before: &FeatureList,
301 after: &FeatureList,
302 writer: Writer<'_>,
303) -> Result<(), String> {
304 if !before.is_locked() {
305 return match writer {
306 Writer::Role("spec_writer") | Writer::Gate => Ok(()),
307 Writer::Role(role) => Err(format!(
308 "feature_list_locked: the feature list is written by the Spec Writer until G2, not by role '{role}'"
309 )),
310 };
311 }
312 let mut before_rest = before.clone();
313 let mut after_rest = after.clone();
314 let passes_changed = before.features.len() == after.features.len()
315 && before
316 .features
317 .iter()
318 .zip(&after.features)
319 .any(|(a, b)| a.passes != b.passes);
320 for feature in before_rest
321 .features
322 .iter_mut()
323 .chain(after_rest.features.iter_mut())
324 {
325 feature.passes = false;
326 }
327 if before_rest != after_rest {
328 return Err(
329 "feature_list_locked: after G2 only features[].passes may change; any other change \
330 is a spec change and sends the task back to spec"
331 .into(),
332 );
333 }
334 if passes_changed && writer != Writer::Gate {
335 let role = match writer {
336 Writer::Role(role) => role,
337 Writer::Gate => "",
338 };
339 return Err(format!(
340 "feature_list_locked: passes is written only by the G6 gate from its own re-run, not by role '{role}'"
341 ));
342 }
343 Ok(())
344}
345
346pub fn apply_gate_passes(path: &Path, results: &BTreeMap<String, bool>) -> Result<(), String> {
350 let before = FeatureList::load(path)?;
351 if !before.is_locked() {
352 return Err(
353 "feature_list_locked: the gate writes passes only after G2 (spec_commit unset)".into(),
354 );
355 }
356 let mut after = before.clone();
357 for feature in &mut after.features {
358 let Some(&passes) = results.get(&feature.id) else {
359 return Err(format!(
360 "gate result for feature {} is missing; the gate sets every feature",
361 feature.id
362 ));
363 };
364 feature.passes = passes;
365 }
366 if results.len() != after.features.len() {
367 return Err("gate result names a feature the list does not hold".into());
368 }
369 validate_transition(&before, &after, Writer::Gate)?;
370 let mut text = serde_json::to_string_pretty(&after).map_err(|e| e.to_string())?;
371 text.push('\n');
372 let tmp = path.with_extension("json.gate-tmp");
373 std::fs::write(&tmp, text).map_err(|e| format!("gate write failed: {e}"))?;
374 std::fs::rename(&tmp, path).map_err(|e| {
375 let _ = std::fs::remove_file(&tmp);
376 format!("gate write failed: {e}")
377 })
378}