mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-05-08 03:01:47 +00:00
feat(ci): add typecheck (warnings only in CI)
This commit is contained in:
parent
63c51d8962
commit
9627ee70e5
3 changed files with 359 additions and 0 deletions
151
.github/workflows/lint.yml
vendored
Normal file
151
.github/workflows/lint.yml
vendored
Normal file
|
|
@ -0,0 +1,151 @@
|
||||||
|
name: Lint (ruff + ty)
|
||||||
|
|
||||||
|
# Surface ruff and ty diagnostics as a diff vs the target branch.
|
||||||
|
# This check is advisory only ATM it always exits zero and never blocks merge.
|
||||||
|
# It posts a Markdown summary to the workflow run and, for pull requests,
|
||||||
|
# comments the same summary on the PR.
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
|
- "website/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- "**/*.md"
|
||||||
|
- "docs/**"
|
||||||
|
- "website/**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: write # needed to post/update PR comments
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: lint-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
lint-diff:
|
||||||
|
name: ruff + ty diff
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0 # need full history for merge-base + worktree
|
||||||
|
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||||
|
|
||||||
|
- name: Install ruff + ty
|
||||||
|
run: |
|
||||||
|
uv tool install ruff
|
||||||
|
uv tool install ty
|
||||||
|
|
||||||
|
- name: Determine base ref
|
||||||
|
id: base
|
||||||
|
run: |
|
||||||
|
# For PRs, diff against the merge base with the target branch.
|
||||||
|
# For pushes to main, diff against the previous commit on main.
|
||||||
|
if [ "${{ github.event_name }}" = "pull_request" ]; then
|
||||||
|
BASE_SHA=$(git merge-base "origin/${{ github.base_ref }}" HEAD)
|
||||||
|
BASE_REF="origin/${{ github.base_ref }}"
|
||||||
|
else
|
||||||
|
BASE_SHA=$(git rev-parse HEAD~1 2>/dev/null || git rev-parse HEAD)
|
||||||
|
BASE_REF="HEAD~1"
|
||||||
|
fi
|
||||||
|
echo "sha=${BASE_SHA}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "ref=${BASE_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "Base SHA: ${BASE_SHA}"
|
||||||
|
echo "Base ref: ${BASE_REF}"
|
||||||
|
|
||||||
|
- name: Run ruff + ty on HEAD
|
||||||
|
run: |
|
||||||
|
mkdir -p .lint-reports/head
|
||||||
|
ruff check --output-format json --exit-zero \
|
||||||
|
> .lint-reports/head/ruff.json || true
|
||||||
|
ty check --output-format gitlab --exit-zero \
|
||||||
|
> .lint-reports/head/ty.json || true
|
||||||
|
echo "HEAD ruff: $(wc -c < .lint-reports/head/ruff.json) bytes"
|
||||||
|
echo "HEAD ty: $(wc -c < .lint-reports/head/ty.json) bytes"
|
||||||
|
|
||||||
|
- name: Run ruff + ty on base (via git worktree)
|
||||||
|
run: |
|
||||||
|
mkdir -p .lint-reports/base
|
||||||
|
# Use a worktree so we don't clobber the main checkout. If the basex
|
||||||
|
# SHA is identical to HEAD (e.g. first commit), skip and leave the
|
||||||
|
# base reports empty — the diff script handles missing files.
|
||||||
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
BASE_SHA="${{ steps.base.outputs.sha }}"
|
||||||
|
if [ "$BASE_SHA" = "$HEAD_SHA" ]; then
|
||||||
|
echo "Base SHA == HEAD SHA, skipping base scan."
|
||||||
|
echo '[]' > .lint-reports/base/ruff.json
|
||||||
|
echo '[]' > .lint-reports/base/ty.json
|
||||||
|
else
|
||||||
|
git worktree add --detach /tmp/lint-base "$BASE_SHA"
|
||||||
|
(
|
||||||
|
cd /tmp/lint-base
|
||||||
|
ruff check --output-format json --exit-zero \
|
||||||
|
> "$GITHUB_WORKSPACE/.lint-reports/base/ruff.json" || true
|
||||||
|
ty check --output-format gitlab --exit-zero \
|
||||||
|
> "$GITHUB_WORKSPACE/.lint-reports/base/ty.json" || true
|
||||||
|
)
|
||||||
|
git worktree remove --force /tmp/lint-base
|
||||||
|
fi
|
||||||
|
echo "base ruff: $(wc -c < .lint-reports/base/ruff.json) bytes"
|
||||||
|
echo "base ty: $(wc -c < .lint-reports/base/ty.json) bytes"
|
||||||
|
|
||||||
|
- name: Generate diff summary
|
||||||
|
run: |
|
||||||
|
python scripts/lint_diff.py \
|
||||||
|
--base-ruff .lint-reports/base/ruff.json \
|
||||||
|
--head-ruff .lint-reports/head/ruff.json \
|
||||||
|
--base-ty .lint-reports/base/ty.json \
|
||||||
|
--head-ty .lint-reports/head/ty.json \
|
||||||
|
--base-ref "${{ steps.base.outputs.ref }}" \
|
||||||
|
--head-ref "${{ github.event_name == 'pull_request' && github.head_ref || github.ref_name }}" \
|
||||||
|
--output .lint-reports/summary.md
|
||||||
|
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||||
|
|
||||||
|
- name: Upload reports as artifact
|
||||||
|
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||||
|
with:
|
||||||
|
name: lint-reports
|
||||||
|
path: .lint-reports/
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Post / update PR comment
|
||||||
|
if: github.event_name == 'pull_request'
|
||||||
|
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
const fs = require('fs');
|
||||||
|
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
|
||||||
|
const marker = '<!-- lint-diff-summary -->';
|
||||||
|
const fullBody = marker + '\n' + body;
|
||||||
|
|
||||||
|
const { data: comments } = await github.rest.issues.listComments({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
});
|
||||||
|
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||||
|
if (existing) {
|
||||||
|
await github.rest.issues.updateComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
comment_id: existing.id,
|
||||||
|
body: fullBody,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await github.rest.issues.createComment({
|
||||||
|
owner: context.repo.owner,
|
||||||
|
repo: context.repo.repo,
|
||||||
|
issue_number: context.issue.number,
|
||||||
|
body: fullBody,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -163,6 +163,7 @@ exclude = ["tinker-atropos"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
exclude = ["tinker-atropos"]
|
exclude = ["tinker-atropos"]
|
||||||
|
select = [] # disable all lints for now, until we've wrangled typechecks a bit more :3
|
||||||
|
|
||||||
[tool.uv]
|
[tool.uv]
|
||||||
exclude-newer = "7 days"
|
exclude-newer = "7 days"
|
||||||
|
|
|
||||||
207
scripts/lint_diff.py
Executable file
207
scripts/lint_diff.py
Executable file
|
|
@ -0,0 +1,207 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Diff ruff + ty diagnostic reports between two git refs.
|
||||||
|
|
||||||
|
Produces a Markdown summary suitable for `$GITHUB_STEP_SUMMARY` and for PR
|
||||||
|
comments. Compares issues by a stable key (file, rule, line) so line-only
|
||||||
|
shifts from unrelated edits are treated as the same issue.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
lint_diff.py \\
|
||||||
|
--base-ruff base/ruff.json --head-ruff head/ruff.json \\
|
||||||
|
--base-ty base/ty.json --head-ty head/ty.json \\
|
||||||
|
[--base-ref origin/main] [--head-ref HEAD]
|
||||||
|
|
||||||
|
Any of the four --{base,head}-{ruff,ty} files may be missing or empty; in that
|
||||||
|
case the tool treats it as "0 diagnostics" (e.g. if base/main doesn't have the
|
||||||
|
config yet, or a tool crashed).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from collections import Counter
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json(path: Path | None) -> list[dict]:
|
||||||
|
if path is None or not path.exists() or path.stat().st_size == 0:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text())
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
print(f"warning: could not parse {path}: {exc}", file=sys.stderr)
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ruff(entries: list[dict]) -> list[dict]:
|
||||||
|
"""Ruff JSON: {code, filename, location.row, message}."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for e in entries:
|
||||||
|
code = e.get("code") or "unknown"
|
||||||
|
# ruff emits absolute paths; relativize to repo root if possible
|
||||||
|
filename = e.get("filename", "")
|
||||||
|
try:
|
||||||
|
filename = os.path.relpath(filename)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
line = (e.get("location") or {}).get("row", 0)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"tool": "ruff",
|
||||||
|
"rule": code,
|
||||||
|
"path": filename,
|
||||||
|
"line": line,
|
||||||
|
"message": e.get("message", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_ty(entries: list[dict]) -> list[dict]:
|
||||||
|
"""ty gitlab JSON: {check_name, location.path, location.positions.begin.line, description}."""
|
||||||
|
out: list[dict] = []
|
||||||
|
for e in entries:
|
||||||
|
loc = e.get("location") or {}
|
||||||
|
begin = (loc.get("positions") or {}).get("begin") or {}
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"tool": "ty",
|
||||||
|
"rule": e.get("check_name", "unknown"),
|
||||||
|
"path": loc.get("path", ""),
|
||||||
|
"line": begin.get("line", 0),
|
||||||
|
"message": e.get("description", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _key(d: dict) -> tuple[str, str, str]:
|
||||||
|
"""Stable diagnostic identity across commits: (path, rule, message)."""
|
||||||
|
# Intentionally omit line so unrelated edits above an issue don't flag it
|
||||||
|
# as "new". Same file + same rule + same message = same issue.
|
||||||
|
return (d["path"], d["rule"], d["message"])
|
||||||
|
|
||||||
|
|
||||||
|
def _diff(base: list[dict], head: list[dict]) -> tuple[list[dict], list[dict], list[dict]]:
|
||||||
|
base_map = {_key(d): d for d in base}
|
||||||
|
head_map = {_key(d): d for d in head}
|
||||||
|
base_keys = set(base_map)
|
||||||
|
head_keys = set(head_map)
|
||||||
|
new_keys = head_keys - base_keys
|
||||||
|
fixed_keys = base_keys - head_keys
|
||||||
|
unchanged_keys = base_keys & head_keys
|
||||||
|
# Return head entries for new (current line numbers), base entries for fixed
|
||||||
|
return (
|
||||||
|
[head_map[k] for k in new_keys],
|
||||||
|
[base_map[k] for k in fixed_keys],
|
||||||
|
[head_map[k] for k in unchanged_keys],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_counts(entries: list[dict]) -> list[tuple[str, int]]:
|
||||||
|
return Counter(e["rule"] for e in entries).most_common()
|
||||||
|
|
||||||
|
|
||||||
|
def _section(title: str, entries: list[dict], limit: int = 25) -> str:
|
||||||
|
if not entries:
|
||||||
|
return f"**{title}:** none\n"
|
||||||
|
lines = [f"**{title} ({len(entries)}):**\n"]
|
||||||
|
# Group by rule for readability
|
||||||
|
counts = _rule_counts(entries)
|
||||||
|
lines.append("| Rule | Count |")
|
||||||
|
lines.append("| --- | ---: |")
|
||||||
|
for rule, count in counts[:15]:
|
||||||
|
lines.append(f"| `{rule}` | {count} |")
|
||||||
|
if len(counts) > 15:
|
||||||
|
lines.append(f"| _+{len(counts) - 15} more rules_ | |")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("<details><summary>First entries</summary>\n")
|
||||||
|
lines.append("```")
|
||||||
|
for e in entries[:limit]:
|
||||||
|
lines.append(f"{e['path']}:{e['line']}: [{e['rule']}] {e['message']}")
|
||||||
|
if len(entries) > limit:
|
||||||
|
lines.append(f"... and {len(entries) - limit} more")
|
||||||
|
lines.append("```")
|
||||||
|
lines.append("</details>\n")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _tool_report(
|
||||||
|
tool_name: str,
|
||||||
|
base: list[dict],
|
||||||
|
head: list[dict],
|
||||||
|
base_available: bool,
|
||||||
|
) -> str:
|
||||||
|
new, fixed, unchanged = _diff(base, head)
|
||||||
|
delta = len(head) - len(base)
|
||||||
|
delta_str = f"+{delta}" if delta > 0 else str(delta)
|
||||||
|
emoji = "🆕" if delta > 0 else ("✅" if delta < 0 else "➖")
|
||||||
|
|
||||||
|
lines = [f"## {tool_name}\n"]
|
||||||
|
if not base_available:
|
||||||
|
lines.append(
|
||||||
|
"_Base report unavailable (likely main has no config for this tool yet); "
|
||||||
|
"treating all head diagnostics as new._\n"
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
f"**Total:** {len(head)} on HEAD, {len(base)} on base "
|
||||||
|
f"({emoji} {delta_str})\n"
|
||||||
|
)
|
||||||
|
lines.append(_section("🆕 New issues", new))
|
||||||
|
lines.append(_section("✅ Fixed issues", fixed))
|
||||||
|
lines.append(
|
||||||
|
f"**Unchanged:** {len(unchanged)} pre-existing issues carried over.\n"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--base-ruff", type=Path, required=True)
|
||||||
|
ap.add_argument("--head-ruff", type=Path, required=True)
|
||||||
|
ap.add_argument("--base-ty", type=Path, required=True)
|
||||||
|
ap.add_argument("--head-ty", type=Path, required=True)
|
||||||
|
ap.add_argument("--base-ref", default="base")
|
||||||
|
ap.add_argument("--head-ref", default="HEAD")
|
||||||
|
ap.add_argument(
|
||||||
|
"--output", type=Path, help="Write summary to this file instead of stdout"
|
||||||
|
)
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
base_ruff_raw = _load_json(args.base_ruff)
|
||||||
|
head_ruff_raw = _load_json(args.head_ruff)
|
||||||
|
base_ty_raw = _load_json(args.base_ty)
|
||||||
|
head_ty_raw = _load_json(args.head_ty)
|
||||||
|
|
||||||
|
base_ruff = _normalize_ruff(base_ruff_raw)
|
||||||
|
head_ruff = _normalize_ruff(head_ruff_raw)
|
||||||
|
base_ty = _normalize_ty(base_ty_raw)
|
||||||
|
head_ty = _normalize_ty(head_ty_raw)
|
||||||
|
|
||||||
|
base_ruff_avail = args.base_ruff.exists() and args.base_ruff.stat().st_size > 0
|
||||||
|
base_ty_avail = args.base_ty.exists() and args.base_ty.stat().st_size > 0
|
||||||
|
|
||||||
|
buf: list[str] = []
|
||||||
|
buf.append(f"# 🔎 Lint report: `{args.head_ref}` vs `{args.base_ref}`\n")
|
||||||
|
buf.append(_tool_report("ruff", base_ruff, head_ruff, base_ruff_avail))
|
||||||
|
buf.append(_tool_report("ty (type checker)", base_ty, head_ty, base_ty_avail))
|
||||||
|
buf.append(
|
||||||
|
"_Diagnostics are surfaced as warnings — this check never fails the build._\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = "\n".join(buf)
|
||||||
|
if args.output:
|
||||||
|
args.output.write_text(summary)
|
||||||
|
else:
|
||||||
|
print(summary)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Loading…
Add table
Add a link
Reference in a new issue