katalyst-plugin-context 0.2.1a12__tar.gz → 0.2.1a14__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.
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/PKG-INFO +3 -3
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/pyproject.toml +3 -3
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/context.py +6 -2
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/paths.py +31 -1
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/policy.py +23 -10
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/lint.py +12 -8
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/service.py +11 -2
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/context_support.py +37 -2
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_context.py +104 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_lint.py +25 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/.gitignore +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/Justfile +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/adapters/runtime_graph.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/cli.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/composition.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/graph.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/markdown.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/domain/settings.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/mcp_tools.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/plugin.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/py.typed +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/rest.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/katalyst_plugin_context/schema.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/src/layer.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/conftest.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_cross_backend_edges.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_shared_names.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_surfaces.py +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/.gitignore +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/Justfile +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/features/cli/52_kata_context.feature +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/features/cli/53_kata_context_budget_policy.feature +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/features/mcp/09_mcp_context.feature +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/areas/payments.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/checks/encrypt-card-data.scan-for-card-numbers.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/components/payments.checkout.web-api.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/decisions/async-events.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/decisions/pin-httpx.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/decisions/use-postgres.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/packages/httpx.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/rules/encrypt-card-data.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/rules/log-correlation-id.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/fixtures/context-catalogue/catalogue/services/payments.checkout.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/layer.yaml +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/package.json +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/playwright.config.ts +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/steps/context.ts +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/steps/steps.ts +0 -0
- {katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/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.
|
|
3
|
+
Version: 0.2.1a14
|
|
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.
|
|
3
|
+
version = "0.2.1a14"
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
{katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/context_support.py
RENAMED
|
@@ -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
|
-
|
|
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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_shared_names.py
RENAMED
|
File without changes
|
{katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/tests/test_surfaces.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/playwright.config.ts
RENAMED
|
File without changes
|
{katalyst_plugin_context-0.2.1a12 → katalyst_plugin_context-0.2.1a14}/xspec/steps/context.ts
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|