arc-kernel 0.1.0__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.
arc/plugin_cli.py ADDED
@@ -0,0 +1,120 @@
1
+ """
2
+ arc.plugin_cli
3
+ -------------------
4
+ Lets a plugin contribute its own `arc <plugin-name> ...` subcommands —
5
+ `arc psqldb connect`, `arc psqldb status`, and so on — the same way
6
+ `register(kernel)` lets it contribute a capability. Kernel stays
7
+ domain-blind (§3.1): this file never knows "psqldb" or "redix" by name, it
8
+ just discovers and mounts whatever Typer app a plugin chooses to expose.
9
+
10
+ A plugin opts in via a SECOND, separate entry-point group in its own
11
+ pyproject.toml (distinct from `arc.plugins`, which is register() only):
12
+
13
+ [project.entry-points."arc.plugins.cli"]
14
+ psqldb = "psqldb.cli:app"
15
+
16
+ ...where the target is a `typer.Typer()` instance (or a zero-arg callable
17
+ returning one). It is mounted under the plugin's own name, so whatever
18
+ subcommands that Typer app defines become `arc psqldb <subcommand>`.
19
+
20
+ Scope rule: a plugin's CLI is mounted if it appears in the CURRENT
21
+ project's plugins.lock at all — regardless of enabled/disabled. Disabling
22
+ a plugin only affects arc.boot() (§3.6, runtime attachment); it must not
23
+ hide operational/debug tooling like `connect`/`status`, which people
24
+ reasonably want to use *while* a plugin is disabled (e.g. mid-migration).
25
+
26
+ Failure handling: a broken or misconfigured plugin CLI must never break
27
+ the base `arc` tool (`arc --help` has to keep working). Every failure mode
28
+ here is a skip-with-a-printed-warning, never a raised exception — mirrors
29
+ the advisory philosophy already used in arc.resolver / arc.kernel.advise.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ from importlib.metadata import entry_points as _installed_entry_points
35
+ import sys
36
+ from typing import Any
37
+ import typer
38
+ from rich.console import Console
39
+
40
+ from . import registry
41
+ from .runtime import find_project_root
42
+
43
+ PLUGIN_CLI_ENTRY_POINT_GROUP = "arc.plugins.cli"
44
+
45
+ # Top-level commands the kernel itself owns — a plugin can never claim these.
46
+ RESERVED_CLI_NAMES = frozenset({"init", "install", "build", "settings", "plugin", "doctor", "health"})
47
+
48
+ console = Console()
49
+
50
+
51
+ def mount_plugin_clis(app: typer.Typer) -> None:
52
+ """
53
+ Best-effort: mount every project plugin's CLI Typer app onto `app`, if it
54
+ has one. A silent no-op outside a project (e.g. the global bootstrap
55
+ `arc` used only for `init`/`install` before any project exists) — there
56
+ is no plugins.lock to consult yet, so there is nothing to mount.
57
+ """
58
+ root = find_project_root() # None outside a project; never raises/exits here
59
+ if root is None:
60
+ return
61
+
62
+ lock_path = root / ".arc" / "plugins.lock"
63
+ if not lock_path.exists():
64
+ return
65
+
66
+ lock_doc = registry.load_lock(lock_path)
67
+ known_plugin_names = {name for name, _entry in registry.list_plugins(lock_doc)}
68
+ mounted: set[str] = set()
69
+
70
+ for ep in _installed_entry_points(group=PLUGIN_CLI_ENTRY_POINT_GROUP):
71
+ if ep.name not in known_plugin_names:
72
+ continue # not part of this project — same "stray, ignore" spirit as the resolver
73
+
74
+ if ep.name in RESERVED_CLI_NAMES:
75
+ console.print(
76
+ f"[yellow]Skipping CLI for plugin '{ep.name}': that name is "
77
+ f"reserved for a built-in `arc` command.[/yellow]"
78
+ )
79
+ continue
80
+ if ep.name in mounted:
81
+ console.print(
82
+ f"[yellow]Skipping duplicate CLI entry point for plugin "
83
+ f"'{ep.name}' — already mounted.[/yellow]"
84
+ )
85
+ continue
86
+
87
+ plugin_app = _load_plugin_cli_app(ep)
88
+ if plugin_app is None:
89
+ continue
90
+
91
+ app.add_typer(plugin_app, name=ep.name, help=f"Commands for the '{ep.name}' plugin.")
92
+ mounted.add(ep.name)
93
+
94
+
95
+ def _load_plugin_cli_app(ep: Any) -> typer.Typer | None:
96
+ try:
97
+ target = ep.load()
98
+ except Exception as exc:
99
+ console.print(
100
+ f"[yellow]Could not import CLI for plugin '{ep.name}': "
101
+ f"{exc.__class__.__name__}: {exc}\n"
102
+ f" This `arc` is running under: {sys.executable}\n"
103
+ f" If that's not this project's .venv/bin/python, you're likely running "
104
+ f"the global bootstrap `arc` instead of the project-local one (§3.8) — "
105
+ f"try the full path to this project's own arc, e.g. `./.venv/bin/arc`, "
106
+ f"or `hash -r` if you activated .venv after `arc` was already resolved "
107
+ f"in this shell.[/yellow]"
108
+ )
109
+ return None
110
+
111
+ plugin_app = target() if callable(target) and not isinstance(target, typer.Typer) else target
112
+
113
+ if not isinstance(plugin_app, typer.Typer):
114
+ console.print(
115
+ f"[yellow]Plugin '{ep.name}'s {PLUGIN_CLI_ENTRY_POINT_GROUP} entry "
116
+ f"point must resolve to a typer.Typer app (or a zero-arg callable "
117
+ f"returning one) — got {type(plugin_app).__name__}. Skipped.[/yellow]"
118
+ )
119
+ return None
120
+ return plugin_app
arc/registry.py ADDED
@@ -0,0 +1,173 @@
1
+ """
2
+ arc.registry
3
+ -------------------
4
+ Plugin manifest discovery and the `.arc/plugins.lock` file.
5
+
6
+ Every plugin directory under `plugins/<name>/` carries a `plugin.toml`
7
+ manifest:
8
+
9
+ [plugin]
10
+ name = "psqldb"
11
+ version = "0.1.0"
12
+ capability = "psqldb" # namespace it exports as arc.<capability>
13
+ requires = [] # other capability names this plugin needs
14
+ optional_requires = [] # capability names used IF present, never required
15
+
16
+ [dependencies]
17
+ asyncpg = ">=0.29"
18
+
19
+ `plugins.lock` is the kernel's source of truth for "what did `arc build`
20
+ resolve, and which plugins are currently enabled". It is distinct from
21
+ "physically present in plugins/" — a plugin can be on disk and disabled,
22
+ which means arc.boot() will not call its register() function and its
23
+ capability namespace is never attached to `arc`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ import tomlkit
32
+
33
+
34
+ class RegistryError(RuntimeError):
35
+ pass
36
+
37
+
38
+ @dataclass
39
+ class PluginManifest:
40
+ name: str
41
+ version: str
42
+ capability: str
43
+ requires: list[str] = field(default_factory=list)
44
+ optional_requires: list[str] = field(default_factory=list)
45
+ source_dir: Path | None = None
46
+
47
+
48
+ def _read_manifest(plugin_toml: Path) -> PluginManifest:
49
+ doc = tomlkit.parse(plugin_toml.read_text())
50
+ plugin_section = doc.get("plugin")
51
+ if not plugin_section:
52
+ raise RegistryError(f"{plugin_toml} is missing a [plugin] section.")
53
+
54
+ name = plugin_section.get("name")
55
+ if not name:
56
+ raise RegistryError(f"{plugin_toml} [plugin] section is missing 'name'.")
57
+
58
+ return PluginManifest(
59
+ name=name,
60
+ version=plugin_section.get("version", "0.0.0"),
61
+ capability=plugin_section.get("capability", name),
62
+ requires=list(plugin_section.get("requires", [])),
63
+ optional_requires=list(plugin_section.get("optional_requires", [])),
64
+ source_dir=plugin_toml.parent,
65
+ )
66
+
67
+
68
+ def read_manifest(plugin_toml: Path) -> PluginManifest:
69
+ """Public entry point for reading a single plugin.toml (e.g. right after a clone)."""
70
+ return _read_manifest(plugin_toml)
71
+
72
+
73
+ def discover_plugins(plugins_dir: Path, only: str | None = None) -> list[PluginManifest]:
74
+ """
75
+ Scan plugins_dir/*/plugin.toml. If `only` is given, restrict to that
76
+ single plugin directory (used by `arc build -p <name>`).
77
+ """
78
+ if not plugins_dir.exists():
79
+ raise RegistryError(f"Plugins directory not found: {plugins_dir}")
80
+
81
+ manifests: list[PluginManifest] = []
82
+ for entry in sorted(plugins_dir.iterdir()):
83
+ if not entry.is_dir():
84
+ continue
85
+ if only is not None and entry.name != only:
86
+ continue
87
+ manifest_path = entry / "plugin.toml"
88
+ if not manifest_path.exists():
89
+ continue
90
+ manifests.append(_read_manifest(manifest_path))
91
+
92
+ if only is not None and not manifests:
93
+ raise RegistryError(
94
+ f"No plugin named '{only}' found under {plugins_dir} "
95
+ f"(expected {plugins_dir / only / 'plugin.toml'})."
96
+ )
97
+ return manifests
98
+
99
+
100
+ def validate_requires(manifests: list[PluginManifest]) -> list[str]:
101
+ """
102
+ Returns a list of human-readable warnings for any hard `requires` that
103
+ isn't satisfied by the given set of manifests. Does not raise — this is
104
+ advisory at build time; arc.boot() is what enforces it at runtime.
105
+ """
106
+ available = {m.capability for m in manifests}
107
+ warnings = []
108
+ for m in manifests:
109
+ for req in m.requires:
110
+ if req not in available:
111
+ warnings.append(
112
+ f"Plugin '{m.name}' requires capability '{req}', "
113
+ f"which is not among the plugins being built."
114
+ )
115
+ return warnings
116
+
117
+
118
+ # ---------------------------------------------------------------------- #
119
+ # plugins.lock
120
+ # ---------------------------------------------------------------------- #
121
+
122
+ def load_lock(lock_path: Path) -> tomlkit.TOMLDocument:
123
+ if not lock_path.exists():
124
+ doc = tomlkit.document()
125
+ doc["plugins"] = tomlkit.table()
126
+ return doc
127
+ return tomlkit.parse(lock_path.read_text())
128
+
129
+
130
+ def save_lock(lock_path: Path, doc: tomlkit.TOMLDocument) -> None:
131
+ lock_path.write_text(tomlkit.dumps(doc))
132
+
133
+
134
+ def merge_manifests_into_lock(
135
+ lock_doc: tomlkit.TOMLDocument, manifests: list[PluginManifest]
136
+ ) -> tomlkit.TOMLDocument:
137
+ """
138
+ Update lock entries for the given manifests. Preserves the existing
139
+ `enabled` flag for plugins already in the lock; defaults new plugins
140
+ to enabled=true. Plugins not in `manifests` are left untouched (this
141
+ lets `-p` scoped builds update one entry without disturbing the rest).
142
+ """
143
+ plugins_table = lock_doc.setdefault("plugins", tomlkit.table())
144
+
145
+ for m in manifests:
146
+ existing = plugins_table.get(m.name)
147
+ enabled = existing.get("enabled", True) if existing else True
148
+
149
+ entry = tomlkit.table()
150
+ entry["version"] = m.version
151
+ entry["capability"] = m.capability
152
+ entry["requires"] = m.requires
153
+ entry["optional_requires"] = m.optional_requires
154
+ entry["enabled"] = enabled
155
+ plugins_table[m.name] = entry
156
+
157
+ return lock_doc
158
+
159
+
160
+ def set_enabled(lock_doc: tomlkit.TOMLDocument, name: str, enabled: bool) -> None:
161
+ plugins_table = lock_doc.get("plugins")
162
+ if not plugins_table or name not in plugins_table:
163
+ available = list(plugins_table.keys()) if plugins_table else []
164
+ raise RegistryError(
165
+ f"Plugin '{name}' is not in plugins.lock. "
166
+ f"Run `arc build` first. Known plugins: {available or 'none'}"
167
+ )
168
+ plugins_table[name]["enabled"] = enabled
169
+
170
+
171
+ def list_plugins(lock_doc: tomlkit.TOMLDocument) -> list[tuple[str, dict]]:
172
+ plugins_table = lock_doc.get("plugins", {})
173
+ return [(name, dict(entry)) for name, entry in plugins_table.items()]
arc/resolver.py ADDED
@@ -0,0 +1,347 @@
1
+ """
2
+ arc.resolver
3
+ -------------------
4
+ Pure, side-effect-free boot planning: turn `.arc/plugins.lock` plus the
5
+ installed `arc.plugins` entry points into an ordered, validated BootPlan.
6
+
7
+ Shared by two consumers with deliberately different natures:
8
+
9
+ * arc.runtime.boot() — resolves a plan, then EXECUTES it,
10
+ * `arc doctor` (CLI) — resolves a plan, then only PRINTS it.
11
+
12
+ To keep the doctor path a true dry run, this module never imports plugin
13
+ code: entry points are matched by name only, never `.load()`ed here.
14
+
15
+ Resolution rules (Architecture §3.1 / §3.3 / §3.6):
16
+ * plugins.lock is the source of truth for what is enabled — an entry point
17
+ installed in the venv but absent from the lock is skipped with a warning
18
+ (run `arc build`), never silently loaded;
19
+ * an ENABLED lock entry with no matching entry point is a hard failure;
20
+ * duplicate capability names among enabled plugins → hard failure;
21
+ * missing hard `requires` → hard failure, with a targeted hint when the
22
+ provider exists but is disabled;
23
+ * `optional_requires` that are absent are simply fine; when present they
24
+ influence load order (dependency first) unless that would create a
25
+ cycle, in which case the optional edge is dropped with a warning;
26
+ * load order is deterministic: topological, alphabetical tie-break.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import heapq
32
+ from collections import deque
33
+ from dataclasses import dataclass, field, replace
34
+ from importlib.metadata import EntryPoint
35
+ from importlib.metadata import entry_points as _installed_entry_points
36
+ from pathlib import Path
37
+ from typing import Any, Iterable
38
+
39
+ import tomlkit
40
+
41
+ from .kernel import KernelError, capability_name_problem
42
+ from .registry import load_lock
43
+
44
+ ENTRY_POINT_GROUP = "arc.plugins"
45
+
46
+
47
+ class ResolutionError(KernelError):
48
+ """Boot planning failed — arc.boot() would not be able to start."""
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class PluginSpec:
53
+ """One plugin as declared by plugins.lock, optionally matched to an entry point."""
54
+ name: str
55
+ capability: str
56
+ version: str
57
+ requires: tuple[str, ...]
58
+ optional_requires: tuple[str, ...]
59
+ enabled: bool
60
+ # importlib.metadata.EntryPoint in production; tests inject duck-typed
61
+ # fakes exposing .name / .value / .load().
62
+ entry_point: EntryPoint | Any | None = None
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class SkippedPlugin:
67
+ name: str
68
+ reason: str
69
+ capability: str | None = None
70
+
71
+
72
+ @dataclass
73
+ class BootPlan:
74
+ """Everything arc.boot() would do, computed without doing any of it."""
75
+ project_root: Path | None
76
+ load_order: list[PluginSpec]
77
+ skipped: list[SkippedPlugin] = field(default_factory=list)
78
+ warnings: list[str] = field(default_factory=list)
79
+
80
+ def to_dict(self) -> dict:
81
+ return {
82
+ "ok": True,
83
+ "project_root": str(self.project_root) if self.project_root else None,
84
+ "load_order": [
85
+ {
86
+ "position": i,
87
+ "name": s.name,
88
+ "capability": s.capability,
89
+ "version": s.version,
90
+ "requires": list(s.requires),
91
+ "optional_requires": list(s.optional_requires),
92
+ "entry_point": getattr(s.entry_point, "value", None),
93
+ }
94
+ for i, s in enumerate(self.load_order, start=1)
95
+ ],
96
+ "skipped": [
97
+ {"plugin": sk.name, "capability": sk.capability, "reason": sk.reason}
98
+ for sk in self.skipped
99
+ ],
100
+ "warnings": list(self.warnings),
101
+ }
102
+
103
+
104
+ # --------------------------------------------------------------------------- #
105
+ # Inputs
106
+ # --------------------------------------------------------------------------- #
107
+ def discover_entry_points() -> tuple[EntryPoint, ...]:
108
+ """All installed entry points in the `arc.plugins` group. Nothing is imported."""
109
+ return tuple(_installed_entry_points(group=ENTRY_POINT_GROUP))
110
+
111
+
112
+ def specs_from_lock(lock_doc: tomlkit.TOMLDocument) -> list[PluginSpec]:
113
+ plugins_table = lock_doc.get("plugins", {}) or {}
114
+ specs: list[PluginSpec] = []
115
+ for name, entry in plugins_table.items():
116
+ specs.append(
117
+ PluginSpec(
118
+ name=str(name),
119
+ capability=str(entry.get("capability", name)),
120
+ version=str(entry.get("version", "0.0.0")),
121
+ requires=tuple(str(r) for r in (entry.get("requires", []) or [])),
122
+ optional_requires=tuple(
123
+ str(r) for r in (entry.get("optional_requires", []) or [])
124
+ ),
125
+ enabled=bool(entry.get("enabled", True)),
126
+ )
127
+ )
128
+ return sorted(specs, key=lambda s: s.name)
129
+
130
+
131
+ # --------------------------------------------------------------------------- #
132
+ # Resolution
133
+ # --------------------------------------------------------------------------- #
134
+ def resolve(
135
+ project_root: Path | None = None,
136
+ *,
137
+ lock_doc: tomlkit.TOMLDocument | None = None,
138
+ entry_points: Iterable[Any] | None = None,
139
+ ) -> BootPlan:
140
+ """
141
+ Compute a BootPlan. Raises ResolutionError for anything that would be a
142
+ hard boot failure; everything advisory lands in BootPlan.warnings.
143
+ """
144
+ if lock_doc is None:
145
+ if project_root is None:
146
+ lock_doc = tomlkit.document()
147
+ else:
148
+ lock_doc = load_lock(Path(project_root) / ".arc" / "plugins.lock")
149
+ eps = tuple(entry_points) if entry_points is not None else discover_entry_points()
150
+
151
+ warnings_: list[str] = []
152
+ skipped: list[SkippedPlugin] = []
153
+ specs = specs_from_lock(lock_doc)
154
+
155
+ # -- entry points: unique names, indexed --------------------------------
156
+ eps_by_name: dict[str, Any] = {}
157
+ for ep in eps:
158
+ if ep.name in eps_by_name:
159
+ raise ResolutionError(
160
+ f"two installed packages both provide an '{ENTRY_POINT_GROUP}' "
161
+ f"entry point named '{ep.name}' "
162
+ f"('{getattr(eps_by_name[ep.name], 'value', '?')}' and "
163
+ f"'{getattr(ep, 'value', '?')}') — plugin names must be unique; "
164
+ f"uninstall or rename one."
165
+ )
166
+ eps_by_name[ep.name] = ep
167
+
168
+ # -- entry points installed but unknown to the lock: never loaded -------
169
+ known_names = {s.name for s in specs}
170
+ for stray in sorted(set(eps_by_name) - known_names):
171
+ reason = "installed in the environment but not present in plugins.lock"
172
+ skipped.append(SkippedPlugin(name=stray, reason=reason))
173
+ warnings_.append(
174
+ f"entry point '{stray}' ({ENTRY_POINT_GROUP}) is {reason} — it will "
175
+ f"NOT be loaded. If it is a real plugin of this project, run "
176
+ f"`arc build` to register it; otherwise remove it from the venv."
177
+ )
178
+
179
+ # -- lock entries: split enabled/disabled, demand entry points ----------
180
+ enabled: list[PluginSpec] = []
181
+ for spec in specs:
182
+ if not spec.enabled:
183
+ skipped.append(
184
+ SkippedPlugin(
185
+ name=spec.name,
186
+ capability=spec.capability,
187
+ reason="disabled in plugins.lock "
188
+ f"(`arc plugin enable {spec.name}` to load it)",
189
+ )
190
+ )
191
+ continue
192
+ ep = eps_by_name.get(spec.name)
193
+ if ep is None:
194
+ raise ResolutionError(
195
+ f"plugin '{spec.name}' is enabled in plugins.lock, but no "
196
+ f"'{ENTRY_POINT_GROUP}' entry point named '{spec.name}' is "
197
+ f"installed in this environment. The plugin's pyproject.toml "
198
+ f"must declare\n"
199
+ f' [project.entry-points."{ENTRY_POINT_GROUP}"]\n'
200
+ f' {spec.name} = "<package>:register"\n'
201
+ f"then run `arc build` (or `uv sync --all-packages`) to install it."
202
+ )
203
+ enabled.append(replace(spec, entry_point=ep))
204
+
205
+ # -- capability names: valid, non-reserved, unique among enabled --------
206
+ by_capability: dict[str, PluginSpec] = {}
207
+ for spec in enabled:
208
+ problem = capability_name_problem(spec.capability)
209
+ if problem:
210
+ raise ResolutionError(
211
+ f"plugin '{spec.name}' declares an invalid capability: {problem}."
212
+ )
213
+ clash = by_capability.get(spec.capability)
214
+ if clash is not None:
215
+ raise ResolutionError(
216
+ f"plugins '{clash.name}' and '{spec.name}' both export capability "
217
+ f"'{spec.capability}' — capability names must be unique (§3.1); "
218
+ f"rename one, or `arc plugin disable` one of them."
219
+ )
220
+ by_capability[spec.capability] = spec
221
+
222
+ # -- hard requires must be satisfiable by the ENABLED set ---------------
223
+ disabled_by_capability = {s.capability: s for s in specs if not s.enabled}
224
+ for spec in enabled:
225
+ for req in spec.requires:
226
+ if req in by_capability:
227
+ continue
228
+ provider = disabled_by_capability.get(req)
229
+ if provider is not None:
230
+ raise ResolutionError(
231
+ f"plugin '{spec.name}' requires capability '{req}', which is "
232
+ f"provided by plugin '{provider.name}' — currently DISABLED. "
233
+ f"Run `arc plugin enable {provider.name}`, or disable "
234
+ f"'{spec.name}' as well."
235
+ )
236
+ raise ResolutionError(
237
+ f"plugin '{spec.name}' requires capability '{req}', but no "
238
+ f"enabled plugin provides it. Install one "
239
+ f"(`arc install <git-url>`) or disable '{spec.name}'."
240
+ )
241
+
242
+ load_order = _topological_order(enabled, warnings_)
243
+ return BootPlan(
244
+ project_root=project_root,
245
+ load_order=load_order,
246
+ skipped=skipped,
247
+ warnings=warnings_,
248
+ )
249
+
250
+
251
+ # --------------------------------------------------------------------------- #
252
+ # Ordering
253
+ # --------------------------------------------------------------------------- #
254
+ def _kahn_unresolved(adjacency: dict[str, set[str]]) -> set[str]:
255
+ """Nodes left over after a Kahn pass — non-empty iff the graph has a cycle."""
256
+ indegree = {node: 0 for node in adjacency}
257
+ for outs in adjacency.values():
258
+ for out in outs:
259
+ indegree[out] += 1
260
+ queue = deque(node for node, deg in indegree.items() if deg == 0)
261
+ while queue:
262
+ node = queue.popleft()
263
+ for out in adjacency[node]:
264
+ indegree[out] -= 1
265
+ if indegree[out] == 0:
266
+ queue.append(out)
267
+ return {node for node, deg in indegree.items() if deg > 0}
268
+
269
+
270
+ def _topological_order(
271
+ specs: list[PluginSpec], warnings_: list[str]
272
+ ) -> list[PluginSpec]:
273
+ """
274
+ Order specs so every hard dependency registers before its dependents.
275
+ Edges run dependency -> dependent. Hard edges are mandatory (a cycle is a
276
+ hard failure); optional edges are honored when possible and dropped with a
277
+ warning when they would close a cycle. Ties break alphabetically by
278
+ capability so load order is fully deterministic.
279
+ """
280
+ by_capability = {s.capability: s for s in specs}
281
+ adjacency: dict[str, set[str]] = {cap: set() for cap in by_capability}
282
+
283
+ for spec in specs:
284
+ for req in spec.requires: # presence already validated by resolve()
285
+ adjacency[req].add(spec.capability)
286
+
287
+ unresolved = _kahn_unresolved(adjacency)
288
+ if unresolved:
289
+ detail = "; ".join(
290
+ f"'{by_capability[cap].name}' requires {list(by_capability[cap].requires)}"
291
+ for cap in sorted(unresolved)
292
+ )
293
+ raise ResolutionError(
294
+ f"hard dependency cycle among plugins: {detail} — cycles in "
295
+ f"`requires` cannot be booted; make one side an optional_requires "
296
+ f"or break the dependency."
297
+ )
298
+
299
+ def reaches(src: str, dst: str) -> bool:
300
+ stack, seen = [src], {src}
301
+ while stack:
302
+ node = stack.pop()
303
+ if node == dst:
304
+ return True
305
+ for out in adjacency[node]:
306
+ if out not in seen:
307
+ seen.add(out)
308
+ stack.append(out)
309
+ return False
310
+
311
+ for spec in sorted(specs, key=lambda s: s.capability):
312
+ for opt in sorted(set(spec.optional_requires)):
313
+ if opt not in by_capability:
314
+ continue # absent optional — allowed, plugin handles it (§3.3)
315
+ if spec.capability in adjacency[opt]:
316
+ continue # already an edge (also a hard require)
317
+ if reaches(spec.capability, opt):
318
+ warnings_.append(
319
+ f"optional ordering skipped: '{spec.name}' optionally "
320
+ f"requires '{opt}', but loading '{opt}' first would create a "
321
+ f"dependency cycle. '{by_capability[opt].name}' will register "
322
+ f"AFTER '{spec.name}', so '{spec.name}' must look it up "
323
+ f"lazily (post-boot) rather than at register() time."
324
+ )
325
+ continue
326
+ adjacency[opt].add(spec.capability)
327
+
328
+ indegree = {cap: 0 for cap in by_capability}
329
+ for outs in adjacency.values():
330
+ for out in outs:
331
+ indegree[out] += 1
332
+ heap = [cap for cap, deg in indegree.items() if deg == 0]
333
+ heapq.heapify(heap)
334
+ order: list[PluginSpec] = []
335
+ while heap:
336
+ cap = heapq.heappop(heap)
337
+ order.append(by_capability[cap])
338
+ for out in sorted(adjacency[cap]):
339
+ indegree[out] -= 1
340
+ if indegree[out] == 0:
341
+ heapq.heappush(heap, out)
342
+
343
+ if len(order) != len(specs): # unreachable: optional edges never close cycles
344
+ raise ResolutionError(
345
+ "internal resolver error: ordering did not cover every plugin."
346
+ )
347
+ return order