From 7266baa1d5af9f3ed6601166e6e6083be186ca46 Mon Sep 17 00:00:00 2001 From: heumsi Date: Mon, 30 Mar 2026 23:45:28 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20fix:=20Match=20submodules=20in?= =?UTF-8?q?=20modules=20pattern=20consistent=20with=20allow/deny?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- python_dependency_linter/matcher.py | 21 +++++++++- tests/test_matcher.py | 59 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/python_dependency_linter/matcher.py b/python_dependency_linter/matcher.py index 52a06e3..eeb7b6f 100644 --- a/python_dependency_linter/matcher.py +++ b/python_dependency_linter/matcher.py @@ -59,12 +59,31 @@ def _match_with_captures( return False +def match_pattern_with_captures_or_submodule( + pattern: str, module: str +) -> dict[str, str] | None: + """Match pattern exactly or treat module as a submodule of the pattern.""" + captures = match_pattern_with_captures(pattern, module) + if captures is not None: + return captures + # Check if a prefix of the module matches the pattern. + # e.g. "contexts.*.domain" should match "contexts.boards.domain.models" + module_parts = module.split(".") + pattern_parts = pattern.split(".") + if len(module_parts) > len(pattern_parts): + prefix = ".".join(module_parts[: len(pattern_parts)]) + captures = match_pattern_with_captures(pattern, prefix) + if captures is not None: + return captures + return None + + def find_matching_rules( module: str, rules: list[Rule] ) -> list[tuple[Rule, dict[str, str]]]: result = [] for r in rules: - captures = match_pattern_with_captures(r.modules, module) + captures = match_pattern_with_captures_or_submodule(r.modules, module) if captures is not None: result.append((r, captures)) return result diff --git a/tests/test_matcher.py b/tests/test_matcher.py index 3469c1f..0e9d5c7 100644 --- a/tests/test_matcher.py +++ b/tests/test_matcher.py @@ -214,3 +214,62 @@ def test_capture_after_double_star(): def test_capture_after_double_star_backtrack(): result = match_pattern_with_captures("**.{x}.end", "a.b.c.end") assert result == {"x": "c"} + + +def test_find_matching_rules_submodule(): + """modules pattern should match submodules automatically.""" + rules = [ + Rule( + name="domain-layer", + modules="contexts.*.domain", + allow=AllowDeny(local=["contexts.*.domain"]), + ), + ] + # Exact match still works + matched = find_matching_rules("contexts.boards.domain", rules) + assert len(matched) == 1 + assert matched[0][0].name == "domain-layer" + + # Submodule should also match + matched = find_matching_rules("contexts.boards.domain.models", rules) + assert len(matched) == 1 + assert matched[0][0].name == "domain-layer" + + # Deeper submodule should also match + matched = find_matching_rules("contexts.boards.domain.entities.metric", rules) + assert len(matched) == 1 + assert matched[0][0].name == "domain-layer" + + # Non-matching module should not match + matched = find_matching_rules("contexts.boards.application.service", rules) + assert len(matched) == 0 + + +def test_find_matching_rules_submodule_with_captures(): + """modules submodule matching should preserve captures.""" + rules = [ + Rule( + name="domain-layer", + modules="contexts.{context}.domain", + allow=AllowDeny(local=["contexts.{context}.domain"]), + ), + ] + matched = find_matching_rules("contexts.boards.domain.models", rules) + assert len(matched) == 1 + rule, captures = matched[0] + assert rule.name == "domain-layer" + assert captures == {"context": "boards"} + + +def test_find_matching_rules_submodule_exact_pattern(): + """Exact (no wildcard) modules pattern should also match submodules.""" + rules = [ + Rule( + name="shared", + modules="src.shared.domain", + allow=AllowDeny(local=["src.shared.domain"]), + ), + ] + matched = find_matching_rules("src.shared.domain.entity.user", rules) + assert len(matched) == 1 + assert matched[0][0].name == "shared"