katalyst-plugin-context 0.2.1a11__tar.gz → 0.2.1a13__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/PKG-INFO +3 -3
  2. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/pyproject.toml +3 -3
  3. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/adapters/runtime_graph.py +106 -2
  4. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/composition.py +6 -2
  5. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/context.py +6 -2
  6. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/paths.py +31 -1
  7. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/policy.py +23 -10
  8. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/lint.py +12 -8
  9. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/service.py +11 -2
  10. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/context_support.py +37 -2
  11. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/test_context.py +104 -0
  12. katalyst_plugin_context-0.2.1a13/tests/test_cross_backend_edges.py +287 -0
  13. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/test_lint.py +25 -0
  14. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/.gitignore +0 -0
  15. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/Justfile +0 -0
  16. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/__init__.py +0 -0
  17. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  18. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/cli.py +0 -0
  19. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  20. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/graph.py +0 -0
  21. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/markdown.py +0 -0
  22. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/settings.py +0 -0
  23. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/mcp_tools.py +0 -0
  24. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/plugin.py +0 -0
  25. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/py.typed +0 -0
  26. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/rest.py +0 -0
  27. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/schema.py +0 -0
  28. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/src/layer.yaml +0 -0
  29. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/conftest.py +0 -0
  30. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/test_shared_names.py +0 -0
  31. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/tests/test_surfaces.py +0 -0
  32. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/.gitignore +0 -0
  33. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/Justfile +0 -0
  34. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/features/cli/52_kata_context.feature +0 -0
  35. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/features/cli/53_kata_context_budget_policy.feature +0 -0
  36. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/features/mcp/09_mcp_context.feature +0 -0
  37. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/areas/payments.yaml +0 -0
  38. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/checks/encrypt-card-data.scan-for-card-numbers.yaml +0 -0
  39. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/components/payments.checkout.web-api.yaml +0 -0
  40. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/async-events.yaml +0 -0
  41. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/pin-httpx.yaml +0 -0
  42. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/use-postgres.yaml +0 -0
  43. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/packages/httpx.yaml +0 -0
  44. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/rules/encrypt-card-data.yaml +0 -0
  45. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/rules/log-correlation-id.yaml +0 -0
  46. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/services/payments.checkout.yaml +0 -0
  47. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/layer.yaml +0 -0
  48. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/package.json +0 -0
  49. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/playwright.config.ts +0 -0
  50. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/steps/context.ts +0 -0
  51. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/steps/steps.ts +0 -0
  52. {katalyst_plugin_context-0.2.1a11 → katalyst_plugin_context-0.2.1a13}/xspec/tsconfig.json +0 -0
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-plugin-context
3
- Version: 0.2.1a11
3
+ Version: 0.2.1a13
4
4
  Summary: Katalyst context plugin — the right context from the taxonomy for an agent working on a node
5
5
  Author: eSimplicity
6
6
  License: Proprietary
7
7
  Requires-Python: <3.14,>=3.13
8
8
  Requires-Dist: click>=8.0
9
9
  Requires-Dist: jsonschema>=4
10
- Requires-Dist: katalyst-engine<6,>=5
10
+ Requires-Dist: katalyst-engine<6,>=5.1
11
11
  Requires-Dist: katalyst-taxonomy-cli<3,>=2
12
12
  Requires-Dist: katalyst-taxonomy-mcp<5,>=4
13
- Requires-Dist: katalyst-taxonomy<6,>=5
13
+ Requires-Dist: katalyst-taxonomy<6,>=5.1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-plugin-context"
3
- version = "0.2.1a11"
3
+ version = "0.2.1a13"
4
4
  description = "Katalyst context plugin — the right context from the taxonomy for an agent working on a node"
5
5
  license = { text = "Proprietary" }
6
6
  requires-python = ">=3.13,<3.14"
@@ -9,8 +9,8 @@ authors = [
9
9
  ]
10
10
  dependencies = [
11
11
  # Bounded to the config-rooted runtime this plugin is written against.
12
- "katalyst-taxonomy>=5,<6",
13
- "katalyst-engine>=5,<6",
12
+ "katalyst-taxonomy>=5.1,<6",
13
+ "katalyst-engine>=5.1,<6",
14
14
  # Its MCP tools and REST routes use the server's response helpers; its command, the CLI context.
15
15
  "katalyst-taxonomy-mcp>=4,<5",
16
16
  "katalyst-taxonomy-cli>=2,<3",
@@ -6,12 +6,23 @@ the profile's filters (:func:`katalyst_taxonomy.services.filters.visible_nodes`)
6
6
  kept only when both its ends are visible. A reference into another backend is stored as
7
7
  ``backend:fqtn`` and keyed that way, so an edge held by one backend can reach a node of
8
8
  another.
9
+
10
+ Cross-backend edges (#418)
11
+ --------------------------
12
+ A backend's ``list_relationships`` resolves references against *its own* documents only, so a
13
+ reference with a ``backend:`` prefix that names a node in another backend silently produces no
14
+ edge. After collecting per-backend edges, :func:`build_graph` runs a second pass that
15
+ resolves declared references (``FieldRef``) over the aggregated document set — the same set
16
+ lint uses — and emits every edge a backend could not emit on its own. The resolver is
17
+ ``engine.references.resolve_cross_references``, so the graph and the lint gate read
18
+ identical outcomes.
9
19
  """
10
20
 
11
21
  from __future__ import annotations
12
22
 
23
+ import logging
13
24
  from collections.abc import Callable, Iterable, Sequence
14
- from typing import Any, cast
25
+ from typing import TYPE_CHECKING, Any, cast
15
26
 
16
27
  from katalyst_engine.model.node import Node
17
28
  from katalyst_taxonomy.engine.accessors import (
@@ -19,14 +30,23 @@ from katalyst_taxonomy.engine.accessors import (
19
30
  get_markdown_body,
20
31
  get_node_backend,
21
32
  )
22
- from katalyst_taxonomy.engine.identity import split_backend_prefix
33
+ from katalyst_taxonomy.engine.identity import (
34
+ split_backend_prefix,
35
+ with_backend_prefix,
36
+ )
23
37
  from katalyst_taxonomy.engine.qualification import split_kind_selector
38
+ from katalyst_taxonomy.engine.references import resolve_cross_references
24
39
  from katalyst_taxonomy.ports.backend import TaxonomyBackend
25
40
 
26
41
  from katalyst_plugin_context.domain.graph import Edge, Graph, GraphNode
27
42
 
43
+ if TYPE_CHECKING: # pragma: no cover - typing only
44
+ from katalyst_engine.schema.registry import SchemaRegistry
45
+
28
46
  __all__ = ["Carriers", "build_graph", "graph_from"]
29
47
 
48
+ logger = logging.getLogger(__name__)
49
+
30
50
  #: What a kind can carry: the relationship types it holds, each with the kinds it may point at
31
51
  #: (empty: any kind).
32
52
  Carriers = Callable[[str], Sequence[tuple[str, frozenset[str]]]]
@@ -136,14 +156,92 @@ def _of_kind(kinds: list[str], address: str) -> list[str]:
136
156
  return kinds if selected is None else [kind for kind in kinds if kind == selected]
137
157
 
138
158
 
159
+ # ---------------------------------------------------------------------------
160
+ # Cross-backend edge stitching (#418)
161
+ # ---------------------------------------------------------------------------
162
+
163
+
164
+ #: An edge by the addresses of its ends: ``((backend, fqtn), rel_type, (backend, fqtn))``.
165
+ EdgeKey = tuple[tuple[str, str], str, tuple[str, str]]
166
+
167
+
168
+ def _edge_key(holder: str, source: str, rel_type: str, target: str) -> EdgeKey:
169
+ """*source* and *target* as their backend and qualified name, *holder* filling in a
170
+ backend an address does not name."""
171
+ return (_address(holder, source), rel_type, _address(holder, target))
172
+
173
+
174
+ def _stitch_cross_backend_edges(
175
+ registry: SchemaRegistry,
176
+ loaded: Sequence[tuple[str, Node]],
177
+ existing: set[EdgeKey],
178
+ ) -> list[tuple[str, str, str, str]]:
179
+ """Emit edges that per-backend resolution missed because the target is elsewhere.
180
+
181
+ Each backend builds its reference index from its own documents only, so a value
182
+ like ``ric:ric-testing`` (target in the ``ric`` backend) produces no edge when
183
+ resolved inside the ``golden`` backend. This function resolves every declared
184
+ ``FieldRef`` over the **aggregated** document set — the same corpus lint uses —
185
+ and returns the edges no backend emitted.
186
+
187
+ *existing* holds the edges the backends already produced, keyed by the addresses of
188
+ their ends (:func:`_edge_key`), so an edge is never duplicated and two backends'
189
+ edges between nodes of the same names are never mistaken for each other.
190
+
191
+ Each returned tuple is ``(holder_backend, source, rel_type, target)``, both ends
192
+ prefixed with their backend.
193
+ """
194
+ all_docs = tuple(doc for _, doc in loaded)
195
+ holder_of = {id(doc): holder for holder, doc in loaded}
196
+
197
+ def fqtn_of(doc: Node) -> str:
198
+ return doc.fqn or doc.name
199
+
200
+ def address_of(doc: Node) -> tuple[str, str]:
201
+ # A document without a backend stamp belongs to the backend that held it.
202
+ return (get_node_backend(doc) or holder_of.get(id(doc), ""), fqtn_of(doc))
203
+
204
+ extra: list[tuple[str, str, str, str]] = []
205
+ for resolution in resolve_cross_references(registry, all_docs, fqtn_of=fqtn_of):
206
+ target = resolution.target
207
+ rel_type = resolution.rel_type
208
+ if target is None or not rel_type:
209
+ continue
210
+
211
+ source_address = address_of(resolution.source)
212
+ target_address = address_of(target)
213
+ key: EdgeKey = (source_address, rel_type, target_address)
214
+ if key in existing:
215
+ continue
216
+
217
+ source_backend = source_address[0]
218
+ extra.append(
219
+ (
220
+ source_backend,
221
+ with_backend_prefix(source_backend, source_address[1]),
222
+ rel_type,
223
+ with_backend_prefix(target_address[0], target_address[1]),
224
+ )
225
+ )
226
+ existing.add(key)
227
+
228
+ if extra:
229
+ logger.debug("Cross-backend stitching added %d edge(s)", len(extra))
230
+ return extra
231
+
232
+
139
233
  def build_graph(
140
234
  sources: Iterable[tuple[str, TaxonomyBackend]],
141
235
  *,
142
236
  profile: str | None = None,
143
237
  carriers: Carriers | None = None,
238
+ registry: SchemaRegistry | None = None,
144
239
  ) -> Graph:
145
240
  """Read every backend in *sources* once and build the graph the profile may see.
146
241
 
242
+ When *registry* is supplied, a second pass resolves cross-backend references that
243
+ no single backend could resolve on its own (#418).
244
+
147
245
  Raises:
148
246
  PermissionDeniedError: If the profile may not read a backend (as every read does).
149
247
  """
@@ -157,6 +255,12 @@ def build_graph(
157
255
  (name, rel.source_fqtn, rel.rel_type, rel.target_fqtn)
158
256
  for rel in backend.list_relationships()
159
257
  )
258
+
259
+ # --- Cross-backend edge stitching (#418) ---
260
+ if registry is not None and len({name for name, _ in loaded}) > 1:
261
+ existing = {_edge_key(*rel) for rel in relationships}
262
+ relationships.extend(_stitch_cross_backend_edges(registry, loaded, existing))
263
+
160
264
  visible = {id(doc) for doc in visible_nodes(None, [doc for _, doc in loaded], profile=profile)}
161
265
  hidden = (
162
266
  (get_node_backend(doc) or holder, doc.fqn or doc.name)
@@ -43,8 +43,12 @@ def service_for(
43
43
  *,
44
44
  profile: str | None,
45
45
  ) -> ContextService:
46
- """The service over what *sources* hold, under the profile named *profile*."""
46
+ """The service over what *sources* hold, under the profile named *profile*.
47
+
48
+ The *registry* is forwarded to :func:`build_graph` so cross-backend references
49
+ can be resolved over the aggregated document set (#418).
50
+ """
47
51
  config, active = plugin_config(profile)
48
52
  schema = RegistrySchema(registry)
49
- graph = build_graph(sources, profile=active, carriers=schema.carried)
53
+ graph = build_graph(sources, profile=active, carriers=schema.carried, registry=registry)
50
54
  return ContextService(registry, graph, config=config, profile=active)
@@ -15,7 +15,7 @@ from dataclasses import dataclass, field
15
15
  from typing import Any, Protocol, cast
16
16
 
17
17
  from katalyst_plugin_context.domain.graph import Graph, GraphNode
18
- from katalyst_plugin_context.domain.paths import OUT, Step, parse_path, path_text
18
+ from katalyst_plugin_context.domain.paths import OUT, Inverses, Step, parse_path, path_text
19
19
  from katalyst_plugin_context.domain.policy import DETAIL_LEVELS, FULL, REFS, SUMMARY, Policy
20
20
  from katalyst_plugin_context.domain.settings import ItemHints, View
21
21
 
@@ -55,6 +55,7 @@ class Schema(Protocol):
55
55
  """What the context needs to know about kinds and relationships."""
56
56
 
57
57
  parent_rel: str
58
+ inverses: Inverses
58
59
 
59
60
  def kind_label(self, kind: str) -> str: ...
60
61
  def hints(self, kind: str) -> ItemHints: ...
@@ -125,6 +126,7 @@ def _walk(
125
126
  inherited: bool,
126
127
  parent_rel: str,
127
128
  collect_every_level: bool,
129
+ inverses: Inverses,
128
130
  ) -> list[_Arrival]:
129
131
  """Every node reached from *start* by following *steps*, with the hops that reached it.
130
132
 
@@ -136,7 +138,8 @@ def _walk(
136
138
  """
137
139
  frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
138
140
  arrivals: list[_Arrival] = []
139
- for index, step in enumerate(steps):
141
+ # A step on the computed side of a declared inverse is the stored side walked the other way.
142
+ for index, step in enumerate(inverses.resolve(s) for s in steps):
140
143
  nxt: list[tuple[str, tuple[_Hop, ...]]] = []
141
144
  for current, hops in frontier:
142
145
  on_branch = {start, *(h.target for h in hops)}
@@ -530,6 +533,7 @@ def _collect(
530
533
  inherited=start != focus,
531
534
  parent_rel=schema.parent_rel,
532
535
  collect_every_level=source.repeat > 1,
536
+ inverses=schema.inverses,
533
537
  )
534
538
  for arrival in arrivals:
535
539
  node = graph.nodes[arrival.key]
@@ -14,13 +14,15 @@ relationship or kind.
14
14
  from __future__ import annotations
15
15
 
16
16
  import re
17
- from dataclasses import dataclass
17
+ from collections.abc import Mapping
18
+ from dataclasses import dataclass, field
18
19
 
19
20
  __all__ = [
20
21
  "DIRECTIONS",
21
22
  "IN",
22
23
  "OUT",
23
24
  "STEP_RE",
25
+ "Inverses",
24
26
  "PathSyntaxError",
25
27
  "Step",
26
28
  "parse_path",
@@ -79,6 +81,34 @@ class Step:
79
81
  return self.rel
80
82
 
81
83
 
84
+ @dataclass(frozen=True, slots=True)
85
+ class Inverses:
86
+ """The declared inverses a path may be written with, as graph labels.
87
+
88
+ A schema stores one side of a pair (``BLOCKS``) and computes the other (``BLOCKED_BY``).
89
+ Only the stored side is an edge in the graph, so a step on the computed side means the
90
+ stored one walked the other way: ``BLOCKED_BY:out`` is ``BLOCKS:in``, kind qualifier
91
+ kept. A symmetric relationship reads the same from either end, so a step on it names no
92
+ direction. Nothing here knows any relationship: the schema supplies the pairs.
93
+ """
94
+
95
+ computed: Mapping[str, str] = field(default_factory=lambda: dict[str, str]())
96
+ """Computed graph label to the graph label of its stored side."""
97
+
98
+ symmetric: frozenset[str] = frozenset()
99
+ """Graph labels that read the same from either end."""
100
+
101
+ def resolve(self, step: Step) -> Step:
102
+ """*step* as the graph holds it: the stored side, walked the right way."""
103
+ stored = self.computed.get(step.rel)
104
+ if stored is not None:
105
+ flipped = {OUT: IN, IN: OUT}.get(step.direction or "")
106
+ return Step(stored, flipped, step.kinds)
107
+ if step.rel in self.symmetric and step.direction is not None:
108
+ return Step(step.rel, None, step.kinds)
109
+ return step
110
+
111
+
82
112
  def parse_step(text: str) -> Step:
83
113
  """Parse ``REL[:DIR[:KIND,...]]``.
84
114
 
@@ -29,7 +29,7 @@ from collections.abc import Mapping, Sequence
29
29
  from dataclasses import dataclass, field
30
30
  from typing import Any, cast
31
31
 
32
- from katalyst_plugin_context.domain.paths import Step, parse_step
32
+ from katalyst_plugin_context.domain.paths import Inverses, Step, parse_step
33
33
 
34
34
  __all__ = [
35
35
  "BUILTIN",
@@ -84,16 +84,22 @@ class Policy:
84
84
  default_inherit: bool = True
85
85
  layers: list[str] = field(default_factory=lambda: [BUILTIN])
86
86
  notes: list[dict[str, Any]] = field(default_factory=lambda: list[dict[str, Any]]())
87
+ inverses: Inverses = field(default_factory=Inverses)
88
+ """The schema's declared inverses: a pattern written on the computed side of a pair
89
+ means the stored side walked the other way."""
87
90
 
88
91
  # ── visibility ──────────────────────────────────────────────────────
89
92
 
90
93
  def hidden_by(self, rel: str, direction: str, kind: str) -> str | None:
91
- """The layer that hides traversing *rel* *direction* to a *kind*, or ``None``."""
94
+ """The layer that hides traversing *rel* *direction* to a *kind*, or ``None``.
95
+
96
+ *rel* and *direction* are the edge as the graph holds it, the stored side.
97
+ """
92
98
  for label, patterns in self.allow:
93
- if not any(p.matches(rel, direction, kind) for p in patterns):
99
+ if not any(self.inverses.resolve(p).matches(rel, direction, kind) for p in patterns):
94
100
  return label
95
101
  for label, pattern in self.deny:
96
- if pattern.matches(rel, direction, kind):
102
+ if self.inverses.resolve(pattern).matches(rel, direction, kind):
97
103
  return label
98
104
  return None
99
105
 
@@ -106,11 +112,12 @@ class Policy:
106
112
  A step is hidden when an allow list has nothing overlapping it, or a deny pattern
107
113
  covers it entirely (same or wider relationship, direction and kinds).
108
114
  """
115
+ step = self.inverses.resolve(step)
109
116
  for label, patterns in self.allow:
110
- if not any(p.overlaps(step) for p in patterns):
117
+ if not any(self.inverses.resolve(p).overlaps(step) for p in patterns):
111
118
  return label
112
119
  for label, pattern in self.deny:
113
- if _covers(pattern, step):
120
+ if _covers(self.inverses.resolve(pattern), step):
114
121
  return label
115
122
  return None
116
123
 
@@ -199,13 +206,16 @@ def _strings(value: object) -> list[str]:
199
206
  return [str(v) for v in cast("list[object]", value)] if isinstance(value, list) else []
200
207
 
201
208
 
202
- def fold(layers: Sequence[Layer], *, views: Sequence[str] = ()) -> Policy:
209
+ def fold(
210
+ layers: Sequence[Layer], *, views: Sequence[str] = (), inverses: Inverses | None = None
211
+ ) -> Policy:
203
212
  """The effective policy *layers* give, in order, after the built-in one.
204
213
 
205
214
  *views* are the names of the views the node's kind declares, the only names
206
- ``always_include`` may hold.
215
+ ``always_include`` may hold. *inverses* are the schema's declared inverses, so a
216
+ pattern written on the computed side of a pair applies to the stored side.
207
217
  """
208
- policy = Policy()
218
+ policy = Policy(inverses=inverses or Inverses())
209
219
  declared_views = set(views)
210
220
  for layer in layers:
211
221
  declares = bool(layer.policy) or bool(layer.defaults)
@@ -223,7 +233,10 @@ def _fold_paths(policy: Policy, layer: Layer) -> None:
223
233
  patterns = tuple(parse_step(text) for text in _strings(paths["allow"]))
224
234
  for pattern in patterns:
225
235
  for label, earlier in policy.allow:
226
- if not any(p.overlaps(pattern) for p in earlier):
236
+ if not any(
237
+ policy.inverses.resolve(p).overlaps(policy.inverses.resolve(pattern))
238
+ for p in earlier
239
+ ):
227
240
  policy.notes.append(
228
241
  _note(
229
242
  layer.label,
@@ -26,7 +26,7 @@ from katalyst_engine.schema.definition import VALUE_FLAG_BLOCKING
26
26
  from katalyst_engine.schema.registry import SchemaRegistry
27
27
  from katalyst_taxonomy.plugins.extensions import LintFinding, LintSubject
28
28
 
29
- from katalyst_plugin_context.domain.paths import IN, OUT, PathSyntaxError, Step, path_text
29
+ from katalyst_plugin_context.domain.paths import IN, OUT, Inverses, PathSyntaxError, Step, path_text
30
30
  from katalyst_plugin_context.domain.policy import Layer, Policy, fold
31
31
  from katalyst_plugin_context.domain.settings import View, views_from
32
32
  from katalyst_plugin_context.service import NAMESPACE, RegistrySchema
@@ -96,10 +96,12 @@ class ContextPolicyRule:
96
96
  )
97
97
  if section is not None
98
98
  ]
99
- policy = fold(layers, views=sorted(views))
100
- declared = _declared_always(layers, views)
99
+ policy = fold(layers, views=sorted(views), inverses=schema.inverses)
100
+ declared = _declared_always(layers, views, schema.inverses)
101
101
  for scope, profile_layers in _profile_scopes(config, kind, wing):
102
- narrowed = fold([*layers, *profile_layers], views=sorted(views))
102
+ narrowed = fold(
103
+ [*layers, *profile_layers], views=sorted(views), inverses=schema.inverses
104
+ )
103
105
  for message in _unreachable(reach, kind, views, declared, narrowed, scope):
104
106
  finding(f"kind {kind!r}: {message}", "warning", kind=kind)
105
107
  except (PathSyntaxError, ValueError, TypeError) as exc:
@@ -153,10 +155,10 @@ class ContextPolicyRule:
153
155
  )
154
156
  if section is not None
155
157
  ]
156
- declared = _declared_always(layers, views)
158
+ declared = _declared_always(layers, views, schema.inverses)
157
159
  label = f"node:{document.fqn}"
158
160
  layers.append(_layer(label, cast("Mapping[str, Any]", own)))
159
- policy = fold(layers, views=sorted(views))
161
+ policy = fold(layers, views=sorted(views), inverses=schema.inverses)
160
162
  except (PathSyntaxError, ValueError, TypeError) as exc:
161
163
  finding(
162
164
  f"{document.fqn}: spec.extensions.{NAMESPACE}: {exc}",
@@ -172,9 +174,11 @@ class ContextPolicyRule:
172
174
  return findings
173
175
 
174
176
 
175
- def _declared_always(layers: Sequence[Layer], views: Mapping[str, View]) -> tuple[str, ...]:
177
+ def _declared_always(
178
+ layers: Sequence[Layer], views: Mapping[str, View], inverses: Inverses
179
+ ) -> tuple[str, ...]:
176
180
  """The views the schema's layers always include."""
177
- return fold(layers, views=sorted(views)).always or ()
181
+ return fold(layers, views=sorted(views), inverses=inverses).always or ()
178
182
 
179
183
 
180
184
  def _rule_may_match(match: Mapping[str, Any], kind: str, wing: str) -> bool:
@@ -24,7 +24,7 @@ from katalyst_plugin_context.domain.context import (
24
24
  )
25
25
  from katalyst_plugin_context.domain.graph import Graph, GraphNode
26
26
  from katalyst_plugin_context.domain.markdown import render_map, render_result
27
- from katalyst_plugin_context.domain.paths import PathSyntaxError, parse_path
27
+ from katalyst_plugin_context.domain.paths import Inverses, PathSyntaxError, parse_path
28
28
  from katalyst_plugin_context.domain.policy import Layer, Policy, fold
29
29
  from katalyst_plugin_context.domain.settings import ItemHints, View, item_hints, views_from
30
30
  from katalyst_plugin_context.schema import (
@@ -49,6 +49,15 @@ class RegistrySchema:
49
49
  self._registry = registry
50
50
  definition = registry.get_relationship("child_of")
51
51
  self.parent_rel = definition.graph_label if definition is not None else CHILD_OF
52
+ relationships = registry.all_relationships()
53
+ self.inverses = Inverses(
54
+ computed={
55
+ r.graph_label: r.inverse_label
56
+ for r in relationships
57
+ if r.computed and r.inverse_label is not None
58
+ },
59
+ symmetric=frozenset(r.graph_label for r in relationships if r.symmetric),
60
+ )
52
61
  self._hints: dict[str, ItemHints] = {}
53
62
 
54
63
  def sections(self, kind: str) -> tuple[Mapping[str, Any] | None, Mapping[str, Any] | None, str]:
@@ -232,7 +241,7 @@ class ContextService:
232
241
  wing_label=f"wing:{wing}",
233
242
  kind_label=f"kind:{node.kind}",
234
243
  )
235
- policy = fold(layers, views=sorted(views))
244
+ policy = fold(layers, views=sorted(views), inverses=self._schema.inverses)
236
245
  except PathSyntaxError as exc:
237
246
  raise ContextError("VALIDATION_ERROR", str(exc)) from exc
238
247
  return policy, views
@@ -88,8 +88,15 @@ def registry(
88
88
  *,
89
89
  unit_settings: Mapping[str, Any] | None = None,
90
90
  wing_settings: Mapping[str, Any] | None = None,
91
+ with_inverse: bool = False,
92
+ gadget_settings: Mapping[str, Any] | None = None,
91
93
  ) -> SchemaRegistry:
92
- """The test schema. *unit_settings* replace the unit kind's context section."""
94
+ """The test schema. *unit_settings* replace the unit kind's context section.
95
+
96
+ With *with_inverse*, ``USED_BY`` is the computed inverse of ``USES_GADGET`` (a gadget
97
+ reads the methods and units that use it) and ``RELATED_TO`` is symmetric.
98
+ *gadget_settings* replace the gadget kind's context section.
99
+ """
93
100
  reg = SchemaRegistry()
94
101
  tree = {"views": {"governance": GOVERNANCE}, "policy": {"always_include": ["governance"]}}
95
102
  reg.register_wing(
@@ -140,7 +147,20 @@ def registry(
140
147
  _kind(
141
148
  "gadget",
142
149
  wing="craft",
143
- settings={
150
+ refs=(
151
+ FieldRef(
152
+ field_name="used_by",
153
+ target_kinds=("method", "unit"),
154
+ rel_type="USED_BY",
155
+ computed=True,
156
+ inverse_of="USES_GADGET",
157
+ ),
158
+ )
159
+ if with_inverse
160
+ else (),
161
+ settings=gadget_settings
162
+ if gadget_settings is not None
163
+ else {
144
164
  "views": {"methods": GADGET_METHODS},
145
165
  "policy": {"always_include": ["methods"]},
146
166
  },
@@ -204,6 +224,21 @@ def registry(
204
224
  description="Decisions that govern a node",
205
225
  )
206
226
  )
227
+ if with_inverse:
228
+ for name, inverse, computed, symmetric in (
229
+ ("uses_gadget", "used_by", False, False),
230
+ ("used_by", "uses_gadget", True, False),
231
+ ("related_to", None, False, True),
232
+ ):
233
+ reg.register_relationship(
234
+ RelationshipDefinition(
235
+ identity=Identity(kind="relationship_definition", name=name),
236
+ describes_kind=name,
237
+ inverse=inverse,
238
+ computed=computed,
239
+ symmetric=symmetric,
240
+ )
241
+ )
207
242
  return reg
208
243
 
209
244
 
@@ -643,3 +643,107 @@ def test_fields_in_markdown_stay_within_the_budget_json_measures(detail: str, bu
643
643
  text = svc.markdown_for_result(METHOD, as_markdown)
644
644
  for item in as_json["items"]:
645
645
  assert ("Runs inside its worktree" in text) == ("checks" in item.get("fields", {}))
646
+
647
+
648
+ # ── declared inverses ────────────────────────────────────────────────────
649
+
650
+ HAMMER = "repo:hammer"
651
+ RELATED: tuple[tuple[str, str, str], ...] = (("m-strict", "RELATED_TO", "m-loose"),)
652
+
653
+
654
+ def _inverse_service(**settings: Any) -> Any:
655
+ """The test schema with the inverse pair, and the gadget's own views left out unless given."""
656
+ settings.setdefault("gadget_settings", {})
657
+ return service(registry(with_inverse=True, **settings), edges=EDGES + RELATED)
658
+
659
+
660
+ def test_a_step_on_the_computed_slot_is_the_stored_slot_walked_the_other_way() -> None:
661
+ from katalyst_plugin_context.domain.paths import Inverses
662
+
663
+ inverses = Inverses(computed={"USED_BY": "USES_GADGET"}, symmetric=frozenset({"RELATED_TO"}))
664
+
665
+ assert inverses.resolve(parse_step("USED_BY:out")) == Step("USES_GADGET", "in")
666
+ assert inverses.resolve(parse_step("USED_BY:in:method")) == Step(
667
+ "USES_GADGET", "out", frozenset({"method"})
668
+ )
669
+ assert inverses.resolve(parse_step("USED_BY")) == Step("USES_GADGET")
670
+ assert inverses.resolve(parse_step("USES_GADGET:in")) == Step("USES_GADGET", "in")
671
+ assert inverses.resolve(parse_step("RELATED_TO:out")) == Step("RELATED_TO")
672
+
673
+
674
+ def test_a_path_on_the_computed_slot_returns_what_the_stored_slot_walked_in_returns() -> None:
675
+ svc = _inverse_service()
676
+
677
+ stored = svc.query(HAMMER, ContextRequest(paths=("USES_GADGET:in",)))
678
+ computed = svc.query(HAMMER, ContextRequest(paths=("USED_BY:out",)))
679
+
680
+ assert _names(computed, view=None) == _names(stored, view=None)
681
+ assert set(_names(computed, view=None)) >= {
682
+ "repo:north.east.core",
683
+ "repo:m-strict",
684
+ "repo:m-loose",
685
+ }
686
+
687
+
688
+ def test_the_kind_qualifier_is_kept_on_the_computed_slot() -> None:
689
+ svc = _inverse_service()
690
+
691
+ stored = svc.query(HAMMER, ContextRequest(paths=("USES_GADGET:in:method",)))
692
+ computed = svc.query(HAMMER, ContextRequest(paths=("USED_BY:out:method",)))
693
+
694
+ assert _names(computed, view=None) == _names(stored, view=None)
695
+ assert set(_names(computed, view=None)) == {"repo:m-strict", "repo:m-loose"}
696
+
697
+
698
+ def test_the_computed_slot_walked_in_is_the_stored_slot_walked_out() -> None:
699
+ svc = _inverse_service()
700
+
701
+ stored = svc.query("repo:m-strict", ContextRequest(paths=("USES_GADGET:out",)))
702
+ computed = svc.query("repo:m-strict", ContextRequest(paths=("USED_BY:in",)))
703
+
704
+ assert _names(computed, view=None) == _names(stored, view=None) == ["repo:hammer"]
705
+
706
+
707
+ def test_a_path_of_both_spellings_reaches_the_same_items() -> None:
708
+ svc = _inverse_service()
709
+
710
+ stored = svc.query(HAMMER, ContextRequest(paths=("USES_GADGET:in/INCLUDES_METHOD:in",)))
711
+ computed = svc.query(HAMMER, ContextRequest(paths=("USED_BY:out/INCLUDES_METHOD:in",)))
712
+
713
+ assert _names(computed, view=None) == _names(stored, view=None) == ["repo:b-all"]
714
+
715
+
716
+ def test_a_view_may_be_written_with_the_computed_spelling() -> None:
717
+ view = {**{"title": "Users", "inherit": False, "detail": "summary", "priority": 1}}
718
+ svc = _inverse_service(
719
+ gadget_settings={
720
+ "views": {"users": {**view, "paths": ["USED_BY:out:method"]}},
721
+ "policy": {"always_include": ["users"]},
722
+ }
723
+ )
724
+
725
+ result = svc.query(HAMMER, ContextRequest())
726
+
727
+ assert set(_names(result, view="users")) == {"repo:m-strict", "repo:m-loose"}
728
+
729
+
730
+ def test_a_deny_on_the_computed_slot_hides_the_stored_slot_walked_the_other_way() -> None:
731
+ svc = service(
732
+ registry(with_inverse=True, gadget_settings={}),
733
+ edges=EDGES,
734
+ config={"policy": {"paths": {"deny": ["USED_BY:out"]}}},
735
+ )
736
+
737
+ result = svc.query(HAMMER, ContextRequest(paths=("USES_GADGET:in",)))
738
+
739
+ assert _names(result, view=None) == []
740
+
741
+
742
+ def test_a_symmetric_relationship_reads_the_same_from_either_end() -> None:
743
+ svc = _inverse_service()
744
+
745
+ from_source = svc.query("repo:m-strict", ContextRequest(paths=("RELATED_TO:out",)))
746
+ from_target = svc.query("repo:m-loose", ContextRequest(paths=("RELATED_TO:out",)))
747
+
748
+ assert _names(from_source, view=None) == ["repo:m-loose"]
749
+ assert _names(from_target, view=None) == ["repo:m-strict"]
@@ -0,0 +1,287 @@
1
+ # pyright: reportPrivateUsage=false
2
+ """Tests for cross-backend edge stitching (#418).
3
+
4
+ When multiple backends contribute documents, a reference like ``beta:d-cross``
5
+ (target in the ``beta`` backend) silently produces no edge when resolved inside the
6
+ ``alpha`` backend, because each backend resolves references against its own documents
7
+ only. The stitching pass in :func:`build_graph` resolves every declared
8
+ ``FieldRef`` over the aggregated document set and emits the missing edges.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import Any
14
+ from unittest.mock import MagicMock
15
+
16
+ from context_support import node, registry
17
+ from katalyst_engine.model.node import Node
18
+ from katalyst_plugin_context.adapters.runtime_graph import (
19
+ _edge_key,
20
+ _stitch_cross_backend_edges,
21
+ build_graph,
22
+ )
23
+ from katalyst_plugin_context.domain.graph import Graph
24
+ from katalyst_taxonomy.engine.accessors import set_node_backend
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+
31
+ def _stamped(n: Node, backend: str) -> Node:
32
+ """Return *n* stamped as owned by *backend*."""
33
+ return set_node_backend(n, backend)
34
+
35
+
36
+ def _all_edges(g: Graph) -> list[tuple[str, str, str]]:
37
+ """Collect every edge in a graph as (source, rel, target) triples."""
38
+ seen: set[tuple[str, str, str]] = set()
39
+ for key in g.nodes:
40
+ for neighbour in g.neighbours(key):
41
+ if neighbour.direction == "out":
42
+ triple = (key, neighbour.rel, neighbour.other)
43
+ else:
44
+ triple = (neighbour.other, neighbour.rel, key)
45
+ seen.add(triple)
46
+ return sorted(seen)
47
+
48
+
49
+ def _two_backend_corpus() -> tuple[list[tuple[str, Node]], list[tuple[str, Node]]]:
50
+ """A corpus split across backends ``alpha`` and ``beta``.
51
+
52
+ alpha has:
53
+ region "north" with decisions=["beta:d-cross"] (target in beta)
54
+ zone "east" under north
55
+
56
+ beta has:
57
+ decision "d-cross" (the target alpha references)
58
+ decision "d-local" (unreferenced)
59
+ """
60
+ north = _stamped(
61
+ node("region", "north", decisions=["beta:d-cross"], rules=[]),
62
+ "alpha",
63
+ )
64
+ east = _stamped(
65
+ node("zone", "east", "north", parents={"node": "north"}, decisions=[], rules=[]),
66
+ "alpha",
67
+ )
68
+ d_cross = _stamped(
69
+ node("decision", "d-cross", title="Cross-backend decision", status="accepted"),
70
+ "beta",
71
+ )
72
+ d_local = _stamped(
73
+ node("decision", "d-local", title="Local decision", status="accepted"),
74
+ "beta",
75
+ )
76
+ alpha_docs: list[tuple[str, Node]] = [("alpha", north), ("alpha", east)]
77
+ beta_docs: list[tuple[str, Node]] = [("beta", d_cross), ("beta", d_local)]
78
+ return alpha_docs, beta_docs
79
+
80
+
81
+ def _make_backend(
82
+ docs: list[tuple[str, Node]], rels: list[tuple[str, str, str]] | None = None
83
+ ) -> Any:
84
+ """A mock TaxonomyBackend that returns the given documents and relationships."""
85
+ backend: Any = MagicMock()
86
+ backend.load_documents.return_value = [doc for _, doc in docs]
87
+ if rels:
88
+ rel_objects: list[Any] = []
89
+ for src, rel, tgt in rels:
90
+ r: Any = MagicMock()
91
+ r.source_fqtn = src
92
+ r.rel_type = rel
93
+ r.target_fqtn = tgt
94
+ rel_objects.append(r)
95
+ backend.list_relationships.return_value = rel_objects
96
+ else:
97
+ backend.list_relationships.return_value = []
98
+ return backend
99
+
100
+
101
+ # ---------------------------------------------------------------------------
102
+ # _stitch_cross_backend_edges
103
+ # ---------------------------------------------------------------------------
104
+
105
+
106
+ class TestStitchCrossBackendEdges:
107
+ """Unit tests for the stitching function itself."""
108
+
109
+ def test_emits_edge_for_cross_backend_reference(self) -> None:
110
+ """A reference from alpha to beta:d-cross produces an edge."""
111
+ alpha_docs, beta_docs = _two_backend_corpus()
112
+ loaded = [*alpha_docs, *beta_docs]
113
+ reg = registry()
114
+
115
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
116
+
117
+ cross_edges = [(src, rel, tgt) for _, src, rel, tgt in extra if "d-cross" in tgt]
118
+ assert len(cross_edges) >= 1, f"Expected cross-backend edge to d-cross, got {extra}"
119
+ assert any(rel == "GOVERNED_BY" for _, rel, _ in cross_edges)
120
+
121
+ def test_does_not_duplicate_existing_edges(self) -> None:
122
+ """Edges the backends already produced are not emitted again."""
123
+ alpha_docs, beta_docs = _two_backend_corpus()
124
+ loaded = [*alpha_docs, *beta_docs]
125
+ reg = registry()
126
+
127
+ extra_first = _stitch_cross_backend_edges(reg, loaded, existing=set())
128
+ assert len(extra_first) > 0, "Precondition: should have found cross-backend edges"
129
+
130
+ existing = {_edge_key(*edge) for edge in extra_first}
131
+ extra_second = _stitch_cross_backend_edges(reg, loaded, existing=existing)
132
+ assert extra_second == [], f"Should not duplicate, got {extra_second}"
133
+
134
+ def test_no_edges_when_all_references_are_same_backend(self) -> None:
135
+ """When all references resolve within the same backend, nothing new is emitted."""
136
+ from context_support import DOCUMENTS, EDGES
137
+
138
+ loaded: list[tuple[str, Node]] = [("repo", doc) for doc in DOCUMENTS]
139
+ reg = registry()
140
+
141
+ existing = {_edge_key("repo", src, rel, tgt) for src, rel, tgt in EDGES}
142
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=existing)
143
+ assert extra == []
144
+
145
+ def test_skips_unresolvable_references(self) -> None:
146
+ """A reference to a node that doesn't exist in any backend produces no edge."""
147
+ north = _stamped(
148
+ node("region", "north", decisions=["beta:nonexistent"], rules=[]),
149
+ "alpha",
150
+ )
151
+ d_other = _stamped(
152
+ node("decision", "d-other", title="Other"),
153
+ "beta",
154
+ )
155
+ loaded: list[tuple[str, Node]] = [("alpha", north), ("beta", d_other)]
156
+ reg = registry()
157
+
158
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
159
+ assert extra == []
160
+
161
+ def test_edge_carries_correct_backend_prefixes(self) -> None:
162
+ """Emitted edges prefix both source and target with their backend."""
163
+ alpha_docs, beta_docs = _two_backend_corpus()
164
+ loaded = [*alpha_docs, *beta_docs]
165
+ reg = registry()
166
+
167
+ extra = _stitch_cross_backend_edges(reg, loaded, existing=set())
168
+
169
+ for _holder, source, _rel, target in extra:
170
+ if "d-cross" in target:
171
+ assert "beta:" in target, f"Target should be beta-prefixed: {target}"
172
+ assert "alpha:" in source, f"Source should be alpha-prefixed: {source}"
173
+
174
+
175
+ # ---------------------------------------------------------------------------
176
+ # build_graph integration
177
+ # ---------------------------------------------------------------------------
178
+
179
+
180
+ class TestBuildGraphCrossBackend:
181
+ """Integration tests: build_graph with a registry resolves cross-backend edges."""
182
+
183
+ def test_cross_backend_edges_appear_in_graph(self) -> None:
184
+ """build_graph with registry stitches cross-backend edges into the result."""
185
+ alpha_docs, beta_docs = _two_backend_corpus()
186
+ reg = registry()
187
+
188
+ g = build_graph(
189
+ [("alpha", _make_backend(alpha_docs)), ("beta", _make_backend(beta_docs))],
190
+ registry=reg,
191
+ )
192
+
193
+ edges = _all_edges(g)
194
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
195
+ assert len(cross_edges) >= 1, f"Expected cross-backend edge to d-cross. All edges: {edges}"
196
+
197
+ def test_no_stitching_without_registry(self) -> None:
198
+ """Without a registry, build_graph does not attempt stitching."""
199
+ alpha_docs, beta_docs = _two_backend_corpus()
200
+
201
+ g = build_graph(
202
+ [("alpha", _make_backend(alpha_docs)), ("beta", _make_backend(beta_docs))],
203
+ registry=None,
204
+ )
205
+
206
+ edges = _all_edges(g)
207
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
208
+ assert cross_edges == [], (
209
+ f"Without registry, should not have cross-backend edges. Edges: {edges}"
210
+ )
211
+
212
+ def test_no_stitching_with_single_backend(self) -> None:
213
+ """With only one backend, stitching is skipped even with a registry."""
214
+ alpha_docs, _ = _two_backend_corpus()
215
+ reg = registry()
216
+
217
+ g = build_graph(
218
+ [("alpha", _make_backend(alpha_docs))],
219
+ registry=reg,
220
+ )
221
+
222
+ edges = _all_edges(g)
223
+ cross_edges = [(s, r, t) for s, r, t in edges if "d-cross" in t]
224
+ assert cross_edges == []
225
+
226
+ def test_existing_same_backend_edges_preserved(self) -> None:
227
+ """Edges within a single backend still work after stitching is added."""
228
+ alpha_docs, beta_docs = _two_backend_corpus()
229
+ reg = registry()
230
+
231
+ alpha_rels = [("north.east", "CHILD_OF", "north")]
232
+ g = build_graph(
233
+ [
234
+ ("alpha", _make_backend(alpha_docs, alpha_rels)),
235
+ ("beta", _make_backend(beta_docs)),
236
+ ],
237
+ registry=reg,
238
+ )
239
+
240
+ edges = _all_edges(g)
241
+ child_edges = [(s, r, t) for s, r, t in edges if r == "CHILD_OF"]
242
+ assert len(child_edges) >= 1, f"Expected CHILD_OF edge to survive. All edges: {edges}"
243
+
244
+ def test_a_same_named_edge_in_another_backend_does_not_hide_a_cross_backend_one(
245
+ self,
246
+ ) -> None:
247
+ """foe's own x -> y edge and ric's x -> foe:y are two edges, both kept."""
248
+ foe_x = _stamped(node("region", "x", decisions=["y"], rules=[]), "foe")
249
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
250
+ ric_x = _stamped(node("region", "x", decisions=["foe:y"], rules=[]), "ric")
251
+ g = build_graph(
252
+ [
253
+ (
254
+ "foe",
255
+ _make_backend([("foe", foe_x), ("foe", foe_y)], [("x", "GOVERNED_BY", "y")]),
256
+ ),
257
+ ("ric", _make_backend([("ric", ric_x)])),
258
+ ],
259
+ registry=registry(),
260
+ )
261
+
262
+ edges = _all_edges(g)
263
+ assert ("foe:x", "GOVERNED_BY", "foe:y") in edges, edges
264
+ assert ("ric:x", "GOVERNED_BY", "foe:y") in edges, edges
265
+
266
+
267
+ class TestUnstampedDocuments:
268
+ """A document with no backend stamp belongs to the backend that held it."""
269
+
270
+ def test_an_unstamped_source_is_stitched_under_its_holder(self) -> None:
271
+ ric_x = node("region", "x", decisions=["foe:y"], rules=[])
272
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
273
+ loaded: list[tuple[str, Node]] = [("ric", ric_x), ("foe", foe_y)]
274
+
275
+ extra = _stitch_cross_backend_edges(registry(), loaded, existing=set())
276
+
277
+ assert ("ric", "ric:x", "GOVERNED_BY", "foe:y") in extra, extra
278
+
279
+ def test_an_unstamped_source_gets_its_edge_in_the_graph(self) -> None:
280
+ ric_x = node("region", "x", decisions=["foe:y"], rules=[])
281
+ foe_y = _stamped(node("decision", "y", title="Y", status="accepted"), "foe")
282
+ g = build_graph(
283
+ [("ric", _make_backend([("ric", ric_x)])), ("foe", _make_backend([("foe", foe_y)]))],
284
+ registry=registry(),
285
+ )
286
+
287
+ assert ("ric:x", "GOVERNED_BY", "foe:y") in _all_edges(g)
@@ -133,3 +133,28 @@ def test_a_node_hiding_its_always_view_is_a_warning(tmp_path: Path) -> None:
133
133
  "north.east.core: node:north.east.core hides always-included view 'governance' path "
134
134
  "'GOVERNED_BY:out'"
135
135
  ]
136
+
137
+
138
+ def test_a_view_path_may_use_either_spelling_of_a_declared_inverse(tmp_path: Path) -> None:
139
+ def views(path: str) -> dict[str, Any]:
140
+ return {
141
+ "views": {"users": {**GOVERNANCE, "paths": [path]}},
142
+ "policy": {"always_include": ["users"]},
143
+ }
144
+
145
+ for path in ("USES_GADGET:in", "USED_BY:out"):
146
+ reg = registry(with_inverse=True, gadget_settings=views(path))
147
+ found = ContextPolicyRule(reg).check(_subject(tmp_path))
148
+ assert [f.message for f in found if f.severity == "error"] == [], path
149
+
150
+
151
+ def test_a_path_naming_neither_spelling_is_still_an_error(tmp_path: Path) -> None:
152
+ settings = {
153
+ "views": {"users": {**GOVERNANCE, "paths": ["USED_NOWHERE:out"]}},
154
+ "policy": {"always_include": ["users"]},
155
+ }
156
+ found = ContextPolicyRule(registry(with_inverse=True, gadget_settings=settings)).check(
157
+ _subject(tmp_path)
158
+ )
159
+
160
+ assert any("USED_NOWHERE" in f.message and f.severity == "error" for f in found)