Retrieved article excerpt
Open article Β· Retrieved 2026-10-08T17:49:32.797323+00:00
# fakegreen
[npm](https://www.npmjs.com/package/fakegreen)
[License: MIT](https://github.com/fitzyracing1/fakegreen/blob/main/LICENSE)
[Node.js](https://nodejs.org)
**One command scans your agent's diff and flags every way it faked a green build.**
### Who it's for
- Teams shipping with Claude Code, Codex, Cursor, Gemini CLI, or Aider who want a hard stop on fake-green diffs
- Maintainers who want a deterministic CI / pre-commit / end-of-turn tripwire β no LLM, no API key, zero runtime deps
- Anyone tired of agents that `.skip` tests, weaken assertions, or append `|| true` and then claim *"All tests pass β
"*
β **If fakegreen catches a fake-green commit for you, [star the repo](https://github.com/fitzyracing1/fakegreen)** β it helps other teams find the tripwire.
### Try it in 30 seconds
```
npx fakegreen
```
No install. Needs Node.js 18+ and `git` on your `PATH`. Pin with `npm i -D fakegreen` when you're ready.
| | LLM code review | fakegreen |
| --- | --- | --- |
| **Speed & cost** | Secondsβminutes, API spend | Usually **under 100 ms**, free, offline |
| **Determinism** | Can vary; easy to talk around | Same answer every time |
| **Setup** | API key + prompts | Zero runtime deps β `npx fakegreen` |
---
Coding agents (Claude Code, Codex, Cursor, Gemini CLI, Aider) are rewarded for "tests pass". Sometimes they get there by
deleting the test, slapping `.skip` on it, swapping `toBe(42)` for `toBeDefined()`, adding `@ts-ignore`, teaching the code
to detect `NODE_ENV === 'test'`, or appending `|| true` to the CI step. Then they say *"All tests pass β
"*.
`fakegreen` reads the git diff and catches those moves. It is **deterministic**, needs **no LLM and no API key**, has
**zero runtime dependencies**, and usually finishes in **under 100 ms**. Hook it into your agent's end-of-turn event so the
agent gets blocked and told what it did *before* it can claim it's done.
[fakegreen catching a fake-green agent commit](https://github.com/fitzyracing1/fakegreen/blob/main/docs/demo.gif)
## Demo
The recording above ([`docs/demo.cast`](https://github.com/fitzyracing1/fakegreen/blob/main/docs/demo.cast), [`docs/demo.gif`](https://github.com/fitzyracing1/fakegreen/blob/main/docs/demo.gif), [`docs/demo.png`](https://github.com/fitzyracing1/fakegreen/blob/main/docs/demo.png))
is a real run against a sample repo built by [`scripts/make-demo-repo.sh`](https://github.com/fitzyracing1/fakegreen/blob/main/scripts/make-demo-repo.sh). An honest commit adds a
cart module with tests. Then an "agent" commit titled `fix: make the test suite pass` skips one test, weakens an
assertion, deletes a test file, adds `@ts-ignore` plus a `NODE_ENV === 'test'` shortcut, and appends `|| true` to CI:
```
$ fakegreen --last-commit
fakegreen Β· last commit (8797f43) Β· 4/4 files Β· +5 β13
HIGH .github/workflows/ci.yml:10 ci-failure-ignored
Failure ignored with `|| true` on a test/check command
β + - run: npm test || true
MED src/cart.ts:4 suppression-added
Checker silenced with @ts-ignore
β + // @ts-ignore
HIGH src/cart.ts:5 test-env-special-case
Non-test code branches on the test env (NODE_ENV === 'test'); tests skip the real path
β + if (process.env.NODE_ENV === 'test') return items.length ? 8 : 0;
HIGH test/cart.test.ts:9 test-skipped
Test skipped with .skip
β + it.skip('applies SAVE10', () => {
HIGH test/cart.test.ts:14 assertion-weakened
Specific assertion replaced with a vague one
β - expect(total([])).toBe(0);
β + expect(total([])).toBeDefined();
HIGH test/checkout.test.ts test-file-deleted
Test file deleted (2 test cases removed)
β - it('rejects negative quantities', () => {
5 high Β· 1 medium Β· 0 low β fake green detected (fail-on: high) Β· 41ms
```
Exit code `1`. Replay it with `asciinema play docs/demo.cast`, or rebuild it with
`scripts/make-demo-repo.sh /tmp/fakegreen-demo && cd /tmp/fakegreen-demo && fakegreen --last-commit`.
## Install
```
npx fakegreen # run once, no install
npm i -D fakegreen # or pin it in a project
```
You need Node.js 18 or newer and `git` on your `PATH`.
## Usage
```
fakegreen # uncommitted + staged changes vs HEAD, plus untracked files (default)
fakegreen --staged # only what is staged (good for pre-commit)
fakegreen --base origin/main # everything since the merge-base with main, including uncommitted work (good for PRs)
fakegreen --last-commit # just HEAD~1..HEAD
fakegreen --commit <sha> # one specific commit
git diff main... | fakegreen --diff - # any unified diff from stdin or a file
fakegreen --json # machine-readable findings
fakegreen --sarif > fg.sarif # SARIF 2.1.0 for GitHub code scanning and IDEs
fakegreen --format github # ::error annotations for GitHub Actions
fakegreen --fail-on medium # exit 1 on medium or higher (default: high; `none` never fails)
fakegreen --min-severity medium # hide low-severity findings
fakegreen rules # list every rule
```
Exit codes: `0` clean (or below `--fail-on`), `1` findings at or above `--fail-on`, `2` usage or git error.
Each finding includes a severity, `file:line`, a rule id, a short explanation, and the offending `+`/`-` lines.
`--json` output looks like this:
```
{
"tool": "fakegreen", "version": "0.1.0", "source": "last commit (8797f43)", "failed": true,
"summary": { "high": 5, "medium": 1, "low": 0, "total": 6, "files": 4, "analyzedFiles": 4, "added": 5, "removed": 13 },
"findings": [
{ "ruleId": "test-skipped", "severity": "high", "file": "test/cart.test.ts", "side": "added", "line": 9,
"message": "Test skipped with .skip", "snippet": "+ it.skip('applies SAVE10', () => {" }
]
}
```
## What it checks
It looks **only at added and removed lines** in the diff, so old code isn't re-flagged. Comments and string literals
are lexed out before matching, which keeps `".skip"` inside a string or a comment from triggering anything. Lines that
were only *moved* (renames, file splits, reordering) are ignored. A test file that moved, or whose tests reappear in
another file, does not count as a deletion. Supported languages: **JavaScript/TypeScript, Python, Go, Rust, Java**
(plus Kotlin test annotations), as well as CI and config files: GitHub Actions, GitLab CI, `package.json`, `tsconfig`,
jest/vitest/c8/nyc configs, `pyproject.toml`/`setup.cfg`/`tox.ini`/`.coveragerc`, Maven/Gradle, Makefiles, and
husky/shell hooks.
| Rule | Default | What it catches |
| --- | --- | --- |
| `test-file-deleted` | high | A test file was deleted (and not moved/renamed elsewhere in the diff). |
| `test-count-dropped` | high | The net number of test cases (test()/it()/def test\_/func Test/#[test]/@Test) went down. |
| `test-skipped` | high | A skip was added: .skip, xit/xdescribe, @pytest.mark.skip/xfail, pytest.skip(), t.Skip(), #[ignore], @Disabled, @Ignore. |
| `test-focused` | high | .only / fit / fdescribe silently disables every other test in the file or run. |
| `test-conditional-skip` | low | A conditional skip (skipif, skipIf, importorskip, assumptions, `if testing.Short()`) was added. |
| `assertion-removed` | medium | Net assertion count in a test file dropped (expect/assert/t.Error/assert\_eq!/assertEquals...). |
| `assertion-weakened` | high | A specific assertion (toBe/toEqual/assert x == y/assertEquals) was replaced by a vague one (toBeTruthy/toBeDefined/assert x/assertNotNull). |
| `assertion-trivial` | high | An assertion that can never fail was added (expect(true).toBe(true), assert True, assert!(true)). |
| `assertion-expected-changed` | low | Only the literal expected value of an assertion changed. Confirm the code was wrong, not the test. |
| `suppression-added` | medium | @ts-ignore, @ts-nocheck, @ts-expect-error, eslint-disable, # type: ignore, noqa, //nolint, #[allow(...)], @SuppressWarnings and friends. Blanket suppressions are medium, ones that name a specific rule are low, file/crate-wide ones are high. |
| `coverage-exclusion-added` | low | istanbul/c8/v8 ignore, pragma: no cover, LCOV\_EXCL, #[coverage(off)]. |
| `ci-failure-ignored` | high | `|| true`, continue-on-error: true, allow\_failure: true, --exit-zero, set +e on a test/lint/build command. |
| `ci-step-removed` | high | A command that ran tests, lint or type checks was removed from CI config, scripts or package.json. |
| `ci-step-disabled` | high | A CI job/step was disabled with `if: false` or `when: never`. |
| `test-script-neutered` | high | The package.json test script was replaced with echo/true/exit 0. |
| `test-exclusion-added` | medium | --passWithNoTests, -DskipTests, -x test, testPathIgnorePatterns, --ignore/--deselect, collect\_ignore. |
| `coverage-threshold-lowered` | high | A coverage threshold (coverageThreshold, fail\_under, --cov-fail-under, thresholds, jacoco minimum...) was lowered or removed. |
| `typecheck-weakened` | medium | tsconfig strict flags turned off, mypy ignore\_errors / strict = false, pyright typeCheckingMode off. |
| `lint-rule-disabled` | low | A lint rule was switched to "off"/0 in an ESLint config. |
| `test-env-special-case` | high | Non-test source code branches on being under test (NODE\_ENV === "test", JEST\_WORKER\_ID, "pytest" in sys.modules, testing.Testing(), cfg!(test)) or on CI. |
| `error-swallowed` | medium | New empty catch / except: pass / .catch(() => {}) / if err != nil {}. |
| `ignore-comment-added` | low | An inline fakegreen-ignore comment was added. Always reported so reviewers see what was waived. |
Severity is graded where it matters. For example, a blanket `# type: ignore` is medium, a targeted
`# type: ignore[attr-defined]` is low, and a file-wide `// @ts-nocheck` is high. `|| true` on `npm test` is high, while
`|| true` on `rm -rf build` is low. A conditional `skipif(sys.platform == "win32")` is low, but `skipif(True)` is high.
**Skipped automatically:** Markdown/docs, lockfiles, vendored and `node_modules` code, generated files, and
`fixtures/` / `testdata/` directories.
## Stop the agent in its tracks: end-of-turn hooks
Claude Code, Codex and Gemini CLI can run a command when the agent finishes its turn and **block** the stop, sending
the command's feedback back to the model. `fakegreen hook` speaks each agent's protocol. If it finds something at or
above `--fail-on`, the agent is told exactly what it faked and asked to fix it, or to stop and explain to you why the
change is intentional.
```
npx fakegreen install claude # .claude/settings.json (--local β settings.local.json, --global β ~/.claude)
npx fakegreen install codex # .codex/hooks.json (--global β ~/.codex/hooks.json)
npx fakegreen install gemini # .gemini/settings.json (--global β ~/.gemini/settings.json)
```
The installer shows a diff of the config change and asks before writing. Pass `--yes` to skip the prompt or
`--dry-run` to only preview. It merges into existing config, is idempotent, and refuses to touch a file that isn't
valid JSON.
**Claude Code**: `.claude/settings.json`
```
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npx --yes fakegreen hook --agent claude",
"timeout": 120,
"statusMessage": "fakegreen: checking the diff for fake-green changes"
}
]
}
]
}
}
```
On findings, the hook prints `{"decision":"block","reason":"..."}` and Claude keeps working with the reason as its
next instruction. ([Claude Code hooks docs](https://code.claude.com/docs/en/hooks))
**Codex**: `.codex/hooks.json`
```
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "npx --yes fakegreen hook --agent codex",
"timeout": 120,
"statusMessage": "fakegreen: checking the diff for fake-green changes"
}