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
135 changes: 125 additions & 10 deletions hydra-gates/scripts/lib/check_contract_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,22 @@

GATE_NUM = 25

# ---------------------------------------------------------------------------
# AN EXIT CODE IS A STATUS. THE COUNT GOES ON STDOUT.
# ---------------------------------------------------------------------------
# Same convention gate-19 settled on after returning its finding count as an
# exit status (.github#209): a byte cannot carry a count, and a count cannot
# carry a status. It carries a status; the number is printed.
#
# EMPTY_SCOPE exists because PASS and "I inspected nothing" used to be the same
# 0, which is why --require-full-coverage — whose whole job is to notice gates
# that did not run — could not see this one. (.github#242)
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_ERROR = 2
EXIT_EMPTY_SCOPE = 3 # scope resolved, selected nothing -> runner _skip structural
EXIT_NOT_APPLICABLE = 4 # subject matter absent entirely -> runner _skip na

# A routed name: 'controller#method' (snake_case controller, camelCase method,
# Settings\Foo namespaced controllers allowed).
_ROUTE_NAME_RE = re.compile(
Expand Down Expand Up @@ -346,12 +362,108 @@ def _collect(app_dir: Path, base_ref: str) -> list[dict]:
return scan_new_endpoints(app_dir, changed, routes)


def _collect_from(app_dir: Path, changed: dict[str, set[int]]) -> list[dict]:
"""``_collect`` with the line map supplied rather than derived from a diff.

Lets run_gate decide the scope — diff or whole tree — instead of the scope
being hardcoded to "diff" inside the collector, which is what hid 32
uncovered endpoints on openconnector behind a PASS (.github#242).
"""
routes_path = app_dir / "appinfo" / "routes.php"
if not routes_path.is_file():
return []
return scan_new_endpoints(app_dir, changed, parse_routes(routes_path))


def _all_controller_lines(app_dir: Path) -> dict[str, set[int]]:
"""Every line of every controller — the full-tree equivalent of a diff.

``scan_new_endpoints`` asks "was this method's declaration line ADDED?".
A full-tree audit answers yes for every line, which makes every registered
public endpoint a candidate exactly as it would be on the commit that first
introduced it.
"""
out: dict[str, set[int]] = {}
cdir = app_dir / "lib" / "Controller"
if not cdir.is_dir():
return out
for cfile in cdir.rglob("*Controller.php"):
try:
n = len(cfile.read_text(encoding="utf-8").splitlines())
except OSError:
continue
out[str(cfile.relative_to(app_dir))] = set(range(1, n + 1))
return out


def run_gate(app_dir: Path) -> int:
base_ref = os.environ.get("HYDRA_GATE_BASE_REF", "origin/development")
endpoints = _collect(app_dir, base_ref)
"""Audit wire-contract coverage. Returns a status; the COUNT is printed.

SCOPE IS THE CALLER'S DECISION, AND IT IS NOT DEFAULTED (.github#242)
--------------------------------------------------------------------
This used to diff against ``HYDRA_GATE_BASE_REF`` UNCONDITIONALLY, with the
ref defaulted to ``origin/development`` even when the caller had asked for
no scoping at all. On a full-tree run the diff came back empty and the gate
printed ``PASS — no new public endpoints in diff`` having opened nothing.

Because the narrowing happened HERE — inside the helper, below the runner's
base resolution — the runner could not tell a full-tree request had been
reduced to nothing, and because the verdict was PASS rather than a skip,
``--require-full-coverage`` could not see it either.

Measured on openconnector 2026-08-08: **PASS** as the runner invoked it,
**32** public endpoints with no contract test against the root commit.
"""
base_ref = os.environ.get("HYDRA_GATE_BASE_REF")

if not (app_dir / "appinfo" / "routes.php").is_file():
print(
f"[gate-{GATE_NUM}] contract-coverage: NOT APPLICABLE — no "
f"appinfo/routes.php, so this app exposes no routed endpoint whose "
f"wire contract could be tested."
)
return EXIT_NOT_APPLICABLE

if base_ref:
changed = changed_lines(base_ref, app_dir)
# The scope that matters is CONTROLLER files, not "any file". A diff of
# a hundred docs commits still opens no controller, and reporting PASS
# for it claims a wire contract was checked when none was read.
changed = {
rel: lines for rel, lines in changed.items()
if rel.startswith("lib/Controller/") and rel.endswith("Controller.php")
}
if not changed:
print(
f"[gate-{GATE_NUM}] contract-coverage: EMPTY SCOPE — "
f"diff-scoped against '{base_ref}' and NO controller file was "
f"touched, so no endpoint was inspected. Wire-contract coverage "
f"is UNVERIFIED by this run. This is not a pass. Audit the whole "
f"tree by running without HYDRA_GATE_BASE_REF, or with "
f"--scope-to-diff --base <root-commit>."
)
return EXIT_EMPTY_SCOPE
endpoints = _collect_from(app_dir, changed)
else:
all_lines = _all_controller_lines(app_dir)
if not all_lines:
print(
f"[gate-{GATE_NUM}] contract-coverage: NOT APPLICABLE — "
f"appinfo/routes.php exists but there is no "
f"lib/Controller/*Controller.php for a route to reach."
)
return EXIT_NOT_APPLICABLE
endpoints = _collect_from(app_dir, all_lines)

if not endpoints:
print(f"[gate-{GATE_NUM}] contract-coverage: PASS — no new public endpoints in diff")
return 0
scope_desc = (
f"the diff against '{base_ref}'" if base_ref else "the whole tree"
)
print(
f"[gate-{GATE_NUM}] contract-coverage: PASS — "
f"{scope_desc} contains no new public endpoint"
)
return EXIT_PASS
newman = _newman_paths(app_dir)
phpunit = _phpunit_text(app_dir)
findings: list[str] = []
Expand All @@ -372,12 +484,15 @@ def run_gate(app_dir: Path) -> int:
f"[gate-{GATE_NUM}] contract-coverage: PASS — "
f"{len(endpoints)} new endpoint(s), all covered"
)
else:
print(
f"[gate-{GATE_NUM}] contract-coverage: FAIL — "
f"{count} new public endpoint(s) without a contract test"
)
return count
return EXIT_PASS
print(
f"[gate-{GATE_NUM}] contract-coverage: FAIL — "
f"{count} new public endpoint(s) without a contract test"
)
# A STATUS, not the count. Returning the count meant 256 findings exited 0
# and read as PASS — the same byte-width bug gate-19 shipped (.github#209).
# The honest number is the one printed above, and the runner reads it there.
return EXIT_FAIL


def run_report(app_dir: Path) -> int:
Expand Down
78 changes: 72 additions & 6 deletions hydra-gates/scripts/lib/check_e2e_coverage.py
Original file line number Diff line number Diff line change
Expand Up @@ -1146,6 +1146,12 @@ def changed_spec_files(base_ref: str, app_dir: Path) -> set[str]:
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_ERROR = 2
# A gate that inspected NOTHING must not answer with the same byte as a gate
# that inspected everything and liked it. PASS and "empty scope" were the same
# 0, so `--require-full-coverage` — whose entire job is to notice gates that did
# not run — could not see this one. (.github#242)
EXIT_EMPTY_SCOPE = 3 # scope resolved, selected nothing -> runner _skip structural
EXIT_NOT_APPLICABLE = 4 # subject matter absent entirely -> runner _skip na


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -1205,13 +1211,73 @@ def run_report(app_dir: Path) -> int:


def run_gate(app_dir: Path) -> int:
"""Diff-scoped gate. Returns EXIT_PASS / EXIT_FAIL; the COUNT is printed."""
base_ref = os.environ.get("HYDRA_GATE_BASE_REF", "origin/development")
touched = changed_spec_files(base_ref, app_dir)
"""Audit @e2e traceability. Returns a status; the COUNT is printed.

SCOPE IS THE CALLER'S DECISION, AND IT IS NOT DEFAULTED (.github#242)
--------------------------------------------------------------------
This function used to diff against ``HYDRA_GATE_BASE_REF`` UNCONDITIONALLY,
defaulting the ref to ``origin/development`` when the caller had not asked
for diff scoping at all. So on a full-tree run — the mode a fleet audit uses
— the default ref resolved, the diff came back empty, and the gate printed
``PASS — no spec files in diff`` over a repository it had never opened.

Two things made that invisible. The scoping happened HERE, inside the
helper, BELOW the runner's base resolution, so the runner could not tell
that a full-tree request had been quietly narrowed to nothing. And the
verdict was ``PASS``, not a skip, so ``--require-full-coverage`` — the one
assertion built to catch gates that did not run — had nothing to catch.

Measured on openconnector 2026-08-08: **5** findings as the runner invoked
it, **412** against the root commit. 407 uncovered scenarios behind a green
line.

HYDRA_GATE_BASE_REF set diff-scoped (ADR-020). An empty diff is an
EMPTY SCOPE and reports as a skip, never a pass.
HYDRA_GATE_BASE_REF unset full-tree audit of every spec in the repo.
"""
# AN UNREADABLE APP DIR IS AN ERROR, NOT AN ABSENCE. "There is no
# openspec/specs here" and "I could not look" produce the same empty set,
# and reporting the second as NOT APPLICABLE would retire the gate on the
# strength of a typo in a path. Distinguish them before anything else.
if not app_dir.is_dir():
print(
f"[gate-{GATE_NUM}] e2e-coverage: ERROR — {app_dir} is not a "
f"readable directory, so nothing was inspected. This is not an "
f"absence of specs; it is a failure to look."
)
return EXIT_ERROR

if not touched:
print(f"[gate-{GATE_NUM}] e2e-coverage: PASS — no spec files in diff")
return EXIT_PASS
spec_root = app_dir / "openspec" / "specs"
all_specs = (
{str(p.relative_to(app_dir)) for p in spec_root.glob("*/spec.md")}
if spec_root.is_dir()
else set()
)

if not all_specs:
print(
f"[gate-{GATE_NUM}] e2e-coverage: NOT APPLICABLE — no "
f"openspec/specs/*/spec.md in this repository, so there is no "
f"declared scenario for an e2e test to trace back to."
)
return EXIT_NOT_APPLICABLE

base_ref = os.environ.get("HYDRA_GATE_BASE_REF")
if base_ref:
touched = changed_spec_files(base_ref, app_dir)
if not touched:
print(
f"[gate-{GATE_NUM}] e2e-coverage: EMPTY SCOPE — diff-scoped "
f"against '{base_ref}' and NO spec file was touched. "
f"{len(all_specs)} spec file(s) exist here and NONE were "
f"inspected: @e2e traceability (ADR-020) is UNVERIFIED by this "
f"run. This is not a pass. Audit the whole tree by running "
f"without HYDRA_GATE_BASE_REF, or with "
f"--scope-to-diff --base <root-commit>."
)
return EXIT_EMPTY_SCOPE
else:
touched = all_specs

covered_refs, dead_refs = collect_ref_status(app_dir)

Expand Down
36 changes: 32 additions & 4 deletions hydra-gates/scripts/lib/check_store_and_settings_surface.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,20 @@
import sys
from pathlib import Path

# ---------------------------------------------------------------------------
# AN EXIT CODE IS A STATUS (.github#240 / #242)
# ---------------------------------------------------------------------------
# "I checked and it was fine", "there was nothing here to check", and "I was
# handed an empty scope and checked nothing" were all the same 0. The runner
# printed PASS for all three, and the log line beside the PASS literally read
# "gate skipped". Only `na` is allowed not to count against coverage; the other
# two must be visible to --require-full-coverage, so they get their own codes.
EXIT_PASS = 0
EXIT_FAIL = 1
EXIT_ERROR = 2
EXIT_EMPTY_SCOPE = 3 # scope resolved, selected nothing -> runner _skip structural
EXIT_NOT_APPLICABLE = 4 # subject matter absent entirely -> runner _skip na

# ADR-077 vocabulary: the canonical glyph for each concept this gate names.
STORE_ICON = "StoreOutline"
TEMPLATE_ICON = "FileReplaceOutline"
Expand Down Expand Up @@ -264,7 +278,7 @@ def main() -> int:
manifest_paths = _manifest_paths(root)
if not manifest_paths:
print("No manifest — gate not applicable (Tier 0 app).")
return 0
return EXIT_NOT_APPLICABLE

changed = _changed_files(root, args.base)
if changed is not None:
Expand All @@ -274,8 +288,22 @@ def main() -> int:
]
layout_changed = "src/menu-layout.json" in changed
if not scoped and not layout_changed:
print("No changed manifest / menu-layout — gate skipped (ADR-020 diff scoping).")
return 0
# THIS BRANCH USED TO `return 0`, and the runner printed PASS. The
# log line said "gate skipped" and the verdict line next to it said
# PASS — the two contradicted each other on the same screen, and the
# PASS is what every consumer counted. (.github#240)
#
# A full-tree audit was the one mode this gate could never reach:
# the runner passed --base unconditionally, so even an unscoped run
# landed here and reported a green over an unopened manifest.
print(
f"No changed manifest / menu-layout — EMPTY SCOPE (ADR-020 diff "
f"scoping against '{args.base}'). {len(manifest_paths)} manifest(s) "
f"exist here and NONE were inspected; this gate's placement rules "
f"are UNVERIFIED by this run. This is not a pass. Omit --base to "
f"audit the whole tree."
)
return EXIT_EMPTY_SCOPE
manifest_paths = scoped or manifest_paths

manifests = []
Expand All @@ -298,7 +326,7 @@ def main() -> int:
hard = [f for f in findings if f.startswith("FAIL")]
warn = [f for f in findings if f.startswith("WARN")]
print(f"\nchecked {len(manifests)} manifest(s): {len(hard)} failure(s), {len(warn)} warning(s).")
return 1 if hard else 0
return EXIT_FAIL if hard else EXIT_PASS


if __name__ == "__main__":
Expand Down
Loading
Loading