Skip to main content

batty_cli/team/
spec_gen.rs

1//! Clean-room behavior spec validation, handoff discovery, and parity syncing.
2
3use std::collections::HashMap;
4use std::fmt::Write as _;
5use std::path::{Path, PathBuf};
6
7use anyhow::{Context, Result, bail};
8
9use crate::team::parity::{
10    ParityMetadata, ParityReport, ParityRow, ParityStatus, VerificationStatus,
11};
12
13const SPEC_ROOT: &str = "specs";
14const SPEC_FILENAME: &str = "SPEC.md";
15
16#[derive(Debug, Clone, PartialEq, Eq)]
17pub struct BehaviorSpec {
18    pub behavior: String,
19    pub relative_path: PathBuf,
20    pub content: String,
21}
22
23pub fn load_behavior_specs(project_root: &Path) -> Result<Vec<BehaviorSpec>> {
24    let specs_root = project_root.join(SPEC_ROOT);
25    if !specs_root.is_dir() {
26        return Ok(Vec::new());
27    }
28
29    let mut files = Vec::new();
30    collect_spec_files(&specs_root, &mut files)?;
31    files.sort();
32
33    let mut specs = Vec::new();
34    for path in files {
35        let content = std::fs::read_to_string(&path)
36            .with_context(|| format!("failed to read {}", path.display()))?;
37        validate_spec_content(&content)
38            .with_context(|| format!("invalid clean-room spec {}", path.display()))?;
39        let behavior = extract_behavior_name(&content)
40            .with_context(|| format!("missing behavior heading in {}", path.display()))?;
41        let relative_path = path
42            .strip_prefix(project_root)
43            .with_context(|| format!("{} is not under {}", path.display(), project_root.display()))?
44            .to_path_buf();
45        specs.push(BehaviorSpec {
46            behavior,
47            relative_path,
48            content,
49        });
50    }
51
52    Ok(specs)
53}
54
55pub fn sync_specs_to_parity(project_root: &Path, specs: &[BehaviorSpec]) -> Result<bool> {
56    if specs.is_empty() {
57        return Ok(false);
58    }
59
60    let parity_path = project_root.join("PARITY.md");
61    let existing = std::fs::read_to_string(&parity_path)
62        .with_context(|| format!("failed to read {}", parity_path.display()))?;
63    let mut report = ParityReport::parse(&existing)
64        .with_context(|| format!("failed to parse {}", parity_path.display()))?;
65
66    let mut changed = false;
67    let mut row_index: HashMap<String, usize> = report
68        .rows
69        .iter()
70        .enumerate()
71        .map(|(index, row)| (row.behavior.clone(), index))
72        .collect();
73
74    for spec in specs {
75        if let Some(index) = row_index.get(&spec.behavior).copied() {
76            let row = &mut report.rows[index];
77            if row.spec != ParityStatus::Complete {
78                row.spec = ParityStatus::Complete;
79                changed = true;
80            }
81            let note = format!("spec: {}", spec.relative_path.display());
82            if row.notes != note {
83                row.notes = note;
84                changed = true;
85            }
86            continue;
87        }
88
89        report.rows.push(ParityRow {
90            behavior: spec.behavior.clone(),
91            spec: ParityStatus::Complete,
92            test: ParityStatus::NotStarted,
93            implementation: ParityStatus::NotStarted,
94            verified: VerificationStatus::NotStarted,
95            notes: format!("spec: {}", spec.relative_path.display()),
96        });
97        let index = report.rows.len() - 1;
98        row_index.insert(report.rows[index].behavior.clone(), index);
99        changed = true;
100    }
101
102    if !changed {
103        return Ok(false);
104    }
105
106    let updated = render_parity_report(&report);
107    std::fs::write(&parity_path, updated)
108        .with_context(|| format!("failed to write {}", parity_path.display()))?;
109    Ok(true)
110}
111
112pub fn validate_spec_content(content: &str) -> Result<()> {
113    let behavior = extract_behavior_name(content)?;
114    if behavior.trim().is_empty() {
115        bail!("behavior heading must not be empty");
116    }
117
118    let required_sections = [
119        "## Purpose",
120        "## Inputs",
121        "## Outputs",
122        "## State Transitions",
123        "## Edge Cases",
124        "## Acceptance Criteria",
125    ];
126    for section in required_sections {
127        if !content.contains(section) {
128            bail!("missing required section '{section}'");
129        }
130    }
131
132    let lower = content.to_ascii_lowercase();
133    for forbidden in [
134        "register",
135        "opcode",
136        "instruction",
137        "address",
138        "memory address",
139        "decompiled",
140        "disassembly",
141        "0x",
142    ] {
143        if lower.contains(forbidden) {
144            bail!("contains forbidden implementation detail '{forbidden}'");
145        }
146    }
147
148    for register in ["AF", "BC", "DE", "HL", "IX", "IY", "SP", "PC"] {
149        if content
150            .split(|ch: char| !ch.is_ascii_alphanumeric())
151            .any(|token| token == register)
152        {
153            bail!("contains forbidden register name '{register}'");
154        }
155    }
156
157    Ok(())
158}
159
160fn extract_behavior_name(content: &str) -> Result<String> {
161    let heading = content
162        .lines()
163        .find_map(|line| line.strip_prefix("# Behavior:"))
164        .map(str::trim)
165        .filter(|line| !line.is_empty())
166        .context("spec must start with '# Behavior: <name>'")?;
167    Ok(heading.to_string())
168}
169
170fn collect_spec_files(dir: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
171    for entry in
172        std::fs::read_dir(dir).with_context(|| format!("failed to read {}", dir.display()))?
173    {
174        let entry = entry?;
175        let path = entry.path();
176        if path.is_dir() {
177            collect_spec_files(&path, files)?;
178            continue;
179        }
180        if path.file_name().and_then(|name| name.to_str()) == Some(SPEC_FILENAME) {
181            files.push(path);
182        }
183    }
184    Ok(())
185}
186
187fn render_parity_report(report: &ParityReport) -> String {
188    let mut output = String::new();
189    output.push_str("---\n");
190    render_metadata(&mut output, &report.metadata);
191    output.push_str("---\n\n");
192    output.push_str("| Behavior | Spec | Test | Implementation | Verified | Notes |\n");
193    output.push_str("| --- | --- | --- | --- | --- | --- |\n");
194    for row in &report.rows {
195        let _ = writeln!(
196            output,
197            "| {} | {} | {} | {} | {} | {} |",
198            row.behavior, row.spec, row.test, row.implementation, row.verified, row.notes
199        );
200    }
201    output
202}
203
204fn render_metadata(output: &mut String, metadata: &ParityMetadata) {
205    let _ = writeln!(output, "project: {}", metadata.project);
206    let _ = writeln!(output, "target: {}", metadata.target);
207    let _ = writeln!(output, "source_platform: {}", metadata.source_platform);
208    let _ = writeln!(output, "target_language: {}", metadata.target_language);
209    let _ = writeln!(output, "last_verified: {}", metadata.last_verified);
210    if let Some(overall_parity) = &metadata.overall_parity {
211        let _ = writeln!(output, "overall_parity: {}", overall_parity);
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    const VALID_SPEC: &str = r#"# Behavior: Player movement
220
221## Purpose
222
223Describe how the player moves in response to directional input.
224
225## Inputs
226
227- Directional control input.
228
229## Outputs
230
231- The visible player position changes on screen.
232
233## State Transitions
234
235- Movement begins after input and stops when input ends.
236
237## Edge Cases
238
239- Movement is blocked by solid obstacles.
240
241## Acceptance Criteria
242
243- Given a move input, the player advances exactly one walk step per tick.
244"#;
245
246    const PARITY: &str = r#"---
247project: manic-miner
248target: analysis-artifacts
249source_platform: zx-spectrum
250target_language: rust
251last_verified: pending
252overall_parity: 0%
253---
254
255| Behavior | Spec | Test | Implementation | Verified | Notes |
256| --- | --- | --- | --- | --- | --- |
257| Startup behavior | -- | -- | -- | -- | |
258| Player movement | draft | -- | -- | -- | stale |
259"#;
260
261    #[test]
262    fn validate_spec_rejects_implementation_leaks() {
263        let leaked = VALID_SPEC.replace(
264            "Describe how the player moves in response to directional input.",
265            "Describe how the player moves using register HL and opcode 0x7E.",
266        );
267        let err = validate_spec_content(&leaked).unwrap_err().to_string();
268        assert!(err.contains("forbidden"));
269    }
270
271    #[test]
272    fn load_behavior_specs_discovers_nested_spec_files() {
273        let tmp = tempfile::tempdir().unwrap();
274        let spec_path = tmp.path().join("specs/player-movement/SPEC.md");
275        std::fs::create_dir_all(spec_path.parent().unwrap()).unwrap();
276        std::fs::write(&spec_path, VALID_SPEC).unwrap();
277
278        let specs = load_behavior_specs(tmp.path()).unwrap();
279        assert_eq!(specs.len(), 1);
280        assert_eq!(specs[0].behavior, "Player movement");
281        assert_eq!(
282            specs[0].relative_path,
283            PathBuf::from("specs/player-movement/SPEC.md")
284        );
285    }
286
287    #[test]
288    fn sync_specs_to_parity_marks_specs_complete_and_adds_rows() {
289        let tmp = tempfile::tempdir().unwrap();
290        std::fs::write(tmp.path().join("PARITY.md"), PARITY).unwrap();
291
292        let specs = vec![
293            BehaviorSpec {
294                behavior: "Player movement".to_string(),
295                relative_path: PathBuf::from("specs/player-movement/SPEC.md"),
296                content: VALID_SPEC.to_string(),
297            },
298            BehaviorSpec {
299                behavior: "Collision detection".to_string(),
300                relative_path: PathBuf::from("specs/collision-detection/SPEC.md"),
301                content: VALID_SPEC.replace("Player movement", "Collision detection"),
302            },
303        ];
304
305        let changed = sync_specs_to_parity(tmp.path(), &specs).unwrap();
306        assert!(changed);
307
308        let updated = std::fs::read_to_string(tmp.path().join("PARITY.md")).unwrap();
309        let report = ParityReport::parse(&updated).unwrap();
310        let movement = report
311            .rows
312            .iter()
313            .find(|row| row.behavior == "Player movement")
314            .unwrap();
315        assert_eq!(movement.spec, ParityStatus::Complete);
316        assert_eq!(movement.notes, "spec: specs/player-movement/SPEC.md");
317
318        let collision = report
319            .rows
320            .iter()
321            .find(|row| row.behavior == "Collision detection")
322            .unwrap();
323        assert_eq!(collision.spec, ParityStatus::Complete);
324        assert_eq!(collision.test, ParityStatus::NotStarted);
325    }
326}