From e385199447521bbfeb4edee47c6d3070cdf39939 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 16 Sep 2026 17:06:13 +0800 Subject: [PATCH 1/4] ci: add informational code-erosion (slop metrics) workflow Report SlopCodeBench verbosity/erosion metrics on every PR using the official scb-check tool, pinned to 0.2.0 (the first release with TypeScript support; 0.1.3 is Python-only). The workflow is informational and never blocks a merge: scb-check's exit code is swallowed, and the numbers are posted as a deduplicated PR comment with the run's job summary as a fallback (so fork PRs, whose token is read-only, still surface the report). Tests are excluded via scb-check.toml so metrics reflect the product surface. On TypeScript the ast-grep verbosity rule component is Python-only and contributes 0, so verbosity reflects clone + wrapper detection only; erosion is fully faithful. This caveat is documented in the bilingual docs/ci-code-erosion.{md,zh-CN.md}. --- .github/CONTRIBUTING.md | 2 + .github/scripts/erosion-summary.py | 105 ++++++++++++++++++++++++++ .github/workflows/code-erosion.yml | 115 +++++++++++++++++++++++++++++ docs/ci-code-erosion.md | 101 +++++++++++++++++++++++++ docs/ci-code-erosion.zh-CN.md | 90 ++++++++++++++++++++++ scb-check.toml | 8 ++ 6 files changed, 421 insertions(+) create mode 100644 .github/scripts/erosion-summary.py create mode 100644 .github/workflows/code-erosion.yml create mode 100644 docs/ci-code-erosion.md create mode 100644 docs/ci-code-erosion.zh-CN.md create mode 100644 scb-check.toml diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 99aa2712..9f784d0d 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -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. diff --git a/.github/scripts/erosion-summary.py b/.github/scripts/erosion-summary.py new file mode 100644 index 00000000..b3bdbcfa --- /dev/null +++ b/.github/scripts/erosion-summary.py @@ -0,0 +1,105 @@ +#!/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 and the +scb-check exit code 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 + +MARKER = "" + + +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 main() -> int: + report_path = sys.argv[1] + exit_code = sys.argv[2] if len(sys.argv) > 2 else "0" + + out: list[str] = [ + MARKER, + "## Code Erosion Report (informational)", + "", + "Reported by `scb-check==0.2.0` (SlopCodeBench metrics). " + "**This never blocks CI.**", + "", + ] + + 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` covers clone + wrapper detection " + "only (scb-check's 197 ast-grep rules are Python-only). `erosion` " + "is faithful. Reference bands are Python-calibrated — read them " + "as direction, not verdict. See `docs/ci-code-erosion.md`.", + ], + ) + + print("\n".join(out)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.github/workflows/code-erosion.yml b/.github/workflows/code-erosion.yml new file mode 100644 index 00000000..a22d99b1 --- /dev/null +++ b/.github/workflows/code-erosion.yml @@ -0,0 +1,115 @@ +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 + + # 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" \ + | 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 = ''; + 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.`); + } diff --git a/docs/ci-code-erosion.md b/docs/ci-code-erosion.md new file mode 100644 index 00000000..de417079 --- /dev/null +++ b/docs/ci-code-erosion.md @@ -0,0 +1,101 @@ +# CI Code Erosion (informational) + +> [English](ci-code-erosion.md) | [简体中文](ci-code-erosion.zh-CN.md) + +The `Code Erosion` workflow (`.github/workflows/code-erosion.yml`) reports two +"code sloppiness" metrics on every pull request, using the official +[`scb-check`](https://pypi.org/project/scb-check/) tool (the SlopCodeBench +reference implementation, from [Measuring the sloppiness of +code](https://earendil.com/posts/measuring-code-sloppiness/)). + +It is **informational only — it never blocks a merge.** The numbers are posted +as a PR comment (and mirrored to the run's job summary) for reviewers to +eyeball; they do not gate anything. + +--- + +## What it measures + +| Metric | Meaning | +|---|---| +| **Verbosity** | Fraction of source lines that are redundant: `\|clone lines ∪ wrapper lines ∪ ast-grep rule hits\| / SLOC`. | +| **Erosion** | Concentration of complexity in already-complex functions: `mass(f) = CC(f) × √SLOC(f)`; the share of total mass held by functions with cyclomatic complexity `> 10`. | +| **Cognitive erosion** | Same shape as erosion, but weighted by cognitive complexity instead of cyclomatic. An extra signal `scb-check` provides. | + +Reference bands from the source post (calibrated on **Python** repos): + +| Metric | Human repos | Agent-generated | +|---|---|---| +| Verbosity | 0.15 ± 0.06 | 0.33 ± 0.10 | +| Erosion | 0.31 ± 0.17 | 0.68 ± 0.20 | + +--- + +## Important caveat for this TypeScript repo + +`scb-check`'s verbosity signal has three components: clone detection, trivial +wrappers, and **197 hand-authored ast-grep rules**. Those 197 rules are +**Python-only** — they encode Python-specific wasteful patterns (dict idioms, +comprehensions, `for i in range(len(...))`, …) that have no TypeScript +equivalent. On this repo they contribute **0**. + +So read the numbers this way: + +- **`erosion` / `cognitive erosion` are faithful.** Cyclomatic and cognitive + complexity are language-agnostic; the TypeScript implementation uses the same + algorithm as Python. +- **`verbosity` is partial.** It reflects clone + wrapper detection only. The + source post notes clones drive ~66% of agent slop growth and the ast-grep + rules only ~15.6%, so the number still captures the larger part — but it is + **not** directly comparable to the paper's verbosity figures or the + Python-calibrated bands. Treat the bands as *direction*, not verdict. + +--- + +## Current baseline + +Scanned `src/` (tests excluded, see `scb-check.toml`), `scb-check==0.2.0`: + +| Metric | Value | Reading | +|---|---|---| +| Verbosity | ~0.092 | below the human band | +| Erosion | ~0.65 | inside the agent band | +| Cognitive erosion | ~0.86 | — | + +Erosion sits in the agent band mainly because of a handful of very large +functions (e.g. `pullForScope`, `init`, `pushCore`). That is the actionable +part of this report if the team ever wants to bring the number down. + +--- + +## Details + +- **Trigger:** any PR targeting `master` / `main`. +- **Tool version:** pinned to `scb-check==0.2.0` — the first release with + TypeScript support. The version the paper pins, `0.1.3`, is Python-only. + Pinning also keeps the numbers comparable across runs (the rule set changes + between releases). +- **Scope:** `src/`, excluding `**/__tests__/**` and `*.test.ts` / `*.spec.ts` + (configured in `scb-check.toml`), so metrics reflect the product surface, not + the far larger test suite. +- **Never blocks:** `scb-check` exits non-zero when it finds any slop (the + normal case). The workflow deliberately swallows that exit code; the job is + always green. +- **Fork PRs:** the PR-comment step needs a write token, which fork PRs don't + get. It degrades gracefully to the job summary — nothing fails. + +## Run it locally + +```bash +uvx --from 'scb-check==0.2.0' scb-check check src \ + --report --include-all --config scb-check.toml +``` + +Add `--output-format human` for a readable console table, or drop `--report` +for the default human output. + +## Where to read results + +1. **PR comment** — one comment per PR, updated in place on each push. +2. **Job summary** — the same table on the workflow run's summary page (the + only surface on fork PRs). diff --git a/docs/ci-code-erosion.zh-CN.md b/docs/ci-code-erosion.zh-CN.md new file mode 100644 index 00000000..1ad64f30 --- /dev/null +++ b/docs/ci-code-erosion.zh-CN.md @@ -0,0 +1,90 @@ +# CI 代码侵蚀检测(informational) + +> [English](ci-code-erosion.md) | [简体中文](ci-code-erosion.zh-CN.md) + +`Code Erosion` 工作流(`.github/workflows/code-erosion.yml`)会在每个 PR 上报告两项 +「代码 slop(邋遢度)」指标,使用官方 +[`scb-check`](https://pypi.org/project/scb-check/) 工具(SlopCodeBench 的权威实现, +出自 [Measuring the sloppiness of code](https://earendil.com/posts/measuring-code-sloppiness/))。 + +它是 **informational——只报告,绝不阻塞合入。** 数值以 PR 评论形式发出(并同步写到 +运行的 job summary),供 reviewer 参考,不做任何门禁。 + +--- + +## 测什么 + +| 指标 | 含义 | +|---|---| +| **Verbosity(冗余度)** | 冗余源码行占比:`\|重复行 ∪ wrapper 行 ∪ ast-grep 规则命中行\| / SLOC`。 | +| **Erosion(侵蚀度)** | 复杂度向已经很复杂的函数集中的程度:`mass(f) = CC(f) × √SLOC(f)`;圈复杂度 `> 10` 的函数占总 mass 的比例。 | +| **Cognitive erosion(认知侵蚀)** | 与 erosion 同公式,但用认知复杂度而非圈复杂度加权。`scb-check` 额外提供的信号。 | + +原文给出的参考区间(基于 **Python** 仓库校准): + +| 指标 | 人类仓库 | Agent 生成 | +|---|---|---| +| Verbosity | 0.15 ± 0.06 | 0.33 ± 0.10 | +| Erosion | 0.31 ± 0.17 | 0.68 ± 0.20 | + +--- + +## 本 TypeScript 仓库的重要说明 + +`scb-check` 的 verbosity 由三部分组成:重复代码检测、trivial wrapper,以及 +**197 条手写 ast-grep 规则**。这 197 条规则是 **Python 专属的**——它们编码的是 +Python 特有的啰嗦写法(dict 惯用法、推导式、`for i in range(len(...))` 等), +在 TypeScript 里没有对应语法。在本仓库它们贡献 **0**。 + +因此数值要这样读: + +- **`erosion` / `cognitive erosion` 是忠实的。** 圈复杂度与认知复杂度都是语言无关的, + TypeScript 实现与 Python 用的是同一套算法。 +- **`verbosity` 是不完整的。** 它只反映重复代码 + wrapper 检测。原文指出重复代码占 + agent slop 增长的约 66%、ast-grep 规则仅约 15.6%,所以这个数仍抓住了主要部分——但它 + **不能**直接与论文的 verbosity 数值或 Python 校准的区间对比。参考区间只当**方向**看, + 别当结论。 + +--- + +## 当前基线 + +扫描 `src/`(已排除测试,见 `scb-check.toml`),`scb-check==0.2.0`: + +| 指标 | 数值 | 读法 | +|---|---|---| +| Verbosity | ~0.092 | 低于人类区间 | +| Erosion | ~0.65 | 落在 agent 区间 | +| Cognitive erosion | ~0.86 | — | + +Erosion 落在 agent 区间,主要是因为少数几个超大函数(如 `pullForScope`、`init`、 +`pushCore`)。如果团队将来想把这个数降下来,这几个函数就是最有行动价值的着手点。 + +--- + +## 细节 + +- **触发:** 任何指向 `master` / `main` 的 PR。 +- **工具版本:** pin 在 `scb-check==0.2.0`——第一个支持 TypeScript 的版本。论文 pin 的 + `0.1.3` 只支持 Python。pin 版本也能让数值跨运行可比(规则集会随版本变化)。 +- **范围:** `src/`,排除 `**/__tests__/**` 和 `*.test.ts` / `*.spec.ts` + (在 `scb-check.toml` 中配置),让指标反映产品代码而非远大得多的测试套件。 +- **绝不阻塞:** `scb-check` 一旦发现任何 slop 就返回非零退出码(这是常态)。工作流 + 刻意吞掉这个退出码,job 永远是绿的。 +- **fork PR:** 发 PR 评论需要写权限,fork PR 拿不到。此时优雅降级到 job summary—— + 不会失败。 + +## 本地运行 + +```bash +uvx --from 'scb-check==0.2.0' scb-check check src \ + --report --include-all --config scb-check.toml +``` + +想要可读的控制台表格就加 `--output-format human`,或去掉 `--report` 用默认的 +human 输出。 + +## 去哪看结果 + +1. **PR 评论**——每个 PR 一条评论,每次 push 就地更新。 +2. **Job summary**——工作流运行的 summary 页上同一张表(fork PR 上唯一的呈现处)。 diff --git a/scb-check.toml b/scb-check.toml new file mode 100644 index 00000000..e65c1602 --- /dev/null +++ b/scb-check.toml @@ -0,0 +1,8 @@ +# Informational slop-metric config for scb-check (see docs/ci-code-erosion.md). +# Exclude test code so verbosity/erosion reflect the product surface, not the +# far larger and structurally different test suite. +exclude = [ + "**/__tests__/**", + "**/*.test.ts", + "**/*.spec.ts", +] From 910ed068285adbc697a55337ab8aa433f2102708 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Wed, 16 Sep 2026 21:06:37 +0800 Subject: [PATCH 2/4] ci(code-erosion): add independent TS verbosity rule layer scb-check only runs its ast-grep rules on Python files, so on this TypeScript repo its verbosity rule component is always 0. This adds a standalone ast-grep pass with a small, hand-ported rule set to fill that gap, reported as a separate "Rule hits (TS verbosity layer)" section in the same non-blocking PR comment. Only purely structural rules are ported. Rules that hinge on truthiness or type semantics (len==0, ==True, redundant template strings) 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). Ported rules verified against src/ for false positives: unnecessary-else-after-return, empty-catch-block, redundant-ternary-same, if-return-boolean-literal, return-ternary-boolean-literal, duplicated-if-condition, self-assignment. Rules use severity: hint and the scan step has `|| true`, so the layer never blocks CI. Bilingual docs updated with the honest scope: this is an extra signal, not a reproduction of the paper's verbosity number. --- .github/ast-grep-rules/ts-verbosity.yml | 83 +++++++++++++++++++++++++ .github/scripts/erosion-summary.py | 52 +++++++++++++++- .github/workflows/code-erosion.yml | 15 ++++- docs/ci-code-erosion.md | 30 +++++++++ docs/ci-code-erosion.zh-CN.md | 27 ++++++++ 5 files changed, 203 insertions(+), 4 deletions(-) create mode 100644 .github/ast-grep-rules/ts-verbosity.yml diff --git a/.github/ast-grep-rules/ts-verbosity.yml b/.github/ast-grep-rules/ts-verbosity.yml new file mode 100644 index 00000000..2ca9b7ae --- /dev/null +++ b/.github/ast-grep-rules/ts-verbosity.yml @@ -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;" diff --git a/.github/scripts/erosion-summary.py b/.github/scripts/erosion-summary.py index b3bdbcfa..1b2ea574 100644 --- a/.github/scripts/erosion-summary.py +++ b/.github/scripts/erosion-summary.py @@ -1,9 +1,10 @@ #!/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 and the -scb-check exit code from argv, writes Markdown to stdout. The workflow feeds -that Markdown both into the PR comment and the job summary. +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. @@ -13,6 +14,7 @@ import json import sys +from collections import Counter MARKER = "" @@ -26,9 +28,50 @@ def band(value: float, human_hi: float, agent_lo: float) -> str: 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)", + "", + "Structural slop rules ported from SlopCodeBench and run via a " + "standalone `ast-grep` (scb-check only rules Python files). " + "**Separate from the verbosity number above; also non-blocking.**", + "", + ] + 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, @@ -97,6 +140,9 @@ def main() -> int: ], ) + if rule_hits_path: + out.extend(rule_hits_section(rule_hits_path)) + print("\n".join(out)) return 0 diff --git a/.github/workflows/code-erosion.yml b/.github/workflows/code-erosion.yml index a22d99b1..40a2197c 100644 --- a/.github/workflows/code-erosion.yml +++ b/.github/workflows/code-erosion.yml @@ -60,13 +60,26 @@ jobs: 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/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 diff --git a/docs/ci-code-erosion.md b/docs/ci-code-erosion.md index de417079..d1e83311 100644 --- a/docs/ci-code-erosion.md +++ b/docs/ci-code-erosion.md @@ -94,6 +94,36 @@ uvx --from 'scb-check==0.2.0' scb-check check src \ Add `--output-format human` for a readable console table, or drop `--report` for the default human output. +## TS verbosity rule layer + +Because scb-check's ast-grep rules never run on TypeScript (see the caveat +above), a small **independent** layer runs a standalone `ast-grep` with a +hand-ported rule set at `.github/ast-grep-rules/ts-verbosity.yml`, and the +report gains a `Rule hits (TS verbosity layer)` table. + +Honest scope: this is **not** a reproduction of the paper's verbosity number. +It is an extra, separate signal. Of SlopCodeBench's 197 Python rules, roughly +half are Python-syntax-specific (dict idioms, comprehensions, `typing`) and +cannot exist in TypeScript. Of the rest, only **purely structural** rules were +ported — rules that hinge on truthiness or type semantics were tried and +**deliberately dropped** because they are false positives in TypeScript: + +- `len(x) == 0` → `arr.length > 0` is idiomatic TS, not slop. +- `x == True` → `x !== true` handles `boolean | undefined` and is *not* + equivalent to `x === false` (TS has `undefined`; Python does not). +- template-string checks mostly flag multi-line string concatenation. + +What is ported (all structural, verified against `src/` for false positives): +`unnecessary-else-after-return`, `empty-catch-block`, `redundant-ternary-same`, +`if-return-boolean-literal`, `return-ternary-boolean-literal`, +`duplicated-if-condition`, `self-assignment`. Rules use `severity: hint` so the +scan never blocks CI. Run it locally with: + +```bash +npx -p @ast-grep/cli@0.45.3 ast-grep scan \ + -r .github/ast-grep-rules/ts-verbosity.yml --json=stream src +``` + ## Where to read results 1. **PR comment** — one comment per PR, updated in place on each push. diff --git a/docs/ci-code-erosion.zh-CN.md b/docs/ci-code-erosion.zh-CN.md index 1ad64f30..55b5f398 100644 --- a/docs/ci-code-erosion.zh-CN.md +++ b/docs/ci-code-erosion.zh-CN.md @@ -84,6 +84,33 @@ uvx --from 'scb-check==0.2.0' scb-check check src \ 想要可读的控制台表格就加 `--output-format human`,或去掉 `--report` 用默认的 human 输出。 +## TS verbosity 规则层 + +因为 scb-check 的 ast-grep 规则在 TypeScript 上永远不跑(见上文局限),我们额外跑一层 +**独立的** `ast-grep`,用手工移植的规则集 `.github/ast-grep-rules/ts-verbosity.yml`, +报告里会多出一张 `Rule hits (TS verbosity layer)` 表。 + +诚实边界:这**不是**还原了论文的 verbosity 数值,而是一个额外、独立的信号。 +SlopCodeBench 的 197 条 Python 规则里约一半是 Python 语法专属(dict 惯用法、推导式、 +`typing`),在 TypeScript 里根本不存在;剩下的里只移植了**纯结构性**的规则——涉及 +真值/类型语义的都试过并**刻意弃用**,因为它们在 TypeScript 里是假阳性: + +- `len(x) == 0` → TS 的 `arr.length > 0` 是地道写法,不是 slop。 +- `x == True` → TS 的 `x !== true` 处理 `boolean | undefined`,与 `x === false` + **不等价**(TS 有 `undefined`,Python 没有)。 +- 模板串检查大多误伤多行字符串拼接。 + +已移植的(全部纯结构性,且在 `src/` 上验过无假阳性): +`unnecessary-else-after-return`、`empty-catch-block`、`redundant-ternary-same`、 +`if-return-boolean-literal`、`return-ternary-boolean-literal`、 +`duplicated-if-condition`、`self-assignment`。规则用 `severity: hint`,扫描永不卡关。 +本地运行: + +```bash +npx -p @ast-grep/cli@0.45.3 ast-grep scan \ + -r .github/ast-grep-rules/ts-verbosity.yml --json=stream src +``` + ## 去哪看结果 1. **PR 评论**——每个 PR 一条评论,每次 push 就地更新。 From 51f7640616bdc05f8c6d4d3e2e5fbb702eb3f0bc Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Thu, 17 Sep 2026 11:10:00 +0800 Subject: [PATCH 3/4] ci(code-erosion): slim down the PR comment, defer detail to docs The comment carried long inline explanations (verbosity footnote, rule-layer paragraph). Move the prose to docs/ci-code-erosion.md and keep the comment to numbers plus a one-line pointer. Also replace the ambiguous "(informational)" tag with plain "never blocks the merge". --- .github/scripts/erosion-summary.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/scripts/erosion-summary.py b/.github/scripts/erosion-summary.py index 1b2ea574..d7c21982 100644 --- a/.github/scripts/erosion-summary.py +++ b/.github/scripts/erosion-summary.py @@ -45,9 +45,8 @@ def rule_hits_section(path: str) -> list[str]: "", "### Rule hits (TS verbosity layer)", "", - "Structural slop rules ported from SlopCodeBench and run via a " - "standalone `ast-grep` (scb-check only rules Python files). " - "**Separate from the verbosity number above; also non-blocking.**", + "_Standalone `ast-grep`, separate from the number above — see " + "`docs/ci-code-erosion.md`._", "", ] if not hits: @@ -75,10 +74,10 @@ def main() -> int: out: list[str] = [ MARKER, - "## Code Erosion Report (informational)", + "## Code Erosion Report", "", - "Reported by `scb-check==0.2.0` (SlopCodeBench metrics). " - "**This never blocks CI.**", + "_Informational — **never blocks the merge**. `scb-check==0.2.0` " + "SlopCodeBench metrics; method & caveats in `docs/ci-code-erosion.md`._", "", ] @@ -133,10 +132,8 @@ def main() -> int: f"Scanned {files_scanned} files / {total_loc} SLOC " f"· high-CC functions {high_cc}/{total_functions}", "", - "\\* On TypeScript, `verbosity` covers clone + wrapper detection " - "only (scb-check's 197 ast-grep rules are Python-only). `erosion` " - "is faithful. Reference bands are Python-calibrated — read them " - "as direction, not verdict. See `docs/ci-code-erosion.md`.", + "\\* On TypeScript, `verbosity` is partial and the bands are " + "Python-calibrated — see `docs/ci-code-erosion.md`.", ], ) From 52653d19680f2f8937d6b02266cb17422a2426e7 Mon Sep 17 00:00:00 2001 From: jeffyxu Date: Thu, 17 Sep 2026 11:14:34 +0800 Subject: [PATCH 4/4] ci(code-erosion): drop the two repeated doc links in the comment The top line already points to docs/ci-code-erosion.md; the verbosity footnote and rule-layer note repeated the same link. Keep one pointer. --- .github/scripts/erosion-summary.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/scripts/erosion-summary.py b/.github/scripts/erosion-summary.py index d7c21982..d5085734 100644 --- a/.github/scripts/erosion-summary.py +++ b/.github/scripts/erosion-summary.py @@ -45,8 +45,7 @@ def rule_hits_section(path: str) -> list[str]: "", "### Rule hits (TS verbosity layer)", "", - "_Standalone `ast-grep`, separate from the number above — see " - "`docs/ci-code-erosion.md`._", + "_Standalone `ast-grep`, separate from the number above._", "", ] if not hits: @@ -133,7 +132,7 @@ def main() -> int: f"· high-CC functions {high_cc}/{total_functions}", "", "\\* On TypeScript, `verbosity` is partial and the bands are " - "Python-calibrated — see `docs/ci-code-erosion.md`.", + "Python-calibrated.", ], )