diff --git a/CHANGELOG.md b/CHANGELOG.md index c5f5db5..65736ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,7 +103,20 @@ surface** (3b). Design records: `docs/design/specs/2026-09-06-leg-2.5-typescript `ddg` edges whose endpoint was never emitted as a body node (codeanalyzer-java#228), so `get_ddg()` over `analysis.json` and over Neo4j now report the same edges — 10,430 on daytrader8, set for set, against a 5,434/5,347 split before. -- **Pins:** `codeanalyzer-java` 2.4.1 → 3.0.3, `codeanalyzer-typescript` 0.4.3 → 1.5.0. +- **Java's code-to-config layer and entrypoint report now answer.** codeanalyzer-java 3.1.0 adds both, so + `get_config_uses()`, `get_config_readers(key)` and `get_unresolved_config_reads()` return real edges instead + of `[]`, and `get_entrypoint_coverage()` returns the pass's own record instead of + `entrypoint_report_unavailable`. **The two config tiers are surfaced, not flattened:** `prov == ["literal"]` + is a string literal at the call site (codeanalyzer-java#233), `"dataflow"` is a key reached over the L3 DDG + or the L4 call graph (#237) — a derived answer, weaker evidence, and it widens with the analysis level. On a + `PyConfigRead` the list is every tier *attempted*, so `["literal", "dataflow"]` means the dataflow tier ran + and still could not name the key. Measured on daytrader8: 13 resolved uses (all `["literal"]`) and 16 + unresolved reads — `["literal"]` at level 1, `["literal", "dataflow"]` at level 4; the graph collapses those + 16 into 8 edges, since `J_READS_CONFIG_UNRESOLVED` is discriminated by `(key, reason)` and carries no site. + **To get this, re-analyse (or re-emit your Neo4j graph) with codeanalyzer-java 3.1.0**; an older analysis + keeps refusing, and the probe is measured from the data rather than from a version string — see the known + limitation below for what it measures and why it cannot be the config layer's own absence. +- **Pins:** `codeanalyzer-java` 2.4.1 → 3.1.0, `codeanalyzer-typescript` 0.4.3 → 1.5.0. - **The Java analyzer ships as a wheel, not a jar in this repo.** The 35 MB checked-in jar, the Temurin download in `_jdk.py`, and the release workflow's jar injection are gone; no `JAVA_HOME` is read or set, and no JDK is downloaded. The published wheel drops from about 35 MB to 320 KB. @@ -191,16 +204,35 @@ surface** (3b). Design records: `docs/design/specs/2026-09-06-leg-2.5-typescript site from the one statement containing it, not from the reaching definition of that argument, so on a reached call site every argument answers `True` together. Paths are complete; per-argument precision is not what the analyzer promises. -- **Java has no entrypoint report**, so `get_entrypoint_coverage()` returns `entrypoint_report_unavailable` - rather than fabricated coverage. `get_entrypoints()` and `get_entrypoint_classes()` carry the real marks. +- **Java's entrypoint report and code-to-config layer need codeanalyzer-java 3.1.0.** On an older analysis — + a cached `analysis.json` is reused whatever wrote it, and a 3.0.x Neo4j graph is still attachable, the floor + being 3.0.1 — `get_entrypoint_coverage()` returns `entrypoint_report_unavailable` rather than fabricated + coverage, and `get_config_uses()` / `get_config_readers()` / `get_unresolved_config_reads()` raise rather + than answer `[]`. The probe is one fact measured from the data: 3.1.0 writes an entrypoint report on every + run and 3.0.x writes none of the three overlays. It deliberately is **not** the config layer's own absence, + which is ambiguous — the analyzer writes `config_uses` / `config_reads_unresolved` only when non-empty, and + the graph declares `J_USES_CONFIG` / `J_READS_CONFIG_UNRESOLVED` only once an edge exists, so a clean 3.1.0 + analysis of a project that reads no configuration carries neither and must still answer. `get_entrypoints()` + and `get_entrypoint_classes()` carry the real marks at every generation, as does `get_config_keys()`. - **Java's `get_external_symbols()` answers over Neo4j and raises locally**: the analyzer homes out-of-project call targets only under `--external-calls`, which `--emit neo4j` forces and a local run does not. - The Java graph carries a `switch` body-node kind, which is outside `SliceNode.KINDS` (that vocabulary is codeanalyzer-python's, and Python has no switch statement). It is reported as the analyzer spells it. -- **Java's `get_config_readers(key)`, `get_config_uses()` and `get_unresolved_config_reads()` are `[]` for - every input**: the Java wire carries no code-to-config edges and no config-read detector, so there is - nothing to resolve to a reading callable. The empty list means the analyzer emits no such edge, not that - no code reads the key. `get_config_keys()` is the configuration accessor Java really answers. +- **Java's `get_ddg()` over Neo4j carries 276 `points-to` edges (on daytrader8) that the same + analyzer's `analysis.json` does not, and that is a difference of what each source was asked.** + codeanalyzer-java 3.1.0 makes the level-4 `points-to` layer depend on `--external-calls`, which + `--emit neo4j` forces on and which the SDK's local run does not pass; 3.0.3 produced the same + 10,430 edges either way. Measured on daytrader8, same tree, four runs: 3.0.3 `-a 4` → 10,430 + (1,134 `points-to`); 3.1.0 `-a 4` → 10,154 (858); 3.1.0 `-a 4 --external-calls` → 10,430, set for + set identical to the graph; 3.1.0 `--emit neo4j` → 10,430. The graph is a strict superset and the + payload has nothing the graph lacks, so `slice_forward` and the other forward walks can reach + further over Neo4j. Reported upstream: the flag is documented as controlling only whether + out-of-project call targets are homed as `external_symbols`. +- **The Java Neo4j projection still carries no comment nodes.** codeanalyzer-java 3.1.0 closes + codeanalyzer-java#231's config-read and entrypoint-report halves and not its comment half: `:JComment` is a + declared label with **zero** nodes on a graph emitted by it, and only a declaration's `docstring` reaches + the graph. So `remove_all_comments` keeps raising, and the file-keyed comment accessors keep refusing over + Neo4j while the javadoc-only ones narrow, exactly as before. ## [v2.0.0-rc.2] - 2026-09-06 Python legs 1, 1.5 and 1.6 of the CLDK 2.0 agent-facing query facade (see diff --git a/CLAUDE.md b/CLAUDE.md index 495a700..587d7c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,7 +10,7 @@ an optional read-only Neo4j backend — selected by the *type* of the `backend=` | Language | Entry point | Local backend | Neo4j backend | Models | |----------|-------------|---------------|---------------|--------| -| Java | `CLDK.java(...)` (needs the `cldk[java]` extra) | `JCodeanalyzer` (the `codeanalyzer-java` 3.0.3 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` | +| Java | `CLDK.java(...)` (needs the `cldk[java]` extra) | `JCodeanalyzer` (the `codeanalyzer-java` 3.1.0 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) | @@ -27,9 +27,11 @@ paths; the policy lives once on `JavaAnalysisBackend` because `JNeo4jBackend` re `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. +`entrypoint_report_unavailable` and the three config-read accessors raise on an analysis older than +codeanalyzer-java 3.1.0, which is the release that added both overlays (the probe is the entrypoint +report's presence, measured from the data, never a version string); `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="").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 diff --git a/cldk/analysis/java/backend.py b/cldk/analysis/java/backend.py index 18413fe..8b41975 100644 --- a/cldk/analysis/java/backend.py +++ b/cldk/analysis/java/backend.py @@ -85,6 +85,7 @@ ) from cldk.analysis.commons.treesitter import TreesitterJava from cldk.analysis.commons.treesitter.models import Captures +from cldk.models.python import PyConfigRead, PyConfigUseEdge from cldk.models.java.models import ( JApplication, JBodyNode, @@ -95,6 +96,8 @@ JCfgEdge, JComment, JCompilationUnit, + JConfigRead, + JConfigUse, JCRUDOperation, JDdgEdge, JEnumConstant, @@ -113,15 +116,39 @@ #: J-4: the CRUD accessors keep their names and raise this on schema v2, on both backends. CRUD_UNAVAILABLE = "CRUD operations are not emitted by codeanalyzer-java 3.0.1 or newer (schema v2); tracked upstream as codeanalyzer-java#187" -#: J-4: what ``get_entrypoint_coverage`` says instead of counting booleans and calling it coverage. -#: Java is the one of the three languages whose analyzer projects **no** entrypoint report -- -#: measured on the reference graph, where the ``:JApplication`` anchor carries four properties and -#: none of them is a report -- so the accessor reports that, through the shared model's own -#: ``entrypoint_report_unavailable`` vocabulary. +#: J-4: what ``get_entrypoint_coverage`` says when this analysis carries no report, instead of +#: counting booleans and calling it coverage. codeanalyzer-java 3.1.0 (codeanalyzer-java#235) emits +#: one -- ``JApplication.entrypoint_report`` on the wire, ``:JApplication.entrypoint_report_json`` +#: on the graph -- and the accessor reads it. Before 3.1.0 there was none at all, and a 3.0.x +#: payload or graph is still servable (the Neo4j floor is 3.0.1, and a cached ``analysis.json`` +#: computed at a sufficient ``max_level`` is reused whatever wrote it), so the refusal stays and is +#: decided by :data:`the overlay probe `, never by a version string. ENTRYPOINT_REPORT_UNAVAILABLE = ( - "codeanalyzer-java 3.0.1 emits no entrypoint report: analysis.json carries no such key and the :JApplication anchor carries only " - "name/schema_version/analyzer_name/analyzer_version, so the entrypoint pass's coverage (frameworks_detected/rulesets/unresolved/errors) " - "cannot be reported. get_entrypoints() and get_entrypoint_classes() still carry the analyzer's own per-declaration marks." + "this analysis carries no entrypoint report: codeanalyzer-java emits one only from 3.1.0 onwards, so the entrypoint pass's coverage " + "(frameworks_detected/rulesets/unresolved/errors) cannot be reported for an older payload or an older graph. Re-analyse, or re-emit " + "your Neo4j graph, with codeanalyzer-java 3.1.0 or newer. get_entrypoints() and get_entrypoint_classes() still carry the analyzer's " + "own per-declaration marks." +) + +#: Why the three config-read accessors refuse, **when they do**. codeanalyzer-java 3.1.0 added the +#: code-to-config layer (codeanalyzer-java#233 literal tier, #237 dataflow tier); 3.0.x has no +#: detector at all, and answering ``[]`` off a 3.0.x analysis would say "this application reads no +#: configuration" when the truth is "nothing looked" -- the ambiguous empty D7 forbids. +#: +#: **The probe cannot be the config layer's own absence**, and that is measured, not assumed: the +#: analyzer writes ``config_uses``/``config_reads_unresolved`` only when non-empty (the committed +#: ``v2/a4`` fixture is a level-4 3.1.0 run whose pruned tree reads no configuration, and it +#: carries neither key), and the graph declares ``J_USES_CONFIG``/``J_READS_CONFIG_UNRESOLVED`` as +#: relationship types only once an edge of that type exists. Both would refuse a clean 3.1.0 +#: analysis that genuinely reads nothing. The witness that *is* unconditional is the sibling +#: overlay from the same release: 3.1.0 writes an entrypoint report on every run and 3.0.x writes +#: none of the three, so all three overlays are probed as the one thing they are -- one analyzer +#: generation's output -- and never as a version literal. +CONFIG_OVERLAY_UNAVAILABLE = ( + "the code-to-config layer cannot be answered for application {app!r}: this analysis carries none of codeanalyzer-java 3.1.0's " + "application-scope overlays (config_uses, config_reads_unresolved, entrypoint_report), so an empty answer here would say " + "'this application reads no configuration' where the truth is that nothing looked. Re-analyse, or re-emit your Neo4j graph, with " + "codeanalyzer-java 3.1.0 or newer. get_config_keys() is unaffected: the keys a config artifact declares are read at every generation." ) #: What ``get_external_symbols`` raises on a payload whose run never homed out-of-project call @@ -930,11 +957,13 @@ def has_resolution_edges(self) -> bool: # ``:JExternal`` set, which :meth:`JNeo4jBackend._external_rows` now projects into # ``JApplication.external_symbols``. # - # THE ONE HONEST REFUSAL IS ``get_entrypoint_coverage``. Java projects no entrypoint report at - # all -- the ``:JApplication`` anchor carries only ``name``/``schema_version``/ - # ``analyzer_name``/``analyzer_version``, and ``analysis.json`` has no such key, unlike - # codeanalyzer-python 1.4.1 and codeanalyzer-typescript 1.3.0 which both project one. A count of - # syntactically-marked callables is not a coverage report, so it is not dressed up as one (J-4). + # ``get_entrypoint_coverage`` READS THE REPORT FROM 3.1.0 ON, AND REFUSES BELOW IT. Java was + # the one of the three languages whose analyzer projected no entrypoint report; 3.1.0 + # (codeanalyzer-java#235) projects one, on the wire as ``JApplication.entrypoint_report`` and on + # the graph as ``:JApplication.entrypoint_report_json``, so the accessor reads the same four + # fields codeanalyzer-python and codeanalyzer-typescript already carry. A 3.0.x analysis carries + # none, and a count of syntactically-marked callables is not a coverage report, so it is not + # dressed up as one (J-4): that case reports :data:`ENTRYPOINT_REPORT_UNAVAILABLE`. # ===================================================================================== def get_callables_overview(self) -> List[JCallableOverview]: """A lightweight projection of every callable in the application, without the full @@ -1015,22 +1044,33 @@ def get_entrypoint_classes(self) -> List[JClassOverview]: return [JClassOverview.of(t, path=self._file_of[name], qualified_name=name) for name, t in self._types.items() if t.is_entrypoint_class] def get_entrypoint_coverage(self) -> EntrypointCoverage: - """**Reports that there is no report** (J-4), identically on both backends. - - codeanalyzer-java 3.0.1 emits the entrypoint *marks* and nothing about the pass that made - them: ``analysis.json`` carries no report key, and the ``:JApplication`` anchor carries only - ``name``/``schema_version``/``analyzer_name``/``analyzer_version`` — measured on the - reference graph, and unlike codeanalyzer-python 1.4.1 and codeanalyzer-typescript 1.3.0, - which both project one. So this answers with a ``diagnostics``-only - :class:`~cldk.analysis.commons.results.EntrypointCoverage`, the same "say so honestly" - shape a Python graph without the report uses. - - It deliberately does **not** synthesise a report out of the ``is_entrypoint`` booleans: a - count of syntactically-marked callables is not a coverage record, and presenting one as if - it were is the ambiguous empty D7 forbids wearing a hat. The day the analyzer emits a - report, this reads it and the ``diagnostics`` go away. + """The entrypoint pass's own coverage and failure record, identically on both backends. + + codeanalyzer-java 3.1.0 (codeanalyzer-java#235) emits it — ``JApplication.entrypoint_report`` + off ``analysis.json``, ``:JApplication.entrypoint_report_json`` off the graph, which + :class:`~cldk.analysis.java.neo4j.neo4j_backend.JNeo4jBackend` parses back into the same + model, so the two backends read one object and there is no lossiness between them. The four + fields are the ones codeanalyzer-python and codeanalyzer-typescript already carry + (``frameworks_detected``, ``rulesets``, ``unresolved``, ``errors``); on daytrader8 that is + three frameworks detected of five rulesets run, nothing unresolved and no errors. + + A 3.0.x analysis carries no report at all, and both a cached ``analysis.json`` and an + attached 3.0.x graph are still servable, so that case answers with a ``diagnostics``-only + :class:`~cldk.analysis.commons.results.EntrypointCoverage` + (:data:`ENTRYPOINT_REPORT_UNAVAILABLE`) rather than fabricating empty-but-clean-looking + fields — the same "say so honestly" shape a Python graph without the report uses. It still + never synthesises a report out of the ``is_entrypoint`` booleans: a count of + syntactically-marked callables is not a coverage record (J-4). """ - return EntrypointCoverage(diagnostics=[Diagnostic(code="entrypoint_report_unavailable", message=ENTRYPOINT_REPORT_UNAVAILABLE)]) + report = self.get_application_view().entrypoint_report + if report is None: + return EntrypointCoverage(diagnostics=[Diagnostic(code="entrypoint_report_unavailable", message=ENTRYPOINT_REPORT_UNAVAILABLE)]) + return EntrypointCoverage( + frameworks_detected=list(report.frameworks_detected), + rulesets=list(report.rulesets), + unresolved=dict(report.unresolved), + errors=list(report.errors), + ) def get_callsites_for(self, signatures: List[str]) -> Dict[str, List[JCallSite]]: """Call sites of the given callables, keyed by the key that named them. @@ -1081,13 +1121,118 @@ def get_external_symbols(self) -> Dict[str, JExternalSymbol]: raise CodeanalyzerExecutionException(EXTERNAL_SYMBOLS_UNAVAILABLE) return external + # -----[ the code-to-config layer (codeanalyzer-java 3.1.0) ]----- + # + # IMPLEMENTED HERE, NOT PER BACKEND, for this section's reason: :class:`JNeo4jBackend` rebuilds + # the canonical :class:`JApplication` from the graph -- these three overlays included -- so both + # backends read one object through one implementation, and the refusal below cannot come to + # differ between them. + # + # THE TWO TIERS ARE SURFACED, NOT FLATTENED. ``prov`` on every edge names how the key was + # reached: ``"literal"`` is a string literal at the call site (codeanalyzer-java#233), + # ``"dataflow"`` is a key closed over the L3 DDG / the L4 call graph (#237) -- weaker evidence, + # and it widens with the analysis level rather than replacing the literal tier. On a + # :class:`PyConfigRead` the list is every tier *attempted* before giving up, so + # ``["literal", "dataflow"]`` there means the dataflow tier ran too and still could not name the + # key. Measured on daytrader8 at level 4: 13 uses, all ``["literal"]``; 16 unresolved reads, + # all ``["literal", "dataflow"]`` (8 edges on the graph -- see :meth:`get_unresolved_config_reads`). + def _config_overlay(self) -> Tuple[List[JConfigUse], List[JConfigRead]]: + """This analysis's config-use and unresolved-read lists, or raise if it has neither pass. + + Raises: + CodeanalyzerExecutionException: This analysis predates codeanalyzer-java 3.1.0 + (:data:`CONFIG_OVERLAY_UNAVAILABLE`, which is also where the probe's choice of + witness is argued from measurement). Both backends raise the same type with the + same message, which names the application and no ``can://`` id (E6). + """ + app = self.get_application_view() + if app.entrypoint_report is None: + raise CodeanalyzerExecutionException(CONFIG_OVERLAY_UNAVAILABLE.format(app=self._application_name)) + return list(app.config_uses or []), list(app.config_reads_unresolved or []) + + def _config_key_ids(self, key: str) -> Set[str]: + """The ids of every declared config key spelling ``key`` — a dotted key can be declared by + more than one artifact (a ``daytrader.properties`` and a ``web.xml`` naming the same one), + so this is a set and not a lookup. Compared on the id rather than on the dotted tail of the + edge's ``dst``, because the id is what the analyzer wrote on both sides of the edge.""" + return {ck.id for a in self.get_application_view().artifacts.values() for ck in a.config_keys if ck.key == key} + + def get_config_uses(self, key: str | None = None) -> List[PyConfigUseEdge]: + """Every code-to-config edge the analyzer resolved: a config read whose key closed on a + declared :class:`~cldk.models.java.models.JConfigKey`. + + Args: + key: Restrict to one dotted key, matched exactly against the declared key's own + ``key`` (never fuzzily, E8). ``None`` returns every edge. + + Returns: + The shared :class:`~cldk.models.python.PyConfigUseEdge`, as the generic ABC promises. + ``src`` is the reading call's body-node id, ``dst`` the matched key's id, and ``prov`` + the tier that resolved it — see this section's note. Empty means the pass ran and + resolved nothing. + + Raises: + CodeanalyzerExecutionException: See :meth:`_config_overlay`. + """ + uses, _ = self._config_overlay() + wanted = None if key is None else self._config_key_ids(key) + return [PyConfigUseEdge(src=u.src, dst=u.dst, prov=list(u.prov)) for u in uses if wanted is None or u.dst in wanted] + + def get_unresolved_config_reads(self) -> List[PyConfigRead]: + """Every config read whose key closed on no declared key — first class, so a read nobody can + trace stays as visible as one that resolves. + + Returns: + The shared :class:`~cldk.models.python.PyConfigRead`. ``key`` is the literal text when it + *was* a literal matching no declared key (``reason="undefined-key"``) and ``None`` when + it never closed on one (``reason="non-literal"``); ``prov`` is every tier attempted. + + **The graph is lossier than the payload here, in two named ways, and neither is a + presence/absence gap.** ``J_READS_CONFIG_UNRESOLVED`` runs application-to-ghost + (``:JApplication`` → ``:JExternal``) and never touches the body node that made the call, + so ``site`` comes back ``""`` off Neo4j; and the edge's discriminant is ``(key, reason)`` + rather than the site, so several call sites reading one key collapse into one edge — + daytrader8's 16 wire entries are 8 edges. Any unresolved read still guarantees at least + one edge, so "no rows" never means a false negative. Same shape, and the same reason, as + codeanalyzer-python's ``PY_READS_CONFIG_UNRESOLVED``. + + Raises: + CodeanalyzerExecutionException: See :meth:`_config_overlay`. + """ + _, reads = self._config_overlay() + return [PyConfigRead(site=r.site, callee=r.callee, key=r.key, reason=r.reason, prov=list(r.prov)) for r in reads] + def get_config_readers(self, key: str) -> List[JCallableOverview]: - """Always ``[]``, for the reason 3a's :meth:`get_config_uses` gives: codeanalyzer-java 3.0.1 - emits no code-to-config edges (there is no ``config_uses`` on the Java wire), so there is no - edge to resolve to a reading callable and no callable to name. Not a fact of its own: it is - empty *because* :meth:`get_config_uses` is, and the day the analyzer emits those edges this - is where they get resolved to callables.""" - return [] + """Overviews of every callable that reads one config key — :meth:`get_config_uses` resolved + from body nodes back to the callables that own them. + + Args: + key: The dotted key, matched exactly as in :meth:`get_config_uses`. + + Returns: + One overview per distinct callable, in the addressing index's order; a callable reading + the key at several call sites appears once. Empty means nothing reads it. + + A ``J_USES_CONFIG`` edge may also be rooted on a field, a type or a callable rather than + on a body node (the schema allows all four sources; every one of daytrader8's 13 is a + body node). A field- or type-rooted read has no reading *callable*, so it has no entry + here — it is still in :meth:`get_config_uses`, which is where the whole edge set lives. + + Raises: + CodeanalyzerExecutionException: See :meth:`_config_overlay`. + """ + # Through :meth:`get_config_uses` rather than around it, so a key this application does not + # declare cannot skip the refusal: whether the overlay is there is not a fact about which + # key you asked for. + found: Dict[str, _Addressed] = {} + for use in self.get_config_uses(key): + # ``src`` is a body-node id (``@:``) or, for the other three + # sources the schema allows, the declaration's own id. One lookup covers both: a + # callable id carries no ``@`` of its own. + row = self._addressing.by_id.get(use.src) or self._addressing.by_id.get(use.src.rpartition("@")[0]) + if row is not None: + found[row.key] = row + return [JCallableOverview.of(r.key, r.type, r.callable, path=r.path) for r in found.values()] # -----[ the type-kind leaf accessors (J-7) ]----- def get_interfaces(self) -> Dict[str, JType]: diff --git a/cldk/analysis/java/codeanalyzer/codeanalyzer.py b/cldk/analysis/java/codeanalyzer/codeanalyzer.py index 2d0203d..5a1e53c 100644 --- a/cldk/analysis/java/codeanalyzer/codeanalyzer.py +++ b/cldk/analysis/java/codeanalyzer/codeanalyzer.py @@ -73,7 +73,7 @@ JMethodDetail, JType, ) -from cldk.models.python import PyArtifact, PyConfigKey, PyConfigRead, PyConfigUseEdge, PyDependency +from cldk.models.python import PyArtifact, PyConfigKey, PyDependency from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, CodeanalyzerUsageException logger = logging.getLogger(__name__) @@ -835,16 +835,6 @@ def get_config_keys(self) -> Dict[str, PyConfigKey]: """ return {f"{path}@key/{ck.key}": PyConfigKey(**ck.model_dump()) for path, a in self.application.artifacts.items() for ck in a.config_keys} - def get_config_uses(self, key: str | None = None) -> List[PyConfigUseEdge]: - """Always empty: codeanalyzer-java 3.0.1 emits no code-to-config edges (there is no - ``config_uses`` on the Java wire), so there is nothing to filter by ``key``.""" - return [] - - def get_unresolved_config_reads(self) -> List[PyConfigRead]: - """Always empty: codeanalyzer-java 3.0.1 has no config-read detector (no ``config_reads`` - on the Java wire).""" - return [] - # -----[ comments ]----- def get_comments_in_a_method(self, qualified_class_name: str, method_signature: str) -> List[JComment]: method = self.get_method(qualified_class_name, method_signature) diff --git a/cldk/analysis/java/neo4j/neo4j_backend.py b/cldk/analysis/java/neo4j/neo4j_backend.py index f093d52..ab30553 100644 --- a/cldk/analysis/java/neo4j/neo4j_backend.py +++ b/cldk/analysis/java/neo4j/neo4j_backend.py @@ -71,10 +71,13 @@ **Strategy.** Unlike the Python and TypeScript Neo4j backends, which answer each accessor with its own statement, this one rebuilds the canonical :class:`JApplication` from the graph and then answers every query with the *same* logic the in-memory backend runs over the same models. The application -is built on first use, not at attach, and cached — **nine round trips in all**: three at attach (the -relationship-type fingerprint, the version probe, the module fetch) and six on first use (one +is built on first use, not at attach, and cached — **fourteen round trips in all**: four at attach +(the relationship-type fingerprint, the version probe, the resolution probe -- which reuses the +fingerprint -- and the module fetch) and ten on first use (the anchor's properties, then one containment-subtree traversal instead of one query per parent, then call sites, imports, call edges, -artifacts and dependencies). +externals, artifacts, dependencies, and the two codeanalyzer-java 3.1.0 config-overlay statements). +On a 3.0.x graph it is twelve: the anchor carries no entrypoint report, so the two config statements +are not issued at all (:meth:`_overlay_rows`). **Lossiness** relative to the in-memory backend (the projection's, not this client's; see :mod:`reconstruct` for the per-node detail): a module carries no ``source`` and no span, so @@ -132,14 +135,17 @@ JCfgEdge, JComment, JCompilationUnit, + JConfigRead, + JConfigUse, JDdgEdge, JDecorator, + JEntrypointReport, JExternalSymbol, JField, JMethodDetail, JType, ) -from cldk.models.python import PyArtifact, PyConfigKey, PyConfigRead, PyConfigUseEdge, PyDependency +from cldk.models.python import PyArtifact, PyConfigKey, PyDependency from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException, GraphSchemaMismatch logger = logging.getLogger(__name__) @@ -332,7 +338,7 @@ def _load_modules(self) -> Dict[str, Dict[str, Any]]: return {r["k"]: r["p"] for r in rows} # ===================================================================================== - # Reconstruction: eight statements, then the canonical JApplication. + # Reconstruction: ten statements (eight on a 3.0.x graph), then the canonical JApplication. # ===================================================================================== #: The whole containment subtree beneath the application's modules, in one statement: the #: ``*0..`` walk reaches every module, type (nested and local), callable and field, and the last @@ -406,6 +412,49 @@ def _artifact_rows(self) -> List[Dict[str, Any]]: app=self.application_name, ) + def _overlay_rows(self) -> Tuple[JEntrypointReport | None, List[JConfigUse], List[JConfigRead]]: + """The three application-scope overlays codeanalyzer-java 3.1.0 added, in two statements. + + The report is the whole ``JEntrypointReport`` as sorted-key JSON on the anchor, exactly as + codeanalyzer-python projects ``PyApplication.entrypoint_report``, so it parses back into the + model with no lossiness. ``properties(a)`` rather than naming the key: a 3.0.x graph has no + such property at all, and naming one statically makes the server log a warning per call. + + **The report is also the overlay probe** (see :data:`~cldk.analysis.java.backend.CONFIG_OVERLAY_UNAVAILABLE`): + a 3.1.0 graph carries it whatever the application reads, whereas ``J_USES_CONFIG`` and + ``J_READS_CONFIG_UNRESOLVED`` are declared as relationship types only once an edge of that + type exists. So the config lists are ``None`` — "nothing looked" — exactly when the report + is absent, and a real (possibly empty) list otherwise. + + Both endpoints of ``J_USES_CONFIG`` carry the scope, and each carries a different one, + because the edge is the one place the two id spaces meet: the **key** is anchored through + the artifact layer (``can://artifact//…``, which no ``$prefix`` predicate matches) and + the **source** by the code prefix, since the schema roots that edge on a body node, a + callable, a field or a type — none of which the application anchor reaches in one hop. + ``J_READS_CONFIG_UNRESOLVED`` runs from the anchor itself and carries no ``site``, which is + the lossiness :meth:`~cldk.analysis.java.backend.JavaAnalysisBackend.get_unresolved_config_reads` + states. + """ + rows = self._run("MATCH (a:JApplication {name: $app}) RETURN properties(a) AS p", app=self.application_name) + raw = rows[0]["p"].get("entrypoint_report_json") if rows else None + if raw is None: + return None, [], [] + uses = self._run( + "MATCH (:JApplication {name: $app})-[:HAS_ARTIFACT]->(:Artifact)-[:DEFINES_CONFIG]->(ck:ConfigKey)<-[u:J_USES_CONFIG]-(src) " + f"WHERE {_scoped('src')} RETURN src.id AS src, ck.id AS dst, u.prov AS prov ORDER BY src.id, ck.id", + app=self.application_name, + prefix=self._scope_prefix, + ) + reads = self._run( + "MATCH (:JApplication {name: $app})-[u:J_READS_CONFIG_UNRESOLVED]->(ghost) RETURN properties(u) AS p, ghost.id AS callee ORDER BY u.key, ghost.id", + app=self.application_name, + ) + return ( + JEntrypointReport.model_validate_json(raw), + [JConfigUse(src=r["src"], dst=r["dst"], prov=list(r["prov"] or [])) for r in uses], + [JConfigRead(site="", callee=r["callee"], key=r["p"].get("key"), reason=r["p"].get("reason", "non-literal"), prov=list(r["p"].get("prov") or [])) for r in reads], + ) + def _dependency_rows(self) -> List[Dict[str, Any]]: return self._run( "MATCH (:JApplication {name: $app})-[:HAS_ARTIFACT]->(a:Artifact)-[r:DECLARES_DEPENDENCY]->(p:Package) " @@ -469,6 +518,7 @@ def _type(self, props: Dict[str, Any], children: Dict[str, List[_Child]], sites: def _reconstruct(self) -> JApplication: """The canonical :class:`JApplication` for this application, rebuilt from the graph.""" + report, uses, reads = self._overlay_rows() children = self._subtree_rows() sites = self._call_site_rows() imports = self._import_rows() @@ -504,6 +554,10 @@ def _reconstruct(self) -> JApplication: ) }, dependencies=[R.dependency(r["rel"], r["pkg"], r["declared_in"]) for r in self._dependency_rows()], + entrypoint_report=report, + # ``None`` and ``[]`` are different answers here: see :meth:`_overlay_rows`. + config_uses=None if report is None else uses, + config_reads_unresolved=None if report is None else reads, ) @staticmethod @@ -1202,17 +1256,6 @@ def get_config_keys(self) -> Dict[str, PyConfigKey]: """ return {f"{path}@key/{ck.key}": PyConfigKey(**ck.model_dump()) for path, a in self.application.artifacts.items() for ck in a.config_keys} - def get_config_uses(self, key: str | None = None) -> List[PyConfigUseEdge]: - """Always empty, and not a projection gap: codeanalyzer-java 3.0.1 emits no code-to-config - edges at all (there is no such relationship type in the Java graph, and no ``config_uses`` - on the Java wire), so the in-memory backend answers the same way.""" - return [] - - def get_unresolved_config_reads(self) -> List[PyConfigRead]: - """Always empty, as on the in-memory backend: codeanalyzer-java 3.0.1 has no config-read - detector.""" - return [] - # -----[ comments ]----- def get_comments_in_a_method(self, qualified_class_name: str, method_signature: str) -> List[JComment]: """The method's javadoc — **narrower than the ABC's "the comments in a method"**: the graph diff --git a/cldk/analysis/java/neo4j/reconstruct.py b/cldk/analysis/java/neo4j/reconstruct.py index 2db32cb..ed71297 100644 --- a/cldk/analysis/java/neo4j/reconstruct.py +++ b/cldk/analysis/java/neo4j/reconstruct.py @@ -301,6 +301,7 @@ def callable_( types=types, is_implicit=implicit, is_entrypoint=bool(props.get("is_entrypoint", False)), + entrypoint_frameworks=list(props.get("entrypoint_frameworks") or []), span=span(props), ) @@ -329,6 +330,7 @@ def type_( callables=callables, types=types, is_entrypoint_class=bool(props.get("is_entrypoint", False)), + entrypoint_frameworks=list(props.get("entrypoint_frameworks") or []), # ``span`` is required on a type and the projection always carries its lines. span=span(props) or _unknown_span(), ) diff --git a/cldk/models/java/models.py b/cldk/models/java/models.py index 9fe4b8e..a3668ac 100644 --- a/cldk/models/java/models.py +++ b/cldk/models/java/models.py @@ -19,7 +19,8 @@ The wire is one containment tree: ``JAnalysis{analyzer, application}`` → ``JApplication{symbol_table{path → JCompilationUnit}, call_graph, param_in, param_out, artifacts, -dependencies}`` → ``JCompilationUnit{types{name → JType}}`` → ``JType{fields{}, callables{signature → +dependencies, config_uses, config_reads_unresolved, entrypoint_report}`` → +``JCompilationUnit{types{name → JType}}`` → ``JType{fields{}, callables{signature → JCallable}, types{}}`` → ``JCallable{body{}, cfg, cdg, ddg, summary, types{}}``. Every node carries a ``can://`` ``id`` and a ``kind``; a unit carries its full ``source`` once and every node's text is a slice of it. Gson omits ``null`` fields, so an absent key is a ``None``/empty default here. @@ -404,6 +405,11 @@ class JCallable(_Node): is_implicit: bool = False comments: List[JComment] = [] is_entrypoint: bool = False + #: The entrypoint rulesets that marked this callable (``["jakarta"]``, ``["spring"]``, …), from + #: codeanalyzer-java 3.1.0 (codeanalyzer-java#235). ``[]`` on an unmarked callable *and* on a + #: payload from an older analyzer; :meth:`~cldk.analysis.java.backend.JavaAnalysisBackend.get_entrypoint_coverage` + #: is where that distinction is drawn, off :attr:`JApplication.entrypoint_report`. + entrypoint_frameworks: List[str] = [] metrics: Optional[JMetrics] = None refs: Optional[JRefs] = None local_variables: List[JLocalVariable] = [] @@ -508,6 +514,8 @@ class JType(_Node): decorators: List[JDecorator] = [] type_parameters: List[JTypeParameter] = [] is_entrypoint_class: bool = False + #: As :attr:`JCallable.entrypoint_frameworks`, for a type marked in its own right. + entrypoint_frameworks: List[str] = [] enum_constants: List[JEnumConstant] = [] record_components: List[JRecordComponent] = [] fields: Dict[str, JField] = {} @@ -774,6 +782,50 @@ class JDependency(_Base): prov: List[str] = [] +class JEntrypointReport(_Base): + """Coverage and failure record for the entrypoint pass (codeanalyzer-java#235, 3.1.0). + + The same four fields Python and TypeScript already carry (``PyEntrypointReport`` / + ``TSEntrypointReport``), so :class:`~cldk.analysis.commons.results.EntrypointCoverage` reads + one shape across the three languages.""" + + frameworks_detected: List[str] = [] + rulesets: List[str] = [] + unresolved: Dict[str, int] = {} + errors: List[str] = [] + + +class JConfigUse(_Base): + """One code-to-config edge: a config read whose key closed on a declared :class:`JConfigKey`. + + ``src`` is the reading call's body-node id (``@:``), ``dst`` the matched + key's id. ``prov`` names the **tier** that resolved it and is the reason this is not flattened: + ``["literal"]`` is a string literal at the call site (codeanalyzer-java#233); ``"dataflow"`` + means the key was reached over the L3 DDG / L4 call graph (codeanalyzer-java#237) — weaker + evidence, and monotone with the analysis level. Python's ``PyConfigUseEdge`` verbatim.""" + + src: str + dst: str + prov: List[str] = [] + + +class JConfigRead(_Base): + """One config read whose key closed on no declared key — first class, so a read nobody can + trace stays as visible as one that resolves. + + ``key`` is the literal text when it *was* a literal that matches no declared key + (``reason="undefined-key"``) and ``None`` when it never closed on one (``reason="non-literal"``); + ``prov`` lists every tier that was **attempted** before giving up, so ``["literal", "dataflow"]`` + means the dataflow tier ran too and still could not name the key. Python's ``PyConfigRead`` + verbatim.""" + + site: str + callee: str + key: Optional[str] = None + reason: str = "non-literal" + prov: List[str] = [] + + class JApplication(_Base): """The application root. ``call_graph``/``param_in``/``param_out`` are absent below the level that computes them — empty here, never ``None``. @@ -793,6 +845,14 @@ class JApplication(_Base): param_out: List[JParamEdge] = [] artifacts: Dict[str, JArtifact] = {} dependencies: List[JDependency] = [] + #: The three 3.1.0 overlays, and the one place in this file where ``None`` and ``[]`` are + #: *different answers*: ``None`` is "this payload predates the pass" (codeanalyzer-java 3.0.x + #: emits none of these keys at all) and an empty list/report is "the pass ran and found + #: nothing". The accessors refuse on the first and answer on the second, measured here rather + #: than from an analyzer version string — the same ruling as the port probe. + config_uses: Optional[List[JConfigUse]] = None + config_reads_unresolved: Optional[List[JConfigRead]] = None + entrypoint_report: Optional[JEntrypointReport] = None def model_post_init(self, __context: Any) -> None: for path, unit in self.symbol_table.items(): diff --git a/docs/agent-api-reference.md b/docs/agent-api-reference.md index 69d0792..11b7475 100644 --- a/docs/agent-api-reference.md +++ b/docs/agent-api-reference.md @@ -227,16 +227,18 @@ accessors** — `get_callables_overview`, `get_method_bodies`, `get_decorated_ca `get_config_uses` / `get_unresolved_config_reads` / `get_config_readers`, and `get_interfaces` / `get_enums` / `get_enum_members` / `get_records`. Five rules: -- **`get_entrypoint_coverage` reports that there is no report.** codeanalyzer-java emits the - entrypoint *marks* and nothing about the pass that made them: `analysis.json` carries no report - key and the `:JApplication` anchor carries only `name` / `schema_version` / `analyzer_name` / - `analyzer_version` — unlike codeanalyzer-python 1.4.1 and codeanalyzer-typescript 1.5.0, which - both project one. So it answers with `diagnostics=[entrypoint_report_unavailable]` and empty - fields that are explicitly *not* coverage. It is not synthesised from the `is_entrypoint` - booleans: a count of syntactically-marked callables is not a coverage record. The marks - themselves are real and unambiguous — 133 callables and 66 types of daytrader8's 1,216 and 149, - 1,501 callables and 904 types of ThingsBoard's — and `get_entrypoints` / - `get_entrypoint_classes` return those. +- **`get_entrypoint_coverage` reads the report from codeanalyzer-java 3.1.0 on.** 3.1.0 + (codeanalyzer-java#235) emits the entrypoint pass's own coverage record — `entrypoint_report` on + `analysis.json`, `entrypoint_report_json` on the `:JApplication` anchor, parsed back into the same + model — so the four fields agree with codeanalyzer-python and codeanalyzer-typescript and the two + Java backends are lossless against each other. On daytrader8: `frameworks_detected` + `["jakarta", "jaxrs", "spring"]` of five `rulesets` run, nothing unresolved, no errors. **A 3.0.x + analysis carries no report**, and both a cached `analysis.json` and an attached 3.0.x graph are + still servable, so that answers `diagnostics=[entrypoint_report_unavailable]` with empty fields + that are explicitly *not* coverage. It is never synthesised from the `is_entrypoint` booleans: a + count of syntactically-marked callables is not a coverage record. The marks themselves are real + and unambiguous — 133 callables and 66 types of daytrader8's 1,216 and 149, 1,501 callables and + 904 types of ThingsBoard's — and `get_entrypoints` / `get_entrypoint_classes` return those. - **A marker matches an annotation by simple name.** `get_decorated_callables(["Test"])`, `["@Test"]` and `["org.junit.Test"]` are the same query: the Java wire carries an annotation's simple name, so both sides are compared on the segment after the last `.` with a leading `@` @@ -254,12 +256,25 @@ accessors** — `get_callables_overview`, `get_method_bodies`, `get_decorated_ca - **`get_config_keys` is keyed `"@key/"`,** artifact-relative rather than by the raw `can://artifact//…` id. Python and TypeScript still key by the id; aligning the three is python-sdk#346 and is deliberately not done piecemeal. -- **`get_config_readers(key)` is `[]` for every key on Java, and so are `get_config_uses` and - `get_unresolved_config_reads`.** The Java wire carries no `config_uses` and no config-read - detector, so there is no code-to-config edge to resolve to a reading callable — the empty list is - "the analyzer emits no such edge", not "no callable reads this key". Do not read a Java `[]` here - as evidence about the code; `get_config_keys` (which is real) is what Java answers about - configuration. Python is where the "which code reads this key" question has an answer today. +- **`get_config_uses` / `get_config_readers` / `get_unresolved_config_reads` answer from + codeanalyzer-java 3.1.0 on, and `prov` says how strong the evidence is.** 3.1.0 added the + code-to-config layer in two tiers, and the SDK surfaces the tier rather than flattening it: + `prov == ["literal"]` is a string literal at the call site (codeanalyzer-java#233); + `"dataflow"` means the key was reached over the L3 DDG / the L4 call graph (#237) — a derived + answer, weaker than a literal, and one that widens with the analysis level rather than replacing + the literal tier. On a `PyConfigRead` the list is every tier *attempted* before giving up, so + `["literal", "dataflow"]` there means the dataflow tier ran too and still could not name the key. + Measured on daytrader8: 13 resolved uses, all `["literal"]`; 16 unresolved reads (the + `System.getenv` calls), `["literal"]` at level 1 and `["literal", "dataflow"]` at level 4. + **A 3.0.x analysis has no detector at all, and the three accessors raise rather than answer `[]`** + — "this application reads no configuration" and "nothing looked" are different facts. The probe is + measured from the data, never from a version string, but it is *not* the config layer's own + absence: the analyzer writes `config_uses` / `config_reads_unresolved` only when non-empty, and + the graph declares `J_USES_CONFIG` / `J_READS_CONFIG_UNRESOLVED` as relationship types only once + an edge exists, so both would refuse a clean 3.1.0 analysis that genuinely reads nothing. The + witness is the sibling overlay from the same release — 3.1.0 writes an entrypoint report on every + run and 3.0.x writes none of the three. `get_config_keys` is unaffected and answers at every + generation. **Still absent for Java**: the scoping keywords (`get_symbol_table(paths=)`, `get_classes(module=)`, `get_call_graph(roots=, depth=)`). Calling those keywords raises @@ -301,8 +316,8 @@ What Java answers today is the 1.x accessor surface, on the v2 models: `get_symb `get_call_graph_json` / `get_callers` / `get_callees` / `get_class_call_graph`, `get_entry_point_classes` / `get_entry_point_methods`, `get_test_methods`, the comment and docstring accessors, and — new in 3a, from the generic backend ABC — `get_artifacts` / -`get_dependencies` / `get_config_keys` (`get_config_uses` and `get_unresolved_config_reads` are -`[]`: the Java analyzer emits neither). Six more joined them in #366 — `get_imports`, +`get_dependencies` / `get_config_keys` / `get_config_uses` / `get_unresolved_config_reads` (the +last two from codeanalyzer-java 3.1.0; they raise on an older analysis rather than answer `[]`). Six more joined them in #366 — `get_imports`, `get_variables`, `get_class_hierarchy`, `get_methods_with_annotations`, `get_call_targets` and `get_calling_lines`, all at their published 1.x signatures, answering identically on both backends and issuing no new Cypher. Note what each one is: `get_imports()` is the project's **distinct @@ -398,6 +413,13 @@ names its upstream issue where there is one: the kind would hide a real branch, so it is reported as the analyzer spells it — the same way TypeScript reports its own out-of-list vertices. Match on the string, don't assume the frozenset is exhaustive across languages. +- **`get_ddg` over Neo4j carries `points-to` edges the local payload does not**, and it is the same + difference of what each source was asked. codeanalyzer-java 3.1.0 made the level-4 `points-to` + layer depend on `--external-calls` — forced on by `--emit neo4j`, never passed by the SDK's local + run — where 3.0.3 produced the same edges either way. On daytrader8 that is 276 edges of 10,430: + the graph is a strict superset, the payload has nothing the graph lacks, and running the analyzer + by hand with `-a 4 --external-calls` reproduces the graph's set exactly. `slice_forward` and the + other forward walks can therefore reach further over Neo4j. Reported upstream. - **`get_external_symbols` answers over Neo4j and raises locally**, and that is a difference of what each source was *asked*, not of policy. codeanalyzer-java emits `external_symbols` only under `--external-calls`, which is off by default and which `--emit neo4j` forces on — so the graph @@ -685,7 +707,12 @@ directly, where `PyConfigUseEdge` gives you `{src, dst, prov}` ids you would hav `PyConfigRead` carries `reason`, so an unresolved read tells you *why* it could not be resolved rather than just vanishing. -This layer is identical across Python, Java and TypeScript — the queries port unchanged. +This layer is identical across Python, Java and TypeScript — the queries port unchanged. Two +language notes: Java keys `get_config_keys()` artifact-relatively rather than by the `can://` id +(python-sdk#346), and Java's three code-to-config accessors need **codeanalyzer-java 3.1.0** — on an +older analysis they raise rather than answer `[]`, since "reads no configuration" and "nothing +looked" are different facts. `prov` is where the evidence lives on all three: `["literal"]` is a +literal at the call site, `"dataflow"` a key reached over the DDG or the call graph. --- diff --git a/pyproject.toml b/pyproject.toml index b645009..6d0a899 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ dependencies = [ # The Neo4j-backed analysis backends (read-only; no analyzer is invoked). neo4j = ["neo4j>=5.14,<7"] # The local Java backend: the analyzer wheel carries the jar and a bundled JVM (jdk4py). -java = ["codeanalyzer-java==3.0.3"] +java = ["codeanalyzer-java==3.1.0"] # Everything. `codeanalyzer-python` and `codeanalyzer-typescript` are still hard # dependencies; #340 moves them here, and this list grows when it does. all = ["cldk[java]", "cldk[neo4j]"] @@ -86,7 +86,7 @@ include = [ ] [tool.backend-versions] -codeanalyzer-java = "3.0.3" +codeanalyzer-java = "3.1.0" codeanalyzer-python = "1.4.1" codeanalyzer-typescript = "1.5.0" diff --git a/tests/analysis/java/conftest.py b/tests/analysis/java/conftest.py index 6a62169..157de5b 100644 --- a/tests/analysis/java/conftest.py +++ b/tests/analysis/java/conftest.py @@ -36,6 +36,11 @@ "DEFINES_CONFIG", "DECLARES_DEPENDENCY", "LOCKS", + # codeanalyzer-java 3.1.0's code-to-config layer (codeanalyzer-java#233/#237). In the + # default set because the fake driver stands in for a graph emitted by the pinned analyzer; + # a test that wants a 3.0.x-shaped graph subtracts them. + "J_USES_CONFIG", + "J_READS_CONFIG_UNRESOLVED", } ) diff --git a/tests/analysis/java/test_java_backend_contract.py b/tests/analysis/java/test_java_backend_contract.py index 8e0011d..f48240d 100644 --- a/tests/analysis/java/test_java_backend_contract.py +++ b/tests/analysis/java/test_java_backend_contract.py @@ -38,9 +38,28 @@ def test_java_contract_parameterises_the_generic_abc(): assert JavaAnalysisBackend.N == "J" +#: The two generic methods :class:`JavaAnalysisBackend` answers **itself** rather than leaving +#: abstract, and why that is not the stub the test below forbids: the code-to-config layer is read +#: off the canonical :class:`JApplication`, which :class:`JNeo4jBackend` rebuilds from the graph +#: (overlays included) and :class:`JCodeanalyzer` holds off the wire — so one implementation serves +#: both sources, and the refusal on a pre-3.1.0 analysis cannot come to differ between them. +#: :func:`test_the_config_layer_is_answered_once_for_both_backends` pins that it stays one. +ANSWERED_ON_THE_CONTRACT = {"get_config_uses", "get_unresolved_config_reads"} + + def test_generic_methods_are_all_abstract_on_the_java_contract(): """Inheriting the generic ABC must not quietly satisfy any of its methods with a stub.""" - assert set(GENERIC_METHODS) <= JavaAnalysisBackend.__abstractmethods__ + assert set(GENERIC_METHODS) - ANSWERED_ON_THE_CONTRACT <= JavaAnalysisBackend.__abstractmethods__ + + +def test_the_config_layer_is_answered_once_for_both_backends(): + """The exemption is only sound while it really is one implementation: concrete on the contract, + and overridden by neither backend.""" + for name in ANSWERED_ON_THE_CONTRACT: + shared = getattr(JavaAnalysisBackend, name) + assert not getattr(shared, "__isabstractmethod__", False), f"{name} is abstract on the contract, so the exemption is stale" + for backend in BACKENDS: + assert getattr(backend, name) is shared, f"{backend.__name__} overrides {name}: the two backends can now disagree about it" @pytest.mark.parametrize("backend", BACKENDS) diff --git a/tests/analysis/java/test_java_dataflow_live.py b/tests/analysis/java/test_java_dataflow_live.py index 558cfa3..977122d 100644 --- a/tests/analysis/java/test_java_dataflow_live.py +++ b/tests/analysis/java/test_java_dataflow_live.py @@ -163,9 +163,14 @@ def test_the_three_graphs_agree_edge_for_edge_on_the_busiest_callables(backends) ``get_ddg`` is compared on the edges whose endpoints the analyzer actually emitted as body nodes. That filter was load-bearing up to codeanalyzer-java 3.0.2, which emitted edges naming - endpoints it never emitted as nodes; on 3.0.3 it removes nothing (the next test asserts the two - sides are equal as *sets*), and it stays because it is what makes this comparison a statement - about the projection rather than about the analyzer. + endpoints it never emitted as nodes; on 3.0.3 it removes nothing, and it stays because it is + what makes this comparison a statement about the projection rather than about the analyzer. + + ``cfg`` and ``cdg`` are equal page for page, in order. ``ddg`` is equal **after removing the + graph's ``points-to`` edges the reference was never asked for** — see + :data:`DDG_POINTS_TO_ONLY_WITH_EXTERNAL_CALLS`. The removal is checked, not assumed: every edge + dropped here is asserted to be ``points-to``, and the surviving page must still equal the + reference's page in order, so a projection defect cannot hide behind it. """ ref, neo = backends keys = _with_bodies(ref, 40) @@ -176,26 +181,55 @@ def test_the_three_graphs_agree_edge_for_edge_on_the_busiest_callables(backends) for accessor in ("get_cfg", "get_cdg", "get_ddg"): a, b = getattr(ref, accessor)(key), getattr(neo, accessor)(key) kept = [e for e in a.edges if e.src in anchored and e.dst in anchored] - assert kept == list(b.edges), f"{accessor} {key}" - assert len(kept) == b.total, f"{accessor} {key}" + page, extra = list(b.edges), [] + assert len(page) == b.total, f"{accessor} {key}" + if accessor == "get_ddg": + # ``JDdgEdge`` is a pydantic model and not hashable, so membership is by tuple. + same = {(e.src, e.dst, e.var, tuple(e.prov)) for e in kept} + extra = [e for e in page if (e.src, e.dst, e.var, tuple(e.prov)) not in same] + assert all(e.prov == ["points-to"] for e in extra), f"{accessor} {key}: the graph carries an ssa edge the payload does not" + page = [e for e in page if (e.src, e.dst, e.var, tuple(e.prov)) in same] + assert kept == page, f"{accessor} {key}" assert a.complete and b.complete seen += len(kept) assert seen > 1000, f"the comparison covered only {seen} edges" +#: The ``points-to`` edges the reference payload does not carry and the graph does, and **why that +#: is a difference of what each source was asked rather than a projection gap** — the same shape as +#: ``get_external_symbols``, measured the same way. +#: +#: Up to codeanalyzer-java 3.0.3 the two agreed exactly: a plain ``-a 4`` run and an ``--emit neo4j`` +#: run produced the same 10,430 ddg edges, set for set. **codeanalyzer-java 3.1.0 makes the level-4 +#: ``points-to`` layer depend on ``--external-calls``**, which ``--emit neo4j`` forces on and which +#: the SDK's own local run does not pass. Measured on daytrader8, four runs of the same tree: +#: +#: * 3.0.3 ``-a 4 --no-build`` → 10,430 (1,134 ``points-to``) +#: * 3.1.0 ``-a 4 --no-build`` → 10,154 (858 ``points-to``) +#: * 3.1.0 ``-a 4 --no-build --external-calls`` → 10,430 (1,134), **identical as a set** to the graph +#: * 3.1.0 ``--emit neo4j --no-build`` → 10,430 (1,134) +#: +#: So the graph is a strict superset of the payload by exactly these edges, the payload has nothing +#: the graph lacks, and asking the analyzer the *same* question reproduces the graph's set exactly. +#: Reported upstream; the flag is documented as controlling only whether out-of-project call targets +#: are homed as ``external_symbols``, not the intraprocedural dataflow. +DDG_POINTS_TO_ONLY_WITH_EXTERNAL_CALLS = 276 + + def test_the_two_backends_report_the_same_ddg_edge_for_edge(backends): - """**The one place the two Java backends used to disagree, and it was the analyzer's.** + """**The one place the two Java backends disagree, and it is the analyzer's, twice over.** codeanalyzer-java up to 3.0.2 emitted ddg edges naming an endpoint it never emitted as a body node — 87 of daytrader8's 5,434, over 38 distinct keys all of the shape ``:0``, all on ``points-to`` edges — and the Neo4j emitter materialises nodes from the ``body{}`` map, so those edges could not be projected and the graph reported 5,347. 3.0.3 drops them - (codeanalyzer-java#228). + (codeanalyzer-java#228), and this test asserted equality: 10,430 on both sides, set for set. - So this asserts the equality rather than the difference: **10,430 ddg edges on both sides, set - for set**, with 0 dangling endpoints in the payload. The subtraction stays in both directions — - a dangling endpoint coming back fails here, and it fails naming what it is rather than as an - off-by-87 in an edge count. + On **3.1.0** they are no longer equal, and the cause is not the projection: see + :data:`DDG_POINTS_TO_ONLY_WITH_EXTERNAL_CALLS` for the four-run measurement. The graph is a + strict superset by 276 ``points-to`` edges and by nothing else, so what is asserted is the + containment, its exact size, and that every edge in the gap is ``points-to`` — a regression in + either direction still fails here, and it fails naming what it is. """ ref, neo = backends from cldk.analysis.java.backend import java_body_node_id @@ -206,9 +240,11 @@ def test_the_two_backends_report_the_same_ddg_edge_for_edge(backends): prefix=neo._scope_prefix, ) graph = {(r["s"], r["d"], r["v"], tuple(r["p"] or ())) for r in rows} - assert (len(local), len(graph)) == (10430, 10430) - assert graph - local == set(), "the graph carries a ddg edge the analyzer's own payload does not" + assert (len(local), len(graph)) == (10154, 10430) assert local - graph == set(), "the analyzer emitted a ddg edge the graph could not project" + gap = graph - local + assert len(gap) == DDG_POINTS_TO_ONLY_WITH_EXTERNAL_CALLS + assert {e[3] for e in gap} == {("points-to",)}, "the gap is the --external-calls points-to layer and nothing else" assert _dangling(ref) == [], "codeanalyzer-java#228 is back: a ddg endpoint that is not a body node" @@ -269,12 +305,14 @@ def test_paging_agrees_across_backends(backends): def test_the_two_provenance_tiers_are_the_only_ones(backends): - """Java's DDG has ``ssa`` and ``points-to`` and nothing else — 324,959 and 1,134 edges across - the whole database. Neither backend may invent a third or collapse the two.""" + """Java's DDG has ``ssa`` and ``points-to`` and nothing else — 324,952 and 1,134 edges across + the whole database (the ``ssa`` figure was 324,959 on a graph emitted by codeanalyzer-java + 3.0.3; 3.1.0 emits seven fewer, all in ThingsBoard). Neither backend may invent a third or + collapse the two.""" _, neo = backends rows = neo._run("MATCH ()-[r:J_DDG]->() RETURN r.prov AS prov, count(*) AS n ORDER BY n DESC") assert {tuple(r["prov"]) for r in rows} == {("ssa",), ("points-to",)} - assert dict((tuple(r["prov"]), r["n"]) for r in rows) == {("ssa",): 324959, ("points-to",): 1134} + assert dict((tuple(r["prov"]), r["n"]) for r in rows) == {("ssa",): 324952, ("points-to",): 1134} def test_the_body_node_kind_vocabulary_is_what_the_graph_holds(backends): @@ -426,18 +464,29 @@ def test_a_value_crosses_three_call_boundaries_on_both_backends(backends): def test_a_forward_slice_agrees_node_for_node_on_both_backends(backends): """``slice_forward`` was refused on Java until codeanalyzer-java 3.0.3, so this is the direction leg 3b could only assert backwards: the two walks over the joined lattice return the same - nodes, the same ``total`` and the same ``resolved``.""" + ``resolved`` seed and the reference's nodes are all in the graph's slice, in order. + + Not equality any more, and the reason is upstream rather than in either backend: the graph + carries 276 ``points-to`` edges the reference payload was never asked for + (:data:`DDG_POINTS_TO_ONLY_WITH_EXTERNAL_CALLS`), so a forward walk over it can reach further. + Containment is the assertion that survives that honestly — a node the *reference* reaches and + the graph does not is still a failure, which is the direction a projection defect shows up in. + """ ref, neo = backends - checked = 0 + checked = wider = 0 for key in _with_bodies(ref, 15): for parameter in ref._addressing.by_key[key].callable.parameters: if not parameter.name: continue a = ref.slice_forward(parameter.name, within=key, depth=None) b = neo.slice_forward(parameter.name, within=key, depth=None) - assert a.nodes == b.nodes and a.total == b.total and a.resolved == b.resolved, f"{key} {parameter.name}" + assert a.resolved == b.resolved, f"{key} {parameter.name}" + assert {n.ref for n in a.nodes} <= {n.ref for n in b.nodes}, f"{key} {parameter.name}: the graph's slice is missing a node the payload reaches" + assert a.total <= b.total, f"{key} {parameter.name}" + wider += a.total < b.total checked += 1 assert checked > 15, f"only {checked} parameters compared" + assert wider, "no slice was wider over the graph -- the points-to gap this tolerance exists for is gone, so restore the equality" # ---- miss paths -------------------------------------------------------------------------------- diff --git a/tests/analysis/java/test_java_entrypoints.py b/tests/analysis/java/test_java_entrypoints.py index 7cc6dd3..6ebc312 100644 --- a/tests/analysis/java/test_java_entrypoints.py +++ b/tests/analysis/java/test_java_entrypoints.py @@ -47,6 +47,8 @@ from cldk.utils.exceptions import SelectorNotInGraph from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException +from cldk.analysis.java.codeanalyzer.codeanalyzer import JCodeanalyzer + from .test_java_addressing import _graph, _local DIRECT_PKG = "com.ibm.websphere.samples.daytrader.impl.direct" @@ -115,18 +117,18 @@ def test_get_entrypoint_classes_is_the_class_level_sibling(both): assert all(c.path in both.get_symbol_table() for c in classes) -def test_get_entrypoint_coverage_reports_the_report_unavailable(both): - """Java projects **no** entrypoint report -- the ``:JApplication`` anchor carries only - ``name``/``schema_version``/``analyzer_name``/``analyzer_version``, and ``analysis.json`` has no - such key -- so the coverage accessor says so through the shared model's own vocabulary rather - than counting the ``is_entrypoint`` booleans and calling that coverage (J-4, D7).""" +def test_get_entrypoint_coverage_reads_the_report(both): + """codeanalyzer-java 3.1.0 (codeanalyzer-java#235) emits the entrypoint pass's own coverage + record, so the accessor reads it rather than saying there is none. Measured on a1: three of the + five shipped rulesets matched, nothing unresolved, no errors. It still never synthesises one + out of the ``is_entrypoint`` booleans (J-4, D7) -- the counts below are 66/133 and appear + nowhere in the report.""" coverage = both.get_entrypoint_coverage() assert isinstance(coverage, EntrypointCoverage) - assert [d.code for d in coverage.diagnostics] == ["entrypoint_report_unavailable"] - assert coverage.frameworks_detected == [] and coverage.rulesets == [] and coverage.unresolved == {} and coverage.errors == [] - message = coverage.diagnostics[0].message - assert "codeanalyzer-java" in message and "can://" not in message - assert coverage.diagnostics[0].suggestions == [] + assert coverage.diagnostics == [] + assert coverage.frameworks_detected == ["jakarta", "jaxrs", "spring"] + assert coverage.rulesets == ["jakarta", "struts", "spring", "camel", "jaxrs"] + assert coverage.unresolved == {} and coverage.errors == [] def test_the_coverage_diagnostic_is_the_same_on_both_backends(analysis_json): @@ -260,13 +262,116 @@ def test_get_enum_members_returns_the_constants_of_a_real_enum(both): del both._types["x.Color"] -# ---- config readers ---------------------------------------------------------------------------- -def test_get_config_readers_has_no_edges_to_resolve(both): - """codeanalyzer-java 3.0.1 emits no code-to-config edges, so ``get_config_uses`` is empty and - there is no reading callable to name -- stated on the accessor, as 3a stated it on - ``get_config_uses`` itself.""" - assert both.get_config_uses() == [] - assert both.get_config_readers("project.artifactId") == [] +# ---- the code-to-config layer (codeanalyzer-java 3.1.0) ---------------------------------------- +def test_get_config_uses_carries_the_resolved_edges_and_their_tier(both): + """13 edges on a1, every one of them ``["literal"]`` -- a string literal at the call site + (codeanalyzer-java#233). ``prov`` is surfaced, not flattened: the dataflow tier (#237) is a + weaker answer and has to stay distinguishable from this one.""" + uses = both.get_config_uses() + assert len(uses) == 13 + assert {tuple(u.prov) for u in uses} == {("literal",)} + assert {u.dst.rpartition("@key/")[2] for u in uses} == { + "displayOrderAlerts", + "listQuotePriceChangeFrequency", + "longRun", + "marketSummaryInterval", + "maxQuotes", + "maxUsers", + "orderProcessingMode", + "primIterations", + "publishQuotePriceChange", + "runtimeMode", + "webInterface", + } + assert all(u.src.startswith(u.src.rpartition("@")[0] + "@") for u in uses), "src is a body-node id" + + +def test_get_config_uses_filters_by_key_exactly(both): + """``key`` is matched against the declared key's own ``key``, never fuzzily (E8), and a key + nothing reads is an empty list rather than an unfiltered one.""" + one = both.get_config_uses("maxUsers") + assert len(one) == 2 and all(u.dst.endswith("@key/maxUsers") for u in one), "two call sites read maxUsers" + assert both.get_config_uses("maxuser") == [] and both.get_config_uses("project.artifactId") == [] + + +def test_get_config_readers_resolves_the_edges_to_their_callables(both): + """All 13 of a1's reads are in one callable, so the 13 edges resolve to **one** overview: a + callable reading a key at several call sites appears once.""" + readers = both.get_config_readers("maxUsers") + assert [r.key for r in readers] == ["com.ibm.websphere.samples.daytrader.web.servlet.TradeWebContextListener.contextInitialized(javax.servlet.ServletContextEvent)"] + assert all(isinstance(r, JCallableOverview) for r in readers) + assert both.get_config_readers("project.artifactId") == [], "a declared key nothing reads has no readers" + assert both.get_config_readers("no.such.key") == [] + + +def test_get_unresolved_config_reads_keeps_the_untraceable_ones_visible(both): + """16 reads on a1 whose key matched no declared key -- every one of them a decoded literal + (``reason="undefined-key"``, the environment variables ``System.getenv`` reads), so ``key`` + carries the text. ``prov`` is every tier *attempted*: a1 is a level-1 analysis, where there is + no DDG for the dataflow tier to run over, so it is ``["literal"]`` alone -- on the level-4 + reference graph the same reads carry ``["literal", "dataflow"]``.""" + reads = both.get_unresolved_config_reads() + assert len(reads) == 16 + assert {r.reason for r in reads} == {"undefined-key"} + assert {tuple(r.prov) for r in reads} == {("literal",)} + assert {r.key for r in reads} == { + "DISPLAY_ORDER_ALERTS", + "LIST_QUOTE_PRICE_CHANGE_FREQUENCY", + "MAX_QUOTES", + "MAX_USERS", + "ORDER_PROCESSING_MODE", + "PUBLISH_QUOTES", + "RUNTIME_MODE", + "WEB_INTERFACE", + } + assert all(r.site and r.callee.startswith("can://java/daytrader8/@external/") for r in reads) + + +def test_a_clean_run_that_reads_nothing_is_an_empty_answer_and_not_a_refusal(both_l4): + """a4 is a level-4 3.1.0 analysis of a pruned tree that reads no configuration at all. It + carries the entrypoint report and **neither config key** -- the analyzer writes those two only + when non-empty -- which is exactly why the overlay probe cannot be the config layer's own + absence. The three accessors answer empty here; the next test is what refusing looks like.""" + assert both_l4.get_config_uses() == [] + assert both_l4.get_unresolved_config_reads() == [] + assert both_l4.get_config_readers("maxUsers") == [] + assert both_l4.get_entrypoint_coverage().frameworks_detected == ["jakarta"] + + +def test_an_analysis_without_the_overlays_refuses_rather_than_answering_empty(analysis_json): + """The refusal, measured from the data and never from a version string (the same ruling as the + port probe). A 3.0.x payload and a 3.0.x graph carry none of the three overlays, and both are + still servable -- a cached ``analysis.json`` at a sufficient ``max_level`` is reused whatever + wrote it, and the Neo4j floor is 3.0.1 -- so answering ``[]`` would say "this application reads + no configuration" where the truth is that nothing looked.""" + for backend in (_local(analysis_json), _graph(analysis_json)): + stripped = backend.get_application_view().model_copy(update={"entrypoint_report": None, "config_uses": None, "config_reads_unresolved": None}) + # Each backend's own seam: the in-memory one holds the application on an attribute, the + # graph one behind the ``_application`` cache its ``application`` property reads. + backend.__dict__["application" if isinstance(backend, JCodeanalyzer) else "_application"] = stripped + calls = ( + backend.get_config_uses, + lambda: backend.get_config_uses("maxUsers"), + backend.get_unresolved_config_reads, + lambda: backend.get_config_readers("maxUsers"), + # A key this application does not declare must refuse too: whether the overlay is + # there is not a fact about which key you asked for. + lambda: backend.get_config_readers("no.such.key"), + ) + for call in calls: + with pytest.raises(CodeanalyzerExecutionException) as excinfo: + call() + message = str(excinfo.value) + assert "daytrader8" in message and "3.1.0" in message and "can://" not in message + # The entrypoint report is the same absence, reported rather than raised -- the shared + # model's own vocabulary, as a Python graph without the report uses. + coverage = backend.get_entrypoint_coverage() + assert [d.code for d in coverage.diagnostics] == ["entrypoint_report_unavailable"] + assert coverage.frameworks_detected == [] and coverage.rulesets == [] and coverage.unresolved == {} and coverage.errors == [] + assert coverage.diagnostics[0].suggestions == [] and "can://" not in coverage.diagnostics[0].message + # get_config_keys() is unaffected: what a config artifact declares is read at every + # generation, and only the code-to-config edges are the 3.1.0 addition. + assert len(backend.get_config_keys()) == 336 # ---- the artifact layer reaches the facade ------------------------------------------------------ @@ -277,7 +382,7 @@ def test_the_artifact_five_are_the_backends_own_answers(both): assert both.get_dependencies(ecosystem="pypi") == [], "a filter that matches nothing is empty, not unfiltered" keys = both.get_config_keys() assert keys and all(k.startswith(tuple(artifacts)) and "@key/" in k for k in keys), "the key is artifact-relative (python-sdk#346 keeps it that way)" - assert both.get_unresolved_config_reads() == [] + assert len(both.get_unresolved_config_reads()) == 16 def test_the_l4_fixture_answers_the_same_shapes(both_l4): diff --git a/tests/analysis/java/test_java_entrypoints_live.py b/tests/analysis/java/test_java_entrypoints_live.py index ed4e514..b7a8788 100644 --- a/tests/analysis/java/test_java_entrypoints_live.py +++ b/tests/analysis/java/test_java_entrypoints_live.py @@ -69,7 +69,11 @@ DAYTRADER_CALLABLES = 1216 DAYTRADER_EXTERNALS = 1195 SCALE_INTERFACES, SCALE_ENUMS, SCALE_RECORDS = 594, 192, 35 -SCALE_EXTERNALS = 2570 +#: 2,570 on a graph emitted by codeanalyzer-java 3.0.3; 3.1.0 adds one, and it is not a call target: +#: ``@external/org.springframework.beans.factory.annotation.Value/value()``, the ghost callee of +#: ThingsBoard's ``@Value`` config reads. ``J_READS_CONFIG_UNRESOLVED`` points at a ``:JExternal``, +#: so a read the analyzer could not resolve now mints one even where nothing calls it. +SCALE_EXTERNALS = 2571 TB_MSG_TYPE = "org.thingsboard.server.common.data.msg.TbMsgType" TB_RECORD = "org.thingsboard.server.coapserver.TbCoapDtlsSessionKey" @@ -140,17 +144,21 @@ def test_the_entrypoint_types_agree_type_for_type(backends): assert sorted(ref.get_entrypoint_classes(), key=lambda c: c.qualified_name) == sorted(neo.get_entrypoint_classes(), key=lambda c: c.qualified_name) -def test_get_entrypoint_coverage_reports_the_report_unavailable_on_a_real_graph(backends): - """The ``:JApplication`` anchor really does carry no entrypoint key -- asserted against the - server, not against a fixture, because that is the fact J-4's ruling rests on.""" +def test_get_entrypoint_coverage_reads_the_report_off_a_real_graph(backends): + """The ``:JApplication`` anchor really does carry the report -- asserted against the server, not + against a fixture, because that is the fact the accessor rests on. codeanalyzer-java 3.1.0 + projects it as ``entrypoint_report_json``, the whole model as JSON, so the graph and the payload + are equal object for object rather than merely both non-empty.""" ref, neo = backends props = neo._run("MATCH (a:JApplication {name: $app}) RETURN keys(a) AS k", app=JAVA_APP)[0]["k"] - assert sorted(props) == ["analyzer_name", "analyzer_version", "name", "schema_version"] - assert not any("entrypoint" in key for key in props) + assert sorted(props) == ["analyzer_name", "analyzer_version", "entrypoint_frameworks", "entrypoint_report_json", "name", "schema_version"] for backend in (ref, neo): coverage = backend.get_entrypoint_coverage() assert isinstance(coverage, EntrypointCoverage) - assert [d.code for d in coverage.diagnostics] == ["entrypoint_report_unavailable"] + assert coverage.diagnostics == [] + assert coverage.frameworks_detected == ["jakarta", "jaxrs", "spring"] + assert coverage.rulesets == ["jakarta", "struts", "spring", "camel", "jaxrs"] + assert coverage.unresolved == {} and coverage.errors == [] assert ref.get_entrypoint_coverage() == neo.get_entrypoint_coverage() @@ -242,6 +250,22 @@ def test_the_artifact_layer_and_config_readers_agree(backends): assert [(d.name, d.ecosystem, d.direct) for d in ref.get_dependencies()] == [(d.name, d.ecosystem, d.direct) for d in neo.get_dependencies()] assert set(ref.get_config_keys()) == set(neo.get_config_keys()) assert all("@key/" in k and not k.startswith("can://") for k in ref.get_config_keys()) + # The code-to-config layer (codeanalyzer-java 3.1.0). The two sources agree on the resolved + # edges exactly and diverge on the unresolved reads for one stated reason: + # ``J_READS_CONFIG_UNRESOLVED`` is discriminated by ``(key, reason)`` and carries no site, so + # the payload's 16 per-site entries are 8 edges. Presence, not count, is the contract. + assert len(ref.get_config_uses()) == len(neo.get_config_uses()) == 13 + assert {(u.src, u.dst, tuple(u.prov)) for u in ref.get_config_uses()} == {(u.src, u.dst, tuple(u.prov)) for u in neo.get_config_uses()} + assert {tuple(u.prov) for u in ref.get_config_uses()} == {("literal",)}, "every resolved use is a literal at the call site" + assert len(ref.get_unresolved_config_reads()) == 16 and len(neo.get_unresolved_config_reads()) == 8 + assert {(r.callee, r.key, r.reason, tuple(r.prov)) for r in ref.get_unresolved_config_reads()} == { + (r.callee, r.key, r.reason, tuple(r.prov)) for r in neo.get_unresolved_config_reads() + }, "the graph collapses per-site duplicates and loses nothing else" + # ``--emit neo4j`` forces level 4, and the reference is read at level 4 too, so both ran the + # dataflow tier over the DDG -- and it still could not name these keys. + assert {tuple(r.prov) for r in ref.get_unresolved_config_reads()} == {("literal", "dataflow")} + assert all(r.site for r in ref.get_unresolved_config_reads()) and all(r.site == "" for r in neo.get_unresolved_config_reads()) for backend in (ref, neo): - assert backend.get_config_uses() == [] and backend.get_unresolved_config_reads() == [] - assert backend.get_config_readers(next(iter(backend.get_config_keys())).split("@key/")[1]) == [] + readers = backend.get_config_readers("maxUsers") + assert [r.key for r in readers] == ["com.ibm.websphere.samples.daytrader.web.servlet.TradeWebContextListener.contextInitialized(javax.servlet.ServletContextEvent)"] + assert backend.get_config_readers("project.artifactId") == [], "a declared key nothing reads has no readers" diff --git a/tests/analysis/java/test_java_neo4j_backend.py b/tests/analysis/java/test_java_neo4j_backend.py index 6774760..c5106a0 100644 --- a/tests/analysis/java/test_java_neo4j_backend.py +++ b/tests/analysis/java/test_java_neo4j_backend.py @@ -394,8 +394,11 @@ def test_artifact_layer_parity(backends): for path, artifact in ref.application.artifacts.items(): for ck in artifact.config_keys: assert f"{path}@key/{ck.key}" in ck_r - assert neo.get_config_uses() == [] == ref.get_config_uses() - assert neo.get_unresolved_config_reads() == [] == ref.get_unresolved_config_reads() + # The 3.1.0 code-to-config layer: identical resolved edges, and unresolved reads that agree on + # every (callee, key, reason, prov) while the graph collapses their per-site duplicates -- + # ``test_java_entrypoints_live.py`` states that lossiness and pins the counts. + assert sorted((u.src, u.dst, tuple(u.prov)) for u in neo.get_config_uses()) == sorted((u.src, u.dst, tuple(u.prov)) for u in ref.get_config_uses()) + assert {(r.callee, r.key, r.reason) for r in neo.get_unresolved_config_reads()} == {(r.callee, r.key, r.reason) for r in ref.get_unresolved_config_reads()} # The Java wire's own artifact models carry two fields the shared Py* ones have no home for. assert sorted(neo.application.artifacts) == sorted(ref.application.artifacts) assert {p: (a.text_truncated, a.sha256) for p, a in neo.application.artifacts.items()} == {p: (a.text_truncated, a.sha256) for p, a in ref.application.artifacts.items()} diff --git a/tests/analysis/java/test_java_neo4j_multi_application_scope.py b/tests/analysis/java/test_java_neo4j_multi_application_scope.py index 20bffe3..5d42bb7 100644 --- a/tests/analysis/java/test_java_neo4j_multi_application_scope.py +++ b/tests/analysis/java/test_java_neo4j_multi_application_scope.py @@ -49,6 +49,7 @@ from cldk.analysis.java.neo4j import neo4j_backend from cldk.analysis.java.neo4j.neo4j_backend import JNeo4jBackend +from cldk.utils.exceptions.exceptions import CodeanalyzerExecutionException from .conftest import FakeDriver @@ -531,7 +532,13 @@ def test_the_artifact_layer_is_reached_only_through_the_application_anchor(): assert backend.get_artifacts()["pom.xml"].source == "", "application B's pom.xml leaked" assert [d.name for d in backend.get_dependencies()] == ["alpha-core"] assert [ck.key for ck in backend.get_config_keys().values()] == ["alpha.key"] - assert backend.get_config_uses() == [] and backend.get_unresolved_config_reads() == [] + # The code-to-config layer is codeanalyzer-java 3.1.0's, and this graph is a 3.0.1 one (see the + # module docstring), so it refuses here rather than answering the empty that would read as + # "application A reads no configuration". The other half of that ruling -- a 3.1.0-shaped + # analysis answering, empty or not -- is pinned in ``test_java_entrypoints.py``. + for call in (backend.get_config_uses, backend.get_unresolved_config_reads): + with pytest.raises(CodeanalyzerExecutionException, match="3.1.0"): + call() def test_the_addressing_surface_answers_from_this_application_only(): @@ -579,7 +586,8 @@ def test_the_task_three_surface_answers_from_this_application_only(): assert backend.get_entrypoints() == [] and backend.get_entrypoint_classes() == [] assert [d.code for d in backend.get_entrypoint_coverage().diagnostics] == ["entrypoint_report_unavailable"] assert backend.get_interfaces() == {} and backend.get_enums() == {} and backend.get_records() == {} - assert backend.get_config_readers("alpha.key") == [] + with pytest.raises(CodeanalyzerExecutionException, match=APP_A): + backend.get_config_readers("alpha.key") def test_external_symbols_are_this_applications_ghosts_only(): @@ -664,6 +672,11 @@ def test_a_module_row_that_is_not_a_type_is_refused_by_the_model(): "J_HAS_BODY_NODE", "HAS_ARTIFACT", "DEFINES_CONFIG", + # codeanalyzer-java 3.1.0: runs from the application node to the ghost callee of a config + # read it could not resolve, minted under the same prefix. ``J_USES_CONFIG`` is deliberately + # **not** here -- its two endpoints live in the two different id spaces (code and artifact), + # so each carries its own scope and neither may be inferred from the other. + "J_READS_CONFIG_UNRESOLVED", } ) diff --git a/tests/analysis/java/test_java_v1_accessors_live.py b/tests/analysis/java/test_java_v1_accessors_live.py index 581c70f..edf8dca 100644 --- a/tests/analysis/java/test_java_v1_accessors_live.py +++ b/tests/analysis/java/test_java_v1_accessors_live.py @@ -73,7 +73,7 @@ TRADE_DIRECT = f"{DIRECT}.TradeDirect" PING_SERVLET = "com.ibm.websphere.samples.daytrader.web.prims.PingServlet" -#: Measured on the reference graph (daytrader8, codeanalyzer-java 3.0.3), 2026-09-07. +#: Measured on the reference graph (daytrader8, codeanalyzer-java 3.1.0), 2026-09-07. DT_IMPORTS = 268 DT_CALLABLES = 1216 DT_DECLARING_LOCALS, DT_LOCALS = 235, 854 diff --git a/tests/analysis/java/test_jcodeanalyzer.py b/tests/analysis/java/test_jcodeanalyzer.py index d590bf3..f65a746 100644 --- a/tests/analysis/java/test_jcodeanalyzer.py +++ b/tests/analysis/java/test_jcodeanalyzer.py @@ -15,7 +15,7 @@ ################################################################################ """ -Test Cases for JCodeanalyzer (codeanalyzer-java 3.0.3, schema v2). The analyzer subprocess is +Test Cases for JCodeanalyzer (codeanalyzer-java 3.1.0, schema v2). The analyzer subprocess is mocked; ``analysis_json`` is daytrader8 at ``-a 1`` (no call graph), ``analysis_json_a4`` the four-file ``-a 4`` slice (247 call edges). """ @@ -103,7 +103,7 @@ def test_unknown_level_raises_instead_of_defaulting(test_fixture, analysis_json) def test_envelope_and_application_are_kept(test_fixture, analysis_json): analyzer, _ = _analyzer(analysis_json, project_dir=test_fixture) assert analyzer.analysis.schema_version == "2.0.0" - assert analyzer.analysis.analyzer.version == "3.0.3" + assert analyzer.analysis.analyzer.version == "3.1.0" assert analyzer.analysis.max_level == 1 assert analyzer.application is analyzer.analysis.application assert analyzer.application.id == "can://java/daytrader8" diff --git a/tests/conftest.py b/tests/conftest.py index 0e11918..3144500 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -68,7 +68,7 @@ def analysis_json(analysis_json_fixture) -> str: @pytest.fixture(scope="session") def analysis_json_a4(analysis_json_fixture) -> str: - """The codeanalyzer-java 3.0.3 ``-a 4`` fixture (``v2/a4``) as a JSON string.""" + """The codeanalyzer-java 3.1.0 ``-a 4`` fixture (``v2/a4``) as a JSON string.""" with gzip.open(analysis_json_fixture.parent / "a4" / "analysis.json.gz", "rt", encoding="utf-8") as json_data: return json.dumps(json.load(json_data)) diff --git a/tests/models/java/test_java_models.py b/tests/models/java/test_java_models.py index 07d5112..a116ece 100644 --- a/tests/models/java/test_java_models.py +++ b/tests/models/java/test_java_models.py @@ -14,7 +14,7 @@ # limitations under the License. ################################################################################ -"""Schema-level tests for the Java v2 models: the codeanalyzer-java 3.0.3 envelope parses at +"""Schema-level tests for the Java v2 models: the codeanalyzer-java 3.1.0 envelope parses at L1 and L4, round-trips losslessly, and rejects anything the wire does not carry.""" import json @@ -46,7 +46,7 @@ def test_envelope_at_both_levels(a1: JAnalysis, a4: JAnalysis): assert a.max_level == level assert a.k_limit is None # never emitted by 3.0.x assert a.analyzer.name == "codeanalyzer-java" - assert a.analyzer.version == "3.0.3" + assert a.analyzer.version == "3.1.0" assert a.application.id == "can://java/daytrader8" assert a.application.kind == "application" diff --git a/tests/resources/java/analysis_json/v2/README.md b/tests/resources/java/analysis_json/v2/README.md index 2614b33..b90171a 100644 --- a/tests/resources/java/analysis_json/v2/README.md +++ b/tests/resources/java/analysis_json/v2/README.md @@ -1,5 +1,5 @@ Generated from `tests/resources/java/application/daytrader8-1.2.zip` (unzipped to `` = -`sample.daytrader8-1.2/`) by **codeanalyzer-java 3.0.3** — the pinned `codeanalyzer-java` wheel (the +`sample.daytrader8-1.2/`) by **codeanalyzer-java 3.1.0** — the pinned `codeanalyzer-java` wheel (the `cldk[java]` extra), run as `codeanalyzer_java.command()`: the wheel's own jar on the JVM it bundles (`jdk4py`, Temurin 21.0.8), with `JAVA_HOME` unset. **Never hand-edit these files** — regenerate them when the pin moves. @@ -24,14 +24,46 @@ earlier by a whole-project `-a 4` run of the analyzer on the unpruned tree (the java -jar codeanalyzer.jar -i -a 4 --no-build --app-name daytrader8 -o a4 -c /fx-cache-a4 -Measured on the committed files: `a1` has `schema_version 2.0.0`, `analyzer.version 3.0.3`, +Measured on the committed files: `a1` has `schema_version 2.0.0`, `analyzer.version 3.1.0`, `max_level 1`, 138 symbol-table keys, no `call_graph`/`param_in`/`param_out` keys at all, 235 -artifacts. `a4` has `max_level 4`, 4 symbol-table keys, `call_graph` 247 edges, `param_in` 258, -`param_out` 97, 2,358 `ddg` edges (2,038 `ssa`, 320 with `prov == ["points-to"]`), 76 `summary` -edges, 235 artifacts, and both `cancelOrder` overloads on `TradeDirect` -(`cancelOrder(java.lang.Integer, boolean)`, `cancelOrder(java.sql.Connection, java.lang.Integer)`). -Artifact text (default `--artifact-text`, 256 KiB cap) is included in both; the four -`jmeter_files/*.jmx` are the largest entries. +artifacts, 149 types and 1,216 callables (133 of them `is_entrypoint`, in 66 `is_entrypoint_class` +types). `a4` has `max_level 4`, 4 symbol-table keys, 128 callables, `call_graph` 247 edges, +`param_in` 258, `param_out` 97, 2,358 `ddg` edges (2,038 `ssa`, 320 with `prov == ["points-to"]`, +20 self-loops), 1,570 `cfg`, 1,258 `cdg`, 76 `summary` edges, 235 artifacts, and both `cancelOrder` +overloads on `TradeDirect` (`cancelOrder(java.lang.Integer, boolean)`, +`cancelOrder(java.sql.Connection, java.lang.Integer)`). Artifact text (default `--artifact-text`, +256 KiB cap) is included in both; the four `jmeter_files/*.jmx` are the largest entries. + +**The 3.1.0 overlays, and what each fixture carries of them.** Both carry `entrypoint_report` +(`a1`: `frameworks_detected ["jakarta", "jaxrs", "spring"]`; `a4`: `["jakarta"]`; both with the +same five `rulesets`, nothing unresolved, no errors) and `entrypoint_frameworks` on every callable +and type — non-empty on exactly the marked ones, 133 callables and 66 types in `a1`, 13 callables +and no type in `a4`. `a1` carries **13** `config_uses` (every one `prov == ["literal"]`, all in +`TradeWebContextListener.contextInitialized`, over 11 distinct `daytrader.properties` keys) and +**16** `config_reads_unresolved` (all `reason "undefined-key"`, the `System.getenv` reads, `prov +["literal"]` — level 1 has no DDG for the dataflow tier to run over; the same reads carry +`["literal", "dataflow"]` at level 4). `a4` carries **neither key at all**: its pruned tree reads no +configuration, and the analyzer writes those two only when non-empty. That is the measurement the +SDK's overlay probe rests on — an absent `config_uses` cannot mean "this analyzer had no detector", +so the probe is the entrypoint report's presence instead (`cldk/analysis/java/backend.py`, +`CONFIG_OVERLAY_UNAVAILABLE`). + +## What the 3.1.0 regeneration moved + +Both files were regenerated with the commands above when the pin moved from 3.0.3 to 3.1.0 +(python-sdk#369, analyzer-side codeanalyzer-java#233 / #235 / #237). Measured by loading the old and +new copies and comparing them structurally: **both are identical to their 3.0.3 copies apart from +the `analyzer.version` stamp and the additive fields** — `entrypoint_report`, `config_uses` and +`config_reads_unresolved` on the application, `entrypoint_frameworks` on every type and callable. +Every figure published above and below this section was re-measured on the new files and none of +them moved: 138 units / 149 types / 1,216 callables / 235 artifacts in `a1`, and `a4`'s whole +dataflow structure (2,358 `ddg`, 2,038 `ssa`, 320 `points-to`, 20 self-loops, 1,570 `cfg`, 1,258 +`cdg`, 76 `summary`, 247 `call_graph`, 258 `param_in`, 97 `param_out`), as well as the signature +table further down (154 / 0 / 45 of 45 for `a1`, 8 / 4 / 4 of 5 for `a4`). + +Note that the new fields make a 3.1.0 payload **unparsable by the pre-#369 models**, which are +`extra="forbid"`: the graph contract stays at 2.0.0 and the wire is additive, but the SDK's mirror +had to grow the five fields before it could read one. ## What the 3.0.3 regeneration moved diff --git a/tests/resources/java/analysis_json/v2/a1/analysis.json.gz b/tests/resources/java/analysis_json/v2/a1/analysis.json.gz index 5243256..6526566 100644 Binary files a/tests/resources/java/analysis_json/v2/a1/analysis.json.gz and b/tests/resources/java/analysis_json/v2/a1/analysis.json.gz differ diff --git a/tests/resources/java/analysis_json/v2/a4/analysis.json.gz b/tests/resources/java/analysis_json/v2/a4/analysis.json.gz index eb27795..a0494aa 100644 Binary files a/tests/resources/java/analysis_json/v2/a4/analysis.json.gz and b/tests/resources/java/analysis_json/v2/a4/analysis.json.gz differ diff --git a/uv.lock b/uv.lock index d2d2a55..76d31bb 100644 --- a/uv.lock +++ b/uv.lock @@ -350,7 +350,7 @@ test = [ requires-dist = [ { name = "cldk", extras = ["java"], marker = "extra == 'all'" }, { name = "cldk", extras = ["neo4j"], marker = "extra == 'all'" }, - { name = "codeanalyzer-java", marker = "extra == 'java'", specifier = "==3.0.3" }, + { name = "codeanalyzer-java", marker = "extra == 'java'", specifier = "==3.1.0" }, { name = "codeanalyzer-python", specifier = "==1.4.1" }, { name = "codeanalyzer-typescript", specifier = "==1.5.0" }, { name = "neo4j", marker = "extra == 'neo4j'", specifier = ">=5.14,<7" }, @@ -398,13 +398,13 @@ wheels = [ [[package]] name = "codeanalyzer-java" -version = "3.0.3" +version = "3.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jdk4py" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/af/647c7ecfc0bb4fc326c85c2a9a2e627c561e3b66cc9c9e6af6044a2bfea5/codeanalyzer_java-3.0.3-py3-none-any.whl", hash = "sha256:c71cefc2b08fbc0cbc9198fc2bc27a44034611f2ee2615993ce7e6e5cd0308f8", size = 32805303, upload-time = "2026-09-07T04:52:37.743Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f8/6c5d4d9399d97ee7b57ffb015073834eb856202ef9b42baaa44b9f3c44c6/codeanalyzer_java-3.1.0-py3-none-any.whl", hash = "sha256:b0efd7bfa3046a8f2bf7d781409b723c7b7f42526f59e88f2dc27a2b91e64a5f", size = 32835152, upload-time = "2026-09-07T22:48:54.142Z" }, ] [[package]]