Skip to content

Check the v2 import architecture with Import Linter in CI - #4184

Draft
anth-volk wants to merge 9 commits into
masterfrom
v2-architecture-import-linter
Draft

anth-volk wants to merge 9 commits into
masterfrom
v2-architecture-import-linter

Conversation

@anth-volk

@anth-volk anth-volk commented Nov 4, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #4181

Summary

Runs Import Linter in CI to check which v2 modules may import which, directly and through other modules, as docs/engineering/skills/v2-code-organization.md describes. Step 2.6 of the agent-proof code-quality plan (its Import Linter part). It builds on #4179 (merged), which removed the import of SQLAlchemy through catalog_selection that would otherwise fail the second contract.

Contracts ([tool.importlinter] in pyproject.toml)

Contract id Rule Checks Status
api-v2-layers api.v2.import-layers Layers fastapi_routes.v2 > services.v2 > data.v2: no import, direct or indirect, goes from a lower layer to a higher one enforced
api-v2-pure-modules-no-database api.v2.pure-modules-no-database v2 validators, transformations and types modules reach no sqlalchemy, sqlmodel, connector, database_session or data.v2.database module, directly or indirectly, except through the table models ratcheted, 1 exception
api-v2-routes-no-database-access api.v2.routes-import-no-database-access v2 route modules import no connector, database_session or data.v2.database module. Direct imports only: routes reach them through fastapi_routes/dependencies.py, which builds each service with its session, and through the services they call enforced
api-v2-types-transformations-no-validators api.v2.types-transformations-no-validators v2 types and transformations modules import no validators module, directly or indirectly ratcheted, 4 exceptions
api-v2-connectors-independent api.v2.connectors-no-cross-calls (doc-only before) No v2 connector module imports another ratcheted, 7 exceptions

Decisions recorded on 2026-11-04 (tracker 6.3 · 1): imports made only under TYPE_CHECKING count (exclude_type_checking_imports = false), and types and transformations may not import validators, with today's 4 imports tolerated.

The table models are SQLModel classes that validators and transformations may use, so the pure-modules contract ignores the models' own imports of SQLAlchemy and SQLModel: 25 imports, listed module by module. They are part of the rule (enforcer.allowed_ignore_imports), not tolerated violations. A new module in data/v2/models/ is not covered until the rule lists it.

Module patterns use ** (policyengine_api.services.v2.**.validators and so on), so a module in a nested resource package is covered too.

Tolerated exceptions (12)

  • services.v2.identity.types -> sqlmodel: a Session annotation in a Protocol, under TYPE_CHECKING. Ignoring an import removes it from the graph the contract checks, so this one exception also covers the four modules that reach SQLModel only through it: identity.transformations, user_accounts.types, user_accounts.transformations and user_accounts.validators. The same holds for chains through the validators exceptions. A module added later can also reach a forbidden module through a tolerated import without a registry change. Each such chain goes away when its tolerated import is fixed.
  • households.types, households.transformations, policies.types and policies.transformations import their resource's validators. policies.validators imports policies.types back inside a function, so the pair is an import cycle.
  • The 7 metadata reads_* connectors import metadata.database_connectors.reads for its read_rows helper.

Keeping the exceptions shrink-only

  • Each contract with exceptions sets unmatched_ignore_imports_alerting = "error", so fixing an import fails Import Linter until its entry is removed.
  • Each ratcheted rule's baseline file under rules/baselines/ lists the same entries, one per line. The rules guard (scripts/rules/import_linter.py, new) fails when a contract's ignore_imports differs from its rule's baseline plus allowed_ignore_imports, when the contract doesn't set unmatched alerting to error, or when any entry uses a wildcard. So a new exception needs a change under the code-owned rules/ directory, and the guard's existing count comparison against the base branch stops a baseline's count from growing. Swapping one entry for another at the same count is caught only by code-owner review until Check declared dependencies with deptry, with today's findings as a baseline #4178's entry comparison lands.
  • enforcer.allowed_ignore_imports has no count limit, so moving a tolerated import into it would lower the baseline count without fixing anything. Code-owner review of rules/registry.toml is the check on that list; rules-registry.md says so.
  • Each rule pins the rest of its contract with enforcer.contract_sha256: the SHA-256 of the top-level [tool.importlinter] settings and every contract field except ignore_imports. Narrowing a contract's modules in pyproject.toml, or setting exclude_type_checking_imports = true, fails the guard until the registry pins the new definition; the guard's message gives the value.
  • An exception covers every import between its two modules, not one import statement, and Import Linter doesn't see names re-exported through another module. v2-code-organization.md says so.
  • The guard also reports an Import Linter contract in pyproject.toml that no rule names, or one without an id, as a hidden rule.

CI and environment variables

  • New step in the quality-guards job of pr.yml: uv run --frozen --extra dev lint-imports --config pyproject.toml. Naming the config matters: Import Linter reads setup.cfg and .importlinter before pyproject.toml. The rules guard requires each Import Linter rule's CI job to run exactly lint-imports --config pyproject.toml (optionally after uv run ...), so dropping the flag, or adding --contract or || true, fails it. It runs after the rules-tool tests, which already install the dev environment from uv.lock. The job is required through CI complete.
  • Environment variables: the new step and Import Linter read none. The job-level UV_PYTHON: "3.12" (read by uv) is a literal in pr.yml. The rules guard, run by the existing "Run quality guards" step, reads GITHUB_BASE_REF and GITHUB_ACTIONS, which the GitHub Actions runner sets on every pull_request run; this PR adds no reads.
  • Known limit of the rules tool, not changed here: it doesn't read continue-on-error: true on a step. Reviewers still need to read workflow diffs.
  • push.yml's quality-guards job doesn't run Import Linter, but test_the_repository_keeps_every_import_contract runs the real contracts under make test, so the release tests on master catch two pull requests that pass apart but break a contract together.
  • import-linter>=2.15,<3 is added to the dev extra, and uv.lock gains import-linter 2.15 and grimp 3.18.
  • make quality doesn't exist on master yet. Whichever of this PR and the make quality PR (tracker 6.4 · 8) merges second adds lint-imports to it.

Docs

v2-code-organization.md cites each new rule id where it states the rule. rules-registry.md describes the allowed_ignore_imports field, the baseline check and the new hidden-rule check. docs/generated/rules.md and AGENTS.md are regenerated.

Rebase and expected text conflicts

After #4179 merged, this branch was rebased onto master. Only docs/generated/rules.md conflicted, and it was regenerated at each step.

Other open rule PRs also edit rules/registry.toml, docs/generated/rules.md, AGENTS.md, pyproject.toml and uv.lock, for example #4178 (deptry). Whichever merges second regenerates the rendered files with python -m scripts.rules render and relocks with uv lock.

Testing

Tests first, in commits that failed before the change: 1df4bc7 and cd0c22a, then 1fc3e30, c2406ef and 6b3c890 for the review findings (a wildcard exception, a types module reaching validators through a helper, the CI step naming its config, pinned contract definitions, a non-TOML config, nested modules, the exact CI command and empty enforcer fields).

  • tests/unit/v2/test_import_linter_contracts.py: one proof per rule. Each proof writes the fixture package from rules/fixtures/<rule id>/bad_package.toml or good_package.toml into a temporary directory and runs the rule's real contract from pyproject.toml on it, with policyengine_api renamed. Each bad package breaks the contract once per source and forbidden module pattern the rule names, including in a nested resource package, and the proof requires Import Linter to report exactly its expected_violations. The good package must report 1 kept, 0 broken. The pure-modules bad fixture has the shape of the former catalog_selection chain, and the routes bad fixture is a route that imports a connector. test_imports_made_only_under_type_checking_count runs the routes contract on a route that imports a connector only under TYPE_CHECKING and requires it to break. test_every_contract_module_expression_matches_a_module requires every module pattern in the contracts to match a module, so a rename can't leave a contract checking nothing.
  • scripts/rules/tests/test_import_linter.py: the guard finds the contract; it rejects an exception missing from the baseline, a baseline entry the contract doesn't ignore, a wildcard in a tolerated or an allowed entry, missing unmatched alerting, a non-list allowed_ignore_imports, a changed contract or top-level setting, a rule without a fingerprint, a non-TOML config, an empty config or contract, and a CI command other than exactly lint-imports --config pyproject.toml; and it reports unregistered contracts and contracts without an id.

After the change:

  • the 9 tests in test_import_linter_contracts.py and the 18 in scripts/rules/tests/test_import_linter.py pass;
  • lint-imports --config pyproject.toml reports 5 contracts kept, 0 broken, in under a second;
  • ruff format --check ., ruff check . and mypy pass;
  • python scripts/run_quality_guards.py passes;
  • python -m scripts.rules guard --base-ref origin/master passes;
  • uv lock --check passes.

🤖 Generated with Claude Code

@anth-volk
anth-volk added this pull request to stack #4185 November 4, 2026 11:54
@anth-volk anth-volk changed the title v2 architecture import linter Check the v2 import architecture with Import Linter in CI Nov 4, 2026
Base automatically changed from v2-architecture-catalog-split to master November 4, 2026 16:52
anth-volk and others added 9 commits November 4, 2026 20:55
- tests/unit/v2/test_import_linter_contracts.py: proofs for five rules.
  Each writes a fixture package from rules/fixtures/<rule id>/ and runs
  the rule's contract from pyproject.toml on it, renamed to the fixture
  package; the bad package must break the contract and the good one keep
  it. Another test pins that TYPE_CHECKING imports count.
- scripts/rules/tests/test_import_linter.py: the rules guard must find the
  rule's contract, require its ignore_imports to equal the rule's baseline
  plus enforcer.allowed_ignore_imports, and require
  unmatched_ignore_imports_alerting = "error".

These fail until the contracts and the guard checks exist.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iling)

The rules guard reports checks the registry doesn't describe. These tests
require it to report an Import Linter contract in pyproject.toml that no
rule names, and a contract with no id, which no rule could name.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Add Import Linter as a dev dependency and five contracts in
[tool.importlinter] in pyproject.toml, run by `lint-imports` in the
quality-guards job of pr.yml. Imports made only under TYPE_CHECKING count.

- api-v2-layers: fastapi_routes.v2 > services.v2 > data.v2.
- api-v2-pure-modules-no-database: validators, transformations and types
  reach no SQLAlchemy, SQLModel, connector, session or v2 database
  module, except through the table models. 1 tolerated exception.
- api-v2-routes-no-database-access: routes import no connector, session
  or v2 database module.
- api-v2-types-transformations-no-validators: 4 tolerated exceptions.
- api-v2-connectors-independent: 7 tolerated exceptions.

Each contract is a rule in rules/registry.toml with proof fixtures;
api.v2.connectors-no-cross-calls moves from doc-only to ratcheted. The
tolerated exceptions are also listed in rules/baselines/, and the rules
guard now fails when a contract's ignore_imports differ from its rule's
baseline plus enforcer.allowed_ignore_imports, when unmatched exceptions
are not errors, and when a contract in pyproject.toml has no rule.

Fixes #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A tolerated exception written with a wildcard, such as
  "pkg.pure.* -> sqlalchemy", must fail the rules guard: one entry could
  replace several, lower the baseline count, and still ignore new
  violations.
- The types-and-transformations proof's bad package now reaches its
  validators through a helper module, which the contract misses while it
  checks direct imports only.
- The five Import Linter rules now require the CI step to run
  "lint-imports --config pyproject.toml", so a setup.cfg or .importlinter
  file can't silently replace the contracts.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The rules guard rejects a wildcard in a tolerated exception; only
  enforcer.allowed_ignore_imports may use one, so a baseline's count is
  its number of tolerated imports.
- api-v2-types-transformations-no-validators checks imports through other
  modules too. The real code still keeps it with the same 4 exceptions.
- CI runs "lint-imports --config pyproject.toml".
- The docs and the pyproject.toml comment say the routes contract checks
  direct imports only, and why: routes reach connectors and session
  modules through fastapi_routes/dependencies.py and their services. They
  also say an exception covers every import between its two modules and
  that re-exported names are not seen.
- New tests: a route importing a connector only under TYPE_CHECKING breaks
  its contract (replacing a check of one config key), and every module
  pattern in the contracts matches at least one module, so a renamed
  module can't leave a contract checking nothing.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ling)

- A rule pins its contract's definition with enforcer.contract_sha256:
  the SHA-256 of the top-level [tool.importlinter] settings and every
  contract field except ignore_imports. Narrowing a contract in
  pyproject.toml, or a setting such as exclude_type_checking_imports, must
  fail the rules guard until the code-owned registry pins it again.
- No ignore_imports entry may use a wildcard, including the ones a rule
  allows by design, so a new module can't fall under an old exception.
- A config that isn't TOML is reported instead of crashing the guard.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each Import Linter rule sets enforcer.contract_sha256, the SHA-256 of
  the top-level [tool.importlinter] settings and every contract field but
  ignore_imports. The rules guard fails when pyproject.toml no longer
  matches, so a contract can't be narrowed, or TYPE_CHECKING imports
  excluded, without a change to the code-owned registry.
- The guard rejects a wildcard in any ignore_imports entry. The table
  models' imports of SQLAlchemy and SQLModel are listed module by module
  (25 imports) instead of models.*, so a new module in the models package
  is not exempt until a rule allows it.
- A config that isn't TOML is reported instead of crashing the guard.
- The docs say that an exception also covers the chains through it:
  identity.types -> sqlmodel covers 5 modules.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Each bad fixture now has one violation per source and forbidden module
  pattern its rule names, including a nested resource package, and lists
  them in expected_violations. The proof requires Import Linter to report
  exactly those, so a narrowed contract fails its proof as well as its
  pinned fingerprint. The pure-modules, types-and-transformations and
  connector proofs fail: their single-level * patterns miss the nested
  modules.
- test_the_repository_keeps_every_import_contract runs the real
  contracts, so the release tests catch two pull requests that pass apart
  but break a contract together.
- The rules guard must report an empty enforcer.config or contract, and a
  CI step that runs Import Linter other than exactly
  "lint-imports --config pyproject.toml" (such as with --contract or
  "|| true").

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The contracts' module patterns use ** instead of *, so a validators,
  types, transformations, session or connector module in a nested
  resource package is checked too. The real code keeps every contract
  with the same exceptions; the fingerprints are pinned again.
- The rules guard reports an empty enforcer.config or contract instead of
  skipping its checks, and requires each CI job to run exactly
  "lint-imports --config pyproject.toml" (optionally after "uv run ..."),
  so --contract or "|| true" can't skip contracts unnoticed.
- rules-registry.md no longer says a new module can't fall under an old
  exception: it can through a tolerated import. It also says the
  allowed_ignore_imports list has no count limit, and how the proofs
  cover each pattern.

Refs #4181

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@anth-volk
anth-volk force-pushed the v2-architecture-import-linter branch from 367b2a5 to 5fae4c4 Compare November 4, 2026 16:56
@codecov

codecov Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 88.37%. Comparing base (f3d1943) to head (5fae4c4).
⚠️ Report is 1 commits behind head on master.

Additional details and impacted files
@@             Coverage Diff             @@
##           master    #4184       +/-   ##
===========================================
+ Coverage   53.85%   88.37%   +34.51%     
===========================================
  Files         281      293       +12     
  Lines       16334    17530     +1196     
  Branches     2646     2882      +236     
===========================================
+ Hits         8797    15492     +6695     
+ Misses       6805     1329     -5476     
+ Partials      732      709       -23     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

This branch was successfully deployed

1 active deployment
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Check the v2 import architecture with Import Linter in CI

1 participant