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.
@@ -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
+ )