katalyst-plugin-context 0.2.1a3__tar.gz → 0.2.1a5__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.1a3 → katalyst_plugin_context-0.2.1a5}/PKG-INFO +1 -1
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/pyproject.toml +1 -1
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/adapters/runtime_graph.py +64 -19
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/composition.py +3 -2
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/context.py +29 -10
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/markdown.py +84 -15
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/context_support.py +48 -3
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/test_context.py +178 -1
- katalyst_plugin_context-0.2.1a5/tests/test_shared_names.py +57 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/.gitignore +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/adapters/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/cli.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/__init__.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/graph.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/paths.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/policy.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/domain/settings.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/lint.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/mcp_tools.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/plugin.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/py.typed +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/rest.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/schema.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/src/katalyst_plugin_context/service.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/conftest.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/test_lint.py +0 -0
- {katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/test_surfaces.py +0 -0
|
@@ -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
|
-
|
|
78
|
+
|
|
79
|
+
def key_of(backend: str, fqn: str, kind: str) -> str:
|
|
68
80
|
shared = len(by_address[(backend, fqn)]) > 1
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
if
|
|
85
|
-
|
|
86
|
-
if not any(end in hidden_addresses for end in
|
|
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=
|
|
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]],
|
|
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
|
-
|
|
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.
|
|
@@ -120,6 +121,7 @@ def _walk(
|
|
|
120
121
|
policy: Policy,
|
|
121
122
|
*,
|
|
122
123
|
exclude: frozenset[str],
|
|
124
|
+
open_to_named: frozenset[str] = frozenset(),
|
|
123
125
|
inherited: bool,
|
|
124
126
|
parent_rel: str,
|
|
125
127
|
collect_every_level: bool,
|
|
@@ -127,8 +129,10 @@ def _walk(
|
|
|
127
129
|
"""Every node reached from *start* by following *steps*, with the hops that reached it.
|
|
128
130
|
|
|
129
131
|
A node never appears twice on one branch (cycles stop). *exclude* (the focus node and
|
|
130
|
-
its ancestors) is never reached
|
|
131
|
-
|
|
132
|
+
its ancestors) is never reached, except that a step which names its relationship
|
|
133
|
+
(not ``*``) may reach the nodes of *open_to_named*: asking for ``CHILD_OF:out`` is
|
|
134
|
+
asking for the ancestors. From an ancestor, its structural parent edges are never
|
|
135
|
+
followed: they would only reach siblings.
|
|
132
136
|
"""
|
|
133
137
|
frontier: list[tuple[str, tuple[_Hop, ...]]] = [(start, ())]
|
|
134
138
|
arrivals: list[_Arrival] = []
|
|
@@ -140,7 +144,11 @@ def _walk(
|
|
|
140
144
|
if inherited and index == 0 and neighbour.rel == parent_rel:
|
|
141
145
|
continue
|
|
142
146
|
other = graph.nodes[neighbour.other]
|
|
143
|
-
if neighbour.other in
|
|
147
|
+
if neighbour.other in on_branch:
|
|
148
|
+
continue
|
|
149
|
+
if neighbour.other in exclude and not (
|
|
150
|
+
step.rel != "*" and neighbour.other in open_to_named
|
|
151
|
+
):
|
|
144
152
|
continue
|
|
145
153
|
if not step.matches(neighbour.rel, neighbour.direction, other.kind):
|
|
146
154
|
continue
|
|
@@ -246,7 +254,7 @@ def _item_payload(
|
|
|
246
254
|
) -> dict[str, Any]:
|
|
247
255
|
node = entry.node
|
|
248
256
|
hints = schema.hints(node.kind)
|
|
249
|
-
whys = sorted(entry.whys, key=lambda w: w[0])
|
|
257
|
+
whys = sorted(entry.whys, key=lambda w: (len(w[1].hops), w[0]))
|
|
250
258
|
payload: dict[str, Any] = {
|
|
251
259
|
"qualifiedName": node.key,
|
|
252
260
|
"kind": node.kind,
|
|
@@ -310,16 +318,25 @@ def _text(value: object) -> str:
|
|
|
310
318
|
return value if isinstance(value, str) else json.dumps(value, default=str, ensure_ascii=False)
|
|
311
319
|
|
|
312
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
|
+
|
|
313
326
|
def content_size(item: Mapping[str, Any]) -> int:
|
|
314
327
|
"""The item's content in estimated tokens, whatever format the answer is given in.
|
|
315
328
|
|
|
316
|
-
Counts what a reader reads: the qualified name, kind and title,
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
for the same
|
|
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.
|
|
320
334
|
"""
|
|
321
335
|
parts: list[str] = [str(item["qualifiedName"]), str(item["kind"]), str(item["title"])]
|
|
322
|
-
|
|
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)
|
|
323
340
|
blocking = item.get("blocking")
|
|
324
341
|
entries = cast(
|
|
325
342
|
"list[Mapping[str, Any]]",
|
|
@@ -491,7 +508,8 @@ def _collect(
|
|
|
491
508
|
"""Every item *sources* reach, at its best rank, and each view's and path's members.
|
|
492
509
|
|
|
493
510
|
The ancestors are never items of a source that inherits, or of any source when the
|
|
494
|
-
call inherits
|
|
511
|
+
call inherits, unless a step names the relationship that reaches them; otherwise
|
|
512
|
+
(inheritance off) they can be.
|
|
495
513
|
"""
|
|
496
514
|
entries: dict[str, _Entry] = {}
|
|
497
515
|
group_members: dict[tuple[str | None, str], set[str]] = {}
|
|
@@ -508,6 +526,7 @@ def _collect(
|
|
|
508
526
|
steps,
|
|
509
527
|
policy,
|
|
510
528
|
exclude=exclude,
|
|
529
|
+
open_to_named=frozenset(ancestors),
|
|
511
530
|
inherited=start != focus,
|
|
512
531
|
parent_rel=schema.parent_rel,
|
|
513
532
|
collect_every_level=source.repeat > 1,
|
|
@@ -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
|
-
|
|
35
|
-
|
|
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
|
|
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
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
{katalyst_plugin_context-0.2.1a3 → katalyst_plugin_context-0.2.1a5}/tests/context_support.py
RENAMED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
@@ -184,6 +184,43 @@ def test_an_always_view_keeps_its_inheritance_when_the_request_turns_it_off() ->
|
|
|
184
184
|
assert "repo:d-region" in _names(result, view="governance")
|
|
185
185
|
|
|
186
186
|
|
|
187
|
+
def test_a_path_that_names_the_parent_relationship_returns_the_ancestors_by_default() -> None:
|
|
188
|
+
"""#303: the ancestors were items only with inheritance off; the default made the path dead."""
|
|
189
|
+
result = service().query(UNIT, ContextRequest(paths=("CHILD_OF:out",), depth=2))
|
|
190
|
+
assert result["effective"]["inherit"] is True
|
|
191
|
+
assert set(_names(result, view=None)) == {"repo:north.east", "repo:north"}
|
|
192
|
+
parent = next(i for i in result["items"] if i["qualifiedName"] == "repo:north.east")
|
|
193
|
+
assert (parent["path"], parent["inheritedFrom"]) == ("CHILD_OF:out", None)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_a_declared_view_path_on_the_parent_relationship_returns_the_ancestors() -> None:
|
|
197
|
+
parents = {"title": "Parents", "paths": ["CHILD_OF:out"], "inherit": True}
|
|
198
|
+
settings = {
|
|
199
|
+
"views": {"governance": GOVERNANCE, "parents": parents},
|
|
200
|
+
"policy": {"always_include": ["governance"]},
|
|
201
|
+
}
|
|
202
|
+
result = service(registry(unit_settings=settings)).query(
|
|
203
|
+
UNIT, ContextRequest(views=("parents",))
|
|
204
|
+
)
|
|
205
|
+
assert set(_names(result, view="parents")) == {"repo:north.east"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_a_wildcard_step_never_returns_the_ancestors_with_inheritance_on() -> None:
|
|
209
|
+
result = service().query(UNIT, ContextRequest(paths=("*:out",)))
|
|
210
|
+
assert not {"repo:north.east", "repo:north"} & set(_names(result, view=None))
|
|
211
|
+
assert not {"repo:north.east", "repo:north"} & set(_names(result))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_the_map_row_and_the_items_of_the_parent_path_agree() -> None:
|
|
215
|
+
mapped = service().map(UNIT)
|
|
216
|
+
row = next(
|
|
217
|
+
r for r in mapped["relationships"] if r["path"] == "CHILD_OF:out" and not r.get("inherited")
|
|
218
|
+
)
|
|
219
|
+
assert row["count"] == 1
|
|
220
|
+
items = service().query(UNIT, ContextRequest(paths=("CHILD_OF:out",)))["items"]
|
|
221
|
+
assert [i["qualifiedName"] for i in items if i["path"] == "CHILD_OF:out"] == ["repo:north.east"]
|
|
222
|
+
|
|
223
|
+
|
|
187
224
|
def test_policy_can_take_an_always_view_out_and_says_which_layer() -> None:
|
|
188
225
|
config: dict[str, Any] = {"policy": {"always_include": []}}
|
|
189
226
|
svc = service(config=config)
|
|
@@ -452,6 +489,86 @@ def test_edges_to_hidden_nodes_are_left_out_without_being_counted() -> None:
|
|
|
452
489
|
assert list(graph.neighbours("a:north")) == [] and graph.dropped == 1
|
|
453
490
|
|
|
454
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
|
+
|
|
455
572
|
# ── markdown ────────────────────────────────────────────────────────────
|
|
456
573
|
|
|
457
574
|
|
|
@@ -466,3 +583,63 @@ def test_markdown_groups_by_view_and_kind_and_reports_cuts() -> None:
|
|
|
466
583
|
blocking = svc.markdown_for_result("repo:hammer", svc.query("repo:hammer", ContextRequest()))
|
|
467
584
|
assert "⛔ blocking" in blocking and "_advisory_" in blocking
|
|
468
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
|
|
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
|