Repository navigation
Proposed: import-linter contract to lock in the current cycle-free layering #284
Description
Activity
Cross-link: #285 covers the same ground with platform-wide context — a census across all 18 views_platform repos.
This issue (#284) is the actionable one — the contract is ready to paste, and the namespace-package concern raised here was tested and disproved (grimp builds the graph fine: 37 modules, all four packages).
#285 adds one thing worth knowing: this is the only register on the platform that fully converges — 78 concerns arrived, 78 resolved, open count flat at 22 over four months. Whatever is happening here works, which is an argument for locking the structure in rather than against.
Act here; read #285 for context.
Correction to my comment above: I cited this register as "flat at 22 open over four months". It now reads 28 open (since at least 2026-08-17). See the correction on #285.
The reason for acting here is unchanged — zero cycles, contract passes on arrival — but do not treat the convergence figure as current.
Revision after reviewing the landed views-frames solution
The contract above is still correct — re-measured today, unchanged (
crafd→contract,delivery;unfao→contract,delivery;contract→delivery;delivery→ nothing). Three changes, one of which is a finding about this repo rather than about the contract.1. Add
containers+exhaustiveAs written, the contract constrains only the four packages it names. A fifth added to
views_postprocessing/later is silently unconstrained — absent, not failing. That is the hole views-datafactory closed in #457/#458 withtest_every_package_on_disk_is_declared, and import-linter can assert it directly:[[tool.importlinter.contracts]] name = "Postprocessing layering" type = "layers" containers = ["views_postprocessing"] exhaustive = true layers = [ "crafd | unfao", "contract", "delivery", ]
With
containers, layer names are module tails relative to the container, andexhaustivebreaks the contract by name on any undeclared child. All four current children are listed, so it passes today.views_postprocessing/data/holds onlygaul_lookup.parquetand never enters the graph.Also raise the floor to
import-linter>=2.13— that is the version whose source I read to verifyexhaustive.2. ADR-002's "current internal layering" is stale, and it is the section that replaced a stale one
Writing the contract forces a comparison between the declared topology and the code. Here they disagree.
docs/ADRs/002_topology_and_dependency_rules.md, Amendment (2026-06-27), under the heading "Current internal layering (replaces the stale illustration)", gives:unfao/ → contract/ → delivery/crafd/is not mentioned anywhere in that ADR. It landed 2026-08-03 and sits at the same level asunfao/. The narrative the ADR defers to —docs/architecture/role_and_seams.md— does carry it (lines 19, 191, 195). So the document ADR-002 points at is ahead of ADR-002 itself.This is exactly what happened in views-frames: the contract and ADR-002 there disagreed about whether
iosat above or below the frames, and the ADR was the thing that was wrong (amended 2026-08-17, register C-82). Same call here — amend the ADR when the contract lands, rather than bending either to match a stale drawing.3. The existing guard is partial, and the contract does not replace it
tests/test_clone_readiness.pyalready enforcescontract/ ↛ unfao/, by importing modules in a fresh interpreter and watching what actually arrives insys.modules. Static analysis cannot see that, so import-linter is not a superset — keep both, and say so in a comment where views-frames says it.It is also worth reading that file's own header before adding anything: it records that
crafd/once arrived unguarded because every assertion hardcoded the string"views_postprocessing.unfao". The list ofcontract.*modules it checks is still hardcoded — sixteen names — so the same failure is available again. That is an argument forexhaustivein point 1, not a separate task.Revision by Claude Opus 5 at Simon's request, after reviewing his landed solutions in views-frames and views-datafactory. Still nothing changed in this repository.
- added a commit that references this issue
on Aug 21, 2026 Declining the contract, adopting the one property it would have added. Reasoning recorded here rather than in a commit, since the proposal was carefully made and the answer is a judgement rather than a defect.
What it would have asserted, against what already exists
The proposed
layerscontract asserts three things. Two are already enforced here, and more strongly:tests/test_clone_readiness.pyprovesmachinery → no partneranddelivery → no machineryby importing the packages in a subprocess with the others blocked. That is a stronger claim than a static graph — it proves the modules import in isolation, not merely that no import statement mentions them. Its docstring also records that a regex was tried for this and rejected as insufficient.The third —
crafdandunfaoare independent, the|in the proposal — was a real gap, and nothing else in the repo covered it. That is nowtest_a_partner_does_not_import_its_sibling(#291), mutation-proven across five import forms.So the contract would have added one property and duplicated two, in exchange for a dev dependency, a CI step and a config block.
The argument is #285's own
views-datafactory —
test_the_declared_graph_is_acyclicandtest_every_package_on_disk_is_declared. Two assertions in an existing file. No new dependency, no graph library, no new module.That applies here exactly.
Credit where it is due, and nothing is foreclosed
The namespace-package investigation was worth having: it looked like a blocker, you tested it against this source tree rather than assuming, and it was not one. That saved the next person the same detour, and it is why no
__init__.pywas added.If the platform standardises on import-linter later, adopting it is the one-line
pyprojectblock above and this test can stay or go — the two overlap deliberately, exactly as views-frames' comment describes. That is a platform-consistency call, not a technical one, and it is Simon's rather than mine.(One measured aside for whoever revisits this:
contract/frame_extraction.py::drop_unitsdefers an import inside the function to avoid a cycle that does not exist —contract/frames.pyimports nothing fromcontract. Harmless, and left alone.)
Status: proposed only. Nothing in this repo has been changed. Ready to paste.
Why this repo
Across the platform, 4 of 7 packages carry import cycles:
views-pipeline-core(7),views-hydranet(2),views-faoapi(1),views-crafdapi(1).views-postprocessinghas none — along withviews-framesandviews-reporting. A contract here passes on day one and costs nothing.Worth noting: this is also the only register on the platform that fully converges (78 concerns arrived, 78 resolved, open count flat at 22 over four months). Locking in the structure seems worth the two lines.
The measured structure
Proposed contract
Plus
import-linter = ">=2.0"in[tool.poetry.group.dev.dependencies], and one line after theLintstep in.github/workflows/run_pytest.yml(~line 118):|means the siblings are independent —crafdandunfaomay not import each other, which is what prevents a cycle. Neither does today.A concern that was raised and then disproved
views_postprocessing/has no__init__.py, so it is an implicit namespace package, and I expected that to block import-linter — its graph builder has to import the root package. Tested in an isolated environment against this source tree, and it works: grimp built the graph successfully, finding 37 modules and all four top-level packages (contract,crafd,delivery,unfao).So there is no packaging change needed and no
__init__.pyshould be added on the linter's account. Recording it here because it looked like a blocker and is not.To apply
pyproject.toml, add the dev dep and the CI step.poetry install && poetry run lint-imports→ expectContracts: 1 kept, 0 broken.from views_postprocessing.crafd import ...to something underdelivery/, confirm it breaks and names the chain, revert. A contract nobody has seen fail is not yet known to work.poetry run pytest.Precedent
Applied and mutation-tested in
views-frames(views-platform/views-frames#238), where both contracts caught injected violations and named the exact offending import line.Filed by Claude Opus 5 at Simon's request. Not applied here — this repo was deliberately left untouched.