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's delta specs by change id.
178pub fn validate_change(
179    change_repo: &impl DomainChangeRepository,
180    ito_path: &Path,
181    change_id: &str,
182    strict: bool,
183) -> CoreResult<ValidationReport> {
184    let mut rep = report(strict);
185
186    let (ctx, schema_name) = resolve_validation_context(ito_path, change_id);
187
188    let resolved = match resolve_schema(Some(&schema_name), &ctx) {
189        Ok(s) => {
190            rep.push(info(
191                "schema",
192                format!(
193                    "Resolved schema '{}' from {}",
194                    s.schema.name,
195                    s.source.as_str()
196                ),
197            ));
198            Some(s)
199        }
200        Err(e) => {
201            rep.push(error(
202                "schema",
203                format!("Failed to resolve schema '{schema_name}': {e}"),
204            ));
205            None
206        }
207    };
208
209    if let Some(resolved) = &resolved {
210        match load_schema_validation(resolved) {
211            Ok(Some(validation)) => {
212                rep.push(info("schema.validation", "Using schema validation.yaml"));
213                validate_change_against_schema_validation(
214                    &mut rep,
215                    change_repo,
216                    ito_path,
217                    change_id,
218                    resolved,
219                    &validation,
220                    strict,
221                )?;
222                return Ok(rep.finish());
223            }
224            Ok(None) => {}
225            Err(e) => {
226                rep.push(error(
227                    "schema.validation",
228                    format!("Failed to load schema validation.yaml: {e}"),
229                ));
230                return Ok(rep.finish());
231            }
232        }
233
234        if is_legacy_delta_schema(&resolved.schema.name) {
235            validate_change_delta_specs(&mut rep, change_repo, change_id)?;
236
237            let tracks_rel = resolved
238                .schema
239                .apply
240                .as_ref()
241                .and_then(|a| a.tracks.as_deref())
242                .unwrap_or("tasks.md");
243
244            if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
245                rep.push(error(
246                    "tracking",
247                    format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
248                ));
249                return Ok(rep.finish());
250            }
251
252            let report_path = format!("changes/{change_id}/{tracks_rel}");
253            let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
254            rep.extend(validate_tasks_tracking_path(
255                &abs_path,
256                &report_path,
257                strict,
258            ));
259            return Ok(rep.finish());
260        }
261
262        rep.push(info(
263            "schema.validation",
264            "Schema has no validation.yaml; manual validation required",
265        ));
266        validate_apply_required_artifacts(&mut rep, ito_path, change_id, resolved);
267        return Ok(rep.finish());
268    }
269
270    validate_change_delta_specs(&mut rep, change_repo, change_id)?;
271    Ok(rep.finish())
272}
273
274/// Returns true for built-in schemas that predate schema-driven `validation.yaml`.
275fn is_legacy_delta_schema(schema_name: &str) -> bool {
276    schema_name == "spec-driven" || schema_name == "tdd"
277}
278
279fn schema_artifact_ids(resolved: &ResolvedSchema) -> Vec<String> {
280    let mut ids = Vec::new();
281    for a in &resolved.schema.artifacts {
282        ids.push(a.id.clone());
283    }
284    ids
285}
286
287fn validate_apply_required_artifacts(
288    rep: &mut ReportBuilder,
289    ito_path: &Path,
290    change_id: &str,
291    resolved: &ResolvedSchema,
292) {
293    let change_dir = paths::change_dir(ito_path, change_id);
294    if !change_dir.exists() {
295        rep.push(error(
296            "change",
297            format!("Change directory not found: changes/{change_id}"),
298        ));
299        return;
300    }
301
302    let required_ids: Vec<String> = match resolved.schema.apply.as_ref() {
303        Some(apply) => apply
304            .requires
305            .clone()
306            .unwrap_or_else(|| schema_artifact_ids(resolved)),
307        None => schema_artifact_ids(resolved),
308    };
309
310    for id in required_ids {
311        let Some(a) = resolved.schema.artifacts.iter().find(|a| a.id == id) else {
312            rep.push(error(
313                "schema.validation",
314                format!("Schema apply.requires references unknown artifact id '{id}'"),
315            ));
316            continue;
317        };
318        if artifact_done(&change_dir, &a.generates) {
319            continue;
320        }
321        rep.push(warning(
322            format!("artifacts.{id}"),
323            format!(
324                "Apply-required artifact '{id}' is missing (expected output: {})",
325                a.generates
326            ),
327        ));
328    }
329}
330
331fn resolve_validation_context(ito_path: &Path, change_id: &str) -> (ConfigContext, String) {
332    let schema_name = read_change_schema(ito_path, change_id);
333
334    let mut ctx = ConfigContext::from_process_env();
335    ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
336
337    (ctx, schema_name)
338}
339
340fn validate_change_against_schema_validation(
341    rep: &mut ReportBuilder,
342    change_repo: &impl DomainChangeRepository,
343    ito_path: &Path,
344    change_id: &str,
345    resolved: &ResolvedSchema,
346    validation: &ValidationYaml,
347    strict: bool,
348) -> CoreResult<()> {
349    let change_dir = paths::change_dir(ito_path, change_id);
350
351    let missing_level = validation
352        .defaults
353        .missing_required_artifact_level
354        .unwrap_or(ValidationLevelYaml::Warning)
355        .as_level_str();
356
357    for (artifact_id, cfg) in &validation.artifacts {
358        let Some(schema_artifact) = resolved
359            .schema
360            .artifacts
361            .iter()
362            .find(|a| a.id == *artifact_id)
363        else {
364            rep.push(error(
365                "schema.validation",
366                format!("validation.yaml references unknown artifact id '{artifact_id}'"),
367            ));
368            continue;
369        };
370
371        let present = artifact_done(&change_dir, &schema_artifact.generates);
372        if cfg.required && !present {
373            rep.push(issue(
374                missing_level,
375                format!("artifacts.{artifact_id}"),
376                format!(
377                    "Missing required artifact '{artifact_id}' (expected output: {})",
378                    schema_artifact.generates
379                ),
380            ));
381        }
382
383        if !present {
384            if let Some(validator_id @ ValidatorId::DeltaSpecsV1) = cfg.validate_as {
385                // Only delta-spec validation runs without a generated artifact because it
386                // validates change-wide state; tasks-tracking validation is file-backed.
387                let ctx = ArtifactValidatorContext {
388                    ito_path,
389                    change_id,
390                    strict,
391                };
392                run_validator_for_artifact(
393                    rep,
394                    change_repo,
395                    ctx,
396                    artifact_id,
397                    &schema_artifact.generates,
398                    validator_id,
399                )?;
400            }
401            continue;
402        }
403
404        let Some(validator_id) = cfg.validate_as else {
405            continue;
406        };
407        let ctx = ArtifactValidatorContext {
408            ito_path,
409            change_id,
410            strict,
411        };
412        run_validator_for_artifact(
413            rep,
414            change_repo,
415            ctx,
416            artifact_id,
417            &schema_artifact.generates,
418            validator_id,
419        )?;
420    }
421
422    if let Some(tracking) = validation.tracking.as_ref() {
423        match tracking.source {
424            crate::templates::ValidationTrackingSourceYaml::ApplyTracks => {
425                let tracks_rel = resolved
426                    .schema
427                    .apply
428                    .as_ref()
429                    .and_then(|a| a.tracks.as_deref());
430
431                let Some(tracks_rel) = tracks_rel else {
432                    if tracking.required {
433                        rep.push(error(
434                            "tracking",
435                            "Schema tracking is required but schema apply.tracks is not set",
436                        ));
437                    }
438                    return Ok(());
439                };
440
441                if !ito_domain::tasks::is_safe_tracking_filename(tracks_rel) {
442                    rep.push(error(
443                        "tracking",
444                        format!("Invalid tracking file path in apply.tracks: '{tracks_rel}'"),
445                    ));
446                    return Ok(());
447                }
448
449                let report_path = format!("changes/{change_id}/{tracks_rel}");
450                let abs_path = paths::change_dir(ito_path, change_id).join(tracks_rel);
451
452                let present = abs_path.exists();
453                if tracking.required && !present {
454                    rep.push(error(
455                        "tracking",
456                        format!("Missing required tracking file: {report_path}"),
457                    ));
458                }
459                if !present {
460                    return Ok(());
461                }
462
463                match tracking.validate_as {
464                    ValidatorId::TasksTrackingV1 => {
465                        rep.extend(validate_tasks_tracking_path(
466                            &abs_path,
467                            &report_path,
468                            strict,
469                        ));
470                    }
471                    ValidatorId::DeltaSpecsV1 => {
472                        rep.push(error(
473                            "schema.validation",
474                            "Validator 'ito.delta-specs.v1' is not valid for tracking files",
475                        ));
476                    }
477                }
478            }
479        }
480    }
481
482    Ok(())
483}
484
485fn run_validator_for_artifact(
486    rep: &mut ReportBuilder,
487    change_repo: &impl DomainChangeRepository,
488    ctx: ArtifactValidatorContext<'_>,
489    artifact_id: &str,
490    generates: &str,
491    validator_id: ValidatorId,
492) -> CoreResult<()> {
493    match validator_id {
494        ValidatorId::DeltaSpecsV1 => {
495            validate_change_delta_specs(rep, change_repo, ctx.change_id)?;
496        }
497        ValidatorId::TasksTrackingV1 => {
498            use format_specs::TASKS_TRACKING_V1;
499
500            if generates.contains('*') {
501                rep.push(with_format_spec(
502                    error(
503                        format!("artifacts.{artifact_id}"),
504                        format!(
505                            "Validator '{}' requires a single file path; got pattern '{}'",
506                            TASKS_TRACKING_V1.validator_id, generates
507                        ),
508                    ),
509                    TASKS_TRACKING_V1,
510                ));
511                return Ok(());
512            }
513
514            let report_path = format!("changes/{}/{generates}", ctx.change_id);
515            let abs_path = paths::change_dir(ctx.ito_path, ctx.change_id).join(generates);
516            rep.extend(validate_tasks_tracking_path(
517                &abs_path,
518                &report_path,
519                ctx.strict,
520            ));
521        }
522    }
523    Ok(())
524}
525
526#[derive(Debug, Clone, Copy)]
527struct ArtifactValidatorContext<'a> {
528    ito_path: &'a Path,
529    change_id: &'a str,
530    strict: bool,
531}
532
533fn validate_tasks_tracking_path(
534    path: &Path,
535    report_path: &str,
536    strict: bool,
537) -> Vec<ValidationIssue> {
538    use format_specs::TASKS_TRACKING_V1;
539    use ito_domain::tasks::{DiagnosticLevel, parse_tasks_tracking_file};
540
541    let contents = match ito_common::io::read_to_string(path) {
542        Ok(c) => c,
543        Err(e) => {
544            return vec![with_format_spec(
545                error(report_path, format!("Failed to read {report_path}: {e}")),
546                TASKS_TRACKING_V1,
547            )];
548        }
549    };
550
551    let parsed = parse_tasks_tracking_file(&contents);
552    let mut issues = Vec::new();
553
554    if parsed.tasks.is_empty() {
555        let msg = "Tracking file contains no recognizable tasks";
556        let i = if strict {
557            error(report_path, msg)
558        } else {
559            warning(report_path, msg)
560        };
561        issues.push(with_format_spec(i, TASKS_TRACKING_V1));
562    }
563    for d in &parsed.diagnostics {
564        let level = match d.level {
565            DiagnosticLevel::Error => LEVEL_ERROR,
566            DiagnosticLevel::Warning => LEVEL_WARNING,
567        };
568        issues.push(with_format_spec(
569            ValidationIssue {
570                path: report_path.to_string(),
571                level: level.to_string(),
572                message: d.message.clone(),
573                line: d.line.map(|l| l as u32),
574                column: None,
575                metadata: None,
576            },
577            TASKS_TRACKING_V1,
578        ));
579    }
580    issues
581}
582
583fn validate_change_delta_specs(
584    rep: &mut ReportBuilder,
585    change_repo: &impl DomainChangeRepository,
586    change_id: &str,
587) -> CoreResult<()> {
588    use format_specs::DELTA_SPECS_V1;
589
590    let files = read_change_delta_spec_files(change_repo, change_id)?;
591    if files.is_empty() {
592        rep.push(with_format_spec(
593            error("specs", "Change must have at least one delta"),
594            DELTA_SPECS_V1,
595        ));
596        return Ok(());
597    }
598
599    let show = parse_change_show_json(change_id, &files);
600    if show.deltas.is_empty() {
601        rep.push(with_format_spec(
602            error("specs", "Change must have at least one delta"),
603            DELTA_SPECS_V1,
604        ));
605        return Ok(());
606    }
607
608    if show.deltas.len() > MAX_DELTAS_PER_CHANGE {
609        rep.push(with_format_spec(
610            info(
611                "deltas",
612                "Consider splitting changes with more than 10 deltas",
613            ),
614            DELTA_SPECS_V1,
615        ));
616    }
617
618    for (idx, d) in show.deltas.iter().enumerate() {
619        let base = format!("deltas[{idx}]");
620        if d.description.trim().is_empty() {
621            rep.push(with_format_spec(
622                error(&base, "Delta description cannot be empty"),
623                DELTA_SPECS_V1,
624            ));
625        } else if d.description.trim().len() < 20 {
626            rep.push(with_format_spec(
627                warning(&base, "Delta description is too brief"),
628                DELTA_SPECS_V1,
629            ));
630        }
631
632        if d.requirements.is_empty() {
633            rep.push(with_format_spec(
634                warning(&base, "Delta should include requirements"),
635                DELTA_SPECS_V1,
636            ));
637        }
638
639        for (ridx, req) in d.requirements.iter().enumerate() {
640            let rp = format!("{base}.requirements[{ridx}]");
641            if req.text.trim().is_empty() {
642                rep.push(with_format_spec(
643                    error(&rp, "Requirement text cannot be empty"),
644                    DELTA_SPECS_V1,
645                ));
646            }
647            let up = req.text.to_ascii_uppercase();
648            if !up.contains("SHALL") && !up.contains("MUST") {
649                rep.push(with_format_spec(
650                    error(&rp, "Requirement must contain SHALL or MUST keyword"),
651                    DELTA_SPECS_V1,
652                ));
653            }
654            if req.scenarios.is_empty() {
655                rep.push(with_format_spec(
656                    error(&rp, "Requirement must have at least one scenario"),
657                    DELTA_SPECS_V1,
658                ));
659            }
660        }
661    }
662    Ok(())
663}
664
665#[derive(Debug, Clone)]
666/// A resolved module reference (directory + key paths).
667pub struct ResolvedModule {
668    /// 3-digit module id.
669    pub id: String,
670    /// Directory name under `.ito/modules/`.
671    pub full_name: String,
672    /// Full path to the module directory.
673    pub module_dir: PathBuf,
674    /// Full path to `module.md`.
675    pub module_md: PathBuf,
676}
677
678/// Resolve a module directory name from user input.
679///
680/// Input can be a full directory name (`NNN_slug`) or the numeric module id
681/// (`NNN`). Empty input returns `Ok(None)`.
682pub fn resolve_module(
683    module_repo: &impl DomainModuleRepository,
684    _ito_path: &Path,
685    input: &str,
686) -> CoreResult<Option<ResolvedModule>> {
687    let trimmed = input.trim();
688    if trimmed.is_empty() {
689        return Ok(None);
690    }
691
692    let module = module_repo.get(trimmed).into_core();
693    match module {
694        Ok(m) => {
695            let full_name = format!("{}_{}", m.id, m.name);
696            let module_dir = m.path;
697            let module_md = module_dir.join("module.md");
698            Ok(Some(ResolvedModule {
699                id: m.id,
700                full_name,
701                module_dir,
702                module_md,
703            }))
704        }
705        Err(_) => Ok(None),
706    }
707}
708
709/// Validate a module's `module.md` for minimal required sections.
710///
711/// Returns the resolved module directory name along with the report.
712pub fn validate_module(
713    module_repo: &impl DomainModuleRepository,
714    ito_path: &Path,
715    module_input: &str,
716    strict: bool,
717) -> CoreResult<(String, ValidationReport)> {
718    let resolved = resolve_module(module_repo, ito_path, module_input)?;
719    let Some(r) = resolved else {
720        let mut rep = report(strict);
721        rep.push(error("module", "Module not found"));
722        return Ok((module_input.to_string(), rep.finish()));
723    };
724
725    let mut rep = report(strict);
726    let md = match ito_common::io::read_to_string_std(&r.module_md) {
727        Ok(c) => c,
728        Err(_) => {
729            rep.push(error("file", "Module must have a Purpose section"));
730            return Ok((r.full_name, rep.finish()));
731        }
732    };
733
734    let purpose = extract_section(&md, "Purpose");
735    if purpose.trim().is_empty() {
736        rep.push(error("purpose", "Module must have a Purpose section"));
737    } else if purpose.trim().len() < MIN_MODULE_PURPOSE_LENGTH {
738        rep.push(error(
739            "purpose",
740            "Module purpose must be at least 20 characters",
741        ));
742    }
743
744    let scope = extract_section(&md, "Scope");
745    if scope.trim().is_empty() {
746        rep.push(error(
747            "scope",
748            "Module must have a Scope section with at least one capability (use \"*\" for unrestricted)",
749        ));
750    }
751
752    Ok((r.full_name, rep.finish()))
753}
754
755fn extract_section(markdown: &str, header: &str) -> String {
756    let mut in_section = false;
757    let mut out = String::new();
758    let normalized = markdown.replace('\r', "");
759    for raw in normalized.split('\n') {
760        let line = raw.trim_end();
761        if let Some(h) = line.strip_prefix("## ") {
762            let title = h.trim();
763            if title.eq_ignore_ascii_case(header) {
764                in_section = true;
765                continue;
766            }
767            if in_section {
768                break;
769            }
770        }
771        if in_section {
772            out.push_str(line);
773            out.push('\n');
774        }
775    }
776    out
777}
778
779/// Validate a change's tasks.md file and return any issues found.
780pub fn validate_tasks_file(
781    ito_path: &Path,
782    change_id: &str,
783    strict: bool,
784) -> CoreResult<Vec<ValidationIssue>> {
785    use crate::templates::{load_schema_validation, read_change_schema, resolve_schema};
786    use ito_domain::tasks::tasks_path_checked;
787
788    // `read_change_schema` uses `change_id` as a path segment; reject traversal.
789    if tasks_path_checked(ito_path, change_id).is_none() {
790        return Ok(vec![error(
791            "tracking",
792            format!("invalid change id path segment: \"{change_id}\""),
793        )]);
794    }
795
796    let schema_name = read_change_schema(ito_path, change_id);
797    let mut ctx = ConfigContext::from_process_env();
798    ctx.project_dir = ito_path.parent().map(|p| p.to_path_buf());
799
800    let mut issues: Vec<ValidationIssue> = Vec::new();
801
802    let mut tracking_file = "tasks.md".to_string();
803    let resolved = match resolve_schema(Some(&schema_name), &ctx) {
804        Ok(r) => Some(r),
805        Err(e) => {
806            issues.push(error(
807                "schema",
808                format!("Failed to resolve schema '{schema_name}': {e}"),
809            ));
810            None
811        }
812    };
813
814    if let Some(resolved) = resolved.as_ref() {
815        // If schema validation declares a non-tasks tracking validator, this file is not a
816        // tasks-tracking file that `ito validate` can interpret.
817        if let Ok(Some(validation)) = load_schema_validation(resolved)
818            && let Some(tracking) = validation.tracking.as_ref()
819            && tracking.validate_as != ValidatorId::TasksTrackingV1
820        {
821            issues.push(error(
822                "tracking",
823                format!(
824                    "Schema tracking validator '{}' is not valid for tasks tracking files",
825                    tracking.validate_as.as_str()
826                ),
827            ));
828            return Ok(issues);
829        }
830
831        if let Some(tracks) = resolved
832            .schema
833            .apply
834            .as_ref()
835            .and_then(|a| a.tracks.as_deref())
836        {
837            tracking_file = tracks.to_string();
838        }
839    }
840
841    if !ito_domain::tasks::is_safe_tracking_filename(&tracking_file) {
842        issues.push(error(
843            "tracking",
844            format!("Invalid tracking file path in apply.tracks: '{tracking_file}'"),
845        ));
846        return Ok(issues);
847    }
848
849    let path = paths::change_dir(ito_path, change_id).join(&tracking_file);
850    let report_path = format!("changes/{change_id}/{tracking_file}");
851    issues.extend(validate_tasks_tracking_path(&path, &report_path, strict));
852    Ok(issues)
853}