Tighten directory plan anchors
- SHA
33957e202caf5b6f9b5552b0ceaa648e625a02ad- Parents
-
e5057a7 - Tree
cc87bf0
33957e2
33957e202caf5b6f9b5552b0ceaa648e625a02ade5057a7
cc87bf0| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/runtime/artifact_invalidation.py
|
24 | 2 |
| M |
tests/test_artifact_invalidation.py
|
25 | 0 |
src/loader/runtime/artifact_invalidation.pymodified@@ -244,7 +244,12 @@ def _text_covers_path_reference(text: str, path: str) -> bool: | ||
| 244 | 244 | ): |
| 245 | 245 | return True |
| 246 | 246 | |
| 247 | - return any(anchor in canonical_text for anchor in _directory_reference_anchors(path)) | |
| 247 | + directory_mentions = _extract_directory_mentions(text) | |
| 248 | + directory_suffixes = _directory_reference_suffixes(path) | |
| 249 | + return any( | |
| 250 | + mention and mention in directory_suffixes | |
| 251 | + for mention in (_canonical_path_reference(item.rstrip("/")) for item in directory_mentions) | |
| 252 | + ) | |
| 248 | 253 | |
| 249 | 254 | |
| 250 | 255 | def _canonical_path_reference(value: str) -> str: |
@@ -253,7 +258,7 @@ def _canonical_path_reference(value: str) -> str: | ||
| 253 | 258 | return " ".join(normalized.split()) |
| 254 | 259 | |
| 255 | 260 | |
| 256 | -def _directory_reference_anchors(path: str) -> tuple[str, ...]: | |
| 261 | +def _directory_reference_suffixes(path: str) -> tuple[str, ...]: | |
| 257 | 262 | normalized = str(path).strip() |
| 258 | 263 | if not normalized: |
| 259 | 264 | return () |
@@ -305,6 +310,23 @@ def _extract_path_mentions(*texts: str | None) -> list[str]: | ||
| 305 | 310 | return mentions |
| 306 | 311 | |
| 307 | 312 | |
| 313 | +def _extract_directory_mentions(*texts: str | None) -> list[str]: | |
| 314 | + mentions: list[str] = [] | |
| 315 | + seen: set[str] = set() | |
| 316 | + for text in texts: | |
| 317 | + if not text: | |
| 318 | + continue | |
| 319 | + for match in re.finditer(r"(?:~|/)?[\w./-]+/", text): | |
| 320 | + if match.end() < len(text) and re.match(r"[\w.-]", text[match.end()]): | |
| 321 | + continue | |
| 322 | + normalized = match.group(0).strip("`'\",.:;()[]{}") | |
| 323 | + if not normalized or normalized in seen: | |
| 324 | + continue | |
| 325 | + seen.add(normalized) | |
| 326 | + mentions.append(normalized) | |
| 327 | + return mentions | |
| 328 | + | |
| 329 | + | |
| 308 | 330 | def _short_requirement(requirement: str, *, limit: int = 72) -> str: |
| 309 | 331 | normalized = " ".join(str(requirement).split()).strip() |
| 310 | 332 | if len(normalized) <= limit: |
tests/test_artifact_invalidation.pymodified@@ -171,3 +171,28 @@ def test_artifact_invalidation_treats_child_files_under_planned_directory_as_in_ | ||
| 171 | 171 | assert freshness.stale_plan is False |
| 172 | 172 | assert freshness.recovery_strategy == WorkflowRecoveryStrategy.NONE.value |
| 173 | 173 | assert "touched_files_outside_plan" not in freshness.reason_codes |
| 174 | + | |
| 175 | + | |
| 176 | +def test_artifact_invalidation_keeps_root_level_sibling_files_out_of_plan() -> None: | |
| 177 | + assessor = ArtifactInvalidationAssessor() | |
| 178 | + | |
| 179 | + freshness = assessor.assess( | |
| 180 | + task_statement="Implement the runtime report artifact.", | |
| 181 | + clarify_text=None, | |
| 182 | + implementation_text=( | |
| 183 | + "# Implementation Plan\n" | |
| 184 | + "- Create `/tmp/session/planned.txt`.\n" | |
| 185 | + ), | |
| 186 | + verification_text=( | |
| 187 | + "# Verification Plan\n" | |
| 188 | + "## Acceptance Criteria\n" | |
| 189 | + "- `/tmp/session/planned.txt` exists.\n" | |
| 190 | + ), | |
| 191 | + acceptance_criteria=["/tmp/session/planned.txt exists."], | |
| 192 | + touched_files=["/tmp/session/notes.txt"], | |
| 193 | + last_verification_result=None, | |
| 194 | + ) | |
| 195 | + | |
| 196 | + assert freshness.stale_plan is True | |
| 197 | + assert freshness.recovery_strategy == WorkflowRecoveryStrategy.PLAN_REFRESH.value | |
| 198 | + assert "touched_files_outside_plan" in freshness.reason_codes | |