Skip to content

Commit ad74b88

Browse files
authored
feat: add application identity namespaces
1 parent 581edfa commit ad74b88

38 files changed

Lines changed: 1464 additions & 236 deletions

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Changelog
2+
3+
## [3.4.0] - 2026-10-31
4+
5+
- Add `--namespace` and namespace-qualified application IDs.
6+
- Scope embedded references, graph reconciliation and caches to the selected application.
7+
- Validate stored identity metadata before connected writes.
8+
9+
Regenerate existing analyses and graphs from source for the new ID roots.

‎README.md‎

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ Usage: codeanalyzer [-hvV] [--no-build] [--no-clean-dependencies]
126126
[-a=<analysisLevel>] [-b=<build>] [-f=<projectRootPom>]
127127
[-i=<input>] [-o=<output>] [-s=<sourceAnalysis>]
128128
[--emit=<emit>] [--app-name=<appName>]
129+
[--namespace=<namespace>]
129130
[--neo4j-uri=<uri>] [--neo4j-user=<user>]
130131
[--neo4j-password=<password>] [--neo4j-database=<db>]
131132
[-t=<targetFiles>]...
@@ -145,6 +146,7 @@ Analyze java application.
145146
schema (the Neo4j schema.neo4j.json contract).
146147
--app-name=<name> Logical application name for the graph :JApplication
147148
anchor (default: input dir name).
149+
--namespace=<name> Identity namespace (defaults to application name).
148150
--neo4j-uri=<uri> Push the graph to a live Neo4j over Bolt (incremental);
149151
omit to write graph.cypher. Falls back to the
150152
NEO4J_URI environment variable.
@@ -157,6 +159,24 @@ Analyze java application.
157159
-V, --version Print version information and exit.
158160
```
159161

162+
**Application identity.** `--app-name` defaults to the basename of `--input` and `--namespace`
163+
defaults to the resolved application name. The canonical root always contains both segments:
164+
`can://<namespace>/<application>`, including `can://inventory/inventory` for a default monolith.
165+
Names are case-sensitive raw strings encoded as UTF-8 URI segments without trimming or Unicode
166+
normalization. Explicit empty/whitespace-only names, controls, unpaired surrogates, slashes,
167+
backslashes, `.` and `..` are rejected. `--namespace` is unavailable with `--schema v1`.
168+
169+
```bash
170+
codeanalyzer -i monorepo/services/orders --app-name orders --namespace commerce
171+
# root: can://commerce/orders
172+
# symbol_table key: src/main/java/com/acme/Order.java
173+
```
174+
175+
File and artifact keys remain relative to `--input`. The draft `--workspace-root` option has
176+
been removed. Analysis envelopes and graph application anchors carry `identity_version: 2`;
177+
anchors also retain raw `application_name` and `identity_namespace`. Namespace changes invalidate
178+
the L1 cache. Legacy outputs must be regenerated from source; retire legacy graph data explicitly.
179+
160180

161181
## 3. Installing `codeanalyzer` as a native binary (once built, no JVM will be required for running `codeanalyzer`)
162182

@@ -241,23 +261,22 @@ build manifest or a configuration key is not a Java concept — a sibling-langua
241261
the same repository lands on the same nodes instead of a per-language duplicate.
242262
`SCHEMA_VERSION` is stamped onto the `:JApplication` node of every emitted graph.
243263

244-
Every node id starts with `can://<app>/` — `can://<app>/java/…` for code, `can://<app>/artifact/…`
245-
for build manifests and config keys, `can://<app>/@external/…` for library symbols — so
246-
`can://<app>` is a prefix of every node the application emits, which is what the destructive
264+
Every node id starts with `can://<namespace>/<app>/` — `can://<namespace>/<app>/java/…` for code, `can://<namespace>/<app>/artifact/…`
265+
for build manifests and config keys, `can://<namespace>/<app>/@external/…` for library symbols — so
266+
`can://<namespace>/<app>` is a prefix of every node the application emits, which is what the destructive
247267
statements scope on. (`:Package` is the exception: it is keyed on a `pkg:` purl, sits under no
248268
application, and no wipe reaches it.) `:JApplication` is keyed on that id, not on the free-text
249269
`--app-name`, so the root is addressable by the same id its descendants are prefixed with. The id
250-
is derived from `--app-name` (`can://<app-name>`), so it does
251-
**not** disambiguate two services analyzed under the same name — those still merge onto one root.
252-
Give each service its own `--app-name` if they share a database.
270+
is derived from the pair (`--namespace`, `--app-name`). Services with the same raw application name
271+
remain distinct in different namespaces. Services selecting the same pair merge onto the same root.
253272

254273
**Polyglot ids.** The `/java/` segment is what makes a code node this analyzer's. Three id families
255274
deliberately omit it and are therefore **language-neutral, shared merge targets**: `:Artifact`,
256275
`:ConfigKey` and `@external` symbols. A sibling analyzer over the same `<app>` mints byte-identical
257276
ids for them, so a `pom.xml`, a config key or `java.util.Map#get` is *one* node in a merged graph
258277
rather than a per-language duplicate. That sharing is the point, and its cost is accepted: two
259278
analyzers' notions of a library symbol are not necessarily the same thing, and merging them says
260-
they are. The consequence for writes: because these shared nodes sit inside `can://<app>/`, a Cypher
279+
they are. The consequence for writes: because these shared nodes sit inside `can://<namespace>/<app>/`, a Cypher
261280
snapshot's prefix wipe rebuilds them, so a cross-language edge into a shared `:Artifact` is dropped
262281
by one analyzer's snapshot and restored on the other's next push.
263282

@@ -297,14 +316,13 @@ codeanalyzer -i /path/to/project -a 2 --emit neo4j -o ./out
297316
cypher-shell -u neo4j -p <password> < ./out/graph.cypher
298317
```
299318

300-
The snapshot is **not** incremental: it constraints, scopes-wipes this application's prior subgraph,
301-
then `UNWIND … MERGE`-loads the full truth. The wipe is everything under `can://<app>/`, plus a
302-
containment traversal that also replaces a prior v1 (pre-`3.0.0`) graph of the same application.
319+
Offline snapshots validate incoming identity metadata; they do not inspect an existing database. Execute them only as part of explicit regeneration. The snapshot is **not** incremental: it constraints, scopes-wipes this application's prior subgraph,
320+
then `UNWIND … MERGE`-loads the full truth. The wipe is everything under `can://<namespace>/<app>/`, plus a
321+
containment traversal scoped to the selected canonical application. Legacy roots are not retired automatically.
303322
That scope **includes** this application's `:Artifact` and `:ConfigKey` nodes — the snapshot rebuilds
304323
them, rather than leaving stale ones to accumulate with nothing ever cleaning them up. The one
305324
consequence: a sibling-language analyzer's edge into a shared `:Artifact` is dropped by a Java
306-
snapshot and restored on that analyzer's next push. The Bolt writer does **not** do this — its
307-
deletions are scoped per module, not per application.
325+
snapshot and restored on that analyzer's next push. The Bolt writer is narrower; see §4.2.
308326

309327
### 4.2. Live incremental push over Bolt
310328

@@ -313,11 +331,23 @@ codeanalyzer -i /path/to/project -a 2 --emit neo4j \
313331
--neo4j-uri bolt://localhost:7687 --neo4j-user neo4j --neo4j-password <password>
314332
```
315333

316-
The Bolt writer reads the database's current state and updates **only what changed**: it diffs each
334+
The Bolt writer checks the selected existing anchor's identity marker and raw names before DDL or writes, then updates **only what changed**: it diffs each
317335
compilation unit's `content_hash`, replaces just the changed units' subgraphs (idempotent
318336
`MERGE` upserts), and — on a full run — prunes units whose source file vanished. Combine with
319337
`--target-files` for a targeted, partial re-push (orphan pruning is then skipped).
320338

339+
On a full run it also reconciles the application's artifacts, so a deleted or renamed file, a
340+
or removed dependency leaves nothing stale within the selected namespace/application:
341+
342+
- Java-owned artifact edges are rebuilt from the run: `HAS_ARTIFACT`, `DECLARES_DEPENDENCY`/`LOCKS`
343+
to `pkg:maven/…`, and code reads of them (`J_USES_CONFIG`, `J_DISPATCHES_TO`,
344+
`J_READS_CONFIG_UNRESOLVED`), so a config-only change updates them too.
345+
- An `:Artifact`/`:ConfigKey` the run no longer emits loses its `:JCanNode` label. An artifact is
346+
then deleted only if no other analyzer's `HAS_ARTIFACT` still points at it; a config key only if
347+
no other analyzer's marker (`:PyCanNode`, `:TSCanNode`) still claims it. A sibling's edges are
348+
never removed.
349+
- Legacy artifacts require explicit retirement; reconciliation never falls back to a legacy root.
350+
321351
### 4.3. Schema contract
322352

323353
```sh
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# Application identity namespaces (supersedes draft workspace-root design)
2+
3+
The unmerged `--workspace-root` proposal is withdrawn. File keys, module suffixes and artifact
4+
paths remain relative to the selected `--input` directory. No path prefix rekeying or URI
5+
translation is performed.
6+
7+
`application_name` is the supplied `--app-name`, otherwise the input basename. `identity_namespace`
8+
is the supplied `--namespace`, otherwise the resolved application name. Both segments are always
9+
present: `can://<namespace>/<application>`, including `can://inventory/inventory`.
10+
11+
Names retain case and Unicode spelling. UTF-8 bytes outside ASCII letters/digits/`-._~` are
12+
percent-encoded with uppercase escapes. Empty or whitespace-only names, controls, unpaired
13+
surrogates, slashes/backslashes and whole-name `.`/`..` are invalid. Explicit invalid flags never
14+
fall back to defaults. Legacy schema v1 rejects `--namespace`.
15+
16+
The existing schema family remains `2.0.0`. The analysis envelope and graph application anchors
17+
carry `identity_version: 2`; anchors retain raw `application_name` and `identity_namespace`.
18+
Missing persisted metadata is unsupported, not defaulted. Regenerate from source and retire legacy
19+
outputs explicitly. Cache identity includes the canonical namespace/application root and marker.
20+
21+
Java keeps its existing `/java/<file>/<type>/<signature>` suffixes and body ordinals. External
22+
symbols and artifacts share the canonical application root; `pkg:` identities remain unchanged.
23+
Canonical-parent builders forward their root unchanged. No mutable global namespace is used.
24+
25+
Bolt checks the selected existing application anchor before DDL or data writes. Incremental hashes,
26+
module purges, orphan pruning and artifact reconciliation stay within that selected application.
27+
Shared-artifact retention rules remain intact. Offline snapshots validate incoming metadata but
28+
cannot inspect the target database; execution is an explicit regeneration operation.

‎gradle.properties‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
version=3.3.4
1+
version=3.4.0

‎packaging/python/src/codeanalyzer_java/__init__.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
from pathlib import Path
2424
from typing import List
2525

26-
__version__ = "0.0.0"
26+
__version__ = "3.4.0"
2727

2828
__all__ = ["jar_path", "java_path", "command", "__version__"]
2929

‎schema.neo4j.json‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@
1313
"properties": {
1414
"id": "string",
1515
"name": "string",
16+
"application_name": "string",
17+
"identity_namespace": "string",
18+
"identity_version": "integer",
1619
"schema_version": "string",
1720
"analyzer_name": "string",
1821
"analyzer_version": "string",

‎src/main/java/com/ibm/cldk/CodeAnalyzer.java‎

Lines changed: 35 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import com.ibm.cldk.neo4j.BoltConfig;
3232
import com.ibm.cldk.neo4j.Neo4jEmitter;
3333
import com.ibm.cldk.schema.Analysis;
34+
import com.ibm.cldk.schema.CanId;
3435
import com.ibm.cldk.schema.JArtifact;
3536
import com.ibm.cldk.schema.JDependency;
3637
import com.ibm.cldk.schema.JEntrypointReport;
@@ -146,6 +147,9 @@ public class CodeAnalyzer implements Runnable {
146147
// values between CommandLine instances in the same JVM. New flags do not add to that.
147148
private String schema = "v2";
148149

150+
@Option(names = "--namespace", description = "Identity namespace (defaults to application name)")
151+
private String namespace;
152+
149153
@Option(names = {"-c",
150154
"--cache-dir" }, description = "Directory holding the incremental analysis cache. When set, "
151155
+ "unchanged files are reused from analysis_cache.json instead of being reparsed.")
@@ -261,6 +265,11 @@ private void analyze() throws Exception {
261265
analyzeV2();
262266
return;
263267
}
268+
if (namespace != null) {
269+
// v1 keys are absolute paths, so there is nothing for a workspace root to relativize.
270+
throw new ParameterException(spec.commandLine(),
271+
"error: --namespace is not supported with --schema v1");
272+
}
264273

265274
JsonObject combinedJsonObject = new JsonObject();
266275
Map<String, JavaCompilationUnit> symbolTable;
@@ -399,9 +408,11 @@ private boolean isV2Schema() {
399408
* which {@code --artifact-text}/{@code --artifact-text-max-bytes} may have emptied or truncated —
400409
* delegates to {@link DependencyView#readFromDisk} rather than re-reading the file itself, so that
401410
* from-disk logic exists exactly once in this codebase.
411+
*
412+
* @param keyRoot the directory artifact paths are relative to (the workspace root, else --input)
402413
*/
403-
private static String readFully(JArtifact artifact) {
404-
return DependencyView.readFromDisk(Paths.get(input), artifact.getPath());
414+
private static String readFully(Path keyRoot, JArtifact artifact) {
415+
return DependencyView.readFromDisk(keyRoot, artifact.getPath());
405416
}
406417

407418
/**
@@ -455,10 +466,16 @@ private void analyzeV2() throws Exception {
455466
if (input == null) {
456467
throw new ParameterException(spec.commandLine(), "error: --input is required");
457468
}
458-
459-
String application = appName != null && !appName.isBlank()
460-
? appName
461-
: Paths.get(input).toAbsolutePath().normalize().getFileName().toString();
469+
Path inputRoot = Paths.get(input).toAbsolutePath().normalize();
470+
Path keyRoot = inputRoot;
471+
String application = spec.commandLine().getParseResult().hasMatchedOption("--app-name")
472+
? appName : inputRoot.getFileName().toString();
473+
String applicationId;
474+
try {
475+
applicationId = CanId.applicationId(application, namespace);
476+
} catch (IllegalArgumentException e) {
477+
throw new ParameterException(spec.commandLine(), "error: invalid --app-name or --namespace: " + e.getMessage());
478+
}
462479

463480
// Always attempt library type resolution: without the dependency jars on the solver's path,
464481
// third-party types degrade to bare spellings (`Model` rather than `org.springframework.ui.Model`)
@@ -485,7 +502,7 @@ private void analyzeV2() throws Exception {
485502
// L1 artifact that must not carry L3 overlays. So bypass the cache entirely at level >= 3.
486503
Map<String, JModule> cached = (eager || analysisLevel >= 3)
487504
? new java.util.LinkedHashMap<>()
488-
: L1Cache.load(cache, application, version);
505+
: L1Cache.load(cache, applicationId, version);
489506

490507
Map<String, JModule> modules;
491508
List<L2CallGraph.RtaEndpoint> rtaEndpoints = null;
@@ -496,8 +513,8 @@ private void analyzeV2() throws Exception {
496513
// is what turns them into a non-zero exit for a caller that cannot read stderr.
497514
List<String> degraded = new ArrayList<>();
498515
try {
499-
modules = L1Extractor.extractAll(
500-
Paths.get(input), application, dependencyDir, cached,
516+
modules = L1Extractor.extractAllForApplication(
517+
Paths.get(input), applicationId, dependencyDir, cached,
501518
analysisLevel, graphFieldDepth, l3Engine, entrypointReport);
502519
// The WALA L3 engine needs the RTA build; --no-rta suppresses it, so a level-3 wala run with
503520
// --no-rta would silently carry no overlays. Warn rather than degrade without a signal.
@@ -559,21 +576,21 @@ private void analyzeV2() throws Exception {
559576
// (an L2 refinement) must not be persisted into it. At level >= 3 the tree carries L3 overlays,
560577
// which are not an L1 artifact, so the cache is not written.
561578
if (analysisLevel < 3) {
562-
L1Cache.save(cache, application, version, modules);
579+
L1Cache.save(cache, applicationId, version, modules);
563580
}
564581

565582
// The repository-artifact layer (build manifests, config files, declared dependencies) sits
566583
// beside the call-graph/SDG assembly below because both are application-scope data built once
567584
// -- but unlike them it is L1 data and runs at EVERY analysis level, so it is computed here,
568585
// ahead of the level gate, rather than inside either branch of it.
569586
Map<String, JArtifact> artifacts =
570-
ArtifactDiscovery.discover(Paths.get(input), application, artifactText, artifactTextMaxBytes);
571-
List<JDependency> dependencies = DependencyView.build(Paths.get(input), artifacts);
587+
ArtifactDiscovery.discover(inputRoot, application, artifactText, artifactTextMaxBytes, namespace);
588+
List<JDependency> dependencies = DependencyView.build(keyRoot, artifacts);
572589
for (JArtifact a : artifacts.values()) {
573590
if (ConfigKeys.isEligible(a)) {
574591
// Re-read from disk: `source` may be truncated or suppressed, and extraction
575592
// must not silently degrade with a capture flag.
576-
ConfigKeys.Result r = ConfigKeys.extract(a, readFully(a), artifactText);
593+
ConfigKeys.Result r = ConfigKeys.extract(a, readFully(keyRoot, a), artifactText);
577594
a.setConfigKeys(r.keys);
578595
// A pre-existing "partial" from the dependency pass is never overwritten.
579596
if (!r.ok) {
@@ -589,7 +606,7 @@ private void analyzeV2() throws Exception {
589606
// engine-free, so they run whenever analysisLevel >= 4 regardless of the WALA build's fate.
590607
Analysis analysis;
591608
if (analysisLevel >= 2) {
592-
L2CallGraph.Result l2 = L2CallGraph.build(application, modules, rtaEndpoints, externalCalls);
609+
L2CallGraph.Result l2 = L2CallGraph.buildForApplication(applicationId, modules, rtaEndpoints, externalCalls);
593610
// SdgVertices needs the callee ids L2CallGraph.build just backfilled onto call body nodes,
594611
// and no dependency jars, so it runs here rather than inside the try block above.
595612
SdgVertices.Result sdg = null;
@@ -604,10 +621,10 @@ private void analyzeV2() throws Exception {
604621
analysis = V2Emitter.emit(application, analysisLevel, modules, version,
605622
l2.callGraph(), l2.externalSymbols(),
606623
sdg == null ? null : sdg.paramIn, sdg == null ? null : sdg.paramOut,
607-
artifacts, dependencies);
624+
artifacts, dependencies, namespace);
608625
} else {
609626
analysis = V2Emitter.emit(application, analysisLevel, modules, version,
610-
null, null, null, null, artifacts, dependencies);
627+
null, null, null, null, artifacts, dependencies, namespace);
611628
}
612629

613630
// Enforced here, after every overlay pass has had its chance and before anything is written:
@@ -629,7 +646,7 @@ private void analyzeV2() throws Exception {
629646
// Config reads join the L1 tree to the artifact layer's declared keys, so it runs after both
630647
// exist and at every analysis level -- the literal tier needs no call graph. Absence means
631648
// "no fact", so an empty result leaves both keys off the envelope.
632-
ConfigUses.Result configReads = ConfigUses.detect(application, modules, artifacts,
649+
ConfigUses.Result configReads = ConfigUses.detectForApplication(applicationId, modules, artifacts,
633650
analysisLevel, analysis.getApplication().getCallGraph());
634651
if (!configReads.uses.isEmpty()) {
635652
analysis.getApplication().setConfigUses(configReads.uses);
@@ -640,7 +657,7 @@ private void analyzeV2() throws Exception {
640657

641658
// View dispatches (#259) join dispatch sites to the artifact layer's view templates -- the
642659
// same shape as config reads, at every level, with the dataflow tiers widening from -a 3.
643-
ViewDispatches.Result views = ViewDispatches.detect(application, modules, artifacts,
660+
ViewDispatches.Result views = ViewDispatches.detectForApplication(applicationId, modules, artifacts,
644661
analysisLevel, analysis.getApplication().getCallGraph());
645662
if (!views.dispatches.isEmpty()) {
646663
analysis.getApplication().setViewDispatches(views.dispatches);

0 commit comments

Comments
 (0)