New feature
This commit is contained in:
@@ -4,4 +4,4 @@
|
||||
server.url=http://localhost:8787
|
||||
# Stamped by manage-ac.sh (stamp_cli_version) from ac-code-server's agenticcode.version
|
||||
# at build time. "dev" means this jar wasn't built via manage-ac.sh.
|
||||
version=247
|
||||
version=250
|
||||
|
||||
@@ -3,7 +3,7 @@ quarkus.http.port=8787
|
||||
# AgenticCode's own release counter (not the Maven project version) — bump this by hand for each
|
||||
# release. Single source of truth for the startup log line, GET /api/version, and the OpenAPI
|
||||
# info version (referenced below via property expression, not duplicated).
|
||||
agenticcode.version=247
|
||||
agenticcode.version=250
|
||||
# OpenAPI / Swagger UI (item 48) — the generated spec is the contract the web-UI TS client
|
||||
# is generated against. Served at /q/openapi (yaml/json); Swagger UI at /q/swagger-ui in dev.
|
||||
mp.openapi.extensions.smallrye.info.title=AgenticCode API
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package com.agenticcode.codeserver.api;
|
||||
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import io.restassured.RestAssured;
|
||||
import jakarta.inject.Inject;
|
||||
import org.junit.jupiter.api.BeforeAll;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.neo4j.driver.Driver;
|
||||
import org.neo4j.driver.Session;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
/**
|
||||
* Item 75-B: a copycode-resident node belongs to the module that includes it, not to the copycode.
|
||||
*
|
||||
* <p>{@code CopycodePreprocessor} splices a {@code .cpy} body into every including module before
|
||||
* parsing, and the nodes it produces used to be MERGEd on {@code (type, name, sourceFile)} — so all
|
||||
* includers shared <em>one</em> node. Two consequences, both of which this test pins:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>Every includer's nesting context landed on one node.</b> A copycode may open a block it
|
||||
* does not close (the {@code END-FOR} lives in the includer — {@code YFRAMBC0.cpy} in
|
||||
* {@code upms} does exactly this), so the shared node was contained by, and contained, the
|
||||
* statements of every module that included it. <b>Note:</b> this is <em>not</em> shown to be
|
||||
* the source of item 75's {@code CONTAINS} cycles — every measured cycle pairs a copycode node
|
||||
* with statements of a <em>single</em> host, which a per-module identity does not separate.
|
||||
* No acyclicity is asserted here, because this fixture cannot produce a cycle either way and
|
||||
* a test that cannot fail guards nothing.</li>
|
||||
* <li><b>The stale-edge reaps could not run on it.</b> Items 124/86/106 key on the source node's
|
||||
* file; with a shared node, reaping during one module's refresh would delete edges the other
|
||||
* includers contributed and never re-create them. The fixture's second half asserts the
|
||||
* opposite property now holds: refreshing one host leaves the other host's copies alone.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@QuarkusTest
|
||||
class CopycodeNodeOwnershipIT {
|
||||
|
||||
private static final String PROJECT = "item75b-copycode-ownership";
|
||||
private static final String CPY = "SHAREDBLK.cpy";
|
||||
|
||||
@TempDir
|
||||
static Path root;
|
||||
|
||||
@Inject
|
||||
Driver driver;
|
||||
|
||||
@BeforeAll
|
||||
static void createProject() {
|
||||
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
|
||||
// Opens a FOR it never closes: the END-FOR is supplied by each including module. This is the
|
||||
// shape that made the shared node cyclic.
|
||||
write(CPY, """
|
||||
FOR #I = 1 TO 10
|
||||
CALLNAT 'SHAREDSUB' #I
|
||||
""");
|
||||
write("HOSTA.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE SHAREDBLK
|
||||
END-FOR
|
||||
*
|
||||
END
|
||||
""");
|
||||
write("HOSTB.nat", """
|
||||
DEFINE DATA
|
||||
LOCAL
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
INCLUDE SHAREDBLK
|
||||
END-FOR
|
||||
*
|
||||
END
|
||||
""");
|
||||
write("SHAREDSUB.nat", """
|
||||
DEFINE DATA
|
||||
PARAMETER
|
||||
1 #I (I2)
|
||||
END-DEFINE
|
||||
*
|
||||
END
|
||||
""");
|
||||
given().contentType("application/json")
|
||||
.body(new ProjectResource.ProjectRequest(null, root.toString(), null, "natural", null, null))
|
||||
.when().post("/api/projects/" + PROJECT)
|
||||
.then().statusCode(201);
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?deep=true")
|
||||
.then().statusCode(200);
|
||||
}
|
||||
|
||||
private static void write(String fileName, String content) {
|
||||
try {
|
||||
Files.write(root.resolve(fileName), content.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (IOException e) {
|
||||
throw new UncheckedIOException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private long count(String cypher) {
|
||||
try (Session session = driver.session()) {
|
||||
return session.run(cypher, Map.of("p", PROJECT)).single().get("c").asLong();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Guards against a vacuous pass: if the copycode were not expanded at all, every assertion below
|
||||
* would hold trivially.
|
||||
*/
|
||||
@Test
|
||||
void theCopycodeIsActuallyExpandedIntoBothHosts() {
|
||||
List<String> owners;
|
||||
try (Session session = driver.session()) {
|
||||
owners = session.run("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy'
|
||||
RETURN DISTINCT n.ownerModule AS owner ORDER BY owner
|
||||
""", Map.of("p", PROJECT))
|
||||
.list(r -> r.get("owner").asString());
|
||||
}
|
||||
assertEquals(List.of("HOSTA.nat", "HOSTB.nat"), owners,
|
||||
"the copycode body must be present once per including module");
|
||||
}
|
||||
|
||||
/**
|
||||
* The point of the item: no node is shared between the two includers.
|
||||
*/
|
||||
@Test
|
||||
void eachIncluderGetsItsOwnCopyOfTheCopycodeNodes() {
|
||||
long shared = count("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule = ''
|
||||
RETURN count(n) AS c
|
||||
""");
|
||||
assertEquals(0, shared, "a copycode-resident node must carry the including module as its owner");
|
||||
}
|
||||
|
||||
/**
|
||||
* A MODULE declared inside a copycode must stay shared — every module lookup binds
|
||||
* {@code (project, name, sourceFile)} and never {@code ownerModule}, so an owned MODULE node
|
||||
* would be invisible to them. Asserted on the whole project because the fixture has no such
|
||||
* module; the invariant is what matters, and it must hold for every node of that type.
|
||||
*/
|
||||
@Test
|
||||
void moduleAndTableNodesAreNeverOwned() {
|
||||
long owned = count("""
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.ownerModule <> '' AND n.type IN ['MODULE', 'DB_TABLE']
|
||||
RETURN count(n) AS c
|
||||
""");
|
||||
assertEquals(0, owned, "MODULE/DB_TABLE nodes must remain shared");
|
||||
}
|
||||
|
||||
/**
|
||||
* The reap hazard: {@code DELETE_STALE_FILE_NODES} and the item-86/106/124 edge reaps key on the
|
||||
* source file, and the copycode file is in every includer's fresh-file set. Keyed on the file
|
||||
* alone, refreshing HOSTA would delete HOSTB's copies (their {@code ingestGen} is a transaction
|
||||
* old) together with their edges, and nothing would rebuild them.
|
||||
*/
|
||||
@Test
|
||||
void refreshingOneHostLeavesTheOtherHostsCopiesIntact() {
|
||||
String cypher = """
|
||||
MATCH (n:AstNode {project: $p})
|
||||
WHERE n.sourceFile ENDS WITH '.cpy' AND n.ownerModule = 'HOSTB.nat'
|
||||
OPTIONAL MATCH (n)-[r]-()
|
||||
RETURN count(DISTINCT n) + count(r) AS c
|
||||
""";
|
||||
long before = count(cypher);
|
||||
assertTrue(before > 0, "HOSTB must own copycode nodes before the refresh");
|
||||
|
||||
given().when().post("/api/projects/" + PROJECT + "/refresh?paths=HOSTA.nat")
|
||||
.then().statusCode(200);
|
||||
|
||||
assertEquals(before, count(cypher), "refreshing HOSTA must not touch HOSTB's copycode nodes or edges");
|
||||
}
|
||||
}
|
||||
@@ -67,10 +67,16 @@ public final class CypherQueries {
|
||||
* fresh id (kept) while a renamed/removed field keeps its old id (deleted). Only files with a real
|
||||
* {@code sourceFile} are reconciled; {@code sourceFile = ""} placeholders are shared across files
|
||||
* and never swept here. Fixes stale identifier-index nodes that outlived a {@code refresh}.
|
||||
*
|
||||
* <p>Item 75-B: keyed on the {@code (sourceFile, ownerModule)} <em>pair</em>, not the file alone.
|
||||
* A copycode-resident node carries the including module's file as its {@code ownerModule}, so
|
||||
* several owners' nodes share one {@code sourceFile}; sweeping by file alone would delete every
|
||||
* other includer's nodes (their {@code ingestGen} is one transaction old) together with their
|
||||
* edges. A module's own nodes carry {@code ownerModule = ""}, so their sweep is unchanged.
|
||||
*/
|
||||
public static final String DELETE_STALE_FILE_NODES = """
|
||||
UNWIND $sourceFiles AS sf
|
||||
MATCH (n:AstNode {project: $project, sourceFile: sf})
|
||||
UNWIND $files AS p
|
||||
MATCH (n:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})
|
||||
WHERE n.ingestGen IS NULL OR n.ingestGen <> $ingestGen
|
||||
DETACH DELETE n
|
||||
""";
|
||||
@@ -103,10 +109,10 @@ public final class CypherQueries {
|
||||
* re-ingest, so a coarse Tier-1 (no-finalize) scan never strips resolved edges it cannot rebuild.
|
||||
*/
|
||||
public static final String DELETE_STALE_RESOLVED_FIELD_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f})-[r:READS|WRITES]->(fld:AstNode)
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o})-[r:READS|WRITES]->(fld:AstNode)
|
||||
WHERE (fld.type = 'VARIABLE' OR fld.type = 'CONSTANT')
|
||||
AND fld.sourceFile <> "" AND fld.sourceFile <> f
|
||||
AND fld.sourceFile <> "" AND fld.sourceFile <> p.f
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
@@ -124,8 +130,8 @@ public final class CypherQueries {
|
||||
* coarse re-ingest both re-emit them, so this is not gated on {@code reconcile}.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_TABLE_ACCESS_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:READS|WRITES]->(t:AstNode {project: $project, sourceFile: ""})
|
||||
WHERE t.type IN ['DB_TABLE', 'WORKFILE']
|
||||
DELETE r
|
||||
""";
|
||||
@@ -147,8 +153,8 @@ public final class CypherQueries {
|
||||
* identically.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_USING_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:INCLUDES]->(d:AstNode {project: $project, type: 'DATA_STRUCTURE'})
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
@@ -195,16 +201,18 @@ public final class CypherQueries {
|
||||
* {@code ..._CROSS} are gated on {@code resolveFields}/{@code dataflow} and so do <em>not</em> run
|
||||
* in a coarse finalize. Same rationale as item 74.
|
||||
*
|
||||
* <p><b>Scope limit.</b> Keyed on the source node's file, so it misses the 605 call edges whose
|
||||
* source subroutine is defined <em>inside a copycode</em> (14 of them stale). Those nodes are
|
||||
* MERGEd per {@code (type, name, sourceFile)} and therefore <b>shared</b> by every module that
|
||||
* includes the copycode, so reaping them during a single module's refresh would delete edges other
|
||||
* modules contributed and not re-create them. Fixing that needs a per-module identity for
|
||||
* copycode-resident nodes — a separate item, not a side effect of this one.
|
||||
* <p><b>Item 75-B — the copycode gap is closed.</b> This used to key on the source node's file
|
||||
* alone and therefore had to miss the 605 call edges whose source subroutine is defined
|
||||
* <em>inside a copycode</em> (14 of them stale): those nodes were MERGEd per
|
||||
* {@code (type, name, sourceFile)} and thus shared by every includer, so reaping during one
|
||||
* module's refresh would have deleted edges the other includers contributed. Copycode-resident
|
||||
* nodes now carry the includer's file as {@code ownerModule}, so the key is the
|
||||
* {@code (sourceFile, ownerModule)} pair and the reap touches exactly the re-parsed module's own
|
||||
* copies — which its own parse re-emits. Same for items 86 and 106 above.
|
||||
*/
|
||||
public static final String DELETE_STALE_NATURAL_CALL_EDGES = """
|
||||
UNWIND $sourceFiles AS f
|
||||
MATCH (src:AstNode {project: $project, sourceFile: f, language: 'natural'})-[r:CALLS]->(t)
|
||||
UNWIND $files AS p
|
||||
MATCH (src:AstNode {project: $project, sourceFile: p.f, ownerModule: p.o, language: 'natural'})-[r:CALLS]->(t)
|
||||
WHERE t.sourceFile = "" OR r.callKind <> 'CALLNAT_DYNAMIC'
|
||||
DELETE r
|
||||
""";
|
||||
|
||||
@@ -1641,21 +1641,41 @@ public class GraphRepository {
|
||||
*/
|
||||
private static String mergeKey(AstNode node, boolean positional, String project, String ownerFile) {
|
||||
String base = node.type() + " | ||||