Skip to main content

ito_core/validate/
mod.rs

1//! Validate Ito repository artifacts.
2//!
3//! This module provides lightweight validation helpers for specs, changes, and
4//! modules.
5//!
6//! The primary consumer is the CLI and any APIs that need a structured report
7//! (`ValidationReport`) rather than a single error.
8
9use std::path::{Path, PathBuf};
10
11use crate::error_bridge::IntoCoreResult;
12use crate::errors::{CoreError, CoreResult};
13use serde::Serialize;
14
15use ito_common::paths;
16
17use crate::show::{parse_change_show_json, parse_spec_show_json, read_change_delta_spec_files};
18use crate::templates::{
19    ResolvedSchema, ValidationLevelYaml, ValidationYaml, ValidatorId, artifact_done,
20    load_schema_validation, read_change_schema, resolve_schema,
21};
22use ito_config::ConfigContext;
23use ito_domain::changes::ChangeRepository as DomainChangeRepository;
24use ito_domain::modules::ModuleRepository as DomainModuleRepository;
25
26mod delta_rules;
27mod domain_discovery_rules;
28mod format_specs;
29mod issue;
30mod repo_integrity;
31mod report;
32mod rules_engine;
33mod tracking_rules;
34
35pub(crate) use issue::with_format_spec;
36pub use issue::{error, info, issue, warning, with_line, with_loc, with_metadata, with_rule_id};
37pub use repo_integrity::validate_change_dirs_repo_integrity;
38pub use report::{ReportBuilder, report};
39
40/// Severity level for a [`ValidationIssue`].
41pub type ValidationLevel = &'static str;
42
43/// Validation issue is an error (always fails validation).
44pub const LEVEL_ERROR: ValidationLevel = "ERROR";
45/// Validation issue is a warning (fails validation in strict mode).
46pub const LEVEL_WARNING: ValidationLevel = "WARNING";
47/// Validation issue is informational (never fails validation).
48pub const LEVEL_INFO: ValidationLevel = "INFO";
49
50// Thresholds: match TS defaults.
51const MIN_PURPOSE_LENGTH: usize = 50;
52const MIN_MODULE_PURPOSE_LENGTH: usize = 20;
53const MAX_DELTAS_PER_CHANGE: usize = 10;
54const DELTA_REQUIREMENT_HEADINGS: &[&str] = &[
55    "## ADDED Requirements",
56    "## MODIFIED Requirements",
57    "## REMOVED Requirements",
58    "## RENAMED Requirements",
59];
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
62/// One validation finding.
63pub struct ValidationIssue {
64    /// Issue severity.
65    pub level: String,
66    /// Logical path within the validated artifact (or a filename).
67    pub path: String,
68    /// Human-readable message.
69    pub message: String,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    /// Optional 1-based line number.
72    pub line: Option<u32>,
73    #[serde(skip_serializing_if = "Option::is_none")]
74    /// Optional 1-based column number.
75    pub column: Option<u32>,
76    #[serde(skip_serializing_if = "Option::is_none")]
77    /// Optional rule id when the issue came from an opt-in rule.
78    pub rule_id: Option<String>,
79    #[serde(skip_serializing_if = "Option::is_none")]
80    /// Optional structured metadata for tooling.
81    pub metadata: Option<serde_json::Value>,
82}
83
84#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85/// A validation report with a computed summary.
86pub struct ValidationReport {
87    /// Whether validation passed for the selected strictness.
88    pub valid: bool,
89
90    /// All issues found (errors + warnings + info).
91    pub issues: Vec<ValidationIssue>,
92
93    /// Counts grouped by severity.
94    pub summary: ValidationSummary,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
98/// Aggregated counts for a validation run.
99pub struct ValidationSummary {
100    /// Number of `ERROR` issues.
101    pub errors: u32,
102    /// Number of `WARNING` issues.
103    pub warnings: u32,
104    /// Number of `INFO` issues.
105    pub info: u32,
106}
107
108impl ValidationReport {
109    /// Construct a report and compute summary + `valid`.
110    ///
111    /// When `strict` is `true`, warnings are treated as failures.
112    pub fn new(issues: Vec<ValidationIssue>, strict: bool) -> Self {
113        let mut errors = 0u32;
114        let mut warnings = 0u32;
115        let mut info = 0u32;
116        for i in &issues {
117            match i.level.as_str() {
118                LEVEL_ERROR => errors += 1,
119                LEVEL_WARNING => warnings += 1,
120                LEVEL_INFO => info += 1,
121                _ => {}
122            }
123        }
124        let valid = if strict {
125            errors == 0 && warnings == 0
126        } else {
127            errors == 0
128        };
129        Self {
130            valid,
131            issues,
132            summary: ValidationSummary {
133                errors,
134                warnings,
135                info,
136            },
137        }
138    }
139}
140
141/// Validate a spec markdown string and return a structured report.
142///
143/// Main specs represent current truth, so delta-operation headings are reported
144/// as formatting findings rather than being accepted as canonical structure.
145/// In strict mode, those findings are errors instead of warnings.
146pub fn validate_spec_markdown(markdown: &str, strict: bool) -> ValidationReport {
147    let json = parse_spec_show_json("<spec>", markdown);
148
149    let mut r = report(strict);
150
151    for (idx, line) in markdown.lines().enumerate() {
152        let heading = line.trim();
153        if DELTA_REQUIREMENT_HEADINGS.contains(&heading) {
154            let message = format!(
155                "Main specs must use '## Requirements' instead of delta operation headings like '{heading}'"
156            );
157            let finding = if strict {
158                error("format", message)
159            } else {
160                warning("format", message)
161            };
162            r.push(with_line(finding, (idx + 1) as u32));
163        }
164    }
165
166    if json.overview.trim().is_empty() {
167        r.push(error("purpose", "Purpose section cannot be empty"));
168    } else if json.overview.len() < MIN_PURPOSE_LENGTH {
169        r.push(warning(
170            "purpose",
171            "Purpose section is too brief (less than 50 characters)",
172        ));
173    }
174
175    if json.requirements.is_empty() {
176        r.push(error(
177            "requirements",
178            "Spec must have at least one requirement",
179        ));
180    }
181
182    for (idx, req) in json.requirements.iter().enumerate() {
183        let path = format!("requirements[{idx}]");
184        if req.text.trim().is_empty() {
185            r.push(error(&path, "Requirement text cannot be empty"));
186        }
187        if req.scenarios.is_empty() {
188            r.push(error(&path, "Requirement must have at least one scenario"));
189        }
190        for (sidx, sc) in req.scenarios.iter().enumerate() {
191            let sp = format!("{path}.scenarios[{sidx}]");
192            if sc.raw_text.trim().is_empty() {
193                r.push(error(&sp, "Scenario text cannot be empty"));
194            }
195        }
196    }
197
198    r.finish()
199}
200
201/// Validate a spec by id from `.ito/specs/<id>/spec.md`.
202pub fn validate_spec(ito_path: &Path, spec_id: &str, strict: bool) -> CoreResult<ValidationReport> {
203    let path = paths::spec_markdown_path(ito_path, spec_id);
204    let markdown = ito_common::io::read_to_string_std(&path)
205        .map_err(|e| CoreError::io(format!("reading spec {}", spec_id), e))?;
206    Ok(validate_spec_markdown(&markdown, strict))
207}
208
209/// Validate a change using schema-driven rules when available, with legacy
210/// delta/task fallback for older schemas.
211pub fn validate_change(
212    change_repo: &(impl DomainChangeRepository + ?Sized),
213    ito_path: &Path,
214    change_id: &str,
215    strict: bool,
216) -> CoreResult<ValidationReport> {
217    let mut rep = report(strict);
218
219    let (ctx, schema_name) = resolve_validation_context(ito_path, change_id);
220
221    let resolved = match resolve_schema(Some(&schema_name), &ctx) {
222        Ok(s) => {
223            rep.push(info(
224                "schema",
225                format!(
226                    "Resolved schema '{}' from {}",
227                    s.schema.name,
228                    s.source.as_str()
229                ),
230            ));
231            Some(s)
232        }
233        Err(e) => {
234            rep.push(error(
235                "schema",
236                format!("Failed to resolve schema '{schema_name}': {e}"),
237            ));
238            None
239        }
240    };
241
242    if let Some(resolved) = &resolved {
243        match load_schema_validation(resolved) {
244            Ok(Some(validation)) => {
245                rep.push(info("schema.validation", "Using schema validation.yaml"));
246                validate_change_against_schema_validation(
247                    &mut rep,
248                    change_repo,
249                    ito_path,
250                    change_id,
251                    resolved,
252                    &validation,
253                    strict,
254                )?;
255                return Ok(rep.finish());
256            }
257            Ok(None) => {}
258            Err(e) => {
259                rep.push(error(
260                    "schema.validation",
261                    format!("Failed to load schema validation.yaml: {e}"),
262                ));
263                return Ok(rep.finish());
264            }
265        }
266
267        if is_legacy_delta_schema(&resolved.schema.name) {
268            validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
269
270            let tracks_rel = resolved
271                .schema
272                .apply
273                .as_ref()
274                .and_then(|a| a.tracks.as_deref())
275                .unwrap_or("tasks.md");
276
277            if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
278                rep.push(error(
279                    "tracking",
280                    format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
281                ));
282                return Ok(rep.finish());
283            }
284
285            let report_path = format!("changes/{change_id}/{tracks_rel}");
286            let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
287            rep.extend(validate_tasks_tracking_path(
288                &abs_path,
289                &report_path,
290                strict,
291            ));
292            return Ok(rep.finish());
293        }
294
295        rep.push(info(
296            "schema.validation",
297            "Schema has no validation.yaml; manual validation required",
298        ));
299        validate_apply_required_artifacts(&mut rep, ito_path, change_id, resolved);
300        return Ok(rep.finish());
301    }
302
303    validate_change_delta_specs(&mut rep, change_repo, change_id, strict)?;
304    Ok(rep.finish())
305}
306
307/// Returns true for built-in schemas that predate schema-driven `validation.yaml`.
308fn is_legacy_delta_schema(schema_name: &str) -> bool {
309    schema_name == "spec-driven" || schema_name == "tdd"
310}
311
312fn required_schema_artifact_ids(resolved: &ResolvedSchema) -> Vec<String> {
313    let mut ids = Vec::new();
314    for a in &resolved.schema.artifacts {
315        if !a.optional {
316            ids.push(a.id.clone());
317        }
318    }
319    ids
320}
321
322fn validate_apply_required_artifacts(
323    rep: &mut ReportBuilder,
324    ito_path: &Path,
325    change_id: &str,
326    resolved: &ResolvedSchema,
327) {
328    let change_dir = paths::change_dir(ito_path, change_id);
329    if !change_dir.exists() {
330        rep.push(error(
331            "change",
332            format!("Change directory not found: changes/{change_id}"),
333        ));
334        return;
335    }
336
337    let required_ids: Vec<String> = match resolved.schema.apply.as_ref() {
338        Some(apply) => apply
339            .requires
340            .clone()
341            .unwrap_or_else(|| required_schema_artifact_ids(resolved)),
342        None => required_schema_artifact_ids(resolved),
343    };
344
345    for id in required_ids {
346        let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
347            rep.push(error(
348                "schema.validation",
349                format!("Schema apply.requires references unknown artifact id '{id}'"),
350            ));
351            continue;
352        };
353        if artifact_done(&change_dir, &a.generates) {
354            continue;
355        }
356        rep.push(warning(
357            format!("artifacts.{id}"),
358            format!(
359                "Apply-required artifact '{id}' is missing (expected output: {})",
360                a.generates
361            ),
362        ));
363    }
364}
365
366fn resolve_validation_context(ito_path: &Path, change_id: &str) -> (ConfigContext, String) {
367    let schema_name = read_change_schema(ito_path, change_id);
368
369    let mut ctx = ConfigContext::from_process_env();
370    ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
371
372    (ctx, schema_name)
373}
374
375fn validate_change_against_schema_validation(
376    rep: &mut ReportBuilder,
377    change_repo: &(impl DomainChangeRepository + ?Sized),
378    ito_path: &Path,
379    change_id: &str,
380    resolved: &ResolvedSchema,
381    validation: &ValidationYaml,
382    strict: bool,
383) -> CoreResult<()> {
384    let change_dir = paths::change_dir(ito_path, change_id);
385
386    if let Some(note) = validation.manual_semantic_validation_note.as_deref() {
387        rep.push(info("schema.validation.manual", note));
388    }
389
390    let missing_level = validation
391        .defaults
392        .missing_required_artifact_level
393        .unwrap_or(ValidationLevelYaml::Warning)
394        .as_level_str();
395
396    for (artifact_id, cfg) in &validation.artifacts {
397        let Some(schema_artifact) = resolved
398            .schema
399            .artifacts
400            .iter()
401            .find(|a| a.id == *artifact_id)
402        else {
403            rep.push(error(
404                "schema.validation",
405                format!("validation.yaml references unknown artifact id '{artifact_id}'"),
406            ));
407            continue;
408        };
409
410        let present = artifact_done(&change_dir, &schema_artifact.generates);
411        if cfg.required && !present {
412            rep.push(issue(
413                missing_level,
414                format!("artifacts.{artifact_id}"),
415                format!(
416                    "Missing required artifact '{artifact_id}' (expected output: {})",
417                    schema_artifact.generates
418                ),
419            ));
420        }
421
422        if !present {
423            if let Some(validator_id @ ValidatorId::DeltaSpecsV1) = cfg.validate_as {
424                // Only delta-spec validation runs without a generated artifact because it
425                // validates change-wide state; tasks-tracking validation is file-backed.
426                let ctx = ArtifactValidatorContext {
427                    ito_path,
428                    change_id,
429                    strict,
430                };
431                run_validator_for_artifact(
432                    rep,
433                    change_repo,
434                    ctx,
435                    artifact_id,
436                    &schema_artifact.generates,
437                    validator_id,
438                )?;
439                rules_engine::run_artifact_rules(
440                    rep,
441                    change_repo,
442                    ctx,
443                    validator_id,
444                    artifact_id,
445                    cfg.rules.as_ref(),
446                )?;
447            }
448            continue;
449        }
450
451        let Some(validator_id) = cfg.validate_as else {
452            continue;
453        };
454        let ctx = ArtifactValidatorContext {
455            ito_path,
456            change_id,
457            strict,
458        };
459        run_validator_for_artifact(
460            rep,
461            change_repo,
462            ctx,
463            artifact_id,
464            &schema_artifact.generates,
465            validator_id,
466        )?;
467        rules_engine::run_artifact_rules(
468            rep,
469            change_repo,
470            ctx,
471            validator_id,
472            artifact_id,
473            cfg.rules.as_ref(),
474        )?;
475    }
476
477    if let Some(proposal) = validation.proposal.as_ref() {
478        let report_path = format!("changes/{change_id}/proposal.md");
479        let abs_path = change_dir.join("proposal.md");
480        let present = abs_path.exists();
481
482        if proposal.required && !present {
483            rep.push(issue(
484                missing_level,
485                "proposal",
486                format!("Missing required proposal artifact: {report_path}"),
487            ));
488        }
489
490        if present && let Some(validator_id) = proposal.validate_as {
491            let ctx = ArtifactValidatorContext {
492                ito_path,
493                change_id,
494                strict,
495            };
496            match validator_id {
497                ValidatorId::DeltaSpecsV1 => {
498                    rules_engine::run_proposal_rules(
499                        rep,
500                        change_repo,
501                        ctx,
502                        validator_id,
503                        proposal.rules.as_ref(),
504                    )?;
505                }
506                ValidatorId::TasksTrackingV1 => {
507                    rep.push(error(
508                        "schema.validation",
509                        "Validator 'ito.tasks-tracking.v1' is not valid for proposal artifacts",
510                    ));
511                }
512            }
513        }
514    }
515
516    if let Some(tracking) = validation.tracking.as_ref() {
517        match tracking.source {
518            crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
519                let tracks_rel = resolved
520                    .schema
521                    .apply
522                    .as_ref()
523                    .and_then(|a| a.tracks.as_deref());
524
525                let Some(tracks_rel) = tracks_rel else {
526                    if tracking.required {
527                        rep.push(error(
528                            "tracking",
529                            "Schema tracking is required but schema apply.tracks is not set",
530                        ));
531                    }
532                    return Ok(());
533                };
534
535                if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
536                    rep.push(error(
537                        "tracking",
538                        format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
539                    ));
540                    return Ok(());
541                }
542
543                let report_path = format!("changes/{change_id}/{tracks_rel}");
544                let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
545
546                let present = abs_path.exists();
547                if tracking.required && !present {
548                    rep.push(error(
549                        "tracking",
550                        format!("Missing required tracking file: {report_path}"),
551                    ));
552                }
553                if !present {
554                    return Ok(());
555                }
556
557                match tracking.validate_as {
558                    ValidatorId::TasksTrackingV1 => {
559                        rep.extend(validate_tasks_tracking_path(
560                            &abs_path,
561                            &report_path,
562                            strict,
563                        ));
564                        let ctx = ArtifactValidatorContext {
565                            ito_path,
566                            change_id,
567                            strict,
568                        };
569                        rules_engine::run_tracking_rules(
570                            rep,
571                            change_repo,
572                            ctx,
573                            ValidatorId::TasksTrackingV1,
574                            &abs_path,
575                            &report_path,
576                            tracking.rules.as_ref(),
577                        )?;
578                    }
579                    ValidatorId::DeltaSpecsV1 => {
580                        rep.push(error(
581                            "schema.validation",
582                            "Validator 'ito.delta-specs.v1' is not valid for tracking files",
583                        ));
584                    }
585                }
586            }
587        }
588    }
589
590    Ok(())
591}
592
593/// Dispatch the configured validator for one artifact and append any findings.
594fn run_validator_for_artifact(
595    rep: &mut ReportBuilder,
596    change_repo: &(impl DomainChangeRepository + ?Sized),
597    ctx: ArtifactValidatorContext<'_>,
598    artifact_id: &str,
599    generates: &str,
600    validator_id: ValidatorId,
601) -> CoreResult<()> {
602    match validator_id {
603        ValidatorId::DeltaSpecsV1 => {
604            validate_change_delta_specs(rep, change_repo, ctx.change_id, ctx.strict)?;
605        }
606        ValidatorId::TasksTrackingV1 => {
607            use format_specs::TASKS_TRACKING_V1;
608
609            if generates.contains('*') {
610                rep.push(with_format_spec(
611                    error(
612                        format!("artifacts.{artifact_id}"),
613                        format!(
614                            "Validator '{}' requires a single file path; got pattern '{}'",
615                            TASKS_TRACKING_V1.validator_id, generates
616                        ),
617                    ),
618                    TASKS_TRACKING_V1,
619                ));
620                return Ok(());
621            }
622
623            let report_path = format!("changes/{}/{generates}", ctx.change_id);
624            let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
625            rep.extend(validate_tasks_tracking_path(
626                &abs_path,
627                &report_path,
628                ctx.strict,
629            ));
630        }
631    }
632    Ok(())
633}
634
635#[derive(Debug, Clone, Copy)]
636struct ArtifactValidatorContext<'a> {
637    ito_path: &'a Path,
638    change_id: &'a str,
639    strict: bool,
640}
641
642fn validate_tasks_tracking_path(
643    path: &Path,
644    report_path: &str,
645    strict: bool,
646) -> Vec<ValidationIssue> {
647    use format_specs::TASKS_TRACKING_V1;
648    use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
649
650    let contents = match ito_common::io::read_to_string(path) {
651        Ok(c) => c,
652        Err(e) => {
653            return vec![with_format_spec(
654                error(report_path, format!("Failed to read {report_path}: {e}")),
655                TASKS_TRACKING_V1,
656            )];
657        }
658    };
659
660    let parsed = parse_tasks_tracking_file(&contents);
661    let mut issues = Vec::new();
662
663    if parsed.tasks.is_empty() {
664        let msg = "Tracking file contains no recognizable tasks";
665        let i = if strict {
666            error(report_path, msg)
667        } else {
668            warning(report_path, msg)
669        };
670        issues.push(with_format_spec(i, TASKS_TRACKING_V1));
671    }
672    for d in &parsed.diagnostics {
673        let level = match d.level {
674            DiagnosticLevel::Error => LEVEL_ERROR,
675            DiagnosticLevel::Warning => LEVEL_WARNING,
676        };
677        issues.push(with_format_spec(
678            ValidationIssue {
679                path: report_path.to_string(),
680                level: level.to_string(),
681                message: d.message.clone(),
682                line: d.line.map(|l| l as u32),
683                column: None,
684                rule_id: None,
685                metadata: None,
686            },
687            TASKS_TRACKING_V1,
688        ));
689    }
690    issues
691}
692
693/// Validate a change's delta specs, including structural checks and traceability.
694fn validate_change_delta_specs(
695    rep: &mut ReportBuilder,
696    change_repo: &(impl DomainChangeRepository + ?Sized),
697    change_id: &str,
698    strict: bool,
699) -> CoreResult<()> {
700    use format_specs::DELTA_SPECS_V1;
701
702    let files = read_change_delta_spec_files(change_repo, change_id)?;
703    if files.is_empty() {
704        rep.push(with_format_spec(
705            error("specs", "Change must have at least one delta"),
706            DELTA_SPECS_V1,
707        ));
708        return Ok(());
709    }
710
711    let show = parse_change_show_json(change_id, &files);
712    if show.deltas.is_empty() {
713        rep.push(with_format_spec(
714            error("specs", "Change must have at least one delta"),
715            DELTA_SPECS_V1,
716        ));
717        return Ok(());
718    }
719
720    if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
721        rep.push(with_format_spec(
722            info(
723                "deltas",
724                "Consider splitting changes with more than 10 deltas",
725            ),
726            DELTA_SPECS_V1,
727        ));
728    }
729
730    for (idx, d) in show.deltas.iter().enumerate() {
731        let base = format!("deltas[{idx}]");
732        if d.description.trim().is_empty() {
733            rep.push(with_format_spec(
734                error(&base, "Delta description cannot be empty"),
735                DELTA_SPECS_V1,
736            ));
737        } else if d.description.trim().len() < 20 {
738            rep.push(with_format_spec(
739                warning(&base, "Delta description is too brief"),
740                DELTA_SPECS_V1,
741            ));
742        }
743
744        if d.requirements.is_empty() {
745            rep.push(with_format_spec(
746                warning(&base, "Delta should include requirements"),
747                DELTA_SPECS_V1,
748            ));
749        }
750
751        for (ridx, req) in d.requirements.iter().enumerate() {
752            let rp = format!("{base}.requirements[{ridx}]");
753            if req.text.trim().is_empty() {
754                rep.push(with_format_spec(
755                    error(&rp, "Requirement text cannot be empty"),
756                    DELTA_SPECS_V1,
757                ));
758            }
759            let up = req.text.to_ascii_uppercase();
760            if !up.contains("SHALL") && !up.contains("MUST") {
761                rep.push(with_format_spec(
762                    error(&rp, "Requirement must contain SHALL or MUST keyword"),
763                    DELTA_SPECS_V1,
764                ));
765            }
766            if req.scenarios.is_empty() {
767                rep.push(with_format_spec(
768                    error(&rp, "Requirement must have at least one scenario"),
769                    DELTA_SPECS_V1,
770                ));
771            }
772        }
773    }
774
775    // --- Traceability validation ---
776    // Collect (title, id) pairs from all delta requirements.
777    let mut delta_requirements: Vec<(String, Option<String>)> = Vec::new();
778    for d in &show.deltas {
779        for req in &d.requirements {
780            delta_requirements.push((req.text.clone(), req.requirement_id.clone()));
781        }
782    }
783
784    // Only run traceability if at least one requirement has an ID.
785    let has_any_id = delta_requirements.iter().any(|(_, id)| id.is_some());
786    if has_any_id {
787        let change_data = change_repo.get(change_id).into_core()?;
788        let trace_result =
789            ito_domain::traceability::compute_traceability(&delta_requirements, &change_data.tasks);
790
791        match &trace_result.status {
792            ito_domain::traceability::TraceStatus::Invalid { missing_ids } => {
793                for title in missing_ids {
794                    rep.push(with_format_spec(
795                        error(
796                            "traceability",
797                            format!(
798                                "Requirement '{}' has no Requirement ID; all requirements must have IDs for traceability",
799                                title
800                            ),
801                        ),
802                        DELTA_SPECS_V1,
803                    ));
804                }
805            }
806            ito_domain::traceability::TraceStatus::Unavailable { reason } => {
807                rep.push(with_format_spec(
808                    info(
809                        "traceability",
810                        format!("Traceability unavailable: {reason}"),
811                    ),
812                    DELTA_SPECS_V1,
813                ));
814            }
815            ito_domain::traceability::TraceStatus::Ready => {
816                for diag in &trace_result.diagnostics {
817                    rep.push(with_format_spec(
818                        error("traceability", diag.clone()),
819                        DELTA_SPECS_V1,
820                    ));
821                }
822                for unresolved in &trace_result.unresolved_references {
823                    rep.push(with_format_spec(
824                        error(
825                            "traceability",
826                            format!(
827                                "Task '{}' references unknown requirement ID '{}'",
828                                unresolved.task_id, unresolved.requirement_id
829                            ),
830                        ),
831                        DELTA_SPECS_V1,
832                    ));
833                }
834                for uncovered in &trace_result.uncovered_requirements {
835                    let i = if strict {
836                        error(
837                            "traceability",
838                            format!(
839                                "Requirement '{}' is not covered by any active task",
840                                uncovered
841                            ),
842                        )
843                    } else {
844                        warning(
845                            "traceability",
846                            format!(
847                                "Requirement '{}' is not covered by any active task",
848                                uncovered
849                            ),
850                        )
851                    };
852                    rep.push(with_format_spec(i, DELTA_SPECS_V1));
853                }
854            }
855        }
856    }
857
858    Ok(())
859}
860
861#[derive(Debug, Clone)]
862/// A resolved module reference (directory + key paths).
863pub struct ResolvedModule {
864    /// 3-digit module id.
865    pub id: String,
866    /// Directory name under `.ito/modules/`.
867    pub full_name: String,
868    /// Full path to the module directory.
869    pub module_dir: PathBuf,
870    /// Full path to `module.md`.
871    pub module_md: PathBuf,
872}
873
874/// Resolve a module directory name from user input.
875pub fn resolve_module(
876    module_repo: &(impl DomainModuleRepository + ?Sized),
877    ito_path: &Path,
878    input: &str,
879) -> CoreResult<Option<ResolvedModule>> {
880    let trimmed = input.trim();
881    if trimmed.is_empty() {
882        return Ok(None);
883    }
884
885    let module = module_repo.get(trimmed).into_core();
886    match module {
887        Ok(m) => {
888            let full_name = format!("{}_{}", m.id, m.name);
889            let module_dir = if m.path.as_os_str().is_empty() {
890                let fallback = paths::modules_dir(ito_path).join(&full_name);
891                if !fallback.exists() {
892                    return Ok(None);
893                }
894                fallback
895            } else {
896                m.path
897            };
898            let module_md = module_dir.join("module.md");
899            Ok(Some(ResolvedModule {
900                id: m.id,
901                full_name,
902                module_dir,
903                module_md,
904            }))
905        }
906        Err(_) => Ok(None),
907    }
908}
909
910/// Validate a module's `module.md` and any discovered sub-modules.
911pub fn validate_module(
912    module_repo: &(impl DomainModuleRepository + ?Sized),
913    ito_path: &Path,
914    module_input: &str,
915    strict: bool,
916) -> CoreResult<(String, ValidationReport)> {
917    let resolved = resolve_module(module_repo, ito_path, module_input)?;
918    let Some(r) = resolved else {
919        let mut rep = report(strict);
920        rep.push(error("module", "Module not found"));
921        return Ok((module_input.to_string(), rep.finish()));
922    };
923
924    let mut rep = report(strict);
925    let md = match ito_common::io::read_to_string_std(&r.module_md) {
926        Ok(c) => c,
927        Err(_) => {
928            rep.push(error("file", "Module must have a Purpose section"));
929            return Ok((r.full_name, rep.finish()));
930        }
931    };
932
933    let purpose = extract_section(&md, "Purpose");
934    if purpose.trim().is_empty() {
935        rep.push(error("purpose", "Module must have a Purpose section"));
936    } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
937        rep.push(error(
938            "purpose",
939            "Module purpose must be at least 20 characters",
940        ));
941    }
942
943    let scope = extract_section(&md, "Scope");
944    if scope.trim().is_empty() {
945        rep.push(error(
946            "scope",
947            "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
948        ));
949    }
950
951    // Validate sub-modules.
952    validate_sub_modules_under_module(&mut rep, module_repo, &r.module_dir, &r.id, strict);
953
954    Ok((r.full_name, rep.finish()))
955}
956
957/// Validate all sub-modules belonging to a parent module.
958fn validate_sub_modules_under_module(
959    rep: &mut ReportBuilder,
960    module_repo: &(impl DomainModuleRepository + ?Sized),
961    module_dir: &Path,
962    parent_id: &str,
963    strict: bool,
964) {
965    let sub_dir = module_dir.join("sub");
966    if !sub_dir.exists() {
967        return;
968    }
969
970    // Retrieve sub-modules through the repository to avoid re-discovering
971    // the same filesystem layout the repository already parsed.
972    let module = match module_repo.get(parent_id) {
973        Ok(m) => m,
974        Err(_) => return, // Parent module not found; outer validation already handles this.
975    };
976
977    // Track which directory names the repository recognized as valid so we
978    // can later flag any unrecognized entries.
979    let mut recognized_dirs: std::collections::HashSet<String> =
980        std::collections::HashSet::with_capacity(module.sub_modules.len());
981
982    for sm in &module.sub_modules {
983        let dir_name = sm
984            .path
985            .file_name()
986            .and_then(|n| n.to_str())
987            .unwrap_or(&sm.name)
988            .to_string();
989        recognized_dirs.insert(dir_name.clone());
990
991        // Validate naming convention: sub_id must be exactly two ASCII digits.
992        if sm.sub_id.len() != 2 || !sm.sub_id.bytes().all(|b| b.is_ascii_digit()) {
993            rep.push(error(
994                format!("sub-modules/{dir_name}"),
995                format!("Sub-module directory '{dir_name}' does not follow the SS_name convention"),
996            ));
997            continue;
998        }
999
1000        // Validate module.md presence.
1001        let module_md = sm.path.join("module.md");
1002        if !module_md.exists() {
1003            let level = if strict { LEVEL_ERROR } else { LEVEL_WARNING };
1004            rep.push(issue(
1005                level,
1006                format!("sub-modules/{dir_name}"),
1007                format!("Sub-module '{dir_name}' is missing module.md"),
1008            ));
1009            continue;
1010        }
1011
1012        // Validate module.md content.
1013        let content = match ito_common::io::read_to_string_std(&module_md) {
1014            Ok(c) => c,
1015            Err(err) => {
1016                rep.push(error(
1017                    format!("sub-modules/{dir_name}/module.md"),
1018                    format!("Failed to read module.md: {err}"),
1019                ));
1020                continue;
1021            }
1022        };
1023
1024        let purpose = extract_section(&content, "Purpose");
1025        if purpose.trim().is_empty() {
1026            rep.push(error(
1027                format!("sub-modules/{dir_name}/purpose"),
1028                format!("Sub-module '{dir_name}' module.md must have a Purpose section"),
1029            ));
1030        } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
1031            rep.push(warning(
1032                format!("sub-modules/{dir_name}/purpose"),
1033                format!(
1034                    "Sub-module '{dir_name}' purpose is too brief (less than {MIN_MODULE_PURPOSE_LENGTH} characters)"
1035                ),
1036            ));
1037        }
1038    }
1039
1040    // Report any sub/ entries that the repository silently skipped because
1041    // they do not follow the required naming convention.
1042    if let Ok(entries) = std::fs::read_dir(&sub_dir) {
1043        for entry in entries.flatten() {
1044            let path = entry.path();
1045            if !path.is_dir() {
1046                continue;
1047            }
1048            let Some(dir_name) = path.file_name().and_then(|n| n.to_str()) else {
1049                continue;
1050            };
1051            if !recognized_dirs.contains(dir_name) {
1052                rep.push(error(
1053                    format!("sub-modules/{dir_name}"),
1054                    format!(
1055                        "Sub-module directory '{dir_name}' does not follow the SS_name convention"
1056                    ),
1057                ));
1058            }
1059        }
1060    }
1061}
1062
1063fn extract_section(markdown: &str, header: &str) -> String {
1064    let mut in_section = false;
1065    let mut out = String::new();
1066    let normalized = markdown.replace('\r', "");
1067    for raw in normalized.split('\n') {
1068        let line = raw.trim_end();
1069        if let Some(h) = line.strip_prefix("## ") {
1070            let title = h.trim();
1071            if title.eq_ignore_ascii_case(header) {
1072                in_section = true;
1073                continue;
1074            }
1075            if in_section {
1076                break;
1077            }
1078        }
1079        if in_section {
1080            out.push_str(line);
1081            out.push('\n');
1082        }
1083    }
1084    out
1085}
1086
1087/// Validate a change's tracking file and return any issues found.
1088pub fn validate_tasks_file(
1089    ito_path: &Path,
1090    change_id: &str,
1091    strict: bool,
1092) -> CoreResult<Vec<ValidationIssue>> {
1093    use crate::templates::{load_schema_validation, read_change_schema, resolve_schema};
1094    use ito_domain::tasks::tasks_path_checked;
1095
1096    // `read_change_schema` uses `change_id` as a path segment; reject traversal.
1097    if tasks_path_checked(ito_path, change_id).is_none() {
1098        return Ok(vec![error(
1099            "tracking",
1100            format!("invalid change id path segment: \"{change_id}\""),
1101        )]);
1102    }
1103
1104    let schema_name = read_change_schema(ito_path, change_id);
1105    let mut ctx = ConfigContext::from_process_env();
1106    ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
1107
1108    let mut issues: Vec<ValidationIssue> = Vec::new();
1109
1110    let mut tracking_file = "tasks.md".to_string();
1111    let resolved = match resolve_schema(Some(&schema_name), &ctx) {
1112        Ok(r) => Some(r),
1113        Err(e) => {
1114            issues.push(error(
1115                "schema",
1116                format!("Failed to resolve schema '{schema_name}': {e}"),
1117            ));
1118            None
1119        }
1120    };
1121
1122    if let Some(resolved) = resolved.as_ref() {
1123        // If schema validation declares a non-tasks tracking validator, this file is not a
1124        // tasks-tracking file that `ito validate` can interpret.
1125        if let Ok(Some(validation)) = load_schema_validation(resolved)
1126            && let Some(tracking) = validation.tracking.as_ref()
1127            && tracking.validate_as != ValidatorId::TasksTrackingV1
1128        {
1129            issues.push(error(
1130                "tracking",
1131                format!(
1132                    "Schema tracking validator '{}' is not valid for tasks tracking files",
1133                    tracking.validate_as.as_str()
1134                ),
1135            ));
1136            return Ok(issues);
1137        }
1138
1139        if let Some(tracks) = resolved
1140            .schema
1141            .apply
1142            .as_ref()
1143            .and_then(|a| a.tracks.as_deref())
1144        {
1145            tracking_file = tracks.to_string();
1146        }
1147    }
1148
1149    if !ito_domain::tasks::is_safe_tracking_filename(&tracking_file) {
1150        issues.push(error(
1151            "tracking",
1152            format!("Invalid tracking file path in apply.tracks: '{tracking_file}'"),
1153        ));
1154        return Ok(issues);
1155    }
1156
1157    let path = paths::change_dir(ito_path, change_id).join(&tracking_file);
1158    let report_path = format!("changes/{change_id}/{tracking_file}");
1159    issues.extend(validate_tasks_tracking_path(&path, &report_path, strict));
1160    Ok(issues)
1161}