diff --git a/research/overlap-path-index/README.md b/research/overlap-path-index/README.md new file mode 100644 index 0000000000..32a090371e --- /dev/null +++ b/research/overlap-path-index/README.md @@ -0,0 +1,115 @@ +# overlap-path-index: search open-PR overlap by changed file path + +**Verifier engineering; no new kappa, no certificate or selected-record change.** +This package documents and verifies one change to the live coordination tool +`scripts/find_overlap.py`: it now caches each open PR's changed-file list and +answers "which open PRs touch this path?", the request left in +[the workflow feedback thread](https://github.com/CrocSwap/integer-mult-bounds/discussions/1200#discussioncomment-18909265). + +## Scope and evidence + +Original card: `replay-runtime` (verifier-engineering), scope **refined** to the +card's allowed "source-bound cache boundary" variation; see +[TASK.json](TASK.json) for the scope-change reason. Source commits, Git blobs +and SHA256 pins are in [SOURCES.json](SOURCES.json); the catalogue and selected +record are pinned as historical provenance. + +**What changed.** `find_overlap.py` gains a third snapshot source, `pr_files`, +fetched only when `--path` is used, plus `fetch_pr_files` and `path_report`. +Body term search, its match order and its report fields are unchanged. With +both axes given, `matches` is the intersection (a record must hit at least one +term and one path) while `path_matches` stays the superset of all path hits, so +a path hit can never disappear silently. Patterns are `fnmatch` patterns or +literal substrings of the repository-relative path. A PR whose changed-file +list is unavailable (for example +[CrocSwap/integer-mult-bounds#376](https://github.com/CrocSwap/integer-mult-bounds/pull/376) +at the measured snapshot) is recorded +with empty paths and `truncated: true`, and PRs beyond GitHub's 100-file +listing are flagged `truncated: true` in the output; neither is silently +trusted. Diff contents remain unsearched, and the report's `path_limits` says so. + +**Measured behavior** (Windows 11, CPython 3.13.15, this host): + +| Check | Before | After | +|---|---:|---:| +| Body-only overlap query (offline cache) | 0.48 s | 0.48 s (no `pr_files` fetch at all) | +| Full changed-file index build | impossible (no path data) | 87.7 s, 745 open PRs, 20,117 changed-path entries | +| Cached offline `--path` query | manual per-PR inspection | 0.48 s | + +The index fetch pages 10 PRs at a time with up to four attempts per page: +25x100-node pages exceed GitHub's ~10 s gateway limit and returned HTTP 502 on +this host (measured: failing calls died at 10.5-10.7 s), while 10x100 pages +completed in 0.8-4.4 s. A failed refresh keeps the previous snapshot and warns; +it never reports "no overlap" from missing data without a warning. + +**Live answers to the request's example questions** at the measured snapshot: +`docs/research/current-status.md` is touched by exactly one open PR, +[CrocSwap/integer-mult-bounds#1232](https://github.com/CrocSwap/integer-mult-bounds/pull/1232); +`research/lean-external-review` is touched by +[CrocSwap/integer-mult-bounds#1365](https://github.com/CrocSwap/integer-mult-bounds/pull/1365) +and +[CrocSwap/integer-mult-bounds#1494](https://github.com/CrocSwap/integer-mult-bounds/pull/1494), +the stacked level-model phases of +[CrocSwap/integer-mult-bounds#442](https://github.com/CrocSwap/integer-mult-bounds/pull/442). + +## Reproduce + +From the repository root (no network needed): + +```sh +python3 scripts/research_package.py --package research/overlap-path-index --check-only +python3 scripts/research_package.py --package research/overlap-path-index +``` + +Expected: `PASS_OVERLAP_PATH_INDEX`, then registered-package success. The +verifier checks the two pinned live inputs, runs the 11 Overlap unit tests and +seven hermetic CLI controls (positive, substring, glob, rejection, +term-and-path, mutation, missing-index) against a fixture snapshot cache placed +outside the repository. A live index build additionally needs authenticated +`gh`; `--path` queries within the cache lifetime cost nothing extra. + +## Limitations + +Tooling only. No mathematical claim, kappa, supplier, certificate, replay +coverage or selected-frontier change. Path matching is a changed-file +inventory, not diff-content search; closed PRs remain represented only through +local READMEs, TASK metadata and the PR ledger. The full index build costs +about 88 s and is paid only by `--path` queries at refresh time; GitHub's +~10 s gateway limit caps the page size. Whether the two live input files change +later is not this package's call: they are pinned, so a future edit of either +needs an explicit source-pin migration (same convention as other tooling +packages here). The pre-existing Windows CRLF failures in `PackageTests` +(fixed in [CrocSwap/integer-mult-bounds#1298](https://github.com/CrocSwap/integer-mult-bounds/pull/1298)) +are unrelated and untouched. No independent human review or hosted-CI success +is claimed. + +## Open next questions + +Structured in [TASK.json](TASK.json): + +1. **Closed-PR path coverage.** Can the index cover closed/merged PRs via the + ledger's `COVERAGE.json.gz` inventories? Matters because closed work holds + most duplicates. First step: a reader for + `research/pr-reconciliation100-20261013/COVERAGE.json.gz`. Blocked by the + need for an explicit freshness/coverage label so batch snapshots cannot + overstate coverage. +2. **Cheaper refresh.** Should body-only searches carry the index, and can the + 87.7 s build be cut? First step: per-page caching keyed by PR head sha. + Blocked by GraphQL offering no ETags for this connection; the time cost must + be shown worth paying first. +3. **True stacking.** "What stacks on PR N" is approximated by shared paths; + base/head chains would be exact. First step: record each open PR's base ref + in `pr_files` rows. Blocked by stacked PRs here mostly targeting `main` and + naming their parent only in prose. + +## Credit and provenance + +The overlap helper, its snapshot design and its coverage contract are Douglas +Colkitt's (with OpenAI Codex assistance, Apache-2.0, retained unchanged except +as described). The changed-file request is Mister-Meeseeks's +[discussion 1200](https://github.com/CrocSwap/integer-mult-bounds/discussions/1200#discussioncomment-18909265) +item 2. The onboarding UTF-8/Windows fixes in +[CrocSwap/integer-mult-bounds#1298](https://github.com/CrocSwap/integer-mult-bounds/pull/1298) +(rtyu193-rgb) affect the same script and are complementary; this change does not +include them. Prepared by Aaron Wang / navinor, with help from opencode. +Apache-2.0. diff --git a/research/overlap-path-index/SOURCES.json b/research/overlap-path-index/SOURCES.json new file mode 100644 index 0000000000..d5f7a95eb1 --- /dev/null +++ b/research/overlap-path-index/SOURCES.json @@ -0,0 +1,30 @@ +{ + "schema_version": 1, + "base_commit": "04aecc3d2d818df020b628a41082c79951202628", + "files": [ + { + "path": "certificates/selected-result.json", + "usage": "provenance", + "git_blob": "ae6a99f28aa468649eb66a361e764d769363bf11", + "sha256": "10b9e340fd8e354a37a0c4813c90e5de1dd42044b5f6aca4a3fc5a42b685fa32" + }, + { + "path": "docs/research/agent-task-catalog.json", + "usage": "provenance", + "git_blob": "c05fafaa25ba95b2d5af86b1800427198e3d20eb", + "sha256": "89b5928bc92c81e2647b6d702a7ad85a6795b8f44e2eb267b2ff3145d8f2b2ce" + }, + { + "path": "scripts/find_overlap.py", + "usage": "input", + "git_blob": "2668844310ea52bb4fe3c4561dd4ee16fcdd20cc", + "sha256": "ce77860b18ea5a58ee527a1055e0ba7c650b2da2167a6aaeaaf83af099689b4f" + }, + { + "path": "tests/test_research_onboarding.py", + "usage": "input", + "git_blob": "8d5dfdee97a59153ddc155d2590a35131aea6076", + "sha256": "b0f23e38a5d6fa2079712df1f564a23b9bab0b997bc90f09f48965aa4802cc2e" + } + ] +} diff --git a/research/overlap-path-index/TASK.json b/research/overlap-path-index/TASK.json new file mode 100644 index 0000000000..56f3a74872 --- /dev/null +++ b/research/overlap-path-index/TASK.json @@ -0,0 +1,73 @@ +{ + "schema_version": 1, + "task_id": "replay-runtime", + "priority_revision": 2, + "base_commit": "04aecc3d2d818df020b628a41082c79951202628", + "original_card": { + "id": "replay-runtime", + "question": "Can one measured replay bottleneck be sped up without dropping coverage?", + "starting_points": [ + "scripts/run_isolated_tests.py", + "scripts/verify_replay_coverage.py", + "certificates/selected-result.json" + ], + "methods": [ + "Profile one isolated replay and optimize the measured bottleneck.", + "Design source-bound reuse with explicit dependency invalidation and negative controls." + ], + "acceptance_gate": "Report before/after wall time on the same fixture, retained checks and invalidation behavior.", + "first_step": { + "command": [ + "python3", + "-B", + "scripts/research_baseline.py" + ], + "expected": "PASS_RESEARCH_BASELINE_BINDINGS_ONLY", + "inspect": "Read the selected reproduction command and choose a single replay stage for profiling.", + "scope": "Positive control / source-bound orientation only; this is not a new construction or full cold replay." + }, + "first_experiment": "Measure one unchanged fixture, preserve check coverage, and test one optimization with invalidation controls.", + "bottleneck_link": "Faster trustworthy replays increase review throughput and let mechanism candidates be checked sooner.", + "allowed_variations": [ + "Profile one stage or design one source-bound cache boundary." + ], + "scope_change_triggers": [ + "Skipping coverage or comparing different fixtures to claim speedup." + ], + "overlap_terms": [ + "replay runtime", + "cache invalidation", + "isolated tests" + ] + }, + "scope_status": "refined", + "actual_scope": "Cache each open PR's changed-file list inside find_overlap.py's existing source-bound snapshot and search it by path (--path), implementing the request in discussion 1200 (comment 18909265), with body term search unchanged and stale/partial indexes reported rather than trusted.", + "scope_change_reason": "The card's measured check chosen is the overlap helper's own remote refresh, the coordination check discussion 1200 names as expensive and unable to see code paths; this is the card's allowed variation 'design one source-bound cache boundary'. The card's other starting points (isolated-test replays) were left alone because open work already covers nearby replay optimizations (CrocSwap/integer-mult-bounds#1461, CrocSwap/integer-mult-bounds#1584, CrocSwap/integer-mult-bounds#1101).", + "bottleneck_link": "Overlap checks are the coordination bottleneck every agent pays before claiming novelty; today they cannot see changed code paths at all, so agents inspect PRs one by one and miss concurrent duplicates (the exact failure discussion 1166 coordination rules warn about). One cached offline path query replaces that manual inspection.", + "budget_minutes": 60, + "stop_rule": "Ship the index and its controls if a cached offline path query answers both discussion 1200 example questions; stop if GitHub cannot serve the file lists within its gateway limits at any page size tested (25 PRs/page 502s at ~10 s, 10 PRs/page works).", + "outcome": "completed", + "evidence": "scripts/find_overlap.py gained fetch_pr_files/path_report and --path; 5 new fixture tests plus 6 pre-existing Overlap tests pass (11 total); the package verifier runs 7 hermetic CLI controls. Measured on Windows 11, CPython 3.13: full index build 745 open PRs / 20117 changed-path entries in 87.7 s, cached offline path query 0.48 s, body searches pay nothing extra (pr_files is fetched only for --path queries). Live answers: docs/research/current-status.md is touched by CrocSwap/integer-mult-bounds#1232 only; research/lean-external-review is touched by CrocSwap/integer-mult-bounds#1365 and CrocSwap/integer-mult-bounds#1494, the stacked CrocSwap/integer-mult-bounds#442 level-model work. 112 PRs (of 745) exceed GitHub's 100-file listing and are flagged truncated; https://github.com/CrocSwap/integer-mult-bounds/pull/376 reports no file list and is recorded empty+flagged.", + "author": "Aaron Wang / navinor, with help from opencode", + "ai_assistance": "Substantial assistance from opencode (agent tooling): design, implementation, tests, measurements and writing. No other AI tools used.", + "open_questions": [ + { + "question": "Can the same path index cover closed/merged PRs, whose changed files are now reachable only through the local ledger's COVERAGE inventories?", + "why_it_matters": "Closed work is where most duplicates already live; today find_overlap searches closed PRs only through local READMEs and the ledger.", + "first_step": "Prototype a reader for research/pr-reconciliation100-20261013/COVERAGE.json.gz changed-file inventories and merge it into path_report with an explicit closed-PR coverage label.", + "blocked_by": "Ledger inventories are batch snapshots; they need a freshness/coverage statement before they can appear in the same report without overstating coverage." + }, + { + "question": "Should body-only searches also carry the changed-file index, and can its 87.7 s refresh be made cheap enough for that?", + "why_it_matters": "A combined body+path score would rank path-touching records higher in ordinary overlap reports.", + "first_step": "Measure the fetch with conditional requests (ETag/If-None-Match on the pulls listing) and per-page caching keyed by PR head sha.", + "blocked_by": "GraphQL does not expose ETags for this connection; the time cost is the price of ~74 pages, so cost accounting must show the win first." + }, + { + "question": "What stacks on a given PR beyond shared files (base-branch chains)?", + "why_it_matters": "The discussion 1200 question about what stacks on https://github.com/CrocSwap/integer-mult-bounds/pull/540 is only approximated by shared-path hits; true stacking is a head/base relationship.", + "first_step": "Record each open PR's base ref in the pr_files rows and flag PRs whose base names another open PR's head branch.", + "blocked_by": "Most stacked PRs here target main and reference their parent only in prose, so the signal would be incomplete and must be labeled as such." + } + ] +} diff --git a/research/overlap-path-index/VERIFY.json b/research/overlap-path-index/VERIFY.json new file mode 100644 index 0000000000..7368013d49 --- /dev/null +++ b/research/overlap-path-index/VERIFY.json @@ -0,0 +1,21 @@ +{ + "schema_version": 1, + "status": "ready", + "python": "3.12", + "requirements": [], + "inputs": [ + "scripts/find_overlap.py", + "tests/test_research_onboarding.py" + ], + "commands": [ + { + "argv": [ + "{python}", + "-B", + "{package}/verify.py" + ], + "timeout_seconds": 300, + "checks": "Identity (SOURCES.json pins for both live inputs), the 11 Overlap unit tests against the pinned tool, and 7 hermetic CLI controls on a fixture snapshot cache: positive path hit, substring and fnmatch patterns, rejection (untouched path), term/path intersection with the superset property, a mutated-fixture control that must lose the hit, and a missing-index control that must warn instead of reporting no overlap. No network access." + } + ] +} diff --git a/research/overlap-path-index/verify.py b/research/overlap-path-index/verify.py new file mode 100644 index 0000000000..45c3173f02 --- /dev/null +++ b/research/overlap-path-index/verify.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 +"""Verify the find_overlap changed-file path index without network access. + +Checks, in order: exact source pins (identity), the Overlap unit tests against +the pinned tool, a hermetic CLI fixture with a positive path match, term/path +AND semantics, a rejection control, a mutated-fixture control that must lose +the match, and a missing-index control that must warn instead of reporting +"no overlap" silently. + +Aaron Wang / navinor, with help from opencode. Apache-2.0. +""" +import hashlib +import json +from pathlib import Path +import subprocess +import sys +import tempfile + +ROOT = Path(__file__).resolve().parents[2] +PACKAGE = Path(__file__).resolve().parent +PYTHON = sys.executable + + +def check_sources(): + pins = json.loads((PACKAGE / "SOURCES.json").read_text(encoding="utf-8")) + for entry in pins["files"]: + if entry["usage"] != "input": + continue + data = (ROOT / entry["path"]).read_bytes() + assert hashlib.sha256(data).hexdigest() == entry["sha256"], "pin mismatch: " + entry["path"] + return sum(1 for entry in pins["files"] if entry["usage"] == "input") + + +def run_overlap_tests(): + result = subprocess.run( + [PYTHON, "-B", "-m", "unittest", "discover", "-s", "tests", "-p", "test_research_onboarding.py", + "-k", "Overlap"], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=300) + assert result.returncode == 0, "overlap unit tests failed:\n" + result.stderr[-2000:] + assert "OK" in result.stderr, "unexpected unittest output:\n" + result.stderr[-2000:] + ran = [line for line in result.stderr.splitlines() if line.startswith("Ran ")] + return ran[-1] if ran else "?" + + +def fixture_cache(path, mutated=False, drop_index=False): + hit_path = "docs/research/current-status.md" + if mutated: + hit_path = "docs/research/unrelated.md" + sources = { + "open_prs": {"fetched_at": "2026-10-14T00:00:00+00:00", "complete_pagination": True, "records": [ + {"kind": "pr", "number": 4242, "title": "Fixture: status rewrite", "body": "touches the status doc", + "url": "https://github.com/CrocSwap/integer-mult-bounds/pull/4242", "head": "a" * 40, + "updated_at": "2026-10-14T00:00:00Z", "draft": False, "state": "open"}, + {"kind": "pr", "number": 4243, "title": "Fixture: other", "body": "unrelated", + "url": "https://github.com/CrocSwap/integer-mult-bounds/pull/4243", "head": "b" * 40, + "updated_at": "2026-10-14T00:00:00Z", "draft": False, "state": "open"}]}, + "discussions": {"fetched_at": "2026-10-14T00:00:00+00:00", "complete_pagination": True, "records": []}, + } + if not drop_index: + sources["pr_files"] = {"fetched_at": "2026-10-14T00:00:00+00:00", "complete_pagination": True, "records": [ + {"number": 4242, "paths": [hit_path, "README.md"], "truncated": False}, + {"number": 4243, "paths": ["research/other/README.md"], "truncated": False}]} + path.write_text(json.dumps({"schema_version": 1, "repository": "CrocSwap/integer-mult-bounds", + "sources": sources}), encoding="utf-8") + + +def run_cli(cache, extra): + result = subprocess.run( + [PYTHON, "-B", str(ROOT / "scripts" / "find_overlap.py"), "--offline", + "--cache", str(cache), "--json", *extra], + cwd=ROOT, capture_output=True, text=True, encoding="utf-8", timeout=300) + assert result.returncode == 0, "CLI failed:\n" + result.stderr[-2000:] + return json.loads(result.stdout) + + +def main(): + pinned = check_sources() + tests = run_overlap_tests() + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) / "fixture-cache.json" + + fixture_cache(cache) + report = run_cli(cache, ["--path", "docs/research/current-status.md", "--limit", "5"]) + numbers = [m["number"] for m in report["path_matches"]] + assert numbers == [4242], "positive fixture failed: " + repr(numbers) + assert report["path_matches"][0]["matched_paths"] == ["docs/research/current-status.md"] + + # Substring and fnmatch pattern forms both match the same fixture. + assert [m["number"] for m in run_cli(cache, ["--path", "current-status.md"])["path_matches"]] == [4242] + assert [m["number"] for m in run_cli(cache, ["--path", "docs/*"])["path_matches"]] == [4242] + + # Rejection control: a pattern no PR touches matches nothing. + rejected = run_cli(cache, ["--path", "no-such-path-xyz"]) + assert rejected["path_matches_total"] == 0, "rejection control failed" + + # Term/path AND semantics: the path hit alone never enters matches ... + both = run_cli(cache, ["--path", "docs/research/current-status.md", "status rewrite"]) + assert [m["number"] for m in both["path_matches"]] == [4242] + assert [m["number"] for m in both["matches"]] == [4242] + # ... while path_matches stays the superset of all path hits (no false negative). + neither = run_cli(cache, ["--path", "docs/research/current-status.md", "no-such-term-xyz"]) + assert neither["matches"] == [] and neither["path_matches_total"] == 1 + + # Mutated fixture: once the changed file is different, the match is gone. + fixture_cache(cache, mutated=True) + assert run_cli(cache, ["--path", "docs/research/current-status.md"])["path_matches_total"] == 0 + + # Missing index warns and reports zero, never a silent "no overlap". + fixture_cache(cache, drop_index=True) + missing = run_cli(cache, ["--path", "docs/research/current-status.md"]) + assert missing["path_matches_total"] == 0 + assert any("pr_files" in w for w in missing["warnings"]), "missing-index warning absent" + + print(json.dumps({ + "status": "PASS_OVERLAP_PATH_INDEX", + "pinned_inputs": pinned, + "unit_tests": tests, + "controls": ["positive", "substring", "glob", "rejection", "term-and-path", "mutation", "missing-index"], + }, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/scripts/find_overlap.py b/scripts/find_overlap.py index 8c43a39f8a..2668844310 100644 --- a/scripts/find_overlap.py +++ b/scripts/find_overlap.py @@ -7,12 +7,14 @@ """ import argparse from datetime import datetime, timezone +import fnmatch import hashlib import json import os from pathlib import Path import subprocess import tempfile +import time ROOT = Path(__file__).resolve().parents[1] REPO = "CrocSwap/integer-mult-bounds" @@ -45,6 +47,68 @@ def pull_records(pages): for page in pages for pr in page] +def fetch_pr_files(): + """Changed file paths per open PR, so overlap can be searched by path. + + GitHub reports at most 100 changed files per page; a PR beyond that is + flagged truncated. This is a file inventory, not a search of diff contents. + """ + rows = [] + cursor = None + while True: + # ponytail: 10 PRs per page (25x100-node pages exceed GitHub's ~10 s + # gateway limit and 502; 10x100 stays under it). A full index refresh is + # ~74 pages and only path queries pay it. Each page gets four attempts. + result = None + for attempt in range(4): + try: + result = graphql('''query($cursor:String) { + repository(owner:"CrocSwap",name:"integer-mult-bounds") { + pullRequests(first:10,states:OPEN,after:$cursor) { + pageInfo {hasNextPage endCursor} + nodes {number files(first:100) { + pageInfo {hasNextPage} + nodes {path} + }} + } + } + }''', {"cursor": cursor})["repository"]["pullRequests"] + break + except subprocess.SubprocessError: + if attempt == 3: + raise + time.sleep(1 + attempt) + for pull in result["nodes"]: + # PR 376 (and similar) report no file list; record it as empty and + # flagged so path search never silently trusts missing data. + connection = pull.get("files") or {"pageInfo": {"hasNextPage": True}, "nodes": []} + rows.append({"number": pull["number"], + "paths": sorted({node["path"] for node in connection["nodes"] if node}), + "truncated": bool(connection["pageInfo"]["hasNextPage"])}) + if not result["pageInfo"]["hasNextPage"]: + return sorted(rows, key=lambda row: row["number"]) + cursor = result["pageInfo"]["endCursor"] + + +def path_report(pr_records, file_rows, patterns): + """Open-PR entries whose changed files match any pattern (fnmatch or substring).""" + pulls = {record["number"]: record for record in pr_records if record.get("kind") == "pr"} + matches = [] + for row in sorted(file_rows, key=lambda item: -item["number"]): + matched = [path for path in row["paths"] + if any(fnmatch.fnmatch(path, pattern) or pattern in path for pattern in patterns)] + if not matched: + continue + pull = pulls.get(row["number"], {}) + matches.append({"kind": "pr", "number": row["number"], "title": pull.get("title", ""), + "url": pull.get("url") or f"https://github.com/{REPO}/pull/{row['number']}", + "head": pull.get("head"), + "updated_at": pull.get("updated_at"), "draft": pull.get("draft", False), + "matched_paths": matched, "changed_paths": len(row["paths"]), + "truncated": row.get("truncated", False)}) + return matches + + def fetch_discussions(): records = [] cursor = None @@ -116,7 +180,7 @@ def error_summary(exc): return str(exc)[:700] -def remote_snapshot(cache, offline=False, refresh=False, max_age=300): +def remote_snapshot(cache, offline=False, refresh=False, max_age=300, need_files=False): warnings = [] try: snapshot = json.loads(cache.read_text()) @@ -125,11 +189,13 @@ def remote_snapshot(cache, offline=False, refresh=False, max_age=300): except (OSError, ValueError): snapshot = {"schema_version": 1, "repository": REPO, "sources": {}} sources = snapshot.setdefault("sources", {}) - for name, fetch in ( - ("open_prs", lambda: pull_records(gh_json([ - "--paginate", "--slurp", f"repos/{REPO}/pulls?state=open&per_page=100&sort=updated&direction=desc"]))), - ("discussions", fetch_discussions), - ): + fetches = [("open_prs", lambda: pull_records(gh_json([ + "--paginate", "--slurp", f"repos/{REPO}/pulls?state=open&per_page=100&sort=updated&direction=desc"]))), + ("discussions", fetch_discussions)] + if need_files: + # Only path queries pay for the changed-file index; body searches stay as they were. + fetches.append(("pr_files", fetch_pr_files)) + for name, fetch in fetches: cached = sources.get(name) age = None if cached: @@ -198,6 +264,8 @@ def main(): ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("terms", nargs="*", help="literal case-insensitive terms; quote multiword phrases") ap.add_argument("--task", help="add the named catalogue card's overlap terms") + ap.add_argument("--path", action="append", default=[], metavar="PATTERN", + help="match open PRs by changed file path (fnmatch pattern or substring); repeatable") ap.add_argument("--offline", action="store_true") ap.add_argument("--refresh", action="store_true") ap.add_argument("--max-age", type=int, default=300, help="remote cache lifetime in seconds") @@ -218,10 +286,12 @@ def main(): ap.error("unknown task") terms.extend([args.task, *cards[0].get("overlap_terms", [])]) terms.extend(Path(p).parent.name for p in cards[0]["starting_points"] if p.startswith("research/")) - if not any(t.strip() for t in terms): - ap.error("provide terms or --task") + if not any(t.strip() for t in terms) and not args.path: + ap.error("provide terms, --task or --path") + patterns = [p.strip() for p in args.path if p.strip()] local = local_records(ROOT) - snapshot, warnings = remote_snapshot(args.cache, args.offline, args.refresh, args.max_age) + snapshot, warnings = remote_snapshot(args.cache, args.offline, args.refresh, args.max_age, + need_files=bool(patterns)) records = list(local) coverage = {} for name in ("open_prs", "discussions"): @@ -230,17 +300,40 @@ def main(): if source: records.extend(source["records"]) coverage[name]["records"] = len(source["records"]) - matches = search(records, terms, args.all_terms) + path_matches = [] + if patterns: + source = snapshot["sources"].get("pr_files") + coverage["pr_files"] = {k: source[k] for k in ("fetched_at", "complete_pagination")} if source else None + if source: + coverage["pr_files"]["records"] = len(source["records"]) + pulls = snapshot["sources"].get("open_prs", {"records": []})["records"] + path_matches = path_report(pulls, source["records"], patterns) + matches = search(records, terms, args.all_terms) if any(t.strip() for t in terms) else [] + if patterns and matches: + # With both axes given, a record must match at least one term and one path. + hits = {m["number"]: m["matched_paths"] for m in path_matches} + matches = [dict(m, matched_paths=hits[m.get("number")]) for m in matches + if m.get("kind") == "pr" and m.get("number") in hits] + if patterns and not any(t.strip() for t in terms): + matches = [] report = {"status": "POSSIBLE_OVERLAP_NOT_A_NOVELTY_CERTIFICATE", "searched_at": now(), "terms": terms, "coverage": coverage, "local_records": len(local), "warnings": warnings, "limits": "Remote PR scope is open PRs including drafts. Closed work is searched through local READMEs, TASK metadata and ledger only. No code diffs or arbitrary package files are searched. Recheck relevant heads before submission.", "matches_total": len(matches), "matches": matches[:args.limit]} + if patterns: + report["path_patterns"] = patterns + report["path_limits"] = ("Path matching covers the changed file paths GitHub reports for open PRs, " + "not diff contents; PRs beyond 100 changed files are flagged truncated.") + report["path_matches_total"] = len(path_matches) + report["path_matches"] = path_matches[:args.limit] if args.json_output: print(json.dumps(report, indent=2)) else: print(report["status"]) print("Coverage: " + json.dumps(coverage)) print(report["limits"]) + if patterns: + print(report["path_limits"]) for warning in warnings: print("WARNING: " + warning) print(f"{len(matches)} possible matches; showing {min(args.limit, len(matches))}") @@ -249,6 +342,15 @@ def main(): if match.get("head"): print("Head: " + match["head"]) print(match["excerpt"]) + if patterns: + print(f"\n{len(path_matches)} open PRs match paths {patterns}; " + f"showing {min(args.limit, len(path_matches))}") + for match in path_matches[:args.limit]: + flags = " (file list truncated)" if match["truncated"] else "" + print(f"\n[pr {match['number']}] {match['title']}{flags}\n{match['url']}") + if match.get("head"): + print("Head: " + match["head"]) + print("Matched paths: " + ", ".join(match["matched_paths"])) if __name__ == "__main__": diff --git a/tests/test_research_onboarding.py b/tests/test_research_onboarding.py index bf17caded1..8d5dfdee97 100644 --- a/tests/test_research_onboarding.py +++ b/tests/test_research_onboarding.py @@ -92,6 +92,117 @@ def test_local_readme_and_ledger(self): self.assertEqual(matches[0]["kind"], "local-task") +class OverlapPathIndexTests(unittest.TestCase): + """Changed-file index for open PRs (discussion 1200 request: search by path).""" + + ROWS = [{"number": 7, "paths": ["docs/research/current-status.md", "README.md"], "truncated": False}, + {"number": 8, "paths": ["research/lean-external-review/EXPLAINER.md"], "truncated": True}] + + def test_path_report_exact_glob_substring_and_rejection(self): + prs = [{"kind": "pr", "number": 7, "title": "T", "body": "b", "url": "u7", "head": "a" * 40, + "updated_at": "d", "draft": False, "state": "open"}] + exact = overlap.path_report(prs, self.ROWS, ["docs/research/current-status.md"]) + self.assertEqual([m["number"] for m in exact], [7]) + self.assertEqual(exact[0]["matched_paths"], ["docs/research/current-status.md"]) + self.assertFalse(exact[0]["truncated"]) + # basename substring and fnmatch glob both match + self.assertEqual(overlap.path_report(prs, self.ROWS, ["current-status.md"])[0]["number"], 7) + globbed = overlap.path_report(prs, self.ROWS, ["docs/*"]) + self.assertEqual(globbed[0]["matched_paths"], ["docs/research/current-status.md"]) + # rejection control: no PR changed a matching path + self.assertEqual(overlap.path_report(prs, self.ROWS, ["TASK.json"]), []) + # PR metadata joined when known (with a URL fallback when the metadata + # snapshot is older than the file index), truncation flag preserved + unjoined = overlap.path_report([], self.ROWS, ["EXPLAINER.md"]) + self.assertEqual(unjoined[0]["title"], "") + self.assertEqual(unjoined[0]["url"], + "https://github.com/CrocSwap/integer-mult-bounds/pull/8") + self.assertTrue(unjoined[0]["truncated"]) + + def test_fetch_pr_files_paginates_and_flags_truncation(self): + def page(rows, more): + return {"repository": {"pullRequests": { + "pageInfo": {"hasNextPage": more, "endCursor": "next"}, + "nodes": [{"number": n, "files": { + "pageInfo": {"hasNextPage": trunc}, + "nodes": [{"path": p} for p in paths]}} + for n, paths, trunc in rows]}}} + pages = [page([(1, ["a.txt", "b.txt"], False)], True), + page([(2, ["c.txt", "c.txt"], True)], False)] + with patch.object(overlap, "graphql", side_effect=pages) as call: + rows = overlap.fetch_pr_files() + self.assertEqual(call.call_count, 2) + self.assertEqual(rows[0], {"number": 1, "paths": ["a.txt", "b.txt"], "truncated": False}) + self.assertEqual(rows[1], {"number": 2, "paths": ["c.txt"], "truncated": True}) + + def test_fetch_pr_files_skips_unavailable_file_lists(self): + page = {"repository": {"pullRequests": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [{"number": 376, "files": None}, + {"number": 9, "files": {"pageInfo": {"hasNextPage": False}, + "nodes": [{"path": "ok.md"}, None]}}]}}} + with patch.object(overlap, "graphql", return_value=page): + rows = overlap.fetch_pr_files() + self.assertEqual(rows[0], {"number": 9, "paths": ["ok.md"], "truncated": False}) + # An unavailable file list is recorded as empty and flagged, never silently trusted. + self.assertEqual(rows[1], {"number": 376, "paths": [], "truncated": True}) + + def test_fetch_pr_files_retries_transient_page_failures(self): + page = {"repository": {"pullRequests": { + "pageInfo": {"hasNextPage": False, "endCursor": None}, + "nodes": [{"number": 1, "files": {"pageInfo": {"hasNextPage": False}, + "nodes": [{"path": "a.md"}]}}]}}} + boom = subprocess.CalledProcessError(1, "gh api graphql") + with patch.object(overlap, "graphql", side_effect=[boom, boom, page]) as call, \ + patch.object(overlap.time, "sleep") as slept: + rows = overlap.fetch_pr_files() + self.assertEqual(rows, [{"number": 1, "paths": ["a.md"], "truncated": False}]) + self.assertEqual(call.call_count, 3) + self.assertEqual(slept.call_count, 2) + with patch.object(overlap, "graphql", side_effect=[boom] * 4), \ + patch.object(overlap.time, "sleep"): + with self.assertRaises(subprocess.CalledProcessError): + overlap.fetch_pr_files() + + def test_pr_files_fetched_only_when_requested_and_cached(self): + rows = [{"number": 3, "paths": ["x.md"], "truncated": False}] + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) / "cache.json" + with patch.object(overlap, "gh_json", return_value=[[]]), \ + patch.object(overlap, "fetch_discussions", return_value=[]), \ + patch.object(overlap, "fetch_pr_files", return_value=rows) as files_fetch: + # Existing invocations pay nothing: pr_files is not fetched or warned about. + snapshot, warnings = overlap.remote_snapshot(cache) + self.assertFalse(warnings) + self.assertNotIn("pr_files", snapshot["sources"]) + self.assertEqual(files_fetch.call_count, 0) + # A path query fetches it once and reuses the cache on the next query. + first, warnings = overlap.remote_snapshot(cache, need_files=True) + self.assertFalse(warnings) + self.assertEqual(first["sources"]["pr_files"]["records"], rows) + second, _ = overlap.remote_snapshot(cache, need_files=True) + self.assertEqual(files_fetch.call_count, 1) + self.assertEqual(second["sources"]["pr_files"]["records"], rows) + with patch.object(overlap, "fetch_pr_files", side_effect=AssertionError("offline network request")): + _, warnings = overlap.remote_snapshot(cache, offline=True, need_files=True) + self.assertTrue(any("pr_files" in w for w in warnings)) + + def test_pr_files_refresh_failure_keeps_old_records(self): + rows = [{"number": 5, "paths": ["keep.md"], "truncated": False}] + with tempfile.TemporaryDirectory() as tmp: + cache = Path(tmp) / "cache.json" + with patch.object(overlap, "gh_json", return_value=[[]]), \ + patch.object(overlap, "fetch_discussions", return_value=[]), \ + patch.object(overlap, "fetch_pr_files", return_value=rows): + overlap.remote_snapshot(cache, need_files=True) + with patch.object(overlap, "gh_json", return_value=[[]]), \ + patch.object(overlap, "fetch_discussions", return_value=[]), \ + patch.object(overlap, "fetch_pr_files", side_effect=ValueError("API unavailable")): + snapshot, warnings = overlap.remote_snapshot(cache, refresh=True, need_files=True) + self.assertTrue(any("pr_files" in w and "refresh failed" in w for w in warnings)) + self.assertEqual(snapshot["sources"]["pr_files"]["records"], rows) + + class PackageTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory()