From 5cb15fd78888a19ac9ffb0eb7c33483be49393d1 Mon Sep 17 00:00:00 2001 From: Antawari de la Torre Cobos Date: Thu, 11 Jun 2026 01:55:09 -0600 Subject: [PATCH 1/4] =?UTF-8?q?test:=20the=20import-contract=20gate=20?= =?UTF-8?q?=E2=80=94=20eleven=20control=20rods=20+=20contract=20lint=20(RE?= =?UTF-8?q?D:=20gate=20not=20yet=20built)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Failing-first battery for cf-import-contract, the layering-direction gate: - PASS 1 contract-file lint (before lint-imports): wildcard ignore_imports entries fail; ignore count ratchets shrink-only against a committed import-contract-baseline.json (absent = zero); every top-level package under the source root must be named in a contract clause (exhaustiveness); pinned settings may not be loosened (exclude_type_checking_imports=true, unmatched alerting downgraded). - PASS 2 lint-imports at the kit-pinned version: CONTRACT_BROKEN with named edge + line; enumerated ignore entries pass with the entry printed; stale ignores fail CONTRACT_STALE_IGNORE; packages-with-no-contract fail CONTRACT_MISSING; environment/config failures (uninstalled src-layout package, unreadable config or baseline) surface as typed CONTRACT_CONFIG_ERROR exit 2, never as a violation verdict. - PASS 3 companion AST scan: module-level importlib.import_module / __import__ inside contract-protected layers fail CONTRACT_DYNAMIC_IMPORT; function-body dynamic loading is out of scope by design. Control rods R1..R11 built inline as fixtures; kit wiring pinned (console script cf-import-contract, import-linter== dev pin, blessed templates/import-contract.toml carrying the pinned settings, honest-scope docstring disclosure). All behaviors anchored against import-linter 2.11 observed output (edge form 'core -> tenants.acme (l.1)'; unmatched-ignore default alerting=error; 'Could not find package' on uninstalled src layout). Suite state at this commit: 262 passed, this file RED (ModuleNotFoundError: cf_quality.import_contract). Co-Authored-By: Claude Fable 5 --- tests/test_import_contract.py | 489 ++++++++++++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 tests/test_import_contract.py diff --git a/tests/test_import_contract.py b/tests/test_import_contract.py new file mode 100644 index 0000000..c55710b --- /dev/null +++ b/tests/test_import_contract.py @@ -0,0 +1,489 @@ +"""Tests for cf-import-contract — the import-contract gate (layering direction). + +Every mounted repo commits ONE exhaustive import contract (import-linter +form) naming every top-level package under its source root, plus a committed +ignore-count baseline. The gate runs three passes: + +- PASS 1 — contract-file lint, BEFORE lint-imports: wildcard ``ignore_imports`` + entries fail (one-line gutting), the ignore count is ratcheted shrink-only + against the committed baseline, every top-level package under the source + root must be named in some contract clause (exhaustiveness — kills + rename-evasion and vacuity), and the kit-pinned settings may not be + loosened (``exclude_type_checking_imports = true`` fails). +- PASS 2 — ``lint-imports`` at the kit-pinned version: a violated contract is + a typed CONTRACT_BROKEN with the named edge + line; an edge covered by an + enumerated ignore entry PASSES with the entry printed; a stale ignore entry + fails (unmatched-alerting, the baseline self-cleans); packages present with + no committed contract are a typed CONTRACT_MISSING; environment/config + failures (uninstalled src-layout package, unreadable config) surface as a + typed CONTRACT_CONFIG_ERROR — never as a violation verdict. +- PASS 3 — companion AST check: ``importlib.import_module`` or ``__import__`` + at module level inside contract-protected layers fails (module-level + dynamic imports are the proven order-dependent-crash shape). + +The eleven control-rod fixtures (R1..R11) are built inline below and MUST +behave as stated before ship. + +HONEST SCOPE: this gate is a layering-direction proxy for dependency +inversion — core receiving concretes via Any-typed runtime injection passes; +abstraction quality stays review-tier; contract CONTENT is review-gated at +mount (vacuous-but-exhaustive remains possible for flat-layout repos). +""" + +from __future__ import annotations + +import json +import tomllib +from pathlib import Path + +import pytest +from cf_quality.import_contract import lint_contract, main, scan_dynamic_imports + +from cf_quality.errors import GateError, GateViolation + +KIT_ROOT = Path(__file__).resolve().parents[1] +BASELINE_FILENAME = "import-contract-baseline.json" +TEMPLATE_PATH = KIT_ROOT / "templates" / "import-contract.toml" + + +def _package(root: Path, name: str, source: str = "") -> None: + pkg = root / name + pkg.mkdir(parents=True, exist_ok=True) + (pkg / "__init__.py").write_text(source, encoding="utf-8") + + +def _contract_toml( + roots: list[str], + ignores: tuple[str, ...], + tc_exclude: str | None, + alerting: str, +) -> str: + root_list = ", ".join(f'"{r}"' for r in roots) + lines = [ + "[tool.importlinter]", + f"root_packages = [{root_list}]", + "include_external_packages = false", + ] + if tc_exclude is not None: + lines.append(f"exclude_type_checking_imports = {tc_exclude}") + lines += [ + "", + "[[tool.importlinter.contracts]]", + 'name = "core does not import tenants"', + 'type = "forbidden"', + 'source_modules = ["core"]', + 'forbidden_modules = ["tenants"]', + f'unmatched_ignore_imports_alerting = "{alerting}"', + ] + if ignores: + entries = ", ".join(f'"{e}"' for e in ignores) + lines.append(f"ignore_imports = [{entries}]") + return "\n".join(lines) + "\n" + + +def _mount( + root: Path, + *, + core: str = "", + ignores: tuple[str, ...] = (), + baseline: int | None = None, + contract: bool = True, + tc_exclude: str | None = "false", + alerting: str = "error", + extra_packages: tuple[str, ...] = (), +) -> Path: + """A flat-layout consumer fixture: core + tenants under the repo root.""" + _package(root, "core", core) + _package(root, "tenants") + (root / "tenants" / "acme.py").write_text("X = 1\n", encoding="utf-8") + for name in extra_packages: + _package(root, name) + if contract: + roots = ["core", "tenants", *extra_packages] + (root / "pyproject.toml").write_text( + _contract_toml(roots, ignores, tc_exclude, alerting), encoding="utf-8" + ) + if baseline is not None: + (root / BASELINE_FILENAME).write_text( + json.dumps({"ignored_imports": baseline}) + "\n", encoding="utf-8" + ) + return root + + +def _mount_src_layout(root: Path, *, ignores: tuple[str, ...] = ()) -> Path: + """A src-layout consumer fixture whose package is NOT pip-installed.""" + for name in ("mypkg", "other"): + pkg = root / "src" / name + pkg.mkdir(parents=True) + (pkg / "__init__.py").write_text("X = 1\n", encoding="utf-8") + lines = [ + "[tool.importlinter]", + 'root_packages = ["mypkg", "other"]', + "include_external_packages = false", + "exclude_type_checking_imports = false", + "", + "[[tool.importlinter.contracts]]", + 'name = "mypkg does not import other"', + 'type = "forbidden"', + 'source_modules = ["mypkg"]', + 'forbidden_modules = ["other"]', + 'unmatched_ignore_imports_alerting = "error"', + ] + if ignores: + entries = ", ".join(f'"{e}"' for e in ignores) + lines.append(f"ignore_imports = [{entries}]") + (root / "pyproject.toml").write_text("\n".join(lines) + "\n", encoding="utf-8") + return root + + +# --- PASS 1: contract-file lint (runs BEFORE lint-imports) ------------------- + + +def test_wildcard_ignore_entry_fails_contract_lint(tmp_path: Path) -> None: + # R6 — `core.** -> tenants.**` is the one-line gutting of the whole gate. + _mount(tmp_path, ignores=("core.** -> tenants.**",), baseline=1) + violations = lint_contract(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_LINT_WILDCARD"] + assert violations[0].context["entry"] == "core.** -> tenants.**" + + +def test_single_star_wildcard_also_fails_contract_lint(tmp_path: Path) -> None: + _mount(tmp_path, ignores=("core.* -> tenants.acme",), baseline=1) + assert [v.code for v in lint_contract(tmp_path)] == ["CONTRACT_LINT_WILDCARD"] + + +def test_ignore_count_above_baseline_fails_ratchet(tmp_path: Path) -> None: + _mount( + tmp_path, + ignores=("core -> tenants.acme", "core -> tenants.beta"), + baseline=1, + ) + violations = lint_contract(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_LINT_RATCHET"] + assert violations[0].context["count"] == 2 + assert violations[0].context["baseline"] == 1 + + +def test_ignore_count_at_baseline_passes_lint(tmp_path: Path) -> None: + _mount(tmp_path, ignores=("core -> tenants.acme",), baseline=1) + assert lint_contract(tmp_path) == [] + + +def test_ignores_without_committed_baseline_fail_ratchet(tmp_path: Path) -> None: + # An absent baseline is an empty (zero) baseline, never a free pass. + _mount(tmp_path, ignores=("core -> tenants.acme",), baseline=None) + violations = lint_contract(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_LINT_RATCHET"] + assert violations[0].context["baseline"] == 0 + + +def test_exclude_type_checking_imports_true_fails_lint(tmp_path: Path) -> None: + # R10 — loosening the pinned setting silently unguards TYPE_CHECKING edges. + _mount(tmp_path, tc_exclude="true") + assert [v.code for v in lint_contract(tmp_path)] == ["CONTRACT_LINT_PINNED_CONFIG"] + + +def test_unmatched_alerting_downgraded_to_none_fails_lint(tmp_path: Path) -> None: + # The baseline self-cleans only while unmatched_ignore_imports_alerting=error. + _mount(tmp_path, alerting="none") + assert [v.code for v in lint_contract(tmp_path)] == ["CONTRACT_LINT_PINNED_CONFIG"] + + +def test_unnamed_top_level_package_fails_exhaustiveness(tmp_path: Path) -> None: + # R7 — `billing` sits in root_packages but in NO contract clause: a package + # kept out of every clause is the rename-evasion/vacuity hole. + _mount(tmp_path, extra_packages=("billing",)) + violations = lint_contract(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_LINT_EXHAUSTIVENESS"] + assert violations[0].context["package"] == "billing" + + +def test_all_packages_named_passes_lint(tmp_path: Path) -> None: + _mount(tmp_path) + assert lint_contract(tmp_path) == [] + + +def test_unparseable_contract_raises_typed_config_error(tmp_path: Path) -> None: + _mount(tmp_path, contract=False) + (tmp_path / "pyproject.toml").write_text("[tool.importlinter\nbroken", encoding="utf-8") + with pytest.raises(GateError) as excinfo: + lint_contract(tmp_path) + assert excinfo.value.code == "CONTRACT_CONFIG_ERROR" + assert excinfo.value.retryable is False + + +def test_unparseable_baseline_raises_typed_config_error(tmp_path: Path) -> None: + _mount(tmp_path, ignores=("core -> tenants.acme",)) + (tmp_path / BASELINE_FILENAME).write_text("not json", encoding="utf-8") + with pytest.raises(GateError) as excinfo: + lint_contract(tmp_path) + assert excinfo.value.code == "CONTRACT_CONFIG_ERROR" + + +# --- PASS 3: companion dynamic-import scan ------------------------------------ + + +def test_module_level_import_module_in_protected_layer_fails(tmp_path: Path) -> None: + # R8 — importlib.import_module at module level inside a contract-protected + # layer is the order-dependent-crash shape the static contract cannot see. + _mount( + tmp_path, + core='import importlib\n_impl = importlib.import_module("tenants.acme")\n', + ) + violations = scan_dynamic_imports(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_DYNAMIC_IMPORT"] + assert violations[0].path == "core/__init__.py" + assert violations[0].line == 2 + assert isinstance(violations[0], GateViolation) + + +def test_module_level_dunder_import_in_protected_layer_fails(tmp_path: Path) -> None: + _mount(tmp_path, core='_impl = __import__("tenants.acme")\n') + violations = scan_dynamic_imports(tmp_path) + assert [v.code for v in violations] == ["CONTRACT_DYNAMIC_IMPORT"] + assert violations[0].line == 1 + + +def test_function_body_dynamic_import_is_not_module_level(tmp_path: Path) -> None: + # The companion check is module-level only; deferred dynamic loading inside + # a function body is pass-2/review territory, not an import-time crash. + _mount( + tmp_path, + core=( + "def load():\n" + " import importlib\n" + ' return importlib.import_module("tenants.acme")\n' + ), + ) + assert scan_dynamic_imports(tmp_path) == [] + + +# --- control rods through the CLI (the parsed-output surface) ----------------- + + +def _run_main(root: Path, monkeypatch: pytest.MonkeyPatch) -> int: + monkeypatch.chdir(root) + return main([]) + + +def test_r1_module_level_upward_edge_fails_contract_broken( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, core="import tenants.acme\n") + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_BROKEN" in out + assert "core -> tenants.acme" in out + assert "l.1" in out + + +def test_r2_enumerated_ignore_entry_passes_with_entry_printed( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, core="import tenants.acme\n", ignores=("core -> tenants.acme",), baseline=1) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 0 + assert "core -> tenants.acme" in out, "the honored ignore entry must be printed visibly" + assert "ignor" in out.lower() + + +def test_r3_packages_with_no_contract_fail_contract_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, contract=False) + exit_code = _run_main(tmp_path, monkeypatch) + assert exit_code == 1 + assert "CONTRACT_MISSING" in capsys.readouterr().out + + +def test_pyproject_without_importlinter_table_is_contract_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, contract=False) + (tmp_path / "pyproject.toml").write_text('[project]\nname = "consumer"\n', encoding="utf-8") + exit_code = _run_main(tmp_path, monkeypatch) + assert exit_code == 1 + assert "CONTRACT_MISSING" in capsys.readouterr().out + + +def test_repo_with_no_packages_and_no_contract_passes( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / "script.py").write_text("X = 1\n", encoding="utf-8") + assert _run_main(tmp_path, monkeypatch) == 0 + + +def test_r4_function_body_deferred_upward_import_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount( + tmp_path, + core="def load():\n import tenants.acme\n return tenants.acme.X\n", + ) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_BROKEN" in out + assert "core -> tenants.acme" in out + + +def test_r5_type_checking_guarded_upward_import_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount( + tmp_path, + core=( + "from typing import TYPE_CHECKING\n" + "if TYPE_CHECKING:\n" + " import tenants.acme\n" + ), + ) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_BROKEN" in out + assert "core -> tenants.acme" in out + + +def test_r6_wildcard_ignore_fails_through_the_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, ignores=("core.** -> tenants.**",), baseline=1) + exit_code = _run_main(tmp_path, monkeypatch) + assert exit_code == 1 + assert "CONTRACT_LINT_WILDCARD" in capsys.readouterr().out + + +def test_r7_unnamed_package_fails_through_the_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, extra_packages=("billing",)) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_LINT_EXHAUSTIVENESS" in out + assert "billing" in out + + +def test_r8_dynamic_import_fails_through_the_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount( + tmp_path, + core='import importlib\n_impl = importlib.import_module("tenants.acme")\n', + ) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_DYNAMIC_IMPORT" in out + + +def test_r9_stale_ignore_entry_fails_unmatched_alerting( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # The entry matches no real edge: the committed baseline must self-clean. + _mount(tmp_path, ignores=("core.absent -> tenants.ghost",), baseline=1) + exit_code = _run_main(tmp_path, monkeypatch) + out = capsys.readouterr().out + assert exit_code == 1 + assert "CONTRACT_STALE_IGNORE" in out + assert "core.absent -> tenants.ghost" in out + + +def test_r10_exclude_type_checking_true_fails_through_the_cli( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, tc_exclude="true") + exit_code = _run_main(tmp_path, monkeypatch) + assert exit_code == 1 + assert "CONTRACT_LINT_PINNED_CONFIG" in capsys.readouterr().out + + +def test_r11_uninstalled_src_layout_is_config_error_never_a_violation( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + _mount_src_layout(tmp_path) + exit_code = _run_main(tmp_path, monkeypatch) + captured = capsys.readouterr() + assert exit_code == 2 + assert "CONTRACT_CONFIG_ERROR" in captured.err + assert "CONTRACT_BROKEN" not in captured.out + assert "CONTRACT_BROKEN" not in captured.err + + +def test_contract_lint_verdict_lands_before_lint_imports_runs( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + # A wildcard ignore in an UNINSTALLED src-layout repo: pass 2 would die + # CONTRACT_CONFIG_ERROR, but pass 1 lints the contract file first — the + # gutting attempt is the verdict (exit 1), not the environment (exit 2). + _mount_src_layout(tmp_path, ignores=("mypkg.** -> other.**",)) + exit_code = _run_main(tmp_path, monkeypatch) + assert exit_code == 1 + assert "CONTRACT_LINT_WILDCARD" in capsys.readouterr().out + + +def test_clean_contracted_repo_passes_with_explicit_root( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + _mount(tmp_path, core="VALUE = 1\n") + exit_code = main(["--root", str(tmp_path)]) + out = capsys.readouterr().out + assert exit_code == 0 + assert "OK" in out + + +# --- kit wiring: pin, console script, blessed template, honest scope ---------- + + +def _kit_pyproject() -> dict[str, object]: + return tomllib.loads((KIT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) + + +def test_console_script_is_registered() -> None: + data = _kit_pyproject() + project = data["project"] + assert isinstance(project, dict) + scripts = project["scripts"] + assert isinstance(scripts, dict) + assert scripts.get("cf-import-contract") == "cf_quality.import_contract:main" + + +def test_kit_pins_the_lint_imports_version() -> None: + # Pass 2 runs lint-imports at the KIT-pinned version — a floating dep + # would let the oracle drift under the contract. + data = _kit_pyproject() + project = data["project"] + assert isinstance(project, dict) + optional = project["optional-dependencies"] + assert isinstance(optional, dict) + dev = optional["dev"] + assert isinstance(dev, list) + pins = [d for d in dev if isinstance(d, str) and d.startswith("import-linter==")] + assert len(pins) == 1, f"dev deps must pin import-linter exactly; found: {dev}" + + +def test_blessed_template_ships_the_pinned_settings() -> None: + assert TEMPLATE_PATH.is_file(), "the kit must ship templates/import-contract.toml" + data = tomllib.loads(TEMPLATE_PATH.read_text(encoding="utf-8")) + tool = data["tool"] + assert isinstance(tool, dict) + table = tool["importlinter"] + assert isinstance(table, dict) + assert table["include_external_packages"] is False + assert table["exclude_type_checking_imports"] is False + contracts = table["contracts"] + assert isinstance(contracts, list) and contracts, "the template carries a sample contract" + for contract in contracts: + assert isinstance(contract, dict) + assert contract["unmatched_ignore_imports_alerting"] == "error" + + +def test_honest_scope_is_disclosed_in_the_gate_docstring() -> None: + import cf_quality.import_contract as gate + + assert gate.__doc__ is not None + assert "proxy" in gate.__doc__, "the layering-direction-proxy scope must be disclosed" + assert "review" in gate.__doc__, "abstraction quality stays review-tier — say so" From 80b8254189116c0b942297fcdbf4d6d9c9f5f99a Mon Sep 17 00:00:00 2001 From: Antawari de la Torre Cobos Date: Thu, 11 Jun 2026 02:05:51 -0600 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20cf-import-contract=20=E2=80=94=20th?= =?UTF-8?q?e=20layering-direction=20gate=20goes=20green=20(295=20passed)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the three-pass import-contract gate against the committed failing-first battery (33 tests, all green; full suite 295 passed): - PASS 1 lint_contract: wildcard ignore_imports fail CONTRACT_LINT_WILDCARD; ignore count ratchets shrink-only against import-contract-baseline.json (absent = zero) as CONTRACT_LINT_RATCHET; every top-level package under the source root must be named in a contract clause (CONTRACT_LINT_EXHAUSTIVENESS); pinned settings may not be loosened (CONTRACT_LINT_PINNED_CONFIG). Unreadable contract/baseline raise typed CONTRACT_CONFIG_ERROR. Pass-1 verdicts land BEFORE lint-imports runs. - PASS 2 subprocess run of the kit-pinned lint-imports (cwd = repo root so flat layouts resolve; --no-cache). Verdict classification anchored against import-linter 2.11 observed output: BROKEN section edges re-emitted under CONTRACT_BROKEN ('core -> tenants.acme (l.1)'); 'No matches for ignored import' is CONTRACT_STALE_IGNORE; anything verdict-less (e.g. "Could not find package" on an uninstalled src layout) is typed CONTRACT_CONFIG_ERROR exit 2, never a violation. Honored enumerated ignores are printed on pass. - PASS 3 scan_dynamic_imports: module-level importlib.import_module / __import__ inside contract-protected layers fail CONTRACT_DYNAMIC_IMPORT; function bodies are out of scope by design (iterative AST walk, no descent into function scopes). Kit wiring: console script cf-import-contract registered; import-linter==2.11 pinned exactly in [dev] (the pass-2 oracle may not drift); blessed templates/import-contract.toml ships the pinned settings; honest layering-direction-proxy scope disclosed in the module docstring. The kit submits to its own gauge: the new S603 subprocess suppression is registered in exemptions.json with frozen_count bumped 1 -> 2 (visible ratchet decision, approver pending maintainer ratification); ruff check + format, mypy (0 errors), cf-file-budget (399 < 500 lines), cf-recursion-check, cf-exemptions all green. Co-Authored-By: Claude Fable 5 --- exemptions.json | 9 +- pyproject.toml | 4 + src/cf_quality/import_contract.py | 399 ++++++++++++++++++++++++++++++ templates/import-contract.toml | 28 +++ 4 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/cf_quality/import_contract.py create mode 100644 templates/import-contract.toml diff --git a/exemptions.json b/exemptions.json index e45dbbc..f23b81a 100644 --- a/exemptions.json +++ b/exemptions.json @@ -1,5 +1,5 @@ { - "frozen_count": 1, + "frozen_count": 2, "entries": [ { "file": "src/cf_quality/exemptions.py", @@ -7,6 +7,13 @@ "rule": "S603", "reason": "subprocess.run executes only the repo-local fold-in script through sys.executable, fixed argv, no shell, no untrusted input", "approver": "Anta (quality-stratum refuter-fix pass, 2026-06-10)" + }, + { + "file": "src/cf_quality/import_contract.py", + "symbol_or_line": "_run_lint_imports", + "rule": "S603", + "reason": "subprocess.run executes only the kit-pinned lint-imports console script found beside sys.executable, fixed argv, no shell, no untrusted input", + "approver": "pending maintainer ratification (import-contract gate build, 2026-06-11)" } ] } diff --git a/pyproject.toml b/pyproject.toml index 65e64cb..d8b08f3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,9 @@ dev = [ "pyyaml", # types-PyYAML: mypy stubs so the test battery type-checks at zero errors. "types-PyYAML", + # import-linter: pinned EXACTLY — cf-import-contract's pass 2 parses its + # observed output; a floating dep would let the oracle drift under the contract. + "import-linter==2.11", ] [project.scripts] @@ -31,6 +34,7 @@ cf-mirror-check = "cf_quality.mirror_check:main" cf-recursion-check = "cf_quality.recursion_check:main" cf-exemptions = "cf_quality.exemptions:main" cf-repo-config = "cf_quality.repo_config:main" +cf-import-contract = "cf_quality.import_contract:main" [tool.setuptools.packages.find] where = ["src"] diff --git a/src/cf_quality/import_contract.py b/src/cf_quality/import_contract.py new file mode 100644 index 0000000..4004fbf --- /dev/null +++ b/src/cf_quality/import_contract.py @@ -0,0 +1,399 @@ +"""cf-import-contract — the import-contract gate (layering direction). + +Every mounted repo commits ONE exhaustive import contract (import-linter +form) naming every top-level package under its source root, plus a committed +ignore-count baseline (``import-contract-baseline.json``). The gate runs +three passes: + +- PASS 1 — contract-file lint, BEFORE lint-imports: wildcard + ``ignore_imports`` entries fail (``CONTRACT_LINT_WILDCARD`` — the one-line + gutting), the ignore count ratchets shrink-only against the committed + baseline (``CONTRACT_LINT_RATCHET``; an absent baseline is zero, never a + free pass), every top-level package under the source root must be named in + some contract clause (``CONTRACT_LINT_EXHAUSTIVENESS`` — kills + rename-evasion and vacuity), and the kit-pinned settings may not be + loosened (``CONTRACT_LINT_PINNED_CONFIG``). +- PASS 2 — ``lint-imports`` at the kit-pinned version (subprocess, cwd = the + repo root, so flat layouts resolve): a violated contract is + ``CONTRACT_BROKEN`` with the named edge + line; an edge covered by an + enumerated ignore entry PASSES with the entry printed; a stale ignore entry + is ``CONTRACT_STALE_IGNORE`` (unmatched-alerting, the baseline + self-cleans); environment/config failures (uninstalled src-layout package, + unreadable config) surface as a typed ``CONTRACT_CONFIG_ERROR`` exit 2 — + never as a violation verdict. +- PASS 3 — companion AST scan: ``importlib.import_module`` / ``__import__`` + at module level inside contract-protected layers fails + (``CONTRACT_DYNAMIC_IMPORT`` — the order-dependent-crash shape the static + contract cannot see). Function-body dynamic loading is out of scope by + design. + +A repo with no contract and no top-level packages passes; packages with no +committed contract are ``CONTRACT_MISSING``. + +HONEST SCOPE: this gate is a layering-direction proxy for dependency +inversion — core receiving concretes via Any-typed runtime injection passes; +abstraction quality stays review-tier; contract CONTENT is review-gated at +mount (vacuous-but-exhaustive remains possible for flat-layout repos). + +Exit codes: 0 clean · 1 violations found · 2 the gate itself could not run +(typed :class:`~cf_quality.errors.GateError` on stderr). +""" + +from __future__ import annotations + +import argparse +import ast +import json + +# subprocess: pass 2 runs the kit-pinned lint-imports beside sys.executable, +# fixed argv, no shell (S603 gated at the call site below). +import subprocess +import sys +import tomllib +from collections.abc import Iterator +from pathlib import Path + +from cf_quality.errors import GateError, GateViolation +from cf_quality.repo_config import resolve_source_root + +BASELINE_FILENAME = "import-contract-baseline.json" +_PINNED_ALERTING = "error" +_CLAUSE_MODULE_KEYS = ("source_modules", "forbidden_modules", "modules", "layers", "containers") +_STALE_IGNORE_MARKER = "No matches for ignored import" +_SCOPE_NODES = (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda) + + +def _config_error(message: str, context: dict[str, object]) -> GateError: + return GateError(code="CONTRACT_CONFIG_ERROR", message=message, context=context) + + +def _load_contract_table(root: Path) -> dict[str, object] | None: + """The ``[tool.importlinter]`` table of the repo's pyproject, None when absent.""" + path = root / "pyproject.toml" + if not path.is_file(): + return None + try: + data = tomllib.loads(path.read_text(encoding="utf-8")) + except (OSError, tomllib.TOMLDecodeError) as exc: + raise _config_error(f"cannot parse pyproject.toml: {exc}", {"path": str(path)}) from exc + tool = data.get("tool") + if not isinstance(tool, dict): + return None + table = tool.get("importlinter") + return table if isinstance(table, dict) else None + + +def _load_baseline(root: Path) -> int: + """The committed ignore-count baseline; ABSENT means zero, never a free pass.""" + path = root / BASELINE_FILENAME + if not path.is_file(): + return 0 + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + raise _config_error( + f"cannot parse {BASELINE_FILENAME}: {exc}", {"path": str(path)} + ) from exc + value = data.get("ignored_imports") if isinstance(data, dict) else None + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + raise _config_error( + f"{BASELINE_FILENAME} must carry a non-negative integer 'ignored_imports'", + {"path": str(path), "value": repr(value)}, + ) + return value + + +def _contracts(table: dict[str, object]) -> list[dict[str, object]]: + raw = table.get("contracts") + if not isinstance(raw, list): + return [] + return [clause for clause in raw if isinstance(clause, dict)] + + +def _ignore_entries(contracts: list[dict[str, object]]) -> list[str]: + entries: list[str] = [] + for clause in contracts: + raw = clause.get("ignore_imports") + if isinstance(raw, list): + entries.extend(entry for entry in raw if isinstance(entry, str)) + return entries + + +def _named_top_level(contracts: list[dict[str, object]]) -> set[str]: + """Top-level package names appearing in any contract clause's module fields.""" + named: set[str] = set() + for clause in contracts: + for key in _CLAUSE_MODULE_KEYS: + raw = clause.get(key) + if isinstance(raw, list): + named.update(m.split(".")[0] for m in raw if isinstance(m, str)) + return named + + +def _top_level_packages(source_root: Path) -> list[str]: + """Importable top-level packages (dirs carrying ``__init__.py``) under the root.""" + return sorted( + child.name + for child in source_root.iterdir() + if child.is_dir() and (child / "__init__.py").is_file() + ) + + +def _contract_violation(code: str, message: str, context: dict[str, object]) -> GateViolation: + return GateViolation(code=code, message=message, path="pyproject.toml", context=context) + + +def _lint_ignores(ignores: list[str], baseline: int) -> list[GateViolation]: + violations = [ + _contract_violation( + "CONTRACT_LINT_WILDCARD", + f"wildcard ignore_imports entry guts the contract: {entry!r} — " + "every ignored edge is enumerated, never globbed", + {"entry": entry}, + ) + for entry in ignores + if "*" in entry + ] + if len(ignores) > baseline: + violations.append( + _contract_violation( + "CONTRACT_LINT_RATCHET", + f"ignore_imports count {len(ignores)} exceeds the committed baseline " + f"{baseline} ({BASELINE_FILENAME}; absent means zero) — the ratchet " + "only shrinks", + {"count": len(ignores), "baseline": baseline}, + ) + ) + return violations + + +def _lint_pinned_settings( + table: dict[str, object], contracts: list[dict[str, object]] +) -> list[GateViolation]: + violations: list[GateViolation] = [] + if table.get("exclude_type_checking_imports") is True: + violations.append( + _contract_violation( + "CONTRACT_LINT_PINNED_CONFIG", + "exclude_type_checking_imports = true loosens the kit pin — " + "TYPE_CHECKING edges stay under contract", + {"setting": "exclude_type_checking_imports"}, + ) + ) + for clause in contracts: + alerting = clause.get("unmatched_ignore_imports_alerting", _PINNED_ALERTING) + if alerting != _PINNED_ALERTING: + violations.append( + _contract_violation( + "CONTRACT_LINT_PINNED_CONFIG", + f"unmatched_ignore_imports_alerting = {alerting!r} downgrades the " + "kit pin ('error') — the baseline self-cleans only while stale " + "ignores fail", + { + "setting": "unmatched_ignore_imports_alerting", + "contract": clause.get("name"), + }, + ) + ) + return violations + + +def _lint_exhaustiveness(root: Path, contracts: list[dict[str, object]]) -> list[GateViolation]: + named = _named_top_level(contracts) + return [ + _contract_violation( + "CONTRACT_LINT_EXHAUSTIVENESS", + f"top-level package '{package}' is named in no contract clause — " + "every package under the source root sits inside the contract", + {"package": package}, + ) + for package in _top_level_packages(resolve_source_root(root)) + if package not in named + ] + + +def lint_contract(root: Path) -> list[GateViolation]: + """PASS 1 — lint the committed contract file itself, before lint-imports.""" + root = root.resolve() + table = _load_contract_table(root) + if table is None: + return [] + contracts = _contracts(table) + violations = _lint_ignores(_ignore_entries(contracts), _load_baseline(root)) + violations += _lint_pinned_settings(table, contracts) + violations += _lint_exhaustiveness(root, contracts) + return violations + + +def _read_tree(path: Path) -> ast.Module: + try: + source = path.read_text(encoding="utf-8") + return ast.parse(source, filename=str(path)) + except (OSError, SyntaxError) as exc: + raise _config_error( + f"cannot read/parse source file under contract: {path}", + {"path": str(path), "detail": str(exc)}, + ) from exc + + +def _module_level_calls(tree: ast.Module) -> Iterator[ast.Call]: + """Calls that execute at import time — never descends into function scopes. + + Iterative walk over the finite parsed tree (explicit pending stack). + """ + pending: list[ast.AST] = list(tree.body) + while pending: + node = pending.pop() + if isinstance(node, _SCOPE_NODES): + continue + if isinstance(node, ast.Call): + yield node + pending.extend(ast.iter_child_nodes(node)) + + +def _is_dynamic_import(call: ast.Call) -> bool: + func = call.func + if isinstance(func, ast.Name): + return func.id == "__import__" + return isinstance(func, ast.Attribute) and func.attr == "import_module" + + +def scan_dynamic_imports(root: Path) -> list[GateViolation]: + """PASS 3 — module-level dynamic imports inside contract-protected layers.""" + root = root.resolve() + table = _load_contract_table(root) + if table is None: + return [] + source_root = resolve_source_root(root) + protected = _named_top_level(_contracts(table)) & set(_top_level_packages(source_root)) + violations: list[GateViolation] = [] + for package in sorted(protected): + for path in sorted((source_root / package).rglob("*.py")): + rel = path.relative_to(root).as_posix() + violations.extend( + GateViolation( + code="CONTRACT_DYNAMIC_IMPORT", + message="module-level dynamic import inside a contract-protected " + "layer — an import the static contract cannot see, and the " + "order-dependent-crash shape", + path=rel, + line=call.lineno, + context={"package": package}, + ) + for call in _module_level_calls(_read_tree(path)) + if _is_dynamic_import(call) + ) + return violations + + +def _run_lint_imports(root: Path) -> tuple[int, str]: + """Run the kit-pinned lint-imports with cwd = the repo root (flat layouts resolve).""" + executable = Path(sys.executable).parent / "lint-imports" + if not executable.is_file(): + raise _config_error( + "lint-imports is not installed beside the running Python — install the " + "kit's [dev] extra (it pins import-linter)", + {"expected": str(executable)}, + ) + try: + proc = subprocess.run( # noqa: S603 — pinned venv executable, fixed argv, no shell + [str(executable), "--no-cache"], + cwd=root, + capture_output=True, + text=True, + check=False, + ) + except OSError as exc: + raise _config_error( + f"lint-imports could not be executed: {exc}", {"executable": str(executable)} + ) from exc + return proc.returncode, proc.stdout + proc.stderr + + +def _edge_lines(output: str) -> list[str]: + return [line.strip() for line in output.splitlines() if "->" in line] + + +def _report_pass_two(exit_code: int, output: str) -> int: + """Classify the observed lint-imports verdict; config trouble raises typed.""" + if exit_code == 0: + return 0 + if _STALE_IGNORE_MARKER in output: + print( + "CONTRACT_STALE_IGNORE: ignore_imports entries match no real edge — " + "remove them; the baseline self-cleans" + ) + elif "BROKEN" in output: + print("CONTRACT_BROKEN: lint-imports reports the committed contract violated") + else: + raise _config_error( + "lint-imports failed without a contract verdict (environment/config " + "trouble, e.g. an uninstalled src-layout package)", + {"exit_code": exit_code, "output_tail": output.strip().splitlines()[-3:]}, + ) + for line in _edge_lines(output): + print(line) + return 1 + + +def _missing_contract_verdict(root: Path) -> int: + packages = _top_level_packages(resolve_source_root(root)) + if not packages: + print("cf-import-contract: OK (no top-level packages, no contract required)") + return 0 + print( + f"CONTRACT_MISSING: top-level package(s) {', '.join(packages)} carry no " + "committed import contract ([tool.importlinter] in pyproject.toml)" + ) + return 1 + + +def _print_violations(violations: list[GateViolation]) -> None: + for violation in violations: + location = ( + f"{violation.path}:{violation.line}" if violation.line is not None else violation.path + ) + print(f"{location}: {violation.code}: {violation.message}") + + +def _run_gate(root: Path) -> int: + table = _load_contract_table(root) + if table is None: + return _missing_contract_verdict(root) + lint_violations = lint_contract(root) + if lint_violations: + _print_violations(lint_violations) + return 1 + exit_code, output = _run_lint_imports(root) + if _report_pass_two(exit_code, output) != 0: + return 1 + for entry in _ignore_entries(_contracts(table)): + print(f"ignored edge honored (enumerated, baseline-counted): {entry}") + dynamic_violations = scan_dynamic_imports(root) + if dynamic_violations: + _print_violations(dynamic_violations) + return 1 + print( + "cf-import-contract: OK (contract linted, lint-imports kept, no module-level " + "dynamic imports)" + ) + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Console entry point. Exit 0 clean, 1 on violations, 2 on gate error.""" + parser = argparse.ArgumentParser( + prog="cf-import-contract", + description="The committed import contract holds — layering direction is gated.", + ) + parser.add_argument("--root", default=".", help="repo root (default: cwd)") + args = parser.parse_args(argv) + try: + return _run_gate(Path(args.root).resolve()) + except GateError as exc: + print(json.dumps(exc.to_dict(), ensure_ascii=False), file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/templates/import-contract.toml b/templates/import-contract.toml new file mode 100644 index 0000000..44a29e9 --- /dev/null +++ b/templates/import-contract.toml @@ -0,0 +1,28 @@ +# Blessed import-contract template — cf-import-contract (the layering gate). +# +# Copy the [tool.importlinter] table below into the consumer repo's +# pyproject.toml and adapt root_packages + the contract clauses to the repo's +# real layers. The PINNED settings are load-bearing and may not be loosened — +# pass 1 of the gate fails the contract file itself if they drift: +# +# include_external_packages = false # the contract gates THIS repo's layers +# exclude_type_checking_imports = false # TYPE_CHECKING edges stay under contract +# unmatched_ignore_imports_alerting = "error" # stale ignores fail; the +# # committed baseline self-cleans +# +# ignore_imports entries are ENUMERATED edges only (no wildcards) and ratchet +# shrink-only against the committed import-contract-baseline.json +# ({"ignored_imports": N}; absent means zero). Every top-level package under +# the source root must be named in some contract clause (exhaustiveness). + +[tool.importlinter] +root_packages = ["core", "tenants"] +include_external_packages = false +exclude_type_checking_imports = false + +[[tool.importlinter.contracts]] +name = "core does not import tenants" +type = "forbidden" +source_modules = ["core"] +forbidden_modules = ["tenants"] +unmatched_ignore_imports_alerting = "error" From 38d79d7006079ffd2cf6ab95354433a9ba2a6b6c Mon Sep 17 00:00:00 2001 From: Antawari de la Torre Cobos Date: Thu, 11 Jun 2026 02:15:13 -0600 Subject: [PATCH 3/4] =?UTF-8?q?fix:=20ruff=20gauge=20drift=20in=20the=20ga?= =?UTF-8?q?te's=20test=20battery=20(I001=20import=20order=20+=20format)=20?= =?UTF-8?q?=E2=80=94=20present=20at=20the=20previous=20commit=20but=20mask?= =?UTF-8?q?ed=20by=20a=20stale=20ruff=20cache;=20surfaced=20by=20a=20no-ca?= =?UTF-8?q?che=20battery=20run?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- tests/test_import_contract.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/tests/test_import_contract.py b/tests/test_import_contract.py index c55710b..7b6fe5e 100644 --- a/tests/test_import_contract.py +++ b/tests/test_import_contract.py @@ -37,9 +37,9 @@ from pathlib import Path import pytest -from cf_quality.import_contract import lint_contract, main, scan_dynamic_imports from cf_quality.errors import GateError, GateViolation +from cf_quality.import_contract import lint_contract, main, scan_dynamic_imports KIT_ROOT = Path(__file__).resolve().parents[1] BASELINE_FILENAME = "import-contract-baseline.json" @@ -334,11 +334,7 @@ def test_r5_type_checking_guarded_upward_import_fails( ) -> None: _mount( tmp_path, - core=( - "from typing import TYPE_CHECKING\n" - "if TYPE_CHECKING:\n" - " import tenants.acme\n" - ), + core=("from typing import TYPE_CHECKING\nif TYPE_CHECKING:\n import tenants.acme\n"), ) exit_code = _run_main(tmp_path, monkeypatch) out = capsys.readouterr().out From 229e2af036fee0c62aa027d295121e3843825229 Mon Sep 17 00:00:00 2001 From: Antawari de la Torre Cobos Date: Thu, 11 Jun 2026 02:15:13 -0600 Subject: [PATCH 4/4] feat: the kit submits to its own import contract (self-run drew CONTRACT_MISSING) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running cf-import-contract against this repo itself reported CONTRACT_MISSING for cf_quality — the gauge did not yet carry the contract it ships. Fix, not baseline (the gate's only baseline surface is the ignore-count ratchet; a missing contract has no baseline path): - pyproject.toml: the kit's own [tool.importlinter] contract — the six gate modules sit above repo_config above errors, pinned settings per templates/import-contract.toml. Refuted against the live oracle: an injected errors -> repo_config edge draws CONTRACT_BROKEN with the named edge + line; reverting restores green. - self-ci: the battery now runs cf-import-contract --root . so the self-policing survives in CI, not just in one verifier run. - test_packaging.py: the clean-wheel harsh oracle extended to the new console script — a non-editable install still delivers the CONTRACT_MISSING verdict (exit 1), never an ImportError crash. Suite: 296 passed. Battery: ruff/format/sticky/file-budget/recursion/ exemptions/import-contract/mypy all green, no-cache. Co-Authored-By: Claude Fable 5 --- .github/workflows/self-ci.yml | 3 +++ pyproject.toml | 21 +++++++++++++++++++++ tests/test_packaging.py | 20 ++++++++++++++++++++ 3 files changed, 44 insertions(+) diff --git a/.github/workflows/self-ci.yml b/.github/workflows/self-ci.yml index d26fa3b..4c4dac7 100644 --- a/.github/workflows/self-ci.yml +++ b/.github/workflows/self-ci.yml @@ -61,6 +61,9 @@ jobs: - name: cf-exemptions — the kit's suppressions trace to reasoned entries run: cf-exemptions + - name: cf-import-contract — the kit's own layering contract holds + run: cf-import-contract --root . + - name: mypy (zero baseline — the new repo owes 0 type errors, bare) run: mypy src diff --git a/pyproject.toml b/pyproject.toml index d8b08f3..8e7353f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -45,6 +45,27 @@ where = ["src"] # non-editable install (CI's exact mode) carries its own gauge data. cf_quality = ["data/*.md"] +[tool.importlinter] +# The kit's OWN import contract — the gauge submits to the gate it ships +# (cf-import-contract; pinned settings per templates/import-contract.toml). +root_packages = ["cf_quality"] +include_external_packages = false +exclude_type_checking_imports = false + +[[tool.importlinter.contracts]] +# The kit's measured layering: the six gate modules sit above repo_config +# (declared-layout resolution) above errors (the typed failure vocabulary). +# Sibling gates are independent — no gate imports another gate. +name = "the gates sit above repo_config above errors" +type = "layers" +containers = ["cf_quality"] +layers = [ + "exemptions | file_budget | import_contract | mirror_check | recursion_check | sticky_check", + "repo_config", + "errors", +] +unmatched_ignore_imports_alerting = "error" + [tool.ruff] line-length = 100 target-version = "py312" diff --git a/tests/test_packaging.py b/tests/test_packaging.py index a4e6faf..2ec0ba7 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -91,3 +91,23 @@ def test_clean_wheel_install_reports_absence_not_gate_failure( proc = _run([str(script), "check", str(fixture)]) assert proc.returncode == 1, f"exit {proc.returncode}:\n{proc.stdout}\n{proc.stderr}" assert "STICKY_INTRO_ABSENT" in proc.stdout + + +def test_import_contract_verdict_rides_a_clean_wheel_install( + clean_install: Path, tmp_path: Path +) -> None: + # The same harsh oracle for the layering gate: from a NON-EDITABLE install + # (no source tree on sys.path) the console script must still deliver a + # verdict. The CONTRACT_MISSING path never reaches lint-imports, so the + # clean venv needs only the wheel — exit 1 with the verdict, never an + # ImportError crash. + fixture = tmp_path / "consumer" + (fixture / "src" / "widget").mkdir(parents=True) + (fixture / "src" / "widget" / "__init__.py").write_text("", encoding="utf-8") + (fixture / "pyproject.toml").write_text( + '[project]\nname = "consumer"\nversion = "0.0.0"\n', encoding="utf-8" + ) + script = clean_install / "bin" / "cf-import-contract" + proc = _run([str(script), "--root", str(fixture)]) + assert proc.returncode == 1, f"exit {proc.returncode}:\n{proc.stdout}\n{proc.stderr}" + assert "CONTRACT_MISSING" in proc.stdout