Build ErsatzTV Image / CI toolchain image resolves (pull_request) Successful in 35s
Build ErsatzTV Image / Delimiter ban (release path) (pull_request) Successful in 57s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 37s
PR Gates / Docs update reminder (pull_request) Successful in 1m0s
PR Gates / decisions lifecycle (pull_request) Successful in 20s
PR Gates / Fix proofs (Proves trailers) (pull_request) Successful in 17s
review-verdict/h10 Review-verdict: MERGEABLE @ a7d91bf (base: main)
Review verdict / Set review-verdict status (pull_request_target) Successful in 45s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 9m25s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 6m17s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Skipped
PR Gates / Script lint and tests (ruff + pytest) (pull_request) Successful in 19m27s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 6m4s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 8s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 7s
`docs.no-session-narrative` reaches every durable artifact, but its detector scanned only `docs/**/*.md` and root markdown, and nothing had ever swept the rest. The issue named four sites from one grep and called them a floor. Deriving the population instead — a whitespace-joined sweep over every tracked file outside the detector, for the detector's own phrasings plus the attribution and review-round class #812 found — gave 453 sites in 108 files at `fb5592971`, and a second pass for phrasings the first list missed (hyphenated `round-N`, "an earlier version", "the reviewer proved") added residuals in the same files. Every site was classified with #812's three dispositions (CUT / SEVER / KEEP with its sub-kind) under the who-benefits test; the per-site manifests are on the PR. The rejected designs, tested-and-rejected fixtures, measurements and traps stay; the attribution of who found them and the round in which they were found go. The detector's population grows to `.claude/`, `.gitea/`, `.husky/` and `scripts/` regardless of extension, minus the detector and its own test (whose fixtures ARE the phrasings) and minus `scripts/tests/fixtures/` (test data, including decision-record copies — the same reasoning as the records' own exemption, and what keeps the record's depth measurement true), and `--all` lists tracked REGULAR files only — a symlink's content is its target and a gitlink has none. The #812 argument for leaving `docs/superpowers/**` in the population runs the other way here: `--diff` sees only ADDED lines, and 287 of the 453 sites were under 30 days old — this corpus is where narrative is being added, so the advisory nudge has reach. Density agrees: 56 line-mode hits over the 113 regular files the predicate admits, against 9 over 66 docs files before #812. `web/` and C# stay out on the same measurement (3 of 74 PATTERNS-matching sites, ~4,600 files). The predicate did not grow: PATTERNS matched 74 of 453 sites, and widening the word list to the attribution class is the treadmill the withdrawn parity test ran on. The population oracle is restated over segments with the new arms, the synthetic cross product gains the process heads and non-markdown extensions, a fixture witnesses that a tracked symlink is neither scanned nor counted, a `.py.bak` axis separates a by-name exemption from a `startswith` over the same tuple, and eight mutants (drop the process arm, drop the by-name exemption, exempt by `startswith`, drop or add a prefix, drop the fixtures exemption, list only markdown, drop the symlink filter, test the mode per row instead of per path) each redden it. A pre-existing silent drop in `--diff` goes with it: git tab-terminates a `+++` filename that contains a space, and the kept tab made `is_scanned_path` refuse the file with no notice — fixed, with a positive control and its own mutant. Code is unchanged by construction, measured per file type against `origin/main`: Python modules are AST-equal with docstrings stripped, except `#` lines inside the embedded fixture programs (string literals) of three test modules; workflows differ only in `#` lines inside `run:` block scalars; shell, C#, TypeScript and jq are equal with comment lines stripped. The stated exceptions: the detector and its test, 26 vitest titles that carried review-round or severity labels or a reviewer attribution (call sites whose title changed — every changed title line walked back to its `it(` / `it.each(...)(` anchor, so a `' + '` concatenation counts once), two registry note strings and the mutation manifest's prose fields. scripts/tests: 1565 passed. Web: lint, typecheck, 1319 tests green. Closes #876. Decisions-Edit: yes Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PEcBoFw7ctrf3Nb7R7x7wk
159 lines
8.4 KiB
Python
159 lines
8.4 KiB
Python
"""ersatztv#820: the SPA guard's disposition table may not silently disagree with this one.
|
|
|
|
WHAT THIS BLOCKS. `web/src/api/completeAnnotations.guard.test.ts` carries a `DISPOSITIONS` table
|
|
saying, per schema, whether the SPA must annotate it `Complete<T>`, must not, or need not. It derives
|
|
its POPULATION (which schemas can drop a member) from the generated types and asserts set equality
|
|
against those keys — so a new optional member cannot ship undispositioned.
|
|
|
|
It did not check the VALUE, and the value is a kill switch, measured: flip
|
|
`MultiCollectionItemRequest` from `ANNOTATED` to `CREATE` and delete the `Complete<…>` from
|
|
`MultiCollectionsScreen.toItemRequest`, and the whole suite stays green while the defect #807 exists
|
|
to prevent — every weight silently reset to 1 on save — is live again. The row's own note still said
|
|
"a prose comment warned about it, and a comment is not a check", which is what the flip made of it.
|
|
|
|
WHY THE CHECK LIVES HERE. `testing.guard-derives-population-from-source`: "When the scope itself
|
|
MIRRORS an authoritative source, the mirror needs its own equality check against that source."
|
|
`test_optional_request_members.py` IS that source — it derives from `ErsatzTV/wwwroot/openapi/v1.json`
|
|
every run and its dispositions are reviewed. The TS table mirrors nine of its rows. Putting the
|
|
comparison on the Python side is what makes it cheap: this process already holds the authoritative
|
|
table and can read a file, whereas the SPA guard runs under Vite with no filesystem access to
|
|
`scripts/` and would need a virtual-module plugin to see it.
|
|
|
|
THE MAPPING IS ASYMMETRIC ON PURPOSE, and stating why is half the check:
|
|
|
|
COVERED -> ANNOTATED exact. This is the row whose claim the SPA guard verifies.
|
|
COMPUTED -> MUST-NOT-ANNOTATE exact. Annotating would force a caller to invent server-computed
|
|
values, so the prohibition must survive on both sides.
|
|
CREATE -> CREATE | MUST-NOT-ANNOTATE
|
|
TRIGGER -> TRIGGER | MUST-NOT-ANNOTATE
|
|
a STRICTER call on the SPA side is allowed, because forbidding an
|
|
annotation is never the unsafe direction. This is not hypothetical
|
|
slack: `CreateChannelFromLineupAdvancedOptionsRequest` is CREATE
|
|
here and MUST-NOT-ANNOTATE there, because an omitted override means
|
|
INHERIT the template value and `Complete<T>` would collapse that
|
|
third state into an explicit null.
|
|
|
|
Schemas the TS table holds that this one does not are NOT an error: that table scopes to every
|
|
generated schema with an optional member, this one to schemas reachable from a request body, so it
|
|
additionally sees response models. The reverse IS an error — a schema ruled on here and absent there
|
|
means the SPA guard is not covering something the API can drop.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib.util
|
|
import re
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
TS_GUARD = REPO_ROOT / "web" / "src" / "api" / "completeAnnotations.guard.test.ts"
|
|
|
|
# `Name: {` then an optional run of `//` comment lines, then `disposition: '...'`. Anchored on the
|
|
# two-space indent the file's formatter produces for a top-level entry, so a `disposition:` appearing
|
|
# in prose or in a nested object cannot be mistaken for a row.
|
|
_TS_ROW = re.compile(
|
|
r"^ (?P<schema>\w+): \{\n(?:\s*//[^\n]*\n)*\s*disposition: '(?P<disposition>[A-Z-]+)'",
|
|
re.MULTILINE,
|
|
)
|
|
|
|
# The compact single-line form the formatter emits for short rows.
|
|
_TS_ROW_INLINE = re.compile(r"^ (?P<schema>\w+): \{ disposition: '(?P<disposition>[A-Z-]+)'", re.MULTILINE)
|
|
|
|
ALLOWED = {
|
|
"COVERED": {"ANNOTATED"},
|
|
"COMPUTED": {"MUST-NOT-ANNOTATE"},
|
|
"CREATE": {"CREATE", "MUST-NOT-ANNOTATE"},
|
|
"TRIGGER": {"TRIGGER", "MUST-NOT-ANNOTATE"},
|
|
}
|
|
|
|
|
|
# A key that is not a bare identifier does not name a generated schema: `test_optional_request_members`
|
|
# also rules on INLINE request bodies, which it keys by endpoint (`POST /api/v1/artwork/uploads
|
|
# (multipart/form-data inline body)`). Those have no `components['schemas'][…]` entry, so the SPA
|
|
# guard — whose population is parsed out of the generated types — cannot carry a row for them and
|
|
# their absence is not a hole. `_comparable` is what keeps that exemption from widening into one: it
|
|
# is a SHAPE test, not a name list, so a new inline body is exempt automatically while a new named
|
|
# schema can never be.
|
|
_SCHEMA_NAME = re.compile(r"^\w+$")
|
|
|
|
|
|
def _comparable(schema: str) -> bool:
|
|
return _SCHEMA_NAME.match(schema) is not None
|
|
|
|
|
|
def _python_dispositions() -> dict[str, str]:
|
|
spec = importlib.util.spec_from_file_location(
|
|
"_optional_request_members", Path(__file__).with_name("test_optional_request_members.py")
|
|
)
|
|
assert spec is not None and spec.loader is not None
|
|
module = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(module)
|
|
return {schema: value[0] for schema, value in module.DISPOSITIONS.items()}
|
|
|
|
|
|
def _ts_dispositions() -> dict[str, str]:
|
|
text = TS_GUARD.read_text(encoding="utf-8")
|
|
found = {m.group("schema"): m.group("disposition") for m in _TS_ROW.finditer(text)}
|
|
found.update({m.group("schema"): m.group("disposition") for m in _TS_ROW_INLINE.finditer(text)})
|
|
return found
|
|
|
|
|
|
def test_the_TS_table_is_parseable_at_all() -> None:
|
|
"""Anti-vacuity, and the only thing standing between a formatting change and a silent no-op.
|
|
|
|
Every comparison below is over an intersection. If the regex stops matching — a prettier setting,
|
|
a rename, a move — the intersection empties and every other test here passes having compared
|
|
nothing. This is the check that turns that into a failure.
|
|
"""
|
|
ts = _ts_dispositions()
|
|
assert len(ts) >= 10, (
|
|
f"parsed only {len(ts)} rows out of {TS_GUARD.relative_to(REPO_ROOT)}; the table has more than "
|
|
f"that, so the parse is broken rather than the table being small. Every comparison in this "
|
|
f"file is over an intersection and would pass vacuously."
|
|
)
|
|
assert set(ts.values()) <= {"ANNOTATED", "MUST-NOT-ANNOTATE", "CREATE", "TRIGGER", "RESPONSE-ONLY"}
|
|
|
|
|
|
def test_the_PYTHON_table_is_non_empty_too() -> None:
|
|
"""The TS side has a floor; without one here the comparisons are still vacuous from the other end.
|
|
|
|
Every check below iterates the Python table. An empty one collects zero parametrised cases and
|
|
makes the missing-schema check pass over an empty set — green, having compared nothing. The
|
|
neighbour file has its own floor, but a guard that depends on a neighbour's floor for its own
|
|
anti-vacuity is borrowing the property rather than holding it.
|
|
"""
|
|
assert len(_python_dispositions()) >= 5
|
|
|
|
|
|
def test_the_inline_body_exemption_stays_small_and_shaped() -> None:
|
|
"""The exemption above is a shape test; this bounds how much it can be carrying."""
|
|
exempt = sorted(s for s in _python_dispositions() if not _comparable(s))
|
|
assert len(exempt) <= 2, f"more inline-body keys than expected: {exempt}"
|
|
assert all(" " in s or "/" in s for s in exempt), f"an exempt key looks like a schema name: {exempt}"
|
|
|
|
|
|
def test_every_schema_ruled_on_HERE_is_also_ruled_on_in_the_SPA_guard() -> None:
|
|
missing = sorted(s for s in _python_dispositions() if _comparable(s) and s not in _ts_dispositions())
|
|
assert not missing, (
|
|
f"schema(s) dispositioned in test_optional_request_members.py with no row in the SPA guard: "
|
|
f"{missing}. The API can drop a member of each, and the guard that checks the SPA's "
|
|
f"annotations is not covering it."
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("schema", sorted(s for s in _python_dispositions() if _comparable(s)))
|
|
def test_the_two_dispositions_AGREE(schema: str) -> None:
|
|
"""The bypass this closes: the TS value alone decided whether an annotation was required."""
|
|
python_disposition = _python_dispositions()[schema]
|
|
ts = _ts_dispositions()
|
|
if schema not in ts:
|
|
pytest.skip("absence is asserted separately, so it is not re-reported per schema here")
|
|
allowed = ALLOWED[python_disposition]
|
|
assert ts[schema] in allowed, (
|
|
f"{schema}: this file says {python_disposition}, the SPA guard says {ts[schema]}, and "
|
|
f"{python_disposition} admits only {sorted(allowed)}. Flipping the SPA-side value is how a "
|
|
f"required `Complete<…>` annotation stops being required — decide which table is wrong."
|
|
)
|