Compare commits

...

3 Commits

Author SHA1 Message Date
AndyMik90 9e06b15d3d fix: address PR #1847 review findings
- Reuse SKIP_DIRS from context.constants instead of duplicating exclusion list
- Fix exception types in write error handlers (TypeError/ValueError, not JSONDecodeError)
- Add warning log when path validation bypassed due to exhausted retries
- Use existing safeReadFileSync helper for attempt_history reads

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:35:33 +01:00
AndyMik90 c2b287c02c refactor: extract scoring logic, use indexed lookup in auto-correct
- Extract _score_and_select() from duplicated candidate scoring blocks
- Use _find_correct_path_indexed() in _auto_correct_subtask_files()
  with a shared file index built once for all missing files
- Use find_subtask_in_plan() helper instead of inline nested loop
- Add more dirs to _EXCLUDE_DIRS (.idea, .vscode, vendor, target, out)
- Narrow exception handlers from (OSError, JSONDecodeError) to OSError
  for write_json_atomic (JSON errors can't occur on write)
- Fix type annotation for missing_entries list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-17 15:32:59 +01:00
AndyMik90 4a8c94538c feat: implement fuzzy file path matching and indexing for self-healing in coder pipeline
- Add functions for building a file index, finding correct paths using fuzzy matching, and auto-correcting subtask file paths.
- Introduce directory exclusion logic to optimize file searching.
- Enhance validation of file paths in implementation plans to support better error recovery.
- Add comprehensive tests for the new functionalities, covering various matching scenarios and edge cases.

This update improves the robustness of the coder pipeline by enabling it to automatically correct file paths based on existing project structure, thus enhancing overall stability and user experience.
2026-02-17 15:32:58 +01:00
4 changed files with 1372 additions and 6 deletions
+445 -5
View File
@@ -13,7 +13,9 @@ import re
from datetime import datetime, timedelta
from pathlib import Path
from context.constants import SKIP_DIRS
from core.client import create_client
from core.file_utils import write_json_atomic
from linear_updater import (
LinearTaskState,
is_linear_enabled,
@@ -84,6 +86,7 @@ from .memory_manager import debug_memory_system_status, get_graphiti_context
from .session import post_session_processing, run_agent_session
from .utils import (
find_phase_for_subtask,
find_subtask_in_plan,
get_commit_count,
get_latest_commit,
load_implementation_plan,
@@ -97,8 +100,383 @@ logger = logging.getLogger(__name__)
# FILE VALIDATION UTILITIES
# =============================================================================
# Directories to exclude from file path search — extends context.constants.SKIP_DIRS
_EXCLUDE_DIRS = frozenset(SKIP_DIRS | {".auto-claude", ".tox", "out"})
def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
def _build_file_index(
project_dir: Path, suffixes: set[str]
) -> dict[str, list[tuple[str, Path]]]:
"""
Build an index of project files grouped by basename, scanning the tree once.
Also indexes index.{ext} files under their parent directory name as a
secondary key (e.g., api/index.ts is indexed under both "index.ts" and
"api" as directory-stem).
Args:
project_dir: Root directory of the project
suffixes: File extensions to index (e.g., {".ts", ".tsx"})
Returns:
Dict mapping basename -> list of (relative_path_str, Path(relative_path))
"""
index: dict[str, list[tuple[str, Path]]] = {}
resolved_str = str(project_dir.resolve())
for root, dirs, files in os.walk(project_dir.resolve()):
dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS]
for filename in files:
ext_idx = filename.rfind(".")
if ext_idx == -1:
continue
file_suffix = filename[ext_idx:]
if file_suffix not in suffixes:
continue
full_path = os.path.join(root, filename)
rel_str = os.path.relpath(full_path, resolved_str).replace(os.sep, "/")
rel_path = Path(rel_str)
# Index by basename
index.setdefault(filename, []).append((rel_str, rel_path))
# Also index index.{ext} files by parent dir name (for stem matching)
stem_part = filename[:ext_idx]
if stem_part == "index":
dir_name = os.path.basename(root)
key = f"__dir_stem__:{dir_name}{file_suffix}"
index.setdefault(key, []).append((rel_str, rel_path))
return index
def _score_and_select(candidates: list[tuple[str, float]]) -> str | None:
"""
Select the best candidate from a scored list of (path, score) pairs.
Requires a minimum score of 8.0 and a gap of at least 3.0 from the
runner-up to avoid ambiguous matches.
Args:
candidates: List of (relative_path, score) tuples
Returns:
Best path if unambiguous, None otherwise
"""
if not candidates:
return None
candidates.sort(key=lambda x: x[1], reverse=True)
best_path, best_score = candidates[0]
if best_score < 8.0:
return None
if len(candidates) > 1:
runner_up_score = candidates[1][1]
if best_score - runner_up_score < 3.0:
return None
return best_path
def _find_correct_path_indexed(
missing_path: str,
parent_parts: tuple[str, ...],
file_index: dict[str, list[tuple[str, Path]]],
) -> str | None:
"""
Find the correct path using a pre-built file index (no tree walk needed).
Args:
missing_path: The incorrect file path from the plan
parent_parts: Parent directory parts of the missing path
file_index: Index built by _build_file_index
Returns:
Corrected relative path, or None if no good match found
"""
missing = Path(missing_path)
basename = missing.name
stem = missing.stem
suffix = missing.suffix
if not suffix:
return None
candidates: list[tuple[str, float]] = []
# Strategy 1: Exact basename match
for rel_str, rel_path in file_index.get(basename, []):
score = 10.0
candidate_parts = rel_path.parent.parts
for i, part in enumerate(parent_parts):
if i < len(candidate_parts) and candidate_parts[i] == part:
score += 3.0
depth_diff = abs(len(candidate_parts) - len(parent_parts))
score -= 0.5 * depth_diff
candidates.append((rel_str, score))
# Strategy 2: index.{ext} in directory matching stem
stem_key = f"__dir_stem__:{stem}{suffix}"
for rel_str, rel_path in file_index.get(stem_key, []):
score = 8.0
candidate_parts = rel_path.parent.parts
for i, part in enumerate(parent_parts):
if i < len(candidate_parts) and candidate_parts[i] == part:
score += 3.0
depth_diff = abs(len(candidate_parts) - len(parent_parts))
score -= 0.5 * depth_diff
candidates.append((rel_str, score))
return _score_and_select(candidates)
def _find_correct_path(missing_path: str, project_dir: Path) -> str | None:
"""
Attempt to find the correct path for a missing file using fuzzy matching.
Strategies:
1. Same basename in nearby directory
2. index.{ext} pattern (e.g., preload/api.ts -> preload/api/index.ts)
Uses os.walk with directory pruning to avoid traversing into node_modules,
.git, dist, etc. — unlike Path.rglob which traverses everything then filters.
Args:
missing_path: The incorrect file path from the plan
project_dir: Root directory of the project
Returns:
Corrected relative path, or None if no good match found
"""
missing = Path(missing_path)
basename = missing.name
stem = missing.stem
suffix = missing.suffix
parent_parts = missing.parent.parts
if not suffix:
return None
candidates: list[tuple[str, float]] = []
resolved_project = project_dir.resolve()
resolved_str = str(resolved_project)
# os.walk with pruning: modify dirs in-place to skip excluded directories
for root, dirs, files in os.walk(resolved_project):
dirs[:] = [d for d in dirs if d not in _EXCLUDE_DIRS]
for filename in files:
if not filename.endswith(suffix):
continue
full_path = os.path.join(root, filename)
rel_str = os.path.relpath(full_path, resolved_str).replace(os.sep, "/")
rel = Path(rel_str)
score = 0.0
# Strategy 1: Exact basename match
if filename == basename:
score += 10.0
# Strategy 2: index.{ext} in directory matching stem
elif filename == f"index{suffix}" and os.path.basename(root) == stem:
score += 8.0
else:
continue
# Bonus: shared parent directory segments
candidate_parts = rel.parent.parts
for i, part in enumerate(parent_parts):
if i < len(candidate_parts) and candidate_parts[i] == part:
score += 3.0
# Penalty: depth difference
depth_diff = abs(len(candidate_parts) - len(parent_parts))
score -= 0.5 * depth_diff
candidates.append((rel_str, score))
return _score_and_select(candidates)
def _auto_correct_subtask_files(
subtask: dict,
missing_files: list[str],
project_dir: Path,
spec_dir: Path,
) -> list[str]:
"""
Attempt to auto-correct missing file paths in a subtask.
Corrects paths in-memory AND persists changes to implementation_plan.json.
Args:
subtask: Subtask dictionary containing files_to_modify
missing_files: List of file paths that don't exist
project_dir: Root directory of the project
spec_dir: Spec directory containing implementation_plan.json
Returns:
List of file paths that could NOT be corrected
"""
corrections: dict[str, str] = {}
still_missing: list[str] = []
# Build file index once for all missing files (avoids repeated os.walk)
suffixes_needed: set[str] = set()
for missing_path in missing_files:
suffix = Path(missing_path).suffix
if suffix:
suffixes_needed.add(suffix)
file_index = (
_build_file_index(project_dir, suffixes_needed) if suffixes_needed else {}
)
for missing_path in missing_files:
missing = Path(missing_path)
corrected = _find_correct_path_indexed(
missing_path, missing.parent.parts, file_index
)
if corrected:
corrections[missing_path] = corrected
logger.info(f"Auto-corrected file path: {missing_path} -> {corrected}")
print_status(f"Auto-corrected: {missing_path} -> {corrected}", "success")
else:
still_missing.append(missing_path)
if not corrections:
return still_missing
# Update subtask in-memory
files_to_modify = subtask.get("files_to_modify", [])
subtask["files_to_modify"] = [corrections.get(f, f) for f in files_to_modify]
# Persist corrections to implementation_plan.json
plan_file = spec_dir / "implementation_plan.json"
if plan_file.exists():
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
subtask_id = subtask.get("id")
if subtask_id is not None:
plan_subtask = find_subtask_in_plan(plan, subtask_id)
if plan_subtask:
plan_files = plan_subtask.get("files_to_modify", [])
plan_subtask["files_to_modify"] = [
corrections.get(f, f) for f in plan_files
]
write_json_atomic(plan_file, plan)
logger.info(
f"Persisted {len(corrections)} path correction(s) to implementation_plan.json"
)
except (OSError, TypeError, ValueError) as e:
logger.warning(f"Failed to persist path corrections: {e}")
return still_missing
def _validate_plan_file_paths(spec_dir: Path, project_dir: Path) -> str | None:
"""
Validate all file paths in the implementation plan after planning.
Builds a file index once, then checks all paths across all subtasks against it.
Attempts auto-correction for missing paths. Returns a retry context string for
the planner if uncorrectable paths remain, or None if all paths are valid.
Args:
spec_dir: Spec directory containing implementation_plan.json
project_dir: Root directory of the project
Returns:
Retry context string if issues remain, None if all OK
"""
plan_file = spec_dir / "implementation_plan.json"
if not plan_file.exists():
return None
try:
with open(plan_file, encoding="utf-8") as f:
plan = json.load(f)
except (OSError, json.JSONDecodeError, UnicodeDecodeError):
return None
resolved_project = project_dir.resolve()
# First pass: collect all missing files and their suffixes
missing_entries: list[
tuple[list[str], int, str]
] = [] # (subtask_files_list, index, path)
suffixes_needed: set[str] = set()
for phase in plan.get("phases", []):
for subtask in phase.get("subtasks", []):
files = subtask.get("files_to_modify", [])
for i, file_path in enumerate(files):
full_path = (resolved_project / file_path).resolve()
if not full_path.is_relative_to(resolved_project):
continue
if full_path.exists():
continue
missing = Path(file_path)
if missing.suffix:
suffixes_needed.add(missing.suffix)
missing_entries.append((files, i, file_path))
if not missing_entries:
return None
# Build index once for all needed suffixes
file_index = _build_file_index(project_dir, suffixes_needed)
all_missing: list[str] = []
corrections_made = 0
for files_list, idx, file_path in missing_entries:
missing = Path(file_path)
corrected = _find_correct_path_indexed(
file_path, missing.parent.parts, file_index
)
if corrected:
files_list[idx] = corrected
corrections_made += 1
logger.info(f"Post-plan auto-corrected: {file_path} -> {corrected}")
print_status(f"Auto-corrected: {file_path} -> {corrected}", "success")
else:
all_missing.append(file_path)
# Persist any corrections that were made
if corrections_made > 0:
try:
write_json_atomic(plan_file, plan)
logger.info(f"Persisted {corrections_made} post-plan path correction(s)")
except (OSError, TypeError, ValueError) as e:
logger.warning(f"Failed to persist post-plan corrections: {e}")
if not all_missing:
return None
return (
"## FILE PATH VALIDATION ERRORS\n\n"
"The following files referenced in your implementation plan do NOT exist "
"and could not be auto-corrected:\n"
+ "\n".join(f"- `{p}`" for p in all_missing)
+ "\n\nPlease fix these file paths in the `implementation_plan.json`.\n"
"Use the project's actual file structure to find the correct paths.\n"
"Common issues: wrong directory nesting, missing index files "
"(e.g., `dir/file.ts` should be `dir/file/index.ts`)."
)
def validate_subtask_files(
subtask: dict, project_dir: Path, spec_dir: Path | None = None
) -> dict:
"""
Validate all files_to_modify exist before subtask execution.
@@ -136,6 +514,15 @@ def validate_subtask_files(subtask: dict, project_dir: Path) -> dict:
}
if missing_files:
# Attempt auto-correction if spec_dir is provided
if spec_dir:
still_missing = _auto_correct_subtask_files(
subtask, missing_files, project_dir, spec_dir
)
if not still_missing:
return {"success": True, "missing_files": [], "invalid_paths": []}
missing_files = still_missing
return {
"success": False,
"error": f"Planned files do not exist: {', '.join(missing_files)}",
@@ -685,7 +1072,10 @@ async def run_autonomous_agent(
# Validate that all files_to_modify exist before attempting execution
# This prevents infinite retry loops when implementation plan references non-existent files
validation_result = validate_subtask_files(next_subtask, project_dir)
# Pass spec_dir to enable auto-correction of wrong paths
validation_result = validate_subtask_files(
next_subtask, project_dir, spec_dir
)
if not validation_result["success"]:
# File validation failed - record error and skip session
error_msg = validation_result["error"]
@@ -719,6 +1109,11 @@ async def run_autonomous_agent(
subtask_id,
f"File validation failed after {attempt_count} attempts: {error_msg}",
)
emit_phase(
ExecutionPhase.FAILED,
f"Subtask {subtask_id} stuck: file validation failed",
subtask=subtask_id,
)
print_status(
f"Subtask {subtask_id} marked as STUCK after {attempt_count} failed validation attempts",
"error",
@@ -812,8 +1207,28 @@ async def run_autonomous_agent(
if is_planning_phase and status != "error":
valid, errors = _validate_and_fix_implementation_plan()
if valid:
plan_validated = True
planning_retry_context = None
# Fix 5: Validate file paths in the newly created plan
path_issues = _validate_plan_file_paths(spec_dir, project_dir)
if (
path_issues
and planning_validation_failures < max_planning_validation_retries
):
planning_validation_failures += 1
planning_retry_context = path_issues
print_status(
"Plan has invalid file paths - retrying planner",
"warning",
)
first_run = True
status = "continue"
else:
if path_issues:
logger.warning(
f"Plan has uncorrectable file paths after "
f"{planning_validation_failures} retries - proceeding anyway"
)
plan_validated = True
planning_retry_context = None
else:
planning_validation_failures += 1
if planning_validation_failures >= max_planning_validation_retries:
@@ -871,6 +1286,11 @@ async def run_autonomous_agent(
recovery_manager.mark_subtask_stuck(
subtask_id, f"Failed after {attempt_count} attempts"
)
emit_phase(
ExecutionPhase.FAILED,
f"Subtask {subtask_id} stuck after {attempt_count} attempts",
subtask=subtask_id,
)
print()
print_status(
f"Subtask {subtask_id} marked as STUCK after {attempt_count} attempts",
@@ -1230,4 +1650,24 @@ async def run_autonomous_agent(
if completed == total:
status_manager.update(state=BuildState.COMPLETE)
else:
status_manager.update(state=BuildState.PAUSED)
# Check if all remaining subtasks are stuck — if so, this is an error, not a pause
all_remaining_stuck = False
if stuck_subtasks:
stuck_ids = {s["subtask_id"] for s in stuck_subtasks}
plan = load_implementation_plan(spec_dir)
if plan:
all_remaining_stuck = True
for phase in plan.get("phases", []):
for s in phase.get("subtasks", []):
if s.get("status") != "completed":
if s.get("id") not in stuck_ids:
all_remaining_stuck = False
break
if not all_remaining_stuck:
break
if all_remaining_stuck and stuck_subtasks:
emit_phase(ExecutionPhase.FAILED, "All remaining subtasks are stuck")
status_manager.update(state=BuildState.ERROR)
else:
status_manager.update(state=BuildState.PAUSED)
+4 -1
View File
@@ -454,8 +454,11 @@ def get_next_subtask(spec_dir: Path) -> dict | None:
str(phase_id_raw) if phase_id_raw is not None else f"unknown:{i}"
)
subtasks = phase.get("subtasks", phase.get("chunks", []))
# Stuck subtasks count as "resolved" for phase dependency purposes.
# This prevents one stuck subtask from blocking all downstream phases.
phase_complete[phase_id_key] = all(
s.get("status") == "completed" for s in subtasks
s.get("status") == "completed" or s.get("id") in stuck_subtask_ids
for s in subtasks
)
# Find next available subtask
@@ -1062,6 +1062,52 @@ export function registerTaskExecutionHandlers(
}
console.log(`[Recovery] Total ${totalResetCount} subtask(s) reset across all locations`);
// Clear attempt_history.json to break infinite recovery loops.
// Without this, the backend re-reads stuck markers from attempt_history
// and immediately re-stucks the same subtasks after recovery.
const specDirsToClean = new Set<string>([specDir]);
if (mainSpecDir !== specDir) specDirsToClean.add(mainSpecDir);
if (worktreeSpecDir && worktreeSpecDir !== specDir) specDirsToClean.add(worktreeSpecDir);
for (const dir of specDirsToClean) {
const attemptHistoryPath = path.join(dir, 'memory', 'attempt_history.json');
const historyContent = safeReadFileSync(attemptHistoryPath);
if (!historyContent) continue;
try {
const history = JSON.parse(historyContent);
// Collect stuck subtask IDs before clearing
const stuckIds = new Set<string>(
(history.stuck_subtasks || [])
.map((s: { subtask_id?: string }) => s.subtask_id)
.filter((id: string | undefined): id is string => Boolean(id))
);
// Clear stuck_subtasks array
history.stuck_subtasks = [];
// Reset attempt entries for previously-stuck subtasks
if (history.subtasks && stuckIds.size > 0) {
for (const stuckId of stuckIds) {
if (history.subtasks[stuckId]) {
history.subtasks[stuckId] = { attempts: [], status: 'pending' };
}
}
}
history.metadata = {
...history.metadata,
last_updated: new Date().toISOString()
};
writeFileAtomicSync(attemptHistoryPath, JSON.stringify(history, null, 2));
console.log(`[Recovery] Cleared attempt_history.json at: ${dir} (reset ${stuckIds.size} stuck entries)`);
} catch (historyErr) {
console.warn(`[Recovery] Could not parse attempt_history at ${dir}:`, historyErr);
}
}
}
// Stop file watcher if it was watching this task
+877
View File
@@ -0,0 +1,877 @@
#!/usr/bin/env python3
"""
Tests for File Path Self-Healing in the Coder Pipeline
=======================================================
Tests cover:
- _find_correct_path: fuzzy file path matching (basename, index.{ext} pattern)
- _find_correct_path_indexed: same logic using pre-built index
- _build_file_index: file indexing with directory pruning
- _auto_correct_subtask_files: end-to-end correction with plan persistence
- _validate_plan_file_paths: post-planning validation of all file paths
- Phase dependency fix: stuck subtasks unblock downstream phases
"""
import json
import sys
from pathlib import Path
import pytest
# Ensure backend is on path
sys.path.insert(0, str(Path(__file__).parent.parent / "apps" / "backend"))
from agents.coder import (
_auto_correct_subtask_files,
_build_file_index,
_find_correct_path,
_find_correct_path_indexed,
_validate_plan_file_paths,
validate_subtask_files,
)
# =============================================================================
# FIXTURES
# =============================================================================
@pytest.fixture
def project_tree(tmp_path):
"""
Create a realistic project structure for path matching tests.
Structure:
src/
renderer/
components/
Button.tsx
Modal.tsx
stores/
task-store.ts
preload/
api/
index.ts <- the index.{ext} pattern
bridge/
index.ts
shared/
utils/
helpers.ts
format.ts
types/
common.ts
apps/
frontend/
src/
main/
agent/
agent-queue.ts
tests/
helpers.ts <- duplicate basename of shared/utils/helpers.ts
node_modules/
react/
index.ts <- should be excluded
.git/
config <- should be excluded
"""
# Source files
(tmp_path / "src/renderer/components").mkdir(parents=True)
(tmp_path / "src/renderer/components/Button.tsx").write_text("export {}")
(tmp_path / "src/renderer/components/Modal.tsx").write_text("export {}")
(tmp_path / "src/renderer/stores").mkdir(parents=True)
(tmp_path / "src/renderer/stores/task-store.ts").write_text("export {}")
(tmp_path / "src/preload/api").mkdir(parents=True)
(tmp_path / "src/preload/api/index.ts").write_text("export {}")
(tmp_path / "src/preload/bridge").mkdir(parents=True)
(tmp_path / "src/preload/bridge/index.ts").write_text("export {}")
(tmp_path / "src/shared/utils").mkdir(parents=True)
(tmp_path / "src/shared/utils/helpers.ts").write_text("export {}")
(tmp_path / "src/shared/utils/format.ts").write_text("export {}")
(tmp_path / "src/shared/types").mkdir(parents=True)
(tmp_path / "src/shared/types/common.ts").write_text("export {}")
(tmp_path / "apps/frontend/src/main/agent").mkdir(parents=True)
(tmp_path / "apps/frontend/src/main/agent/agent-queue.ts").write_text("export {}")
(tmp_path / "tests").mkdir(parents=True)
(tmp_path / "tests/helpers.ts").write_text("export {}")
# Excluded directories (should never match)
(tmp_path / "node_modules/react").mkdir(parents=True)
(tmp_path / "node_modules/react/index.ts").write_text("export {}")
(tmp_path / ".git").mkdir(parents=True)
(tmp_path / ".git/config").write_text("[core]")
return tmp_path
@pytest.fixture
def spec_dir_with_plan(tmp_path):
"""Create a spec directory with an implementation plan containing wrong paths."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"feature": "test feature",
"phases": [
{
"id": "phase-1",
"name": "Phase 1",
"subtasks": [
{
"id": "task-1",
"description": "Fix the API",
"status": "pending",
"files_to_modify": [
"src/preload/api.ts", # Wrong: should be src/preload/api/index.ts
"src/renderer/components/Button.tsx", # Correct
],
},
{
"id": "task-2",
"description": "Update store",
"status": "pending",
"files_to_modify": [
"src/renderer/stores/task-store.ts", # Correct
],
},
],
}
],
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan, indent=2))
return spec_dir
# =============================================================================
# _find_correct_path TESTS
# =============================================================================
class TestFindCorrectPath:
"""Tests for the _find_correct_path fuzzy file matcher."""
def test_index_pattern_match(self, project_tree):
"""preload/api.ts -> preload/api/index.ts (the core spec-232 scenario)."""
result = _find_correct_path("src/preload/api.ts", project_tree)
assert result is not None
assert Path(result) == Path("src/preload/api/index.ts")
def test_index_pattern_with_different_dir(self, project_tree):
"""preload/bridge.ts -> preload/bridge/index.ts."""
result = _find_correct_path("src/preload/bridge.ts", project_tree)
assert result is not None
assert Path(result) == Path("src/preload/bridge/index.ts")
def test_basename_match_in_different_dir(self, project_tree):
"""When file exists but in wrong directory, finds it by basename."""
result = _find_correct_path("src/utils/format.ts", project_tree)
assert result is not None
assert Path(result) == Path("src/shared/utils/format.ts")
def test_exact_basename_with_shared_parents_wins(self, project_tree):
"""Score prefers candidates sharing more parent directory segments."""
result = _find_correct_path("src/renderer/components/Modal.tsx", project_tree)
# File exists at the exact path, but _find_correct_path is only called
# for missing paths. Let's test a wrong parent instead.
result = _find_correct_path("src/components/Modal.tsx", project_tree)
assert result is not None
assert Path(result) == Path("src/renderer/components/Modal.tsx")
def test_no_match_for_nonexistent_file(self, project_tree):
"""Returns None when no file with matching basename or index pattern exists."""
result = _find_correct_path("src/does-not-exist.ts", project_tree)
assert result is None
def test_no_match_without_extension(self, project_tree):
"""Returns None for paths without file extension."""
result = _find_correct_path("src/preload/api", project_tree)
assert result is None
def test_excluded_dirs_not_matched(self, project_tree):
"""Files in node_modules/.git are never returned as matches."""
# The only index.ts files are in src/preload/api/ and src/preload/bridge/
# and node_modules/react/. A search for "react.ts" should not match
# node_modules/react/index.ts because node_modules is excluded.
result = _find_correct_path("react.ts", project_tree)
assert result is None
def test_ambiguous_match_returns_none(self, project_tree):
"""When two candidates have similar scores, returns None (ambiguous)."""
# "helpers.ts" exists at both:
# src/shared/utils/helpers.ts
# tests/helpers.ts
# With parent "foo/" (no shared segments), both score 10.0 with slight
# depth differences. The gap should be < 3.0 so it's ambiguous.
result = _find_correct_path("foo/helpers.ts", project_tree)
assert result is None
def test_unambiguous_basename_match_with_shared_parents(self, project_tree):
"""When one candidate clearly shares more parent path, it wins."""
# "helpers.ts" at src/shared/utils/helpers.ts vs tests/helpers.ts
# Searching for "src/shared/helpers.ts":
# src/shared/utils/helpers.ts: 10.0 + 3.0(src) + 3.0(shared) - 0.5 = 15.5
# tests/helpers.ts: 10.0 + 0 - 1.0 = 9.0
# Gap = 6.5 >= 3.0, so src/shared/utils/helpers.ts wins
result = _find_correct_path("src/shared/helpers.ts", project_tree)
assert result is not None
assert Path(result) == Path("src/shared/utils/helpers.ts")
def test_deeply_nested_path_still_matches(self, project_tree):
"""Files deep in the tree can be found when path is partially wrong."""
result = _find_correct_path(
"apps/frontend/src/main/agent-queue.ts", project_tree
)
assert result is not None
assert "agent-queue.ts" in result
# =============================================================================
# _build_file_index + _find_correct_path_indexed TESTS
# =============================================================================
class TestBuildFileIndex:
"""Tests for the file index builder."""
def test_indexes_files_by_basename(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
assert "format.ts" in index
assert len(index["format.ts"]) == 1
def test_indexes_index_files_by_dir_stem(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
# api/index.ts should be indexed under __dir_stem__:api.ts
key = "__dir_stem__:api.ts"
assert key in index
assert len(index[key]) == 1
assert "api/index.ts" in index[key][0][0]
def test_excludes_node_modules(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
# node_modules/react/index.ts should NOT appear
for entries in index.values():
for rel_str, _ in entries:
assert "node_modules" not in rel_str
def test_excludes_git_dir(self, project_tree):
index = _build_file_index(project_tree, {".ts", ""})
for entries in index.values():
for rel_str, _ in entries:
assert ".git" not in rel_str
def test_multiple_suffixes(self, project_tree):
index = _build_file_index(project_tree, {".ts", ".tsx"})
assert "Button.tsx" in index
assert "format.ts" in index
def test_only_requested_suffixes(self, project_tree):
index = _build_file_index(project_tree, {".tsx"})
# .ts files should not be in the index
assert "format.ts" not in index
assert "Button.tsx" in index
class TestFindCorrectPathIndexed:
"""Tests for the indexed path finder (same logic, uses pre-built index)."""
def test_index_pattern_match(self, project_tree):
"""Same as _find_correct_path but using indexed version."""
index = _build_file_index(project_tree, {".ts"})
result = _find_correct_path_indexed(
"src/preload/api.ts", ("src", "preload"), index
)
assert result is not None
assert Path(result) == Path("src/preload/api/index.ts")
def test_basename_match(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
result = _find_correct_path_indexed(
"src/shared/format.ts", ("src", "shared"), index
)
assert result is not None
assert Path(result) == Path("src/shared/utils/format.ts")
def test_no_match(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
result = _find_correct_path_indexed(
"nonexistent.ts", (), index
)
assert result is None
def test_ambiguous_returns_none(self, project_tree):
index = _build_file_index(project_tree, {".ts"})
# "helpers.ts" has two matches with no shared parent context
result = _find_correct_path_indexed(
"foo/helpers.ts", ("foo",), index
)
assert result is None
# =============================================================================
# _auto_correct_subtask_files TESTS
# =============================================================================
class TestAutoCorrectSubtaskFiles:
"""Tests for auto-correcting file paths in a subtask."""
def test_corrects_index_pattern_and_persists(self, project_tree, tmp_path):
"""Corrects api.ts -> api/index.ts and writes to plan file."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{
"id": "t1",
"status": "pending",
"files_to_modify": [
"src/preload/api.ts",
"src/renderer/components/Button.tsx",
],
}
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
subtask = {
"id": "t1",
"files_to_modify": [
"src/preload/api.ts",
"src/renderer/components/Button.tsx",
],
}
still_missing = _auto_correct_subtask_files(
subtask, ["src/preload/api.ts"], project_tree, spec_dir
)
# No files should remain missing
assert still_missing == []
# In-memory subtask should be updated
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
assert "src/preload/api.ts" not in subtask["files_to_modify"]
# Plan file should be persisted with correction
saved_plan = json.loads(
(spec_dir / "implementation_plan.json").read_text()
)
saved_files = saved_plan["phases"][0]["subtasks"][0]["files_to_modify"]
assert "src/preload/api/index.ts" in saved_files
assert "src/preload/api.ts" not in saved_files
def test_uncorrectable_files_returned(self, project_tree, tmp_path):
"""Files with no match are returned as still missing."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {"phases": [{"id": "p1", "subtasks": [{"id": "t1", "status": "pending", "files_to_modify": ["nonexistent.ts"]}]}]}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
subtask = {"id": "t1", "files_to_modify": ["nonexistent.ts"]}
still_missing = _auto_correct_subtask_files(
subtask, ["nonexistent.ts"], project_tree, spec_dir
)
assert still_missing == ["nonexistent.ts"]
def test_no_corrections_skips_write(self, project_tree, tmp_path):
"""When nothing can be corrected, plan file is not rewritten."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
original_content = json.dumps({"phases": [{"id": "p1", "subtasks": [{"id": "t1", "status": "pending", "files_to_modify": ["gone.ts"]}]}]})
plan_file = spec_dir / "implementation_plan.json"
plan_file.write_text(original_content)
mtime_before = plan_file.stat().st_mtime
subtask = {"id": "t1", "files_to_modify": ["gone.ts"]}
_auto_correct_subtask_files(subtask, ["gone.ts"], project_tree, spec_dir)
# File should not have been rewritten (mtime unchanged)
assert plan_file.stat().st_mtime == mtime_before
def test_corrects_in_memory_without_plan_file(self, project_tree, tmp_path):
"""When implementation_plan.json does not exist, corrections still apply in-memory."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Deliberately do NOT create implementation_plan.json
subtask = {
"id": "t1",
"files_to_modify": [
"src/preload/api.ts",
"src/renderer/components/Button.tsx",
],
}
still_missing = _auto_correct_subtask_files(
subtask, ["src/preload/api.ts"], project_tree, spec_dir
)
# All correctable files should be resolved
assert still_missing == []
# In-memory subtask should be updated with corrected path
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
assert "src/preload/api.ts" not in subtask["files_to_modify"]
# Uncorrected file should remain unchanged
assert "src/renderer/components/Button.tsx" in subtask["files_to_modify"]
# Plan file should still not exist (no side-effect creation)
assert not (spec_dir / "implementation_plan.json").exists()
def test_corrects_in_memory_with_corrupt_plan_file(self, project_tree, tmp_path):
"""When implementation_plan.json contains invalid JSON, corrections still apply in-memory."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
# Write corrupt plan file
plan_file = spec_dir / "implementation_plan.json"
plan_file.write_text("not valid json")
subtask = {
"id": "t1",
"files_to_modify": [
"src/preload/api.ts",
"src/renderer/components/Button.tsx",
],
}
still_missing = _auto_correct_subtask_files(
subtask, ["src/preload/api.ts"], project_tree, spec_dir
)
# All correctable files should be resolved
assert still_missing == []
# In-memory subtask should be updated with corrected path
assert "src/preload/api/index.ts" in subtask["files_to_modify"]
assert "src/preload/api.ts" not in subtask["files_to_modify"]
# Corrupt plan file should be left unchanged (not overwritten or deleted)
assert plan_file.read_text() == "not valid json"
# =============================================================================
# validate_subtask_files (with auto-correction integration) TESTS
# =============================================================================
class TestValidateSubtaskFilesWithCorrection:
"""Tests for validate_subtask_files with auto-correction enabled."""
def test_passes_when_all_files_exist(self, project_tree):
subtask = {
"files_to_modify": [
"src/renderer/components/Button.tsx",
"src/shared/utils/format.ts",
]
}
result = validate_subtask_files(subtask, project_tree)
assert result["success"] is True
def test_fails_without_spec_dir(self, project_tree):
"""Without spec_dir, auto-correction is skipped and validation fails."""
subtask = {"files_to_modify": ["src/preload/api.ts"]}
result = validate_subtask_files(subtask, project_tree)
assert result["success"] is False
assert "src/preload/api.ts" in result["missing_files"]
def test_auto_corrects_with_spec_dir(self, project_tree, tmp_path):
"""With spec_dir, auto-correction fixes the path and passes."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{"id": "t1", "status": "pending", "files_to_modify": ["src/preload/api.ts"]}
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
subtask = {"id": "t1", "files_to_modify": ["src/preload/api.ts"]}
result = validate_subtask_files(subtask, project_tree, spec_dir)
assert result["success"] is True
def test_rejects_path_traversal(self, project_tree):
"""Paths that resolve outside project are rejected."""
subtask = {"files_to_modify": ["../../etc/passwd"]}
result = validate_subtask_files(subtask, project_tree)
assert result["success"] is False
assert len(result["invalid_paths"]) > 0
# =============================================================================
# _validate_plan_file_paths TESTS
# =============================================================================
class TestValidatePlanFilePaths:
"""Tests for post-planning file path validation."""
def test_all_paths_valid_returns_none(self, project_tree, tmp_path):
"""When all paths exist, returns None (no issues)."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{
"id": "t1",
"status": "pending",
"files_to_modify": [
"src/renderer/components/Button.tsx",
"src/shared/utils/format.ts",
],
}
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
result = _validate_plan_file_paths(spec_dir, project_tree)
assert result is None
def test_auto_corrects_and_returns_none(self, project_tree, tmp_path):
"""Correctable paths are fixed, plan persisted, returns None."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{
"id": "t1",
"status": "pending",
"files_to_modify": ["src/preload/api.ts"],
}
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
result = _validate_plan_file_paths(spec_dir, project_tree)
assert result is None
# Verify plan was updated on disk
saved = json.loads((spec_dir / "implementation_plan.json").read_text())
assert saved["phases"][0]["subtasks"][0]["files_to_modify"] == [
"src/preload/api/index.ts"
]
def test_uncorrectable_returns_retry_context(self, project_tree, tmp_path):
"""Returns retry context string when paths can't be corrected."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{
"id": "t1",
"status": "pending",
"files_to_modify": ["totally/fake/path.ts"],
}
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
result = _validate_plan_file_paths(spec_dir, project_tree)
assert result is not None
assert "FILE PATH VALIDATION ERRORS" in result
assert "totally/fake/path.ts" in result
def test_mixed_valid_invalid_correctable(self, project_tree, tmp_path):
"""Mix of correct, correctable, and uncorrectable paths across subtasks."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{
"id": "t1",
"status": "pending",
"files_to_modify": [
"src/renderer/components/Button.tsx", # valid
"src/preload/api.ts", # correctable
],
},
{
"id": "t2",
"status": "pending",
"files_to_modify": [
"nonexistent/file.xyz", # uncorrectable
],
},
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
result = _validate_plan_file_paths(spec_dir, project_tree)
assert result is not None
assert "nonexistent/file.xyz" in result
# api.ts should have been corrected
assert "api.ts" not in result
def test_no_plan_file_returns_none(self, tmp_path):
"""Returns None if implementation_plan.json doesn't exist."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
result = _validate_plan_file_paths(spec_dir, tmp_path)
assert result is None
def test_subtask_without_files_to_modify(self, project_tree, tmp_path):
"""Subtasks with no files_to_modify are gracefully skipped."""
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "p1",
"subtasks": [
{"id": "t1", "status": "pending", "description": "No files"},
],
}
]
}
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
result = _validate_plan_file_paths(spec_dir, project_tree)
assert result is None
# =============================================================================
# PHASE DEPENDENCY FIX (progress.py get_next_subtask) TESTS
# =============================================================================
class TestStuckSubtasksUnblockPhases:
"""Tests that stuck subtasks in a phase allow downstream phases to proceed."""
def _write_plan(self, spec_dir, plan):
(spec_dir / "implementation_plan.json").write_text(json.dumps(plan))
def _write_stuck(self, spec_dir, stuck_ids):
memory_dir = spec_dir / "memory"
memory_dir.mkdir(parents=True, exist_ok=True)
history = {
"subtasks": {},
"stuck_subtasks": [
{"subtask_id": sid, "reason": "test"} for sid in stuck_ids
],
"metadata": {"created_at": "2025-01-01", "last_updated": "2025-01-01"},
}
(memory_dir / "attempt_history.json").write_text(json.dumps(history))
def test_stuck_in_phase1_unblocks_phase2(self, tmp_path):
"""When all non-completed subtasks in phase 1 are stuck, phase 2 proceeds."""
from progress import get_next_subtask
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "1",
"name": "Phase 1",
"subtasks": [
{"id": "1.1", "status": "completed"},
{"id": "1.2", "status": "pending"}, # This one is stuck
],
},
{
"id": "2",
"name": "Phase 2",
"depends_on": ["1"],
"subtasks": [
{"id": "2.1", "status": "pending"},
],
},
]
}
self._write_plan(spec_dir, plan)
self._write_stuck(spec_dir, ["1.2"])
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "2.1", "Phase 2 should be unblocked since 1.2 is stuck"
def test_completed_plus_stuck_unblocks(self, tmp_path):
"""Phase with mix of completed and stuck subtasks counts as resolved."""
from progress import get_next_subtask
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "1",
"name": "Phase 1",
"subtasks": [
{"id": "1.1", "status": "completed"},
{"id": "1.2", "status": "completed"},
{"id": "1.3", "status": "pending"}, # stuck
],
},
{
"id": "2",
"name": "Phase 2",
"depends_on": ["1"],
"subtasks": [
{"id": "2.1", "status": "pending"},
],
},
]
}
self._write_plan(spec_dir, plan)
self._write_stuck(spec_dir, ["1.3"])
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "2.1"
def test_pending_non_stuck_blocks_phase(self, tmp_path):
"""Phase with a pending (non-stuck) subtask still blocks dependents."""
from progress import get_next_subtask
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "1",
"name": "Phase 1",
"subtasks": [
{"id": "1.1", "status": "completed"},
{"id": "1.2", "status": "pending"}, # stuck
{"id": "1.3", "status": "pending"}, # NOT stuck
],
},
{
"id": "2",
"name": "Phase 2",
"depends_on": ["1"],
"subtasks": [
{"id": "2.1", "status": "pending"},
],
},
]
}
self._write_plan(spec_dir, plan)
self._write_stuck(spec_dir, ["1.2"])
result = get_next_subtask(spec_dir)
assert result is not None
# Should pick 1.3 (pending, not stuck) from phase 1, NOT 2.1
assert result["id"] == "1.3"
def test_all_phases_stuck_returns_none(self, tmp_path):
"""When every pending subtask across all phases is stuck, returns None."""
from progress import get_next_subtask
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "1",
"name": "Phase 1",
"subtasks": [
{"id": "1.1", "status": "pending"},
],
},
{
"id": "2",
"name": "Phase 2",
"depends_on": ["1"],
"subtasks": [
{"id": "2.1", "status": "pending"},
],
},
]
}
self._write_plan(spec_dir, plan)
self._write_stuck(spec_dir, ["1.1", "2.1"])
result = get_next_subtask(spec_dir)
assert result is None
def test_chain_of_three_phases_with_stuck(self, tmp_path):
"""Phase 1 stuck -> phase 2 stuck -> phase 3 can still run."""
from progress import get_next_subtask
spec_dir = tmp_path / "spec"
spec_dir.mkdir()
plan = {
"phases": [
{
"id": "1",
"name": "Phase 1",
"subtasks": [
{"id": "1.1", "status": "pending"},
],
},
{
"id": "2",
"name": "Phase 2",
"depends_on": ["1"],
"subtasks": [
{"id": "2.1", "status": "pending"},
],
},
{
"id": "3",
"name": "Phase 3",
"depends_on": ["2"],
"subtasks": [
{"id": "3.1", "status": "pending"},
],
},
]
}
self._write_plan(spec_dir, plan)
self._write_stuck(spec_dir, ["1.1", "2.1"])
result = get_next_subtask(spec_dir)
assert result is not None
assert result["id"] == "3.1"