tenseleyflow/loader / fa51860

Browse files

Parse nested planned file changes

Authored by espadonne
SHA
fa51860a32636c1ce2dafda4f93db66c70630575
Parents
b8752d4
Tree
88c0309

2 changed files

StatusFile+-
M src/loader/runtime/dod.py 55 1
M tests/test_dod.py 66 1
src/loader/runtime/dod.pymodified
@@ -648,7 +648,9 @@ def collect_planned_artifact_targets(
648648
 
649649
     markdown = plan_path.read_text()
650650
     file_change_lines = _extract_markdown_section_lines(markdown, "File Changes")
651
-    candidates = _extract_planned_path_literals(file_change_lines or markdown.splitlines())
651
+    candidates = _extract_file_change_path_literals(file_change_lines)
652
+    if not candidates:
653
+        candidates = _extract_planned_path_literals(file_change_lines or markdown.splitlines())
652654
     if not candidates:
653655
         confirmed_progress_lines = _extract_markdown_section_lines(
654656
             markdown,
@@ -877,6 +879,58 @@ def _extract_planned_path_literals(lines: list[str]) -> list[str]:
877879
     return paths
878880
 
879881
 
882
+def _extract_file_change_path_literals(lines: list[str]) -> list[str]:
883
+    paths: list[str] = []
884
+    seen: set[str] = set()
885
+    directory_stack: list[tuple[int, str]] = []
886
+
887
+    for line in lines:
888
+        indent = len(line) - len(line.lstrip(" "))
889
+        while directory_stack and indent <= directory_stack[-1][0]:
890
+            directory_stack.pop()
891
+
892
+        backticked = re.findall(r"`([^`]+)`", line)
893
+        if backticked:
894
+            candidates = backticked
895
+        else:
896
+            stripped = line.strip()
897
+            stripped = re.sub(r"^[-*+]\s+", "", stripped)
898
+            stripped = re.sub(r"^\d+[.)]\s+", "", stripped)
899
+            stripped = stripped.strip("`'\",.:;()[]{}")
900
+            candidates = [stripped] if _looks_like_path_literal(stripped) else []
901
+
902
+        for candidate in candidates:
903
+            normalized = candidate.strip("`'\",.:;()[]{}")
904
+            if not _looks_like_file_change_literal(normalized):
905
+                continue
906
+            contextual = _apply_directory_context_to_file_change(
907
+                normalized,
908
+                directory_stack[-1][1] if directory_stack else None,
909
+            )
910
+            if contextual in seen:
911
+                continue
912
+            seen.add(contextual)
913
+            paths.append(contextual)
914
+            if contextual.endswith("/"):
915
+                directory_stack.append((indent, contextual))
916
+    return paths
917
+
918
+
919
+def _looks_like_file_change_literal(value: str) -> bool:
920
+    return _looks_like_path_literal(value) or bool(Path(value).suffix)
921
+
922
+
923
+def _apply_directory_context_to_file_change(
924
+    value: str,
925
+    directory_context: str | None,
926
+) -> str:
927
+    if not directory_context:
928
+        return value
929
+    if value.startswith(("~/", "./", "../", "/")) or "/" in value:
930
+        return value
931
+    return directory_context.rstrip("/") + "/" + value
932
+
933
+
880934
 def _resolve_declared_html_artifact_root(
881935
     target: Path,
882936
     *,
tests/test_dod.pymodified
@@ -294,6 +294,40 @@ def test_collect_planned_artifact_targets_ignores_prose_path_fragments_in_refres
294294
     assert targets == [(touched_index, False)]
295295
 
296296
 
297
+def test_collect_planned_artifact_targets_resolves_nested_file_changes_relative_to_parent_directory(
298
+    tmp_path: Path,
299
+) -> None:
300
+    implementation_plan = tmp_path / "implementation.md"
301
+    implementation_plan.write_text(
302
+        "\n".join(
303
+            [
304
+                "# Implementation Plan",
305
+                "",
306
+                "## File Changes",
307
+                f"- `{tmp_path / 'guide' / 'index.html'}`",
308
+                f"- Create chapter files in `{tmp_path / 'guide' / 'chapters'}/`:",
309
+                "  - `00-introduction.html`",
310
+                "  - `01-installation.html`",
311
+                "  - `02-configuration.html`",
312
+                "",
313
+            ]
314
+        )
315
+    )
316
+
317
+    dod = create_definition_of_done("Create a multi-page guide.")
318
+    dod.implementation_plan = str(implementation_plan)
319
+
320
+    targets = collect_planned_artifact_targets(dod, project_root=tmp_path)
321
+
322
+    assert targets == [
323
+        (tmp_path / "guide" / "index.html", False),
324
+        (tmp_path / "guide" / "chapters", True),
325
+        (tmp_path / "guide" / "chapters" / "00-introduction.html", False),
326
+        (tmp_path / "guide" / "chapters" / "01-installation.html", False),
327
+        (tmp_path / "guide" / "chapters" / "02-configuration.html", False),
328
+    ]
329
+
330
+
297331
 def test_all_planned_artifacts_exist_requires_file_contents_for_planned_output_directory(
298332
     tmp_path: Path,
299333
 ) -> None:
@@ -325,7 +359,38 @@ def test_all_planned_artifacts_exist_requires_file_contents_for_planned_output_d
325359
 
326360
     assert all_planned_artifacts_exist(dod, project_root=tmp_path) is False
327361
 
328
-    (chapters / "01-getting-started.html").write_text("<h1>Intro</h1>\n")
362
+
363
+def test_all_planned_artifacts_exist_respects_nested_file_change_entries(
364
+    tmp_path: Path,
365
+) -> None:
366
+    implementation_plan = tmp_path / "implementation.md"
367
+    implementation_plan.write_text(
368
+        "\n".join(
369
+            [
370
+                "# Implementation Plan",
371
+                "",
372
+                "## File Changes",
373
+                f"- `{tmp_path / 'guide' / 'index.html'}`",
374
+                f"- Create chapter files in `{tmp_path / 'guide' / 'chapters'}/`:",
375
+                "  - `00-introduction.html`",
376
+                "  - `01-installation.html`",
377
+                "",
378
+            ]
379
+        )
380
+    )
381
+
382
+    guide = tmp_path / "guide"
383
+    chapters = guide / "chapters"
384
+    chapters.mkdir(parents=True)
385
+    (guide / "index.html").write_text("<html></html>\n")
386
+    (chapters / "00-introduction.html").write_text("<html></html>\n")
387
+
388
+    dod = create_definition_of_done("Create a multi-page guide.")
389
+    dod.implementation_plan = str(implementation_plan)
390
+
391
+    assert all_planned_artifacts_exist(dod, project_root=tmp_path) is False
392
+
393
+    (chapters / "01-installation.html").write_text("<h1>Installation</h1>\n")
329394
 
330395
     assert all_planned_artifacts_exist(dod, project_root=tmp_path) is True
331396