个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Whats wrong with your repo?
翻译准备中
取自仓库 README
正在加载完整 README。
查看完整 README ↗A repository health & decay monitor. It scans a codebase for the kinds of rot that accumulate silently — undocumented env vars, abandoned & vulnerable dependencies, stale branches, aging TODOs, committed secrets, dead & commented-out code, disabled tests, and binary bloat — scores it A–F, and shows everything in a dashboard. An optional AI pass adds recommendations and verification steps via OpenRouter.
npx repo-janitor scan .
No clone, no install, no signup — a grade and a ranked list of findings for the
repo you're standing in. --format json|md|sarif and --output <file> if you
want it in a file or in CI. Add --fix to print the top quick-wins first.
Large card (github-readme-stats size):
[](https://repo-anti-rot.onrender.com/r/<owner>/<name>/<token>)
Small badge (title-line strip):
[](https://repo-anti-rot.onrender.com/r/<owner>/<name>/<token>)
Two optional query params: theme=light for a light README, and motion=off
to stop the drifting background shapes. There used to be five more — layout
switches for the card and the badge — and they are gone: a picture with one good
layout does not need a control panel, and the panel was competing for attention
with the snippet people actually came for.
The badge and the card both draw faint grey shapes that drift behind the text —
small and sparse on the badge, larger and slower on the card. The card's
background colour is seeded the same way: the hue identifies the repository and
the saturation follows the grade, so an A is quietly tinted and an F is drained
to near grey. Every project looks a little different and any one project always
looks the same. prefers-reduced-motion is honoured when the SVG
is opened on its own, but browsers do not pass that preference into an image
embedded in a page — which is how a README shows it — so ?motion=off is the
reliable way to turn the movement off.
Website embed (not GitHub — READMEs strip iframes):
<iframe
src="https://repo-anti-rot.onrender.com/embed/<owner>/<name>/<token>"
title="Repo Anti-Rot"
width="420"
height="228"
style="border:0;border-radius:12px;overflow:hidden;background:transparent"
loading="lazy"
></iframe>
Create the snippets on the deployed site (not localhost) and keep Supabase configured so the share token survives redeploys. Re-scanning from the same browser refreshes the snapshot — the README URL stays put.
Everything below is for working on Repo Anti-Rot itself.
This is a monorepo:
| Path | Package | Role |
|---|---|---|
packages/core | @repo-anti-rot/core | Scanner engine, scoring, report schema, reporters |
packages/cli | repo-janitor | repo-janitor CLI — clones/scans a repo, emits a report (published to npm) |
packages/action | @repo-anti-rot/action | GitHub Action wrapper around the CLI |
app/, components/, lib/ | — | Next.js dashboard (App Router) |
The dashboard's /api/scan route shells out to the built CLI
(packages/cli/dist/index.js) to clone and scan repositories.
fetch)corepack enable and the pinned version (packageManager in package.json)
is provisioned automaticallygit on PATH (used by the scan engine and by the dashboard to clone repos)Works the same on macOS, Linux and Windows — every command below is a
portable pnpm/node invocation (no shell-specific syntax), the engine shells
out to git/node directly (no .cmd/.sh wrappers), and all paths go through
Node's path/os.tmpdir() so separators and temp dirs are handled per-OS.
For a Windows mini-server with Docker, a domain, persistent storage and automatic
maintenance, see Self-hosting. Run pnpm setup:server
to create a private .env.local without overwriting existing settings.
Для переноса на Windows-сервер с доменом repo-janitor.app: инструкция на русском.
The product review records positioning, implemented improvements and remaining work. The standalone auditscout tools are not part of the dashboard or its Docker image.
Important:
packages/*/distis git-ignored, so the compiled CLI is not in the repo. After cloning you must build it once — otherwise the dashboard's "New scan" / "Rescan" buttons will fail with "scan failed" becausepackages/cli/dist/index.jsdoesn't exist yet.The one exception is
packages/action/dist/index.cjs, which is committed: a GitHub Action runs straight from the tagged ref with no install step, so the bundle has to be in the repository oruses:resolves to a missing file. CI rebuilds it and fails if the committed copy has drifted from the source.
git clone https://github.com/YpCIIIaK/repo-janitor.git
cd repo-janitor
# install deps + build the CLI in one step
pnpm run setup
pnpm run setup is just:
pnpm install # installs deps and links the workspace packages
pnpm run build:cli # builds packages/cli/dist (required for dashboard scans)
The CLI is rebuilt automatically before the dashboard starts: pnpm dev
runs a fast ESM-only rebuild first (predev → build:cli without type
declarations, ~50 ms), and pnpm build runs the full build (prebuild). This
means the dashboard always scans with the current engine — no more "I changed
a scanner but the dashboard still shows the old score" (the dashboard executes the
bundled packages/cli/dist/index.js, which otherwise goes stale silently).
To rebuild by hand after changing packages/core or packages/cli:
pnpm run build:cli # full build (esm + cjs + .d.ts)
pnpm --filter repo-janitor build:fast # fast esm-only (what predev uses)
The whole workspace is covered by Vitest — suites in each
package (packages/*/test) plus the dashboard's pure modules (test/). They
exercise the scoring engine, every scanner and all reporters through an in-memory
ScanContext (packages/core/test/helpers.ts) — no real filesystem, git, or
network — plus the CLI/Action helpers and the dashboard's lib/* logic.
Everything is fast and deterministic.
pnpm test # run everything (dashboard + all packages)
pnpm test:dashboard # just the dashboard lib/* tests
pnpm --filter @repo-anti-rot/core test # scoped to the engine
pnpm --filter repo-janitor test # CLI helpers
pnpm --filter @repo-anti-rot/action test # Action helpers
Landing proof grades come from lib/proof-snapshot.json (or a live CI ingest when
present). Refresh the snapshot after scanner/score changes:
pnpm exec node scripts/refresh-proof-snapshot.mjs
Coverage highlights (850+ tests):
repo-anti-rot-ignore markers, config-driven weights, the lines-of-code metric
and progress callbacks.repo-anti-rot.json loading, weight merge, ignore globs, mute-rule
matching (id / category / path glob), and graceful fallback on malformed input.git stripping, local
fallback), safe report filenames, and the real git blame-age adapter (driven
against a throwaway repo with a back-dated commit, so finding ages can't
silently regress to zero)fail-on grade threshold, the ingest endpoint
builder, and PR-comment rendering (severity breakdown, 10-row cap, pipe escaping)lib/* modules: client score mirror, age histogram &
median, hotspot ranking, synonym/typo-tolerant issue search, report export
(JSON/Markdown/CSV with RFC-4180 escaping + UTF-8 BOM), schedule due-logic,
GitHub permalink building, and prefilled new-issue URL compositionwindow stub and a mocked completion transport:
snooze/won't-fix partitioning, AI settings (normalize, legacy migration, enabled
categories), the verdict cache (model scoping + LRU eviction), the proxy client's
429/5xx retry-with-backoff, and the enrichment/summary orchestration (per-category
batching, cache re-use, order-independent fingerprints, graceful no-key/empty paths)Add a new scanner test under packages/core/test/scanners/, build a context with
makeContext from packages/core/test/helpers.ts, and assert on the returned issues.
Dashboard lib/* tests live in the root test/ directory.
Continuous integration. .github/workflows/ci.yml builds the packages,
typechecks, runs the full test suite and builds the dashboard on a matrix of
Ubuntu, macOS and Windows (Node 20) for every push and pull request — so the
cross-platform support (path/line-ending normalization, the CLI shebang, git
invocation) is verified continuously, not just claimed.
pnpm dev # http://localhost:3000
Paste one or more public git URLs into New scan, or Rescan a repo already in the sidebar. Reports are stored in the browser (localStorage); a real progress bar streams live clone → per-scanner → AI progress.
Click any finding in the Issues tab to open a detail drawer with its full
context, code evidence, an on-demand AI verdict (one cheap model call, cached),
and quick actions — open the GitHub permalink, copy as Markdown, snooze it, or
Create issue. The last opens GitHub's own new-issue form with the title,
body (severity, category, detail, evidence and a permalink) and labels
(repo-anti-rot, severity) prefilled, so a finding becomes a tracked task in one
click. No token and no API call are involved — the link just prefills the form,
and you review and submit it on GitHub yourself.
Use Export (top-right, next to Rescan) to download the current report as Markdown (grouped by category, for PRs/docs), CSV (one row per finding, for spreadsheets) or JSON (the raw report). Everything is generated in the browser — nothing leaves your machine.
Press ⌘K / Ctrl+K (or the command button in the toolbar) to open the command palette: jump between repositories, switch tabs, start a new scan, or export the current report — all from the keyboard.
In Settings ▸ Scheduled scans you can have the dashboard auto-rescan your tracked repos on a flexible schedule:
HH:MM, with catch-up if
the tab was closed at that moment.Because repos and reports live in your browser, the scheduler runs only while a
tab is open (it checks once a minute and shows a toast when it runs). For truly
unattended scanning — machine asleep, no tab open — use the GitHub Action's
schedule: trigger in CI instead.
The CLI is published to npm as repo-janitor,
so you can run it against any checkout without cloning this repo:
# one-off, no install
npx repo-janitor scan --path . --format terminal
# or install globally
npm i -g repo-janitor
repo-janitor scan --path . --format sarif --output repo-anti-rot.sarif
Or run the workspace build directly from a checkout of this repo:
# scan a local checkout
node packages/cli/dist/index.js scan --path . --format terminal
# write a JSON report
node packages/cli/dist/index.js scan --path . --format json --output report.json
# write a SARIF file for GitHub code scanning
node packages/cli/dist/index.js scan --path . --format sarif --output repo-anti-rot.sarif
# scan many cloned repos under a directory
node packages/cli/dist/index.js batch ./repos --out-dir ./reports
Formats: terminal (default), json, md, sarif.
During development you can run the CLI from source without building:
pnpm --filter repo-janitor dev -- scan --path .
Open the Settings (gear icon), paste an
OpenRouter API key, pick a model id (free presets
provided — openai/gpt-oss-120b:free is the recommended free default and the
strongest of the free options for code reasoning, or
nvidia/nemotron-3-ultra-550b-a55b:free for a 1M-token window), and enable the
scanner categories you want analyzed. The key is stored
only in your browser and is sent through the app's own /api/ai/complete proxy —
never directly to a third party. For secret findings the snippet is redacted
before it leaves the machine.
A shared key can also be provided server-side via the OPENROUTER_API_KEY
environment variable. To stop that shared key from becoming an open, billable
proxy, the shared-key path requires Authorization: Bearer <REPO_ANTI_ROT_AI_PROXY_TOKEN>
and (optionally) an OPENROUTER_ALLOWED_MODELS whitelist; requests that carry
the user's own key are unaffected. Output size is always clamped. See
Security & hardening.
Findings in the security and dependency categories point at external advisories (CVE/GHSA pages, package registries) that may be newer than the model's training data — so the model can end up just paraphrasing the finding text. Toggle Check current sources in Settings to fetch exact CVE/GHSA records from OSV, npm latest-version metadata, and the linked GitHub repository's archive/activity status. The server supplies these facts to the model and displays source URLs with the answer. This is bounded source retrieval, not general web search: at most four advisories, two npm packages, and their GitHub repositories per request. Non-npm package metadata and identifiers outside this budget are not looked up.
It's off by default, uses no paid OpenRouter search plugin, and only fires for those two advisory-bearing categories — repo-internal findings (dead code, TODOs, hygiene) never trigger a web call. Web-informed verdicts are cached in a separate namespace, so toggling the option re-asks rather than serving a stale answer.
Successful source responses are cached in memory for one hour; failures for one minute.
The cache is process-local and resets on restart. Timeouts, rate limits, missing IDs,
and invalid responses are marked unverified, never interpreted as safety. Published
advisories do not prove exploitability in the scanned repository. Public API limits,
server costs, and model token charges still apply. :online model IDs are rejected
to prevent accidentally enabling paid search.
The Overview tab has an on-demand executive summary — one short, decisive
paragraph on the repo's overall health (verdict, the biggest concrete risks, and
the single highest-leverage next action). It costs exactly one model call and is
cached by model + the exact set of findings, so reopening the repo or rescanning
with no changes never re-asks. Only finding metadata (title, location, category,
severity) is sent — never the evidence snippet — so a redacted secret's masked
value still never leaves the machine. When Check current sources is on and the repo has a
security or dependency finding, the summary call also consults live advisories to
weight CVE severity accurately.
The per-finding enrichment is tuned to stay cheap on small/free models:
Share-link templates (card, badge, embed, query params) live under Put a card on your README at the top — copy those first. The Share dialog after a scan also offers one-click markdown / HTML.
GitHub READMEs cannot host iframes; use the SVG card or badge there. Self-hosted
deploys: swap repo-anti-rot.onrender.com for your origin.
After a scan (or on a shared report), leave an email on Watch for drops. No
account: we store a baseline grade/score for that public repo and email you only
when it falls meaningfully. Manage / unsubscribe are capability links
(/watch/<token>, one-click unsub). Recover the manage page anytime at /watch
(magic link to the same address).
Needs Supabase for the watch_subscriptions table (SQL in .env.example), plus
RESEND_API_KEY to actually send mail (without it, mail is logged server-side).
Point a daily cron at POST /api/cron/watch with Authorization: Bearer $CRON_SECRET
(see .github/workflows/watch-cron.yml).
From a scan you shared. Scan a repository, tick the consent box, create a share link — card and badge markdown are offered right there, ready to paste. No account, no Action, no self-hosting.
It is a snapshot, not a live widget. The token names one stored report, and sharing a newer scan mints a new token — so you get a new line to paste, and the old image keeps showing the scan it was made from. A badge that tracks a repository over time needs a stable per-repo address, which is what the CI form below provides.
The token grants no more through the card than the share page already shows
(grade, score, counts — no finding titles). A token belonging to a different
repository renders unknown rather than that repository's grade — a card sits
at the top of a README, and one project's score under another project's name
would make it a way to misrepresent.
Two things to get right before pasting one anywhere public:
localhost:3000 points at your own
machine and renders for nobody else.SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY).
Without it, shares are written to the container filesystem, which is ephemeral
on most hosts — a redeploy silently kills every link already posted. That is
bad for a link and worse for a badge, which lives at the top of a README and
degrades to unknown in front of everyone.From CI. Once a repo's report has been ingested (via the GitHub Action's
dashboard-url), the same endpoint serves a badge with no token at all:

Or make it clickable, linking to the repository's report page — same tokenless form, always the newest CI upload:
[](https://your-deploy.example.com/r/<owner>/<name>)
It renders repo anti-rot | <grade> <score>, colored by grade (green → amber →
red), and falls back to a neutral unknown badge for repos with no report (so a
README image never 404s). Optional query params: ?theme=light and
?motion=off (stops the drifting background shapes); both are accepted by
/api/card too.
The scanner can emit SARIF 2.1.0, the format GitHub code scanning consumes. Findings then appear as native annotations in the Security ▸ Code scanning tab and inline on pull-request diffs — no dashboard required.
The Action writes a SARIF file when given a sarif-file input; pair it with
github/codeql-action/upload-sarif:
permissions:
contents: read
security-events: write # required for upload-sarif
jobs:
anti-rot:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # full history for git-blame ages
- uses: YpCIIIaK/repo-janitor@v1 # or a full SHA — see the note below
with:
sarif-file: repo-anti-rot.sarif
- uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: repo-anti-rot.sarif
@v1 is a moving tag: it follows the latest v1 release, so bug fixes arrive
without you editing anything. That convenience is also a supply-chain risk, and
this repository's own workflow-security scanner will say so about any action
pinned to a tag — including this one. Pin the full commit SHA if you would rather
audit upgrades yourself.
Mapping: each finding becomes a SARIF result with level derived from severity
(critical → error, warning → warning, info → note), grouped into one rule
per category. File-anchored findings carry a path:line location; repo-level
ones (stale branches, missing standard files) are still reported, just without a
code location. A stable per-finding fingerprint lets GitHub track an alert across
runs instead of reopening it each scan.
When a report is ingested via POST /api/ingest (i.e. from the GitHub Action in
CI) and the repo's score is lower than its previous scan, the server can fire
a webhook so the team is alerted to the regression immediately. Opt-in via env:
| Variable | Description |
|---|---|
REPO_ANTI_ROT_WEBHOOK_URL | Destination URL. Unset → feature off. The body is Slack/Discord-compatible ({ "text": "…" }), which most custom receivers also accept. |
REPO_ANTI_ROT_WEBHOOK_MIN_DROP | Minimum score drop to alert on (default 1, i.e. any drop). Raise it to ignore small dips. |
REPO_ANTI_ROT_DASHBOARD_URL | Optional; appended to the message as a link. |
Example message:
🔴 acme/api health dropped: B (85) → D (52), −33. +2 new criticals. https://your-dashboard
It only fires on a real regression (score held or improved → silent), never on the first scan (nothing to compare), and a failed webhook never blocks ingestion. Note this is server-side: it works for reports POSTed to the dashboard (CI), not for local browser-only scans.
The server-side API routes are written to be safe to expose:
| Concern | Mitigation |
|---|---|
SSRF via /api/scan | The clone target must be a public http(s) URL. Loopback, private (10/8, 172.16/12, 192.168/16), link-local (169.254/16, incl. cloud metadata), CGNAT and reserved ranges are rejected — for IP literals and for DNS names whose resolved addresses land in private space (which also blunts DNS rebinding). |
| Open AI proxy / billing abuse | Using the shared OPENROUTER_API_KEY requires Authorization: Bearer <REPO_ANTI_ROT_AI_PROXY_TOKEN>; an optional OPENROUTER_ALLOWED_MODELS whitelist restricts models; maxTokens is clamped. Requests with the user's own key are unaffected. |
| Report ingestion | POST /api/ingest accepts a Bearer token in REPO_ANTI_ROT_INGEST_TOKEN (constant-time compared). Unset → open (local dev). |
| Report read access | GET /api/reports returns full reports (paths + redacted evidence). Set REPO_ANTI_ROT_READ_TOKEN to require a Bearer token for reads; unset → open so the in-browser dashboard works. (The badge endpoint stays open — it exposes only grade + score.) |
| Secrets → AI | For committed-secret findings the evidence snippet is redacted by the scanner before it ever reaches the AI proxy, and the executive summary sends finding metadata only — never evidence. |
| Command injection | git/node are spawned with an argv array (no shell), and the clone URL must parse as http(s), so it can't be coerced into a flag. |
All tokens are compared in constant time (crypto.timingSafeEqual over hashed
inputs). Token-gated checks are default-off so local development needs no
configuration; set the relevant env var to switch each one on.
The secrets scanner flags credentials committed to the repo using
high-confidence provider patterns (AWS, Stripe, GitHub, Slack, Google, private
keys) plus a generic high-entropy fallback for secret = "…"-style assignments.
It runs two passes, both reported under the Security category:
git log -p). This
catches a credential that was committed and later deleted: gone from the
tree, but still recoverable from history. History findings are located as
path @ <short-sha>, deduped (one finding per credential, newest commit wins),
and never double-reported when the secret still lives in the working tree. The
pass is bounded (recent commits, capped findings) and degrades to a no-op when
git isn't available — so a non-git directory still gets the working-tree scan.Evidence is always redacted (a 4-char prefix, the rest masked) before it
leaves the machine, in both passes. Secrets in test/example/fixture paths are
downgraded to info rather than dropped; .env.example and lockfiles are
skipped. A history secret is still critical: it remains in history until the
repo is rewritten (git filter-repo / BFG) and the key is rotated.
The vulnerable-deps scanner cross-references the project's dependencies against
the public OSV database and flags packages with known security
advisories (CVEs / GHSAs). No API key is required.
It is polyglot — Python, Go, Rust, Ruby and PHP projects get real findings too, not just npm. Each ecosystem is read from its manifests and lockfiles:
| Ecosystem | Manifest (floor) | Lockfile (exact) |
|---|---|---|
npm | package.json | package-lock.json, pnpm-lock.yaml, yarn.lock, npm-shrinkwrap.json |
PyPI | requirements.txt, pyproject.toml | poetry.lock, Pipfile.lock |
Go | go.mod | (versions are pinned in go.mod) |
crates.io | Cargo.toml | Cargo.lock |
RubyGems | Gemfile | Gemfile.lock |
Packagist | composer.json | composer.lock |
npm audit), not just the
handful in package.json. Transitive findings are labelled as such. Without a
lockfile it falls back to the declared deps only.CRITICAL/HIGH → critical, MODERATE → warning,
LOW → info; each finding links to the advisory on osv.dev, names the affected
ecosystem, and points at the fixed version to upgrade to.Like the other registry-backed checks, it degrades to a no-op offline (no network adapter) rather than failing the scan. npm dev dependencies are labelled.
Vulnerability findings and committed-secret findings share a single Security category in the dashboard and reports, so everything that is a genuine security risk surfaces together — separate from ordinary dependency-health issues.
Separate from vulnerabilities, two scanners flag dependencies that are simply falling behind:
dependency-funeral checks package.json deps for unused (static
import analysis), deprecated, abandoned and outdated (major/minor behind)
against the npm registry. Abandoned is graduated by publish gap: a 2-3 year
gap is an info note (many micro-libs are simply feature-complete), only a 3+
year gap is a warning. The unused check
also counts deps referenced only by an npm script or a build-config file
(postcss/eslint/etc.) and skips framework-implicit runtimes (e.g. react-dom),
so build tools and renderers aren't mistaken for dead deps. The outdated
check is range-aware — a caret (^) that already auto-accepts a newer minor
isn't reported as behind; only a true major gap (or a pinned/tilde dep behind
by a minor) is flagged.outdated-deps checks direct
deps from the manifest (requirements.txt / pyproject.toml, Cargo.toml,
Gemfile, go.mod, composer.json) against each registry: outdated (major →
warning, minor → info) and
abandoned where the registry exposes a publish date (PyPI, crates.io, Go;
RubyGems' single-call endpoint has none, so it's outdated-only).outdated-deps checks only direct dependencies (one registry call each, capped)
and is a no-op offline. No API keys are required for any registry.
Beyond the headline scanners, a set of lightweight hygiene checks catch everyday
rot:
Repo bloat (repo-bloat) — binary artifacts and oversized files committed
to git: archives, compiled binaries, db dumps (flagged regardless of size) and
any file over 5 MB or heavy media over 2 MB. Sizes come from fs.stat (no file
read), ranked largest-first. Suggests .gitignore / Git LFS.
Skipped & focused tests (skipped-tests) — it.skip / xit / it.todo
and pytest @skip mark coverage that no longer runs (info); it.only / fit /
fdescribe are flagged as a warning because focusing silently disables every
other test in the file, so CI can stay green while almost nothing runs.
Commented-out code (commented-code) — blocks of commented-out code (not
prose). Git already remembers deleted code, so these are pure noise. The check is
deliberately conservative: it fires only on runs of 3+ consecutive // lines
with structural code punctuation, skipping doc comments, license headers, inline
back-ticked prose and directives.
Dockerfile hygiene (dockerfile) — scans Dockerfile / *.dockerfile for
an unpinned base image (:latest or no tag → non-reproducible builds,
warning), running as root (no non-root USER, info) and ADD of a remote
URL (info). Digest-pinned images, scratch, build-args and multi-stage aliases
are exempt.
Insecure code (insecure-code) — dangerous constructs in your own source:
eval/new Function on non-literals, shell commands and SQL built by string
interpolation, disabled TLS verification, pickle/yaml.load, weak hashes and
Math.random used for secrets. Deliberately high-precision rather than
exhaustive — every rule that could fire on safe code requires evidence of
dynamic input, matches inside strings, regexes and comments are ignored (a
description of code is not code), and findings in test files drop one severity
step. JS/TS and Python.
Dead links (dead-links) — the external http(s) links your repo publishes,
checked one HEAD each: 404/410 are reported as dead, unreachable hosts are
reported separately because "the site is gone" and "the page moved" call for
different fixes. Collected from markdown, metadata and code
only — a URL in a string literal is an API endpoint, not a link
anyone follows. Private, loopback and reserved-documentation addresses are
never requested. Sibling to , which covers relative paths.
These join the existing hygiene scanners (missing project files / tests / CI,
leftover console/debugger, broken doc links, bus-factor risk).
The public pages — the landing page, the scan form on it, its results, and
the shared-report page at /r/… — are available in English and Russian, chosen
from Accept-Language and overridable with the switcher (the choice is kept in
the rar_locale cookie).
The dashboard is English only, on purpose: several hundred strings of table headers, filters and settings, re-translated with every feature, buys nothing until somebody asks. One exception, which is really a stronger rule — the consent text is translated wherever it appears, including in the dashboard, because agreeing to something you cannot read is not consent.
Scanner and category names (Dependency Funeral, Env Lifecycle) stay English
everywhere: they are identifiers that also appear in the CLI, the JSON report and
.repo-anti-rot.json, so a translated name would not match what you grep for.
Adding a locale is one object in lib/i18n.ts — every locale is
typed against the English table, so a missing key is a compile error rather than
a blank space at runtime.
Some checks are language-agnostic (git history, doc links, repo bloat); others are per-language. Coverage by scanner:
| Scanner | Languages |
|---|---|
secrets | language-agnostic (provider patterns + entropy); working tree + git history |
vulnerable-deps | npm, PyPI, Go, crates.io, RubyGems, Packagist (see above) |
env-lifecycle | JS/TS (AST), Python, Go, Ruby, PHP (incl. Laravel env()) |
leftover-debug | JS/TS (AST), Python, Go, Rust, Ruby, PHP |
todo-debt | JS/TS + Python, Go, Rust, Java, Ruby, PHP, C/C++, C#, Kotlin, Swift, Scala |
skipped-tests | JS/TS, Python |
commented-code | JS/TS, Java, C/C++, C#, Go, Rust, Swift, Kotlin, Scala, PHP |
bus-factor | all common source extensions |
dockerfile | Dockerfile, *.dockerfile |
lockfile-drift | npm (missing-lockfile + drift); Python, Rust, Ruby, Go, PHP (missing-lockfile) |
outdated-deps | PyPI, crates.io, RubyGems, Go, Packagist (npm covered by dependency-funeral) |
dead-code | JS/TS (cross-module unused exports, incl. dynamic import()/require and the @/ alias); Python, Go (unused symbols) |
dependency-funeral | JS/TS only |
For env vars, the scanner understands the idiomatic readers per language —
process.env.X (JS/TS), os.environ / os.getenv (Python), os.Getenv /
os.LookupEnv (Go), ENV["X"] / ENV.fetch (Ruby), getenv / $_ENV / Laravel
env() (PHP) —
and compares them against the first of .env.example, .env.sample,
.env.template or .env.dist. A reader with an in-code default is reported as
optional (info); a required, undocumented var is a warning. Platform/CI-provided
vars (GITHUB_*, RUNNER_*, INPUT_*, CI, NODE_ENV, NO_COLOR, …) are
excluded — they're injected by the runtime, not documented in .env.example.
leftover-debug treats console.log as the program's output (not debug) inside
CLI / GitHub Action entrypoints — files with a #! shebang, or the bin /
action.yml runs.main target of their package — so a CLI that prints results
isn't flagged. A stray debugger is still reported everywhere. todo-debt only
counts a marker that leads the comment (mirroring eslint no-warning-comments
with location: "start"), so prose that merely mentions "TODO" mid-sentence is
ignored.
.repo-anti-rot.json)An optional file at the repo root tunes the scanner for that project. It is committed with the repo, so its rules travel to CI and teammates (unlike Snooze, which is browser-local). Everything is optional and merges over the defaults — no file means default behaviour, and a partial file only overrides what it sets.
{
"ignore": ["vendor/**", "**/*.generated.ts"],
"mute": [
{ "id": "todo-src/legacy.ts:42", "reason": "tracked in JIRA-1234" },
{ "category": "dead-code", "path": "src/legacy/**" }
],
"weights": { "critical": 20, "warning": 3, "info": 0 }
}
ignore — extra glob patterns excluded from the scanned file set (on top of
the built-in node_modules, dist, etc.). Affects all file-based scanners.
mute — suppress individual findings you've reviewed and accepted, without
hiding the rest of their file (which is all ignore can do). Each rule matches a
finding when every field it sets matches; reason is free-text for your team.
id — the finding's stable id (the surgical option; copy it from the report).category — mute a whole category (todo, dead-code, security, …).path — glob (*, **, ?) against the finding's file path. ** spans
directories. Combine with category to mute, say, only TODOs under legacy/**.Muted findings are dropped before scoring, exactly like inline ignores — they don't count against the score and don't appear in the report or SARIF output.
weights — override the per-severity score penalties. Partial is fine
(e.g. just "info": 0); unspecified severities keep their defaults. The
effective weights are echoed into the report so the dashboard recomputes the
score identically.
Invalid JSON or unknown shapes are ignored with a warning (the scan never breaks).
Suppress a single false positive directly in code (eslint-style):
const legacyKey = process.env.OLD_TOKEN // repo-anti-rot-ignore
// repo-anti-rot-ignore-next-line
const noisy = computeThing()
// repo-anti-rot-ignore — suppresses a finding on that line.// repo-anti-rot-ignore-next-line — suppresses a finding on the next line.Only findings that resolve to a file:line location can be inline-ignored;
file-level findings (e.g. package.json, branches) use ignore or Snooze.
A check that fires on a healthy repository is a bug, not a finding. Reporting one is the most useful thing anyone outside this repository can do, so the path is deliberately short.
From the app. Open the finding and click Report false positive. It opens the issue form with the scanner id, the finding text, the location and the repository URL already filled in, leaving you the one field nobody else can answer: why the code is fine. Nothing is submitted on your behalf — you review and press the button on GitHub.
By hand. Same form, filled in yourself. The two things worth getting right
are the scanner id (duplicate-code, not "Code weight") and a public
repository the rule can be re-run against. If the code is private, please
reduce it to a small public repository that reproduces the finding rather than
pasting the original.
| Label | Meaning |
|---|---|
false-positive | Applied automatically by the form. The report is open. |
false-positive: confirmed | The rule was wrong and has been fixed. |
Only the second one counts. Filing a report is free and unscored — a count of reports would reward volume over being right, which is how this kind of arrangement usually goes bad: it becomes worth filing a genuine finding as a false positive, because the number goes up either way.
Once a report of yours is confirmed, you can put the count in your own README:
[](https://github.com/search?q=repo%3AYpCIIIaK%2Frepo-janitor+is%3Aissue+label%3A%22false-positive%3A+confirmed%22+author%3A%3Cyour-login%3E&type=issues)
Publish it linked to that search, which is the form lib/hunter.ts generates.
The badge is served to anyone who asks for any login, so the number is only worth
anything because a reader can run the same public search and get the same answer.
A credential nobody can check is a credential worth faking.
There is no account and no table behind this. Identity is GitHub's, the count is a public search, and nothing about it touches what the scanner stores or what the consent text promises.
Starts at 100 and subtracts weighted penalties: critical −10, warning −3,
info −0.25, then rounds and clamps to 0. Grades: A ≥ 90, B ≥ 75,
C ≥ 60, D ≥ 40, else F. Penalties are configurable per-repo via the
weights field in .repo-anti-rot.json (see above).
Within a severity tier the charge tapers: the first few findings cost their full weight (2 criticals, 8 warnings, 20 info notes), and after that each additional one costs less than the one before it — but never nothing. So a swarm of low-signal notes can't sink a repo harder than a genuine critical, while a repo with a hundred warnings still scores below one with fifty.
This replaced a hard per-tier cap, for two reasons. The cap made findings free:
past warning number fourteen the tool listed a finding and privately valued it at
zero, which is the tool disagreeing with itself in front of you. And it stopped
the scale from discriminating at the bottom — psf/requests and clap-rs/clap
both scored exactly 0/100, which is a scale that has stopped measuring. Under the
taper they score 11 and 2. Typical repositories are unaffected: the thresholds sit
where the old linear stretch ended, so ordinary scores move by at most a point.
Alongside the score the dashboard shows issue density — findings per 1000
lines of code — so repos of very different sizes can be compared fairly (raw
issue count alone favours small repos). The scan records non-blank source
lines in report.metrics.linesOfCode.
The Overview tab also surfaces hotspot files — the source files attracting the most rot, ranked by weighted penalty — so a fix can be targeted at the few files where decay concentrates instead of chasing scattered findings. Each file links straight to the pinned commit on GitHub.
The Tree tab visualizes a scan as an interactive graph (built on
React Flow): only the affected paths are drawn, folded
into a directory hierarchy, so you see exactly where rot concentrates without the
clutter of the whole repo. Each node aggregates the subtree's severity counts and
is colored by its worst finding; linear single-child directory chains are
collapsed (src/legacy/handler.js → one src/legacy node), and stale branches /
repo-wide findings sit in dedicated "Branches" / "Repository" buckets. Click a
folder to expand or collapse it, or a file to open an in-canvas card listing its
findings — with a jump straight to the filtered Issues tab or the file on GitHub.
Large trees (30+ nodes) auto-switch to a left-to-right layout and collapse deep
levels on first paint.
A toolbar keeps the map manageable: severity toggles (mute info, or focus on
criticals only — the tree rebuilds from just the active severities), a path
search that expands ancestors and pans/zooms to the first match, expand /
collapse all, and export to PNG or SVG. The tree-building, collapse and
search logic lives in lib/file-tree.ts (pure + unit-tested); the React Flow view
is lazy-loaded so it stays out of the initial bundle.
The History tab answers "when did this rot set in?" Rather than scanning one
snapshot, it clones a repo's history, samples its commits, and scans each —
then draws them as an interactive tree (also React Flow),
newest at the top. Each node shows that commit's grade, score, severity counts and
a +added / −fixed delta versus the previous scanned commit; click one to open a
side panel with its full finding list. Tagged releases and merge commits are
marked, so you can see health shift at the moments it most plausibly did.
Scanning every commit would be far too slow, so the sampling
(lib/commit-sampling.ts, pure + unit-tested) keeps a
fixed budget (default 18, configurable up to 40): it anchors both ends of history,
prioritizes tagged and merge commits, then fills with one commit per week for an
even spread. The clone is a partial (--filter=blob:none) clone so the whole
commit graph comes cheaply and file blobs are fetched lazily per checkout, and
every commit's report is cached by sha (lib/scan-cache.ts) —
re-opening the tree only rescans commits it hasn't seen. The endpoint
(POST /api/scan/history) streams NDJSON so the graph renders immediately and
fills in as each commit finishes.
The About tab answers "what is this repo made of?" at a glance: a language
breakdown (by file count and non-blank lines, with the long tail folded into an
"Other" bucket) and the ecosystems/tooling detected from manifest files
(Node.js, pnpm/Yarn/npm, TypeScript, Next.js, Vite, Tailwind, Docker, GitHub
Actions, Go modules, pip/Poetry, Cargo, Bundler, Composer, Maven/Gradle, …), plus
headline facts (files scanned, lines of code, grade). The data is produced by the
engine during its single read pass and shipped in the report's optional profile
field (packages/core/src/profile.ts — pure language/tool classification, unit-
tested); the dashboard renders shares via lib/repo-profile.ts. Reports created
before profiling shipped simply prompt for a rescan.
package.jsonbroken-doc-linksWorkflow security (workflow-security) — GitHub Actions workflows that hand
out more trust than they need: pull_request_target checking out the PR's own
code, untrusted github.event values interpolated straight into run:, actions
pinned to a mutable tag, and a missing top-level permissions: block.
End-of-life runtimes (eol-runtime) — Node, Python and GitHub-hosted runner
images the project is pinned to that no longer receive security patches, read
from .nvmrc, engines, requires-python, Dockerfiles and workflows. The EOL
table is bundled rather than fetched, so it works offline; an unknown version is
always silence, never a finding, which means a stale table under-reports instead
of crying wolf. A pin to a dead runtime is a warning; a >= floor that merely
allows one is info.
Docs drift (docs-drift) — what the README claims, checked against the repo:
a documented npm run <script> that no package.json defines, a CI badge for a
workflow file that is not there, install instructions in a package manager the
committed lockfile contradicts. Commands are only read inside fenced blocks or
behind a shell prompt, so prose that happens to start with "pnpm" is not an
instruction.
Duplicate code (duplicate-code) — blocks that appear more than once,
verbatim. Matching is deliberately exact rather than structural: whitespace and
comments are dropped, noise lines (braces, imports) are ignored, and then the
remaining text must be identical. A renamed copy therefore goes unreported —
the trade is on purpose, because "these two blocks are the same" can be checked
by opening both, while "these are structurally similar" starts an argument, and
a tool that starts arguments gets switched off. Eight significant lines minimum.
Excluded: tests (a table of near-identical cases is a good suite), generated and
vendored trees, migrations, scaffolding templates and playgrounds (parallel
copies are their job), TypeScript overload signatures and pasted inline SVG —
in all of those, the repetition is required rather than chosen.
CI health (ci-health) — whether anything is actually being checked. A
repo with tests, a workflow and a green badge can be running nothing: the test
job left the trigger during a migration, a flaky step got continue-on-error: true "for now" in 2023, someone appended || true to get a release out. Each
leaves CI green forever, which is worse than no CI — a missing check is a known
gap, a green check that verifies nothing is a false statement the whole team
relies on. Reports silenced failures, if: false steps, discarded test exit
codes, a suite that exists but no workflow runs, and (info) workflows that run
on push but never on pull_request. Deliberately quiet where it cannot be sure:
nothing at all when there is no CI (that is project-hygiene's finding), when
another provider's config is present (nest runs its whole suite on CircleCI), on
workflows that never trigger on a change, and on steps that regenerate fixtures
rather than verify them.
License risk (license-risk) — the licenses of your direct dependencies,
compared against the one the project claims for itself (from package.json, or
sniffed from the root LICENSE when the manifest is silent). AGPL/SSPL in a
shipped dependency is critical — its obligations trigger on network use, which
is what most products do; GPL is a warning; LGPL/MPL is info. A copyleft project
gets no copyleft findings, because GPL-on-GPL is the point of the GPL, and a
dev dependency drops one level because a build tool is not distributed. Licenses
are read from the installed node_modules copy first (exact and offline) and
from the registry only as a fallback. SPDX expressions are resolved properly:
MIT OR GPL-2.0 imposes nothing, because the choice is yours. Nothing here is
legal advice, and every finding says so.
Config conflicts (config-conflict) — two files configuring the same thing,
where one is silently ignored: an .eslintrc.* left beside a flat
eslint.config.* (ESLint 9 does not load it, and does not say so), a
package.json prettier/jest key beside a dedicated config file, .babelrc
beside babel.config.js, and an abandoned CI provider's config beside
.github/workflows. Plus the one case where nothing is ignored and that is
worse: two managers' lockfiles in one directory, which is two dependency trees
from one commit. Conflicts are only paired within a single directory, and
fixture / test / example trees are skipped entirely — a repository whose test
data deliberately contains two lockfiles is not drifting.