fix(scripts): enforce relative-import scope in check_layer_imports - #46
Merged
Conversation
CLAUDE.md `## Import Policy` and CONTRIBUTING.md:59 both state "same package:
relative imports; cross-package: absolute imports", but no enforcer covered the
second half — a `from ..parent import X` passed every check.
- Add `_check_relative_scope`: a relative import with level >= 2 escapes its own
package, so it is a cross-package import written relatively. Flagged, with the
resolved absolute module offered as the fix.
- Narrow the private-access carve-out from `level > 0` to `level == 1`. Its
comment claimed relative imports are "same-package by construction", which
only holds at level 1; a `from .._x import _foo` slipped past both the
private-name and protected-module rules.
- Resolve level >= 2 relative imports to absolute before applying those two
rules — `node.module` is the bare tail ("ir"), so every rule short-circuited
on the `sieval.` prefix test.
`sieval/` and `scripts/` are clean under the new check today; full preflight
passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
13 tasks
`_check_layer_imports` matched on `node.module`, which for a relative import
is only the bare tail ("tasks" for `from ...tasks import x`). Such an import
short-circuited on the `sieval.` prefix test and was reported solely as an
import-style error by `_check_relative_scope` — whose suggested fix is itself
a layer violation.
`from sieval import tasks` went unreported entirely: it names the layer as an
imported alias rather than in the module path, so the `len(parts) >= 2` test
never fired. The two holes chained — `_check_relative_scope` offers
`from sieval import ...` as the fix for `from ... import tasks`, steering
authors toward the one shape the layer check could not see.
- extract `_absolute_module` as the single relative->absolute normalization
that both `_check_layer_imports` and `_check_private_access` read through,
replacing the latter's inline branch so the contract lives in one place
- match the imported-alias shape when the resolved module is bare `sieval`
- docstring: stale "Two categories of check", `_check_layer_imports`'s
"(existing behavior)", and both closed holes
- tests: hoist `import ast` to module level (10 local/inline uses)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Narrowing the carve-out from `level > 0` to `level == 1` was still wider than "same-package": a *dotted* level-1 module walks DOWN into a child subpackage, so `from .sub._hidden import X` resolved to `sieval.tasks.sub._hidden` and escaped the protected-module rule — while the identical absolute spelling was flagged. Same semantic import, two verdicts depending only on how it was written. The predicate is now `level == 1 and "." not in (node.module or "")`. No-op on the current tree (zero level-1 dotted relative imports under `sieval/` + `scripts/`), so this closes a latent hole rather than changing behavior. Residual limit, recorded in a comment and a test: `from .sub import _priv` is cross-package when `sub` is a package, but is syntactically identical to a sibling module (`from .mod import _priv`). Separating them needs a filesystem lookup, which would make the verdict depend on checkout completeness. Left exempt on purpose — the dotted form is where a private module segment can actually appear mid-path. Docs touched in the same pass: * `_check_relative_scope` docstring now states that level-1 dotted descent is knowingly permitted. Rule 3 is the style half only; checks 1 and 2 resolve through it either way, so layer boundaries and private-module protection are unaffected. * Module docstring: hoisted the "holes this closed" block out from under heading 3, which described fixes to checks 1 and 2. * `check_preflight.check_imports` no longer claims parity with the pre-commit file set. It names the `sieval/community/` divergence the global `exclude:` creates, why it is inert today, and why the fix is a design call. Behavior unchanged. Tests: 97 -> 102. The spelling-equivalence test kills reverting the predicate to `level == 1`, widening to `level > 0`, and `and` -> `or`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 task
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.
Type
Summary
CLAUDE.md## Import PolicyandCONTRIBUTING.md:59both state "same package: relative imports; cross-package: absolute imports", but no enforcer covered the second half — afrom ..parent import Xpassed every check. This adds_check_relative_scope: a relative import withlevel >= 2escapes its own package, so it is a cross-package import written relatively. Flagged, with the resolved absolute module offered as the fix.level > 0, so afrom .._x import _fooslipped past both the private-name and protected-module rules.node.moduleis the bare tail ("ir") and every rule short-circuits on thesieval.prefix test — i.e. narrowing the carve-out alone would have been dead code.Found while reviewing #45, whose
sieval/core/models/transports/*.pyare currently the onlyfrom ..imports in the tree. This PR is deliberately independent of that review: it lands the enforcement onmain, which is clean today.Second commit — two chained holes in the layer check
Self-review surfaced that check 1 never got the resolve-to-absolute treatment that check 2 did, and that the omission chained into a second, older hole:
_check_layer_importsmatched onnode.modulealone. For a relative import that is only the bare tail ("tasks"forfrom ...tasks import x), so a cross-layer import written relatively short-circuited on thesieval.prefix test and was reported solely as an import-style error — whose suggested fix is itself a layer violation.from sieval import taskswas not reported at all (exit 0, pre-existing). It names the layer as an imported alias rather than in the module path, so thelen(parts) >= 2test never fired._check_relative_scopeoffersfrom sieval import ...as the fix forfrom ... import tasks, steering authors toward the one shape the layer check could not see. Removing theand node.moduleguard also made that shape newly reachable via relative syntax, so the alias branch is load-bearing for this change rather than adjacent cleanup.Implementation:
_absolute_moduleis now the single relative→absolute normalization that both_check_layer_importsand_check_private_accessread through — it replaced the latter's inline branch, so the contract lives in one place instead of two sites that must agree.Third commit — the narrowed carve-out was still wider than same-package
Code review of the second commit caught that
level > 0→level == 1did not go far enough.level == 1is same-package only when the module is undotted; a dotted level-1 module walks DOWN into a child subpackage:from .sub._hidden import Xresolves tosieval.tasks.sub._hidden, owned by subtreesieval.tasks.sub. The importer atsieval.tasksis an ancestor, not a descendant, so perCLAUDE.mdthat access is out-of-subtree and forbidden — yet it escaped while the identical absolute spelling was flagged. Same semantic import, two verdicts depending only on how it was written: the same class of hole as the first two commits, mirrored (downward escape instead of upward).Predicate is now
node.level == 1 and "." not in (node.module or "").Residual limit, recorded in a comment + test rather than left as an apparent oversight:
from .sub import _privis cross-package whensubis a package, but is syntactically identical to a sibling module (from .mod import _priv). Separating them needs a filesystem lookup, which would make a lint verdict depend on checkout completeness. Left exempt on purpose — the dotted form is where a private module segment can actually appear mid-path.Rule 3 deliberately stays quiet on level-1 dotted descent: it reads as a local descent and is idiomatic enough that banning it buys little, and checks 1 and 2 resolve through it either way, so layer boundaries and private-module protection are unaffected. Now stated in the
_check_relative_scopedocstring instead of left for the next reader to re-derive.sieval/community/scope divergence — now documented in code, still deferred.check_preflight.check_importscarried a comment claiming its file set "must match the pre-commit hook'sfiles:filter". It matchesfiles:but not the effective set: pre-commit also applies the globalexclude: ^(sieval/community/|vendor/), so it skipscommunity/while preflight checks it — exactly what.claude/rules/engineering-infra.mdwarns about ("compute the actual file set: hookfiles:× globalexclude:, not the regex alone"). Inert today (all 6 relative imports undercommunity/are bare level-1from . import x, verified), but a future vendored drop usingfrom ..x import ywould pass pre-commit and fail preflight, with the only offered fix being to edit code kept byte-identical to upstream. The comment now names the divergence and why fixing it is a design call — hoisting the exemption into_check_filewould also drop check 2's coverage ofcommunity/. Behavior unchanged; the design decision remains open.Related Issues
Refs #45
Test Plan
Automated
ruff check && ruff format --check)ty check)tests/unit/scripts/as a whole: 210 passedcheck_importsManual
sieval/+scripts/clean under all three checks:git ls-files '*.py' | grep -E '^(sieval|scripts)/' | python scripts/check_layer_imports.py --stdin→ exit 0The carve-out tightening is a no-op on the current tree — AST-scanned every tracked
.pyundersieval/+scripts/for level-1 relative imports with a dotted module: 0 hits. This closes a latent hole rather than changing behavior, so there is no false-positive risk.Verified the check fires with a correct fix suggestion on the feat(models): capability-based Model IR + Transport frontends (RFC #25) #45 pattern:
_resolve_relativecross-checked against CPython's own resolver: 75(package, level, module)combinations againstimportlib.util.resolve_name, 0 mismatches. The above-root guardstrip >= len(parts)is equivalent to CPython'slen(bits) < level._file_packagealso verified correct for__init__.py(drops the filename, so__package__semantics match for both__init__and regular modules).Layer-check behaviour, before → after, in a scratch tree rooted at a
core/file:from sieval import taskscore/ must not import tasks/ (sieval.tasks)from sieval import tasks, datasetsfrom sieval import tasks as Tasnamehandled)from ... import tasksfrom ...tasks import registryfrom .sibling import helperfrom sieval import __version__core/)from sieval import settingsPrivate-access behaviour, before → after, from
sieval/tasks/foo.py:from .sub._hidden import Ximport from private module 'sieval.tasks.sub._hidden' outside its subtreefrom sieval.tasks.sub._hidden import Xfrom .sub.mod import helperfrom ._arc import _helperfrom .sub import _privMutation-checked for discriminating power — 11 targeted mutations, all caught. Rule 3: revert the carve-out to
level > 0; threshold>= 2→>= 3; guard>=→>; unwire from_check_file. Layer check: un-resolve back tonode.module; delete the imported-alias branch;_absolute_modulereturning""for level 0; returningNoneinstead of""above root. Third commit's predicate: revert tolevel == 1, widen tolevel > 0, flipand→or— all three killed bytest_dotted_level_1_matches_its_absolute_spelling, which asserts the spelling-equivalence invariant directly rather than a specific message..claude/rules/engineering-infra.mdchain walked: pre-commitfiles:scope unchanged (^(sieval|scripts)/); hookname:updated to mention the third check;check_preflight.pywrapper key and message strings still accurate, and its file-set comment corrected; mirror test extended;CLAUDE.md/CONTRIBUTING.mdwording already described this rule, so no doc change was needed — only the enforcement was missing. Thefiles:× globalexclude:interaction is now written up incheck_preflight.pyitself as a deferred item.Checklist
Required (all PRs)
type(scope): description)AI-Generated Code - <model> (<provider>)in module docstringcore/🤖 Generated with Claude Code