Measured: coverage 89% across 47 projects and 358,845 symbols (Flask 93%, Django 91%,
Express 69%, FastAPI 58%, NestJS 49%, Next.js 46%) · precision 54% on 133 hand-labelled
claims · recall 6/6 · corpus 34/34 scanned with no crash and no lost routes.
Against 0.10.0 the coverage figure is **not comparable**, and saying so is the point: the
denominator tripled. A Django project's Postgres half used to be 65 model names; it is now
332,177 symbols — tables, columns and the querysets that touch them — so 84% → 91% means
there is far more of a project in the map, judged at about the same rate. Precision is
flat (55% → 54%), which is what a release spent on ONE lens looks like: the Redis claims
that changed were mostly not in the labelled set, and a claim that stops being made simply
leaves the count.
The map asked Google for type every time somebody opened it
- **Fixed** — the map is one self-contained file and the promise it is sold on is that
opening it fetches nothing. It fetched a Google Fonts stylesheet for seven families.
A report is built against a private codebase and opened from inside a company network,
so that request told a third party the file was opened, from where, and when — and a
page that arrives with its webfonts on one machine and without them on another is two
different documents. **Every pack now names faces the reader's machine already has**,
each keeping its genre: a geometric humanist for Aurora, a neo-grotesque for Blueprint,
monospace throughout Phosphor, an old-style serif for Signal's headings, the plain UI
face for Slate.
- **Fixed** — `env_read:<not comparable>`, the one symbol raised when a project's
`.env.example` covers too little of what the code reads to judge any key by, shipped
with an empty evidence line. It carries the first read site now, like every other row.
A delete nobody writes is not a write nobody reads
They shared one red, **43 : 17** on the reference project — and every fix that mattered
came from the 17. The two are opposite findings:
- **A write with no reader is usually correct.** Telemetry counters and audit trails
exist to be read by a human with `redis-cli` after an incident. No scan will ever see
that reader, so those stay red permanently, and rightly.
- **A delete with no writer is almost always a bug, and it is invisible by
construction.** `DEL` on a missing key returns 0, raises nothing, logs nothing, and the
surrounding code reads as working invalidation. That is exactly how a stale-cache
defect survived a full test suite: the suite compared the API against the DOM, and a
stale cache makes both surfaces agree. Two views of one number can only prove they came
from the same place.
- **Added** — `redis_invalidation`, *"Invalidations that clear nothing"*, ranked first.
- **Added** — `redis_cleanup`, *"Erasure and teardown deletes (correct, and dead)"* — a
GDPR wipe, a logout, a harness reset. Finding nothing is what those are FOR, so the
claim is true and unactionable forever. One ordinary delete among the erasure ones and
the whole key goes back to actionable.
- **Added** — the limits beside the count, not only in the footer: code is not the
keyspace, and Celery, subscribers, WebSocket handlers and webhooks are untraced entry
points — which is where Redis is reached from.
**If you read the graph JSON and filter by kind, add both names.** A delete-only key is
no longer a `redis_key` node.
The split paid for itself the day it shipped: working the new list on the reference
project turned up a live cache bug — a delete keyed `{date}:{period}` against a cache
keyed by unix timestamp below a 1-hour rotation, so on the environment where an admin is
most likely to add a store item, "appears immediately" silently did not hold.
A sweep is what its consumer makes it
- **Added** — `scan(cursor, match="user:*:hourly_patterns")`. The key is not the first
argument, it is the **match pattern** — the cursor sits where a key normally would, so
nothing in the call looked like a key at all.
- **Fixed** — and what a sweep MEANS is decided by what consumes the names it returns.
`scan → hgetall` is a read of every key it matches; `scan → delete` is a wipe and only
evidence that the keyspace is visited. Counting every scan as a read invented eight
*"read here and written nowhere"* claims, all of them cleanup scripts; counting every
scan as a sweep called a live analytics keyspace write-only. Followed through
`.extend()`, `list(...)` and a comprehension, because
`[k.decode() … for k in scan_iter(…)]` is what these lines actually look like.
- **Added** — a `*` spans separators in Redis, so a sweep that is read through reaches
deeper than its own segment count: `analytics:seo:*` really does read
`analytics:seo:ref:{host}:{day}`. Two guards keep that from becoming a shrug — the
pattern must name two segments of its own, and the sweep must end in a read.
- **Fixed** — the pattern itself is no longer a claim. Nothing stores `analytics:seo:*`.
A guard's return value is the read
- **Added** — an `INCR`/`HINCRBY`/`DECR` whose result is bound to a name, awaited, or
unpacked from a `pipeline.execute()` that is actually drained. A rate limiter never
reads its counter back; it uses what the write returned, so every working limiter in a
project reported as a write nobody reads. A pipeline whose result is thrown away stays
a write. The command list is the ones whose answer is the STATE — not `rpush`, whose
answer is a length: a dead key that looks alive costs more than the red it removes.
- **Added** — a Lua `redis.call("SET", key, v, "NX", …)` is the same guard as
`set(key, v, nx=True)` in Python — `if not redis.call(…)` is the read — so the atomic
guards on a project's hottest path are no longer written-and-never-read.
Three more ways a key never appears at the call site
- **Fixed** — **two functions of one name.** `safe_get(r, key, default=None)` is a
project's Redis wrapper; a helper nested inside an export is also called
`safe_get(key, default=None)`, and keeping only the last signature filed every key
under the parameter named `default` — so the wrapper's own `r.get(key)` found nothing
and a cache in constant use read as written-and-never-read. Every signature is kept and
each is tried.
- **Added** — `PREFIX + str(user.pk)`, an f-string spelled the older way. Assignments are
read twice now, so the second pass sees the constant the first one learned.
- **Added** — `eval(SCRIPT, 1, _cold_slots_key(name))`: a script handed its key by a
builder. Only literals were read there, so the keyspace of the scripts that run on the
hottest path was invisible.
- **Fixed** — a Lua local keeps its whole name. Taking the first quoted piece and calling
the rest one hole turned `'dedup:' .. user_id .. ':' .. request_id` into `dedup:{}`.
Code the project itself calls disposable is not evidence about the project
- **Added** — directories named by the repo's own `.gitignore` are not read. A demo
seeder and a folder called `management_commands_archived` produced nine findings that
were all true and none about the product. Simple entries only — a bare name or a
trailing slash; a glob, a path or a negation is left alone, because reimplementing
gitignore is not the point.
All of the above came from a reader adjudicating **every** Redis claim on the reference
project by hand, twice: 117 claims read one at a time, then the 92 that survived. Claims
on that project went **92 → 62** with no code deleted for it, and the two that were
provably wrong are now connected.
One row per id — 6,817 of the reference project's rows were duplicates
An id is the map's node key, so a repeated id was never drawn twice. That is precisely why
nobody noticed: nothing looked wrong. **7,009 of 60,240 rows** on the reference project
named an id another row already had — every count that walks the symbol list was inflated
by them, and they were paid for in bytes on every render.
- **Fixed** — **`relativise` was making them.** It rewrites the absolute path inside a
symbol's id, so two spellings of one file — `./pointless/x.js` and `pointless/x.js` —
only collapse to the same id at that point. Deduping anywhere earlier cannot see them:
they were made identical by the very pass that exists to make ids stable.
- **Fixed** — `:evidence` appended to a `sub` that already ended in it, giving
`class:string:evidence:evidence` — a value nothing downstream matches on.
- **Added** — the invariant is enforced in one place at the boundary rather than at the
half-dozen places that append to the list, and where two copies disagree the one
carrying an owner and a note wins: it was resolved against real evidence, the other is
the same symbol seen again with less attached.
**53,233 rows, 53,233 distinct ids, and not one verdict changed.**
The SWR cache, which read as write-only
Every cached endpoint on the reference project has moved onto the `swr:*` keyspace, and
the lens could see the writes and not the reads — so the keyspace the project's whole
caching strategy now runs on looked like a cache nobody reads. Two reasons, both about
one hop of indirection:
- **Added** — **a key builder may return a PAIR.** A serve-stale-while-revalidate cache
keeps a fresh copy and a stale one, so `swr_keys()` returns two, and only a single
return was followed. `fresh, stale = swr_keys(name, uid)` binds each name to its own
key now.
- **Added** — **a function that returns a client is a client factory.**
`reader = _swr_reader(user_id)` is not a client-shaped name and neither is the
function, so every read through it was invisible while the writes — through a pipeline
off a recognisable client — were seen. And when the branches disagree
(`_swr_reader` hands back the replica or the user's shard depending on config) it is
still a client, just one whose connection is not known: an unknown connection is not a
second one. Requiring the branches to agree left the read path invisible.
`swr:*:fresh:*` and `swr:*:stale:*` are both connected now, three more keys moved off
`uncertain`, and the reference project reads **504 keys · 322 connected · 3,306 uses**.
Bulk invalidation, which is how half the deletes in a real project are written
- **Added** — **a command handed a LIST of keys touches every one of them**: inline
(`cache.delete_many([...])`), by name, splatted (`r.delete(*keys_to_clear)`), and built
up by `.append(...)` in a loop. The literals were right there and the command was right
there, and nothing joined them because the argument is a list rather than a key.
- **Added** — **a loop over a key list**: `for key in keys_to_clear: r.exists(key)`. The
loop variable stands for every key in the list, one at a time.
- **Fixed** — a concrete key could never be reconciled with the pattern that writes it.
Only wildcards were matched against other keys, so `pps:board:i:mouse` could not be
answered by `pps:board:i:*`. And the "a bare `*` may not vouch" rule was too blunt:
`user:*:*` spells out one segment and is what a GDPR wipe scans, `pps:board:i:*` spells
out three and simply IS that board. The difference is how much of the key the pattern
actually names.
On the reference project: **501 keys** (was 469), **317 connected**, **3,294 uses** (was
3,052).
A band's languages are containers now, not a label in the corner
Reported from use: *"each section actually holds multiple languages, multiple backends,
microservices — inside the main sections there should be separate containers per
language, so the .py files are together and any JS is together, in a different coloured
container."*
The band already knew. It collected every language it held and printed them top-right as
`CSS · JavaScript · Template`, while the cards themselves sat intermixed — so the one
thing that label promised, that this strip crosses a language boundary, could not be seen
anywhere on the canvas.
- **Added** — **one container per language inside a band**, drawn as a box in that
language's own colour with the language named on it. The hues are the ones a developer
already reads as that language, lifted where a dark ground would swallow them.
Solid and 2px against the band's 3px white: the same idea one level in, which is what
was asked for. A band holding one language is left alone — the band already says so,
and a box round everything it contains divides nothing.
- Language is its own channel: it colours the **container**, never a card and never a
wire, so it cannot be read as status — which is what colour means everywhere else here.
The store keeps its existing service lanes (Postgres, Redis), which answer a better
question about that band than "Python" would.
- **Fixed** — **a function's page showed where it goes and not that anything arrives.**
Filtering the reference project on `submit_push` drew THE SERVER and THE STORE and
nothing above them: the page walks what the function REACHES, and the browser is on the
other side — the fetch reaches IN. So the one view built for *"I am working on
submit_push, show me everything"* could not answer **does a push actually get here**.
The request is followed as a shape — store row → handler → route → fetch → js call —
rather than by widening, because the seam is three or four hops from a store row and
widening that far drags in half the project. All four bands now.
- **Fixed** — and `visible()` threw it away again. It re-derived the set from
owner-matched seeds with the same one-hop walk the page had already done: two
implementations of one thing, disagreeing, with the weaker one running last. A
function's own page is now trusted, since it was built for exactly that filter.
- **Fixed** — **the chain reached one store row on the busiest handler in the reference
project.** `submit_push` walked 74 functions deep and matched four of them: the call
graph names a method `Class.method` and a symbol's owner was looked up bare, so every
method was dropped and only the undotted names survived. `submit_push` reaches **87**
store rows now — 73 Redis keys, 6 tables, 8 columns — and the project's handler-to-store
edges went from 1,017 to **2,958**. Found by asking the map whether a push really
reaches the store, which is the question the band exists to answer.
A key only ever deleted is a finding now, because the reasons not to say so are gone
*"Only ever invalidated here"* was `uncertain`, on the reasoning that **deleting a key is
evidence that something writes it**, so the writer must be somewhere the scan could not
follow. That was right while the writer usually WAS: the async cache API was unreadable, a
key built into a local was unreadable, a key handed to a helper was unreadable.
All three are read now, and the reasoning has inverted — the hedge was hiding the finding.
On the reference project `api:user_stats:{uid}` appears **ten times and every one is a
delete**: the endpoint moved to the `swr:user_stats:*` keyspace and the invalidations were
left behind, so ten round-trips on the purchase, push and gift paths clear a key nothing
writes. A key named in a file the scan reads no code from is still not claimed.
- **Fixed** — **`adelete` was in the invalidation set and in no command table**, so it was
never a command at all: every async delete in the project was invisible while the sync
ones were read. The set that decides what a delete MEANS is not the set that decides
whether a call is Redis at all.
- **Added** — `self.CACHE_KEY` / `cls.CACHE_KEY` / `Service.CACHE_KEY`: a constant on the
class body, which is where a service keeps the name of its own cache. Only a bare name
was understood, so those reads were invisible and the key read as delete-only.
**On the reference project: 461 keys, 302 connected, 2,995 uses — and `uncertain` down
from 90 to 48**, because a verdict it can now reach is not an unknown.
The half of the cache API the lens could not see
`adelete` was in the invalidation set and **`aget` and `aset` were in nothing at all**.
On the reference project that is **96 `aget` and 56 `aset` calls invisible while 23
`adelete` were read** — so 45 keys came back *"only ever invalidated here"*, a category
built entirely out of the half of the API that was missing. The tool was describing its
own blind spot and blaming the code.
- **Added** — Django's async cache API: `aget`, `aset`, `aadd`, `aget_many`, `aset_many`,
`aincr`, `adecr`, `atouch`, `ahas_key`, `aget_or_set` — and `add`/`aadd` read as the
SETNX they are, a lock whose return value is the read.
- **Added** — **a key follows the argument into the helper it is handed to.**
`cache_key = f"cache:avatar:{uid}"`, read in the caller, then passed to
`_build(request, cache_key)` where the `aset` lives. Inside the helper the name is a
parameter, so nothing in that file assigns it and the write could not be seen — and
that is how every cached endpoint on the reference project is written. One helper
called with two keys writes both, which is not ambiguity but two writes.
On the reference project: **459 keys, 297 connected** (was 223), **2,958 uses** (was
2,388), and the claims down to **5 read-never-written — every one hand-verified real —
and 75 written-never-read.**
A table is asked the same question a key is
`connected` on a table meant "some queryset mentions this", which put **59 of the
reference project's 61 tables in one bucket** and asked nobody to look at anything. Redis
has paired writes against reads from the start. A table is the same question.
- **Added** — **reads paired against writes per table**, and the verdicts that follow:
written and never read, read and never written, or wired. The row now reads
`6 write / 0 read` the way a Redis key does.
- **Added** — three things that had to be readable first, or the pairing would have
invented findings out of its own blind spots:
- **a reverse accessor is a read of the table it returns.** `self.tier_set.order_by(…)`
is how the reference project reads its bonus tiers; reading only `Model.objects` saw
those tables written by a seeding command and read by nobody.
- **`select_related("config")` is a read of the config table** — the join is the whole
point of it, and it used to be skipped entirely.
- **an admin-registered table is written by a person**, at runtime, through a form no
scan will ever see. Reading such a table and never writing it in code is how every
config table in every Django project works, and is not a finding.
- **Fixed** — a reverse name two models share answers for neither. saleor declares
`related_name="lines"` on OrderLine, FulfillmentLine and more; keeping the first seen
made `fulfillment.lines.create(…)` a write to the ORDER line table and checked its
keywords against that model's fields — **609 invented missing columns on that project
alone.** Same rule as a class name two apps share.
- **Fixed** — a chain stops at a call whose return type is not in the expression.
`request.profile.checks_from_all_projects().only("code")` returns a queryset of another
model, and unwrapping calls blindly walked past it to `profile` and checked `code`
against the wrong table's fields.
- **Fixed** — one table row per PLACE. `Model.objects.filter(…).first()` is two queryset
methods, one line and one query, and counting both made every chained read read as two.
**Across the corpus: 1,817 tables, 176,612 queries, one claim** — a field renamed by a
migration that a fixture command still passes. On the reference project the eight tables
the pairing first flagged came down to **one**, and it is real: `SeasonConfig` is written
by its own `save()` and read by nothing, because the readers were migrated to `Season` —
the code says so itself, in a comment reading *"replaces SeasonConfig.get_all_divisions_config"*.
Reading the wires
Reported from use: *"on the full map it is not visible which the wire is connecting"*, and
*"different colours for bi-directional and one-directional?"*
- **Added** — **hover a card and its own wires come forward**, everything else drops back
far enough to read as background. Its own wires, not its chain: a chain on hover lights
half the canvas and is exactly as unreadable as the thing being fixed. Until now the
only way to trace a wire was to CLICK a card and isolate it, so reading a dense page
meant committing to a click per guess and the pointer did nothing at all. Class
toggling on wires already drawn — a redraw per pointer move on a ten-thousand-node page
is a frozen tab.
- **Fixed** — **an arrowhead was scaled by the wire's thickness, and thickness is the
edge-COUNT channel.** An SVG marker defaults to `markerUnits="strokeWidth"`, so the
common case — one edge at 1.1px — got the smallest head on the canvas while a merged
bundle got a giant one. Direction was drawn correctly on every wire and rendered
unreadably on the ones that needed it: on the reference project's store page **30 of
106 wires run both ways** and not one could be seen doing it. Fixed size now, in canvas
units, whatever the wire weighs.
- **Fixed** — **picking another page carried the isolated node with it.** Its chain on
the new page is empty, so a page holding 3,273 nodes drew *"Nothing to draw here. Try
another page."* An isolated empty canvas is indistinguishable from a broken one.
- **Fixed** — a redraw left the highlight stuck. `tracing` is module state and the
dimming is a class on the `<svg>`, which survives `innerHTML` — so after any redraw the
canvas stayed dimmed with nothing lit, and hovering the same card returned early
because the place had not changed. Found on the real map, where the first pointer
worked and the second did nothing.
**On colouring direction: no, and not as a matter of taste.** Every channel already
carries meaning — **colour** is the status (connected / unresolved / unused / uncertain),
which is the whole claim vocabulary; **width** is how many edges the wire stands for;
**opacity** is focus. A red one-way and a red two-way wire would make *"is this red
because it is broken, or because it is one-way?"* a question the reader has to ask, which
is the ambiguity this map exists to remove. Direction keeps the one channel that is its
own — the arrowhead — and that channel now works.
The chain reaches the store
- **Added** — the data layer is in the **mandatory gate**. It had no fixture at all: the
suite that must pass on every commit planted routes, fetches, DOM and CSS, and not one
table, column or key - so everything above could regress and only a column in the
corpus table would notice, if somebody read it. The planted set is a `Meta.db_table`,
a ForeignKey named both ways, a `values("naem")` that Django raises on, a key built
into a local, and **a handler that touches nothing itself** - the shape every real
handler has, and the one that used to stop the map at the server band.
- **Added** — **a handler is joined to the store rows its own work reaches**, through the
functions it calls. The map drew the reference project's Index page as
browser → seam → server and stopped there, on a page whose handler exists to increment
a Redis counter: the counter was found, the handler was found, and nothing joined them,
because the reads sit in `read_hero_push_count`, a helper one call away from
`hero_push_counter`. **Delegating is what handlers do**, so the chain stopped one hop
short on every page that has one — and *the database, the server, and everything going
out to the client and coming back* is the question a map of a Django project is for.
1,017 of these edges on the reference project; four calls deep, evidence and never a
verdict.
Open
- **13,700 duplicate symbol ids**, all in the DOM lens: 11,199 `dom_selector` and 2,518
`dom_attr` rows on the reference project share an id with an identical twin - same
status, same note, same line. The map keys nodes by id so nothing is drawn wrong, but
every count that walks the symbol list is inflated by them and they are paid for in
bytes on every render. Found by checking the map's own wires; not yet fixed.
The Redis half: 260 keys to 446, and 98 claims to 92
The same pass over the other store. The lens read the calls whose key is written out at
the call site, which on the reference project is a minority of them - so it found 260
keys and could not connect most of them. It finds **446** now, **186 connected** (was 59),
and **2,260 uses** (was 1,399).
Every one of these was a shape the source spells out and the lens did not read:
- **Added** — a key built into a local. `cache_key = f"api:user_stats:{uid}"` and then
`cache.set(cache_key, …)` is the house style for every cached endpoint here, and it
made the WRITE invisible: 65 keys read "1 invalidate / 0 read", the delete spelling the
literal out and the write one line above not. Scoped to the enclosing function, then the
module, and a name reassigned per branch (`err_key` in the 5xx arm and again in the 4xx
arm) means whichever was assigned nearest above the line.
- **Added** — the key a Lua script names. `evalsha(sha, 1, KEY)`, `keys=[KEY]` on any
call, a Script from `register_script`, and key literals inside the embedded Lua source
itself. A script names its keys and hides its commands, so this is `uncertain` with that
said - never a read, which is what it was being counted as.
- **Added** — composed keys. `base = f"challenges:schedule:django_{id}"` then
`f"{base}:joined"` was the pattern `*:joined`, which matches nothing. Fragments count:
`schedule_id = f"django_{id}"` is not a key and is what the key is built around.
- **Added** — two spellings of one keyspace meet. `pps:records:t*` read here and
`pps:records:tnormal` written there, matched segment by segment because a Redis key is a
colon-separated namespace and its depth is part of its identity. A namespace sweep
(`user:*:*`, what a GDPR wipe scans) vouches for nothing.
- **Added** — a key named in a file this reads no code from. `celery:beat:crashloop` is
set by the beat wrapper, a shell script with Python inside it. It is not written
nowhere; it is written somewhere this cannot follow, and only one of those is a finding.
- **Fixed** — **`ar` was not a Redis client.** The async client throughout the reference
project is called `ar`, which is not `redis`, `cache`, `client` or exactly `r` - so
every async wrapper (`async def a_safe_rpush(ar, key, *values)`) read as not-Redis and
the whole async write path was invisible.
- **Fixed** — a client is one because of what MADE it. `ar` and `r` from the same factory
are one connection, and the "touched through more than one client" warning fired on 21
keys for the difference between two variable names. An unresolved name is an unknown
connection, not a second one; an unstated database is not a different one; an import
alias (`from … import redis_client as _r_ann`) is a client. 42 warnings down to 12, and
each remaining one names two real factories.
- **Fixed** — `cache.set(key, value, 3600)`. The timeout is positional in Django's API and
in redis-py's, and reading only `ex=`/`timeout=` keywords reported six correctly-expiring
caches as keys Redis keeps forever. A separate `expire(key, …)` beside the write is the
expiry too - that is how a fixed-window rate limiter is written.
- **Fixed** — a key whose namespace is decided at runtime is never claimed.
`sk = sched_map.get(id)` then `f"{sk}:progress"`: there is no saying which key that is,
let alone that nothing writes it.
**Then the paths were checked on the map itself, which is where four more came from.**
A row on the map is a place a reader clicks, and reading it as one turned up things no
count would have:
- **Fixed** — **the commands in a Lua script all pointed at line 1 of it.** The script is
one Python string, so every key it touched carried the string's line number, and a
write on line 40 opened the top of the file.
- **Fixed** — **a script's keys belonged to nobody.** The body is a module-level constant,
so `PPSService.submit_result` touched nothing while the eight writes its own script
performs sat unattributed at module scope. The map's function filter - which is how a
reader asks *what does this handler touch* - could not reach one of them. A script
belongs to whoever runs it.
- **Fixed** — **eight rows on one line, sharing one id.** A script with eight commands on
one key produced eight rows at the call site. One row per key per PLACE now, a write
outranking a read outranking a bare script touch.
- **Fixed** — a script touch said it "reads". `evalsha(sha, 1, KEY)` names the key and
hides the command; the row says **runs a script over**, and a key touched ONLY by
scripts now gets its rows at all - it used to appear on the map with no path to any
line, which is the opposite of what the map is for.
- **Fixed** — `":sum"` and `"unknown:n"` were keys. Lua builds hash fields the way keys
are built (`prev_input .. ":sum"`), and only a `local` declaration is a key now.
- **Fixed** — `HINCRBYFLOAT` was not a command. It sat one line below `HINCRBY`, which
was, and the difference was visible only by reading the two rows side by side. The
sorted-set ranges went in with it - most of what a leaderboard does.
- **Fixed** — **a docstring that explains Lua is not Lua**, caught by this tool scanning
itself: the docstring describing this very feature quotes a `local` and a `redis.call`,
and the lens read its own explanation as a script.
**Five claims survive on the reference project, and all five are real** - reads of keys
nothing in the repository writes, in any file type: a reward payout that collects an empty
list every run, a seven-day history that renders empty for everyone, and a streak column
reading a key the codebase's own notes record as the wrong spelling.
The Postgres half of a Django project, which did not exist
A Django scan knew 65 model NAMES on the reference project and nothing else - every one
`uncertain`, noted "no ORM-usage extractor yet". No table, no column, no query. So the
one question a data layer is for - *which table does this page read, and where* - could
not be asked at all on the stack this tool is built for.
It is read from the source now, so a checkout that cannot be imported gets the same
answer as a project that can. Across the corpus: **1,817 tables and 164,331 queries that
were not there before.**
- **Added** — `db_table` / `db_column` for every model, and `db_table_use` /
`db_column_use` for every queryset that touches one. `Meta.db_table` and `Meta.app_label`
where set, Django's `<app>_<model>` otherwise; a ForeignKey stored as `<name>_id` and
answering to `user`, `user_id` and `pk`; proxy, abstract and multi-table inheritance;
`cls.objects` inside a model's own classmethod; the far end of every relation; and
django-modeltranslation's per-language columns.
- **Added** — a **Data Layer** section in the terminal report. Tables, keys and columns
had no section at all: they appeared only if something was already wrong with them.
- **Added** — `tables`, `queries` and `badcol` columns to the corpus gate. The lens
landed on 17 Django repositories and every total held steady, because every column
counted routes. **A lens no column watches is a lens that can regress silently** - and
the first run of those three columns reported 17,080 missing-column claims where the
reference project produced 5.
- **Fixed** — **a Django app with no ROOT_URLCONF scanned to nothing at all.** `scan`
returned an empty result the moment it could not find a URLconf; the reasoning is about
routes and it was quietly deciding the database too. mezzanine, a CMS with 24 models,
came back empty. It reads the data layer either way now.
- **Removed** — the model extractor that shelled out to django-extensions'
`graph_models`. It needed an optional dependency, a subprocess and a project that
imports cleanly to return names alone. `pip install 'seamcheck[models]'` is gone with
it; nothing optional is needed for the data layer now.
**Every claim it makes is one Django would raise on.** The first run said 17,080 columns
were missing across the corpus and 744 on the reference project; **every one was wrong**,
in eleven distinct ways, each now a test:
| what it read wrong | what Django actually does |
|---|---|
| `filter(user=…)` | a relation answers to its own name, not only `user_id` |
| `filter(id=…)`, `filter(pk=…)` | every model gets an `id` it never declares |
| `order_by("-created")` | the `-` is a direction, and `"?"` is a shuffle |
| `aggregate(total=Sum(…))` | the alias is the name of the RESULT, not a column |
| `get_or_create(defaults={…})` | `defaults` is how the call works |
| `dates("created", "day")` | the second argument is the granularity |
| `type: models.Field[…] = …` | an annotated assignment is still a field |
| `FlexibleForeignKey(…)` | half the ecosystem ships its own relation fields |
| `create(url=…)` on a property | Django pops it and swallows the missing setter |
| `apps.get_model(…)` in a test | that is the model at one migration, not this class |
| a base class from a package | it may declare any number of columns |
The last one is the rule under all of them: **a model this could not fully read never
claims a missing column.** Across 34 repositories, 1,817 tables and 164,331 queries, one
claim survives - a field renamed by a migration that a fixture command still passes, and
it is real.
From the 0.10.0 report: all 869 findings on two surfaces of the reference project
re-adjudicated against this release, every REAL verified against the running page before
anything was touched. The new `dead_region` lens deleted 161 lines that four hand passes
had walked past - and flagged one thing that must never be deleted, which is the first
fix below.
- **Fixed** — **a guard element more than one module reaches for is CONDITIONAL, not
missing.** `avatarGrid` is read by one cluster in one file: the region under it was
dead, and 161 lines went, including a `window` resize listener every arena page was
registering for code that cannot run. `lostStreakBtn` had **identical evidence** -
absent from every template, absent at runtime - and is the production streak-save CTA,
rendered only when a player has a lost-streak buyback opportunity, read by a second
module and asserted by the regression suite. A conditionally rendered element and a
nonexistent one are indistinguishable to guard reachability, and to a probe run on an
account in the wrong state. What separates them is mechanical: **who else reaches for
it.** A region whose guard element another module also reads is now `uncertain`, says
"check first", and does not fold the findings inside it away. Getting this wrong deletes
a payment-adjacent feature no ordinary test run would catch.
- **Fixed** — **an element the markup itself wires up is in use.** `<label for="ann-title">`
on line 72 and `<input id="ann-title">` on line 73: the browser resolves that with no
script and no rule, and the input was reported because no stylesheet names it. Nobody
styles a form field by id. `for`, `aria-controls`, `aria-labelledby`, `headers`, `list`,
`form` and `popovertarget` were already read as evidence; the CSS branch never asked.
246 findings of this shape across two surfaces.
- **Fixed** — **somebody else's stylesheet is an oracle, not a finding.** Django's own
admin CSS was carried as symbols, so 180 rules in a package directory - which nobody
reading the report can edit - were listed. It still answers "is this class defined
anywhere"; it is no longer reported.
- **Fixed** — **a model knows which file it is in.** 65 model symbols carried no path at
all: not openable, not attributable to a page, not distinguishable from another of the
same name. Django knows; nothing was asking.
- **Fixed** — **the `unverified` JSON lists one row per place**, like the console and the
map already do.
- **Fixed** — **copying a command is not marking anything, and the panel said otherwise.**
Tapping a reason in "This is wrong" replaced that option's DESCRIPTION with "copied",
permanently and for every option tapped - so five taps left five identical green rows
with their meanings gone, nothing recorded anywhere, and no way back. It reads exactly
like five marks that cannot be undone. The description comes back after a few seconds,
only one option shows the copied state, the button that opens the panel closes it, and
the panel says plainly that nothing is marked until the command is run.
- **Fixed** — **a `data-` attribute in generated markup declares an element.**
`arena_band.js` builds its purchase confirm dialog as a string -
`'<button … data-ab-c-buy>'` - and queries `[data-ab-c-buy]` four times. The
generated-markup reader looked for `id=` and `class=` in those strings and not for
`data-`, so thirteen live hooks read as elements nothing renders. Acting on them would
have broken the arena's push-purchase dialog.
- **Fixed** — **a project's own wrapper around the client is still the client.**
`safe_set(r, "store:current_period", value, ex=3600)` is a SET, and the lens saw a plain
function call with a string in it. Learned from the wrapper's BODY, never its name: a
function is a Redis command when its first parameter is client-shaped and its body calls
`<that parameter>.<command>(<another parameter>)`. `def set_the_table(guests, key):
guests.append(key)` is not - `append` is a Redis command and also what every list does,
which is exactly the trap the body rule avoids. Eleven keys the wrapper hid went from
claimed to connected.
- **Fixed** — **an alias is not another client.** The "touched through more than one
client" check compares receiver NAMES, and a wrapper is called `r` in one module,
`redis_client` in another and `cache` in a third. Feeding it three names for one
connection turned eleven correct keys uncertain - the same mistake the pipeline change
had to undo a day earlier, in a second place.
Measured on the reference project: claims **3,646 → 3,531**, non-connected
**7,230 → 6,904**, Redis keys seen **240 → 260** with connected **44 → 59**, and
`showLostStreakBuyBackButton` now reads *"22 lines, check first"* instead of *"22 lines
unreachable"*.
**Checked and left alone:** the report asks for `store:current_period` to resolve,
saying the read is behind a variable. Every literal mention of that key in the project
is a write, a delete, or membership of a health-check list - there is no read of it
anywhere - so `written and never read` is the honest answer and it stays.