jdx — An IDE for Agents
Proposal / design specification — v1.0 Date: 2026-09-13 · Status: approved scope, pending implementation
Table of contents#
- Problem statement
- What an IDE actually gives a human
- What an agent needs that an IDE does not provide
- Product goals and non-goals
- Conceptual model
- Symbol reference grammar
- Command surface
- Output design
- Architecture
- The index
- Sources, bytecode and decompilation
- Kotlin support
- Classpath resolution
- Interfaces: CLI, MCP, daemon, HTTP
- Performance plan
- Error handling, exit codes, degradation
- Security and safety
- Testing strategy
- Dependencies and licensing
- Delivery plan / milestones
- Deferred to phase 2
- Risks
- Appendix A: worked agent session
- Appendix B: full flag reference
1. Problem statement#
When an AI agent works on a JVM project, most of its wall-clock time and most of its context window are consumed not by writing code but by discovering what the code around it can do.
The current state of the art for an agent investigating a library is a sequence like:
unzip -l ~/.gradle/caches/.../gson-2.14.0.jar | grep -i gson
unzip -p ~/.gradle/caches/.../gson-2.14.0-sources.jar com/google/gson/Gson.java | head -200
javap -p -cp gson-2.14.0.jar com.google.gson.Gson
javap -c -p -cp gson-2.14.0.jar com.google.gson.Gson | sed -n '/toJson/,/^$/p'
grep -rn "TypeAdapter" src/Every step of that is a problem:
| Symptom | Cost |
|---|---|
unzip -p ... | head -200 on a 1,400-line file |
Either the answer is cut off, or 1,400 lines of mostly-irrelevant code land in context |
javap prints erased signatures (java.util.List not List<String>) |
The agent reasons about the wrong types |
javap -c prints opcodes |
The agent must mentally decompile to understand behaviour |
| Inherited members are invisible | The agent must manually walk extends/implements chains, one javap per level, to answer "what can I call on this?" |
| No sources jar | The agent gives up or guesses the API |
| "Who calls this?" | grep across jars is impossible; across source it's noisy and misses dynamic/compiled uses |
| Every question costs a new shell round-trip | Latency and token cost multiply |
The underlying issue: an agent is doing by hand what an IDE does with an index. IntelliJ solved this for humans twenty years ago. Nothing exposes it to a process that speaks stdin and stdout.
jdx is that exposure.
2. What an IDE actually gives a human#
Enumerating this explicitly matters, because the goal is parity — not "a nicer javap".
IntelliJ IDEA provides, over any jar on the classpath, with or without sources:
| IDE capability | Human gesture | jdx equivalent |
|---|---|---|
| Code completion on a receiver | type foo. |
jdx members <type> --inherited |
| Parameter info | Ctrl+P inside ( |
jdx signature '<type>#<method>' |
| Quick documentation | Ctrl+Q / F1 |
jdx doc '<symbol>' |
| Go to declaration | Ctrl+Click |
jdx show <type> / jdx body '<member>' |
| Go to implementation | Ctrl+Alt+Click |
jdx hierarchy <type> --implementors |
| Type hierarchy | Ctrl+H |
jdx hierarchy <type> |
| Call hierarchy | Ctrl+Alt+H |
jdx callers / jdx calls |
| Find usages | Alt+F7 |
jdx usages <symbol> |
| Search everywhere / Go to class / symbol | Shift Shift, Ctrl+N, Ctrl+Alt+Shift+N |
jdx search <pattern> |
| File structure popup | Ctrl+F12 |
jdx outline <type> |
| Decompile a class without sources | open a .class |
jdx body / jdx source (Vineflower) |
| Attach/browse sources jar | automatic | automatic (sources jar detection) |
| Show inherited members in completion | default on | --inherited, default on |
| External library browser | Project view → External Libraries | jdx ls / jdx tree |
| Show bytecode | Show Bytecode action | jdx body --engine javap |
| Analyze dependencies of a class | Analyze → Dependencies | jdx calls --depth N |
All of the above are in scope for v1 except where §21 says otherwise.
3. What an agent needs that an IDE does not provide#
Parity is the floor, not the ceiling. An agent is not a human and has different constraints. These features have no IDE equivalent and exist purely because the consumer is a language model:
3.1 Context-window economy#
An IDE renders into a scrollable window; wasted pixels are free. For an agent, every token is paid for and crowds out reasoning. So:
--max-lines/--brief— bounded output with an explicit continuation hint:… 47 more methods. Narrow with --grep or --from <supertype>.- Signature-only modes that omit bodies and docs by default.
- No decorative output. No banners, no box drawing, no repeated file paths.
- Deduplicated overload rendering where the difference is only one parameter.
3.2 Machine-legible failure#
A human reads "not found" and adapts. An agent needs a branch it can take without parsing
prose: distinct exit codes for not found, ambiguous, no index, bad usage (§16),
and an --json envelope that carries the same information structurally.
3.3 Never-guess semantics#
IntelliJ can pop a chooser dialog. A CLI cannot. So an ambiguous reference is a first-class
result, not an error: jdx exits 2 and prints every candidate as an exact,
copy-pasteable canonical reference. The agent's retry is then mechanical.
3.4 Self-describing next actions#
Every rendered symbol carries the exact reference string needed to query it further. An agent should never have to construct a reference by string surgery — it copies one it was handed.
3.5 Provenance and confidence#
The agent must know whether it is reading ground truth or a reconstruction:
source: gson-2.14.0-sources.jar (com/google/gson/Gson.java:412-430)versus
source: decompiled by vineflower from gson-2.14.0.jar (no sources jar) ⚠ reconstructedAn agent that knows a body is decompiled will not quote it as canonical in a code review. An agent that does not, will.
3.6 Real usage examples#
jdx samples '<symbol>' returns actual call sites from the indexed classpath, trimmed to
a few lines of surrounding context. "How is this API really used?" answered with evidence
rather than the model's recollection of a README. This is often more valuable than the
javadoc, and no IDE surfaces it in this form.
3.7 Batch execution#
JVM start-up amortized across many questions:
jdx batch --json <<'Q'
members com.google.gson.Gson --inherited
body com.google.gson.Gson#toJson(Object)
usages com.google.gson.TypeAdapter --limit 10
QOne process, one index load, N results as JSON lines. Cuts a 10-question investigation from ~4 s of process start-up to ~0.4 s.
3.8 A self-installing instruction block#
jdx help --agent prints a compact, paste-ready cheat sheet designed to be dropped into a
CLAUDE.md, system prompt, or MCP tool description — so an agent learns the tool without the
user writing documentation.
3.9 Idempotent, cacheable, deterministic#
Identical output for identical input means the agent's own prompt cache stays warm across turns, and golden-file testing is possible.
4. Product goals and non-goals#
Goals#
- G1 Answer any "what is this / what can I call / what does it do / who uses it" question about JVM code on a classpath, in one command, in minimal tokens.
- G2 Work with sources jars, plain jars, class dirs, source dirs, Maven coordinates, and the JDK itself — uniformly, with no configuration in the common case.
- G3 Resolve inherited members transitively so an agent never walks a hierarchy by hand.
- G4 Be fast enough to be called dozens of times in a single agent task.
- G5 Be equally usable from a shell, from MCP, and over HTTP.
- G6 Be honest: always state provenance, never fabricate, never silently truncate.
Non-goals#
- N1 Not a compiler, type-checker, or language server for your code being edited.
(
jdxreads a classpath; it does not do incremental compilation or diagnostics.) - N2 Not a refactoring or code-modification tool. Strictly read-only with respect to user code.
- N3 Not a build tool. It reads Gradle/Maven outputs and caches; it does not run builds or resolve full dependency graphs with conflict resolution.
- N4 Not a general decompiler UI. Decompilation is a means to answer questions.
- N5 Not multi-language. JVM only (Java, Kotlin, and the JVM projection of anything else — Scala/Groovy/Clojure classes are readable as bytecode, but get no language-specific view).
5. Conceptual model#
Four nouns. Everything in the tool is expressed in terms of them.
5.1 Root#
A place where code can be found. Exactly six kinds:
| Root kind | Example | Provides |
|---|---|---|
BinaryJar |
gson-2.14.0.jar |
classes (bytecode) |
SourcesJar |
gson-2.14.0-sources.jar |
.java / .kt text |
ClassDir |
build/classes/java/main |
classes (bytecode) |
SourceDir |
src/main/kotlin |
.java / .kt text |
JrtModules |
the running JDK via jrt:/ |
JDK classes (+ lib/src.zip for JDK sources) |
MavenCoord |
com.google.code.gson:gson:2.14.0 |
resolved to a BinaryJar + SourcesJar pair |
5.2 Artifact#
A BinaryJar paired with its matching SourcesJar when one can be found, identified by
a content hash of the binary. Pairing rules, in order:
- Sibling file
<name>-sources.jar. - Gradle cache layout: same
modules-2/files-2.1/<group>/<artifact>/<version>/*/parent. - Maven cache layout: same directory in
~/.m2/repository. - Explicit
--sources <path>override. - Sources embedded in the binary jar itself (some artifacts ship
.javaalongside.class).
The content hash is the cache key, so an artifact indexed once is reused by every workspace
that contains it. Re-indexing gson for a second project is free.
5.3 Workspace#
A named, ordered list of roots plus its resolution settings. Workspaces are how an agent avoids passing a 2,000-entry classpath on every invocation:
jdx ws create mc --jars '~/.gradle/caches/**/*.jar' --src ./src/main/java --jdk
jdx -w mc members net.minecraft.world.item.ItemStackOrdering matters: earlier roots shadow later ones, exactly like a JVM classpath, so
jdx reports the class the JVM would actually load and warns on duplicate FQNs across
artifacts (shaded/relocated jars are a real and frequent source of agent confusion).
Stored as TOML in <config-dir>/workspaces/<name>.toml (~/.config/jdx/... on Linux;
per-OS config defaults in §17.1). Human-editable and diffable.
5.4 Symbol#
A type, method, field, constructor, package, or module — each with a canonical reference (§6), a declaring artifact, a kind, and a resolved signature. The symbol is the unit every command consumes and emits.
6. Symbol reference grammar#
Designed to be generous on input, canonical on output.
ref := typeRef [ memberSep memberRef ]
typeRef := [ coord '/' ] [ package '.' ] simpleName { ('$'|'.') nestedName }
coord := group ':' artifact ':' version (Maven coordinate prefix)
memberSep := '#' | '::' | '.'
memberRef := ( name | '<init>' | '<clinit>' ) [ params ]
params := '(' [ paramList ] ')' [ ':' returnType ]
| descriptor (exact JVM descriptor)
paramList := param { ',' param }
param := simpleTypeName | fqTypeName | primitiveName | param '[]' | '...'Accepted forms, all valid#
| Input | Meaning |
|---|---|
Gson |
short type name → resolve across workspace; exit 2 if ambiguous |
com.google.gson.Gson |
exact type |
com.google.gson.Gson$1 |
anonymous class |
java.util.Map.Entry |
nested via . (normalizes to Map$Entry) |
com.google.code.gson:gson:2.14.0/com.google.gson.Gson |
coordinate-scoped |
Gson#toJson |
all overloads of toJson |
Gson#toJson(Object) |
one overload, simple param names |
Gson#toJson(java.lang.Object) |
one overload, fully qualified |
Gson#toJson(Object):String |
disambiguated by return type (needed for bridge methods) |
Gson#toJson(Ljava/lang/Object;)Ljava/lang/String; |
exact descriptor — never ambiguous |
Gson::toJson |
:: separator |
Gson#<init>(Excluder,FieldNamingStrategy) |
constructor |
Gson#excluder |
field |
com.google.gson.* |
package glob (for search, ls) |
Canonical output form#
jdx always prints the most specific unambiguous form:
com.google.gson.Gson#toJson(java.lang.Object)
adding :returnType only when required to disambiguate bridge/covariant overloads.
Ambiguity protocol#
$ jdx body 'Gson#toJson'
ambiguous: 4 candidates for Gson#toJson
com.google.gson.Gson#toJson(java.lang.Object)
com.google.gson.Gson#toJson(java.lang.Object, java.lang.reflect.Type)
com.google.gson.Gson#toJson(com.google.gson.JsonElement)
com.google.gson.Gson#toJson(java.lang.Object, java.lang.Appendable)
hint: re-run with one of the refs above
$ echo $?
27. Command surface#
7.1 Inspection — the core loop#
jdx show <type>#
The "class card". Kind, modifiers, generic declaration, supertype chain, interfaces, enclosing/nested types, annotations, deprecation, origin artifact, source availability, Kotlin-ness, and a member count summary (not the members themselves).
class com.google.gson.Gson
package com.google.gson · gson-2.14.0.jar · sources: yes · java
public final class Gson
extends java.lang.Object
nested: Gson$FutureTypeAdapter, Gson$1
members: 39 methods (14 public), 12 fields (0 public), 2 constructors
doc: This is the main class for using Gson. Gson is typically used by first
constructing a Gson instance and then invoking toJson or fromJson…
next: jdx members com.google.gson.Gson --inheritedjdx members <type>#
The most important command — the . completion equivalent.
--inherited(default: on) — include members from superclasses and interfaces, grouped and labelled by declaring type, with overridden members collapsed.--declared— only members declared on this exact type.--kind method|field|ctor|all,--static,--instance--access public|protected|package|private|all(default:public+protected)--from <supertype>— only members inherited from a specific supertype--grep <regex>— filter by name--with-doc— include first javadoc sentence per member--brief— signatures only: bare member rows, no group headers, no provenance block (warnings and footers stay); mutually exclusive with--with-doc; text only,--jsonunaffected--max-lines N— cap text lines with a continuation footer; text only,--jsonunaffected--sort name|kind|declaring(default:kindthenname)--include-synthetic— bridge/synthetic/lambda members, hidden by default
Overloads of the same name are grouped. Inherited java.lang.Object members are collapsed to
one line by default (+ 9 from java.lang.Object (--from java.lang.Object to expand)) because
they are almost never what the agent wants and cost ~200 tokens every call.
jdx signature <member-ref>#
Just the signature(s), with real parameter names, generics, throws, annotations, and default values. The "parameter info" popup. One line per overload.
jdx doc <symbol>#
Javadoc/KDoc only, rendered to plain text: HTML stripped, {@link}/{@code} unwrapped,
@param/@return/@throws/@since/@deprecated preserved as a tidy block. Inherited
documentation is pulled from the nearest supertype that documents the method (matching
IntelliJ's quick-doc), labelled as such.
jdx body <member-ref>#
The headline feature: "give me the body of org.some.lib.AClass.someMethod(int,int)".
Resolution order:
- Sources jar/dir → exact AST node range → verbatim text (ground truth).
- No sources → Vineflower decompilation of just the enclosing class, method extracted.
--engine javap→ raw JVM instructions.
Flags: --engine vineflower|javap, --with-doc, --with-signature, --context N
(surrounding lines), --line-numbers, --max-lines N.
Also accepts a type ref, in which case it prints the whole type body — which is exactly
jdx source with the type's range.
jdx source <type>#
Whole source file or a slice: --lines 100:180, --around <member-ref> --context 20.
Decompiles if no sources exist. This is the escape hatch when a body is not enough.
jdx outline <type>#
One dense line per member — the File Structure popup. Cheapest way to see a class's shape.
Takes the same --brief / --max-lines text budgets as members.
7.2 Search and navigation#
jdx search <pattern>#
Symbol search across the workspace.
- Matching: glob (
*Http*Client), regex (--regex), camel-hump (HMap→HashMap,gJson→getJson) — IntelliJ'sCtrl+Nbehaviour. --kind class|interface|enum|record|annotation|method|field|package|module--in <artifact-glob>,--package <glob>,--limit N(default 50)--fuzzy— Levenshtein fallback when nothing matches exactly
jdx resolve <name>#
"What is this identifier?" — takes an unqualified or partial name and lists every candidate with its kind and artifact. The disambiguator an agent reaches for when it sees an unknown symbol in a stack trace or a code snippet.
jdx imports <type>#
The import statement(s) to add, plus a warning when several artifacts provide the same FQN.
jdx ls [<package-glob>] / jdx tree <artifact>#
Browse an artifact's packages and types. --depth N, --counts.
7.3 The relationship graph#
jdx hierarchy <type>#
Supertypes upward (full transitive chain, interfaces included) and subtypes/implementors
downward across the whole workspace.
--up, --down, --depth N, --direct (one level), --in <artifact-glob>.
java.util.Map
↑ (none)
↓ implementors in workspace (37, showing 10)
java.util.HashMap java.base
java.util.LinkedHashMap java.base (extends HashMap)
java.util.TreeMap java.base
com.google.gson.internal.LinkedTreeMap gson-2.14.0.jar
…jdx usages <symbol>#
Find Usages across all workspace jars plus the project's own source dirs.
--kind call|ref|impl|override|read|write|new|throw|annotation|all--in <artifact-glob>,--exclude <glob>,--limit N,--context N- Groups by artifact, then by declaring method, with the canonical ref of each call site.
--kind throwmatchesthrowsdeclarations (declaration-level), notATHROWthrow sites.
jdx callers <method> / jdx calls <method>#
Incoming / outgoing call edges. --depth N walks the tree (cycle-safe, with …(cycle)
markers). This lets an agent trace an execution path across jars without decompiling every
class along the way.
jdx samples <symbol>#
Real call sites as usage examples, source-rendered when sources are available, ranked by
"exemplariness" (prefers non-test, non-generated, short enclosing methods, and call sites
that use more of the API's parameters). --limit N (default 3).
jdx overrides <method> / jdx implementors <type>#
Convenience aliases over hierarchy/usages because they are so frequently wanted.
7.4 Workspace and cache management#
jdx ws create <name> [--jars <glob|path>…] [--src <dir>…] [--coord <g:a:v>…] [--jdk]
jdx ws list | info <name> | remove <name> | add <name> <root> | use <name>
jdx index [-w <name>] [--force] build / refresh the index
jdx cache info | gc | clear
jdx kotlin install [--repo <url>] [--force] fetch the Kotlin PSI sidecar set
jdx doctor environment diagnostics
jdx version [--json]
jdx help --agent paste-ready cheat sheet for an agent prompt7.5 Serving#
jdx mcp MCP server over stdio
jdx daemon start|stop|status|restart background index host, 5-min idle auto-shutdown
jdx serve [--port 7070] [--bind 127.0.0.1] HTTP/JSON API
jdx batch [--json] read queries from stdin, one result per line7.6 Global flags#
-w/--workspace, --jars, --src, --coord, --jdk/--no-jdk, --json, --no-color,
--max-lines, --brief, --verbose, --quiet, --no-daemon, --timeout, --cache-dir.
7.7 Public-API diff#
jdx diff <old> <new>#
Answers the question a dependency upgrade poses: what changed, and can I still compile
and link against it? It compares the API surface of two artifacts — jars, class
directories, globs, or Maven coordinates (--fetch to download) — and reports every
difference, most severe first, each naming one rule.
jdx diff gson-2.11.0.jar gson-2.14.0.jar
api diff gson-2.11.0.jar -> gson-2.14.0.jar (public surface)
breaking MEMBER_REMOVED com.google.gson.stream.JsonReader#getLenient()
suspicious CHECKED_EXCEPTION_ADDED com.google.gson.Gson#toJson(java.lang.Object): java.io.IOException
info MEMBER_ADDED com.google.gson.stream.JsonReader#getNestingLimit()
1 breaking · 1 suspicious · 15 informational · 2 types added · 0 types removed
source: gson-2.11.0.jar (bytecode)
source: gson-2.14.0.jar (bytecode)
next: jdx members com.google.gson.stream.JsonReaderFlags: --visibility public|all (default public; all compares every declared
member, for refactors and mod/mixin work), --include-synthetic, --severity all|suspicious|breaking, --fail-on none|breaking|any, --limit N (default 200),
--fetch, --repo, --json, --no-color.
Structure only. A diff reads bytecode and nothing else: no sources jar, no decompilation, no classpath. An API has no "flesh" to recover — the whole surface is in the class files, and the truth model of §11.1 already says structure comes from bytecode.
What is compared. Types and their kind, visibility, supertypes and annotations;
members by name plus erased parameter list, so a covariant return is one
RETURN_TYPE_CHANGED rather than a removal plus an addition. Field identity includes
the field type. <clinit> is never part of an API.
Three severities, one rule each. Every finding carries exactly one rule id — the
CompatRule name, so an agent filters on a code, never on prose:
- BREAKING — an already-compiled caller can fail to link or to run:
TYPE_REMOVED,TYPE_KIND_CHANGED,TYPE_VISIBILITY_NARROWED,TYPE_MADE_FINAL,SUPERTYPE_REMOVED,MEMBER_REMOVED,MEMBER_VISIBILITY_NARROWED,MEMBER_MADE_FINAL,STATIC_TO_INSTANCE,INSTANCE_TO_STATIC,ABSTRACT_ADDED,INTERFACE_METHOD_ADDED,ENUM_CONSTANT_REMOVED,PARAMETER_TYPE_CHANGED,RETURN_TYPE_CHANGED,FIELD_TYPE_CHANGED. - SUSPICIOUS — binary-compatible by the letter of JLS §13.5, but recompiling, the
Kotlin view or the behaviour can still break:
MEMBER_MOVED_TO_SUPERTYPE,GENERIC_SIGNATURE_CHANGED,CHECKED_EXCEPTION_ADDED,VARARGS_CHANGED,NATIVE_ADDED/NATIVE_REMOVED,KOTLIN_NAME_CHANGED,KOTLIN_SUSPEND_CHANGED,KOTLIN_NULLABILITY_CHANGED,KOTLIN_DEFAULT_ARG_REMOVED,KOTLIN_PARAMETER_NAME_CHANGED. - INFO — additive or cosmetic:
TYPE_ADDED,SUPERTYPE_ADDED,MEMBER_ADDED,FINAL_REMOVED,ABSTRACT_REMOVED,ANNOTATION_ADDED/ANNOTATION_REMOVED/ANNOTATION_VALUES_CHANGED,DEPRECATED_ADDED/DEPRECATED_REMOVED,THROWS_REMOVED,PARAMETER_NAME_CHANGED,PARAMETER_NAMES_LOST,SYNCHRONIZED_ADDED/SYNCHRONIZED_REMOVED,TRANSIENT_CHANGED,FIELD_CONSTANT_VALUE_CHANGED,ANNOTATION_DEFAULT_CHANGED,KOTLIN_DEFAULT_ARG_ADDED,KOTLIN_PROPERTY_BECAME_MUTABLE.
Three pairing passes, so the output is not a remove/add avalanche. A member that
disappeared and one that appeared, agreeing on name and arity, is one retyped
signature (PARAMETER_TYPE_CHANGED, FIELD_TYPE_CHANGED), not two findings. What is
left over, agreeing on the Kotlin name and the arity a Kotlin caller writes, is a JVM
rename (KOTLIN_NAME_CHANGED — an @JvmName appearing or disappearing, an internal
member's module-name mangling) or a suspend flip (KOTLIN_SUSPEND_CHANGED, whose
descriptor carries the hidden Continuation). The rest are plain removals and
additions. An ambiguous bucket is never paired — with two removals of one shape
there is no way to know which addition replaced which, and guessing is worse than
asking (§3.3).
The inheritance check, and where it refuses to guess. A member gone from its type
but declared by one of that type's new supertypes is MEMBER_MOVED_TO_SUPERTYPE, not
a removal: the caller's invokevirtual still resolves through the chain. When the walk
hits a supertype the artifact does not hold, absence proves nothing — so the removal
stays MEMBER_REMOVED and the detail says which supertype was missing. A diff never
downgrades a breaking change on a maybe.
Exit codes. A successful comparison exits 0 whatever it found — "found something"
is the answer, not a failure, and an agent must not have to parse prose to learn it. The
per-severity tally always describes the whole comparison, even when --severity or
--limit hid part of it. --fail-on breaking (or any) turns the report into a process
status for a pipeline and exits 1; the verdict is also in the envelope as
result.gate, so a consumer that cannot see exit codes still sees the answer. Usage
errors are 3 and an unreadable artifact is 5, exactly as everywhere else.
No workspace, no --jars, no --no-jdk, no --no-daemon. A diff names its own
two artifacts, so there is no classpath to resolve — it works in an empty directory with
no workspace ever created. And it is not forwarded to the daemon: a daemon warm on one
workspace's classpath holds nothing a two-artifact comparison can reuse, and a
--fail-on verdict has no way back over the v1 wire, which carries an exit code only as
an error. The daemon still serves diff — jdx_diff over MCP and GET /v1/diff?query=…&new=…
dispatch the same JdxService.diff call and answer byte-identically (§14).
8. Output design#
8.1 Text (default)#
Optimised for a language model reading it:
- Two-space indentation, never box-drawing or tables — they cost tokens and tokenize badly.
- Leading kind word on every entity line (
class,method,field) so the model never has to infer structure from punctuation. - Grouping headers instead of repeating the declaring type on every line.
- A
next:line on single-entity output, suggesting the highest-value follow-up command. - Truncation is always explicit, always with the flag that would reveal the rest.
- ANSI colour only when stdout is a TTY; never when piped (agents always get plain text).
8.2 JSON (--json)#
A stable envelope, versioned, identical across CLI / HTTP / MCP:
{
"jdx": 1,
"ok": true,
"command": "members",
"query": "com.google.gson.Gson",
"result": { "...": "command-specific" },
"truncated": { "shown": 50, "total": 97, "hint": "--limit 200" },
"warnings": [
{"code": "SOURCES_VERSION_MISMATCH", "message": "sources jar does not match binary"}
],
"provenance": [
{"symbol": "com.google.gson.Gson", "artifact": "gson-2.14.0.jar",
"origin": "sources", "file": "com/google/gson/Gson.java", "lines": [412, 430]}
]
}ok: false carries error.code matching the exit-code taxonomy, plus candidates[] for the
ambiguous case — so an HTTP/MCP client gets the same disambiguation protocol as the CLI.
8.3 Truncation policy#
Never cut mid-entity. Prefer dropping whole low-value groups (inherited Object members,
synthetic members, private members) before dropping high-value ones, and always report what
was dropped and how to get it.
9. Architecture#
9.1 Modules (Gradle multi-project)#
core/ Pure Kotlin domain: Symbol/Type/Member model, SymbolRef parser+printer,
hierarchy & member-resolution algorithms, rendering (text + JSON),
truncation/token-budget logic. No file or network IO. Fully unit-testable.
index/ ASM class reading, @Metadata decoding, reference-edge extraction,
SQLite store, incremental indexer, artifact hashing & sources pairing.
sources/ Sources-jar/dir access, JavaParser integration, Kotlin PSI integration
(behind an interface, loaded lazily in an isolated classloader),
symbol→(file,range) mapping, body extraction.
decompile/ DecompilerEngine interface; Vineflower and javap implementations;
single-class decompilation with caching.
cli/ Clikt command tree; flag parsing; renderer selection; exit codes;
daemon client (transparently forwards to a running daemon).
mcp/ MCP stdio server; tool schema generation from the same command metadata
the CLI uses (single source of truth — no schema drift).
server/ HTTP/JSON server (JDK built-in com.sun.net.httpserver — zero dependency)
and the daemon host with idle-timeout supervision.
app/ Fat-jar assembly, AppCDS archive generation, `jdx` launcher script, install.Hard rule: cli, mcp and server are adapters. They contain argument parsing and
transport only. All behaviour lives in core/index/sources/decompile behind a
JdxService facade, so the four interfaces cannot drift apart.
9.2 The service facade#
interface JdxService {
fun show(ref: TypeRef, opts: ShowOptions): Result<ClassCard>
fun members(ref: TypeRef, opts: MemberOptions): Result<MemberListing>
fun body(ref: MemberRef, opts: BodyOptions): Result<CodeBlock>
fun search(pattern: Pattern, opts: SearchOptions): Result<SymbolHits>
fun usages(ref: SymbolRef, opts: UsageOptions): Result<UsageListing>
fun hierarchy(ref: TypeRef, opts: HierarchyOptions): Result<Hierarchy>
fun callGraph(ref: MemberRef, dir: Direction, opts: CallOptions): Result<CallTree>
// …
}One implementation over a live index; one implementation that is an RPC client to the daemon. The CLI picks between them transparently.
9.3 Member resolution algorithm (the --inherited core)#
This is the piece that saves agents the most work, so it is specified precisely:
- Load the target type's
ClassInfofrom the index. - Compute the linearized supertype set: BFS over
superclasstheninterfaces, recording depth and path. Cycle-safe. Includesjava.lang.Objectlast. - For each supertype, substitute generic type arguments down the chain, so
class StringList extends ArrayList<String>reportsboolean add(String), notadd(E). (Signature attribute → type-variable environment → substitution.) - Collect members, applying JLS visibility rules from the perspective of the query:
privatemembers of supertypes are excluded; package-private only when same package. - Collapse overrides: a member with the same name+erased-descriptor as one already
collected from a nearer type is dropped, but recorded as
overridden in <type>metadata. - Drop bridge/synthetic members unless
--include-synthetic. - For Kotlin types, map the collected JVM members back onto Kotlin declarations (properties instead of getter/setter pairs, default-arg synthetics folded in).
- Group by declaring type, sort, render.
10. The index#
10.1 Why persistent#
"Find usages" and "implementors" require scanning every class in the workspace for references to a target. For a Minecraft-scale workspace (~20k classes plus dependencies) that is seconds per query if done live, and milliseconds if done from an index. The index is also what makes camel-hump symbol search viable.
10.2 Storage: SQLite, content-hash keyed, globally shared#
One database: <cache-dir>/index/v1.db (~/.cache/jdx/index/v1.db on Linux;
per-OS cache defaults in §17.1) in WAL mode — many readers, one writer.
Every row is scoped by artifact_id, which is the content hash of the binary jar.
Consequences, all good:
- Indexing
gsononce serves every workspace that contains it. - A workspace is just an ordered list of
artifact_ids — cheap to create, cheap to switch. - Cache invalidation is trivial: if the hash changed, it is a different artifact.
jdx cache gcdrops artifacts no workspace references and none was used recently.
10.3 Schema (abbreviated)#
artifact(id, hash, path, sources_path, kind, jar_mtime, jar_size, indexed_at, schema_ver)
class (id, artifact_id, fqn, simple_name, package, access, kind, signature,
super_id, source_file, is_kotlin, deprecated, outer_id)
iface (class_id, iface_fqn) -- implements edges
member (id, class_id, name, descriptor, signature, access, kind,
param_names, throws, default_value, deprecated)
annot (owner_kind, owner_id, annot_fqn, values)
ktmeta (class_id, blob) -- decoded @Metadata
doc (owner_kind, owner_id, text, tags) -- javadoc/KDoc extracted from sources
srcmap (owner_kind, owner_id, file, start_line, end_line, start_col, end_col)
ref (from_member_id, to_fqn, to_member, kind, line) -- the usage/call graph
name_idx(simple_name, camel_humps, class_id) -- search accelerationIndices on class.fqn, class.simple_name, member.name, ref.to_fqn,
ref.to_member, iface.iface_fqn, name_idx.camel_humps.
10.4 Indexing pipeline#
Per artifact, parallel across artifacts using virtual threads:
- Hash the jar (streaming,
SHA-256truncated to 128 bits — enough, and fast). - If
artifact.hashalready present with a matchingschema_ver, skip entirely. - Stream entries; for each
.class:ClassReaderwithSKIP_FRAMES(we need debug info for parameter names, so notSKIP_DEBUG).- Extract class/member/annotation rows.
- Extract reference edges from the method bodies via a lightweight
MethodVisitor(visitMethodInsn,visitFieldInsn,visitTypeInsn,visitInvokeDynamicInsn,visitLdcInsnfor class constants) — no full control-flow analysis. - Decode
@Metadataif present.
- If a sources jar is paired, index it lazily on first source query rather than eagerly —
source parsing (especially Kotlin PSI) is an order of magnitude more expensive than ASM,
and most queries never need it. Source indexing populates
docandsrcmap. - Commit in batches inside one transaction per artifact.
Incremental behaviour: a changed ClassDir/SourceDir (the user's own project, which
changes constantly) is re-indexed per-file by mtime, not wholesale.
10.5 Estimated cost#
Rough, to be validated against minecraft-client.jar during M2:
- Index rate target: ≥ 3,000 classes/second for the bytecode pass on this machine.
- Index size target: ≤ 6 % of jar size (bytecode pass), dominated by the
reftable. - Query latency target: < 15 ms warm for any single-type query; < 150 ms for
usagesover a 20k-class workspace.
11. Sources, bytecode and decompilation#
11.1 The truth model — "bytecode is the skeleton, sources are the flesh"#
| Fact | Authority |
|---|---|
| Which members exist | bytecode (includes compiler-generated members) |
| Modifiers, erased descriptors, generic signatures | bytecode |
| Supertypes, interfaces, annotations | bytecode |
| Parameter names | sources → else MethodParameters attr → else LocalVariableTable → else arg0 |
| Method bodies | sources → else decompiled |
| Javadoc / KDoc | sources only (jars strip it) |
| Inline comments | sources only |
This ordering matters: a sources jar can be stale or mismatched (a real and common
packaging bug). By taking structure from the binary, jdx cannot be lied to about the API.
When a member in the binary has no counterpart in the sources — or vice versa — it emits:
⚠ SOURCES_VERSION_MISMATCH: gson-2.14.0-sources.jar declares Gson#toJson(Object, Writer)
which is absent from gson-2.14.0.jar. Structure shown from bytecode.Synthetic members present only in bytecode (bridges, access$000, lambda bodies,
$VALUES) are hidden by default rather than reported as a mismatch.
11.2 Decompilation#
Vineflower (Apache-2.0, the actively maintained Fernflower fork; already present in the local Gradle cache since Fabric Loom uses it) is bundled and is the default engine.
- Decompiles one class at a time with the workspace as the classpath context, so generics and inherited-method resolution come out correct.
- Decompiled output is cached on disk keyed by
(class hash, engine, engine version)— decompiling a large class costs 50–500 ms, and agents ask for several members of the same class in a row. - Loaded in an isolated, lazily-created classloader, so the common path (no decompilation needed) never pays for it.
- Method extraction from decompiled text reuses the JavaParser path: the decompiled class is
parsed, the requested member's node range is taken. This keeps
jdx bodyoutput identical in shape whether it came from real sources or a decompiler.
javap (--engine javap) shells out to the system javap (resolved from JAVA_HOME, the
running JVM's home, then PATH) for cases where the agent wants exact opcodes: verifying a
compiler optimisation, reading a switch bootstrap, confirming a constant value. Output is
passed through with minimal reformatting.
Always labelled. Decompiled output is never presented as if it were the original source.
12. Kotlin support#
Kotlin's JVM projection is misleading: String? and String are the same JVM type,
suspend fun gains a hidden Continuation parameter and returns Object, properties become
getX/setX pairs, default arguments become foo$default synthetics, and data class
generates componentN/copy. An agent reading the JVM view of a Kotlin library will write
wrong code.
12.1 Binaries — @Metadata#
Decoded with kotlin-metadata-jvm. jdx renders true Kotlin declarations:
class kotlinx.coroutines.flow.FlowKt
public suspend fun <T> Flow<T>.collect(collector: FlowCollector<T>): Unit
public fun <T> flowOf(vararg elements: T): Flow<T>
public val <T> Flow<T>.replayCache: List<T>instead of the JVM view (Object collect(Flow, FlowCollector, Continuation)).
Specifically recovered: nullability, variance, suspend, inline/reified, extension
receivers, properties (with backing-field/accessor detail), default arguments, data/sealed
/value/object/companion, type aliases, and @JvmName mappings.
--view jvm forces the Java projection when the agent genuinely needs the JVM truth (e.g.
calling Kotlin from Java, or reading a stack trace).
12.2 Sources — full PSI parsing#
Per the user's decision, .kt files are parsed with kotlin-compiler-embeddable into a
real PSI tree, rather than by lightweight brace matching. This buys exactness on the hard
cases (nested string templates containing braces, multi-line raw strings, trailing lambdas,
when blocks, expression-bodied functions, nested local functions) and enables accurate
Kotlin-aware source-level usages.
Cost and mitigation. kotlin-compiler-embeddable is ~55 MB and takes ~0.6–1.0 s to
initialise. Therefore it is:
- behind the
sourcesmodule'sSourceParserinterface; - loaded lazily, only when a
.ktfile must actually be parsed; - loaded in an isolated
URLClassLoaderso its shaded Guava/Intellij-platform classes cannot collide with anything else; - initialised once per daemon lifetime — with the daemon running (the normal case for an agent doing repeated queries), the cost is paid once and then never again;
- shipped in a separate jar fetched/verified on first use, not baked into the main fat
jar, so a user who never inspects Kotlin sources never downloads it. (
jdx doctorreports whether the Kotlin source module is installed.)
Java sources use JavaParser, which is small and fast and needs none of this.
13. Classpath resolution#
Resolution order when a command runs. First match wins; --jars/--src always merge in.
- Explicit flags:
--jars <path|glob|dir>,--src <dir>,--coord <g:a:v>,--sources. -w <name>— a named workspace.JDX_WORKSPACEenv var, then the workspace set byjdx ws use.- Project auto-discovery — walk up from CWD looking for
settings.gradle(.kts),build.gradle(.kts),pom.xml, or.idea/. On a hit, assemble a workspace from:- the project's own
src/main/{java,kotlin}andbuild/classes/**; - dependency jars found in
~/.gradle/caches/modules-2/files-2.1/**and~/.m2/repository/**that the project's lock/metadata files actually reference, falling back to a broader cache scan with a warning if the reference set can't be determined (we do not run Gradle — see N3); - the JDK.
The derived workspace is cached under
<cache-dir>/auto/<project-hash>.toml(~/.cache/jdx/auto/...on Linux; per-OS cache defaults in §17.1) and invalidated when the build files change.
- the project's own
- JDK stdlib via
jrt-fson the running JDK — always appended unless--no-jdk, with$JAVA_HOME/lib/src.zippaired as its sources root, sojdx members java.util.HashMapworks with zero configuration, with real JDK source and javadoc.
Maven coordinates (--coord g:a:v, or a g:a:v/ ref prefix) are resolved against
~/.gradle/caches and ~/.m2 first; if absent, fetched from the configured
repositories (--repo mirrors in flag order, Maven Central last) into
<cache-dir>/m2/ (~/.cache/jdx/m2/ on Linux; per-OS cache defaults in §17.1),
including the -sources.jar, with checksum
verification. This lets an agent evaluate a library the project does not yet depend on.
Network fetching is opt-in per invocation via --fetch (or fetch = true in config) so the
tool never surprises anyone with network traffic.
14. Interfaces: CLI, MCP, daemon, HTTP#
All four are thin adapters over JdxService. They share one command/flag metadata table, so
adding a command makes it appear in all four automatically.
14.1 CLI (always available)#
Clikt-based. One-shot. Transparently uses the daemon if one is running (and --no-daemon
forces in-process). This is the baseline every agent can use with zero setup.
14.2 MCP server — jdx mcp#
JSON-RPC over stdio, exposing each command as a typed MCP tool with a generated JSON schema.
Why it matters: the agent gets structured arguments and tool descriptions instead of guessing
shell syntax, cannot make quoting mistakes with # and (, and receives structured content
back. For the stated goal — an IDE for agents — this is the highest-leverage interface.
Tools exposed (v1): jdx_show, jdx_members, jdx_signature, jdx_doc, jdx_body,
jdx_source, jdx_outline, jdx_search, jdx_resolve, jdx_usages, jdx_hierarchy,
jdx_callers, jdx_calls, jdx_samples, jdx_workspace.
The MCP server holds the index in memory for its lifetime — so it is effectively a per-session daemon, and repeated queries are instant.
14.3 Daemon — jdx daemon#
A background JVM holding a hot index (and, once touched, a warm Kotlin PSI environment),
listening on a unix domain socket at <runtime-dir>/jdx/<workspace-hash>.sock
($XDG_RUNTIME_DIR/jdx/… on Linux; per-OS runtime dirs in §17.1 — a missing
XDG_RUNTIME_DIR falls back per table, never exit 3).
- The CLI auto-spawns it on first use and becomes a thin client: per-query latency drops from ~250 ms to ~10–20 ms.
- Idle shutdown after 5 minutes of no requests (user-specified;
--idle <duration>to change,0to disable). SimpleScheduledExecutorServiceresetting on each request. - Version-stamped socket path, so an upgraded
jdxnever talks to a stale daemon. sun_pathguard: socket paths past 104 bytes on macOS / 108 elsewhere exit 3 naming the dir and theJDX_RUNTIME_DIRescape hatch (macOS/var/folders/…trips this) — never a generic bind failure or a silent cold fallback.- Start mutual exclusion via
<hash>-v1.lock(FileChannel.tryLock, held while serving): a concurrentstartfails fast naming the holder. stopvalidates the pid file owns adaemon runchild before signalling (never kills a reused pid), escalatesdestroy()todestroyForcibly(), and sweeps stale.sock/.pidfiles so a killed daemon restarts clean.jdx daemon statusreports uptime, workspace, memory, indexed artifacts, query count.
14.4 HTTP/JSON — jdx serve#
Built on the JDK's own com.sun.net.httpserver (zero dependencies), bound to 127.0.0.1 by
default. GET /v1/members?type=…&inherited=true, POST /v1/batch, GET /v1/health, etc.
Serves a different audience from MCP — non-MCP agents, editor plugins, scripts, notebooks,
and remote use — and shares the exact JSON envelope of --json. Separate port/socket from
the daemon; the two do not interfere.
14.5 The shared wire contract (v1)#
All four adapters speak one request format, so a payload is written and parsed in exactly one
place: core/.../rpc/RpcProtocol.kt is normative, this section is the summary.
A request is one line of JSON, newline-terminated (\n). Keys are in fixed order and
params is sorted by key, so equal requests are byte-equal:
{"jdx":1,"command":"members","query":"com.google.gson.Gson","params":{"inherited":"true"}}commandis one of the read-only queries —showmembersoutlinebodysourcesignaturedocsearchresolvelstreeusageshierarchyimplementorscallerscallssamples— plusversion,doctorandhealth. The state-changing commands (ws,cache) are deliberately not on the wire in v1: one client of a shared daemon must not be able to reconfigure the others.paramsvalues are text, the same spelling the CLI flags use. Typed clients (MCP schemas, HTTP query strings) may send JSON numbers and booleans; the decoder canonicalises them.- Decoding is generous where it is safe — surrounding whitespace, a trailing
\r\n, absentjdx/query/params, and unknown keys (so a later version may add fields) are all accepted — and strict where a guess would be dangerous: an unknown command, a version it does not speak, a wrongly-typed field, a truncated line or trailing content are refused outright rather than half-understood. Decoding never throws, on any input.
A response is not a new shape: it is the --json envelope of §8.2 verbatim, one per line.
That is what makes the adapter-parity guarantee (docs/TESTING.md §9) structural rather than
a per-adapter pile of goldens.
15. Performance plan#
| Path | Target | Technique |
|---|---|---|
| Cold CLI, simple query | ≤ 250 ms | AppCDS archive; lazy module loading; no eager index scan |
| Warm (daemon/MCP) query | ≤ 20 ms | hot index, prepared statements, in-memory LRU |
| First index of a 20k-class jar | ≤ 8 s | virtual-thread fan-out, ASM SKIP_FRAMES, batched inserts |
| Re-run on unchanged workspace | ~0 ms | content-hash short-circuit |
usages over 20k classes |
≤ 150 ms | indexed ref.to_fqn / ref.to_member |
| Decompile a large class | ≤ 500 ms first, ~0 cached | on-disk decompilation cache |
Additional measures:
- Lazy source indexing — never parse sources until a source-level query arrives.
- Lazy classloaders for Vineflower and Kotlin PSI.
-XX:TieredStopAtLevel=1 -XX:+UseSerialGC -Xshare:auto -Xlog:cds=offin the launcher for the one-shot path (classic CLI JVM tuning; irrelevant and disabled for the daemon).-Xlog:cds=offkeeps a stale AppCDS archive's mismatch notice off stderr, where it reads like a crash for a command that then works fine.- A
jdx benchcommand, tagged out of normal test runs, measuring against the localminecraft-client.jarso regressions are caught.
16. Error handling, exit codes, degradation#
Exit codes#
0 ok · 1 not found · 2 ambiguous · 3 usage error · 4 no index/workspace ·
5 artifact read error · 6 internal error.
One documented exception: the diff gate. jdx diff (§7.7) exits 0 whenever the
comparison ran, whatever it found, and --fail-on breaking|any exits 1 when the
gate trips — a deliberate, owner-approved reuse of 1, since a diff has no "not found"
outcome and the gate is a result rather than an error. The verdict is in the envelope
too (result.gate), so nothing depends on reading the process status. No other command
may bend the table.
Degradation ladder#
Never fail where a lesser answer exists — but always label the fallback:
sources jar → decompiled (vineflower) → javap → signatures-only-from-bytecode → not found
Kotlin metadata → JVM projection
param names from source → MethodParameters → LocalVariableTable → arg0, arg1
javadoc on member → inherited javadoc from supertype → none"Did you mean"#
On a miss, jdx suggests candidates by camel-hump match, then Levenshtein distance, then
simple-name match in other packages — capped at 5, each printed as a canonical ref:
not found: com.google.gson.JsonParse
did you mean:
com.google.gson.JsonParser gson-2.14.0.jar
com.google.gson.JsonParseException gson-2.14.0.jarWarnings, never silent#
Duplicate FQNs across artifacts (shading!), sources/binary mismatch, corrupt class files,
unsupported class-file major versions, multi-release jar variants, unresolvable supertypes
(a hierarchy edge the workspace cannot provide — inherited members may be incomplete),
project-discovery fallback (auto-discovery found the project but not its dependency set —
dependency jars omitted, PROJECT_DISCOVERY_FALLBACK), unnameable class names (a class file
whose own name carries an empty $-separated segment — JFR Exception$JB$$Assertion shapes —
skipped with UNNAMEABLE_CLASS, never CORRUPT_CLASS), sealed/hidden classes —
all surface as named warning codes in both text and JSON.
17. Security and safety#
- Strictly read-only with respect to user code.
jdxwrites only inside its own cache and config directories (per-OS defaults in §17.1). - No code from an inspected jar is ever executed. ASM parses; it does not load. Class initialisers never run. This is a meaningful property — agents inspect untrusted artifacts.
- Zip-slip / zip-bomb hardened: entry-name normalisation, path traversal rejection, decompressed-size caps, entry-count caps.
- Network is opt-in (
--fetch), localhost-bound by default (jdx serve), with checksum verification on downloaded artifacts. - Resource limits: decompilation and source parsing run under a wall-clock timeout, so a pathological class cannot hang an agent's tool call.
- No telemetry.
17.1 Platform directories (D-044, issue #44)#
Phase-0 decision; Phase 1 implements it. Per-OS defaults for every location jdx
writes. Supersedes D-013/D-027 and removes the no XDG_RUNTIME_DIR → exit 3
contract: every OS has a defined fallback, so a missing XDG_RUNTIME_DIR (the norm
on macOS/Windows) is never an error.
| Root | Linux | macOS | Windows |
|---|---|---|---|
| cache | ~/.cache/jdx ($XDG_CACHE_HOME when set) |
~/Library/Caches/jdx |
%LOCALAPPDATA%/jdx/cache |
| config | ~/.config/jdx ($XDG_CONFIG_HOME when set) |
~/Library/Application Support/jdx |
%APPDATA%/jdx |
| daemon socket dir | $XDG_RUNTIME_DIR/jdx, else $TMPDIR/jdx-$UID, else <cache-dir>/run |
$TMPDIR/jdx-$UID, else <cache-dir>/run |
%LOCALAPPDATA%/jdx/run (AF_UNIX, Win10 17063+ floor) |
| install | ~/.local/share/jdx + ~/.local/bin |
same as Linux | %LOCALAPPDATA%/Programs/jdx + PATH shim |
<cache-dir> / <config-dir> / <runtime-dir> below mean the resolved values of
the cache / config / socket-dir rows. The daemon socket keeps its version-stamped
name (<runtime-dir>/jdx/<workspace-hash>-v1.sock).
Override precedence (first match wins; Phase 1 implements):
- Explicit CLI flags (
--cache-dirfor the cache root, which covers the index DB,m2/, the Kotlin sidecar, andauto/workspaces). JDX_*env (JDX_CACHE_DIR,JDX_CONFIG_DIR,JDX_RUNTIME_DIR).- Platform env:
XDG_CACHE_HOME/XDG_CONFIG_HOME/XDG_RUNTIME_DIRon Linux,TMPDIRfor the macOS socket dir,LOCALAPPDATA/APPDATAon Windows. - OS default from the table above (with the socket-dir
elsefallbacks).
Migration: on first run under the new defaults, if the OS-default location is
absent/empty and the old location (~/.cache/jdx, ~/.config/jdx) exists with
content, move it (rename, never copy-merge) to the new location and print an info
line; if both exist, keep the new location and warn naming the old one. Linux
defaults are unchanged, so Linux installs never migrate.
17.2 Release assets and signing (issue #48)#
Tagged pushes (vX.Y.Z on main) build in .github/workflows/release.yml on a
three-OS matrix (ubuntu / windows / macos — the Windows leg builds through
gradlew.bat, and each leg smoke-tests its own launcher: jdx.bat on Windows,
./app/build/jdx elsewhere). Each leg publishes two bundles plus checksums:
| Asset | Contents |
|---|---|
jdx-<version>-<os>.tar.gz (+ .sha256) |
jdx/ + jdx/libs/ layout: jdx, jdx.bat, jdx.ps1, libs/jdx-*-all.jar, libs/jdx.jsa |
jdx-<version>-<os>.zip (+ .sha256) |
Same membership (Explorer/Defender-first for Windows) |
<os> is linux, macos, or windows. The linux leg additionally publishes
bare jdx-<version>.tar.gz/.zip aliases (byte copies): that is the URL
install-release.sh and jdx upgrade fetch by default, so old clients keep
working. Checksums are <hex> <filename> lines verified with sha256sum,
shasum -a 256, or Windows CertUtil — a published checksum that cannot be
checked fails the install loudly, never silently. install-release.sh
--tarball accepts either extension (re-applying +x to the POSIX launcher
after unzip, since repacked zips may lose it).
The jdx.jsa AppCDS archive in each bundle is trained by that leg's own JDK:
the archive format is host-specific and a foreign one is ignored via
-Xshare:auto, so per-OS training is what makes the ≤ 250 ms cold-start budget
(§15) hold on every OS instead of only Linux. Build skew (issue #54, closed):
the archive is keyed to the exact JDK build (CI Temurin 21), so a different
vendor, update, or newer JDK (e.g. a local 27) rejects it — but every launcher
passes -Xshare:auto -Xlog:cds=off with -XX:SharedArchiveFile after
-Xshare:auto, so the rejection degrades silently to the plain run: exit 0,
correct output, empty stderr, just without the archive speedup. Silencing
matters beyond cosmetics: the [warning][cds] notice lands on stdout, where
it would corrupt piped and --json output. The silent-degradation path is
pinned end-to-end by LauncherScriptTest (a garbage archive still yields a
clean jdx version). jdx upgrade keeps tracking the
bare .tar.gz (portable layout, pure-Java extraction, all launchers inside —
it upgrades Windows installs without any external tar).
Signing: releases are unsigned (no SmartScreen / Gatekeeper notarization).
First-run warnings are expected: Unblock-File (PowerShell) on Windows,
xattr -d com.apple.quarantine <install-dir>/jdx on macOS. Adding signing is
tracked in #31 (packaging), not here.
18. Testing strategy#
Full strategy:
docs/TESTING.md. Summarised here; that document is authoritative and is required reading before writing tests.
This is a project whose worst bugs are "the real world contains a jar shaped like that"
bugs — not logic errors in code we wrote. Tests written from imagination cannot find those.
So TDD is the floor, not the ceiling: it is mandatory for core (which is dependency-free
and has no excuse for being hard to test), and it is paired with five families of test that
generate their own cases instead of relying on anyone to enumerate them.
| Family | What it catches |
|---|---|
| Unit (TDD) | logic errors in rules we defined — core, test-first, no exceptions |
| Property-based | edge cases nobody imagined (ref round-trips, descriptor parsing, resolver invariants, truncation) |
| Differential / oracle | wrong bytecode interpretation — member sets and descriptors diffed against javap -p -s for every fixture and a sampled slice of the real corpus |
| Golden / approval | accidental output changes in every renderer, text and JSON |
| Metamorphic | inconsistency between related answers (hierarchy --up ⟺ hierarchy --down, callers ⟺ calls, --inherited ⊇ --declared, run-twice determinism) |
| Fault injection | crashes on malformed input — truncated classes, zip-slip, zip bombs, mismatched sources jars, -g:none, decompiler timeouts |
| Corpus soak | real-world shapes we never imagined — every command over the ~2,183 local jars, asserting invariants (never crash, always valid JSON, always deterministic) rather than values |
| Parity / contract | front-end drift — CLI --json, HTTP and MCP must return byte-identical payloads |
| Performance regression | silent slowdowns against the §15 budgets |
| Mutation (Pitest) | tests that run code without asserting anything — core gated at ≥ 80 % mutation score |
Two oracles do most of the work and cost almost nothing: javap, which tells us the
ground truth for any class file without us writing it down, and compile-back testing for
decompilation (decompiled source must recompile to the same API surface — a far more useful
assertion than pinning decompiler text that changes every release).
Tests run in four tiers so the inner loop stays fast: test (< 30 s, the TDD loop),
check (< 3 min, pre-commit), soak (corpus, tagged off by default), and
bench/mutationTest (on demand). A tier-1 suite that creeps past 30 s kills TDD, and then
it kills testing — that budget is enforced.
19. Dependencies and licensing#
| Dependency | Purpose | License |
|---|---|---|
| Kotlin stdlib | language | Apache-2.0 |
| ASM | bytecode reading | BSD-3-Clause |
| kotlin-metadata-jvm | @Metadata decoding |
Apache-2.0 |
| JavaParser | .java parsing |
Apache-2.0 (dual w/ LGPL — we take Apache-2.0) |
| kotlin-compiler-embeddable | .kt PSI parsing (lazy, side-loaded) |
Apache-2.0 |
| Vineflower | decompilation | Apache-2.0 |
| sqlite-jdbc (Xerial) | index storage | Apache-2.0 |
| Clikt | CLI parsing | Apache-2.0 |
| kotlinx-serialization-json | JSON envelope | Apache-2.0 |
| MCP Kotlin SDK | MCP transport (fallback: ~300 LoC hand-rolled JSON-RPC) | MIT |
JDK com.sun.net.httpserver |
HTTP server | — (JDK builtin) |
| JUnit 5 + kotlin-test | tests | EPL-2.0 / Apache-2.0 |
All permissive; all compatible with shipping jdx itself under Apache-2.0.
Bundled third-party notices go in NOTICE.
20. Delivery plan / milestones#
Each milestone ends with a working, committed, demoable binary.
| # | Milestone | Contents | Demo gate |
|---|---|---|---|
| M0 | Skeleton | Gradle multi-project, version catalog, fat jar, jdx launcher, jdx version, jdx doctor, CI-less local test task |
./jdx doctor prints a clean environment report |
| M1 | Read path | Artifact loading + sources pairing, ASM class model, symbol-ref parser/printer, show, outline, members --inherited with generic substitution, text + JSON renderers, JDK jrt root |
jdx members java.util.HashMap --inherited matches IntelliJ's completion list |
| M2 | Index | SQLite store, content-hash keying, parallel indexer, search (glob/regex/camel-hump), ls/tree, workspaces, project auto-discovery, cache cmds |
gson + JDK indexed; jdx search 'H*Map' instant |
| M3 | Bodies | Sources jars, JavaParser, srcmap, body, source, signature, doc (incl. inherited doc), Vineflower engine + cache, javap engine |
jdx body 'com.google.gson.Gson#toJson(Object)' from sources and from a sources-less jar |
| M4 | Graph | Reference-edge extraction, usages, hierarchy, implementors, callers, calls --depth, samples, project source-dir usages |
jdx usages on a Minecraft symbol under 150 ms |
| M5 | Kotlin | @Metadata rendering, Kotlin member mapping, --view jvm, side-loaded Kotlin PSI source parsing |
jdx members on a coroutines class shows suspend fun and nullability |
| M6 | Serving | Daemon + unix socket + 5-min idle shutdown, transparent CLI client, MCP server, HTTP server, batch |
Same query answered identically via CLI, MCP, and HTTP |
| M7 | Polish | Token budgets, did-you-mean, warnings, AppCDS, help --agent, README, install script, bench |
Cold query ≤ 250 ms; docs complete |
Phase-2 backlog is §21. GitHub Issues track limitations and the backlog against this table.
21. Deferred to phase 2#
Explicitly out of v1 scope, recorded so they are not lost:
- ~~
jdx diff a.jar b.jar— public-API diff with binary-compatibility warnings, for dependency upgrades.~~ Shipped (issue #23): spec in §7.7, flags in Appendix B. - Mappings / remapping — Minecraft-style obfuscated jars: read Tiny/SRG/ProGuard mapping
files so
jdxcan answer in either namespace. High value on this particular machine. - Resources & metadata inspection —
META-INF/services,module-info, manifest attributes, SPI providers, resource listing and extraction. - Annotation-driven views — "show me every
@Deprecated(forRemoval=true)in this dependency", "all Spring@Beanfactories", "all JUnit@Tests". --since/ API-level reporting — which JDK release introduced a member.- Dataflow-lite —
jdx flow <method>for a simplified reaching-values view. - Multi-release jar variant selection beyond warning.
- Scala/Groovy language views.
- Publishing — Homebrew/AUR packaging, GitHub Releases with a native-image build.
22. Risks#
| Risk | Impact | Mitigation |
|---|---|---|
kotlin-compiler-embeddable bloat/startup (~55 MB, ~1 s) |
Slow cold path, big install | Side-loaded jar, isolated lazy classloader, warm in daemon, doctor reports it. Fallback lexer extractor kept behind a flag. |
| Decompiler output is wrong or unreadable on hard methods | Agent reasons from bad code | Always labelled as reconstructed; --engine javap escape hatch; phase-2 second engine |
| Index staleness on rapidly-changing project dirs | Wrong answers | Content hashing for jars, per-file mtime for dirs, --force, hash in doctor |
| Global SQLite DB grows unbounded | Disk usage | cache gc, per-artifact eviction by last-use, cache info |
| Shaded/duplicate FQNs across a 2000-jar classpath | Silently wrong class shown | Classpath-order shadowing + explicit duplicate warning with all providers listed |
| Scope creep (this document is large) | Never ships | Strict milestone gating; M1–M3 alone already deliver the headline value |
| Output format churn breaking agent prompts | Agents mis-parse | jdx: 1 envelope version; golden tests; changes to text layout treated as breaking |
Appendix A: worked agent session#
The scenario from the original brief — "I want the body of org.some.lib.AClass.someMethod(int,int)" — plus the investigation around it.
jdx ws create app --auto # discovers the Gradle project + its cached jars + JDK
workspace 'app' created · 214 artifacts · 18,443 classes · indexed in 4.1s
jdx search 'JsonAdapter' --kind class
class com.google.gson.annotations.JsonAdapter gson-2.14.0.jar annotation
class com.google.gson.internal.bind.JsonAdapterAnnotationTypeAdapterFactory gson-2.14.0.jar
2 results
jdx members com.google.gson.Gson --inherited --grep 'json'
methods on com.google.gson.Gson
public <T> T fromJson(String, Class<T>) throws JsonSyntaxException
public <T> T fromJson(String, Type) throws JsonSyntaxException
public <T> T fromJson(JsonElement, Class<T>) throws JsonSyntaxException
public String toJson(Object)
public String toJson(Object, Type)
public void toJson(Object, Appendable) throws JsonIOException
public JsonElement toJsonTree(Object)
+ 9 inherited from java.lang.Object (--from java.lang.Object)
7 of 39 methods shown (filtered by --grep 'json')
jdx body 'com.google.gson.Gson#toJson(Object)'
com.google.gson.Gson#toJson(java.lang.Object)
source: gson-2.14.0-sources.jar · com/google/gson/Gson.java:722-728
public String toJson(Object src) {
if (src == null) {
return toJson(JsonNull.INSTANCE);
}
return toJson(src, src.getClass());
}
next: jdx body 'com.google.gson.Gson#toJson(java.lang.Object, java.lang.reflect.Type)'
jdx usages 'com.google.gson.Gson#toJson(Object)' --limit 5
5 of 23 usages
app/src/main/java (2)
com.example.api.UserController#serialize(User) UserController.java:44 call
com.example.api.ErrorMapper#toBody(Throwable) ErrorMapper.java:19 call
gson-2.14.0.jar (3)
com.google.gson.Gson#toJson(Object, Appendable) call
com.google.gson.internal.Streams#write(JsonElement, …) call
…
jdx hierarchy com.google.gson.TypeAdapter --down --in 'app/**'
com.google.gson.TypeAdapter<T>
↓ subclasses in app/**
com.example.json.InstantAdapter extends TypeAdapter<java.time.Instant>
com.example.json.MoneyAdapter extends TypeAdapter<com.example.Money>
jdx samples 'com.google.gson.TypeAdapter#write' --limit 1
example from app/src/main/java · com.example.json.InstantAdapter#write:21-24
@Override public void write(JsonWriter out, Instant value) throws IOException {
if (value == null) { out.nullValue(); return; }
out.value(DateTimeFormatter.ISO_INSTANT.format(value));
}
jdx body 'net.minecraft.world.item.ItemStack#getMaxStackSize()' # no sources jar
net.minecraft.world.item.ItemStack#getMaxStackSize()
source: decompiled by vineflower 1.11 from minecraft-client.jar ⚠ reconstructed
public int getMaxStackSize() {
return this.getOrDefault(DataComponents.MAX_STACK_SIZE, 1);
}
next: jdx body '…#getMaxStackSize()' --engine javap (exact bytecode)Seven questions answered, no file dumped, nothing grepped, total output a few hundred tokens.
The equivalent investigation with unzip/javap/grep is roughly 20 commands and tens of
thousands of tokens.
Appendix B: full flag reference#
(Maintained as commands land; jdx help --agent is generated from the same metadata and is
the authoritative machine-readable version.)
Global#
-w, --workspace <name> use a named workspace
--jars <path|glob|dir> add binary roots (repeatable)
--src <dir> add source roots (repeatable)
--coord <g:a:v> add a Maven coordinate root (repeatable)
--repo <url> add a Maven repository base URL for --coord fetches (repeatable)
--sources <path> explicitly pair a sources jar
--jdk / --no-jdk include the JDK stdlib (default: include)
--fetch allow network fetches from Maven repositories (Central last by default)
--json structured output
--no-color disable ANSI even on a TTY
--max-lines <n> bound output size
--brief minimal output (signatures only, no doc)
-v, --verbose / -q, --quiet
--no-daemon force in-process execution
--timeout <duration> per-operation wall-clock cap
--cache-dir <path> override the OS cache default (§17.1; ~/.cache/jdx on Linux)Per-command highlights#
show <type> [-w …] [--jars …] [--no-jdk] [--json] [--no-color]
outline <type> [--kind --static/--instance --access --from --grep
--include-synthetic --limit --brief --max-lines --view kotlin|jvm] [-w …] [--jars …] [--no-jdk] [--json] [--no-color]
members --inherited/--declared --kind --static --instance --access --from
--grep --with-doc --brief --max-lines --sort --view kotlin|jvm --include-synthetic
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
body --engine vineflower|javap --with-doc --with-signature --context N
--line-numbers --max-lines
source --lines A:B --around <ref> --context N --line-numbers --max-lines
--engine vineflower|javap
signature <member> [--include-synthetic --limit --view kotlin|jvm]
doc --inherited/--no-inherited --raw --max-lines
search --kind --regex --fuzzy --in --package --limit
resolve <name> [--limit]
ls [package-glob] [--limit]
tree [artifact-glob] [--depth --counts --limit]
usages --kind call|ref|impl|override|read|write|new|throw|annotation|all
--in --exclude --limit --context --src
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
hierarchy --up --down --direct --depth N --in --exclude --limit
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
implementors [--direct --depth N --in --exclude --limit]
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
callers --depth N --in --exclude --limit
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
calls --depth N --in --exclude --external-only --limit
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
samples --limit N --prefer-sources
[-w …] [--jars …] [--no-jdk] [--json] [--no-color]
diff <old> <new> [--visibility public|all] [--include-synthetic]
[--severity all|suspicious|breaking] [--fail-on none|breaking|any]
[--limit N] [--fetch] [--repo] [--json] [--no-color]
(no --jars/-w/--no-jdk/--no-daemon: a diff names its own two artifacts)
ws create|list|info|remove|add|use --auto --jars --src --coord --repo --jdk
index --force -w
cache info|gc|clear [--cache-dir --json] (gc: --dry-run)
kotlin install [--repo --force] [--json]
daemon start|stop|status|restart --idle <duration>
serve [-w …] --port --bind
mcp [-w …] (stdio; per-call workspace override)
batch --json
bench [--jars …] [-w …] [--no-jdk] [--iterations N] [--json]
upgrade [--version <tag>] [--check] [--repo OWNER/NAME] [--json]
setup --agent opencode|claude-code|cursor|kilo|cline|codex|copilot|antigravity --scope project|system [--check] [--remove] [--json]
help [--agent] [--json]