diff --git a/docs/architecture.md b/docs/architecture.md index 9875905..3e8cc31 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,6 +67,73 @@ Azure, Bedrock, Perplexity and Ollama transports retain their existing contracts The Go UI splits its model, startup, events, update/input handling, navigation, settings, layout and views inside the same package and Bubble Tea model. +## V3 subsystem ownership contract + +This contract covers [V3 foundation issue #227](https://github.com/EvanProgramming/OpenKyrozen/issues/227), +requirements R236–R249. Each row names one package owner, not a new class to scaffold. +The existing components are the migration starting points on V2; the linked issues +implement the V3 behavior. In particular, native AgentEngine execution, separate +Working/Strategic Plans, typed provider responses and typed runtime events are +future work, not capabilities delivered by this ownership change. + +| Requirement / subsystem | Package owner and existing components | Responsibility and authority boundary | V3 implementation | +| --- | --- | --- | --- | +| R236 AgentEngine | `agent`: `runtime.AgentRuntime`, turn phases in `preparation`, `action_rounds`, `completion` | Sole foreground model/tool loop coordinator; calls provider, planning and executor contracts. Does not translate provider wire formats or bypass authorization. | [#238](https://github.com/EvanProgramming/OpenKyrozen/issues/238) | +| R237 PlanningService | `tasks`: `engine.TaskManager`, `models`, `ports.TaskStore`; `agent.planner` currently bridges textual plans | Owns Working Plan and Strategic Plan transitions, validation and completion evidence. Working Plans are mutable; Strategic Plans require user approval and material revisions require reapproval. Storage persists transitions; the engine requests them. | [#241](https://github.com/EvanProgramming/OpenKyrozen/issues/241), [#242](https://github.com/EvanProgramming/OpenKyrozen/issues/242), [#245](https://github.com/EvanProgramming/OpenKyrozen/issues/245), [#246](https://github.com/EvanProgramming/OpenKyrozen/issues/246) | +| R238 ToolRegistry | `tools`: `manifest.ToolRegistry`, `ToolManifest`, `registry`, `adapters.ToolAdapters` | Owns definitions, schemas, metadata and executor registration. Supplies the catalog to providers and execution; registration or discovery never grants permission. Extend this registry rather than adding a competing catalog. | [#232](https://github.com/EvanProgramming/OpenKyrozen/issues/232) | +| R239 ToolExecutor | `agent`: `executor`, `types.ExecutionReceipt` | Owns authorized invocation and receipts. Uses registry definitions, security decisions and bound tool implementations; denied or failed calls cannot become successful effects. Does not own permission policy or plan transitions. | [#233](https://github.com/EvanProgramming/OpenKyrozen/issues/233), [#236](https://github.com/EvanProgramming/OpenKyrozen/issues/236) | +| R240 SecurityPolicy | `security`: `capabilities`, `tool_policy`, `permissions`, `permission_gate` | Owns capability and approval authorization. Effective permissions stay within configured, session and delegated bounds; model output, routing advice and catalog entries cannot approve a call. The executor enforces the decision. | [#261](https://github.com/EvanProgramming/OpenKyrozen/issues/261) | +| R241 ProviderAdapter | `providers`: `base.LLMProvider`, provider transport modules, `factory`, `calls` | Owns native request/response translation, streaming and provider errors. Receives conversation/tool contracts; returns model results and usage. Never executes model-requested tools or mutates plans. | [#228](https://github.com/EvanProgramming/OpenKyrozen/issues/228), [#229](https://github.com/EvanProgramming/OpenKyrozen/issues/229), [#230](https://github.com/EvanProgramming/OpenKyrozen/issues/230), [#231](https://github.com/EvanProgramming/OpenKyrozen/issues/231) | +| R242 ContextManager | `agent`: `models.AgentSession`, `context`, `compaction.ContextState` | Owns active conversation representation and compaction. Uses provider token/usage information and plan snapshots; preserves call/result identity and active plans. Does not promote memories or independently change plan state. | [#247](https://github.com/EvanProgramming/OpenKyrozen/issues/247), [#248](https://github.com/EvanProgramming/OpenKyrozen/issues/248) | +| R243 Task/Plan Store | `persistence`: `tasks.TasksRepository`, `events.EventsRepository`, `store.EventStore`, `database` | Owns durable task/plan records and revision events. Implements feature-owned storage ports and atomic writes/migrations. PlanningService owns transition semantics; storage cannot infer user approval or completion from prose. | [#244](https://github.com/EvanProgramming/OpenKyrozen/issues/244), [#264](https://github.com/EvanProgramming/OpenKyrozen/issues/264) | +| R244 RoutingService | `routing`: `router`, `policy`, `models`, `choices` | Owns model/provider/reasoning/resource selection. Reads provider capabilities, complexity and resource bounds; returns a routing decision. Cannot grant capabilities, invoke tools or mark tasks complete. | [#251](https://github.com/EvanProgramming/OpenKyrozen/issues/251) | +| R245 SubagentManager | `agent`: `subagents.SubAgentManager`, `delegation`, `delegation_runtime` | Owns child orchestration, budgets, isolation and lifecycle. Uses the shared engine/executor and security bounds; child authority can only narrow parent authority. Results require evidence/review before parent acceptance. | [#254](https://github.com/EvanProgramming/OpenKyrozen/issues/254), [#258](https://github.com/EvanProgramming/OpenKyrozen/issues/258) | +| R246 MemoryService | `memory`: `service.MemoryBank`, `retrieval`, `claims`, `ports` | Owns retrieval/storage semantics and scoped claims. Uses injected SQLite/vector ports; Chroma remains derived. Retrieved content is evidence/context, never execution authority or an approved plan. | [#252](https://github.com/EvanProgramming/OpenKyrozen/issues/252) | +| R247 LearningService | `learning`: `engine.LearningEngine`, `dispatcher`, evidence/evaluation/promotion modules | Owns post-turn evidence, evaluation and promotion. Uses memory, skill and storage contracts; promotion requires evidence. Learning cannot change foreground execution authority or approve its own tool calls. | [#253](https://github.com/EvanProgramming/OpenKyrozen/issues/253) | +| R248 Jev/System One | `routing`: `system_one`, `decision_assist`, `transport`, `kev` | Advisory only: proposes routing/ranking/review choices within RoutingService. Never owns execution authority, permission approval, plan acceptance or completion. Remote/local transports stay behind injected boundaries. | [#251](https://github.com/EvanProgramming/OpenKyrozen/issues/251) | +| R249 Interfaces | `interfaces`: CLI, TUI JSONL, web REST/SSE and MCP adapters; `tui` renders the Go client | Consume/render runtime events and submit user input, approvals and cancellation. Do not implement model/tool loops, plan transitions or completion rules. Typed event migration replaces existing surface shapes through explicit adapters. | [#255](https://github.com/EvanProgramming/OpenKyrozen/issues/255), [#256](https://github.com/EvanProgramming/OpenKyrozen/issues/256) | + +### Dependency direction and enforcement + +`app.bootstrap` is the composition root: it constructs concrete adapters and injects +feature services, callbacks and ports. Existing method binding is retained during +migration; it is not a license to move semantic decisions into presentation code. +Interfaces call the runtime/service contracts; feature code emits events through +`agent.ports.EventSink` and requests approval through `Approval`, rather than importing +interface implementations. Concrete provider, tool, SQLite, vector and routing +transports remain behind the injected boundaries. + +Permitted feature dependencies follow the responsibilities above: `agent` coordinates +`tasks`, `tools` catalog/model contracts, `security`, provider contracts, `routing`, +`memory` and `learning`; `tasks` owns its models/storage ports; `memory` owns memory +models/storage/vector ports; `learning` consumes memory, skill and storage contracts. +Shared persistence models are allowed; concrete repositories belong at composition. +`tools` uses security/contracts and adapter-specific libraries inside adapter modules; +`providers` uses provider configuration/contracts and SDKs inside transport modules; +`routing` reads provider metadata and security/resource bounds; `persistence` implements +storage without importing interfaces or coordinating agent execution. New dependencies +must preserve these authority limits and avoid import-time cycles. + +`scripts/check_architecture.py` enforces the existing static import policy across +`agent`, `tasks`, `memory` and `learning`: no legacy root implementation imports, no +interface package-root or descendant imports, and no listed concrete adapters or +external adapter libraries. Absolute and relative imports are checked, including +function-local imports and concrete adapters reached through statically named import +re-exports (including aliased and chained re-exports). Existing entry adapters (`memory.vector`, `learning.worker` +and `learning.benchmark`) retain their explicit exemption from core adapter bans. +Import-time statement bodies (including branches, try/else/finally, with, loops, +match and class bodies) contribute cycle edges. Function bodies and type-only +`TYPE_CHECKING` / `typing.TYPE_CHECKING` branches do not; their runtime `else` +branches still do. `check(root=...)` accepts a temporary source root for +regressions; `check()` and the script entry point still check this repository. + +This is a static import guard, not an authority proof: it does not inspect dynamic +imports, dynamically assigned exports, injected callables or every semantic +dependency listed above. Runtime safety +and ownership tests remain necessary. Each linked implementation issue must extend +contract/authority tests as it migrates behavior; this issue does not claim the whole +V3 architecture is already implemented. + ## Ports and durable data The inbound chat contract is diff --git a/docs/index.md b/docs/index.md index ee2575d..378ba0f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -26,6 +26,7 @@ This index covers the current product guides, developer references, and dated en - [Development guide](development.md) and [contributor guide](../CONTRIBUTING.md) cover setup, layout, checks, and pull requests. - [Reports index](reports.md) explains the dates, scope, and limits of recorded audits, validation runs, and benchmarks. - [Modular refactor validation](modular-refactor-validation.md), [production audit](production-audit-2026-10-04.md), [shipping audit](production-shipping-audit-2026-10-04.md), and [self-learning audit](self-learning-audit-2026-10-03.md) preserve historical evidence. +- [V3 architecture ownership plan](superpowers/plans/2026-10-07-v3-issue-227.md) records the scoped implementation and verification for issue #227. - Machine-readable audit receipts and benchmark inputs remain beside their corresponding reports under `docs/` and `docs/benchmarks/`. The repository's [English overview](../README.md) links here. Technical guides are maintained in English; README translations provide localized entry points. diff --git a/docs/superpowers/plans/2026-10-07-v3-issue-227.md b/docs/superpowers/plans/2026-10-07-v3-issue-227.md new file mode 100644 index 0000000..809ba1b --- /dev/null +++ b/docs/superpowers/plans/2026-10-07-v3-issue-227.md @@ -0,0 +1,59 @@ +# V3 issue #227: subsystem ownership and architecture boundaries + +Issue: https://github.com/EvanProgramming/OpenKyrozen/issues/227 +Base: f8eeb4bb5bd0d35f315620cf597142a9962682b2 (main) +Branch: Evan/v3-227-architecture + +## Contract + +Complete R236–R249 by documenting one package owner, existing implementation, +permitted dependencies, and authority boundary for each subsystem. These are V3 +contracts; subsequent linked issues implement the new engine, typed events, +provider contracts and plan semantics. Do not create placeholder services or +claim those later implementations are complete. + +## Task 1: checker regression and fix + +1. Add temporary-package unittest fixtures for absolute/relative interface root + and descendant imports, allowed imports, concrete/external adapters, legacy + imports, runtime cycles and TYPE_CHECKING exclusions. +2. Demonstrate the interface-root bypass against the unchanged checker. +3. Add `check(root: Path = ROOT)` and use root-relative scanning/diagnostics. +4. Reject the exact interface namespace and descendants in the existing shared + guard. Keep existing check() callers and adapter/cycle rules working. +5. Run focused checker tests on Python 3.12 and 3.13 and the real package check. + +## Task 2: ownership contract + +Extend docs/architecture.md with all fourteen requirement IDs, package owners, +existing component pointers, implementation issue links and authority limits. +Document composition/injection, core-to-adapter rules and the limits of static +import checking. Preserve the distinction between current V2 behavior and V3 +contracts, including mutable Working Plans and approved Strategic Plans. + +## Task 3: verification and delivery + +Run focused architecture tests, make test, make check, make lint and make +docs-check using the supported dependency-equipped Python 3.12 environment. +Run checker fixtures with Python 3.13 as well. Capture failures and determine +whether they predate this diff; blocked validation is not a pass. +Obtain a fresh independent review of the whole diff and resolve material +findings. Commit only scoped files with GPG signing and verify the signature. +Push the issue branch, create and attach one PR with coverage/evidence and +compatibility/security notes. Check CI. Stop; do not merge or start another issue. + +## Review focus + +Absolute and relative import resolution, package __init__ cases, default callers, +fixture isolation, retained cycle/legacy/adapter checks, accurate current-versus- +planned ownership, all R236–R249 covered. No paid provider calls or runtime data. + +## Review follow-up: PR #269 + +Reproduce and fix all three verified Codex findings: qualified type-checking +guards, missing import-time compound bodies, and concrete named adapter re-exports. +Preserve runtime else branches, deferred function bodies and allowed contract +re-exports. Test aliased/chained re-exports and cycle termination without executing +application modules. Run fixtures on both supported Python versions and the full +browser suite/checks, obtain independent review, then push a signed follow-up +commit and resolve the three original review threads with evidence. Do not merge. diff --git a/scripts/check_architecture.py b/scripts/check_architecture.py index 6d9eace..280f414 100644 --- a/scripts/check_architecture.py +++ b/scripts/check_architecture.py @@ -6,7 +6,6 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] -PACKAGE = ROOT / "openkyrozen" LEGACY_MODULES = frozenset({ "main", "server", "providers", "tools", "memory", "task_engine", "event_store", "learning_engine", "learning_worker", "interaction", "fast_mode", "system_one_policy", @@ -29,31 +28,75 @@ def _imports_at_import_time(tree): - """Include class method bindings; skip function bodies and type-only imports.""" - for node in tree.body: - if isinstance(node, (ast.Import, ast.ImportFrom)): - yield node - elif isinstance(node, ast.ClassDef): - yield from _imports_at_import_time(node) - elif isinstance(node, ast.If): - if isinstance(node.test, ast.Name) and node.test.id == "TYPE_CHECKING": - continue - yield from _imports_at_import_time(node) - for child in node.orelse: - if isinstance(child, (ast.Import, ast.ImportFrom)): - yield child - elif isinstance(node, ast.Try): - yield from _imports_at_import_time(node) - for handler in node.handlers: - yield from _imports_at_import_time(handler) + """Traverse initialization statements, excluding deferred/type-only bodies.""" + if isinstance(tree, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): + return + if isinstance(tree, (ast.Import, ast.ImportFrom)): + yield tree + return + if isinstance(tree, ast.If): + guard = tree.test + if ((isinstance(guard, ast.Name) and guard.id == "TYPE_CHECKING") + or (isinstance(guard, ast.Attribute) and guard.attr == "TYPE_CHECKING" + and isinstance(guard.value, ast.Name) and guard.value.id == "typing")): + for statement in tree.orelse: + yield from _imports_at_import_time(statement) + return + for child in ast.iter_child_nodes(tree): + yield from _imports_at_import_time(child) + + +def _imported_module(module, path, node): + """Resolve an ImportFrom namespace without importing application code.""" + if not node.level: + return node.module or "" + base = module if path.name == "__init__.py" else module.rsplit(".", 1)[0] + parts = base.split(".") + base = ".".join(parts[:len(parts) - node.level + 1]) + return base + ("." + node.module if node.module else "") + +def check(root: Path = ROOT) -> list[str]: + paths = {".".join(p.relative_to(root).with_suffix("").parts).removesuffix(".__init__"): p + for p in (root / "openkyrozen").rglob("*.py")} + trees = {module: ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + for module, path in paths.items()} + exports = {(module, alias.asname or alias.name): (_imported_module(module, paths[module], node), alias.name) + for module, tree in trees.items() for node in tree.body + if isinstance(node, ast.ImportFrom) for alias in node.names if alias.name != "*"} + + wildcard_exports = {} + for module, tree in trees.items(): + names = {name for owner, name in exports if owner == module and not name.startswith("_")} + for node in tree.body: + if (isinstance(node, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == "__all__" for target in node.targets)): + try: + value = ast.literal_eval(node.value) + except (ValueError, TypeError, SyntaxError): + continue + if isinstance(value, (list, tuple)) and all(isinstance(name, str) for name in value): + names = set(value) + wildcard_exports[module] = names + + def reexport_origins(module, name, seen=frozenset()): + """Follow named import re-exports, terminating on circular bindings.""" + key = (module, name) + if key in seen: + return + if name == "*": + for exported in wildcard_exports.get(module, ()): + yield from reexport_origins(module, exported, seen | {key}) + elif key in exports: + source, imported = exports[key] + yield source + if source + "." + imported in paths: + yield source + "." + imported + yield from reexport_origins(source, imported, seen | {key}) -def check() -> list[str]: - paths = {".".join(p.relative_to(ROOT).with_suffix("").parts).removesuffix(".__init__"): p - for p in PACKAGE.rglob("*.py")} errors, graph = [], {} for module, path in paths.items(): - tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path)) + tree = trees[module] graph[module] = set() for node in ast.walk(tree): if isinstance(node, ast.Import): @@ -64,7 +107,7 @@ def check() -> list[str]: continue for name in names: if name.split(".")[0] in LEGACY_MODULES: - errors.append(f"{path.relative_to(ROOT)}:{node.lineno}: legacy import {name}") + errors.append(f"{path.relative_to(root)}:{node.lineno}: legacy import {name}") import_time_nodes = set(_imports_at_import_time(tree)) for node in ast.walk(tree): if not isinstance(node, (ast.Import, ast.ImportFrom)): @@ -72,22 +115,22 @@ def check() -> list[str]: if isinstance(node, ast.Import): targets = [alias.name for alias in node.names] else: - base = module if path.name == "__init__.py" else module.rsplit(".", 1)[0] - if node.level: - parts = base.split(".") - base = ".".join(parts[:len(parts) - node.level + 1]) - target = base + ("." + node.module if node.module else "") - else: - target = node.module or "" + target = _imported_module(module, path, node) targets = [target] targets.extend(target + "." + alias.name for alias in node.names if target + "." + alias.name in paths) + policy_targets = [origin for alias in node.names + for origin in reexport_origins(target, alias.name)] + if isinstance(node, ast.Import): + policy_targets = [] for target in targets: if node in import_time_nodes and target in paths and target != module: graph[module].add(target) - if (module.split(".")[1] in CORE and module not in ENTRY_ADAPTERS - and (target.startswith("openkyrozen.interfaces.") or target in ADAPTERS or target.split(".")[0] in EXTERNAL_ADAPTERS)): - errors.append(f"{path.relative_to(ROOT)}:{node.lineno}: core imports adapter {target}") + for target in targets + policy_targets: + if (module.partition(".")[2].split(".")[0] in CORE and module not in ENTRY_ADAPTERS + and (target == "openkyrozen.interfaces" or target.startswith("openkyrozen.interfaces.") + or target in ADAPTERS or target.split(".")[0] in EXTERNAL_ADAPTERS)): + errors.append(f"{path.relative_to(root)}:{node.lineno}: core imports adapter {target}") visiting, complete = [], set() def visit(module): diff --git a/tests/test_architecture_checker.py b/tests/test_architecture_checker.py new file mode 100644 index 0000000..f8f8524 --- /dev/null +++ b/tests/test_architecture_checker.py @@ -0,0 +1,191 @@ +"""Exercise dependency policy against real temporary Python packages.""" +import tempfile +import unittest +from pathlib import Path + +from scripts import check_architecture + + +class ArchitectureCheckerTests(unittest.TestCase): + def check_sources(self, sources): + with tempfile.TemporaryDirectory() as directory: + root = Path(directory) + for name, source in sources.items(): + path = root / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(source, encoding="utf-8") + return check_architecture.check(root) + + def test_core_rejects_interface_root_and_descendant_imports(self): + for source in ( + "import openkyrozen.interfaces", + "import openkyrozen.interfaces as ui", + "from openkyrozen import interfaces", + "from openkyrozen.interfaces import render", + "import openkyrozen.interfaces.cli", + "from .. import interfaces", + "from ..interfaces import render", + "from ..interfaces import cli", + "def render():\n from .. import interfaces", + ): + for module in ("agent", "memory", "learning", "tasks"): + with self.subTest(source=source, module=module): + errors = self.check_sources({ + f"openkyrozen/{module}/probe.py": source, + "openkyrozen/interfaces/__init__.py": "", + "openkyrozen/interfaces/cli.py": "", + }) + self.assertTrue(any("core imports adapter openkyrozen.interfaces" in e + for e in errors), errors) + + def test_core_package_init_rejects_relative_interface_import(self): + errors = self.check_sources({ + "openkyrozen/agent/__init__.py": "from .. import interfaces", + "openkyrozen/interfaces/__init__.py": "", + }) + self.assertTrue(any("core imports adapter" in e for e in errors), errors) + + def test_package_root_initializer_can_import_core_package(self): + self.assertEqual(self.check_sources({ + "openkyrozen/__init__.py": "from . import agent", + "openkyrozen/agent/__init__.py": "", + }), []) + + def test_allowed_dependencies_and_composition_remain_valid(self): + self.assertEqual(self.check_sources({ + "openkyrozen/agent/probe.py": "from ..providers.base import LLMProvider\n" + "from ..security.capabilities import CapabilityToken\n" + "from ..tasks.ports import TaskStore\n" + "import openkyrozen.interfaces_extra", + "openkyrozen/app/bootstrap.py": "import openkyrozen.interfaces\n" + "import openkyrozen.persistence.database", + "openkyrozen/interfaces/__init__.py": "", + "openkyrozen/interfaces_extra.py": "", + }), []) + + def test_concrete_and_external_adapters_remain_forbidden(self): + for source in ("import sqlite3", "from subprocess import run", + "from ..persistence.database import SQLiteDatabase", + "import openkyrozen.tools.adapters"): + with self.subTest(source=source): + errors = self.check_sources({"openkyrozen/tasks/probe.py": source}) + self.assertTrue(any("core imports adapter" in e for e in errors), errors) + + def test_legacy_imports_remain_forbidden(self): + for source in ("import tools", "from main import Agent"): + with self.subTest(source=source): + errors = self.check_sources({"openkyrozen/app/probe.py": source}) + self.assertTrue(any("legacy import" in e for e in errors), errors) + + def test_runtime_cycle_is_detected_but_type_only_cycle_is_not(self): + sources = { + "openkyrozen/tasks/a.py": "from . import b", + "openkyrozen/tasks/b.py": "from . import a", + } + self.assertTrue(any("Import cycle:" in e for e in self.check_sources(sources))) + sources["openkyrozen/tasks/b.py"] = ( + "from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n from . import a\n" + ) + self.assertEqual(self.check_sources(sources), []) + + def test_qualified_type_checking_guard_excludes_only_type_branch(self): + sources = { + "openkyrozen/tasks/a.py": "from . import b", + "openkyrozen/tasks/b.py": ( + "import typing\nif typing.TYPE_CHECKING:\n from . import a\n" + ), + } + self.assertEqual(self.check_sources(sources), []) + sources["openkyrozen/tasks/b.py"] += "else:\n from . import a\n" + self.assertTrue(any("Import cycle:" in e for e in self.check_sources(sources))) + sources["openkyrozen/tasks/b.py"] = ( + "other = object()\nif other.TYPE_CHECKING:\n from . import a\n" + ) + self.assertTrue(any("Import cycle:" in e for e in self.check_sources(sources))) + + def test_cycle_imports_in_compound_statements_are_detected(self): + for source in ( + "try:\n pass\nexcept Exception:\n pass\nelse:\n from . import a", + "try:\n pass\nfinally:\n from . import a", + "with context():\n from . import a", + "for item in items:\n from . import a", + "while ready:\n from . import a", + "for item in items:\n pass\nelse:\n from . import a", + "match value:\n case 1:\n from . import a", + "if ready:\n pass\nelse:\n with context():\n from . import a", + "class Binding:\n from . import a", + ): + with self.subTest(source=source): + self.assertTrue(any("Import cycle:" in e for e in self.check_sources({ + "openkyrozen/tasks/a.py": "from . import b", + "openkyrozen/tasks/b.py": source, + }))) + for source in ("def later():\n from . import a", + "async def later():\n from . import a", + "class Binding:\n def later(self):\n from . import a"): + with self.subTest(source=source): + self.assertEqual(self.check_sources({ + "openkyrozen/tasks/a.py": "from . import b", + "openkyrozen/tasks/b.py": source, + }), []) + + def test_named_reexports_do_not_hide_concrete_adapters(self): + sources = { + "openkyrozen/tools/__init__.py": ( + "from .adapters import ToolAdapters as Adapters\n" + "from .models import CommandResult\n" + ), + "openkyrozen/tools/adapters.py": "class ToolAdapters: pass", + "openkyrozen/tools/models.py": "class CommandResult: pass", + "openkyrozen/providers/__init__.py": ( + "from .openai import OpenAICompatProvider\n" + "from .base import LLMProvider\n" + ), + "openkyrozen/providers/openai.py": "class OpenAICompatProvider: pass", + "openkyrozen/providers/base.py": "class LLMProvider: pass", + "openkyrozen/tools/public.py": "from . import Adapters as Wrapped", + } + for source in ("from openkyrozen.tools import Adapters", + "from ..tools import Adapters as Factory", + "from ..tools.public import Wrapped", + "from ..providers import OpenAICompatProvider", + "from ..tools import *"): + with self.subTest(source=source): + errors = self.check_sources(sources | {"openkyrozen/agent/probe.py": source}) + self.assertTrue(any("core imports adapter" in e for e in errors), errors) + self.assertEqual(self.check_sources(sources | { + "openkyrozen/agent/probe.py": ( + "from ..tools import CommandResult\nfrom ..providers import LLMProvider" + ), + }), []) + + def test_module_reexport_alias_does_not_hide_adapter(self): + errors = self.check_sources({ + "openkyrozen/tools/__init__.py": "from . import adapters as Backend", + "openkyrozen/tools/adapters.py": "", + "openkyrozen/agent/probe.py": "from ..tools import Backend", + }) + self.assertTrue(any("core imports adapter openkyrozen.tools.adapters" in e + for e in errors), errors) + + def test_wildcard_reexports_respect_literal_all(self): + sources = { + "openkyrozen/tools/__init__.py": ( + "from .adapters import ToolAdapters, ToolAdapters as _Factory\n" + "from .models import CommandResult\n__all__ = ['CommandResult']" + ), + "openkyrozen/tools/adapters.py": "class ToolAdapters: pass", + "openkyrozen/tools/models.py": "class CommandResult: pass", + "openkyrozen/agent/probe.py": "from ..tools import *", + } + self.assertEqual(self.check_sources(sources), []) + sources["openkyrozen/tools/__init__.py"] += "\n__all__ = ['CommandResult', '_Factory']" + self.assertTrue(any("core imports adapter" in e for e in self.check_sources(sources))) + + def test_circular_reexport_resolution_terminates_with_cycle_error(self): + errors = self.check_sources({ + "openkyrozen/tools/__init__.py": "from .public import Factory", + "openkyrozen/tools/public.py": "from . import Factory", + "openkyrozen/agent/probe.py": "from ..tools import Factory", + }) + self.assertTrue(any("Import cycle:" in e for e in errors), errors)