Skip to content

feat(ids): @external drops the language segment; document the polyglot rule - #248

Merged
rahlk merged 3 commits into
mainfrom
feat/issue-244-language-neutral-externals
Sep 8, 2026
Merged

rahlk merged 3 commits into
mainfrom
feat/issue-244-language-neutral-externals

Conversation

@rahlk

@rahlk rahlk commented Sep 8, 2026

Copy link
Copy Markdown
Collaborator

Completes Java's convergence onto the id grammar codeanalyzer-python and codeanalyzer-typescript already use. Ships in 3.1.1, which is on main and still untagged.

@external becomes language-neutral

before   can://<app>/java/@external/java.util.Map/get(java.lang.Object)
after    can://<app>/@external/java.util.Map/get(java.lang.Object)

@external now sits in the position the language occupies for code nodes — the same treatment artifact already had. A sibling analyzer over the same <app> mints a byte-identical id, so a library symbol is one node in a merged graph rather than a per-language duplicate. codeanalyzer-python's own docstring for this ends "TypeScript's form; java follows it"; Java was the last holdout.

The cost is carried into the javadoc rather than left implicit: two analyzers' notions of a library symbol are not necessarily the same thing, and merging them says they are. That was accepted deliberately upstream.

The schema change this forced, and why it is a union not a widening

External ids no longer match canId (^can://[^/]+/java/) — correct, they have no /java/. But two sites that $ref canId legitimately hold them: callEdge.dst and bodyNode.callee, both set from CanId.externalId when --external-calls is on (8,909 of 10,785 call-graph edges on daytrader8).

Loosening canId would have admitted external ids into module.id, type.id, field.id and callable.id — exactly the malformed-node case that gate exists to catch. So instead: codeOrExternalCanId = anyOf[canId, externalCanId], applied to those two sites only. Review walked every $ref and confirmed they are the only two that need it, and that nothing was over-widened: cfg/cdg/ddg endpoints stay localId, configRead.callee and external_symbols keys stay strict externalCanId, and callEdge.src stays strict canId — verified in code (L2CallGraph.java:301,304: the RTA path returns early for non-app callers, so a library caller can never appear as a src).

README: the polyglot rule

§4 now states plainly which id families are language-neutral shared merge targets (:Artifact, :ConfigKey, @external) versus code nodes that keep /java/, and the write consequence: because the shared nodes sit inside can://<app>/, a 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.

It also records what the app id does not do: it is derived from --app-name, so two services sharing a name merge onto one root. Give each its own --app-name.

Verification

Suite 562 tests, 1 failure (CodeAnalyzerIntegrationTest, no Docker)
daytrader8 -a 2 --external-calls 1,197 externals, 0 retaining the language segment
Real payload 0 schema errors, with 8,909 external-dst call edges exercising the new union
canId unchanged and still tight — confirmed by inspection
schema.neo4j.json correctly not regenerated: it encodes labels/keys/property types, never id value shapes (zero occurrences of can:// or pattern in it)

Note for the 3.1.1 release notes

Schema version stays 2.0.0, so nothing signals to a consumer holding stored can://<app>/java/@external/… ids that they will no longer join. Worth a line when the tag is cut.

Not changed

docs/design/plans/2026-09-07-can-uri-app-outermost.md still shows the old external shape. That is committed design provenance describing what that plan shipped, so it is left as a historical record rather than rewritten.

`@external` joins `artifact` as a language-NEUTRAL id family: it now sits in
the position the language occupies for code nodes, so sibling analyzers over
the same `<app>` mint the same id for a library symbol and it is one node in a
merged graph. Mirrors codeanalyzer-python and codeanalyzer-typescript.

    can://<app>/java/@external/java.util.Map/get(java.lang.Object)
 -> can://<app>/@external/java.util.Map/get(java.lang.Object)

The cost was accepted deliberately: two analyzers' notions of a library symbol
are not necessarily the same thing, and merging them says they are.

Schema: `externalCanId` loses `/java/`, so it no longer matches `canId`
(`^can://[^/]+/java/`) at all. Two sites legitimately hold either shape -- a
call edge's `dst` and a body node's `callee`, both of which carry an external
id when --external-calls homes a target out of project -- so they now `$ref` a
new `codeOrExternalCanId` union rather than `canId` being loosened to admit ids
that are not this analyzer's.
`:Artifact`, `:ConfigKey` and `@external` ids omit the `/java/` segment and are
shared merge targets across sibling analyzers over the same app; code nodes
carry it and stay this analyzer's. Names the consequence the prefix-scoped
snapshot wipe already has for a cross-language edge into a shared `:Artifact`.
@rahlk
rahlk merged commit fdab050 into main Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant