From a15b77f6a6dff50c8fe2077b4c399eea68049efc Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Thu, 7 May 2026 12:11:31 +0000 Subject: [PATCH] fix(acms): normalize context path matching for absolute paths in _path_matches Fixes issue #10972 where _path_matches() used PurePath.full_match()/match() which requires the entire path to match. Since fragment metadata stores absolute paths (e.g. /app/.opencode/skills/SKILL.md) while project context --exclude-path/--include-path settings produce relative globs (.opencode/*, docs/*), include/exclude filters were silently ineffective. Added _matches_any() static helper in execute_phase_context_assembler.py that: - Tries full_match(pattern) as-is for relative paths and anchored patterns - Auto-prefixes relative patterns with **/ so they match absolute paths Updated _matches_pattern() in context_phase_analysis.py with same logic plus zero-depth compatibility shim. Added 7 new BDD regression scenarios with @tdd_issue tags: - 5 in execute_phase_context_assembler_coverage.feature (absolute path matching) - 1 extra trailing ** glob exclusion test - 1 in project_context_phase_analysis.feature (phase analysis exclusion) ISSUES CLOSED: #10972 --- CHANGELOG.md | 10 ++++ CONTRIBUTORS.md | 1 + ...e_phase_context_assembler_coverage.feature | 25 +++++++++ .../project_context_phase_analysis.feature | 7 +++ .../project_context_phase_analysis_steps.py | 51 +++++++++++++++++++ .../services/context_phase_analysis.py | 18 +++++-- .../execute_phase_context_assembler.py | 29 +++++++++-- 7 files changed, 132 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 905c8b89c..0c694525c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -127,6 +127,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). and milestone assignment. This eliminates systemic PR merge blockers caused by workers omitting required items. +- **ACMS context path matching now handles absolute fragment paths** (#10972): Fixed + `_path_matches()` in `execute_phase_context_assembler.py` and `_matches_pattern()` in + `context_phase_analysis.py` to correctly match absolute paths (e.g. `/app/.opencode/skills/SKILL.md`) + against relative glob patterns (e.g. `.opencode/**`, `docs/*`). Previously + `PurePath.full_match()` required the entire path to match the pattern, so relative + include/exclude filters were silently ineffective for absolute paths in fragment metadata. + Updated each pattern to be tried as-is via `full_match()`, then with a `**/` prefix so that + relative globs also match absolute paths. Added BDD regression tests in + `execute_phase_context_assembler_coverage.feature` and `project_context_phase_analysis.feature`. + ### Changed - Restored `benchmark-regression` CI job to `master.yml` with `pull_request` trigger guard diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index b7d1bc7a0..358d21b58 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -35,3 +35,4 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files. * HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. * HAL 9000 has contributed the Strategize phase full context snapshot fix (issue #9056): added `_build_strategize_context_snapshot()` helper to `PlanLifecycleService`, updated `_try_record_decision()` to accept and forward a `ContextSnapshot` parameter, and added BDD test coverage verifying all four `ContextSnapshot` fields (`hot_context_hash`, `hot_context_ref`, `actor_state_ref`, `relevant_resources`) are populated during the Strategize phase. +* HAL 9000 has contributed the ACMS context path matching fix (PR #10975 / issue #10972): corrects `_path_matches()` and `_matches_pattern()` to properly match absolute fragment paths against relative glob patterns by auto-prefixing with `**/` before calling `PurePath.full_match()`, preventing silent inefficacy of include/exclude filters for absolute paths in fragment metadata. diff --git a/features/execute_phase_context_assembler_coverage.feature b/features/execute_phase_context_assembler_coverage.feature index 58e24a72d..d4f95a5ba 100644 --- a/features/execute_phase_context_assembler_coverage.feature +++ b/features/execute_phase_context_assembler_coverage.feature @@ -46,6 +46,31 @@ Feature: Execute-phase context assembler coverage When epcov I check path matching for "src/foo.py" with exclude "src/secret*" Then epcov the path should match +@tdd_issue @tdd_issue_10972 + Scenario: epcov path matches absolute path against relative include glob + When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with include ".opencode/**" + Then epcov the path should match + + @tdd_issue @tdd_issue_10972 + Scenario: epcov path matches absolute path against relative exclude glob + When epcov I check path matching for "/app/.opencode/skills/SKILL.md" with exclude ".opencode/**" + Then epcov the path should not match + + @tdd_issue @tdd_issue_10972 + Scenario: epcov path matches absolute path against relative include glob with wildcard + When epcov I check path matching for "/app/docs/readme.md" with include "docs/*" + Then epcov the path should match + + @tdd_issue @tdd_issue_10972 + Scenario: epcov path matches absolute path not matching relative include glob + When epcov I check path matching for "/app/src/main.py" with include "docs/*" + Then epcov the path should not match + + @tdd_issue @tdd_issue_10972 + Scenario: epcov relative path is excluded by trailing ** glob + When epcov I check path matching for "build/debug/output.log" with exclude "build/**" + Then epcov the path should not match + # ---- _resource_matches static method ---- Scenario: epcov resource matches with no rules passes all diff --git a/features/project_context_phase_analysis.feature b/features/project_context_phase_analysis.feature index fa87993f4..5d751c366 100644 --- a/features/project_context_phase_analysis.feature +++ b/features/project_context_phase_analysis.feature @@ -29,3 +29,10 @@ Feature: Project context phase analysis summaries When I compute project context phase analysis with budget 1000 Then execute phase should have fewer tokens than strategize phase And apply phase should have fewer or equal tokens than execute phase + + @tdd_issue @tdd_issue_10972 + Scenario: Absolute path fragments are correctly excluded by relative exclude globs + Given a phase analysis policy with opencode exclude paths + And an absolute path fragment for phase analysis + When I compute project context phase analysis with budget 2000 + Then strategize phase should exclude the absolute path fragment diff --git a/features/steps/project_context_phase_analysis_steps.py b/features/steps/project_context_phase_analysis_steps.py index 9076cdf5f..77e80bef9 100644 --- a/features/steps/project_context_phase_analysis_steps.py +++ b/features/steps/project_context_phase_analysis_steps.py @@ -183,3 +183,54 @@ def step_apply_less_or_equal(context: Any) -> None: exec_tokens = context.phase_result["phases"]["execute"]["total_tokens"] apply_tokens = context.phase_result["phases"]["apply"]["total_tokens"] assert apply_tokens <= exec_tokens + + +@given("a phase analysis policy with opencode exclude paths") +def step_policy_opencode_exclude(context: Any) -> None: + """Policy that excludes .opencode/** paths using relative globs.""" + context.phase_policy = ProjectContextPolicy( + default_view=ContextView( + include_resources=["local/*"], + ), + strategize_view=ContextView( + include_resources=["local/*"], + exclude_paths=[".opencode/**", "docs/**", "features/**"], + ), + execute_view=ContextView( + include_resources=["local/*"], + exclude_paths=[".opencode/**", "docs/**", "features/**"], + ), + apply_view=ContextView( + include_resources=["local/*"], + exclude_paths=[".opencode/**", "docs/**", "features/**"], + ), + ) + + +@given("an absolute path fragment for phase analysis") +def step_absolute_path_fragment(context: Any) -> None: + """Fragment with an absolute path that should be excluded by relative globs.""" + context.phase_fragments = [ + TieredFragment( + fragment_id="abs-skill", + content="skill content", + tier=ContextTier.HOT, + resource_id="local/repo-a", + project_name="local/ctx-app", + token_count=50, + metadata={ + "path": "/app/.opencode/skills/SKILL.md", + "byte_size": 1000, + }, + ) + ] + + +@then("strategize phase should exclude the absolute path fragment") +def step_strat_excludes_absolute(context: Any) -> None: + """Verify that the absolute path fragment is excluded by relative glob patterns.""" + strat = context.phase_result["phases"]["strategize"] + assert strat["fragment_count"] == 0, ( + f"Expected 0 fragments (absolute path should be excluded by relative glob), " + f"got {strat['fragment_count']}" + ) diff --git a/src/cleveragents/application/services/context_phase_analysis.py b/src/cleveragents/application/services/context_phase_analysis.py index b5501d8f7..0e77a0bda 100644 --- a/src/cleveragents/application/services/context_phase_analysis.py +++ b/src/cleveragents/application/services/context_phase_analysis.py @@ -31,10 +31,20 @@ def _path_matches(path: str, include_patterns: list[str]) -> bool: def _matches_pattern(path_obj: PurePosixPath, pattern: str) -> bool: - """Match with a small compatibility shim for ``**/`` zero-depth cases.""" - return path_obj.match(pattern) or ( - "**/" in pattern and path_obj.match(pattern.replace("**/", "")) - ) + """Match a path against a glob pattern, handling absolute vs relative paths. + + Tries ``full_match()`` with the pattern as-is, then with a ``**/`` + prefix so that relative globs (e.g. ``.opencode/*``) correctly match + absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``). Also + handles the ``**/`` zero-depth compatibility shim. + """ + if path_obj.full_match(pattern): + return True + # Auto-prefix with **/ so relative patterns match absolute paths + if not pattern.startswith("**/") and path_obj.full_match(f"**/{pattern}"): + return True + # Zero-depth shim: "**/" in pattern but full_match already tried above + return bool("**/" in pattern and path_obj.full_match(pattern.replace("**/", ""))) def _extract_path(fragment: TieredFragment) -> str: diff --git a/src/cleveragents/application/services/execute_phase_context_assembler.py b/src/cleveragents/application/services/execute_phase_context_assembler.py index f3810ab3e..144f18b8d 100644 --- a/src/cleveragents/application/services/execute_phase_context_assembler.py +++ b/src/cleveragents/application/services/execute_phase_context_assembler.py @@ -72,13 +72,32 @@ class ACMSExecutePhaseContextAssembler(ExecutePhaseContextAssembler): @staticmethod def _path_matches(path: str, include: list[str], exclude: list[str]) -> bool: - """Return whether *path* passes include/exclude path globs.""" + """Return whether *path* passes include/exclude path globs. + + Handles both absolute paths (e.g. ``/app/.opencode/skills/SKILL.md``) + and relative paths (e.g. ``src/foo.py``) against relative glob + patterns (e.g. ``.opencode/**``, ``src/**/*.py``). + + Each pattern is tried with ``full_match()`` as-is (handles + relative paths and ``**`` patterns). If the pattern is not + already anchored with ``**/``, a second attempt prefixes + ``**/`` so that relative globs also match absolute paths. + """ pure_path = PurePath(path) - if include and not any(pure_path.full_match(pattern) for pattern in include): + + def _matches_any(patterns: list[str]) -> bool: + for pattern in patterns: + if pure_path.full_match(pattern): + return True + if not pattern.startswith("**/") and pure_path.full_match( + f"**/{pattern}" + ): + return True return False - return not ( - exclude and any(pure_path.full_match(pattern) for pattern in exclude) - ) + + if include and not _matches_any(include): + return False + return not (exclude and _matches_any(exclude)) @staticmethod def _resource_matches( -- 2.52.0