arborescent 0.2.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.
- arborescent/__init__.py +13 -0
- arborescent/__main__.py +3 -0
- arborescent/adapters.py +11 -0
- arborescent/baseline.py +150 -0
- arborescent/bench.py +93 -0
- arborescent/certification.py +251 -0
- arborescent/cli.py +210 -0
- arborescent/cli_install.py +119 -0
- arborescent/cli_report.py +107 -0
- arborescent/clock_adapter.py +12 -0
- arborescent/config.py +217 -0
- arborescent/domain.py +248 -0
- arborescent/engine.py +305 -0
- arborescent/engines.py +132 -0
- arborescent/facts.py +273 -0
- arborescent/filesystem_adapter.py +64 -0
- arborescent/generate.py +197 -0
- arborescent/install_tools.py +41 -0
- arborescent/installer_adapter.py +210 -0
- arborescent/ports.py +136 -0
- arborescent/practices.py +205 -0
- arborescent/presets.py +64 -0
- arborescent/py.typed +0 -0
- arborescent/rules.py +213 -0
- arborescent/scanner.py +252 -0
- arborescent/selftest.py +168 -0
- arborescent/stdlib.py +111 -0
- arborescent/suggestions.py +110 -0
- arborescent/testing.py +183 -0
- arborescent/tool_runner.py +100 -0
- arborescent/typecheckers.py +181 -0
- arborescent-0.2.0.dist-info/METADATA +185 -0
- arborescent-0.2.0.dist-info/RECORD +37 -0
- arborescent-0.2.0.dist-info/WHEEL +5 -0
- arborescent-0.2.0.dist-info/entry_points.txt +2 -0
- arborescent-0.2.0.dist-info/licenses/LICENSE +21 -0
- arborescent-0.2.0.dist-info/top_level.txt +1 -0
arborescent/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Arborescent: one architecture policy that compiles to import-linter, tach and a type checker, and enforces more.
|
|
2
|
+
|
|
3
|
+
- ``domain`` / ``facts`` / ``rules`` -- the policy, one-pass AST facts, and the pure checks (no I/O).
|
|
4
|
+
- ``ports`` -- ``ToolRunner``, ``TypeChecker``, ``ModuleScanner``.
|
|
5
|
+
- ``generate`` / ``engine`` / ``baseline`` / ``selftest`` / ``practices`` / ``bench`` -- the use cases.
|
|
6
|
+
- ``adapters`` / ``typecheckers`` -- subprocesses, the file system + content-hash cache, pyright/basedpyright/ty/pyrefly.
|
|
7
|
+
- ``testing`` -- fakes. ``cli`` -- the composition root.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .domain import Policy, PolicyError, Violation
|
|
11
|
+
|
|
12
|
+
__all__ = ["Policy", "PolicyError", "Violation"]
|
|
13
|
+
__version__ = "0.1.0"
|
arborescent/__main__.py
ADDED
arborescent/adapters.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Compatibility exports for Arborescent's runtime adapters.
|
|
2
|
+
|
|
3
|
+
New code should import adapters from their responsibility-specific modules.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from .clock_adapter import SystemClock
|
|
7
|
+
from .filesystem_adapter import LocalFileSystem
|
|
8
|
+
from .scanner import FsScanner, ScanData
|
|
9
|
+
from .tool_runner import SubprocessToolRunner, resolve_executable
|
|
10
|
+
|
|
11
|
+
__all__ = ["FsScanner", "LocalFileSystem", "ScanData", "SubprocessToolRunner", "SystemClock", "resolve_executable"]
|
arborescent/baseline.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""The shrink-only ratchet: a baseline file records today's violations; only NEW ones fail, and entries that no
|
|
2
|
+
longer occur must be deleted (``arborescent baseline`` rewrites the file), so the debt can only go down."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .domain import PolicyError, Violation
|
|
11
|
+
from .ports import FileSystem
|
|
12
|
+
|
|
13
|
+
__all__ = ["Ratchet", "apply", "growth_consistent", "load", "record", "save"]
|
|
14
|
+
|
|
15
|
+
VERSION = 2
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class BaselineError(PolicyError):
|
|
19
|
+
"""A requested baseline change violates the ratchet contract."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Ratchet:
|
|
24
|
+
new: list[Violation] # not in the baseline: fail
|
|
25
|
+
stale: list[str] # in the baseline but gone: fail (shrink-only)
|
|
26
|
+
tolerated: int # in the baseline and still present
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def ok(self) -> bool:
|
|
30
|
+
return not self.new and not self.stale
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def load(fs: FileSystem, path: Path) -> set[str]:
|
|
34
|
+
violations = document(fs, path)["violations"]
|
|
35
|
+
if not isinstance(violations, list):
|
|
36
|
+
raise ValueError("baseline violations must be an array")
|
|
37
|
+
return {str(k) for k in violations}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def document(fs: FileSystem, path: Path) -> dict[str, object]:
|
|
41
|
+
"""Read a v1 or v2 baseline as a normalized document."""
|
|
42
|
+
if not fs.is_file(path):
|
|
43
|
+
return {"version": VERSION, "violations": [], "growth": []}
|
|
44
|
+
data = fs.read_json(path)
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
raise ValueError("baseline must be a JSON object")
|
|
47
|
+
version = data.get("version", 1)
|
|
48
|
+
if version not in (1, VERSION):
|
|
49
|
+
raise ValueError(f"unsupported baseline version {version!r}")
|
|
50
|
+
violations = data.get("violations", [])
|
|
51
|
+
growth = data.get("growth", [])
|
|
52
|
+
if not isinstance(violations, list) or not isinstance(growth, list):
|
|
53
|
+
raise ValueError("baseline violations and growth must be arrays")
|
|
54
|
+
return {"version": VERSION, "violations": [str(key) for key in violations], "growth": growth}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def save(fs: FileSystem, path: Path, violations: Iterable[Violation]) -> int:
|
|
58
|
+
keys = sorted({v.key for v in violations})
|
|
59
|
+
fs.write_json(path, {"version": VERSION, "violations": keys, "growth": []})
|
|
60
|
+
return len(keys)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def record(
|
|
64
|
+
fs: FileSystem,
|
|
65
|
+
path: Path,
|
|
66
|
+
violations: Iterable[Violation],
|
|
67
|
+
*,
|
|
68
|
+
adopt: str | None = None,
|
|
69
|
+
allow_growth: bool = False,
|
|
70
|
+
reason: str | None = None,
|
|
71
|
+
) -> int:
|
|
72
|
+
"""Create or shrink a baseline; growth requires a one-time adoption or an explicit reason."""
|
|
73
|
+
if adopt is not None and allow_growth:
|
|
74
|
+
raise BaselineError("`--adopt` and `--allow-growth` cannot be used together")
|
|
75
|
+
if reason is not None and not allow_growth:
|
|
76
|
+
raise BaselineError("`--reason` requires `--allow-growth`")
|
|
77
|
+
if allow_growth and (reason is None or not reason.strip()):
|
|
78
|
+
raise BaselineError("`--allow-growth` requires a non-empty `--reason`")
|
|
79
|
+
current = {v.key for v in violations}
|
|
80
|
+
if not fs.is_file(path):
|
|
81
|
+
fs.write_json(path, {"version": VERSION, "violations": sorted(current), "growth": []})
|
|
82
|
+
return len(current)
|
|
83
|
+
|
|
84
|
+
old_doc = document(fs, path)
|
|
85
|
+
old_violations = old_doc["violations"]
|
|
86
|
+
existing_growth = old_doc["growth"]
|
|
87
|
+
if not isinstance(old_violations, list) or not isinstance(existing_growth, list):
|
|
88
|
+
raise ValueError("baseline violations and growth must be arrays")
|
|
89
|
+
old = {str(key) for key in old_violations}
|
|
90
|
+
growth = list(existing_growth)
|
|
91
|
+
added = current - old
|
|
92
|
+
if adopt is not None:
|
|
93
|
+
family_old = {key for key in old if _in_family(key, adopt)}
|
|
94
|
+
if family_old:
|
|
95
|
+
raise BaselineError(f"baseline already contains {adopt!r} family entries; adoption is allowed once per family")
|
|
96
|
+
other_new = {key for key in added if not _in_family(key, adopt)}
|
|
97
|
+
if other_new:
|
|
98
|
+
raise BaselineError(f"`--adopt {adopt}` cannot record findings outside that family: {', '.join(sorted(other_new))}")
|
|
99
|
+
added = current - old
|
|
100
|
+
if added:
|
|
101
|
+
growth.append({"reason": f"adopt {adopt} family", "added": sorted(added)})
|
|
102
|
+
elif added and not allow_growth:
|
|
103
|
+
raise BaselineError(f"baseline growth refused; {len(added)} new violation(s); use `--allow-growth --reason TEXT`")
|
|
104
|
+
if allow_growth and added:
|
|
105
|
+
growth.append({"reason": reason.strip() if reason is not None else "", "added": sorted(added)})
|
|
106
|
+
fs.write_json(path, {"version": VERSION, "violations": sorted(current), "growth": growth})
|
|
107
|
+
return len(current)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _in_family(key: str, family: str) -> bool:
|
|
111
|
+
rule = key.split("|", 1)[0]
|
|
112
|
+
if family == "coverage":
|
|
113
|
+
return rule in {"unclassified", "noise-dir-code"}
|
|
114
|
+
if family == "effects":
|
|
115
|
+
return rule.startswith(("effect-", "effects-")) or rule == "dynamic-import"
|
|
116
|
+
if family == "suppressions":
|
|
117
|
+
return rule == "suppression"
|
|
118
|
+
return rule == family or rule.startswith(f"{family}-")
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def growth_consistent(current: dict[str, object], at_ref: dict[str, object]) -> bool:
|
|
122
|
+
"""Check that every newly added key is listed in growth entries appended since ``at_ref``."""
|
|
123
|
+
current_violations = current.get("violations", [])
|
|
124
|
+
ref_violations = at_ref.get("violations", [])
|
|
125
|
+
if not isinstance(current_violations, list) or not isinstance(ref_violations, list):
|
|
126
|
+
return False
|
|
127
|
+
current_keys = {str(key) for key in current_violations}
|
|
128
|
+
ref_keys = {str(key) for key in ref_violations}
|
|
129
|
+
current_log = current.get("growth", [])
|
|
130
|
+
ref_log = at_ref.get("growth", [])
|
|
131
|
+
if not isinstance(current_log, list) or not isinstance(ref_log, list) or current_log[: len(ref_log)] != ref_log:
|
|
132
|
+
return False
|
|
133
|
+
logged: set[str] = set()
|
|
134
|
+
for entry in current_log[len(ref_log) :]:
|
|
135
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("reason"), str) or not entry["reason"].strip():
|
|
136
|
+
return False
|
|
137
|
+
keys = entry.get("added")
|
|
138
|
+
if not isinstance(keys, list) or not all(isinstance(key, str) for key in keys):
|
|
139
|
+
return False
|
|
140
|
+
logged.update(keys)
|
|
141
|
+
return current_keys - ref_keys == logged
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def apply(violations: Iterable[Violation], baseline: set[str], complete: bool = True) -> Ratchet:
|
|
145
|
+
"""``complete`` is False for a partial (``--changed``) run, where an absent entry proves nothing."""
|
|
146
|
+
current = list(violations)
|
|
147
|
+
keys = {v.key for v in current}
|
|
148
|
+
new = [v for v in current if v.key not in baseline]
|
|
149
|
+
stale = sorted(baseline - keys) if complete else []
|
|
150
|
+
return Ratchet(new, stale, len(keys & baseline))
|
arborescent/bench.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""``arborescent bench``: measure, don't assert. Cold and warm wall-clock for every engine and for the whole ``check``
|
|
2
|
+
on codebases you prepared (each directory holds an ``arborescent.toml``). Prints a markdown table.
|
|
3
|
+
|
|
4
|
+
"cold" = no Arborescent cache (and the first run of an external tool); "warm" = the immediately following run.
|
|
5
|
+
External tools keep their own caches or the OS file cache; the numbers say what a developer would see, not what
|
|
6
|
+
the algorithms cost.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import dataclasses
|
|
12
|
+
import sys
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from .config import load_policy
|
|
17
|
+
from .engine import Options, Services, pick_checker, run_check, run_own, write_generated
|
|
18
|
+
from .generate import generate
|
|
19
|
+
from .ports import FileSystem
|
|
20
|
+
|
|
21
|
+
__all__ = ["run_bench"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _clear(fs: FileSystem, root: Path) -> None:
|
|
25
|
+
fs.remove_tree(root / ".arborescent")
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def bench_one(root: Path, names: Sequence[str], make: Callable[[Path], Services]) -> list[str]:
|
|
29
|
+
svc = make(root)
|
|
30
|
+
fs = svc.fs
|
|
31
|
+
policy, _ = load_policy(fs, root / "arborescent.toml")
|
|
32
|
+
policy = dataclasses.replace(policy, type_checkers=tuple(names))
|
|
33
|
+
_ = pick_checker(policy, svc) # an unknown checker name is a PolicyError before anything is timed
|
|
34
|
+
rows: list[tuple[str, str, str, str]] = [] # engine, cold, warm, note
|
|
35
|
+
|
|
36
|
+
def twice(label: str, fn: Callable[[], str], reset: Callable[[], None] | None = None) -> None:
|
|
37
|
+
if reset:
|
|
38
|
+
reset()
|
|
39
|
+
t0 = svc.clock.now()
|
|
40
|
+
note = fn()
|
|
41
|
+
cold = svc.clock.now() - t0
|
|
42
|
+
t0 = svc.clock.now()
|
|
43
|
+
fn()
|
|
44
|
+
warm = svc.clock.now() - t0
|
|
45
|
+
rows.append((label, f"{cold:.2f}", f"{warm:.2f}", note))
|
|
46
|
+
print(f" [{root.name}] {label}: cold {cold:.2f}s warm {warm:.2f}s {note}", file=sys.stderr, flush=True)
|
|
47
|
+
|
|
48
|
+
scan = svc.scanner.scan(policy, root)
|
|
49
|
+
n_files = len(scan.known)
|
|
50
|
+
_clear(fs, root)
|
|
51
|
+
|
|
52
|
+
def own() -> str:
|
|
53
|
+
found, s = run_own(policy, root, svc, practices=False)
|
|
54
|
+
return f"{len(found)} violations, {s.parsed} parsed"
|
|
55
|
+
|
|
56
|
+
twice("arborescent own checks (parse+rules)", own, lambda: _clear(fs, root))
|
|
57
|
+
chosen = [c for c in svc.checkers if c.name in names]
|
|
58
|
+
twice("generate (in memory)", lambda: f"{len(generate(policy, sorted(scan.known), chosen))} files")
|
|
59
|
+
_ = write_generated(policy, root, svc)
|
|
60
|
+
|
|
61
|
+
def tool(argv: list[str]) -> Callable[[], str]:
|
|
62
|
+
def go() -> str:
|
|
63
|
+
if not svc.runner.available(argv[0]):
|
|
64
|
+
return "not installed"
|
|
65
|
+
r = svc.runner.run(argv, root)
|
|
66
|
+
return f"rc={r.returncode}"
|
|
67
|
+
|
|
68
|
+
return go
|
|
69
|
+
|
|
70
|
+
for e in svc.engines:
|
|
71
|
+
twice(e.name, tool(e.command([])))
|
|
72
|
+
for c in chosen:
|
|
73
|
+
fs.write_text(root / c.config_file, c.render_config(policy, sorted(set(policy.sources))))
|
|
74
|
+
twice(f"type checker: {c.name}", tool(c.command([])))
|
|
75
|
+
for c in chosen[:1]:
|
|
76
|
+
fs.write_text(root / c.config_file, c.render_config(policy, sorted(set(policy.sources))))
|
|
77
|
+
first = names[0]
|
|
78
|
+
|
|
79
|
+
def whole() -> str:
|
|
80
|
+
rep = run_check(policy, root, svc, Options(type_checker=first, use_baseline=False))
|
|
81
|
+
slowest = max((e for e in rep.engines), key=lambda e: e.seconds)
|
|
82
|
+
return f"slowest engine: {slowest.name} {slowest.seconds:.2f}s; sum of engines {sum(e.seconds for e in rep.engines):.2f}s"
|
|
83
|
+
|
|
84
|
+
twice(f"WHOLE check (parallel, checker={first})", whole, lambda: _clear(fs, root))
|
|
85
|
+
out = [f"### {root.name}: {n_files} modules", "", "| engine | cold s | warm s | note |", "|---|---:|---:|---|"]
|
|
86
|
+
out += [f"| {a} | {b} | {c} | {d} |" for a, b, c, d in rows]
|
|
87
|
+
return out
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def run_bench(dirs: Sequence[Path], names: Sequence[str], make: Callable[[Path], Services]) -> int:
|
|
91
|
+
for d in dirs:
|
|
92
|
+
print("\n".join(bench_one(d.resolve(), list(names), make)) + "\n")
|
|
93
|
+
return 0
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Behavioral checks for the versioned Arborescent minimum profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from .domain import Policy
|
|
11
|
+
from .facts import ModuleInfo
|
|
12
|
+
from .ports import FileSystem, ScanResult, ToolRunner
|
|
13
|
+
|
|
14
|
+
from .domain import NOISE_DIRS, ToolResult
|
|
15
|
+
|
|
16
|
+
PROFILE = "v1"
|
|
17
|
+
RUFF_VERSION = "0.17.2"
|
|
18
|
+
TACH_VERSION = "0.35.3"
|
|
19
|
+
REQUIRED_RUFF = (
|
|
20
|
+
"BLE001", "E722", "F401", "B006", "C901", "PLR0912", "PLR0913", "PLR0915", "TID252",
|
|
21
|
+
"PLW1514", "DTZ001", "T201", "PLC0415",
|
|
22
|
+
)
|
|
23
|
+
_PROBES = {
|
|
24
|
+
"BLE001": "try:\n pass\nexcept Exception:\n pass\n",
|
|
25
|
+
"E722": "try:\n pass\nexcept:\n pass\n",
|
|
26
|
+
"F401": "import os\n",
|
|
27
|
+
"B006": "def f(value=[]):\n return value\n",
|
|
28
|
+
"C901": "def f(x):\n" + "".join(f" if x == {i}:\n x += 1\n" for i in range(12)) + " return x\n",
|
|
29
|
+
"PLR0912": "def f(x):\n" + "".join(f" if x == {i}:\n x += 1\n" for i in range(13)) + " return x\n",
|
|
30
|
+
"PLR0913": "def f(a,b,c,d,e,f):\n return a\n",
|
|
31
|
+
"PLR0915": "def f():\n" + "".join(f" x{i} = {i}\n" for i in range(60)),
|
|
32
|
+
"TID252": "from .. import sibling\n",
|
|
33
|
+
"PLW1514": "open('file.txt')\n",
|
|
34
|
+
"DTZ001": "from datetime import datetime\ndatetime(2024, 1, 1)\n",
|
|
35
|
+
"T201": "print('hello')\n",
|
|
36
|
+
"PLC0415": "def f():\n import os\n return os.name\n",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def certify(root: Path, policy: Policy, scan: ScanResult, runner: ToolRunner, fs: FileSystem) -> tuple[str, ...]:
|
|
41
|
+
"""Verify exact tool versions and Ruff rules using probes beneath a configured source root."""
|
|
42
|
+
failures = _native_minimum(policy, scan)
|
|
43
|
+
failures.extend(_tool_versions(root, runner, policy.disabled_tools))
|
|
44
|
+
if "ruff" not in policy.disabled_tools and runner.available("ruff"):
|
|
45
|
+
failures.extend(_ruff_probes(root, policy.source_roots, runner, fs))
|
|
46
|
+
if "tach" not in policy.disabled_tools:
|
|
47
|
+
failures.extend(_tach_probes(root, policy, scan, runner, fs))
|
|
48
|
+
return tuple(failures)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _native_minimum(policy: Policy, scan: object) -> list[str]:
|
|
52
|
+
failures: list[str] = []
|
|
53
|
+
if not policy.forbid_circular_imports:
|
|
54
|
+
failures.append("minimum profile requires `forbid_circular_imports = true`")
|
|
55
|
+
failures.extend(
|
|
56
|
+
f"required profile tool {tool} is disabled by policy"
|
|
57
|
+
for tool in ("ruff", "tach")
|
|
58
|
+
if tool in policy.disabled_tools
|
|
59
|
+
)
|
|
60
|
+
outer = policy.rings[-1].name
|
|
61
|
+
for rule in policy.rings:
|
|
62
|
+
if rule.name != outer and "*" in rule.external:
|
|
63
|
+
failures.append(f"ring {rule.name} has wildcard external access")
|
|
64
|
+
if rule.name != outer and "*" in rule.may_import:
|
|
65
|
+
failures.append(f"ring {rule.name} has wildcard internal imports")
|
|
66
|
+
unclassified = getattr(scan, "unclassified", ())
|
|
67
|
+
if unclassified:
|
|
68
|
+
failures.append(f"{len(unclassified)} first-party module(s) are unclassified")
|
|
69
|
+
for rule in policy.rings[:-1]:
|
|
70
|
+
required = policy.forbidden_effects(rule.name)
|
|
71
|
+
if rule.forbid is not None and not required <= rule.forbid:
|
|
72
|
+
failures.append(f"ring {rule.name} weakens positional effect defaults")
|
|
73
|
+
return failures
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _tool_versions(root: Path, runner: ToolRunner, disabled_tools: tuple[str, ...] = ()) -> list[str]:
|
|
77
|
+
failures: list[str] = []
|
|
78
|
+
for executable, expected in (("ruff", RUFF_VERSION), ("tach", TACH_VERSION)):
|
|
79
|
+
if executable in disabled_tools:
|
|
80
|
+
continue
|
|
81
|
+
if not runner.available(executable):
|
|
82
|
+
failures.append(f"{executable} is not installed")
|
|
83
|
+
continue
|
|
84
|
+
result = runner.run([executable, "--version"], root)
|
|
85
|
+
actual = result.stdout.strip().split()[-1] if result.returncode == 0 and result.stdout.strip() else "unknown"
|
|
86
|
+
if actual != expected:
|
|
87
|
+
failures.append(f"{executable} version {actual}; profile {PROFILE} requires {expected}")
|
|
88
|
+
return failures
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _ruff_probes(root: Path, sources: tuple[str, ...], runner: ToolRunner, fs: FileSystem) -> list[str]:
|
|
92
|
+
failures: list[str] = []
|
|
93
|
+
source_root = next((root / source for source in sources if fs.is_dir(root / source)), root)
|
|
94
|
+
try:
|
|
95
|
+
with fs.temporary_directory("arborescent-profile-", source_root) as temp:
|
|
96
|
+
package = temp / "probe_package"
|
|
97
|
+
nested = package / "nested"
|
|
98
|
+
fs.mkdir(nested)
|
|
99
|
+
fs.write_text(package / "__init__.py", "")
|
|
100
|
+
fs.write_text(nested / "__init__.py", "")
|
|
101
|
+
filename = nested / "probe.py"
|
|
102
|
+
fs.write_text(filename, "\n")
|
|
103
|
+
for code, snippet in _PROBES.items():
|
|
104
|
+
fs.write_text(filename, snippet)
|
|
105
|
+
result = runner.run(
|
|
106
|
+
["ruff", "check", "--ignore-noqa", "--exit-zero", "--output-format", "json", str(filename)], root
|
|
107
|
+
)
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
failures.append(f"Ruff {code} probe exited with status {result.returncode}")
|
|
110
|
+
continue
|
|
111
|
+
try:
|
|
112
|
+
diagnostics = json.loads(result.stdout)
|
|
113
|
+
except json.JSONDecodeError:
|
|
114
|
+
failures.append(f"Ruff {code} probe returned malformed JSON")
|
|
115
|
+
continue
|
|
116
|
+
if not isinstance(diagnostics, list) or not any(
|
|
117
|
+
isinstance(item, dict) and item.get("code") == code for item in diagnostics
|
|
118
|
+
):
|
|
119
|
+
failures.append(f"Ruff required rule {code} is not effective for {filename.relative_to(root)}")
|
|
120
|
+
except OSError as exc:
|
|
121
|
+
failures.append(f"could not create profile canaries: {exc}")
|
|
122
|
+
return failures
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _tach_probes(root: Path, policy: Policy, scan: ScanResult, runner: ToolRunner, fs: FileSystem) -> list[str]:
|
|
126
|
+
"""Prove active Tach config rejects a forbidden ring edge and undeclared external import."""
|
|
127
|
+
if not runner.available("tach"):
|
|
128
|
+
return ["Tach is not installed; profile behavior canaries cannot run"]
|
|
129
|
+
failures: list[str] = []
|
|
130
|
+
if not fs.is_file(root / "tach.toml"):
|
|
131
|
+
return ["Tach profile canaries require the active tach.toml at the project root"]
|
|
132
|
+
source_module, outer_modules = _tach_canary_modules(policy, scan)
|
|
133
|
+
if source_module is None or not outer_modules:
|
|
134
|
+
return ["Tach profile canaries need at least one classified innermost and outermost module"]
|
|
135
|
+
try:
|
|
136
|
+
with fs.temporary_directory("arborescent-tach-profile-") as temp:
|
|
137
|
+
_copy_tach_project(root, policy.source_roots, temp, fs)
|
|
138
|
+
source_path = temp / source_module.path
|
|
139
|
+
if not fs.is_file(source_path):
|
|
140
|
+
return [f"Tach canary source is unavailable in the isolated project: {source_module.path}"]
|
|
141
|
+
failures.extend(_tach_baseline(temp, runner))
|
|
142
|
+
if failures:
|
|
143
|
+
return failures
|
|
144
|
+
if not _tach_layer_probe(temp, source_module, outer_modules, runner, fs):
|
|
145
|
+
failures.append(
|
|
146
|
+
f"Tach layer canary is not effective: {source_module.name} may import every outermost module"
|
|
147
|
+
)
|
|
148
|
+
if not _tach_external_probe(temp, source_path, runner, fs):
|
|
149
|
+
failures.append("Tach external-dependency canary is not effective for undeclared packages")
|
|
150
|
+
except (OSError, UnicodeDecodeError) as exc:
|
|
151
|
+
failures.append(f"could not create Tach profile canaries: {exc}")
|
|
152
|
+
return failures
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _tach_baseline(temp: Path, runner: ToolRunner) -> list[str]:
|
|
156
|
+
failures: list[str] = []
|
|
157
|
+
for command, label in ((["tach", "check", "--dependencies"], "layer"), (["tach", "check-external"], "external-dependency")):
|
|
158
|
+
result = runner.run(command, temp)
|
|
159
|
+
if result.returncode != 0:
|
|
160
|
+
failures.append(f"Tach {label} baseline failed in isolated project: {_result_detail(result)}")
|
|
161
|
+
return failures
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _tach_layer_probe(
|
|
165
|
+
temp: Path, source_module: ModuleInfo, outer_modules: tuple[str, ...], runner: ToolRunner, fs: FileSystem
|
|
166
|
+
) -> bool:
|
|
167
|
+
source_path = temp / source_module.path
|
|
168
|
+
original = fs.read_bytes(source_path)
|
|
169
|
+
accepted_command = ["tach", "check", "--dependencies"]
|
|
170
|
+
for target in outer_modules:
|
|
171
|
+
_append_import(fs, source_path, original, target)
|
|
172
|
+
result = runner.run(accepted_command, temp)
|
|
173
|
+
fs.write_bytes(source_path, original)
|
|
174
|
+
if result.returncode != 0:
|
|
175
|
+
return True
|
|
176
|
+
return False
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _tach_external_probe(temp: Path, source_path: Path, runner: ToolRunner, fs: FileSystem) -> bool:
|
|
180
|
+
original = fs.read_bytes(source_path)
|
|
181
|
+
_append_import(fs, source_path, original, "__arborescent_profile_missing_external_7f0f6c19__")
|
|
182
|
+
result = runner.run(["tach", "check-external"], temp)
|
|
183
|
+
fs.write_bytes(source_path, original)
|
|
184
|
+
return result.returncode != 0
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _append_import(fs: FileSystem, path: Path, original: bytes, module: str) -> None:
|
|
188
|
+
source = original.decode("utf-8-sig")
|
|
189
|
+
fs.write_text(path, f"{source.rstrip()}\nimport {module}\n")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _tach_canary_modules(policy: Policy, scan: ScanResult) -> tuple[ModuleInfo | None, tuple[str, ...]]:
|
|
193
|
+
modules = tuple(scan.modules.values())
|
|
194
|
+
leaves = tuple(
|
|
195
|
+
module for module in modules if not any(other.name.startswith(module.name + ".") for other in modules)
|
|
196
|
+
)
|
|
197
|
+
inner = policy.rings[0].name
|
|
198
|
+
outer = policy.rings[-1].name
|
|
199
|
+
sources = sorted((module for module in leaves if policy.ring_of(module.name) == inner), key=lambda item: item.name)
|
|
200
|
+
targets = tuple(sorted(module.name for module in leaves if policy.ring_of(module.name) == outer))
|
|
201
|
+
if not sources:
|
|
202
|
+
return None, ()
|
|
203
|
+
return sources[0], tuple(target for target in targets if target != sources[0].name)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _copy_tach_project(root: Path, source_roots: tuple[str, ...], temp: Path, fs: FileSystem) -> None:
|
|
207
|
+
"""Clone only source roots and dependency/tool config into an isolated relative-path-preserving project."""
|
|
208
|
+
roots = sorted({source.strip("/") or "." for source in source_roots}, key=lambda value: (value != ".", value.count("/")))
|
|
209
|
+
if "." in roots:
|
|
210
|
+
_copy_project_root(root, temp, fs)
|
|
211
|
+
else:
|
|
212
|
+
_copy_source_roots(root, roots, temp, fs)
|
|
213
|
+
_copy_project_configs(root, temp, fs)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _copy_project_root(root: Path, temp: Path, fs: FileSystem) -> None:
|
|
217
|
+
ignored = {*NOISE_DIRS, "dist", "build"}
|
|
218
|
+
for source in [*fs.glob(root, "*"), *fs.glob(root, ".*")]:
|
|
219
|
+
if source.name in ignored:
|
|
220
|
+
continue
|
|
221
|
+
target = temp / source.name
|
|
222
|
+
if fs.is_dir(source):
|
|
223
|
+
fs.copy_tree(source, target, ignore=[*ignored, "__pycache__"])
|
|
224
|
+
elif fs.is_file(source):
|
|
225
|
+
fs.write_bytes(target, fs.read_bytes(source))
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _copy_source_roots(root: Path, roots: list[str], temp: Path, fs: FileSystem) -> None:
|
|
229
|
+
copied: list[str] = []
|
|
230
|
+
for source in roots:
|
|
231
|
+
if any(source == parent or source.startswith(parent + "/") for parent in copied):
|
|
232
|
+
continue
|
|
233
|
+
source_path = root / source
|
|
234
|
+
if fs.is_dir(source_path):
|
|
235
|
+
fs.copy_tree(source_path, temp / source, ignore=[*NOISE_DIRS, ".git", "node_modules"])
|
|
236
|
+
copied.append(source)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _copy_project_configs(root: Path, temp: Path, fs: FileSystem) -> None:
|
|
240
|
+
for relative in ("tach.toml", "pyproject.toml", "poetry.lock", "uv.lock", "requirements.txt", "setup.cfg"):
|
|
241
|
+
source = root / relative
|
|
242
|
+
target = temp / relative
|
|
243
|
+
if fs.is_file(source) and not fs.exists(target):
|
|
244
|
+
fs.mkdir(target.parent)
|
|
245
|
+
fs.write_bytes(target, fs.read_bytes(source))
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _result_detail(result: ToolResult) -> str:
|
|
249
|
+
stdout = result.stdout.strip()
|
|
250
|
+
stderr = result.stderr.strip()
|
|
251
|
+
return stderr or stdout or "Tach exited unsuccessfully"
|