Skip to content

Test integrity after #130: a test may not replace another test's method (rules@5) - #158

Merged
sykosyber merged 1 commit into
feat/integrity-next-after-123from
feat/integrity-next-after-130
Nov 4, 2026
Merged

sykosyber merged 1 commit into
feat/integrity-next-after-123from
feat/integrity-next-after-130

Conversation

@sykosyber

Copy link
Copy Markdown
Member

Summary

Stacked on #130 (base: feat/integrity-next-after-123, head c0e58bc). This takes one leftover from #130's NOTES, "Another test's method replaced at run time" (GAPS 58 (b)). On #130's head, restacked onto main, each row below got run_shards.py --shards 1 to exit 0, serial unittest discover to say OK, and the critic (rules@4) to report no finding:

Row Before (runner at c0e58bc) After: runner Critic before → after
A test's body sets Victim.test_1_must_fail = lambda self: None before that test runs 0 1, test code changed test_replace.Victim.test_1_must_fail while test_replace.Victim.test_0_replace ran none → high (test_replaced, rules@5)
A test module loaded later replaces an earlier module's test method when it is imported 0 1, ran test_r_victim.Victim.test_must_fail after test code changed test_r_victim.Victim.test_must_fail none → high (test_replaced, rules@5)
A setUpClass uses setattr(Victim, "test_must_fail", …) to replace another class's test method 0 1, harness_changed naming the replaced test none → high (test_replaced, rules@5)

Changes

  • scripts/run_shards.py
    • A _Loader subclass of TestLoader records, for every discovered test, where its test method comes from as each module is loaded (_test_method: the instance attribute, the class that owns it in the MRO, and what that class holds, all by identity). The record is taken per module, before the next test module is imported. So a module imported later that replaces an earlier module's test method is seen as a change, not as part of what was loaded.
    • _check_harness, at each test's start and stop, also compares every discovered test against its record. It compares all of them, not only the shard's own tests, so the result does not depend on which shard a test was packed into. A mismatch is reported as harness_changed and names the replaced test id. A test that replaces its own method on its instance is refused as well, conservatively.
    • Cost: about 1.1 ms per check over the 1276 tests here (measured over 100 checks), so roughly 3 s added to a run with --shards 1 and well under 1 s per shard with 8 shards.
  • examples/critic_rules.py: a new family test_replaced. It flags an assignment, del or setattr of an attribute named test… on anything other than self, and a patch of one through mock.patch("pkg.Cls.test_x"), patch.object(Cls, "test_x") or patch.multiple(Cls, test_x=…). patch.dict is not flagged. The family is in HARNESS_RULES, so it applies to every changed Python file. The verdicts change, so the reviewer is now rules@5, and tests/test_publish.py and tests/test_critic.py pin it.
  • Tests
    • tests/test_run_shards.py, four new Integrity tests:
      • the three rows above, each with --shards 1 and with --shards 2, each also asserting that serial unittest reports OK;
      • a test that replaces its own method on its instance;
      • a negative control that passes under both runners and counts the same as serial unittest: three tests generated with setattr(Generated, f"test_generated_{n}", …) before the module is loaded, an instance attribute self.test_dir, and a test that builds its own inner TestCase and replaces that case's method;
      • the critic's line-exact findings for each row (0 findings on the victim module).
    • tests/test_critic.py: 9 TRIPS entries for test_replaced (class-level and fixture cls.test_a = …, at import, setattr, del type(t).test_a, tuple target, patch.object, patch.multiple, patch(...).start()) and 3 CLEAN entries (self.test_dir, setattr(self, "testing", …), patch.dict with a test_… key, obj.tests_dir.path, computed-name setattr generation).
  • Docs: docs/TESTING.md (the "While running" guarantee, three table rows and the vacuous-test bullet), docs/CRITIC.md (the rule table, the HARNESS_RULES list and the revision paragraph), and spec/GAPS.md 58.

Restack (done in this lane, before this PR)

Evidence (local: Linux, CPython 3.13.5, 8 CPUs, venv with hypothesis 6.170.14 so tests/test_properties.py runs)

NOTES: claim ceiling

  • What's closed: a test, a module imported by discovery, or a class or module fixture can no longer replace or delete a discovered test's method (on its class or its instance) without the runner failing the shard and naming the replaced test, provided the change is still in place at the next start or stop check. The critic flags the assignment, deletion or patch, both in test files and in any changed Python file.
  • Not covered (still open, GAPS 58 (b), narrowed):
    • A replacement made and undone within one test before it stops. The runner checks only at each start and stop. A test that runs another test inline while it is replaced is the same case.
    • Replacing what a test calls instead of the test itself: a helper, a fixture method (setUp), or an assertion method (assertEqual, fail, failureException). The runner compares only the method named by _testMethodName.
    • A vacuous body: a try/except around the test's own assertions, or an assertion removed in the diff.
    • Methods supplied through __getattr__ or a metaclass. The runner reads vars() along the MRO.
    • Names the critic does not read: setattr(Cls, computed_name, …) is deliberately not matched, because that is how tests are generated at import. The runner still sees the replacement if it happens after loading.
  • Critic precision: it matches names, not objects. It also flags a test that builds its own inner TestCase and replaces that case's test… method, which the runner allows (the negative control asserts this one finding). No such pattern exists on main or on any open PR head.
  • Behaviour change in the runner: a test helper that rebinds a test… attribute on a discovered test class after loading now fails the shard. None exists in this suite.
  • Same-process residual (GAPS 58 (a)): unchanged. Test code can still reach the runner's record in its own memory. Only a per-shard sandbox (N4) closes that.
  • Not measured: the critic's precision against human review. Python 3.11, 3.12 and Windows ran only in CI, not locally.
  • Untouched: Bridge, Console, ed25519, G5/.github, Attest, RISE, required checks, the evaluation record, schemas and golden traces.

🤖 Generated with Claude Code

The runner records every discovered test's method (instance attribute, owning
class, what that class holds) as each module is loaded, before the next test
module is imported, and fails the shard (harness_changed) when a test starts
or stops and one of them is no longer what unittest would call. Before, a test
that replaced another test's method in its body, at import from a module
loaded later, or in a class fixture made the replaced test pass, and serial
unittest, run_shards and the rules@4 critic all said OK.

The critic gains test_replaced (a harness family, so it applies to every
changed Python file): an assignment, deletion or setattr of a test... attribute
on anything other than self, or a mock.patch / patch.object / patch.multiple of
one. Reviewer bumped to rules@5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 1, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-01T02:57:54.144376Z f93e29b PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f93e29bf64

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread examples/critic_rules.py
Comment on lines +349 to +350
if len(args) >= 2 and isinstance(args[1], ast.Constant) and isinstance(args[1].value, str) \
and not self._on_self(args[0]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Read keyword attributes when scanning patch.object

When changed code uses the valid patch.object(target=Cls, attribute="test_a", new=...) form, this branch only reads positional args[1], so _patched_attributes returns no attribute and the high test_replaced finding is skipped; the positional form is detected. Read attribute (and target) from keyword arguments too so this equivalent patch form is covered by the critic's blocking integrity rule.

Useful? React with 👍 / 👎.

@sykosyber
sykosyber merged commit d7b599f into feat/integrity-next-after-123 Nov 4, 2026
25 checks passed
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