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 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
42pub type ValidationLevel = &'static str;
44
45pub const LEVEL_ERROR: ValidationLevel = "ERROR";
47pub const LEVEL_WARNING: ValidationLevel = "WARNING";
49pub const LEVEL_INFO: ValidationLevel = "INFO";
51
52const 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)]
64pub struct ValidationIssue {
66 pub level: String,
68 pub path: String,
70 pub message: String,
72 #[serde(skip_serializing_if = "Option::is_none")]
73 pub line: Option<u32>,
75 #[serde(skip_serializing_if = "Option::is_none")]
76 pub column: Option<u32>,
78 #[serde(skip_serializing_if = "Option::is_none")]
79 pub rule_id: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
82 pub metadata: Option<serde_json::Value>,
84}
85
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87pub struct ValidationReport {
89 pub valid: bool,
91
92 pub issues: Vec<ValidationIssue>,
94
95 pub summary: ValidationSummary,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
100pub struct ValidationSummary {
102 pub errors: u32,
104 pub warnings: u32,
106 pub info: u32,
108}
109
110impl ValidationReport {
111 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
143pub 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
203pub 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
211pub 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
309fn 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 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
595fn 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
695fn 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 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 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)]
864pub struct ResolvedModule {
866 pub id: String,
868 pub full_name: String,
870 pub module_dir: PathBuf,
872 pub module_md: PathBuf,
874}
875
876pub 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
912pub 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_under_module(&mut rep, module_repo, &r.module_dir, &r.id, strict);
955
956 Ok((r.full_name, rep.finish()))
957}
958
959fn 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 let module = match module_repo.get(parent_id) {
975 Ok(m) => m,
976 Err(_) => return, };
978
979 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 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 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 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 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
1089pub 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 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 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}