Rulebearing Playground Coverage Schemas GitHub

ADR-0051: A rule redirects the imports it sees: graph.redirect, grimp's import of an unwalked module

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' }
  1. Order. redirect applies first. The first entry whose to matches decides, and into is a module path as the document writes source, not a pattern. ignore, modulesNot and dependencyTypesNot then see the edge where it leads, so in the example modulesNot removes 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.
  2. Where it reads. The direct-edge matcher matches to against the dependency with resolved replaced by the redirect's into, so to.path and the cross-language keys read the module the edge leads to. The reachability derivation walks the redirected edges, so a chain continues from into. Slice edges are joined from the redirected edges. The liveness of graph.ignore (ADR-0038 decision 3) reads the redirected edges, so an entry naming the package matches the import grimp attributes to it.
  3. 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 via path is a path through the rule's graph, as it already is under ADR-0038.
  4. Where it is legal. Wherever graph is (ADR-0038 decision 1 and 2): a forbidden rule, direct or reachability, the layers and independence shorthands, and a slice rule over modules. It is refused in a .dependency-cruiser.* file with the rest of graph. An entry needs both to and a non-empty into. redirect describes what the graph is, like modulesNot, so it has no liveness of its own.
  5. rulebearing import import-linter writes it when the settings set include_external_packages: for each topmost folder without __init__.py below a regular package, beside the modulesNot prefix ADR-0038 writes, one redirect entry from that prefix into the module grimp distils an import of one of its modules to (its __init__.py, or its .py file), and none where grimp drops the import. Without the option grimp drops every such import, which modulesNot already does, so no redirect is written. A folder without __init__.py below a namespace root package (one the settings name, such as metadata.ingestion) is walked by grimp, so it is neither modulesNot nor redirected. A protected contract is unchanged: its narrowing is written as allowed rules, which carry no graph.

Consequences

Alternatives considered