diff --git a/backend/app/modules/design/domain/services.py b/backend/app/modules/design/domain/services.py index e69de29..38da6a7 100644 --- a/backend/app/modules/design/domain/services.py +++ b/backend/app/modules/design/domain/services.py @@ -0,0 +1,128 @@ +from dataclasses import dataclass + +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.value_objects import FileStatus + +TEMPLATE_MARKERS = ["TODO", "EXAMPLE", " FileStatus: + if not content or not content.strip(): + return FileStatus.MISSING + + lines = [l for l in content.strip().splitlines() if l.strip()] + is_csv = file_path.endswith(".csv") + + # Sparse check + if is_csv and len(lines) < 2: + return FileStatus.SPARSE + if not is_csv and len(lines) < 5: + return FileStatus.SPARSE + + # Count placeholder tokens + total_cells = 0 + placeholder_cells = 0 + for line in lines: + cells = line.split(",") if is_csv else [line] + for cell in cells: + total_cells += 1 + if any(m.lower() in cell.lower() for m in TEMPLATE_MARKERS): + placeholder_cells += 1 + + # Placeholder heavy: >50% + if total_cells > 0 and placeholder_cells / total_cells > 0.5: + return FileStatus.PLACEHOLDER_HEAVY + + # Template residue: any marker present but <=50% + if placeholder_cells > 0: + return FileStatus.TEMPLATE_RESIDUE + + return FileStatus.OK + + @staticmethod + def check_capability_module_linkage( + capabilities: list[Capability], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + linked_caps = {link.capability_id for link in traceability_links} + return [ + ConstraintViolation( + rule="capability_module_linkage", + entity_id=cap.capability_id, + message=f"Capability {cap.capability_id} has no TraceabilityLink to any module", + ) + for cap in capabilities + if cap.capability_id not in linked_caps + ] + + @staticmethod + def check_entity_owner(entities: list[Entity]) -> list[ConstraintViolation]: + return [ + ConstraintViolation( + rule="entity_owner", + entity_id=ent.entity_id, + message=f"Entity {ent.entity_id} has no owner module", + ) + for ent in entities + if not ent.owner_module + ] + + @staticmethod + def check_traceability_references( + links: list[TraceabilityLink], + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + ) -> list[ConstraintViolation]: + cap_ids = {c.capability_id for c in capabilities} + mod_ids = {m.module_id for m in modules} + ent_ids = {e.entity_id for e in entities} + violations: list[ConstraintViolation] = [] + for link in links: + if link.capability_id not in cap_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown capability {link.capability_id}", + )) + if link.module_id not in mod_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown module {link.module_id}", + )) + for eid in link.entity_ids: + if eid not in ent_ids: + violations.append(ConstraintViolation( + "traceability_ref", link.trace_id, + f"References unknown entity {eid}", + )) + return violations + + @classmethod + def validate_all( + cls, + capabilities: list[Capability], + modules: list[Module], + entities: list[Entity], + traceability_links: list[TraceabilityLink], + ) -> list[ConstraintViolation]: + violations: list[ConstraintViolation] = [] + violations.extend(cls.check_capability_module_linkage(capabilities, traceability_links)) + violations.extend(cls.check_entity_owner(entities)) + violations.extend(cls.check_traceability_references( + traceability_links, capabilities, modules, entities, + )) + return violations diff --git a/backend/tests/test_design_services.py b/backend/tests/test_design_services.py new file mode 100644 index 0000000..f163134 --- /dev/null +++ b/backend/tests/test_design_services.py @@ -0,0 +1,77 @@ +import pytest +from app.modules.design.domain.entities import ( + Capability, + Entity, + Module, + TraceabilityLink, +) +from app.modules.design.domain.services import DesignValidationService +from app.modules.design.domain.value_objects import FileStatus + + +class TestFileStatusDetermination: + def test_empty_content_is_missing(self): + assert DesignValidationService.determine_file_status("", "test.csv") == FileStatus.MISSING + + def test_csv_header_only_is_sparse(self): + assert DesignValidationService.determine_file_status("id,name\n", "test.csv") == FileStatus.SPARSE + + def test_csv_with_data_is_ok(self): + content = "id,name\n1,foo\n2,bar\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.OK + + def test_md_too_short_is_sparse(self): + assert DesignValidationService.determine_file_status("# Title\n\nShort.\n", "test.md") == FileStatus.SPARSE + + def test_template_residue_detected(self): + content = "id,name\nTODO,\nreal,data\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.TEMPLATE_RESIDUE + + def test_placeholder_heavy(self): + content = "id,name,desc\nTODO,TODO,TODO\nTODO,TODO,TODO\n" + assert DesignValidationService.determine_file_status(content, "test.csv") == FileStatus.PLACEHOLDER_HEAVY + + def test_ok_md_file(self): + content = "---\ndoc_id: DOC-01\ntitle: Test\n---\n\n# Title\n\nThis is a real document with enough content.\nLine 4.\nLine 5.\nLine 6.\n" + assert DesignValidationService.determine_file_status(content, "test.md") == FileStatus.OK + + +class TestConstraintValidation: + def _make_cap(self, cap_id: str) -> Capability: + return Capability(cap_id, "n", "d", "must", "MVP", []) + + def _make_mod(self, mod_id: str) -> Module: + return Module(mod_id, "n", "backend", "d", "MVP", [], []) + + def _make_ent(self, ent_id: str, owner: str) -> Entity: + return Entity(ent_id, "n", "d", owner, "d", "MVP", "f.csv") + + def _make_link(self, trace_id: str, cap: str, mod: str, ents: list[str]) -> TraceabilityLink: + return TraceabilityLink(trace_id, cap, mod, ents, [], "") + + def test_capability_without_module_link_is_violation(self): + caps = [self._make_cap("CAP-01")] + links = [] + violations = DesignValidationService.check_capability_module_linkage(caps, links) + assert len(violations) == 1 + + def test_entity_without_owner_is_violation(self): + entities = [self._make_ent("ENT-01", "")] + violations = DesignValidationService.check_entity_owner(entities) + assert len(violations) == 1 + + def test_valid_traceability_passes(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-01", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) == 0 + + def test_broken_traceability_reference_is_violation(self): + caps = [self._make_cap("CAP-01")] + mods = [self._make_mod("MOD-01")] + ents = [self._make_ent("ENT-01", "MOD-01")] + links = [self._make_link("TR-01", "CAP-MISSING", "MOD-01", ["ENT-01"])] + violations = DesignValidationService.check_traceability_references(links, caps, mods, ents) + assert len(violations) >= 1