diff --git a/agents/conductors/hygiene/_hygiene_refs.py b/agents/conductors/hygiene/_hygiene_refs.py index 714a9f2..2d2ba84 100644 --- a/agents/conductors/hygiene/_hygiene_refs.py +++ b/agents/conductors/hygiene/_hygiene_refs.py @@ -162,7 +162,30 @@ # Directory names whose contents are produced by a run, so their absence from a # checkout proves nothing about the reference. -RUNTIME_DIRECTORIES = frozenset({"output", "outputs", "dataset", "datasets"}) +RUNTIME_DIRECTORIES = frozenset({ + "output", "outputs", "dataset", "datasets", + # A fit's own output subfolder (`files/search_internal`) and the run caches + # and scratch areas a reduction writes (`cache/`, `cache_root/crds/`, + # `work/injected/`) — PyAutoBrain#583. + "files", "cache", "cache_root", "work", +}) +# Directories some workspaces commit and others only generate at release time +# (`notebooks`): unresolvable, not dead, where the checkout lacks them. +GENERATED_DIRECTORIES = frozenset({"notebooks"}) +# A HowTo course's prose points readers at its companion workspace ("the +# workspace's `scripts/interferometer/modeling.py`"); a reference that misses the +# course repository is checked there before it is called dead. The pairing is +# the naming convention (HowTo -> auto_workspace), derived rather +# than listed so no repository name is hard-coded here. +COURSE_PREFIX = "HowTo" + + +def companion_workspace(repo: str) -> str | None: + if not repo.startswith(COURSE_PREFIX) or len(repo) == len(COURSE_PREFIX): + return None + return f"auto{repo[len(COURSE_PREFIX):].lower()}_workspace" + +REPOSITORY_WORD = re.compile(r"[A-Za-z0-9_]+") # Directories never worth walking when indexing a repository. PRUNED_DIRECTORIES = frozenset({".git", ".ipynb_checkpoints", "__pycache__"}) # Library package (or its test suite) -> the repository whose checkout holds it. @@ -393,6 +416,8 @@ def resolves(self, reference: str, repo: str, directory: str) -> bool | None: head = path.split("/", 1)[0] if head in RUNTIME_DIRECTORIES: return None + if head in GENERATED_DIRECTORIES and not target.has_directory(head): + return None if "/" in path: if head in LIBRARY_REPOSITORIES: target = self.index(LIBRARY_REPOSITORIES[head]) @@ -428,6 +453,29 @@ def resolves(self, reference: str, repo: str, directory: str) -> bool | None: return None return _resolves_extensionless(target, path, directory) + def alternates(self, repo: str, line_text: str) -> list[str]: + """Other repositories a reference on this line may legitimately point into. + + The course's companion workspace (``companion_workspace``), then any + sibling checkout named on the same line ("``_assistant``'s + ``colab/contexts.yaml``"). Only consulted for a reference that is dead in + its own repository, so a genuinely dead path stays dead unless it + resolves in one of these. + """ + named = [ + word for word in REPOSITORY_WORD.findall(line_text) + if word in self.siblings and word != repo + ] + companion = companion_workspace(repo) + ordered = ([companion] if companion in self.siblings else []) + named + return list(dict.fromkeys(ordered)) + + def resolves_elsewhere(self, reference: str, repo: str, line_text: str) -> bool: + return any( + self.resolves(reference, other, "") is True + for other in self.alternates(repo, line_text) + ) + def resolves_name(self, name: str, repo: str, directory: str) -> bool | None: """Resolve a bare structure-list name as either a folder or a file.""" target = self.index(repo) @@ -435,6 +483,8 @@ def resolves_name(self, name: str, repo: str, directory: str) -> bool | None: return None if name in RUNTIME_DIRECTORIES: return None + if name in GENERATED_DIRECTORIES and not target.has_directory(name): + return None if "." in name: return bool( target.has_file(name) @@ -517,6 +567,12 @@ def findings_in_file( continue seen.add((line, reference)) resolved = resolver.resolves(reference, repository.name, directory) + if resolved is False: + line_start = offsets[line - 1] if line - 1 < len(offsets) else match.start() + line_end = text.find("\n", match.end()) + line_text = text[line_start : line_end if line_end != -1 else len(text)] + if resolver.resolves_elsewhere(reference, repository.name, line_text): + resolved = True if resolved is None: resolver.suppressed += 1 elif not resolved: diff --git a/tests/test_hygiene_conductor.py b/tests/test_hygiene_conductor.py index 4e92bd6..671ab4d 100644 --- a/tests/test_hygiene_conductor.py +++ b/tests/test_hygiene_conductor.py @@ -1295,3 +1295,65 @@ def test_escapes_clean_repo_reports_no_findings(tmp_path): assert row["status"] == "clean" assert row["count"] == 0 assert row["findings"] == [] + + +def _companion_pair(): + """A made-up course and the workspace the naming convention pairs it with.""" + return "HowToDemo", "autodemo_workspace" + + +def _write_cross_repo_refs_fixture(root): + """A HowTo course, its companion workspace and an assistant (PyAutoBrain#583).""" + course_name, workspace_name = _companion_pair() + workspace = root / workspace_name + (workspace / "scripts/interferometer").mkdir(parents=True) + (workspace / "scripts/interferometer/modeling.py").write_text("x = 1\n") + + assistant = root / "demo_assistant" + (assistant / "colab").mkdir(parents=True) + (assistant / "colab/contexts.yaml").write_text("a: 1\n") + (assistant / "scripts").mkdir() + + course = root / course_name + (course / "scripts/chapter_1").mkdir(parents=True) + (course / "scripts/chapter_1/tutorial_1.py").write_text( + '"""\n' + "The workspace's `scripts/interferometer/modeling.py` shows the full fit.\n" + "The `scripts/interferometer/missing_example.py` example was removed.\n" + "Instructions come from demo_assistant's `colab/contexts.yaml`.\n" + "Fits write `files/search_internal` and cache to `cache_root/crds/`.\n" + '"""\n' + ) + (course / "README.md").write_text( + "\n".join( + [ + "- `scripts`: the tutorials.", + "- `config`: configuration.", + "- `notebooks`: generated at release time.", + "- `cache`: downloads, re-used across runs.", + "", + ] + ) + ) + (course / "config").mkdir() + return course + + +def test_refs_checks_companion_workspace_and_named_sibling(tmp_path): + _write_cross_repo_refs_fixture(tmp_path) + + found = {f["reference"] for f in _refs_row(tmp_path)["findings"]} + + # Resolves in the course's companion workspace / the sibling named on the line. + assert "scripts/interferometer/modeling.py" not in found + assert "colab/contexts.yaml" not in found + # Dead in the course AND in the companion: still a finding. + assert "scripts/interferometer/missing_example.py" in found + + +def test_refs_treats_run_output_and_generated_folders_as_unresolvable(tmp_path): + _write_cross_repo_refs_fixture(tmp_path) + + found = {f["reference"] for f in _refs_row(tmp_path)["findings"]} + + assert {"files/search_internal", "cache_root/crds/", "notebooks", "cache"}.isdisjoint(found)