Parse nested planned file changes
- SHA
fa51860a32636c1ce2dafda4f93db66c70630575- Parents
-
b8752d4 - Tree
88c0309
fa51860
fa51860a32636c1ce2dafda4f93db66c70630575b8752d4
88c0309| Status | File | + | - |
|---|---|---|---|
| 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( | ||
| 648 | 648 | |
| 649 | 649 | markdown = plan_path.read_text() |
| 650 | 650 | 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()) | |
| 652 | 654 | if not candidates: |
| 653 | 655 | confirmed_progress_lines = _extract_markdown_section_lines( |
| 654 | 656 | markdown, |
@@ -877,6 +879,58 @@ def _extract_planned_path_literals(lines: list[str]) -> list[str]: | ||
| 877 | 879 | return paths |
| 878 | 880 | |
| 879 | 881 | |
| 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 | + | |
| 880 | 934 | def _resolve_declared_html_artifact_root( |
| 881 | 935 | target: Path, |
| 882 | 936 | *, |
tests/test_dod.pymodified@@ -294,6 +294,40 @@ def test_collect_planned_artifact_targets_ignores_prose_path_fragments_in_refres | ||
| 294 | 294 | assert targets == [(touched_index, False)] |
| 295 | 295 | |
| 296 | 296 | |
| 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 | + | |
| 297 | 331 | def test_all_planned_artifacts_exist_requires_file_contents_for_planned_output_directory( |
| 298 | 332 | tmp_path: Path, |
| 299 | 333 | ) -> None: |
@@ -325,7 +359,38 @@ def test_all_planned_artifacts_exist_requires_file_contents_for_planned_output_d | ||
| 325 | 359 | |
| 326 | 360 | assert all_planned_artifacts_exist(dod, project_root=tmp_path) is False |
| 327 | 361 | |
| 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") | |
| 329 | 394 | |
| 330 | 395 | assert all_planned_artifacts_exist(dod, project_root=tmp_path) is True |
| 331 | 396 | |