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