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
71 changes: 68 additions & 3 deletions hydra-gates/scripts/lib/check_contract_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,11 +191,76 @@ def parse_routes(routes_path: Path) -> dict[str, str]:
# ---------------------------------------------------------------------------


def _decl_preamble(lines: list[str], decl_idx: int) -> list[str]:
"""The lines PHP itself binds to the declaration at ``decl_idx``.

Walks upward from the declaration and accepts only what belongs to that
declaration's own preamble — its attribute list, its ONE docblock, `//`
notes and blank lines — stopping at the first line that belongs to
something else (a closing brace, a statement, another declaration).

WHY THIS IS NOT A LINE WINDOW (.github#363)
-------------------------------------------
This used to be ``lines[decl_idx - 20 : decl_idx + 1]``. A distance in
LINES is not the relationship being tested, and it was wrong in both
directions at once — confirmed live in ONE file, ONE run, 2026-08-12:

listThings #[PublicPage] on its own line above it correct
adminOnlyPurge NO auth attribute of its own FLAGGED —
the window reached back over the previous method's
closing brace and read ITS #[PublicPage]
farAttribute #[PublicPage] 21 lines up, separated only
by its own (long) docblock MISSED —
its own attribute fell outside the window

So the gate reported an administrator-only endpoint as a newly-exposed
public one, and stayed SILENT about a genuinely public, genuinely
untested one. The silent half is the dangerous one: detecting a
newly-exposed endpoint is the entire purpose of this gate, and
attributes-before-a-long-docblock is simply what a house style with long
descriptions produces.

Structure decides, not distance. ``_docblock_block`` below already walked
upward this way for the @contract tag; the auth question now asks it the
same way.
"""
out: list[str] = []
i = decl_idx - 1
seen_doc = False
while i >= 0:
raw = lines[i]
s = raw.strip()
# Blank lines, PHP attributes (`#[...]`, incl. the `]` / `)]` tail of a
# multi-line one) and `//` notes are all part of the preamble.
if s == "" or s.startswith("#[") or s.startswith("]") or s.startswith(")]") \
or s.startswith("//"):
out.append(raw)
i -= 1
continue
# The declaration's own docblock — exactly one, and only when it ENDS
# on this line. Consume it whole, then keep walking: an attribute
# written ABOVE the docblock still belongs to this declaration.
if not seen_doc and s.endswith("*/"):
j = i
while j >= 0 and "/*" not in lines[j]:
j -= 1
if j < 0:
break
out.extend(lines[j : i + 1])
i = j - 1
seen_doc = True
continue
break
return out


def _method_is_public_endpoint(lines: list[str], decl_idx: int) -> bool:
"""True if the method at ``decl_idx`` carries a PublicPage / NoAdminRequired
attribute or docblock tag in the ~20 lines above its declaration."""
start = max(0, decl_idx - 20)
head = "\n".join(lines[start : decl_idx + 1])
attribute or docblock tag IN ITS OWN declaration preamble.

Never in a neighbour's — see ``_decl_preamble``.
"""
head = "\n".join(_decl_preamble(lines, decl_idx) + [lines[decl_idx]])
return bool(_PUBLIC_AUTH_RE.search(head))


Expand Down
116 changes: 116 additions & 0 deletions hydra-gates/scripts/lib/check_relation_dialect.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@
import re
import subprocess
import sys
import tempfile


# --------------------------------------------------------------------------
Expand Down Expand Up @@ -270,6 +271,117 @@ def _is_tracked_at(file_path, base_ref):
return False


# --------------------------------------------------------------------------
# INHERITED vs INTRODUCED
#
# Checks (a) and (c)-(f) are FILE-scoped by design: the module docstring says
# "a banned dialect or a dangling $ref anywhere in a file the PR touched is a
# structural defect", and that stays true — a register you edited is a
# register you own. Only check (b) is narrowed to the property level.
#
# The COST of that design was invisible in the output. Measured 2026-08-12:
# a base commit carrying an `x-openregister-relations` block, and a head
# commit whose ENTIRE diff is one `"title"` string, produced
#
# [gate-54] relation-dialect: FAIL — 1 non-canonical relation dialect finding(s)
#
# with nothing anywhere saying the finding predates the change. The verdict
# was right and unreadable: a reader cannot act on a finding without knowing
# whether they wrote it, and "byte-identical base and head findings" is what
# a fleet sweep sees over and over with no way to rank them.
#
# The verdict is deliberately UNCHANGED — an inherited structural defect in a
# file you touched still blocks, and softening it here would be making the
# gate green by weakening it. What changes is that every finding now says
# which it is, so a reader can triage and a sweep can count the two apart.
# --------------------------------------------------------------------------
def _base_revision_text(path, base_ref):
"""This file's content at *base_ref*, or None when it cannot be read.

None covers BOTH "the file is new in this change" and "git could not be
asked" — the caller must not print either as a fact, so it distinguishes
them with ``_is_tracked_at`` before labelling anything.
"""
try:
proc = subprocess.run(
["git", "show", f"{base_ref}:{path}"],
capture_output=True, text=True, check=False,
)
except (OSError, ValueError):
return None
if proc.returncode != 0:
return None
return proc.stdout


def _findings_at_base(path, keys, base_ref):
"""The same checks, run over this file's content AT *base_ref*.

Returns a set of finding messages (rewritten to name the real path), or
None when the base revision could not be produced — in which case NOTHING
is labelled, because an unknown must never be printed as a verdict.

The base revision is written to a temp file and pushed back through
``check_file`` with ``base_ref=None``, so the ONLY variable between the two
runs is the file's own content: the register-set key set is held at the
head value, and check (b)'s property-level diff scope — which is already
diff-scoped and therefore introduced by construction — is switched off on
the base side rather than being asked a question about itself.
"""
text = _base_revision_text(path, base_ref)
if text is None:
return None
tmp = None
try:
with tempfile.NamedTemporaryFile(
"w", suffix=".json", delete=False, encoding="utf-8"
) as fh:
fh.write(text)
tmp = fh.name
sub = []
check_file(tmp, keys, sub, None)
return {msg.replace(tmp, path) for _p, msg in sub}
except OSError:
return None
finally:
if tmp:
try:
os.unlink(tmp)
except OSError:
pass


def _label_inheritance(path, findings, start, keys, base_ref):
"""Suffix each finding this file produced with INHERITED or INTRODUCED.

A SUFFIX, never a prefix: the runner counts failures with
``grep -cv '^WARN:'`` and warnings with ``grep -c '^WARN:'``, so anything
written in front of a message would silently re-bucket it.
"""
if not base_ref or start >= len(findings):
return
existed_at_base = _is_tracked_at(path, base_ref)
if not existed_at_base:
for i in range(start, len(findings)):
p, msg = findings[i]
findings[i] = (p, msg + " [INTRODUCED — this file did not exist at "
f"'{base_ref}']")
return
base_msgs = _findings_at_base(path, keys, base_ref)
if base_msgs is None:
return # unknown: label nothing rather than guess
for i in range(start, len(findings)):
p, msg = findings[i]
if msg in base_msgs:
findings[i] = (p, msg + f" [INHERITED — identical finding already "
f"present at '{base_ref}'; this change did "
f"not introduce it, but it is in a file this "
f"change touched]")
else:
findings[i] = (p, msg + f" [INTRODUCED — not present at "
f"'{base_ref}']")


# --------------------------------------------------------------------------
# Register-set discovery — the global schema-key set is needed so a string
# $ref in a changed file resolves against EVERY register (base + fragments),
Expand Down Expand Up @@ -543,6 +655,7 @@ def _collect_properties(props, prefix, depth, seen, out, parents=None):
# Per-file checks.
# --------------------------------------------------------------------------
def check_file(path, keys, findings, base_ref):
_first = len(findings)
try:
with open(path, encoding="utf-8") as fh:
text = fh.read()
Expand Down Expand Up @@ -710,6 +823,9 @@ def check_file(path, keys, findings, base_ref):
# (a) banned dialect + (c) misplaced/inert x-relation-filter — raw walk.
_raw_walk(doc, path, property_ids, findings, missing_ref_ids)

# Every finding this file produced now says whether this change wrote it.
_label_inheritance(path, findings, _first, keys, base_ref)


def _raw_walk(node, path, property_ids, findings, missing_ref_ids=frozenset(), loc=""):
"""Walk every node of the document for checks (a) and (c).
Expand Down
7 changes: 6 additions & 1 deletion hydra-gates/scripts/lib/gate_fixture_support.sh
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ gf_run_wrapper() {
}

# gf_verdict <output> <gate-n>
gf_verdict() { printf '%s' "$1" | grep -E "^\[gate-$2\] " | head -1; }
# A gate may print advisory lines that carry its own `[gate-N] ` prefix before
# it prints its verdict — gate-61's provenance NOTE is the first. `head -1` used
# to return whichever came first, so a NOTE silently DISPLACED the verdict and
# every assertion here read "no FAIL" while the gate was failing correctly. Skip
# the advisory forms; a verdict is `[gate-N] <name>: <VERDICT>`.
gf_verdict() { printf '%s' "$1" | grep -E "^\[gate-$2\] " | grep -vE "^\[gate-[0-9]+\] (NOTE|WARN|INFO):" | head -1; }
Loading
Loading