Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .claude/FACADE_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,21 @@
Decision log for SDK-surface design (per designing-cldk-changes, sdk-facade-design-loop).
One line per locked decision; newest section first.

## 2026-07-27 — L3/L4 verb wiring is staged per release

- **Staging:** rc.1 wires the five slice/flow verbs on the Python facade only (local + Neo4j);
rc.2 adds TypeScript; rc.3 adds the Java honest-degrade leg; rc.4 (Go) and rc.5 (C++) are
new-language legs entering via designing-cldk-changes (the C++ leg absorbs the existing C
facade); 2.0.0 final swaps the Rust query core (#279) in under the verbs, restores the
all-language DoD, and closes #270.
- **URI minting:** providers mint body-vertex URIs as `<callable_id>@<local_key sans leading @>`,
matching the analyzers' own param_in/param_out vocabulary (the real 1.0.2 Neo4j emitter stores
these can:// ids directly on PyCFGNode). Local keys never escape a provider.
- **source_slice contract:** existing span-less (synthetic) vertex → `(module_path, None)`;
unknown vertex → `(None, None)`; never derive a line from key shape. Both backends identical.
- **Rust hand-off unchanged:** the Rust query core (#279) replaces the Python engine underneath
these verbs post-M1; the facade surface added here is the stable contract it must satisfy.

## 2026-07-21 — Rust query engine (fluent API core)

- **M1 scope:** Epic B success criteria — the two Odoo PoE audit queries (#155), single-language,
Expand Down
23 changes: 23 additions & 0 deletions cldk/analysis/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,26 @@ class AnalysisLevel(str, Enum):
call_graph = "call graph"
program_dependency_graph = "program dependency graph"
system_dependency_graph = "system dependency graph"


def to_analysis_level(value) -> AnalysisLevel:
"""Normalize a level given as an AnalysisLevel, its value ("call graph"),
or its name ("call_graph") — both spellings are in the wild."""
if isinstance(value, AnalysisLevel):
return value
try:
return AnalysisLevel(value)
except ValueError:
try:
return AnalysisLevel[value]
except KeyError:
raise ValueError(f"unknown analysis level: {value!r}") from None


#: Facade-level vocabulary → the analyzers' integer analysis level (schema 2.0 ``max_level``).
ANALYSIS_LEVEL_TO_INT = {
AnalysisLevel.symbol_table: 1,
AnalysisLevel.call_graph: 2,
AnalysisLevel.program_dependency_graph: 3,
AnalysisLevel.system_dependency_graph: 4,
}
3 changes: 2 additions & 1 deletion cldk/analysis/python/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@

import networkx as nx

from cldk.graph.provider import ProgramGraphProvider
from cldk.models.python import (
PyApplication,
PyCallable,
Expand All @@ -45,7 +46,7 @@
)


class PythonAnalysisBackend(ABC):
class PythonAnalysisBackend(ProgramGraphProvider, ABC):
"""Abstract base every Python analysis backend implements.

A backend owns all indexing and query logic for a Python application; the
Expand Down
51 changes: 36 additions & 15 deletions cldk/analysis/python/codeanalyzer/codeanalyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,9 @@
from codeanalyzer.options import AnalysisOptions
from codeanalyzer.schema import model_dump_json

from cldk.analysis import AnalysisLevel
from cldk.analysis import ANALYSIS_LEVEL_TO_INT, AnalysisLevel, to_analysis_level
from cldk.analysis.python.backend import PythonAnalysisBackend
from cldk.graph._cpg_local import CpgLocalProviderMixin
from cldk.utils.exceptions import CldkSchemaMismatchException
from cldk.models.python import (
PyApplication,
Expand Down Expand Up @@ -104,7 +105,7 @@ def _overview(c: PyCallable, class_signature: str | None, kind: str) -> PyCallab
)


class PyCodeanalyzer(PythonAnalysisBackend):
class PyCodeanalyzer(CpgLocalProviderMixin, PythonAnalysisBackend):
"""In-process driver for the ``codeanalyzer-python`` analysis backend.

This class serves as the primary interface to the codeanalyzer-python
Expand Down Expand Up @@ -194,6 +195,7 @@ def __init__(
if not self.project_dir.is_dir():
raise ValueError(f"project_dir does not exist or is not a directory: {self.project_dir}")
self.analysis_level = analysis_level
self._level_int = ANALYSIS_LEVEL_TO_INT[to_analysis_level(analysis_level)]
self.eager_analysis = eager_analysis
self.target_files = target_files
self.use_ray = use_ray
Expand All @@ -211,7 +213,7 @@ def __init__(
for class_sig in module.types:
self._class_to_file[class_sig] = file_path

if analysis_level == AnalysisLevel.call_graph:
if self._level_int >= 2:
self.call_graph: nx.DiGraph | None = self._build_call_graph(self.application.call_graph, self._id_to_signature())
else:
self.call_graph = None
Expand Down Expand Up @@ -246,18 +248,27 @@ def _run_analyzer(self) -> PyApplication:
logger.warning("codeanalyzer-python supports only a single target file; using the first.")
target_file = Path(self.target_files[0])

options = AnalysisOptions(
input=self.project_dir,
output=self.analysis_json_path,
format=OutputFormat.JSON,
using_ray=self.use_ray,
rebuild_analysis=self.eager_analysis,
skip_tests=True,
file_name=target_file,
cache_dir=self.cache_dir,
clear_cache=False,
verbosity=0,
)
opts = {
"input": self.project_dir,
"output": self.analysis_json_path,
"format": OutputFormat.JSON,
"using_ray": self.use_ray,
"rebuild_analysis": self.eager_analysis,
"skip_tests": True,
"file_name": target_file,
"cache_dir": self.cache_dir,
"clear_cache": False,
"verbosity": 0,
}
# Add analysis_level if _level_int is available (set in __init__). The guard IS
# load-bearing, not dead defensiveness: tests/analysis/python/test_python_schema_contract.py
# exercises this method on a `PyCodeanalyzer.__new__` instance that never ran `__init__`
# (and so never set `_level_int`) to isolate the schema-envelope gate below from analyzer
# construction — removing the guard would make those tests raise AttributeError.
if hasattr(self, "_level_int"):
opts["analysis_level"] = self._level_int

options = AnalysisOptions(**opts)

with Codeanalyzer(options) as analyzer:
analysis = analyzer.analyze()
Expand All @@ -266,8 +277,18 @@ def _run_analyzer(self) -> PyApplication:
f"analysis schema {analysis.schema_version!r} from codeanalyzer-python, this SDK speaks "
f"{self.SUPPORTED_ANALYSIS_SCHEMA!r} — align the pinned codeanalyzer-python with the SDK"
)
# The envelope reports what the analyzer actually computed; the capability gate
# (cldk/graph/capability.py) reads it via max_level() — recorded, never sniffed.
# Schema 2.0.0+'s Analysis envelope always carries max_level (default 1), so capturing
# it is unconditional here; an envelope that somehow lacks the attribute should fail
# loudly rather than silently fabricate a level.
self._max_level: int = analysis.max_level
return analysis.application

def max_level(self) -> int:
"""The analysis level of the underlying run (1-4), as reported by the analyzer."""
return self._max_level

def _id_to_signature(self) -> Dict[str, str]:
"""Map every symbol-table callable's ``can://`` id to its dotted signature.

Expand Down
Loading