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