Skip to content

Assertions framework for BabelTest expectations - #102

Merged
gaurav merged 24 commits into
mainfrom
split/2-assertions
Aug 19, 2026
Merged

Assertions framework for BabelTest expectations#102
gaurav merged 24 commits into
mainfrom
split/2-assertions

Conversation

@gaurav

@gaurav gaurav commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

Adds src/babel_validation/assertions: the engine that turns a named BabelTest assertion plus its parameters into a check evaluated against NodeNorm/NameRes. Nothing consumes it yet — the GitHub issue parser that produces the parameters arrives later in the stack — so this PR is the engine, its generated documentation, and its offline tests.

Stack 2 of 4 splitting #67. Base: main (#101 is merged).

The assertion types

AssertionHandler is the base class; NodeNormTest and NameResTest specialize it per service. Each concrete handler declares its parameters and yields TestResults. Registered: Resolves, DoesNotResolve, ResolvesWith, DoesNotResolveWith, HasLabel, ResolvesWithType, SearchByName, and Needed (a placeholder that always fails, marking an issue as still needing a real test).

Both wiki ({{BabelTest|Resolves|CHEBI:15365}}) and YAML syntax are supported, and an assertion can carry several independent params lists, each evaluated separately so one bad one doesn't sink the rest.

Shared parameter handling

AssertionHandler.prepare_params_lists() strips whitespace from every param, rejects params lists whose CURIEs are malformed, and warms the NodeNorm cache for the survivors in a single batched request. Both test_with_nodenorm() and test_with_nameres() route through it, so the NameRes path gets the same validation and the same one-request warming rather than a NodeNorm round-trip per params list.

Two escape hatches keep that uniform treatment from being wrong for particular assertions:

  • curie_params() narrows which params are CURIEs — HasLabel to the first, ResolvesWithType to everything after the Biolink type, SearchByName to the expected CURIE only (its first param is a free-text query).
  • VALIDATE_CURIES = False opts an assertion out of format validation entirely. DoesNotResolve sets it: an identifier that isn't even a well-formed CURIE trivially doesn't resolve, which is exactly what that assertion exists to state, so rejecting it up front would leave the assertion unable to express its own purpose.

A NodeNorm bulk-normalization fix

CachedNodeNorm.normalize_curies() built its return value from response.json(), so a CURIE that NodeNorm silently omitted from its response was absent from the returned dict rather than present with a None value. A caller iterating the results would never see it and would report success for a CURIE it never tested. The warm-cache path happened to re-add the missing key, so the hole only opened on a cold lookup.

It now builds the result from the requested CURIEs: exactly one entry per request, in request order. The ordering guarantee matters independently — "first CURIE that resolved" logic previously depended on the server's JSON ordering when cold and on set iteration order (per-process string hash randomization) when warm, so which CURIE got blamed in a failure message could vary between runs of the same test.

Types and naming

Parameters are named rather than left as nested list[str]:

  • ParamsList — one assertion invocation's parameters. Position is significant (ResolvesWithType takes its Biolink type first, HasLabel is [curie, label]), which is why this is a list.
  • PreparedParamsList — a frozen (params, failure) record. prepare_params_lists() returns a list of these rather than a (stripped, failures_by_index) tuple, so callers don't re-zip two structures by hand.

Service parameters are annotated with the NodeNormService / NameResService Protocols that services/ already defines for the purpose, rather than the concrete CachedNodeNorm / CachedNameRes, so a future drop-in replacement needs no changes here.

Guardrails

  • Registration. _register() replaces the {h.NAME: h for h in [...]} comprehension and raises on a NAME that isn't lowercase or one that's already taken. Lowercase is load-bearing — the README promises users that assertion names are matched case-insensitively, which only holds if every registry key is lowercase — and a duplicate NAME would otherwise silently drop a handler. Both are mistakes only made while adding an assertion, so they fail loudly at import.
  • Missing Biolink types. When NodeNorm returns a node with no type, messages show NO TYPE RETURNED. The earlier placeholder, unknown type, had the shape of a real type — the older Biolink vocabulary was lowercase prose like chemical entity — so it could be misread as something Babel actually returned.

Documentation

gen_docs.py renders assertions/README.md from the handler class attributes, grouping handlers by the service they test rather than by their order in ASSERTION_HANDLERS — otherwise a handler registered in the wrong place lands under the wrong heading, and the sync test can't catch it because it regenerates the same wrong output. tests/test_environment/test_assertions_docs.py asserts the checked-in README stays in sync.

The "Adding a New Assertion Type" instructions live in that generated README and nowhere else. There had been a second copy in the package docstring and the two had already drifted; the docstring now points at the README and describes the module layout instead.

Tests

tests/test_environment/test_assertions.py stubs requests.post rather than the service, so handlers run against the real CachedNodeNorm and exercise its contract instead of a fake restating it. The fixture DB drops one CURIE from the response entirely, reproducing what NodeNorm does for some unknown identifiers. 13 unit tests, all offline.

Also registers the unit pytest marker, first used by these tests.

Outcomes

  • The assertion vocabulary is fixed, documented, and enforced, so the issue parser in the next part of the stack has a stable target.
  • Every check here runs offline — no network, no GitHub API, no Google Sheet.
  • Deliberately not here: parsing assertions out of issue bodies, and any wiring into the existing pytest suites.

Notes for review

gh pr diff shows hunks in .gitignore, services/nameres.py, sources/google_sheets/, and tests/conftest.py that came from #101 and are already on main — they merge as no-ops. The net change against main is the assertions/ package, the normalize_curies() fix, the two test files, and the pytest marker.

result: dict is left untyped for NodeNorm response entries. A TypedDict would be more precise, but the response shape is Babel's to change and a wrong one is worse than an honest dict, so the docstrings say what the dict is instead.

Verify

  • uv run pytest -m unit -q → 13 assertion tests pass offline.
  • uv run python -m src.babel_validation.assertions.gen_docs reproduces the committed README.

🤖 Generated with Claude Code

Introduce src/babel_validation/assertions: a small framework of handler
classes that turn a named BabelTest assertion (HasLabel, SearchByName,
ResolvesWith, DoesNotResolveWith, ResolvesWithType, Needed, ...) plus its
parameters into a check evaluated against NodeNorm/NameRes, returning a
TestResult. assertions/README.md is generated from the handler class
attributes by gen_docs.py, and test_assertions_docs.py (marked `unit`,
offline) asserts the checked-in README stays in sync. Register the `unit`
marker, first used by that test.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

gaurav and others added 2 commits June 26, 2026 14:57
…path

- ResolvesWithTypeHandler: use node.get('type') or [] instead of node['type']
  to match the existing first_type() guard (NodeNorm can return results without
  a type key)
- NeededHandler: add test_with_nameres override so Needed issues fail on both
  NodeNorm and NameRes paths, not just NodeNorm

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Remove drug_chemical_conflate='true' from the NodeNorm call used to
  canonicalize the expected CURIE; NameRes normalizes without conflation, so
  using it caused structural false failures for drug/chemical CURIEs
- Use .get('label', '') instead of ['label'] to avoid KeyError for CURIEs
  that NodeNorm resolves without a preferred label
- Regenerate assertions/README.md to reflect the updated parameter description

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.

Comment thread src/babel_validation/assertions/nameres.py
Comment thread src/babel_validation/assertions/__init__.py Outdated
Comment thread src/babel_validation/assertions/__init__.py Outdated
gaurav and others added 14 commits June 26, 2026 15:50
…ionHandler

- AssertionHandler.test_with_nodenorm/test_with_nameres: return iter([]) instead
  of [] to match the Iterator[TestResult] annotation; simplify docstrings to
  remove reference to github_issues_test_cases.py (module not yet in this PR)
- Remove same stale reference from NodeNormTest.test_param_set and
  NameResTest.test_param_set docstrings
- SearchByNameHandler.DESCRIPTION: "exactly two" not "at least two" to match
  the len(params) != 2 enforcement in test_param_set
- Regenerate assertions/README.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Switch absolute src.babel_validation import to a relative import (..core.testrow)
  so internal modules aren't coupled to the top-level packaging layout.
- Use full MD5 digest for cache filename instead of truncating to 8 chars,
  eliminating the theoretical hash-collision risk across different sheet IDs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds module docstrings explaining the per-(identifier, params) cache key,
the no-auto-eviction policy, and the intended cache-warming pattern:
call the batch method once for all identifiers in a task, then use the
single-item method per assertion at zero HTTP cost.

Adds method docstrings to from_url(), the batch methods (normalize_curies /
bulk_lookup), the single-item methods (normalize_curie / lookup), and the
cache-clearing methods.  Includes a note that lookup() targets a distinct
NameRes endpoint from bulk_lookup() and does not delegate to it.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
NodeNormService and NameResService Protocols document the public interface
callers should type against.  When the implementation is later replaced by an
external library, any code typed against the Protocol will need no changes.

Rename clear_curie() → invalidate_curie() and delete_query() →
invalidate_query().  "Invalidate" is standard cache vocabulary and makes the
full-eviction-across-all-param-variants semantics clearer than "clear/delete".

Also tightens type annotations on normalize_curie / lookup signatures
(str, return type) to match the Protocol.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The repo's .gitignore predates the Python rewrite (it only covered
Scala/Giter8/IntelliJ artifacts), so __pycache__/ and *.pyc were tracked
as untracked noise and easy to commit by accident. Append the standard
GitHub Python.gitignore template (bytecode, build/dist, .pytest_cache,
.venv/.env, mypy/coverage caches, etc.) plus a .DS_Store entry for macOS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
normalize_curies() built its return value from response.json(), so a CURIE
that NodeNorm silently omitted from its response was simply absent from the
returned dict rather than present with a None value.  A caller iterating
results.items() would never see it, and would report success for a CURIE it
never actually tested.  The warm-cache path happened to add the missing key
back, so the hole only opened on a cold cache — which is exactly when a
first-time lookup happens.

Build the result from *curies* instead.  Every requested CURIE is in the
cache by that point, so the dict now has exactly one entry per request, in
request order.  Request ordering also makes downstream "first result that
resolved" logic deterministic; previously it depended on the server's JSON
ordering on a cold cache and on set iteration order (i.e. per-process string
hash randomization) on a warm one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CURIE-format validation and NodeNorm cache warming lived in
NodeNormTest.test_with_nodenorm(), so NameRes assertions got neither:
SearchByName's expected CURIE went to NodeNorm unvalidated, contradicting the
documented "malformed CURIEs are never sent to NodeNorm" invariant, and each
param_set cost its own NodeNorm round-trip instead of one batched call.

Hoist the logic to AssertionHandler.prepare_param_sets() and call it from
both test_with_nodenorm() and test_with_nameres().  SearchByName overrides
curie_params() to params[1:2] — its first param is a free-text search query.
The slice rather than an index keeps a malformed param_set out of validation
so test_param_set() reports the arity problem instead.

prepare_param_sets() also strips surrounding whitespace from every param.
_CURIE_RE is anchored, so a wiki-syntax param with incidental padding
({{BabelTest|Resolves| CHEBI:15365 }}) was reported as malformed; HasLabel
already stripped its label param, so this makes the treatment uniform.

Adds a VALIDATE_CURIES class attribute (default True) for assertions that
need to opt out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Up-front CURIE-format validation applied to DoesNotResolve, which is the one
assertion whose entire purpose is identifiers that don't resolve.
{{BabelTest|DoesNotResolve|not a curie}} failed with "Malformed CURIE(s)"
rather than passing, so the assertion could not express "this junk identifier
must not resolve".

Set VALIDATE_CURIES = False on the handler.  A param that isn't a well-formed
CURIE trivially does not resolve, which is the assertion's expected outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
generate_readme() emitted a group header the first time it saw a handler for
that service while walking ASSERTION_HANDLERS in insertion order.  The
registry happens to be grouped today, so the output is correct; register a
NodeNormTest after SearchByNameHandler and it renders under "## NameRes
Assertions" with no header of its own.  test_assertions_docs.py can't catch
it either, since it regenerates the same wrong output.

Iterate _GROUP_HEADERS and filter handlers per group instead.  The README
regenerates byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stubs requests.post rather than the service, so the tests run against the
real CachedNodeNorm and exercise its bulk-normalization contract instead of
restating it in a fake.  The fixture DB drops one CURIE from the response
entirely, which is what NodeNorm does for some unknown identifiers.

Covers the omitted-CURIE contract, ResolvesWith/DoesNotResolveWith handling
of an unresolvable CURIE, which CURIE gets blamed when one of three differs,
DoesNotResolve accepting a malformed identifier, param stripping, SearchByName
rejecting a bad CURIE without calling NodeNorm, and README grouping for a
handler registered out of order.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
# Conflicts:
#	src/babel_validation/services/nodenorm.py
@gaurav
gaurav requested a balanced review from Copilot August 18, 2026 20:33
"The response is merged with the cached results before returning" described
the old implementation, which built its return value from response.json() and
then added the cached entries to it.  The method now caches the response and
assembles the return value from the cache, which is what lets it guarantee an
entry per requested CURIE.

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 10 out of 10 changed files in this pull request and generated no new comments.

Suppressed comments (6)

src/babel_validation/assertions/nodenorm.py:205

  • No unit test exercises HasLabelHandler; the exact-match, mismatch, unresolved-node, and missing-label branches are all currently uncovered. Add offline cases for these outcomes using the existing NodeNorm fixture so this registered assertion's contract is verified.
        if 'label' not in result['id']:
            yield self.failed(
                f"CURIE {curie} has no label but expected '{expected_label}' on {nodenorm}"
            )
            return

src/babel_validation/assertions/nodenorm.py:122

  • The public contract omits the implementation's requirement that every CURIE resolve. As written, users can reasonably expect one unresolved CURIE to satisfy “must NOT all resolve to the same result,” but lines 137–144 reject it as a configuration error. Document that all CURIEs must resolve in DESCRIPTION/PARAMETERS, then regenerate the README.

This issue also appears on line 201 of the same file.

    PARAMETERS = "Two or more CURIEs per param_set. They must not all resolve to the same result."

src/babel_validation/assertions/nodenorm.py:5

  • This module types every handler against the concrete cache class even though services/nodenorm.py:28-33 explicitly requires callers to depend on NodeNormService. Use that protocol in the import and annotations so alternative service implementations remain type-compatible.
from src.babel_validation.services.nodenorm import CachedNodeNorm

src/babel_validation/assertions/nameres.py:8

  • These concrete client imports conflict with the explicit interface convention in services/nameres.py:34-39 and services/nodenorm.py:28-33. Annotate the handler with NameResService and NodeNormService instead, preserving compatibility with drop-in implementations and test doubles.
from src.babel_validation.services.nameres import CachedNameRes
from src.babel_validation.services.nodenorm import CachedNodeNorm

src/babel_validation/assertions/nameres.py:52

  • The only SearchByName test supplies a malformed CURIE and nameres=None, so it exits during prevalidation and never exercises this lookup or the canonical-ID/top-N matching. Add offline found, not-found, and rank-boundary cases using a fake NameRes client; otherwise the only NameRes assertion's core behavior can regress unnoticed.
        results = nameres.lookup(search_query, autocomplete='false', limit=pass_if_found_in_top)

src/babel_validation/assertions/nodenorm.py:252

  • ResolvesWithTypeHandler has no behavioral coverage, including the newly guarded missing/empty type response. Add offline resolved, wrong-type, missing-type, and unresolved-CURIE cases using the existing NodeNorm fixture.
            if not node:
                yield self.failed(f"Could not resolve {curie} with NodeNormalization service {nodenorm}")
                continue
            biolink_types = node.get('type') or []
            if expected_biolink_type in biolink_types:

gaurav and others added 6 commits August 19, 2026 01:39
Signatures like `tuple[list[list[str]], dict[int, TestResult]]` said nothing
about what the strings were or how the two halves related.  Introduce Params
(one assertion invocation's parameters) and ParamSets (a list of them) as
aliases, and use them throughout the handler modules.

The deeper problem was that prepare_param_sets() returned two structures keyed
by the same index — the stripped param_sets and a dict of failures — leaving
both NodeNormTest and NameResTest to re-zip them by hand with an index lookup.
Return a list of PreparedParamSet instead, a frozen two-field record pairing
each param_set with the reason it was rejected (None when it wasn't).  The
callers become a single loop over prepared param_sets with no index bookkeeping.

Also splits the per-param_set rejection check out into _rejection(), so
prepare_param_sets() reads as strip / reject / warm rather than interleaving
all three.

No behaviour change; the generated README is byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The package calls this concept a "param set" everywhere that matters — the
generated README, every handler's PARAMETERS string, the error messages, and
test_param_set(), which is the documented extension point for new assertions.
Naming the alias Params while the record pairing it with a failure was called
PreparedParamSet gave the same concept two names depending on which stage of
the pipeline it was in.

Keeps the domain term rather than renaming toward something like ParamsList:
"set" here is the English sense the README already defines ("independent groups
of parameters"), not Python's set type, and renaming only the alias would leave
the type vocabulary at odds with the docs and the method name.

Adds a comment on the alias saying so, since "set" naturally raises the
question of whether order is significant — it is.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Set" invited the reading that these are unordered and deduplicated, which
they are not: position carries meaning (ResolvesWithType takes its Biolink
type first, HasLabel is [curie, label]) and duplicates are evaluated as given.
Nothing outside this repo depends on the vocabulary yet, so fix it before the
issue parser in the next part of the stack builds on it.

Renames the whole vocabulary rather than just the type alias, since a rename
of only the alias would leave signatures at odds with the generated README,
the handler PARAMETERS strings, the error messages users read, and the name of
the extension point itself:

  ParamSet          -> ParamsList
  PreparedParamSet  -> PreparedParamsList
  prepare_param_sets-> prepare_params_lists
  test_param_set    -> test_params_list
  param_set(s)      -> params_list(s)   (identifiers, messages, prose)

Also drops the ParamSets alias in favour of spelling out list[ParamsList];
"ParamsLists" is a worse name than the nesting it hides, and the element type
is now self-describing.

No behaviour change. The README is regenerated and differs only in wording.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The nodenorm and nameres parameters were entirely untyped, so nothing in a
signature said what they were or what a handler could call on them — the worst
case being AssertionHandler.test_with_nodenorm(), whose one-line docstring
described neither.

Annotate them with the NodeNormService and NameResService Protocols the
services modules already define for exactly this purpose ("type parameters
against this Protocol rather than CachedNodeNorm directly so that a future
drop-in library replacement requires no caller changes").  The concrete
CachedNodeNorm/CachedNameRes annotations in the handler modules move to the
Protocols too, since they were the same instruction ignored a second time.

Documents the parts a new handler author has to know and could not previously
find in the code:

- what label is for, and that it surfaces in failure messages
- what pass_if_found_in_top means, and that it also caps the NameRes request
- why NameRes assertions receive NodeNorm as well (normalizing the expected
  CURIE so comparison is by canonical identifier, not exact string)
- that yielding nothing from test_with_* means "not applicable to this service"
- what test_params_list() may assume about its params (non-empty, stripped,
  CURIEs validated and pre-warmed) and that it should yield one result per
  CURIE rather than one aggregate
- that handlers are shared singletons and must not hold per-evaluation state

Also declares PARAMETERS, WIKI_EXAMPLES and YAML_PARAMS on the base class
alongside NAME and DESCRIPTION.  All five are read by gen_docs.py, so they are
part of the contract, but only two were previously visible as such.

Uses the :param: style already used in google_sheet_test_cases.py and conftest.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'unknown type' had the shape of a real Biolink type: current ones are prefixed
("biolink:Gene"), but the older vocabulary NodeNorm used was lowercase prose
("chemical entity"), so a reader scanning a failure message could reasonably
take 'unknown type' for something Babel actually returned.

Use NO TYPE RETURNED instead, named as NodeNormTest.NO_TYPE so a test can
assert on it without restating the literal.  Wording covers both branches:
first_type() falls back when the type list is empty *and* when the key is
missing altogether, so "empty type list" would have been wrong in the second
case.

Adds a test, since this branch had no coverage — it checks the placeholder
can't be confused with either type vocabulary rather than just comparing to
the constant, which would pass for any value at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three separate comments said NAME must be lowercase and nothing checked it.
The rule is load-bearing: README.md promises users that assertion names are
case-insensitive, which only holds if every registry key is already lowercase,
so a handler declaring NAME = "Resolves" would become unreachable the moment
the issue parser lowercases its input.  Building the registry with a dict
comprehension had a second silent failure mode — two handlers sharing a NAME
would drop one with no error.

Replace the comprehension with _register(), which rejects both.  Neither can
happen except while adding an assertion, so failing at import is the cheapest
possible feedback.  Tests cover both rejections and assert the real registry
satisfies them.

Also folds the "adding a new assertion" instructions into one place.  There
were two: a six-step list in this module's docstring and gen_docs.ADDING_NEW,
which renders into assertions/README.md.  They had already drifted — the
docstring split the attributes across two steps and never mentioned the
lowercase rule.  README.md keeps the instructions, since it is what someone
adding an assertion is already reading; the docstring now points at it and
describes the module layout instead, which is what a code reader wants.

The README section also gains what a new author previously had to infer from
the base classes: what each documentation attribute is for, what
test_params_list() may assume about its params, and when to override
curie_params().

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gaurav
gaurav merged commit 8766968 into main Aug 19, 2026
@gaurav
gaurav deleted the split/2-assertions branch August 19, 2026 06:31
@gaurav gaurav changed the title Assertions framework for BabelTest expectations (2/4) Assertions framework for BabelTest expectations Aug 19, 2026
gaurav added a commit that referenced this pull request Aug 26, 2026
)

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:

- **10 open issues XPASS** — every assertion now passes, so they look
closeable.
- **7 closed issues have failing assertions** — #406, #552, #584, #711,
#714, #723, #906 — so they look like they should be reopened.
- **1 issue uses an assertion type we have not written yet**:
`ShouldNotHaveSynonym`, in NCATSTranslator/Babel#744. Unknown assertion
names fail loudly by design rather than being skipped, so this stays a
hard failure until #110 lands. It is unrelated to any NodeNorm
behaviour.

## 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 #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:

- #110 — implement the `ShouldNotHaveSynonym` assertion type, which is
the one live failure above that is about this harness rather than about
NodeNorm.
- #114 — a tier of GitHub API tests that need a token but not a full
issue crawl, so CI can cover the integration itself.
- #115 — the per-issue size caps are checked after every
`GitHubIssueTest` has been built, so an oversized body still constructs
a few thousand objects before being rejected. Bounded by GitHub's own
body limit and off the network path, so it matters only if the caps are
ever tightened much further.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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.

2 participants