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