Documentation menu

jdx — An IDE for Agents

Proposal / design specification — v1.0 Date: 2026-09-13 · Status: approved scope, pending implementation


Table of contents#

  1. Problem statement
  2. What an IDE actually gives a human
  3. What an agent needs that an IDE does not provide
  4. Product goals and non-goals
  5. Conceptual model
  6. Symbol reference grammar
  7. Command surface
  8. Output design
  9. Architecture
  10. The index
  11. Sources, bytecode and decompilation
  12. Kotlin support
  13. Classpath resolution
  14. Interfaces: CLI, MCP, daemon, HTTP
  15. Performance plan
  16. Error handling, exit codes, degradation
  17. Security and safety
  18. Testing strategy
  19. Dependencies and licensing
  20. Delivery plan / milestones
  21. Deferred to phase 2
  22. Risks
  23. Appendix A: worked agent session
  24. 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)   ⚠ reconstructed

An 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
Q

One 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. (jdx reads 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:

  1. Sibling file <name>-sources.jar.
  2. Gradle cache layout: same modules-2/files-2.1/<group>/<artifact>/<version>/*/ parent.
  3. Maven cache layout: same directory in ~/.m2/repository.
  4. Explicit --sources <path> override.
  5. Sources embedded in the binary jar itself (some artifacts ship .java alongside .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.ItemStack

Ordering 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 $?
2

7. 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 --inherited

jdx 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, --json unaffected
  • --max-lines N — cap text lines with a continuation footer; text only, --json unaffected
  • --sort name|kind|declaring (default: kind then name)
  • --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:

  1. Sources jar/dir → exact AST node range → verbatim text (ground truth).
  2. No sources → Vineflower decompilation of just the enclosing class, method extracted.
  3. --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's Ctrl+N behaviour.
  • --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 throw matches throws declarations (declaration-level), not ATHROW throw 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 prompt

7.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 line

7.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.JsonReader

Flags: --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:

  1. Load the target type's ClassInfo from the index.
  2. Compute the linearized supertype set: BFS over superclass then interfaces, recording depth and path. Cycle-safe. Includes java.lang.Object last.
  3. For each supertype, substitute generic type arguments down the chain, so class StringList extends ArrayList<String> reports boolean add(String), not add(E). (Signature attribute → type-variable environment → substitution.)
  4. Collect members, applying JLS visibility rules from the perspective of the query: private members of supertypes are excluded; package-private only when same package.
  5. 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.
  6. Drop bridge/synthetic members unless --include-synthetic.
  7. For Kotlin types, map the collected JVM members back onto Kotlin declarations (properties instead of getter/setter pairs, default-arg synthetics folded in).
  8. 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 gson once 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 gc drops 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 acceleration

Indices 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:

  1. Hash the jar (streaming, SHA-256 truncated to 128 bits — enough, and fast).
  2. If artifact.hash already present with a matching schema_ver, skip entirely.
  3. Stream entries; for each .class:
    • ClassReader with SKIP_FRAMES (we need debug info for parameter names, so not SKIP_DEBUG).
    • Extract class/member/annotation rows.
    • Extract reference edges from the method bodies via a lightweight MethodVisitor (visitMethodInsn, visitFieldInsn, visitTypeInsn, visitInvokeDynamicInsn, visitLdcInsn for class constants) — no full control-flow analysis.
    • Decode @Metadata if present.
  4. 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 doc and srcmap.
  5. 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 ref table.
  • Query latency target: < 15 ms warm for any single-type query; < 150 ms for usages over 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 body output 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 sources module's SourceParser interface;
  • loaded lazily, only when a .kt file must actually be parsed;
  • loaded in an isolated URLClassLoader so 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 doctor reports 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.

  1. Explicit flags: --jars <path|glob|dir>, --src <dir>, --coord <g:a:v>, --sources.
  2. -w <name> — a named workspace.
  3. JDX_WORKSPACE env var, then the workspace set by jdx ws use.
  4. 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} and build/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.
  5. JDK stdlib via jrt-fs on the running JDK — always appended unless --no-jdk, with $JAVA_HOME/lib/src.zip paired as its sources root, so jdx members java.util.HashMap works 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, 0 to disable). Simple ScheduledExecutorService resetting on each request.
  • Version-stamped socket path, so an upgraded jdx never talks to a stale daemon.
  • sun_path guard: socket paths past 104 bytes on macOS / 108 elsewhere exit 3 naming the dir and the JDX_RUNTIME_DIR escape 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 concurrent start fails fast naming the holder.
  • stop validates the pid file owns a daemon run child before signalling (never kills a reused pid), escalates destroy() to destroyForcibly(), and sweeps stale .sock/.pid files so a killed daemon restarts clean.
  • jdx daemon status reports 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"}}
  • command is one of the read-only queries — show members outline body source signature doc search resolve ls tree usages hierarchy implementors callers calls samples — plus version, doctor and health. 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.
  • params values 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, absent jdx/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=off in the launcher for the one-shot path (classic CLI JVM tuning; irrelevant and disabled for the daemon). -Xlog:cds=off keeps a stale AppCDS archive's mismatch notice off stderr, where it reads like a crash for a command that then works fine.
  • A jdx bench command, tagged out of normal test runs, measuring against the local minecraft-client.jar so 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.jar

Warnings, 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. jdx writes 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):

  1. Explicit CLI flags (--cache-dir for the cache root, which covers the index DB, m2/, the Kotlin sidecar, and auto/ workspaces).
  2. JDX_* env (JDX_CACHE_DIR, JDX_CONFIG_DIR, JDX_RUNTIME_DIR).
  3. Platform env: XDG_CACHE_HOME / XDG_CONFIG_HOME / XDG_RUNTIME_DIR on Linux, TMPDIR for the macOS socket dir, LOCALAPPDATA / APPDATA on Windows.
  4. OS default from the table above (with the socket-dir else fallbacks).

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 jdx can 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 @Bean factories", "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]

Edit this page on GitHub · View as Markdown · Updated