Skip to content

feat(corpus): private corpus support, and the shared library from #11 - #12

Merged
Goldziher merged 25 commits into
mainfrom
feat/private-corpus-support
Aug 27, 2026
Merged

feat(corpus): private corpus support, and the shared library from #11#12
Goldziher merged 25 commits into
mainfrom
feat/private-corpus-support

Conversation

@Goldziher

Copy link
Copy Markdown
Member

Closes #11, and adds the private-corpus support it was blocking.

Private corpus support

fetch_corpus.py gains --manifest, --root and --auth; publish_corpus.py gains --manifest, --root and --patterns. Every one defaults to today's value, so the public commands are byte-for-byte unchanged.

Authentication is opt-in and anonymous stays the default — that is not a convenience, it is what keeps the credential-free CI proof meaningful. It uses Application Default Credentials via gcloud, so a local login and workload-identity federation in CI both work with no configuration, and it re-acquires as the token ages because a large fetch on a slow runner can outlive one.

Three guards, because publishing is irreversible and the public bucket cannot un-serve an object:

  • a non-default root, manifest or pattern file aimed at the public bucket is refused before any network call;
  • --root must not sit above where the explicitly-named paths put the corpus, unless --allow-external-root says you mean it;
  • any non-default publish prints resolved root, file count, byte total and extension histogram, and asks.

The second one matters more than it looks: corpus patterns use gitignore semantics, so a bare *.zip matches a basename at any depth and a root one level too high sweeps in whatever sits beside the corpus.

Issue #11

download() existed five times, sha256_of three, and three of five call sites retried with no delay at all — the same request into the same transient failure, three times within milliseconds. All of it now sits behind scripts/corpus_tools/, and the ten entry points are 30–180 lines of docstring, parser and main().

Two transports stay, deliberately. curl is the path consumers take — the CI action fetches with curl — so verifying with urllib would leave CI green while asserting something about a TLS stack no consumer uses. The five unexplained timeouts became three with their derivation written beside them; 300s turned out to belong to exactly one URL.

No consumer repository needs a change. All ten entry-point paths are frozen, the flag surface is purely additive, and corpus.lock.json is byte-identical — so the cache key xberg-io/actions/fetch-test-documents computes is unchanged. Submodule-bump reviewers can stop looking.

Behaviour changes, declared

  • Retry backoff added; fetch_corpus and verify_corpus --sample gain retries (the CI flake surface).
  • build_diagram_pdfs's hashing goes whole-file → chunked. Same digest, lower peak memory; the corpus holds a 62 MiB object.
  • fetch_regression's error prefix gains the exception type — it previously printed a bare RuntimeError as the word "error" and nothing else.
  • Python floor is 3.10, declared and now enforced in CI.

Corpus protection

ruff format reads Jupyter notebooks natively: unconfigured, it would rewrite four fixtures under jupyter/math/ and fail to parse three more, and ruff check --fix would delete the two deliberately unused imports from code/hello.py. force-exclude = true is the line that matters — without it any explicitly-named path bypasses the exclude list, which is what an editor-on-save does.

test_lint_scope.py asserts that behaviourally against the real binary rather than trusting the config, and cross-checks that poly.toml and pyproject.toml still describe the same corpus. Verified by running ruff check --fix . && ruff format . && poly fmt . at the repo root: zero corpus files modified.

Adopting uv also opened a publish hazard worth naming — corpus_paths() walks the tree ignoring git, and .venv/ was neither pruned nor gitignored, so venv assets were reachable by a publish to the public bucket. Closed three ways, with tests that fail if any one is removed.

Tests: 98 → 191

Six modules had none. Both runners still agree, and the stdlib one is kept working on purpose — it is the same "works on a bare checkout" property verify_corpus defends.

Everything network-free, through injected seams rather than patched globals: the curl transport takes a runner, get() takes a sleep, AdcCredential takes a clock.

Four reviews of this branch found real defects, all fixed here — including one I introduced: rewriting the SVG stripper around argparse silently dropped its "nothing to strip" guard, which would have let the regeneration loop write geometry fixtures identical to their parents. Two tests provably could not fail and are rewritten. test_entry_points.py exists because moving the EPUB tools broke fetch_epub_edge_cases.py at import while ruff and all 186 tests stayed green.

Also

.ai-rulez/ with five critical rules, four context files and two local subagents — this was the only repo in the polyrepo with Python and no AI governance, so CONTRIBUTING.md's instruction to generate it produced nothing. Plus the poly gate poly.toml has been describing with nothing enforcing it, a Taskfile (task setup had nothing to run), and three stale doc references corrected.

The four manifests the tools read sat beside the tools themselves, so
scripts/ mixed executables with the data they consume. Move them under
scripts/data/ and point every reader at the new path.

PATTERNS_FILENAME moves with them, which is why .gitignore's comment block
and the README prose describing it change in the same commit — that path is
named in three places and they must not drift.

_tree() in the publisher tests created root/scripts before writing the
patterns file at PATTERNS_FILENAME. That only worked while the constant was
one level deep; derive the directory from the constant instead.
Puts the tests in their own package instead of interleaving test_*.py with
the tools under test. scripts/tests/__init__.py makes the directory a
package, which is what keeps `python3 -m unittest discover -s scripts` — the
command CI runs today — finding all 98, and additionally allows the more
explicit `-s scripts/tests -t scripts` form. Both were verified.

Deliberately no scripts/__init__.py: pytest walks up while __init__.py
exists and prepends the first directory without one, so leaving scripts/ a
plain directory is what puts it on sys.path and lets the tests import the
tooling the way the entry points do. conftest.py makes that explicit rather
than load-bearing-by-accident.

That also retires the sys.path.insert in the EPUB tests, which existed only
to reach a sibling module. sys.path now appears nowhere in scripts/ except
conftest.py. The `if __name__ == "__main__"` block goes with it: running a
test file by path puts scripts/tests/ on sys.path instead of scripts/, so it
could not have worked from the new location anyway.
Brings the repo onto the polyrepo's uv/pytest/ruff/mypy convention. It was
the only repo here with Python and none of it. The scripts stay stdlib-only
at runtime — nothing in the dev group is ever imported by them, so the
credential-free `python3 scripts/verify_corpus.py` path is untouched.

The ruff config is corpus protection, not style. Unconfigured, `ruff format`
reads Jupyter notebooks natively and would rewrite four fixtures under
jupyter/math/ while failing to parse three more, and `ruff check --fix`
would delete the two deliberately unused imports from code/hello.py — F401
is a safe fix and those imports are the entire point of that fixture.
force-exclude is the line that matters: without it exclusions apply only
while walking directories, so any explicitly passed path bypasses them,
which is what an editor-on-save or `ruff format jupyter/` does.

test_lint_scope.py asserts that behaviourally against the real binary rather
than trusting the config, and cross-checks that poly.toml and pyproject.toml
still describe the same corpus. Its top-level directory set comes from git,
not the filesystem: only about 30 of the 53 corpus directories exist in a
fresh clone, so a filesystem-derived expectation would pass vacuously in CI.

Adopting uv also opened a publish hazard. corpus_paths() walks the working
tree rather than asking git, the patterns are gitignore-shaped so one
without '/' matches a basename at any depth, and the list contains *.png,
*.pdf and *.zip — which installed packages ship as bundled assets. A .venv
at the repo root was therefore reachable by a publish to the public bucket.
It is now pruned from the walk, rejected by the forbidden-path guard, and
gitignored, with tests that fail if any of the three is removed.

BLE001 is deliberately not ignored globally, unlike upstream: doing so makes
every `# noqa: BLE001 - <reason>` unused, and RUF100 then strips the reason
along with the directive.
The first two shared modules, and the two smallest.

paths.py replaces nine separate `Path(__file__).resolve().parent.parent`
computations. That arithmetic was correct only for a file at one particular
depth, and a wrong root does not raise — it just finds nothing — so moving a
file broke it silently. Deriving everything from one module's own location
means the depth is written down once.

git_repo_root keeps asking git rather than deriving from __file__, and the
publisher keeps using it. The two answers differ when a tool is run by path
against a different working tree, and the publisher feeds this straight to
`git -C` for its tracked-file guard, so the guard has to be asking about the
same repository git is. Changing that is a separate argument, not this one.

hashing.py collapses three implementations into one. Two were byte-identical
chunked readers carrying their own READ_CHUNK_SIZE; the third, in
build_diagram_pdfs, slurped the whole file. That is the one real behaviour
change here: the digests are identical, but the corpus contains a 62 MiB
object and the peak memory was being paid for nothing. test_hashing.py
asserts the agreement across the chunk boundary, which is where a chunked
reader actually goes wrong, rather than only on small inputs.
patterns.py puts the two pattern predicates side by side, which is the point
of the module. They look alike and are not: matches_corpus_pattern answers
"is this file corpus?" with gitignore semantics, where a pattern without '/'
matches a basename at ANY depth, while matches_any answers "did the caller's
--include select this manifest path?" with plain fnmatch. Having them in
separate files read as an accidental duplicate. load_patterns also grows an
optional patterns_path, so a corpus outside this repo can bring its own list.

manifest.py takes the corpus.lock.json shape out of the publisher, and with
it the byte-stability contract. Consumers fetch through
xberg-io/actions/fetch-test-documents, which hashes the file to key its
object cache, so the sort in build_manifest and the dumps/newline in
write_manifest decide whether every consumer's cache survives a republish.

Nothing tested that path. The publisher's tests exercise --dry-run, which
deliberately never writes the manifest, and CI cannot publish at all because
the corpus binaries are not in git. So the write path only ever ran on a
maintainer's machine, and only where noticing was already too late.
test_manifest.py closes that: it feeds the committed manifest's own objects
back through both functions and requires the bytes to match. No network, no
corpus binaries, no credentials. Confirmed it fails on an indent change.
The concurrency limit was configured three ways: a MAX_WORKERS constant in
two tools, a --jobs flag defaulting to 8 in two more, and 6 in the fifth.
Now there is one flag, one help string, and one default.

fetch_corpus and verify_corpus gain --jobs, which is purely additive — the
consumers that invoke these by path never pass flags, so the contract with
the xberg repo's ~14 references is untouched.

fetch_regression keeps 6, and the reason is now written down next to the
number instead of being indistinguishable from a typo: its ~1,500 requests
go to gutenberg.org, ebi.ac.uk and arxiv.org, which throttle bulk clients,
while everything else here talks to one GCS bucket that does not care. The
honest fix is a per-host limit rather than a global one, and the comment
says so.

map_parallel preserves input order because two callers report a per-item
failure list and a stable order is what makes a failing run reproducible for
whoever reads it; run_parallel does not, because its callers only tally.
Five call sites become one retry policy over two deliberate transports.

The transports stay two on purpose. curl is the path consumers actually
take: fixtures are materialised in CI by xberg-io/actions/fetch-test-documents,
whose fetch.sh downloads with curl, and verify_corpus exists to prove the
bucket still serves exactly that. Had it switched to urllib, CI would stay
green while asserting something about a TLS stack, proxy handling and
redirect policy that no consumer uses. urllib stays for the provenance
fetchers, which pull from eleven third-party hosts where real exception
types are worth having and 1,483 requests should not each buy a subprocess.

What was accidental is now gone. Two call sites retried three times with no
delay at all — the same request into the same transient failure, three times
within milliseconds — and two did not retry. There is now one policy with
real backoff, and fetch_corpus and verify_corpus --sample gain retries,
which is the CI flake surface.

The five unexplained timeouts become three values with their derivation
written next to them. 300s was not arbitrary: it belongs to one URL, the
govdocs1 archive that serves 267 regression members in a single request.
Every other regression entry is a direct file.

head_batch splits along a knowledge boundary rather than moving wholesale.
curl's batching, the write-out format and its parsing are transport, so they
go to CurlTransport.head_many; mapping a URL back to its pin and phrasing
the failure is corpus knowledge and stays in verify_corpus. Batching is
deliberately not on the Transport protocol — urllib cannot do it, and
pretending otherwise would be a fake abstraction. The payoff is that the
write-out parser is now unit-testable, including the case where curl exits 0
but a response never parsed, which previously read as success.

materialize.py takes the third duplication issue #11 names but assigns no
module. All three copies produced the same status vocabulary and the same
sidecar behaviour, but fetch_regression reported `error {error}` without the
type, so a bare RuntimeError printed as the word "error" and nothing else.

Verified against the live bucket: 575/575 metadata, 5/5 content.
Adds the mechanism for a second corpus whose index lives outside this
repository, and the guards that make pointing the publisher at one safe.

fetch_corpus gains --manifest, --root and --auth; publish_corpus gains
--manifest, --root and --patterns. Every one defaults to today's value, so
`python3 scripts/fetch_corpus.py` and `publish_corpus.py --bucket
xberg-test-documents` are unchanged — which matters because ~14 places in
the xberg repo tell people to run the first by path, and the dry-run output
is byte-identical before and after.

Authentication is opt-in and anonymous stays the default. That is not a
convenience: this repo's CI proves the public bucket is still fetchable with
no credentials at all, and a tool that quietly authenticated would make that
proof meaningless. AdcCredential shells out to gcloud rather than importing
google.auth, because the package is stdlib-only and because WIF in CI
populates ADC exactly as a local login does — one path serves both. It
re-acquires as the token ages: WIF tokens last an hour and cannot be
extended, and a 15 GB fetch on a slow runner can outlive one, so a
credential minted once at startup would 401 halfway through.

Three guards, because publishing is not reversible and the public bucket
cannot un-serve an object:

  - a non-default root or manifest aimed at the public bucket is refused
    outright, before any network call;
  - --root must be the manifest's own directory unless --allow-external-root
    says otherwise. This is the one that catches aiming a level too high:
    patterns are gitignore-shaped, so a bare `*.zip` matches a basename at
    any depth and a parent directory sweeps in whatever sits beside the
    corpus;
  - any non-default publish prints resolved root, file count, byte total and
    extension histogram, and asks. 4,412 files and 4,419 files are
    distinguishable at a glance; an exit code is not.

A refusal prints its reason and exits 1 rather than showing a traceback —
it is a designed outcome, not a crash.

The tracked-file guard now runs only for this repository, where corpus
binaries are deliberately untracked. A corpus root outside a git working
tree has nothing to ask git about.

Every fixture in the new tests is a neutral placeholder. The corpus this
exists for belongs to a design partner whose filenames identify them, and
this repository is public.
…pus root

upload_extra_files raises when ATTRIBUTIONS.md, LICENSES.md or
ground_truth/corpus_manifest.json is missing, and it runs after the objects
are uploaded. Publishing a corpus root that is not this repository would
therefore push the whole corpus and only then fail, because those files are
this repository's licence notices and live beside its corpus rather than
inside it. A corpus elsewhere keeps its provenance with its own manifest.

Caught by dry-running the private path before uploading anything.
The 80-line specification of what is stripped and what is preserved moves
with the code it describes, and is now surfaced as argparse epilog so
--help still prints it. The entry point gains a real parser: it previously
hand-rolled argv handling and printed __doc__ on wrong argc, which meant
`--help` exited non-zero and it was the one tool the smoke test could not
check. All ten now answer --help.
Four reviews of the branch found real problems. The ones that mattered:

strip_svg_graph_metadata lost its "nothing to strip" guard when main() was
rewritten around argparse. The original refused to write and returned 1 when
the output matched the input; the new one wrote unconditionally.
diagrams/README.md regenerates the whole fixture set in a bare shell loop, so
a producer that stopped emitting metadata would have yielded geometry
fixtures byte-identical to their parents, exit 0, no stderr — a fixture pair
that measures nothing, and nothing downstream would have noticed.

The root-above-corpus guard was a tautology. resolve_targets derives the
manifest from the root when --manifest is omitted, so manifest.parent == root
by construction and the check could not fire in the exact case it existed
for. It now compares the root against the directory the explicitly-named
targets actually point at, and names the tighter root in the message.

The public-bucket guard never looked at --patterns, though a pattern file
selects the byte set as directly as the root does: --patterns with `*` in it
would have swept the whole working tree into the world-readable bucket while
root and manifest still looked like the defaults.

is_default_root keyed off which flags were typed rather than the resolved
targets, so `--root .` from the repo root — semantically identical to passing
nothing — silently dropped both the tracked-corpus guard and the attribution
refresh.

A credential failure was being retried. It fails identically every time, so
`--auth` without a valid login would have spawned three gcloud subprocesses
and three seconds of backoff per object across thousands of objects before
printing the same message it could have printed at the start. It is now its
own non-retryable error type. AdcCredential also takes a lock — token() is
called from every worker thread and the first batch stampeded gcloud — and
dates the token from after acquisition rather than before, since gcloud can
take a second or two and erring short is the wrong direction when the point
is surviving a 15 GB transfer.

Two tests could not fail, proven by mutation. The manifest serialisation test
asserted the shape of the committed lock file rather than the output of
write_manifest, so indent=4 with no trailing newline left it green. The fetch
flag test never imported fetch_corpus at all, so making auth unconditional —
the exact regression it named — passed. Transport selection is now extracted
as build_transport() and the test drives it.

New coverage where mutants had been surviving silently: pool.py had none at
all (reversing map_parallel's order, forcing DEFAULT_JOBS to 1, and ignoring
add_jobs_argument's default all passed); verify_corpus had none, though it is
the only thing this repo's CI actually proves — dropping the Content-Length
comparison and replacing the deterministic sample with a prefix both passed.
Each new test was checked to fail against its mutant. The user-agent test now
asserts the header is sent rather than that the constant exists, because
removing it from the request survived the whole suite.

mypy is now clean over the tooling, and scoped to it: strict mode over a test
suite built on stub transports and injected runners demands annotations on
every throwaway helper, which is noise. This matches xberg-io/actions, which
runs mypy over its scripts and not its tests. fetch_direct now returns a list
like fetch_shard, which makes the pool homogeneous and retires an isinstance
check at the call site.
This was the only repo in the polyrepo with Python and no .ai-rulez/, so
CONTRIBUTING.md's instruction to run `npx ai-rulez generate` produced
nothing. Follows sceptre's shape: core + cicd includes from
agent-conventions, python/cicd/documentation/default-commands builtins.

The value is the context and rules, which record what is easy to get wrong
here and impossible to infer from the code:

- corpus-model — the binaries are not in git, corpus.lock.json's bytes key
  every consumer's fetch cache, and CI cannot publish because a checkout has
  nothing to upload.
- tooling — why http.py keeps two transports (curl is the path consumers
  take, so verifying with urllib would assert something no consumer uses),
  why the timeouts differ, and why both test runners must stay green.
- private-corpus — the flags, ADC auth, and why an anchored `data/*` pattern
  beats an extension list: it cannot silently under-publish.
- Five critical rules: fixture bytes are load-bearing, entry-point paths are
  a cross-repo API, the tools import nothing but the stdlib, the manifest's
  bytes are the contract, and this repository is public.

Two local subagents: corpus-tooling-engineer for scripts/, corpus-curator
for fixtures and their provenance. Nine more come from the shared modules.

poly.toml picks up the polyrepo root's rumdl disable list — ai-rulez authors
agents as front-matter plus prose, so MD041's "first line must be an H1" is
wrong for them by construction. Generated output (CLAUDE.md, AGENTS.md,
.claude/, .codex/, .mcp.json) is gitignored via --gitignore.
The tooling targets Python 3.10+, where PEP 604 unions and PEP 585 generics
evaluate natively, so the import buys nothing. Eleven files carried it and
nothing checked; TID251 now bans it, which is what stops it coming back.

Worth recording why this was a manual pass: UP010 removes unnecessary
__future__ imports and has an always-available fix, but it deliberately
leaves `annotations` alone. That one still changes runtime behaviour, since
PEP 563 was never made the default — so no autofix exists and the ban has to
be enforced rather than repaired.

Verified all ten entry points still answer --help on 3.10, the declared
floor, and the suite passes there too.
publish_corpus.py was 559 lines doing four unrelated jobs. It is now 183:
its docstring, its parser, main(), and the exit.

backends.py splits along the obvious seam. LocalDirBackend exists only so
the upload path can be tested without a bucket or credentials, so it and the
Protocol it satisfies are a natural module — and keeping a second real
implementation beside the Protocol is what stops the seam rotting.

publish.py takes the enumeration, the guards, staging and the uploads. The
guards carry their reasoning with them: the public bucket cannot un-serve an
object, and patterns match basenames at any depth, so both failure modes are
silent and permanent.

Verified the dry-run reports the same 538 unique objects and 3 attribution
files as before, and corpus.lock.json is untouched.
…orpus

fetch_corpus.py drops to 103 lines and verify_corpus.py to 56 — docstring,
parser, main, exit.

verify.py keeps the interpretation of a HEAD result: mapping a URL back to
its pin, comparing Content-Length, phrasing the failure. Issuing the request
stays in the transport. That split is why the write-out parser became
unit-testable, including curl exiting 0 with a response that never parsed —
the case that previously read as success.

Verified live against the public bucket: 575/575 metadata, and all ten entry
points still answer --help from a foreign directory.
The builder and the fetcher become library modules; the entry points keep
their parsers. nested_30000 keeps its exact name, because EPUB_EDGE_CASES.md
names that symbol in prose.

The determinism test is the proof this moved unchanged: it rebuilds all
fourteen synthesized files and compares each sha256 against the manifest
pin, so a byte of drift anywhere in the builder fails it.
Moving the EPUB tools into a subpackage left `Path` and `Any` unimported in
corpus_tools/epub/fetch.py and its `__file__`-relative manifest path
pointing two directories too deep. ruff reported nothing, all 186 tests
stayed green, and `python3 scripts/fetch_epub_edge_cases.py` raised
NameError at import — the exact failure mode fourteen call sites in the
xberg repo would have hit. The manifest now comes from paths.py, which is
what that module exists for.

So the smoke test becomes a real test rather than something to remember.
test_entry_points.py runs every tool's --help in a subprocess from a
temporary directory, checks each data file resolves, and walks the runtime
import graph with ast to prove nothing third-party crept in. Running from
elsewhere is the point: a tool that only works when the cwd happens to be
the repository is broken for every consumer.

Parsing rather than grepping that import graph is also the point — the first
version matched a docstring line beginning "from the content instead..." and
reported it as an import, and a test that cries wolf gets switched off.

check_diagram_ground_truth.py gains the parser it never had, so it answers
--help like the other nine, and --only to check a single fixture.
verify-corpus.yaml becomes single-purpose: the bucket check, on the runner's
bare python3, with no setup step and no install. A second ~keep says so
explicitly, because the missing setup-python reads as an oversight and is
not one — it is the executable proof that a consumer with a stock
interpreter can resolve this corpus. The unit tests move to test-unit.yml,
which is allowed to install things.

validate.yml wires up the poly gate that poly.toml has been describing with
nothing enforcing it. Python is pinned to 3.10, the declared floor, so
requires-python becomes something CI checks rather than something the
manifest asserts; poly is pinned so a rate-limited "resolve latest" request
cannot kill the job, and so a poly release cannot change what this gate
enforces without a commit here.

test-unit.yml runs pytest on 3.10 and 3.14, then re-runs the same suite
through python3 -m unittest with nothing installed, because that runner is
the same guarantee one level up. It is deliberately not path-filtered:
most PRs here add corpus documents and touch nothing under scripts/, and a
filtered workflow that never runs leaves a required check pending forever.
The final `git diff --exit-code` is a tripwire for a future edit that drops
a --check or adds a --fix.

Taskfile.yml fixes CONTRIBUTING.md's instruction to run `task setup`, which
had nothing to run. Every command is scoped, and `task format` ends by
failing if anything outside scripts/ changed.
Adds a README section on serving a corpus whose index lives elsewhere: the
flags, ADC auth, what the guards refuse and why, and why an anchored pattern
beats an extension list. All placeholders — <private-repo>, <namespace> —
because this repository is public.

Three things were already wrong before this branch:

- README named `scripts/normalize_gt.py`, which does not exist here. It is
  in the xberg repo beside `build_corpus.py`, which the same section already
  attributes correctly two paragraphs earlier.
- CONTRIBUTING told contributors to run `git lfs install && git lfs pull`,
  though README says the corpus moved off LFS; the binaries are not in git at
  all. It now points at `task setup` and the fetcher.
- CONTRIBUTING told them to run `task setup` in a repo with no Taskfile.
  There is one now.

The test command in README and diagrams/README moves to `uv run pytest`, and
the CI paragraph says why the bucket job deliberately has no setup step.
Completes the layout issue #11 asks for. Every entry point is now its
docstring, its parser, main(), and the exit — the largest is 184 lines and
most are under 80.

diagrams/render.py takes the renderer invocations and the font check;
diagrams/recipes.py takes the fixture table and build(), deliberately free of
subprocess calls at import time because test_diagram_manifest imports RECIPES
in CI where no renderer exists. The EMBEDDABLE_FAMILIES allowlist moves as
one block with its comment: ATTRIBUTIONS.md cites it as a licence-compliance
mechanism, so the reasoning has to travel with the constant.

diagrams/ground_truth.py, math_binaries.py and regression.py follow the same
shape. Both fetchers now take their manifest path from paths.py rather than
computing it from __file__, which is what makes the move safe — a
__file__-relative data path silently points somewhere else the moment its
module changes depth, and finding nothing does not raise.

The entry-point test caught two breaks during this: a lost ENGINES table and
a missing import. That is the second time it has paid for itself.
Closes the last coverage holes from the branch review, each verified against
the mutant that used to survive.

fetch_corpus.main had none: only fetch_one was tested, so nothing checked
what gets fetched or where it lands. Disabling the --include filter and
ignoring --root both passed the whole suite. The first quietly pulls ~580 MiB
into every consumer's CI; the second writes into the wrong tree.

Proving that second one could fail wrote three files straight into the
working tree — matched by corpus patterns, hidden by .gitignore, invisible to
`git status`, and therefore something the next publish would have picked up.
Removed, and the test now asserts against REPO_ROOT rather than cwd, which is
where a broken --root actually lands.

fetch_regression's shard path had none either, and it is the highest-risk
code left: govdocs1 publishes archives, so one download yields 267 objects
located by basename inside the zip. The missing-member branch, whose deletion
survived, now has a test — along with the mismatch sidecar, the
already-current no-op that must not re-download hundreds of megabytes, and a
shard failure reporting per wanted member.

The curl stubs swallowed **kwargs, so nothing checked that `fetch` gets bytes
and `head_many` gets text=True. The two genuinely differ — one decodes stderr
itself, the other parses stdout as a string — so flipping either flag passed
the suite and would have failed against the real subprocess module.

Also: resolve_objects' missing-file branch, which is what stops a manifest
pinning an object the bucket will never serve; publish_corpus.parse_args,
including that both safety flags default to the safe setting; and the font
allowlist ATTRIBUTIONS.md cites as a licence-compliance mechanism — its
parsing is split out so it can be tested without qpdf, since it otherwise
only runs behind a renderer-dependent build and never executes in CI.

98 -> 215 tests.
…gs do

The polyrepo type-checks with pyrefly, not mypy, and runs it through poly
rather than as a dev dependency — it is a system tool like poly itself,
which is why no sibling lists it in [dependency-groups]. Confirmed that
`poly lint .` executes it in its whole-project phase by introducing a
deliberate bad return and watching poly report it, so validate.yml already
covers type-checking and test-unit.yml no longer needs a step for it. That
workflow's `setup-python: true` turns out to exist for exactly this reason:
pyrefly resolves imports against the interpreter it is handed.

The larger fix is that poly was not configured. It does NOT read
pyproject.toml's [tool.ruff] — verified: a file containing
`from __future__ import annotations` was clean to `poly lint` and a TID251
error to `uv run ruff check`. The commit hook and the CI gate were enforcing
different rules and nothing said so. poly.toml now carries a
[lint.python.ruff] block mirroring the select, ignore, per-file-ignores and
limits, matching how liter-llm, crawlberg and actions configure theirs.

pyproject.toml keeps its own [tool.ruff] rather than deferring entirely,
because that is what constrains a human running `ruff format .` or an
editor-on-save — neither reads poly.toml, and its force-exclude is the
corpus protection. Two tools, two configs, one corpus, and a test that fails
when they disagree.

One asymmetry is recorded rather than papered over: poly cannot express
`banned-api`. It is a ruff setting rather than a rule code, and poly accepts
`flake8_tidy_imports_banned_api` without complaint and without effect — so
the future-import ban is enforced by the ruff step in test-unit.yml and not
by poly. A test asserts that, so the next person finds it written down.
The poly gate failed on the runner with `pyrefly: not found`. My previous
commit message asserted pyrefly is a system tool absent from every sibling's
dependency groups — that was wrong, and the CI failure is what proved it.
liter-llm and crawlberg both carry `pyrefly>=1.1.1` in their dev group, and
they have to: poly runs pyrefly in its whole-project phase, and
reusable-validate.yml puts the synced .venv/bin on PATH, which is the only
way `poly lint .` finds it on a runner.

Taskfile calls it through `uv run` for the same reason, so a contributor
needs no system install.
`poly doctor` reports that an unanchored glob matches a directory of that
name at ANY depth. `diagrams/**` and `epub/**` were therefore also hiding
scripts/corpus_tools/diagrams and scripts/corpus_tools/epub — eight files
this branch added that poly then silently stopped linting. It went from 70
files to 78 once anchored.

The original config already anchored `/office/**` and `/MATH_PROVENANCE.md`,
so the pattern was known; the rest were left unanchored and it did not
matter until a package name collided with a corpus directory name. All 55
rules are anchored now, and a ~keep records why, since the next subpackage
called `text` or `code` or `json` would hit the same thing.

Verified the corpus is still excluded (52 directories, and code/hello.py and
jupyter/mime.ipynb are both refused by name), and that an adversarial
`poly fmt .` still changes nothing outside scripts/.
The same bug as the poly one, in the other config, and worse because nothing
reported it. `extend-exclude = ["diagrams", "epub", ...]` excludes a
directory of that name at ANY depth, so ruff was skipping
scripts/corpus_tools/diagrams and scripts/corpus_tools/epub — eight
first-party files, never linted, never format-checked, with four dead
imports sitting in them. `poly doctor` surfaces the poly half; the ruff half
was silent.

Verified all five anchoring forms against a real tree: a bare `diagrams`
excludes both the corpus directory and the source package; `/diagrams` is
ignored by ruff's globset entirely, so it excludes neither; `./diagrams` and
`diagrams/` exclude only the top-level one. Every entry now uses `./`, and
poly.toml uses the leading slash it wants for the same list.

Three tests hold it: both exclude lists must be anchored, and ruff must
actually lint the two subpackages whose names collide with corpus
directories. The sync test normalises the two anchoring styles rather than
comparing raw strings, since neither tool accepts the other's form.

poly went 70 -> 78 files linted; ruff found and fixed the four dead imports.
Corpus protection is unchanged: 52 directories still excluded, and
code/hello.py and jupyter/mime.ipynb are still refused by name.
@Goldziher
Goldziher merged commit 347d2a1 into main Aug 27, 2026
8 checks passed
@Goldziher
Goldziher deleted the feat/private-corpus-support branch August 27, 2026 16:34
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.

refactor(scripts): extract a shared library from the corpus tooling

2 participants