1use 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
21pub 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
44fn 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
54fn 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; 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 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
103fn 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
122fn has_then(text: &str) -> bool {
124 !findall_words(text, &["then"]).is_empty()
125}
126
127fn 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 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 let digits: Vec<char> = after_ws.chars().collect();
149 digits.len() == 4 && digits.iter().all(|&c| is_re_digit(c))
150}
151
152pub const TICKETING_SECTION: &str = "related tickets";
157
158fn 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 key_dash_digits(e, 2)
172}
173
174fn linear_key(e: &str) -> bool {
175 key_dash_digits(e, 1)
177}
178
179fn 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 !digits.is_empty() && digits.chars().all(is_re_digit)
201}
202
203fn github_ref(e: &str) -> bool {
204 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 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 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
241fn 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
253pub fn has_errors(issues: &[Issue]) -> bool {
258 issues.iter().any(|i| i.severity == "error")
259}
260
261pub 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
487fn 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 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#[derive(Debug, Clone, Default)]
732pub struct SeverityOverrides {
733 pub rules: Vec<(String, String)>,
735 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
759pub 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
776pub 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
797pub 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
824pub 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
900pub 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
919fn 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 let (pairs, _issues) = load_frontmatter_mapping(&text);
952 pairs
953}
954
955fn 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
982pub 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
999pub 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
1019pub 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
1031pub 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
1045pub 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}