Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [v2.1.0] - 2026-10-31

- Add namespace/application bindings to Python, Java, JavaScript/TypeScript and Go backends.
- Preserve canonical IDs through URI resolution, module indexes and Neo4j reconstruction.
- Isolate caches and reject foreign or incompatible identity metadata before lookup.
- Pin namespace-ready analyzers: Python/TypeScript 1.7.0, Java 3.4.0 and Go 0.3.0.

Application roots are now `can://<namespace>/<application>`, including the repeated default
`can://inventory/inventory`. Regenerate existing analyses and graphs from source. SDK reads
remain read-only; legacy IDs are not rewritten or aliased.

## [v2.0.0] - 2026-10-15

CLDK 2.0 gives Python, Java and TypeScript one agent-facing query surface — addressing, per-callable
Expand Down
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,33 @@ analysis = CLDK.python(
)
```

### Namespace binding

Set the same raw identity pair on local analysis and graph attachment:

```python
from cldk import CLDK
from cldk.analysis.commons.backend_config import CodeAnalyzerConfig

analysis = CLDK.typescript(
project_path="online-boutique/src/currencyservice",
backend=CodeAnalyzerConfig(
namespace="online-boutique", application_name="currencyservice",
),
)
```

For `Neo4jConnectionConfig`, pass the same `namespace` and `application_name`.
Omitted application names use the selected input directory basename; omitted namespaces
use that resolved application name. A default inventory application therefore has root
`can://inventory/inventory`. Raw names are case sensitive and URI encoded once. Module
URIs from another application, namespace or language are rejected before path lookup.
Identity version 2 is required; regenerate legacy outputs and caches from source.

This branch requires coordinated namespace-ready producer artifacts. The currently
published dependency pins are retained and do not establish release compatibility;
use reviewed local producer builds until compatible releases are available.

| Language | Analyzer | Built on | Runs as |
| --- | --- | --- | --- |
| Java | [`codeanalyzer-java`](https://github.com/codellm-devkit/codeanalyzer-java) | JavaParser and WALA | a subprocess on the bundled JVM |
Expand Down
20 changes: 16 additions & 4 deletions cldk/analysis/commons/backend_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,13 @@

from __future__ import annotations

from dataclasses import dataclass
from dataclasses import dataclass, field
import hashlib
from pathlib import Path
from typing import Union

from cldk.analysis.commons.identity import ApplicationIdentity

# The canonical sub-directory name each language's artifacts live under inside the shared cache
# root. Keyed so that a polyglot repository analyzed under more than one language does not have its
# backends overwrite a single shared ``analysis.json``.
Expand All @@ -54,10 +57,14 @@ class CodeAnalyzerConfig:
Attributes:
cache_dir: Root directory for analysis artifacts. When ``None`` the facade defaults it to
``<project>/.codeanalyzer``. Each backend writes under a language-keyed subdirectory of
this root (see :func:`cache_subdir`), so the same root can be shared across languages.
this root (see :func:`cache_subdir`), with a separate digest for each application identity.
namespace: Raw namespace; defaults to the resolved application name.
application_name: Raw application name; defaults to the normalized project-root basename.
"""

cache_dir: Union[str, Path, None] = None
namespace: str | None = field(default=None, kw_only=True)
application_name: str | None = field(default=None, kw_only=True)


@dataclass
Expand Down Expand Up @@ -112,6 +119,7 @@ class Neo4jConnectionConfig:
password: str = "neo4j"
database: str | None = None
application_name: str | None = None
namespace: str | None = field(default=None, kw_only=True)


# Per-language discriminated unions the facades match on.
Expand All @@ -124,17 +132,19 @@ class Neo4jConnectionConfig:
GoBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig]


def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str) -> Path | None:
def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str, *, identity: ApplicationIdentity | None = None) -> Path | None:
"""Resolve the language-keyed cache directory for a backend.

Args:
cache_dir: The cache root from the backend config. When ``None``, defaults to
``<project_dir>/.codeanalyzer``.
project_dir: The project directory, used to derive the default root.
identity: Bound application identity; when supplied its digest isolates cached outputs.
language: The canonical language key (``"java"``, ``"python"``, ``"typescript"``, ``"go"``).

Returns:
``<root>/<language>`` as an absolute path, or ``None`` if no root can be determined
``<root>/<language>/<identity digest>`` (or the language directory when unbound)
as an absolute path, or ``None`` if no root can be determined
(no ``cache_dir`` and no ``project_dir``).
"""
key = _CACHE_KEYS.get(language, language)
Expand All @@ -144,4 +154,6 @@ def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path
root = Path(project_dir).expanduser().resolve() / ".codeanalyzer"
else:
return None
if identity is not None:
return root / key / hashlib.sha256(identity.application_id.encode()).hexdigest()
return root / key
114 changes: 114 additions & 0 deletions cldk/analysis/commons/identity.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
################################################################################
# Copyright IBM Corporation 2026
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################

"""Validated raw application identity and canonical URI ownership."""

import json
import unicodedata
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import quote

from cldk.utils.exceptions import CodeanalyzerExecutionException


@dataclass(frozen=True)
class ApplicationIdentity:
namespace: str
application_name: str
application_id: str


def _segment(value: str) -> str:
if not isinstance(value, str) or not value.strip() or value in {".", ".."} or any(c in "/\\" or unicodedata.category(c) in {"Cc", "Cs"} for c in value):
raise ValueError("identity names must be nonempty raw names without separators or control characters")
return quote(value, safe="-._~", encoding="utf-8", errors="strict")


def resolve_identity(application_name: str | None, namespace: str | None, *, project_root: Path | None = None) -> ApplicationIdentity:
if application_name is None:
if project_root is None:
raise CodeanalyzerExecutionException("application_name is required to scope queries to an application.")
application_name = Path(project_root).expanduser().resolve().name
namespace = application_name if namespace is None else namespace
return ApplicationIdentity(namespace, application_name, f"can://{_segment(namespace)}/{_segment(application_name)}")


def validate_identity(payload, identity: ApplicationIdentity, *, envelope: bool = True):
"""Reject legacy or foreign persisted data without synthesizing metadata."""
if envelope and hasattr(payload, "application"):
app_model = payload.application
if hasattr(app_model, "identity_version") and "identity_version" not in app_model.model_fields_set:
raise CodeanalyzerExecutionException("Analysis identity marker is missing from the application.")
raw = (
payload.model_dump(include={"identity_version": True, "language": True, "application": {"identity_version", "id", "application_name", "identity_namespace"}})
if envelope and hasattr(payload, "model_dump")
else payload
)
if not isinstance(raw, dict):
raise CodeanalyzerExecutionException("Analysis identity metadata must be an object.")
app = raw.get("application", {}) if envelope else raw
if not isinstance(app, dict):
raise CodeanalyzerExecutionException("Application identity metadata must be an object.")
if envelope and raw.get("language") != "java" and (type(app.get("identity_version")) is not int or app.get("identity_version") != 2):
raise CodeanalyzerExecutionException("Analysis identity marker is missing from the application.")
if (
type(raw.get("identity_version")) is not int
or raw.get("identity_version") != 2
or app.get("application_name") != identity.application_name
or app.get("identity_namespace") != identity.namespace
or app.get("id") != identity.application_id
):
raise CodeanalyzerExecutionException("Analysis identity metadata is missing, incompatible, or outside the requested application scope.")
return payload


def cache_matches(path: Path, identity: ApplicationIdentity) -> bool:
try:
validate_identity(json.loads(path.read_text(encoding="utf-8")), identity)
return True
except (OSError, ValueError, CodeanalyzerExecutionException):
return False


def index_module_rows(rows: Iterable[Mapping[str, str]]) -> dict[str, dict[str, str]]:
"""Collapse repeated module projections by canonical identity, never by filename."""
index: dict[str, dict[str, str]] = {}
for row in rows:
module_id, file_key = row["module_id"], row["file_key"]
if module_id in index and index[module_id]["file_key"] != file_key:
raise ValueError(f"Conflicting file keys for canonical module {module_id}: {index[module_id]['file_key']!r} and {file_key!r}")
index[module_id] = {"module_id": module_id, "file_key": file_key}
return index


def scoped_module_keys(index: Mapping[str, Mapping[str, str]], application_id: str, languages: tuple[str, ...]) -> dict[str, str]:
"""Derive the relative-key API only after validating owner and language.

Candidate IDs remain in the primary index; an ambiguous relative key cannot silently
select whichever module happened to be projected last.
"""
candidates: dict[str, set[str]] = {}
prefixes = tuple(f"{application_id}/{language}/" for language in languages)
for module_id, row in index.items():
if not module_id.startswith(prefixes):
raise ValueError(f"Module URI is outside the bound application/language scope: {module_id}")
candidates.setdefault(row["file_key"], set()).add(module_id)
for key, ids in candidates.items():
if len(ids) != 1:
raise ValueError(f"Ambiguous module file key {key!r}: {sorted(ids)}")
return {key: next(iter(ids)) for key, ids in candidates.items()}
68 changes: 43 additions & 25 deletions cldk/analysis/commons/keys.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,38 +26,44 @@

import os
import posixpath
from typing import Collection, Iterable, List, Sequence
from typing import Collection, Iterable, List, Sequence, Mapping
from pathlib import Path

from cldk.analysis.commons.bounds import check_depth, check_selector, reject_bare_string


def resolve_module_key(path: str, keys: Iterable[str]) -> str:
"""The symbol-table / graph ``file_key`` naming ``path``, or ``path`` unchanged if none does.

A caller of :meth:`PythonAnalysisBackend.locate` hands over whatever its scanner printed —
``./src/app.py``, ``src/../src/app.py``, or an absolute path from the machine the scan ran on —
while both backends are keyed by the project-relative path the analyzer saw. Exact key first,
then the normalised form, then the longest known key the normalised path *ends on a segment
boundary* of (which is what an absolute path is). Returning ``path`` unchanged when nothing
matches is deliberate: the caller then gets ``file_not_in_graph`` naming the path it asked
about, not a silently substituted neighbour.
"""
keys = list(keys)
if path in keys:
def resolve_module_key(
path: str, keys: Iterable[str], *, application_id: str | None = None, module_ids: Mapping[str, str] | None = None, source_root: str | Path | None = None
) -> str:
"""Resolve only within the bound application's verified module IDs and source root."""
path = str(path)
known = list(keys)
if path.startswith("can:") or "://" in path:
if application_id is None or not path.startswith(application_id + "/") or module_ids is None:
raise ValueError("Module URI is outside the bound application scope")
matches = [key for key, value in module_ids.items() if value == path and key in known]
if len(matches) != 1:
raise ValueError("Module URI is not a module in the selected language view")
return matches[0]
if Path(path).is_absolute():
if source_root is None or not Path(source_root).is_dir():
raise ValueError("Absolute module paths require a bound source root")
try:
path = Path(path).resolve().relative_to(Path(source_root).resolve()).as_posix()
except ValueError as exc:
raise ValueError("Absolute module path is outside the bound source root") from exc
if path in known:
return path
norm = posixpath.normpath(str(path).replace(os.sep, "/"))
if norm in keys:
return norm
suffix_matches = [k for k in keys if norm.endswith("/" + k)]
return max(suffix_matches, key=len) if suffix_matches else path
norm = posixpath.normpath(path.replace(os.sep, "/"))
return norm if norm in known else path


def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "paths") -> List[str] | None:
def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "paths", **scope) -> List[str] | None:
"""Resolve requested module paths to symbol-table keys, or ``None`` for "the whole application".

Both backends route their ``paths=`` / ``module=`` keywords through here, so the lenient
resolution (:func:`resolve_module_key` — an absolute path or one with native separators finds
its module) and the strictness (:func:`check_selector` — a path naming no module raises) cannot
resolution (:func:`resolve_module_key` — relative keys, verified module URIs, or absolute
paths under the existing bound source root) and the strictness (:func:`check_selector` — a path naming no module raises) cannot
drift apart between them.

Args:
Expand All @@ -68,8 +74,8 @@ def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "p
single-valued keyword routes through here as a one-element sequence.

**Resolution is many-to-one, and the result is de-duplicated.** Leniency is the whole point of
:func:`resolve_module_key` — ``"pkg/a.py"`` and ``"/abs/pkg/a.py"`` are two spellings a scanner
may plausibly hand over for the *same* module — so two requested paths legitimately collapse to
:func:`resolve_module_key` — a relative key and an absolute path under the bound root may
name the *same* module — so two requested paths legitimately collapse to
one key and the caller gets one entry back. Raising on the collapse would punish the very
caller the leniency exists for; de-duplicating explicitly is what keeps the returned list from
naming the same module twice and asking both backends to fetch it twice.
Expand All @@ -83,7 +89,7 @@ def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "p
if paths is None:
return None
known = list(keys)
resolved = [resolve_module_key(p, known) for p in paths]
resolved = [resolve_module_key(p, known, **scope) for p in paths]
check_selector(kind, list(paths), [p for p, r in zip(paths, resolved) if r not in known])
return list(dict.fromkeys(resolved))

Expand Down Expand Up @@ -182,3 +188,15 @@ def body_key_column(key: str) -> int:
"""
_, _, col = key.split("/", 1)[0].partition(":")
return int(col) if col.isdigit() else -1


def module_scope(backend) -> dict:
"""Carry the backend's selected owner and existing source root into path resolution."""
identity = backend.identity
if hasattr(backend, "_module_ids"):
ids = backend._module_ids
elif hasattr(backend, "_module_props"):
ids = {key: props["id"] for key, props in backend._module_props.items()}
else:
ids = {key: value.id for key, value in backend.get_application_view().symbol_table.items()}
return dict(application_id=identity.application_id, module_ids=ids, source_root=getattr(backend, "source_root", getattr(backend, "project_dir", None)))
Loading
Loading