Skip to content

Commit 5344a76

Browse files
committed
test(model,ir,mapping,rules): handle nested container ids in validator and add v2 coverage
- Fix validator to compare local container id against dot-qualified nested ids - Add IR serialization backward-compat tests for v1 snapshots and v2 fields - Add enrichment tests for v2 nested containers, service/kind propagation, and unmatched nodes - Add v2 loader/resolver/validator tests (versioning, kinds, nested containers, relations) - Add built-in rule field access tests for new node/edge service and kind fields
1 parent a400f4f commit 5344a76

5 files changed

Lines changed: 753 additions & 1 deletion

File tree

pacta/model/validator.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,9 @@ def _validate_container(
142142
details={"container_id": cid},
143143
)
144144
)
145-
if c.id != cid:
145+
# For nested containers, cid is dot-qualified (e.g. "svc.mod") but c.id is the local id ("mod")
146+
expected_local_id = cid.rsplit(".", 1)[-1]
147+
if c.id != expected_local_id:
146148
errors.append(
147149
EngineError(
148150
type="config_error",

tests/ir/test_serialization.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
"""Tests for IR serialization backward compatibility (v1 snapshots with v2 code)."""
2+
3+
from pacta.ir.types import (
4+
CanonicalId,
5+
DepType,
6+
IREdge,
7+
IRNode,
8+
Language,
9+
SymbolKind,
10+
)
11+
12+
13+
def cid(fqname: str) -> CanonicalId:
14+
return CanonicalId(language=Language.PYTHON, code_root="repo", fqname=fqname)
15+
16+
17+
def test_node_from_dict_without_v2_fields():
18+
"""v1 snapshot data (no service, container_kind) deserializes with None defaults."""
19+
data = {
20+
"id": {"language": "python", "code_root": "repo", "fqname": "a.b"},
21+
"kind": "module",
22+
"name": "b",
23+
"path": "a/b.py",
24+
"loc": None,
25+
"container": "svc",
26+
"layer": "domain",
27+
"context": "billing",
28+
"tags": ["internal"],
29+
"attributes": {},
30+
}
31+
node = IRNode.from_dict(data)
32+
assert node.container == "svc"
33+
assert node.service is None
34+
assert node.container_kind is None
35+
36+
37+
def test_node_roundtrip_with_v2_fields():
38+
"""v2 node serializes and deserializes with new fields."""
39+
node = IRNode(
40+
id=cid("a.b"),
41+
kind=SymbolKind.MODULE,
42+
name="b",
43+
path="a/b.py",
44+
container="billing-service.invoice-module",
45+
layer="domain",
46+
context="billing",
47+
service="billing-service",
48+
container_kind="module",
49+
)
50+
data = node.to_dict()
51+
assert data["service"] == "billing-service"
52+
assert data["container_kind"] == "module"
53+
54+
restored = IRNode.from_dict(data)
55+
assert restored.service == "billing-service"
56+
assert restored.container_kind == "module"
57+
58+
59+
def test_edge_from_dict_without_v2_fields():
60+
"""v1 snapshot edge data deserializes with None for new fields."""
61+
data = {
62+
"src": {"language": "python", "code_root": "repo", "fqname": "a"},
63+
"dst": {"language": "python", "code_root": "repo", "fqname": "b"},
64+
"dep_type": "import",
65+
"loc": None,
66+
"confidence": 1.0,
67+
"details": {},
68+
"src_container": "svc",
69+
"src_layer": "domain",
70+
"src_context": "billing",
71+
"dst_container": "svc",
72+
"dst_layer": "infra",
73+
"dst_context": "billing",
74+
}
75+
edge = IREdge.from_dict(data)
76+
assert edge.src_container == "svc"
77+
assert edge.src_service is None
78+
assert edge.dst_service is None
79+
assert edge.src_container_kind is None
80+
assert edge.dst_container_kind is None
81+
82+
83+
def test_edge_roundtrip_with_v2_fields():
84+
"""v2 edge serializes and deserializes with new fields."""
85+
edge = IREdge(
86+
src=cid("a"),
87+
dst=cid("b"),
88+
dep_type=DepType.IMPORT,
89+
src_service="billing-service",
90+
dst_service="shared-utils",
91+
src_container_kind="service",
92+
dst_container_kind="library",
93+
)
94+
data = edge.to_dict()
95+
assert data["src_service"] == "billing-service"
96+
assert data["dst_container_kind"] == "library"
97+
98+
restored = IREdge.from_dict(data)
99+
assert restored.src_service == "billing-service"
100+
assert restored.dst_service == "shared-utils"
101+
assert restored.src_container_kind == "service"
102+
assert restored.dst_container_kind == "library"

tests/mapping/test_enricher.py

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
ArchitectureModel,
1414
CodeMapping,
1515
Container,
16+
ContainerKind,
1617
Context,
1718
Layer,
1819
)
@@ -637,3 +638,181 @@ def test_enricher_exact_root_match():
637638

638639
# node3: prefix but not subdirectory - should NOT match
639640
assert enriched.nodes[2].container is None
641+
642+
643+
# ----------------------------
644+
# v2: Nested container enrichment
645+
# ----------------------------
646+
647+
648+
def build_v2_test_model() -> ArchitectureModel:
649+
"""Build a v2 model with nested containers for enrichment tests."""
650+
contexts = {
651+
"billing": Context(id="billing", name="Billing Context"),
652+
}
653+
containers = {
654+
"billing-service": Container(
655+
id="billing-service",
656+
kind=ContainerKind.SERVICE,
657+
context="billing",
658+
code=CodeMapping(
659+
roots=("services/billing",),
660+
layers={
661+
"api": Layer(id="api", patterns=("services/billing/api/**",)),
662+
"domain": Layer(id="domain", patterns=("services/billing/domain/**",)),
663+
},
664+
),
665+
tags=("critical",),
666+
children={
667+
"invoice-module": Container(
668+
id="invoice-module",
669+
kind=ContainerKind.MODULE,
670+
code=CodeMapping(
671+
roots=("services/billing/domain/invoice",),
672+
layers={
673+
"model": Layer(id="model", patterns=("services/billing/domain/invoice/model/**",)),
674+
"repo": Layer(id="repo", patterns=("services/billing/domain/invoice/repo/**",)),
675+
},
676+
),
677+
),
678+
},
679+
),
680+
"shared-utils": Container(
681+
id="shared-utils",
682+
kind=ContainerKind.LIBRARY,
683+
code=CodeMapping(
684+
roots=("libs/shared",),
685+
layers={},
686+
),
687+
),
688+
}
689+
model = ArchitectureModel(
690+
version=2,
691+
contexts=contexts,
692+
containers=containers,
693+
relations=(),
694+
metadata={},
695+
)
696+
return DefaultModelResolver().resolve(model)
697+
698+
699+
def test_enricher_v2_nested_container_deepest_match():
700+
"""Nested container with more specific root wins over parent."""
701+
model = build_v2_test_model()
702+
703+
node = IRNode(
704+
id=CanonicalId(language=Language.PYTHON, code_root="billing", fqname="invoice.model.entity"),
705+
kind=SymbolKind.MODULE,
706+
path="services/billing/domain/invoice/model/entity.py",
707+
)
708+
ir = ArchitectureIR(
709+
schema_version=2, produced_by="test", repo_root="/test",
710+
nodes=(node,), edges=(), metadata={},
711+
)
712+
713+
enriched = DefaultArchitectureEnricher().enrich(ir, model)
714+
n = enriched.nodes[0]
715+
716+
# Should match the nested container (longer root)
717+
assert n.container == "billing-service.invoice-module"
718+
assert n.layer == "model"
719+
assert n.context == "billing" # inherited from parent
720+
assert n.service == "billing-service"
721+
assert n.container_kind == "module"
722+
723+
724+
def test_enricher_v2_parent_container_match():
725+
"""Node under parent root but not nested child root matches parent."""
726+
model = build_v2_test_model()
727+
728+
node = IRNode(
729+
id=CanonicalId(language=Language.PYTHON, code_root="billing", fqname="billing.api.routes"),
730+
kind=SymbolKind.MODULE,
731+
path="services/billing/api/routes.py",
732+
)
733+
ir = ArchitectureIR(
734+
schema_version=2, produced_by="test", repo_root="/test",
735+
nodes=(node,), edges=(), metadata={},
736+
)
737+
738+
enriched = DefaultArchitectureEnricher().enrich(ir, model)
739+
n = enriched.nodes[0]
740+
741+
assert n.container == "billing-service"
742+
assert n.layer == "api"
743+
assert n.service == "billing-service"
744+
assert n.container_kind == "service"
745+
746+
747+
def test_enricher_v2_library_container():
748+
"""Library container sets container_kind correctly."""
749+
model = build_v2_test_model()
750+
751+
node = IRNode(
752+
id=CanonicalId(language=Language.PYTHON, code_root="shared", fqname="libs.shared.util"),
753+
kind=SymbolKind.MODULE,
754+
path="libs/shared/util.py",
755+
)
756+
ir = ArchitectureIR(
757+
schema_version=2, produced_by="test", repo_root="/test",
758+
nodes=(node,), edges=(), metadata={},
759+
)
760+
761+
enriched = DefaultArchitectureEnricher().enrich(ir, model)
762+
n = enriched.nodes[0]
763+
764+
assert n.container == "shared-utils"
765+
assert n.service == "shared-utils"
766+
assert n.container_kind == "library"
767+
768+
769+
def test_enricher_v2_edge_service_and_kind():
770+
"""Edges get src/dst service and container_kind from enriched nodes."""
771+
model = build_v2_test_model()
772+
773+
src_node = IRNode(
774+
id=CanonicalId(language=Language.PYTHON, code_root="billing", fqname="billing.api.routes"),
775+
kind=SymbolKind.MODULE,
776+
path="services/billing/api/routes.py",
777+
)
778+
dst_node = IRNode(
779+
id=CanonicalId(language=Language.PYTHON, code_root="shared", fqname="libs.shared.util"),
780+
kind=SymbolKind.MODULE,
781+
path="libs/shared/util.py",
782+
)
783+
edge = IREdge(src=src_node.id, dst=dst_node.id, dep_type=DepType.IMPORT)
784+
785+
ir = ArchitectureIR(
786+
schema_version=2, produced_by="test", repo_root="/test",
787+
nodes=(src_node, dst_node), edges=(edge,), metadata={},
788+
)
789+
790+
enriched = DefaultArchitectureEnricher().enrich(ir, model)
791+
e = enriched.edges[0]
792+
793+
assert e.src_service == "billing-service"
794+
assert e.src_container_kind == "service"
795+
assert e.dst_service == "shared-utils"
796+
assert e.dst_container_kind == "library"
797+
798+
799+
def test_enricher_v2_unmatched_node_has_no_service():
800+
"""Nodes that don't match any container have None for service/container_kind."""
801+
model = build_v2_test_model()
802+
803+
node = IRNode(
804+
id=CanonicalId(language=Language.PYTHON, code_root="other", fqname="lib.utils"),
805+
kind=SymbolKind.MODULE,
806+
path="other/utils.py",
807+
)
808+
ir = ArchitectureIR(
809+
schema_version=2, produced_by="test", repo_root="/test",
810+
nodes=(node,), edges=(), metadata={},
811+
)
812+
813+
enriched = DefaultArchitectureEnricher().enrich(ir, model)
814+
n = enriched.nodes[0]
815+
816+
assert n.container is None
817+
assert n.service is None
818+
assert n.container_kind is None

0 commit comments

Comments
 (0)