Files
zeekay d696a015aa fix(brand): one name for the script language, and a guard that keeps it
The rename runs on the way in and nothing checked it afterwards, so paths and
content drifted apart in three places, and each one broke something quietly.

The script language had three names at once.
Files on disk carried `.iql`/`.iqle`, the source matchers said `.script`, and
the bytecode matchers said `.hoge`, so the test harness globbed zero files and
the STL compiler regenerated an empty table.
It is `script` now, in one place: `.script` source, `.scriptc` bytecode,
`bin/script` to run one and `bin/scriptc` to compile one.

190 TypeScript specifiers named files that had been renamed away, which is the
whole CDP consumer graph -- the event worker, not the web boot.
`--check` only ever read Python imports; it reads both module systems now, and
repoints both.

`-hog-` became `-mascot-` in paths while content said `script`, so the code
review product's skill directories stopped matching the prefix that discovers
them and it ran with no perspectives at all.

PATH_REGEX is CONTENT_REGEX -- the same list, not a copy -- so a rule can no
longer land in one and miss the other, and the four disagreeing `hog` path
rules collapse into the one word rule content already had.

`HOGLI_*` becomes `INSIGHTSCLI_*` at every reader and writer, with no alias: an
env var read under two names is two names.
The seven casings of `hogql` and five of `hogvm` collapse into one rule per
prefix casing, which leaves the suffix alone because `QL` is SQL's and `VM` is
a virtual machine's.

`--check` now refuses every retired spelling, in content and in paths, and
draws the line the rules draw: `insights_hogfunction` is a Postgres table so it
is left alone, and a migration filename is its identity in `django_migrations`
so it is frozen rather than failed.
Third-party names we cannot rename are reported as pending, never exempted.
2026-08-08 18:38:11 -07:00

619 lines
27 KiB
Python

"""Catch the deterministic CI failures reachable from your diff before you push.
``ci:preflight`` is the pre-push counterpart to ``ci:insights``: insights tells
you what's broken on main, preflight stops you from being the one who breaks
it. It scopes a curated set of checks to the files your branch actually touched,
each mapped to a CI failure class we've seen take main down, plus an always-on
branch-freshness check that flags concrete merge risks (textual conflicts,
migration collisions, generated-file drift, CI changes on main).
insightscli ci:preflight # report what your diff could break in CI
insightscli ci:preflight --fix # auto-remediate what's safe, report the rest
insightscli ci:preflight --strict # exit non-zero on failed checks (the pre-push hook)
insightscli ci:preflight --against origin/main # diff against an explicit base
``INSIGHTSCLI_PREFLIGHT_DISABLED=1`` makes the command (and thus the pre-push hook) a
no-op — the rollout/emergency kill switch (still emits a run event so opt-out
prevalence is measurable).
Checks declare what they need (``node`` modules, the dev ``stack``) and skip with
a note when it's absent, so the no-dependency checks always run — even on a bare
checkout or inside an agent sandbox. The pre-push hook runs ``--strict`` (failures
block, advisories never do); the fix loop is ``--fix`` — see the running-ci-preflight skill.
"""
from __future__ import annotations
import os
import json
import time
import shutil
import socket
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any, Literal
import click
from insightscli import telemetry
from insightscli.hooks import telemetry_property_hooks
from insightscli.manifest import REPO_ROOT
from insightscli_commands.build import (
TRIGGERS as BUILD_TRIGGERS,
_match_commands,
)
from insightscli_commands.change_detection import changed_files, matches_globs
from insightscli_commands.devenv.generator import TRACKED_MPROCS_FILES
Requirement = Literal["node", "desktop-node", "stack", "datastore"]
@dataclass
class DiffCheck:
"""A CI failure class triggered by file globs in the diff, and how to catch it locally.
The always-on branch-freshness check is a different shape — see ``_staleness``."""
key: str
label: str # the CI failure class this intercepts
triggers: list[str] # fnmatch globs against changed paths (`*` spans `/`, as in build.py)
verify: list[str] | None # advisory command; None = guidance only (no runnable local check)
fix: list[str] | None = None # remediation for --fix
advice: str | None = None # nudge-only: preflight never runs this check, it just says what to run
requires: tuple[Requirement, ...] = () # capabilities the check needs, else it skips
takes_files: bool = False # append matched files to the command
# Run once per pnpm workspace containing matched files (cwd = that workspace),
# so nested workspaces like products/desktop validate their own lockfile instead
# of the root one. Capability (node_modules present) is checked per workspace.
workspace_scoped: bool = False
matched: list[str] = field(default_factory=list)
# Ordered cheapest-first. Grounded in failure classes seen in `insightscli ci:insights`:
# broken lockfile blocking all CI, OpenAPI drift, formatting/lint/type checking,
# flag sort, workflow-convention failures, migration conflicts.
DIFF_CHECKS: list[DiffCheck] = [
DiffCheck(
key="lockfile",
label="broken pnpm-lock.yaml (blocks ALL CI)",
# pnpm-workspace.yaml (catalog versions) and patches/* (patchedDependencies
# hashes) invalidate the lockfile just like a package.json edit. The `*/`
# variants reach nested standalone workspaces (products/desktop), which
# workspace scoping then validates against their own lockfile.
triggers=[
"package.json",
"*/package.json",
"pnpm-lock.yaml",
"*/pnpm-lock.yaml",
"pnpm-workspace.yaml",
"*/pnpm-workspace.yaml",
"patches/*",
"*/patches/*",
],
# --lockfile-only validates manifest/lockfile agreement without touching node_modules.
verify=["pnpm", "install", "--frozen-lockfile", "--lockfile-only"],
fix=["pnpm", "install", "--no-frozen-lockfile"],
workspace_scoped=True,
),
DiffCheck(
key="uv-lock",
label="uv.lock out of sync with pyproject.toml (blocks Python CI)",
triggers=["pyproject.toml", "*/pyproject.toml", "uv.lock"],
verify=["uv", "lock", "--check"],
fix=["uv", "lock"],
),
DiffCheck(
key="ruff-lint",
label="Python lint (ruff check)",
triggers=["*.py"],
verify=["ruff", "check"],
fix=["ruff", "check", "--fix"],
takes_files=True,
),
DiffCheck(
key="ruff-format",
label="Python format (ruff format)",
triggers=["*.py"],
verify=["ruff", "format", "--check"],
fix=["ruff", "format"],
takes_files=True,
),
DiffCheck(
key="desktop-biome",
label="desktop lint/format (Biome, what desktop-quality CI runs)",
triggers=[
"products/desktop/*.ts",
"products/desktop/*.tsx",
"products/desktop/*.json",
"products/desktop/*.jsonc",
"products/desktop/*.css",
],
# `pnpm --dir` runs from the nested workspace, so `.` is products/desktop
# and Biome resolves desktop's own biome.jsonc. `biome ci` is read-only;
# the fix path applies safe fixes only (desktop's own lint script is --unsafe).
verify=["pnpm", "--dir", "products/desktop", "exec", "biome", "ci", "."],
fix=["pnpm", "--dir", "products/desktop", "exec", "biome", "check", "--write", "."],
requires=("desktop-node",),
),
DiffCheck(
key="type-check",
label="Python type checking (mypy)",
triggers=["*.py", "*.pyi"],
# A nudge, not a run: mypy is only meaningful repo-wide (it follows imports, so a
# changed-file subset both blames files outside the diff and misses reverse-dependency
# breakage), and that costs minutes cold. Naming the command lets the agent judge.
verify=None,
advice="a type error costs a full CI re-run — consider `uv run mypy --cache-fine-grained .` (what CI runs)",
),
DiffCheck(
key="markdown-format",
label="markdown formatting (oxfmt)",
triggers=["*.md", "*.mdx"],
# Mirrors lint-staged's `format:markdown`, which agents bypass via --no-verify.
# --no-error-on-unmatched-pattern: .oxfmtrc.json ignores whole trees (rust/,
# fixtures, ...) and oxfmt exits 2 when every given file is ignored.
verify=["pnpm", "exec", "oxfmt", "--check", "--no-error-on-unmatched-pattern"],
fix=["insightscli", "format:markdown"],
requires=("node",),
takes_files=True,
),
DiffCheck(
key="feature-flags",
label="FEATURE_FLAGS not alphabetically sorted",
triggers=["frontend/src/lib/constants.tsx"],
verify=["insightscli", "lint:feature-flags"],
fix=["insightscli", "lint:feature-flags:fix"],
),
DiffCheck(
key="workflow-lint",
label="workflow-convention failure in .github/workflows",
triggers=[".github/workflows/*.yml", ".github/workflows/*.yaml"],
verify=["insightscli", "lint:workflows"],
),
DiffCheck(
key="mprocs",
label="bin/mprocs*.yaml docker-compose shell out of sync with generator.py",
# From TRACKED_MPROCS_FILES so preflight can't drift on which files need a regen.
triggers=[
"tools/insightscli-commands/insightscli_commands/devenv/generator.py",
*(t.name for t in TRACKED_MPROCS_FILES),
],
verify=["insightscli", "dev:regenerate-mprocs", "--check"],
fix=["insightscli", "dev:regenerate-mprocs"],
),
DiffCheck(
key="openapi",
label="OpenAPI types out of date (frontend/MCP drift)",
# From build.py so preflight and build:openapi can't drift on which diffs need a regen.
triggers=list(BUILD_TRIGGERS["build:openapi"]),
# Drift detection regenerates then diffs — needs the DB. Guidance-only here.
verify=None,
fix=["insightscli", "build:openapi"],
requires=("stack",),
),
DiffCheck(
key="migrations",
label="migration conflict / orphaned migration",
triggers=["*/migrations/*.py"],
# migrations:check declares both postgresql and datastore services.
verify=["insightscli", "migrations:check"],
requires=("stack", "datastore"),
),
]
def _has_node_modules() -> bool:
return (REPO_ROOT / "node_modules" / ".pnpm").exists()
def _port_open(port: int) -> bool:
try:
with socket.create_connection(("localhost", port), timeout=0.3):
return True
except OSError:
return False
def _capability_met(req: Requirement) -> bool:
if req == "node":
return _has_node_modules()
if req == "desktop-node":
# products/desktop is a nested standalone workspace with its own install.
return (REPO_ROOT / "products" / "desktop" / "node_modules" / ".pnpm").exists()
if req == "stack":
# Postgres reachable — proxy for "dev stack is running".
return _port_open(5432)
return _port_open(8123) # Datastore HTTP
def _unmet(chk: DiffCheck) -> list[Requirement]:
return [req for req in chk.requires if not _capability_met(req)]
Status = Literal["pass", "fail", "advisory", "skipped"]
# Generous: pnpm installs and migrations:check are legitimately slow, but a wedged
# command must not hang the agent loop forever (output is captured, not streamed).
_CHECK_TIMEOUT_SECONDS = 600
def _pnpm_workspace_root(file_path: str) -> str:
"""Repo-relative root of the pnpm workspace owning *file_path* ("." for the root
workspace): the nearest ancestor directory with a pnpm-workspace.yaml. The lockfile
is not a workspace marker on purpose — products/desktop/packages/agent carries a
publish-only pnpm-lock.yaml but belongs to the desktop workspace."""
current = (REPO_ROOT / file_path).parent.resolve()
root = REPO_ROOT.resolve()
while current != root and root in current.parents:
if (current / "pnpm-workspace.yaml").exists():
return current.relative_to(root).as_posix()
current = current.parent
return "."
def _workspace_install_present(ws_root: Path) -> bool:
return (ws_root / "node_modules" / ".pnpm").exists()
def _run_workspace_scoped(chk: DiffCheck, do_fix: bool) -> tuple[Status, str]:
"""Run *chk* once per pnpm workspace containing matched files, cwd'd into it."""
statuses: list[Status] = []
parts: list[str] = []
for ws in sorted({_pnpm_workspace_root(f) for f in chk.matched}):
ws_root = REPO_ROOT if ws == "." else REPO_ROOT / ws
label = "root" if ws == "." else ws
if not _workspace_install_present(ws_root):
statuses.append("skipped")
parts.append(f"{label}: needs node (no install)")
continue
cmd = list(chk.fix) if do_fix and chk.fix is not None else list(chk.verify or [])
if not cmd or shutil.which(cmd[0]) is None:
statuses.append("skipped")
parts.append(f"{label}: {cmd[0] if cmd else 'command'} not found")
continue
try:
result = subprocess.run(cmd, cwd=ws_root, capture_output=True, text=True, timeout=_CHECK_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
statuses.append("fail")
parts.append(f"{label}: timed out after {_CHECK_TIMEOUT_SECONDS}s")
continue
if result.returncode == 0:
statuses.append("pass")
parts.append(f"{label}: {'fixed' if do_fix else 'ok'}")
else:
lines = (result.stdout or result.stderr).strip().splitlines()
parts.append(f"{label}: {lines[0] if lines else f'exit {result.returncode}'}")
statuses.append("fail")
if "fail" in statuses:
overall: Status = "fail"
elif "pass" in statuses:
overall = "pass"
else:
overall = "skipped"
return overall, " · ".join(parts)
def _run_diff_check(chk: DiffCheck, do_fix: bool) -> tuple[Status, str]:
if chk.advice is not None:
# Nudge-only: nothing to run, nothing to auto-fix — the advisory *is* the check.
return "advisory", chk.advice
if chk.workspace_scoped:
return _run_workspace_scoped(chk, do_fix)
unmet = _unmet(chk)
if do_fix and chk.fix is not None and not unmet:
cmd = list(chk.fix)
elif chk.verify is None:
# Guidance-only (no runnable local check, or its fix needs an absent capability):
# advise regardless, so the hint still shows on a bare checkout — even with --fix.
# Ownership framing lives once in the advisory footer, not per check.
return "advisory", f"run `{' '.join(chk.fix or [])}` and commit before pushing"
elif unmet:
return "skipped", f"needs {', '.join(unmet)}"
else:
cmd = list(chk.verify)
if chk.takes_files:
# Drop deleted paths: ruff (and friends) error E902 on a path that no longer exists.
present = [f for f in chk.matched if (REPO_ROOT / f).exists()]
if not present:
return "skipped", "only deleted files"
cmd += present
if shutil.which(cmd[0]) is None:
return "skipped", f"{cmd[0]} not found"
try:
result = subprocess.run(cmd, cwd=REPO_ROOT, capture_output=True, text=True, timeout=_CHECK_TIMEOUT_SECONDS)
except subprocess.TimeoutExpired:
return "fail", f"`{cmd[0]}` timed out after {_CHECK_TIMEOUT_SECONDS}s"
if result.returncode == 0:
return "pass", "fixed" if do_fix else "ok"
lines = (result.stdout or result.stderr).strip().splitlines()
return "fail", " · ".join(lines[:3]) if lines else f"exit {result.returncode}"
# Branch-freshness backstop thresholds. The risk signals in ``_staleness_risks``
# are the primary advisory trigger; these are a secondary net for branches old
# enough that generic (undetected) drift is likely. Deliberately aggressive to
# start — main moves fast and we'd rather over-warn and tune down from
# telemetry than under-warn. Advisory only (never auto-merged). Env-tunable.
_TRUNK_REF = "origin/main"
_STALE_COMMITS_DEFAULT = 5
_STALE_DAYS_DEFAULT = 2
_FETCH_TTL_SECONDS = 600 # skip re-fetching origin/main if refreshed this recently
def _git_run(*args: str, timeout: float = 15.0) -> subprocess.CompletedProcess[str] | None:
"""Run a git command in the repo; None on OS error or timeout."""
try:
return subprocess.run(["git", "-C", str(REPO_ROOT), *args], capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return None
def _git(*args: str, timeout: float = 5.0) -> str | None:
"""Like ``_git_run`` but collapses to trimmed stdout, None on any failure."""
result = _git_run(*args, timeout=timeout)
return result.stdout.strip() if result is not None and result.returncode == 0 else None
def _fetch_marker() -> Path | None:
"""Our own TTL marker — FETCH_HEAD won't do, since fetching *any* ref touches it."""
raw = _git("rev-parse", "--git-path", "insightscli-preflight-fetched")
if not raw:
return None
path = Path(raw)
return path if path.is_absolute() else REPO_ROOT / raw
def _fetch_trunk() -> None:
"""Refresh origin/main for an accurate diff base and behind-count, skipping the
fetch if done recently (the agent loop reruns preflight often). Offline → keep
the local ref."""
marker = _fetch_marker()
if marker is not None:
try:
if time.time() - marker.stat().st_mtime < _FETCH_TTL_SECONDS:
return
except OSError:
pass
if _git("fetch", "--quiet", "origin", "main", timeout=10.0) is not None and marker is not None:
marker.touch()
def _env_int(var: str, default: int) -> int:
try:
return int(os.environ.get(var, default))
except ValueError:
return default
def _commit_age_days(ref: str) -> int | None:
"""Whole days since *ref* was committed — how long since the branch last held main's state."""
iso = _git("show", "-s", "--format=%cI", ref)
if not iso:
return None
try:
when = datetime.fromisoformat(iso)
except ValueError:
return None
return max(0, (datetime.now(when.tzinfo) - when).days)
def _merge_conflicts() -> list[str] | None:
"""Files that would conflict if main were merged right now, computed without
touching the working tree (``git merge-tree``, git >= 2.38). None = can't tell."""
result = _git_run("merge-tree", "--write-tree", "--name-only", "HEAD", _TRUNK_REF)
if result is None or result.returncode not in (0, 1):
return None
# returncode 1 = conflicts; first output line is the merged tree OID.
return [line for line in result.stdout.splitlines()[1:] if line] if result.returncode == 1 else []
def _changed_on_trunk(merge_base: str) -> list[str]:
result = _git_run("diff", "--name-only", "-z", f"{merge_base}..{_TRUNK_REF}")
if result is None or result.returncode != 0:
return []
return [path for path in result.stdout.split("\0") if path]
_MIGRATION_GLOB = ["*/migrations/*.py"]
def _staleness_risks(branch_files: list[str], trunk_files: list[str], conflicts: list[str] | None) -> list[str]:
"""Concrete ways merging main late will break this branch — each a failure
class that recurs on unrebased PRs: textual conflicts, migration collisions,
generated-file drift, and CI workflows changing underneath the branch."""
risks: list[str] = []
if conflicts:
risks.append(f"merging main conflicts in {len(conflicts)} file(s) (e.g. {conflicts[0]})")
branch_apps = {str(Path(f).parent) for f in branch_files if matches_globs(f, _MIGRATION_GLOB)}
trunk_apps = {str(Path(f).parent) for f in trunk_files if matches_globs(f, _MIGRATION_GLOB)}
collisions = sorted(branch_apps & trunk_apps)
if collisions:
risks.append(f"migrations added on both sides in {', '.join(collisions)}")
drift = sorted(set(_match_commands(branch_files)) & set(_match_commands(trunk_files)))
if drift:
risks.append(f"main also changed {', '.join(drift)} inputs — regenerate after merging")
workflows = sum(f.startswith(".github/workflows/") for f in trunk_files)
if workflows:
risks.append(f"CI workflows changed on main ({workflows} file(s))")
return risks
def _staleness(branch_files: list[str]) -> tuple[Status, str, dict[str, Any]]:
"""Whether merging main *now* would break this branch. Risk signals are the
primary advisory trigger; a raw behind-count backstop also fires, aggressively
by default. On squash-merge main, commits behind ≈ PRs merged.
Returns (status, detail, telemetry props)."""
merge_base = _git("merge-base", "HEAD", _TRUNK_REF)
if not merge_base:
return "skipped", f"no merge-base with {_TRUNK_REF}", {"stale": None}
# Commits on main we don't have = how far behind; 0 means up to date.
# A failed/timed-out count must not read as "even with main".
count = _git("rev-list", "--count", f"{merge_base}..{_TRUNK_REF}")
if count is None or not count.isdigit():
return "skipped", "could not count commits behind main", {"stale": None}
behind = int(count)
if behind == 0:
return "pass", "even with main", {"stale": False, "behind_commits": 0, "branch_age_days": 0}
age_days = _commit_age_days(merge_base) # merge-base age ≈ time since the branch last synced with main
conflicts = _merge_conflicts()
risks = _staleness_risks(branch_files, _changed_on_trunk(merge_base), conflicts)
if behind >= _env_int("INSIGHTSCLI_PREFLIGHT_STALE_COMMITS", _STALE_COMMITS_DEFAULT):
risks.append(f"{behind} commits (≈ PRs) behind")
elif age_days is not None and age_days >= _env_int("INSIGHTSCLI_PREFLIGHT_STALE_DAYS", _STALE_DAYS_DEFAULT):
risks.append(f"last synced {age_days}d ago")
props: dict[str, Any] = {
"stale": bool(risks),
"behind_commits": behind,
"branch_age_days": age_days,
"merge_conflict_files": len(conflicts) if conflicts is not None else None,
"staleness_risks": len(risks),
}
if risks:
return "advisory", f"{' · '.join(risks)} — merge main in: git merge {_TRUNK_REF}", props
synced = f", synced {age_days}d ago" if age_days is not None else ""
return "pass", f"{behind} commits behind main{synced} — no conflict or drift risk detected", props
_ICON: dict[Status, str] = {"pass": "✓", "fail": "✗", "advisory": "→", "skipped": "·"}
_COLOR: dict[Status, str] = {"pass": "green", "fail": "red", "advisory": "yellow", "skipped": "bright_black"}
def _emit_telemetry(summary: dict[str, Any]) -> None:
"""Emit ``ci_preflight_run`` to measure failures intercepted locally vs. what
would reach CI. Gated on ``is_active()`` first so an opt-out/CI run doesn't fork
the ``gh``/``git`` property hooks for an event ``track()`` would drop anyway."""
if not telemetry.is_active():
return
keys = (
"changed_files",
"triggered",
"failures",
"advisories",
"mode",
"stale",
"behind_commits",
"branch_age_days",
"merge_conflict_files",
"staleness_risks",
)
props: dict[str, Any] = {k: summary[k] for k in keys if k in summary}
props["results"] = {r["check"]: r["status"] for r in summary["results"]}
# Registries are read directly by design (see insightscli.hooks). Merge the same
# dev-context props the command lifecycle attaches to command_completed
# (incl. git_branch, so a run ties back to its PR).
for hook in telemetry_property_hooks:
try:
props.update(hook("ci:preflight"))
except Exception:
pass
telemetry.track("ci_preflight_run", props)
@click.command(
name="ci:preflight",
help="Catch the deterministic CI failures reachable from your diff before you push.",
)
@click.option("--fix", "do_fix", is_flag=True, help="Auto-remediate what's safe instead of only reporting.")
@click.option(
"--strict",
is_flag=True,
help="Exit non-zero on any failed check (advisories never block) — for the pre-push hook.",
)
@click.option("--against", default=None, help="Diff against this base ref instead of the branch default.")
@click.option("--json", "as_json", is_flag=True, help="Emit the result summary as JSON.")
def ci_preflight(do_fix: bool, strict: bool, against: str | None, as_json: bool) -> None:
if os.environ.get("INSIGHTSCLI_PREFLIGHT_DISABLED", "").lower() in {"1", "true"}:
disabled_summary: dict[str, Any] = {"mode": "disabled", "results": []}
if as_json:
click.echo(json.dumps(disabled_summary))
else:
click.secho(
" ci:preflight disabled by operator (INSIGHTSCLI_PREFLIGHT_DISABLED) — intentional; "
"do not unset. Nothing to check, CI remains the gate.",
fg="yellow",
)
_emit_telemetry(disabled_summary)
return
# Fetch first so both the diff base and the staleness check see a fresh
# origin/main — a stale local ref would inflate the diff with main's own commits.
_fetch_trunk()
# --strict is the blocking pre-push hook: scope it to the committed diff, since a
# push only carries commits. Untracked/uncommitted work never reaches CI, so gating
# a push on lint errors in a scratch file is a false block. Advisory/--fix runs keep
# the working tree in scope so agents can clean edits before committing.
files = changed_files(against, include_worktree=not strict)
base = against or "origin/main"
triggered: list[DiffCheck] = []
for chk in DIFF_CHECKS:
chk.matched = [f for f in files if matches_globs(f, chk.triggers)]
if chk.matched:
triggered.append(chk)
results: list[dict[str, Any]] = []
failures = 0
advisories = 0
if not as_json:
click.secho(f"\n ci:preflight — {len(files)} changed file(s) vs {base}\n", bold=True)
# Always-on branch-health check, independent of which files changed: a stale
# branch breaks CI on merge no matter what the diff touches. Advisory only —
# it never counts toward `failures` (a merge is the human/agent's call).
stale_status, stale_detail, stale_props = _staleness(files)
results.append({"check": "staleness", "status": stale_status, "files": 0, "detail": stale_detail})
if not as_json:
click.secho(f" {_ICON[stale_status]} [staleness] branch freshness vs main", fg=_COLOR[stale_status])
click.echo(f" {stale_detail}")
for chk in triggered:
status, detail = _run_diff_check(chk, do_fix)
failures += status == "fail"
# Nudges say "consider this", not "this is drift" — counting them would cry wolf in
# the footer on every matching push and cost the detected advisories their weight.
advisories += status == "advisory" and chk.advice is None
results.append({"check": chk.key, "status": status, "files": len(chk.matched), "detail": detail})
if not as_json:
click.secho(f" {_ICON[status]} [{chk.key}] {chk.label}", fg=_COLOR[status])
click.echo(f" {len(chk.matched)} file(s) · {detail}")
summary = {
"changed_files": len(files),
"triggered": [c.key for c in triggered],
"failures": failures,
"advisories": advisories,
"mode": "fix" if do_fix else ("strict" if strict else "advisory"),
"results": results,
**stale_props,
}
if as_json:
click.echo(json.dumps(summary))
else:
if not triggered:
click.secho(" ✓ Nothing in this diff maps to a known CI failure class.", fg="green")
click.echo()
click.echo(
f" summary {json.dumps({k: summary[k] for k in ('changed_files', 'triggered', 'failures', 'mode')})}"
)
if advisories:
# Non-blocking, so agents skip these as pre-existing. Restate ownership.
click.secho(
f"\n {advisories} advisory(ies) are unpushed CI failures — resolve before pushing, "
"including drift you didn't introduce. You own the branch state you push.",
fg="yellow",
)
click.echo()
_emit_telemetry(summary)
# Advisory by default — --strict turns verified *failures* into a non-zero exit
# for the pre-push hook. Advisories never block: guidance-only checks can't tell
# done from not-done, so blocking on them would false-block every matching push.
# SystemExit so the telemetry wrapper records it.
raise SystemExit(1 if (strict and failures) else 0)