diff --git a/.map/scripts/map_step_runner.py b/.map/scripts/map_step_runner.py index 187aa918..61d65cd7 100755 --- a/.map/scripts/map_step_runner.py +++ b/.map/scripts/map_step_runner.py @@ -11797,9 +11797,11 @@ def refresh_blueprint_affected_files( except (OSError, subprocess.TimeoutExpired): pass # Layer 2: uncommitted (worktree + index) via porcelain. + # -uall ensures files inside untracked directories are listed individually + # rather than collapsed to the directory name (e.g. "docs/" -> "docs/a.md"). try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -12606,8 +12608,8 @@ def _subtask_baseline_path(branch: str, subtask_id: str, project_dir: Path) -> P def record_subtask_baseline(branch: str, subtask_id: str) -> dict: - """Snapshot the current `git status --porcelain` set + HEAD SHA as a - per-subtask baseline that validate_mutation_boundary will subtract + """Snapshot the current ``git status --porcelain -uall`` set + HEAD SHA as + a per-subtask baseline that validate_mutation_boundary will subtract from `actual` for THIS subtask only — independent from the branch-wide scope-baseline. @@ -12622,11 +12624,15 @@ def record_subtask_baseline(branch: str, subtask_id: str) -> dict: full per-subtask diff (committed + uncommitted) instead of seeing porcelain-only and recording an empty current set after a clean per-subtask commit. + + Uses ``-uall`` so files inside untracked directories are listed at file + granularity — a collapsed ``docs/`` entry in the baseline would otherwise + mask new files added inside that directory during the subtask (#376). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13021,12 +13027,18 @@ def record_scope_baseline(branch: str) -> dict: runs. Use when the branch carries pre-existing artifacts from prior waves that would otherwise flood every subtask with `warning`. + Uses ``-uall`` so files inside untracked directories are recorded at file + granularity. This ensures the per-file subtraction in + validate_mutation_boundary correctly suppresses pre-existing files while + still surfacing new files added inside a pre-existing untracked directory + (#376). + Returns dict with: status, path, files (count + list). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13217,8 +13229,14 @@ def validate_mutation_boundary( ) else: diff_result = None + # -uall lists files inside untracked directories individually rather + # than collapsing them to the directory name (e.g. "?? docs/" becomes + # "?? docs/a.md", "?? docs/b.md"). Without -uall a new file created + # inside a pre-existing untracked directory is invisible to both the + # actual_set and the baseline subtraction, producing a false-negative + # scope check (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13231,15 +13249,15 @@ def validate_mutation_boundary( "subtask_id": subtask_id, } - # `git status --porcelain` non-zero ⇒ not a git repo (or git is broken); - # without it we can't observe uncommitted work, and treating `actual_set` - # as empty would mis-report `clean`. Always a hard error. + # `git status --porcelain -uall` non-zero ⇒ not a git repo (or git is + # broken); without it we can't observe uncommitted work, and treating + # `actual_set` as empty would mis-report `clean`. Always a hard error. if status_result.returncode != 0: return { "status": "error", "subtask_id": subtask_id, "message": ( - f"`git status --porcelain` failed (exit {status_result.returncode}): " + f"`git status --porcelain -uall` failed (exit {status_result.returncode}): " f"{status_result.stderr.strip() or 'no stderr'}" ), } @@ -13485,8 +13503,10 @@ def _current_subtask_changed_files( ) else: diff_result = None + # -uall: see validate_mutation_boundary for the same flag; without it + # new files inside a pre-existing untracked directory are invisible (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, diff --git a/src/mapify_cli/templates/map/scripts/map_step_runner.py b/src/mapify_cli/templates/map/scripts/map_step_runner.py index 187aa918..61d65cd7 100755 --- a/src/mapify_cli/templates/map/scripts/map_step_runner.py +++ b/src/mapify_cli/templates/map/scripts/map_step_runner.py @@ -11797,9 +11797,11 @@ def refresh_blueprint_affected_files( except (OSError, subprocess.TimeoutExpired): pass # Layer 2: uncommitted (worktree + index) via porcelain. + # -uall ensures files inside untracked directories are listed individually + # rather than collapsed to the directory name (e.g. "docs/" -> "docs/a.md"). try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -12606,8 +12608,8 @@ def _subtask_baseline_path(branch: str, subtask_id: str, project_dir: Path) -> P def record_subtask_baseline(branch: str, subtask_id: str) -> dict: - """Snapshot the current `git status --porcelain` set + HEAD SHA as a - per-subtask baseline that validate_mutation_boundary will subtract + """Snapshot the current ``git status --porcelain -uall`` set + HEAD SHA as + a per-subtask baseline that validate_mutation_boundary will subtract from `actual` for THIS subtask only — independent from the branch-wide scope-baseline. @@ -12622,11 +12624,15 @@ def record_subtask_baseline(branch: str, subtask_id: str) -> dict: full per-subtask diff (committed + uncommitted) instead of seeing porcelain-only and recording an empty current set after a clean per-subtask commit. + + Uses ``-uall`` so files inside untracked directories are listed at file + granularity — a collapsed ``docs/`` entry in the baseline would otherwise + mask new files added inside that directory during the subtask (#376). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13021,12 +13027,18 @@ def record_scope_baseline(branch: str) -> dict: runs. Use when the branch carries pre-existing artifacts from prior waves that would otherwise flood every subtask with `warning`. + Uses ``-uall`` so files inside untracked directories are recorded at file + granularity. This ensures the per-file subtraction in + validate_mutation_boundary correctly suppresses pre-existing files while + still surfacing new files added inside a pre-existing untracked directory + (#376). + Returns dict with: status, path, files (count + list). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13217,8 +13229,14 @@ def validate_mutation_boundary( ) else: diff_result = None + # -uall lists files inside untracked directories individually rather + # than collapsing them to the directory name (e.g. "?? docs/" becomes + # "?? docs/a.md", "?? docs/b.md"). Without -uall a new file created + # inside a pre-existing untracked directory is invisible to both the + # actual_set and the baseline subtraction, producing a false-negative + # scope check (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13231,15 +13249,15 @@ def validate_mutation_boundary( "subtask_id": subtask_id, } - # `git status --porcelain` non-zero ⇒ not a git repo (or git is broken); - # without it we can't observe uncommitted work, and treating `actual_set` - # as empty would mis-report `clean`. Always a hard error. + # `git status --porcelain -uall` non-zero ⇒ not a git repo (or git is + # broken); without it we can't observe uncommitted work, and treating + # `actual_set` as empty would mis-report `clean`. Always a hard error. if status_result.returncode != 0: return { "status": "error", "subtask_id": subtask_id, "message": ( - f"`git status --porcelain` failed (exit {status_result.returncode}): " + f"`git status --porcelain -uall` failed (exit {status_result.returncode}): " f"{status_result.stderr.strip() or 'no stderr'}" ), } @@ -13485,8 +13503,10 @@ def _current_subtask_changed_files( ) else: diff_result = None + # -uall: see validate_mutation_boundary for the same flag; without it + # new files inside a pre-existing untracked directory are invisible (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, diff --git a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja index 187aa918..61d65cd7 100755 --- a/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja +++ b/src/mapify_cli/templates_src/map/scripts/map_step_runner.py.jinja @@ -11797,9 +11797,11 @@ def refresh_blueprint_affected_files( except (OSError, subprocess.TimeoutExpired): pass # Layer 2: uncommitted (worktree + index) via porcelain. + # -uall ensures files inside untracked directories are listed individually + # rather than collapsed to the directory name (e.g. "docs/" -> "docs/a.md"). try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -12606,8 +12608,8 @@ def _subtask_baseline_path(branch: str, subtask_id: str, project_dir: Path) -> P def record_subtask_baseline(branch: str, subtask_id: str) -> dict: - """Snapshot the current `git status --porcelain` set + HEAD SHA as a - per-subtask baseline that validate_mutation_boundary will subtract + """Snapshot the current ``git status --porcelain -uall`` set + HEAD SHA as + a per-subtask baseline that validate_mutation_boundary will subtract from `actual` for THIS subtask only — independent from the branch-wide scope-baseline. @@ -12622,11 +12624,15 @@ def record_subtask_baseline(branch: str, subtask_id: str) -> dict: full per-subtask diff (committed + uncommitted) instead of seeing porcelain-only and recording an empty current set after a clean per-subtask commit. + + Uses ``-uall`` so files inside untracked directories are listed at file + granularity — a collapsed ``docs/`` entry in the baseline would otherwise + mask new files added inside that directory during the subtask (#376). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13021,12 +13027,18 @@ def record_scope_baseline(branch: str) -> dict: runs. Use when the branch carries pre-existing artifacts from prior waves that would otherwise flood every subtask with `warning`. + Uses ``-uall`` so files inside untracked directories are recorded at file + granularity. This ensures the per-file subtraction in + validate_mutation_boundary correctly suppresses pre-existing files while + still surfacing new files added inside a pre-existing untracked directory + (#376). + Returns dict with: status, path, files (count + list). """ project_dir = Path(os.environ.get("CLAUDE_PROJECT_DIR", os.getcwd())).resolve() try: status_proc = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13217,8 +13229,14 @@ def validate_mutation_boundary( ) else: diff_result = None + # -uall lists files inside untracked directories individually rather + # than collapsing them to the directory name (e.g. "?? docs/" becomes + # "?? docs/a.md", "?? docs/b.md"). Without -uall a new file created + # inside a pre-existing untracked directory is invisible to both the + # actual_set and the baseline subtraction, producing a false-negative + # scope check (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, @@ -13231,15 +13249,15 @@ def validate_mutation_boundary( "subtask_id": subtask_id, } - # `git status --porcelain` non-zero ⇒ not a git repo (or git is broken); - # without it we can't observe uncommitted work, and treating `actual_set` - # as empty would mis-report `clean`. Always a hard error. + # `git status --porcelain -uall` non-zero ⇒ not a git repo (or git is + # broken); without it we can't observe uncommitted work, and treating + # `actual_set` as empty would mis-report `clean`. Always a hard error. if status_result.returncode != 0: return { "status": "error", "subtask_id": subtask_id, "message": ( - f"`git status --porcelain` failed (exit {status_result.returncode}): " + f"`git status --porcelain -uall` failed (exit {status_result.returncode}): " f"{status_result.stderr.strip() or 'no stderr'}" ), } @@ -13485,8 +13503,10 @@ def _current_subtask_changed_files( ) else: diff_result = None + # -uall: see validate_mutation_boundary for the same flag; without it + # new files inside a pre-existing untracked directory are invisible (#376). status_result = subprocess.run( - ["git", "status", "--porcelain"], + ["git", "status", "--porcelain", "-uall"], cwd=project_dir, capture_output=True, text=True, diff --git a/tests/test_map_step_runner.py b/tests/test_map_step_runner.py index 0daad86f..1565e4bb 100644 --- a/tests/test_map_step_runner.py +++ b/tests/test_map_step_runner.py @@ -6209,6 +6209,35 @@ def test_subtask_baseline_filters_prior_wave_diff( assert "old_a.py" not in report["actual"] assert "b.py" in report["actual"] + def test_baseline_records_individual_files_in_untracked_dirs( + self, branch_workspace, monkeypatch + ): + """Regression #376: record_subtask_baseline must list individual files + inside untracked directories (``-uall``), not the collapsed directory + name (``docs/``). Without -uall the baseline holds ``docs/`` and the + validate_mutation_boundary subtraction removes that single entry, + leaving any new file added inside ``docs/`` invisible to the check. + """ + repo = branch_workspace.parents[1] + self._init_git(repo) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo)) + + # Create an untracked directory with two files — never git-added. + (repo / "docs").mkdir() + (repo / "docs" / "a.md").write_text("doc a") + (repo / "docs" / "b.md").write_text("doc b") + + snap = map_step_runner.record_subtask_baseline("test-branch", "ST-001") + assert snap["status"] == "success" + + baseline_path = map_step_runner._subtask_baseline_path( + "test-branch", "ST-001", repo + ) + recorded = json.loads(baseline_path.read_text())["files"] + assert "docs/a.md" in recorded, f"Individual file must be in baseline: {recorded}" + assert "docs/b.md" in recorded, f"Individual file must be in baseline: {recorded}" + assert "docs/" not in recorded, f"Collapsed dir must not appear: {recorded}" + class TestRecordScopeBaseline: """record_scope_baseline snapshots current git status into @@ -6276,6 +6305,36 @@ def test_warning_includes_diagnostic_hint( hint = report["diagnostic_hint"] assert "record_scope_baseline" in hint or "record_subtask_baseline" in hint, hint + def test_scope_baseline_records_individual_files_in_untracked_dirs( + self, branch_workspace, tmp_path, monkeypatch + ): + """Regression #376: record_scope_baseline must list individual files + inside untracked directories (``-uall``), not collapsed directory names. + A baseline containing ``docs/`` instead of ``docs/a.md`` would suppress + the whole directory from validate_mutation_boundary, hiding new files. + """ + del tmp_path + repo = branch_workspace.parents[1] + self._init_git(repo) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo)) + + # Pre-existing untracked directory with files. + (repo / "notes").mkdir() + (repo / "notes" / "design.md").write_text("design") + (repo / "notes" / "todo.md").write_text("todo") + + baseline = map_step_runner.record_scope_baseline("test-branch") + assert baseline["status"] == "success" + assert "notes/design.md" in baseline["files"], ( + f"Individual file must be in scope baseline: {baseline['files']}" + ) + assert "notes/todo.md" in baseline["files"], ( + f"Individual file must be in scope baseline: {baseline['files']}" + ) + assert "notes/" not in baseline["files"], ( + f"Collapsed dir must not appear in scope baseline: {baseline['files']}" + ) + class TestDetectAlreadyDone: """detect_already_done suggests whether a subtask's affected_files @@ -6691,6 +6750,98 @@ def test_genuinely_empty_subtask_still_reports_no_actual( assert report["status"] == "clean", report assert report["actual"] == [], report # nothing changed -> false-progress can fire + def test_new_file_in_preexisting_untracked_dir_is_visible( + self, branch_workspace, monkeypatch + ): + """Regression #376: a new file created inside a directory that was + ALREADY untracked before the subtask started must appear in `actual` + and satisfy `expected`. + + Without ``-uall``, ``git status --porcelain`` collapses the whole + directory to ``?? docs/``; the baseline subtraction then removes that + entry entirely and the new file is invisible in the report. + """ + repo = branch_workspace.parents[1] + self._init_git(repo) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo)) + monkeypatch.delenv("MAP_STRICT_SCOPE", raising=False) + + # Pre-existing file inside an untracked directory (never committed). + docs = repo / "docs" + docs.mkdir() + (docs / "brd-resource-quotas.md").write_text("pre-existing doc\n") + + # Subtask baseline captured while docs/brd-resource-quotas.md already exists. + snap = map_step_runner.record_subtask_baseline("test-branch", "ST-001") + assert snap["status"] == "success" + + # During ST-001 the Actor creates a new file inside the same directory. + decisions = docs / "decisions" + decisions.mkdir() + new_file = decisions / "hc8-contention.md" + new_file.write_text("decision record\n") + + # Blueprint declares the new file in affected_files. + self._write_blueprint( + branch_workspace, "ST-001", + ["docs/decisions/hc8-contention.md"], + ) + + report = map_step_runner.validate_mutation_boundary("test-branch", "ST-001") + + # The new file must appear in `actual` — it was invisible before the fix. + assert "docs/decisions/hc8-contention.md" in report["actual"], ( + f"New file inside pre-existing untracked dir must be in actual. report={report}" + ) + # The pre-existing file must NOT appear (it was in the baseline). + assert "docs/brd-resource-quotas.md" not in report["actual"], report + # The new file satisfies the declared affected_files → status should be clean. + assert report["status"] == "clean", report + assert report["unexpected"] == [], report + + def test_baseline_subtraction_file_granular_not_dir_collapsed( + self, branch_workspace, monkeypatch + ): + """Regression #376 (complementary): the subtask baseline must record + individual file paths, not collapsed directory names, so adding a new + file to a pre-existing untracked dir is treated as a genuine addition + (not silently absorbed by a directory-level baseline entry). + """ + repo = branch_workspace.parents[1] + self._init_git(repo) + monkeypatch.setenv("CLAUDE_PROJECT_DIR", str(repo)) + monkeypatch.delenv("MAP_STRICT_SCOPE", raising=False) + + # Two pre-existing files in an untracked directory. + data_dir = repo / "data" + data_dir.mkdir() + (data_dir / "old1.csv").write_text("a,b\n") + (data_dir / "old2.csv").write_text("c,d\n") + + snap = map_step_runner.record_subtask_baseline("test-branch", "ST-001") + assert snap["status"] == "success" + # With -uall, individual files are recorded — not the collapsed "data/". + baseline_files = snap.get("count", 0) + baseline_path = map_step_runner._subtask_baseline_path( + "test-branch", "ST-001", repo + ) + recorded = json.loads(baseline_path.read_text())["files"] + assert "data/old1.csv" in recorded, f"Expected individual file in baseline: {recorded}" + assert "data/old2.csv" in recorded, f"Expected individual file in baseline: {recorded}" + assert "data/" not in recorded, f"Collapsed dir entry must not appear: {recorded}" + del baseline_files + + # Actor adds a new in-scope file. + (data_dir / "new_output.csv").write_text("x,y\n") + self._write_blueprint(branch_workspace, "ST-001", ["data/new_output.csv"]) + + report = map_step_runner.validate_mutation_boundary("test-branch", "ST-001") + # new_output.csv is in scope and NOT in the baseline → visible and clean. + assert "data/new_output.csv" in report["actual"], report + assert "data/old1.csv" not in report["actual"], report + assert "data/old2.csv" not in report["actual"], report + assert report["status"] == "clean", report + class TestDetectCrossSubtaskRegressionRisk: """detect_cross_subtask_regression_risk flags when the in-flight subtask