This commit is contained in:
Ingo Schnabel
2026-07-19 08:34:46 +02:00
parent 1095ce94fe
commit 2a45f9fdc2
15 changed files with 959 additions and 744 deletions

View File

@@ -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=73
version=77

View File

@@ -14,6 +14,10 @@ import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -184,6 +188,23 @@ public class ProjectIngestService {
.toList();
}
/**
* Item 24: reads and parses one candidate file, applying the shell/user-exit metric enrichment.
* Pure per-file work (no shared mutable state), so it is safe to run on a virtual thread per file.
*/
private Parsed parseCandidate(Candidate candidate, Path root, boolean coarse,
CopycodeLibrary copycodes, Map<String, LocMetrics> userExit,
ProjectInfo project) throws IOException {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
ParseResult result = coarse
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
return new Parsed(candidate, result);
}
/**
* Identity used for cross-file duplicate detection: the fully-qualified name for Java modules
* (so {@code com.a.Foo} and {@code com.b.Foo} are distinct), the bare name otherwise.
@@ -364,20 +385,28 @@ public class ProjectIngestService {
List<String> examinedFiles = candidates.stream()
.map(c -> relativeSourceFile(root, c.file()))
.toList();
// Item 24: parse+read each file on its own virtual thread. Reading source is IO-bound and
// parsing is CPU-bound but per-file independent (the JavaParser/NaturalParser instances hold no
// mutable state; copycodes/userExit are read-only), so this scales the parse phase across cores.
// Results are collected in candidate order (futures list is parallel to candidates), so the
// downstream duplicate detection and persist order stay deterministic.
List<Parsed> parsed = new ArrayList<>();
List<IngestSummary.Failure> failed = new ArrayList<>();
for (Candidate candidate : candidates) {
try {
String content = SourceFiles.read(candidate.file());
String sourceFile = relativeSourceFile(root, candidate.file());
ParseResult result = coarse
? astIngestService.coarseScan(candidate.kind().language(), sourceFile, content, copycodes)
: withShellMetrics(astIngestService.parse(candidate.kind().language(), sourceFile, content, copycodes),
content, candidate.kind().language());
result = withUserExitMetrics(result, project.generatedDir(), userExit);
parsed.add(new Parsed(candidate, result));
} catch (RuntimeException | IOException e) {
failed.add(new IngestSummary.Failure(candidate.file().toString(), e.toString()));
try (ExecutorService parseExecutor = Executors.newVirtualThreadPerTaskExecutor()) {
List<Future<Parsed>> futures = candidates.stream()
.map(candidate -> parseExecutor.submit(
() -> parseCandidate(candidate, root, coarse, copycodes, userExit, project)))
.toList();
for (int i = 0; i < futures.size(); i++) {
try {
parsed.add(futures.get(i).get());
} catch (ExecutionException e) {
Throwable cause = e.getCause() != null ? e.getCause() : e;
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), cause.toString()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
failed.add(new IngestSummary.Failure(candidates.get(i).file().toString(), e.toString()));
}
}
}

View File

@@ -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 MCP
# 'version' tool/server-info (referenced below via property expression, not duplicated).
agenticcode.version=73
agenticcode.version=77
# MCP server (HTTP/SSE transport) — tools exposed at http://<host>:8787/mcp/sse
quarkus.mcp.server.server-info.name=agenticcode

View File

@@ -0,0 +1,87 @@
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.InputStream;
import java.io.UncheckedIOException;
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;
/**
* Item 81 — a leaf qualified by a <em>group</em> whose name is not a {@code USING} member must resolve
* by using the group to disambiguate an otherwise-ambiguous leaf name. `WGEAGB0S` reads
* {@code #MAP-T.V-ID}, where {@code #MAP-T} is a group inside the included {@code BGEAGA01.pda} and
* {@code V-ID} occurs in dozens of PDAs, so the bare resolver's {@code size(matches)=1} guard left it
* unresolved.
*
* <p>{@code MG81} includes both {@code GRPPDA} (whose {@code MAPT} group holds {@code FLD-X}) and
* {@code AMBIG} (which also declares {@code FLD-X}), then reads {@code MAPT.FLD-X}. The bare leaf
* {@code FLD-X} is ambiguous across the two includes, but the group qualifier {@code MAPT} pins it to
* {@code GRPPDA.lda}. Asserted against the raw {@code READS} target. Written RED.
*/
@QuarkusTest
class GroupQualifiedLeafResolveIT {
private static final String PROJECT = "nat-group-qualifier";
@TempDir
static Path root;
@Inject
Driver driver;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String f : List.of("GRPPDA.lda", "AMBIG.lda", "MG81.nat")) {
copyFixture("fixtures/natural/groupqual/" + f);
}
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 copyFixture(String cp) {
String fileName = cp.substring(cp.lastIndexOf('/') + 1);
try (InputStream in = GroupQualifiedLeafResolveIT.class.getClassLoader().getResourceAsStream(cp)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + cp);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void groupQualifierDisambiguatesAnAmbiguousLeaf() {
try (Session session = driver.session()) {
session.run("CALL db.awaitIndexes(120)").consume();
List<String> targets = session.run("""
MATCH (m:AstNode {project:$p, type:'MODULE', name:'MG81'})
-[:CONTAINS*0..]->(s)-[:READS]->(v:AstNode {name:'FLD-X'})
WHERE v.sourceFile <> ""
RETURN DISTINCT v.sourceFile AS f ORDER BY f
""", Map.of("p", PROJECT))
.list(r -> r.get("f").asString());
assertEquals(List.of("GRPPDA.lda"), targets,
"MG81 reads MAPT.FLD-X — the MAPT group pins the ambiguous FLD-X to GRPPDA.lda, not "
+ "AMBIG.lda, and it must not stay unresolved. Targets: " + targets);
}
}
}

View File

@@ -0,0 +1,84 @@
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.InputStream;
import java.io.UncheckedIOException;
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;
/**
* Item 80 — a qualified reference may name a <em>group</em> (a {@code DATA_STRUCTURE}), not only a leaf
* field. The qualifier-scoped field resolver matched targets of type {@code VARIABLE}/{@code CONSTANT}
* only, so a write to a group stayed unresolved ({@code sourceFile=""}) — e.g. `WGEAGB0S` writing
* {@code BGEAGBA0.#P-DESC-NAME}, a level-1 group of {@code BGEAGBA0.pda}.
*
* <p>{@code MGRP} includes {@code GRPPA} and writes {@code GRPPA.GRP-FLD}, where {@code GRP-FLD} is a
* level-2 group (it has children {@code SUB-A}/{@code SUB-B}). The write must resolve into
* {@code GRPPA.lda}, not stay a placeholder. Asserted against the raw {@code WRITES} target. Written RED.
*/
@QuarkusTest
class QualifiedGroupTargetResolveIT {
private static final String PROJECT = "nat-group-target";
@TempDir
static Path root;
@Inject
Driver driver;
@BeforeAll
static void ingest() {
RestAssured.port = Integer.getInteger("quarkus.http.test-port", 8081);
for (String f : List.of("GRPPA.lda", "MGRP.nat")) {
copyFixture("fixtures/natural/groupfield/" + f);
}
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 copyFixture(String cp) {
String fileName = cp.substring(cp.lastIndexOf('/') + 1);
try (InputStream in = QualifiedGroupTargetResolveIT.class.getClassLoader().getResourceAsStream(cp)) {
if (in == null) {
throw new IllegalStateException("Resource not found: " + cp);
}
Files.write(root.resolve(fileName), in.readAllBytes());
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
@Test
void qualifiedWriteToAGroupResolvesIntoTheDataArea() {
try (Session session = driver.session()) {
session.run("CALL db.awaitIndexes(120)").consume();
List<String> targets = session.run("""
MATCH (m:AstNode {project:$p, type:'MODULE', name:'MGRP'})
-[:CONTAINS*0..]->(s)-[:WRITES]->(v:AstNode {name:'GRP-FLD'})
RETURN DISTINCT v.sourceFile AS f ORDER BY f
""", Map.of("p", PROJECT))
.list(r -> r.get("f").asString());
assertEquals(List.of("GRPPA.lda"), targets,
"MGRP writes GRPPA.GRP-FLD (a group), so it must resolve into GRPPA.lda, not stay an "
+ "unresolved placeholder. Targets: " + targets);
}
}
}

View File

@@ -0,0 +1,70 @@
package com.agenticcode.codeserver.api;
import com.agenticcode.neo4jstore.graph.CypherQueries;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.Test;
import org.neo4j.driver.Driver;
import org.neo4j.driver.Session;
import org.neo4j.driver.summary.Plan;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* Item 75 (read path) — the runtime read queries traversed a module's statements with an unbounded
* {@code (m)-[:CONTAINS*0..]->(src)}. {@code CONTAINS} is not acyclic (shared copycode nodes, parser
* line-range artefacts), so on a heavy module that expansion blew up and {@code callees}/{@code digest}/
* {@code context} hung (measured: {@code ACCNPE01 callees} timed out &gt; 2 min). Every edge source is a
* {@code MODULE} (depth 0) or a {@code FUNCTION} (a direct {@code CONTAINS} child, depth 1) — verified 0
* sources deeper — so the traversal is bounded to {@code *0..1}, which cannot walk the cycles.
*
* <p>This guards the fix at the plan level (like {@code StaleFileSweepIndexIT}): every variable-length
* {@code CONTAINS} expansion in the {@code callees} plan must carry an upper bound, so a revert to
* {@code *0..} is caught. {@code EXPLAIN} plans without executing.
*/
@QuarkusTest
class ReadPathBoundedTraversalIT {
/**
* A var-length CONTAINS expansion with no upper bound, e.g. {@code CONTAINS*} or {@code CONTAINS*0..}.
*/
private static final Pattern UNBOUNDED_CONTAINS = Pattern.compile("CONTAINS\\*(\\d*\\.\\.)?(?![\\d.])");
@Inject
Driver driver;
private static void collectOperators(Plan plan, List<String> into) {
Object details = plan.arguments().get("Details");
into.add(plan.operatorType() + "(" + (details == null ? "" : details.toString()) + ")");
plan.children().forEach(child -> collectOperators(child, into));
}
@Test
void calleesBoundsItsContainsTraversal() {
try (Session session = driver.session()) {
Plan plan = session.run("EXPLAIN " + CypherQueries.callees(null, false),
Map.of("name", "ANY", "project", "any-project"))
.consume().plan();
List<String> operators = new ArrayList<>();
collectOperators(plan, operators);
// Guard against a vacuous pass: the plan must actually expand CONTAINS.
assertTrue(operators.stream().anyMatch(op -> op.contains("CONTAINS")),
"callees plan should traverse CONTAINS; plan format may have changed: " + operators);
List<String> unbounded = operators.stream()
.filter(op -> UNBOUNDED_CONTAINS.matcher(op).find())
.toList();
assertTrue(unbounded.isEmpty(),
"callees must bound every CONTAINS traversal to *0..1 (item 75 read path); an "
+ "unbounded expansion explodes on the cyclic copycode region. Offending plan "
+ "operators: " + unbounded + " | full plan: " + operators);
}
}
}

View File

@@ -0,0 +1,8 @@
*-----------------------------------------------------------------
* Item 80: data area whose GRP-FLD is a level-2 GROUP (DATA_STRUCTURE),
* not a leaf field — it has children SUB-A / SUB-B.
*-----------------------------------------------------------------
1GRPPA
2GRP-FLD
A 5 3SUB-A
A 5 3SUB-B

View File

@@ -0,0 +1,9 @@
DEFINE DATA
LOCAL USING GRPPA
LOCAL
1 #V (A10)
END-DEFINE
*
MOVE #V TO GRPPA.GRP-FLD
*
END

View File

@@ -0,0 +1,6 @@
*-----------------------------------------------------------------
* Item 81: an unrelated area that also declares FLD-X, so the bare
* leaf name is ambiguous across MG81's two includes.
*-----------------------------------------------------------------
1AMBIG
A 10 2FLD-X

View File

@@ -0,0 +1,6 @@
*-----------------------------------------------------------------
* Item 81: data area whose MAPT group contains the leaf FLD-X.
*-----------------------------------------------------------------
1GRPPDA
2MAPT
A 10 3FLD-X

View File

@@ -0,0 +1,10 @@
DEFINE DATA
LOCAL USING GRPPDA
LOCAL USING AMBIG
LOCAL
1 #V (A10)
END-DEFINE
*
MOVE MAPT.FLD-X TO #V
*
END

View File

@@ -248,9 +248,17 @@ public final class NaturalParser implements LanguageParser {
// graph links the include. Model FIELD as a bare included-field placeholder so
// CypherQueries.resolveBareIncludedFieldTargets redirects it to the real PDA field, instead of
// dropping the access entirely. Gated exactly like the bare path above.
if (allowIncludePlaceholder && scope.hasIncludes() && IDENTIFIER_TOKEN.matcher(fieldName).matches()) {
return scope.bareFields().computeIfAbsent(fieldName, key -> {
AstNode field = node(NodeType.VARIABLE, key, "", lineNo, lineNo, null, null);
//
// Item 81: keep the group qualifier (STRUCT) as a `qualifierGroup` property so the bare-included
// resolver can pick the one included field that sits under a group of that name — otherwise a
// leaf like V-ID (in dozens of PDAs) is project-wide-ambiguous and stays unresolved. Cache under
// the compound "STRUCT.FIELD" key so a group-qualified ref and a truly-bare one to the same leaf
// are distinct placeholders.
if (allowIncludePlaceholder && scope.hasIncludes() && IDENTIFIER_TOKEN.matcher(fieldName).matches()
&& IDENTIFIER_TOKEN.matcher(structName).matches()) {
return scope.bareFields().computeIfAbsent(structName + "." + fieldName, key -> {
AstNode field = node(NodeType.VARIABLE, fieldName, "", lineNo, lineNo, null, null,
Map.of("qualifierGroup", structName));
scope.nodes().add(field);
scope.edges().add(edge(EdgeType.CONTAINS, scope.module().id(), field.id(), lineNo));
return field;

View File

@@ -2121,3 +2121,55 @@ live server); full `ac-code-server` IT suite green (190/0/0) with no regressions
them, so a module that changes which area it includes does not keep the old resolved edge. Scoped to
`VARIABLE`/`CONSTANT` and cross-file targets; gated on `reconcile`. Two-phase test
`QualifiedWriteReconcileIT.reIngestDeletesTheStaleResolvedFieldEdge`.
## Parallel parse phase (item 24) — 2026-07-18
The ingest parse phase (read file + parse + shell/user-exit metric enrichment) ran as a sequential loop
over all candidate files. It is per-file independent — the `JavaParser`/`NaturalParser` instances hold no
mutable state, and `copycodes`/`userExit` are read-only — so `ProjectIngestService.ingestRoot` now submits
one task per file to `Executors.newVirtualThreadPerTaskExecutor()` (extracted helper `parseCandidate`).
Results are collected in candidate order (the futures list is parallel to `candidates`), so cross-file
duplicate detection and persist order stay deterministic; a per-file parse/read failure is still recorded
as an `IngestSummary.Failure` for that file only. Full IT suite green (192/0/0).
## Qualified group-target resolution (item 80) — 2026-07-18
A Natural qualified reference can name a *group* (a `DATA_STRUCTURE`), not only a leaf field — e.g.
`WGEAGB0S` writes `BGEAGBA0.#P-DESC-NAME`, a level-1 group of `BGEAGBA0.pda`. The qualifier-scoped field
resolvers matched a real target of type `VARIABLE`/`CONSTANT` only, so such writes/reads stayed
unresolved (`sourceFile=""`). Fix: the two qualifier-scoped (INCLUDES-gated) resolvers
(`buildResolvePlaceholderFieldTargetQueries` + `…ScopedQueries`) now accept a `DATA_STRUCTURE` target as
well (`realv.type IN ['VARIABLE','CONSTANT','DATA_STRUCTURE']`). Left the bare-field resolvers (which
carry a `size(matches)=1` guard) leaf-only, so adding groups cannot turn a previously-unique bare match
ambiguous. Test `QualifiedGroupTargetResolveIT.qualifiedWriteToAGroupResolvesIntoTheDataArea`. *(The
`#MAP-T.*` reads that were unresolved in the same `WGEAGB0S` snapshot are leaves, a separate concern, not
covered here.)*
## Group-qualifier field resolution (item 81) — 2026-07-18
A qualified reference can name a leaf via a *group* the parser cannot see as a `USING` member — e.g.
`WGEAGB0S` reads `#MAP-T.V-ID`, where `#MAP-T` is a group inside the included `BGEAGA01.pda` and the leaf
`V-ID` also occurs in dozens of other PDAs. After items 78/79 the read was captured but fell to the
bare-field resolver, whose `size(matches)=1` guard rightly refused the project-wide-ambiguous leaf, so it
stayed `sourceFile=""`. Fix: `NaturalParser.lookupVariable` now keeps the group qualifier as a
`qualifierGroup` property on the bare placeholder (cached under the compound `STRUCT.FIELD` key so a
group-qualified and a truly-bare reference to the same leaf are distinct); the two bare-included resolvers
(`buildResolveBareIncludedFieldQueries` + `…ScopedQueries`) then keep only a candidate nested under a group
of that name, so the qualifier pins the leaf to the one right PDA. The placeholder is a bare `VARIABLE`
under the module (not a `DATA_STRUCTURE` area), so the by-name resolvers never see it — no
`#74`-style misattribution risk. Test `GroupQualifiedLeafResolveIT.groupQualifierDisambiguatesAnAmbiguousLeaf`;
full IT suite green (193/0/0).
## Read-path CONTAINS bounding (item 75 read path) — 2026-07-19
The runtime read queries traversed a module's statements with an unbounded `(m)-[:CONTAINS*0..]->(src)`.
`CONTAINS` is not acyclic (shared copycode `CONTROL_FLOW` nodes, parser line-range artefacts), so on a
heavy module (`ACCNPE01`) that expansion blew up and `callees`/`digest`/`context` hung (callees >2 min).
Every edge source is a `MODULE` (depth 0) or a `FUNCTION` that is a *direct* `CONTAINS` child (depth 1) —
verified corpus-wide (0 sources deeper; `DB_ACCESS` parents only `FUNCTION`/`MODULE`) — so the traversal is
provably equivalent when bounded to `*0..1`, which cannot walk the cycles. 20 read-side traversals updated
(`callees`, `MODULE_HOP_OUT`, `DISPATCH_TABLE`, `EGO_NEIGHBORS_*`, `VARIABLE_ACCESSES`, `DB_ACCESSES`,
`SQL_STATEMENTS`, `FUNCTION_CALLERS`, `SEARCH_BY_VALUE`, `fieldFlow`, `BUILD_CALLS_MODULE`, and the
`*_FOR_MODULES` batch variants). Query-only change (no `recreate`). Guard `ReadPathBoundedTraversalIT`
(EXPLAIN asserts no unbounded CONTAINS expand); full IT suite 193/0/0. Live (v76): `ACCNPE01 callees`
> 2 min → 0.16 s, `digest` >10 s → 3.3 s, `context` >10 s → 3.1 s.

View File

@@ -72,70 +72,11 @@ with stale check, the `refresh` surface, and hash-based auto-invalidation) — s
bottleneck — and 25 — batch persist, found already implemented — completed 2026-07-16 and moved to
`x-docs/features.md`. A full `upms` call-graph refresh (6311 files) now takes **179 s** end-to-end
(~63 s parse, 104.5 s persist across 32 batches, ~12 s finalize), against ~2.5–2.8 min **per batch**
before. **No open items remain in this track**, with one optional idea parked below.)*
- [ ] **Parallel parse phase (OPTIONAL, unmeasured — was the original item 24)** — parse is ~63 s of a
179 s Natural refresh, so parallelising it on virtual threads (`JavaParser.parse()` /
`NaturalParser.parse()` are stateless and safely parallelisable) would buy at most ~35 % and cost
deterministic log ordering. **Possibly worth it for a large *Java* project**, where building a
`CompilationUnit` is genuinely CPU-heavy — upms is Natural. Revisit only with a timing against
`pur` (Java) showing parse is a real share there.
before. The parked "parallel parse phase" idea was implemented 2026-07-18 (item 24) — see
`x-docs/features.md`. **No open items remain in this track.**)*
## Known bugs
- [ ] **80. A qualified assignment/read of a *group* (not a leaf field) stays unresolved** (found
2026-07-18, `WGEAGB0S` post-fix audit). The field resolvers (finalize 18–25) match a real target with
`realv.type IN ['VARIABLE','CONSTANT']`, so a reference to a `DATA_STRUCTURE` **group** never resolves.
`WGEAGB0S` writes `BGEAGBA0.#P-DESC-NAME` (lines 529/1350) and reads it (622), but `#P-DESC-NAME` is a
level-1 *group* in `BGEAGBA0.pda` (a `DATA_STRUCTURE` node), so all three stay `sourceFile=""`. Same
class for the `#MAP-T.V-ID`/`LOCK-ID`/`LOG-TIME`/`LOG-DATE`/`LOG-USER` reads and `P-REST-POINT-OBJ` — 9
unresolved in `WGEAGB0S` after items 74/78/79. Independent of those fixes (it is a target-type gap, not
a qualifier/scoping bug). A fix would let a group placeholder resolve to a `DATA_STRUCTURE` of the same
name under the included area (and, for a `MOVE BY NAME`, arguably fan out to the group's leaves). Not
yet fixed; needs its own design decision on group-level edge semantics.
- [x] **76. A bare unresolved field is ONE node per (name, project) — shared by up to 916 modules**
(fixed 2026-07-18 — field placeholders now carry a per-module identity)
(found 2026-07-17 while root-causing item 74). `MERGE_NODES` (`CypherQueries.java:40`) keys a node on
`{type, name, sourceFile, project}`. An unresolved bare field has `sourceFile=""`, so the key collapses
to `{VARIABLE, "P-DESCRIPTION", "", upms}` — **one** node for the whole project, `CONTAINS`ed by every
module that mentions the name. Measured in `upms`: **7,262** such nodes, **532** owned by more than one
module, **max 916 owners**; the `P-DESCRIPTION` placeholder has 39 owners, 70 `READS` + 35 `WRITES`
edges from 8 different origin files.
This is the *shared root* of item 74 and of the resolver defect below it: the resolver's per-module
reasoning is done on a node that is not per-module. It also leaks into the API — `variable_reads` /
`variable_writes` for such a name merge unrelated modules' local variables into one answer, and cannot
tell them apart because they are genuinely the same node.
**This is a schema decision, not a query fix**: giving placeholders a per-module identity (e.g. keying
on the owning module, as `POSITIONAL_NODE_TYPES` already does with `startLine` for `DB_ACCESS` /
`CONTROL_FLOW`) changes node identity, hence `DELETE_STALE_FILE_NODES` (which explicitly skips
`sourceFile=""` because they "are shared across files"), the item-58 sweep, and every `CONTAINS`
consumer. Deliberately **not** folded into the item-74 fix.
**2026-07-18 — now the #1 finalize-cost driver.** Once the item-75 `CONTAINS*` explosions were removed
(dynamic-`CALLNAT` + `link-args`), a full deep `upms` finalize (~28 min) is dominated **entirely** by
step 19 (`resolve-field-placeholder WRITES`, ~25 min). Root cause is exactly this sharing: the step
joins `(ph)-[:CONTAINS]->(phv)` × `(src)-[:WRITES]->(phv)`, and because `phv` is one shared node with
many qualifier-parents and ~170k writers, the join blows up to ~28.7M rows. Three query
restructurings (owner-scoped, includes-driven, sourceFile-indexed) each stayed > 2.5 min read-only —
confirming this needs the per-module-identity change above, not a query rewrite. A `PROPOSAL` for that
change is drafted (scope: field placeholders only — `MODULE`/`DB_TABLE` stay shared; new `ownerModule`
merge-key term; ~+45 % placeholder nodes; re-validate steps 18–26, item-77, `DELETE_STALE_FILE_NODES`),
awaiting sign-off before implementation. Note the suspicious 53-`READS`-vs-170k-`WRITES` asymmetry —
worth confirming the `WRITES` placeholder edges are not themselves over-created at ingest before
multiplying them per module.
**2026-07-18 — fixed.** Field-level placeholders ({@code VARIABLE}/{@code CONSTANT}, `sourceFile=""`)
now get a per-module identity: `GraphRepository.placeholderOwner` stamps an `ownerModule` (the
referencing file) that is added to both the in-memory dedup key (`mergeKey`) and the DB merge key
(`MERGE_NODES`/`MERGE_POSITIONAL_NODES`). `MODULE`/`DB_TABLE`/`DATA_STRUCTURE` placeholders keep
`ownerModule=""` and stay shared (so a `CALLNAT`/`USING` target still resolves once — the dual role of
the `DATA_STRUCTURE` qualifier is thereby sidestepped entirely). The stale-node sweep gained a companion
`DELETE_STALE_PLACEHOLDER_NODES` (keyed by `ownerModule`) so a re-ingested file's obsolete placeholders
are reaped instead of orphaning. Corpus result on `upms` v69: step 19 **55 min → 55 s** (~60×), whole
deep finalize **~59 min → ~6 min**; item-77 misattribution stays **0**; the graph grew by only **+2,207**
nodes total (the per-module placeholders resolve and are deleted in step 26, so only ~13.9k unresolved
remain); `ARG_TO_PARAM` is well-formed (30,583 edges, 0 position mismatches). The suspected `WRITES`
over-creation was a measurement artefact (READS were counted *after* step 18 had already resolved them).
- [ ] **75. `CONTAINS` is not acyclic — 22 self-loops and 162 two-cycles in `upms`**
(found 2026-07-17 while root-causing item 74; **cause NOT established — do not treat the notes below as
settled**). The containment hierarchy that dozens of queries traverse with `CONTAINS*` contains cycles:
@@ -192,122 +133,23 @@ before. **No open items remain in this track**, with one optional idea parked be
So the UI's Callees / module-overview / context panels spin on large modules. Same cure as the dynamic-
`CALLNAT` fix (`src.sourceFile = m.sourceFile` hash-join, INCLUDES-scoped for variables). Affected read
constants: `CALLEES`, `CALLERS`, `DIGEST`/`CONTEXT` query, `FUNCTION_CALLERS`, `DB_ACCESSES`,
variable `READS`/`WRITES`, `*_FOR_MODULES`. Deferred by user decision on 2026-07-18 — record only, no fix
this session.
- [ ] **74. A resolved field edge and a fresh placeholder edge for the same statement coexist**
(observed 2026-07-17 while verifying item 72; **not fully root-caused — do not treat the cause below as
settled**). After a *plain* (call-graph) refresh of a module that had previously been deep-ingested,
`VMULTMN4` holds **two** `WRITES` edges for the same statement, differing only in their target:
```
line 223 MOVE YAPRFMA0.DES-AUTHPROF TO P-DESCRIPTION
-> node 206827 P-DESCRIPTION sourceFile="" (fresh, unresolved)
-> node 507598 P-DESCRIPTION sourceFile=.../old/VMULTMA1.pda (older, resolved)
```
A plain refresh re-parses and re-creates the placeholder-targeted edge but does not run field
resolution, while the previously resolved edge survives — the item-58 sweep deletes stale *nodes* only,
and both target nodes are live. The edge MERGE cannot dedupe them because the targets are genuinely
different nodes.
**Item 72 did not cause this; it exposed it.** Before the guard chain the two rows produced *identical*
`dispatch-table` tuples and `DISTINCT` collapsed them. With `guards` present (set on the fresh edge,
absent on the old one) they no longer collapse: `VMULTMN4`'s table went from 69 to 82 rows, the extra
13 being phantom duplicates with an empty chain. `variables/{name}/reads|writes` still collapses them
(no distinguishing column), so it under-reports the same condition rather than double-counting.
**Open questions:** a subsequent deep ingest produced 69 resolved+chained edges but left the 13 old ones
in place, which the MERGE key should have prevented if the targets were the same node — so *why* the
resolution picks a different target on different passes is unexplained.
**Do not "fix" this by filtering chain-less rows out of the API** — that hides a graph-state defect
behind a query.
**Root-cause attempt 2026-07-17 — the `old/` vs `new/` guess above is dropped, it was never more than a
filename coincidence.** The investigation instead turned up items **75**, **76** and **77**; item 77
(fixed) removed *one* concrete mechanism for "different pass, different target": the scoped and unscoped
bare-field resolvers walked the include chain with different bounds (`*1..10` vs unbounded), so a name
matching at two depths was one match to one pass and two (ambiguous, skip) to the other. They are now in
lockstep. **Whether that was the mechanism behind these 13 edges is unverified** — plausible, not shown.
What is certain is that item 77 does *not* address the coexistence itself: a plain refresh still
re-creates the placeholder edge without running field resolution, and nothing deletes the previously
resolved one. That deletion is the actual fix and is still open.
**2026-07-18 — fresh reproduction in a fully deep-refreshed `upms` (a `WGEAGB0S` audit), so it is
*not* only a plain-refresh artefact.** The write `BXFRABA4.#L-FORWARD-VIA-PRI := TRUE` (WGEAGB0S:335)
carries **three** `WRITES` edges: the correct one to `BXFRABA4.pda` **plus** two misattributed to
same-named local fields in `JX0124N6.nat` and `JX0129N0.nat` — modules WGEAGB0S neither includes nor
calls. Same for lines 442/443/559 (`#L-ADJUST-NO-STOP-CHAR`, `#FORWARD`); uniquely-named fields
(`TOTAL-ACTION-LINES`, only in BXFRABA4) resolve cleanly to one edge. Mechanism: the by-name field
resolver (`buildResolvePlaceholderFieldTargetByNameQueries`) matches a placeholder field to **any**
same-named real field project-wide, unscoped to what the accessing module includes, and `MERGE`s an
edge to every match. `field-flow` cannot see this defect (WGEAGB0S never calls JX*, so no
producer→consumer path) and `variables/{name}/reads|writes` collapses same-named nodes — a faithful
failing test must assert on the raw `WRITES` target file at the `ac-neo4j-store` graph level, not the
REST surface.
**2026-07-18 — root-caused: these are STALE resolved edges, not a single-pass resolver bug.** Two
clean single-file fixtures (a qualified write onto an unincluded area; a qualified write coexisting
with same-named locals in unrelated modules) both deep-ingested **without any spill** — the current
field resolvers (steps 18–25) are correct in one pass: 18/19 are qualifier-scoped, 20/21 match on the
placeholder *area* name (so they cannot reach `JX0124N6`'s `#L-FORWARD-VIA-PRI`, which lives under a
local group `INPUT-VALUES`, not an area named `BXFRABA4`), 23/24 are include-scoped. A targeted
`refresh/WGEAGB0S?scope=neighborhood&deep=true` against the live graph left every misattributed
`JX0124N6`/`JX0129N0` edge **in place** and *added* two fresh unresolved (`sourceFile=""`) placeholder
writes beside them — the exact coexistence this item describes. So the `JX*` edges are cruft from an
older resolver generation that no reconcile step deletes. **Consequence for the test:** a faithful
failing test is **two-phase** (deep-ingest → re-ingest/plain-refresh → assert the stale/duplicate edge
is gone), not the single-pass graph test guessed above. The actual fix is a reconcile step that deletes
a module's prior resolved field edges before (or when) its placeholders are re-created. **A full clean
`recreate` (CLEAR_ALL) removes the existing cruft; an incremental `refresh` does not.**
**2026-07-18 — TRUE root cause found and fixed (the stale-edge theory above was WRONG).** A clean
`recreate` (CLEAR_ALL, no stale state possible) still produced the `WGEAGB0S → JX0124N6/JX0129N0`
misattribution, so it is **created fresh in a single pass**, not surviving cruft. Cause: there are
**three** `DATA_STRUCTURE`s named `BXFRABA4` — the real `BXFRABA4.pda` **and** a `1 BXFRABA4` group each
of `JX0124N6.nat`/`JX0129N0.nat` declares *inline* (module-owned) — and the placeholder resolver
(`buildResolvePlaceholderTargetQueries`, plus the two by-name field resolvers) matched `USING BXFRABA4`
to a real `DATA_STRUCTURE` by **name alone**, so `WGEAGB0S` got `INCLUDES` edges to all three and the
field resolver linked `#L-FORWARD-VIA-PRI` into all three. **Fix (the semantic key, per the observation
that a Natural `USING` always names a data area — PDA/LDA/GDA — never a subprogram):** a
`DATA_STRUCTURE` placeholder now resolves only to a real area that is **not owned by a `MODULE`**
(`NOT EXISTS { (:MODULE)-[:CONTAINS]->(real) }`) — a top-level member of a data-area file, never a
program-internal group. Applied in all three name-matching resolvers. Data-area files produce no
`MODULE` node, so real PDAs (`BXFRABA4.pda`, `CDPDA-M.pda`) are kept; the `.nat` inline groups are
dropped. **Test (GREEN, single-pass):**
`UsingResolvesToDataAreaNotLocalGroupIT.usingWriteResolvesOnlyIntoTheDataAreaFileNotASameNamedLocalGroup`.
**Verified live** (deploy v73 + clean `recreate`, 2026-07-18): `WGEAGB0S`'s `#L-FORWARD-VIA-PRI` now
targets only `BXFRABA4.pda` (the `JX0124N6`/`JX0129N0` edges are gone), and its whole variable-access
set has **zero** `FOREIGN` (module-file) resolutions where before there were 9+9. `##MSG-NR`/`##MSG-PGM`
(items 78/79) resolve to `CDPDA-M.pda`; callees/functions/data-structures/db-accesses unchanged.
**Secondary safeguard (still kept):** `mergeResults` also runs `DELETE_STALE_RESOLVED_FIELD_EDGES` in
the `reconcile` branch (deep re-ingest only) — deletes a re-parsed file's prior cross-file
`READS`/`WRITES` to a `VARIABLE`/`CONSTANT`, so finalize rebuilds them from the fresh placeholders.
This does *not* fix the misattribution (that is the resolver fix above); it addresses a genuine but
separate re-ingest scenario — a module that changes which area it includes must not keep the old
resolved edge. Scoped to `VARIABLE`/`CONSTANT` (not `DB_TABLE`) and cross-file targets; gated on
`reconcile`. **Test (GREEN, two-phase):**
`QualifiedWriteReconcileIT.reIngestDeletesTheStaleResolvedFieldEdge`.
- [x] **79. A qualified field read inside an expression is dropped** (found 2026-07-18, `WGEAGB0S`
audit; **fixed 2026-07-18**). *(Originally filed as "a qualifier does not disambiguate a shared field
name" — that framing was wrong: a controlled fixture proved the qualifier disambiguates correctly on
the write side (`QCPDA.QC-DESC` resolves to `QCPDA` even when `QC-DESC` also exists in `QCOTHER`). The
live `WGEAGB0S` "unresolved `BGEAGBA0.#P-DESC-NAME`" observation was stale-state (item 74), not a
resolver bug.)* The real defect: the read side of an assignment/expression/`IF` tokenised on
`IDENTIFIER_TOKEN` (`[#A-Za-z][#A-Za-z0-9\-]*`, no dot), so a qualified read such as
`#C := QCPDA.QC-DESC` or `IF MSG-INFO.##MSG-NR EQ …` split into two tokens and neither resolved — the
read was lost, and `IF` conditions were not read-scanned at all. **Fix:** new `OPERAND_TOKEN` keeps a
dotted operand as one token; `addExpressionReads` resolves a *qualified* token with
`allowIncludePlaceholder` (a placeholder for an explicit field access is legitimate) but a *bare* token
without (no placeholder for the multi-token "expression soup", so item 74's by-name path is not fed);
the assign-RHS loop and the `IF`-condition handler now both call it. **Test (GREEN):**
`QualifiedFieldResolveIT.qualifiedFieldReadInsideAnExpressionIsCaptured`.
- [x] **78. A field qualified by its group name (not the `USING` name) is dropped entirely** (found
2026-07-18, `WGEAGB0S` audit; **fixed 2026-07-18**). `WGEAGB0S` does `PARAMETER USING CDPDA-M`;
`CDPDA-M`'s root group is `MSG-INFO`, and the program writes its fields qualified by the **group** name
— `MSG-INFO.##MSG-PGM := *PROGRAM` (line 1378). `NaturalParser.lookupVariable` only recognised a
qualifier that matches a `USING`/PDA name; a group-name qualifier fell through to a local-only lookup
and, the field not being local, produced **zero** edges (`CDPDA-M.pda` parses fine and holds
`MSG-INFO`/`##MSG-NR`/`##MSG-PGM`). **Fix:** when the qualifier is not a known include and the field is
not local, `lookupVariable` now mints a bare included-field placeholder (gated exactly like the bare
path: `allowIncludePlaceholder && hasIncludes && identifier-shaped`), which `resolveBareIncludedFieldTargets`
redirects to the real PDA field. **Test (GREEN):**
`QualifiedFieldResolveIT.groupQualifiedFieldAccessIsCaptured`. *(The read side of the same construct —
`IF MSG-INFO.##MSG-NR` — is captured by the item-79 fix.)*
variable `READS`/`WRITES`, `*_FOR_MODULES`.
**2026-07-19 — fixed.** Rather than the `sourceFile` hash-join, the read queries use a tighter, provably
equivalent bound: every {@code CALLS}/{@code READS}/{@code WRITES}/{@code DB_ACCESS}-parent edge source
is a {@code MODULE} (depth 0) or a {@code FUNCTION} that is a *direct* {@code CONTAINS} child of the
module (depth 1) — verified corpus-wide (0 sources deeper, 0 non-direct-child edge-source functions, and
{@code DB_ACCESS} parents are only {@code FUNCTION}/{@code MODULE}, never {@code CONTROL_FLOW}). So
`(m)-[:CONTAINS*0..]->(src)` becomes `(m)-[:CONTAINS*0..1]->(src)`, which returns the identical set but
cannot walk the cyclic copycode region. 20 read-side traversals updated (`callees`, `MODULE_HOP_OUT`(+
wiring), `DISPATCH_TABLE`, `EGO_NEIGHBORS_*`, `VARIABLE_ACCESSES`, `DB_ACCESSES`(+`FOR_MODULES`),
`SQL_STATEMENTS`(+`FOR_MODULES`), `FUNCTION_CALLERS`, `SEARCH_BY_VALUE`(+`_CONTAINS`), `fieldFlow`,
`BUILD_CALLS_MODULE`, `FLOW_FRONTIER_SOURCE_FILES`). Finalize/resolve queries left as-is (they completed).
Guarded by `ReadPathBoundedTraversalIT` (EXPLAIN plan asserts no unbounded CONTAINS expand in `callees`);
full IT suite green (193/0/0). No `recreate` needed — a query-only change against the existing graph.
**Verified live (v76):** `ACCNPE01 callees` **&gt;2 min → 0.16 s**, `digest` **&gt;10 s → 3.3 s**,
`context` **&gt;10 s → 3.1 s`; `WGEAGB0S callees` unchanged (7). The underlying `CONTAINS` cycles
(parser artefacts) still exist, but both the finalize and the read consumers are now bounded — item 75
no longer has a practical impact.
- [ ] **73. A `NONE`/`ANY` branch is reported under its enclosing guard alone — the condition is a
negation no guard chain can express** (found 2026-07-17 while fixing item 72; **item 72 does not fix