Seamcheck

Latest version: v0.16.0

Safety actively analyzes 1036830 Python packages for vulnerabilities to keep your Python projects secure.

Scan your dependencies

Page 1 of 3

0.16.0

**The map finds pages beyond Vite and Django.** A page used to be a Vite entry or a script
a Django template loads, and nothing else. On leanos-app, a Next.js 16 project, that meant
**0** pages and every one of its 6,268 symbols in "Not reached from any page" - while the
Next.js adapter had read all 27 of its routes. Pages now come from *entry sources*, a
second layer beside the backend adapters: each detects with a confidence, and every
confident one runs, so a monorepo gets all of them. Phase 1 of 4 in
`docs/plans/2026-09-15-entry-sources-design.md`.

- **Added** - Next.js pages. Every App Router `page.*`, rooted with the `layout.*` and
`template.*` files around it, and every Pages Router file except `_app`, `_document` and
`api/`. Addresses come from the Next.js adapter itself, so route groups and slots are
read in one place; a monorepo holding more than one app qualifies each page by its app.
- **Added** - server entries, from any backend. Every file holding a route handler or a
view is an entry in its own picker group below the pages, titled by the URLs it serves,
and draws its own routes and handlers. A project with no pages at all
(fastapi-realworld) now has something to open.
- **Added** - the `entry_sources` config key forces which sources run, the way
`server_adapter` does for backends. `seamcheck config` prints each source with its
confidence, and names a source that does not exist rather than raising.
- **Fixed** - an import through a `tsconfig`/`jsconfig` alias (`/components/X`) or a
workspace package was taken for `node_modules` and never followed, in every import
walk: a leanos-app page reached **1** of the 83 files it imports. One resolver now
handles relative paths, `paths`/`baseUrl` through `extends`, workspace packages through
their manifests, and non-script imports (a stylesheet, JSON) as assets that are reached
but not walked.
- **Changed** - a symbol is on a page when an entry reaches its *file*. Drawing still starts
only from seeds and stays bounded, and the seeds now include every kind of touch: store
uses, env reads, Stripe, URL references, JSX attributes. What a page's files hold but no
page draws - class applications, rules in an imported stylesheet - goes to a new bucket,
"On a page, nothing to draw", instead of "Not reached". This moves bucket counts on
projects whose page lists do not change.
- **Changed** - an entry with nothing to draw stays in the picker, and its canvas says how
many symbols its files hold; a page with a single node used to vanish. The map opens on
the first page that draws something.
- **Changed** - on a Next.js project, autoconfig's sweep of loose `.js` files no longer
becomes pages (cal.com and dub lose 22 and 5 of them: `jest.config`, `next.config`, ...).
Only when `js_entry_files` was detected, never when it was configured. Vite entries and
scripts a template loads are never dropped.
- **Fixed** - a not-reached bucket's blurb carried its count, and the picker appended it
again ("- 3 - 3 nodes"). Blurbs are now worded from the detected backends and the
languages actually in the bucket, not in Django's vocabulary.
- **Fixed** - a project whose workspace lists its own root (`packages: ["."]` in
`pnpm-workspace.yaml`, as saleor-dashboard's does) crashed `seamcheck share` and the
`seamcheck_services` MCP tool on Python 3.12, and its map silently lost its service
labels: `Path.glob(".")` raises `IndexError` there (3.13 and 3.14 raise a `ValueError`
that was already caught). The root is now read as that package - by the new import
resolver too, which walks the same globs and crashed every map of such a project before
this release shipped.
- **Fixed** - a Vite entry written the ordinary Node way was not read at all.
`path.resolve(__dirname, "src", "index.tsx")` failed three times over: the pattern
demanded a bare `resolve(`, so a dotted `path.resolve` never matched; only the first
string after `__dirname` was captured, so a path split across arguments read as `src`, a
directory; and anything not ending in `.js` was dropped, so a `.ts`/`.tsx` entry never
survived. A single-string `build.rollupOptions.input` and `vite-plugin-html`'s
`createHtmlPlugin({ entry })` are read too, and a `resolve.alias` pointing at a directory
or into `node_modules` is no longer mistaken for an entry.
- **Fixed** - every Vite entry vanished when the scan ran from anywhere but the repository
root. An entry is written relative to `__dirname`, the directory holding the config, and
was tested with `is_file()` on the literal string - which resolves against the PROCESS's
working directory. The two agree only when the command runs from the repository root, so
`--repo-root`, an editor plugin and the corpus harness all silently found no entries.
- **Changed** - the last-resort fallback, which lists every first-party script no other
first-party file imports, no longer offers tests, stories, `__tests__`/`e2e` files or a
tool's `*.config.*` as pages; it caps the list at 200, nearest the repository root first;
and each entry says so when a bundler config it could not read is what really declares
where the project starts. It was never meant to be a project's page list, and on
saleor-dashboard it had become one.
- **Changed** - a map too large for one file is no longer printed to stdout. `seamcheck
map` with neither `--out` nor `--serve` says which to use and exits 3, rather than
writing a 43 MB document into a terminal. The single-file threshold drops 50 MB → 10 MiB:
at 50 MB it had never once fired on real output, because the largest map the corpus had
ever produced still sat under it.

**On saleor-dashboard** (corpus; Vite + TypeScript, no framework page convention), measured
against fa8b438b4: its one real entry `src/index.tsx` is read, so the page list is **1 page**
where it was **1,240 `entry_file` entries** - 902 of them tests and stories, 6 tool configs,
leaving 332 the new filters would still have offered. The map falls from **43,516,011 bytes
over 415,844 nodes to 1,837,956 over 8,125**, and both render sound. Symbols on a page fall
with it, **4,144/5,637 → 1,452/5,844**: the 4,144 came from 1,240 fabricated pages each
dragging its own reach set, and what leaves them lands in the not-reached buckets, where it
is visible as unreached instead of hidden inside a page that was never real.

Across the 20 repositories `tools/corpus.py entries` measures, every page list is identical
before and after these four fixes, and the share of symbols on a page is unmoved at 49.0%:
saleor-dashboard is not one of the 20, and no repository there gained a page or lost one.

**Measured, 0.15.1 against this release.** Symbols on a page, across the 20 corpus
repositories `tools/corpus.py entries` measures: **1.2% → 49.0%**. The six with Next.js
gain their pages - dub +194, cal.com +83, commerce +5, nextjs-subscription-payments +4,
platforms +3, documenso +2 - and the page lists of the 14 without it are identical.

**On leanos-app** (private, not in the corpus - `tools/labels/leanos-app.json`): 0 pages →
20 Next.js pages and 10 route handlers, every page and route file on disk; on a page 0 →
1,215 of 6,316 symbols; "Everything else" 898 → 50. Precision on its hand-labelled sample
unchanged, 5 true / 2 false.

**Corpus (34/34 cloned repos):** `tools/corpus.py scan` identical before and after - no
CRASH, no repo lost its routes, uncertain share **4.982%** both times. That gate runs the
adapters and never builds a map, so it is flat by construction. Recall **6/6**, unchanged.
Render **46/46**: every cloned repository builds a map whose emitted scripts parse and
whose payload matches its graph - the gate that caught the workspace crash above.

**Measured:** coverage **91%** across 48 projects and 368,052 symbols (Django 93%, Flask
93%, Next.js 72%, Express 69%, FastAPI 58%, NestJS 49%) · recall **6/6** · render **46/46**
· corpus 34/34 scanned with no crash and no lost routes.

Coverage was last published at 0.11.0 - 89% across 47 projects and 358,845 symbols - so
89% → 91% spans 0.12.0 through this release, not this one alone. **Next.js 46% → 72%** is
this line of work: an import through a `tsconfig`/`jsconfig` alias is now followed, so a
page reaches the files it imports rather than one of the eighty-three. The four backends
with no entry-source work in this release - Flask, Express, FastAPI, NestJS - are
unchanged to the point, which is what a targeted change should look like.

Precision is deliberately not quoted. At 78 true of 140 hand-labelled claims it is where

0.15.1

**Next.js App Router coverage, found by dogfooding against a real Next.js 16/React
19/Supabase project** (`docs/seamcheck-findings-from-leanos.md` - the Node/React
counterpart to the Django dogfooding that drove 0.14/0.15). Seven fixes, two of them
chained: closing one gap (a CSS `attr()` read) exposed two more underneath it that had
been silently masked by the leniency the first one relied on.

- **Fixed** - `href={`/x/${param}`}` - the idiomatic way to link to a parameterised
Next.js route from JSX - never resolved: the extractor correctly truncates it to a
literal prefix, but the resolver's symmetric slash-strip meant a prefix ending right
where a required dynamic segment begins could never match. `UrlIndex.resolve_prefix()`
now credits a prefix against a parameterised route's own literal lead-in, guarded to a
complete segment boundary so a partial word (`/kaiz`) is never matched.
- **Fixed** - `window.location.assign/replace()` and `location.href = "..."` were not
read as navigation at all - only `router.push`-shaped calls were - so a deliberate
full-page navigation (an admin shell bypassing the client router, a hard reload after
sign-out) looked unreferenced no matter how literal its target was.
- **Fixed** - `css_source_root` autoconfig only ever looked in `public`/`static`/`assets`-
shaped directories. A Next.js App Router's own `app/globals.css` - imported straight
into the root layout, never linked with `<link>`, never under a `public/`-style
directory - was invisible as a stylesheet, not merely unread: none of its selectors,
tokens or attribute rules existed as symbols at all. Now also checks `app/`,
`components/`, `pages/`.
- **Fixed** - `content: attr(data-x)` in a CSS rule is a read of that attribute; the CSS
extractor never looked for it, so an attribute set purely for a stylesheet to render
through generated content looked completely unread.
- **Fixed** - a CSS custom property set at runtime via `element.style.setProperty()`
wasn't linked to its `var()` reads anywhere else in the project - only to reads its own
(nonexistent) definition site could see - so a real, working per-instance override
looked like a dead definition.
- **Fixed** - a class name built by plain JS string concatenation or a ternary
(`"a" + (cond ? " b" : "")`) wasn't recognised as a static literal, unlike the
equivalent template-literal ternary.
- **Fixed** - `styles_are_local` asked whether any *standalone* `.css` file existed
rather than whether the project has any styles at all, so a project whose entire
stylesheet lives in templates' own `<style>` blocks got the CDN-style "no local CSS
was found" benefit of the doubt everywhere - masking every real unresolved class
behind an excuse that was never true. Surfacing this correctly then exposed that
`wired_by_markup` (the exemption for `<a href="x">`/`id="x"` in-page navigation
targets) only ever read one of the two parameters the template scanner's evidence
actually arrives through - real since the matcher was first written, hidden until the
line above stopped excusing it.

**Measured on leanos-app** (private, not in the corpus - `tools/labels/leanos-app.json`):
connected 5,116→5,349, uncertain 1,012→891, unused 15→22 (+7 genuine, in newly-readable
CSS), unresolved 0→6 (2 genuine `next/font` gaps + 4 copies of one genuine unstyled
class). Precision on the original 15-finding hand-graded sample, 33%→71%.

**Corpus (34/34 cloned repos), before/after by stashing this diff:** no repo's gate1/
gate2 changed, no CRASH, no repo lost its routes; corpus-wide `by_status` totals
identical (uncertain share **4.987%** both times) - flat, not improved, since none of
the 34 public repos happen to exercise these particular shapes; leanos-app itself isn't
in the corpus. Recall **6/6**, unchanged.

0.15.0

**See what you're about to commit before you commit it.** A new question the map and the
CLI/MCP surface didn't answer before: not "what changed since a ref" (`diff`/`check`) but
"what does the page I'm touching right now look like" - plus a batch of fixes found by
actually driving all of it against a real 500k-line project, not synthetic fixtures.

- **Added** - `seamcheck scope commit`/`scope push` (and the `seamcheck_scope` MCP tool):
resolves the page(s) a staged commit or an unpushed push touches from the live git diff,
and reports what's currently unresolved/unused there - a pre-existing issue on that page
counts, not just what the diff introduced, unlike `diff`/`check`. `--serve` opens one
map per touched page, already focused on it, instead of the whole-repo map with a picker.
- **Added** - `seamcheck install-hooks`: writes advisory `pre-commit`/`pre-push` hooks that
run `scope` and print a short summary. Never blocks - always exits 0, whatever it finds.
- **Added** - F37: a build-graph divergence check (`build_gap`) - a module reachable from
an entry point in source but silently absent from the bundler's own manifest, the shape
a real incident took (a dynamic-imported chunk a build step dropped). Follows
`import()`, not just static `import`, which the reachability walk was blind to before.
- **Fixed** - git hooks installed by `install-hooks` shelled out to a bare `python3 -m
seamcheck.hooks`, so a `python3` with no seamcheck installed silently did nothing, and
`-m` prepending the hook's CWD to `sys.path` let a project directory literally named
`seamcheck` shadow the real package. Hooks now embed `sys.executable` and run the module
by its own absolute path.
- **Fixed** - `install-hooks` assumed hooks live at `.git/hooks/` - wrong for a project
using husky, lefthook or any custom `core.hooksPath` (common in a mixed JS/Python repo):
real, marker-carrying hook files got written and git never read them. Resolved via
`git rev-parse --git-path hooks` now (also correct for a worktree or a submodule); a
husky-managed directory is never written into at all, since it is regenerated by `npm
install` - the exact lines to add to `.husky/pre-commit`/`.husky/pre-push` are printed
instead.
- **Fixed** - a hook run imported and scanned the host project without suppressing its
own start-up logging, so a WARNING line written to the host's own log file on every
single run looked, to the scan cache's freshness check, like the newest input on every
run - a ~12s cache hit became a ~60-70s cold scan on every commit and push.
`scoped_findings()` also checked the git diff AFTER scanning; reordered so nothing
staged/unpushed skips the scan entirely.
- **Fixed** - `check --format json` fell into the whole-graph JSON renderer, which hit its
own size gate on any real project (75 MB / ~18.7M tokens measured) and exited 3 with the
CI verdict never computed, undocumented in `help check`. Now prints the actual verdict
(`passed`, `new_unresolved`, `counts`, ...) as JSON - the same dict the MCP
`seamcheck_check` tool already returns.
- **Fixed** - a one-off script a project's own `.gitignore` disowns (`OTHER/`, by
convention) fed both the JS reachability walk and the scan cache's freshness check as if
it were the product - a Playwright probe living there was read as a first-party writer
of a DOM element it never touches in production. New `seamcheck.gitfiles.tracked_files`
asks `git ls-files` directly rather than re-parsing `.gitignore` by hand, so nested
`.gitignore` files and negation are honoured for free.
- **Fixed** - six false-positive classes found by hand-checking 110 live findings on a
real project (26 wrong): `setAttribute`/`removeAttribute`/`toggleAttribute` (and a name
held in a `const`) now count as producers, not phantom unresolved reads; a CSS-escaped
character in a class name (`.p-0\.5`, how Tailwind spells a fractional class) is
unescaped instead of truncating the token; a descendant/combinator selector's WRITE
attributes only to its last compound, not every ancestor in the chain - corrected
same-day after re-measurement caught it silently dropping the ancestor as evidence too
(`.goal-bar .progress` with zero `.goal-bar` producers anywhere is still a real finding;
only the WRITE target narrows, existence-checking does not).
- **Fixed** - the served scope map's "view code" panel could never fetch a file's real
contents, even though the map genuinely was being served - the server endpoint that
serves source refuses any path not on an explicit allow-list, and the scoped map's
lighter renderer never populated one. Found live, driving a real map on a real project.
- **Changed** - `serve` is `map` under another name and always has been - one
implementation, so it no longer takes its own line in `seamcheck --help` or
`docs/commands.md`'s command table. `seamcheck serve` still works exactly as before.

**Measured:** precision **54%** (133 hand-labelled claims) · recall **6/6** · render
**46/46**. Coverage is unchanged by construction - the F44 fixes reclassify existing
`unresolved` findings as `connected`, they do not change what gets judged at all - and the
corpus was re-run to confirm: 34/34 cloned repos scanned clean, no CRASH, no repo lost its
routes, no IMPLAUSIBLE volume.

0.14.0

**A newer seamcheck now says so, and a click stays lit.** Reported from use, driving the
map on a real phone against a real project.

- **Added** - `seamcheck` and `seamcheck-mcp` check PyPI once a day for a newer release
and print one line on stderr when you are behind - `pip install -U seamcheck`
(`pipx upgrade seamcheck`). Off with `SEAMCHECK_NO_UPDATE_CHECK=1`, and skipped
automatically whenever `CI` is set, so "no network... in CI" stays true. The one
deliberate exception to "seamcheck makes no network call" anywhere else in this
package - narrowly scoped (a public version number in, nothing about the scanned
project out) and documented everywhere that other claim is made (README, SECURITY.md,
docs/reporting.md, the map's own "Send a report" panel).
- **Fixed** - clearing the search box after picking a *file* result left the map still
narrowed to it. The box also sets a *function* pick from the same field, and its own
clear button only ever reset that half - `fileFilter`, set by a file pick, was never
touched, so the box read empty while the canvas stayed filtered with nothing showing
why.
- **Fixed** - `.hud.tr` (Back/List/Widen/the theme button, top-right on a phone) had no
width cap and could not wrap. Enough of those pills visible together pushed the row
past the left edge with nothing left to reveal what fell off it - `body` is
`overflow:hidden` on both axes. Capped and made to scroll sideways, the same pattern
`.hud.bl` already uses for the same reason.
- **Fixed** - click a card and its whole chain lights (`.ed.lit`, the rest dimmed via
`.ed.faded`); move the pointer to any OTHER card afterward and the highlight visibly
vanished. Not because the click's state changed - `cv.tracing .ed
{ stroke-opacity:.07 }` is an ID selector and always outranks the click's class-only
`.ed.lit { stroke-opacity:1 }`, whichever node the hover was even over. Tracing is now
suppressed while a card is lit, the same guard already used for isolate.
- **Changed** - "Show only this chain" is filled solid now, not outlined the same as
"This is wrong" and "Undo the mark" beside it - the one button in that row that
changes what the CANVAS shows, and it read as the least important thing there.
- **Changed** - the canvas bands gain plain names beside the ones this tool already
uses: THE BROWSER's subtitle now says FRONTEND, THE SERVER's says BACKEND, and THE
STORE is fully renamed THE DATABASE. THE SEAM keeps its own name - it is not a side of
the wire, it IS the crossing, which is the reason this tool is named what it is. The
detail sheet's hop list (PAGE, MODULE, FETCH_TARGET, ...) now tags each hop with the
same phase word, for the reader who gets that list on a phone with no band to look at.

**Measured:** none of this reads source any differently, so the scan numbers are
unchanged by construction rather than by assertion. The corpus was re-run to confirm: no
CRASH, no repo lost its routes, identical totals.

0.13.0

**Two buttons, and one box to search with.** Reported from use, with the phone layout as
the reference: *"I want this to be like the Mobile. I want, MENU and then FILTER."*

- **Changed** - the map's chrome is two buttons at every width. **Menu** is the view list
and nothing else - Overview, Map, Files, Changes, Send a report. **Filter** is
everything that narrows what you are looking at - commit, emphasis, page, section,
search. The menu used to answer both questions while three more controls sat on the
glass beside it, so one intention was behind two different buttons and the five words
naming the views were the top fifth of a panel you had to read past. The phone already
had this shape; the desk monitor was the layout that had drifted.
- **Changed** - the button says **Menu**, not the name of the view you are in. "Map"
beside "Filter" is one noun and one verb, and it was never clear which was a place. The
breadcrumb already says where you are.
- **Changed** - **one search box, three destinations.** A file opens the map narrowed to
that file, on the page that holds it; a function opens its call graph; anything else
opens where it sits. Every row is badged with where it will take you. There were two
boxes over one intention - "Function..." on the glass and "Search everything" two clicks
deep in the menu - and each was right about its own half and silent about the other: a
filename typed into the first answered *"No function is called that"*.
- **Changed** - the served links are labelled by **who can reach them**, not by what you
would do with them: `this machine` (loopback), `same wifi` (the LAN address) and
`anywhere` (the public HTTPS one, when it is turned on). Reported from use: *"what is
open and what is mobile - they seem to be the same"*. They were two different addresses
and nothing said so. When there is no public link the footer now says what that costs -
a device on cellular, or on another wifi, cannot reach **either** address above.
- **Fixed** - the two buttons **overlapped on a phone**. `.hud.tl` carries a desktop cap of
`calc(100% - 250px)` so the left corner cannot grow into the right one's controls; the
phone rule sets both edges instead and never reset it - 140px of room on a 390px screen,
for 176px of buttons - so the menu's wrapper shrank below its own button and Filter was
drawn on top of the word "Menu".
- **Fixed** - a leftover `order:2`, written when the page picker was on the glass and had
to drop to its own row. `order` applies to a grid item as much as a flex one, so it was
silently reordering the filter sheet and putting the search above the page picker it
belongs under.
- **Fixed** - a `<select>` sizes to its widest option, so "Whole page" drew two thirds the
width of the page picker above it: the same control, twice, at two sizes.
- **Fixed** - a result row is two lines. Side by side in a 324px list the name and the path
both ellipsised, so six files read as six rows of `pointless/static/pointl...` with the
filename - the part anybody typed - cut off the end.

**Measured:** nothing in this release reads source, so the scan numbers are unchanged by
construction rather than by assertion. The corpus was re-run to prove it: 34 repos,
**identical totals**, no CRASH, and no repo lost its routes.

0.12.1

**One list, not two.** The Findings view was the map's own graph shown a second time, with a
second set of filters that had to be kept in step - and every time they drifted, the two
views disagreed about the same question.

- **Removed** - the Findings view. The map answers it, drawn or as a list, under one set of
controls. `seamcheck findings` and the `seamcheck_findings` MCP tool are unchanged: an
agent has no map, and that is still the only way a program asks what is wrong.
- **Fixed** - `explain <wrong-id>` printed "No symbol with id ..." and exited **0**. It now
exits 3 on both doors, like every other coded failure. The "did you mean" hint still prints.
- **Fixed** - list rows were never clickable, though every row already carried its symbol id.
A row opens the same detail the map opens, and carries its triage mark and owner.
- **Fixed** - the page picker was granted by one function and hidden by another, so it never
appeared on a list. One table decides which view has which control.
- **Fixed** - clicking a file in Files returns to the map as a drawing, which is what the row
promises.
- **Changed** - on a phone the status pills are one scrollable row, and Filter no longer drops
to a second line on views with a longer name.

Page 1 of 3

© 2026 Safety CLI Cybersecurity Inc. All Rights Reserved.