katalyst-plugin-context 0.2.1a4__tar.gz → 0.2.1a6__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 (47) hide show
  1. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/PKG-INFO +1 -1
  2. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/pyproject.toml +1 -1
  3. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/adapters/runtime_graph.py +64 -19
  4. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/composition.py +3 -2
  5. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/context.py +16 -6
  6. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/markdown.py +84 -15
  7. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/tests/context_support.py +48 -3
  8. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/tests/test_context.py +141 -1
  9. katalyst_plugin_context-0.2.1a6/tests/test_shared_names.py +57 -0
  10. katalyst_plugin_context-0.2.1a6/xspec/.gitignore +8 -0
  11. katalyst_plugin_context-0.2.1a6/xspec/features/cli/52_kata_context.feature +274 -0
  12. katalyst_plugin_context-0.2.1a6/xspec/features/cli/53_kata_context_budget_policy.feature +265 -0
  13. katalyst_plugin_context-0.2.1a6/xspec/features/mcp/09_mcp_context.feature +190 -0
  14. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/areas/payments.yaml +13 -0
  15. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/checks/encrypt-card-data.scan-for-card-numbers.yaml +12 -0
  16. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/components/payments.checkout.web-api.yaml +15 -0
  17. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/decisions/async-events.yaml +10 -0
  18. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/decisions/pin-httpx.yaml +10 -0
  19. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/decisions/use-postgres.yaml +10 -0
  20. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/packages/httpx.yaml +11 -0
  21. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/rules/encrypt-card-data.yaml +10 -0
  22. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/rules/log-correlation-id.yaml +10 -0
  23. katalyst_plugin_context-0.2.1a6/xspec/fixtures/context-catalogue/catalogue/services/payments.checkout.yaml +13 -0
  24. katalyst_plugin_context-0.2.1a6/xspec/package.json +15 -0
  25. katalyst_plugin_context-0.2.1a6/xspec/playwright.config.ts +5 -0
  26. katalyst_plugin_context-0.2.1a6/xspec/steps/context.ts +216 -0
  27. katalyst_plugin_context-0.2.1a6/xspec/steps/steps.ts +5 -0
  28. katalyst_plugin_context-0.2.1a6/xspec/tsconfig.json +4 -0
  29. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/.gitignore +0 -0
  30. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/__init__.py +0 -0
  31. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
  32. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/cli.py +0 -0
  33. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/__init__.py +0 -0
  34. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/graph.py +0 -0
  35. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/paths.py +0 -0
  36. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/policy.py +0 -0
  37. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/domain/settings.py +0 -0
  38. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/lint.py +0 -0
  39. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/mcp_tools.py +0 -0
  40. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/plugin.py +0 -0
  41. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/py.typed +0 -0
  42. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/rest.py +0 -0
  43. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/schema.py +0 -0
  44. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/src/katalyst_plugin_context/service.py +0 -0
  45. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/tests/conftest.py +0 -0
  46. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/tests/test_lint.py +0 -0
  47. {katalyst_plugin_context-0.2.1a4 → katalyst_plugin_context-0.2.1a6}/tests/test_surfaces.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: katalyst-plugin-context
3
- Version: 0.2.1a4
3
+ Version: 0.2.1a6
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "katalyst-plugin-context"
3
- version = "0.2.1a4"
3
+ version = "0.2.1a6"
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.12"
@@ -10,7 +10,7 @@ another.
10
10
 
11
11
  from __future__ import annotations
12
12
 
13
- from collections.abc import Iterable
13
+ from collections.abc import Callable, Iterable, Sequence
14
14
  from typing import Any, cast
15
15
 
16
16
  from katalyst_engine.model.node import Node
@@ -20,11 +20,16 @@ from katalyst_taxonomy.engine.accessors import (
20
20
  get_node_backend,
21
21
  )
22
22
  from katalyst_taxonomy.engine.identity import split_backend_prefix
23
+ from katalyst_taxonomy.engine.qualification import split_kind_selector
23
24
  from katalyst_taxonomy.ports.backend import TaxonomyBackend
24
25
 
25
26
  from katalyst_plugin_context.domain.graph import Edge, Graph, GraphNode
26
27
 
27
- __all__ = ["build_graph", "graph_from"]
28
+ __all__ = ["Carriers", "build_graph", "graph_from"]
29
+
30
+ #: What a kind can carry: the relationship types it holds, each with the kinds it may point at
31
+ #: (empty: any kind).
32
+ Carriers = Callable[[str], Sequence[tuple[str, frozenset[str]]]]
28
33
 
29
34
 
30
35
  def _graph_node(doc: Node, backend: str, key: str) -> GraphNode:
@@ -47,10 +52,16 @@ def graph_from(
47
52
  relationships: Iterable[tuple[str, str, str, str]],
48
53
  *,
49
54
  hidden: Iterable[tuple[str, str]] = (),
55
+ carriers: Carriers | None = None,
50
56
  ) -> Graph:
51
57
  """A graph over *documents* (``(holder backend, node)``) and *relationships*
52
58
  (``(holder backend, source fqtn, rel, target fqtn)``).
53
59
 
60
+ An edge names its ends by qualified name, so a name two kinds share is ambiguous. With
61
+ *carriers* the edge is placed by identity: the source is the node of a kind that carries
62
+ the relationship, the target one that kind may point at. An edge that still fits several
63
+ nodes, or none, is dropped rather than guessed.
64
+
54
65
  An edge to an address in *hidden* (``(backend, fqtn)`` of nodes the profile's filters
55
66
  hide) is left out silently. Any other edge that reaches no node is counted as dropped.
56
67
  Nothing about a hidden node is counted.
@@ -64,39 +75,72 @@ def graph_from(
64
75
  fqn = doc.fqn or doc.name
65
76
  pending.append((backend, fqn, doc))
66
77
  by_address.setdefault((backend, fqn), []).append(doc.kind)
67
- for backend, fqn, doc in pending:
78
+
79
+ def key_of(backend: str, fqn: str, kind: str) -> str:
68
80
  shared = len(by_address[(backend, fqn)]) > 1
69
- key = f"{backend}:{fqn}#{doc.kind}" if shared else f"{backend}:{fqn}"
70
- nodes.append(_graph_node(doc, backend, key))
71
-
72
- def key_of(holder: str, address: str) -> str | None:
73
- prefix, rest = split_backend_prefix(address)
74
- backend = prefix or holder
75
- kinds = by_address.get((backend, rest))
76
- if kinds is None or len(kinds) != 1:
81
+ return f"{backend}:{fqn}#{kind}" if shared else f"{backend}:{fqn}"
82
+
83
+ nodes.extend(
84
+ _graph_node(doc, backend, key_of(backend, fqn, doc.kind)) for backend, fqn, doc in pending
85
+ )
86
+
87
+ def ends_of(holder: str, source: str, rel: str, target: str) -> tuple[str, str] | None:
88
+ source_backend, source_fqn = _address(holder, source)
89
+ target_backend, target_fqn = _address(holder, target)
90
+ sources = _of_kind(by_address.get((source_backend, source_fqn), []), source)
91
+ targets = _of_kind(by_address.get((target_backend, target_fqn), []), target)
92
+ if len(sources) == 1 and len(targets) == 1:
93
+ return (
94
+ key_of(source_backend, source_fqn, sources[0]),
95
+ key_of(target_backend, target_fqn, targets[0]),
96
+ )
97
+ if carriers is None or not sources or not targets:
77
98
  return None
78
- return f"{backend}:{rest}"
99
+ placed = {
100
+ (source_kind, target_kind)
101
+ for source_kind in sources
102
+ for carried_rel, accepted in carriers(source_kind)
103
+ if carried_rel == rel
104
+ for target_kind in targets
105
+ if not accepted or target_kind in accepted
106
+ }
107
+ if len(placed) != 1:
108
+ return None
109
+ source_kind, target_kind = next(iter(placed))
110
+ return (
111
+ key_of(source_backend, source_fqn, source_kind),
112
+ key_of(target_backend, target_fqn, target_kind),
113
+ )
79
114
 
80
115
  edges: list[Edge] = []
81
116
  dropped = 0
82
117
  for holder, source, rel, target in relationships:
83
- source_key, target_key = key_of(holder, source), key_of(holder, target)
84
- if source_key is None or target_key is None:
85
- ends = (_address(holder, source), _address(holder, target))
86
- if not any(end in hidden_addresses for end in ends):
118
+ ends = ends_of(holder, source, rel, target)
119
+ if ends is None:
120
+ addresses = (_address(holder, source), _address(holder, target))
121
+ if not any(end in hidden_addresses for end in addresses):
87
122
  dropped += 1
88
123
  continue
89
- edges.append(Edge(rel=rel, source=source_key, target=target_key))
124
+ edges.append(Edge(rel=rel, source=ends[0], target=ends[1]))
90
125
  return Graph(nodes, edges, dropped=dropped)
91
126
 
92
127
 
93
128
  def _address(holder: str, value: str) -> tuple[str, str]:
94
- prefix, rest = split_backend_prefix(value)
129
+ prefix, rest = split_backend_prefix(split_kind_selector(value)[0])
95
130
  return (prefix or holder, rest)
96
131
 
97
132
 
133
+ def _of_kind(kinds: list[str], address: str) -> list[str]:
134
+ """The kinds at an address, narrowed to its ``#kind`` when it names one."""
135
+ selected = split_kind_selector(address)[1]
136
+ return kinds if selected is None else [kind for kind in kinds if kind == selected]
137
+
138
+
98
139
  def build_graph(
99
- sources: Iterable[tuple[str, TaxonomyBackend]], *, profile: str | None = None
140
+ sources: Iterable[tuple[str, TaxonomyBackend]],
141
+ *,
142
+ profile: str | None = None,
143
+ carriers: Carriers | None = None,
100
144
  ) -> Graph:
101
145
  """Read every backend in *sources* once and build the graph the profile may see.
102
146
 
@@ -123,4 +167,5 @@ def build_graph(
123
167
  ((holder, doc) for holder, doc in loaded if id(doc) in visible),
124
168
  relationships,
125
169
  hidden=list(hidden),
170
+ carriers=carriers,
126
171
  )
@@ -15,7 +15,7 @@ from katalyst_engine.schema.registry import SchemaRegistry
15
15
  from katalyst_taxonomy.ports.backend import TaxonomyBackend
16
16
 
17
17
  from katalyst_plugin_context.adapters.runtime_graph import build_graph
18
- from katalyst_plugin_context.service import NAMESPACE, ContextService
18
+ from katalyst_plugin_context.service import NAMESPACE, ContextService, RegistrySchema
19
19
 
20
20
  __all__ = ["plugin_config", "service_for"]
21
21
 
@@ -45,5 +45,6 @@ def service_for(
45
45
  ) -> ContextService:
46
46
  """The service over what *sources* hold, under the profile named *profile*."""
47
47
  config, active = plugin_config(profile)
48
- graph = build_graph(sources, profile=active)
48
+ schema = RegistrySchema(registry)
49
+ graph = build_graph(sources, profile=active, carriers=schema.carried)
49
50
  return ContextService(registry, graph, config=config, profile=active)
@@ -27,6 +27,7 @@ __all__ = [
27
27
  "build_result",
28
28
  "content_size",
29
29
  "estimate_tokens",
30
+ "more_routes",
30
31
  ]
31
32
 
32
33
  #: How much of a description ``summary`` shows, and of each summary field's value.
@@ -253,7 +254,7 @@ def _item_payload(
253
254
  ) -> dict[str, Any]:
254
255
  node = entry.node
255
256
  hints = schema.hints(node.kind)
256
- whys = sorted(entry.whys, key=lambda w: w[0])
257
+ whys = sorted(entry.whys, key=lambda w: (len(w[1].hops), w[0]))
257
258
  payload: dict[str, Any] = {
258
259
  "qualifiedName": node.key,
259
260
  "kind": node.kind,
@@ -317,16 +318,25 @@ def _text(value: object) -> str:
317
318
  return value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False)
318
319
 
319
320
 
321
+ def more_routes(count: int) -> str:
322
+ """The note that *count* further routes reach an item."""
323
+ return f"(+{count} more route{'' if count == 1 else 's'})"
324
+
325
+
320
326
  def content_size(item: Mapping[str, Any]) -> int:
321
327
  """The item's content in estimated tokens, whatever format the answer is given in.
322
328
 
323
- Counts what a reader reads: the qualified name, kind and title, each ``why``, each
324
- blocking value, the description, the fields, and at ``full`` the spec and the body.
325
- No keys, punctuation or markup, so a JSON and a markdown answer hold the same items
326
- for the same budget.
329
+ Counts what a reader reads: the qualified name, kind and title, the ``why`` (the
330
+ shortest route and a note of the others, or every route at ``full``), each blocking
331
+ value, the description, the fields, and at ``full`` the spec and the body. No keys,
332
+ punctuation or markup, so a JSON and a markdown answer hold the same items for the same
333
+ budget.
327
334
  """
328
335
  parts: list[str] = [str(item["qualifiedName"]), str(item["kind"]), str(item["title"])]
329
- parts.extend(str(why["text"]) for why in item.get("why") or ())
336
+ routes = [str(why["text"]) for why in item.get("why") or ()]
337
+ if item.get("detail") != FULL and len(routes) > 1:
338
+ routes = [routes[0], more_routes(len(routes) - 1)]
339
+ parts.extend(routes)
330
340
  blocking = item.get("blocking")
331
341
  entries = cast(
332
342
  "list[Mapping[str, Any]]",
@@ -10,6 +10,8 @@ from __future__ import annotations
10
10
  from collections.abc import Mapping, Sequence
11
11
  from typing import Any, cast
12
12
 
13
+ from katalyst_plugin_context.domain.context import more_routes
14
+
13
15
  __all__ = ["item_block", "render_map", "render_result"]
14
16
 
15
17
 
@@ -27,32 +29,99 @@ def _blocking_marker(item: Mapping[str, Any]) -> str:
27
29
  return "_advisory_ · "
28
30
 
29
31
 
32
+ def _text(value: object) -> str:
33
+ if isinstance(value, bool):
34
+ return "true" if value else "false"
35
+ return "null" if value is None else str(value)
36
+
37
+
38
+ def _empty(value: object) -> bool:
39
+ return value is None or value == "" or (isinstance(value, (list, dict)) and not value)
40
+
41
+
42
+ def _entry(label: str | None, value: object, indent: int) -> list[str]:
43
+ """One value as a bullet at *indent*: lists as bullets, mappings as nested bullets.
44
+
45
+ A multi-line text sits under its label, one line per line. A list element that is a
46
+ mapping is one bullet of its plain values, with any longer value nested under it.
47
+ """
48
+ pad = " " * indent
49
+ lead = f"{label}: " if label is not None else ""
50
+ if isinstance(value, Mapping):
51
+ members = {
52
+ str(key): member
53
+ for key, member in cast("Mapping[object, object]", value).items()
54
+ if not _empty(member)
55
+ }
56
+ if label is None:
57
+ plain = [f"{k}: {_text(v)}" for k, v in members.items() if _inline(v)]
58
+ rest = [
59
+ line
60
+ for k, v in members.items()
61
+ if not _inline(v)
62
+ for line in _entry(k, v, indent + 2)
63
+ ]
64
+ return [f"{pad}- " + " · ".join(plain) if plain else f"{pad}-", *rest]
65
+ inner = [line for k, v in members.items() for line in _entry(k, v, indent + 2)]
66
+ return [f"{pad}- {label}:", *inner]
67
+ if isinstance(value, (list, tuple)):
68
+ inner = [
69
+ line
70
+ for member in cast("Sequence[object]", value)
71
+ for line in _entry(None, member, indent + 2)
72
+ ]
73
+ return [f"{pad}- {label}:", *inner] if label is not None else inner
74
+ text = _text(value).strip()
75
+ if "\n" in text:
76
+ body = [f"{pad} {line}".rstrip() for line in text.splitlines()]
77
+ return [f"{pad}- {lead.rstrip()}".rstrip(), *body]
78
+ return [f"{pad}- {lead}{text}"]
79
+
80
+
81
+ def _inline(value: object) -> bool:
82
+ return isinstance(value, (str, int, float, bool)) and "\n" not in str(value)
83
+
84
+
85
+ def _routes(item: Mapping[str, Any]) -> list[str]:
86
+ routes = [str(why["text"]) for why in cast("list[Mapping[str, Any]]", item.get("why") or [])]
87
+ if item.get("detail") != "full" and len(routes) > 1:
88
+ return [f"{routes[0]} {more_routes(len(routes) - 1)}"]
89
+ return routes
90
+
91
+
30
92
  def item_block(item: Mapping[str, Any]) -> str:
31
- """One item as a markdown list entry."""
93
+ """One item as a markdown list entry: what the JSON item holds, in the same words.
94
+
95
+ At ``full`` every spec field (long text whole); at ``summary`` the declared summary
96
+ fields; both with lists as bullets and mappings as nested bullets.
97
+ """
32
98
  head = f"- {_blocking_marker(item)}**{item['title']}** `{item['qualifiedName']}`"
33
99
  fields = cast("Mapping[str, Any]", item.get("fields") or {})
34
- shown = [f"{k}: {v}" for k, v in fields.items() if not isinstance(v, (list, dict))]
35
- lines = [head + (" · " + " · ".join(shown) if shown else "")]
100
+ full = item.get("detail") == "full"
101
+ spec = cast("Mapping[str, Any]", item.get("spec") or {})
36
102
  description = item.get("description")
103
+ shown = {k: v for k, v in fields.items() if _inline(v) and not (full and spec.get(k, v) != v)}
104
+ lines = [head + (" · " + " · ".join(f"{k}: {v}" for k, v in shown.items()) if shown else "")]
37
105
  if description:
38
106
  length = int(item.get("descriptionLength") or 0)
39
107
  more = (
40
108
  f" _({length:,} chars; detail=full for all)_"
41
- if item.get("detail") != "full" and length > len(str(description))
109
+ if not full and length > len(str(description))
42
110
  else ""
43
111
  )
44
112
  lines.append(f" {str(description).strip()}{more}")
45
- for why in cast("list[Mapping[str, Any]]", item.get("why") or []):
46
- lines.append(f" why: {why['text']}")
47
- if item.get("detail") == "full":
48
- spec = cast("Mapping[str, Any]", item.get("spec") or {})
49
- extra = {k: v for k, v in spec.items() if k not in fields}
50
- for key, value in extra.items():
51
- lines.append(f" {key}: {value}")
52
- body = item.get("body")
53
- if body:
54
- lines.append("")
55
- lines.extend(f" {line}" for line in str(body).strip().splitlines())
113
+ lines.extend(f" why: {route}" for route in _routes(item))
114
+ held = spec if full else fields
115
+ for key, value in held.items():
116
+ if key in shown or _empty(value):
117
+ continue
118
+ if full and value in (item.get("title"), description):
119
+ continue
120
+ lines.extend(_entry(key, value, 2))
121
+ body = item.get("body")
122
+ if full and body:
123
+ lines.append("")
124
+ lines.extend(f" {line}" for line in str(body).strip().splitlines())
56
125
  return "\n".join(lines)
57
126
 
58
127
 
@@ -27,6 +27,7 @@ from katalyst_engine.schema.registry import SchemaRegistry
27
27
  from katalyst_plugin_context.adapters.runtime_graph import graph_from
28
28
  from katalyst_plugin_context.domain.graph import Graph
29
29
  from katalyst_plugin_context.service import ContextService
30
+ from katalyst_taxonomy.engine.relationships import carriers_of
30
31
 
31
32
  GOVERNANCE: dict[str, Any] = {
32
33
  "title": "Governing decisions",
@@ -103,7 +104,7 @@ def registry(
103
104
  WingDefinition(
104
105
  identity=Identity(kind="wing_definition", name="craft"),
105
106
  describes_kind="craft",
106
- member_kinds=("decision", "rule", "guide", "gadget", "method", "bundle"),
107
+ member_kinds=("decision", "rule", "guide", "gadget", "method", "bundle", "checklist"),
107
108
  plugin_settings={"context": {"title_field": "title"}},
108
109
  )
109
110
  )
@@ -145,6 +146,21 @@ def registry(
145
146
  },
146
147
  )
147
148
  )
149
+ reg.register_node(
150
+ _kind(
151
+ "checklist",
152
+ wing="craft",
153
+ fields=(
154
+ *titled,
155
+ FieldSpec(name="checks", field_type="list"),
156
+ FieldSpec(name="violations", field_type="list"),
157
+ FieldSpec(name="rules", field_type="list"),
158
+ FieldSpec(name="rationale"),
159
+ FieldSpec(name="notes"),
160
+ ),
161
+ settings={"summary_fields": ["checks", "violations"]},
162
+ )
163
+ )
148
164
  mode = FieldSpec(
149
165
  name="mode",
150
166
  default="must",
@@ -225,6 +241,26 @@ DOCUMENTS: tuple[Node, ...] = (
225
241
  node("method", "m-strict", title="Strict", mode="must", gadgets=["hammer"]),
226
242
  node("method", "m-loose", title="Loose", mode="may", gadgets=["hammer"]),
227
243
  node("bundle", "b-all", title="All", methods=["m-strict", "m-loose"]),
244
+ node(
245
+ "checklist",
246
+ "c-hygiene",
247
+ title="Hygiene",
248
+ status="required",
249
+ checks=["Runs inside its worktree", "Leaves no stray files"],
250
+ violations=[
251
+ {"pattern": "rm -rf /", "severity": "error", "message": "Never delete the root"},
252
+ {"pattern": "git push --force", "severity": "warn", "message": "Force pushes"},
253
+ ],
254
+ rules=[
255
+ {
256
+ "id": "stay-put",
257
+ "title": "Stay in the worktree",
258
+ "body": "Write only inside the run worktree.\nNever outside it.",
259
+ }
260
+ ],
261
+ rationale="Because isolation matters. " * 12,
262
+ notes="Short note",
263
+ ),
228
264
  )
229
265
 
230
266
  #: (source, rel, target) within backend "repo", as list_relationships would give them.
@@ -241,15 +277,20 @@ EDGES: tuple[tuple[str, str, str], ...] = (
241
277
  ("m-loose", "USES_GADGET", "hammer"),
242
278
  ("b-all", "INCLUDES_METHOD", "m-strict"),
243
279
  ("b-all", "INCLUDES_METHOD", "m-loose"),
280
+ ("m-strict", "FOLLOWS_CHECKLIST", "c-hygiene"),
244
281
  )
245
282
 
246
283
 
247
284
  def graph(
248
- documents: tuple[Node, ...] = DOCUMENTS, edges: tuple[tuple[str, str, str], ...] = EDGES
285
+ documents: tuple[Node, ...] = DOCUMENTS,
286
+ edges: tuple[tuple[str, str, str], ...] = EDGES,
287
+ reg: SchemaRegistry | None = None,
249
288
  ) -> Graph:
289
+ schema = reg or registry()
250
290
  return graph_from(
251
291
  (("repo", doc) for doc in documents),
252
292
  (("repo", source, rel, target) for source, rel, target in edges),
293
+ carriers=lambda kind: [(c.rel_type, c.target_kinds) for c in carriers_of(schema, kind)],
253
294
  )
254
295
 
255
296
 
@@ -257,7 +298,11 @@ def service(
257
298
  reg: SchemaRegistry | None = None,
258
299
  *,
259
300
  documents: tuple[Node, ...] = DOCUMENTS,
301
+ edges: tuple[tuple[str, str, str], ...] = EDGES,
260
302
  config: Mapping[str, Any] | None = None,
261
303
  profile: str | None = None,
262
304
  ) -> ContextService:
263
- return ContextService(reg or registry(), graph(documents), config=config or {}, profile=profile)
305
+ schema = reg or registry()
306
+ return ContextService(
307
+ schema, graph(documents, edges, schema), config=config or {}, profile=profile
308
+ )
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from typing import Any
6
6
 
7
7
  import pytest
8
- from context_support import DOCUMENTS, GOVERNANCE, node, registry, service
8
+ from context_support import DOCUMENTS, EDGES, GOVERNANCE, node, registry, service
9
9
  from katalyst_plugin_context.adapters.runtime_graph import graph_from
10
10
  from katalyst_plugin_context.domain.context import ContextError, ContextRequest
11
11
  from katalyst_plugin_context.domain.paths import PathSyntaxError, Step, parse_path, parse_step
@@ -489,6 +489,86 @@ def test_edges_to_hidden_nodes_are_left_out_without_being_counted() -> None:
489
489
  assert list(graph.neighbours("a:north")) == [] and graph.dropped == 1
490
490
 
491
491
 
492
+ def test_a_name_two_kinds_share_is_walked_by_its_own_edges() -> None:
493
+ shared = (*DOCUMENTS, node("decision", "hammer", title="Hammer decision"))
494
+ svc = service(documents=shared)
495
+ gadget = svc.query("repo:hammer#gadget", ContextRequest())
496
+ assert gadget["node"]["qualifiedName"] == "repo:hammer#gadget"
497
+ assert {"repo:m-strict", "repo:m-loose", UNIT} <= set(_names(gadget))
498
+ decision = svc.query("repo:hammer#decision", ContextRequest(detail="refs"))
499
+ assert _names(decision) == []
500
+ assert (
501
+ svc.query(UNIT, ContextRequest(paths=("USES_GADGET:out",)))["items"][-1]["qualifiedName"]
502
+ == "repo:hammer#gadget"
503
+ )
504
+
505
+
506
+ def test_a_source_that_shares_its_name_keeps_its_own_edges() -> None:
507
+ shared = (*DOCUMENTS, node("rule", "m-strict", title="Same name"))
508
+ svc = service(documents=shared)
509
+ method = svc.query("repo:m-strict#method", ContextRequest(paths=("USES_GADGET:out",)))
510
+ assert _names(method, view=None) == ["repo:hammer"]
511
+ rule = svc.query("repo:m-strict#rule", ContextRequest(paths=("USES_GADGET:out",)))
512
+ assert _names(rule, view=None) == []
513
+
514
+
515
+ def test_an_edge_the_schema_cannot_place_between_shared_names_is_dropped_not_guessed() -> None:
516
+ shared = (*DOCUMENTS, node("decision", "c-hygiene", title="Same name"))
517
+ graph = service(documents=shared)._graph # pyright: ignore[reportPrivateUsage]
518
+ assert list(graph.neighbours("repo:c-hygiene#checklist")) == []
519
+ assert graph.dropped == 1
520
+
521
+
522
+ # ── one item, many routes ───────────────────────────────────────────────
523
+
524
+ SHORT = "USES_GADGET:out/USES_GADGET:in"
525
+ LONG_ROUTE = "USES_GADGET:out/USES_GADGET:in/INCLUDES_METHOD:in"
526
+ SECOND_ROUTE = (*EDGES, ("b-all", "USES_GADGET", "hammer"))
527
+
528
+
529
+ def _bundle(result: dict[str, Any]) -> dict[str, Any]:
530
+ return next(i for i in result["items"] if i["qualifiedName"] == "repo:b-all")
531
+
532
+
533
+ def test_the_shortest_route_comes_first_and_the_json_keeps_every_route() -> None:
534
+ svc = service(edges=SECOND_ROUTE)
535
+ result = svc.query(UNIT, ContextRequest(paths=(LONG_ROUTE, SHORT), views=()))
536
+ bundle = _bundle(result)
537
+ assert [len(w["hops"]) for w in bundle["why"]] == [2, 3, 3]
538
+
539
+
540
+ def test_markdown_prints_the_shortest_route_and_counts_the_others() -> None:
541
+ svc = service(edges=SECOND_ROUTE)
542
+ request = ContextRequest(paths=(LONG_ROUTE, SHORT), format="markdown")
543
+ text = svc.markdown_for_result(UNIT, svc.query(UNIT, request))
544
+ block = text[text.index("**All**") :].split("\n- ")[0]
545
+ assert block.count("why:") == 1 and "(+2 more routes)" in block
546
+ assert text.count("more route") == 1
547
+
548
+
549
+ def test_markdown_lists_every_route_at_full() -> None:
550
+ svc = service(edges=SECOND_ROUTE)
551
+ request = ContextRequest(paths=(LONG_ROUTE, SHORT), detail="full", format="markdown")
552
+ text = svc.markdown_for_result(UNIT, svc.query(UNIT, request))
553
+ block = text[text.index("**All**") :].split("\n- ")[0]
554
+ assert block.count("why:") == 3 and "more route" not in block
555
+
556
+
557
+ def test_an_item_with_many_routes_is_counted_once_in_the_budget() -> None:
558
+ from katalyst_plugin_context.domain.context import content_size
559
+
560
+ svc = service(edges=SECOND_ROUTE)
561
+ asked = ContextRequest(paths=(LONG_ROUTE, SHORT), detail="refs", budget=32000)
562
+ bundle = _bundle(svc.query(UNIT, asked))
563
+ assert len(bundle["why"]) == 3
564
+ one_route = {**bundle, "why": bundle["why"][:1]}
565
+ # Collapsed: the shortest route and the "(+2 more routes)" note, never the others.
566
+ assert content_size(bundle) - content_size(one_route) <= 4
567
+ assert content_size({**bundle, "detail": "full"}) > content_size(bundle) + 8
568
+ full = ContextRequest(paths=(LONG_ROUTE, SHORT), detail="full", budget=32000)
569
+ assert content_size(_bundle(svc.query(UNIT, full))) > content_size(bundle)
570
+
571
+
492
572
  # ── markdown ────────────────────────────────────────────────────────────
493
573
 
494
574
 
@@ -503,3 +583,63 @@ def test_markdown_groups_by_view_and_kind_and_reports_cuts() -> None:
503
583
  blocking = svc.markdown_for_result("repo:hammer", svc.query("repo:hammer", ContextRequest()))
504
584
  assert "⛔ blocking" in blocking and "_advisory_" in blocking
505
585
  assert "## Relationships" in svc.markdown_for_map(svc.map(UNIT))
586
+
587
+
588
+ # ── markdown shows a node's fields ──────────────────────────────────────
589
+
590
+ CHECKLIST = "FOLLOWS_CHECKLIST:out"
591
+ METHOD = "repo:m-strict"
592
+
593
+
594
+ def _markdown(detail: str, budget: int | None = None) -> str:
595
+ svc = service()
596
+ request = ContextRequest(paths=(CHECKLIST,), detail=detail, budget=budget, format="markdown")
597
+ return svc.markdown_for_result(METHOD, svc.query(METHOD, request))
598
+
599
+
600
+ def test_markdown_at_full_renders_lists_as_bullets_and_mappings_as_nested_bullets() -> None:
601
+ text = _markdown("full")
602
+ assert " - checks:\n - Runs inside its worktree\n - Leaves no stray files" in text
603
+ assert (
604
+ " - violations:\n"
605
+ " - pattern: rm -rf / · severity: error · message: Never delete the root\n"
606
+ " - pattern: git push --force · severity: warn · message: Force pushes" in text
607
+ )
608
+ assert " - id: stay-put · title: Stay in the worktree\n - body:\n" in text
609
+ assert "['" not in text and "{'" not in text
610
+
611
+
612
+ def test_markdown_at_full_keeps_long_text_and_multiline_text() -> None:
613
+ text = _markdown("full")
614
+ assert "Because isolation matters. " * 12 in text.replace("\n", " ").replace(" ", " ")
615
+ assert "…" not in text
616
+ assert "Write only inside the run worktree." in text and "Never outside it." in text
617
+
618
+
619
+ def test_markdown_at_summary_renders_only_the_declared_summary_fields() -> None:
620
+ text = _markdown("summary")
621
+ assert " - checks:\n - Runs inside its worktree" in text
622
+ assert " - pattern: rm -rf / · severity: error" in text
623
+ assert "rationale" not in text and "stay-put" not in text and "Short note" not in text
624
+
625
+
626
+ def test_markdown_at_refs_renders_no_fields() -> None:
627
+ text = _markdown("refs")
628
+ assert "Hygiene" in text and "checks" not in text and "Runs inside" not in text
629
+
630
+
631
+ @pytest.mark.parametrize("detail", ["refs", "summary", "full"])
632
+ @pytest.mark.parametrize("budget", [1, 40, 120, 300, 32000])
633
+ def test_fields_in_markdown_stay_within_the_budget_json_measures(detail: str, budget: int) -> None:
634
+ svc = service()
635
+
636
+ def answer(fmt: str) -> dict[str, Any]:
637
+ request = ContextRequest(paths=(CHECKLIST,), detail=detail, budget=budget, format=fmt)
638
+ return svc.query(METHOD, request)
639
+
640
+ as_json, as_markdown = answer("json"), answer("markdown")
641
+ assert _names(as_json) == _names(as_markdown)
642
+ assert as_json["used"] == as_markdown["used"] <= budget
643
+ text = svc.markdown_for_result(METHOD, as_markdown)
644
+ for item in as_json["items"]:
645
+ assert ("Runs inside its worktree" in text) == ("checks" in item.get("fields", {}))
@@ -0,0 +1,57 @@
1
+ """An edge to a name two kinds share is placed by the kind the listing names (#308)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from katalyst_engine.core.identity import Identity
6
+ from katalyst_engine.model.node import Node
7
+ from katalyst_plugin_context.adapters.runtime_graph import graph_from
8
+ from katalyst_plugin_context.domain.graph import OUT, Graph
9
+
10
+
11
+ def _node(kind: str, name: str) -> tuple[str, Node]:
12
+ return "local", Node(identity=Identity(kind=kind, name=name), spec={})
13
+
14
+
15
+ def _edges(graph: Graph) -> set[tuple[str, str, str]]:
16
+ return {
17
+ (n.rel, key, n.other)
18
+ for key in graph.nodes
19
+ for n in graph.neighbours(key)
20
+ if n.direction == OUT
21
+ }
22
+
23
+
24
+ DOCUMENTS = [_node("tool", "just"), _node("runner", "just"), _node("job", "build")]
25
+
26
+
27
+ def test_an_edge_to_a_shared_name_lands_on_the_kind_it_names() -> None:
28
+ graph = graph_from(
29
+ DOCUMENTS,
30
+ [("local", "build", "USES_TOOL", "just#tool"), ("local", "build", "USES", "just#runner")],
31
+ )
32
+
33
+ assert _edges(graph) == {
34
+ ("USES_TOOL", "local:build", "local:just#tool"),
35
+ ("USES", "local:build", "local:just#runner"),
36
+ }
37
+ assert graph.dropped == 0
38
+
39
+
40
+ def test_an_edge_from_a_shared_name_lands_on_the_kind_it_names() -> None:
41
+ graph = graph_from(DOCUMENTS, [("local", "just#runner", "RUNS", "build")])
42
+
43
+ assert _edges(graph) == {("RUNS", "local:just#runner", "local:build")}
44
+
45
+
46
+ def test_an_edge_to_a_shared_name_nothing_places_is_counted_as_dropped() -> None:
47
+ graph = graph_from(DOCUMENTS, [("local", "build", "USES", "just")])
48
+
49
+ assert _edges(graph) == set()
50
+ assert graph.dropped == 1
51
+
52
+
53
+ def test_an_edge_to_a_kind_that_is_not_at_the_name_is_counted_as_dropped() -> None:
54
+ graph = graph_from(DOCUMENTS, [("local", "build", "USES", "just#job")])
55
+
56
+ assert _edges(graph) == set()
57
+ assert graph.dropped == 1