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