From 924f9e1303c861169d516fc2a4a4990d63e0021d Mon Sep 17 00:00:00 2001 From: nstarman Date: Wed, 19 Aug 2026 22:56:14 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test(spec):=20the=20**Fields**=20ta?= =?UTF-8?q?bles=20must=20name=20fields=20that=20exist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three spec sections have now been found describing fields the class does not have -- `Scale` (#750), `Shear` (#762), and `Tangent` here -- each by accident rather than by looking. This closes the class: every `!!! info` section with a **Fields** block is checked against `dataclasses.fields`. `Tangent` was the fifth stale entry. It listed a `manifold` field that has never existed; the manifold is reached through `M`, a property derived from the chart. Its "Post-init checks" were wrong too -- the hook is `__check_init__`, and it calls `M.check_chart(chart)`, not `manifold.has_chart(chart)`. Two deliberate limits: Sets, not sequences. `Translate` defines its own `__init__` whose parameter order is the useful one to document and differs from `dataclasses.fields`; enforcing order would fail a section that is right. Per-type sections only. The exported-objects table is a curated summary -- the `coordinax.charts` row omits 50-odd class names on purpose -- so it is not an `__all__` mirror and is left alone. Both spellings of a Fields block are parsed, bullet list and table, and a guard test asserts the parser still finds sections: a regex that quietly matches nothing would pass forever. Co-Authored-By: Claude Opus 5 --- docs/spec.md | 7 +- tests/unit/test_spec_fields_match_code.py | 102 ++++++++++++++++++++++ 2 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_spec_fields_match_code.py diff --git a/docs/spec.md b/docs/spec.md index 9e779f7fa..b6270975e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -2450,14 +2450,15 @@ Vectors support two comparison relations — a strict one and a coordinate-free |------------|--------------------------|----------------------------------------| | `data` | `dict[str, V]` | component name → scalar value | | `chart` | `ChartT` | coordinate system; static (JAX-frozen) | - | `manifold` | `AbstractManifold` | manifold the tangent lives in | | `basis` | `BasisT` | linear basis; static | | `semantic` | `SemanticT` | physical interpretation; static | | `frame` | `AbstractReferenceFrame` | defaults to `cxf.noframe` | - **Post-init checks:** + The manifold is not a field: `M` is a property, derived from the chart. - - `manifold.has_chart(chart)` — chart must belong to the manifold's atlas. + **Init checks** (`__check_init__`): + + - `M.check_chart(chart)` — chart must belong to the manifold's atlas. - `chart.check_data(data, keys=True)` — data keys must match the chart's component schema. **Methods & Properties:** diff --git a/tests/unit/test_spec_fields_match_code.py b/tests/unit/test_spec_fields_match_code.py new file mode 100644 index 000000000..cc859bfbd --- /dev/null +++ b/tests/unit/test_spec_fields_match_code.py @@ -0,0 +1,102 @@ +"""The spec's **Fields** tables must name the dataclass fields that exist. + +`docs/spec.md` is the authoritative description of the public API, but nothing +tied its **Fields** entries to the code, so they drifted: `Scale` was documented +with `factor`/`chart` long after it stored a matrix (#750), `Shear` likewise +(#762), and `Tangent` listed a `manifold` field that never existed. + +Only the per-type sections are checked. The exported-objects *table* earlier in +the spec is a curated summary -- the `coordinax.charts` row deliberately omits +50-odd class names -- so it is not an `__all__` mirror and is left alone. +""" + +__all__: tuple[str, ...] = () + +import dataclasses +import importlib +import re +from pathlib import Path + +import pytest + +SPEC = Path(__file__).parents[2] / "docs" / "spec.md" + +# Where a documented type name might live. Searched in order; the first hit that +# is a dataclass wins. +_NAMESPACES = ( + "coordinax", + "coordinax.vectors", + "coordinax.transforms", + "coordinax.charts", + "coordinax.manifolds", + "coordinax.representations", + "coordinax.frames", + "coordinax.angles", + "coordinax.distances", +) + +# A `**Fields:**` block, up to the next bold heading or the end of the section. +_FIELDS_BLOCK = re.compile( + r"^\s+\*\*Fields:?\*\*:?\s*\n(.*?)(?=^\s+\*\*|\Z)", re.MULTILINE | re.DOTALL +) +# Two spellings are in use: a bullet list, and a markdown table. +_BULLET = re.compile(r"^\s+-\s+`([A-Za-z_]\w*)\s*[:`]", re.MULTILINE) +_TABLE_ROW = re.compile(r"^\s+\|\s*`([A-Za-z_]\w*)`\s*\|", re.MULTILINE) + + +def _documented() -> list[tuple[str, list[str]]]: + """Every ``!!! info `Name`` section that makes a **Fields** claim.""" + text = SPEC.read_text(encoding="utf-8") + out = [] + for block in re.split(r"^!!! info `", text, flags=re.MULTILINE)[1:]: + name = block.split("`")[0] + found = _FIELDS_BLOCK.search(block) + if found is None: + continue + body = found.group(1) + fields = _BULLET.findall(body) + _TABLE_ROW.findall(body) + if fields: + out.append((name, fields)) + return out + + +def _resolve(name: str) -> type | None: + for ns in _NAMESPACES: + obj = getattr(importlib.import_module(ns), name, None) + if isinstance(obj, type) and dataclasses.is_dataclass(obj): + return obj + return None + + +DOCUMENTED = _documented() + + +def test_the_spec_still_has_fields_sections_to_check() -> None: + """Guard the parser: a regex that silently matches nothing proves nothing.""" + assert len(DOCUMENTED) >= 8, DOCUMENTED + + +@pytest.mark.parametrize(("name", "fields"), DOCUMENTED, ids=[n for n, _ in DOCUMENTED]) +def test_documented_fields_exist_on_the_dataclass(name: str, fields: list[str]) -> None: + """Compared as *sets*, not sequences. + + Order is deliberately not enforced. `Translate` defines its own `__init__`, + whose parameter order is the useful one to document and differs from + `dataclasses.fields`; requiring the latter would fail a section that is + right. + """ + cls = _resolve(name) + if cls is None: + pytest.skip(f"{name} is not a resolvable dataclass") + + actual = {f.name for f in dataclasses.fields(cls)} + documented = set(fields) + + assert not (documented - actual), ( + f"{name}: spec documents field(s) the class does not have: " + f"{sorted(documented - actual)}" + ) + assert not (actual - documented), ( + f"{name}: class has field(s) the spec does not document: " + f"{sorted(actual - documented)}" + )