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