katalyst-plugin-context 0.1.1a1__py3-none-any.whl
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/__init__.py +8 -0
- katalyst_plugin_context/adapters/__init__.py +0 -0
- katalyst_plugin_context/adapters/runtime_graph.py +126 -0
- katalyst_plugin_context/cli.py +139 -0
- katalyst_plugin_context/composition.py +49 -0
- katalyst_plugin_context/domain/__init__.py +0 -0
- katalyst_plugin_context/domain/context.py +762 -0
- katalyst_plugin_context/domain/graph.py +97 -0
- katalyst_plugin_context/domain/markdown.py +201 -0
- katalyst_plugin_context/domain/paths.py +114 -0
- katalyst_plugin_context/domain/policy.py +345 -0
- katalyst_plugin_context/domain/settings.py +92 -0
- katalyst_plugin_context/lint.py +149 -0
- katalyst_plugin_context/mcp_tools.py +132 -0
- katalyst_plugin_context/plugin.py +64 -0
- katalyst_plugin_context/py.typed +0 -0
- katalyst_plugin_context/rest.py +133 -0
- katalyst_plugin_context/schema.py +200 -0
- katalyst_plugin_context/service.py +319 -0
- katalyst_plugin_context-0.1.1a1.dist-info/METADATA +13 -0
- katalyst_plugin_context-0.1.1a1.dist-info/RECORD +23 -0
- katalyst_plugin_context-0.1.1a1.dist-info/WHEEL +4 -0
- katalyst_plugin_context-0.1.1a1.dist-info/entry_points.txt +5 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"""Context policy: layered controls that can only narrow what an agent is given.
|
|
2
|
+
|
|
3
|
+
Layers fold in order: built-in → wing → kind → node (``spec.extensions.context``) →
|
|
4
|
+
profile (the plugin's config, then the active profile's section and its matching rules).
|
|
5
|
+
Each control narrows in its own way:
|
|
6
|
+
|
|
7
|
+
================= ===========================================================
|
|
8
|
+
``paths.allow`` the steps visible are those every layer's allow list matches
|
|
9
|
+
``paths.deny`` a step any layer denies is hidden
|
|
10
|
+
``max_depth`` the smallest declared
|
|
11
|
+
``detail`` the levels every layer allows (``refs`` is always allowed)
|
|
12
|
+
``budget.max`` the smallest declared
|
|
13
|
+
``inheritance`` off once any layer turns it off
|
|
14
|
+
``always_include`` the first declaring layer sets it; later layers intersect
|
|
15
|
+
================= ===========================================================
|
|
16
|
+
|
|
17
|
+
A layer that tries to widen is not an error. Its declaration has no effect, and the
|
|
18
|
+
effective policy records a note saying which layer limited it. Defaults (``depth``,
|
|
19
|
+
``detail``, ``budget``, ``inherit``) are not limits: a later layer's default replaces an
|
|
20
|
+
earlier one, and the result is clamped into the policy.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from typing import Any, cast
|
|
28
|
+
|
|
29
|
+
from katalyst_plugin_context.domain.paths import Step, parse_step
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"BUILTIN",
|
|
33
|
+
"DETAIL_LEVELS",
|
|
34
|
+
"Layer",
|
|
35
|
+
"Policy",
|
|
36
|
+
"fold",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
REFS, SUMMARY, FULL = "refs", "summary", "full"
|
|
40
|
+
DETAIL_LEVELS: tuple[str, ...] = (REFS, SUMMARY, FULL)
|
|
41
|
+
|
|
42
|
+
BUILTIN = "builtin"
|
|
43
|
+
_BUILTIN_MAX_DEPTH = 4
|
|
44
|
+
_BUILTIN_BUDGET_MAX = 32000
|
|
45
|
+
_BUILTIN_DEPTH = 1
|
|
46
|
+
_BUILTIN_BUDGET = 4000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class Layer:
|
|
51
|
+
"""One source of policy: its label (``kind:<kind>``) and what it declares."""
|
|
52
|
+
|
|
53
|
+
label: str
|
|
54
|
+
policy: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
|
|
55
|
+
defaults: Mapping[str, Any] = field(default_factory=lambda: dict[str, Any]())
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass
|
|
59
|
+
class Policy:
|
|
60
|
+
"""The effective policy for one node, and how it came about."""
|
|
61
|
+
|
|
62
|
+
allow: list[tuple[str, tuple[Step, ...]]] = field(
|
|
63
|
+
default_factory=lambda: list[tuple[str, tuple[Step, ...]]]()
|
|
64
|
+
)
|
|
65
|
+
deny: list[tuple[str, Step]] = field(default_factory=lambda: list[tuple[str, Step]]())
|
|
66
|
+
max_depth: int = _BUILTIN_MAX_DEPTH
|
|
67
|
+
max_depth_by: str = BUILTIN
|
|
68
|
+
detail: tuple[str, ...] = DETAIL_LEVELS
|
|
69
|
+
detail_by: dict[str, str] = field(default_factory=lambda: dict[str, str]())
|
|
70
|
+
budget_max: int = _BUILTIN_BUDGET_MAX
|
|
71
|
+
budget_by: str = BUILTIN
|
|
72
|
+
inheritance: bool = True
|
|
73
|
+
inheritance_by: str = BUILTIN
|
|
74
|
+
always: tuple[str, ...] | None = None
|
|
75
|
+
always_by: str = ""
|
|
76
|
+
default_depth: int = _BUILTIN_DEPTH
|
|
77
|
+
default_detail: str | None = None
|
|
78
|
+
default_budget: int = _BUILTIN_BUDGET
|
|
79
|
+
default_inherit: bool = True
|
|
80
|
+
layers: list[str] = field(default_factory=lambda: [BUILTIN])
|
|
81
|
+
notes: list[dict[str, Any]] = field(default_factory=lambda: list[dict[str, Any]]())
|
|
82
|
+
|
|
83
|
+
# ── visibility ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
def hidden_by(self, rel: str, direction: str, kind: str) -> str | None:
|
|
86
|
+
"""The layer that hides traversing *rel* *direction* to a *kind*, or ``None``."""
|
|
87
|
+
for label, patterns in self.allow:
|
|
88
|
+
if not any(p.matches(rel, direction, kind) for p in patterns):
|
|
89
|
+
return label
|
|
90
|
+
for label, pattern in self.deny:
|
|
91
|
+
if pattern.matches(rel, direction, kind):
|
|
92
|
+
return label
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
def visible(self, rel: str, direction: str, kind: str) -> bool:
|
|
96
|
+
return self.hidden_by(rel, direction, kind) is None
|
|
97
|
+
|
|
98
|
+
def step_hidden_by(self, step: Step) -> str | None:
|
|
99
|
+
"""The layer that hides every traversal *step* could make, or ``None``.
|
|
100
|
+
|
|
101
|
+
A step is hidden when an allow list has nothing overlapping it, or a deny pattern
|
|
102
|
+
covers it entirely (same or wider relationship, direction and kinds).
|
|
103
|
+
"""
|
|
104
|
+
for label, patterns in self.allow:
|
|
105
|
+
if not any(p.overlaps(step) for p in patterns):
|
|
106
|
+
return label
|
|
107
|
+
for label, pattern in self.deny:
|
|
108
|
+
if _covers(pattern, step):
|
|
109
|
+
return label
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
# ── clamping a request ──────────────────────────────────────────────
|
|
113
|
+
|
|
114
|
+
def clamp_depth(self, requested: int | None) -> tuple[int, dict[str, Any] | None]:
|
|
115
|
+
wanted = self.default_depth if requested is None else requested
|
|
116
|
+
if wanted > self.max_depth:
|
|
117
|
+
return self.max_depth, _clamp("depth", wanted, self.max_depth, self.max_depth_by)
|
|
118
|
+
return max(wanted, 0), None
|
|
119
|
+
|
|
120
|
+
def clamp_detail(
|
|
121
|
+
self, requested: str | None, fallback: str
|
|
122
|
+
) -> tuple[str, dict[str, Any] | None]:
|
|
123
|
+
wanted = requested or self.default_detail or fallback
|
|
124
|
+
if wanted in self.detail:
|
|
125
|
+
return wanted, None
|
|
126
|
+
allowed = [
|
|
127
|
+
level for level in DETAIL_LEVELS[: DETAIL_LEVELS.index(wanted)] if level in self.detail
|
|
128
|
+
]
|
|
129
|
+
effective = allowed[-1] if allowed else REFS
|
|
130
|
+
return effective, _clamp("detail", wanted, effective, self.detail_by.get(wanted, BUILTIN))
|
|
131
|
+
|
|
132
|
+
def clamp_budget(self, requested: int | None) -> tuple[int, dict[str, Any] | None]:
|
|
133
|
+
wanted = min(self.default_budget, self.budget_max) if requested is None else requested
|
|
134
|
+
if wanted > self.budget_max:
|
|
135
|
+
return self.budget_max, _clamp("budget", wanted, self.budget_max, self.budget_by)
|
|
136
|
+
return max(wanted, 1), None
|
|
137
|
+
|
|
138
|
+
def clamp_inherit(self, requested: bool | None) -> tuple[bool, dict[str, Any] | None]:
|
|
139
|
+
wanted = self.default_inherit if requested is None else requested
|
|
140
|
+
if wanted and not self.inheritance:
|
|
141
|
+
return False, _clamp("inherit", True, False, self.inheritance_by)
|
|
142
|
+
return wanted, None
|
|
143
|
+
|
|
144
|
+
def describe(self) -> dict[str, Any]:
|
|
145
|
+
"""The effective policy, as a payload."""
|
|
146
|
+
return {
|
|
147
|
+
"layers": list(self.layers),
|
|
148
|
+
"paths": {
|
|
149
|
+
"allow": [
|
|
150
|
+
{"source": label, "patterns": [p.text for p in patterns]}
|
|
151
|
+
for label, patterns in self.allow
|
|
152
|
+
],
|
|
153
|
+
"deny": [{"source": label, "pattern": p.text} for label, p in self.deny],
|
|
154
|
+
},
|
|
155
|
+
"maxDepth": self.max_depth,
|
|
156
|
+
"detail": list(self.detail),
|
|
157
|
+
"budget": {
|
|
158
|
+
"default": min(self.default_budget, self.budget_max),
|
|
159
|
+
"max": self.budget_max,
|
|
160
|
+
},
|
|
161
|
+
"inheritance": self.inheritance,
|
|
162
|
+
"alwaysInclude": list(self.always or ()),
|
|
163
|
+
"notes": list(self.notes),
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _clamp(control: str, requested: object, effective: object, by: str) -> dict[str, Any]:
|
|
168
|
+
return {"control": control, "requested": requested, "effective": effective, "limitedBy": by}
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _covers(wide: Step, narrow: Step) -> bool:
|
|
172
|
+
return (
|
|
173
|
+
wide.rel in ("*", narrow.rel)
|
|
174
|
+
and (wide.direction is None or wide.direction == narrow.direction)
|
|
175
|
+
and (not wide.kinds or (bool(narrow.kinds) and narrow.kinds <= wide.kinds))
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _note(
|
|
180
|
+
layer: str, control: str, declared: object, effective: object, by: str, text: str
|
|
181
|
+
) -> dict[str, Any]:
|
|
182
|
+
return {
|
|
183
|
+
"source": layer,
|
|
184
|
+
"control": control,
|
|
185
|
+
"declared": declared,
|
|
186
|
+
"effective": effective,
|
|
187
|
+
"limitedBy": by,
|
|
188
|
+
"text": text,
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _strings(value: object) -> list[str]:
|
|
193
|
+
return [str(v) for v in cast("list[object]", value)] if isinstance(value, list) else []
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def fold(layers: Sequence[Layer], *, views: Sequence[str] = ()) -> Policy:
|
|
197
|
+
"""The effective policy *layers* give, in order, after the built-in one.
|
|
198
|
+
|
|
199
|
+
*views* are the names of the views the node's kind declares, the only names
|
|
200
|
+
``always_include`` may hold.
|
|
201
|
+
"""
|
|
202
|
+
policy = Policy()
|
|
203
|
+
declared_views = set(views)
|
|
204
|
+
for layer in layers:
|
|
205
|
+
declares = bool(layer.policy) or bool(layer.defaults)
|
|
206
|
+
if declares:
|
|
207
|
+
policy.layers.append(layer.label)
|
|
208
|
+
_fold_paths(policy, layer)
|
|
209
|
+
_fold_limits(policy, layer, declared_views)
|
|
210
|
+
_fold_defaults(policy, layer)
|
|
211
|
+
return policy
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _fold_paths(policy: Policy, layer: Layer) -> None:
|
|
215
|
+
paths = cast("Mapping[str, Any]", layer.policy.get("paths") or {})
|
|
216
|
+
if "allow" in paths:
|
|
217
|
+
patterns = tuple(parse_step(text) for text in _strings(paths["allow"]))
|
|
218
|
+
for pattern in patterns:
|
|
219
|
+
for label, earlier in policy.allow:
|
|
220
|
+
if not any(p.overlaps(pattern) for p in earlier):
|
|
221
|
+
policy.notes.append(
|
|
222
|
+
_note(
|
|
223
|
+
layer.label,
|
|
224
|
+
"paths.allow",
|
|
225
|
+
pattern.text,
|
|
226
|
+
None,
|
|
227
|
+
label,
|
|
228
|
+
f"{layer.label} allows {pattern.text}; {label} does not, so it stays hidden",
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
break
|
|
232
|
+
policy.allow.append((layer.label, patterns))
|
|
233
|
+
for text in _strings(paths.get("deny")):
|
|
234
|
+
policy.deny.append((layer.label, parse_step(text)))
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _fold_limits(policy: Policy, layer: Layer, declared_views: set[str]) -> None:
|
|
238
|
+
declared = layer.policy
|
|
239
|
+
if "max_depth" in declared:
|
|
240
|
+
depth = int(declared["max_depth"])
|
|
241
|
+
if depth < policy.max_depth:
|
|
242
|
+
policy.max_depth, policy.max_depth_by = depth, layer.label
|
|
243
|
+
elif depth > policy.max_depth:
|
|
244
|
+
policy.notes.append(
|
|
245
|
+
_note(
|
|
246
|
+
layer.label,
|
|
247
|
+
"max_depth",
|
|
248
|
+
depth,
|
|
249
|
+
policy.max_depth,
|
|
250
|
+
policy.max_depth_by,
|
|
251
|
+
f"{layer.label} asks max_depth {depth}; {policy.max_depth_by} caps it at {policy.max_depth}",
|
|
252
|
+
)
|
|
253
|
+
)
|
|
254
|
+
if "detail" in declared:
|
|
255
|
+
levels = set(_strings(declared["detail"])) | {REFS}
|
|
256
|
+
for level in levels - set(policy.detail):
|
|
257
|
+
policy.notes.append(
|
|
258
|
+
_note(
|
|
259
|
+
layer.label,
|
|
260
|
+
"detail",
|
|
261
|
+
level,
|
|
262
|
+
None,
|
|
263
|
+
policy.detail_by.get(level, BUILTIN),
|
|
264
|
+
f"{layer.label} allows detail {level!r}; "
|
|
265
|
+
f"{policy.detail_by.get(level, BUILTIN)} does not",
|
|
266
|
+
)
|
|
267
|
+
)
|
|
268
|
+
for level in set(policy.detail) - levels:
|
|
269
|
+
policy.detail_by[level] = layer.label
|
|
270
|
+
policy.detail = tuple(
|
|
271
|
+
level for level in DETAIL_LEVELS if level in policy.detail and level in levels
|
|
272
|
+
)
|
|
273
|
+
budget = cast("Mapping[str, Any]", declared.get("budget") or {})
|
|
274
|
+
if "max" in budget:
|
|
275
|
+
cap = int(budget["max"])
|
|
276
|
+
if cap < policy.budget_max:
|
|
277
|
+
policy.budget_max, policy.budget_by = cap, layer.label
|
|
278
|
+
elif cap > policy.budget_max:
|
|
279
|
+
policy.notes.append(
|
|
280
|
+
_note(
|
|
281
|
+
layer.label,
|
|
282
|
+
"budget.max",
|
|
283
|
+
cap,
|
|
284
|
+
policy.budget_max,
|
|
285
|
+
policy.budget_by,
|
|
286
|
+
f"{layer.label} asks budget max {cap}; {policy.budget_by} caps it at {policy.budget_max}",
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
if "inheritance" in declared:
|
|
290
|
+
if not declared["inheritance"]:
|
|
291
|
+
if policy.inheritance:
|
|
292
|
+
policy.inheritance, policy.inheritance_by = False, layer.label
|
|
293
|
+
elif not policy.inheritance:
|
|
294
|
+
policy.notes.append(
|
|
295
|
+
_note(
|
|
296
|
+
layer.label,
|
|
297
|
+
"inheritance",
|
|
298
|
+
True,
|
|
299
|
+
False,
|
|
300
|
+
policy.inheritance_by,
|
|
301
|
+
f"{layer.label} turns inheritance on; {policy.inheritance_by} turned it off",
|
|
302
|
+
)
|
|
303
|
+
)
|
|
304
|
+
if "always_include" in declared:
|
|
305
|
+
names = _strings(declared["always_include"])
|
|
306
|
+
for name in names:
|
|
307
|
+
if name not in declared_views:
|
|
308
|
+
policy.notes.append(
|
|
309
|
+
_note(
|
|
310
|
+
layer.label,
|
|
311
|
+
"always_include",
|
|
312
|
+
name,
|
|
313
|
+
None,
|
|
314
|
+
layer.label,
|
|
315
|
+
f"{layer.label} always includes {name!r}, which is not a declared view",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
elif policy.always is not None and name not in policy.always:
|
|
319
|
+
policy.notes.append(
|
|
320
|
+
_note(
|
|
321
|
+
layer.label,
|
|
322
|
+
"always_include",
|
|
323
|
+
name,
|
|
324
|
+
None,
|
|
325
|
+
policy.always_by,
|
|
326
|
+
f"view {name!r} is not always included by {policy.always_by}",
|
|
327
|
+
)
|
|
328
|
+
)
|
|
329
|
+
kept = tuple(n for n in names if n in declared_views)
|
|
330
|
+
policy.always = (
|
|
331
|
+
kept if policy.always is None else tuple(n for n in policy.always if n in kept)
|
|
332
|
+
)
|
|
333
|
+
policy.always_by = layer.label
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _fold_defaults(policy: Policy, layer: Layer) -> None:
|
|
337
|
+
defaults = layer.defaults
|
|
338
|
+
if "depth" in defaults:
|
|
339
|
+
policy.default_depth = int(defaults["depth"])
|
|
340
|
+
if "detail" in defaults:
|
|
341
|
+
policy.default_detail = str(defaults["detail"])
|
|
342
|
+
if "budget" in defaults:
|
|
343
|
+
policy.default_budget = int(defaults["budget"])
|
|
344
|
+
if "inherit" in defaults:
|
|
345
|
+
policy.default_inherit = bool(defaults["inherit"])
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""What a wing or kind declares in its ``context`` settings section: views and item hints.
|
|
2
|
+
|
|
3
|
+
The section's shape is the plugin's ``settings_schema``
|
|
4
|
+
(:data:`katalyst_plugin_context.schema.SETTINGS_SCHEMA`). Views declared by a wing apply to
|
|
5
|
+
every member kind, and a kind's view of the same name replaces the wing's. Item hints
|
|
6
|
+
(``title_field``, ``summary_fields``, ``priority``) are taken key by key, the kind's over
|
|
7
|
+
the wing's.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import Any, cast
|
|
15
|
+
|
|
16
|
+
from katalyst_plugin_context.domain.paths import Step, parse_path, path_text
|
|
17
|
+
|
|
18
|
+
__all__ = ["ItemHints", "View", "item_hints", "views_from"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True, slots=True)
|
|
22
|
+
class View:
|
|
23
|
+
"""A named set of paths a wing or kind declares."""
|
|
24
|
+
|
|
25
|
+
name: str
|
|
26
|
+
title: str
|
|
27
|
+
description: str
|
|
28
|
+
paths: tuple[tuple[Step, ...], ...]
|
|
29
|
+
inherit: bool = True
|
|
30
|
+
detail: str = "summary"
|
|
31
|
+
priority: int = 0
|
|
32
|
+
blocking_first: bool = False
|
|
33
|
+
declared_by: str = ""
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def path_texts(self) -> list[str]:
|
|
37
|
+
return [path_text(p) for p in self.paths]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True, slots=True)
|
|
41
|
+
class ItemHints:
|
|
42
|
+
"""How a kind is shown when it is a result item."""
|
|
43
|
+
|
|
44
|
+
title_field: str | None = None
|
|
45
|
+
summary_fields: tuple[str, ...] | None = None
|
|
46
|
+
priority: int = 0
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _section(settings: Mapping[str, Any] | None) -> Mapping[str, Any]:
|
|
50
|
+
return settings or {}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def views_from(
|
|
54
|
+
wing: Mapping[str, Any] | None,
|
|
55
|
+
kind: Mapping[str, Any] | None,
|
|
56
|
+
*,
|
|
57
|
+
wing_label: str,
|
|
58
|
+
kind_label: str,
|
|
59
|
+
) -> dict[str, View]:
|
|
60
|
+
"""The views a kind has: its wing's, then its own (replacing same-named ones)."""
|
|
61
|
+
views: dict[str, View] = {}
|
|
62
|
+
for label, settings in ((wing_label, _section(wing)), (kind_label, _section(kind))):
|
|
63
|
+
declared = cast("Mapping[str, Mapping[str, Any]]", settings.get("views") or {})
|
|
64
|
+
for name, view in declared.items():
|
|
65
|
+
views[name] = View(
|
|
66
|
+
name=name,
|
|
67
|
+
title=str(view.get("title") or name),
|
|
68
|
+
description=str(view.get("description") or ""),
|
|
69
|
+
paths=tuple(
|
|
70
|
+
parse_path(str(p)) for p in cast("list[object]", view.get("paths") or [])
|
|
71
|
+
),
|
|
72
|
+
inherit=bool(view.get("inherit", True)),
|
|
73
|
+
detail=str(view.get("detail") or "summary"),
|
|
74
|
+
priority=int(view.get("priority") or 0),
|
|
75
|
+
blocking_first=view.get("order") == "blocking-first",
|
|
76
|
+
declared_by=label,
|
|
77
|
+
)
|
|
78
|
+
return views
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def item_hints(wing: Mapping[str, Any] | None, kind: Mapping[str, Any] | None) -> ItemHints:
|
|
82
|
+
"""A kind's item hints: each key from the kind, else from its wing."""
|
|
83
|
+
merged = {**_section(wing), **_section(kind)}
|
|
84
|
+
summary = merged.get("summary_fields")
|
|
85
|
+
title = merged.get("title_field")
|
|
86
|
+
return ItemHints(
|
|
87
|
+
title_field=str(title) if title else None,
|
|
88
|
+
summary_fields=tuple(str(f) for f in cast("list[object]", summary))
|
|
89
|
+
if isinstance(summary, list)
|
|
90
|
+
else None,
|
|
91
|
+
priority=int(merged.get("priority") or 0),
|
|
92
|
+
)
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""``context-policy``: context settings that are well-formed but don't do what they say.
|
|
2
|
+
|
|
3
|
+
The plugin's schemas (checked by ``kata tax lint``'s plugin-settings pass) catch a malformed
|
|
4
|
+
section. This rule catches what a schema can't:
|
|
5
|
+
|
|
6
|
+
- error: an ``always_include`` naming a view the kind doesn't declare;
|
|
7
|
+
- error: a view path naming a relationship type no registered kind carries;
|
|
8
|
+
- warning: a layer that tries to widen what an earlier layer allows (wing → kind → node),
|
|
9
|
+
which has no effect. The message names the layer that limits it.
|
|
10
|
+
|
|
11
|
+
Definitions are checked once per repository the rule runs for; nodes only in the
|
|
12
|
+
repository being checked.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from typing import Any, cast
|
|
19
|
+
|
|
20
|
+
from katalyst_engine.schema.registry import SchemaRegistry
|
|
21
|
+
from katalyst_taxonomy.plugins.extensions import LintFinding, LintSubject
|
|
22
|
+
|
|
23
|
+
from katalyst_plugin_context.domain.paths import PathSyntaxError
|
|
24
|
+
from katalyst_plugin_context.domain.policy import Layer, fold
|
|
25
|
+
from katalyst_plugin_context.domain.settings import views_from
|
|
26
|
+
from katalyst_plugin_context.service import NAMESPACE, RegistrySchema
|
|
27
|
+
|
|
28
|
+
__all__ = ["ContextPolicyRule"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ContextPolicyRule:
|
|
32
|
+
"""Reports context settings that have no effect, or name what doesn't exist."""
|
|
33
|
+
|
|
34
|
+
name = "context-policy"
|
|
35
|
+
|
|
36
|
+
def __init__(self, registry: SchemaRegistry | None = None) -> None:
|
|
37
|
+
self._registry = registry
|
|
38
|
+
|
|
39
|
+
def _registry_or_default(self) -> SchemaRegistry | None:
|
|
40
|
+
if self._registry is not None:
|
|
41
|
+
return self._registry
|
|
42
|
+
from katalyst_taxonomy.engine.bridge import default_registry_or_none
|
|
43
|
+
|
|
44
|
+
return default_registry_or_none()
|
|
45
|
+
|
|
46
|
+
def check(self, subject: LintSubject) -> list[LintFinding]:
|
|
47
|
+
from katalyst_taxonomy.engine.accessors import get_extensions_map, get_source_path
|
|
48
|
+
from katalyst_taxonomy.engine.relationships import relationship_types_of
|
|
49
|
+
|
|
50
|
+
registry = self._registry_or_default()
|
|
51
|
+
if registry is None:
|
|
52
|
+
return []
|
|
53
|
+
schema = RegistrySchema(registry)
|
|
54
|
+
carried = {
|
|
55
|
+
rel for kind in registry.all_kinds() for rel in relationship_types_of(registry, kind)
|
|
56
|
+
}
|
|
57
|
+
findings: list[LintFinding] = []
|
|
58
|
+
|
|
59
|
+
def finding(message: str, severity: str, **detail: str) -> None:
|
|
60
|
+
findings.append(
|
|
61
|
+
LintFinding(rule=self.name, message=message, severity=severity, detail=detail) # pyright: ignore[reportArgumentType]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
for kind in registry.all_kinds():
|
|
65
|
+
wing_section, kind_section, wing = schema.sections(kind)
|
|
66
|
+
if wing_section is None and kind_section is None:
|
|
67
|
+
continue
|
|
68
|
+
try:
|
|
69
|
+
views = views_from(
|
|
70
|
+
wing_section, kind_section, wing_label=f"wing:{wing}", kind_label=f"kind:{kind}"
|
|
71
|
+
)
|
|
72
|
+
layers = [
|
|
73
|
+
_layer(label, section)
|
|
74
|
+
for label, section in (
|
|
75
|
+
(f"wing:{wing}", wing_section),
|
|
76
|
+
(f"kind:{kind}", kind_section),
|
|
77
|
+
)
|
|
78
|
+
if section is not None
|
|
79
|
+
]
|
|
80
|
+
policy = fold(layers, views=sorted(views))
|
|
81
|
+
except (PathSyntaxError, ValueError, TypeError) as exc:
|
|
82
|
+
finding(f"kind {kind!r}: context settings: {exc}", "error", kind=kind)
|
|
83
|
+
continue
|
|
84
|
+
for view in views.values():
|
|
85
|
+
for steps in view.paths:
|
|
86
|
+
for step in steps:
|
|
87
|
+
if step.rel != "*" and step.rel not in carried:
|
|
88
|
+
finding(
|
|
89
|
+
f"kind {kind!r}: view {view.name!r} ({view.declared_by}) follows "
|
|
90
|
+
f"{step.rel}, which no registered kind carries",
|
|
91
|
+
"error",
|
|
92
|
+
kind=kind,
|
|
93
|
+
view=view.name,
|
|
94
|
+
)
|
|
95
|
+
for note in policy.notes:
|
|
96
|
+
severity = (
|
|
97
|
+
"error"
|
|
98
|
+
if note.get("control") == "always_include"
|
|
99
|
+
and note.get("limitedBy") == note.get("source")
|
|
100
|
+
else "warning"
|
|
101
|
+
)
|
|
102
|
+
finding(f"kind {kind!r}: {note['text']}", severity, kind=kind)
|
|
103
|
+
|
|
104
|
+
root = subject.root.resolve()
|
|
105
|
+
for document in subject.taxonomy.documents:
|
|
106
|
+
own = get_extensions_map(document).get(NAMESPACE)
|
|
107
|
+
if not isinstance(own, Mapping):
|
|
108
|
+
continue
|
|
109
|
+
source = get_source_path(document)
|
|
110
|
+
if source is None or not source.resolve().is_relative_to(root):
|
|
111
|
+
continue
|
|
112
|
+
wing_section, kind_section, wing = schema.sections(document.kind)
|
|
113
|
+
try:
|
|
114
|
+
views = views_from(
|
|
115
|
+
wing_section,
|
|
116
|
+
kind_section,
|
|
117
|
+
wing_label=f"wing:{wing}",
|
|
118
|
+
kind_label=f"kind:{document.kind}",
|
|
119
|
+
)
|
|
120
|
+
layers = [
|
|
121
|
+
_layer(label, section)
|
|
122
|
+
for label, section in (
|
|
123
|
+
(f"wing:{wing}", wing_section),
|
|
124
|
+
(f"kind:{document.kind}", kind_section),
|
|
125
|
+
)
|
|
126
|
+
if section is not None
|
|
127
|
+
]
|
|
128
|
+
label = f"node:{document.fqn}"
|
|
129
|
+
layers.append(_layer(label, cast("Mapping[str, Any]", own)))
|
|
130
|
+
policy = fold(layers, views=sorted(views))
|
|
131
|
+
except (PathSyntaxError, ValueError, TypeError) as exc:
|
|
132
|
+
finding(
|
|
133
|
+
f"{document.fqn}: spec.extensions.{NAMESPACE}: {exc}",
|
|
134
|
+
"error",
|
|
135
|
+
node=document.fqn,
|
|
136
|
+
)
|
|
137
|
+
continue
|
|
138
|
+
for note in policy.notes:
|
|
139
|
+
if note.get("source") == label:
|
|
140
|
+
finding(f"{document.fqn}: {note['text']}", "warning", node=document.fqn)
|
|
141
|
+
return findings
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _layer(label: str, section: Mapping[str, Any]) -> Layer:
|
|
145
|
+
return Layer(
|
|
146
|
+
label=label,
|
|
147
|
+
policy=cast("Mapping[str, Any]", section.get("policy") or {}),
|
|
148
|
+
defaults=cast("Mapping[str, Any]", section.get("defaults") or {}),
|
|
149
|
+
)
|