Skip to content

Commit f138651

Browse files
authored
feat: scope SDK identities by namespace
1 parent 1b49043 commit f138651

96 files changed

Lines changed: 2174 additions & 588 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,17 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [v2.1.0] - 2026-10-31
9+
10+
- Add namespace/application bindings to Python, Java, JavaScript/TypeScript and Go backends.
11+
- Preserve canonical IDs through URI resolution, module indexes and Neo4j reconstruction.
12+
- Isolate caches and reject foreign or incompatible identity metadata before lookup.
13+
- Pin namespace-ready analyzers: Python/TypeScript 1.7.0, Java 3.4.0 and Go 0.3.0.
14+
15+
Application roots are now `can://<namespace>/<application>`, including the repeated default
16+
`can://inventory/inventory`. Regenerate existing analyses and graphs from source. SDK reads
17+
remain read-only; legacy IDs are not rewritten or aliased.
18+
819
## [v2.0.0] - 2026-10-15
920

1021
CLDK 2.0 gives Python, Java and TypeScript one agent-facing query surface — addressing, per-callable

‎README.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,33 @@ analysis = CLDK.python(
134134
)
135135
```
136136

137+
### Namespace binding
138+
139+
Set the same raw identity pair on local analysis and graph attachment:
140+
141+
```python
142+
from cldk import CLDK
143+
from cldk.analysis.commons.backend_config import CodeAnalyzerConfig
144+
145+
analysis = CLDK.typescript(
146+
project_path="online-boutique/src/currencyservice",
147+
backend=CodeAnalyzerConfig(
148+
namespace="online-boutique", application_name="currencyservice",
149+
),
150+
)
151+
```
152+
153+
For `Neo4jConnectionConfig`, pass the same `namespace` and `application_name`.
154+
Omitted application names use the selected input directory basename; omitted namespaces
155+
use that resolved application name. A default inventory application therefore has root
156+
`can://inventory/inventory`. Raw names are case sensitive and URI encoded once. Module
157+
URIs from another application, namespace or language are rejected before path lookup.
158+
Identity version 2 is required; regenerate legacy outputs and caches from source.
159+
160+
This branch requires coordinated namespace-ready producer artifacts. The currently
161+
published dependency pins are retained and do not establish release compatibility;
162+
use reviewed local producer builds until compatible releases are available.
163+
137164
| Language | Analyzer | Built on | Runs as |
138165
| --- | --- | --- | --- |
139166
| Java | [`codeanalyzer-java`](https://github.com/codellm-devkit/codeanalyzer-java) | JavaParser and WALA | a subprocess on the bundled JVM |

‎cldk/analysis/commons/backend_config.py‎

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,13 @@
3434

3535
from __future__ import annotations
3636

37-
from dataclasses import dataclass
37+
from dataclasses import dataclass, field
38+
import hashlib
3839
from pathlib import Path
3940
from typing import Union
4041

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

6065
cache_dir: Union[str, Path, None] = None
66+
namespace: str | None = field(default=None, kw_only=True)
67+
application_name: str | None = field(default=None, kw_only=True)
6168

6269

6370
@dataclass
@@ -112,6 +119,7 @@ class Neo4jConnectionConfig:
112119
password: str = "neo4j"
113120
database: str | None = None
114121
application_name: str | None = None
122+
namespace: str | None = field(default=None, kw_only=True)
115123

116124

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

126134

127-
def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str) -> Path | None:
135+
def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str, *, identity: ApplicationIdentity | None = None) -> Path | None:
128136
"""Resolve the language-keyed cache directory for a backend.
129137
130138
Args:
131139
cache_dir: The cache root from the backend config. When ``None``, defaults to
132140
``<project_dir>/.codeanalyzer``.
133141
project_dir: The project directory, used to derive the default root.
142+
identity: Bound application identity; when supplied its digest isolates cached outputs.
134143
language: The canonical language key (``"java"``, ``"python"``, ``"typescript"``, ``"go"``).
135144
136145
Returns:
137-
``<root>/<language>`` as an absolute path, or ``None`` if no root can be determined
146+
``<root>/<language>/<identity digest>`` (or the language directory when unbound)
147+
as an absolute path, or ``None`` if no root can be determined
138148
(no ``cache_dir`` and no ``project_dir``).
139149
"""
140150
key = _CACHE_KEYS.get(language, language)
@@ -144,4 +154,6 @@ def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path
144154
root = Path(project_dir).expanduser().resolve() / ".codeanalyzer"
145155
else:
146156
return None
157+
if identity is not None:
158+
return root / key / hashlib.sha256(identity.application_id.encode()).hexdigest()
147159
return root / key

‎cldk/analysis/commons/identity.py‎

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
################################################################################
2+
# Copyright IBM Corporation 2026
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
################################################################################
16+
17+
"""Validated raw application identity and canonical URI ownership."""
18+
19+
import json
20+
import unicodedata
21+
from collections.abc import Iterable, Mapping
22+
from dataclasses import dataclass
23+
from pathlib import Path
24+
from urllib.parse import quote
25+
26+
from cldk.utils.exceptions import CodeanalyzerExecutionException
27+
28+
29+
@dataclass(frozen=True)
30+
class ApplicationIdentity:
31+
namespace: str
32+
application_name: str
33+
application_id: str
34+
35+
36+
def _segment(value: str) -> str:
37+
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):
38+
raise ValueError("identity names must be nonempty raw names without separators or control characters")
39+
return quote(value, safe="-._~", encoding="utf-8", errors="strict")
40+
41+
42+
def resolve_identity(application_name: str | None, namespace: str | None, *, project_root: Path | None = None) -> ApplicationIdentity:
43+
if application_name is None:
44+
if project_root is None:
45+
raise CodeanalyzerExecutionException("application_name is required to scope queries to an application.")
46+
application_name = Path(project_root).expanduser().resolve().name
47+
namespace = application_name if namespace is None else namespace
48+
return ApplicationIdentity(namespace, application_name, f"can://{_segment(namespace)}/{_segment(application_name)}")
49+
50+
51+
def validate_identity(payload, identity: ApplicationIdentity, *, envelope: bool = True):
52+
"""Reject legacy or foreign persisted data without synthesizing metadata."""
53+
if envelope and hasattr(payload, "application"):
54+
app_model = payload.application
55+
if hasattr(app_model, "identity_version") and "identity_version" not in app_model.model_fields_set:
56+
raise CodeanalyzerExecutionException("Analysis identity marker is missing from the application.")
57+
raw = (
58+
payload.model_dump(include={"identity_version": True, "language": True, "application": {"identity_version", "id", "application_name", "identity_namespace"}})
59+
if envelope and hasattr(payload, "model_dump")
60+
else payload
61+
)
62+
if not isinstance(raw, dict):
63+
raise CodeanalyzerExecutionException("Analysis identity metadata must be an object.")
64+
app = raw.get("application", {}) if envelope else raw
65+
if not isinstance(app, dict):
66+
raise CodeanalyzerExecutionException("Application identity metadata must be an object.")
67+
if envelope and raw.get("language") != "java" and (type(app.get("identity_version")) is not int or app.get("identity_version") != 2):
68+
raise CodeanalyzerExecutionException("Analysis identity marker is missing from the application.")
69+
if (
70+
type(raw.get("identity_version")) is not int
71+
or raw.get("identity_version") != 2
72+
or app.get("application_name") != identity.application_name
73+
or app.get("identity_namespace") != identity.namespace
74+
or app.get("id") != identity.application_id
75+
):
76+
raise CodeanalyzerExecutionException("Analysis identity metadata is missing, incompatible, or outside the requested application scope.")
77+
return payload
78+
79+
80+
def cache_matches(path: Path, identity: ApplicationIdentity) -> bool:
81+
try:
82+
validate_identity(json.loads(path.read_text(encoding="utf-8")), identity)
83+
return True
84+
except (OSError, ValueError, CodeanalyzerExecutionException):
85+
return False
86+
87+
88+
def index_module_rows(rows: Iterable[Mapping[str, str]]) -> dict[str, dict[str, str]]:
89+
"""Collapse repeated module projections by canonical identity, never by filename."""
90+
index: dict[str, dict[str, str]] = {}
91+
for row in rows:
92+
module_id, file_key = row["module_id"], row["file_key"]
93+
if module_id in index and index[module_id]["file_key"] != file_key:
94+
raise ValueError(f"Conflicting file keys for canonical module {module_id}: {index[module_id]['file_key']!r} and {file_key!r}")
95+
index[module_id] = {"module_id": module_id, "file_key": file_key}
96+
return index
97+
98+
99+
def scoped_module_keys(index: Mapping[str, Mapping[str, str]], application_id: str, languages: tuple[str, ...]) -> dict[str, str]:
100+
"""Derive the relative-key API only after validating owner and language.
101+
102+
Candidate IDs remain in the primary index; an ambiguous relative key cannot silently
103+
select whichever module happened to be projected last.
104+
"""
105+
candidates: dict[str, set[str]] = {}
106+
prefixes = tuple(f"{application_id}/{language}/" for language in languages)
107+
for module_id, row in index.items():
108+
if not module_id.startswith(prefixes):
109+
raise ValueError(f"Module URI is outside the bound application/language scope: {module_id}")
110+
candidates.setdefault(row["file_key"], set()).add(module_id)
111+
for key, ids in candidates.items():
112+
if len(ids) != 1:
113+
raise ValueError(f"Ambiguous module file key {key!r}: {sorted(ids)}")
114+
return {key: next(iter(ids)) for key, ids in candidates.items()}

‎cldk/analysis/commons/keys.py‎

Lines changed: 43 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,38 +26,44 @@
2626

2727
import os
2828
import posixpath
29-
from typing import Collection, Iterable, List, Sequence
29+
from typing import Collection, Iterable, List, Sequence, Mapping
30+
from pathlib import Path
3031

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

3334

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

5460

55-
def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "paths") -> List[str] | None:
61+
def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "paths", **scope) -> List[str] | None:
5662
"""Resolve requested module paths to symbol-table keys, or ``None`` for "the whole application".
5763
5864
Both backends route their ``paths=`` / ``module=`` keywords through here, so the lenient
59-
resolution (:func:`resolve_module_key` — an absolute path or one with native separators finds
60-
its module) and the strictness (:func:`check_selector` — a path naming no module raises) cannot
65+
resolution (:func:`resolve_module_key` — relative keys, verified module URIs, or absolute
66+
paths under the existing bound source root) and the strictness (:func:`check_selector` — a path naming no module raises) cannot
6167
drift apart between them.
6268
6369
Args:
@@ -68,8 +74,8 @@ def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "p
6874
single-valued keyword routes through here as a one-element sequence.
6975
7076
**Resolution is many-to-one, and the result is de-duplicated.** Leniency is the whole point of
71-
:func:`resolve_module_key` — ``"pkg/a.py"`` and ``"/abs/pkg/a.py"`` are two spellings a scanner
72-
may plausibly hand over for the *same* module — so two requested paths legitimately collapse to
77+
:func:`resolve_module_key` — a relative key and an absolute path under the bound root may
78+
name the *same* module — so two requested paths legitimately collapse to
7379
one key and the caller gets one entry back. Raising on the collapse would punish the very
7480
caller the leniency exists for; de-duplicating explicitly is what keeps the returned list from
7581
naming the same module twice and asking both backends to fetch it twice.
@@ -83,7 +89,7 @@ def scope_paths(paths: Sequence[str] | None, keys: Iterable[str], kind: str = "p
8389
if paths is None:
8490
return None
8591
known = list(keys)
86-
resolved = [resolve_module_key(p, known) for p in paths]
92+
resolved = [resolve_module_key(p, known, **scope) for p in paths]
8793
check_selector(kind, list(paths), [p for p, r in zip(paths, resolved) if r not in known])
8894
return list(dict.fromkeys(resolved))
8995

@@ -182,3 +188,15 @@ def body_key_column(key: str) -> int:
182188
"""
183189
_, _, col = key.split("/", 1)[0].partition(":")
184190
return int(col) if col.isdigit() else -1
191+
192+
193+
def module_scope(backend) -> dict:
194+
"""Carry the backend's selected owner and existing source root into path resolution."""
195+
identity = backend.identity
196+
if hasattr(backend, "_module_ids"):
197+
ids = backend._module_ids
198+
elif hasattr(backend, "_module_props"):
199+
ids = {key: props["id"] for key, props in backend._module_props.items()}
200+
else:
201+
ids = {key: value.id for key, value in backend.get_application_view().symbol_table.items()}
202+
return dict(application_id=identity.application_id, module_ids=ids, source_root=getattr(backend, "source_root", getattr(backend, "project_dir", None)))

0 commit comments

Comments
 (0)