A coding agent can't fit your repository in context, and what it doesn't read, it guesses. zzop reads it and answers with one JSON map — which calls reach which routes, and which reach nothing at all. Same input, same answer, every time.
It writes no code. It makes the understanding your agent works from accurate.
Two independently authored apps — a React frontend, an Express backend. They share no code and no types. Someone tidies up one backend route.
- router.put('/user', auth.required, …) + router.put('/users/me', auth.required, …)
The frontend build stays clean — the route is a string literal, so there is no type to check it against — and its mocked tests stay green. The contract is already broken, and a linter, a type-checker or a test suite scoped to one repository is structurally unable to see it: the evidence is split across two repos and never crosses a compiler boundary.
=== unprovided consumes === "PUT /api/user" @ fe-vite src/pages/Settings.jsx:19 ← the call now hits nothing === unconsumed provides === "PUT /api/users/me" @ be-express auth.controller.ts:61 ← the route nobody calls
Both ends of the break, located to the file and line, across two repos that share nothing on disk.
Frontend calls joined to backend routes: unconsumed endpoints, method mismatches, path drift — even across repositories.
SQL injection, weak hashing, SSRF, hardcoded secrets — DSL rules plus native analyses, across languages.
Circular dependencies, dead code, refactor priority — structural debt quantified per file.
Eight languages are parsed natively — TypeScript · Python · Java · C# · Rust · Go · Prisma · SQL. Anything else joins through an adapter.
The real hazard in static analysis isn't a wrong answer — it's silence. If "found nothing" and "couldn't look" are indistinguishable, green means nothing.
warnings, never stubbed.That is what separates it from a flat list of findings: results are ranked by refactor ROI, and "two codebases quietly disagree" becomes a first-class finding.
Twelve repositories X (formerly Twitter) and xAI have open-sourced — from the For You feed to Grok's build system — in one run. The numbers below were measured 2026-08-15 with zzop 0.31.0, and each counts a different thing: walked is files visited in the tree, dispatched is the subset the eight native parsers actually received, and symbols are the declarations those parsers extracted.
facts run over everything: 73s cold, 30s warm.The point of this table is refusing to blend two ratios: 96% belongs to one tree
(x-algorithm), 37% to the whole set. Hide the low one and the high one stops being credible.
To re-measure: clone the repositories and run zzop facts --config once — each tree's
coverage block prints its own file / dispatched / symbol counts, and the twelve
blocks sum to the totals above. The seconds are that run's wall clock, not a printed field.
The same twelve repositories, run against the rule packs (2026-08-15, zzop 0.32.0). Only four produced any finding; eight came back with zero — a zero is a result too, disclosed rather than hidden. 171 in total, split 5 critical · 122 warning · 44 info. The point that makes the number honest: severity is a lexical judgment, not a vulnerability verdict.
conn-string-credentials (a URL embedding
scheme://user:pass@host in source), and all five sit inside a
#[test] function — test inputs for the code that strips credentials
(strip_url_credentials_removes_token). zzop never calls them vulnerabilities. It reports
what it lexically sees, names the config key that silences it, and lets a human read them as fixtures
in five seconds.command-and-interpolation 33 · reqwest-no-timeout 24 ·
hardcoded-secret 19 · high-entropy-secret 13 · fs-check-then-use 9.
The cross-layer join added 20 more — contract gaps like unconsumed endpoints and unprovided calls, the
layer a single-file rule cannot see.This is the product's thesis: zzop does not brag about findings — it states honestly what it
saw and what it could not. That all five criticals are test fixtures is not the weakness; the
strength is that it did not dress them as vulnerabilities. Reproduce: clone the twelve
repositories and run zzop cross --config once — each tree prints counts by severity and rule,
and every finding carries a rule id, a file:line, and the config key that silences it. The
five graphs of this corpus — including the full dep of all 4,457 files drawn on one canvas —
are on a page of their own.
zzop init
writes the config · once per tree
zzop analyze .
analyze this tree · JSON out
zzop cross ./web ./api
join two repositories
No Node.js, no npm, nothing to compile — download the binary from GitHub Releases.
zzopzzop-mcpA config is required rather than optional: the names zzop would otherwise guess about your project live in it, and a key you don't declare is a judgment zzop doesn't make.
zzop walks a repository and pulls the same shape of fact out of every file. Whatever the language, the result is one neutral representation. Those are merged into a graph, and every judgment is made on that graph.
This page is conceptual only — the field-by-field shape lives in the Contract.
Everything one file needs happens in a single pass: parse it, fold it into the neutral form, run the rules. Files go in parallel. The parser's raw AST never leaves that step.
Walk
collect the files · gitignore-aware
Parse → IR → rules
one fused pass per file
Assemble
one graph for the whole tree
Envelope
one JSON out
Stage three is where the files first meet each other: circular dependencies, dead code and structural scores come out here — and, when several trees are analyzed together, so does the cross-layer join.
Language-specific syntax ends at stage two: every file folds into the same four slots of CommonIr,
and everything downstream reads those four slots rather than any original syntax.
depsymbolslocioThe order is fixed from the walk onward — which is why the same input gives byte-identical output.
Support is not a yes or no flag. Each language is disclosed as a tier naming what reads it, and the precision that parser can stand behind follows from that.
Full AST — each language's own parser, linked as a library (swc · ruff · syn 2): symbols, imports, routes, outbound calls, ORM tables.Full CST — read through tree-sitter grammars: gin, Spring MVC and ASP.NET Core routes, GORM, JPA and EF Core tables, all into the same channels.Lexical — schemas only. Prisma models and CREATE TABLE statements provide the tables that queries elsewhere consume.External adapter — hand the same shape in through the Normalized AST envelope: stand in for a whole tree (Mode A), or overlay facts onto a natively parsed one (Mode B).What separates Full AST from Full CST is who reads the file — and therefore the grain of failure.
Full AST links the language's own parser, so it sees the tree that language's own toolchain sees,
and a file that fails to parse degrades to the lexical fallback whole.
Full CST reads through a tree-sitter grammar — an independent reimplementation, pinned to a version,
and error-tolerant: one broken member does not blank the rest of the file.
The price is that very new syntax may parse as ordinary CST while carrying no dedicated extraction yet
(Java 21's sealed-permits and pattern switches sit exactly there).
The tier is not a capability ranking — which channels a language actually produces varies by language,
not by tier, and the repo's per-language table in docs/ARCHITECTURE.md owns that list.
Every parser lives inside the Rust binary — reading Python needs no Python runtime.
A file with no native parser isn't dropped: it still gets a line count and still runs every text-scanning rule. What's missing is symbols, imports and IO — and the fact that it is missing gets written into the warnings.
What gets a native parser is decided by how common an environment is, not by what happens to be detectable. Anything niche arrives through an adapter instead — so a short list is not a ceiling.
Every parser records two things per file: what this file provides and what it consumes. Analyze several trees together and the two sides are matched on a normalized key — not by matching ASTs, but by asking whether the keys are exactly equal.
consume @ fe-vite fetch("/users/:id") → http GET /users/:id
provide @ be-express router.get('/users/:id') → http GET /users/:id
^^^^^^^^^^^^^^
exactly equal, so one edge
That is why even a crude external adapter can take part: get the key normalization right and it is a first-class participant. The two repositories need share nothing on disk.
/login and the like) is flagged low confidence.A gateway's mount prefix, and which hosts a tree owns, exist in neither repository's source — you declare them in
config (mounts · hosts). If a declaration moves nothing, that too is reported as a warning.
Incompleteness is the premise, so zzop writes what it could not do into the result itself. Green only means something when silence can be told apart from "found nothing".
degradedwarningscoveragedisclosureA file built of enormous single lines — bundler output and its kin — is a separate case: every text-scanning rule is skipped while structural extraction proceeds as normal, because a giant line gives a rule no context to scope to.
That is the concept. Field names and shapes live in the Contract, individual rules in Rules, and this repository actually analyzed in Graph.
Two binaries, one engine — so you choose by who runs it, not by what it can do. Decide this first, then install.
zzop-mcp.mcpb bundle and you type nothing.zzopBoth dispatch to the same handlers, so the same path gets the same verdict. Only these lanes are
CLI-only: manifest, diff, facts, coverage,
graph, explain, init. Neither binary makes a network request.
No Node.js, no npm. Four ways in — the first two are the agent lane, the last two the CLI lane.
/plugin marketplace add eezz4/zzop, then /plugin install zzop@zzop. The first session doesn't list the tools yet — restart once..mcpb bundle; it carries the platform binary.zzop-cli-<platform> (CLI) or zzop-mcp-<platform> (MCP) from GitHub Releases and put it on PATH.npm i -g @zzop/cli — a thin launcher that fetches and spawns this exact native binary.zzop init
writes the config · once per tree
zzop analyze .
analyze this tree · JSON out
zzop cross ./web ./api
join two trees · each needs its own config
On the MCP lane you type none of it: the client runs zzop-mcp mcp for you and the agent calls the tools.
zzop refuses a tree with no config rather than judge your code under assumptions you never saw.
The vocabulary block is where those assumptions live — what you call an auth guard, which URL segments
mark your API. A key you don't declare is a question zzop never asks.
{
"roots": ["."], // trees to analyze
"packs": { "only": ["security", "sql"] }, // whole packs
"rules": { "sql/nplus1": "off" }, // one rule at a time
"exclude": ["legacy/"], // drop by path
"vocabulary": {
"skipDirs": ["node_modules", "dist", "build", ".git"]
// zzop init fills in the rest
}
}
You needn't hand-write it: zzop init writes an annotated starter file whose every value
is zzop's own suggestion — so it documents the defaults instead of changing them. It never overwrites an
existing config without --force.
The full key list doesn't belong on a page: zzop contract config-surface prints the machine-checked
vocabulary and zzop contract config-template prints that starter file — from the binary alone.
The same goes for default values: what counts as an auth guard is decided by
several vocabulary keys together, authGuardPattern first among them
(a class name can prove a guard through authGuardQualifierTokens even when no method name matches),
and the template output owns their full text, not this page —
a regex copied truncated silently detects less.
The input/output JSON contract is on the Contract page.
Three layers, widest first — a whole pack (packs), one rule
(rules), one line (an inline marker). The first two are the config block above.
You never look a marker up: strip the pack prefix from the rule id and wrap it as zzop-…-ok.
It is derived rather than stored, so renaming a rule renames its marker — and every finding's message spells the
exact one. Put it on the flagged line or the line directly above it.
sql/nplus1 → zzop-nplus1-ok const items = list.map(x => db.find(x.id)); // zzop-nplus1-ok: batched below
Native analyses — dead-candidates, cross-layer/unconsumed-endpoint and friends — carry no
marker and are disabled in config only: "dead-candidates": "off". To soften rather than silence, use the
object form: { "severity": "warn", "exclude": ["legacy/"] }.
Two exceptions only: non-idempotent-write / unsafe-read-endpoint honor a hand-written
// idempotent-ok: <reason> (the colon is required), and dead-candidates /
unimported-export skip a file whose first 8 lines carry a generated-file banner (@generated, …).
zzop analyze <path>zzop cross <path>...zzop file <path> <tree>...zzop endpoint <pattern> <path>...zzop coverage <path>...zzop explain <rule-id>analyze and cross narrow the list with --severity, --rule and
--limit — only the list; counts always cover everything and truncation is disclosed.
Exit codes: 0 ran, 1 runtime failure, 2 bad argument shape. There is no
severity-gated exit code, so gate CI by reading the JSON yourself.
zzop help is the full list — the binary is authoritative, not this page.
Pictures come from zzop graph (Graph); the rules live on the
Rules page.
Every run answers with one JSON object carrying the same set of keys. Some slots stay even when empty; others vanish entirely when the capability didn't run — because those two are not the same statement.
This page is about what those slots promise. The per-operation field tables are not here — the last band says why.
The run is refused — a quietly smaller answer never arrives in its place. And
the starter config zzop init writes switches nothing off: packs.disabled is an
empty array, rules an empty object, exclude an empty array.
What it couldn't see, couldn't do, or had to cut all land in a named field. And the counts never shrink when you filter — only the shown list does.
One file failing to parse does not stop the run — that file is named in degraded.
An MCP tool failure is an ordinary result flagged isError, not a protocol error, so the
server stays up.
The CLI differs on that last one: a failure prints one zzop: <message> line to stderr and
exits 1 — not JSON. stdout is written on success only, so a pipeline can parse
stdout and nothing else.
What zzop analyze prints and what the MCP analyze_repo tool returns are
one object through one shaper. Neither host reshapes it into a dialect of its own.
{
"path": "/repo/api",
"config": "/repo/api/zzop.config.jsonc",
"fileCount": 1284,
"degraded": [], // files that lost structural extraction
"packsLoaded": [
{ "id": "security", "rules": 49, "source": "inline", "filesInScope": 912 }
],
"findings": {
"total": 137, // the full count, always
"bySeverity": { "critical": 3, "warning": 61, "info": 73 },
"byRule": { "security/hardcoded-secret": 2 },
"shown": [ ], // the filtered, capped list
"truncated": { "shown": 50, "totalMatching": 137, "hint": "..." }
},
"warnings": [ ],
"coverage": {
"files": 1284, "parserDispatched": 1102, "symbols": 8431,
"resolvedImportEdges": 3126,
"ioProvides": 84, "ioConsumesKeyed": 57, "ioConsumesUnresolved": 12,
"degraded": 0, "joinContributionZero": false
},
"configWarnings": [ ],
"disclosure": { "classes": 18, "asserted": 6, "partial": 10, "notYetDetected": 2 },
"gitWindow": { "recentDays": 30, "since": null }
}
ruleOverridesApplied · architecture · ruleTimings · degradedTruncated
the eleven above are always present; the conditional ones are on the line before this
What happens when there is no value is where this envelope's real contract lives. Three different answers.
warnings, configWarnings and packsLoaded ship even when empty — the empty array is the answer, "nothing to report".ruleOverridesApplied and architecture are absent when the capability didn't run — never a null that reads as "measured, came out empty".nullgitWindow: null means git signals never ran; architecture.pain: null means no metric had a population — which is not 0.The hazard is not a wrong answer but an answer that quietly got smaller. So every thing that narrows the scope has a field of its own to say so.
findings.totalbySeverity and byRule. --severity, --rule and --limit move shown and nothing else, so a number you quote can't shrink because of your filter.findings.truncated{shown, totalMatching, hint}. The hint names a remedy that actually works on that list — a fixed-cap list is never told to "raise the limit".packsLoaded[].filesInScope0 means the pack loaded but no analyzed file was in any of its rules' scope: zero findings is "out of scope", not "clean".ruleOverridesApplied{disabled, severityRemapped, only}. A mistyped rule id never lands here; it lands in configWarnings.coverage.joinContributionZerowarningsdisclosure is not about this run but about zzop itself: it counts the classes of
silence zzop does not yet catch. 18 today, 12 of them only partially detected
or not at all. The text is identical every run, so it ships once via
zzop contract disclosure-classes — the counts stay in the reply.
zzop cross takes two or more trees — one is not a valid call. What comes back
is not an array of single-tree replies but a different object: the per-tree summaries in
sources[], and beside them the join's own result.
{
"config": null, // paths mode: no single config governs it
"sources": [
{ "sourceId": "web", "path": "/repo/web", "fileCount": 812, "findingCount": 44, "coverage": { } },
{ "sourceId": "api", "path": "/repo/api", "fileCount": 1284, "findingCount": 137, "coverage": { } }
],
"buckets": {
"edges": 61, // matched consume->provide pairs
"unconsumedProvides": 9, // routes nobody calls
"unprovidedConsumes": 23, // calls nothing serves
"unresolvedConsumes": 7, "externalConsumes": 4, "ambiguousConsumes": 0
},
"bucketMeaning": "...", // the arithmetic, on the wire
"distinctBucketKeys": { "unprovidedConsumes": ["PUT /api/user"] },
"distinctBucketKeyFirstSites": { },
"edges": [ ],
"crossLayerFindings": { "total": 5 },
"configWarnings": [ ], "warnings": [ ], "disclosure": { }
}
buckets counts rows; distinctBucketKeys lists the keys
those rows collapse into. The two legitimately differ — one route called from three places is three rows and one
key — and the reply states that relationship itself, in bucketMeaning, so no reader has to go find a
document to check it.
The join also reports what it was not given: when every analyzed root sits under one common
parent, that parent's unanalyzed sibling directories are named in configWarnings — so the
join never quietly narrows to "the trees you happened to pass".
The full request- and output-field tables for every operation live in the source reference. Not copying them here is not about length: Rust meta-tests check the request table against the field list the deserializer actually accepts, and the output table against the registry below. A copy made here would be the one copy standing outside that check.
What the reply drops is registered too. Every top-level field the engine computes gets exactly one row saying whether the delivery surface carries it, carries it conditionally, or omits it — 28 rows today, 9 of them omissions, and an omission row does not pass unless it also names where that value can be had instead.
Per-operation field tables for all twelve operations live in the source reference. That is why this page only links: a table needs exactly one owner, and the owner is the copy a test reads.
The default load is 11 packs · 116 DSL rules plus 60 native analyses (33 single-tree + 27 cross-repo). The source site prints all 176 as one table — exact when you already know what you're looking for, and silent about what kind of tool this is. This page is the map, not the table.
Rule ids, pack names and suppress markers are strings you type into a config. Every one below keeps its original spelling.
security · 49hardcoded-secret · sql-string-concat · weak-password-hash ·
jwt-none-algorithm · cors-credentials-wildcard.db · 21update-delete-no-where · multi-write-no-tx · connection-no-release.reliability · 16fetch-no-timeout · async-route-no-catch · sync-fs-in-handler ·
interval-no-clear.sql · 8nplus1 · delete-no-where · destructive-migration.browser · 8unsafe-html-sink · postmessage-wildcard · vue-v-html.redis · 6flushall-in-code · keys-command-in-code · lock-no-ttl.egress · 3http-url-literal · ws-no-auth · get-and-body.http · 2protected-path-no-auth-evidence · dev-path-no-guard-hint.go · perf · reactgoroutine-in-loop · api-in-loop ·
setstate-after-async-unguarded. A pack is a category, not a quota.Which languages a rule reaches is a per-rule fact, decided by that rule's own
file_pattern — there is no pack-level equivalent. A pack can be dense for one language and
empty for another, so "how many rules for language X" has no answer. Ask about a concrete path instead.
Scoped to a single file — each rule declares exactly one matcher shape and cannot see a second file's content. In exchange, it's JSON, so you can write one.
Whole-tree: the dependency graph, dead code, schema, routes. Five of them (seams ·
criticality · scores · health · recommendations) are score
computations, not findings, and carry no severity at all.
Findings that only exist once several trees are joined — they run under
zzop cross alone: cross-layer/method-mismatch ·
cross-layer/body-field-drift · cross-layer/sensitive-response-field.
The split is about expressibility, not taste. Four things have no honest regex-over-lines encoding: declaration-to-use tracking ("declared, never read"), a cross-file join (the constant or route handler lives in another file), call-graph traversal ("handler X, or something it calls transitively, does Y"), and real AST/JSX shape rather than text co-occurrence. Those, and only those, are native.
Turning them off differs too. A DSL rule can be silenced one finding at a time with an inline comment; a
native analysis is disable-only — with two exceptions: non-idempotent-write and
unsafe-read-endpoint honor a hand-written // idempotent-ok: <reason> (the
trailing colon is required), and dead-candidates / unimported-export skip files
carrying a generated-file banner.
A rule isn't compiled code, it's an <id>.json file. Drop one in a tree's
zzop/rules/ and the next run loads it, no config key needed. There is
no first-party / third-party distinction at the interpreter level.
{
"id": "house-rules",
"schema_version": 1,
"rules": [
{
"id": "hardcoded-debug-token",
"severity": "warning",
"message": "X-Debug-Token header set to a string literal — read it from env/config instead.",
"matcher": {
"type": "line-scan",
"file_pattern": "(?i)\\.(ts|tsx)$",
"require_file": "X-Debug-Token",
"skip_comment_lines": true,
"line_pattern": "[\"']X-Debug-Token[\"']\\s*:\\s*[\"'][^\"'`]+[\"']",
"snippet_max": 160
}
}
]
}
those two ids ARE the contract → findings say house-rules/hardcoded-debug-token,
the marker is // zzop-hardcoded-debug-token-ok
Six matcher shapes to choose from — line-scan (one line's shape) · method-scan
(co-occurrence inside one function) · symbol-scan (declared symbols) · io-scan
(IO facts like routes and tables) · call-scan (calls the parser witnessed) ·
literal-scan (a literal's binding name, hash and entropy — never the value itself).
zzop/rules/packs.extraDirszzop.config.jsonc. One directory, or an array.packsDirDirectories load independently, then merge by pack id: if the same id appears twice, the
pack from the later directory replaces the earlier one whole — never a per-rule merge. That is how
you override a bundled pack without forking the engine. Four finished packs deliberately kept out of the
default set live in examples/packs/, each also served as a contract document.
db/float-money-compare info src/billing/invoice.ts:212 A money-named identifier (`price`/`amount`/`balance`/`fee`/`cost`) compared with `==`/`===`/`!=`/`!==` against a float literal (e.g. `price === 19.99`) — floating-point rounding error makes strict equality on monetary values unreliable. Represent money as integer minor units (cents) or a decimal library. the rule author wrote this much Suppress a vetted case with `// zzop-float-money-compare-ok`. Disable via config `rules: { "db/float-money-compare": "off" }` (embedders: `disabledRules`) the engine appends these two, always
The cause and the fix, the comment that silences this one case, and the config key that turns the rule off for the whole run — one package. You never go to the docs to ask why it fired or how to stop it.
zzop-<rule id>-ok — stored nowhere, so it can
never drift. The pack prefix is stripped: security/hardcoded-secret →
// zzop-hardcoded-secret-ok.symbol-scan findings have no source-line concept to anchor a comment against,
so they carry none. They are still always turnable off with
rules: { "<id>": "off" }.message
renders it twice — and the hand-written sentence goes stale the moment the matcher kind
changes, because it names comment leaders the engine no longer honours.All 176 rows — every rule's severity, matcher and exact subject, plus the 60-row native table — live in the
source catalog. That page
is generated from docs/rules/catalog.md in the repo, and a Rust meta-test machine-checks that every
id listed there is one the engine actually loads — the catalog cannot silently drift from the code.
zzop graph serializes the analysis into a standard graph format, writes it to stdout,
and stops there. Not one line of pixel-drawing code lives in this repository. You pick the viewer.
The picture those tables produce — this repository's own import graph — is at the foot of this page. Its coordinates and counts are not owned here: every build pulls them from the source graph page as they stand.
A viewer strictly requires the links table; the nodes table is the optional styling half. Two tables, one stdout, and zzop writes no files — so the format name is the selector.
zzop graph --domain dep --format cosmograph-links > links.ndjson
the edges · required
zzop graph --domain dep --format cosmograph-nodes > nodes.ndjson
the points · styling axes
links.ndjson — one line is one import {"endpointsInCycle":false,"source":"src/app.ts","target":"src/db.ts"} nodes.ndjson — one line is one file {"degree":7,"fanIn":6,"fanOut":1,"folder":"src","id":"src/db.ts","inCycle":false,"label":"db.ts","loc":214,"path":"src/db.ts","source":"web"} a run that collected git adds changeCount · churn · authorCount · lastModified
That sample is one row from one particular run, so it cannot tell you which columns are always there and which ones the run had to measure first. That split is the schema.
source/target/endpointsInCycleid/source/path/label/folder/fanIn/fanOut/degree/inCyclelocauthorCount/changeCount/churn/lastModifiedsource and target are spelled the way a viewer's mapping step already guesses,
so the common case needs no mapping at all. The direction is importer → imported.
churn: 0 would spell "never changed" in the same bytes as "nobody looked".endpointsInCycle> catches it whole.Owning a renderer means owning a coordinate system, a library and a viewer — none of which is an analysis engine's job. A table is the opposite: the same two files load into Cosmograph, Gephi or any force-graph library without conversion.
dep at 40 nodes by default. Drawing thousands produces a black square, which is worse than drawing nothing because it looks like information. This lane is uncapped instead: zoom does that job.--top or --fold to this format and it exits with an error — no flag is accepted and then ignored. --format cosmograph-* requires --domain dep.--scope <prefix>. An edge with one end outside the filter is dropped too, so no row points at a node the table doesn't contain.--domain joinedges list must not push a whole other bucket out of the picture.--domain dep--domain risk--domain posture--domain cochangedep, so it stands apart rather than blending — an import is read from source, a co-change is a sample of history. Default cap 30, lower than dep's: a co-change edge carries a weight the reader has to compare, and 40 weighted edges is already past the point where a flowchart reads as a picture rather than a list.--format takes mermaid (the default), cosmograph-nodes or
cosmograph-links. Mermaid serializes all five domains as flowchart text; the cosmograph tables
exist for dep alone. This lane is CLI-only — it has no MCP tool twin.
--top defaults differ per domain because their densities do: a join has tens of relations where an
import graph has thousands. All five are printed by zzop graph --help itself, which owns them —
not this page. Whatever a cap removes is disclosed inside the document, as a census line and a visible note node.
What follows is how the source page's viewer chose to arrange those tables — rules that live nowhere in zzop's output. That the same two tables support a different arrangement is this page's whole argument.
bin roots, scripts, files a <script> tag loads), so nothing imports them by definition.That drawing and every count on it are a snapshot: they describe the tree the page was last regenerated against, and nothing wires it to CI. Re-running the two commands above on your current checkout is the recount — which is also why none of that data is copied onto this page.
Drag to pan, scroll to zoom. Hover a dot and the left panel names the file's path and its
fanIn · fanOut · degree. The colour and size axes switch at the top of that panel —
what changes is the rendering, never the table.
The line above is what the command prints on stderr — stdout stays a parseable table. No node or edge count is hardcoded anywhere in this prose: the viewer counts the loaded table on the spot, so the numbers have one owner and re-measuring the graph can never leave this page stale. The layout is precomputed and rides in the data, so this picture is the same on every visit.