diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 50741c6..34482e4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -334,16 +334,19 @@ jobs:
- uses: actions/download-artifact@v4
with:
name: codegraph-csharp-${{ matrix.rid }}
- path: bin
+ # Where build.sh publishes it — never bin/, whose bin/codegraph-csharp
+ # is the committed wrapper the download would overwrite.
+ path: extractors/csharp/dist/${{ matrix.rid }}
# Nothing is installed on this runner for the extractor: the binary
# carries the runtime, Roslyn and the BCL. Run from the repo root with
# the RELATIVE --src the snapshot was made with (the typed path is the
# header's `root`).
- shell: bash
run: |
- chmod +x "bin/${{ matrix.exe }}"
- "bin/${{ matrix.exe }}" --version
- "bin/${{ matrix.exe }}" --src fixtures/csharp/src --out out.jsonl --progress plain
+ bin="extractors/csharp/dist/${{ matrix.rid }}/${{ matrix.exe }}"
+ chmod +x "$bin"
+ "$bin" --version
+ "$bin" --src fixtures/csharp/src --out out.jsonl --progress plain
cmp out.jsonl fixtures/csharp/expected/model.jsonl
echo "byte-identical on ${{ matrix.os }}"
diff --git a/CLAUDE.md b/CLAUDE.md
index a48d1f0..58c6ead 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -174,6 +174,8 @@ cd extractors/java && ./mvnw package # Maven Wrapper — `mvn` is NOT install
java -jar target/codegraph-java.jar --src
--out model.jsonl
# the jar needs a JDK on PATH; non-interactive shells do not source sdkman:
# export JAVA_HOME="$HOME/.sdkman/candidates/java/25.0.4-tem"
+./bin/codegraph-java --src --out model.jsonl # the jar, from the repo root, on the first Java 17+
+# in JAVA_HOME, on PATH or under ~/.sdkman — no export needed
./build.sh --java --native # GraalVM binary for THIS platform only —
# native-image cannot cross-compile, so CI
# builds one RID per runner
@@ -185,6 +187,7 @@ cd extractors/csharp && dotnet test -c Release # needs the .NET 10 SDK (see ex
./build.sh --csharp --publish-all # linux-x64/arm64, osx-x64/arm64, win-x64 — all from this host
./test.sh --csharp # dotnet test + the published binary must reproduce the snapshot
extractors/csharp/dist/linux-x64/codegraph-csharp --src --out model.jsonl
+./bin/codegraph-csharp --src --out model.jsonl # the same, for THIS host's RID
# runtime-only or SDK forms (dotnet , dotnet run): docs/csharp-extractor.md
./build.sh --ts --sea # ONE Node single-executable of the CLI + daemon + both frontends
diff --git a/bin/codegraph-csharp b/bin/codegraph-csharp
new file mode 100755
index 0000000..330109b
--- /dev/null
+++ b/bin/codegraph-csharp
@@ -0,0 +1,32 @@
+#!/usr/bin/env bash
+# The C# extractor as a program: `./bin/codegraph-csharp --src DIR --out model.jsonl`.
+#
+# Extensionless on purpose — the name IS the command. It runs the
+# self-contained binary `build.sh --csharp` publishes for THIS host beside
+# ITSELF (a git worktree or a moved checkout runs its own dist/); the binary
+# carries its runtime, Roslyn and the BCL reference pack — no .NET installed.
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+# The RID build.sh publishes by default (scripts/lib.sh host_rid).
+case "$(uname -s)" in
+ Darwin) os="osx" ;;
+ MINGW*|MSYS*|CYGWIN*) os="win" ;;
+ *) os="linux" ;;
+esac
+case "$(uname -m)" in
+ arm64|aarch64) arch="arm64" ;;
+ *) arch="x64" ;;
+esac
+rid="$os-$arch"
+
+bin="$here/../extractors/csharp/dist/$rid/codegraph-csharp"
+[ -x "$bin" ] || bin="$bin.exe"
+
+if [ ! -x "$bin" ]; then
+ printf 'codegraph-csharp: extractors/csharp/dist/%s/codegraph-csharp is missing\n' "$rid" >&2
+ printf 'Run `./build.sh --csharp` in %s and try again.\n' "$(cd "$here/.." && pwd)" >&2
+ exit 1
+fi
+
+exec "$bin" "$@"
diff --git a/bin/codegraph-java b/bin/codegraph-java
new file mode 100755
index 0000000..ee53eda
--- /dev/null
+++ b/bin/codegraph-java
@@ -0,0 +1,50 @@
+#!/usr/bin/env bash
+# The Java extractor as a program: `./bin/codegraph-java --src DIR --out model.jsonl`.
+#
+# Extensionless on purpose — the name IS the command. It runs the shaded jar
+# `build.sh --java` produces beside ITSELF (a git worktree or a moved checkout
+# runs its own target/), on the first Java at or above pom.xml's
+# maven.compiler.release — a JRE will do, ECJ only needs its class library.
+# Non-interactive shells never source sdkman, so its candidates are probed too.
+set -euo pipefail
+here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+jar="$here/../extractors/java/target/codegraph-java.jar"
+
+if [ ! -f "$jar" ]; then
+ printf 'codegraph-java: extractors/java/target/codegraph-java.jar is missing\n' >&2
+ printf 'Run `./build.sh --java` in %s and try again.\n' "$(cd "$here/.." && pwd)" >&2
+ exit 1
+fi
+
+want="$(sed -n 's:.*\([0-9][0-9]*\).*:\1:p' \
+ "$here/../extractors/java/pom.xml" | head -1)"
+
+# "1.8.0_392" -> 8, "17.0.2" -> 17, "21" -> 21 (a GA build prints no minor).
+major() {
+ "$1" -version 2>&1 | sed -n '1s/.*version "\([^"]*\)".*/\1/p' | awk -F'[.+_-]' '{ print ($1 == 1 ? $2 : $1) }'
+}
+
+# Search order is preference order: JAVA_HOME, PATH, sdkman's selected JDK,
+# every sdkman candidate, then macOS's registry. The first one recent enough wins.
+find_java() {
+ local candidate have
+ set -- "${JAVA_HOME:+$JAVA_HOME/bin/java}" "$(command -v java 2>/dev/null || true)" \
+ "$HOME/.sdkman/candidates/java/current/bin/java" "$HOME"/.sdkman/candidates/java/*/bin/java
+ if [ -x /usr/libexec/java_home ]; then
+ set -- "$@" "$(/usr/libexec/java_home -v "$want+" 2>/dev/null || true)/bin/java"
+ fi
+ for candidate in "$@"; do
+ [ -n "$candidate" ] && [ -x "$candidate" ] || continue
+ have="$(major "$candidate")"
+ if [ -n "$have" ] && [ "$have" -ge "$want" ] 2>/dev/null; then printf '%s' "$candidate"; return 0; fi
+ done
+ return 1
+}
+
+if ! java="$(find_java)"; then
+ printf 'codegraph-java: no Java %s+ in JAVA_HOME, on PATH or under ~/.sdkman — the jar needs one\n' "$want" >&2
+ printf 'sdkman: sdk install java 25.0.4-tem homebrew: brew install --cask temurin\n' >&2
+ exit 1
+fi
+
+exec "$java" -jar "$jar" "$@"
diff --git a/build.sh b/build.sh
index ebe11ea..957ae96 100755
--- a/build.sh
+++ b/build.sh
@@ -5,7 +5,7 @@
# TypeScript : pnpm -r build -> packages/*/dist (incl. the viz bundle
# `codegraph city --serve` looks for) and
# extractors/typescript/dist/cli.js (bin/codegraph-typescript)
-# Java : ./mvnw package -> extractors/java/target/codegraph-java.jar
+# Java : ./mvnw package -> extractors/java/target/codegraph-java.jar (bin/codegraph-java)
# --native -> extractors/java/dist//codegraph-java
# (GraalVM native image; HOST platform only —
# native-image drives the host linker and
@@ -13,7 +13,8 @@
# is one CI job per runner, not one job)
# C# : dotnet publish -> extractors/csharp/dist//codegraph-csharp
# (self-contained single file; --publish-all
-# builds the five-RID matrix from this host)
+# builds the five-RID matrix from this host;
+# bin/codegraph-csharp runs the host's)
# Elixir mix escript.build -> extractors/elixir/dist/codegraph-elixir
# (an escript: Elixir embedded, needs Erlang/OTP
# on the machine that runs it — bin/codegraph-elixir)
diff --git a/docs/cli.md b/docs/cli.md
index 9cc6825..855790c 100644
--- a/docs/cli.md
+++ b/docs/cli.md
@@ -440,6 +440,9 @@ java -jar target/codegraph-java.jar # defaults: the current directory,
# into -codegraph.jsonl
```
+From the repo root, `./bin/codegraph-java` runs the same jar with the same
+flags, on the first Java 17+ in `JAVA_HOME`, on `PATH` or under `~/.sdkman`.
+
Spoon runs in noClasspath mode, so the sources need not compile and no
dependency jars are needed. What cannot be resolved becomes a stub with its
edges kept, so the picture is honest about its gaps. `--src` is repeatable:
diff --git a/docs/csharp-extractor.md b/docs/csharp-extractor.md
index 955b19d..d122b35 100644
--- a/docs/csharp-extractor.md
+++ b/docs/csharp-extractor.md
@@ -47,6 +47,9 @@ xattr -d com.apple.quarantine codegraph-csharp
codegraph-csharp.exe --src path\to\src --out model.jsonl
```
+From a clone, `./bin/codegraph-csharp` runs the binary published for the
+host's platform, with the same flags.
+
No `libicu` is needed on Linux: the extractor is built with invariant
globalization, so it runs on a minimal container image.
diff --git a/extractors/java/README.md b/extractors/java/README.md
index c612a06..f818fc4 100644
--- a/extractors/java/README.md
+++ b/extractors/java/README.md
@@ -64,6 +64,9 @@ Go or .NET extractor would use.
```bash
java -jar target/codegraph-java.jar --src src/main/java --out model.jsonl
+# the same jar from the repo root, on the first Java 17+ in JAVA_HOME, on PATH or under ~/.sdkman
+./bin/codegraph-java --src src/main/java --out model.jsonl
+
# both options default: extract the current directory into -codegraph.jsonl
java -jar target/codegraph-java.jar
```
diff --git a/test.sh b/test.sh
index c0b9915..1153b12 100755
--- a/test.sh
+++ b/test.sh
@@ -7,11 +7,12 @@
# unit pnpm -r test (vitest + fast-check property suites)
# schemas pnpm run gen:schemas must be a NO-OP — schemas/ is a committed
# artifact and the cross-language contract; drift there is a bug
-# java ./mvnw test, then the NATIVE binary (if build.sh made one)
-# re-extracts fixtures/java/src and must reproduce the committed
-# snapshot — the only test that sees the image's own code path: no
-# java.home, no VM class library, the embedded ct.sym reference
-# csharp dotnet test, then the PUBLISHED binary (if build.sh made one)
+# java ./mvnw test, then the built JAR (bin/codegraph-java) and the
+# NATIVE binary (each if build.sh made one) re-extract
+# fixtures/java/src and must reproduce the committed snapshot — the
+# native one is the only test that sees the image's own code path:
+# no java.home, no VM class library, the embedded ct.sym reference
+# csharp dotnet test, then the PUBLISHED binary (bin/codegraph-csharp, if build.sh made one)
# re-extracts fixtures/csharp/src and must reproduce the committed
# snapshot byte for byte — the only test that sees the single-file
# code path (embedded BCL, no Assembly.Location)
@@ -225,6 +226,15 @@ if wants_java && [ "$SKIP_JAVA" = "no" ]; then
phase "java extractor tests (./mvnw test)" \
sh -c "cd '$JAVA_DIR' && ./mvnw -B test"
+ # The shaded jar, not the test classpath, must reproduce the snapshot: it is
+ # what `./bin/codegraph-java` runs from a clone. Same `root` rewrite as below.
+ if [ -f "$JAVA_DIR/target/codegraph-java.jar" ]; then
+ phase "built java jar reproduces the snapshot" \
+ sh -c "cd '$ROOT' && out=\$(mktemp) && ./bin/codegraph-java --src fixtures/java/src --out \"\$out\" --progress none >/dev/null 2>&1 && head -1 \"\$out\" | grep -qE '\"root\":\"[^\"]*fixtures[^\"]*java[^\"]*src\"' && sed '1s|\"root\":\"[^\"]*\"|\"root\":\"fixtures/java/src\"|' \"\$out\" | cmp - fixtures/java/expected/model.jsonl; rc=\$?; rm -f \"\$out\"; exit \$rc"
+ else
+ warn "no jar at $JAVA_DIR/target/codegraph-java.jar — run ./build.sh --java for the smoke test"
+ fi
+
# The same acceptance check the C# binary gets, and for the same reason: a
# native image is a different runtime, not a repackaging. It has no java.home,
# so ECJ finds no platform library and falls back to the ct.sym reference the
@@ -263,7 +273,7 @@ if wants_csharp && [ "$SKIP_CSHARP" = "no" ]; then
[ -x "$bin" ] || bin="$bin.exe"
if [ -x "$bin" ]; then
phase "published csharp binary reproduces the snapshot" \
- sh -c "cd '$ROOT' && out=\$(mktemp) && '$bin' --src fixtures/csharp/src --out \"\$out\" --progress none >/dev/null 2>&1 && cmp \"\$out\" fixtures/csharp/expected/model.jsonl; rc=\$?; rm -f \"\$out\"; exit \$rc"
+ sh -c "cd '$ROOT' && out=\$(mktemp) && ./bin/codegraph-csharp --src fixtures/csharp/src --out \"\$out\" --progress none >/dev/null 2>&1 && cmp \"\$out\" fixtures/csharp/expected/model.jsonl; rc=\$?; rm -f \"\$out\"; exit \$rc"
else
warn "no published binary at $CSHARP_DIR/dist/$(host_rid) — run ./build.sh --csharp for the smoke test"
fi
diff --git a/website/content/docs/how-to/install.md b/website/content/docs/how-to/install.md
index e908864..96ae117 100644
--- a/website/content/docs/how-to/install.md
+++ b/website/content/docs/how-to/install.md
@@ -152,16 +152,17 @@ for Elixir, Rust 1.94+ and libclang for Ruby, Rust 1.94+ for the metrics pass.
```bash
export JAVA_HOME="$HOME/.sdkman/candidates/java/25.0.4-tem"
export PATH="$JAVA_HOME/bin:$PATH"
- (cd extractors/java && ./mvnw -B package) # → extractors/java/target/codegraph-java.jar; `mvn` is not needed
- ./build.sh --csharp # → extractors/csharp/dist//codegraph-csharp
+ (cd extractors/java && ./mvnw -B package) # → bin/codegraph-java (the jar); `mvn` is not needed
+ ./build.sh --csharp # → bin/codegraph-csharp
./build.sh --elixir # → bin/codegraph-elixir
./build.sh --ruby # → bin/codegraph-ruby (Rust 1.94+ and libclang to build)
./build.sh --metrics # → bin/codegraph-metrics (Rust 1.94+ to build)
```
- `bin/codegraph-typescript` is already built by step 1. The jar runs as
- `java -jar extractors/java/target/codegraph-java.jar`; every page of this
- documentation writes `codegraph-java`, and the two take the same flags.
+ `bin/codegraph-typescript` is already built by step 1. `bin/codegraph-java`
+ runs the jar on the first Java 17+ in `JAVA_HOME`, on `PATH` or under
+ `~/.sdkman`; every page of this documentation writes `codegraph-java`, and
+ the two take the same flags.
## Using it at work
diff --git a/website/content/docs/reference/csharp-extractor.md b/website/content/docs/reference/csharp-extractor.md
index d5c86c4..dcdea1c 100644
--- a/website/content/docs/reference/csharp-extractor.md
+++ b/website/content/docs/reference/csharp-extractor.md
@@ -14,6 +14,7 @@ codegraph-csharp --src --out model.jsonl
./build.sh --csharp # publish the host's binary → extractors/csharp/dist//codegraph-csharp
./build.sh --csharp --publish-all # linux-x64, linux-arm64, osx-x64, osx-arm64, win-x64 — all from one host
extractors/csharp/dist/osx-arm64/codegraph-csharp --src --out model.jsonl
+./bin/codegraph-csharp --src --out model.jsonl # the same, for the host's platform
```
The binary needs nothing installed; building it needs the .NET 10 SDK (see `extractors/csharp/README.md`).
diff --git a/website/content/docs/reference/java-extractor.md b/website/content/docs/reference/java-extractor.md
index bbf84d4..ec2a096 100644
--- a/website/content/docs/reference/java-extractor.md
+++ b/website/content/docs/reference/java-extractor.md
@@ -22,6 +22,8 @@ cd extractors/java && ./mvnw -B package # `mvn` is not installed; use the wr
java -jar target/codegraph-java.jar --src --out model.jsonl
```
+From the repo root, `./bin/codegraph-java` runs the same jar without the exports: it takes the first Java 17+ in `JAVA_HOME`, on `PATH` or under `~/.sdkman`.
+
## Synopsis
```