ADR-0051: A rule redirects the imports it sees: graph.redirect, grimp's import of an unwalked module
- Status: Proposed
- Date: 2026-09-27
- Derives from: ADR-0038 (a rule narrows the graph it sees), ADR-0005 (native additions are additive), ADR-0007 (liveness), ADR-0010 (the engine compares strings the document carries), ADR-0013 (the import-linter mapping)
- Constrains:
crates/rb-config/src/model.rs(GraphFilter,RedirectedImports),crates/rb-config/src/normalize.rs,crates/rb-rules/src/graph/view.rs,crates/rb-rules/src/graph/indexed.rs,crates/rb-rules/src/validate.rs,crates/rb-rules/src/slices.rs,crates/rb-cli/src/cmd/import/import_linter.rs,schema/config-v1.json - Implemented by: the same files;
crates/rb-rules/tests/narrowed_graph.rsand thenarrowedimport-linter fixture undercrates/rb-cli/tests/fixtures/import/ - Requirements: FR-RULE-07, FR-RULE-08, NFR-CONF-03
Context
ADR-0038 writes a folder without __init__.py below a root package as graph.modulesNot: every edge from or to a module in it leaves the rule's graph. That is half of what grimp does. grimp walks a root package's regular packages only, so it reads none of the imports of a module in such a folder, which modulesNot reproduces. But when a module grimp does walk imports one inside such a folder, grimp does not always drop the import. grimp 3.17 (the one import-linter 2.15 installs, rust/src/import_scanning.rs) resolves an imported name to the name or its parent when either is a module it found. When neither is, the name is external to it: with include_external_packages off (import-linter's default) the import is dropped, and with it on the name is distilled (_distill_external_module). For each root package with a dotted name that shares the imported name's first component, the name is cut after their common prefix and one more component, and the import is dropped when that root package is a whole prefix of the name; the deepest cut wins; with no such root package the name is cut to its first component.
On langgenius/dify, which sets include_external_packages = True and whose root packages are single names, every such import goes to the root: from services.tools.mcp_tools_manage_service import X in core/mcp/auth_client.py, where services/tools/ has no __init__.py, is core.mcp.auth_client -> services, and from controllers.console.auth.error import X, with controllers/console/ a package and controllers/console/auth/ not, is -> controllers. With a dotted root package it goes elsewhere: were open-metadata/OpenMetadata to set the option, from metadata.profiler.api.models import X would be -> metadata.profiler, because metadata.ingestion is also a root package.
The nightly Python oracle run of 2026-09-27 found the consequence on langgenius/dify. Its backend-layers contract lists core.mcp.auth_client -> services and eight more imports of that shape in ignore_imports, with unmatched_ignore_imports_alerting = error. import-linter matches each against the import it attributed to the package and keeps the contract. In the imported rules the same edge had been removed by modulesNot, so each entry matched nothing and every rule of the contract was vacuous (graph.ignore[<n>]). The harness recorded the contract as an error. The committed result of 2026-09-24 had the same six vacuous rules, and the harness then scored a vacuous rule as agreement.
The ignore entries are only the visible half. Without an entry, grimp's core -> services import breaks a layers contract that puts services above core, while the imported rule, having dropped the edge, keeps it. That would be a silent disagreement on a contract that import-linter breaks. A chain differs as well: grimp continues it from the module it attributed the import to (services/__init__.py's imports), not from the unwalked module, whose imports it never read.
Decision
A dependency rule's graph may carry redirect, a list of { to, into } entries: an edge whose resolved matches to leads to the module into in the rule's graph.
graph:
modulesNot: '^services/tools(/|$)'
redirect:
- { to: '^services/tools(/|$)', into: 'services/__init__.py' }
- Order.
redirectapplies first. The first entry whosetomatches decides, andintois a module path as the document writessource, not a pattern.ignore,modulesNotanddependencyTypesNotthen see the edge where it leads, so in the examplemodulesNotremoves the edges from the unwalked folder and keeps the redirected edges to it. An edge that a redirect leads to its own importer is removed, since grimp records no import of a module by itself. An import the document already has of a module by itself is not touched by that clause. - Where it reads. The direct-edge matcher matches
toagainst the dependency withresolvedreplaced by the redirect'sinto, soto.pathand the cross-language keys read the module the edge leads to. The reachability derivation walks the redirected edges, so a chain continues frominto. Slice edges are joined from the redirected edges. The liveness ofgraph.ignore(ADR-0038 decision 3) reads the redirected edges, so an entry naming the package matches the import grimp attributes to it. - What a violation names. A violation of a direct rule names the edge as the document resolved it (the file under the unwalked folder), because that is the line to change. A reachability violation's
viapath is a path through the rule's graph, as it already is under ADR-0038. - Where it is legal. Wherever
graphis (ADR-0038 decision 1 and 2): aforbiddenrule, direct or reachability, thelayersandindependenceshorthands, and a slice rule over modules. It is refused in a.dependency-cruiser.*file with the rest ofgraph. An entry needs bothtoand a non-emptyinto.redirectdescribes what the graph is, likemodulesNot, so it has no liveness of its own. rulebearing import import-linterwrites it when the settings setinclude_external_packages: for each topmost folder without__init__.pybelow a regular package, beside themodulesNotprefix ADR-0038 writes, oneredirectentry from that prefix into the module grimp distils an import of one of its modules to (its__init__.py, or its.pyfile), and none where grimp drops the import. Without the option grimp drops every such import, whichmodulesNotalready does, so noredirectis written. A folder without__init__.pybelow a namespace root package (one the settings name, such asmetadata.ingestion) is walked by grimp, so it is neithermodulesNotnor redirected. Aprotectedcontract is unchanged: its narrowing is written asallowedrules, which carry nograph.
Consequences
- The imported dify contract keeps its
ignore_importsexactly, and a contract that grimp breaks through an unwalked folder is broken by the imported rules too. - grimp distils an import of a missing module in a walked package (
from core.app.missing import X) the same way. The Python extractor reports that import unresolved, so it is not a local edge and noredirectreaches it; the harness's graph comparison counts it asancestorOfMissing, as before. - The graph comparison in
testbeds/oracles/compare.pystill counts grimp's attributed edge asancestorOfMissing, because the document keeps the file the extractor resolved. The document is unchanged: the redirect is a view one rule takes, as the rest ofgraphis. - A rule with
redirectbuilds one more string per redirected edge. A rule without it costs nothing. schema/config-v1.jsondocumentsredirectandRedirectedImports.
Alternatives considered
- Resolve the import to the package in the Python extractor. Rejected: the extractor's
resolvedis the file Python loads, which dependency-cruiser's shape promises and every other rule reads. grimp's attribution is a property of one import-linter contract's view of the graph, which is whatgraphis for (ADR-0010: no language fact invented for the engine). - Widen each ignore entry to the unwalked folders below the package it names. Rejected: it keeps the entries live but still drops the unignored imports grimp attributes to the package, so a contract import-linter breaks would still be kept.
- Change
modulesNotto drop only the edges from a matching module. Rejected:modulesNotis accepted with its meaning in ADR-0038, and dropping an edge is not the same as leading it to another module. A chain would still end at the unwalked module instead of continuing from the package. - A general edge rewrite (
fromandtoon both sides). Rejected for now: grimp never re-attributes the importer, because it never reads an unwalked module's imports, andmodulesNotalready removes them. One side is what the known translation needs.