Skip to content

Commit 068da62

Browse files
authored
feat(python): leg 1.6 — scope by can:// id prefix, retire _module, pin codeanalyzer-python 1.4.1 (#330)
feat(python): leg 1.6 — scope by can:// id prefix, retire _module, pin codeanalyzer-python 1.4.1
2 parents ff0d00c + 63b19ae commit 068da62

27 files changed

Lines changed: 1748 additions & 393 deletions

‎CHANGELOG.md‎

Lines changed: 46 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,31 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10-
Python legs 1 and 1.5 of the CLDK 2.0 agent-facing query facade (see
11-
`docs/design/specs/2026-09-03-agent-facing-query-facade.md` and
12-
`docs/design/specs/2026-09-05-leg-1.5-bounded-queries-and-dataflow.md`). Targets `2.0.0-rc.1`.
10+
Python legs 1, 1.5 and 1.6 of the CLDK 2.0 agent-facing query facade (see
11+
`docs/design/specs/2026-09-03-agent-facing-query-facade.md`,
12+
`docs/design/specs/2026-09-05-leg-1.5-bounded-queries-and-dataflow.md` and
13+
`docs/design/specs/2026-09-06-leg-1.6-id-prefix-scoping.md`). Targets `2.0.0-rc.2`.
1314

1415
### Changed
16+
- **Pinned `codeanalyzer-python` 1.4.0 → 1.4.1** (`pyproject.toml` `dependencies` and
17+
`[tool.backend-versions]`). 1.4.1 removes the `_module` node property from every graph it emits
18+
(upstream #183), so the Neo4j backend no longer scopes on it: **every statement is scoped to the
19+
application by its `can://` id prefix** (`n.id STARTS WITH 'can://python/<app>/'`), the rule the
20+
analyzer's own destructive statements use, and a callable's repo-relative `path` is derived from
21+
its id and verified against the application's module keys rather than projected from a property.
22+
No accessor changes name, signature, return type or value; the `_module` scoping is simply gone.
23+
Ghosts (`:PyExternal`) fall inside the prefix, so every call-graph walk pins its traversal source
24+
to `:PyCallable` by label -- an `@external` node is still reached and never traversed through.
25+
- **The schema probe reads the graph's analyzer generation.** Attaching to a graph whose
26+
`:PyApplication.analyzer_version` is below **1.4.0** (or missing) raises `GraphSchemaMismatch`
27+
naming the version found and the floor -- before this it was served with silent empties. A
28+
**1.4.0** graph and a **1.4.1** graph are served identically and silently: both carry the unique
29+
`:PySymbol(id)` range index, so `locate` / `locate_many` and `resolve_callable` anchor on
30+
`(c:PyCallable:PySymbol)` and the prefix *seeks* on either generation (40 positions: 381 → 46 ms
31+
on 1.4.1, 427 → 53 ms on 1.4.0; `resolve_callable` 19.2 → 15.3 ms on 1.4.1, a wash on 1.4.0).
32+
1.4.1's `:PyCanNode(id)` index was measured and rejected -- it spans all 955,961 application
33+
nodes, so seeking it made `resolve_callable` 10× slower -- and no statement names it. Statements
34+
whose prefix is the whole application stay on the `:PyCallable` label scan, which is faster there.
1535
- **BREAKING: Neo4j graph vocabulary migration.** The Python Neo4j backend
1636
(`cldk.analysis.python.neo4j.PyNeo4jBackend`) now queries `PyBodyNode` / `PY_HAS_BODY_NODE`
1737
instead of the pre-1.4.0 `PyCallSite` / `PY_HAS_CALLSITE` / `PySymbol` vocabulary, matching what
@@ -43,8 +63,9 @@ Python legs 1 and 1.5 of the CLDK 2.0 agent-facing query facade (see
4363
`PyClassOverview.path` and the keys of `get_symbol_table()`, which it previously could not be
4464
joined against. Both backends changed together: the Neo4j projections behind
4565
`get_callables_overview()`, `get_decorated_callables()`, `get_entrypoints()` and
46-
`get_config_readers()` now read `:PyCallable._module` rather than `:PyCallable.path`, and the
47-
local backend projects the symbol table key rather than `PyCallable.path`.
66+
`get_config_readers()` now project the module key rather than `:PyCallable.path` (from the
67+
`_module` property on a 1.4.0 graph in leg 1; derived from the node's `can://` id since leg 1.6),
68+
and the local backend projects the symbol table key rather than `PyCallable.path`.
4869
**Migration:** a caller that stored or persisted these paths will see different strings for the
4970
same callable, and one that stripped a project-root prefix off them must stop.
5071

@@ -104,7 +125,7 @@ Python legs 1 and 1.5 of the CLDK 2.0 agent-facing query facade (see
104125
| SDK version | Requires `codeanalyzer-python` | Graph vocabulary |
105126
| --- | --- | --- |
106127
| <= 1.5.0 | 0.3.x | `PyCallSite` / `PY_HAS_CALLSITE` / `PySymbol` |
107-
| 2.0.0-rc.1 (this) | >= 1.4.0 | `PyBodyNode` / `PY_HAS_BODY_NODE` |
128+
| 2.0.0-rc.2 (this) | 1.4.1 pinned; graphs emitted by >= 1.4.0 served identically | `PyBodyNode` / `PY_HAS_BODY_NODE`, scope by `can://` id prefix |
108129

109130
### Added
110131
- **Slices and reachability: `slice_backward(src, within=)` / `slice_forward(src, within=)` /
@@ -312,6 +333,23 @@ Python legs 1 and 1.5 of the CLDK 2.0 agent-facing query facade (see
312333
schema probe at attach time; see the breaking-change note above.
313334

314335
### Fixed
336+
- **`get_entrypoint_coverage()` over Neo4j reads the projected report.** It answered with an
337+
`entrypoint_report_unavailable` diagnostic unconditionally; codeanalyzer-python 1.4.1 (#182)
338+
projects `entrypoint_frameworks` / `entrypoint_report_json` onto `:PyApplication`, so on such a
339+
graph the answer is now the pass's own `PyEntrypointReport`, field for field what the local
340+
backend returns. The diagnostic survives only for a graph that genuinely lacks the property (one
341+
emitted by 1.4.0).
342+
- **Resolved through the 1.4.1 pin** -- python-sdk's upstream reports #176 / #177 / #178, fixed
343+
in codeanalyzer-python as #180 (body nodes and parameters carry their `id` in `analysis.json`;
344+
the SDK's composed body-node id is now pinned equal to the analyzer's own per run), #182 / #185
345+
(entrypoint report projected, Odoo `@http.route` / `http.Controller` detected: 534 callables and
346+
94 classes on the same checkout that 1.4.0 flagged 0 / 0), #181 (`PY_EXTENDS` is actually emitted:
347+
1,573 edges where every 2.0.0 graph had none) and #183 (`_module` retired, `:PyCanNode` range
348+
index on `id`).
349+
- **The N+1 timing assertion measured the coverage tracer, not the query.** The two timed live
350+
tests (`get_symbol_table` / `get_classes` under 15 s) ran under `sys.settrace`, which adds ~5 s
351+
to a ~10 s Python-side reconstruction; they passed the ceiling by luck. They now run with coverage
352+
paused (`pytest-cov`'s `no_cover`), so the ceiling measures the round trips it exists to bound.
315353
- **Java: a missing JDK cache root is refused, not crashed on.** `JCodeanalyzer._get_codeanalyzer_exec`
316354
now raises `CodeanalyzerExecutionException` ("no cache directory and no project directory")
317355
when neither is available, instead of letting `ensure_jdk` fail with `TypeError: ... not
@@ -328,7 +366,8 @@ Python legs 1 and 1.5 of the CLDK 2.0 agent-facing query facade (see
328366
child fetches (`get_class()` and everything reconstructing one declaration) matched by a bare
329367
`{signature: $sig}` / `{file_key: $fk}` while their bulk twins were application-scoped, so in a
330368
database holding two applications `get_class()` merged another application's members and
331-
`get_all_classes()` did not. All twelve carry the same `_module IN $mods` predicate now, and so
369+
`get_all_classes()` did not. All twelve carry the same application-scope predicate now (`_module
370+
IN $mods` when this landed, the `can://` id prefix since leg 1.6), and so
332371
do the leg-1.5 call-graph statements behind `reaches`, `backward_cone`, `call_paths_between` and
333372
`flows_to_call`, which had matched by signature unscoped too. Statements keyed only by a
334373
body-node or ghost id (`slice_*`, `paths_between`, the value-reachability predicate) are scoped

‎cldk/analysis/commons/results.py‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -263,9 +263,10 @@ class EntrypointCoverage(BaseModel):
263263
unresolved: Count of near-misses, keyed by rule/framework, that could not be resolved to a
264264
definite entrypoint — non-zero counts are exactly the under-approximation gap.
265265
errors: Hard failures the detection pass hit while running.
266-
diagnostics: Non-empty when a backend cannot supply this report at all: the Neo4j
267-
projection does not carry ``PyApplication.entrypoint_report`` on the graph (only the
268-
derived ``is_entrypoint``/``entrypoint_frameworks`` per-node properties), so it returns
266+
diagnostics: Non-empty when a backend cannot supply this report at all: a Neo4j graph
267+
emitted by codeanalyzer-python 1.4.0 does not carry ``PyApplication.entrypoint_report``
268+
(only the derived ``is_entrypoint``/``entrypoint_frameworks`` per-node properties;
269+
1.4.1 projects it onto ``:PyApplication``), so the Neo4j backend returns
269270
``entrypoint_report_unavailable`` here instead of fabricating empty-but-clean-looking
270271
fields — the same "say so honestly" precedent as ``LocateResult``'s
271272
``module_source_unavailable``. When ``diagnostics`` is non-empty, the other fields are

‎cldk/analysis/python/backend.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -894,9 +894,9 @@ def get_entrypoint_coverage(self) -> EntrypointCoverage:
894894
so a caller can tell "the pass ran clean and found nothing" apart from "the pass had gaps"
895895
— a distinction :meth:`get_entrypoints`'s empty list alone cannot make. See
896896
:class:`~cldk.analysis.commons.results.EntrypointCoverage` for the field-by-field contract,
897-
including the per-backend availability caveat (the Neo4j projection does not carry this
898-
report at all; that backend answers with a ``diagnostics``-only result rather than
899-
fabricating empty-but-clean-looking coverage fields)."""
897+
including the per-backend availability caveat (a Neo4j graph emitted by codeanalyzer-python
898+
1.4.0 does not carry this report; that backend then answers with a ``diagnostics``-only
899+
result rather than fabricating empty-but-clean-looking coverage fields)."""
900900

901901
@property
902902
@abstractmethod

0 commit comments

Comments
 (0)