ci: close two blind spots in the key scan

Review found the gate was scanning less than it claimed.

READERS missed two modules that genuinely touch feedpak manifests:
lib/routers/ws_highway.py (reads `authors`) and lib/gp2notation.py (loads
manifest.yaml, stamps feedpak_version, writes the file back). Keys touched
there were going entirely unchecked.

The scan also only recognised writes done via subscript, so
`manifest.setdefault("k", v)` — exactly how gp2notation.py stamps
feedpak_version — was invisible. setdefault with a literal key now counts as
a write.

The deeper problem is that READERS is hand-maintained, and a hand-maintained
list rots; that is how both modules went unnoticed. check_readers_complete()
now re-derives the set: any module under lib/ (or server.py) that both
touches manifest keys and shows a feedpak signal must be listed, or the build
fails. It is a guard on the gate itself.

The list stays explicit rather than becoming a glob, because `manifest` is
overloaded here: lib/loosefolder.py (the loose-folder manifest.json) and
lib/diagnostics_bundle.py (the diagnostics bundle manifest) have their own
unrelated manifests, and scanning those would flag *their* keys as feedpak
drift. Both score zero on the feedpak signals, which is what keeps them out.

Now scanning 5 modules: 20 reads, 2 writes, all spec-declared.

Signed-off-by: topkoa <topkoa@gmail.com>
This commit is contained in:
topkoa
2026-07-13 00:23:36 -04:00
parent 32d723b774
commit d806d12c22
+70 -11
View File
@@ -10,12 +10,17 @@ being a fully-working pack, and the format's real definition migrates into our
source tree. See #933 for the instance that motivated this gate. source tree. See #933 for the instance that motivated this gate.
We cannot mechanically prove core *interprets* a key the way the spec means. We We cannot mechanically prove core *interprets* a key the way the spec means. We
can prove three surface properties, and those cover the drift that actually can prove four surface properties, and those cover the drift that actually
happens: happens:
1. key-coverage — every manifest key core reads is declared by the spec. 1. key-coverage — every manifest key core reads OR WRITES is declared by the
2. forward — core ingests the spec's own example packs. spec. (Guarded by check_readers_complete(), so the list of
3. reverse — packs committed here satisfy the spec's reference validator. scanned modules cannot quietly fall behind the codebase.)
2. allowlist-closed— feedpak-spec-exceptions.yml never grows. It grandfathers
keys that predate this gate; it is not a way to merge a
new one. The only route for a new key is the FEP process.
3. forward — core ingests the spec's own example packs.
4. reverse — packs committed here satisfy the spec's reference validator.
Dev/CI tooling only: never imported on the serve or Docker path (constitution Dev/CI tooling only: never imported on the serve or Docker path (constitution
Principle I — same category as scripts/build-tailwind.sh). `jsonschema` is Principle I — same category as scripts/build-tailwind.sh). `jsonschema` is
@@ -31,6 +36,7 @@ from __future__ import annotations
import argparse import argparse
import ast import ast
import json import json
import re
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
@@ -38,16 +44,34 @@ from pathlib import Path
REPO = Path(__file__).resolve().parent.parent REPO = Path(__file__).resolve().parent.parent
# Modules that read a feedpak manifest dict. Listed explicitly rather than # Modules that read or write a feedpak manifest dict. Explicit rather than
# globbed so that adding a new reader is a deliberate act that shows up in # globbed, because `manifest` is an overloaded name in this codebase: the
# review — a new reader is exactly when key drift gets introduced. A missing # loose-folder format (lib/loosefolder.py) and the diagnostics bundle
# file here is a hard error, so a rename cannot silently disable the scan. # (lib/diagnostics_bundle.py) both have their own unrelated `manifest`, and
# scanning those would flag *their* keys as feedpak drift.
#
# A hand-maintained list is itself a blind spot, so check_readers_complete()
# below re-derives the set and fails if this list has fallen behind. A missing
# file here is a hard error too, so a rename cannot silently disable the scan.
READERS = [ READERS = [
"lib/sloppak.py", "lib/sloppak.py",
"lib/enrichment.py", "lib/enrichment.py",
"lib/songmeta.py", "lib/songmeta.py",
"lib/gp2notation.py", # rewrites manifest.yaml; stamps feedpak_version
"lib/routers/ws_highway.py", # reads `authors` off a feedpak manifest
] ]
# Where check_readers_complete() looks for modules READERS may have missed.
READER_SEARCH = ["lib/**/*.py", "server.py"]
# A module is handling a *feedpak* manifest (rather than some other manifest) if
# it shows one of these signals. lib/loosefolder.py and lib/diagnostics_bundle.py
# score zero on all of them, which is what keeps their keys out of the scan.
FEEDPAK_SIGNALS = re.compile(r"import sloppak|from sloppak|load_manifest|manifest\.yaml|feedpak")
# Does this module touch manifest keys at all?
KEY_OPS = re.compile(r"(manifest|mf)\.(get|setdefault)\(|(manifest|mf)\[")
# Locals that hold a manifest dict. The loaders use a uniform idiom # Locals that hold a manifest dict. The loaders use a uniform idiom
# (`manifest.get("key")`), so binding by name is sufficient today. See # (`manifest.get("key")`), so binding by name is sufficient today. See
# "Limitations" in docs/feedpak-spec-gate.md for the hardening path. # "Limitations" in docs/feedpak-spec-gate.md for the hardening path.
@@ -104,13 +128,17 @@ def keys_touched(path: Path) -> tuple[set[str], set[str]]:
if ( if (
isinstance(node, ast.Call) isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute) and isinstance(node.func, ast.Attribute)
and node.func.attr == "get" # `setdefault("k", v)` writes k when absent — lib/gp2notation.py
# stamps feedpak_version that way, and a subscript-only scan misses
# it entirely, letting an emitted key slip past the gate.
and node.func.attr in ("get", "setdefault")
and _is_manifest_receiver(node.func.value) and _is_manifest_receiver(node.func.value)
and node.args and node.args
and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str) and isinstance(node.args[0].value, str)
): ):
reads.add(node.args[0].value) bucket = writes if node.func.attr == "setdefault" else reads
bucket.add(node.args[0].value)
elif ( elif (
isinstance(node, ast.Subscript) isinstance(node, ast.Subscript)
and _is_manifest_receiver(node.value) and _is_manifest_receiver(node.value)
@@ -191,6 +219,37 @@ def check_allowlist_closed(baseline: Path | None, bootstrap: bool) -> bool:
return not added return not added
def check_readers_complete() -> bool:
"""READERS must not fall behind the codebase.
The key-coverage scan is only as good as the list of modules it scans, and a
hand-maintained list rots: `lib/routers/ws_highway.py` and
`lib/gp2notation.py` both touched feedpak manifests for a while without being
on it. So re-derive the set — any module that both touches manifest keys and
shows a feedpak signal must be listed — and fail if one is missing.
This is a guard on the gate itself, not on the format.
"""
listed = set(READERS)
missing: list[str] = []
for pattern in READER_SEARCH:
for path in sorted(REPO.glob(pattern)):
rel = path.relative_to(REPO).as_posix()
if rel in listed:
continue
src = path.read_text(encoding="utf-8", errors="replace")
if KEY_OPS.search(src) and FEEDPAK_SIGNALS.search(src):
missing.append(rel)
for rel in missing:
_fail(
f"{rel} touches feedpak manifest keys but is not in READERS "
f"({Path(__file__).name}) — its keys are going unchecked. Add it."
)
print(f" scanning {len(listed)} modules; readers-complete: {'FAILED' if missing else 'OK'}")
return not missing
def check_key_coverage(spec: Path) -> bool: def check_key_coverage(spec: Path) -> bool:
"""Layer 1 — core must not read or write a manifest key the spec does not declare.""" """Layer 1 — core must not read or write a manifest key the spec does not declare."""
schema = json.loads((spec / "schemas" / "manifest.schema.json").read_text(encoding="utf-8")) schema = json.loads((spec / "schemas" / "manifest.schema.json").read_text(encoding="utf-8"))
@@ -358,7 +417,7 @@ def main() -> int:
return 1 return 1
print("[1/4] key-coverage — core reads/writes only keys the spec declares") print("[1/4] key-coverage — core reads/writes only keys the spec declares")
ok1 = check_key_coverage(spec) ok1 = check_readers_complete() & check_key_coverage(spec)
print("[2/4] allowlist-closed — the grandfather list may shrink, never grow") print("[2/4] allowlist-closed — the grandfather list may shrink, never grow")
ok2 = check_allowlist_closed(args.baseline_exceptions, args.bootstrap_allowlist) ok2 = check_allowlist_closed(args.baseline_exceptions, args.bootstrap_allowlist)
print("[3/4] forward — core ingests the spec's example packs") print("[3/4] forward — core ingests the spec's example packs")