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
4 changes: 4 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,9 @@
description: "Validate the repo's .loop/ contract objects; fails on a dishonest or malformed contract."
entry: loop doctor .
language: python
# The CLI default is pure-stdlib structural validation; the gate surface runs
# the strict path. pre-commit installs these into the hook's isolated env so
# loop/contract.py takes the jsonschema branch and PyYAML parses the manifest.
additional_dependencies: ["jsonschema>=4", "pyyaml>=6"]
pass_filenames: false
always_run: true
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,9 +304,14 @@ refuses an evidence-free `Succeeded` at write time. Recipe:
path: "."
```

`doctor` failure fails the job; the inspect verdict is warn-only unless you set
`fail-under-score`. Pre-commit users: hook id `loop-doctor`. This repo dogfoods
the same action in CI against its flagship example contract
`doctor` failure fails the job (and the action installs the `[schemas]` extra so
`doctor` runs real JSON-Schema validation, not the structural fallback). The
inspect **score is an advisory heuristic** — useful as a trend, but a determined
author can game it, so it is warn-only unless you set `fail-under-score`;
**`loop doctor` is the hard gate.** The optional PR comment is sticky — it edits
one scorecard comment in place across re-runs. Pre-commit users: hook id
`loop-doctor` (its `additional_dependencies` pin the schema extras too). This repo
dogfoods the same action in CI against its flagship example contract
([`examples/coverage-repair`](examples/coverage-repair)) — the gate gates its maker.

---
Expand Down
74 changes: 40 additions & 34 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ inputs:
required: false
default: ""
fail-under-score:
description: "Fail the job when the inspect score (0-100) is below this. 0 keeps inspect warn-only."
description: "Fail the job when the inspect score (0-100) is below this. 0 keeps inspect warn-only. NOTE: the inspect score is an advisory heuristic and a determined author can game it — loop doctor is the hard gate."
required: false
default: "0"
python-version:
Expand All @@ -38,61 +38,67 @@ runs:
env:
LOOP_VERSION: "${{ inputs.version }}"
run: |
# Install WITH the schema extras on both paths: the gate surface must run
# real JSON-Schema validation, not the CLI's pure-stdlib structural default.
if [ -n "$LOOP_VERSION" ]; then
python -m pip install --quiet "loop-engineer==$LOOP_VERSION"
python -m pip install --quiet "loop-engineer[schemas,yaml]==$LOOP_VERSION"
else
python -m pip install --quiet "${{ github.action_path }}"
python -m pip install --quiet "${{ github.action_path }}[schemas,yaml]"
fi

- name: loop doctor (hard gate)
shell: bash
env:
LOOP_PATH: "${{ inputs.path }}"
run: loop doctor "$LOOP_PATH"
run: |
# -eo pipefail (GitHub's bash default) makes a doctor failure fail the step
# despite the tee. Then assert the strict validation path actually ran, so a
# future packaging regression that drops the extras fails loudly here. The
# check reads one fixed field from doctor's own JSON — no fragile parsing.
loop doctor "$LOOP_PATH" | tee "${RUNNER_TEMP}/doctor.json"
python - "${RUNNER_TEMP}/doctor.json" <<'PY'
import json, sys
mode = json.load(open(sys.argv[1])).get("validation_mode")
if mode != "jsonschema":
print(f"::error::loop doctor ran in {mode!r} mode, not 'jsonschema' — "
"the gate is installed without its [schemas] extra")
raise SystemExit(1)
PY

- name: loop inspect (scorecard)
shell: bash
env:
LOOP_PATH: "${{ inputs.path }}"
LOOP_FAIL_UNDER: "${{ inputs.fail-under-score }}"
run: |
# inspect is advisory; capture its JSON even on a non-zero verdict, then
# let the extracted (tested) scorecard render + apply fail-under. The
# script validates fail-under as an int instead of tracebacking on it.
set +e
loop inspect "$LOOP_PATH" > "${RUNNER_TEMP}/inspect.json"
set -e
python - "${RUNNER_TEMP}/inspect.json" "$LOOP_FAIL_UNDER" <<'PY'
import json, os, sys

report = json.load(open(sys.argv[1]))
fail_under = int(sys.argv[2])
score, verdict = report.get("score", 0), report.get("verdict", "?")
lines = [
"## loop-engineer scorecard",
"",
f"| metric | value |",
f"|---|---|",
f"| verdict | **{verdict}** |",
f"| score | {score}/100 |",
f"| gaps | {len(report.get('gaps', []))} |",
"",
]
for gap in report.get("gaps", [])[:10]:
lines.append(f"- {gap}")
summary = "\n".join(lines) + "\n"
with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as fh:
fh.write(summary)
open(os.path.join(os.environ["RUNNER_TEMP"], "scorecard.md"), "w", encoding="utf-8").write(summary)
if verdict == "weak":
print(f"::warning::loop inspect verdict is weak (score {score}/100)")
if fail_under and score < fail_under:
print(f"::error::inspect score {score} < fail-under-score {fail_under}")
raise SystemExit(1)
PY
python "${{ github.action_path }}/scripts/action_scorecard.py" \
"${RUNNER_TEMP}/inspect.json" "$LOOP_FAIL_UNDER"

- name: PR scorecard comment (optional)
if: ${{ inputs.github-token != '' && github.event_name == 'pull_request' }}
shell: bash
env:
GH_TOKEN: ${{ inputs.github-token }}
GH_REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
gh api "repos/${GITHUB_REPOSITORY}/issues/${{ github.event.pull_request.number }}/comments" \
-f body="$(cat "${RUNNER_TEMP}/scorecard.md")" || echo "::warning::PR comment failed (non-fatal)"
# Sticky comment: find our own marker-bearing comment and edit it in place,
# else post a fresh one — so re-runs update one comment instead of spamming.
# Non-fatal on any API failure.
set +e
body="$(cat "${RUNNER_TEMP}/scorecard.md")"
existing="$(gh api --paginate "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
--jq '.[] | select(.body | contains("<!-- loop-engineer-scorecard -->")) | .id' | head -n1)"
if [ -n "$existing" ]; then
gh api --method PATCH "repos/${GH_REPO}/issues/comments/${existing}" \
-f body="$body" || echo "::warning::PR comment update failed (non-fatal)"
else
gh api --method POST "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
-f body="$body" || echo "::warning::PR comment failed (non-fatal)"
fi
114 changes: 114 additions & 0 deletions scripts/action_scorecard.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Scorecard renderer for the loop-engineer GitHub Action.

Invoked by the composite action from its own checkout:

python "${{ github.action_path }}/scripts/action_scorecard.py" <inspect.json> <fail-under>

The inspect score is an ADVISORY heuristic — a determined author can game it.
`loop doctor` is the hard gate; this only summarizes and optionally fails the job
when the score dips below an author-set floor.

Exit codes:
0 scorecard rendered (verdict may be weak → a ::warning, still a pass)
1 the inspect score is below <fail-under>
2 bad input (non-integer/out-of-range fail-under, missing/malformed inspect.json,
wrong argument count) — distinct from the fail-under-breach code
"""

from __future__ import annotations

import json
import os
import sys
from pathlib import Path
from typing import Any

MARKER = "<!-- loop-engineer-scorecard -->"


def parse_fail_under(raw: str) -> int:
"""Parse the fail-under threshold as an integer in [0, 100].

Raises ValueError on anything else (``"80.5"``, ``"abc"``, ``""``, ``"101"``)
so the caller can emit one clear ::error instead of a raw traceback.
"""
try:
value = int(raw.strip())
except (AttributeError, ValueError):
raise ValueError(f"fail-under-score must be an integer 0-100, got {raw!r}")
if not 0 <= value <= 100:
raise ValueError(f"fail-under-score must be in [0, 100], got {value}")
return value


def render_summary(report: dict[str, Any]) -> str:
"""Render the Markdown scorecard. The sticky marker leads the body so the
PR-comment step can find-and-edit its own prior comment in place."""
score = report.get("score", 0)
verdict = report.get("verdict", "?")
gaps = report.get("gaps", [])
lines = [
MARKER,
"## loop-engineer scorecard",
"",
"| metric | value |",
"|---|---|",
f"| verdict | **{verdict}** |",
f"| score | {score}/100 |",
f"| gaps | {len(gaps)} |",
"",
]
for gap in gaps[:10]:
lines.append(f"- {gap}")
return "\n".join(lines) + "\n"


def _publish(summary: str) -> None:
"""Append to the job summary and drop scorecard.md for the PR-comment step.
Both destinations come from the runner env; absent (e.g. in tests) → skipped."""
step_summary = os.environ.get("GITHUB_STEP_SUMMARY")
if step_summary:
with open(step_summary, "a", encoding="utf-8") as fh:
fh.write(summary)
runner_temp = os.environ.get("RUNNER_TEMP")
if runner_temp:
(Path(runner_temp) / "scorecard.md").write_text(summary, encoding="utf-8")


def main(argv: list[str] | None = None) -> int:
argv = list(sys.argv[1:] if argv is None else argv)
if len(argv) != 2:
print("::error::action_scorecard.py expects <inspect.json> <fail-under>")
return 2

inspect_path, fail_under_raw = argv
try:
fail_under = parse_fail_under(fail_under_raw)
except ValueError as exc:
print(f"::error::{exc}")
return 2

try:
report = json.loads(Path(inspect_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
print(f"::error::could not read inspect report {inspect_path!r}: {exc}")
return 2
if not isinstance(report, dict):
print(f"::error::inspect report {inspect_path!r} is not a JSON object")
return 2

score = report.get("score", 0)
verdict = report.get("verdict", "?")
_publish(render_summary(report))

if verdict == "weak":
print(f"::warning::loop inspect verdict is weak (score {score}/100)")
if fail_under and isinstance(score, (int, float)) and score < fail_under:
print(f"::error::inspect score {score} < fail-under-score {fail_under}")
return 1
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading