An editorial review desk for diffs. Every change lines up, walks the gates, and leaves with a stamp β pass, warn, or fail β that CI can trust because it was earned the same way every time.
stdlib-only Go core Β· zero-dependency TypeScript renderer Β· no network Β· no LLM in the loop Β· byte-identical output
Maintainers don't drown in bad code. They drown in plausible code β large, tidy-looking diffs produced in seconds that take a human an hour to actually vouch for. The secret in the base64 blob, the InsecureSkipVerify: true three files down, the forty near-identical getters, the 300 new lines with zero tests: they all look fine at a glance. PatchSentinel reads the whole diff, measures the boring-but-load-bearing stuff, and puts the risky material in front of your eyes before it reaches your users.
It does not claim to detect "AI-written code" β that claim isn't honest and this tool won't fake it. It measures transparent, explainable properties of a unified diff β repetition, risk-pattern hits, test-to-code balance, sensitive-path touches, review cost β and turns them into a reproducible verdict. See Heuristics, honestly for exactly where the lines are.
60-second tour Β· Install Β· Three diffs, three verdicts Β· Anatomy of a score Β· Policy knobs Β· Exit codes Β· Report formats Β· GitHub Actions Β· Triage playbooks Β· Heuristics, honestly Β· Edge cases Β· Limitations Β· Customization Β· Architecture Β· Development Β· Roadmap Β· License
make build # -> ./bin/PatchSentinel
git diff origin/main... | ./bin/PatchSentinel -format textThe core is a Go CLI built from the standard library alone: parse a unified diff (files or stdin), evaluate it against a policy, print evidence, exit with a code that mirrors the verdict. A separate dependency-free TypeScript renderer turns that JSON into a self-contained HTML page or GitHub check output. Two languages, one schema, zero runtime dependencies.
Requires Go β₯ 1.24 and Node β₯ 20 (Node only for HTML/GitHub rendering; the CLI stands alone).
make build # compile the Go CLI into ./bin/PatchSentinel
make test # run Go + TypeScript test suites
make demo # regenerate the example HTML + Markdown reports
make help # list every target
# or raw:
go build -o bin/PatchSentinel ./cmd/PatchSentinel
cd web && npm install && npm run build # dev-only deps; runtime stays zero-depThe examples/ directory ships three diffs spanning the full spectrum. Output below is the actual text from the built CLI β not a mock-up.
A small helper plus its test. Balanced, mergeable.
$ PatchSentinel -format text examples/clean.diff
Verdict: PASS
------------------------------------------------------------
Files changed : 2
Lines +/- : +23 / -0
Production added : 11
Test added : 12
Test-to-code ratio : 1.09
Risk score : 0
Repetition ratio : 0.00
Sensitive files : 0
Est. review time : 11 min
Findings:
[INFO] No policy violations detected
All configured gates passed.
Files:
modified +11 -0 pkg/mathutil/mathutil.go
added +12 -0 pkg/mathutil/mathutil_test.go (test)
Hardcoded secrets, eval, string-built SQL, wildcard CORS, a TODO, plus edits to an auth service and a CI workflow. Two hard gates trip.
$ PatchSentinel -format text examples/risky.diff
Verdict: FAIL
------------------------------------------------------------
Files changed : 2
Lines +/- : +27 / -0
Production added : 27
Test added : 0
Test-to-code ratio : 0.00
Risk score : 42
Repetition ratio : 0.00
Sensitive files : 2
Est. review time : 18 min
Findings:
[ERROR] Risk score exceeds maximum
Cumulative risk score 42 β₯ max 25.
[ERROR] Sensitive paths modified
2 sensitive file(s) changed; require domain-owner review.
Risk signals:
(w10 x2) hardcoded-secret [services/auth/login.go] Possible hardcoded secret
var apiKey = "sk_live_51H8xYzABCDEFghijklmnop"
(w8 x1) eval-usage [services/auth/login.go] Dynamic code evaluation via eval()
eval("dangerousDynamicCode()")
(w7 x1) sql-format [services/auth/login.go] Possible SQL built via string concatenation
return fmt.Sprintf("SELECT * FROM users WHERE name = '" + name + "'")
(w5 x1) wildcard-cors [services/auth/login.go] Wildcard CORS origin
(w2 x1) todo-marker [services/auth/login.go] Unfinished work marker
Each signal line also prints the offending snippet (two shown above). The risk score 42 = hardcoded-secret 10Γ2 + eval 8 + sql-format 7 + wildcard-cors 5 + todo 2. Nothing hidden.
A brand-new file of 33 added lines: getters/setters plus the same validate() stub repeated over and over. No dangerous patterns, no tests β just suspicious uniformity.
$ PatchSentinel -format text examples/generated.diff
Verdict: WARN
------------------------------------------------------------
Files changed : 1
Lines +/- : +33 / -0
Risk score : 0
Repetition ratio : 0.75
Sensitive files : 0
Est. review time : 12 min
Findings:
[WARN] High repetition in added code
Repetition ratio 0.75 β₯ 0.60 suggests boilerplate or generated code that needs closer review.
That 0.75 is the fraction of added meaningful production lines that are duplicates β the closest thing to a "smells machine-made" signal, phrased as a nudge, not an accusation.
Five independent signals feed one verdict; the verdict is the worst finding severity.
| # | Signal | What it measures | Default gate |
|---|---|---|---|
| 1 | Risk score | Ξ£ weight Γ matchCount over risk patterns matching added lines |
error β₯ 25 Β· warn β₯ 10 |
| 2 | Test-to-code ratio | testAdded Γ· productionAdded, only once productionAdded β₯ 40 |
warn < 0.25 |
| 3 | Repetition ratio | fraction of added meaningful production lines that are duplicates | warn β₯ 0.60 |
| 4 | Sensitive paths | changed files matching sensitive globs | error if any (βwarn) |
| 5 | Review cost | explainable minutes estimate | warn if ceil > 90 |
The ten built-in risk patterns, id (weight) β what they flag:
hardcoded-secret (10)βapi_key/secret/password/token = "β¦8+ charsβ¦"disable-tls (9)βInsecureSkipVerify: true,rejectUnauthorized: false,verify=Falseeval-usage (8)β dynamic code eval viaeval(Β·sql-format (7)βSELECT/INSERT/UPDATE/DELETEbuilt with+,%s,${,format(shell-exec (6)βexec.Command,child_process,os.system,subprocess.Β·wildcard-cors (5)βAccess-Control-Allow-Origin: *unsafe-yaml (4)βyaml.Unmarshal/yaml.load(Β·broad-except (3)β bareexcept:/ emptycatch () {todo-marker (2)βTODO/FIXME/XXX/HACKΒ·debug-print (1)βconsole.log/fmt.Println/print(
Review cost is deliberately auditable: per file, added Γ minutesPerAddedLine + removed Γ minutesPerRemovedLine + minutesPerFile, ΓsensitiveMultiplier for sensitive files, summed. With defaults the risky example is ~17.9 min β rounded to 18 min. Every report also carries a SHA-256 content hash so identical input yields byte-identical, cacheable output.
Pass -policy path.json and its fields overlay the built-in default. Anything you omit keeps its default; unknown fields are rejected so typos fail loudly instead of silently.
Also configurable: testPathPatterns and the riskPatterns array (id/regex/message/weight) β full schema in docs/POLICY.md.
Glob tokens: * = within a segment, ? = one non-separator char, ** = any number of segments. Paths compare with /; Windows \ is normalized.
A shipped examples/policy.strict.json tightens thresholds and flips sensitivePathIsWarning to true. Run it on the generated example and the lower minProductionLinesForTestCheck surfaces a second finding (review time rises to 16 min):
$ PatchSentinel -policy examples/policy.strict.json -format text examples/generated.diff
Verdict: WARN
Findings:
[WARN] Insufficient test coverage for change size
Test-to-code ratio 0.00 < required 0.30 for 33 added production lines.
[WARN] High repetition in added code
Repetition ratio 0.75 β₯ 0.50 suggests boilerplate or generated code that needs closer review.
The exit code is the verdict β no parsing required to gate a pipeline.
| Exit | Verdict | Meaning |
|---|---|---|
0 |
pass | every gate satisfied |
1 |
warn | acceptable, but notable signals present |
2 |
fail | at least one hard (error) gate violated |
2 |
β | also used for operational errors (bad flag, unreadable policy, unparsable diff) |
-strict promotes a warn to exit 2 β warnings block without editing the policy. The TypeScript renderer mirrors the report's exitCode, so a second pipeline stage stays consistent.
Go CLI -format: json (default β canonical schema, web/src/types.ts) Β· text (stable CI logs) Β· markdown (GitHub summary / PR comment) Β· annotations (::error/::warning workflow commands).
TypeScript renderer node web/dist/cli.js --format: html (self-contained page) Β· github (the Markdown summary from JSON) Β· annotations (workflow commands with %/newline escaping).
Annotations for the risky example:
::error title=Risk score exceeds maximum::Cumulative risk score 42 β₯ max 25.
::error title=Sensitive paths modified::2 sensitive file(s) changed; require domain-owner review.
The gate is stdin-friendly. Fail on fail, post a summary regardless, drop inline annotations:
name: PatchSentinel
on: pull_request
permissions: { contents: read, pull-requests: write }
jobs:
gate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: actions/setup-go@v5
with: { go-version: "1.24" }
- uses: actions/setup-node@v4
with: { node-version: "20" }
- name: Build tools
run: |
go build -o bin/PatchSentinel ./cmd/PatchSentinel
cd web && npm install --no-audit --no-fund && npm run build
- name: Analyze + report
run: |
git diff origin/${{ github.base_ref }}... > pr.diff
./bin/PatchSentinel -format json pr.diff > report.json || true
node web/dist/cli.js --format github --in report.json >> "$GITHUB_STEP_SUMMARY"
node web/dist/cli.js --format annotations --in report.json
- name: Enforce verdict # non-zero exit fails the job
run: ./bin/PatchSentinel -format text pr.diffThe || true always renders the summary; Enforce verdict blocks the merge (add -strict to block on warnings too). This repo's .github/workflows/ci.yml self-gates the examples the same way.
What a maintainer should actually do with each stamp.
- π’ PASS β review as normal. Not a correctness proof; it means the load-bearing checks are green.
- π‘ WARN Β· repetition β skim for copy-paste that should be a loop or generator. Legitimately generated (protobuf, mocks, sqlc)? Raise
maxRepetitionRatiofor that repo. A human fanning out near-identical code? That's a refactor conversation. - π‘ WARN Β· thin tests β the change crossed
minProductionLinesForTestCheckwith too few tests. Ask for tests, or justify and tune the threshold. - π‘ WARN Β· review cost β the diff is expensive to review carefully. Push back on scope: split mechanical from behavioral.
- π΄ FAIL Β· risk score β read every
Risk signalsline first;hardcoded-secret,disable-tlsandsql-formatend incidents. Confirm real vs. false positive before overriding. - π΄ FAIL Β· sensitive paths β auth/infra/CI/secrets changed; route to that path's owner. Flip
sensitivePathIsWarningif you'd rather it advise than block.
The part marketing usually skips.
- PatchSentinel does not detect AI. No model, no classifier, no "confidence this was written by an LLM." The
repetitionsignal correlates with boilerplate and some generated output, but human boilerplate scores identically and hand-crafted AI output can score zero. Treat it as "worth a closer look," never as proof of origin. - Risk patterns are regexes over added lines β they find shapes, not meaning.
eval(in a comment counts; an obfuscated secret may not. - The verdict is evidence for a human, not a replacement. Nothing here understands your architecture or threat model; a person still owns the merge.
- Determinism is the actual promise. Same diff + same policy β same bytes, every run, every OS β cacheable and trustworthy, with no claim of intelligence.
- Binary files are tracked (flagged
binary) but not line-scanned β they add file-level review cost only. - Renames are detected and reported with
oldPath; deletions never trip risk patterns (only added lines are scanned) and cost less. - Tiny diffs skip the test-ratio gate entirely β below
minProductionLinesForTestCheck(default 40) a one-liner is never nagged for missing tests. - Trivial lines (
{,}, blanks, sub-4-char lines) are excluded from repetition analysis so braces don't inflate the ratio. - Zero production, some tests β ratio
1by definition; zero of both β0. - Invalid regex in a custom policy is skipped defensively instead of crashing.
- Multiple diff files on the command line are concatenated (newline-safe) and analyzed as one change.
- Line-oriented, not semantic β no AST, taint tracking or dataflow. It reads diffs, not programs.
- Regex risk patterns have false positives and negatives β the accepted trade for zero dependencies and total determinism.
- Language-agnostic, so it can't apply language-specific rules (
errcheck, ESLint). Pair it with real linters; it isn't one. - Context-free β it sees the diff, not the surrounding file or history, so an "unused" new function may actually be wired up elsewhere.
- Review-cost is a heuristic calibrated by your numbers β minutes are relative, not a stopwatch.
A supplied riskPatterns array replaces the defaults (arrays don't merge), so add a rule like { "id": "banned-md5", "regex": "crypto/md5|hashlib\\.md5", "weight": 9 } alongside the built-ins you keep. Common threshold tweaks:
{ "thresholds": { "sensitivePathIsWarning": true } } // sensitive paths advise, not block
{ "thresholds": { "maxRepetitionRatio": 0.85 } } // relax for a mocks-heavy service
{ "thresholds": { "maxRiskScore": 15, "warnRiskScore": 5, "minTestRatio": 0.5 } } // tighten security repo
{ "reviewCost": { "minutesPerAddedLine": 0.5, "maxReviewMinutes": 60 } } // recalibrate effortPair a tightened policy with -strict to make warnings block in CI.
flowchart LR
A["diff (file/stdin)"] --> B[diff.Parse] --> C[analyze.Analyze]
P["policy JSON / default"] --> C
C --> E["Report JSON (schema v1)"]
E --> F["Go renderer: text Β· md Β· annotations"]
E --> G["TS renderer: html Β· github Β· annotations"]
C -->|"exit 0 / 1 / 2"| K[CI gate]
Layout: cmd/PatchSentinel (CLI entrypoint) Β· internal/{diff,policy,glob,analyze,render} (stdlib-only engine) Β· web/src (zero-dep TypeScript renderer) Β· examples/ (the three diffs + strict policy) Β· docs/POLICY.md + docs/assets/ (the two local SVGs above) Β· .github/workflows/ (Go + TS + self-gate CI).
make vet Β· make cover (Go tests + coverage) Β· make test-ts (type-check + build + test the renderer) Β· make demo (regenerate example reports) Β· make ci (what CI runs).
Direction, not commitments:
- Baseline mode β gate only on findings new since a stored baseline.
- SARIF output so findings land in GitHub's "Code scanning" tab.
- Per-directory policy overlays for monorepos.
- Pattern packs β versioned, composable risk-pattern bundles.
- Suppression comments with an audit trail for accepted false positives.
- Published JSON schema for the report format.
MIT. Bring it to your review desk, tune the gates, ship with a clearer conscience.
{ "sensitivePaths": ["**/.github/workflows/**", "**/auth/**", "**/*secret*"], "thresholds": { "maxRiskScore": 25, "warnRiskScore": 10, "minTestRatio": 0.25, "minProductionLinesForTestCheck": 40, "maxRepetitionRatio": 0.60, "maxReviewMinutes": 90, "sensitivePathIsWarning": false }, "reviewCost": { "minutesPerAddedLine": 0.30, "minutesPerFile": 2.0, "sensitiveMultiplier": 1.5 } }