Skip to content

Commit 26150a5

Browse files
committed
feat(artifacts): project Artifact/Package/ConfigKey; graph contract 2.2.0
Fills in the neutral Artifact/Package labels V2SchemaCatalog already reserved, adds ConfigKey, and emits HAS_ARTIFACT/DEFINES_CONFIG/ DECLARES_DEPENDENCY/LOCKS. This is the Neo4j half of #197 that was deferred on the premise that the Java projector ran off a legacy model and could not carry a v2 addition -- that stopped being true once the v2 graph projection shipped, so the deferral is withdrawn. Package nodes are graph-only (minted from CanId.purlMaven); analysis.json continues to carry only the bare group/name, matching python. Nodes and containment edges are un-prefixed -- cross-language merge targets, same reasoning python's schema.py already states at its own declaration. DECLARES_DEPENDENCY carries a `_k`=kind MERGE discriminant via the L4 overlay's keyedEdge machinery, since one manifest may declare a package under two kinds. LOCKS fans out to every lock artifact present, since lock pins are merged upstream with no per-lock attribution -- a known limitation carried over from codeanalyzer-python as-is. codeanalyzer-python deliberately did not bump its own schema version for this layer ("no consumers yet"), so a consumer cannot detect the layer's presence from python's version alone. Java bumps to 2.2.0 anyway, the better behaviour and a deliberate divergence from python here.
1 parent 304d4a0 commit 26150a5

5 files changed

Lines changed: 364 additions & 9 deletions

File tree

‎schema.neo4j.json‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"schema_version": "2.1.0",
2+
"schema_version": "2.2.0",
33
"generator": "codeanalyzer-java",
44
"marker_labels": [
55
"JEntrypoint"
@@ -177,6 +177,47 @@
177177
"properties": {
178178
"name": "string"
179179
}
180+
},
181+
{
182+
"label": "Artifact",
183+
"merge_label": "Artifact",
184+
"key": "id",
185+
"properties": {
186+
"id": "string",
187+
"path": "string",
188+
"format": "string",
189+
"roles": "string[]",
190+
"size_bytes": "integer",
191+
"sha256": "string",
192+
"source": "string",
193+
"text_truncated": "boolean",
194+
"extraction": "string"
195+
}
196+
},
197+
{
198+
"label": "Package",
199+
"merge_label": "Package",
200+
"key": "id",
201+
"properties": {
202+
"id": "string",
203+
"ecosystem": "string",
204+
"group": "string",
205+
"name": "string"
206+
}
207+
},
208+
{
209+
"label": "ConfigKey",
210+
"merge_label": "ConfigKey",
211+
"key": "id",
212+
"properties": {
213+
"id": "string",
214+
"key": "string",
215+
"namespace": "string",
216+
"value": "string",
217+
"references": "string[]",
218+
"start_line": "integer",
219+
"end_line": "integer"
220+
}
180221
}
181222
],
182223
"relationship_types": [
@@ -407,6 +448,55 @@
407448
"JBodyNode"
408449
],
409450
"properties": {}
451+
},
452+
{
453+
"type": "HAS_ARTIFACT",
454+
"from": [
455+
"JApplication"
456+
],
457+
"to": [
458+
"Artifact"
459+
],
460+
"properties": {}
461+
},
462+
{
463+
"type": "DEFINES_CONFIG",
464+
"from": [
465+
"Artifact"
466+
],
467+
"to": [
468+
"ConfigKey"
469+
],
470+
"properties": {}
471+
},
472+
{
473+
"type": "DECLARES_DEPENDENCY",
474+
"from": [
475+
"Artifact"
476+
],
477+
"to": [
478+
"Package"
479+
],
480+
"properties": {
481+
"spec": "string",
482+
"kind": "string",
483+
"extras": "string[]",
484+
"prov": "string[]",
485+
"direct": "boolean",
486+
"_k": "string"
487+
}
488+
},
489+
{
490+
"type": "LOCKS",
491+
"from": [
492+
"Artifact"
493+
],
494+
"to": [
495+
"Package"
496+
],
497+
"properties": {
498+
"version": "string"
499+
}
410500
}
411501
],
412502
"constraints": [
@@ -419,7 +509,10 @@
419509
"CREATE CONSTRAINT jrecordcomponent_id IF NOT EXISTS FOR (x:JRecordComponent) REQUIRE x.id IS UNIQUE",
420510
"CREATE CONSTRAINT jbodynode_id IF NOT EXISTS FOR (x:JBodyNode) REQUIRE x.id IS UNIQUE",
421511
"CREATE CONSTRAINT jpackage_name IF NOT EXISTS FOR (x:JPackage) REQUIRE x.name IS UNIQUE",
422-
"CREATE CONSTRAINT jannotation_name IF NOT EXISTS FOR (x:JAnnotation) REQUIRE x.name IS UNIQUE"
512+
"CREATE CONSTRAINT jannotation_name IF NOT EXISTS FOR (x:JAnnotation) REQUIRE x.name IS UNIQUE",
513+
"CREATE CONSTRAINT artifact_id IF NOT EXISTS FOR (x:Artifact) REQUIRE x.id IS UNIQUE",
514+
"CREATE CONSTRAINT package_id IF NOT EXISTS FOR (x:Package) REQUIRE x.id IS UNIQUE",
515+
"CREATE CONSTRAINT configkey_id IF NOT EXISTS FOR (x:ConfigKey) REQUIRE x.id IS UNIQUE"
423516
],
424517
"indexes": [
425518
"CREATE INDEX j_callable_name IF NOT EXISTS FOR (c:JCallable) ON (c.name)",

‎src/main/java/com/ibm/cldk/neo4j/V2GraphProjector.java‎

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@
1414

1515
import com.ibm.cldk.neo4j.GraphRows.NodeRef;
1616
import com.ibm.cldk.schema.Analysis;
17+
import com.ibm.cldk.schema.CanId;
18+
import com.ibm.cldk.schema.JApplication;
19+
import com.ibm.cldk.schema.JArtifact;
1720
import com.ibm.cldk.schema.JBodyNode;
1821
import com.ibm.cldk.schema.JCallEdge;
1922
import com.ibm.cldk.schema.JCallable;
2023
import com.ibm.cldk.schema.JCdgEdge;
2124
import com.ibm.cldk.schema.JCfgEdge;
2225
import com.ibm.cldk.schema.JComment;
26+
import com.ibm.cldk.schema.JConfigKey;
2327
import com.ibm.cldk.schema.JDdgEdge;
2428
import com.ibm.cldk.schema.JDecorator;
29+
import com.ibm.cldk.schema.JDependency;
2530
import com.ibm.cldk.schema.JEnumConstant;
2631
import com.ibm.cldk.schema.JExternalSymbol;
2732
import com.ibm.cldk.schema.JField;
@@ -42,7 +47,7 @@
4247

4348
/**
4449
* The schema v2 → Neo4j projection: a pure {@code (Analysis, appName) → GraphRows} function, no
45-
* I/O, no driver. The vocabulary is {@link V2SchemaCatalog} (graph contract 2.1.0), mirroring
50+
* I/O, no driver. The vocabulary is {@link V2SchemaCatalog} (graph contract 2.2.0), mirroring
4651
* codeanalyzer-python's projection: call sites are {@code :JBodyNode} rows (no call-site nodes),
4752
* parameters flatten to {@code parameters_json}, javadoc collapses to {@code docstring}, and the
4853
* L3 {@code cfg}/{@code cdg}/{@code ddg} and L4 {@code param_in}/{@code param_out}/{@code summary}
@@ -143,6 +148,8 @@ public static GraphRows project(Analysis analysis, String appName) {
143148
}
144149
}
145150

151+
projectArtifacts(b, analysis.getApplication(), app);
152+
146153
return b.finish();
147154
}
148155

@@ -389,6 +396,106 @@ private static String globalOrdinal(String callableId, String localKey) {
389396
return localKey.startsWith("@") ? callableId + localKey : callableId + "@" + localKey;
390397
}
391398

399+
// ------------------------------------------------------------------------------------------
400+
// Repository-artifact layer: build manifests, config files, declared dependencies.
401+
// ------------------------------------------------------------------------------------------
402+
403+
// Mirrors DependencyView.LOCK_BASENAMES (kept duplicated locally rather than exposing a new
404+
// cross-package constant for one entry -- codeanalyzer-python accepts the identical tradeoff
405+
// for its own two independent lock-basename constants).
406+
private static final String LOCK_BASENAME = "gradle.lockfile";
407+
408+
/**
409+
* Neutral {@code Artifact}/{@code Package}/{@code ConfigKey} subgraph -- no {@code J}/{@code J_}
410+
* prefix (see {@link V2SchemaCatalog}'s declaration comment: these are cross-language merge
411+
* targets, unlike everything else this class projects). L1 data, present at every analysis
412+
* level regardless of {@code -a} (mirrors {@code analysis.json}: {@link JApplication#getArtifacts()}
413+
* / {@link JApplication#getDependencies()} are populated ahead of the level gate).
414+
*/
415+
private static void projectArtifacts(RowBuilder b, JApplication application, NodeRef app) {
416+
Map<String, JArtifact> artifacts = application.getArtifacts();
417+
List<NodeRef> lockRefs = new ArrayList<>();
418+
if (artifacts != null) {
419+
for (Map.Entry<String, JArtifact> e : artifacts.entrySet()) {
420+
JArtifact art = e.getValue();
421+
Map<String, Object> ap = RowBuilder.props();
422+
ap.put("id", art.getId());
423+
ap.put("path", art.getPath());
424+
ap.put("format", art.getFormat());
425+
ap.put("roles", art.getRoles());
426+
ap.put("size_bytes", art.getSizeBytes());
427+
ap.put("sha256", art.getSha256());
428+
ap.put("source", art.getSource());
429+
if (art.isTextTruncated()) {
430+
ap.put("text_truncated", true);
431+
}
432+
ap.put("extraction", art.getExtraction());
433+
NodeRef artRef = b.node(Arrays.asList("Artifact"), "id", art.getId(), RowBuilder.prune(ap));
434+
b.edge("HAS_ARTIFACT", app, artRef);
435+
436+
for (JConfigKey ck : art.getConfigKeys()) {
437+
Map<String, Object> cp = RowBuilder.props();
438+
cp.put("id", ck.getId());
439+
cp.put("key", ck.getKey());
440+
cp.put("namespace", ck.getNamespace());
441+
cp.put("value", ck.getValue());
442+
cp.put("references", ck.getReferences());
443+
putLines(cp, ck.getSpan());
444+
NodeRef ckRef = b.node(Arrays.asList("ConfigKey"), "id", ck.getId(), RowBuilder.prune(cp));
445+
b.edge("DEFINES_CONFIG", artRef, ckRef);
446+
}
447+
448+
if (isLockArtifact(e.getKey())) {
449+
lockRefs.add(artRef);
450+
}
451+
}
452+
}
453+
454+
List<JDependency> dependencies = application.getDependencies();
455+
if (dependencies != null) {
456+
for (JDependency dep : dependencies) {
457+
String pkgId = CanId.purlMaven(dep.getGroup(), dep.getName());
458+
Map<String, Object> pp = RowBuilder.props();
459+
pp.put("id", pkgId);
460+
pp.put("ecosystem", dep.getEcosystem());
461+
pp.put("group", dep.getGroup());
462+
pp.put("name", dep.getName());
463+
NodeRef pkgRef = b.node(Arrays.asList("Package"), "id", pkgId, RowBuilder.prune(pp));
464+
465+
// `_k` (merges per `kind`): the same manifest may declare one package twice under
466+
// different kinds -- same endpoint pair, so a plain MERGE would collapse the two
467+
// declarations into one row.
468+
Map<String, Object> dp = RowBuilder.props();
469+
dp.put("spec", dep.getSpec());
470+
dp.put("kind", dep.getKind());
471+
dp.put("extras", dep.getExtras());
472+
dp.put("prov", dep.getProv());
473+
dp.put("direct", dep.isDirect());
474+
b.keyedEdge("DECLARES_DEPENDENCY", new NodeRef("Artifact", "id", dep.getDeclaredIn()), pkgRef,
475+
RowBuilder.prune(dp), dep.getKind());
476+
477+
// Every lock artifact present LOCKS every dependency it pinned. Pins from every lock
478+
// file are already merged into one lockedVersion per dependency upstream
479+
// (DependencyView.build), so there is no per-lock-file attribution left to split on --
480+
// a dependency locked with N lock artifacts present gets N LOCKS edges (matches
481+
// analysis.json; a known limitation carried over from codeanalyzer-python as-is).
482+
if (dep.getLockedVersion() != null) {
483+
for (NodeRef lockRef : lockRefs) {
484+
Map<String, Object> lp = RowBuilder.props();
485+
lp.put("version", dep.getLockedVersion());
486+
b.edge("LOCKS", lockRef, pkgRef, RowBuilder.prune(lp));
487+
}
488+
}
489+
}
490+
}
491+
}
492+
493+
private static boolean isLockArtifact(String path) {
494+
int slash = path.lastIndexOf('/');
495+
String base = slash < 0 ? path : path.substring(slash + 1);
496+
return LOCK_BASENAME.equals(base);
497+
}
498+
392499
// ------------------------------------------------------------------------------------------
393500
// Imports, annotations, shared helpers
394501
// ------------------------------------------------------------------------------------------

‎src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java‎

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
import java.util.Map;
2222

2323
/**
24-
* The schema v2 Neo4j graph catalog (graph contract {@code 2.1.0}) — the in-repo source of truth
24+
* The schema v2 Neo4j graph catalog (graph contract {@code 2.2.0}) — the in-repo source of truth
2525
* for what {@link V2GraphProjector} may emit, serialized by {@code --emit schema} and enforced by
2626
* the v2 conformance test. Mirrors codeanalyzer-python's {@code neo4j/schema.py} vocabulary with
2727
* {@code J}/{@code J_} namespacing; java-only constructs (enum constants, record components,
@@ -42,7 +42,10 @@ private V2SchemaCatalog() {}
4242

4343
// 2.1.0: additive MINOR — L4 SDG overlay (JBodyNode.var/call_node; J_PARAM_IN/J_PARAM_OUT/
4444
// J_SUMMARY, reserved at 2.0.0, now actually emitted).
45-
public static final String SCHEMA_VERSION = "2.1.0";
45+
// 2.2.0: additive MINOR — the repository-artifact layer (#197): Artifact/Package/ConfigKey
46+
// reserved at 2.0.0, now actually emitted, plus HAS_ARTIFACT/DEFINES_CONFIG/
47+
// DECLARES_DEPENDENCY/LOCKS.
48+
public static final String SCHEMA_VERSION = "2.2.0";
4649

4750
/** Labels layered onto a node in addition to its merge + specific labels. */
4851
public static final List<String> MARKER_LABELS = Arrays.asList("JEntrypoint");
@@ -135,6 +138,32 @@ private static List<NodeLabel> buildNodeLabels() {
135138

136139
n.add(node("JAnnotation", "JAnnotation", "name", new P().put("name", "string").done()));
137140

141+
// Neutral artifact/dependency subgraph (the repository-artifact layer, #197). No `J`/`J_`
142+
// prefix on these three nodes or the containment edges below -- deliberate: `Artifact`,
143+
// `Package` and `ConfigKey` are cross-language merge targets, so a sibling-language analyzer
144+
// over the same repository lands on the same nodes instead of a per-language duplicate.
145+
// Mirrors codeanalyzer-python's identical un-prefixed vocabulary and its rationale.
146+
n.add(node("Artifact", "Artifact", "id",
147+
new P().put("id", "string").put("path", "string").put("format", "string")
148+
.put("roles", "string[]").put("size_bytes", "integer").put("sha256", "string")
149+
.put("source", "string").put("text_truncated", "boolean")
150+
.put("extraction", "string").done()));
151+
152+
// `group` is additive over codeanalyzer-python's `Package` (PyPI names are single-segment);
153+
// Maven splits a coordinate into groupId + artifactId, so both are carried.
154+
n.add(node("Package", "Package", "id",
155+
new P().put("id", "string").put("ecosystem", "string").put("group", "string")
156+
.put("name", "string").done()));
157+
158+
// A configuration key flattened out of a config-bearing Artifact. Neutral vocabulary like
159+
// Artifact/Package -- a properties/yaml/xml/env key is not a Java concept. `value` is
160+
// omitted (not null) when the source model's value is null (--no-artifact-text, or a
161+
// namespace with no value at that path); `references` is omitted only when empty (the
162+
// general list-property rule every other node in this catalog already follows).
163+
n.add(node("ConfigKey", "ConfigKey", "id",
164+
lines(new P().put("id", "string").put("key", "string").put("namespace", "string")
165+
.put("value", "string").put("references", "string[]"))));
166+
138167
return n;
139168
}
140169

@@ -178,6 +207,20 @@ private static List<RelType> buildRelTypes() {
178207
r.add(rel("J_PARAM_OUT", body, body, new P().put("var", "string").done()));
179208
r.add(rel("J_SUMMARY", body, body, none));
180209

210+
// Neutral artifact/dependency subgraph (the repository-artifact layer, #197) -- no `J_`
211+
// prefix, same cross-language-merge-target reasoning as the Artifact/Package/ConfigKey
212+
// nodes above.
213+
r.add(rel("HAS_ARTIFACT", Arrays.asList("JApplication"), Arrays.asList("Artifact"), none));
214+
r.add(rel("DEFINES_CONFIG", Arrays.asList("Artifact"), Arrays.asList("ConfigKey"), none));
215+
// `_k` (merges per `kind`): the same manifest may declare one package twice under different
216+
// kinds (e.g. a runtime dependency re-listed under an optional extra) -- same endpoint pair,
217+
// so without the discriminant the plain MERGE collapses the two declarations into one row.
218+
r.add(rel("DECLARES_DEPENDENCY", Arrays.asList("Artifact"), Arrays.asList("Package"),
219+
new P().put("spec", "string").put("kind", "string").put("extras", "string[]")
220+
.put("prov", "string[]").put("direct", "boolean").put("_k", "string").done()));
221+
r.add(rel("LOCKS", Arrays.asList("Artifact"), Arrays.asList("Package"),
222+
new P().put("version", "string").done()));
223+
181224
return r;
182225
}
183226

‎src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,10 @@ void emitSchemaAlwaysEmitsTheV2Catalog(@TempDir Path tmp) throws IOException {
184184
assertEquals(0, run("--emit", "schema", "-o", out.toString()));
185185
JsonObject doc = JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json")))
186186
.getAsJsonObject();
187-
assertEquals("2.1.0", doc.get("schema_version").getAsString());
187+
assertEquals("2.2.0", doc.get("schema_version").getAsString());
188188
assertEquals(0, run("--emit", "schema", "-o", out.toString(), "--schema", "v1"),
189189
"--emit schema ignores --schema");
190-
assertEquals("2.1.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json")))
190+
assertEquals("2.2.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json")))
191191
.getAsJsonObject().get("schema_version").getAsString());
192192
}
193193

0 commit comments

Comments
 (0)