Repository navigation
Conversation
- 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
force-pushed
the
v2-architecture-import-linter
branch
from
November 4, 2026 16:56
367b2a5 to
5fae4c4
Compare
Codecov Report✅ All modified and coverable lines are covered by tests. 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. 🚀 New features to boost your workflow:
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mddescribes. 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 throughcatalog_selectionthat would otherwise fail the second contract.Contracts (
[tool.importlinter]inpyproject.toml)api-v2-layersapi.v2.import-layersfastapi_routes.v2 > services.v2 > data.v2: no import, direct or indirect, goes from a lower layer to a higher oneapi-v2-pure-modules-no-databaseapi.v2.pure-modules-no-databasevalidators,transformationsandtypesmodules reach nosqlalchemy,sqlmodel, connector,database_sessionordata.v2.databasemodule, directly or indirectly, except through the table modelsapi-v2-routes-no-database-accessapi.v2.routes-import-no-database-accessdatabase_sessionordata.v2.databasemodule. Direct imports only: routes reach them throughfastapi_routes/dependencies.py, which builds each service with its session, and through the services they callapi-v2-types-transformations-no-validatorsapi.v2.types-transformations-no-validatorstypesandtransformationsmodules import novalidatorsmodule, directly or indirectlyapi-v2-connectors-independentapi.v2.connectors-no-cross-calls(doc-only before)Decisions recorded on 2026-11-04 (tracker 6.3 · 1): imports made only under
TYPE_CHECKINGcount (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 indata/v2/models/is not covered until the rule lists it.Module patterns use
**(policyengine_api.services.v2.**.validatorsand so on), so a module in a nested resource package is covered too.Tolerated exceptions (12)
services.v2.identity.types -> sqlmodel: aSessionannotation in aProtocol, underTYPE_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.transformationsanduser_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.typesandpolicies.transformationsimport their resource's validators.policies.validatorsimportspolicies.typesback inside a function, so the pair is an import cycle.reads_*connectors importmetadata.database_connectors.readsfor itsread_rowshelper.Keeping the exceptions shrink-only
unmatched_ignore_imports_alerting = "error", so fixing an import fails Import Linter until its entry is removed.rules/baselines/lists the same entries, one per line. The rules guard (scripts/rules/import_linter.py, new) fails when a contract'signore_importsdiffers from its rule's baseline plusallowed_ignore_imports, when the contract doesn't set unmatched alerting toerror, or when any entry uses a wildcard. So a new exception needs a change under the code-ownedrules/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_importshas no count limit, so moving a tolerated import into it would lower the baseline count without fixing anything. Code-owner review ofrules/registry.tomlis the check on that list;rules-registry.mdsays so.enforcer.contract_sha256: the SHA-256 of the top-level[tool.importlinter]settings and every contract field exceptignore_imports. Narrowing a contract's modules inpyproject.toml, or settingexclude_type_checking_imports = true, fails the guard until the registry pins the new definition; the guard's message gives the value.v2-code-organization.mdsays so.pyproject.tomlthat no rule names, or one without anid, as a hidden rule.CI and environment variables
quality-guardsjob ofpr.yml:uv run --frozen --extra dev lint-imports --config pyproject.toml. Naming the config matters: Import Linter readssetup.cfgand.importlinterbeforepyproject.toml. The rules guard requires each Import Linter rule's CI job to run exactlylint-imports --config pyproject.toml(optionally afteruv run ...), so dropping the flag, or adding--contractor|| true, fails it. It runs after the rules-tool tests, which already install the dev environment fromuv.lock. The job is required throughCI complete.UV_PYTHON: "3.12"(read by uv) is a literal inpr.yml. The rules guard, run by the existing "Run quality guards" step, readsGITHUB_BASE_REFandGITHUB_ACTIONS, which the GitHub Actions runner sets on everypull_requestrun; this PR adds no reads.continue-on-error: trueon a step. Reviewers still need to read workflow diffs.push.yml'squality-guardsjob doesn't run Import Linter, buttest_the_repository_keeps_every_import_contractruns the real contracts undermake test, so the release tests onmastercatch two pull requests that pass apart but break a contract together.import-linter>=2.15,<3is added to thedevextra, anduv.lockgainsimport-linter2.15 andgrimp3.18.make qualitydoesn't exist onmasteryet. Whichever of this PR and themake qualityPR (tracker 6.4 · 8) merges second addslint-importsto it.Docs
v2-code-organization.mdcites each new rule id where it states the rule.rules-registry.mddescribes theallowed_ignore_importsfield, the baseline check and the new hidden-rule check.docs/generated/rules.mdandAGENTS.mdare regenerated.Rebase and expected text conflicts
After #4179 merged, this branch was rebased onto
master. Onlydocs/generated/rules.mdconflicted, and it was regenerated at each step.Other open rule PRs also edit
rules/registry.toml,docs/generated/rules.md,AGENTS.md,pyproject.tomlanduv.lock, for example #4178 (deptry). Whichever merges second regenerates the rendered files withpython -m scripts.rules renderand relocks withuv 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 fromrules/fixtures/<rule id>/bad_package.tomlorgood_package.tomlinto a temporary directory and runs the rule's real contract frompyproject.tomlon it, withpolicyengine_apirenamed. 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 itsexpected_violations. The good package must report1 kept, 0 broken. The pure-modules bad fixture has the shape of the formercatalog_selectionchain, and the routes bad fixture is a route that imports a connector.test_imports_made_only_under_type_checking_countruns the routes contract on a route that imports a connector only underTYPE_CHECKINGand requires it to break.test_every_contract_module_expression_matches_a_modulerequires 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-listallowed_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 exactlylint-imports --config pyproject.toml; and it reports unregistered contracts and contracts without an id.After the change:
test_import_linter_contracts.pyand the 18 inscripts/rules/tests/test_import_linter.pypass;lint-imports --config pyproject.tomlreports 5 contracts kept, 0 broken, in under a second;ruff format --check .,ruff check .andmypypass;python scripts/run_quality_guards.pypasses;python -m scripts.rules guard --base-ref origin/masterpasses;uv lock --checkpasses.🤖 Generated with Claude Code