Contributing to jdx
This project is built by a rotating cast of contributors — humans and AI agents — who do not share memory, habits, or taste. Nobody here has read the whole codebase. The conventions below exist so that code written by six different people looks like it was written by one.
If you are an AI agent, start with AGENTS.md, then read the For AI agents
section at the bottom of this file.
The one-minute version#
git clone <repo> && cd jdx
./gradlew build # no system gradle/maven needed; the wrapper is the only entry point
cat AGENTS.md # architecture rules, build/test/lint, pointers
gh issue list --limit 40 # limitations and the phase-2 backlog — where new work starts
# ... do the work ...
./gradlew check # tests + lint must pass
# update GitHub Issues and the touched module's AGENTS.md if behaviour changed, commitWhere things are written down#
| Question | File |
|---|---|
| What is this project and what are its rules? | AGENTS.md |
| Why is it designed this way? What does each command do? | docs/PROPOSAL.md |
| Why was X chosen over Y? May I change it? | AGENTS.md (root locked rules + module notes) |
| What should I work on next? | GitHub Issues (limitations, follow-ups, phase-2 backlog) |
| What happened before I got here? | git history (the v1 board and session logs were removed post-v1) |
| What mistakes have already been made here? | The module AGENTS.md gotchas (distilled post-v1; history in git) |
| How do I write code that fits in? | this file |
Keep them accurate. A stale doc in this project is worse than a missing one, because the next contributor has no way to tell the difference.
Non-negotiables#
These are not style preferences. Violating one breaks the product.
corehas no dependencies. No ASM, no SQLite, no JavaParser, no file IO, no network. It is the shared vocabulary and must stay trivially testable. If you need bytecode incore, you need a different design.- Front-ends contain no logic.
cli,mcp,serverparse input, callJdxService, render, and map exit codes. Nothing else. Four front-ends were a deliberate choice and they only stay consistent if they are all thin. If you catch yourself writing anifabout behaviour in a Clikt command, move it. - Never load an inspected class into the JVM. Parse with ASM. Reflection on a third-party jar executes its static initialisers — that is a security property we sell.
- Never guess a symbol. Ambiguous input exits
2with candidates listed. - Exit codes are a public contract. Changing one is a breaking change.
- Text and JSON must carry the same information.
- Output is deterministic. Sorted. No timestamps, absolute paths, hashes, or ANSI in non-TTY output. Golden tests enforce this; do not weaken them to make a test pass.
- Every fallback is labelled. Decompiled output says it is decompiled. A guessed
parameter name says so.
jdxmust never present a reconstruction as ground truth.
Code style#
We optimise for a stranger reading this once, under time pressure, not for elegance.
- Kotlin official style, 4-space indent, 100-column soft limit.
- Explicit API mode in
core— every public declaration needs an explicit visibility and return type. - Name things fully.
resolveInheritedMembers, notresolve.sourcesJarPath, notsp. Long names are free; re-reading a function to learn whatsis, is not. - Prefer boring code. No operator overloading for domain types, no DSL builders for internal use, no reflection, no clever generic gymnastics. If a reviewer has to pause, it is too clever.
- Sealed classes over booleans + nulls for states with a closed set of cases
(
Provenance,SymbolRef, query results). Kotlin's exhaustivewhenis the main reason we chose Kotlin; use it. - Errors are values, not exceptions, on any path an agent can trigger. Exceptions are for
bugs. A missing class is a
Result, not a throw. - Comment the invariant, not the syntax.
// callers rely on this list being sorted by declaring-type depthis useful.// loop over membersis noise. - Every public type gets one KDoc line saying what it represents and who produces it. That single line is what lets a newcomer navigate without reading call sites.
Comment density#
Match the surrounding file. In core/resolve/ (subtle algorithms) comments are dense and
explain why. In cli/commands/ (mechanical wiring) they are nearly absent, because the
code says everything. Do not sprinkle uniform comment density across both.
Adding a command — the checklist#
A command is not finished until all of these are true. This list is the most common source of half-done work.
- [ ] Behaviour implemented in
core/index/sources/decompile, exposed viaJdxService - [ ] Clikt command in
cli, thin, with complete--helptext (agents read--help) - [ ] Text renderer following the layout conventions in
docs/PROPOSAL.md§8 - [ ] JSON renderer, same information, inside the standard envelope
- [ ] Exit codes per the public contract, including the ambiguity path
- [ ] Truncation handled: bounded output with
shown/total/hint - [ ]
next:hint line on single-entity output, where a sensible follow-up exists - [ ] Golden tests for text and JSON against the fixture corpus
- [ ] MCP tool entry — generated from shared metadata, not hand-written twice
- [ ] Row in the docs/COMMANDS.md command table
- [ ] Flag documented in
docs/PROPOSAL.mdAppendix B - [ ] GitHub Issue filed/updated if behaviour or limitations changed
- [ ]
./gradlew :site:buildSitegreen — the website runs everyconsole execdoc example and checks documented flags against--help; updatedocs/guide/if users need to know
Testing#
Read docs/TESTING.md before writing tests. It is the authoritative
strategy; this is the two-minute version.
coreis written test-first. Red → green → refactor, no exceptions. It has no dependencies and no IO, so "hard to test" is never true there.- Hand-written examples are not enough. They plateau at what you already thought of. Every
new behaviour also needs at least one test family that generates its own cases:
property-based, differential-vs-
javap, metamorphic, fault-injection, or corpus soak. - Fixtures over mocks. The
testfixturescorpus compiles real, nasty Java and Kotlin at build time. Hand-builtClassInfoobjects encode your assumptions — and your assumptions are exactly what the test should be checking. There is no mocking framework incore; if you need one, push the IO outward instead. - Use the oracles.
javap -p -stells you the truth about any class file without you writing it down. For decompilation, assert that the output recompiles to the same API, never that it matches particular text. - Test names are sentences:
fun `inherited members from a generic supertype are substituted`(). The test report should read as a specification of whatjdxdoes. - Every bug fix starts with a failing regression test named after the issue.
- Respect the tiers.
./gradlew testmust stay under 30 s — anything touching disk, network or a real jar belongs in tier 2 or above. A slow inner loop kills TDD, and then it kills testing. - A gate that cannot run is not a gate. If a check can only fail under a condition you
never reproduce locally — a clean checkout, a bare CI runner — it will be discovered by a
red nightly months later, or never. Give the precondition its own cheap assertion in
check.verifyPitestWiringis the worked example: thecoremutation gate silently produced no score at all in CI (issue #66), because PIT's pre-scan aborts before mutating anything when the fixture corpus is missing. - Ask, then resolve. A missing input is a property of the machine, not a defect. Test
helpers expose an availability probe and the suite
assumeTrues on it, so absence is a visible SKIP rather than a red whose stack trace points at product code (docs/TESTING.md§10.1, §11.3).
./gradlew test # tier 1 — the TDD loop (<30s)
./gradlew check # tier 2 — pre-commit (<3min)
./gradlew soak # tier 3 — real jar corpus (tagged, needs local jars)
./gradlew bench mutationTest -PconfirmTier4=true # tier 4 — performance budgets and mutation score
./gradlew test -Pgolden.update=true # rewrite goldens — THEN READ THE DIFFThe golden-file hazard.
-Pgolden.update=truemakes every failing output test pass instantly, correct or not. Always read the diff before committing it. A golden file you updated without reading is a test you deleted. A large diff is a signal to check whether you changed behaviour you didn't mean to — not a signal to skim faster.
Commits and branches#
- Conventional Commits:
feat:,fix:,docs:,refactor:,test:,build:,perf:,chore:. Scope with the module:feat(index): parallel artifact indexer. - The cadence is: one small change → commit → push → next. Not
"finish everything, then commit". Contributors here share no memory; an unpushed
working tree is invisible to everyone else, and if the session ends there the work
is effectively lost. Small pushed increments also keep
git bisectuseful and let a bad change be reverted without unpicking four others. - Small, working increments. Every commit should build. A 2,000-line commit cannot be reviewed and cannot be bisected.
- If a change won't fit in one sitting, split it first, then do the first half. That is normal, not an admission of failure.
- Branch per change:
outline-command,daemon-log-gc. - Never push to a remote without an explicit go-ahead from the project owner in the current conversation/session.
Changing a locked rule#
AGENTS.md (root) marks which rules are owner-settled (locked) and which are
defaults.
- Defaults — improve them in a normal PR and update the note in the same PR.
locked— the project owner decided them explicitly, usually after being shown the alternatives. Do not change them, and do not erode them gradually through refactors. If you think one is wrong, open an issue making the case, get an explicit reversal, and record it inAGENTS.md(and the commit message).
Never silently re-litigate a settled rule. The history is the value.
For AI agents#
You are a first-class contributor here, and this project is designed around the assumption that you have no memory of yesterday.
Your session is only complete when the documentation is. Concretely:
- Read before writing.
AGENTS.md→ the moduleAGENTS.mdfor your area → GitHub Issues. Roughly 15 minutes. It will save you from re-deciding things that are already settled and from repeating paid-for mistakes. - Ask the owner about genuine ambiguity instead of picking. This is an explicit standing instruction from the owner. A wrong guess propagates silently through a codebase that nobody fully reads.
- Update GitHub Issues (always on). Move finished items out, record newly found ones — deferred follow-ups, environment caveats, limitations. A change that alters behaviour or finds a caveat and leaves it stale is incomplete.
- Update the touched module's
AGENTS.md. New invariant, new gotcha, new key file — write it down in the same change, compressed. The next agent reads it cold and trusts it. - Report honestly. If tests fail, say so and paste the output. If you skipped part of a task, say which part and why. Half-finished work that is documented as half-finished is useful; half-finished work reported as done is a trap that costs the next contributor more than the work was worth.
- Do not expand scope silently. If you find adjacent work, file an issue; do not fold it into the current change.
- Leave the build green. If you cannot, say so loudly, with the exact failing command.