diff --git a/schema.neo4j.json b/schema.neo4j.json index f6a5f630..6148f9d3 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -1,8 +1,9 @@ { - "schema_version": "2.2.0", + "schema_version": "3.0.0", "generator": "codeanalyzer-java", "marker_labels": [ - "JEntrypoint" + "JEntrypoint", + "JCanNode" ], "node_labels": [ { @@ -24,8 +25,7 @@ "id": "string", "file_key": "string", "package": "string", - "content_hash": "string", - "_module": "string" + "content_hash": "string" } }, { @@ -41,7 +41,6 @@ "interfaces": "string[]", "docstring": "string", "is_entrypoint": "boolean", - "_module": "string", "start_line": "integer", "end_line": "integer" } @@ -67,7 +66,6 @@ "accessed_fields": "string[]", "is_implicit": "boolean", "is_entrypoint": "boolean", - "_module": "string", "start_line": "integer", "end_line": "integer" } @@ -94,7 +92,6 @@ "initializer": "string", "modifiers": "string[]", "docstring": "string", - "_module": "string", "start_line": "integer", "end_line": "integer" } @@ -108,7 +105,6 @@ "name": "string", "type": "string", "initializer": "string", - "_module": "string", "start_line": "integer", "end_line": "integer" } @@ -121,8 +117,7 @@ "id": "string", "name": "string", "arguments": "string[]", - "docstring": "string", - "_module": "string" + "docstring": "string" } }, { @@ -135,8 +130,7 @@ "type": "string", "modifiers": "string[]", "is_variadic": "boolean", - "docstring": "string", - "_module": "string" + "docstring": "string" } }, { @@ -155,7 +149,6 @@ "is_static_call": "boolean", "argument_types": "string[]", "argument_expr": "string[]", - "_module": "string", "var": "string", "call_node": "string", "start_line": "integer", @@ -518,6 +511,7 @@ "CREATE INDEX j_callable_name IF NOT EXISTS FOR (c:JCallable) ON (c.name)", "CREATE INDEX j_type_name IF NOT EXISTS FOR (t:JType) ON (t.name)", "CREATE INDEX j_annotation_name_idx IF NOT EXISTS FOR (an:JAnnotation) ON (an.name)", - "CREATE FULLTEXT INDEX j_code_fts IF NOT EXISTS FOR (c:JCallable) ON EACH [c.code, c.docstring]" + "CREATE FULLTEXT INDEX j_code_fts IF NOT EXISTS FOR (c:JCallable) ON EACH [c.code, c.docstring]", + "CREATE INDEX j_can_node_id IF NOT EXISTS FOR (n:JCanNode) ON (n.id)" ] } diff --git a/src/main/java/com/ibm/cldk/neo4j/BoltWriter.java b/src/main/java/com/ibm/cldk/neo4j/BoltWriter.java index e5b41dad..e20d0242 100644 --- a/src/main/java/com/ibm/cldk/neo4j/BoltWriter.java +++ b/src/main/java/com/ibm/cldk/neo4j/BoltWriter.java @@ -49,27 +49,54 @@ */ public final class BoltWriter implements BoltSink { /** - * Purge the edges a changed module owns, and the declarations that vanished from it. + * The v2 purge: scoped by the module's own {@code can://} id rather than by a bare file key. * - *

Both are anchored on {@link CypherWriter#MODULE_OWNED}, and that anchor is the whole point: - * {@code _module} is a shared convention rather than a java-private property, so the sibling - * analyzers set it on their own nodes with the same value (the module's file key). The - * unlabelled {@code MATCH (x {_module: $m})} these replaced therefore matched a sibling's nodes - * wherever a file key collided across languages in a shared database, and deleted a graph this - * tool did not write and cannot see -- silently, unless the delete happened to be large enough - * to exhaust the transaction limit and roll back (#213). + *

The id is a path — {@code can://java//} — so matching the module itself by + * equality and its declarations by {@code id STARTS WITH + '/'} is containment, and it is + * simultaneously scoped to one language, one application and one module. That last one is what + * neither a label anchor nor {@code _module} could give: two java applications sharing + * {@code src/main/java/Foo.java} have identical labels and an identical file key, and only the + * id distinguishes them (.github#50). * - *

Constants rather than inline strings so the anchoring is assertable without a live - * database; {@code BoltWriterPurgeTest} pins it. + *

The trailing slash is not cosmetic. A bare {@code STARTS WITH } would also match + * {@code Xtra}, so descendants are matched on the separator and the module on equality. + * + *

{@code :JCanNode} is present only so the prefix predicate can seek: Neo4j property indexes + * are label-scoped, and without a label this scans the whole node store. */ - static final String PURGE_MODULE_EDGES = - "MATCH (x:" + CypherWriter.MODULE_OWNED + ") WHERE x._module = $m " + static final String PURGE_MODULE_EDGES_V2 = + "MATCH (x:" + RowBuilder.CAN_NODE + ") WHERE x.id = $mid OR x.id STARTS WITH $pre " + "MATCH (x)-[r]->() DELETE r"; - /** @see #PURGE_MODULE_EDGES */ - static final String PURGE_VANISHED_NODES = - "MATCH (x:" + CypherWriter.MODULE_OWNED + ") WHERE x._module = $m " - + "AND NOT coalesce(x.id, x.file_key) IN $keys DETACH DELETE x"; + /** @see #PURGE_MODULE_EDGES_V2 */ + static final String PURGE_VANISHED_NODES_V2 = + "MATCH (x:" + RowBuilder.CAN_NODE + ") WHERE (x.id = $mid OR x.id STARTS WITH $pre) " + + "AND NOT x.id IN $keys DETACH DELETE x"; + + /** + * The prefix that matches a module's declarations but not a sibling module whose path merely + * starts the same way. The separator is the whole point: {@code can://java/app/src/Foo.java} + * is also a prefix of {@code can://java/app/src/Foo.javaX}, so a bare {@code STARTS WITH} on + * the module id would purge a different module's nodes. The module itself is matched by + * equality instead, since its own id does not end in a separator. + */ + static String descendantPrefix(String moduleId) { + return moduleId + "/"; + } + + /** + * The {@code can://} id of the module these rows belong to, or {@code null} on the v1 path. + * Taken from the module's own row rather than parsed out of a declaration's id, because a file + * key may itself contain {@code /} and so cannot be recovered by splitting. + */ + private static String canModuleIdOf(List nodes) { + for (NodeRow n : nodes) { + if (n.labels.contains("JModule") && RowBuilder.isCanId(n.value)) { + return n.value; + } + } + return null; + } private static final int BATCH = 1000; @@ -115,10 +142,9 @@ void run(GraphRows rows, boolean fullRun) { List shared = new ArrayList<>(); Map unitOf = new HashMap<>(); // node value → owning unit for (NodeRow n : rows.nodes) { - Object m = n.props.get("_module"); - if (m instanceof String) { - byUnit.computeIfAbsent((String) m, x -> new ArrayList<>()).add(n); - unitOf.put(n.value, (String) m); + if (n.moduleKey != null) { + byUnit.computeIfAbsent(n.moduleKey, x -> new ArrayList<>()).add(n); + unitOf.put(n.value, n.moduleKey); } else { shared.add(n); } @@ -155,8 +181,27 @@ void run(GraphRows rows, boolean fullRun) { } try (Session s = session()) { s.writeTransaction(tx -> { - tx.run(PURGE_MODULE_EDGES, Values.parameters("m", unit)); - tx.run(PURGE_VANISHED_NODES, Values.parameters("m", unit, "keys", keys)); + String moduleId = canModuleIdOf(nodes); + if (moduleId == null) { + // Legacy v1 rows: FQN-shaped ids carry no application segment, so there + // is no prefix to scope by. v2 is the schema as of 3.0.0, so rather than + // carry a second scoping mechanism for a legacy path, the purge is + // skipped and said so. Nodes are still upserted -- a v1 push updates, it + // just never deletes. + Log.info("neo4j(bolt): legacy v1 rows for " + unit + + " - purge skipped (v1 ids carry no application scope)"); + return null; + } + // The module's own can:// id scopes this to one module of one application of + // one language at once, because the id is a path and a prefix match on it is + // containment. `_module` could not: it is a bare project-relative file key, + // so two applications sharing a source path purged each other (.github#50). + Map args = new LinkedHashMap<>(); + args.put("mid", moduleId); + args.put("pre", descendantPrefix(moduleId)); + args.put("keys", keys); + tx.run(PURGE_MODULE_EDGES_V2, args); + tx.run(PURGE_VANISHED_NODES_V2, args); return null; }); } diff --git a/src/main/java/com/ibm/cldk/neo4j/CypherWriter.java b/src/main/java/com/ibm/cldk/neo4j/CypherWriter.java index 2ace072e..bc34479b 100644 --- a/src/main/java/com/ibm/cldk/neo4j/CypherWriter.java +++ b/src/main/java/com/ibm/cldk/neo4j/CypherWriter.java @@ -17,7 +17,6 @@ import java.io.IOException; import java.io.StringWriter; import java.util.ArrayList; -import java.util.Arrays; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; @@ -56,38 +55,6 @@ public final class CypherWriter { + "|J_HAS_CRUD_OPERATION|J_HAS_CRUD_QUERY|J_HAS_COMMENT" + "|J_DECLARES|J_HAS_METHOD|J_HAS_BODY_NODE*1..]"; - /** - * The labels this analyzer owns per module -- every label in either generation's catalog that - * carries the internal {@code _module} provenance property, as a Cypher label disjunction - * ({@code JModule|JType|JCallable|...}). - * - *

{@code _module} is NOT java-private. The sibling analyzers set the same property on their - * own nodes, using the same value (the module's file key), so an unlabelled - * {@code MATCH (x {_module: $m})} matches THEIR nodes wherever a file key collides across - * languages in a shared database -- and a delete built on it destroys a graph this tool did not - * write and cannot see. Every statement that matches on {@code _module} must be anchored here. - * The hazard is the same one {@link #DESCENDANTS} is written to avoid, one property over. - * - *

Derived from the catalogs rather than hand-listed, so a label that gains {@code _module} - * later is covered without a second list to maintain, and spans BOTH generations for the same - * reason {@code DESCENDANTS} does: {@link BoltWriter} is schema-agnostic and may be pushing - * either one. - */ - static final String MODULE_OWNED = moduleOwnedPattern(); - - private static String moduleOwnedPattern() { - List labels = new ArrayList<>(); - for (List catalog : - Arrays.asList(SchemaCatalog.NODE_LABELS, V2SchemaCatalog.NODE_LABELS)) { - for (SchemaCatalog.NodeLabel nl : catalog) { - if (nl.properties.containsKey("_module") && !labels.contains(nl.label)) { - labels.add(nl.label); - } - } - } - return String.join("|", labels); - } - private CypherWriter() {} /** diff --git a/src/main/java/com/ibm/cldk/neo4j/GraphRows.java b/src/main/java/com/ibm/cldk/neo4j/GraphRows.java index 0e2e40c9..4946838c 100644 --- a/src/main/java/com/ibm/cldk/neo4j/GraphRows.java +++ b/src/main/java/com/ibm/cldk/neo4j/GraphRows.java @@ -56,8 +56,25 @@ public static final class NodeRow { public final String keyProp; public final String value; public final Map props; + /** + * The file key of the module owning this node, or {@code null} for a shared node. + * + *

In memory only — deliberately NOT a property, so it is never written to the graph. The + * incremental writer needs to group nodes by module; the graph does not need to be told, + * because a node's owning module is already the prefix of its {@code can://} id. Emitting it + * was how a delete came to be scoped by a bare file key that carries no language and no + * application, which is what made two applications sharing a path delete each other's nodes + * (codellm-devkit/.github#50). + */ + public final String moduleKey; public NodeRow(List labels, String keyProp, String value, Map props) { + this(labels, keyProp, value, props, null); + } + + public NodeRow(List labels, String keyProp, String value, Map props, + String moduleKey) { + this.moduleKey = moduleKey; this.labels = labels; this.keyProp = keyProp; this.value = value; diff --git a/src/main/java/com/ibm/cldk/neo4j/RowBuilder.java b/src/main/java/com/ibm/cldk/neo4j/RowBuilder.java index 32b17156..efbf2db9 100644 --- a/src/main/java/com/ibm/cldk/neo4j/RowBuilder.java +++ b/src/main/java/com/ibm/cldk/neo4j/RowBuilder.java @@ -78,13 +78,47 @@ public NodeRef node(List labels, String keyProp, String value, Map(labels), keyProp, value, new LinkedHashMap<>(props))); + Map p = new LinkedHashMap<>(props); + // Lift the owning module off the emitted properties and onto the row. The writer needs + // it to group nodes; the graph does not, because a v2 node's owning module is already + // the prefix of its can:// id. See NodeRow.moduleKey. + Object m = p.remove(MODULE_PROP); + String moduleKey = m instanceof String ? (String) m : null; + List allLabels = new ArrayList<>(labels); + if (isCanId(value)) { + // The index anchor for prefix-scoped statements: Neo4j property indexes are + // label-scoped, so `WHERE n.id STARTS WITH $p` needs a label to seek on. Carries no + // safety claim of its own -- the prefix does that -- and is java-namespaced so an + // anchoring mistake still cannot reach another language's nodes. + allLabels.add(CAN_NODE); + } else if (moduleKey != null) { + // v1 ids are FQN-shaped and carry no application segment, so they cannot be + // prefix-scoped. That path keeps the property it has always had; only the v2 + // contract drops it. + p.put(MODULE_PROP, moduleKey); + } + nodes.put(id, new NodeRow(allLabels, keyProp, value, p, moduleKey)); } keys.add(value); return new NodeRef(labels.get(0), keyProp, value); } + /** The marker label carried by every node keyed on a {@code can://} id. */ + public static final String CAN_NODE = "JCanNode"; + + private static final String MODULE_PROP = "_module"; + + /** Whether a node key is a v2 {@code can://} id, as opposed to a v1 FQN-shaped one. */ + static boolean isCanId(String value) { + return value != null && value.startsWith("can://"); + } + /** An edge whose endpoints are known to exist (both ends emitted as nodes this run). */ public void edge(String type, NodeRef from, NodeRef to, Map props) { edges.add(new EdgeRow(type, from, to, props)); diff --git a/src/main/java/com/ibm/cldk/neo4j/Schema.java b/src/main/java/com/ibm/cldk/neo4j/Schema.java index 31991334..b84e3cf2 100644 --- a/src/main/java/com/ibm/cldk/neo4j/Schema.java +++ b/src/main/java/com/ibm/cldk/neo4j/Schema.java @@ -57,5 +57,10 @@ private Schema() {} "CREATE INDEX j_callable_name IF NOT EXISTS FOR (c:JCallable) ON (c.name)", "CREATE INDEX j_type_name IF NOT EXISTS FOR (t:JType) ON (t.name)", "CREATE INDEX j_annotation_name_idx IF NOT EXISTS FOR (an:JAnnotation) ON (an.name)", - "CREATE FULLTEXT INDEX j_code_fts IF NOT EXISTS FOR (c:JCallable) ON EACH [c.code, c.docstring]"); + "CREATE FULLTEXT INDEX j_code_fts IF NOT EXISTS FOR (c:JCallable) ON EACH [c.code, c.docstring]", + // The incremental purge matches `id STARTS WITH `. Neo4j property indexes are + // label-scoped, so without this the prefix seek degrades to a scan of every node in the + // store, once per changed module. STARTS WITH is index-backed on a range index; CONTAINS + // and ENDS WITH are not, which is why the predicate is written this way. + "CREATE INDEX j_can_node_id IF NOT EXISTS FOR (n:" + RowBuilder.CAN_NODE + ") ON (n.id)"); } diff --git a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java index ab65465b..c3ace8d3 100644 --- a/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java +++ b/src/main/java/com/ibm/cldk/neo4j/V2SchemaCatalog.java @@ -45,10 +45,20 @@ private V2SchemaCatalog() {} // 2.2.0: additive MINOR — the repository-artifact layer (#197): Artifact/Package/ConfigKey // reserved at 2.0.0, now actually emitted, plus HAS_ARTIFACT/DEFINES_CONFIG/ // DECLARES_DEPENDENCY/LOCKS. - public static final String SCHEMA_VERSION = "2.2.0"; + public static final String SCHEMA_VERSION = "3.0.0"; /** Labels layered onto a node in addition to its merge + specific labels. */ - public static final List MARKER_LABELS = Arrays.asList("JEntrypoint"); + /** + * Additional labels a node may carry beyond its declared and merge labels. + * + *

{@code JCanNode} is on every node keyed by a {@code can://} id and exists purely so a + * prefix predicate can seek — Neo4j property indexes are label-scoped, so + * {@code WHERE n.id STARTS WITH $p} scans the whole store without one. It makes no semantic + * claim, unlike {@code JSymbol}, and is java-namespaced so an anchoring mistake still cannot + * reach another language's nodes. + */ + public static final List MARKER_LABELS = + Arrays.asList("JEntrypoint", RowBuilder.CAN_NODE); /** Tiny ordered-map builder for property declarations. */ private static final class P { @@ -84,13 +94,13 @@ private static List buildNodeLabels() { n.add(node("JModule", "JModule", "id", new P().put("id", "string").put("file_key", "string").put("package", "string") - .put("content_hash", "string").put("_module", "string").done())); + .put("content_hash", "string").done())); n.add(node("JType", "JSymbol", "id", lines(new P().put("id", "string").put("name", "string").put("kind", "string") .put("modifiers", "string[]").put("base_types", "string[]") .put("interfaces", "string[]").put("docstring", "string") - .put("is_entrypoint", "boolean").put("_module", "string")))); + .put("is_entrypoint", "boolean")))); n.add(node("JCallable", "JSymbol", "id", lines(new P().put("id", "string").put("name", "string").put("signature", "string") @@ -100,7 +110,7 @@ private static List buildNodeLabels() { .put("cyclomatic_complexity", "integer") .put("referenced_types", "string[]").put("accessed_fields", "string[]") .put("is_implicit", "boolean").put("is_entrypoint", "boolean") - .put("_module", "string")))); + ))); n.add(node("JExternal", "JSymbol", "id", new P().put("id", "string").put("kind", "string").put("signature", "string") @@ -109,20 +119,20 @@ private static List buildNodeLabels() { n.add(node("JField", "JField", "id", lines(new P().put("id", "string").put("name", "string").put("type", "string") .put("initializer", "string").put("modifiers", "string[]") - .put("docstring", "string").put("_module", "string")))); + .put("docstring", "string")))); n.add(node("JVariable", "JVariable", "id", lines(new P().put("id", "string").put("name", "string").put("type", "string") - .put("initializer", "string").put("_module", "string")))); + .put("initializer", "string")))); n.add(node("JEnumConstant", "JEnumConstant", "id", new P().put("id", "string").put("name", "string").put("arguments", "string[]") - .put("docstring", "string").put("_module", "string").done())); + .put("docstring", "string").done())); n.add(node("JRecordComponent", "JRecordComponent", "id", new P().put("id", "string").put("name", "string").put("type", "string") .put("modifiers", "string[]").put("is_variadic", "boolean") - .put("docstring", "string").put("_module", "string").done())); + .put("docstring", "string").done())); n.add(node("JBodyNode", "JBodyNode", "id", lines(new P().put("id", "string").put("kind", "string").put("method_name", "string") @@ -130,7 +140,6 @@ private static List buildNodeLabels() { .put("return_type", "string").put("accessibility", "string") .put("is_constructor_call", "boolean").put("is_static_call", "boolean") .put("argument_types", "string[]").put("argument_expr", "string[]") - .put("_module", "string") // L4 SDG synthetic-vertex payload. .put("var", "string").put("call_node", "string")))); diff --git a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java index e28cec0e..5321bae9 100644 --- a/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java +++ b/src/test/java/com/ibm/cldk/CodeAnalyzerV2CliTest.java @@ -184,10 +184,10 @@ void emitSchemaAlwaysEmitsTheV2Catalog(@TempDir Path tmp) throws IOException { assertEquals(0, run("--emit", "schema", "-o", out.toString())); JsonObject doc = JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) .getAsJsonObject(); - assertEquals("2.2.0", doc.get("schema_version").getAsString()); + assertEquals("3.0.0", doc.get("schema_version").getAsString()); assertEquals(0, run("--emit", "schema", "-o", out.toString(), "--schema", "v1"), "--emit schema ignores --schema"); - assertEquals("2.2.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) + assertEquals("3.0.0", JsonParser.parseString(Files.readString(out.resolve("schema.neo4j.json"))) .getAsJsonObject().get("schema_version").getAsString()); } diff --git a/src/test/java/com/ibm/cldk/neo4j/BoltWriterPurgeTest.java b/src/test/java/com/ibm/cldk/neo4j/BoltWriterPurgeTest.java index a38939ab..713e6140 100644 --- a/src/test/java/com/ibm/cldk/neo4j/BoltWriterPurgeTest.java +++ b/src/test/java/com/ibm/cldk/neo4j/BoltWriterPurgeTest.java @@ -3,85 +3,68 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; import org.junit.jupiter.api.Test; /** - * The incremental push deletes, and a delete anchored on nothing deletes everything that matches. + * The incremental push deletes, and what it is scoped by decides whose data it deletes. * - *

{@code _module} is a shared convention, not a java-private property: the sibling analyzers set - * it on their own nodes with the same value (the module's file key). An unlabelled - * {@code MATCH (x {_module: $m})} therefore reaches THEIR nodes wherever a file key collides in a - * shared database (#213). These tests pin the anchor that stops it. + *

Scoping by {@code _module} — a bare project-relative file key — was wrong twice over. It is set + * by the sibling analyzers too, so an unlabelled match reached their graphs (#213); and it carries + * no application, so even once labelled it could not tell two java applications sharing + * {@code src/main/java/Foo.java} apart. No label can: their nodes are identical in every respect + * except identity. * - *

They assert the statement text rather than round-tripping through Neo4j deliberately: the - * live-database path needs Docker and does not run everywhere, and the property that makes the fix - * correct -- which labels the match is confined to -- is fully determined by the catalogs. + *

Scoping on the module's own {@code can://} id fixes both at once, because the id is a path — + * {@code can://java//} — so a prefix match is language-, application- and module-scoped + * simultaneously. + * + *

Asserted on the statement text rather than through a live database: that path needs Docker and + * does not run everywhere, while the property that makes the fix correct is entirely in the + * predicate. */ class BoltWriterPurgeTest { - private static List moduleOwnedLabels() { - List labels = new ArrayList<>(); - for (List catalog : - Arrays.asList(SchemaCatalog.NODE_LABELS, V2SchemaCatalog.NODE_LABELS)) { - for (SchemaCatalog.NodeLabel nl : catalog) { - if (nl.properties.containsKey("_module") && !labels.contains(nl.label)) { - labels.add(nl.label); - } - } - } - return labels; - } - @Test - void neitherPurgeStatementMatchesAnUnlabelledNode() { - for (String stmt : new String[] {BoltWriter.PURGE_MODULE_EDGES, BoltWriter.PURGE_VANISHED_NODES}) { - assertFalse(stmt.contains("(x {_module"), - "an unlabelled _module match reaches a sibling analyzer's nodes: " + stmt); - assertTrue(stmt.contains("(x:" + CypherWriter.MODULE_OWNED + ")"), - "every _module match must be anchored on java-owned labels: " + stmt); + void thePurgeIsScopedByCanIdPrefixAndNotByTheRetiredProperty() { + for (String stmt : new String[] {BoltWriter.PURGE_MODULE_EDGES_V2, BoltWriter.PURGE_VANISHED_NODES_V2}) { + assertFalse(stmt.contains("_module"), + "the purge must not reference the retired property: " + stmt); + assertTrue(stmt.contains("x.id = $mid"), + "the module itself is matched by equality: " + stmt); + assertTrue(stmt.contains("STARTS WITH $pre"), + "its declarations are matched by id prefix: " + stmt); } } @Test - void theAnchorCoversEveryLabelThatCarriesModule() { - // A label that carries _module but is missing from the anchor would stop being purged -- - // stale nodes surviving a push, the opposite failure from deleting a sibling's. - for (String label : moduleOwnedLabels()) { - assertTrue(Arrays.asList(CypherWriter.MODULE_OWNED.split("\\|")).contains(label), - label + " carries _module but is not in the purge anchor"); + void thePurgeCarriesALabelSoThePrefixPredicateCanSeek() { + // Neo4j property indexes are label-scoped: without a label this scans every node in the + // store, once per changed module. The label is a performance anchor, not a safety one -- + // the prefix does the scoping -- but it is java-namespaced anyway, so an anchoring mistake + // still cannot reach another language's nodes. + for (String stmt : new String[] {BoltWriter.PURGE_MODULE_EDGES_V2, BoltWriter.PURGE_VANISHED_NODES_V2}) { + assertTrue(stmt.contains("(x:" + RowBuilder.CAN_NODE + ")"), stmt); } } @Test - void theAnchorSpansBothSchemaGenerations() { - // BoltWriter is schema-agnostic and may be pushing either generation, so a v2-only anchor - // would silently stop purging a v1 graph. - List anchor = Arrays.asList(CypherWriter.MODULE_OWNED.split("\\|")); - assertTrue(anchor.contains("JCompilationUnit"), "v1's module-owned label must be covered"); - assertTrue(anchor.contains("JModule"), "v2's module-owned label must be covered"); - } - - @Test - void theAnchorExcludesCrossLanguageAndSharedNodes() { - // Artifact/Package/ConfigKey are deliberately un-prefixed cross-language merge targets, and - // JPackage/JAnnotation/JExternal are shared across modules. None carries _module, so none - // may appear in an anchor used for deletion -- see CypherWriter.DESCENDANTS' javadoc. - List anchor = Arrays.asList(CypherWriter.MODULE_OWNED.split("\\|")); - for (String shared : new String[] {"Artifact", "Package", "ConfigKey", "JPackage", - "JAnnotation", "JExternal", "JApplication"}) { - assertFalse(anchor.contains(shared), shared + " must never be reachable by a purge"); - } + void theDescendantPrefixEndsWithTheSeparator() { + // Dropping the separator is silent and wrong: can://java/app/src/Foo.java also prefixes + // can://java/app/src/Foo.javaX, so a bare STARTS WITH would purge a different module. + String prefix = BoltWriter.descendantPrefix("can://java/app/src/Foo.java"); + assertTrue(prefix.endsWith("/"), prefix); + assertFalse("can://java/app/src/Foo.javaX".startsWith(prefix), + "a sibling module whose path merely starts the same way must not match"); + assertTrue("can://java/app/src/Foo.java/Foo/m()".startsWith(prefix), + "a real declaration of this module must match"); } @Test - void theAnchorIsNotEmpty() { - // A pattern that degraded to "" would render `MATCH (x:)`, which is a syntax error rather - // than a silent over-delete -- but assert it anyway, since the value is derived. - assertFalse(CypherWriter.MODULE_OWNED.isEmpty()); - assertFalse(CypherWriter.MODULE_OWNED.startsWith("|")); - assertFalse(CypherWriter.MODULE_OWNED.endsWith("|")); + void aCanIdIsRecognizedAndAVersionOneIdIsNot() { + // What selects the purge path at all: v1 ids are FQN-shaped and carry no application + // segment, so there is nothing to prefix and the purge is skipped rather than mis-scoped. + assertTrue(RowBuilder.isCanId("can://java/app/src/Foo.java")); + assertFalse(RowBuilder.isCanId("com.l4.Arity#caller(int, int)@22:16-22:25")); + assertFalse(RowBuilder.isCanId(null)); } }