llman 0.0.69

A tool for managing LLM application rules(prompts) ...
Documentation
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
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
//! Single-track feature-as-spec backend: one `.feature` file per capability is
//! the only spec artifact (spec-format r131-r136).
//!
//! File anatomy:
//! ```text
//! # language: zh-CN          ← optional Gherkin language header
//! # capability: <name>       ← required (r133)
//! # purpose: <one-liner>     ← required (r133)
//! # scope: src/a, src/b      ← required (r133); drives staleness
//!
//! 功能: <title>
//!   @req:<id> @human         ← constraint layer (rule; statement = description)
//!   场景: <title>
//!     <free-text statement lines>
//!
//!   @req:<id> @executable    ← acceptance layer (runner-bound)
//!   场景: <scenario-id>
//!     假如/当/那么 …
//! ```
//!
//! Scenarios nested inside Gherkin `Rule:` blocks are rejected: rstest-bdd's
//! `scenarios!` macro silently skips them, so accepting them here would hide
//! executable scenarios from the runner (design D1).

use crate::sdd::spec::ir::{MainSpecDoc, RequirementEntry, ScenarioEntry};
use anyhow::{Result, anyhow};
use sha2::{Digest, Sha256};
use std::fmt::Write as _;

pub struct FeatureBackend;

/// Process-wide singleton (single-track spec-format r131).
pub static FEATURE_BACKEND: FeatureBackend = FeatureBackend;

/// Tier of a scenario under the single-track grammar (r132).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScenarioTier {
    /// Human-owned constraint (`@human`); never runner-bound.
    Constraint,
    /// Runner-bound acceptance (`@executable`).
    Acceptance,
    /// Explicit manual-review waiver (`@human @manual`).
    Manual,
}

/// Richly parsed scenario retaining everything the lock-hash needs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RichScenario {
    pub name: String,
    pub description: Option<String>,
    pub given: Vec<String>,
    pub when_: Vec<String>,
    pub then_: Vec<String>,
    pub req_ids: Vec<String>,
    pub tags: Vec<String>,
    pub tier: Option<ScenarioTier>,
}

/// Gherkin keyword set for a supported language.
#[derive(Debug, Clone, Copy)]
pub struct GherkinKw {
    pub feature: &'static str,
    pub scenario: &'static str,
    pub given: &'static str,
    pub when: &'static str,
    pub then: &'static str,
}

/// Keywords for the parsed/rendered language (zh-CN or English).
pub fn keywords_for(lang: &str) -> GherkinKw {
    if lang.starts_with("zh") {
        GherkinKw {
            feature: "功能",
            scenario: "场景",
            given: "假如",
            when: "",
            then: "那么",
        }
    } else {
        GherkinKw {
            feature: "Feature",
            scenario: "Scenario",
            given: "Given",
            when: "When",
            then: "Then",
        }
    }
}

impl ScenarioTier {
    /// Whether scenarios of this tier are locked for agent edits (r135).
    pub fn is_locked(self) -> bool {
        matches!(self, ScenarioTier::Constraint | ScenarioTier::Manual)
    }

    /// Stable machine label used in JSON output.
    pub fn as_str(self) -> &'static str {
        match self {
            ScenarioTier::Constraint => "constraint",
            ScenarioTier::Manual => "manual",
            ScenarioTier::Acceptance => "acceptance",
        }
    }
}

/// Fully parsed single-track spec file.
#[derive(Debug, Clone)]
pub struct ParsedFeatureSpec {
    pub name: String,
    pub purpose: String,
    pub valid_scope: Vec<String>,
    pub feature_title: String,
    pub scenarios: Vec<RichScenario>,
    /// Raw file content (for lock hashing continuity checks).
    pub raw: String,
}

impl ParsedFeatureSpec {
    /// Locked-rule scenarios (`@human`), including `@manual` waivers.
    pub fn rule_scenarios(&self) -> impl Iterator<Item = &RichScenario> {
        self.scenarios.iter().filter(|sc| {
            matches!(
                sc.tier,
                Some(ScenarioTier::Constraint | ScenarioTier::Manual)
            )
        })
    }

    /// Manual-waiver scenarios (`@human @manual`).
    pub fn manual_scenarios(&self) -> impl Iterator<Item = &RichScenario> {
        self.scenarios
            .iter()
            .filter(|sc| sc.tier == Some(ScenarioTier::Manual))
    }
}

impl ParsedFeatureSpec {
    /// Acceptance scenarios (`@executable`).
    pub fn acceptance_scenarios(&self) -> impl Iterator<Item = &RichScenario> {
        self.scenarios
            .iter()
            .filter(|sc| sc.tier == Some(ScenarioTier::Acceptance))
    }
}

impl FeatureBackend {
    /// Parse single-track feature content with explicit Gherkin language.
    pub fn parse_content(&self, content: &str, context: &str) -> Result<ParsedFeatureSpec> {
        let (headers, _body_offset) = parse_header_comments(content);
        let name = headers.capability.ok_or_else(|| {
            anyhow!("{context}: missing `# capability:` header comment (spec-format r133)")
        })?;
        let purpose = headers.purpose.unwrap_or_default();
        let valid_scope: Vec<String> = headers
            .scope
            .unwrap_or_default()
            .split(',')
            .map(|s| s.trim().to_string())
            .filter(|s| !s.is_empty())
            .collect();

        // Language resolution: explicit `# language:` header wins; otherwise
        // try English then fall back to Chinese keywords (most llman-authored
        // specs are zh-CN even when the header is omitted).
        let header_lang = detect_language(content);
        let mut candidates: Vec<String> = vec![header_lang.clone(), "zh-CN".to_string()];
        candidates.dedup();
        let mut parsed: Option<(gherkin::Feature, String)> = None;
        let mut last_err = String::new();
        for lang in &candidates {
            match gherkin::GherkinEnv::new(lang)
                .map_err(|err| anyhow!("{context}: gherkin env `{lang}`: {err}"))
                .and_then(|env| {
                    gherkin::Feature::parse(content, env)
                        .map_err(|err| anyhow!("{context}: failed to parse Gherkin: {err}"))
                }) {
                Ok(feature) => {
                    parsed = Some((feature, lang.clone()));
                    break;
                }
                Err(err) => last_err = err.to_string(),
            }
        }
        let Some((parsed_feature, _lang)) = parsed else {
            return Err(anyhow!("{last_err}"));
        };
        let parsed = parsed_feature;

        // Rule-blocked scenarios would be silently skipped by the runner macros.
        for rule in &parsed.rules {
            if !rule.scenarios.is_empty() {
                return Err(anyhow!(
                    "{context}: scenarios inside Gherkin `Rule:` blocks are not supported \
                     (rstest-bdd skips them silently); keep all scenarios top-level"
                ));
            }
        }

        let mut scenarios = Vec::new();
        for sc in &parsed.scenarios {
            let mut given = Vec::new();
            let mut when_ = Vec::new();
            let mut then_ = Vec::new();
            for step in &sc.steps {
                match step.ty {
                    gherkin::StepType::Given => given.push(step.value.clone()),
                    gherkin::StepType::When => when_.push(step.value.clone()),
                    gherkin::StepType::Then => then_.push(step.value.clone()),
                }
            }
            let tags = sc.tags.clone();
            let tier = classify_tier(&tags).map_err(|msg| anyhow!("{context}: {msg}"))?;
            scenarios.push(RichScenario {
                name: sc.name.clone(),
                description: sc.description.clone(),
                given,
                when_,
                then_,
                req_ids: req_ids_from_tags(&tags),
                tags,
                tier,
            });
        }

        Ok(ParsedFeatureSpec {
            name,
            purpose,
            valid_scope,
            feature_title: parsed.name,
            scenarios,
            raw: content.to_string(),
        })
    }
}

struct HeaderComments {
    capability: Option<String>,
    purpose: Option<String>,
    scope: Option<String>,
}

/// Split leading `# key: value` comment headers from the Gherkin body.
/// Returns the headers and the byte offset where the body starts (unused by
/// callers today; the Gherkin parser tolerates the comment block).
fn parse_header_comments(content: &str) -> (HeaderComments, usize) {
    let mut capability = None;
    let mut purpose = None;
    let mut scope = None;
    let mut offset = 0usize;
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() || trimmed.starts_with("# language:") {
            offset += line.len() + 1;
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix('#') {
            let rest = rest.trim();
            if let Some(v) = rest.strip_prefix("capability:") {
                capability = Some(v.trim().to_string());
            } else if let Some(v) = rest.strip_prefix("purpose:") {
                purpose = Some(v.trim().to_string());
            } else if let Some(v) = rest.strip_prefix("scope:") {
                scope = Some(v.trim().to_string());
            }
            offset += line.len() + 1;
            continue;
        }
        break;
    }
    (
        HeaderComments {
            capability,
            purpose,
            scope,
        },
        offset,
    )
}

pub fn detect_language(content: &str) -> String {
    for line in content.lines() {
        let trimmed = line.trim();
        if trimmed.is_empty() {
            continue;
        }
        if let Some(rest) = trimmed.strip_prefix("# language:") {
            return rest.trim().to_string();
        }
        break;
    }
    "en".to_string()
}

/// Classify a scenario tier from its tags; errors on reserved-tag misuse (r132).
fn classify_tier(tags: &[String]) -> Result<Option<ScenarioTier>> {
    let has = |want: &str| {
        tags.iter().any(|t| {
            t.trim()
                .trim_start_matches('@')
                .eq_ignore_ascii_case(want.trim_start_matches('@'))
        })
    };
    let human = has("human");
    let executable = has("executable");
    let manual = has("manual");
    if human && executable {
        return Err(anyhow!(
            "scenario cannot be both @human (constraint) and @executable (acceptance)"
        ));
    }
    if manual && !human {
        return Err(anyhow!(
            "@manual requires @human (manual review waives a constraint rule)"
        ));
    }
    if human && manual {
        Ok(Some(ScenarioTier::Manual))
    } else if human {
        Ok(Some(ScenarioTier::Constraint))
    } else if executable {
        Ok(Some(ScenarioTier::Acceptance))
    } else {
        Ok(None)
    }
}

/// Extract `@req:<id>` tags (deduplicated, order-preserving).
pub fn req_ids_from_tags(tags: &[String]) -> Vec<String> {
    let mut out = Vec::new();
    for tag in tags {
        let t = tag.trim().trim_start_matches('@');
        if let Some(rest) = t.strip_prefix("req:") {
            let id = rest.trim();
            if !id.is_empty() && !out.iter().any(|x| x == id) {
                out.push(id.to_string());
            }
        }
    }
    out
}

/// Normalized lock-hash input lines for one scenario (design D4):
/// id(name), description, and each step prefixed by its type, all
/// right-trimmed. Whitespace inside lines is preserved verbatim.
pub fn normalized_hash_lines(sc: &RichScenario) -> Vec<String> {
    let mut lines = vec![format!("scenario: {}", sc.name)];
    for rid in &sc.req_ids {
        lines.push(format!("req: {rid}"));
    }
    if let Some(desc) = sc.description.as_deref() {
        for l in desc.lines() {
            lines.push(format!("desc: {}", l.trim_end()));
        }
    }
    for (label, steps) in [
        ("given", &sc.given),
        ("when", &sc.when_),
        ("then", &sc.then_),
    ] {
        for s in steps {
            lines.push(format!("{label}: {}", s.trim_end()));
        }
    }
    lines
}

/// SHA-256 hex of the normalized lock-hash lines (design D4).
pub fn lock_hash(sc: &RichScenario) -> String {
    let mut hasher = Sha256::new();
    for line in normalized_hash_lines(sc) {
        hasher.update(line.as_bytes());
        hasher.update(b"\n");
    }
    let digest = hasher.finalize();
    let mut hex = String::with_capacity(digest.len() * 2);
    for b in digest {
        let _ = write!(hex, "{b:02x}");
    }
    hex
}

/// Statement for the IR requirement row: description verbatim, or synthesized
/// from steps when the author relied purely on Given/When/Then decomposition.
pub fn rule_statement(sc: &RichScenario) -> String {
    if let Some(desc) = sc.description.as_deref()
        && !desc.trim().is_empty()
    {
        // Strip the renderer's `- ` bullet prefixes so dump→parse round-trips.
        let stripped: Vec<&str> = desc
            .lines()
            .map(|l| l.trim().strip_prefix("- ").unwrap_or(l.trim()))
            .collect();
        return stripped.join("\n");
    }
    let mut parts: Vec<String> = Vec::new();
    if !sc.given.is_empty() {
        parts.push(format!("假如 {}", sc.given.join("")));
    }
    if !sc.when_.is_empty() {
        parts.push(format!("{}", sc.when_.join("")));
    }
    if !sc.then_.is_empty() {
        parts.push(format!("那么 {}", sc.then_.join("")));
    }
    parts.join("")
}

impl FeatureBackend {
    pub fn parse_main_spec(&self, content: &str, context: &str) -> Result<MainSpecDoc> {
        let parsed = self.parse_content(content, context)?;
        Ok(parsed_to_doc(&parsed))
    }

    pub fn parse_main_spec_strict(&self, content: &str, context: &str) -> Result<MainSpecDoc> {
        // Strict mode adds nothing beyond the base grammar today: header
        // completeness and tag grammar already fail hard in `parse_content`.
        self.parse_main_spec(content, context)
    }

    /// Deterministically render a main spec back to single-track gherkin
    /// (canonical form: zh-CN keywords, requirements as `@human` rules).
    pub fn dump_main_spec(&self, doc: &MainSpecDoc) -> Result<String> {
        self.dump_main_spec_lang(doc, "zh-CN")
    }

    /// Language-aware variant of [`FeatureBackend::dump_main_spec`]: keywords
    /// and the `# language:` header follow `lang` (see [`keywords_for`]).
    pub fn dump_main_spec_lang(&self, doc: &MainSpecDoc, lang: &str) -> Result<String> {
        let kw = keywords_for(lang);
        let mut out = String::new();
        let _ = writeln!(out, "# language: {lang}");
        let _ = writeln!(out, "# capability: {}", doc.name.trim());
        let _ = writeln!(out, "# purpose: {}", doc.purpose.trim());
        if !doc.valid_scope.is_empty() {
            let _ = writeln!(out, "# scope: {}", doc.valid_scope.join(", "));
        }
        let _ = writeln!(out);
        let _ = writeln!(out, "{}: {}", kw.feature, doc.name.trim());

        for req in &doc.requirements {
            let _ = writeln!(out);
            let _ = writeln!(out, "  @req:{} @human", req.req_id);
            let _ = writeln!(out, "  {}: {}", kw.scenario, req.title);
            // Bullet-prefix each statement line: free text starting with a
            // Gherkin keyword (e.g. a line beginning with `场景`/`当`) would
            // otherwise be parsed as structure and break the file.
            let statement = if req.statement.is_empty() {
                "(约束陈述待补充)"
            } else {
                req.statement.as_str()
            };
            for line in statement.lines() {
                let _ = writeln!(out, "    - {line}");
            }
        }
        for sc in &doc.scenarios {
            if !sc.feature {
                continue;
            }
            let _ = writeln!(out);
            let _ = writeln!(out, "  @req:{} @executable", sc.req_id);
            let _ = writeln!(out, "  {}: {}", kw.scenario, sc.id);
            // Collapse multi-line step values to one physical line: bare
            // continuation lines would re-parse as bogus steps.
            for (kw_str, field) in [
                (kw.given, &sc.given),
                (kw.when, &sc.when_),
                (kw.then, &sc.then_),
            ] {
                let value = field
                    .split('\n')
                    .map(str::trim)
                    .filter(|l| !l.is_empty())
                    .collect::<Vec<_>>()
                    .join(" ");
                if !value.is_empty() {
                    let _ = writeln!(out, "    {kw_str} {value}");
                }
            }
        }
        Ok(out)
    }
}

/// Rule-tier morphology for `list --specs` / `show` (spec-format r134).
#[derive(Debug, Clone, serde::Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct RuleMorphology {
    pub rule_count: usize,
    pub rule_enforced_count: usize,
    pub rule_manual_count: usize,
    pub rule_pending_count: usize,
    pub acceptance_count: usize,
    pub orphan_acceptance_count: usize,
}

/// Map rule id -> number of `@executable` scenarios linked to it.
pub fn acceptance_index(parsed: &ParsedFeatureSpec) -> std::collections::HashMap<String, usize> {
    let mut idx: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
    for acc in parsed.acceptance_scenarios() {
        for rid in &acc.req_ids {
            *idx.entry(rid.clone()).or_insert(0) += 1;
        }
    }
    idx
}

/// Compute the three-tier coverage counts from a parsed spec.
pub fn compute_rule_morphology(parsed: &ParsedFeatureSpec) -> RuleMorphology {
    let rules: Vec<&RichScenario> = parsed.rule_scenarios().collect();
    let rule_count = rules.len();
    let mut enforced = 0usize;
    let mut manual = 0usize;
    let mut pending = 0usize;
    let idx = acceptance_index(parsed);
    for sc in &rules {
        let has_acceptance = sc
            .req_ids
            .iter()
            .any(|r| idx.get(r).copied().unwrap_or(0) > 0);
        match (sc.tier, has_acceptance) {
            (Some(ScenarioTier::Manual), _) => manual += 1,
            (_, true) => enforced += 1,
            _ => pending += 1,
        }
    }
    let orphan = parsed
        .acceptance_scenarios()
        .filter(|acc| acc.req_ids.is_empty())
        .count();
    RuleMorphology {
        rule_count,
        rule_enforced_count: enforced,
        rule_manual_count: manual,
        rule_pending_count: pending,
        acceptance_count: parsed.acceptance_scenarios().count(),
        orphan_acceptance_count: orphan,
    }
}

/// Project a parsed single-track spec onto the stable IR.///
/// - `@human` scenarios become `requirements[]` rows (statement from
///   description or synthesized from steps).
/// - Only `@executable` scenarios land in `scenarios[]` (`feature: true`).
pub fn parsed_to_doc(parsed: &ParsedFeatureSpec) -> MainSpecDoc {
    let requirements = parsed
        .rule_scenarios()
        .filter_map(|sc| {
            sc.req_ids.first().map(|rid| RequirementEntry {
                req_id: rid.clone(),
                title: sc.name.clone(),
                statement: rule_statement(sc),
            })
        })
        .collect();
    let scenarios = parsed
        .acceptance_scenarios()
        .map(|sc| ScenarioEntry {
            req_id: sc.req_ids.first().cloned().unwrap_or_default(),
            id: sc.name.clone(),
            given: sc.given.join("\n"),
            when_: sc.when_.join("\n"),
            then_: sc.then_.join("\n"),
            feature: true,
        })
        .collect();
    MainSpecDoc {
        kind: "llman.sdd.spec".to_string(),
        name: parsed.name.clone(),
        purpose: parsed.purpose.clone(),
        valid_scope: parsed.valid_scope.clone(),
        requirements,
        scenarios,
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::sdd::spec::backend::FEATURE_BACKEND;

    const ZH_SAMPLE: &str = "\
# language: zh-CN
# capability: sample-cap
# purpose: 单轨格式样例
# scope: src/a, src/b

功能: 样例能力

  @req:r1 @human
  场景: start 门禁
    工作区在默认分支且有未提交变更时
    系统 MUST 拒绝 change start。

  @req:r1 @executable
  场景: dirty-tree-start-rejected
    假如 已初始化 sdd 项目且 bdd 配置为 \"on\"
    当 运行 llman sdd change start x
    那么 退出码非零

  场景: 无标签的普通场景
    假如 前置
    当 动作
    那么 结果
";

    #[test]
    fn parses_zh_sample_headers_and_tiers() {
        let parsed = FEATURE_BACKEND.parse_content(ZH_SAMPLE, "test").unwrap();
        assert_eq!(parsed.name, "sample-cap");
        assert_eq!(parsed.purpose, "单轨格式样例");
        assert_eq!(parsed.valid_scope, vec!["src/a", "src/b"]);
        assert_eq!(parsed.scenarios.len(), 3);

        let rules: Vec<_> = parsed.rule_scenarios().collect();
        assert_eq!(rules.len(), 1);
        assert_eq!(rules[0].name, "start 门禁");
        assert_eq!(rules[0].req_ids, vec!["r1"]);
        assert!(rules[0].description.as_deref().unwrap().contains("MUST"));

        let acc: Vec<_> = parsed.acceptance_scenarios().collect();
        assert_eq!(acc.len(), 1);
        assert_eq!(acc[0].name, "dirty-tree-start-rejected");

        // IR projection: requirement + executable scenario only.
        let doc = FEATURE_BACKEND.parse_main_spec(ZH_SAMPLE, "test").unwrap();
        assert_eq!(doc.requirements.len(), 1);
        assert_eq!(doc.requirements[0].req_id, "r1");
        assert_eq!(doc.requirements[0].title, "start 门禁");
        assert!(doc.requirements[0].statement.contains("MUST"));
        assert_eq!(doc.scenarios.len(), 1);
        assert!(doc.scenarios[0].feature);
        assert_eq!(doc.scenarios[0].id, "dirty-tree-start-rejected");
    }

    #[test]
    fn missing_capability_header_fails() {
        let content = "# purpose: x\n\n功能: t\n  场景: s\n    假如 a\n";
        let err = FEATURE_BACKEND
            .parse_content(content, "ctx")
            .unwrap_err()
            .to_string();
        assert!(err.contains("# capability:"), "got: {err}");
    }

    #[test]
    fn human_and_executable_are_mutually_exclusive() {
        let content = "\
# language: zh-CN
# capability: cap
# purpose: p
# scope: src

功能: t
  @req:r9 @human @executable
  场景: bad
    假如 a
";
        let err = FEATURE_BACKEND
            .parse_content(content, "ctx")
            .unwrap_err()
            .to_string();
        assert!(err.contains("@human"), "got: {err}");
    }

    #[test]
    fn rule_block_scenarios_are_rejected() {
        // Note: the official zh-CN Gherkin dictionary keeps `Rule` in English
        // (`规则` is NOT a keyword and parses as description text).
        let content = "\
# language: zh-CN
# capability: cap
# purpose: p
# scope: src

功能: t
  Rule: 分组
    @executable
    场景: nested
      假如 a
";
        let err = FEATURE_BACKEND
            .parse_content(content, "ctx")
            .unwrap_err()
            .to_string();
        assert!(
            err.contains("Rule") || err.contains("规则"),
            "should reject Rule-blocked scenarios, got: {err}"
        );
    }

    #[test]
    fn lock_hash_is_stable_and_whitespace_tolerant() {
        let a = FEATURE_BACKEND.parse_content(ZH_SAMPLE, "t").unwrap();
        let mut tweaked = ZH_SAMPLE.replace("那么 退出码非零", "那么 退出码非零   ");
        tweaked.push('\n');
        let b = FEATURE_BACKEND.parse_content(&tweaked, "t").unwrap();

        let ha = lock_hash(&a.scenarios[1]);
        let hb = lock_hash(&b.scenarios[1]);
        assert_eq!(ha, hb, "trailing whitespace must not change the hash");

        let mutated = ZH_SAMPLE.replace("dirty-tree-start-rejected", "dirty-tree-start-blocked");
        let c = FEATURE_BACKEND.parse_content(&mutated, "t").unwrap();
        assert_ne!(
            ha,
            lock_hash(&c.scenarios[1]),
            "renames must change the hash"
        );
        assert_eq!(ha.len(), 64, "sha256 hex length");
    }

    #[test]
    fn manual_waiver_tier() {
        let content = "\
# capability: cap
# purpose: p
# scope: src

Feature: t
  @req:r2 @human @manual
  Scenario: waived rule
    Style consistency is reviewed by humans only.
";
        let parsed = FEATURE_BACKEND.parse_content(content, "t").unwrap();
        assert_eq!(
            parsed.scenarios[0].tier,
            Some(ScenarioTier::Manual),
            "@manual requires @human"
        );
    }

    #[test]
    fn dump_roundtrips_through_parse() {
        let doc = FEATURE_BACKEND.parse_main_spec(ZH_SAMPLE, "t").unwrap();
        let dumped = FEATURE_BACKEND.dump_main_spec(&doc).unwrap();
        let reparsed = FEATURE_BACKEND
            .parse_main_spec(&dumped, "roundtrip")
            .unwrap();
        assert_eq!(doc.name, reparsed.name);
        assert_eq!(doc.requirements, reparsed.requirements);
        assert_eq!(doc.scenarios, reparsed.scenarios);
    }

    #[test]
    fn dump_main_spec_lang_renders_keyword_sets() {
        let doc = FEATURE_BACKEND.parse_main_spec(ZH_SAMPLE, "t").unwrap();
        let zh = FEATURE_BACKEND.dump_main_spec_lang(&doc, "zh-CN").unwrap();
        assert!(zh.contains("# language: zh-CN"));
        assert!(zh.contains("功能: "));
        assert!(zh.contains("场景: "));
        assert!(zh.contains("假如 "));
        // dump_main_spec stays the zh-CN canonical form.
        assert_eq!(FEATURE_BACKEND.dump_main_spec(&doc).unwrap(), zh);

        let en = FEATURE_BACKEND.dump_main_spec_lang(&doc, "en").unwrap();
        assert!(en.contains("# language: en"));
        assert!(en.contains("Feature: "));
        assert!(en.contains("Scenario: "));
        assert!(en.contains("Given "));
        // English output must re-parse to the same IR.
        let reparsed = FEATURE_BACKEND.parse_main_spec(&en, "en").unwrap();
        assert_eq!(reparsed.requirements, doc.requirements);
        assert_eq!(reparsed.scenarios, doc.scenarios);
    }
}