feat(design): add DesignValidationService — file status detection and constraint rules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
58800a01d8
commit
c4b33850fb
|
|
@ -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", "<replace", "{{", "Lorem ipsum"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConstraintViolation:
|
||||
rule: str
|
||||
entity_id: str
|
||||
message: str
|
||||
|
||||
|
||||
class DesignValidationService:
|
||||
@staticmethod
|
||||
def determine_file_status(content: str, file_path: str) -> 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
|
||||
77
backend/tests/test_design_services.py
Normal file
77
backend/tests/test_design_services.py
Normal file
|
|
@ -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,<replace this>\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
|
||||
Loading…
Reference in New Issue
Block a user