Skip to main content

difflore_core/packs/
install.rs

1//! `install_pack` — write a fetched [`PackManifest`] into the local `skills`
2//! store (roadmap §5 step 6). Reuses the `remember.rs` INSERT-INTO-skills
3//! shape: a row + SKILL.md on disk + an optional `rule_examples` pair, all in
4//! one transaction. Every installed row carries `origin = 'pack'`, a synthetic
5//! `source_repo = "pack:<id>"`, the mandatory pack tags, and `confidence = 0.55`
6//! — the levers that confine pack rules to the suggestion-only cross-repo
7//! starter fallback (roadmap §4).
8//!
9//! The rule **body** is rendered through item ⑥'s public renderer
10//! [`crate::context::rule_render::render_code_spec`] so an installed pack rule is
11//! byte-for-byte indistinguishable in body from a mined rule.
12
13use sqlx::SqlitePool;
14
15use crate::context::rule_render::{RuleRenderInput, render_code_spec};
16use crate::context::rule_source::RuleExample;
17use crate::errors::CoreError;
18use crate::observability::privacy::{redact_secretish_tokens, strip_private_tagged_regions};
19use crate::packs::manifest::{PackManifest, PackRule};
20use crate::packs::{
21    PACK_CONFIDENCE, PACK_ORIGIN, pack_rule_tag, pack_source_repo, pack_version_tag,
22};
23
24/// On-disk source bucket for pack SKILL.md dirs: `<data>/skills/pack/<slug>`.
25/// Distinct from `local`/`cloud`/`team` so a pack rule's files never collide
26/// with a mined rule's.
27const PACK_SKILL_SOURCE: &str = "pack";
28
29/// Defense-in-depth redaction (roadmap §5 step 4): even though packs are
30/// public, run their bodies/examples through the same redaction the ingest
31/// pipeline uses before they touch disk/DB.
32fn sanitize(input: &str) -> String {
33    redact_secretish_tokens(&strip_private_tagged_regions(input))
34}
35
36/// Path-traversal-safe slug, mirroring `remember.rs`'s `create_local`
37/// algorithm so the generated directory name is predictable and can't escape
38/// the skills root.
39fn slugify(value: &str) -> String {
40    value
41        .to_lowercase()
42        .chars()
43        .map(|c| {
44            if c.is_ascii_alphanumeric() || c == '_' {
45                c
46            } else {
47                '-'
48            }
49        })
50        .collect::<String>()
51        .split('-')
52        .filter(|s| !s.is_empty())
53        .collect::<Vec<_>>()
54        .join("-")
55}
56
57/// Short, deterministic hex suffix derived from the pack-namespaced rule id, so
58/// the local `skills.id` is stable across re-installs (idempotency) yet can
59/// never collide with `conv-*` / `local-*` ids or a cloud UUID (which
60/// `looks_like_cloud_uuid` rejects — this id is not a UUID).
61fn deterministic_suffix(pack_rule_id: &str) -> String {
62    use std::fmt::Write as _;
63
64    use sha2::{Digest, Sha256};
65    let mut hasher = Sha256::new();
66    hasher.update(pack_rule_id.as_bytes());
67    let digest = hasher.finalize();
68    digest.iter().take(4).fold(String::new(), |mut acc, b| {
69        let _ = write!(acc, "{b:02x}");
70        acc
71    })
72}
73
74/// Local `skills.id` for a pack rule: `pack-<packSlug>-<ruleSlug>-<8hex>`.
75fn local_skill_id(pack_id: &str, pack_rule_id: &str) -> String {
76    // The rule slug strips the redundant `<packSlug>/` namespace prefix the
77    // manifest carries (e.g. `go-http-safety/413-body-limit`) so the id reads
78    // cleanly; the deterministic suffix is hashed over the FULL pack-rule id so
79    // distinctness is preserved even if two packs share a leaf slug.
80    let pack_slug = slugify(pack_id);
81    let rule_leaf = pack_rule_id.rsplit('/').next().unwrap_or(pack_rule_id);
82    let rule_slug = slugify(rule_leaf);
83    format!(
84        "pack-{pack_slug}-{rule_slug}-{}",
85        deterministic_suffix(pack_rule_id)
86    )
87}
88
89/// Resolve the effective glob list for a rule: its own `fileGlobs` override the
90/// pack-level `target.fileGlobs` default.
91fn effective_globs(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
92    let mut globs: Vec<String> = if rule.file_globs.is_empty() {
93        manifest
94            .target
95            .as_ref()
96            .map(|t| t.file_globs.clone())
97            .unwrap_or_default()
98    } else {
99        rule.file_globs.clone()
100    };
101    globs.retain(|g| !g.trim().is_empty());
102    globs
103}
104
105/// Assemble the mandatory tag set for an installed pack rule (roadmap §4.1):
106/// `pack`, `pack:<id>@<version>`, `pack-rule:<ruleId>`, the language tag,
107/// `severity:<level>` (when present), plus the rule's own declared tags.
108fn build_tags(rule: &PackRule, manifest: &PackManifest) -> Vec<String> {
109    let mut tags: Vec<String> = Vec::new();
110    tags.push(PACK_ORIGIN.to_owned());
111    tags.push(pack_version_tag(&manifest.id, &manifest.version));
112    tags.push(pack_rule_tag(&rule.id));
113
114    if let Some(lang) = manifest
115        .target
116        .as_ref()
117        .and_then(|t| t.languages.first())
118        .map(|l| l.trim().to_ascii_lowercase())
119        .filter(|l| !l.is_empty())
120    {
121        tags.push(lang);
122    }
123
124    if let Some(sev) = rule
125        .severity
126        .as_deref()
127        .map(|s| s.trim().to_ascii_lowercase())
128        .filter(|s| !s.is_empty())
129    {
130        tags.push(format!("severity:{sev}"));
131    }
132
133    for tag in &rule.tags {
134        let trimmed = tag.trim();
135        if !trimmed.is_empty() {
136            tags.push(trimmed.to_owned());
137        }
138    }
139
140    // De-dup while preserving first-seen order.
141    let mut seen = std::collections::HashSet::new();
142    tags.retain(|t| seen.insert(t.clone()));
143    tags
144}
145
146/// Render the rule body via item ⑥'s canonical renderer. We construct a
147/// [`RuleRenderInput`] exactly as the MCP `get_rules` path does, so a pack rule
148/// renders identically to a mined rule. The single optional example (when both
149/// sides are present) is fed in so the renderer can emit its Validation matrix
150/// + Cases sections.
151fn render_body(
152    skill_id: &str,
153    rule: &PackRule,
154    manifest: &PackManifest,
155    globs: &[String],
156    example: Option<&RuleExample>,
157) -> String {
158    let source_repo = pack_source_repo(&manifest.id);
159    let description = rule
160        .body
161        .as_deref()
162        .map(str::trim)
163        .filter(|b| !b.is_empty())
164        .map(sanitize)
165        .unwrap_or_default();
166    let examples_slice = example.map(std::slice::from_ref);
167    let input = RuleRenderInput {
168        id: skill_id,
169        name: rule.title.trim(),
170        r#type: "review_standard",
171        confidence: PACK_CONFIDENCE,
172        origin: PACK_ORIGIN,
173        source_repo: Some(source_repo.as_str()),
174        file_patterns: globs,
175        description: &description,
176        // Packs carry no separate trigger/check_prompt column; the renderer
177        // omits those sections (progressive disclosure).
178        trigger: None,
179        check_prompt: None,
180        examples: examples_slice,
181    };
182    render_code_spec(&input)
183}
184
185/// Build the optional example pair for a rule. Only present when BOTH sides are
186/// non-empty after redaction — a one-sided example hurts few-shot quality
187/// (mirrors `remember.rs`).
188fn build_example(skill_id: &str, rule: &PackRule) -> Option<RuleExample> {
189    let ex = rule.examples.as_ref()?;
190    let bad = sanitize(ex.bad.as_deref().unwrap_or_default());
191    let good = sanitize(ex.good.as_deref().unwrap_or_default());
192    if bad.trim().is_empty() || good.trim().is_empty() {
193        return None;
194    }
195    let description = ex
196        .description
197        .as_deref()
198        .map(sanitize)
199        .map(|d| d.trim().to_owned())
200        .filter(|d| !d.is_empty());
201    Some(RuleExample {
202        id: format!(
203            "example-pack-{}",
204            crate::packs::manifest::manifest_sha256(skill_id.as_bytes())
205        ),
206        skill_id: skill_id.to_owned(),
207        bad_code: bad.trim().to_owned(),
208        good_code: good.trim().to_owned(),
209        description,
210        source: PACK_ORIGIN.to_owned(),
211    })
212}
213
214/// SKILL.md markdown for a pack rule, so the on-disk file reads naturally and
215/// the rendered code-spec body round-trips. Mirrors the frontmatter shape
216/// `remember.rs` writes.
217fn build_skill_md(rule: &PackRule, tags: &[String], body: &str) -> String {
218    let mut md = String::new();
219    md.push_str("---\n");
220    md.push_str("type: review_standard\n");
221    md.push_str("engines: [claude]\n");
222    md.push_str(&format!("tags: [{}]\n", tags.join(", ")));
223    md.push_str("origin: pack\n");
224    md.push_str("---\n\n");
225    md.push_str(&format!("# {}\n\n", rule.title.trim()));
226    md.push_str(body);
227    md.push('\n');
228    md
229}
230
231/// One installed-rule summary, returned for `--dry-run` preview and the install
232/// confirmation. Carries exactly the fields roadmap §5 step 5 says a dry-run
233/// must print: id, globs, tags, origin, synthetic source_repo, confidence.
234#[derive(Debug, Clone)]
235pub struct InstalledPackRule {
236    pub skill_id: String,
237    pub pack_rule_id: String,
238    pub title: String,
239    pub file_patterns: Vec<String>,
240    pub tags: Vec<String>,
241    pub origin: String,
242    pub source_repo: String,
243    pub confidence: f64,
244    pub has_example: bool,
245}
246
247/// Result of an [`install_pack`] run.
248#[derive(Debug, Clone)]
249pub struct InstallPackOutcome {
250    pub pack_id: String,
251    pub pack_version: String,
252    /// The rules that would be / were written.
253    pub rules: Vec<InstalledPackRule>,
254    /// Rows removed because a different version of the same `pack-rule:<id>` was
255    /// already installed (version supersede). Empty for a fresh install and a
256    /// `dry_run`.
257    pub superseded_rule_ids: Vec<String>,
258    /// True when nothing was written: either `dry_run`, or this exact
259    /// `pack:<id>@<version>` was already fully installed (idempotent no-op).
260    pub dry_run: bool,
261}
262
263/// Install (or dry-run preview) every rule in a fetched pack manifest.
264///
265/// Idempotency / supersede (roadmap §5 step 6): a rule already present at this
266/// exact `pack:<id>@<version>` is left untouched; a rule present at a *different*
267/// version of the same pack is deleted and replaced (version supersede), keyed
268/// on its `pack-rule:<ruleId>` tag. All writes happen in one transaction; the
269/// store's `updated_at` is bumped so the cross-repo starter index rebuilds
270/// lazily on next recall.
271pub async fn install_pack(
272    db: &SqlitePool,
273    manifest: &PackManifest,
274    dry_run: bool,
275) -> Result<InstallPackOutcome, CoreError> {
276    let source_repo = pack_source_repo(&manifest.id);
277
278    // First pass: compute everything purely (no DB writes) so a dry-run is a
279    // faithful preview of the real install.
280    struct Prepared {
281        skill_id: String,
282        pack_rule_id: String,
283        title: String,
284        globs: Vec<String>,
285        tags: Vec<String>,
286        body: String,
287        skill_md: String,
288        example: Option<RuleExample>,
289    }
290    let mut prepared: Vec<Prepared> = Vec::with_capacity(manifest.rules.len());
291    for rule in &manifest.rules {
292        if rule.title.trim().is_empty() {
293            return Err(CoreError::Validation(format!(
294                "pack '{}' has a rule with an empty title (id '{}')",
295                manifest.id, rule.id
296            )));
297        }
298        let skill_id = local_skill_id(&manifest.id, &rule.id);
299        let globs = effective_globs(rule, manifest);
300        let tags = build_tags(rule, manifest);
301        let example = build_example(&skill_id, rule);
302        let body = render_body(&skill_id, rule, manifest, &globs, example.as_ref());
303        let skill_md = build_skill_md(rule, &tags, &body);
304        prepared.push(Prepared {
305            skill_id,
306            pack_rule_id: rule.id.clone(),
307            title: rule.title.trim().to_owned(),
308            globs,
309            tags,
310            body,
311            skill_md,
312            example,
313        });
314    }
315
316    let installed: Vec<InstalledPackRule> = prepared
317        .iter()
318        .map(|p| InstalledPackRule {
319            skill_id: p.skill_id.clone(),
320            pack_rule_id: p.pack_rule_id.clone(),
321            title: p.title.clone(),
322            file_patterns: p.globs.clone(),
323            tags: p.tags.clone(),
324            origin: PACK_ORIGIN.to_owned(),
325            source_repo: source_repo.clone(),
326            confidence: PACK_CONFIDENCE,
327            has_example: p.example.is_some(),
328        })
329        .collect();
330
331    if dry_run {
332        return Ok(InstallPackOutcome {
333            pack_id: manifest.id.clone(),
334            pack_version: manifest.version.clone(),
335            rules: installed,
336            superseded_rule_ids: Vec::new(),
337            dry_run: true,
338        });
339    }
340
341    // Resolve and confine the on-disk pack root once.
342    let base_dir = crate::skill_fs::skills_base_dir()
343        .map_err(CoreError::Internal)?
344        .join(PACK_SKILL_SOURCE);
345    std::fs::create_dir_all(&base_dir)
346        .map_err(|e| CoreError::Internal(format!("failed to create pack skills dir: {e}")))?;
347    let canonical_base = base_dir
348        .canonicalize()
349        .map_err(|e| CoreError::Internal(format!("failed to resolve pack skills dir: {e}")))?;
350
351    let now_utc = chrono::Utc::now();
352    let now = now_utc.format("%Y-%m-%d %H:%M:%S").to_string();
353    let now_ms: i64 = now_utc.timestamp_millis();
354
355    let mut tx = db.begin().await?;
356    let mut superseded_rule_ids: Vec<String> = Vec::new();
357    let mut written_dirs: Vec<std::path::PathBuf> = Vec::new();
358
359    for p in &prepared {
360        let version_tag = pack_version_tag(&manifest.id, &manifest.version);
361        let rule_tag = pack_rule_tag(&p.pack_rule_id);
362
363        // Idempotency: an identical row already installed at THIS exact
364        // version is a no-op (skip it, leaving the existing row + examples).
365        let already_at_version: Option<String> = sqlx::query_scalar(
366            "SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%' \
367             AND tags LIKE '%' || ?3 || '%' LIMIT 1",
368        )
369        .bind(PACK_ORIGIN)
370        .bind(&rule_tag)
371        .bind(&version_tag)
372        .fetch_optional(&mut *tx)
373        .await?;
374        if already_at_version.is_some() {
375            continue;
376        }
377
378        // Version supersede: a DIFFERENT version of the same pack-rule is
379        // delete-and-replaced. Match on the `pack-rule:<id>` tag (version
380        // independent) but only among pack-origin rows.
381        let stale_ids: Vec<String> = sqlx::query_scalar(
382            "SELECT id FROM skills WHERE origin = ?1 AND tags LIKE '%' || ?2 || '%'",
383        )
384        .bind(PACK_ORIGIN)
385        .bind(&rule_tag)
386        .fetch_all(&mut *tx)
387        .await?;
388        for stale in &stale_ids {
389            // Runtime-checked queries (not the `sqlx::query!` macro) so the
390            // build does not need an offline `.sqlx` cache entry — matching the
391            // `query_scalar` lookups above.
392            sqlx::query("DELETE FROM rule_examples WHERE skill_id = ?1")
393                .bind(stale)
394                .execute(&mut *tx)
395                .await?;
396            sqlx::query("DELETE FROM skills WHERE id = ?1")
397                .bind(stale)
398                .execute(&mut *tx)
399                .await?;
400            // Best-effort disk cleanup of the superseded row's dir.
401            let stale_dir = base_dir.join(stale);
402            let _ = std::fs::remove_dir_all(&stale_dir);
403            superseded_rule_ids.push(stale.clone());
404        }
405
406        // Write SKILL.md, path-confined to the pack root.
407        let skill_dir = base_dir.join(&p.skill_id);
408        let skill_dir_for_check = canonical_base.join(&p.skill_id);
409        if !skill_dir_for_check.starts_with(&canonical_base) {
410            tx.rollback().await.ok();
411            cleanup_dirs(&written_dirs);
412            return Err(CoreError::Validation(
413                "install_pack: invalid slug after sanitization".into(),
414            ));
415        }
416        std::fs::create_dir_all(&skill_dir)
417            .map_err(|e| CoreError::Internal(format!("failed to create skill directory: {e}")))?;
418        let canonical_skill = skill_dir
419            .canonicalize()
420            .map_err(|e| CoreError::Internal(format!("failed to resolve skill directory: {e}")))?;
421        if !canonical_skill.starts_with(&canonical_base) {
422            tx.rollback().await.ok();
423            cleanup_dirs(&written_dirs);
424            return Err(CoreError::Validation("install_pack: path escape".into()));
425        }
426        std::fs::write(skill_dir.join("SKILL.md"), &p.skill_md)
427            .map_err(|e| CoreError::Internal(format!("failed to write SKILL.md: {e}")))?;
428        written_dirs.push(skill_dir.clone());
429
430        let engines_json = serde_json::to_string(&["claude"])?;
431        let tags_json = serde_json::to_string(&p.tags)?;
432        let file_patterns_json: Option<String> = if p.globs.is_empty() {
433            None
434        } else {
435            Some(serde_json::to_string(&p.globs)?)
436        };
437
438        // Reuse the `remember.rs:577` INSERT-INTO-skills shape, swapping
439        // source='pack', origin='pack', synthetic source_repo, and the 0.55
440        // confidence floor. `enabled_for_claude = 1` mirrors the remember path.
441        // Runtime-checked `sqlx::query` (not the macro) so no offline `.sqlx`
442        // cache entry is required for this new statement.
443        sqlx::query(
444            "INSERT INTO skills
445             (id, name, source, directory, version, description, type, engines, tags,
446              trigger, check_prompt, file_patterns, source_repo, enabled_for_claude,
447              confidence_score, installed_at, updated_at, origin, content_hash, hash_created_at)
448             VALUES (?1, ?2, 'pack', ?3, ?4, ?5, 'review_standard', ?6, ?7,
449                     NULL, NULL, ?8, ?9, 1, ?10, ?11, ?11, ?12, NULL, ?13)",
450        )
451        .bind(&p.skill_id)
452        .bind(&p.title)
453        .bind(&p.skill_id)
454        .bind(&manifest.version)
455        .bind(&p.body)
456        .bind(&engines_json)
457        .bind(&tags_json)
458        .bind(file_patterns_json.as_deref())
459        .bind(source_repo.as_str())
460        .bind(PACK_CONFIDENCE)
461        .bind(now.as_str())
462        .bind(PACK_ORIGIN)
463        .bind(now_ms)
464        .execute(&mut *tx)
465        .await?;
466
467        if let Some(example) = &p.example {
468            let ex_now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
469            sqlx::query(
470                "INSERT INTO rule_examples (id, skill_id, bad_code, good_code, description, source, created_at)
471                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
472            )
473            .bind(&example.id)
474            .bind(&example.skill_id)
475            .bind(&example.bad_code)
476            .bind(&example.good_code)
477            .bind(example.description.as_deref())
478            .bind(&example.source)
479            .bind(&ex_now)
480            .execute(&mut *tx)
481            .await?;
482        }
483    }
484
485    if let Err(e) = tx.commit().await {
486        cleanup_dirs(&written_dirs);
487        return Err(e.into());
488    }
489
490    // Keep the claude engine link consistent with `enabled_for_claude = 1`,
491    // mirroring `remember.rs`. Best-effort: a link failure must not fail an
492    // otherwise-committed install.
493    for p in &prepared {
494        if let Err(e) =
495            crate::skill_fs::sync_engine_link(PACK_SKILL_SOURCE, &p.skill_id, "claude", true)
496        {
497            eprintln!(
498                "warning: sync_engine_link failed for pack rule {}: {e}",
499                p.skill_id
500            );
501        }
502    }
503
504    Ok(InstallPackOutcome {
505        pack_id: manifest.id.clone(),
506        pack_version: manifest.version.clone(),
507        rules: installed,
508        superseded_rule_ids,
509        dry_run: false,
510    })
511}
512
513/// Best-effort cleanup of partially-written SKILL.md dirs after a transaction
514/// rollback, mirroring `remember.rs`'s `remove_dir_all` on insert failure.
515fn cleanup_dirs(dirs: &[std::path::PathBuf]) {
516    for dir in dirs {
517        let _ = std::fs::remove_dir_all(dir);
518    }
519}
520
521#[cfg(test)]
522mod tests {
523    use super::*;
524    use crate::packs::manifest::{PackRuleExamples, PackTarget};
525
526    fn sample_manifest() -> PackManifest {
527        PackManifest {
528            schema_version: 1,
529            id: "difflore/go-http-safety".to_owned(),
530            name: "Go HTTP handler safety".to_owned(),
531            version: "1.2.0".to_owned(),
532            description: None,
533            target: Some(PackTarget {
534                languages: vec!["go".to_owned()],
535                frameworks: vec!["net/http".to_owned()],
536                file_globs: vec!["**/*.go".to_owned()],
537            }),
538            maintainer: None,
539            license: None,
540            provenance: None,
541            rules: vec![PackRule {
542                id: "go-http-safety/413-body-limit".to_owned(),
543                title: "Return 413 when a request body exceeds the size limit".to_owned(),
544                severity: Some("error".to_owned()),
545                file_globs: vec![],
546                tags: vec!["http".to_owned(), "security".to_owned()],
547                body: Some(
548                    "Reject oversized request bodies with HTTP 413 instead of \
549                     reading them unbounded into memory."
550                        .to_owned(),
551                ),
552                examples: Some(PackRuleExamples {
553                    bad: Some("data, _ := io.ReadAll(r.Body)".to_owned()),
554                    good: Some("r.Body = http.MaxBytesReader(w, r.Body, max)".to_owned()),
555                    description: Some("reviewer flagged unbounded read".to_owned()),
556                }),
557                provenance: None,
558            }],
559        }
560    }
561
562    #[test]
563    fn local_skill_id_is_namespaced_and_not_a_uuid() {
564        let id = local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit");
565        assert!(id.starts_with("pack-difflore-go-http-safety-413-body-limit-"));
566        // Not a UUID -> looks_like_cloud_uuid rejects it, so it's never
567        // mistaken for a cloud-synced rule.
568        assert!(!id.contains('/'));
569        // Deterministic: same input -> same id (idempotency).
570        assert_eq!(
571            id,
572            local_skill_id("difflore/go-http-safety", "go-http-safety/413-body-limit")
573        );
574    }
575
576    #[test]
577    fn mandatory_tags_present() {
578        let manifest = sample_manifest();
579        let tags = build_tags(&manifest.rules[0], &manifest);
580        assert!(tags.contains(&"pack".to_owned()));
581        assert!(tags.contains(&"pack:difflore/go-http-safety@1.2.0".to_owned()));
582        assert!(tags.contains(&"pack-rule:go-http-safety/413-body-limit".to_owned()));
583        assert!(tags.contains(&"go".to_owned()));
584        assert!(tags.contains(&"severity:error".to_owned()));
585        assert!(tags.contains(&"http".to_owned()));
586    }
587
588    #[test]
589    fn rule_globs_override_pack_default() {
590        let mut manifest = sample_manifest();
591        manifest.rules[0].file_globs = vec!["internal/http/**/*.go".to_owned()];
592        let globs = effective_globs(&manifest.rules[0], &manifest);
593        assert_eq!(globs, vec!["internal/http/**/*.go".to_owned()]);
594    }
595
596    #[test]
597    fn pack_default_globs_used_when_rule_has_none() {
598        let manifest = sample_manifest();
599        let globs = effective_globs(&manifest.rules[0], &manifest);
600        assert_eq!(globs, vec!["**/*.go".to_owned()]);
601    }
602
603    #[test]
604    fn body_renders_via_item_six_code_spec() {
605        let manifest = sample_manifest();
606        let rule = &manifest.rules[0];
607        let globs = effective_globs(rule, &manifest);
608        let skill_id = local_skill_id(&manifest.id, &rule.id);
609        let example = build_example(&skill_id, rule);
610        let body = render_body(&skill_id, rule, &manifest, &globs, example.as_ref());
611        // Header / Scope / Contract / Cases come from item ⑥'s renderer.
612        assert!(body.starts_with(&format!("## Rule {skill_id} —")));
613        assert!(body.contains("Scope: **/*.go"));
614        assert!(body.contains("Confidence: 0.55"));
615        assert!(body.contains("Origin: pack"));
616        assert!(body.contains("### Contract"));
617        assert!(body.contains("### Cases"));
618        // Curated rule with no "When X, Y" / 'pr_review' origin still gets a
619        // Validation matrix from its example pair.
620        assert!(body.contains("### Validation / Error matrix"));
621    }
622
623    #[test]
624    fn example_requires_both_sides() {
625        let manifest = sample_manifest();
626        let rule = &manifest.rules[0];
627        let skill_id = local_skill_id(&manifest.id, &rule.id);
628        assert!(build_example(&skill_id, rule).is_some());
629
630        let mut one_sided = rule.clone();
631        one_sided.examples = Some(PackRuleExamples {
632            bad: Some("x".to_owned()),
633            good: None,
634            description: None,
635        });
636        assert!(build_example(&skill_id, &one_sided).is_none());
637    }
638}