Skip to content

Run BabelTest assertions embedded in GitHub issues as pytest tests - #103

Merged
gaurav merged 33 commits into
mainfrom
split/3-github-issues
Aug 26, 2026
Merged

Run BabelTest assertions embedded in GitHub issues as pytest tests#103
gaurav merged 33 commits into
mainfrom
split/3-github-issues

Conversation

@gaurav

@gaurav gaurav commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Test cases for Babel currently live in a Google Sheet, which is fine for bulk regression data and poor for anything tied to a specific bug: the sheet has no idea which issue a row came from, so nothing tells us when a fixed issue regresses or when an open one has quietly started working. This PR lets a test case live in the GitHub issue that motivated it, and runs those cases as pytest tests.

An issue body carries assertions in either of two syntaxes — a wiki-style {{BabelTest|Resolves|CHEBI:15365}} marker, or a fenced YAML block whose top-level key is babel_tests: — and the harness turns each issue into one pytest item, with each assertion a subtest. Open issues are expected to fail, so an open issue whose assertions all pass is reported as a strict XPASS: the tool is telling you the issue looks closeable. Closed issues are expected to pass, so one that starts failing is telling you to reopen it.

Stack 3 of 4 splitting #67. #101 and #102 have merged, so this now bases on main; #104 is the remaining piece. This leaves #67 open, which stays open for #104.

What's here

Parsing — src/babel_validation/sources/github/. GitHubIssuesTestCases finds assertion blocks in issue bodies and resolves each to a handler from the assertions framework that landed in #102. Issue discovery uses the GitHub search API rather than paginating every issue, and reads body and html_url straight off the search result, so scanning the five configured repositories costs two search requests per repo and no core requests at all.

Harness — tests/github_issues/. One pytest item per issue, parametrized by issue ID, across the repositories listed under Repositories in targets.ini's [DEFAULT] section. --issue targets a specific issue by org/repo#N, repo#N or N. Issue IDs and hydrated issues are cached and shared across pytest-xdist workers behind a FileLock.

Input validation. Issue bodies are untrusted: anyone with a GitHub account can write one, nothing reviews it, and we parse it and turn it into live NodeNorm and NameRes calls. That is tolerable while a human watches the run and can hit Ctrl-C; it is not tolerable for the unattended daily runs this is meant to enable. Two of the gaps were reproducible denial of service rather than theory:

  • The pattern that finds a babel_tests block ended with \s+.*?\s+ before the closing fence. Those three nested backtracking quantifiers made matching cubic on a body that opens a block and never closes the fence — 6.8s at 4KB, 53s at 8KB, and hours at GitHub's 65536-character body limit. It runs during collection, which pytest-timeout does not cover, so a single such issue hung the whole run before any test started. Anchoring on the newline that follows babel_tests: in a real fenced block removes the ambiguity: 0.0008s at 65536 characters.
  • yaml.safe_load blocks code execution but still resolves aliases, and PyYAML shares the aliased nodes rather than copying them, so the load looks cheap and the cost lands on whatever formats the result afterwards. A 337-byte body of chained anchors reached 25MB in an error message. Aliases are refused outright, which covers merge keys too.

On top of those: caps on body length, assertions, param sets and parameters per issue; per-parameter checks for empty, over-long and non-printable values; duplicate YAML keys rejected, because YAML keeps the last silently and the block a reviewer reads would not be the block that runs; issue text repr()'d into log lines so an ANSI escape or bidi override cannot reach an operator's terminal; and --issue resolving only within the configured repositories.

Structural caps fail the whole issue — the fix is to split it across several issues — while a bad parameter fails only its own param set, so the rest of the issue still runs. The caps sit far above anything the configured repositories currently contain, so they are a no-op for real issues today, and they are documented in the generated assertions/README.md where issue authors will meet them.

Caches moved out of the shared temp directory. Both the issue-ID cache and the Google Sheet CSV cache used fixed names in the world-writable temp directory. The issue cache holds the IDs a later run fetches and executes assertions from, so being able to write it was close to being able to choose what the run tests. They now live in ~/.cache/babel-validation, created 0700, overridable with BABEL_VALIDATION_CACHE_DIR — and failing to create that directory names the override, since a read-only home on a locked-down runner otherwise raises a PermissionError that gives no hint an escape hatch exists.

The cache sweep in pytest_configure also stopped deleting the Google Sheet .lock files. 48b1c44 had already removed that for the GitHub issue lock — a concurrent pytest holding the lock keeps its now-unlinked inode while this run creates a fresh one, so two processes end up inside "the" lock — and the Google Sheet path had the same shape. unlink_if_exists() now also refuses any path outside the cache directory, because it deletes whatever it is handed and runs before anything else in the session.

Test layout. The offline tests live in tests/github_issues/unit/test_syntax.py (what the two syntaxes mean), test_discovery.py (finding issues, identifying them, resolving an ID) and test_untrusted_input.py (the guards on body content). A subdirectory rather than three siblings because of the fixture: tests/github_issues/conftest.py defines a session-scoped github_issues_test_cases that needs a real GITHUB_TOKEN, and these tests want a dummy-token parser instead. A conftest.py in the subdirectory scopes that override to exactly the files that want it, where putting it in the parent would replace the real fixture for the live test_github_issues.py as well.

Documentation. CLAUDE.md gains an Untrusted Input section: which inputs are hostile and which (targets.ini) are trusted config, and each failure mode above written as the shape to look for rather than as the fix that was applied — so the next parser added here starts from them. The generated assertions/README.md carries the caps, where issue authors meet them. And it now says in as many words that a red pytest tests/github_issues is the tool working, not a defect to be fixed by editing the assertions, because the obvious reading of eighteen red tests is otherwise the wrong one. And it warns against writing a complete {{BabelTest|...}} marker into an issue: this repository is itself in the scanned Repositories list, so an issue that merely describes an assertion gets collected and runs it.

What a run produces

pytest -m unit is fully offline and needs no token: 97 passed, in about a second. This is what CI runs, and it is green.

pytest tests/github_issues --target dev currently reports 18 failing issues, and that is the tool working rather than a defect in it:

What it deliberately does not do

  • No new CLI options for the limits. They are module constants. pytest --timeout=N already exists and is the runtime knob for a slow issue.
  • No guards around the NodeNorm/NameRes responses. Those URLs come from targets.ini, which is trusted config, not from issue bodies.
  • CI runs -m unit only, so nothing here exercises the live GitHub path on a PR. Enabling it needs issues: read and a token; the recipe is in a comment in tests.yaml, and Add a tier of GitHub API tests that need a token but not a full issue crawl #114 covers building a tier of tests that need a token but not a full crawl. Worth knowing that without a token the issue tests skip rather than fail, so a run can go green having tested nothing.

Follow-on work

Nothing is blocking this merge. Two pieces are tracked separately:

History — stack split, review rounds, and two false paths. Kept for anyone tracing why a particular line looks the way it does; the durable conclusions are in the code comments and docs above.

Originally part of #67, split into four stacked PRs. This one was rebased onto main once #101 (library reorg) and #102 (assertions framework) merged; the description previously said it based on split/2-assertions.

Review rounds on #67 produced the arity check on ResolvesWith/DoesNotResolveWith, the coercion of YAML param values to str (YAML 1.1 reads a bare NO as boolean, so HasLabel: - [CHEBI:16480, NO] used to crash), reading SearchByName's top-N from targets.ini rather than hardcoding 5, and making ResolvesWith failure messages deterministic instead of dict-ordered.

get_all_issues() and the tqdm dependency were added and then dropped: once discovery moved to the search API, nothing paginated every issue any more.

issue_id() was originally read from issue.repository.full_name. Search results carry no repository, so that attribute cost two extra REST calls per issue; it is parsed out of html_url instead. There is a test asserting .repository is never touched, because the regression is invisible — it costs requests, not correctness.

Two false paths from the hardening pass, both caught by running the live suite:

  • The first fix for the backtracking regex was simply ```yaml\s+babel_tests:.*?```, which is linear but too loose: it started matching a one-line ```yaml babel_tests: ``` written in prose while discussing the syntax, and then failed that issue. Optionally factor BabelTest parsing out from GitHub fetching (futureproofing) #100 is exactly such an issue. Requiring the newline a real fenced block must have fixes both.
  • MAX_PARAM_LENGTH was first set to 255, which would have rejected legitimate data: IUPAC chemical names run well past that, and HasLabel compares against NodeNorm labels. It is 1000.

Issue #115, filed from this branch to track a deferred item, explained the size caps using a complete {{BabelTest|Resolves|CHEBI:1}} marker. The harness collected it and ran it on the next live run, and because a new issue is open, the assertion passing reported as a strict XPASS failure. The issue body now quotes a partial marker, and the rule is in CLAUDE.md.

Three verification false paths, all of which produced a test that looked fine and proved nothing:

  • test_cache_dir_is_not_world_writable_temp pointed BABEL_VALIDATION_CACHE_DIR at a tmp_path and then asserted the result was not under the temp directory — a self-contradiction that passed locally only because pytest resolves tmp_path to /private/var on macOS while gettempdir() reports /var. Linux CI caught it. It now asserts against the default location instead.
  • Asserting on caplog.text cannot test control-character escaping: pytest does not carry a raw ESC through it, so the test passed whether the code used %r or %s. The assertions read caplog.records and getMessage().
  • A mutation check on %r%s appeared to pass because the two are the same length, so the source size was unchanged and the .pyc was never invalidated — the interpreter never ran the mutation. Both gotchas are now in CLAUDE.md.

Running black on the touched files was also reverted. The repository is not black-clean, so it turned a 384-line diff into 703 lines and buried the change in reformatting; CLAUDE.md now records that.

gaurav and others added 2 commits June 26, 2026 01:02
GitHubIssueTest / GitHubIssuesTestCases pull BabelTest assertions embedded
in GitHub issue bodies (wiki {{BabelTest|...}} markers or fenced YAML
blocks), resolve each to an assertion handler from the assertions package,
and evaluate it against NodeNorm/NameRes. Add the dependencies this needs:
pygithub, pyyaml, tqdm and python-dotenv.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Dynamically generate one pytest per GitHub issue (across the repos listed
in targets.ini [DEFAULT] Repositories), each running the issue's BabelTest
assertions as independent subtests. Open-issue failures are reported as
xfail so they don't block CI. Add a --issue option (and
selected_github_issues fixture) to restrict a run to specific issues, wipe
the issues cache at session start, parallelize with pytest-xdist, and add
a PR workflow that runs the offline `-m unit` suite. tests/github_issues/
test_system.py covers the parsing/dispatch logic with mocked GitHub data.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
gaurav and others added 9 commits August 19, 2026 02:35
# Conflicts:
#	tests/conftest.py
Arity was checked by each handler at the top of test_params_list(), which runs
after prepare_params_lists() has already warmed the NodeNorm cache.  A
params_list of the wrong length can never pass, so that lookup was always
wasted: {{BabelTest|SearchByName|water|CHEBI:15377|unexpected}} normalized
CHEBI:15377 before rejecting the assertion for having three params.

Declare the bounds instead, as MIN_PARAMS/MAX_PARAMS alongside PARAMETERS, and
check them in _rejection() before CURIE validation.  Arity now sits with the
rest of what the class says about its params rather than buried in a method
body, and five hand-rolled checks go away.  Ordering it first also removes a
wart in SearchByName.curie_params(), which had to slice rather than index
specifically so a malformed params_list could survive validation long enough
for test_params_list() to report the arity problem.

Messages are generated from the bounds, preserving the "exactly two" and "at
least two" wording the existing tests pin.  display_name() moves to
AssertionHandler so gen_docs.py and the failure messages share one definition.

Adds tests that a rejected params_list issues no NodeNorm request, and that a
well-formed one still gets pre-warmed.  Disabling the new check fails four of
them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two ways a well-meaning issue body could break a run:

- {{BabelTest| Resolves |CHEBI:15365}} kept the padding in the assertion
  name, so it missed ASSERTION_HANDLERS and the test hard-failed as an
  unknown assertion type — bypassing the open-issue xfail path entirely.
  The name is now stripped, and a name that is only whitespace raises the
  "Missing assertion name" error rather than sneaking through.

- YAML 1.1 resolves an unquoted `no`, `on` or `1.5` to a bool or a float,
  so `HasLabel: [CHEBI:X, no]` reached prepare_params_lists and died on
  param.strip() with an opaque AttributeError. _to_str_list now rejects a
  non-string leaf where we can name the offending value and say to quote it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_issues_by_ids only caught UnknownObjectException in the bare-'N'
branch, so `--issue org/repo#99999` let PyGitHub's 404 escape out of
pytest_generate_tests as a raw collection error. The 'org/repo#N' and
'repo#N' branches now fall through to the "Could not resolve issue ID ..."
ValueError that was written for exactly this case.

The loop variable is renamed to raw_id, making room for the module-level
issue_id() helper added next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
For an issue that came out of the search API, PyGitHub's .repository getter
first completes the issue (a full GET, since search payloads omit the
repository) and then returns an incomplete Repository whose .full_name
triggers a second GET — two extra REST calls per issue, on every collection
and in every xdist worker. html_url already spells out owner and repo.

Both call sites now share one issue_id() helper rather than formatting the
ID by hand.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
get_all_issues() has had no callers since get_issues_with_tests() replaced
it, and importing its progress bar was the only reason tqdm was a
dependency. It also logged its "Found %d issues" summary from inside a
generator, so the count never printed unless a caller exhausted it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
_fetched_issues_cache was read by the fixture but only ever written on the
network path in _get_all_test_issue_ids. Whenever the JSON file cache was
hit — i.e. every xdist worker after the first — each test refetched its own
issue individually, and refetched it again for every --target.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…test

pytest_configure unlinked babel_validation_issues_cache.lock along with the
cache itself. A concurrent pytest run holding that lock while it fetched
from GitHub would keep its now-unlinked inode, while this run created a
fresh one — two processes inside "the" lock at once. The lock is left alone
now; only the cache file is cleared.

The two filenames were also duplicated as string literals in the root
conftest, with nothing tying them to the definitions in the github_issues
conftest, so renaming the cache there would have silently stopped the
cleanup. Both paths now live in tests/_pytest_helpers.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
nodenorm-es has completely replaced the nodenorm.ci that [ci-redis] names,
so [ci] tracks it as what CI actually runs. [ci-es] pairs the same NodeNorm
with the Elasticsearch NameLookup this repository is validating, and will be
folded back into [ci] once namelookup-es is signed off. Reading like a
copy-paste slip, this had already been flagged once in review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav force-pushed the split/3-github-issues branch from 068608f to b91805c Compare August 19, 2026 18:02
gaurav and others added 6 commits August 19, 2026 17:48
nodenorm.ci.transltr.io no longer exists — nodenorm-es has completely
replaced it — so [ci-redis] only made `pytest --target all` hit a dead
endpoint. The Vue validator offered the same dead URL under
"NodeNorm-ITRB-ci" and now points at nodenorm-es too.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Declaring a permissions block sets every unlisted scope to none, so the job
stops inheriting whatever the org's default-permissions setting happens to
be. Only actions/checkout needs anything, and it needs contents:read.

The GITHUB_TOKEN passed to the pytest step was never read: test_github_issue
carries no `unit` marker, so `-m unit` deselects it and deselected_by_markexpr
short-circuits the parametrization before conftest looks for a token, while
the unit tests build their own parser with a dummy one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Spells out what to add back, and the constraint that is not obvious from
the workflow: github.token is scoped to this repository, so the other
Repositories in targets.ini are reachable only because they are public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Without GITHUB_TOKEN the GitHub issue tests skip at the module level, so
dropping the -m unit filter without restoring the env: block would leave CI
green having tested nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The top-level README covered the Google Sheet tests but never mentioned the
GitHub issue tests, the GITHUB_TOKEN they need, or why they need it.

A token is not required for authentication — the scanned repositories are
public and both the single-issue and search endpoints answer unauthenticated
requests — but the unauthenticated budget of 60 core requests an hour is
counted per IP, which a run cannot stay inside. Measured rather than assumed:
discovery of all 96 issues costs zero core requests, because search results
already carry the body and html_url the harness reads; core requests are spent
re-hydrating issues one at a time when the cached ID list is reused.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Both behaviours changed in this branch without direct coverage. The issue_id
tests pin the html_url parsing and assert the helper never touches
.repository, which is the whole point of it. The get_issues_by_ids tests
cover all three ID formats plus an unparseable one, since the 404 previously
escaped from two of the three branches.

Also records two things worth knowing before touching this code: the repo is
not black-clean, so formatting the whole tree buries real changes; and
`GITHUB_TOKEN=` (empty) is how to test the no-token path, because
load_dotenv() will not override a key already in os.environ.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav gaurav changed the title GitHub-issue test parsing + harness (3/4) GitHub-issue test parsing and harness Aug 26, 2026
gaurav added 5 commits August 26, 2026 14:12
Issue bodies are untrusted input: anyone can write one, nothing reviews it, and
we parse it and turn it into live NodeNorm and NameRes calls. Two of the gaps
were reproducible denial of service, not theory.

The babel_tests block pattern ended `babel_tests:\s+.*?\s+```. Those three
nested backtracking quantifiers made matching cubic on a body that opens a block
and never closes the fence: 6.8s at 4KB, 53s at 8KB, hours at GitHub's
65536-character limit. It runs in issue_has_tests() during collection, which
pytest-timeout does not cover, so one such issue hung the whole run before any
test started. Anchoring on the newline that follows `babel_tests:` in a real
fenced block removes the ambiguity: 0.0008s at 65536 characters, matching
byte-identical text. It also stops matching a one-line ```yaml babel_tests: ```
written in prose while discussing the syntax, which the old pattern picked up
and then failed the issue on (#100).

safe_load blocks code execution but still resolves aliases, and PyYAML shares
the aliased nodes rather than copying them, so the load looks cheap and the cost
lands on whatever formats the result. A 337-byte body of chained anchors reached
25 MB in an error message. Aliases are now refused, which covers merge keys too.
Duplicate keys go with them: YAML keeps the last silently, so the block a
reviewer reads would not be the one that runs.

Also: caps on body length, assertions, param sets and parameters per issue,
counted across both syntaxes, since one test item runs all of it — exceeding one
fails the issue rather than running part of it, and the fix is to split it up.
Assertion names are shape-checked where both syntaxes meet. Issue text is
repr()'d into log lines, which escapes exactly what str.isprintable() rejects,
so an ANSI escape or bidi override cannot reach an operator's console. __str__
no longer dumps param_sets, which was repeated into every TestResult message.
And --issue resolves only within the configured repositories, checked before
get_repo() because the pattern's repo group admits slashes and would otherwise
let 'org/repo/../../elsewhere#1' reach the API as a URL path.
…service

AssertionHandler._rejection() is the one choke point every handler routes
through, and the only check that sees every param: _CURIE_RE skips whatever
curie_params() excludes — notably SearchByName's free-text query, the one value
that reaches a URL query string — and handlers with VALIDATE_CURIES off skip it
entirely.

The checks run before the arity and CURIE checks, because both of those
interpolate the params into their message.

MAX_PARAM_LENGTH is 1000 rather than something tighter: CURIEs run under 100
characters and Biolink types under 60, but a chemical label can be long — IUPAC
names run well past 255 — and rejecting those would be a false positive on real
data. str.isprintable() covers ANSI escapes, C0/C1 controls, bidi overrides and
zero-width characters in one call.

A bad param fails only its own param set, so the rest of the issue still runs.
Both caches used fixed names in the world-writable temp directory, so on a
multi-user machine or a CI runner anyone could pre-create one as a symlink and
have us overwrite whatever it pointed at, or simply rewrite its contents.

That second one matters more than it looks: the GitHub issue cache holds the
issue IDs a later run fetches and executes assertions from, so being able to
write it was close to being able to choose what the run tests.

A directory under the user's own home, created 0700, has neither exposure — it
removes the problem rather than mitigating it. BABEL_VALIDATION_CACHE_DIR
overrides it for a runner without a writable home.
The load-bearing one is test_unterminated_yaml_block_is_fast: a 40KB hostile
body must parse in under a second, where the old pattern took hours. The budget
is deliberately generous, because what it catches is a difference between
milliseconds and hours rather than a slow regression.

The rest pin the asymmetry the guards are built around — structural caps fail
the whole issue, a bad param fails only its own param set — and the two halves
that are easy to lose: that a rejected param never reaches the service, and that
it is escaped in the message rather than merely rejected.

Also records something found while writing them: PyYAML's reader refuses control
characters outright, so the YAML syntax never reaches our isprintable() check and
the wiki syntax is the path that needs it.
The caps belong where issue authors will meet them, so they go in the generated
assertion reference rather than only in the code. Regenerated with gen_docs.
It deletes whatever it is handed, and it runs from pytest_configure before
anything else in the session. Both callers today build their paths from
cache_dir(); the check is so a later one cannot quietly turn a cache sweep into
a delete of something that matters.

An OSError other than a missing file — a directory left where a cache file
belongs, a permissions problem — now warns instead of failing the run before it
starts. A stale cache is not worth that.

Not a symlink fix: os.unlink removes the link rather than following it, so this
was never an overwrite vector. It was the write side that could be redirected,
and moving the caches out of the shared temp directory closed that.
@gaurav gaurav changed the title GitHub-issue test parsing and harness Run BabelTest assertions embedded in GitHub issues as pytest tests Aug 26, 2026
gaurav added 6 commits August 26, 2026 14:44
The guards added across this branch are only useful if the next person writing
a parser knows why they are there. Each entry is a failure mode that was real in
this code, phrased as the shape to look for rather than as a description of the
fix: adjacent backtracking quantifiers, safe_load still expanding aliases, %s
where %r was needed, a guard placed after the value was already logged, an
outside identifier choosing what we fetch, and a cache in the shared temp
directory.

Also states which inputs are trusted — targets.ini is checked-in config — since
"validate everything" and "validate the right thing" are not the same advice.
An open issue whose assertions all pass is a strict XPASS meaning it looks
closeable, and a closed issue with failing assertions means it looks reopenable.
Both are findings about Babel rather than defects here, so the repair is to the
issue, not to the assertion. Without this written down the obvious reading of 18
red tests is that something needs fixing in this repository.
test_cache_dir_is_not_world_writable_temp pointed BABEL_VALIDATION_CACHE_DIR at
a tmp_path and then asserted the result was not under the temp directory, which
contradicts itself. It passed locally only because pytest resolves tmp_path to
/private/var on macOS while gettempdir() reports /var, so the parent check never
matched; on Linux CI, where tmp_path really is under /tmp, it failed.

Split into the two things actually worth asserting: the default location is
under the user's home and not the shared temp directory, and a created cache
directory carries no group or other permissions. Both fail if cache_dir() moves
back to gettempdir().
Neither had a test. The log line matters because it runs before anything has
validated the text, so it is the last place an escape sequence can be stopped
before it reaches a terminal.

The assertions read caplog.records rather than caplog.text: pytest does not
carry a raw control character through caplog.text, so a test written against it
passes whether the code escapes or not. Both tests were checked by reverting the
behaviour and confirming they fail — worth doing here, because %r and %s are the
same length, so restoring the file left a same-size source whose .pyc was not
invalidated and the first attempt appeared to pass a mutation it had not seen.
An unset HOME is not the problem it looks like — Path.home() falls back to the
pwd database — but a read-only home on a locked-down runner or in a container
is, and the bare PermissionError names a path without hinting that
BABEL_VALIDATION_CACHE_DIR exists, which is the only reason it does.

Both cases are pinned, the second skipped under root, where the permission bits
it relies on are ignored and it would fail rather than skip.
48b1c44 stopped unlinking the GitHub issue cache's .lock, because a concurrent
pytest holding it while fetching would keep its now-unlinked inode while this
run created a fresh one, putting two processes inside "the" lock. The Google
Sheet sweep had the same shape — it derived `<name>.lock` from each
`<name>.csv` and unlinked both — and was missed at the time.

Only the .csv files are cleared now, which is all that has to be fresh.
gaurav added 4 commits August 26, 2026 14:56
Both cost real time this round and both leave a test that looks verified and is
worthless: a same-length source edit whose .pyc is never invalidated, so the
mutation is never actually run, and caplog.text, which does not carry control
characters through and so passes whether the code escapes them or not.
At 605 lines it was the largest test file in the repo, and "system tests" named
none of the three things it covered: what the two syntaxes mean, how issues are
discovered and identified, and the guards on untrusted body content. Those are
now test_syntax.py, test_discovery.py and test_untrusted_input.py.

A subdirectory rather than three siblings, because of the fixture. The parent
conftest defines a session-scoped github_issues_test_cases that needs a real
GITHUB_TOKEN, and these tests override it with a dummy-token parser. Three flat
files would each need their own copy of that override, and it cannot move up to
the parent conftest without replacing the real fixture for the live
test_github_issues.py as well. A conftest here scopes it to exactly these files.

_mock_issue() sits in _helpers.py rather than conftest.py: it is a plain
function, not a fixture, so it has to be imported rather than discovered.

A pure move — every test body is verbatim and only the imports differ. Verified
by diffing collected node IDs with the file path stripped, before and after:
identical, 97 either way.
Every one of the 13 @pytest.mark.unit class decorators was already implied by
the module-level pytestmark, and two classes declared a local `fixture` that
rebuilt the dummy-token parser the shared github_issues_test_cases fixture
already provides. Removing those orphaned a _REPOS constant and the
GitHubIssuesTestCases import along with them.

Kept separate from the move so that one stays reviewable as a pure move. The
collected node IDs are unchanged either way.
This repository is in the scanned Repositories list, so a complete marker
written into an issue body is collected and run — an issue describing an
assertion becomes a test of it, and since a new issue is open, an assertion that
passes reports as a strict XPASS failure.

Issue #115 was filed during this work with a marker in its explanation of the
size caps, and failed the live suite on the next run. The README warns about
this for anyone discussing the syntax; CLAUDE.md is where an agent about to file
an issue will actually look.
@gaurav
gaurav merged commit ab13a9e into main Aug 26, 2026
1 check passed
@gaurav
gaurav deleted the split/3-github-issues branch August 26, 2026 21:02
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.

1 participant