Skip to content

Commit 03a92a7

Browse files
committed
feat(rules): add within field to distinguish top-level container kind
Enrich IR nodes/edges with `within` (top-level container kind) alongside immediate `kind`, update rule field accessors/docs/examples, and add tests covering nested container behavior.
1 parent fd18ecf commit 03a92a7

11 files changed

Lines changed: 640 additions & 46 deletions

File tree

docs/architecture/v2.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,39 @@ All v1 container fields (`name`, `description`, `context`, `code`, `tags`) remai
7676
| `module` | Logical module within a service |
7777
| `library` | Shared library used by other containers |
7878

79+
### Enriched Fields: `kind` vs `within`
80+
81+
When code is analyzed, each node and edge is enriched with container metadata. For nested containers, there are two important fields:
82+
83+
| Field | Meaning | Example Value |
84+
|-------|---------|---------------|
85+
| `kind` | Immediate container's kind | `module` for code in `billing-service.invoice-module` |
86+
| `within` | Top-level container's kind | `service` for any code inside `billing-service` |
87+
88+
**Example:** For code at `services/billing/domain/invoice/model/invoice.py`:
89+
90+
```
91+
billing-service (kind: service)
92+
└── invoice-module (kind: module)
93+
└── model/invoice.py ← this file
94+
```
95+
96+
The enriched fields would be:
97+
98+
| Field | Value |
99+
|-------|-------|
100+
| `container` | `billing-service.invoice-module` |
101+
| `service` | `billing-service` |
102+
| `kind` | `module` |
103+
| `within` | `service` |
104+
105+
**When to use each in rules:**
106+
107+
- Use `kind` to match the specific container type (e.g., target only code directly in modules)
108+
- Use `within` to match anything inside a service hierarchy (e.g., catch all service code, including nested modules)
109+
110+
See [Rules DSL: kind vs within](../rules.md#kind-vs-within) for detailed examples.
111+
79112
### Dot-Qualified IDs
80113
81114
Nested containers are addressed using dot-qualified IDs. For the example above:

docs/rules.md

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,9 @@ rule:
4141
| Field | Description |
4242
|-------|-------------|
4343
| `node.symbol_kind` | Symbol type: `file`, `module`, `class`, `function`, etc. |
44-
| `node.kind` | Container kind: `service`, `module`, `library` (v2 only) |
45-
| `node.service` | Top-level container ancestor (v2 only) |
44+
| `node.kind` | Immediate container kind: `service`, `module`, `library` (v2 only) |
45+
| `node.within` | Top-level container's kind (v2 only) — see [kind vs within](#kind-vs-within) |
46+
| `node.service` | Top-level container ancestor ID (v2 only) |
4647
| `node.path` | File path |
4748
| `node.name` | Symbol name |
4849
| `node.layer` | Architectural layer |
@@ -59,8 +60,9 @@ rule:
5960
| `from.layer` / `to.layer` | Source/target layer |
6061
| `from.context` / `to.context` | Source/target bounded context |
6162
| `from.container` / `to.container` | Source/target container ID |
62-
| `from.service` / `to.service` | Source/target top-level service (v2 only) |
63-
| `from.kind` / `to.kind` | Source/target container kind (v2 only) |
63+
| `from.service` / `to.service` | Source/target top-level service ID (v2 only) |
64+
| `from.kind` / `to.kind` | Source/target immediate container kind (v2 only) |
65+
| `from.within` / `to.within` | Source/target top-level container's kind (v2 only) — see [kind vs within](#kind-vs-within) |
6466
| `from.fqname` / `to.fqname` | Fully qualified names |
6567
| `from.id` / `to.id` | Full canonical ID strings |
6668
| `dep.type` | Dependency type (`import`, `call`, etc.) |
@@ -154,9 +156,36 @@ rule:
154156
suggestion: Access infrastructure through application services instead
155157
```
156158

159+
## kind vs within
160+
161+
In v2 schemas with nested containers, there's an important distinction:
162+
163+
| Field | Meaning | Example |
164+
|-------|---------|---------|
165+
| `kind` | Immediate container's kind | `module` for code in `billing-service.invoice-module` |
166+
| `within` | Top-level container's kind | `service` for any code inside `billing-service` |
167+
168+
**When to use each:**
169+
170+
- Use **`kind`** when you want to match the specific container type (e.g., "only code directly in a module")
171+
- Use **`within`** when you want to match anything inside a service hierarchy (e.g., "any code belonging to a service, including nested modules")
172+
173+
**Example:** Code at `services/billing/domain/invoice/model/invoice.py` inside `billing-service.invoice-module`:
174+
175+
```
176+
billing-service (kind: service)
177+
└── invoice-module (kind: module)
178+
└── invoice.py ← this file
179+
```
180+
181+
| Field | Value |
182+
|-------|-------|
183+
| `kind` | `module` (immediate container) |
184+
| `within` | `service` (top-level container) |
185+
157186
## Example: Cross-Service Rules (v2)
158187
159-
These rules use v2-only fields (`from.service`, `to.service`, `from.kind`, `to.kind`):
188+
These rules use v2-only fields (`from.service`, `to.service`, `from.kind`, `to.kind`, `from.within`, `to.within`):
160189
161190
```yaml
162191
# Forbid cross-service domain dependencies
@@ -172,7 +201,7 @@ rule:
172201
- from.layer == domain
173202
message: Domain code must not depend on another service
174203
175-
# Libraries must not depend on services
204+
# Libraries must not depend on services (using 'within' for nested containers)
176205
rule:
177206
id: library-no-service-deps
178207
name: Libraries must be independent of services
@@ -181,7 +210,13 @@ rule:
181210
action: forbid
182211
when:
183212
all:
184-
- from.kind == library
185-
- to.kind == service
213+
- from.within == library
214+
- to.within == service
186215
message: Library code must not import service code
216+
suggestion: Move shared code to a library or create a proper API contract
187217
```
218+
219+
!!! note "Why use `within` instead of `kind`?"
220+
Using `to.kind == service` would NOT match code inside nested modules like `billing-service.invoice-module`, because the immediate container's kind is `module`, not `service`.
221+
222+
Using `to.within == service` matches ANY code inside a service hierarchy, including nested modules.

examples/microservices-platform/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,25 @@ The platform consists of three containers:
2424
The rules demonstrate v2-specific fields:
2525

2626
- `from.service != to.service` — detect cross-service dependencies
27-
- `from.kind == library` / `to.kind == service` — enforce library independence
27+
- `from.within == library` / `to.within == service` — enforce library independence
2828
- `from.layer` / `to.layer` — standard layer constraints
2929

30+
### `kind` vs `within`
31+
32+
For nested containers, there's an important distinction:
33+
34+
| Field | Meaning | Example |
35+
|-------|---------|---------|
36+
| `kind` | Immediate container's kind | `module` for code in `invoice-module` |
37+
| `within` | Top-level container's kind | `service` for any code inside `billing-service` |
38+
39+
**Why use `within`?** Using `to.kind == service` would NOT match code inside nested modules (like `invoice-module`), because the immediate kind is `module`. Using `to.within == service` matches ANY code inside the service hierarchy.
40+
3041
## Running
3142

3243
```bash
3344
cd examples/microservices-platform
3445
pacta scan . --model architecture.yml --rules rules.pacta.yml
3546
```
47+
48+
This will detect a violation because `libs/shared/money.py` imports from `services/billing/domain/invoice/model/invoice.py`.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,12 @@
1+
# This import violates the library-no-service-deps rule
2+
# Libraries should not depend on service code
3+
from services.billing.domain.invoice.model.invoice import Invoice
4+
5+
16
class Money:
27
def __init__(self, amount: float):
38
self.amount = amount
9+
10+
def to_invoice(self) -> Invoice:
11+
# This method couples library code to service code - a violation!
12+
return Invoice(amount=self.amount)
Lines changed: 47 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,49 @@
1-
rules:
2-
- id: no-cross-service-domain-deps
3-
name: Domain must not depend on other services
4-
severity: error
5-
target: dependency
6-
action: forbid
7-
when:
8-
all:
9-
- from.service != to.service
10-
- from.layer == domain
11-
message: Domain code must not depend on another service
1+
# Microservices Platform Rules
2+
#
3+
# Field reference:
4+
# - kind: immediate container's kind (service, module, library)
5+
# - within: top-level container's kind (for nested containers)
6+
#
7+
# Example: code in billing-service.invoice-module has:
8+
# - kind = 'module' (immediate container)
9+
# - within = 'service' (top-level container)
10+
#
11+
# Use 'within' to match any code inside a service hierarchy (including nested modules).
12+
# Use 'kind' to match only the immediate container type.
1213

13-
- id: library-no-service-deps
14-
name: Libraries must be independent of services
15-
severity: error
16-
target: dependency
17-
action: forbid
18-
when:
19-
all:
20-
- from.kind == library
21-
- to.kind == service
22-
message: Library code must not import service code
14+
rule:
15+
id: no-cross-service-domain-deps
16+
name: Domain must not depend on other services
17+
severity: error
18+
target: dependency
19+
action: forbid
20+
when:
21+
all:
22+
- from.service != to.service
23+
- from.layer == domain
24+
message: Domain code must not depend on another service
2325

24-
- id: no-infra-to-api
25-
name: Infrastructure must not depend on API layer
26-
severity: error
27-
target: dependency
28-
action: forbid
29-
when:
30-
all:
31-
- from.layer == infra
32-
- to.layer == api
33-
message: Infrastructure layer must not depend on the API layer
26+
rule:
27+
id: library-no-service-deps
28+
name: Libraries must be independent of services
29+
severity: error
30+
target: dependency
31+
action: forbid
32+
when:
33+
all:
34+
- from.within == library
35+
- to.within == service
36+
message: Library code must not import service code
37+
suggestion: Move shared code to a library or create a proper API contract
38+
39+
rule:
40+
id: no-infra-to-api
41+
name: Infrastructure must not depend on API layer
42+
severity: error
43+
target: dependency
44+
action: forbid
45+
when:
46+
all:
47+
- from.layer == infra
48+
- to.layer == api
49+
message: Infrastructure layer must not depend on the API layer

pacta/ir/types.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,8 @@ class IRNode:
146146

147147
# v2: service-level enrichment
148148
service: str | None = None # top-level container ancestor
149-
container_kind: str | None = None # container kind (service/module/library)
149+
container_kind: str | None = None # immediate container kind (service/module/library)
150+
within: str | None = None # top-level container's kind (for nested containers)
150151

151152
attributes: Mapping[str, Any] = field(default_factory=dict)
152153

@@ -163,6 +164,7 @@ def to_dict(self) -> dict[str, Any]:
163164
"tags": list(self.tags),
164165
"service": self.service,
165166
"container_kind": self.container_kind,
167+
"within": self.within,
166168
"attributes": dict(self.attributes),
167169
}
168170

@@ -180,6 +182,7 @@ def from_dict(data: Mapping[str, Any]) -> "IRNode":
180182
tags=tuple(data.get("tags", [])),
181183
service=data.get("service"),
182184
container_kind=data.get("container_kind"),
185+
within=data.get("within"),
183186
attributes=dict(data.get("attributes", {})),
184187
)
185188

@@ -214,6 +217,8 @@ class IREdge:
214217
dst_service: str | None = None
215218
src_container_kind: str | None = None
216219
dst_container_kind: str | None = None
220+
src_within: str | None = None # top-level container's kind for source
221+
dst_within: str | None = None # top-level container's kind for destination
217222

218223
def to_dict(self) -> dict[str, Any]:
219224
return {
@@ -233,6 +238,8 @@ def to_dict(self) -> dict[str, Any]:
233238
"dst_service": self.dst_service,
234239
"src_container_kind": self.src_container_kind,
235240
"dst_container_kind": self.dst_container_kind,
241+
"src_within": self.src_within,
242+
"dst_within": self.dst_within,
236243
}
237244

238245
@staticmethod
@@ -254,6 +261,8 @@ def from_dict(data: Mapping[str, Any]) -> "IREdge":
254261
dst_service=data.get("dst_service"),
255262
src_container_kind=data.get("src_container_kind"),
256263
dst_container_kind=data.get("dst_container_kind"),
264+
src_within=data.get("src_within"),
265+
dst_within=data.get("dst_within"),
257266
)
258267

259268

pacta/mapping/enricher.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,14 +72,20 @@ def _enrich_node(self, node: IRNode, model: ArchitectureModel) -> IRNode:
7272
if container_id is not None:
7373
context_id = model.get_context_for_container(container_id)
7474

75-
# v2: derive service (top-level ancestor) and container_kind
75+
# v2: derive service (top-level ancestor), container_kind, and within
7676
service: str | None = None
7777
container_kind: str | None = None
78+
within: str | None = None
7879
if container_id is not None:
7980
service = container_id.split(".")[0]
81+
# container_kind = immediate container's kind
8082
container = model.get_container(container_id)
8183
if container is not None and container.kind is not None:
8284
container_kind = container.kind.value
85+
# within = top-level container's kind (for nested containers)
86+
top_container = model.get_container(service)
87+
if top_container is not None and top_container.kind is not None:
88+
within = top_container.kind.value
8389

8490
# Return enriched node if anything changed, otherwise return original
8591
if (
@@ -89,6 +95,7 @@ def _enrich_node(self, node: IRNode, model: ArchitectureModel) -> IRNode:
8995
and node.tags == tags
9096
and node.service == service
9197
and node.container_kind == container_kind
98+
and node.within == within
9299
):
93100
return node
94101

@@ -100,6 +107,7 @@ def _enrich_node(self, node: IRNode, model: ArchitectureModel) -> IRNode:
100107
tags=tags,
101108
service=service,
102109
container_kind=container_kind,
110+
within=within,
103111
)
104112

105113
def _match_container(self, node: IRNode, model: ArchitectureModel) -> str | None:
@@ -175,12 +183,14 @@ def _enrich_edge(self, edge: IREdge, node_lookup: dict[str, IRNode]) -> IREdge:
175183
src_context = src_node.context if src_node else None
176184
src_service = src_node.service if src_node else None
177185
src_container_kind = src_node.container_kind if src_node else None
186+
src_within = src_node.within if src_node else None
178187

179188
dst_container = dst_node.container if dst_node else None
180189
dst_layer = dst_node.layer if dst_node else None
181190
dst_context = dst_node.context if dst_node else None
182191
dst_service = dst_node.service if dst_node else None
183192
dst_container_kind = dst_node.container_kind if dst_node else None
193+
dst_within = dst_node.within if dst_node else None
184194

185195
# Return enriched edge if anything changed
186196
if (
@@ -194,6 +204,8 @@ def _enrich_edge(self, edge: IREdge, node_lookup: dict[str, IRNode]) -> IREdge:
194204
and edge.dst_service == dst_service
195205
and edge.src_container_kind == src_container_kind
196206
and edge.dst_container_kind == dst_container_kind
207+
and edge.src_within == src_within
208+
and edge.dst_within == dst_within
197209
):
198210
return edge
199211

@@ -209,6 +221,8 @@ def _enrich_edge(self, edge: IREdge, node_lookup: dict[str, IRNode]) -> IREdge:
209221
dst_service=dst_service,
210222
src_container_kind=src_container_kind,
211223
dst_container_kind=dst_container_kind,
224+
src_within=src_within,
225+
dst_within=dst_within,
212226
)
213227

214228
def _normalize_path(self, path: str) -> str:

0 commit comments

Comments
 (0)