Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
ce71e5f
ci(release): bundle the pinned codeanalyzer-java jar, not releases/la…
rahlk Sep 6, 2026
b9a1da9
docs: spec leg 3 — Java to Python's leg-1.6 maturity (#338)
rahlk Sep 6, 2026
22ab47c
docs(java): implementation plan for leg 3a — Java on schema v2
rahlk Sep 6, 2026
d765aa3
test(java): codeanalyzer-java 3.0.1 fixtures for daytrader8 at L1 and L4
rahlk Sep 6, 2026
d9c8768
test(java): point the Java suites at the 3.0.1 fixtures, retire the 2…
rahlk Sep 6, 2026
f8dbe0a
test(java): store the 3.0.1 fixtures gzip-compressed
rahlk Sep 6, 2026
9542a9f
feat(java): v2 models mirroring codeanalyzer-java 3.0.1, v1 field nam…
rahlk Sep 6, 2026
3129af5
fix(java): address Task 1 review
rahlk Sep 6, 2026
fe11692
feat(java): inherit the generic backend ABC, drive codeanalyzer-java …
rahlk Sep 6, 2026
758e127
feat(java): Neo4j backend on the codeanalyzer-java 3.0.1 vocabulary, …
rahlk Sep 6, 2026
b03354e
fix(java): qualify local-class names by their declaring callable, hon…
rahlk Sep 6, 2026
781684c
docs(java): record the schema-v2 migration, the 3.0.1 floor and the s…
rahlk Sep 6, 2026
cb9001e
feat(java): drive the analyzer through the codeanalyzer-java wheel an…
rahlk Sep 6, 2026
ce141f4
fix(java): report the analyzer's own L3/L4 degradation instead of ret…
rahlk Sep 6, 2026
f800805
fix(java): answer get_test_methods off the graph, repair the changelo…
rahlk Sep 6, 2026
49b4199
docs: one release-notes block for the three legs
rahlk Sep 7, 2026
155887f
docs(java): implementation plan for leg 3b — the query surface
rahlk Sep 6, 2026
e394f81
feat(java): the addressing vocabulary — package-derived dotted names …
rahlk Sep 7, 2026
67a55a5
docs(java): record how the shared resolver reconciles with leg 2.5b
rahlk Sep 7, 2026
345b673
fix(java): "supplied none" is not "derive it", and an ambiguity advic…
rahlk Sep 7, 2026
be32b7f
docs(java): record that a4's signature spellings are a pruning artifact
rahlk Sep 7, 2026
af7efd4
feat(java): addressing — locate, resolve, source, describe
rahlk Sep 7, 2026
655c596
feat(java): per-callable graphs, slices, reachability and flow predic…
rahlk Sep 7, 2026
275aec1
feat(java): entrypoints, artifacts and leaf accessors; record the que…
rahlk Sep 7, 2026
368452a
fix(java): a located call site says what it resolves to over Neo4j too
rahlk Sep 7, 2026
8493b6c
test(java): the fake Cypher engine binds a WHERE to the OPTIONAL MATC…
rahlk Sep 7, 2026
3389637
fix(java): J-6's implicit callables refuse a graph and a value, not a…
rahlk Sep 7, 2026
16bbbdf
fix(java): scope the interior of the two variable-length SDG walks
rahlk Sep 7, 2026
d654e0b
fix(java): a formal_in ref is answered from the parameter list, not t…
rahlk Sep 7, 2026
982bd57
fix(java): a graph that homed no external answers {}, not 'never asked'
rahlk Sep 7, 2026
6482232
fix(java): the port probe counts only an edge whose target is a node
rahlk Sep 7, 2026
5de1df3
fix(commons): a bodyless locate() result is refused in its own words
rahlk Sep 7, 2026
eddae40
fix(commons): 'Narrow it with by naming ...' is not a sentence
rahlk Sep 7, 2026
369a3e3
fix(commons): reaches(x, x) answers the cycle question it is advertis…
rahlk Sep 7, 2026
92476bd
docs(java): a signature's parameter tail is not erased, in the models…
rahlk Sep 7, 2026
d69fc28
docs: name the real helper and the real body-node count
rahlk Sep 7, 2026
eb954f7
docs(java): get_method_bodies returns 1,117, and omits only implicit …
rahlk Sep 7, 2026
6fd1fcd
docs(java): the a4 caveat's evidence was measured with the walk J-8 r…
rahlk Sep 7, 2026
eab53f7
docs(java): three "arrives in 3b" forward references, shipped inside 3b
rahlk Sep 7, 2026
19d7822
docs(commons): the ambiguity advice changed for all three languages
rahlk Sep 7, 2026
7eaff2f
docs(java): say that get_config_readers is [] for every key
rahlk Sep 7, 2026
5eff68b
docs(java): three statements to four is an extra round trip
rahlk Sep 7, 2026
d07ad8d
docs(java): J-3 erratum — the tail separates overloads, not types
rahlk Sep 7, 2026
90f9a4d
docs(java): two facts this leg established and did not write down
rahlk Sep 7, 2026
aac9ea3
docs: cite TypeScript's own measurement for the port-lattice claim
rahlk Sep 7, 2026
8e04705
docs: rewrap the paragraph the raiser note ran into
rahlk Sep 7, 2026
72aa9d2
docs(java): name the label a JBodyNode actually carries beside its own
rahlk Sep 7, 2026
9885042
test(python): compare the same analysis, then tolerate only what the …
rahlk Sep 7, 2026
8bb93ed
Merge remote-tracking branch 'origin/release/2.0' into feat/issue-311…
rahlk Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 4 additions & 47 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ jobs:
- name: Check out code
uses: actions/checkout@v4

# Load-bearing for **Maven**, not for the analyzer. The analyzer jar and the JVM it runs
# on come from the `codeanalyzer-java` wheel (#339), but codeanalyzer-java's own auto-build
# shells out to `mvn`, which needs a JDK on PATH; without one the Java e2e degrades to a
# declared-only call graph and CI goes green on a partial answer. Do not delete this step.
- name: Set up GraalVM CE Java 11
uses: graalvm/setup-graalvm@v1
with:
Expand Down Expand Up @@ -65,56 +69,9 @@ jobs:
git push --delete origin ${GITHUB_REF#refs/tags/}
exit 1

- name: Inject the pinned Code Analyzer JAR
run: |
# The pin in pyproject.toml is authoritative: fetch exactly that release's
# versioned jar (codeanalyzer-<pin>.jar). A pin without a matching asset fails
# the step, and the gate below deletes the tag - never fall back to latest.
PIN=$(grep -E '^codeanalyzer-java\s*=' pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')
test -n "$PIN"
CODE_ANALYZER_URL=$(curl -sf "https://api.github.com/repos/codellm-devkit/codeanalyzer-java/releases/tags/v${PIN}" | jq -er --arg n "codeanalyzer-${PIN}.jar" '.assets[] | select(.name == $n) | .browser_download_url')
echo "Downloading: $CODE_ANALYZER_URL"
wget -q "$CODE_ANALYZER_URL"
mkdir -p ${{ github.workspace }}/cldk/analysis/java/codeanalyzer/jar/
rm -f ${{ github.workspace }}/cldk/analysis/java/codeanalyzer/jar/codeanalyzer-*.jar
mv "codeanalyzer-${PIN}.jar" ${{ github.workspace }}/cldk/analysis/java/codeanalyzer/jar/

- name: Build Package
run: uv build

- name: Verify the codeanalyzer JAR is bundled
# Guard against the hatchling/.gitignore regression (issue #284): a jarless wheel
# installs fine but fails at runtime with "codeanalyzer jar not found". Fail the
# release here rather than publish a broken artifact to PyPI.
#
# The listing is captured before grepping: piping `tar tzf` (which decompresses the
# whole 32MB sdist) straight into `grep -q` lets grep close the pipe on first match,
# SIGPIPE-killing tar and — under `pipefail` — reporting a false "missing JAR".
run: |
set -euo pipefail
PIN=$(grep -E '^codeanalyzer-java\s*=' pyproject.toml | sed -E 's/.*"([^"]+)".*/\1/')
jar_re='codeanalyzer/jar/codeanalyzer-[0-9][^/]*\.jar$'
fail=0
for f in dist/*.whl dist/*.tar.gz; do
case "$f" in
*.whl) listing=$(unzip -l "$f") ;;
*.tar.gz) listing=$(tar tzf "$f") ;;
esac
# Exactly one jar, and it is the pinned one (issue #336: a stray second jar
# from a mismatched download must fail the release, not ride along).
jars=$(grep -oE "$jar_re" <<<"$listing" || true)
if [ "$(wc -l <<<"$jars" | tr -d ' ')" = "1" ] && [ -n "$jars" ] && grep -qF "codeanalyzer-${PIN}.jar" <<<"$jars"; then
echo " ✓ $f ($jars)"
else
echo "::error::$f must bundle exactly codeanalyzer-${PIN}.jar; found: ${jars:-none}"
fail=1
fi
done
if [ "$fail" -ne 0 ]; then
echo "Refusing to publish a jarless release."; exit 1
fi
echo "codeanalyzer JAR present in wheel and sdist ✓"

- name: Extract release notes from CHANGELOG.md
id: notes
# Source the release body from the hand-written CHANGELOG.md section for this tag —
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
.mtj.tmp/

# Package Files #
*.jar
*.war
*.nar
*.ear
Expand Down
289 changes: 127 additions & 162 deletions CHANGELOG.md

Large diffs are not rendered by default.

19 changes: 18 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,27 @@ an optional read-only Neo4j backend — selected by the *type* of the `backend=`

| Language | Entry point | Local backend | Neo4j backend | Models |
|----------|-------------|---------------|---------------|--------|
| Java | `CLDK.java(...)` | `JCodeanalyzer` (bundled JAR, subprocess) | `JNeo4jBackend` | `cldk/models/java/` |
| Java | `CLDK.java(...)` (needs the `cldk[java]` extra) | `JCodeanalyzer` (the `codeanalyzer-java` 3.0.2 wheel's jar on its bundled JVM, subprocess, `-a 1..4` — no jar in this repo, no JDK download) | `JNeo4jBackend` (3.0.1 graph, probed at attach) | `cldk/models/java/` (schema v2 mirror) + `projections.py` |
| Python | `CLDK.python(...)` | `PyCodeanalyzer` (in-process `codeanalyzer-python`) | `PyNeo4jBackend` | re-exported from `codeanalyzer-python` |
| TypeScript (+ JavaScript modules) | `CLDK.typescript(...)` | `TSCodeanalyzer` (`codeanalyzer-typescript` 1.3.0 binary from the wheel, subprocess; `-a 1..4`, but `--emit neo4j` takes no `-a` and is always full depth) | `TSNeo4jBackend` (graphs emitted by ≥ 1.3.0; older refused at attach) | `cldk/models/typescript/` (schema v2 mirror) |

**Java, since leg 3a (#310):** the models are an `extra="forbid"` mirror of canonical schema v2, so
a 1.x `analysis.json` (and a pre-3.0.1 Neo4j graph) is refused, not parsed; `get_call_graph()` keys
nodes by the string `"<type fqn>.<signature>"`; the `source_code` single-file mode is gone; the CRUD
accessors raise (codeanalyzer-java#187).

**Java, since leg 3b (#311):** the leg-1.5/1.6 query surface, 38 accessors with `PythonAnalysis`'s
signatures — addressing, per-callable graphs and dataflow, entrypoints and the bulk projections, the
artifact six, and the J-7 leaf accessors (`get_interfaces`/`get_enums`/`get_enum_members`/
`get_records`, names shared with TypeScript). Both backends answer identically, including the miss
paths; the policy lives once on `JavaAnalysisBackend` because `JNeo4jBackend` rebuilds the canonical
`JApplication` and answers from it. Four things Java says rather than answering, each measured:
`slice_forward` / `paths_between` / `flows_to_call` / `flows_to_argument` raise (the analyzer's L4
port lattice carries no dependence edge, codeanalyzer-java#227); `get_entrypoint_coverage` reports
`entrypoint_report_unavailable` (Java projects no report, J-4); `get_external_symbols` raises off a
local run (`--external-calls` is opt-in and `--emit neo4j` forces it); the CRUD accessors still
raise. `docs/agent-api-reference.md` has the full lossiness list.

The legacy `CLDK(language="<lang>").analysis(...)` entry still works as a compat shim. Adding a
language means a new factory method + facade + backend ABC/impl(s) + models + tests — **update this
table in the same change**.
Expand Down
15 changes: 2 additions & 13 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -39,20 +39,9 @@ clean: ## Cleans up from previous compiles
$(info Cleaning up compile artifacts...)
rm -fr dist

.PHONY: refresh
refresh: ## Refresh code analyzer
$(info Refreshing CodeAnalyzer...)
wget $(curl -s https://api.github.com/repos/IBM/codenet-minerva-code-analyzer/releases/latest | grep "browser_download_url" | grep codeanalyzer.jar | cut -d '"' -f 4)
mv codeanalyzer.jar cldk/analysis/java/codeanalyzer/jar/codeanalyzer.jar

.PHONY: build
build: ## Builds a new Python wheel
$(info Building artifacts...)

# Inject the latest Code Analyzer JAR
wget -q $(shell curl -s https://api.github.com/repos/IBM/codenet-minerva-code-analyzer/releases/latest | jq -r '.assets[] | .browser_download_url')
mkdir -p cldk/analysis/java/codeanalyzer/jar/
mv codeanalyzer-*.jar cldk/analysis/java/codeanalyzer/jar/

# Build the package
# No jar is fetched or injected: the Java analyzer is the `codeanalyzer-java` wheel
# (the `java` extra), a normal locked dependency. Nothing is downloaded at build time.
uv build
63 changes: 61 additions & 2 deletions cldk/analysis/commons/graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
import networkx as nx

from cldk.analysis.commons.bounds import check_selector, reject_bare_string
from cldk.analysis.commons.results import FlowPath, PathHop, SliceNode
from cldk.analysis.commons.results import FlowPath, LocateResult, PathHop, SliceNode


def bounded_subgraph(graph: nx.DiGraph, roots: List[str], depth: int | None, declared: Iterable[str]) -> nx.DiGraph:
Expand Down Expand Up @@ -248,6 +248,59 @@ def walk(node: str, walked: list) -> None:
return out


def call_reaches(graph: "nx.DiGraph", a: str, b: str, depth: "int | None") -> bool:
"""Whether a call path of **at least one hop** runs from ``a`` to ``b``, within ``depth`` hops.

One function for the three in-memory backends because ``reaches(x, x)`` is the case they all
got wrong in the same way, and because the advice that points at it is shared too
(:func:`~cldk.analysis.commons.bounds.check_distinct_endpoints` tells a caller to "ask
``reaches(X, X)`` whether a cycle exists").

``nx.descendants`` and ``ego_graph(...) - {a}`` both **exclude the source**, even when the
source has a self-loop or sits on a cycle — that is what "descendants" means — so asking them
``b in reachable`` for ``b == a`` answered ``False`` for every input. Both Neo4j backends
already answer the cycle question, because their quantified pattern is ``{1,depth}`` and lands
back on the source like any other node, so this was also a backend divergence and not only a
wrong docstring.

The self-question is asked of the predecessors instead: ``a`` is on a cycle exactly when
something that reaches ``a`` is reachable *from* ``a`` — including ``a`` itself, which is the
direct self-loop. Bounded, the two halves have to add up to ``depth``, so the reachable half is
one hop shorter.
"""
if a not in graph or b not in graph:
return False
reachable = nx.descendants(graph, a) if depth is None else set(nx.ego_graph(graph, a, radius=depth).nodes) - {a}
if a != b:
return b in reachable
inner = {a} | reachable if depth is None else set(nx.ego_graph(graph, a, radius=depth - 1).nodes)
return any(predecessor in inner for predecessor in graph.predecessors(a))


def _no_body_node(found: LocateResult) -> str:
"""Why a :class:`~cldk.analysis.commons.results.LocateResult` has no ``node_id``, in the
caller's own vocabulary.

Three distinguishable reasons and three sentences, because they call for different next steps: a
position inside a callable but on no emitted vertex (a declaration line, a blank line, a comment
-- the common case), a position at module scope, and a file the analysis does not cover. Each
ends with something that actually runs on the value the caller already has (E8: advice must be
followable), and none of them spells a ``can://`` id (E6)."""
where = f"{found.module.path}:{found.span.start[0]}"
if found.callable is not None:
return (
f"the locate() result for {where} landed inside {found.callable.signature} but on no statement, call or branch "
"the analyzer emitted, so it carries no ref for describe() to look up. Its enclosing callable does: pass "
f"resolve_callable({found.callable.signature!r}) instead, or read the text off the result's own .source."
)
reasons = ", ".join(d.code for d in found.diagnostics) or "no enclosing callable"
return (
f"the locate() result for {where} is not inside any callable ({reasons}), so it carries no ref for describe() "
"to look up. Nothing below the module is addressable at that position; locate() a line inside a callable, or name "
"a callable with resolve_callable()."
)


def as_slice_node(node: object) -> SliceNode:
"""The :class:`~cldk.analysis.commons.results.SliceNode` for anything carrying an address.

Expand All @@ -263,12 +316,18 @@ def as_slice_node(node: object) -> SliceNode:

Raises:
TypeError: ``node`` carries neither a ``ref`` nor a ``node_id``, so there is nothing to
look up. Guessing an address from a file and a line is what ``locate`` is for.
look up. Guessing an address from a file and a line is what ``locate`` is for. A
:class:`~cldk.analysis.commons.results.LocateResult` that landed on **no body node** is
the common way to arrive here — measured on daytrader8, 187 of 300 random in-callable
positions have ``body is None`` — so it is refused in its own words: naming the type
among the accepted ones and then refusing it reads as a bug in the accessor.
"""
if isinstance(node, SliceNode):
return node
ref = getattr(node, "node_id", None)
if ref is None:
if isinstance(node, LocateResult):
raise TypeError(_no_body_node(node))
raise TypeError(f"describe() needs something carrying a ref (a SliceNode, a path hop endpoint, a locate() result); got {type(node).__name__}")
module, callable_ref, body = node.module, node.callable, getattr(node, "body", None)
return SliceNode(
Expand Down
Loading