Agents write the code, agents review the code, and then a human clicks Approve mostly on vibes. Farol — a Show HN launch today, an MIT-licensed CLI from Winnin (npx @winninjs/farol, version 0.1.3) — wants to sit between the AI review and that click. It builds a declaration graph of your program with the compilers' own tooling (the TypeScript checker, go/types, Python's ast), works out which entry points actually reach the changed code, and emits a 0–100 merge-risk score, a verdict (fast track / quick review / careful review), and a list of the files to read first.
Its pitch leans hard on one word: deterministic. "The same pull request always gets the same answer." That's a falsifiable claim, which is my favorite kind. I built two small FastAPI services, fed farol four pull requests of escalating nastiness, and checked.
Test setup
Service one: a single app.py, four endpoints (GET /items, GET /items/{item_id}, POST /checkout, GET /health), one shared format_price() helper returning a string like "$19.99". Endpoints return plain dicts — the way a lot of real FastAPI code is actually written.
Service two: three modules. pricing.py holds format_price(), service.py wraps it, app.py exposes three endpoints, one of them with a typed response_model=ItemOut. A fourth file, worker.py, calls format_price() from a plain nightly_report() function that no framework knows about.
PR 1: the untyped break — score 39, contract invisible
The first PR did three bad things at once: changed format_price to return a float instead of a string, dropped the id field from the item response, and built a SQL query with an f-string.
Farol got the reach exactly right:
app.py: func app.format_price changed; func app.get_item changed:
reached by 3 existing entry point(s): GET /items; GET /items/{item_id}; POST /checkout
Risk: app.py — used by 3 of 4 existing operations
Merge risk score: 39/100 (quick review)
GET /health, which never touches the helper, was correctly left out. But the contract report read four endpoint contracts and found exactly one change — a "safe" one, the new optional db parameter I'd added. The dropped response field and the str → float type change, the two things that would actually break clients, were not flagged at all. There is no response_model on those endpoints, so as far as any static tool is concerned there is no contract — just dicts flowing out. The f-string SQL also produced no finding: farol doesn't run your linters, it reads their reports, and without a ruff report the security section honestly says "not verified" rather than "clean."
That honesty is good design. The 39 is not.
PRs 2–4: reach, contracts, and the calibration spread
On the typed service I ran three separate PRs:
PR-A change format_price() body only, in pricing.py
→ chain reported per endpoint: app.py → service.py → pricing.py
→ affected: GET /items/{item_id}, POST /checkout (2 of 3 ops)
→ score 50/100
PR-B break the typed contract: remove ItemOut.id, change price str → float
→ "response.id field removed"
→ "response.price type changed from str to float"
→ score 64/100, verdict: careful review
PR-C add one new unused file (notes.py)
→ score 2/100
Three things worth noting. First, the transitive reach is real: a one-line change in a leaf module three files away was attributed to exactly the two operations that run it, with the full chain printed for each. Second, the typed contract break was caught itemized — field removal and type change listed separately, which is precisely the information an approver needs. Third, the spread is sane: 2 for a no-op, 50 for a behavior change in a shared helper, 64 for a breaking contract. Scores you can rank are scores you can use.
The determinism claim: verified, byte for byte
I ran the PR-A analysis three times and diffed the full JSON output field by field. Every field that describes the code — classification, reasons, risk, routes, contracts — was identical across all three runs, and the report derived the same 50/100 score from it every time (I ran that derivation ten times in a row to be sure). The only differences were analyzedAt (the timestamp) and diagnostics.timingsMs (farol's own stopwatch: one run's diff step took 29 ms, another's 32 ms). So not byte-identical — but identical in everything you'd ever act on, which is the claim that matters.
No model in the loop, no sampling temperature, nothing to drift. This is the entire argument for a tool like this existing alongside an LLM reviewer: the agent's review is a new opinion every run; farol's is an audit trail.
Where it goes blind
Two blind spots, both structural:
-
Untyped responses have no contract. The identical semantic break from PR-B — field dropped, type changed — scored zero contract findings in PR 1 because the endpoints returned dicts. Farol's contract detection is exactly as good as your typing discipline, and the codebases that most need a risk map are usually the ones returning dicts. If you adopt farol, adopting
response_modeleverywhere is the multiplier. -
Entry points means framework entry points. In service two,
worker.py'snightly_report()also callsformat_price(). Farol's route list contains only the three HTTP operations, so PR-A's blast radius silently excludes the script. A nightly job that starts printing19.99 USDinstead of$19.99is exactly the kind of break a risk map is supposed to catch — and it's invisible until the job is wired into something farol recognizes as an entry point.
Neither is a gotcha the authors hide: the docs are explicit that Python support is AST-based (it parses, never imports, never executes your code), and the reports say "not verified" instead of inventing comfort. But both mean the score is a floor, not a ceiling.
Verdict
Farol does the one job it claims: it tells the approver where a change reaches, and on a typed codebase it prices a breaking contract at 64 against 2 for a no-op and 50 for a shared-helper change — a ranking I'd sign. Run it in shadow mode (its own default; it never blocks a merge), and treat every untyped endpoint in your service as a hole in the map, because it is one — I measured that too: same break, typed 64 with both changes itemized, untyped 0 contract findings.
Sources: farol on GitHub (github.com/winnin/farol) and its Show HN thread, Oct 25, 2026. All runs are my own: @winninjs/farol 0.1.3, Node 24.22.1, Python 3.12, on synthetic FastAPI services I wrote for this test.
Top comments (0)