Skip to main content

difflore_core/context/
rule_render.rs

1//! Render a mined/remembered rule as a concrete **code-spec** instead of a
2//! free-prose blob (roadmap item ⑥).
3//!
4//! The output is a single slot-based Markdown template with progressive
5//! disclosure: a section is emitted only when its source data exists, and a
6//! missing slot is silently dropped rather than rendered as "N/A" (an empty
7//! placeholder reads as fabricated emptiness). Every rendered slot traces to a
8//! field we already store or a stored `rule_example` — nothing here invents a
9//! contract, a validation condition, a trigger, or a metric. The mapping is
10//! pure, deterministic string reshaping with no LLM call and no new persisted
11//! data.
12//!
13//! This module is intentionally **public and DB-free** so item ① (rule packs)
14//! can import the same `render_*` helpers and serialize a published pack in the
15//! exact format a locally-recalled rule renders in — see the roadmap's
16//! "missing-field decision table" which both surfaces honor.
17//!
18//! The MCP `get_rules` detail path drives this via
19//! [`crate::mcp_server::tools::util::render_full_rule_with_examples`], which
20//! builds a [`RuleRenderInput`] from its private `SkillDetailRow` and calls
21//! [`render_code_spec`]. The PostToolUse hook keeps its own compact render
22//! (`serve_render::render_rule_block`) by design — it renders from the indexed
23//! body string, not a row, and enforces a tight per-injection token budget.
24
25use crate::context::rule_source::{RuleExample, repo_scope_from_source_repo};
26use crate::skills::{parse_candidate_drafted_rule, parse_candidate_source_proof};
27
28/// Cap the rendered reviewer excerpt so a verbose mined rule can't balloon a
29/// `get_rules` batch of 20. The stored excerpt is already capped (≤500 chars in
30/// `candidates::reviewer_excerpt`); we tighten it further at render time.
31const REVIEWER_EXCERPT_RENDER_LIMIT: usize = 300;
32
33/// Cap the rationale (the prose that follows a conversation rule's directive
34/// first line) at roughly the first two sentences so the Contract stays a
35/// checkable obligation, not a paragraph.
36const RATIONALE_SENTENCE_LIMIT: usize = 2;
37
38/// Borrowed, DB-free view of the fields a renderer needs. Built from the MCP
39/// layer's private `SkillDetailRow` (which can't leak out of `mcp_server`) and
40/// — for item ① — from a pack row, so both surfaces render identically.
41pub struct RuleRenderInput<'a> {
42    pub id: &'a str,
43    pub name: &'a str,
44    pub r#type: &'a str,
45    pub confidence: f64,
46    pub origin: &'a str,
47    /// `owner/repo` attribution column, if any.
48    pub source_repo: Option<&'a str>,
49    /// Already-parsed `file_patterns` (use [`crate::mcp_server::tools::util::parse_file_patterns`]
50    /// or the candidate parser at the call site).
51    pub file_patterns: &'a [String],
52    /// The rule body prose. For mined rules this is the structured
53    /// `Rule:` / `Source evidence:` / `Reviewer said:` shape that the candidate
54    /// parsers understand.
55    pub description: &'a str,
56    /// `skills.trigger`, surfaced when present (free specificity).
57    pub trigger: Option<&'a str>,
58    /// `skills.check_prompt`, surfaced when present.
59    pub check_prompt: Option<&'a str>,
60    /// Structured example rows, already loaded by the caller.
61    pub examples: Option<&'a [RuleExample]>,
62}
63
64/// Whether a directive states a positive obligation (`MUST`) or a prohibition
65/// (`AVOID`). Cheap keyword classify; defaults to `Must` when ambiguous.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum Polarity {
68    Must,
69    Avoid,
70}
71
72impl Polarity {
73    const fn label(self) -> &'static str {
74        match self {
75            Self::Must => "MUST",
76            Self::Avoid => "AVOID",
77        }
78    }
79}
80
81/// Classify a directive statement as a positive obligation or a prohibition by
82/// scanning for negative-polarity keywords. Pure and unit-testable; ambiguous
83/// statements default to [`Polarity::Must`].
84#[must_use]
85pub fn directive_polarity(statement: &str) -> Polarity {
86    let lower = statement.to_ascii_lowercase();
87    const AVOID_MARKERS: &[&str] = &[
88        "avoid",
89        "don't",
90        "do not",
91        "dont",
92        "never",
93        "no longer",
94        "must not",
95        "mustn't",
96        "should not",
97        "shouldn't",
98        "stop ",
99    ];
100    if AVOID_MARKERS.iter().any(|m| lower.contains(m)) {
101        Polarity::Avoid
102    } else {
103        Polarity::Must
104    }
105}
106
107/// Human-readable origin label for the code-spec header. Mirrors the
108/// `origin_to_kind` mapping used by the timeline/telemetry so the same origin
109/// string reads consistently across surfaces.
110fn origin_label(origin: &str) -> &str {
111    match origin {
112        "pr_review" => "PR review",
113        "conversation" => "remembered in conversation",
114        "extracted" => "extracted",
115        "manual" => "manual",
116        "cloud" => "cloud-synced",
117        "team" => "team-synced",
118        other => other,
119    }
120}
121
122/// Split a paragraph into up to `limit` sentences, re-joining them. Used to cap
123/// a rationale; deliberately simple (splits on `. ` / `? ` / `! `) so it stays
124/// pure and predictable. Never drops body text silently — if there are fewer
125/// than `limit` sentences the whole input round-trips.
126fn first_sentences(text: &str, limit: usize) -> String {
127    let trimmed = text.trim();
128    if trimmed.is_empty() || limit == 0 {
129        return trimmed.to_owned();
130    }
131    let mut out = String::new();
132    let mut count = 0usize;
133    let mut chars = trimmed.chars().peekable();
134    while let Some(c) = chars.next() {
135        out.push(c);
136        if matches!(c, '.' | '!' | '?') && chars.peek().is_none_or(|n| n.is_whitespace()) {
137            count += 1;
138            if count >= limit {
139                break;
140            }
141        }
142    }
143    out.trim().to_owned()
144}
145
146/// Truncate to at most `limit` chars without splitting a grapheme, appending an
147/// ellipsis only when something was actually dropped.
148fn truncate_with_ellipsis(s: &str, limit: usize) -> String {
149    let mut chars = s.chars();
150    let head: String = chars.by_ref().take(limit).collect();
151    if chars.next().is_some() {
152        format!("{head}...")
153    } else {
154        head
155    }
156}
157
158/// `### Contract` block. Always present (the verbatim-description fallback
159/// guarantees body text is never dropped). For mined rules the `Rule:`
160/// statement becomes a single `MUST:`/`AVOID:` obligation; for verb-led
161/// conversation rules the first sentence becomes the obligation and the rest
162/// becomes a `Rationale:` sub-line (the WHY is the value, so it is kept).
163#[must_use]
164pub fn render_contract_block(origin: &str, description: &str) -> String {
165    let mut out = String::from("### Contract\n");
166
167    // Mined rule: reuse the battle-tested candidate parser, don't re-implement.
168    if origin == "pr_review"
169        && let Some(stmt) = parse_candidate_drafted_rule(description)
170        && !stmt.trim().is_empty()
171    {
172        let stmt = stmt.trim();
173        let polarity = directive_polarity(stmt);
174        out.push_str(&format!("- {}: {}\n", polarity.label(), stmt));
175        return out;
176    }
177
178    // Conversation / other origin with a verb-led first line: the first
179    // sentence becomes the obligation, remaining prose becomes the rationale.
180    let trimmed = description.trim();
181    if let Some((first, rest)) = split_directive_and_rationale(trimmed) {
182        let polarity = directive_polarity(&first);
183        out.push_str(&format!("- {}: {}\n", polarity.label(), first.trim()));
184        if let Some(rest) = rest {
185            let rationale = first_sentences(&rest, RATIONALE_SENTENCE_LIMIT);
186            if !rationale.is_empty() {
187                out.push_str(&format!("\nRationale: {rationale}\n"));
188            }
189        }
190        return out;
191    }
192
193    // Fallback (reached only for an empty/edge body, since a non-empty body
194    // always yields a directive first line above): emit the raw description
195    // verbatim and never drop body text. A contract we can't structure is
196    // still strictly more useful rendered than dropped.
197    if trimmed.is_empty() {
198        // Defensive: keep the section non-empty so the template stays valid.
199        out.push_str("- (no rule body)\n");
200    } else {
201        out.push_str(&format!("{trimmed}\n"));
202    }
203    out
204}
205
206/// Split a body into (first sentence, remaining prose) when the first line
207/// reads like a directive. Returns `None` when the body has no usable first
208/// line so the caller falls back to verbatim rendering.
209fn split_directive_and_rationale(body: &str) -> Option<(String, Option<String>)> {
210    let body = body.trim();
211    if body.is_empty() {
212        return None;
213    }
214    // Use the first sentence as the obligation; everything after it is rationale.
215    let first = first_sentences(body, 1);
216    if first.is_empty() {
217        return None;
218    }
219    let rest = body
220        .get(first.len()..)
221        .map(str::trim)
222        .filter(|r| !r.is_empty())
223        .map(ToOwned::to_owned);
224    Some((first, rest))
225}
226
227/// `### Validation / Error matrix` block, or `None` when no row is derivable.
228///
229/// Derivation-only, never generation: a row comes from a stored `rule_example`
230/// (its own bad/good/description) or from a mined directive that already has a
231/// "When X, do Y" shape (the distiller emits "When touching `path`, …"). If
232/// neither source exists the whole section is omitted — we never render an
233/// empty table or a hallucinated edge case.
234#[must_use]
235pub fn render_validation_matrix(
236    origin: &str,
237    description: &str,
238    examples: Option<&[RuleExample]>,
239) -> Option<String> {
240    let mut rows: Vec<(String, String, String)> = Vec::new();
241
242    // Row from the first example: the bad pattern → the good form, flagged.
243    if let Some(ex) = examples.and_then(|e| e.first()) {
244        let condition = matrix_cell(&ex.bad_code);
245        let expected = matrix_cell(&ex.good_code);
246        let on_violation = ex
247            .description
248            .as_deref()
249            .map(str::trim)
250            .filter(|d| !d.is_empty())
251            .map_or_else(|| "reviewer flagged this".to_owned(), matrix_cell);
252        rows.push((condition, expected, on_violation));
253    }
254
255    // Row from a mined "When X, Y" directive: split on the first comma.
256    if origin == "pr_review"
257        && let Some(stmt) = parse_candidate_drafted_rule(description)
258        && let Some(row) = when_directive_row(&stmt)
259    {
260        rows.push(row);
261    }
262
263    if rows.is_empty() {
264        return None;
265    }
266
267    let mut out = String::from("### Validation / Error matrix\n");
268    out.push_str("| Condition | Expected | On violation |\n");
269    out.push_str("|---|---|---|\n");
270    for (cond, expected, on_violation) in rows {
271        out.push_str(&format!("| {cond} | {expected} | {on_violation} |\n"));
272    }
273    Some(out)
274}
275
276/// Turn a "When X, Y" directive into a single matrix row. The distiller emits
277/// "When touching `path`, <directive>." so we split on the first comma into
278/// Condition / Expected. Returns `None` when there is no comma (no "when…,"
279/// shape) so the caller doesn't fabricate a condition.
280fn when_directive_row(statement: &str) -> Option<(String, String, String)> {
281    let stmt = statement.trim();
282    let lower = stmt.to_ascii_lowercase();
283    if !lower.starts_with("when ") {
284        return None;
285    }
286    let (cond, expected) = stmt.split_once(',')?;
287    let cond = cond.trim();
288    let expected = expected.trim().trim_end_matches('.').trim();
289    if cond.is_empty() || expected.is_empty() {
290        return None;
291    }
292    Some((
293        matrix_cell(cond),
294        matrix_cell(expected),
295        "directive applies".to_owned(),
296    ))
297}
298
299/// Flatten a snippet into a single Markdown-table cell: collapse newlines,
300/// escape the pipe that would break the column, and cap length so a multi-line
301/// code example can't blow out the table.
302fn matrix_cell(value: &str) -> String {
303    let flat: String = value
304        .trim()
305        .chars()
306        .map(|c| if c == '\n' || c == '\r' { ' ' } else { c })
307        .collect();
308    let escaped = flat.replace('|', "\\|");
309    truncate_with_ellipsis(escaped.trim(), 80)
310}
311
312/// `### Cases` block, or `None` when there are no examples. Framed as a
313/// conformance test (`❌ Counter-example` / `✅ Conforming`) rather than
314/// decoration so the agent reads the pair as the acceptance criterion.
315#[must_use]
316pub fn render_cases_block(examples: Option<&[RuleExample]>) -> Option<String> {
317    let examples = examples.filter(|e| !e.is_empty())?;
318    let mut out = String::from("### Cases\n");
319    for ex in examples {
320        out.push_str(&format!(
321            "❌ Counter-example:\n```\n{}\n```\n\n✅ Conforming:\n```\n{}\n```\n",
322            ex.bad_code, ex.good_code
323        ));
324        if let Some(desc) = ex.description.as_deref().map(str::trim)
325            && !desc.is_empty()
326        {
327            out.push_str(&format!("\n{desc}\n"));
328        }
329    }
330    Some(out)
331}
332
333/// `### Provenance` block. Reuses the candidate source-proof parser to pull the
334/// PR `Source:`, the `Comment:` URL and a short reviewer excerpt out of a mined
335/// description. Returns `None` only when there is neither parseable source proof
336/// nor a `source_repo` — the header's `← learned from` segment already carries
337/// the top-level attribution, so a conversation rule with no proof omits this
338/// section rather than repeating the header.
339#[must_use]
340pub fn render_provenance_block(description: &str, source_repo: Option<&str>) -> Option<String> {
341    let proof = parse_candidate_source_proof(description);
342    let has_repo = source_repo.map(str::trim).is_some_and(|r| !r.is_empty());
343    if proof.is_none() && !has_repo {
344        return None;
345    }
346
347    let mut out = String::from("### Provenance\n");
348    if let Some(proof) = proof.as_ref() {
349        let source = proof
350            .source
351            .as_deref()
352            .map(str::trim)
353            .filter(|s| !s.is_empty());
354        let comment = proof
355            .comment_url
356            .as_deref()
357            .map(str::trim)
358            .filter(|s| !s.is_empty());
359        match (source, comment) {
360            (Some(s), Some(c)) => out.push_str(&format!("Source: {s} · {c}\n")),
361            (Some(s), None) => out.push_str(&format!("Source: {s}\n")),
362            (None, Some(c)) => out.push_str(&format!("Source: {c}\n")),
363            (None, None) => {}
364        }
365        if let Some(excerpt) = proof
366            .excerpt
367            .as_deref()
368            .map(str::trim)
369            .filter(|s| !s.is_empty())
370        {
371            let excerpt = truncate_with_ellipsis(excerpt, REVIEWER_EXCERPT_RENDER_LIMIT);
372            out.push_str(&format!("Reviewer: {excerpt}\n"));
373        }
374    }
375    // If proof carried nothing renderable but we still have a repo, keep the
376    // section honest: the header already shows "← learned from", so only emit
377    // the section when it adds something. Drop an otherwise-empty header.
378    if out == "### Provenance\n" {
379        return None;
380    }
381    Some(out)
382}
383
384/// Render a full rule as the §3 code-spec template. Pure and DB-free: the
385/// caller supplies a [`RuleRenderInput`]; every section is derived
386/// deterministically from those fields with progressive disclosure.
387#[must_use]
388pub fn render_code_spec(input: &RuleRenderInput<'_>) -> String {
389    let mut out = String::new();
390
391    // Header. Keep the id in a stable, greppable position.
392    out.push_str(&format!("## Rule {} — {}\n", input.id, input.name));
393
394    // Scope line.
395    let scope = if input.file_patterns.is_empty() {
396        "repo-wide (no file scope)".to_owned()
397    } else {
398        input.file_patterns.join(", ")
399    };
400    out.push_str(&format!("Scope: {scope}\n"));
401
402    // Type · Confidence · Origin (+ learned-from attribution).
403    let learned_from = repo_scope_from_source_repo(input.source_repo)
404        .map(|s| format!(" \u{2190} learned from {s}"))
405        .unwrap_or_default();
406    out.push_str(&format!(
407        "Type: {} · Confidence: {:.2} · Origin: {}{}\n",
408        input.r#type,
409        input.confidence,
410        origin_label(input.origin),
411        learned_from,
412    ));
413
414    // Contract — always present.
415    out.push('\n');
416    out.push_str(&render_contract_block(input.origin, input.description));
417
418    // Validation / Error matrix — only when derivable.
419    if let Some(matrix) = render_validation_matrix(input.origin, input.description, input.examples)
420    {
421        out.push('\n');
422        out.push_str(&matrix);
423    }
424
425    // Trigger — only when the column is populated.
426    if let Some(trigger) = input.trigger.map(str::trim).filter(|t| !t.is_empty()) {
427        out.push_str(&format!("\n### Trigger\n{trigger}\n"));
428    }
429
430    // Self-check — only when the column is populated.
431    if let Some(check) = input.check_prompt.map(str::trim).filter(|c| !c.is_empty()) {
432        out.push_str(&format!("\n### Self-check\n{check}\n"));
433    }
434
435    // Cases — only when ≥1 example.
436    if let Some(cases) = render_cases_block(input.examples) {
437        out.push('\n');
438        out.push_str(&cases);
439    }
440
441    // Provenance — source proof and/or learned-from repo.
442    if let Some(prov) = render_provenance_block(input.description, input.source_repo) {
443        out.push('\n');
444        out.push_str(&prov);
445    }
446
447    out
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use crate::context::rule_source::RuleExample;
454
455    fn example(bad: &str, good: &str, desc: Option<&str>) -> RuleExample {
456        RuleExample {
457            id: "ex1".to_owned(),
458            skill_id: "rule1".to_owned(),
459            bad_code: bad.to_owned(),
460            good_code: good.to_owned(),
461            description: desc.map(ToOwned::to_owned),
462            source: "test".to_owned(),
463        }
464    }
465
466    #[test]
467    fn directive_polarity_classifies_negatives_as_avoid() {
468        assert_eq!(
469            directive_polarity("never unwrap in handlers"),
470            Polarity::Avoid
471        );
472        assert_eq!(directive_polarity("Avoid magic numbers"), Polarity::Avoid);
473        assert_eq!(directive_polarity("don't swallow errors"), Polarity::Avoid);
474        assert_eq!(
475            directive_polarity("Should not panic in hot paths"),
476            Polarity::Avoid
477        );
478    }
479
480    #[test]
481    fn directive_polarity_defaults_to_must() {
482        assert_eq!(
483            directive_polarity("prefer structured parsing in resolve"),
484            Polarity::Must
485        );
486        assert_eq!(
487            directive_polarity("return a structured error instead"),
488            Polarity::Must
489        );
490    }
491
492    #[test]
493    fn first_sentences_caps_at_limit_and_roundtrips_shorter() {
494        assert_eq!(first_sentences("One. Two. Three.", 2), "One. Two.");
495        assert_eq!(first_sentences("Only one sentence", 2), "Only one sentence");
496        assert_eq!(first_sentences("", 2), "");
497    }
498
499    #[test]
500    fn contract_from_mined_rule_renders_must_bullet() {
501        let desc = "Rule:\nWhen touching `src/**/*.rs`, prefer structured parsing.\n\nSource evidence:\nSource: acme/widgets#7\n\nReviewer said:\nPlease prefer structured parsing.";
502        let block = render_contract_block("pr_review", desc);
503        assert!(block.starts_with("### Contract\n"));
504        assert!(
505            block.contains("- MUST: When touching `src/**/*.rs`, prefer structured parsing."),
506            "got: {block}"
507        );
508    }
509
510    #[test]
511    fn contract_from_mined_avoid_rule_renders_avoid_bullet() {
512        let desc = "Rule:\nWhen touching `src/http`, never unwrap request payloads.\n\nSource evidence:\nSource: acme/widgets#7";
513        let block = render_contract_block("pr_review", desc);
514        assert!(block.contains("- AVOID:"), "got: {block}");
515    }
516
517    #[test]
518    fn contract_from_conversation_rule_splits_directive_and_rationale() {
519        let desc = "Prefer dependency injection for clients. It makes the handler testable and avoids hidden globals.";
520        let block = render_contract_block("conversation", desc);
521        assert!(block.contains("- MUST: Prefer dependency injection for clients."));
522        assert!(block.contains("Rationale: It makes the handler testable"));
523    }
524
525    #[test]
526    fn contract_never_drops_body_when_unparseable() {
527        // Mined origin but no `Rule:` section -> falls back to verbatim.
528        let desc = "Some freeform mined note without the structured shape";
529        let block = render_contract_block("pr_review", desc);
530        assert!(block.contains("Some freeform mined note"), "got: {block}");
531    }
532
533    #[test]
534    fn validation_matrix_row_from_example() {
535        let ex = [example(
536            "foo.unwrap()",
537            "foo?",
538            Some("reviewer asked for ?"),
539        )];
540        let matrix =
541            render_validation_matrix("conversation", "irrelevant", Some(&ex)).expect("matrix");
542        assert!(matrix.contains("| Condition | Expected | On violation |"));
543        assert!(matrix.contains("foo.unwrap()"));
544        assert!(matrix.contains("foo?"));
545        assert!(matrix.contains("reviewer asked for ?"));
546    }
547
548    #[test]
549    fn validation_matrix_row_from_when_directive() {
550        let desc = "Rule:\nWhen touching `src/http`, return a structured error.\n\nSource evidence:\nSource: acme/widgets#7";
551        let matrix = render_validation_matrix("pr_review", desc, None).expect("matrix");
552        assert!(matrix.contains("When touching `src/http`"));
553        assert!(matrix.contains("return a structured error"));
554    }
555
556    #[test]
557    fn validation_matrix_omitted_when_no_source() {
558        assert!(render_validation_matrix("conversation", "freeform prose", None).is_none());
559    }
560
561    #[test]
562    fn matrix_cell_escapes_pipes_and_collapses_newlines() {
563        assert_eq!(matrix_cell("a | b\nc"), "a \\| b c");
564    }
565
566    #[test]
567    fn cases_block_uses_conformance_framing_not_bad_good() {
568        let ex = [example("bad()", "good()", None)];
569        let block = render_cases_block(Some(&ex)).expect("cases");
570        assert!(block.contains("❌ Counter-example:"));
571        assert!(block.contains("✅ Conforming:"));
572        // Must NOT reuse the old index-leak markers.
573        assert!(!block.contains("### Examples"));
574    }
575
576    #[test]
577    fn cases_block_omitted_when_empty() {
578        assert!(render_cases_block(None).is_none());
579        let empty: [RuleExample; 0] = [];
580        assert!(render_cases_block(Some(&empty)).is_none());
581    }
582
583    #[test]
584    fn provenance_from_mined_rule_includes_source_and_reviewer() {
585        let desc = "Rule:\nPrefer X.\n\nSource evidence:\nSource: acme/widgets#7\nComment: https://example.com/c/1\n\nReviewer said:\nPlease prefer X over Y.";
586        let prov = render_provenance_block(desc, Some("acme/widgets")).expect("provenance");
587        assert!(prov.contains("Source: acme/widgets#7"));
588        assert!(prov.contains("https://example.com/c/1"));
589        assert!(prov.contains("Reviewer: Please prefer X over Y."));
590    }
591
592    #[test]
593    fn provenance_omitted_for_conversation_rule_without_proof() {
594        // No source proof, no repo -> the header already says nothing to learn
595        // from, so the section is dropped.
596        assert!(render_provenance_block("freeform note", None).is_none());
597    }
598
599    #[test]
600    fn golden_mined_rule_with_example() {
601        let ex = [example(
602            "let v = resolve(x).unwrap();",
603            "let v = resolve(x)?;",
604            Some("reviewer flagged unwrap"),
605        )];
606        let input = RuleRenderInput {
607            id: "conv-foo-ab12",
608            name: "Prefer structured parsing in resolve",
609            r#type: "review_standard",
610            confidence: 0.82,
611            origin: "pr_review",
612            source_repo: Some("vitejs/vite"),
613            file_patterns: &["packages/vite/src/**/*.ts".to_owned()],
614            description: "Rule:\nWhen touching `packages/vite/src`, never unwrap resolve results.\n\nSource evidence:\nSource: vitejs/vite#42\nComment: https://example.com/pr/42#c\nFile: resolve.ts\n\nReviewer said:\nPlease return a structured error.",
615            trigger: None,
616            check_prompt: None,
617            examples: Some(&ex),
618        };
619        let body = render_code_spec(&input);
620        assert!(body.starts_with("## Rule conv-foo-ab12 — Prefer structured parsing in resolve\n"));
621        assert!(body.contains("Scope: packages/vite/src/**/*.ts"));
622        assert!(body.contains("Confidence: 0.82"));
623        assert!(body.contains("\u{2190} learned from vitejs/vite"));
624        assert!(body.contains("### Contract"));
625        assert!(
626            body.contains(
627                "- AVOID: When touching `packages/vite/src`, never unwrap resolve results."
628            )
629        );
630        assert!(body.contains("### Validation / Error matrix"));
631        assert!(body.contains("### Cases"));
632        assert!(body.contains("❌ Counter-example:"));
633        assert!(body.contains("### Provenance"));
634        assert!(body.contains("Source: vitejs/vite#42"));
635        // No trigger/self-check columns populated -> sections omitted.
636        assert!(!body.contains("### Trigger"));
637        assert!(!body.contains("### Self-check"));
638    }
639
640    #[test]
641    fn golden_conversation_rule_with_neither_trigger_nor_example() {
642        let input = RuleRenderInput {
643            id: "conv-bare-1",
644            name: "Keep handlers thin",
645            r#type: "review_standard",
646            confidence: 0.5,
647            origin: "conversation",
648            source_repo: None,
649            file_patterns: &[],
650            description: "Keep request handlers thin and push logic into services.",
651            trigger: None,
652            check_prompt: None,
653            examples: None,
654        };
655        let body = render_code_spec(&input);
656        assert!(body.contains("## Rule conv-bare-1 — Keep handlers thin"));
657        assert!(body.contains("Scope: repo-wide (no file scope)"));
658        assert!(body.contains("### Contract"));
659        assert!(body.contains("- MUST: Keep request handlers thin"));
660        // Slot omission: no matrix, trigger, self-check, cases, or provenance.
661        assert!(!body.contains("### Validation / Error matrix"));
662        assert!(!body.contains("### Trigger"));
663        assert!(!body.contains("### Self-check"));
664        assert!(!body.contains("### Cases"));
665        assert!(!body.contains("### Provenance"));
666    }
667
668    #[test]
669    fn golden_rule_with_check_prompt_only() {
670        let input = RuleRenderInput {
671            id: "team-rule-9",
672            name: "Validate webhook signatures",
673            r#type: "review_standard",
674            confidence: 0.9,
675            origin: "team",
676            source_repo: None,
677            file_patterns: &["src/webhooks/**/*.ts".to_owned()],
678            description: "Always verify the HMAC signature before processing a webhook.",
679            trigger: None,
680            check_prompt: Some("Did you verify the signature before reading the body?"),
681            examples: None,
682        };
683        let body = render_code_spec(&input);
684        assert!(body.contains("### Self-check"));
685        assert!(body.contains("Did you verify the signature before reading the body?"));
686        assert!(!body.contains("### Trigger"));
687        assert!(!body.contains("### Cases"));
688    }
689}