Skip to content

Release 0.16.0: repository context - #23

Merged
sblattj merged 46 commits into
mainfrom
release/0.16.0
Sep 25, 2026
Merged

sblattj merged 46 commits into
mainfrom
release/0.16.0

Conversation

@sblattj

@sblattj sblattj commented Sep 25, 2026

Copy link
Copy Markdown
Owner

Release 0.16.0. The full notes are in CHANGELOG.md under [0.16.0], and the handoff with live-check results is in HANDOFF.md.

What's in it

  • Repository context outside the diff: cross-file definitions, contract excerpts, cross-chunk links #17 Repository context, off by default (PRXREF_REPO_CONTEXT=off|diff|repo). At diff, a chunk worker also sees definitions from the PR's other changed files and the lines another chunk changes inside a type it uses. At repo it also sees definitions from files outside the diff (through imports, Java's same-package convention and a file-name search) and a ### Contract excerpts block: the OpenAPI operation or schema, JSON Schema, or earlier migration a changed route, table or name matches. off sends prompts byte-identical to 0.15.0.
    • New keys: PRXREF_REPO_CONTEXT, PRXREF_REPO_CONTEXT_MAX_CHARS, PRXREF_CONTEXT_CONTRACT_GLOBS, PRXREF_CONTEXT_EXCLUDE_GLOBS (added to a floor that always keeps eval labels, .env* and key files out)
    • --repo-dir PATH reads files from a local checkout of the PR head instead of the forge
    • Every forge adapter can list the repository at the head commit (GitLab through GraphQL, with a REST fallback)
    • --format json and the run record gain repo_context; -v prints a repo context: line in text output
    • Eval cases take a repo_dir field
  • Fixed: an empty model reply is retried once instead of losing the chunk

Verified

  • At 236ce08: 7675 tests pass and ruff check src tests is clean.
  • uv build makes prxref-0.16.0.tar.gz and prxref-0.16.0-py3-none-any.whl, and both pass the leak scans with 0 non-low hits. The version is 0.16.0 in pyproject.toml, __init__.py and uv.lock.
  • Live checks. list_paths matched a raw walk on GitHub, GitLab, Bitbucket Cloud and Azure DevOps (19 of 19 checks, no LLM call). With GLM 5.3 Flash, the Repository context outside the diff: cross-file definitions, contract excerpts, cross-chunk links #17 fixture and the bundled eval cases (28 LLM calls, no failed case) sent byte-identical prompts within each level, and repo added only the context block. Bundled recall was 7 of 8 at both off and diff. An HTTP 201 that exists only in the injected OpenAPI excerpt appeared in all 3 repo runs' findings and in none of the 3 off runs. End to end on #3: 0.1.7: stay on the best account automatically (best --auto, on-limit hook, statusline) multi-auto-claude-sub#5, the GitHub forge and --repo-dir built the same 110 entries, and off recorded repo_context: null. Identical prompts gave 3 findings and 0, so the effect on findings is unmeasured and the default stays off. The thinking model needed PRXREF_LLM_MAX_TOKENS=32768 and PRXREF_LLM_TIMEOUT=900.

Closes #17

🤖 Authored with Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459

…nd GitHub trees (#17 T8)

Repository context (#17) needs to know which files exist at the reviewed
commit before it can match contract globs or resolve a path. This adds the
optional listing surface and its first adapter.

- forges/base.py: the frozen PathListing(paths, complete) dataclass, the
  MAX_LISTING_PAGES = 20 page cap for paged adapters, and the optional
  Protocol method list_paths(ref, *, sha) -> PathListing | None after
  get_pr_history. Callers resolve it with getattr, it never raises, and it
  returns None when no listing can be fetched.
- forges/replay.py: ReplayForge.list_paths delegates to the inner forge like
  get_file_content, passing the caller's sha through, and gives None when the
  inner forge has no listing or raises. LocalDiffForge stays without one.
- forges/github.py: one Git Trees API request (git/trees/{sha}?recursive=1)
  keeping only blob entries, sorted and deduplicated, with complete = not
  truncated. An empty sha, a transport error, a non-2xx status, a non-JSON
  body or a body without a tree list gives None, logged at DEBUG.
- tests/test_forge_github.py: the timeout sweep pins the adapter's public
  method set, so list_paths is driven there and the routed session answers
  the trees route.
- tests/test_issue_17_list_paths.py: new, 37 tests.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Adds tests/fixtures/issue17/, a small Java repo fixture that reproduces the
two repository-context misses from issue #17: an OpenAPI idempotency-key
contract that sits outside the diff and disagrees with a new migration's
unique index, and a TransportConfig mutual-exclusion constraint that changes
in one diff chunk while a service in another chunk violates it.

repo/ holds the working tree at the PR head (Java sources under
src/main/java/, an OpenAPI spec under api/openapi/, and three Liquibase
changesets under db/changelog/). pr.diff adds the 003 migration and modifies
TransportConfig.java and ConnectorService.java; every added line matches the
repo/ head content, verified by a new head-consistency test. cases.json
carries the two "error" labels a resolver should surface, each anchored on
an added diff line with a must_match regex.

tests/test_issue_17_fixture.py checks the fixture structurally: cases.json
loads and its labels anchor, build_chunks(max_files_per_chunk=1) puts
TransportConfig.java and ConnectorService.java in different chunks, the
OpenAPI spec and the earlier two migrations exist under repo/ but are absent
from the diff, every added diff line matches repo/ at its new line number,
and the fixture text uses only placeholder identities (acme, example.com).

🤖 Authored with Claude Code

— Claude Sonnet 5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Adds src/prxref/forges/repo_dir.py holding RepoDir, a confined local
directory reader that stands in for a PR head with no network access, for
the upcoming --repo-dir flag (wired by a later seat). RepoDir.read(path)
matches the forge adapters' get_file_content contract byte-for-byte
(missing/directory/unsafe-path/.git/oversized/binary all read as None,
otherwise utf-8 with errors="replace", 512 KiB ceiling, size checked from a
stat before the file is read). RepoDir.list_files() walks the tree, never
follows a symlinked directory, skips .git at any depth, omits symlinks that
escape the root, and caps at 100_000 files, returning (paths, complete).

The confinement check resolves real paths and compares against root plus
a path separator, not a string prefix, so a sibling directory that extends
the root's name (".../r2" next to ".../r") is never mistaken for being
inside it; a dedicated regression test and a falsifiability run against a
naive startswith(root) check both confirm this.

list_files() returns a plain tuple rather than the PathListing dataclass
because that type is landing in forges/base.py under a parallel seat (T8);
the wiring seat (T15) wraps this return value.

tests/test_repo_dir_reader.py covers normal reads, missing/directory
targets, unsafe paths (absolute, "..", backslash, empty, NUL), symlinked
files and directories both inside and outside the root, .git exclusion,
the 512 KiB boundary, NUL-byte and invalid-utf8 handling, sorted POSIX
listing output, the file-count cap at and past the boundary, and the
ValueError raised for a non-existent root.

🤖 Authored with Claude Code

— Claude Sonnet 5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Add PRXREF_REPO_CONTEXT (choice off|diff|repo, default off),
PRXREF_REPO_CONTEXT_MAX_CHARS (int, default 12000, _Range(0)),
PRXREF_CONTEXT_CONTRACT_GLOBS (list key defaulting to the OQ5 built-in
contract-glob set; a set value replaces the default, empty reads as
unset) and PRXREF_CONTEXT_EXCLUDE_GLOBS (list key, default empty,
added to a floor a later seat owns) to config._DEFAULTS and its
_INT_KEYS/_LIST_KEYS/_CHOICE_KEYS/_RANGES tables, per decisions.md
"Config keys" (D2: all four surfaces land in this one seat).

Document all four on every surface: the config.py module docstring
(key table and the list-key sentence), .env.example, and
docs/env-vars.md, and recompute the two stated key-count totals in
docs/env-vars.md (63->67 configuration keys, 64->68 accepted variable
names with the legacy alias) with code rather than by hand. Add
_KEYS_0_16 and TestKeys016AreDocumented to tests/test_config.py,
mirroring the 0.15 substring-trap guard, and a new
tests/test_issue_17_config.py pinning load_config behaviour: defaults,
exact case-sensitive matching for PRXREF_REPO_CONTEXT (like
PRXREF_FAIL_ON), the open-zero _Range(0) rejection of 0 and negative
values for the max-chars key, and the replace/empty-is-unset contract
for the contract-globs default.

Nothing reads these keys yet; wiring lands in later tasks (T13/T14).

🤖 Authored with Claude Code

— Claude Sonnet 5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Add src/prxref/repo_context.py, the pure core the 0.16.0 repository-context
feature builds on: the ContextEntry record (rendered() in the
referenced_definitions "path:line: text" shape, record() for the run record),
the REASONS admission ranks and KINDS, language_of (chunk_context's map plus
.java), definition_regexes (a Java type-declaration regex for class,
interface, record, enum and @interface after modifiers and same-line
annotations; chunk_context's regexes otherwise), referenced_names (first-seen
identifiers minus keywords, names defined on the added lines, and for Java the
JDK names and non-type-like names), and find_definitions (the
referenced_definitions scan loop generalized to any file's text, first
definition per name, skip_lines, max_lines, the 512 KiB byte cap).

chunk_context.py is untouched: Java stays out of its _definition_regexes
because referenced_definitions runs with the feature off (D1). The module
reuses chunk_context's underscore helpers so both scan and render alike.

Tests: tests/test_repo_context_defs.py (65 tests).

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Add the optional Forge.list_paths to the two paged adapters, behaving as
T8's GitHub method does: file paths only, sorted and deduplicated, never
raising, None on an empty sha with no request.

GitLab walks repository/tree?recursive=true with per_page=_PAGE_SIZE and
page=1, 2, 3 ..., keeps type == "blob" entries, and ends the walk when the
X-Next-Page header is absent or empty (never on a short page), as the
2026-09-24 keyless probe of gitlab.com showed.

Bitbucket Server walks the documented /files?at=<sha> listing with
start/limit paging and ends on isLastPage (or its absence) or a missing
nextPageStart, as the activity walk does. The endpoint shape comes from the
REST documentation and was not probed live (no public instance), so it is
UNVERIFIED.

Both cap the walk at MAX_LISTING_PAGES (imported from forges.base). A walk
the cap stops, or a failure on a later page, returns the paths read so far
with complete=False; a failure on the first page returns None. Failures log
at DEBUG, as get_file_content does.

Tests: tests/test_issue_17_list_paths_paged.py (52 tests).

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Bitbucket Cloud walks GET /2.0/repositories/{owner}/{repo}/src/{sha}/ with
max_depth=64 and pagelen=100, then follows each page's next URL verbatim
with no params of its own, capped at MAX_LISTING_PAGES. Only commit_file
entries are kept. The listing is complete=False when the cap stops a walk
with a next still pending, when a later page cannot be read (the paths
read so far are returned), or when a commit_directory sits at the depth
limit. That last rule is inferred from the orchestrator's max_depth probe,
not documented by Bitbucket, and the docstring says so.

Azure DevOps makes one items?recursionLevel=Full request at the commit
through the adapter's _get and _json helpers, keeps blob entries that are
not folders, and strips the leading slash Azure puts on every path. An
x-ms-continuationtoken header marks the listing complete=False and is not
followed, because continuation on this endpoint is undocumented and was
never observed.

Both return None for an empty sha (no request) and for a failed first
request, log only at DEBUG, and never raise. Tests are in
tests/test_issue_17_list_paths_bbc_ado.py.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
New pure module src/prxref/repo_contracts.py (stdlib json and re only, no
YAML library, no I/O) that cuts the slice of a contract file a changed
route, operation, schema or table points at:

- normalize_name folds idempotency_keys, idempotency-key and IdempotencyKey
  to one key; route_key makes {id}, :id, <id> and <int:id> routes compare
  equal to a spec path.
- openapi_yaml_excerpts slices by indentation under paths:, the enclosing
  HTTP-method block of an operationId, and components.schemas (plus the
  Swagger 2.0 definitions:), skipping block-scalar text.
- openapi_json_excerpts and json_schema_excerpts do the same through
  json.loads, with a best-effort line found by following the key path.
- sql_excerpts finds CREATE TABLE, ALTER TABLE and CREATE [UNIQUE] INDEX ON
  statements; comments, quoted strings and $$ bodies do not end a statement,
  and a Liquibase --changeset line or a GO line does.
- liquibase_excerpts slices XML, YAML and JSON changeSets holding
  tableName, baseTableName or referencedTableName.

Every excerpt is capped at 40 lines and 2000 chars, and a cut block ends
with an "N more lines" marker line that counts toward both caps. An
excerpt that another one already shows in full is dropped. The regexes
stay linear on long whitespace runs.

Tests: tests/test_repo_context_contracts.py (63), with every fixture inline.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
…17 T12)

Add src/prxref/repo_reader.py, the one reader a review run shares across its
parallel chunk workers when repository context is on. RepoReader wraps a
fetch(path) and an optional lister():

- read(path) is cached and single-flight per path: concurrent callers share
  one fetch, and a None result is cached, so a miss costs one fetch per run.
  It is not capped; its fetches count in stats()["reads"] only.
- chunk_reader() returns a new callable per chunk that enforces
  MAX_CHUNK_READS (16) per chunk and MAX_RUN_READS (200) across all chunk
  readers. A cached or in-flight path is served without counting; a path
  refused at a cap returns None without a fetch, stays uncached, and sets
  read_cap_hit.
- listing() calls the lister at most once per run, single-flight, and caches
  its result, with a raise or a non-PathListing read as None.
- exclude(path) makes a path unreadable, uncounted and unlisted at the one
  boundary every read crosses; an exclude that raises fails closed.
- stats() snapshots {"reads", "read_cap_hit", "listing"} for the run record.

forge_reader(forge, ref, sha) mirrors _make_file_reader's gate (None without
get_file_content or with an empty sha) and passes ref and sha to both
get_file_content and list_paths; repo_dir_reader(repo_dir) wraps a RepoDir.
orchestrator.py is untouched: the wiring seat builds the reader and maps
stats() onto the run record.

Tests: tests/test_orchestrator_repo_reader.py (49 tests).

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
… T5)

New pure module src/prxref/repo_crosschunk.py with diff_definitions(chunk,
all_files, read) -> list[ContextEntry]. It closes issue #17 miss (b): a type
changed in one chunk (TransportConfig's new compact constructor) is now shown
to the worker of another chunk that calls it (ConnectorService), together with
the changed lines inside that type, not just its declaration.

Names are the union of repo_context.referenced_names over the chunk files'
added lines. Every diff file with definition regexes is searched with
find_definitions, reading at the PR head or, with no reader or a None read,
scanning each contiguous run of known hunk lines on its own so entries keep
true new-file line numbers and never show a line the hunks lack. Skips: this
chunk's own hunk lines, js/python files in the chunk when a reader is given
(referenced_definitions already covers them, D1), and removed files. A hit in
a file outside the chunk that has + lines anywhere in the PR is "cross-chunk"
and also yields one change entry per contiguous + run after the definition
line and before the next definition indented no deeper (capped at
MAX_CHANGE_LINES = 12 plus a "... N more changed lines" line); every other hit
is "diff-file". Output is ordered by (REASONS rank, path, line) and
deduplicated on (path, line). No budget: T7 owns it.

Tests: tests/test_repo_context_crosschunk.py, including the issue17 fixture
end to end with a reader and from hunks only.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Issue #17, task T3. New pure module src/prxref/repo_resolve.py turns a
referencing file, its text and the names referenced_names reports into an
ordered list of Candidate(name, path, reason) files for the caller to read.
It does no I/O: reading candidates and running find_definitions is T7's job.

- Imports first: Java same-organization imports (first two package
  segments) under the source root derived from the package line, wildcard
  and nested-type imports included; Python from-imports at the root and
  under src/, relative dots counted from the file; TS/JS relative
  import/export specifiers probed as .ts, .tsx, .d.ts, .js, /index.ts.
- Then the Java same-package convention <dir>/<Name>.java for names no
  single-name import binds.
- Then a name search over a listing: same-language files whose stem matches
  (exact, case-insensitive, Python snake_case), deepest shared directory
  first, at most 3 per name.
- Dead-candidate filters: a complete listing drops absent import and
  convention paths; third-party Java, stdlib Python and bare TS/JS imports
  give no candidate and are not name-searched; languages without
  definition regexes resolve nothing.

Tests: tests/test_repo_context_resolve.py (81 tests, pure), including the
issue17 fixture: TransportConfig resolves by path-convention and the Spring
imports give no candidate.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
…test

The nested-type test named dedup but never produced two entries on one
(path, line), so removing the dedup loop left the suite green. Making the
nested record's declaration an added line puts Outer's change run and
Inner's definition on the same line; the test now pins that the definition
entry wins that tie, and a no-dedup mutation goes red.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Repository context (PRXREF_REPO_CONTEXT=repo) needs a zero-network way to
run against a fixture or a checked-out PR head, so eval cases gain an
optional repo_dir field: a directory holding the repository at the PR
head, read and listed by repository context in place of a forge.

- EvalCase gains repo_dir: str | None = None as its LAST field, so no
  positional construction shifts.
- _CASE_KEYS gains "repo_dir" directly after "context_file", which fixes
  the allowed: list in the unknown-field message and the key order
  case_to_json writes; the cases.json form joins a relative repo_dir onto
  the dataset directory as context_file is joined, and requires an
  existing directory; the case-*/ directory form picks up a repo/
  subdirectory as ticket.md becomes context_file.
- case_to_json/case_from_json_record round-trip repo_dir, both set and
  None; a 0.15-shaped case.json record with no repo_dir key still loads.
- The issue #17 acceptance fixture gains "repo_dir": "repo" and L1's
  must_match loosens from re:(?i)connector_id to re:(?i)connector, so it
  also matches a correct finding written as "connectorId" or "the
  connector" (the fixture-seat correction carried into this wave).
- docs/evals.md documents the field for both dataset forms.

This seat builds only the eval-case half. The --repo-dir CLI flag, the
threading into evals._run_case and the orchestrator, the run-record
fields and the README CLI Flags entry belong to seat T15b, which reads
case.repo_dir once it lands.

🤖 Authored with Claude Code

— Claude Sonnet 5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
…ct entries (#17)

Adds the T6 surface to src/prxref/repo_contracts.py for issue #17's
misses (a) and (c): a migration whose unique index disagrees with an
OpenAPI contract outside the diff, and a route whose operation and
schemas the worker never sees.

- ContractTriggers and contract_triggers(): route literals (Spring,
  JAX-RS, FastAPI/Flask, Express; class-level @RequestMapping/@path,
  Blueprint url_prefix and APIRouter prefix joined when the head text is
  given), tables (T4's _SQL_HEADS and _bare_table, REFERENCES targets,
  Liquibase tableName/baseTableName/referencedTableName in XML, YAML and
  JSON), referenced names, and operation ids. Every scan is linear on a
  512 KiB line; the INDEX ... ON head is bounded at the next CREATE/ALTER.
- literal_contract_paths() and select_contract_files(): the run's
  contract files from the globs, the listing and the diff paths.
- earlier_migrations(): up to MAX_EARLIER_MIGRATIONS = 4 same-directory
  predecessors under a natural sort.
- contract_excerpts(): extension dispatch to T4's excerpters, plus
  whole-file fragments matched by stem.
- contract_entries(): the per-chunk entry point for T7, reading at most
  MAX_SPEC_FILES = 6 ranked spec files plus earlier migrations, through
  the reader it is given, and returning ContextEntry(kind="contract",
  reason="contract") ordered and deduplicated by (path, line).

Tests: tests/test_repo_context_triggers.py (82), including the issue17
fixture end to end.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
…ude floor)

Issue #17, task T7. build_unit_context (new src/prxref/repo_unit.py)
combines the three repository-context sources for one worker chunk:
diff_definitions (diff and repo), contract_entries and the resolver
(repo with a reader only), called in admission-rank order so a capped
reader spends its reads on the higher-ranked sources first. Every read
goes through a guard that refuses an excluded path without calling the
reader (an exclude that raises fails closed). Entries at excluded paths
are dropped, the rest ordered by (REASONS rank, path, line), deduplicated
on (path, line), and admitted against one character budget that stops at
the first entry that does not fit; the omitted line goes to the block of
the first entry left out and does not count toward the budget. "off"
returns EMPTY_UNIT with no read, and "repo" with no reader equals "diff"
with no reader (OQ2).

repo_context.py gains EXCLUDE_FLOOR and exclude_predicate(extra_globs),
which matches the floor and the extra globs as two separate match_globs
lists, so a "!" in PRXREF_CONTEXT_EXCLUDE_GLOBS can never re-admit a
label file. chunk_context.render_context_blocks gains extra_def_lines
(under the one definitions header) and contract_lines (under the new
CONTRACT_HEADER); with the defaults it is byte-identical to before (D1),
pinned against a verbatim oracle copy. Nothing calls the new code yet;
T13 wires it.

Tests: tests/test_repo_context_unit.py (94), including the issue17
fixture end to end through the real capped reader, the observed read
order, the chunk cap degrading, and the budget, order and exclusion
rules on canned sources.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
orchestrate_review takes five new keyword-only parameters after
max_findings_per_rule: repo_context ("off" by default), repo_context_max_chars
(12000), context_contract_globs, context_exclude_globs and repo_dir. An
unknown level raises ValueError before any forge call.

Off is byte-identical to a run without the kwargs; the run record gains a
repo_context key that is None when off. On, the run builds one RepoReader
(repo_dir first, else the forge at the head sha), takes the listing and the
contract files once per run in repo mode, and builds each chunk's unit
context inside its worker before the first attempt. Diff-file reads go
through the shared uncapped read and every other path through the chunk's
capped reader. A unit build that raises gives that chunk the empty unit and
one WARNING. The timeout retry carries neither new block and marks the
chunk's row retry_dropped. Repo mode with no reader or no listing logs one
WARNING naming PRXREF_REPO_CONTEXT. The trace gains one chunk context event
per chunk and one repo_context ok event per run.

The CLI and JSON output are not wired yet (T14); tests/test_cli.py and
tests/test_run_record.py carry NOT_WIRED_YET / NOT_IN_JSON_YET tripwires
for that seat to remove.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
The live check V1 found that GitLab's REST repository/tree?recursive=true
listing returns every directory before any file across the whole recursive
walk. Under the 20-page cap, a project with more than 2000 entries lost
files, and gitlab-org/gitlab came back with zero paths and complete=False.

list_paths now walks GraphQL's files-only tree.blobs connection first
(POST https://{host}/api/graphql, variables p/ref/after, the same
PRIVATE-TOKEN header and timeout as every other request), following
endCursor while hasNextPage for at most MAX_LISTING_PAGES pages. The cap,
or a later-page failure, keeps the paths read so far with complete=False.

When the first GraphQL page is unusable, the unchanged REST walk answers.
That covers a transport failure, a non-2xx status, a non-JSON body, a
non-empty errors array, a null data.project, any other wrong shape, and an
empty first page with hasNextPage false. GraphQL answers a sha it cannot
resolve with that empty page, so REST decides between 404 -> None and an
empty tree. The fallback reason is logged at DEBUG.

Tests: tests/test_issue_17_gitlab_graphql.py (58 new, including the
directories-first defect with a control). T9's REST tests pass unedited.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
A reply with no text (or whitespace only) and a non-truncation finish
reason failed its unit at parse time ("no parseable content") and was
never asked again, though the provider billed it. The review of 0.15.0's
own pull request lost 1 of 8 chunks this way.

_invoke_and_parse now repeats that call once, with the same prompt and
the same budget, after one WARNING naming the unit label and the finish
reason. It never retries a reply stopped at the budget (length or
max_tokens, the truncation path), a non-empty reply that fails to parse,
or a call that raised. Tokens and elapsed_ms cover both calls, model is
the second call's, and cost folds through costs.combine_reported: the
sum when both calls reported a figure, else None. A second empty reply
fails the unit with the same error as before. The chunk and the systemic
sweep share the path. Worst case per chunk is 4 invoke calls with the
orchestrator's timeout retry, 2 for the sweep; the module, review_chunk
and review_systemic docstrings are corrected to say so.

Tests: tests/test_empty_reply_retry.py (23 cases, including one run
through orchestrate_review).

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Seat T14 (T14 + T15b). _run_review now passes the four repository-context
settings (repo_context, repo_context_max_chars, context_contract_globs,
context_exclude_globs) from load_config to orchestrate_review, and takes a
new repo_dir keyword. `review --repo-dir PATH` is opened as a RepoDir right
after the prompts directory loads, before make_forge and the LLM client, so
a path that is not an existing directory (or a blank one, which would
otherwise resolve to the working directory) exits 2 naming --repo-dir with
no network I/O. It is checked and passed even while PRXREF_REPO_CONTEXT is
off; the webhook daemon passes none.

--format json gains repo_context right after rule_counts (null when off),
and -v prints one `repo context:` line after `spec:` when the record has
one. prxref eval threads each case's repo_dir into the review, and
RUN_CONFIG_KEYS ends with the four settings, so run.json records them.

Docs: README gets the --repo-dir bullet and the repo_context key in the
--format json list; docs/evals.md lists the sixteen allowlisted settings.
T13's tripwires are removed (NOT_WIRED_YET, NOT_IN_JSON_YET) and the pinned
JSON key orders move deliberately, including two pins the brief did not
name (tests/test_eval_run.py's exact run_review kwargs and
tests/test_cli_scoped_rules.py's scoped_rules-to-sampling distance).
New tests: tests/test_cli_repo_context.py (34), including an end-to-end
eval of the issue17 fixture with no network and no LLM.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Adds tests/test_issue_17_acceptance.py, 20 tests. Each one drives
orchestrate_review end to end over tests/fixtures/issue17, using the real
reviewer, a read-and-list fake forge and a capturing LLM, and asserts on
what a user sees: the prompts, the run record and the trace. The checks:
- both labeled chunks carry their evidence, with an off-run control;
- the record rows and the trace events carry their reasons;
- off is byte-identical to the RELEASED 0.15.0, in three kwarg variants,
  with a repo-level control;
- a 300-char budget holds and prints the omitted-entries line;
- LocalDiffForge makes zero reads at repo and at diff;
- repo_dir on LocalDiffForge gives the check-1 evidence;
- the exclude floor holds against a "!**/cases.json" negation, with a
  schema control and a name-search control;
- read-cap starvation (D-L) keeps the spec read, with a control that
  routes every read through the capped reader;
- one worker and four workers give equal records and prompts.

tests/fixtures/issue17/harness.py is version-neutral (orchestrate_review,
PRRef, PRData, InvokeResult only). tests/fixtures/issue17/make_golden.py
runs it under `uv run --no-project --with prxref==0.15.0` from outside the
repository. golden_off_prompts.json is that run's output: two design points
(the fixture, and the fixture plus a Python file whose chunk renders the
pre-0.16 dependency and definitions blocks). The tip reproduces it byte for
byte, and no source file changes.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Add the 0.16.0 CHANGELOG section (repository context, Java definitions,
the three new budget and glob keys, bounded reads, --repo-dir, eval
support, the repo_context record, per-forge listing, the empty-reply
retry fix, and known limitations), a README "Repository Context"
section, the --repo-dir bullet moved into the main CLI Flags list, the
-v bullet's repo context line (text output only), a listing paragraph
per forge in docs/forges.md, the corrected PRXREF_REPO_CONTEXT row and
an empty-reply note on PRXREF_TRACE_DIR in docs/env-vars.md, the
repository-context and empty-reply paragraphs in docs/llm.md, and a
"Measuring repository context" recipe in tests/evals/README.md.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Bump the version to 0.16.0 in pyproject.toml, src/prxref/__init__.py and
uv.lock. Date the CHANGELOG's [0.16.0] section and fix its link references:
[Unreleased] now compares v0.16.0...HEAD, with [0.16.0] and [0.15.0] tag
links added.

Rewrite HANDOFF.md for 0.16.0: the #17 module map, entry points and caps,
the GitLab GraphQL listing and the empty-reply retry; this release's
lessons; the recounted config coupling (67 keys, 1 alias, 68 names); how
the release was built; the verified counts; the live checks; and the open
items, including #20 and #21.

Correct README's description of the repo_context record's units: it is
{"chunks": [...]}, one row per chunk in chunk order.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
The sdist ships tests/ (183 files); task and check IDs (T#, V1-V3, "seat",
"wave", "OQ#", "map-#") from every prior build's internal coordination mean
nothing to a reader and are banned from public artifacts. Strip them from
comments and docstrings across src/ and tests/, keeping public issue numbers
(#10-#17), version numbers, commit SHAs, and cross-references to other test
files intact.

Also correct three comments that had gone stale as their described work
landed: repo_dir.py's docstring said the --repo-dir CLI flag "belongs to a
later seat" (it is wired in prxref.cli now); formatter.py's ImportError
comment said "reviewer seat not landed yet" (reviewer.py has existed and
imported cleanly for several releases); and two tests (test_issue_05 and
test_issue_06) described gates ("apply_hedge_gate", match_class's kind
parameter) as future work when both now exist and are exercised for real.

No code, test logic, string literal a test asserts on, or fixture changed:
an AST-equality check (docstrings stripped) over every changed file proves
it, with a control confirming the check detects a real literal change.
PYTHONDONTWRITEBYTECODE=1 uv run pytest -q stays at exactly 7675 passed;
ruff check src tests stays clean.

🤖 Authored with Claude Code

— Claude Sonnet 5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

prxref automated review: Approved

PR: Release 0.16.0: repository context · files reviewed: 113

🟥 0 error · 🟧 1 warning · 🔍 0 spec · ⬜ 0 outofscope

  • 🟧 src/prxref/reviewer.py:643 — Chunk worker failure swallowed into a warning after a billed LLM call

Reviewed by prxref · model=z-ai/glm-5.3-flash · 215446 tok · 118.6s

⚠️ Partial review: 7 of 9 chunks were reviewed; 2 failed. Findings may be incomplete.

  • chunk of 6 files (src/prxref/repo_contracts.py, tests/test_repo_context_defs.py, src/prxref/forges/repo_dir.py, +3 more): LLMError: all models failed: z-ai/glm-5.3-flash: timeout (Timeout)
  • chunk of 14 files (src/prxref/repo_resolve.py, tests/test_repo_context_resolve.py, src/prxref/repo_reader.py, +11 more): LLMError: all models failed: z-ai/glm-5.3-flash: timeout (Timeout)

The PRXREF_TRACE_FILE row named eight events and said each carries start,
then ok or fail. 0.16 adds a `chunk` `context` event per chunk and a
`repo_context` `ok` event per run whenever PRXREF_REPO_CONTEXT is not off,
and earlier releases had already added `sweep`, `heartbeat` `tick`,
`chunk` `retry` and the one-off input and pass events with their own
phases. The row now lists them all, with the fields of the two new ones.
Found by prxref's own review of the 0.16.0 release PR.

🤖 Authored with Claude Code

— Claude Opus 5.5 via Claude Code

Claude-Session-Id: f915b0ff-b27c-46f3-bc7c-430dca61d459
Comment thread src/prxref/reviewer.py
json_mode=True,
)
except Exception as e: # noqa: BLE001
logger.warning("worker review failed for %s: %s", label, e)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 🟧 [WARNING] Chunk worker failure swallowed into a warning after a billed LLM call (src/prxref/reviewer.py:643)

The digest shows except Exception as e: # noqa: BLE001 followed by logger.warning("worker review failed for %s: %s", label, e) on the chunk-worker path. The chunk's LLM call has already been billed (docs/llm.md notes both calls' tokens are summed), and reducing the failure to a log line means the paid call's result is lost with no retry or persisted state, unlike the empty-reply path which the release explicitly fixed to retry.


Reviewed by prxref · model=z-ai/glm-5.3-flash

@sblattj
sblattj merged commit 3025628 into main Sep 25, 2026
4 checks passed
@sblattj
sblattj deleted the release/0.16.0 branch September 25, 2026 03:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Repository context outside the diff: cross-file definitions, contract excerpts, cross-chunk links

1 participant