Skip to main content

rac_engine/
validate.rs

1//! Structural validation (`decided.core.validation`), severity overrides
2//! (`decided.core.overrides`), OKF conformance (`decided.services.okf_conformance`),
3//! and the `.decided/config.yaml` loaders (`decided.services.init`) — per
4//! PORT-CONTRACT.d/04 §4-6.
5//!
6//! Emission order is the contract: [metadata issues][ticketing issues]
7//! [per-type issues], with each per-type validator's internal append order
8//! replicated verbatim. Messages are byte-exact, `{x!r}` via
9//! `pycompat::py_repr_str`.
10
11use std::path::{Path, PathBuf};
12
13use crate::classify::classify;
14use crate::identity::identity_conflict;
15use crate::parse::{Artifact, Issue};
16use crate::pycompat::{is_re_digit, py_casefold, py_is_space, py_repr_str, py_splitlines, py_strip};
17use crate::spec::{spec_for, ArtifactSpec};
18
19pub const MAX_REQUIREMENTS: usize = 50;
20
21// ---------------------------------------------------------------------------
22// Small Python-semantics helpers
23// ---------------------------------------------------------------------------
24
25/// Python `str.title()` — titlecase the first char of each run of cased
26/// chars, lowercase the rest. (Reached only with ASCII section/type names.)
27pub fn py_title(s: &str) -> String {
28    let mut out = String::with_capacity(s.len());
29    let mut prev_cased = false;
30    for c in s.chars() {
31        let cased = c.is_alphabetic();
32        if cased && !prev_cased {
33            out.extend(c.to_uppercase());
34        } else if cased {
35            out.extend(c.to_lowercase());
36        } else {
37            out.push(c);
38        }
39        prev_cased = cased;
40    }
41    out
42}
43
44/// validation's `_first_value(body)`: first non-blank stripped line — NO
45/// list-marker stripping (distinct from identity's helper).
46fn first_value(body: &str) -> String {
47    crate::pycompat::first_nonempty_line(body).to_string()
48}
49
50fn is_word_char(c: char) -> bool {
51    crate::pycompat::is_re_word(c)
52}
53
54/// `re.findall` over `\b(word1|word2|...)\b` IGNORECASE (ASCII words):
55/// returns the matched substrings (original casing) in scan order.
56/// Alternation is tried in `words` order at each position, mirroring the
57/// regex engine.
58fn findall_words<'a>(text: &'a str, words: &[&str]) -> Vec<&'a str> {
59    let mut found = Vec::new();
60    let chars: Vec<(usize, char)> = text.char_indices().collect();
61    let n = chars.len();
62    let mut i = 0;
63    while i < n {
64        let at_boundary = i == 0 || !is_word_char(chars[i - 1].1);
65        if at_boundary {
66            let mut matched_len = 0usize; // in chars
67            for w in words {
68                let wlen = w.chars().count();
69                if i + wlen > n {
70                    continue;
71                }
72                let candidate: bool = w
73                    .chars()
74                    .zip(chars[i..i + wlen].iter().map(|(_, c)| *c))
75                    .all(|(wc, tc)| {
76                        tc == wc || (tc.is_ascii_alphabetic() && tc.to_ascii_lowercase() == wc)
77                    });
78                if candidate {
79                    // Trailing word boundary.
80                    if i + wlen == n || !is_word_char(chars[i + wlen].1) {
81                        matched_len = wlen;
82                        break;
83                    }
84                }
85            }
86            if matched_len > 0 {
87                let start = chars[i].0;
88                let end = if i + matched_len < n {
89                    chars[i + matched_len].0
90                } else {
91                    text.len()
92                };
93                found.push(&text[start..end]);
94                i += matched_len;
95                continue;
96            }
97        }
98        i += 1;
99    }
100    found
101}
102
103/// `_EARS_IF_RE = ^\s*if\b` (IGNORECASE, `re.search` — `^` anchors at the
104/// string start only, no MULTILINE).
105fn ears_if(text: &str) -> bool {
106    let rest = text.trim_start_matches(py_is_space);
107    let mut it = rest.chars();
108    match (it.next(), it.next()) {
109        (Some(a), Some(b))
110            if a.eq_ignore_ascii_case(&'i') && b.eq_ignore_ascii_case(&'f') =>
111        {
112            match it.next() {
113                Some(c) => !is_word_char(c),
114                None => true,
115            }
116        }
117        (Some(a), None) if a.eq_ignore_ascii_case(&'i') => false,
118        _ => false,
119    }
120}
121
122/// `\bthen\b` IGNORECASE search.
123fn has_then(text: &str) -> bool {
124    !findall_words(text, &["then"]).is_empty()
125}
126
127/// `_QUARTER_RE = ^Q[1-4]\s+\d{4}$` (`re.match`; `$` = end or before one
128/// trailing `\n`).
129fn quarter_match(text: &str) -> bool {
130    let t = text.strip_suffix('\n').unwrap_or(text);
131    let mut it = t.char_indices();
132    match it.next() {
133        Some((_, 'Q')) => {}
134        _ => return false,
135    }
136    match it.next() {
137        Some((_, c)) if ('1'..='4').contains(&c) => {}
138        _ => return false,
139    }
140    // \s+
141    let rest_start = match it.next() {
142        Some((i, c)) if py_is_space(c) => i,
143        _ => return false,
144    };
145    let rest = &t[rest_start..];
146    let after_ws = rest.trim_start_matches(py_is_space);
147    // \d{4}$
148    let digits: Vec<char> = after_ws.chars().collect();
149    digits.len() == 4 && digits.iter().all(|&c| is_re_digit(c))
150}
151
152// ---------------------------------------------------------------------------
153// Ticketing format-lint (ADR-087)
154// ---------------------------------------------------------------------------
155
156pub const TICKETING_SECTION: &str = "related tickets";
157
158/// `^https?://\S+$` — `\S` = not Python-whitespace.
159fn url_match(entry: &str) -> bool {
160    let rest = entry
161        .strip_prefix("https://")
162        .or_else(|| entry.strip_prefix("http://"));
163    match rest {
164        Some(rest) => !rest.is_empty() && rest.chars().all(|c| !py_is_space(c)),
165        None => false,
166    }
167}
168
169fn jira_key(e: &str) -> bool {
170    // ^[A-Z][A-Z0-9]+-\d+$
171    key_dash_digits(e, 2)
172}
173
174fn linear_key(e: &str) -> bool {
175    // ^[A-Z][A-Z0-9]*-\d+$
176    key_dash_digits(e, 1)
177}
178
179/// `^[A-Z][A-Z0-9]{min_key-1,}-\d+$` (both Jira/Linear shapes).
180fn key_dash_digits(e: &str, min_key: usize) -> bool {
181    let Some(dash) = e.find('-') else {
182        return false;
183    };
184    let (key, digits) = (&e[..dash], &e[dash + 1..]);
185    let kchars: Vec<char> = key.chars().collect();
186    if kchars.len() < min_key {
187        return false;
188    }
189    if !kchars[0].is_ascii_uppercase() {
190        return false;
191    }
192    if !kchars[1..]
193        .iter()
194        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit())
195    {
196        return false;
197    }
198    // NOTE: regex `-\d+$` — the FIRST dash found may not be the regex's:
199    // `[A-Z0-9]+` cannot contain '-', so the first '-' is the split point.
200    !digits.is_empty() && digits.chars().all(is_re_digit)
201}
202
203fn github_ref(e: &str) -> bool {
204    // ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+#\d+$
205    let Some(slash) = e.find('/') else {
206        return false;
207    };
208    let owner = &e[..slash];
209    let rest = &e[slash + 1..];
210    let Some(hash) = rest.find('#') else {
211        return false;
212    };
213    let repo = &rest[..hash];
214    let digits = &rest[hash + 1..];
215    let seg_ok = |s: &str| {
216        !s.is_empty()
217            && s.chars()
218                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-'))
219    };
220    seg_ok(owner) && seg_ok(repo) && !digits.is_empty() && digits.chars().all(is_re_digit)
221}
222
223fn ado_ref(e: &str) -> bool {
224    // ^(?:AB#)?\d+$
225    let rest = e.strip_prefix("AB#").unwrap_or(e);
226    !rest.is_empty() && rest.chars().all(is_re_digit)
227}
228
229fn servicenow_ref(e: &str) -> bool {
230    // ^[A-Z]{2,}\d{5,}$
231    let letters: usize = e.chars().take_while(|c| c.is_ascii_uppercase()).count();
232    if letters < 2 {
233        return false;
234    }
235    let rest: Vec<char> = e.chars().skip(letters).collect();
236    rest.len() >= 5 && rest.iter().all(|&c| is_re_digit(c))
237}
238
239type TicketValidator = fn(&str) -> bool;
240
241/// `(validator, label)` per recognised provider.
242fn ticketing_provider(name: &str) -> Option<(TicketValidator, &'static str)> {
243    match name {
244        "jira" => Some((jira_key, "Jira key (e.g. PROJ-1234) or URL")),
245        "github" => Some((github_ref, "GitHub issue (e.g. owner/repo#123) or URL")),
246        "linear" => Some((linear_key, "Linear key (e.g. ENG-123) or URL")),
247        "azure-devops" => Some((ado_ref, "Azure DevOps work item (e.g. 1234 or AB#1234) or URL")),
248        "servicenow" => Some((servicenow_ref, "ServiceNow record (e.g. INC0010023) or URL")),
249        _ => None,
250    }
251}
252
253// ---------------------------------------------------------------------------
254// validate() — the finding catalog
255// ---------------------------------------------------------------------------
256
257pub fn has_errors(issues: &[Issue]) -> bool {
258    issues.iter().any(|i| i.severity == "error")
259}
260
261/// `decided.core.validation.validate(product, ticketing_provider, artifact_type)`.
262pub fn validate(
263    artifact: &Artifact,
264    ticketing_provider_name: Option<&str>,
265    artifact_type: Option<&str>,
266) -> Vec<Issue> {
267    let artifact_type: String = match artifact_type {
268        Some(t) => t.to_string(),
269        None => classify(artifact).artifact_type,
270    };
271    let mut issues = validate_metadata(artifact, &artifact_type);
272    issues.extend(validate_ticketing_references(
273        artifact,
274        ticketing_provider_name,
275        &artifact_type,
276    ));
277    match artifact_type.as_str() {
278        "decision" => {
279            issues.extend(validate_decision(artifact));
280            issues.extend(crate::sentry::validate_artifact(artifact));
281        }
282        "roadmap" => issues.extend(validate_roadmap(artifact)),
283        "prompt" => issues.extend(validate_prompt(artifact)),
284        "design" => issues.extend(validate_design(artifact)),
285        "requirement" => {
286            let spec = spec_for("requirement").expect("requirement spec exists");
287            issues.extend(validate_requirement(artifact));
288            issues.extend(validate_status_metadata(artifact, spec));
289            issues.extend(validate_requirement_standards(artifact));
290        }
291        _ => issues.extend(validate_requirement(artifact)),
292    }
293    issues
294}
295
296fn validate_metadata(artifact: &Artifact, artifact_type: &str) -> Vec<Issue> {
297    let mut issues: Vec<Issue> = artifact.metadata_issues.clone();
298    issues.extend(artifact.parse_issues.iter().cloned());
299    let spec = spec_for(artifact_type);
300    if let Some((fm_id, legacy_id)) = identity_conflict(artifact, spec) {
301        issues.push(Issue::new(
302            "error",
303            "conflicting-identity",
304            format!(
305                "frontmatter id {} conflicts with declared legacy identity {}; \
306                 align them — RAC will not choose one",
307                py_repr_str(&fm_id),
308                py_repr_str(&legacy_id)
309            ),
310            None,
311        ));
312    }
313    issues
314}
315
316fn validate_ticketing_references(
317    artifact: &Artifact,
318    provider: Option<&str>,
319    artifact_type: &str,
320) -> Vec<Issue> {
321    let Some(provider) = provider else {
322        return Vec::new();
323    };
324    if provider.is_empty() || provider == "none" {
325        return Vec::new();
326    }
327    let Some((is_valid, label)) = ticketing_provider(provider) else {
328        return Vec::new();
329    };
330    let Some(spec) = spec_for(artifact_type) else {
331        return Vec::new();
332    };
333    if !spec.optional.iter().any(|s| s == TICKETING_SECTION) {
334        return Vec::new();
335    }
336    let body = artifact.section(TICKETING_SECTION).unwrap_or("");
337    let mut issues = Vec::new();
338    for line in py_splitlines(body) {
339        let entry =
340            py_strip(crate::identity::strip_list_marker(py_strip(line))).to_string();
341        if !entry.is_empty() && !url_match(&entry) && !is_valid(&entry) {
342            issues.push(Issue::new(
343                "error",
344                "malformed-ticket-reference",
345                format!(
346                    "## Related Tickets entry {} is not a valid {}.",
347                    py_repr_str(&entry),
348                    label
349                ),
350                None,
351            ));
352        }
353    }
354    issues
355}
356
357fn validate_status_metadata(artifact: &Artifact, spec: &ArtifactSpec) -> Vec<Issue> {
358    let mut issues = Vec::new();
359    for (field_name, allowed) in &spec.metadata {
360        let body = artifact.section(field_name).unwrap_or("");
361        let value = first_value(body);
362        if value.is_empty() {
363            continue;
364        }
365        let vf = py_casefold(&value);
366        if !allowed.iter().any(|a| py_casefold(a) == vf) {
367            issues.push(Issue::new(
368                "error",
369                &format!("invalid-{}-{}", spec.name, field_name),
370                format!(
371                    "## {} value {} is not one of: {}.",
372                    py_title(field_name),
373                    py_repr_str(&value),
374                    allowed.join(", ")
375                ),
376                None,
377            ));
378        }
379    }
380    issues
381}
382
383fn validate_title(artifact: &Artifact) -> Vec<Issue> {
384    let mut issues = Vec::new();
385    let missing = match &artifact.product.title {
386        None => true,
387        Some(t) => t.is_empty(),
388    };
389    if missing {
390        issues.push(Issue::new(
391            "error",
392            "missing-title",
393            "File has no top-level # title.".to_string(),
394            None,
395        ));
396    }
397    if !artifact.product.extra_title_lines.is_empty() {
398        issues.push(Issue::new(
399            "error",
400            "multiple-titles",
401            "File has more than one top-level # title; expected exactly one.".to_string(),
402            Some(artifact.product.extra_title_lines[0]),
403        ));
404    }
405    issues
406}
407
408fn validate_required_sections(artifact: &Artifact, spec: &ArtifactSpec) -> Vec<Issue> {
409    let mut issues = Vec::new();
410    for section in &spec.required {
411        if !artifact.has_section(section) {
412            issues.push(Issue::new(
413                "error",
414                &format!("missing-{}", section.replace(' ', "-")),
415                format!(
416                    "{} is missing a ## {} section.",
417                    py_title(&spec.name),
418                    py_title(section)
419                ),
420                None,
421            ));
422        }
423    }
424    issues
425}
426
427fn validate_decision(artifact: &Artifact) -> Vec<Issue> {
428    let spec = spec_for("decision").expect("decision spec exists");
429    let mut issues = validate_title(artifact);
430    issues.extend(validate_required_sections(artifact, spec));
431    issues.extend(validate_status_metadata(artifact, spec));
432    issues
433}
434
435fn validate_roadmap(artifact: &Artifact) -> Vec<Issue> {
436    let spec = spec_for("roadmap").expect("roadmap spec exists");
437    let mut issues = validate_title(artifact);
438    issues.extend(validate_required_sections(artifact, spec));
439
440    let horizon = first_value(artifact.section("horizon").unwrap_or(""));
441    if !horizon.is_empty() {
442        let hf = py_casefold(&horizon);
443        if hf != "now" && hf != "next" && hf != "later" && !quarter_match(&horizon) {
444            issues.push(Issue::new(
445                "error",
446                "invalid-roadmap-horizon",
447                format!(
448                    "## Horizon value {} is not one of: now, next, later, \
449                     or a quarter (e.g. Q3 2026).",
450                    py_repr_str(&horizon)
451                ),
452                None,
453            ));
454        }
455    }
456
457    if !artifact.has_section("related requirements") && !artifact.has_section("related decisions") {
458        issues.push(Issue::new(
459            "warning",
460            "roadmap-no-advancement-link",
461            "Roadmap links no ## Related Requirements or ## Related Decisions it advances."
462                .to_string(),
463            None,
464        ));
465    }
466
467    issues.extend(validate_status_metadata(artifact, spec));
468    issues
469}
470
471fn validate_prompt(artifact: &Artifact) -> Vec<Issue> {
472    let spec = spec_for("prompt").expect("prompt spec exists");
473    let mut issues = validate_title(artifact);
474    issues.extend(validate_required_sections(artifact, spec));
475    issues.extend(validate_status_metadata(artifact, spec));
476    issues
477}
478
479fn validate_design(artifact: &Artifact) -> Vec<Issue> {
480    let spec = spec_for("design").expect("design spec exists");
481    let mut issues = validate_title(artifact);
482    issues.extend(validate_required_sections(artifact, spec));
483    issues.extend(validate_status_metadata(artifact, spec));
484    issues
485}
486
487/// `_report_duplicates`: one issue per duplicated key, at the first
488/// occurrence, in document order.
489fn report_duplicates<K: Fn(&crate::markdown::Requirement) -> String>(
490    requirements: &[crate::markdown::Requirement],
491    key: K,
492    severity: &'static str,
493    code: &str,
494    message: impl Fn(&crate::markdown::Requirement, usize) -> String,
495) -> Vec<Issue> {
496    let keys: Vec<String> = requirements.iter().map(&key).collect();
497    let mut issues = Vec::new();
498    let mut seen: Vec<&str> = Vec::new();
499    for (idx, r) in requirements.iter().enumerate() {
500        let k = keys[idx].as_str();
501        let count = keys.iter().filter(|x| x.as_str() == k).count();
502        if count > 1 && !seen.contains(&k) {
503            seen.push(k);
504            issues.push(Issue::new(severity, code, message(r, count), Some(r.line)));
505        }
506    }
507    issues
508}
509
510fn malformed_requirement_issues(artifact: &Artifact) -> Vec<Issue> {
511    let mut issues = Vec::new();
512    for m in &artifact.product.malformed_requirements {
513        match &m.bad_id {
514            None => issues.push(Issue::new(
515                "error",
516                "req-missing-id",
517                format!(
518                    "Requirement line has no [REQ-NNN] ID: {}",
519                    py_repr_str(&m.raw)
520                ),
521                Some(m.line),
522            )),
523            Some(bad_id) if m.empty_text => issues.push(Issue::new(
524                "error",
525                "empty-req-text",
526                format!("Requirement [{bad_id}] has no description text."),
527                Some(m.line),
528            )),
529            Some(bad_id) => issues.push(Issue::new(
530                "error",
531                "malformed-req-id",
532                format!("Malformed requirement ID [{bad_id}]; expected form [REQ-NNN]."),
533                Some(m.line),
534            )),
535        }
536    }
537    issues
538}
539
540fn requirement_warning_issues(artifact: &Artifact) -> Vec<Issue> {
541    let p = &artifact.product;
542    let mut issues = Vec::new();
543
544    if !p.has_metrics_section {
545        issues.push(Issue::new(
546            "warning",
547            "missing-success-metrics",
548            "No ## Success Metrics section (optional, but recommended).".to_string(),
549            None,
550        ));
551    }
552    if !p.has_risks_section {
553        issues.push(Issue::new(
554            "warning",
555            "missing-risks",
556            "No ## Risks section (optional, but recommended).".to_string(),
557            None,
558        ));
559    }
560
561    if p.has_problem_section && py_strip(p.problem.as_deref().unwrap_or("")).is_empty() {
562        issues.push(Issue::new(
563            "warning",
564            "empty-problem",
565            "## Problem section is empty.".to_string(),
566            None,
567        ));
568    }
569
570    if p.requirements.len() > MAX_REQUIREMENTS {
571        issues.push(Issue::new(
572            "warning",
573            "too-many-requirements",
574            format!(
575                "{} requirements (more than {MAX_REQUIREMENTS}); consider splitting the feature.",
576                p.requirements.len()
577            ),
578            None,
579        ));
580    }
581
582    issues.extend(report_duplicates(
583        &p.requirements,
584        |r| py_casefold(py_strip(&r.text)),
585        "warning",
586        "duplicate-req-text",
587        |r, _n| format!("Duplicate requirement text: {}.", py_repr_str(&r.text)),
588    ));
589
590    issues.extend(ambiguous_verb_issues(artifact));
591    issues
592}
593
594const AMBIGUOUS_VERBS: [&str; 4] = ["support", "handle", "allow", "enable"];
595
596fn ambiguous_verb_issues(artifact: &Artifact) -> Vec<Issue> {
597    let mut issues = Vec::new();
598    for r in &artifact.product.requirements {
599        let found = findall_words(&r.text, &AMBIGUOUS_VERBS);
600        if !found.is_empty() {
601            let mut unique: Vec<String> = Vec::new();
602            for v in &found {
603                let lower = v.to_lowercase();
604                if !unique.contains(&lower) {
605                    unique.push(lower);
606                }
607            }
608            unique.sort();
609            issues.push(Issue::new(
610                "warning",
611                "ambiguous-verb",
612                format!(
613                    "{} uses ambiguous verb(s) ({}); be more specific.",
614                    r.id,
615                    unique.join(", ")
616                ),
617                Some(r.line),
618            ));
619        }
620    }
621    issues
622}
623
624fn validate_requirement(artifact: &Artifact) -> Vec<Issue> {
625    let p = &artifact.product;
626    let mut issues = validate_title(artifact);
627
628    if !p.has_problem_section {
629        issues.push(Issue::new(
630            "error",
631            "missing-problem",
632            "File is missing a ## Problem section.".to_string(),
633            None,
634        ));
635    }
636    if !p.has_requirements_section {
637        issues.push(Issue::new(
638            "error",
639            "missing-requirements",
640            "File is missing a ## Requirements section.".to_string(),
641            None,
642        ));
643    }
644
645    issues.extend(malformed_requirement_issues(artifact));
646    issues.extend(report_duplicates(
647        &p.requirements,
648        |r| r.id.clone(),
649        "error",
650        "duplicate-req-id",
651        |r, n| format!("Duplicate requirement ID {} (used {} times).", r.id, n),
652    ));
653    issues.extend(requirement_warning_issues(artifact));
654    issues
655}
656
657const NORMATIVE_KEYWORDS: [&str; 3] = ["shall", "must", "should"];
658
659fn validate_requirement_standards(artifact: &Artifact) -> Vec<Issue> {
660    let mut issues = Vec::new();
661    for r in &artifact.product.requirements {
662        let keywords = findall_words(&r.text, &NORMATIVE_KEYWORDS);
663
664        // BCP-14: `sorted({k for k in keywords if k != k.upper()})`.
665        let mut ambiguous: Vec<&str> = Vec::new();
666        for k in &keywords {
667            if *k != k.to_uppercase() && !ambiguous.contains(k) {
668                ambiguous.push(k);
669            }
670        }
671        ambiguous.sort();
672        if !ambiguous.is_empty() {
673            issues.push(Issue::new(
674                "error",
675                "requirement-normative-keyword",
676                format!(
677                    "{} uses non-normative {}; only uppercase MUST/SHALL/SHOULD/MAY \
678                     carry normative weight (BCP 14).",
679                    r.id,
680                    py_repr_str(&ambiguous.join(", "))
681                ),
682                Some(r.line),
683            ));
684        }
685
686        if keywords.len() > 1 {
687            issues.push(Issue::new(
688                "warning",
689                "requirement-not-singular",
690                format!(
691                    "{} has {} normative keywords; a requirement should be singular \
692                     (ISO/IEC/IEEE 29148).",
693                    r.id,
694                    keywords.len()
695                ),
696                Some(r.line),
697            ));
698        }
699
700        if keywords.is_empty() {
701            issues.push(Issue::new(
702                "warning",
703                "requirement-non-ears",
704                format!(
705                    "{} has no normative keyword (SHALL/SHOULD/MAY); it does not \
706                     state a testable requirement (EARS).",
707                    r.id
708                ),
709                Some(r.line),
710            ));
711        } else if ears_if(&r.text) && !has_then(&r.text) {
712            issues.push(Issue::new(
713                "warning",
714                "requirement-ears-clause",
715                format!(
716                    "{} opens with 'If' but has no 'then' response clause \
717                     (EARS unwanted-behaviour pattern: If <condition> then <system> SHALL \u{2026}).",
718                    r.id
719                ),
720                Some(r.line),
721            ));
722        }
723    }
724    issues
725}
726
727// ---------------------------------------------------------------------------
728// Severity overrides (ADR-053)
729// ---------------------------------------------------------------------------
730
731#[derive(Debug, Clone, Default)]
732pub struct SeverityOverrides {
733    /// rule code -> error | warning | off
734    pub rules: Vec<(String, String)>,
735    /// artifact type -> error | warning
736    pub types: Vec<(String, String)>,
737}
738
739impl SeverityOverrides {
740    pub fn is_empty(&self) -> bool {
741        self.rules.is_empty() && self.types.is_empty()
742    }
743
744    fn rule(&self, code: &str) -> Option<&str> {
745        self.rules
746            .iter()
747            .find(|(k, _)| k == code)
748            .map(|(_, v)| v.as_str())
749    }
750
751    fn type_ceiling(&self, artifact_type: &str) -> Option<&str> {
752        self.types
753            .iter()
754            .find(|(k, _)| k == artifact_type)
755            .map(|(_, v)| v.as_str())
756    }
757}
758
759/// `resolve_severity(base, code, type, overrides)`.
760pub fn resolve_severity<'a>(
761    base: &'a str,
762    code: &str,
763    artifact_type: &str,
764    overrides: &'a SeverityOverrides,
765) -> &'a str {
766    let mut sev = base;
767    if overrides.type_ceiling(artifact_type) == Some("warning") && sev == "error" {
768        sev = "warning";
769    }
770    if let Some(rule) = overrides.rule(code) {
771        sev = rule;
772    }
773    sev
774}
775
776/// `apply_overrides(issues, artifact_type, overrides)`.
777pub fn apply_overrides(
778    issues: Vec<Issue>,
779    artifact_type: &str,
780    overrides: &SeverityOverrides,
781) -> Vec<Issue> {
782    if overrides.is_empty() {
783        return issues;
784    }
785    let mut out = Vec::with_capacity(issues.len());
786    for mut issue in issues {
787        let sev = resolve_severity(issue.severity, &issue.code, artifact_type, overrides);
788        if sev == "off" {
789            continue;
790        }
791        issue.severity = if sev == "error" { "error" } else { "warning" };
792        out.push(issue);
793    }
794    out
795}
796
797// ---------------------------------------------------------------------------
798// OKF conformance (ADR-048)
799// ---------------------------------------------------------------------------
800
801pub const OKF_TYPES: [&str; 5] = ["requirement", "decision", "design", "roadmap", "prompt"];
802pub const RESERVED_FILENAMES: [&str; 2] = ["index.md", "log.md"];
803
804#[derive(Debug, Clone)]
805pub struct OkfFinding {
806    pub code: String,
807    pub path: String,
808    pub message: String,
809    pub severity: String,
810}
811
812#[derive(Debug, Clone)]
813pub struct OkfConformanceReport {
814    pub artifacts_checked: usize,
815    pub findings: Vec<OkfFinding>,
816}
817
818impl OkfConformanceReport {
819    pub fn ok(&self) -> bool {
820        !self.findings.iter().any(|f| f.severity == "error")
821    }
822}
823
824/// One walked corpus entry's OKF projection: `(display path, artifact type,
825/// final filename)`.
826pub struct OkfEntry<'a> {
827    pub path: &'a str,
828    pub artifact_type: &'a str,
829    pub file_name: &'a str,
830}
831
832pub fn check_okf_conformance(
833    entries: &[OkfEntry<'_>],
834    overrides: &SeverityOverrides,
835) -> OkfConformanceReport {
836    let mut findings = Vec::new();
837    let mut checked = 0usize;
838    for entry in entries {
839        if spec_for(entry.artifact_type).is_none() {
840            continue;
841        }
842        checked += 1;
843        if !OKF_TYPES.contains(&entry.artifact_type) {
844            add_okf(
845                &mut findings,
846                "okf-unmapped-type",
847                entry.path,
848                format!(
849                    "artifact type {} has no OKF type mapping; add it to \
850                     decided.core.okf.OKF_TYPE so the artifact is carried in the \
851                     OKF bundle (ADR-048)",
852                    py_repr_str(entry.artifact_type)
853                ),
854                entry.artifact_type,
855                overrides,
856            );
857        }
858        if RESERVED_FILENAMES.contains(&entry.file_name) {
859            add_okf(
860                &mut findings,
861                "okf-reserved-filename-collision",
862                entry.path,
863                format!(
864                    "a typed artifact named {} collides with the generated OKF \
865                     bundle entry point; rename the file — OKF reserves index.md \
866                     and log.md (ADR-048)",
867                    py_repr_str(entry.file_name)
868                ),
869                entry.artifact_type,
870                overrides,
871            );
872        }
873    }
874    OkfConformanceReport {
875        artifacts_checked: checked,
876        findings,
877    }
878}
879
880fn add_okf(
881    findings: &mut Vec<OkfFinding>,
882    code: &str,
883    path: &str,
884    message: String,
885    artifact_type: &str,
886    overrides: &SeverityOverrides,
887) {
888    let severity = resolve_severity("error", code, artifact_type, overrides);
889    if severity == "off" {
890        return;
891    }
892    findings.push(OkfFinding {
893        code: code.to_string(),
894        path: path.to_string(),
895        message,
896        severity: severity.to_string(),
897    });
898}
899
900// ---------------------------------------------------------------------------
901// .decided/config.yaml loaders (decided.services.init)
902// ---------------------------------------------------------------------------
903
904/// `find_config_file(start_dir)`: the nearest `.decided/config.yaml` at or above
905/// the resolved `start_dir`.
906pub fn find_config_file(start_dir: &str) -> Option<PathBuf> {
907    let resolved = resolve_path(start_dir);
908    let mut current: Option<&Path> = Some(resolved.as_path());
909    while let Some(dir) = current {
910        let candidate = dir.join(".decided").join("config.yaml");
911        if candidate.is_file() {
912            return Some(candidate);
913        }
914        current = dir.parent();
915    }
916    None
917}
918
919/// Python `Path(p).resolve()` approximation: canonicalize when possible,
920/// else absolutize against the CWD (non-strict resolve of a missing path).
921fn resolve_path(p: &str) -> PathBuf {
922    if let Ok(c) = std::fs::canonicalize(p) {
923        return c;
924    }
925    let path = Path::new(p);
926    if path.is_absolute() {
927        path.to_path_buf()
928    } else {
929        std::env::current_dir()
930            .unwrap_or_else(|_| PathBuf::from("/"))
931            .join(path)
932    }
933}
934
935use crate::frontmatter::{load_frontmatter_mapping, Yaml};
936
937fn yaml_map_get<'a>(pairs: &'a [(Yaml, Yaml)], name: &str) -> Option<&'a Yaml> {
938    pairs.iter().find_map(|(k, v)| match k {
939        Yaml::Str(s) if s == name => Some(v),
940        _ => None,
941    })
942}
943
944fn load_config_mapping(start_dir: &str) -> Option<Vec<(Yaml, Yaml)>> {
945    let config_path = find_config_file(start_dir)?;
946    let text = std::fs::read_to_string(&config_path).ok()?;
947    // The oracle uses full `yaml.safe_load`; the bounded frontmatter loader
948    // covers the well-formed configs the parity corpus contains. (A config
949    // exercising PyYAML beyond the bounded subset would be a divergence to
950    // fix here, not silently accept.)
951    let (pairs, _issues) = load_frontmatter_mapping(&text);
952    pairs
953}
954
955/// Coerce a YAML 1.1 severity value: bare `off` parses as Bool(false).
956fn severity_value(v: &Yaml) -> Option<String> {
957    match v {
958        Yaml::Bool(false) => Some("off".to_string()),
959        Yaml::Bool(true) => Some("on".to_string()),
960        Yaml::Str(s) => Some(s.clone()),
961        _ => None,
962    }
963}
964
965fn parse_severity_map(section: Option<&Yaml>, allowed: &[&str]) -> Vec<(String, String)> {
966    let Some(Yaml::Map(pairs)) = section else {
967        return Vec::new();
968    };
969    let mut out = Vec::new();
970    for (k, v) in pairs {
971        let Yaml::Str(name) = k else { continue };
972        let Some(sev) = severity_value(v) else {
973            continue;
974        };
975        if allowed.contains(&sev.as_str()) {
976            out.push((name.clone(), sev));
977        }
978    }
979    out
980}
981
982/// `load_overrides(start_dir)` (ADR-053).
983pub fn load_overrides(start_dir: &str) -> SeverityOverrides {
984    let Some(pairs) = load_config_mapping(start_dir) else {
985        return SeverityOverrides::default();
986    };
987    let Some(Yaml::Map(section)) = yaml_map_get(&pairs, "validation") else {
988        return SeverityOverrides::default();
989    };
990    SeverityOverrides {
991        rules: parse_severity_map(
992            yaml_map_get(section, "rules"),
993            &["error", "warning", "off"],
994        ),
995        types: parse_severity_map(yaml_map_get(section, "types"), &["error", "warning"]),
996    }
997}
998
999/// `load_freshness_threshold(start_dir)` (ADR-045): the
1000/// `freshness.stale_after_days` from the nearest `.decided/config.yaml`.
1001/// Defaults to 180 when there is no config, no `freshness` mapping, or the
1002/// value is not a positive int — YAML 1.1 bools are explicitly rejected
1003/// (`true`/`false` are not day counts even though `bool` is an `int`
1004/// subclass in Python).
1005pub fn load_freshness_threshold(start_dir: &str) -> i64 {
1006    const DEFAULT: i64 = 180;
1007    let Some(pairs) = load_config_mapping(start_dir) else {
1008        return DEFAULT;
1009    };
1010    let Some(Yaml::Map(section)) = yaml_map_get(&pairs, "freshness") else {
1011        return DEFAULT;
1012    };
1013    match yaml_map_get(section, "stale_after_days") {
1014        Some(Yaml::Int(v)) if *v > 0 => *v,
1015        _ => DEFAULT,
1016    }
1017}
1018
1019/// `load_ticketing_provider(start_dir)` (ADR-088).
1020pub fn load_ticketing_provider(start_dir: &str) -> Option<String> {
1021    let pairs = load_config_mapping(start_dir)?;
1022    let Some(Yaml::Map(section)) = yaml_map_get(&pairs, "ticketing") else {
1023        return None;
1024    };
1025    match yaml_map_get(section, "provider") {
1026        Some(Yaml::Str(provider)) => Some(provider.clone()),
1027        _ => None,
1028    }
1029}
1030
1031/// `repository_root(directory)` (scope_paths): nearest dir at or above the
1032/// resolved directory holding `.decided/config.yaml`, else the resolved dir.
1033pub fn repository_root(directory: &str) -> PathBuf {
1034    let resolved = resolve_path(directory);
1035    let mut current: Option<&Path> = Some(resolved.as_path());
1036    while let Some(dir) = current {
1037        if dir.join(".decided").join("config.yaml").is_file() {
1038            return dir.to_path_buf();
1039        }
1040        current = dir.parent();
1041    }
1042    resolved
1043}
1044
1045/// `validate_product(product, start)` — classification-dispatched rules with
1046/// the repository's severity overrides applied.
1047pub fn validate_product(artifact: &Artifact, start: &str) -> Vec<Issue> {
1048    let artifact_type = classify(artifact).artifact_type;
1049    let provider = load_ticketing_provider(start);
1050    let issues = validate(artifact, provider.as_deref(), Some(&artifact_type));
1051    apply_overrides(issues, &artifact_type, &load_overrides(start))
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056    use super::*;
1057
1058    #[test]
1059    fn word_boundaries() {
1060        assert_eq!(
1061            findall_words("MUST support export", &AMBIGUOUS_VERBS),
1062            vec!["support"]
1063        );
1064        assert!(findall_words("supports and handles stuff", &AMBIGUOUS_VERBS).is_empty());
1065        assert_eq!(
1066            findall_words("Shall we MUST?", &NORMATIVE_KEYWORDS),
1067            vec!["Shall", "MUST"]
1068        );
1069    }
1070
1071    #[test]
1072    fn ears_and_quarter() {
1073        assert!(ears_if("  If the input is bad"));
1074        assert!(ears_if("if x"));
1075        assert!(!ears_if("iffy"));
1076        assert!(quarter_match("Q3 2026"));
1077        assert!(!quarter_match("Q5 2026"));
1078        assert!(!quarter_match("Q3 26"));
1079    }
1080
1081    #[test]
1082    fn ticket_shapes() {
1083        assert!(jira_key("PROJ-1234"));
1084        assert!(!jira_key("P-1"));
1085        assert!(linear_key("P-1"));
1086        assert!(github_ref("owner/repo#123"));
1087        assert!(!github_ref("owner/repo/123"));
1088        assert!(ado_ref("AB#1234") && ado_ref("1234"));
1089        assert!(servicenow_ref("INC0010023"));
1090        assert!(url_match("https://x.example/y"));
1091        assert!(!url_match("https://"));
1092    }
1093
1094    #[test]
1095    fn title_case() {
1096        assert_eq!(py_title("user need"), "User Need");
1097        assert_eq!(py_title("status"), "Status");
1098        assert_eq!(py_title("it's"), "It'S");
1099    }
1100}