4b6817381b
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
238 lines
11 KiB
Python
238 lines
11 KiB
Python
"""Enforce forbidden *direct* import edges between top-level packages.
|
|
|
|
Unlike import-linter (which flags transitive chains), this checker looks at
|
|
**module-top-level** and **nested** (function/class-body) ``import`` /
|
|
``from … import`` statements. Module-top-level uses the same AST walk as
|
|
``check_import_cycles``; nested imports close the loophole where lazy
|
|
``surfaces.*`` imports bypass the module-level graph.
|
|
|
|
Used by ``make check-imports`` (and ``check_imports``) alongside
|
|
import-linter's config contract.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
_CI_DIR = Path(__file__).resolve().parent
|
|
if str(_CI_DIR) not in sys.path:
|
|
sys.path.insert(0, str(_CI_DIR))
|
|
|
|
from check_import_cycles import ( # noqa: E402
|
|
_build_graph,
|
|
_nested_imports,
|
|
discover_first_party_roots,
|
|
module_from_path,
|
|
)
|
|
|
|
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
|
|
# ``source_prefix -> forbidden destination roots`` for direct imports only.
|
|
# Enforces the layering contract documented in ``surfaces/__init__.py``:
|
|
# "Nothing first-party may import from surfaces/". Adds an explicit bound
|
|
# on ``platform``, ``core``, ``gateway`` so the surfaces ban
|
|
# is CI-enforced, not just doc-described.
|
|
_FORBIDDEN_DIRECT: dict[str, frozenset[str]] = {
|
|
"platform": frozenset({"surfaces"}),
|
|
"core": frozenset({"surfaces"}),
|
|
"gateway": frozenset({"surfaces"}),
|
|
"integrations": frozenset({"tools", "surfaces"}),
|
|
"tools": frozenset({"surfaces"}),
|
|
}
|
|
|
|
# Known direct violations being burned down — remove entries as fixes land.
|
|
# Format: ``"source.module -> dest.module"`` (exact modules from the graph).
|
|
_BASELINE_IGNORES: frozenset[str] = frozenset(
|
|
{
|
|
# Gateway hosts the interactive_shell runtime — pre-existing reuse
|
|
# to be burned down by extracting shared runtime primitives out of
|
|
# ``surfaces/interactive_shell/`` and into a layer below ``surfaces``.
|
|
"gateway.storage.session.resolver -> surfaces.interactive_shell.runtime.context",
|
|
# tools/interactive_shell action tools reach UP into surfaces/interactive_shell
|
|
# for runtime / command_registry / UI primitives. Clears when the action
|
|
# tools themselves are refactored to be UI-agnostic (e.g. return
|
|
# "approval-required" sentinels instead of calling execution_confirm
|
|
# directly) so the surface owns its own confirmation UX.
|
|
"tools.interactive_shell.actions.cli_command -> surfaces.interactive_shell.runtime.subprocess_runner",
|
|
# These shell action tools type against the shell ``Session`` (investigation_launch
|
|
# reads session.terminal.background_mode_enabled). Clears when they are made
|
|
# session-core-agnostic so they no longer import the shell session type.
|
|
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.session",
|
|
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.session",
|
|
"tools.interactive_shell.shared.investigation_launch -> surfaces.interactive_shell.session",
|
|
"tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.command_registry",
|
|
"tools.interactive_shell.actions.llm_provider -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry",
|
|
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.command_registry.slash_catalog",
|
|
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui",
|
|
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.actions.slash -> surfaces.interactive_shell.utils.telemetry.turn_outcome",
|
|
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.command_registry",
|
|
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.runtime",
|
|
"tools.interactive_shell.actions.task_cancel -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.runtime",
|
|
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
|
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.ui",
|
|
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.implementation.claude_code_executor -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.runtime",
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui",
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
|
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.runtime",
|
|
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.runtime.subprocess_runner.task_streaming",
|
|
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.ui",
|
|
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.ui.execution_confirm",
|
|
"tools.interactive_shell.synthetic.runner -> surfaces.interactive_shell.utils.error_handling.exception_reporting",
|
|
}
|
|
)
|
|
|
|
# Function/class-body lazy imports that bypass the module-level graph.
|
|
# Format matches ``_BASELINE_IGNORES``; burn down by moving shared code
|
|
# below ``surfaces/`` or making tools UI-agnostic.
|
|
_NESTED_BASELINE_IGNORES: frozenset[str] = frozenset(
|
|
{
|
|
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.background.runner",
|
|
"tools.interactive_shell.actions.investigation -> surfaces.interactive_shell.runtime.investigation_adapter",
|
|
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.background.runner",
|
|
"tools.interactive_shell.actions.sample_alert -> surfaces.interactive_shell.runtime.investigation_adapter",
|
|
"tools.interactive_shell.actions.llm_provider -> surfaces.cli.wizard.config",
|
|
# Nested only: runner.py also has a module-level ui import (see _BASELINE_IGNORES).
|
|
"tools.interactive_shell.shell.runner -> surfaces.interactive_shell.ui",
|
|
}
|
|
)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class DirectViolation:
|
|
source: str
|
|
target: str
|
|
|
|
@property
|
|
def edge(self) -> str:
|
|
return f"{self.source} -> {self.target}"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class NestedViolation:
|
|
source: str
|
|
target: str
|
|
lineno: int
|
|
|
|
@property
|
|
def edge(self) -> str:
|
|
return f"{self.source} -> {self.target}"
|
|
|
|
|
|
def _source_root(module: str) -> str:
|
|
return module.split(".", 1)[0]
|
|
|
|
|
|
def find_direct_violations(
|
|
graph: dict[str, set[str]],
|
|
*,
|
|
forbidden: dict[str, frozenset[str]] | None = None,
|
|
baseline_ignores: frozenset[str] | None = None,
|
|
) -> list[DirectViolation]:
|
|
rules = forbidden or _FORBIDDEN_DIRECT
|
|
ignores = baseline_ignores if baseline_ignores is not None else _BASELINE_IGNORES
|
|
violations: list[DirectViolation] = []
|
|
|
|
for source_module, targets in sorted(graph.items()):
|
|
source_root = _source_root(source_module)
|
|
forbidden_roots = rules.get(source_root)
|
|
if not forbidden_roots:
|
|
continue
|
|
for target_module in sorted(targets):
|
|
target_root = _source_root(target_module)
|
|
if target_root not in forbidden_roots:
|
|
continue
|
|
edge = DirectViolation(source_module, target_module)
|
|
if edge.edge in ignores:
|
|
continue
|
|
violations.append(edge)
|
|
return violations
|
|
|
|
|
|
def find_nested_direct_violations(
|
|
root: Path,
|
|
first_party_roots: tuple[str, ...],
|
|
*,
|
|
forbidden: dict[str, frozenset[str]] | None = None,
|
|
baseline_ignores: frozenset[str] | None = None,
|
|
) -> list[NestedViolation]:
|
|
rules = forbidden or _FORBIDDEN_DIRECT
|
|
ignores = baseline_ignores if baseline_ignores is not None else _NESTED_BASELINE_IGNORES
|
|
roots = frozenset(first_party_roots)
|
|
violations: list[NestedViolation] = []
|
|
|
|
for pkg in first_party_roots:
|
|
if pkg not in rules:
|
|
continue
|
|
pkg_path = root / pkg
|
|
if not pkg_path.exists():
|
|
continue
|
|
for py in pkg_path.rglob("*.py"):
|
|
if "__pycache__" in py.parts:
|
|
continue
|
|
source_module = module_from_path(root, py)
|
|
source_root = _source_root(source_module)
|
|
forbidden_roots = rules.get(source_root)
|
|
if not forbidden_roots:
|
|
continue
|
|
source = py.read_text(encoding="utf-8")
|
|
for target_module, lineno in _nested_imports(source, first_party_roots=roots):
|
|
target_root = _source_root(target_module)
|
|
if target_root not in forbidden_roots:
|
|
continue
|
|
violation = NestedViolation(source_module, target_module, lineno)
|
|
if violation.edge in ignores:
|
|
continue
|
|
violations.append(violation)
|
|
|
|
return sorted(violations, key=lambda item: (item.source, item.lineno, item.target))
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
del argv
|
|
root = _REPO_ROOT
|
|
first_party_roots = discover_first_party_roots(root)
|
|
graph = _build_graph(root, first_party_roots)
|
|
module_violations = find_direct_violations(graph)
|
|
nested_violations = find_nested_direct_violations(root, first_party_roots)
|
|
|
|
if not module_violations and not nested_violations:
|
|
print(
|
|
"No forbidden direct import edges found "
|
|
f"(module baseline: {len(_BASELINE_IGNORES)}, "
|
|
f"nested baseline: {len(_NESTED_BASELINE_IGNORES)})."
|
|
)
|
|
return 0
|
|
|
|
if module_violations:
|
|
print(f"FAIL: {len(module_violations)} forbidden module-level direct import edge(s):")
|
|
for violation in module_violations:
|
|
print(f" {violation.edge}")
|
|
|
|
if nested_violations:
|
|
if module_violations:
|
|
print()
|
|
print(f"FAIL: {len(nested_violations)} forbidden nested direct import edge(s):")
|
|
for violation in nested_violations:
|
|
print(f" {violation.edge} (line {violation.lineno})")
|
|
|
|
print(
|
|
"\nFix by moving shared code to a lower layer (platform/common, core/contracts) "
|
|
"or add a temporary baseline entry in .github/ci/check_direct_imports.py "
|
|
"with a linked issue — do not use function-level lazy imports to hide "
|
|
"new direct edges."
|
|
)
|
|
return 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|