What Changed Inside the PViz Analyzer, and Why the Service Is Now Free
More languages, compiler-assisted call resolution, honest reporting of what the analyzer cannot see, and why PViz moved from tokens to free use with fair-use limits.
The PViz analyzer changed a great deal this year.
The service around it changed too. Analyses no longer cost tokens, and the hosted product no longer includes AI reports or an MCP server.
Those are two separate changes, but they share a motive. Both were decisions about what PViz should claim.
An analyzer makes claims. Every edge in a dependency graph says that one piece of code depends on another. Every missing edge says nothing, which is easy to mistake for a claim that no dependency exists. A tool like this earns trust by being careful about both.
What should an analyzer say when it knows, and what should it say when it does not?
This post describes what was done to the analyzer, what it changed in the output, what it still cannot do, and why the hosted service is now free.
What we wanted the analyzer to do
There were four goals, and they pull against each other.
Cover more languages. A repository that mixes a Kotlin backend, a TypeScript frontend, and a C# service is normal. A tool that handles one of them well and ignores the others gives a misleading map of the whole.
Resolve more relationships without guessing. A call graph is only useful if its edges are right. A wrong edge is worse than a missing one, because people act on it.
Report its own coverage. If a file could not be read, the analysis should say so. A result that looks complete when it is not is the most dangerous kind.
Fit inside a fixed budget. A free service runs on a small, fixed amount of memory and time. An analyzer that is correct but needs unbounded resources cannot be offered to everyone.
Most of the work was finding changes that served more than one of those goals without trading another away.
One engine, changed one patch at a time
PViz uses a single analysis engine. It is a controlled fork, and the rule for changing it is strict: every change is a numbered patch.
There are 78 patches so far, numbered 0001 through 0083, with a few numbers withdrawn or skipped after experiments that did not pay off. Each patch has its own tests. A tool applies the whole series to the tree and proves that the result matches the reviewed edits byte for byte. Every build carries an engine identity, and results record which engine produced them. A stored slice result is reused only when the same engine version made it.
That sounds like bookkeeping. It is what makes the next sections checkable. When an output changes, there is a patch number that explains why, and a before-and-after comparison that shows what else moved.
More languages without more special cases
Adding a language used to mean touching 19 places across nine modules of the engine: the file bucketing, the pipeline, the merge step, the export, the call graph, the symbol tables, and more. Every new language made the shared code harder to reason about.
The fix was to put those places behind one registry. A language now supplies its own helper, its own runner, and a registration entry, and the shared engine does not change. C# and PHP were added that way, without editing a shared engine module.
C# is analyzed with the Roslyn compiler's syntax and semantic models. It does not build or restore the project. On the jellyfin repository, the analyzer found 420 ASP.NET Core routes and recorded every one as an exact match.
PHP is parsed with php-parser, and nothing is executed. On BookStack it found all 340 literal Laravel route registrations, mapped 339 of them to an exact controller action, and placed 695 of the 696 classes it saw where Composer's PSR-4 autoloading says they should be.
The supported set is now Python, JavaScript and TypeScript, Go, Java, Kotlin, Rust, Ruby, C#, and PHP, and repositories that mix them are analyzed together.
Resolving calls without guessing
A call edge says that this function calls that one. Getting it right is harder than it sounds, because many calls cannot be settled from the text alone. A method call depends on the type of the thing it is called on, and that type may come from another file, a library, or an inference.
The analyzer's outcomes for a call are deliberately limited:
- Resolved: the call goes to a declaration inside the repository, and the edge is recorded.
- External: the call goes to a library, and it is labelled as such.
- Ambiguous: more than one declaration could be the target, and the candidates are listed instead of one being picked.
- Unresolved: the analyzer does not know, and says so.
The work this year widened the first two categories without letting wrong answers leak into them. One round added 1,037 internal edges that were independently verified against the source, plus 921 verified external labels. In the audited samples, it introduced no incorrect edges and lost no correct ones. Rust method calls on typed parameters and builder chains now resolve. Kotlin calls inside scope functions such as apply and run resolve when the receiver type is known. Python calls on parameters with type annotations, and super() calls, resolve too, with a guard so a possible override stays ambiguous instead of being decided.
Kotlin: letting the compiler decide
Kotlin was the hardest case, and it shows the whole approach.
A line like val client = factory.create().configure() cannot be resolved from its text. Knowing what configure refers to needs the type that create() returns, which needs type inference. Re-implementing Kotlin's type checker inside a Python analyzer would have been a project of its own, and an unreliable one.
So the analyzer asks the real compiler. The Kotlin helper now runs the frontend of the pinned Kotlin compiler as its primary evidence for call resolution. It does not build the repository. It does not run plugins, annotation processors, or scripts, and it does not fetch dependencies or touch the network. It uses a small set of trusted, pinned libraries. Wherever the compiler cannot decide, the older rules are still there as a fallback.
The compiler is also too confident in one way: it sees every source set at once. In the first audit, before any guards were added, that produced 31 regressions, such as picking another platform's declaration or confusing an expect declaration with its actual. Guards built from the repository's static layout fixed those, and none of them need a build. After the guards, 542 hand-audited changed relationships contained no wrong ones.
The results, measured against hand-audited ground truth on four real repositories:
- Internal call recall rose from a range of 0.135 to 0.569 to a range of 0.653 to 0.891.
- On ktor, the number of internal edges went from 6,235 to 33,809.
- The compiler also corrected 21 wrong targets and 6 false positives that the old rules had produced.
Here, recall means the share of true internal calls that the analyzer found in the audited sample.
It costs something. Kotlin analysis takes roughly three times as long, about six minutes for ktor, and peaks at 1,390 MiB of the worker's 1,792 MiB. That is within the limit with headroom to spare. The limit was not raised. There is also an operational switch to turn the compiler path off and fall back to the old rules, without changing any code.
Telling you what was not seen
One of the most important fixes was not about accuracy at all.
While testing the new Kotlin path, the new coverage reporting flagged a file that the analyzer had been silently dropping, and had been dropping for a long time. The file contained a string literal with the Unicode next-line character, U+0085. Python's splitlines treats that character as a line break, so a record in the helper's output was cut in half, and the file simply vanished from the result. The analysis still reported itself complete.
The fix was one line: split records on the newline character only. The larger change was making the analysis report what it could not read. Two such silent failures were found, this one and another triggered by very deep nesting. Both are now reported as partial coverage in the analysis health.
A count of zero should never look like a finding. It should look like missing data, because that is what it is.
Fitting inside a budget
The hosted service runs analyses on a worker with 1.75 GiB of memory and two CPUs, one analysis at a time. That budget shaped the engineering.
The memory work was measured, patch by patch, on real repositories. Saving the Ruby folder index without making a second full copy of it lowered the peak on the brew repository, a 261,000-line Ruby codebase, and took it off the worker's memory limit entirely. Giving two hot classes fixed-size storage, writing the Ruby helper's output in a compact form, and turning off an AST cache that cost more than it saved did the rest. Across the accepted patches, peaks fell by roughly 5 to 8 percent on the heaviest repositories, and the output was identical on all seven test workloads.
The experiments that did not help were written down as well, with the reason, and withdrawn. A rejected idea is evidence too.
C# and PHP were measured under the same limit. A 318,000-line C# repository peaked at 1,203 MiB and a 357,000-line PHP repository peaked at 885 MiB.
What this means for the output
The demos page shows the result on real code. The ten open-source repositories there, plus a scoped analysis of PViz itself, were analyzed on 1 November with the same analyzer and the same memory limit as a hosted analysis. Each page links the exact commit and offers the full output for download.
Across those eleven analyses, the share of call sites resolved to a declaration inside the repository ranges from about 6 percent to about 33 percent.
That can look low, and it is worth explaining. Many calls in real code go to libraries, and the analyzer labels those as external instead of resolving them. Many others are decided at runtime, through dynamic dispatch, callbacks, or reflection, and no static tool can settle them from the source. The number is a property of the code and of what static analysis can know. It is not a score the analyzer is trying to maximize. Hiding it, or inflating it with guesses, would make the output look better and be worth less.
What it still cannot do
The remaining limits are known and written down.
- Library types outside the pinned set stay unknown. A Kotlin chain stops at the first call whose receiver comes from a library the analyzer does not have, such as Okio, Netty, or Compose. It is left unresolved or labelled external, never guessed.
- There is no build-derived module graph. The direction of dependencies between modules is not enforced, because reading it would mean evaluating the build, which executes repository code.
- Generated code is not generated. Annotation processors and compiler plugins are chosen by the repository, so code produced by tools like KSP, Room, or Hilt is not seen.
- Constructor calls are not edges.
- Function values and reflection are unresolved. What they point to is decided at runtime.
PViz is static analysis. It does not execute your application, and it does not prove which paths run. It gives you the structure that the source can support, and says where it stops.
Why the service is now free
Until this release, PViz was built around a token balance. Analyses cost tokens, and optional AI reports and an MCP server sat on top of the bundle.
The move to free came down to interest. Very few people were using the paid version, and a price is one more thing standing between a developer and finding out whether the output is useful. That was not a verdict on the output. The analyzer is the strongest it has been, and this release is a large step up in what it can say and how honestly it says it.
The services that came off were removed to streamline the pipeline, not because their output lost value. A token balance, an AI report layer, and a protocol server are all machinery around the analyzer. Taking them away leaves a smaller, simpler service that does one thing well: it analyzes a repository and hands you a bundle and slices you can take anywhere.
PViz is now free to use with a verified account. An analysis produces a bundle in two encodings, a START_HERE guide, and access to the slicer, which generates focused views such as hubs, blast radius, cycles, and tests. What you do with the bundle is up to you: review, refactoring, onboarding, or context for an AI assistant you already use.
Public GitHub repositories work with nothing else. Private repositories work too. Tick the private repository box when you submit, and add a GitHub access token. A fine-grained token limited to that one repository, with read-only access to its contents and a short expiry, is all it needs. The token is used only to download that repository for that analysis. It is sent once over HTTPS, kept encrypted for a few hours at most, deleted when the analysis ends or is canceled, and never stored in our database, our logs, or your browser. The results of a private repository are visible only to your account and are deleted after the retention period like any others.
Free does not mean unlimited. A small server serves everyone, so accounts have fair-use limits:
- how many analyses you can run at once, per rolling 24 hours, and per rolling 30 days;
- how many slices you can generate;
- the size of the repository;
- a lower allowance on a new account's first day.
Your usage page shows your exact limits and when used allowance frees up. When the service is at capacity, new submissions are refused for a short time, and the message says when to try again. Results are kept for a limited period, currently seven days, and then deleted, so download what you need. Download links are also limited per user. Sign-up needs a verified email address and a short challenge check, which keeps automated abuse from using up the allowance meant for people.
Repositories are fetched from GitHub over HTTPS, and by design nothing from the repository is built or executed.
The limits are settings, not promises. They exist to match the measured budget of the server, and they can change as we learn how the service is used.
The question to keep asking
The work this year came down to one habit: whenever the analyzer could look more complete by guessing, it was changed to say what it knew and what it did not.
What can this analysis be trusted to say, and what does it say when it does not know?
That is the question to ask of any dependency graph, including ours. The demos and showcase are a good place to check the answer on real code, and the first post in the Dependency Graph Field Guide covers how to decide which graph a given question needs.
If you want to see the output on your own repository, sign up and run an analysis. The FAQ explains the limits, and the demos show what a finished analysis looks like.
Try PViz on your own codebase
Get dependency graphs, coupling signals, and a compressed bundle ready for your LLM, for GitHub repositories, free with an account.