pleme-doc-gen 0.1.40

Rust replacement for the M0 Python _gen-patterns.py + _gen-docs.py scripts in pleme-io/actions. Walks every action.yml + emits substrate's patterns-full.nix + per-action README.md + root catalog. Per the NO-SHELL prime directive.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
//! caixa-forge OSS conversion — typed one-shot primitive.
//!
//! Per the ★★ CLOSED-LOOP MASS-SYNTHESIS directive (Rule 2 — closed-
//! loop primitive composition at the CLI layer), this module collapses
//! the 6-step agent workflow documented in
//! `blackmatter-pleme/skills/caixa-oss-conversion-agent/SKILL.md` into
//! a SINGLE typed substrate call: `pleme-doc-gen convert --source X
//! .ossconv.lisp`.
//!
//! The 6 steps run internally:
//!   1. PARSE the (defossconv …) typed form
//!   2. DISCOVER via discover::detect_github_url
//!   3. GATE A — ecosystem matches :expect-ecosystem
//!   4. FORGE via scaffold::build + caixa::render
//!   5. GATE B — autobump-wired + spec-persisted + substrate-ref
//!   6. PUBLISH per :publish-mode (deferred: :public + :private require
//!      `gh repo create + push`; this primitive ships :dry-run first)
//!
//! Output: typed JSON attestation via json_ast::Value (no format!()
//! of JSON syntax — dogfoods the prime directive at the attestation
//! emit layer too).

use anyhow::{anyhow, bail, Context, Result};
use std::path::Path;

/// Typed view over a parsed (defossconv …) form. Operators author the
/// .ossconv.lisp source by hand; agents read it via parse_str().
#[derive(Debug, Clone)]
pub struct OssConvSpec {
    pub conversion: String,         // (defossconv NAME …) — symbol after defossconv
    pub upstream: String,           // :upstream "owner/repo"
    pub wrapper: String,            // :wrapper "name"
    pub expect_ecosystem: String,   // :expect-ecosystem :rust-workspace
    pub description: Option<String>,
    pub license: Option<String>,
    pub version: Option<String>,
    pub publish_org: String,        // default: pleme-io
    pub publish_mode: PublishMode,
}

/// Where the conversion ends up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PublishMode {
    DryRun,
    Public,
    Private,
}

impl PublishMode {
    fn parse(kw: &str) -> Result<Self> {
        match kw {
            "dry-run" | ":dry-run" => Ok(Self::DryRun),
            "public" | ":public" => Ok(Self::Public),
            "private" | ":private" => Ok(Self::Private),
            other => bail!("unknown :publish-mode {other:?} — expected :dry-run|:public|:private"),
        }
    }
    fn as_str(&self) -> &'static str {
        match self { Self::DryRun => "dry-run", Self::Public => "public", Self::Private => "private" }
    }
}

/// Parse a `.ossconv.lisp` source into the typed spec. The format is
/// intentionally simple: one (defossconv NAME :k v :k v …) form;
/// strings are double-quoted; keywords are :leading-colon.
pub fn parse_str(src: &str) -> Result<OssConvSpec> {
    // Strip comments (lines starting with optional whitespace + ;).
    let cleaned: String = src.lines()
        .map(|l| match l.find(';') {
            Some(i) => &l[..i],
            None => l,
        })
        .collect::<Vec<_>>()
        .join("\n");

    // Find the defossconv form.
    let trimmed = cleaned.trim();
    let after_paren = trimmed.strip_prefix('(')
        .ok_or_else(|| anyhow!("expected '(' at start of form"))?;
    let mut tokens = after_paren.split_whitespace();
    let head = tokens.next().ok_or_else(|| anyhow!("empty form"))?;
    if head != "defossconv" {
        bail!("expected (defossconv …) form, got ({head} …)");
    }
    let conversion = tokens.next()
        .ok_or_else(|| anyhow!("missing conversion name after defossconv"))?
        .to_string();

    let mut spec = OssConvSpec {
        conversion,
        upstream: String::new(),
        wrapper: String::new(),
        expect_ecosystem: String::new(),
        description: None,
        license: None,
        version: None,
        publish_org: "pleme-io".to_string(),
        publish_mode: PublishMode::DryRun,
    };

    // Slot-extraction: walk pairs of `:keyword value`. Value is either
    // a quoted string or a leading-colon keyword.
    let mut pairs = pair_iter(&cleaned);
    while let Some((slot, value)) = pairs.next() {
        match slot.as_str() {
            ":upstream"         => spec.upstream         = unquote(&value),
            ":wrapper"          => spec.wrapper          = unquote(&value),
            ":expect-ecosystem" => spec.expect_ecosystem = strip_colon(&value),
            ":description"      => spec.description      = Some(unquote(&value)),
            ":license"          => spec.license          = Some(unquote(&value)),
            ":version"          => spec.version          = Some(unquote(&value)),
            ":publish-org"      => spec.publish_org      = unquote(&value),
            ":publish-mode"     => spec.publish_mode     = PublishMode::parse(&value)?,
            ":rationale" | ":upstream-path" | ":emit-receipt" => {
                // Documentation/operator slots; not load-bearing for the conversion.
            }
            _ => {} // tolerate unknown slots — operators may extend
        }
    }
    // Required-slot check.
    if spec.upstream.is_empty()       { bail!("missing :upstream"); }
    if spec.wrapper.is_empty()        { bail!("missing :wrapper"); }
    if spec.expect_ecosystem.is_empty() { bail!("missing :expect-ecosystem"); }
    Ok(spec)
}

/// Iterate (key, raw-value) pairs from the form body. Recognizes
/// quoted strings ("…" possibly multi-line) + leading-colon keywords
/// + bare tokens. Returns when the body's `)` is reached or input ends.
fn pair_iter(body: &str) -> SlotIter<'_> {
    SlotIter { body, pos: body.find(':').unwrap_or(body.len()) }
}

struct SlotIter<'a> {
    body: &'a str,
    pos: usize,
}

impl<'a> Iterator for SlotIter<'a> {
    type Item = (String, String);
    fn next(&mut self) -> Option<Self::Item> {
        // Find next `:keyword`.
        let rest = self.body.get(self.pos..)?;
        let kw_start = rest.find(':')?;
        let abs_start = self.pos + kw_start;
        let after_colon = &self.body[abs_start..];
        // Read the keyword token.
        let kw_end = after_colon[1..].find(|c: char| c.is_whitespace() || c == ')')
            .map(|n| n + 1).unwrap_or(after_colon.len());
        let kw = &after_colon[..kw_end];
        let mut cursor = abs_start + kw_end;
        // Skip whitespace to value start.
        let body_bytes = self.body.as_bytes();
        while cursor < body_bytes.len() && body_bytes[cursor].is_ascii_whitespace() {
            cursor += 1;
        }
        // Read value: quoted string, leading-colon keyword, or bare token.
        let (val, after_val) = if body_bytes.get(cursor) == Some(&b'"') {
            // Quoted string — find unescaped closing quote.
            let val_start = cursor + 1;
            let mut i = val_start;
            while i < body_bytes.len() {
                if body_bytes[i] == b'\\' { i += 2; continue; }
                if body_bytes[i] == b'"' { break; }
                i += 1;
            }
            let val = &self.body[val_start..i];
            (val.to_string(), i + 1)
        } else {
            // Bare token — read until whitespace or `)`.
            let val_start = cursor;
            let mut i = val_start;
            while i < body_bytes.len() {
                let c = body_bytes[i];
                if c.is_ascii_whitespace() || c == b')' { break; }
                i += 1;
            }
            (self.body[val_start..i].to_string(), i)
        };
        self.pos = after_val;
        Some((kw.to_string(), val))
    }
}

fn unquote(s: &str) -> String {
    s.trim_matches('"').to_string()
}
fn strip_colon(s: &str) -> String {
    s.trim_start_matches(':').to_string()
}

/// The typed attestation a conversion emits. Per Rule 3 of the ★★
/// directive (persistent spec alongside generated artifacts), this
/// record is the audit trail; serialized via json_ast (no format!()).
#[derive(Debug, Clone)]
pub struct Attestation {
    pub conversion: String,
    pub upstream: String,
    pub wrapper: String,
    pub ecosystem: String,
    pub mode: PublishMode,
    pub gate_a: bool,
    pub gate_b: bool,
    pub error: Option<String>,
    pub forged_path: Option<String>,
}

impl Attestation {
    /// Render as typed JSON via the json_ast AST. Dogfoods the prime
    /// directive — the attestation IS code-shaped output + flows through
    /// the typed AST stack like every other emit in the substrate.
    pub fn to_json(&self) -> String {
        use crate::json_ast::Value;
        let mut root = Value::obj();
        root.insert("conversion", Value::s(&self.conversion));
        root.insert("upstream", Value::s(&self.upstream));
        root.insert("wrapper", Value::s(&self.wrapper));
        root.insert("ecosystem", Value::s(&self.ecosystem));
        root.insert("mode", Value::s(self.mode.as_str()));
        let mut gates = Value::obj();
        gates.insert("A", Value::s(if self.gate_a { "pass" } else { "fail" }));
        gates.insert("B", Value::s(if self.gate_b { "pass" } else { "fail" }));
        root.insert("gates", gates);
        if let Some(err) = &self.error {
            root.insert("error", Value::s(err));
        }
        if let Some(path) = &self.forged_path {
            root.insert("forged-path", Value::s(path));
        }
        crate::json_ast::render(&root)
    }
}

/// Run the 6-step conversion pipeline against a parsed spec. Returns
/// a typed Attestation regardless of success/failure — operators always
/// get a structured record of what happened.
///
/// `out_root` is where forge output lands (one subdir per conversion);
/// `allow_publish` controls whether :public / :private modes actually
/// shell out to `gh repo create` — operators must set it explicitly.
pub fn execute(spec: &OssConvSpec, out_root: &Path, allow_publish: bool) -> Attestation {
    let mut att = Attestation {
        conversion: spec.conversion.clone(),
        upstream: spec.upstream.clone(),
        wrapper: spec.wrapper.clone(),
        ecosystem: String::new(),
        mode: spec.publish_mode.clone(),
        gate_a: false,
        gate_b: false,
        error: None,
        forged_path: None,
    };

    // STEP 2 — DISCOVER via the GithubClient trait + URL detector.
    let detected = match crate::discover::detect_github_url(&spec.upstream) {
        Some(d) => d,
        None => {
            att.error = Some(format!("discover returned no match for {}", spec.upstream));
            return att;
        }
    };
    att.ecosystem = detected.ecosystem.to_string();

    // STEP 3 — GATE A: ecosystem matches expectation.
    if detected.ecosystem != spec.expect_ecosystem {
        att.error = Some(format!(
            "Gate A: expected ecosystem {:?}, discovered {:?}",
            spec.expect_ecosystem, detected.ecosystem
        ));
        return att;
    }
    att.gate_a = true;

    // STEP 4 — FORGE via scaffold + caixa::render.
    let target = out_root.join(&spec.wrapper);
    if let Err(e) = std::fs::create_dir_all(&target) {
        att.error = Some(format!("create_dir_all failed: {e}"));
        return att;
    }
    let mut sspec = crate::scaffold::ScaffoldSpec::new(&spec.wrapper, &spec.expect_ecosystem);
    sspec.description = spec.description.clone();
    sspec.license = spec.license.clone();
    sspec.version = spec.version.clone();
    let mut repo_url = String::from("https://github.com/");
    repo_url.push_str(&spec.publish_org); repo_url.push('/'); repo_url.push_str(&spec.wrapper);
    sspec.repository = Some(repo_url);
    let src = crate::ast::Render::render(&crate::scaffold::build(&sspec));
    let caixa_path = target.join(format!("{}.caixa.lisp", spec.wrapper));
    if let Err(e) = std::fs::write(&caixa_path, &src) {
        att.error = Some(format!("write caixa: {e}"));
        return att;
    }
    if let Err(e) = crate::caixa::render(&src, &target, true) {
        att.error = Some(format!("caixa::render failed: {e}"));
        return att;
    }
    att.forged_path = target.to_str().map(String::from);

    // STEP 5 — GATE B: substrate-correctness invariants via the
    // typed Validator trait (shared with the `lint` subcommand +
    // the forge_lint_matrix integration test; one trait = three
    // consumers per the prime directive's macros-everywhere rule).
    let validator = crate::validator::validator_for(&spec.expect_ecosystem);
    let issues = validator.validate(&target);
    if !issues.is_empty() {
        let mut msg = String::from("Gate B failed: ");
        let first = issues.iter().take(3).map(|i| i.message.as_str())
            .collect::<Vec<_>>().join("; ");
        msg.push_str(&first);
        if issues.len() > 3 {
            msg.push_str(&format!(" (+{} more)", issues.len() - 3));
        }
        att.error = Some(msg);
        return att;
    }
    att.gate_b = true;

    // STEP 6 — PUBLISH (gated on allow_publish + mode).
    if matches!(spec.publish_mode, PublishMode::Public | PublishMode::Private) {
        if !allow_publish {
            att.error = Some("publish requested but --yes not passed".into());
            return att;
        }
        let visibility = if spec.publish_mode == PublishMode::Private {
            "--private"
        } else {
            "--public"
        };
        let slug = format!("{}/{}", spec.publish_org, spec.wrapper);
        // git init + commit + push via gh
        let _ = std::process::Command::new("git").arg("init").arg("-q")
            .current_dir(&target).status();
        let _ = std::process::Command::new("git").args(["add", "-A"])
            .current_dir(&target).status();
        let _ = std::process::Command::new("git")
            .args(["commit", "-q", "-m", "init: caixa-oss-conversion-agent forge"])
            .current_dir(&target).status();
        let st = std::process::Command::new("gh")
            .args(["repo", "create", &slug, visibility, "--source=.", "--push"])
            .current_dir(&target).status();
        if !matches!(st, Ok(s) if s.success()) {
            att.error = Some(format!("Step 6: gh repo create failed for {slug}"));
            return att;
        }
    }
    att
}

#[cfg(test)]
mod tests {
    use super::*;

    const EXAMPLE: &str = r#"
;; comment line
(defossconv pleme-io-wrap-foo
  :upstream         "tokio-rs/tracing"
  :wrapper          "wrap-foo"
  :rationale        "wrap a thing"
  :expect-ecosystem :rust-workspace
  :description      "demo wrap"
  :license          "MIT"
  :version          "0.1.0"
  :publish-org      "pleme-io"
  :publish-mode     :dry-run)
"#;

    #[test]
    fn parses_minimal_form() {
        let s = parse_str(EXAMPLE).expect("parse");
        assert_eq!(s.conversion, "pleme-io-wrap-foo");
        assert_eq!(s.upstream, "tokio-rs/tracing");
        assert_eq!(s.wrapper, "wrap-foo");
        assert_eq!(s.expect_ecosystem, "rust-workspace");
        assert_eq!(s.description.as_deref(), Some("demo wrap"));
        assert_eq!(s.license.as_deref(), Some("MIT"));
        assert_eq!(s.version.as_deref(), Some("0.1.0"));
        assert_eq!(s.publish_org, "pleme-io");
        assert_eq!(s.publish_mode, PublishMode::DryRun);
    }

    #[test]
    fn missing_required_slot_errors_clearly() {
        let bad = "(defossconv x :upstream \"a/b\")";
        let err = parse_str(bad).unwrap_err().to_string();
        assert!(err.contains("missing :wrapper") || err.contains("missing :expect-ecosystem"),
            "got: {err}");
    }

    #[test]
    fn attestation_renders_typed_json() {
        let att = Attestation {
            conversion: "x".into(), upstream: "o/r".into(), wrapper: "w".into(),
            ecosystem: "rust-single-crate".into(),
            mode: PublishMode::DryRun,
            gate_a: true, gate_b: true,
            error: None, forged_path: Some("./out/w".into()),
        };
        let json = att.to_json();
        assert!(json.contains("\"conversion\": \"x\""));
        assert!(json.contains("\"mode\": \"dry-run\""));
        assert!(json.contains("\"A\": \"pass\""));
        assert!(json.contains("\"forged-path\": \"./out/w\""));
    }

    #[test]
    fn publish_mode_parser_accepts_with_and_without_leading_colon() {
        assert_eq!(PublishMode::parse(":dry-run").unwrap(), PublishMode::DryRun);
        assert_eq!(PublishMode::parse("dry-run").unwrap(), PublishMode::DryRun);
        assert_eq!(PublishMode::parse(":public").unwrap(), PublishMode::Public);
        assert!(PublishMode::parse("nonsense").is_err());
    }
}