Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 13s
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 20s
PR Gates / Docs update reminder (pull_request) Successful in 28s
PR Gates / decisions lifecycle (pull_request) Successful in 30s
Review verdict / Set review-verdict status (pull_request) Successful in 12s
PR Gates / Script tests (pytest) (pull_request) Successful in 42s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m31s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m33s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m27s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m24s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fefd11d
Squashed from 7 commits (4 review rounds) to keep the rebase onto #621 tractable; the full round-by-round history is on PR #642. corpus was 5658/5600 — over budget and warning again — 3h35m after #619 put it at 5228, with nobody consolidating anything. So this does NOT re-baseline. An aggregate over a monotonically growing corpus can only ratchet; that is the "permanently red, therefore no signal" state #542 re-baselined away from, and growth is not even a smooth rate to plan against (the corpus FELL from 5089 to 5042 across four days, then gained 427 in one evening as two large records landed). Replaces it with a per-record prose ceiling (default 60), non-blocking, naming each record over it — not monotonic, so it can go red AND green, and it points at a file. The aggregate is still printed as an unthresholded trend notice, with record prose and non-record scaffolding reported separately because they are not the same unit. The GENERATED catalog is no longer counted at all: it gains one row per record and no consolidation can shrink it, which made the metric partly a record COUNT in a line-count costume. The calibration test took FOUR versions, and the failures are the durable lesson: v1 true by construction (`max(under) <= 60 < min(over)` over lists built by that test) v2 a gap WIDTH — a ceiling of 200 also sits in a wide gap, so it passed v3 fraction band + "clear air" vs the nearest record above — hostage to an unrelated record: one ordinary 62-line addition reddened it with the ceiling correctly placed, and the only remedy was to RAISE the ceiling. That is this very treadmill, as a hard failure in what #631 makes a blocking job. v4 `p90 <= ceiling <= p95` — the property stated directly and scale-free. Two rules recorded: a guard test must depend only on the thing it guards, and a threshold over a growing population must be expressed in that population's own terms. Candidates: all over-ceiling records assessed, each actioned or declined with a reason. The largest (scan.libraryfolder-unique-identity, 230 lines) is a legitimate DECLINE — a dozen-odd distinct traps whose only copy that is. Nothing pruned, so no archive or supersession was required. An automated redundancy metric is explicitly rejected. Also: `--budget` is accepted but announces its retirement rather than no-opping silently; the dead `budget_ok` parameter is gone; and five "untresholded" typos are fixed, one of which was propagating into the generated catalog row and MemPalace's per-key drawer. Refs #620 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
748 lines
38 KiB
Python
748 lines
38 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate ErsatzTV decision-record lifecycle invariants.
|
|
|
|
Replaces the line-level append-only guard (ersatztv#303 H9) with lifecycle checks that preserve its
|
|
spirit — rationale is never silently rewritten or deleted; every history touch is deliberate and
|
|
reviewable. A `Decisions-Edit: yes` commit trailer is kept ONLY for rationale-prose edits; routine
|
|
lifecycle metadata writes are marker-free. Fail-open on tooling trouble (missing refs, git errors,
|
|
parse issues), matching the old guard — with ONE deliberate exception: resolving the edit marker
|
|
itself fails CLOSED (`_edit_trailer_armed`), because a fail-open marker lookup is just #609 again.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import date
|
|
from pathlib import Path
|
|
|
|
import scripts.decisions_lib as dl # noqa: E402 (run with PYTHONPATH=. or as module)
|
|
|
|
SKIP_HEADINGS = dl.SKIP_HEADINGS # single source of truth
|
|
# `signals` is required alongside the lifecycle fields: the `**Signals:**` line (plus `key:`) is what
|
|
# MemPalace's keyword recall matches on when surfacing a decision, so a record without it ingests with
|
|
# weak recall metadata and produces confident false-negatives for the "MemPalace to find, file to
|
|
# confirm" workflow (ersatztv#545).
|
|
REQUIRED_META = ("key", "status", "since", "supersedes", "superseded_by", "signals")
|
|
# The rationale-edit marker is a git TRAILER, not a bare substring anywhere in the commit range.
|
|
# ersatztv#609: the original `[decisions-edit]` substring test armed on ANY commit message containing
|
|
# the literal string — including prose *about* the mechanism, and including a message explaining why
|
|
# no token was needed — which silently disabled all three body-diff comparisons while the job still
|
|
# reported green. Git recognises a trailer only in a message's final trailer block, so discussing the
|
|
# marker mid-message — which is what actually happened — can no longer arm anything. It is NOT a total
|
|
# immunity: a quoted example that IS the final paragraph of a NON-MERGE commit message parses as a
|
|
# real trailer and does arm (merge commits are excluded — see `_edit_trailer_armed`). See
|
|
# `ci.decisions-edit-trailer` for that residual, stated rather than papered over.
|
|
EDIT_TRAILER = "Decisions-Edit"
|
|
_EDIT_TRAILER_AFFIRMATIVE = frozenset({"yes", "true", "1"})
|
|
_LEGACY_EDIT_TOKEN = "[decisions-edit]" # noqa: S105 (a commit-message marker, not a credential)
|
|
|
|
|
|
# `date.fromisoformat` is NOT a YYYY-MM-DD validator: on Python >= 3.11 it also accepts ISO basic
|
|
# format ("20270101") and week dates ("2027-W01-1"), and which of those parse depends on the
|
|
# interpreter version — so the same corpus could validate differently on a dev machine and on the
|
|
# runner (pr-checks.yml pins only `python-version: '3.x'`). Gate on the extended calendar form first.
|
|
# The catalog's Review-due section additionally sorts on the raw STRING, which is only equivalent to
|
|
# sorting by date because this regex forces a fixed-width zero-padded form.
|
|
_STALE_AFTER_RE = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
# Per-record prose ceiling (#620). A module constant, not a bare argparse default, so the test that
|
|
# guards the calibration claim asserts against the SAME value the CLI uses and the two cannot drift.
|
|
RECORD_CEILING_DEFAULT = 60
|
|
|
|
|
|
def _parse_stale_after(value: str | None) -> date | None:
|
|
"""`stale-after` as a date, or None if absent, empty, or malformed.
|
|
|
|
Callers must NOT use a None return to mean "absent" — an empty or malformed value returns None
|
|
too. Distinguish on the raw field with `is not None` (absent stays None; present-but-empty is "").
|
|
"""
|
|
if not value or not _STALE_AFTER_RE.match(value.strip()):
|
|
return None
|
|
try:
|
|
return date.fromisoformat(value.strip())
|
|
except ValueError:
|
|
return None # e.g. 2027-02-30 — well-shaped but not a real date
|
|
|
|
|
|
def stale_records(records, today: date) -> list[tuple[str, str]]:
|
|
"""(heading, stale-after) for ACTIVE records that are past due — today >= stale-after.
|
|
|
|
OKF v0.2 semantics: an absolute date, no TTL. Deliberately NOT part of `validate()` — a record
|
|
going stale is the passage of time, not a defect in the commit under test, so it must never fail
|
|
a PR that didn't touch it. main() reports these as a non-blocking notice.
|
|
"""
|
|
out = []
|
|
for r in records:
|
|
if r.status != "active":
|
|
continue
|
|
d = _parse_stale_after(r.stale_after)
|
|
if d is not None and today >= d:
|
|
out.append((r.heading, r.stale_after))
|
|
return sorted(out)
|
|
|
|
|
|
def _key_of(ref: str | None) -> str | None:
|
|
if not ref or ref == "none":
|
|
return None
|
|
return ref.split("@", 1)[0].strip()
|
|
|
|
|
|
def validate(
|
|
records,
|
|
*,
|
|
archive_keys,
|
|
catalog_ok,
|
|
removed,
|
|
rewritten,
|
|
archive_records=None,
|
|
demoted=(),
|
|
wing_faults=(),
|
|
) -> list[str]:
|
|
errs: list[str] = []
|
|
archive_records = archive_records or []
|
|
# #621: structural "one keyed record per file". ERRORS, not warnings — a file under the record
|
|
# wings that is not a record is a mistake by definition. Reported first because a file that
|
|
# failed to parse is absent from `records`, so every downstream check below is silently
|
|
# evaluating an incomplete corpus.
|
|
errs += list(wing_faults)
|
|
decision_recs = [r for r in records if r.heading not in SKIP_HEADINGS and r.status != "legacy-unmigrated"]
|
|
|
|
active_by_key: dict[str, int] = {}
|
|
known_keys = set(archive_keys)
|
|
for r in decision_recs:
|
|
if r.status not in dl.STATUSES:
|
|
errs.append(f"{r.heading}: invalid status {r.status!r}")
|
|
if not r.key or not dl.KEY_RE.match(r.key):
|
|
errs.append(f"{r.heading}: bad key format {r.key!r}")
|
|
continue
|
|
known_keys.add(r.key)
|
|
for f in REQUIRED_META:
|
|
if getattr(r, f) in (None, ""):
|
|
errs.append(f"{r.heading}: missing required metadata {f}")
|
|
# `stale-after` is OPTIONAL, but a malformed one is a defect in the commit that wrote it —
|
|
# it would silently never fire. Absent is fine; unparseable is not.
|
|
if r.stale_after is not None and _parse_stale_after(r.stale_after) is None:
|
|
errs.append(f"{r.heading}: stale-after {r.stale_after!r} is not a YYYY-MM-DD date")
|
|
if r.status == "active":
|
|
active_by_key[r.key] = active_by_key.get(r.key, 0) + 1
|
|
if r.status in ("superseded", "retired"):
|
|
errs.append(
|
|
f"{r.heading}: status {r.status} but still in the active set — relocate to docs/decisions/archive/"
|
|
)
|
|
n_meta = dl.metadata_line_count(r)
|
|
if n_meta > 1:
|
|
errs.append(f"{r.heading}: {n_meta} metadata blocks found (expected 1) — duplicate metadata block")
|
|
|
|
# PATH <-> KEY (#610). In the split layout the filename is derived from the key, which is what
|
|
# makes one-active-record-per-key a filesystem property rather than a check. That only holds if
|
|
# the two cannot drift, so enforce the correspondence directly.
|
|
for r in decision_recs + list(archive_records):
|
|
if not r.key:
|
|
continue
|
|
src = Path(r.source)
|
|
if dl.RECORDS_DIR not in src.parents and dl.ARCHIVE_DIR not in src.parents:
|
|
continue # legacy multi-record file; the rule applies to the split layout only
|
|
area, _, topic = r.key.partition(".")
|
|
if src.parent.name != area or src.stem != topic:
|
|
errs.append(f"{r.heading}: key {r.key!r} does not match its path {src.parent.name}/{src.name}")
|
|
|
|
for r in archive_records:
|
|
if r.status == "active":
|
|
errs.append(f"{r.heading}: active record must not live under docs/decisions/archive/")
|
|
# An archived record's staleness is moot, but a malformed date there is still a typo worth
|
|
# catching — and checking both wings keeps the field's contract symmetric.
|
|
if r.stale_after is not None and _parse_stale_after(r.stale_after) is None:
|
|
errs.append(f"{r.heading}: stale-after {r.stale_after!r} is not a YYYY-MM-DD date")
|
|
|
|
for key, n in active_by_key.items():
|
|
if n > 1:
|
|
errs.append(f"key {key!r}: more than one active record ({n})")
|
|
|
|
# by_key map across BOTH wings, for reciprocity checks (existence-only checks still use
|
|
# known_keys, which additionally includes keys the caller only knows about via archive_keys).
|
|
by_key: dict[str, dl.Record] = {}
|
|
for r in decision_recs + list(archive_records):
|
|
if r.key:
|
|
by_key[r.key] = r
|
|
|
|
# reciprocal supersession (successor/predecessor may live in active OR archive). Runs over the
|
|
# union of active + archive records so a twice-reversed decision (an archived record whose
|
|
# superseded-by points to ANOTHER archived record) is checked from both sides too.
|
|
for r in decision_recs:
|
|
if r.status == "active" and r.superseded_by not in (None, "", "none"):
|
|
errs.append(f"{r.heading}: active record cannot already be superseded (superseded-by set)")
|
|
|
|
for r in decision_recs + list(archive_records):
|
|
sk = _key_of(r.superseded_by)
|
|
if sk:
|
|
if sk not in known_keys:
|
|
errs.append(f"{r.heading}: superseded-by points to unknown key {sk!r}")
|
|
else:
|
|
b = by_key.get(sk)
|
|
if b is not None and r.key and _key_of(b.supersedes) != r.key:
|
|
errs.append(f"{r.heading}: superseded-by {sk} but {sk} does not point back (supersedes)")
|
|
pk = _key_of(r.supersedes)
|
|
if pk:
|
|
if pk not in known_keys:
|
|
errs.append(f"{r.heading}: supersedes points to unknown key {pk!r}")
|
|
else:
|
|
a = by_key.get(pk)
|
|
if a is not None and r.key and _key_of(a.superseded_by) != r.key:
|
|
errs.append(f"{r.heading}: supersedes {pk} but {pk} does not point back (superseded-by)")
|
|
|
|
for h in removed:
|
|
errs.append(f"record removed from the active set without an archive copy: {h!r}")
|
|
for h in rewritten:
|
|
errs.append(f"rationale prose of {h!r} changed without a '{EDIT_TRAILER}: yes' commit trailer")
|
|
for h in demoted:
|
|
errs.append(f"{h}: migrated record demoted to legacy-unmigrated (metadata block removed)")
|
|
|
|
if not catalog_ok:
|
|
errs.append("docs/decisions/README.md active catalog is stale — run build_decisions_catalog.py")
|
|
return errs
|
|
|
|
|
|
# ---- git helpers (all fail-open: return neutral values on any error) ----
|
|
|
|
|
|
def _run(args: list[str]) -> str | None:
|
|
try:
|
|
r = subprocess.run(args, capture_output=True, text=True, timeout=30)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
return r.stdout if r.returncode == 0 else None
|
|
|
|
|
|
def _merge_base(base: str, head: str) -> str | None:
|
|
out = _run(["git", "merge-base", base, head])
|
|
return out.strip() if out else None
|
|
|
|
|
|
def _active_paths_at(ref: str) -> list[str]:
|
|
"""Active decision files at a ref: decisions.md + docs/decisions/*.md minus README/archive/migration-map."""
|
|
out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions.md", "docs/decisions/"])
|
|
if out is None:
|
|
return []
|
|
paths = []
|
|
for p in out.splitlines():
|
|
if not p.endswith(".md"):
|
|
continue
|
|
if p.startswith("docs/decisions/archive/") or Path(p).name in dl._NON_DECISION_FILES:
|
|
continue
|
|
paths.append(p)
|
|
return paths
|
|
|
|
|
|
def _records_at(ref: str, paths: list[str]):
|
|
"""{key: Record} across the given paths at a ref.
|
|
|
|
Keyed by `key`, NOT by heading (#610). Heading-keying made a pure RENAME look like a removal
|
|
plus an unrelated addition, so retitling a record failed CI as an "unlogged removal" — a trap
|
|
that has bitten before. `key` is the record's identity and is stable across a retitle, a move
|
|
between files, and the legacy->frontmatter format change, which is also what lets this compare
|
|
correctly straight through the migration commit.
|
|
|
|
A record with no key (legacy-unmigrated) falls back to its heading, prefixed so it cannot
|
|
collide with a real dotted key.
|
|
"""
|
|
by_key = {}
|
|
for p in paths:
|
|
blob = _run(["git", "show", f"{ref}:{p}"])
|
|
if blob is None:
|
|
continue
|
|
for rec in dl.parse_text(blob, Path(p)):
|
|
if rec.heading in SKIP_HEADINGS:
|
|
continue
|
|
by_key[rec.key or f"heading:{rec.heading}"] = rec
|
|
return by_key
|
|
|
|
|
|
def _archive_records_at(ref: str):
|
|
out = _run(["git", "ls-tree", "-r", "--name-only", ref, "docs/decisions/archive/"])
|
|
paths = [p for p in (out or "").splitlines() if p.endswith(".md")]
|
|
by_key = {}
|
|
for p in paths:
|
|
blob = _run(["git", "show", f"{ref}:{p}"])
|
|
if blob is None:
|
|
continue
|
|
for rec in dl.parse_text(blob, Path(p)):
|
|
# Filter SKIP_HEADINGS exactly as _records_at does. Without it the generated
|
|
# "Records formerly in this file" section in each stripped archive file parses as a
|
|
# pseudo-record; they then collide by heading across files, and any later PR that
|
|
# touches those generated bullets trips a false "rationale prose changed without a
|
|
# trailer" failure.
|
|
if rec.heading not in SKIP_HEADINGS:
|
|
by_key[rec.key or f"heading:{rec.heading}"] = rec
|
|
return by_key
|
|
|
|
|
|
def _rationale(rec) -> str:
|
|
"""Record body with the contiguous top metadata block stripped, whitespace-normalized.
|
|
|
|
Only the block from the first non-blank line (when it is the `key:` meta line) up to the first
|
|
following blank line is metadata. A **Rule:**/**Signals:**/**Mechanics:**/`key:` line appearing
|
|
later in rationale prose is prose, not metadata (mirrors decisions_lib's contiguous-block rule).
|
|
"""
|
|
lines = rec.body.splitlines()
|
|
start = 0
|
|
while start < len(lines) and not lines[start].strip():
|
|
start += 1
|
|
if start < len(lines) and lines[start].strip().startswith("`key:"):
|
|
end = start + 1
|
|
while end < len(lines) and lines[end].strip():
|
|
end += 1
|
|
rest = lines[:start] + lines[end:]
|
|
else:
|
|
rest = lines
|
|
return "\n".join(s for s in (ln.strip() for ln in rest) if s)
|
|
|
|
|
|
def _edit_trailer_armed(mb: str, head: str) -> bool:
|
|
"""True when some NON-MERGE commit in `mb..head` carries an affirmative `Decisions-Edit:` trailer.
|
|
|
|
A non-affirmative value (`no`, `not-needed`, …) deliberately does NOT arm the exemption: the
|
|
marker must be an explicit yes, so a trailer written to *record that no edit was intended* can
|
|
never disable the guard — the ersatztv#609 failure mode, one abstraction level up.
|
|
|
|
This is the one git helper here that deliberately does NOT fail open (see the module docstring):
|
|
on a git error it returns False, leaving the guard ON. A lost marker costs a contributor one
|
|
clear error message and a re-push; a lost guard costs a silent history rewrite in the PR that
|
|
most needed policing. Failing open here would reintroduce #609 through a different door.
|
|
|
|
`--no-merges` skips merge commits: on a `pull_request` event `actions/checkout` lands on a
|
|
synthetic merge commit whose body is composed by the forge from the PR description, not by the
|
|
author, so a trailer parsed out of it was never a deliberate marker.
|
|
|
|
This does cost a real (if narrow) false negative, and the trade is deliberate. Merging main into
|
|
a PR branch is discouraged by convention but NOT mechanically blocked — `prepush-rebase-check.sh`
|
|
only refuses a branch that is BEHIND origin/main, and a merge makes origin/main an ancestor, so
|
|
the push is allowed. An author who resolves a rationale conflict in such a merge and puts the
|
|
ONLY marker on that merge commit gets their legitimate rewrite rejected. That failure is LOUD and
|
|
costs one extra commit carrying the trailer; honoring forge-composed merge bodies would instead
|
|
disable the guard SILENTLY, which is the #609 failure itself. Prefer the loud one.
|
|
|
|
`unfold` joins RFC-822 continuation lines before the value is compared. Without it, a folded
|
|
`Decisions-Edit: no\\n yes` yields two lines and the continuation ` yes` arms the exemption on
|
|
its own — the value the author actually wrote is `no`.
|
|
"""
|
|
fmt = f"--format=%(trailers:key={EDIT_TRAILER},valueonly,unfold)"
|
|
out = _run(["git", "log", "--no-merges", fmt, f"{mb}..{head}"])
|
|
if out is not None and any(ln.strip().lower() in _EDIT_TRAILER_AFFIRMATIVE for ln in out.splitlines()):
|
|
return True
|
|
# Retired-form nudge: without it, a contributor using the pre-#609 habit gets a bare "prose
|
|
# changed" failure and no hint that the marker's FORM (not their intent) is what changed.
|
|
if _LEGACY_EDIT_TOKEN in (_run(["git", "log", "--no-merges", "--format=%B", f"{mb}..{head}"]) or "").lower():
|
|
print(
|
|
f"::warning::decisions-validate: the commit range carries the retired "
|
|
f"{_LEGACY_EDIT_TOKEN} marker but no '{EDIT_TRAILER}: yes' trailer — since ersatztv#609 "
|
|
"the rationale-edit marker is a git trailer, and the substring no longer exempts anything",
|
|
file=sys.stderr,
|
|
)
|
|
return False
|
|
|
|
|
|
def _diff_findings(base: str, head: str) -> tuple[list[str], list[str], list[str]]:
|
|
"""(removed, rewritten, demoted) between merge-base(base,head) and head. Fail-open → ([], [], []).
|
|
|
|
Records are MATCHED by `key` and REPORTED by heading. Matching by key means a retitle is a
|
|
retitle rather than a removal-plus-addition (#610) — heading-keying made renaming a heading fail
|
|
CI as an "unlogged removal", a trap that has bitten before — and it is what lets the comparison
|
|
work straight through the legacy→frontmatter migration, since `key` survives both the format
|
|
change and the move between files. Headings are still what appears in the error, because a key
|
|
alone is not enough for a human to find the record.
|
|
"""
|
|
mb = _merge_base(base, head)
|
|
if not mb:
|
|
print(
|
|
f"::warning::decisions-validate: could not resolve merge-base({base},{head}); no-vanish/body-diff skipped",
|
|
file=sys.stderr,
|
|
)
|
|
return [], [], []
|
|
token = _edit_trailer_armed(mb, head)
|
|
|
|
base_active = _records_at(mb, _active_paths_at(mb))
|
|
head_active = _records_at(head, _active_paths_at(head))
|
|
head_archive = _archive_records_at(head)
|
|
base_archive = _archive_records_at(mb)
|
|
|
|
def name(rec) -> str:
|
|
return rec.heading
|
|
|
|
gone = set(base_active) - set(head_active)
|
|
removed = [name(base_active[k]) for k in sorted(gone) if k not in head_archive]
|
|
|
|
# an archive record present at base must not vanish entirely (neither wing has it at head)
|
|
archive_gone = set(base_archive) - set(head_archive)
|
|
removed += [name(base_archive[k]) for k in sorted(archive_gone) if k not in head_active]
|
|
|
|
rewritten: list[str] = []
|
|
if not token:
|
|
# surviving records whose rationale prose changed
|
|
for k in set(base_active) & set(head_active):
|
|
if _rationale(base_active[k]) != _rationale(head_active[k]):
|
|
rewritten.append(name(head_active[k]))
|
|
# archived records must body-match their base active copy (no laundering rewrites via archive)
|
|
for k in gone & set(head_archive):
|
|
if _rationale(base_active[k]) != _rationale(head_archive[k]):
|
|
rewritten.append(name(head_archive[k]))
|
|
# archive records that survive in the archive wing: rationale must not be rewritten either
|
|
for k in set(base_archive) & set(head_archive):
|
|
if _rationale(base_archive[k]) != _rationale(head_archive[k]):
|
|
rewritten.append(name(head_archive[k]))
|
|
|
|
# Demotion: a record that HAD a key at base and lost it at head. Under key-matching that shows
|
|
# up as its key vanishing while a `heading:`-fallback entry appears for the same heading, so it
|
|
# is detected on the heading axis rather than the key axis.
|
|
head_by_heading = {r.heading: r for r in head_active.values()}
|
|
demoted: list[str] = []
|
|
for k in gone:
|
|
rec = base_active[k]
|
|
if not rec.key:
|
|
continue
|
|
successor = head_by_heading.get(rec.heading)
|
|
if successor is not None and successor.key != rec.key:
|
|
demoted.append(rec.heading)
|
|
|
|
# a demoted record is not also "removed" — report the specific fault, not both
|
|
removed = [h for h in removed if h not in set(demoted)]
|
|
|
|
return sorted(set(removed)), sorted(set(rewritten)), sorted(set(demoted))
|
|
|
|
|
|
def record_wing_files(records_dir: Path | None = None, archive_dir: Path | None = None) -> list[Path]:
|
|
"""Every file that MUST be exactly one keyed record.
|
|
|
|
Scope is EVERY `*.md` under `records/**` and `archive/**` — including the files directly in
|
|
`archive/`, which are scanned but may qualify for the stripped-index exemption applied in
|
|
`record_wing_faults` (see there). Nothing is excluded by BASENAME.
|
|
|
|
Excluding by BASENAME was a real hole: `_NON_DECISION_FILES` is `{README.md, migration-map.md,
|
|
retrieval-eval.md}` — three TOPIC-dir names — and applying it to the wings meant a genuine
|
|
record at `records/docs/retrieval-eval.md` was silently skipped. That path is not hypothetical:
|
|
the path<->key rule forces key `docs.retrieval-eval` to live at exactly that filename, and
|
|
`docs/decisions/retrieval-eval.md` is a real unmigrated file, i.e. a plausible migration target.
|
|
Worse, `dl.active_files()` applies that filter only to the TOPIC_DIR glob, not to
|
|
`RECORDS_DIR.rglob` — so such a file IS a corpus source while being exempt from the guard.
|
|
|
|
So the exemption is by exact RELATIVE PATH, never by basename. The only entry is
|
|
`archive/README.md`, a hand-written directory README that really does exist — an earlier
|
|
version of this function excluded any wing-root `README.md` "since no such file exists today",
|
|
which was simply false and would additionally have exempted a future `records/README.md`, i.e.
|
|
reintroduced the very hole one directory over.
|
|
"""
|
|
records_dir = dl.RECORDS_DIR if records_dir is None else records_dir
|
|
archive_dir = dl.ARCHIVE_DIR if archive_dir is None else archive_dir
|
|
exempt_paths = {archive_dir / "README.md"}
|
|
files: list[Path] = []
|
|
for wing in (records_dir, archive_dir):
|
|
if wing.exists():
|
|
files += sorted(p for p in wing.rglob("*.md") if p not in exempt_paths)
|
|
return files
|
|
|
|
|
|
def _is_stripped_index(path: Path, archive_dir: Path, recs: list) -> bool:
|
|
"""True for a #610 stripped legacy topic file sitting DIRECTLY in `archive/`.
|
|
|
|
Those five files (`api.md`, `scan.md`, `spa.md`, `startup.md`, `release-ci-governance.md`) are
|
|
generated "Records formerly in this file" indexes — keyless by construction, and what keeps
|
|
older date-based pointers resolvable. They are indexes, not records.
|
|
|
|
The exemption is by IDENTITY, not by location. Exempting everything directly in `archive/`
|
|
would leave that one directory unguarded: a new unparseable `archive/foo.md` would vanish
|
|
silently — the same defect this guard exists to close, in the last place it isn't watched. So a
|
|
file there is exempt only if it actually LOOKS like a stripped index: exactly one keyless
|
|
record whose heading is one of the known generated ones (`dl.SKIP_HEADINGS`).
|
|
"""
|
|
return (
|
|
path.parent == archive_dir
|
|
and len(recs) == 1
|
|
and not recs[0].key
|
|
and recs[0].heading in dl.SKIP_HEADINGS
|
|
)
|
|
|
|
|
|
def record_wing_faults(records_dir: Path | None = None, archive_dir: Path | None = None) -> list[str]:
|
|
"""Structural check (#621): every file in the record wings parses to exactly ONE keyed record.
|
|
|
|
Without this, a file the frontmatter reader cannot parse yields `[]` and simply VANISHES — no
|
|
error, no warning, validator OK, catalog "up to date", record absent from the corpus. That is
|
|
the corpus's own failure mode turned on itself: the one thing worse than a missing record is a
|
|
missing record that reports success (cf. the #609 marker that printed OK while doing nothing and
|
|
the #603 `stale-after` that silently never fired).
|
|
|
|
An EXISTING record disappearing was already loud — the no-vanish diff check catches it. This
|
|
closes the case the diff check structurally cannot see: a NEWLY ADDED record, where the author's
|
|
own PR looks clean because there is no prior state to diff against.
|
|
|
|
Making it path-driven rather than record-driven is the point: it converts a whole CLASS of
|
|
reader limitations — parse-to-zero, parse-to-many, keyless — from silent to loud in one move,
|
|
instead of enumerating the constructs we happen to know about today.
|
|
|
|
It does NOT catch parse-to-WRONG: frontmatter that yields one keyed record with corrupted
|
|
values. `rule: >-` followed by an UNINDENTED continuation containing a colon parses to a valid
|
|
record whose `rule` is literally `>-`, plus a junk key from the continuation line. The junk-key
|
|
check below is what catches that case; a mis-parse producing only known fields would still slip
|
|
through, so this is a strong guard, not a total one.
|
|
"""
|
|
records_dir = dl.RECORDS_DIR if records_dir is None else records_dir
|
|
archive_dir = dl.ARCHIVE_DIR if archive_dir is None else archive_dir
|
|
faults: list[str] = []
|
|
files = record_wing_files(records_dir, archive_dir)
|
|
|
|
# A wing that is absent or empty must be LOUD, not silently clean. Otherwise a partial checkout,
|
|
# a renamed directory, or a bad monkeypatch turns the whole guard into a no-op that reports
|
|
# success — which is the defect this function exists to close, applied to itself.
|
|
# DELIBERATELY asymmetric: only the ACTIVE wing must be non-empty. A corpus with zero active
|
|
# records is definitionally broken (and means the scan is measuring nothing, so a clean result
|
|
# would be vacuous). An empty ARCHIVE is a perfectly normal state — it just means nothing has
|
|
# been superseded or retired yet, which is true of any young repo and of a fresh clone before
|
|
# the first supersession. Faulting on it would fail a correct corpus. Review asked for symmetry
|
|
# here; the semantics genuinely differ, so this stays asymmetric with the reason stated.
|
|
if not records_dir.exists() or not any(records_dir.rglob("*.md")):
|
|
faults.append(
|
|
f"{records_dir}: the active record wing is missing or contains no *.md files — refusing "
|
|
f"to report a clean corpus from an empty scan."
|
|
)
|
|
|
|
for p in files:
|
|
try:
|
|
recs = dl.parse_file(p)
|
|
except Exception as exc: # unreadable/undecodable file is a fault, not a crash
|
|
faults.append(f"{p}: could not be read as a decision record ({exc})")
|
|
continue
|
|
if _is_stripped_index(p, archive_dir, recs):
|
|
continue
|
|
if len(recs) != 1:
|
|
faults.append(
|
|
f"{p}: parsed to {len(recs)} records, expected exactly 1 — a file under the record "
|
|
f"wings must be one record. Common cause: YAML the dependency-free frontmatter "
|
|
f"reader does not accept (a block scalar such as `rule: >-` or `rule: |`, any "
|
|
f"indented/nested structure, or unterminated `---` frontmatter). Put the value on "
|
|
f"ONE line."
|
|
)
|
|
continue
|
|
if not recs[0].key:
|
|
faults.append(f"{p}: parsed to a record with no `key` — every record in the wings must be keyed.")
|
|
continue
|
|
# Junk keys: the reader accepted a line it should not have. The realistic source is a block
|
|
# scalar whose continuation was read as its own `k: v` pair, which silently truncates the
|
|
# real value (`rule` becomes `>-`). PyYAML REJECTS that input, so the hand reader is more
|
|
# permissive than the writer and nothing else in the pipeline notices.
|
|
unknown = _unknown_frontmatter_keys(p)
|
|
if unknown:
|
|
faults.append(
|
|
f"{p}: frontmatter has unrecognized key(s) {sorted(unknown)} — a value was probably "
|
|
f"split across lines (a block scalar continuation read as its own key), which "
|
|
f"silently truncates the real value. Put each value on ONE line."
|
|
)
|
|
return faults
|
|
|
|
|
|
def _unknown_frontmatter_keys(path: Path) -> set[str]:
|
|
"""Frontmatter keys outside the known schema. Empty on any read/parse failure (reported elsewhere)."""
|
|
try:
|
|
text = path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return set()
|
|
if not dl.has_frontmatter(text):
|
|
return set()
|
|
lines = text.splitlines()
|
|
end = next((i for i, ln in enumerate(lines[1:], start=1) if ln.rstrip() == "---"), None)
|
|
if end is None:
|
|
return set()
|
|
meta = dl._read_frontmatter("\n".join(lines[1:end]))
|
|
if not meta:
|
|
return set()
|
|
known = set(dl._FM_TO_FIELD) | {"title"}
|
|
return {k for k in meta if k not in known}
|
|
|
|
|
|
def _archive_keys() -> set[str]:
|
|
keys: set[str] = set()
|
|
if dl.ARCHIVE_DIR.exists():
|
|
for f in dl.ARCHIVE_DIR.rglob("*.md"): # rglob: archive is nested by area after #610
|
|
for r in dl.parse_file(f):
|
|
if r.key:
|
|
keys.add(r.key)
|
|
return keys
|
|
|
|
|
|
def _budget_total() -> int:
|
|
"""Lines of PROSE in the active corpus — YAML frontmatter excluded.
|
|
|
|
Since #620 this is a TREND figure with no threshold attached; the name is historical. It counts
|
|
prose rather than metadata because the question it answers is how much narrative a reader/agent
|
|
must get through. Under the #610 split each record carries ~11 frontmatter lines plus two
|
|
fences (1789 lines across 166 records), which are the structured restatement of what used to be
|
|
one dense backtick line — counting them would inflate the metric without any new knowledge
|
|
being added.
|
|
|
|
NOTE this is a change of METRIC, not a consolidation: it re-measures the same corpus, it does
|
|
not shrink it. At the time of the split, whole-file counting put the corpus a few hundred lines
|
|
over budget; prose-only puts the same content comfortably under. The consolidation work is still
|
|
worth doing — it is simply no longer being signalled by a warning that was partly measuring
|
|
punctuation. Pre-migration this function is inert: no legacy file has frontmatter.
|
|
|
|
Since #620 this is reported as an informational TREND only — it no longer carries a threshold.
|
|
The GENERATED catalog (`docs/decisions/README.md`) is also no longer counted: it used to be
|
|
added on top, which quietly made this partly a record-COUNT metric wearing a line-count
|
|
costume, since the catalog gains exactly one row per active record and no amount of
|
|
consolidating prose can shrink it (189 of the 5658 lines it last reported were that file).
|
|
Counting un-prunable generated output in a number whose stated remedy was "schedule a
|
|
consolidation" pointed the reader at work that cannot be done.
|
|
"""
|
|
total = 0
|
|
for f in dl.active_files():
|
|
if not f.exists():
|
|
continue
|
|
text = f.read_text(encoding="utf-8")
|
|
lines = text.splitlines()
|
|
if dl.has_frontmatter(text):
|
|
end = next((i for i, ln in enumerate(lines[1:], start=1) if ln.rstrip() == "---"), None)
|
|
if end is not None:
|
|
lines = lines[end + 1 :]
|
|
total += len(lines)
|
|
return total
|
|
|
|
|
|
def record_prose_lines(rec) -> int:
|
|
"""Prose lines in one record's body (frontmatter already stripped by the parser)."""
|
|
return len((rec.body or "").splitlines())
|
|
|
|
|
|
def oversized_records(records, ceiling: int) -> list[tuple[str, int]]:
|
|
"""Active records whose prose exceeds `ceiling`, longest first (#620).
|
|
|
|
This REPLACES the aggregate line budget as the corpus's actionable size signal. The aggregate
|
|
measured a monotonically growing quantity: a healthy project's decision corpus only gets
|
|
bigger, so any fixed total is a ratchet that must periodically be raised — which is exactly the
|
|
"permanently red, therefore no signal at all" state #542 re-baselined away from and #620 was
|
|
filed about. Re-baselining it again would only restart that treadmill: the growth is not a smooth
|
|
rate to set a threshold against — the corpus FELL from 5089 to 5042 across four days, then gained
|
|
427 in a single evening as two large records landed.
|
|
|
|
A per-record ceiling is NOT monotonic. It measures the shape of individual records rather than
|
|
the size of the corpus, so it can go red and green again, and it names a file the reader can
|
|
act on instead of asserting that "the corpus" is too big.
|
|
|
|
The ceiling sits at a natural gap in the real distribution rather than a round number: at #620
|
|
the records run 0..59 prose lines (median 26, p90 52) and then jump straight to 83, with
|
|
nothing in between. 60 separates the bulk from the tail without splitting a cluster.
|
|
|
|
IMPORTANT — a prompt for judgement, not a target. Length is a PROXY for "grown past what a
|
|
reader can absorb", and the proxy is demonstrably wrong sometimes: the largest record in the
|
|
corpus (`scan.libraryfolder-unique-identity`, 230 lines) is thirteen distinct hard-won traps,
|
|
and shortening it would delete the only copy of most of them. Appearing in this list is an
|
|
invitation to check for REDUNDANCY, never an instruction to cut.
|
|
"""
|
|
out = [(r.key, record_prose_lines(r)) for r in records if r.key]
|
|
return sorted([kv for kv in out if kv[1] > ceiling], key=lambda kv: -kv[1])
|
|
|
|
|
|
def _catalog_ok() -> bool:
|
|
try:
|
|
import scripts.build_decisions_catalog as bc # pyright: ignore[reportMissingImports]
|
|
except Exception:
|
|
return True # fail-open; the CI job also runs an independent --check step
|
|
want = bc.render_catalog(dl.all_active_records())
|
|
cat = dl.TOPIC_DIR / "README.md"
|
|
have = cat.read_text(encoding="utf-8") if cat.exists() else ""
|
|
return want.strip() == have.strip()
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("--base")
|
|
ap.add_argument("--head")
|
|
# Per-record prose ceiling — the corpus's size signal since #620, replacing the aggregate
|
|
# budget. See `oversized_records` for why a total was the wrong instrument (it ratchets on a
|
|
# monotonically growing quantity) and why 60 (a natural gap in the distribution, not a round
|
|
# number). `--budget` is still ACCEPTED so an existing caller keeps working, but it no longer
|
|
# does anything — and it SAYS SO when passed (below) rather than no-opping quietly. A flag that
|
|
# takes a threshold and silently enforces nothing is the same "reports success while doing
|
|
# nothing" defect as #603's `stale-after` and #609's marker; retiring one signal must not
|
|
# introduce another.
|
|
ap.add_argument("--record-ceiling", type=int, default=RECORD_CEILING_DEFAULT)
|
|
ap.add_argument("--budget", type=int, default=None, help=argparse.SUPPRESS)
|
|
args = ap.parse_args(argv)
|
|
|
|
if args.budget is not None:
|
|
print(
|
|
f"::warning::decisions-validate: --budget {args.budget} is RETIRED and was IGNORED "
|
|
f"(#620) — the aggregate is now an unthresholded trend. Use --record-ceiling for the "
|
|
f"enforceable per-record signal.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
records = dl.all_active_records()
|
|
archive_records = []
|
|
if dl.ARCHIVE_DIR.exists():
|
|
for f in dl.ARCHIVE_DIR.rglob("*.md"): # rglob: archive is nested by area after #610
|
|
archive_records += dl.parse_file(f)
|
|
removed, rewritten, demoted = _diff_findings(args.base, args.head) if args.base and args.head else ([], [], [])
|
|
oversized = oversized_records(records, args.record_ceiling)
|
|
errs = validate(
|
|
records,
|
|
archive_keys=_archive_keys(),
|
|
catalog_ok=_catalog_ok(),
|
|
removed=removed,
|
|
rewritten=rewritten,
|
|
archive_records=archive_records,
|
|
demoted=demoted,
|
|
wing_faults=record_wing_faults(),
|
|
)
|
|
|
|
# Aggregate: an unthresholded TREND, not a gate (#620). Printed every run so the number stays
|
|
# visible, with no pass/fail attached — a total over a monotonically growing corpus can only
|
|
# ratchet, and a permanently-tripped warning is indistinguishable from no warning at all.
|
|
keyed = [r for r in records if r.key]
|
|
record_prose = sum(record_prose_lines(r) for r in keyed)
|
|
total_prose = _budget_total()
|
|
print(
|
|
f"::notice::decisions-validate: {record_prose} prose lines across {len(keyed)} records "
|
|
f"(mean {record_prose // max(len(keyed), 1)}), plus {total_prose - record_prose} lines of "
|
|
f"non-record scaffolding = {total_prose} total. Trend only — no threshold; the per-record "
|
|
f"ceiling below is the actionable signal.",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
# Per-record ceiling: NON-BLOCKING by design (#520 — a size condition must never turn an
|
|
# unrelated PR red). Names the files, so the reader can act instead of being told the corpus is
|
|
# "too big". Being listed is an invitation to check for redundancy, NOT an instruction to cut:
|
|
# the longest record in the corpus is 13 distinct traps and is a legitimate decline (#620).
|
|
if oversized:
|
|
listed = "; ".join(f"{k} ({n} lines)" for k, n in oversized)
|
|
print(
|
|
f"::warning::decisions-validate: {len(oversized)} record(s) exceed the "
|
|
f"{args.record_ceiling}-line prose ceiling — check each for redundancy against its "
|
|
f"siblings (a long record that is all distinct findings is fine, say so and move on): "
|
|
f"{listed}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
stale = stale_records(records, date.today())
|
|
if stale:
|
|
listed = "; ".join(f"{h} (stale-after {d})" for h, d in stale)
|
|
print(
|
|
f"::notice::decisions-validate: {len(stale)} active record(s) past their stale-after date "
|
|
f"— re-confirm or extend: {listed}",
|
|
file=sys.stderr,
|
|
)
|
|
|
|
unmigrated = [r for r in records if r.status == "legacy-unmigrated" and r.heading not in SKIP_HEADINGS]
|
|
if unmigrated:
|
|
print(f"::notice::{len(unmigrated)} legacy-unmigrated decision record(s) remain (must trend to 0).")
|
|
if errs:
|
|
for e in errs:
|
|
print(f"decisions-validate: {e}", file=sys.stderr)
|
|
return 1
|
|
print("decisions-validate: OK")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|