tenseleyflow/loader / 67be201

Browse files

Keep child artifacts within plan

Authored by espadonne
SHA
67be201764a94d11e10a67777ce0e4f41722f8d1
Parents
18ae40f
Tree
c699b9d

2 changed files

StatusFile+-
M src/loader/runtime/artifact_invalidation.py 25 2
M tests/test_artifact_invalidation.py 33 0
src/loader/runtime/artifact_invalidation.pymodified
@@ -4,6 +4,7 @@ from __future__ import annotations
44
 
55
 import re
66
 from enum import StrEnum
7
+from pathlib import Path
78
 
89
 from .workflow_policy import (
910
     ArtifactEvidence,
@@ -237,10 +238,13 @@ def _text_covers_path_reference(text: str, path: str) -> bool:
237238
             return True
238239
 
239240
     canonical_text = _canonical_path_reference(text)
240
-    return any(
241
+    if any(
241242
         canonical_candidate and canonical_candidate in canonical_text
242243
         for canonical_candidate in (_canonical_path_reference(candidate) for candidate in candidates)
243
-    )
244
+    ):
245
+        return True
246
+
247
+    return any(anchor in canonical_text for anchor in _directory_reference_anchors(path))
244248
 
245249
 
246250
 def _canonical_path_reference(value: str) -> str:
@@ -249,6 +253,25 @@ def _canonical_path_reference(value: str) -> str:
249253
     return " ".join(normalized.split())
250254
 
251255
 
256
+def _directory_reference_anchors(path: str) -> tuple[str, ...]:
257
+    normalized = str(path).strip()
258
+    if not normalized:
259
+        return ()
260
+
261
+    candidate = Path(normalized)
262
+    directory = candidate if not candidate.suffix else candidate.parent
263
+    parts = [part for part in directory.parts if part not in {"", "/", "~"}]
264
+    if len(parts) < 2:
265
+        return ()
266
+
267
+    anchors: list[str] = []
268
+    for width in range(min(4, len(parts)), 1, -1):
269
+        anchor = _canonical_path_reference("/".join(parts[-width:]))
270
+        if anchor and anchor not in anchors:
271
+            anchors.append(anchor)
272
+    return tuple(anchors)
273
+
274
+
252275
 def _text_covers_requirement(text: str, requirement: str) -> bool:
253276
     normalized_text = text.lower()
254277
     normalized_requirement = requirement.lower()
tests/test_artifact_invalidation.pymodified
@@ -138,3 +138,36 @@ def test_artifact_invalidation_allows_supplemental_repair_files_after_failed_ver
138138
         and "styles.css" in item.summary
139139
         for item in freshness.evidence
140140
     )
141
+
142
+
143
+def test_artifact_invalidation_treats_child_files_under_planned_directory_as_in_plan() -> None:
144
+    assessor = ArtifactInvalidationAssessor()
145
+
146
+    freshness = assessor.assess(
147
+        task_statement="Build a multi-file nginx guide.",
148
+        clarify_text=None,
149
+        implementation_text=(
150
+            "# Implementation Plan\n"
151
+            "- Create `~/Loader/guides/nginx/index.html`.\n"
152
+            "- Create `~/Loader/guides/nginx/chapters/`.\n"
153
+        ),
154
+        verification_text=(
155
+            "# Verification Plan\n"
156
+            "## Acceptance Criteria\n"
157
+            "- `~/Loader/guides/nginx/index.html` exists.\n"
158
+            "- Chapter files exist under `~/Loader/guides/nginx/chapters/`.\n"
159
+        ),
160
+        acceptance_criteria=[
161
+            "~/Loader/guides/nginx/index.html exists.",
162
+            "Chapter files exist under ~/Loader/guides/nginx/chapters/.",
163
+        ],
164
+        touched_files=[
165
+            "/private/tmp/session/Loader/guides/nginx/index.html",
166
+            "/private/tmp/session/Loader/guides/nginx/chapters/03-configuration.html",
167
+        ],
168
+        last_verification_result=None,
169
+    )
170
+
171
+    assert freshness.stale_plan is False
172
+    assert freshness.recovery_strategy == WorkflowRecoveryStrategy.NONE.value
173
+    assert "touched_files_outside_plan" not in freshness.reason_codes