Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions docs/CHANGELOG-DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -7513,3 +7513,52 @@ real logged frame/position/paused values before being corrected.
- **PyFlow 0.4.0**, cut when Stage 9 closed. A minor bump on two
independent grounds: shipped physics changed, and two configuration
fields were removed.

### Findings (second pass, same day)

- **Four of the nine validators in `make ci` returned success when they
examined nothing.** Found by asking, of the repository generally, what
else might not be implemented as believed, and answering it
mechanically: every validator run with its discovery function replaced
by one returning an empty set. `check_docs`, `check_duplicate_blocks`,
`check_references` and `check_scenarios` printed their ordinary success
message and exited 0. The other five already failed correctly, and
nothing distinguished the two groups.
- **`check_references.py` had carried a twelve-line comment explaining
this exact failure mode since 2026-08-30** -- written as instance 2 of
`docs/practices.md`'s "A rule that matches nothing reports nothing" --
and its own `main()` had no guard against it.
- **`check_scenarios.py` guarded one half of itself and not the other,
and that one was not an omission.** Its bindings side failed loudly
when no module bound a feature file; its features side printed "No
feature files found; nothing to check" and returned 0, in the same
function -- deliberately, with a test stating the reason: the gate was
built before Stage 4 wrote its first `.feature`, so an empty
`tests/features/` was then the repository's ordinary state. The reason
expired on 2026-08-27 with TASK-023 and went unrevisited for 17 days,
under the gate `adr/ADR-007-executable-acceptance-criteria.md` rests
on entirely. An exemption states its own expiry condition and then
nobody is assigned to watch it.
- **Three other classes were probed and came back clean**, which is worth
recording so the next pass does not redo them: no configuration field
is unread by engine code (the generalisation of TASK-055's sweep to all
14 schema dataclasses); the six ADR-003 registries agree exactly with
the schema `Literal`s that select them (`periodic` and
`boussinesq_buoyancy` are apparent mismatches and both are deliberate,
the latter pinned by `tests/integration/
test_boussinesq_buoyancy_registration.py` in three directions); and all
163 `@then` step definitions assert something.

### Decisions

- **The fix is a sweep, not four guards.** Four guards would be four
fixes and a hope. `tests/unit/test_validator_guards.py` runs every
`check_*.py` with nothing to find and requires a non-zero exit *and* a
message saying so, with `test_every_validator_is_covered` failing if a
validator is added without an entry. A gate that cannot return non-zero
(`check_claims`, advisory by design) is excluded by name with its
reason, never by absence.
- **The general form is recorded as the rule rather than the four
instances**: when a check reports success, ask what it would have
reported had it examined nothing. If those outputs are the same, it is
not yet a check.
8 changes: 6 additions & 2 deletions docs/planning/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,7 +381,7 @@ This paragraph previously said `make install` and `make test` were still
expected to fail, pending `uv.lock` and a test suite (B2/C1) -- stale
since 2026-08-16 and corrected 2026-08-19. Both now succeed: `uv.lock`
is committed (B2) and `make test` runs the suite with coverage
(C1a/C1b): **1240 tests as of 2026-09-13**, up from 1209 on 2026-09-11
(C1a/C1b): **1259 tests as of 2026-09-13**, up from 1209 on 2026-09-11
(TASK-052, Stage 9: 9 in `tests/unit/test_boundary_velocity.py` and 3 in
`tests/golden/test_sealed_box.py` for the wall-permeability fix and its
own golden demo, plus 5 the fixtures those changed gained along the way;
Expand All @@ -394,7 +394,11 @@ the other four did not hold the `velocity.*` filter); then TASK-055:
sweep, and the guard that the sweep reaches anything) and a net -1 in
`tests/unit/test_configuration.py`, which lost the mutual-exclusivity
rejection test along with the two fields it was about and gained four
against the re-homed net-flux rule.
against the re-homed net-flux rule; then 19 in
`tests/unit/test_validator_guards.py`, the sweep proving every gate in
`make ci` fails when it examines nothing, which four of the nine did
not (`docs/practices.md`, "A rule that matches nothing reports
nothing", fourth instance).
Before that, 1209 on 2026-09-11
(the Stage 8 exit audit, closing the gap between three Completion
Criteria and what actually checked them: 4 in
Expand Down
2 changes: 1 addition & 1 deletion docs/planning/status.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ pie showData
## Live repository facts

- **49** `CLAUDE.md` files
- **1240** tests collected
- **1259** tests collected
- **156** Gherkin scenarios (`tests/features/*.feature`)

## Stages
Expand Down
53 changes: 53 additions & 0 deletions docs/practices.md
Original file line number Diff line number Diff line change
Expand Up @@ -1717,6 +1717,59 @@ consults it.** `ALLOWED_MISSING`, `PLANNED`, and any list like them
should be assumed inert until something has been seen to fail because of
an entry in them.

### The fourth instance was the gates themselves, 2026-09-13

All three cases above are a check whose *pattern* matched nothing. The
fourth is a check whose *input* was empty, and it went unnoticed longer
because every instance above trained attention on the pattern.

Asked what else the repository might not be implementing as believed,
a sweep ran every validator with its discovery function replaced by one
returning nothing. **Four of the nine validators in `make ci` returned
success**: `check_docs`, `check_duplicate_blocks`, `check_references`
and `check_scenarios`. The other five (`check_dates`,
`check_documents`, `check_graph`, `check_manifest`, `check_stages`)
already failed correctly. Nothing distinguished the two groups -- it was
not a judgement anybody had made, just whichever author happened to
think of it.

Two details make this the sharpest instance rather than merely another
one:

- **`check_references.py` carries a twelve-line comment explaining this
exact failure mode**, written on 2026-08-30 as instance 2 above, and
its `main()` had no guard against it. A principle stated in a comment
and not implemented two hundred lines below is the defect the comment
is about.
- **`check_scenarios.py` printed "No feature files found; nothing to
check" and returned 0** -- while its *bindings* side, in the same
function, failed loudly on the same condition. **That exemption was
correct when written and had a test saying why**: the gate predates
Stage 4's first `.feature` file, so an empty `tests/features/` was
once the repository's ordinary state. The reason expired on
2026-08-27 when TASK-023 landed that file; nothing revisited it, and
it sat for 17 days under the one gate
`adr/ADR-007-executable-acceptance-criteria.md` depends on entirely.
This is "A checkable trigger still needs somebody to check it"
(below) applied to an *exemption* rather than a promise -- a
distinction worth carrying, because an exemption states its own
expiry condition and then nobody is assigned to watch it.

**The fix is a sweep, not four guards.** Four guards would be four fixes
and a hope; `tests/unit/test_validator_guards.py` runs every
`check_*.py` with nothing to find and requires a non-zero exit *and* a
message saying so, with `test_every_validator_is_covered` failing if a
new validator is added without an entry. A gate that cannot return
non-zero at all (`check_claims`, advisory by design) is excluded by name
with its reason, never by being absent -- an unexplained omission is how
this gap existed.

**The general form, which is what to carry forward:** *when a check
reports success, ask what it would have reported had it examined
nothing.* If those two outputs are the same, the check is not yet a
check. That question is cheap, it is mechanical, and applying it to nine
validators took one afternoon and found four.

### Render it and look at it before calling a rendering stage done

**This applies to documents, not only to pixels** (added 2026-09-04).
Expand Down
3 changes: 2 additions & 1 deletion docs/repository-inventory.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ reading job and lives in the manifest. Test counts and coverage are
not here either -- those come from running the suite, not from
listing files.

**370 tracked files** across 49 directories;
**371 tracked files** across 49 directories;
2 are empty.

## (root)
Expand Down Expand Up @@ -483,6 +483,7 @@ listing files.
- `test_structured_cartesian_mesh.py`
- `test_temperature_field.py`
- `test_uniform_vertex_coordinate_system.py`
- `test_validator_guards.py`
- `test_vector_field.py`
- `test_velocity_field_support.py`

Expand Down
12 changes: 12 additions & 0 deletions tests/integration/test_frame_failure.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,18 @@

fluid:
viscosity: 0.01

# The one glfw case below opens a **real window** on any machine with a
# display, and what a watcher sees is a lid-driven cavity tearing itself
# apart -- arrows exploding off screen -- because that is what this
# fixture is for. Reported as a suspected engine failure by the
# maintainer on 2026-09-13, which is a fair reading of an unlabelled
# window doing that. Stage 7's whole premise is that the render window
# explains itself without the config file beside it; this one did not,
# so it says so now. `rendering.title` is drawn *in* the scene
# (`hud.build_title_text`), not only in the OS title bar.
rendering:
title: "PyFlow self-test -- this run is SUPPOSED to diverge"
"""

_FRAMES = 25
Expand Down
26 changes: 22 additions & 4 deletions tests/unit/test_check_scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,12 +130,30 @@ def test_scenario_outlines_count_as_scenarios(tree: tuple[Path, Path]) -> None:
assert _load(features, tests).main() == 1


def test_an_empty_features_directory_is_not_a_failure(tree: tuple[Path, Path]) -> None:
"""Before Stage 4 writes its first physics feature, a branch that
touches no scenarios must not fail this gate.
def test_an_empty_features_directory_is_a_failure(tree: tuple[Path, Path]) -> None:
"""**This test asserted the opposite until 2026-09-13, and it was
right when it was written.** Its reason was stated and real: "Before
Stage 4 writes its first physics feature, a branch that touches no
scenarios must not fail this gate." The gate was built before the
content it guards existed, so an empty `tests/features/` was the
ordinary state of the repository.

That reason expired on 2026-08-27, when TASK-023 landed the first
`.feature` file, and nothing revisited it -- the same shape as
`docs/practices.md`'s "A checkable trigger still needs somebody to
check it". With 32 feature files and 156 scenarios in the tree, an
empty features directory no longer means "not yet"; it means the
discovery path broke, and the gate
`adr/ADR-007-executable-acceptance-criteria.md` rests on entirely
reported success over it.

The bindings half of the same function has always failed loudly on
its own empty case (the test directly above this one). The asymmetry
was not a judgement anybody made about the two halves -- it was one
half's exemption outliving its reason.
"""
features, tests = tree
assert _load(features, tests).main() == 0
assert _load(features, tests).main() == 1


def test_the_real_feature_tree_has_no_unrun_scenarios(
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/test_validator_guards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
"""Every gate in `make ci` must fail when it examines nothing.

**"A rule that matches nothing reports nothing" is this repository's own
phrase**, written into `check_dates.py`, `check_documents.py`,
`check_stages.py` and -- as a twelve-line comment explaining the failure
mode after it had already cost the project once -- `check_references.py`.
Four validators stated the principle. Four implemented it. They were not
the same four.

A gate that returns success while discovering zero files is
indistinguishable, in CI output, from a gate that discovered everything
and found no problems. That is worse than having no gate: the pipeline
reports a check that did not happen.

This sweep is the enforcement, because the alternative is remembering.
Each validator is imported, the function it discovers work through is
replaced with one returning nothing, and `main()` must return non-zero.
`test_every_validator_is_covered` keeps the map honest -- a new
`check_*.py` with no entry fails here rather than being silently
exempt, which is the mistake this whole file exists to stop being
possible (`tests/unit/test_boundary_field_reachability.py` has the same
shape, for the same reason).
"""

from __future__ import annotations

import importlib
import io
import sys
from collections.abc import Callable
from contextlib import redirect_stdout
from pathlib import Path
from typing import Any

import pytest

_VALIDATOR_DIR = Path(__file__).resolve().parents[2] / "tools" / "validators"

if str(_VALIDATOR_DIR) not in sys.path:
sys.path.insert(0, str(_VALIDATOR_DIR))


# Validator module -> (the attribute it discovers work through, a
# replacement returning "nothing found"). Each discovery point is named
# explicitly because there is no way to infer it: a validator's own
# glob, `git ls-files` call, or document parse is the thing being
# emptied, and those have no common shape.
_DISCOVERY: dict[str, tuple[str, Callable[..., Any]]] = {
"check_dates": ("tracked_files", lambda *a, **k: []),
"check_docs": ("iter_markdown_files", lambda *a, **k: []),
"check_documents": ("tracked_markdown", lambda *a, **k: []),
"check_duplicate_blocks": ("_tracked_markdown_files", lambda *a, **k: []),
"check_graph": ("_data_files", lambda *a, **k: []),
"check_manifest": ("_tracked_files", lambda *a, **k: []),
"check_references": ("tracked_paths", lambda *a, **k: (set(), set())),
"check_scenarios": ("feature_files", lambda *a, **k: []),
"check_stages": ("parse_stages", lambda *a, **k: []),
}

# `check_claims.py` is deliberately advisory and exits 0 even with
# findings (root `CLAUDE.md`), so "returns non-zero" is not a property it
# has. It is excluded by name, with that reason, rather than by being
# absent from the map -- an unexplained omission is how the gap above
# happened.
_ADVISORY = {"check_claims"}


def _run_with_nothing_found(module_name: str) -> tuple[int, str]:
attribute, blank = _DISCOVERY[module_name]
module = importlib.import_module(module_name)
assert hasattr(module, attribute), (
f"{module_name}.{attribute} no longer exists -- this sweep is patching a "
"name that is gone, so it proves nothing about the real discovery path"
)
original = getattr(module, attribute)
setattr(module, attribute, blank)
try:
buffer = io.StringIO()
with redirect_stdout(buffer):
code = int(module.main())
return code, buffer.getvalue()
finally:
setattr(module, attribute, original)


@pytest.mark.parametrize("module_name", sorted(_DISCOVERY))
def test_a_validator_that_finds_nothing_fails(module_name: str) -> None:
code, output = _run_with_nothing_found(module_name)

assert code != 0, (
f"{module_name} returned success while examining nothing at all. In CI that is "
f"indistinguishable from a clean run. Its output was: {output.strip()!r}"
)


@pytest.mark.parametrize("module_name", sorted(_DISCOVERY))
def test_a_validator_that_finds_nothing_says_so(module_name: str) -> None:
"""Failing is necessary but not sufficient -- a validator that fails
with a message about some unrelated problem sends whoever reads CI
looking in the wrong place. The message has to name the real cause.
"""
_, output = _run_with_nothing_found(module_name)

assert output.strip(), f"{module_name} failed silently when it found nothing"
assert any(phrase in output.lower() for phrase in ("nothing", "no ", "not find", "empty")), (
f"{module_name} failed, but its message does not say it found nothing: {output.strip()!r}"
)


def test_every_validator_is_covered() -> None:
"""The guard on the sweep itself.

Without this, adding `tools/validators/check_something_new.py` with
no entry in `_DISCOVERY` leaves it unswept and every test above still
green -- a sweep that covers less than it appears to, which is the
exact shape of the defect being fixed.
"""
on_disk = {path.stem for path in _VALIDATOR_DIR.glob("check_*.py")}

assert on_disk, f"no validators found under {_VALIDATOR_DIR} -- has the layout changed?"

unswept = on_disk - set(_DISCOVERY) - _ADVISORY
assert not unswept, (
f"{sorted(unswept)} are gates nothing here sweeps. Add each to _DISCOVERY with "
"the function it discovers work through, or to _ADVISORY with the reason it "
"cannot return non-zero."
)

stale = set(_DISCOVERY) - on_disk
assert not stale, f"{sorted(stale)} are in _DISCOVERY but no longer exist on disk"
Loading
Loading