katalyst-plugin-context 0.2.1a12__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.1a12 → katalyst_plugin_context-0.2.1a13}/PKG-INFO +3 -3
  2. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/pyproject.toml +3 -3
  3. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/context.py +6 -2
  4. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/paths.py +31 -1
  5. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/policy.py +23 -10
  6. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/lint.py +12 -8
  7. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/service.py +11 -2
  8. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/context_support.py +37 -2
  9. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/test_context.py +104 -0
  10. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/test_lint.py +25 -0
  11. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/.gitignore +0 -0
  12. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/Justfile +0 -0
  13. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/__init__.py +0 -0
  14. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  15. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
  16. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/cli.py +0 -0
  17. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/composition.py +0 -0
  18. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  19. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/graph.py +0 -0
  20. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/markdown.py +0 -0
  21. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/domain/settings.py +0 -0
  22. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/mcp_tools.py +0 -0
  23. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/plugin.py +0 -0
  24. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/py.typed +0 -0
  25. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/rest.py +0 -0
  26. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/katalyst_plugin_context/schema.py +0 -0
  27. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/src/layer.yaml +0 -0
  28. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/conftest.py +0 -0
  29. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/test_cross_backend_edges.py +0 -0
  30. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/test_shared_names.py +0 -0
  31. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/tests/test_surfaces.py +0 -0
  32. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/.gitignore +0 -0
  33. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/Justfile +0 -0
  34. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/features/cli/52_kata_context.feature +0 -0
  35. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/features/cli/53_kata_context_budget_policy.feature +0 -0
  36. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/features/mcp/09_mcp_context.feature +0 -0
  37. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/areas/payments.yaml +0 -0
  38. {katalyst_plugin_context-0.2.1a12 → 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.1a12 → 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.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/async-events.yaml +0 -0
  41. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/pin-httpx.yaml +0 -0
  42. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/decisions/use-postgres.yaml +0 -0
  43. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/packages/httpx.yaml +0 -0
  44. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/rules/encrypt-card-data.yaml +0 -0
  45. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/rules/log-correlation-id.yaml +0 -0
  46. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/fixtures/context-catalogue/catalogue/services/payments.checkout.yaml +0 -0
  47. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/layer.yaml +0 -0
  48. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/package.json +0 -0
  49. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/playwright.config.ts +0 -0
  50. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/steps/context.ts +0 -0
  51. {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a13}/xspec/steps/steps.ts +0 -0
  52. {katalyst_plugin_context-0.2.1a12 → 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.1a12
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.1a12"
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",
@@ -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"]
@@ -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)