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
2 changes: 2 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ See [docs/providers.md](../docs/providers.md) for how to add a new git provider.
4. Use conventional commits where possible: `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:`.
5. Open a PR with a clear description: what's the problem, what's the fix, anything reviewers should pay attention to.

Your PR also gets an informational `Code Erosion` report (SlopCodeBench verbosity/erosion metrics) posted as a comment — it never blocks the merge and is just there to flag creeping complexity. See [docs/ci-code-erosion.md](../docs/ci-code-erosion.md).

## Coding Style

- TypeScript strict mode is on; avoid `any` unless genuinely needed.
Expand Down
83 changes: 83 additions & 0 deletions .github/ast-grep-rules/ts-verbosity.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# TypeScript verbosity rules for the code-erosion CI layer.
#
# These are hand-ported from the SlopCodeBench Python ast-grep rule set
# (scb-check 0.2.0, 197 Python rules). scb-check only runs its rules on
# Python files, so on this TypeScript repo its verbosity rule component is
# always 0; this file is the independent TS layer that fills that gap.
#
# IMPORTANT — only PURELY STRUCTURAL rules are ported. Python rules that hinge
# on truthiness or type semantics (`len(x) == 0`, `x == True`) were tried and
# DELIBERATELY DROPPED: they are false positives in TypeScript, where
# `arr.length > 0` is idiomatic and `x !== true` is not equivalent to
# `x === false` (TS has `undefined`; Python does not). See docs/ci-code-erosion.md.
#
# severity is `hint` so `ast-grep scan` never exits non-zero on a match — this
# layer is informational and must never block CI.

id: unnecessary-else-after-return
language: typescript
severity: hint
message: "else after a return in the if-branch - drop the else and flatten the body"
rule:
kind: if_statement
all:
- has:
field: consequence
has:
kind: return_statement
stopBy: end
- has:
field: alternative
kind: else_clause
---
id: empty-catch-block
language: typescript
severity: hint
message: "empty catch block swallows errors silently - handle or log, or add a comment saying why it is safe"
rule:
kind: catch_clause
has:
kind: statement_block
regex: "^\\{\\s*\\}$"
---
id: redundant-ternary-same
language: typescript
severity: hint
message: "ternary with identical branches - drop it, the condition is irrelevant"
rule:
pattern: "$C ? $X : $X"
---
id: if-return-boolean-literal
language: typescript
severity: hint
message: "if/else returning boolean literals - return the condition directly"
rule:
any:
- pattern: "if ($COND) { return true; } else { return false; }"
- pattern: "if ($COND) { return false; } else { return true; }"
- pattern: "if ($COND) return true; else return false;"
- pattern: "if ($COND) return false; else return true;"
---
id: return-ternary-boolean-literal
language: typescript
severity: hint
message: "return cond ? true : false - return the condition (or its negation) directly"
rule:
any:
- pattern: "return $C ? true : false;"
- pattern: "return $C ? false : true;"
---
id: duplicated-if-condition
language: typescript
severity: hint
message: "same condition in if and else-if - the later branch is unreachable"
rule:
kind: if_statement
pattern: "if ($COND) { $$$ } else if ($COND) { $$$ }"
---
id: self-assignment
language: typescript
severity: hint
message: "self-assignment has no effect"
rule:
pattern: "$X = $X;"
147 changes: 147 additions & 0 deletions .github/scripts/erosion-summary.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
#!/usr/bin/env python3
"""Render scb-check's JSON report as a Markdown block.

Used by .github/workflows/code-erosion.yml. Reads the report path, the
scb-check exit code, and (optionally) the ast-grep rule-hits path from argv,
writes Markdown to stdout. The workflow feeds that Markdown both into the PR
comment and the job summary.

This never raises on a missing/garbled report or a renamed key: the workflow
is informational and must not fail. See docs/ci-code-erosion.md.
"""

from __future__ import annotations

import json
import sys
from collections import Counter

MARKER = "<!-- code-erosion-report -->"


def band(value: float, human_hi: float, agent_lo: float) -> str:
"""Bucket a metric against the SlopCodeBench reference bands."""
if value <= human_hi:
return "human"
if value >= agent_lo:
return "agent"
return "between"


def rule_hits_section(path: str) -> list[str]:
"""Render the independent ast-grep TS-verbosity layer as Markdown.

Reads ast-grep's `--json=stream` output (one JSON object per line).
Returns an empty list when the file is missing or unreadable, so the
section is simply omitted rather than ever failing the report.
"""
try:
with open(path, encoding="utf-8") as handle:
hits = [json.loads(line) for line in handle if line.strip()]
except (OSError, ValueError):
return []

lines = [
"",
"### Rule hits (TS verbosity layer)",
"",
"_Standalone `ast-grep`, separate from the number above._",
"",
]
if not hits:
lines.append("No rule hits. 🎉")
return lines

by_rule = Counter(h.get("ruleId", "?") for h in hits)
distinct = {
(h.get("file"), h.get("range", {}).get("start", {}).get("line"))
for h in hits
}
lines.append("| Rule | Hits |")
lines.append("|---|---|")
for rule, count in by_rule.most_common():
lines.append(f"| `{rule}` | {count} |")
lines.append("")
lines.append(f"{len(distinct)} distinct lines flagged across `src/`.")
return lines


def main() -> int:
report_path = sys.argv[1]
exit_code = sys.argv[2] if len(sys.argv) > 2 else "0"
rule_hits_path = sys.argv[3] if len(sys.argv) > 3 else ""

out: list[str] = [
MARKER,
"## Code Erosion Report",
"",
"_Informational — **never blocks the merge**. `scb-check==0.2.0` "
"SlopCodeBench metrics; method & caveats in `docs/ci-code-erosion.md`._",
"",
]

try:
with open(report_path, encoding="utf-8") as handle:
data = json.load(handle)
except (OSError, ValueError) as error:
out.append(
f"> scb-check produced no parseable report (exit {exit_code}): "
f"`{error}`",
)
print("\n".join(out))
return 0

if exit_code == "2":
out.append(
"> **Warning:** scb-check exited 2 (path/config error). "
"Numbers below may be incomplete.",
)
out.append("")

# scb-check's JSON keys are pinned via the 0.2.0 version pin, but a future
# bump could rename one. Never crash on that — this report must not block
# CI, so a missing key degrades to a warning instead of an exception.
try:
verbosity = float(data["verbosity"])
erosion = float(data["erosion"])
cog_erosion = float(data["cog_erosion"])
files_scanned = data["files_scanned"]
total_loc = data["total_loc"]
high_cc = data["high_cc_functions"]
total_functions = data["total_functions"]
except (KeyError, TypeError, ValueError) as error:
out.append(
f"> scb-check report is missing an expected field (`{error}`). "
"The tool version may have changed its JSON schema; update "
"`.github/scripts/erosion-summary.py`.",
)
print("\n".join(out))
return 0

out.extend(
[
"| Metric | Value | Human band | Agent band | Reading |",
"|---|---|---|---|---|",
f"| Verbosity\\* | {verbosity:.3f} | 0.15 | 0.33 | "
f"{band(verbosity, 0.21, 0.23)} |",
f"| Erosion | {erosion:.3f} | 0.31 | 0.68 | "
f"{band(erosion, 0.48, 0.48)} |",
f"| Cognitive erosion | {cog_erosion:.3f} | – | – | – |",
"",
f"Scanned {files_scanned} files / {total_loc} SLOC "
f"· high-CC functions {high_cc}/{total_functions}",
"",
"\\* On TypeScript, `verbosity` is partial and the bands are "
"Python-calibrated.",
],
)

if rule_hits_path:
out.extend(rule_hits_section(rule_hits_path))

print("\n".join(out))
return 0


if __name__ == "__main__":
raise SystemExit(main())
128 changes: 128 additions & 0 deletions .github/workflows/code-erosion.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: Code Erosion

# Informational "slop" metrics for the TypeScript source, using the official
# scb-check (SlopCodeBench) tool. This workflow NEVER blocks CI: scb-check's
# exit code is deliberately swallowed and the numbers are posted to the PR (as
# a comment, with the job summary as a fallback) for reviewers to eyeball. It
# runs fully independently of ci.yml and shares no state with it.
#
# Caveat (documented in docs/ci-code-erosion.md): scb-check's ast-grep
# verbosity rules are Python-only, so on TypeScript the `verbosity` number
# reflects clone + wrapper detection only (the 197 syntax rules contribute 0).
# `erosion` is fully faithful on TypeScript. Read the numbers accordingly.

on:
pull_request:
branches:
- master
- main

# One run per PR; cancel superseded runs. Separate group from ci.yml so the
# two workflows never cancel each other.
concurrency:
group: code-erosion-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

# pull-requests:write lets us post the report comment. On PRs from forks the
# token is downgraded to read-only and the comment step degrades gracefully to
# the job summary (see the comment step's try/catch).
permissions:
contents: read
pull-requests: write

jobs:
erosion:
name: Code erosion metrics
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4

# setup-uv no longer publishes moving major tags; pin to a released tag.
- name: Setup uv
uses: astral-sh/setup-uv@v10.1.0

# scb-check exits 1 when it finds slop (the normal case for any real
# codebase) and 2 on path/config errors. Neither may fail this job, so we
# record the code (instead of failing) and hand it to the renderer, which
# surfaces a genuine exit-2 error while still never blocking. scb-check is
# pinned to 0.2.0 — the first release with TypeScript support (0.1.3, the
# version the paper pins, is Python-only). We scan src with an absolute
# path because uvx runs from a temp dir, and point --config at the repo
# root so test files are excluded (see scb-check.toml).
- name: Run scb-check
run: |
set +e
uvx --from 'scb-check==0.2.0' scb-check check "$GITHUB_WORKSPACE/src" \
--report --include-all \
--config "$GITHUB_WORKSPACE/scb-check.toml" \
> "$RUNNER_TEMP/erosion.json"
echo "scb_exit=$?" >> "$GITHUB_ENV"
set -e

# Independent TS-verbosity layer: scb-check only runs its ast-grep rules
# on Python files, so on this TS repo we run a standalone ast-grep with a
# small hand-ported rule set (see .github/ast-grep-rules/ts-verbosity.yml).
# Rules use severity: hint so scan never exits non-zero on a match; the
# `|| true` is a belt-and-braces guard. Pinned to the same ast-grep line
# scb-check bundles. Empty output on any failure just omits the section.
- name: Rule scan (TS verbosity)
run: |
npx --yes -p @ast-grep/cli@0.45.3 ast-grep scan \
-r "$GITHUB_WORKSPACE/.github/ast-grep-rules/ts-verbosity.yml" \
--json=stream "$GITHUB_WORKSPACE/src" \
> "$RUNNER_TEMP/rule-hits.jsonl" || true

# The renderer is written to always exit 0, but this whole workflow is
# informational — a bug in the renderer itself must not fail the job
# either, so we guard the step regardless.
- name: Render report
run: |
python3 "$GITHUB_WORKSPACE/.github/scripts/erosion-summary.py" \
"$RUNNER_TEMP/erosion.json" "$scb_exit" "$RUNNER_TEMP/rule-hits.jsonl" \
| tee "$RUNNER_TEMP/erosion.md" >> "$GITHUB_STEP_SUMMARY" || true

# Post or update a single PR comment, keyed by the marker the renderer
# emits, so repeated pushes edit one comment instead of stacking new ones.
# A read-only fork token makes this throw; we warn and rely on the job
# summary rather than failing the run.
- name: Post or update PR comment
uses: actions/github-script@v7
with:
script: |
const fs = require('fs');
const marker = '<!-- code-erosion-report -->';
try {
const body = fs.readFileSync(process.env.RUNNER_TEMP + '/erosion.md', 'utf8');
// Without the marker (e.g. the renderer itself crashed and left an
// empty file) we can't dedupe, so skip rather than stack a new
// marker-less comment on every run.
if (!body.includes(marker)) {
core.warning('code-erosion: report has no marker; skipping the PR comment. See the job summary.');
return;
}
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
per_page: 100,
});
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,
});
} else {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body,
});
}
} catch (error) {
core.warning(`code-erosion: could not post the PR comment (${error.message}). See the job summary.`);
}
Loading
Loading