Repository navigation
feat(guardian): give the finder diff-check's dependants as leads (flagged) #736
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
55d497c
feat(guardian): give the finder diff-check's dependants as leads (fla…
zaebee 449c193
test(guardian): cover the dependants section's failure paths
zaebee 6378f8f
fix(guardian): leads in repo paths, from the merge base, without oper…
zaebee 9874dd5
fix(guardian): no git means no merge base, not a crash
zaebee 8e2a9f3
fix(guardian): fence the leads as data, log a missing merge base
zaebee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,202 @@ | ||
| """The `diff_check` feature: diff-check's dependants as leads in the finder's context (#733).""" | ||
|
|
||
| import subprocess | ||
| from pathlib import Path | ||
| from unittest.mock import patch | ||
|
|
||
| import pytest | ||
|
|
||
| from cgis.guardian.collector import ContextCollector, parse_features | ||
| from cgis.guardian.prompts import PromptBuilder | ||
| from cgis.languages import extractors | ||
| from cgis.pipeline import IngestionPipeline | ||
| from cgis.query.diffcheck import DiffItem, DiffReport | ||
| from cgis.storage.sqlite_store import SQLiteStore | ||
|
|
||
| CALLEE = "def total(items):\n return sum(items)\n" | ||
| CALLER = "from calc import total\n\n\ndef report(items):\n return f'{total(items)}'\n" | ||
|
|
||
|
|
||
| def _git(repo: Path, *args: str) -> str: | ||
| done = subprocess.run( | ||
| ["git", "-c", "user.name=t", "-c", "user.email=t@t", "-C", str(repo), *args], | ||
| capture_output=True, | ||
| text=True, | ||
| check=True, | ||
| ) | ||
| return done.stdout.strip() | ||
|
|
||
|
|
||
| def _commit(repo: Path, files: dict[str, str], message: str) -> str: | ||
| for name, text in files.items(): | ||
| (repo / name).write_text(text) | ||
| _git(repo, "add", "-A") | ||
| _git(repo, "commit", "-q", "-m", message) | ||
| return _git(repo, "rev-parse", "HEAD") | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def changed_callee(tmp_path: Path) -> tuple[Path, str, Path]: | ||
| """(repo, base sha, graph db): `calc.total` changes, `report.report` calls it.""" | ||
| repo = tmp_path / "repo" | ||
| repo.mkdir() | ||
| _git(repo, "init", "-q") | ||
| base = _commit(repo, {"calc.py": CALLEE, "report.py": CALLER}, "base") | ||
| _commit(repo, {"calc.py": CALLEE.replace("sum(items)", "sum(items) or None")}, "edit") | ||
| db = tmp_path / "g.db" | ||
| store = SQLiteStore(str(db)) | ||
| store.connect() | ||
| with store: | ||
| IngestionPipeline(extractors([], data_keys=True), data_keys=True).run( | ||
| str(repo), store=store, rebuild=True | ||
| ) | ||
| return repo, base, db | ||
|
|
||
|
|
||
| def _collector(repo: Path, base: str, db: Path | None, *features: str) -> ContextCollector: | ||
| return ContextCollector( | ||
| project_root=repo, base_ref=base, db_path=db, source_root="", features=frozenset(features) | ||
| ) | ||
|
|
||
|
|
||
| def test_diff_check_is_a_feature() -> None: | ||
| """A typo in GUARDIAN_FEATURES raises; the real name must not.""" | ||
| assert parse_features("diff_check") == frozenset({"diff_check"}) | ||
|
|
||
|
|
||
| def test_the_caller_of_a_changed_function_is_listed(changed_callee: tuple[Path, str, Path]) -> None: | ||
| """The lead the finder would otherwise never see: a caller outside the diff.""" | ||
| repo, base, db = changed_callee | ||
| context = _collector(repo, base, db, "diff_check").collect_all() | ||
| assert "report.report" in context["dependants"] | ||
| assert "report.py" in context["dependants"] | ||
|
|
||
|
|
||
| def test_without_the_flag_nothing_is_added(changed_callee: tuple[Path, str, Path]) -> None: | ||
| """Off by default: the production prompt is unchanged until the flag is set.""" | ||
| repo, base, db = changed_callee | ||
| assert "dependants" not in _collector(repo, base, db).collect_all() | ||
|
|
||
|
|
||
| def test_without_a_graph_nothing_is_added(changed_callee: tuple[Path, str, Path]) -> None: | ||
| """No graph, no leads — and no error.""" | ||
| repo, base, _db = changed_callee | ||
| assert "dependants" not in _collector(repo, base, None, "diff_check").collect_all() | ||
|
|
||
|
|
||
| def test_an_unknown_base_ref_degrades_to_nothing( | ||
| changed_callee: tuple[Path, str, Path], | ||
| ) -> None: | ||
| """No merge base for the ref: the section is dropped before diff-check runs. | ||
|
|
||
| The DiffError path itself is test_a_base_git_cannot_diff_drops_the_section. | ||
| """ | ||
| repo, _base, db = changed_callee | ||
| context = _collector(repo, "no-such-ref", db, "diff_check").collect_all() | ||
| assert "dependants" not in context | ||
|
|
||
|
|
||
| def test_the_leads_get_their_own_section_and_rule() -> None: | ||
| """The finder is told these are leads to read, anchored on a line of the diff.""" | ||
| prompt = PromptBuilder.build_user_prompt( | ||
| {"diff": "+x", "dependants": " [caller] report.report (report.py:4) - calls total"} | ||
| ) | ||
| assert "CODE OUTSIDE THIS DIFF THAT DEPENDS ON IT" in prompt | ||
| assert "[caller] report.report" in prompt | ||
| assert "leads, not findings" in prompt | ||
|
|
||
|
|
||
| def test_no_leads_no_section() -> None: | ||
| """Without the key the prompt is byte-identical to today's.""" | ||
| prompt = PromptBuilder.build_user_prompt({"diff": "+x"}) | ||
| assert "DEPENDS ON IT" not in prompt | ||
|
|
||
|
|
||
| def test_a_base_git_cannot_diff_drops_the_section(changed_callee: tuple[Path, str, Path]) -> None: | ||
| """A merge base that resolves but cannot be diffed is DiffError, logged and dropped.""" | ||
| repo, _base, db = changed_callee | ||
| graph = _collector(repo, "HEAD", db, "diff_check").graph | ||
| assert graph.collect_dependants(repo, "0" * 40) == "" | ||
|
|
||
|
|
||
| def test_an_unexpected_error_drops_the_section(changed_callee: tuple[Path, str, Path]) -> None: | ||
| """Whatever diff-check raises, the review goes on without the section.""" | ||
| repo, base, db = changed_callee | ||
| graph = _collector(repo, base, db, "diff_check").graph | ||
| with patch("cgis.guardian.collector.check_database", side_effect=RuntimeError("boom")): | ||
| assert graph.collect_dependants(repo, base) == "" | ||
|
|
||
|
|
||
| def test_a_change_nothing_depends_on_adds_no_section( | ||
| changed_callee: tuple[Path, str, Path], | ||
| ) -> None: | ||
| """An empty list is not a section: the finder would read 'nothing outside tests' as a claim.""" | ||
| repo, _base, db = changed_callee | ||
| head = _git(repo, "rev-parse", "HEAD") | ||
| graph = _collector(repo, head, db, "diff_check").graph | ||
| assert graph.collect_dependants(repo, head) == "" | ||
|
|
||
|
|
||
| @pytest.fixture | ||
| def ci_shaped(tmp_path: Path) -> Path: | ||
| """The CI layout: `pr/` cloned from an origin, sources under `src/`, main moved on.""" | ||
| up = tmp_path / "up" | ||
| up.mkdir() | ||
| _git(up, "init", "-q", "-b", "main") | ||
| (up / "src").mkdir() | ||
| _commit(up, {"src/calc.py": CALLEE, "src/report.py": CALLER}, "base") | ||
| pr = tmp_path / "pr" | ||
| _git(tmp_path, "clone", "-q", str(up), str(pr)) | ||
| _git(pr, "checkout", "-q", "-b", "feature") | ||
| _commit(pr, {"src/calc.py": CALLEE.replace("sum(items)", "sum(items) or None")}, "edit") | ||
| _commit(up, {"src/later.py": "def later():\n return 1\n"}, "main moves on") | ||
| _git(pr, "fetch", "-q", "origin", "main") | ||
| return pr | ||
|
|
||
|
|
||
| def test_the_ci_shape_lists_the_caller_from_the_merge_base(ci_shaped: Path, tmp_path: Path) -> None: | ||
| """`cgis ingest pr/src`, no base_ref: the range starts where the PR did, paths are repo paths. | ||
|
|
||
| Diffing against the moved tip instead would count main's own new symbol as | ||
| changed by this PR. | ||
| """ | ||
| db = tmp_path / "ci.db" | ||
| store = SQLiteStore(str(db)) | ||
| store.connect() | ||
| with store: | ||
| IngestionPipeline(extractors([], data_keys=True), data_keys=True).run( | ||
| str(ci_shaped / "src"), store=store, rebuild=True | ||
| ) | ||
| collector = ContextCollector( | ||
| project_root=ci_shaped, db_path=db, features=frozenset({"diff_check"}) | ||
| ) | ||
| dependants = collector.collect_all()["dependants"] | ||
| assert "1 symbol(s)" in dependants | ||
| assert "report.report (src/report.py:" in dependants | ||
| assert "cgis_" not in dependants # operator hints are not the finder's business | ||
|
|
||
|
|
||
| def test_the_section_is_bounded_in_characters(tmp_path: Path) -> None: | ||
| """Fifteen long key paths must not crowd out the diff.""" | ||
| long = DiffItem("key-reader", "key:|x", "m.f", "m.py", 1, "reads " + "a." * 400, 1.0) | ||
| report = DiffReport([long] * 15, 0, 0, ["m.f"], ["key:|x"]) | ||
| graph = ContextCollector(project_root=tmp_path, source_root="src").graph | ||
| text = graph.render_dependants(report) | ||
| assert len(text) <= 6_100 | ||
| assert text.endswith("(cut for length)") | ||
| assert "(src/m.py:1)" in text | ||
|
|
||
|
|
||
| def test_no_git_means_no_merge_base(tmp_path: Path) -> None: | ||
| """A runner without git costs the section, not the review.""" | ||
| collector = ContextCollector(project_root=tmp_path, base_ref="main") | ||
| with patch("cgis.guardian.collector.subprocess.run", side_effect=FileNotFoundError("git")): | ||
| assert collector.source.merge_base() is None | ||
|
|
||
|
|
||
| def test_the_leads_are_fenced_as_data() -> None: | ||
| """Names come from the PR's code; the block must read as data, not as prompt text.""" | ||
| prompt = PromptBuilder.build_user_prompt( | ||
| {"diff": "+x", "dependants": "- [caller] a.b (a.py:1)"} | ||
| ) | ||
| assert "```text\n- [caller] a.b (a.py:1)\n```" in prompt |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.