Seamcheck — find the bugs between your frontend and your backend
Cross-language static analysis for Django, Express, FastAPI, Flask, NestJS, Next.js and
Fastify. Finds dead CSS classes, unused template attributes, fetch() calls to routes
that do not exist, Redis keys written under one name and read under another, and Stripe
events nothing handles. Reads your source; never runs your code; no API key, no network.
pip install seamcheck && seamcheck map
A small Express shop, scanned. Read it downwards: the browser at the top, the seam where requests cross the network, the server underneath, and the store where it talks to its data. Red is a request with nothing at the other end.
The bugs it is looking for
None of these throw. Nothing fails a test. That is exactly why they survive:
A fetch() to a route that does not exist |
someone renamed /api/orders to /api/order and the caller is one character behind |
| A CSS class nothing defines | the rule was deleted; three templates still ask for it, and the element renders unstyled |
| A template attribute nothing writes | data-modal-level-name sits in the HTML, the function that filled it is gone, and the value has been frozen since it shipped |
| Two writers for one element | two managers set the same counter to different values, so it flickers between them |
| A Redis key with a typo | written as user:{id}:stats, read as user:{id}:stat, and the read just returns nothing, quietly, for a year |
| A Stripe event nothing handles | your code calls an API that makes Stripe send invoice.paid, and no handler exists |
The red list is the point. Every finding names the file and line on both sides of the seam, and says which evidence it has.
What it has actually found
Not hypothetical. These came out of the 511,000-line Django app it is measured against, and every one of them was reported as an ordinary row that looked like dead CSS:
| finding | what it really was |
|---|---|
| three dead selectors | timers fetching on a loop to fill elements that exist in no template — ~12,000 requests per second at the app's target concurrency |
a dead dom_attr |
the level-up modal had been frozen since it shipped — the only function writing it was never called, so it showed the page-load value through every level-up |
| a selector that could never match | a lookup whose own comment said "ORDER IS LOAD-BEARING", in six files, matching nothing |
| a duplicated feature | a second, complete out-of-pushes system — its own 5-second interval and its own MutationObserver — running beside the real one |
| two live copies of a panel | both wrote elements that exist in no template; the panel had already been deleted once |
| duplicated writers | 1,248 lines of JavaScript deleted across 15 files — second copies left behind by refactors that never finished |
None of them threw. None failed a test. No error, no ticket — which is exactly why they had all been running for years. A value that is quietly wrong and an element that is quietly dead do not announce themselves.
The detail, with the numbers and the query that separated them from the cosmetic findings, is in Field notes below.
It runs in CI with no API key, no network call and no model, so this costs nothing per run and nothing per repository — and an AI agent working on the code can read the findings instead of re-deriving them from the source every session. See In CI and If you build with an AI agent.
Why I made it
I was building a game — a fairly large Django app with a lot of hand-written JavaScript — and I kept losing afternoons to the same kind of bug. The Python was fine. The JavaScript was fine. The route one asked for and the route the other served were one character apart, and nothing I already had read both sides of that.
So I wrote something to find them, for myself, on that project. It kept catching things I would not have found on my own, and after a while it seemed like other people might have the same afternoons to lose. So here it is.
It does not catch everything, and I am sure there are things it gets wrong. When it cannot
tell, it tries to say uncertain rather than guess. If you find it being confidently
wrong somewhere, please open an issue —
that is the most useful thing anyone can send me.
What changed, per release: CHANGELOG.md.
Field notes from the measurement project
Real findings from the 511,000-line Django + vanilla-JS project this tool is measured against — what each one cost, and how it was actually found. Dated, so you can see what is new.
2026-09-02 · What a dead selector actually cost, once
A finding that reads like lint is not always lint. Three dom_selector findings — rows that looked
exactly like an unused CSS class — turned out to be this:
| endpoint | every | on | requests/second at 50k users | |
|---|---|---|---|---|
| a latency readout | /api/get-user-stats/ — per-user |
5 seconds | every arena page | ~10,000 |
| a leaderboard | /get_leaderboard_data/ |
30 s / 120 s | every page but one | ~417–1,667 |
| the same leaderboard again, in a second file | /get_leaderboard_data/ |
30 s / 120 s | every arena page | ~417–1,667 |
Each one fetched on a timer and wrote the result into elements that exist in no template and are
built by no script. The first did not even bail when its three target ids came back null — it
just kept measuring latency and assigning it to nothing, every five seconds, per user.
Nothing failed. No error, no ticket, no failing test — which is precisely why all three had been running for as long as anyone could remember. The markup for the leaderboard had been deleted once; two independent pollers survived it, in two different files, neither aware of the other.
~12,000 requests per second of pure waste, at the concurrency the application is designed for.
The query that found them. Not a multi-writer finding and not a code review — all three were
ordinary unresolved rows. What separated them from the cosmetic ones was one pass over the
findings:
a dead selector within a few lines of a
fetch(, asetInterval(or anew MutationObserver
The scan already held both halves — this selector never matches and there is a timer in the same function. It had simply never put them together. If you are reading your own findings and they look like housekeeping, sort them by that question first.
2026-09-02 · What to do about uncertain
uncertain is the tool declining to guess, so the goal is never to make the number smaller — it is
to convert each cause into evidence. On the measurement project 2,814 of them break down by cause,
and each cause has a different answer:
| Cause, as the scan reports it | Share | What actually resolves it |
|---|---|---|
| javascript puts this class on an element | ~960 | seamcheck observe. The browser sees classList.add() happen. Nothing static can. |
selector built at runtime — `[data-tab="${x}"]` |
~175 | Partly static: the attribute name is a literal even when the value is not. Credit data-tab as read, leave the value unknown. The rest is observe. |
| fetch target built at runtime | ~240 | observe — it records the URL actually requested. |
| a class-name prefix, not a class | ~200 | Static: report the prefix as a prefix, never as a missing class. |
| nothing references this rule, but its… | ~300 | Usually a vendor or framework stylesheet. Read it, then decline to judge it. |
Two rules that keep the number honest while you shrink it:
- Never convert
uncertaintounusedby assumption. A tool that guesses to look decisive is the failure mode this project exists to avoid. Convert with evidence or leave it. - A page the run never visited leaves no trace, and looks exactly like a page that is broken.
Everything
observepromotes is labelled as observed for that reason — souncertaingoing down should always be traceable to a specific run over a specific set of pages.
Install
pip install seamcheck
seamcheck map
That is the whole setup. It opens a map of your project and prints a link you can open on your phone. Have a look around before reading any further — it explains itself better than this page does.
Needs Python 3.10 or newer, and nothing else.
If your project is Django, put it in the project's own virtualenv:
source .venv/bin/activate
pip install seamcheck
A Django project is the one thing seamcheck reads by importing it, so it has to run where that project's imports resolve. Every other backend is read from source, so anywhere works.
For the agent server: pip install 'seamcheck[mcp]'.
If pip says externally-managed-environment
That is PEP 668. Homebrew's Python and most Linux distro Pythons refuse to let pip install into them globally, on purpose — it is how you break your OS. A virtualenv is the answer, and it is what you want here anyway:
python3.12 -m venv .venv
source .venv/bin/activate
pip install seamcheck
Do not reach for --break-system-packages. It does what it says.
macOS
The python3 that ships with macOS is 3.9, which is too old — and it is why
pip3 install seamcheck can report "could not find a version that satisfies the
requirement seamcheck (from versions: none)". That message means "nothing here matches
your Python", not "no such package".
brew install python@3.12
cd your-project
python3.12 -m venv .venv
source .venv/bin/activate
pip install seamcheck
Debian / Ubuntu
sudo apt install python3-venv # if `python3 -m venv` is missing
python3 -m venv .venv
source .venv/bin/activate
pip install seamcheck
Windows
py -3.12 -m venv .venv
.venv\Scripts\activate
pip install seamcheck
pipx and uv tool — read this before you use them
Both work and both give you seamcheck on your PATH everywhere:
pipx install seamcheck
uv tool install seamcheck # uv fetches its own Python, so no Homebrew needed
But a pipx or uv-tool copy is isolated from your project on purpose, so it cannot scan a Django project — importing your settings needs your project's own dependencies, and they are not in there. Seamcheck will say so rather than showing you a traceback.
They are fine for everything else, since nothing there has to be imported: Express, Fastify, NestJS, Next.js, Flask, FastAPI, and Supabase, Firebase or Redis projects.
Upgrading and getting an old version
pip caches the package index, so shortly after a release you can be handed the previous one:
pip install --no-cache-dir --upgrade seamcheck
seamcheck --version
How it compares to Knip, depcheck and ESLint
Worth being clear, because it is the first thing a JavaScript developer will ask. Knip and its neighbours work inside one language's module graph — unused files, unused exports, unused dependencies — and they do it very well. Frontend or backend does not come into it; if your server is TypeScript, Knip already covers it.
Seamcheck looks at the boundaries between languages instead. A template referencing a
CSS class no stylesheet defines. A fetch() naming a route the Python never registered. A
Redis key written under one name and read under another. A Stripe event dispatched on that
Stripe will never send. Those are invisible to a module graph, because each side is
individually valid.
They are not competitors, and on a TypeScript codebase running both is reasonable. If what you actually want is unused JavaScript exports, use Knip — it is better at that than this will be.
What it looks like
Click a red one. The chain that reaches it lights up and everything else recedes, so you can see where the request came from and where it stopped — with the file and line for every hop, and a sentence saying what it means and what to check.
checkout.js asks for /api/shipping/quotes. The server serves
/api/shipping/quote. One character, valid on both sides, and nothing else would have told
you. The lit line runs straight and carries an arrow, so the direction is the request's
direction; everything not on the path recedes.
The number it opens on is the only one that matters: how much is worth looking at, and what the rest of the scan is instead.
Ten of 101 symbols are findings. The other 91 are named rather than hidden — 76 connected with evidence attached, 15 uncertain, which is the scan declining to guess. Each region carries its own rate, so "the store is 11% findings" is a sentence you can act on and "the frontend is bigger" is not.
It reads your data layer as the second seam — a query crosses a boundary and lands on a table the same way a request crosses one and lands on a route.
Lanes by store, because each fails differently. Every lane says whether it has an
oracle: schema in repo means a name can be checked, no schema · pairing only means it
cannot, and a grey card there is unknowable rather than dead. Redis never has one — nothing
declares a key — so it can only ever show you that two halves of your own code disagree.
One menu, and the counts are the current page's.
Views on top, then the lenses: the whole scan, or just the database, Redis, configuration or background jobs.
The findings list at the top of this page is the other half of the same view: everything it is willing to claim, each one explained in a sentence, worst first, with the file and line on both sides.
It reads on a phone, because that is where you end up looking at it.
Five looks, if you care. Aurora is the default.
Four words, and it never says more than it can prove
| connected | Something reaches it, and the evidence is attached. |
| unresolved | Something reaches for it by name and it is not there. Usually a bug. |
| unused | Both ends are visible and nothing connects them. Usually a decision. |
| uncertain | The scan cannot tell. |
uncertain is a real answer rather than a cop-out. A route assembled at runtime genuinely
cannot be known by reading the source, and I would rather it admitted that than pretended
otherwise. It is also why the other three can be trusted.
How the four fit together, and the two numbers that measure them
Every symbol gets exactly one of the four. Nothing is counted twice, and nothing is dropped:
every symbol found
│
├─ a verdict was reached ─────────────────────────── COVERAGE
│ ├─ connected something reaches it, evidence attached
│ ├─ unresolved reaches for a name that is not there ─┐
│ └─ unused both ends visible, nothing connects ─┴─ these two are CLAIMS
│
└─ uncertain ── no evidence either way. NOT a claim that it is dead.
├─ no oracle the evidence is not in this repository, so nothing that
│ reads source can ever settle it. Not a gap - the shape of
│ the project. A CDN <link>, or Bootstrap in an uncommitted
│ node_modules.
└─ fixable the evidence IS in the repository and Seamcheck cannot
read it yet. This half is the to-do list.
Two numbers, two different denominators. Quoting either one alone is misleading, and quoting one as though it were the other is worse:
| answers | ||
|---|---|---|
| Coverage | verdicts ÷ symbols | "how much of my project can it speak to at all?" |
| Precision | true claims ÷ claims | "when it does say something is broken, is it right?" |
Precision says nothing about uncertain, because uncertain is not a claim. A backend
that answered uncertain for every single symbol would have flawless precision and be
completely useless — which is exactly why coverage is reported per backend below, and why
it is reported next to its ceiling.
And uncertain is never "probably dead". It is the scan naming the evidence it does
not have. If that reads as an accusation against working code, the wording is wrong and I
would like to know.
Does it work with my stack?
No configuration. It works out which one you are using from what is in the repo.
How well, though, is a fair question, and the honest answer is that it varies enormously — so the table below reports it as a number rather than a claim.
Coverage, as defined just above: the share it reached a verdict on. A backend with low coverage is not lying to you — it is mostly declining to answer, and that is the thing worth knowing before you install it.
Django is the one being made good first, deliberately. It is where the tool is used every day against a large production codebase, so it is the only place a wrong finding gets noticed the same afternoon. Every other backend is real, kept working and improving in the background — none of them are going away — but I would rather say plainly which one is finished than imply all seven are equal.
Measured, not asserted. 47 open-source projects, 136,181 symbols, regenerated by
python tools/coverage.py:
| backend | repos | symbols | judged | coverage | ceiling |
|---|---|---|---|---|---|
| Flask | 5 | 9,257 | 8,508 | 91% | 93% |
| Django | 21 | 113,946 | 94,586 | 83% | 92% |
| FastAPI | 5 | 4,754 | 2,663 | 56% | 56% |
| Express | 6 | 2,529 | 1,162 | 45% | 46% |
| Next.js | 6 | 2,968 | 1,071 | 36% | 36% |
| NestJS | 4 | 2,727 | 898 | 32% | 32% |
| all | 47 | 136,181 | 108,888 | 79% | 87% |
Ceiling is where coverage would land if every missing reader were written — the gap
between the two columns is the to-do list, and everything below the ceiling is evidence
that is not in the repository at all. Django's ceiling sits 9 points above its coverage
because a lot of Django projects load their CSS from a CDN or build it from an uncommitted
node_modules; nothing that reads source can judge those, and it says so rather than
guessing.
The four JavaScript backends share one gap, and it is the same line every time: a route
whose caller was searched for and not found. Seamcheck reads fetch() and little else, so
a call written as a React Query hook, a router.push, a <Link href> or a generated
client is invisible and its route comes back uncertain. That is one body of work rather
than four, and it is why they move together or not at all.
| detected by | reads | ||
|---|---|---|---|
| Django | 🟢 used daily | manage.py |
the URLconf, imported; templates, models, admin, Celery |
| Express | 🟡 tried on real repos | app.get(...) |
call sites, from source |
| Next.js | 🟡 tried on real repos | pages/api, app/**/route.ts |
the file tree |
| FastAPI | 🟡 tried on real repos | @app.get |
decorators, from source |
| Flask | 🟡 tried on real repos | @app.route |
decorators, from source |
| NestJS | 🟠 read, barely used | @Controller |
decorators, composed with the controller prefix |
| Fastify | 🟠 read, barely used | fastify.get(...) |
call sites, from source |
| data layer | reads | |
|---|---|---|
| Supabase / Postgres | 🟡 tried on real repos | supabase/migrations/*.sql against every .from(), .select() and .rpc() |
| Redis | 🟡 tried on real repos | keys written and read, across Python and JavaScript, and cache keys with no expiry |
| Firebase | 🟠 read, barely used | firestore.rules against collection(db, …), and callables against their exports |
| Django ORM · Prisma · Mongo | 🔴 not yet | — |
🟢 used daily — one large production app, every day, for months. Every false-positive class below was found there. 🟡 tried on real repos — run against open-source projects and hand-checked, but nobody is living with it. 🟠 read, barely used — the reader exists and its demo passes; almost no real-world exposure. 🔴 not yet — measured as worth doing, not built.
The order I am working in
One backend at a time, and one repository at a time inside it. The method is dull on purpose: scan a real project, hand-check what it claims, fix the rule the mistake belongs to, then move to the next project. Coverage and precision both have to move before a backend is called finished — a tool that judges everything and is wrong half the time is worse than one that stays quiet.
- Django — now. Getting coverage and precision high enough that a finding can be trusted without checking it. This is the one with a large private codebase behind it and a growing set of open-source Django projects beside it.
- The Python web backends next — FastAPI and Flask, which share the extractors and the template story, so most of what Django buys carries over.
- The JavaScript backends after that — Express, Fastify, NestJS, Next.js. The gap here
is well understood and measured: seamcheck reads
fetch()and little else, so callers written as React Query hooks,router.push,<Link href>or a generated API client are invisible and their routes come backuncertain. That is one body of work, not seven. - The data layers throughout — Redis is the most valuable of them, because no compiler or ORM checks a key name and a typo is silent.
Nothing gets removed while it waits its turn. Everything in the table works today; the question is only how much of your project it can speak to.
This is where I could use help
The gap between 🟢 and 🟡 is not code, it is someone running it on their own project and telling me what it got wrong. Every improvement in the last releases came from exactly that: a Supabase user reporting 728 findings against tables that existed, someone finding that 65 multi-writer findings named one file, an icon font judged against a stylesheet that was never in the repo.
If you run it and something is wrong — findings against things that plainly exist, or silence where there is plainly something — that is the useful part:
seamcheck triage '<id>' --wrong consumed-by-dependency # nine fixed words, see help triage
seamcheck share # counts only, no code, no paths
Or open an issue in your own words. A report saying "NestJS, 200 findings, most of them nonsense, here is one" is worth more than any amount of me guessing.
Each one is checked against a small app written for the purpose, with a deliberately mistyped endpoint in it, and each one finds it:
flask /api/orders → the route is /api/order
fastapi /api/sign-up → the route is /api/signup
express /api/does-not-exist → there is no such route
fastify /api/comments → the route is /api/comment
nestjs /api/orders/check-out → the route is /api/orders/checkout
nextjs /api/account/profil → the file is pages/api/account/profile.js
Only Django is imported; the rest are read from source, so nothing has to run and none of their dependencies have to be installed.
Frontends — plain JavaScript · TypeScript · Django templates · CSS and design tokens
Also reads — Stripe (the webhook, the events your code dispatches on, and the events
your own API calls will make Stripe send that nothing handles) · background jobs (Celery,
BullMQ, Inngest, Agenda, pg-boss, Temporal, RQ, Dramatiq, arq — plus cron expressions that
no parser will accept) · configuration keys against your .env.example or compose file ·
GraphQL schemas
Monorepos — a repository with several services is not one application. Seamcheck reads
the workspace manifests, project files and Dockerfiles, tells the deployables apart from
the libraries, and can say which service owns a file (seamcheck share, or the services
MCP tool). A large monorepo commonly declares a hundred packages and deploys a handful.
The corpus. It runs against a standing set of open-source projects on every change, so "it works on code I did not write" is measured rather than hoped. Reading a lot of lines is not the same as understanding them, though, which is why the number reported above is coverage per backend and not a line count. Aggregate only: I do not publish findings against a project by name — at these precision levels a wrong finding published against someone's working code is a public accusation, and some of them will be wrong.
Reproduce any of it yourself:
python tools/corpus.py clone && python tools/coverage.py # coverage, per backend
python tools/precision.py # precision, against hand labels
python tools/recall.py # planted bugs, does it find them
It reads your data layer too
The seam is a name crossing a boundary nothing checks. A route string is one instance. Where you keep your data is another, and usually nobody is checking that at all.
Supabase
Your client names a table, a column, a function and an edge function as strings, and
they are checked against supabase/migrations/*.sql.
UNRESOLVED db_table_use order the migrations declare `orders`
UNRESOLVED db_column_use order.total PostgREST returns the row WITHOUT it
UNRESOLVED db_function_use get_statistics the function is `get_stats`
UNRESOLVED edge_function_use send-mail the directory is `send-email`
UNUSED db_table audit_log no client code touches it
UNRESOLVED db_policy orders read by the client, RLS off
A mistyped column is the quiet one. PostgREST returns the rows without it, the client
reads undefined, and a blank field ships. Nothing raises. supabase gen types catches it
only if you regenerate after every migration — and that drift is the bug.
The last line is a security check: the anon key ships in your browser bundle, so a table the client reads with row level security off is readable by anyone who opens devtools.
The schema reader is not Supabase-specific — it also reads migrations/, db/migrate/,
database/migrations/ and sql/, so Alembic, dbmate, Sqitch and hand-rolled folders work.
Firebase
httpsCallable('sendEmail') is checked against what your functions directory exports —
same shape as a fetch against a route.
Firestore is schemaless, so "does this collection exist" has no answer in your files and
is never claimed. But firestore.rules is a declaration: a collection with no match
block is denied by default and fails silently in production, and a match block for a
collection nobody touches is usually a rename left behind.
Redis
No schema either, so a key is checked against its counterpart.
UNRESOLVED redis_key user:*:stat read here, written nowhere — can only ever miss
UNUSED redis_key user:*:legacy written here, read nowhere
UNRESOLVED redis_ttl cache:board:* names itself a cache, written with no expiry
Key patterns are normalised before they are compared, so user:{uid}:stats,
user:${id}:stats and user:%s:stats are one key — a Python writer meets a JavaScript
reader.
The commands
seamcheck map # scan, then open the canvas. Start here.
seamcheck check # the CI gate. Exit 1 on new findings, 2 with no baseline, 0 clean.
seamcheck report # the findings digest, as text or markdown
seamcheck explain # why one symbol is classified the way it is
seamcheck triage # record "this one is fine, and here is why"
seamcheck backfill # scan the last N commits so the map has history
seamcheck observe # drive your pages in a real browser and record what it saw
seamcheck config # what was detected, and how it was worked out
seamcheck share # a report about the scan containing none of your code
seamcheck triage # record "this one is fine, and here is why"
seamcheck help <command> explains any of them with examples.
Useful flags: --format terminal|markdown|html|map|json · --out FILE · --serve /
--no-serve · --tunnel (a temporary public HTTPS link, for your phone) · --local-only ·
--since REF · --open.
If you build with an AI agent
This is where I have found it most useful, honestly. Asked who writes this element?, an agent tends to grep, read a handful of files, and make a reasonable guess. On a big repo that costs a lot of context and is still a guess.
And there is a failure I have run into more than once: asked to fix something on screen, an agent will sometimes add a second place that writes the same element rather than finding the one already there. The symptom moves, the next session adds another, and it slowly gets worse.
So there is an MCP server. The agent asks, and gets the answer with the exact lines.
And it is cheaper than asking the model. A question like "is any of this dead?" has a deterministic answer, and paying an LLM to re-derive it is paying for the same reasoning every session, at the price of reading half the repository into context each time. Seamcheck computes it once, from the source, with no model and no tokens at all — then hands the agent a list of file-and-line answers to act on. The agent spends its context on the fix rather than on rediscovering the problem.
That difference matters most exactly where the codebase is too big to hold at once, which is the same place the bugs hide.
There is a correctness argument too, and it is the more important one. An agent reading
code to decide whether something is dead will produce an answer either way — it has no way
to say "I could not tell." Seamcheck does, and says it constantly: uncertain is a real
verdict here, and the whole design refuses to convert it into unused by assumption. An
agent that trusts a confident guess deletes working code; one that is handed unresolved
with the evidence attached, and uncertain where the evidence is missing, does not.
pip install 'seamcheck[mcp]'
claude mcp add seamcheck -- seamcheck-mcp
| tool | what the agent gets |
|---|---|
seamcheck_check |
every finding, with counts and what is new since the last scan |
seamcheck_explain |
one symbol: where it is, how it was reached, why it is classified so |
seamcheck_report |
the digest as markdown, to paste into a PR |
seamcheck_triage |
records "this one is fine, and here is why", so it stops being raised |
seamcheck_services |
which services this repository declares, and which are deployable |
seamcheck_share |
the code-free scan report, for an agent to show you before you send it |
seamcheck_why_wrong |
the nine fixed reasons, so an agent can pick one when it triages |
The server talks over stdin/stdout — no port, no daemon. Run it with the agent's working directory set to the project root. For a Django project it has to run inside that project's virtualenv, for the same reason the CLI does: it reads the project by importing it. Every other backend is read from source, so anywhere works.
The tools are thin wrappers over the same functions the CLI runs. If the agent and your terminal ever disagreed, neither would be worth trusting.
In CI — no token, no network, no model
This is the part worth knowing before you try it anywhere else: seamcheck is static analysis. It reads files and exits. There is no API key, no account, no service to sign up for, no model call, and no network request of any kind — so there is no per-run cost, no rate limit, and nothing about your source leaves the machine it runs on. That last point is usually the one that decides whether a tool is allowed near a private repository at all.
seamcheck check --since $BASE_SHA
Exit 1 on new findings, 2 if there is no baseline yet, 0 when clean. Add
--format markdown for a digest you can post as a PR comment.
--since is the part that makes it adoptable. Pointed at an existing codebase, any
tool like this finds hundreds of things, and the usual outcome is that nobody fixes any of
them and it gets switched off within a week. --since compares against a baseline and
fails only on what your branch added, so the backlog stays where it is and the diff
stays clean. You can turn it on today on a codebase with three thousand open findings and
it will pass.
# .github/workflows/seamcheck.yml
name: seamcheck
on: pull_request
jobs:
seams:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 } # needs history for --since
- uses: actions/setup-python@v5
with: { python-version: "3.12" }
- run: pip install seamcheck
- run: seamcheck check --since ${{ github.event.pull_request.base.sha }}
That is the whole integration. It runs on a stock runner in seconds to a couple of minutes depending on repository size, needs no secrets, and works the same on GitLab CI, CircleCI or a pre-push hook — it is one command with an exit code.
What it catches there that a test suite does not: a renamed route whose caller was missed, a template attribute nothing writes any more, a CSS class deleted from one file and still referenced in three, a Redis key written under one name and read under another, a Stripe event your code dispatches on that Stripe will never send. None of these fail a test, because nothing throws — the value is just quietly wrong, or the element is quietly dead, and it ships.
If it gets your project wrong, I would like to know
This is the one thing I would actually ask for. The scans worth learning from are the ones that got something wrong, and those are almost always private repositories nobody can send.
A real example, and the reason this section exists: someone ran it on a Supabase project
and got 728 findings claiming their tables did not exist. They all existed. Their schema
lives in the Supabase dashboard rather than in supabase/migrations/, so seamcheck found no
schema and read that absence as proof. It was fixed the same day. One aggregate line —
Supabase detected, no schema present, 728 findings against it — would have made it obvious
long before, and that line contains nothing of theirs.
seamcheck share
It prints a report of counts and fixed words: how many findings of each kind, in each
status, and why the uncertain ones are uncertain. No file paths. No symbol, table, column or
route names. No code. No repository name, no git remote, no SHA. Every value is a number or a
word seamcheck itself defines — which you can verify by reading one file,
seamcheck/share.py, rather than taking my word for it.
Nothing is sent. Seamcheck makes no network calls at all, and never has. The report is
printed, written to seamcheck-share.md, and followed by a link that opens a pre-filled
GitHub issue in your browser — which submits nothing until you press the button. Or paste it
into an email. Or read it, decide it is too much, and delete it; that is a fine outcome too.
One thing worth saying plainly: if the repository belongs to an employer or a client, that is their call rather than yours. Please do not send metrics about someone else's code because a README asked nicely.
The part that actually helps: tell it which findings were wrong
Counts say a scan produced three thousand findings. They cannot say which of them were wrong, and wrongness is the only thing that improves the tool — hand-labelling eight repositories is what took its precision from 28% to 42%.
You are already deciding this, one finding at a time, whenever you look at your backlog and think "that one's fine." Say so and it stops being raised:
seamcheck triage '<symbol-id>' --wrong consumed-by-dependency
Or on the map: open a finding, press This is wrong, pick a reason. One tap puts the command on your clipboard — the page cannot write to disk, so it hands you the thing that can rather than pretending.
The reason is one of nine fixed words, and that is deliberate. The prose you type in
--reason stays on your machine forever; only the fixed word can travel, because free text
is exactly where a path or a table name would escape. The nine are not invented either —
each is a false-positive class measured on a real repository:
consumed-by-dependency |
a CDN bundle, a package, the framework's own code |
built-at-runtime |
the name is assembled, so no literal for it exists |
read-outside-repo |
a container, CI, a shell script, another app |
declared-elsewhere |
the schema or config it needs lives somewhere else |
generated |
build output, or a copy of code already read |
test-or-fixture |
a test, not the product |
framework-implicit |
the framework does this without being asked |
genuinely-dead |
nothing wrong with it — it really is dead |
other |
none of the above |
genuinely-dead matters as much as the rest. A finding confirmed right is evidence too.
Seeing it before you send it
The map has a Send a report view: the exact values, in a table, with a Copy button and a pre-filled GitHub issue. Nothing leaves until you press a button, and the button is on GitHub's page rather than this one.
What it cannot see
Routes built at runtime from variables. Elements a framework renders from components rather than templates — React and Vue handlers are props, not listeners, and the trail stops at the module boundary. Anything reached only by a string it cannot resolve.
In the data layer: Firestore collections, because Firestore has no schema and there is nothing in your files that declares one — only the rules are checked. Storage buckets, which are usually created in a dashboard rather than in the repo. And a Redis key whose name is assembled from variables end to end.
It says uncertain in all of those cases instead of guessing. That is the whole discipline:
uncertain is a real answer, and it is why the other three can be trusted.
Contributing
Issues and pull requests welcome, especially "it got this wrong and here is the file".
License
MIT. Take it, fork it, improve it.
A note on the copy: the wording on this page was drafted with an LLM, and partly for them — realistically a coding agent is going to read this README before a person does, and decide whether to install it. So it is written to be easy to parse as well as to read. The tool itself does the opposite: it will not tell you anything without showing you the line it came from.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file seamcheck-0.8.2.tar.gz.
File metadata
- Download URL: seamcheck-0.8.2.tar.gz
- Upload date:
- Size: 590.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b590ac9da096da942a34300ae428708bf0e6ec4744ff1732fe0e0b8b6c94e0ba
|
|
| MD5 |
f632779aeeb1ba381150d52a9eae149d
|
|
| BLAKE2b-256 |
274aca64cb48f6f0d99ce6570f9609895634616c45941ba1219cba27a8265f07
|
Provenance
The following attestation bundles were made for seamcheck-0.8.2.tar.gz:
Publisher:
release.yml on dardameiz/seamcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
seamcheck-0.8.2.tar.gz -
Subject digest:
b590ac9da096da942a34300ae428708bf0e6ec4744ff1732fe0e0b8b6c94e0ba - Sigstore transparency entry: 2694693005
- Sigstore integration time:
-
Permalink:
dardameiz/seamcheck@d4ae91b10ec73a107bd3a878c36ff71c448a94fe -
Branch / Tag:
refs/tags/v0.8.2 - Owner: https://github.com/dardameiz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d4ae91b10ec73a107bd3a878c36ff71c448a94fe -
Trigger Event:
push
-
Statement type:
File details
Details for the file seamcheck-0.8.2-py3-none-any.whl.
File metadata
- Download URL: seamcheck-0.8.2-py3-none-any.whl
- Upload date:
- Size: 529.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
68c23c4dd74d44d95a6629ee01046959a8f5a6fd54b3d70d9710eab5b5f2dbc8
|
|
| MD5 |
62b533df47814ba313fd9d5c75e06181
|
|
| BLAKE2b-256 |
0050363cc489d42aadabc57fcb95bf34537dacb37c6560137e1dea63a3eff3a9
|
Provenance
The following attestation bundles were made for seamcheck-0.8.2-py3-none-any.whl:
Publisher:
release.yml on dardameiz/seamcheck
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
seamcheck-0.8.2-py3-none-any.whl -
Subject digest:
68c23c4dd74d44d95a6629ee01046959a8f5a6fd54b3d70d9710eab5b5f2dbc8 - Sigstore transparency entry: 2694693313
- Sigstore integration time:
-
Permalink:
dardameiz/seamcheck@d4ae91b10ec73a107bd3a878c36ff71c448a94fe -
Branch / Tag:
refs/tags/v0.8.2 - Owner: https://github.com/dardameiz
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@d4ae91b10ec73a107bd3a878c36ff71c448a94fe -
Trigger Event:
push
-
Statement type: