diff --git a/README.md b/README.md index db9a9548..77ce3bb2 100644 --- a/README.md +++ b/README.md @@ -242,14 +242,25 @@ the same repository lands on the same nodes instead of a per-language duplicate. `SCHEMA_VERSION` is stamped onto the `:JApplication` node of every emitted graph. Every node id starts with `can:///` — `can:///java/…` for code, `can:///artifact/…` -for build manifests and config keys — so `can://` is a prefix of every node the application -emits, which is what the destructive statements scope on. (`:Package` is the exception: it is keyed -on a `pkg:` purl, sits under no application, and no wipe reaches it.) `:JApplication` is keyed on -that id, not on the free-text `--app-name`, so the root is addressable by the same id its -descendants are prefixed with. The id is derived from `--app-name` (`can://`), so it does +for build manifests and config keys, `can:///@external/…` for library symbols — so +`can://` is a prefix of every node the application emits, which is what the destructive +statements scope on. (`:Package` is the exception: it is keyed on a `pkg:` purl, sits under no +application, and no wipe reaches it.) `:JApplication` is keyed on that id, not on the free-text +`--app-name`, so the root is addressable by the same id its descendants are prefixed with. The id +is derived from `--app-name` (`can://`), so it does **not** disambiguate two services analyzed under the same name — those still merge onto one root. Give each service its own `--app-name` if they share a database. +**Polyglot ids.** The `/java/` segment is what makes a code node this analyzer's. Three id families +deliberately omit it and are therefore **language-neutral, shared merge targets**: `:Artifact`, +`:ConfigKey` and `@external` symbols. A sibling analyzer over the same `` mints byte-identical +ids for them, so a `pom.xml`, a config key or `java.util.Map#get` is *one* node in a merged graph +rather than a per-language duplicate. That sharing is the point, and its cost is accepted: two +analyzers' notions of a library symbol are not necessarily the same thing, and merging them says +they are. The consequence for writes: because these shared nodes sit inside `can:///`, a Cypher +snapshot's prefix wipe rebuilds them, so a cross-language edge into a shared `:Artifact` is dropped +by one analyzer's snapshot and restored on the other's next push. + **Configuration reads.** `DEFINES_CONFIG` says which artifact declares a key; `J_USES_CONFIG` says which code reads one. Its source is whichever node the read was attributed to — a `:JBodyNode` for a call site (`System.getenv("X")`, `env.getProperty("X")`), or the `:JField` / `:JCallable` / diff --git a/src/main/java/com/ibm/cldk/schema/CanId.java b/src/main/java/com/ibm/cldk/schema/CanId.java index 077e7daa..e29acfdd 100644 --- a/src/main/java/com/ibm/cldk/schema/CanId.java +++ b/src/main/java/com/ibm/cldk/schema/CanId.java @@ -41,13 +41,24 @@ public static String ordinalId(String callableId, String tag) { } /** - * {@code can:///java/@external//} — a callable outside the project. - * Positionally parallel to an in-project callable id with {@code @external} in the file slot (D19); - * the type is a binary name ({@code java.util.Map$Entry}) so the id is unambiguous and - * joins WALA natively. + * {@code can:///@external//} — a callable outside the project. The + * type is a binary name ({@code java.util.Map$Entry}) so the id is unambiguous and joins + * WALA natively. + * + *

Language-NEUTRAL, like {@link #artifactId}: {@code @external} sits in the position the + * language occupies for code nodes, so sibling analyzers over the same {@code } name a + * library symbol identically and it is one node in a merged graph. The cost is real and was + * accepted deliberately — two analyzers' notions of {@code java.util.Map#get} are not necessarily + * the same thing, and merging them says they are. codeanalyzer-typescript's form; + * codeanalyzer-python follows it, and so does this. + * + *

Consequently an external id does not match the {@code canId} shape + * ({@code ^can://[^/]+/java/}): the analysis schema's {@code externalCanId} is a separate + * definition, and the two sites that hold either — a call edge's {@code dst} and a body node's + * {@code callee} — {@code $ref} the union, not a widened {@code canId}. */ public static String externalId(String appName, String binaryType, String signature) { - return applicationId(appName) + "/" + LANG + "/@external/" + binaryType + "/" + signature; + return applicationId(appName) + "/@external/" + binaryType + "/" + signature; } /** diff --git a/src/test/java/com/ibm/cldk/schema/CanIdTest.java b/src/test/java/com/ibm/cldk/schema/CanIdTest.java index 0e1401e9..963a9d54 100644 --- a/src/test/java/com/ibm/cldk/schema/CanIdTest.java +++ b/src/test/java/com/ibm/cldk/schema/CanIdTest.java @@ -46,9 +46,11 @@ void ordinalId_appendsTagAfterAt() { } @Test - void externalIdIsAppThenLanguage() { + void externalIdIsLanguageNeutralUnderTheApp() { + // No `/java/` segment: @external sits where the language sits for code nodes, so a sibling + // analyzer over the same app mints the same id and merges onto the same node (#244). assertEquals( - "can://daytrader8/java/@external/java.util.Map/get(java.lang.Object)", + "can://daytrader8/@external/java.util.Map/get(java.lang.Object)", CanId.externalId("daytrader8", "java.util.Map", "get(java.lang.Object)")); } diff --git a/src/test/resources/schema/analysis.v2.schema.json b/src/test/resources/schema/analysis.v2.schema.json index 3c1147e7..f50eca08 100644 --- a/src/test/resources/schema/analysis.v2.schema.json +++ b/src/test/resources/schema/analysis.v2.schema.json @@ -188,7 +188,7 @@ "required": ["src", "dst", "prov", "weight"], "properties": { "src": { "$ref": "#/$defs/canId" }, - "dst": { "$ref": "#/$defs/canId" }, + "dst": { "$ref": "#/$defs/codeOrExternalCanId" }, "prov": { "type": "array", "items": { "enum": ["declared", "rta"] }, @@ -250,8 +250,8 @@ "externalCanId": { "type": "string", - "pattern": "^can://[^/]+/java/@external/", - "description": "Id of a symbol outside the project, so an in-project id landing in the external map fails the gate instead of validating happily." + "pattern": "^can://[^/]+/@external/", + "description": "Id of a symbol outside the project, so an in-project id landing in the external map fails the gate instead of validating happily. Language-NEUTRAL like artifactCanId: @external sits where the language segment sits for code nodes, so sibling analyzers over the same name a library symbol identically. It therefore does NOT match canId." }, "externalSymbol": { @@ -269,6 +269,11 @@ } }, + "codeOrExternalCanId": { + "anyOf": [{ "$ref": "#/$defs/canId" }, { "$ref": "#/$defs/externalCanId" }], + "description": "A call target: an in-project canId, or an externalCanId when --external-calls homed it out of project. externalCanId is language-neutral and so does not match canId; this union is the fix, rather than loosening canId to admit ids that are not this analyzer's." + }, + "artifactCanId": { "type": "string", "pattern": "^can://[^/]+/artifact/", @@ -514,7 +519,7 @@ "span": { "$ref": "#/$defs/span" }, "parent": { "$ref": "#/$defs/localId" }, "of": { "type": "string" }, - "callee": { "$ref": "#/$defs/canId" }, + "callee": { "$ref": "#/$defs/codeOrExternalCanId" }, "arguments": { "type": "array", "items": { "$ref": "#/$defs/localId" } }, "receiver_expr": { "type": "string" }, "receiver_type": { "type": "string" },