171 KiB
AgenticCode — Implemented Features
Completed work, moved out of x-docs/roadmap.md (which now tracks only open
items). Each entry records what was built; IDs are preserved from the roadmap
(some IDs recur across sections — they are kept as-is for traceability).
Metrics
-
47. Generated vs. user-exit LoC/SLoC split (2026-07-13) — a project can declare a source
language(required at creation; attribute only — ingest still classifies by extension) and ageneratedDir/userExitDirpair (directory names, matched as path components likeexcludeDirs; both-or-neither,400 LANGUAGE_REQUIRED/GENERATED_USEREXIT_PAIRotherwise). A generated module already contains its hand-written user-exit twin inline, so at ingest a module undergeneratedDirwhose name also occurs underuserExitDiris annotated with that twin's LoC/SLoC (userExitLoc/userExitSloc); user-exit files are not ingested as standalone modules (the walk skipsuserExitDir, avoiding name collisions). NewUserExitMetrics.scancomputes the twins with the same per-languageLineCounter;ProjectIngestService.withUserExitMetricsstamps the generated nodes in both the coarse (Tier-1) and deep paths (correct at any depth).GET /loc(project_locMCP,ac loc) now reports, per language row and in the total:loc/sloc(total, incl. exit),userExitLoc/userExitSloc, andgeneratedExclusiveLoc/generatedExclusiveSloc(= total − exit, clamped ≥0 per file). Fields added toProjectInfo/ProjectRequest(REST + CLIproject create/updatewith-l/-g/-u; project create has no MCP tool). Tests:SourceFilesTest,ProjectResourceIT(validation),UserExitLocIT(rollup split against the counter). -
46. Deterministic per-file LoC / SLoC, per language (2026-07-12) — every file-level node (
MODULE, orDATA_STRUCTUREfor a Natural.lda/.pda) is stamped at ingest withloc(physical lines) andsloc(source lines: non-blank, non-comment). SLOC is computed per language by a newLineCounterSPI inac-parser-core(LocMetricsrecord +loc/slocproperty keys):NaturalLineCounterfollowsnatural-grammar.md§22.6.1 (full-line*/**//*, inline/*);JavaLineCounteris a char-state scan handling//,/* … */and string/char/text-block literals (so//inside a string stays code). Both the Tier-1 coarse scan and the Tier-2 deep parse call the same counter, so a module's metrics are identical at any ingest depth (numbers are reproducible and summable). Stamped inNaturalCoarseScanner/JavaCoarseScannerand, on the deep path, inProjectIngestService.withShellMetrics(viaAstIngestService.count). Surfaced onlist_modulesandmodule_context(newloc/slocfields), oninspect_node(raw properties), and via a new rollup:GET /api/projects/{p}/loc?language=&sourceFile=(PROJECT_LOCcypher →GraphRepository.projectLoc→ProjectLocDTO) — per-languagefileCount/loc/slocplus a project total, each source file counted once (collapses multi-node files viamaxper(language, sourceFile)before summing). Delivered as REST + MCP (project_loc) + CLI (ac loc) together. Tests:LocMetricsTest,NaturalLineCounterTest,JavaLineCounterTest, andAnalysisResourceIT(metrics match the counter; rollup totals/per-file consistent).
Natural ingest fidelity (WSUBPX0S findings)
Surfaced 2026-07-12 while re-analysing WSUBPX0S (project upms) via the API vs. the
grep-based analyses. Two classes of information the API could not recover; both now fixed.
Executable acceptance tests: ac-code-server/.../api/NaturalIncludeMacroAndXmlPayloadIT.java
(the _target tests are green; fixtures under src/test/resources/fixtures/natural/framework-gaps/).
-
44. Framework-mediated DB access via
INCLUDEmacros (2026-07-12) — table access performed through the generic table-access framework (INCLUDE YFRAMGC0 … '"<accessor>"' …) was invisible: theCALLNATto the generic accessor lives in the copycode member, not the including module, socallees/db-accesseswere empty.NaturalParserandNaturalCoarseScannernow recognise the statement-level framework macro (a newINCLUDE_MACROmatcher, gated on the declarativeFrameworkMacrosregistry that maps a macro name → the positional index of the accessor argument), de-quote the accessor name ('"FRELEMG0"'→FRELEMG0, args collected across continuation lines), and emit aCALLSedge taggedcallKind=INCLUDE_MACRO(newCallKindconstant; surfaced asedgeKindon callers/callees). Because it's a normalCALLS, the accessor's table access surfaces transitively via the existingdb-accesses?depth=query withvia=accessor. Scope: targeted recogniser only (roadmap option 1); general.nsccopycode expansion and direct READS/WRITES mode tagging were deliberately left out (unnecessary for the acceptance criteria). -
45. XML payload / interface schema (2026-07-12) — XML wrapper subprograms map data-area fields to XML tags via the
ADD-XML-LINEemit idiom (#W-TAG := '<tag>'/#W-VALUE := <field>/PERFORM ADD-XML-LINE, whose subroutineCOMPRESSes'<' #W-TAG '>' #W-VALUE); that tag↔field↔direction contract was not captured. The deep parser now detects the emit subroutine and its tag/value variable pair, walks the emit sequences, and models each triple as a newPAYLOAD_FIELDnode ({tag, field, direction=REQUEST}, qualifier stripped to the field name) contained by the module. Exposed as RESTGET /modules/{name}/payload+ MCPmodule_payload+ CLIac payload(PAYLOADcypher →GraphRepository.payload→PayloadFieldDTO). Scope: the emit (REQUEST) idiom with literal tags; parse-side (RESPONSE) and derived-tag normalisation remain open. -
46a. General copycode (
.cpy) expansion (2026-07-13) — statement-levelINCLUDE <member> <args>is now expanded (deep + coarse) before parsing, so a copycode'sCALLNAT/PERFORM, DB access and dataflow surface on the including module (previously invisible).CopycodePreprocessorsplices the resolved copycode body with positional&1&…substitution and records a per-line origin table; after parsing, node/edge line numbers are remapped back to their real file positions — host statements to the host, copycode statements into the.cpy(taggedviaCopycode/includedAt) — so line-based navigation stays correct despite the splice. Copycodes are resolved by a per-ingestCopycodeLibrary(.cpyname→path, read + cached); they are not ingestible as standalone modules. Framework macros (item 44), data-areaUSING, unknown members and copycodes declaringDEFINE DATAare excluded; recursion is cycle-guarded. Threaded via a newCopycodeResolverintoNaturalParser.parse/NaturalCoarseScanner.scan(Java unaffected). Acceptance:CopycodeExpansionIT(a host whose only DB access + call live in a.cpy). -
46b. XML payload derived from the interface PDA (2026-07-13) — real production XML wrappers (
WNAUTD0S-style) are generic, runtime-driven serializers (YFRAMN07tag-builder,ADD-XML-LINE/ADD-XML-ACT) with no static tag list in source, so item-45's idiom scan finds nothing. Such a module is now flaggedxmlWrapperwith itsPARAMETER USINGinterfacePda, andGET /payloadderives the contract from that PDA when no static idiom exists: each field → a payload field, wire tag = field name normalised (#→_), directionREQUEST,source=PDA(vssource=IDIOM). Idiom fields take precedence. Acceptance:NaturalIncludeMacroAndXmlPayloadIT.genericWrapperPayloadIsDerivedFromInterfacePda. The static idiom additionally handles derived tags (EXAMINE #W-TAG FOR '#' REPLACE '_'→#→_) and both directions:ADD-XML-LINE-style emit sub →REQUEST,GET-XML-LINE-style parse sub (reversefield := #W-VALUEbinding) →RESPONSE. -
46c. Ingest Global Data Areas (
.gda) (2026-07-13) —.gdafiles now classify as NaturalDATA_STRUCTUREs (like.lda/.pda) andDEFINE DATA GLOBAL USING <gda>resolves to them (theUSINGrecogniser now acceptsGLOBAL). Was: GDAs never ingested,GLOBAL USINGunresolved.
Lazy / deferred three-tier ingest
Reworked ingest from eager whole-project parsing into a lazy, on-demand model.
Three tiers: Tier 1 = cheap eager reference index (per file: nodes,
identifiers, coarse call/DB references — no deep bodies); Tier 2 = lazy deep
ingest (control flow, statement-level dataflow, precise reads/writes) triggered
on demand; Tier 3 = source served from the filesystem, no longer stored on
nodes. Reverse queries (callers, search_identifier, flow_backward) stay
answerable because Tier 1 pre-indexes coarse references globally. (Item 43,
automatic invalidation, remains open in the roadmap.)
-
36. Tier 1 reference index + tri-state ingest status (2026-07-11) — on project create, scan every source file and create nodes (no deep edges) plus the identifier index and coarse call/DB references. Each node carries a tri-state status
not-ingested/ingesting/ingestedwith a lock to serialise concurrent deep-ingest of the same node. Coarse references include Natural dynamic call targets (CALLNAT PGM-VAR), copycode/INCLUDE, continuation lines — computed by the lexer, not grep.- The Tier-1 coarse reference scan on project create: new
CoarseScannerSPI (ac-parser-core);NaturalCoarseScanneris a lexer-level single pass (module /function/data-structure shells, declared-field identifier index, coarseCALLS/READS/WRITES/INCLUDESincl.PERFORM,CALLNAT '...', dynamicCALLNAT PGM-VAR,PARAMETER/LOCAL USINGcopybooks — attributed to the enclosing subroutine exactly asNaturalParserdoes, so coarse edges merge cleanly with a later deep ingest);JavaCoarseScannerreusesJavaParserbut persists only the coarse projection (class/method shells + call/type edges), since a symbol-free Java scan would mis-resolve. Each shell carries asourceHash(feeds item 41).AstIngestService.coarseScan+ProjectIngestService.scanTier1walk the root, persist the coarse graph and run the cheap CALL_GRAPH enrichment (so cross-filecallers/calleesresolve immediately) — modules landCALL_GRAPH/NOT_INGESTED. Wired intoPOST /api/projects/{p}create (configagenticcode.tier1.scan-on-create, default true; non-fatal on an unscannable root). Covered byTier1IndexIT,NaturalCoarseScannerTest,JavaCoarseScannerTest,SourceHashTest. - The durable tri-state status + lock: real
MODULEnodes carry a durableingestStatus(NOT_INGESTED/INGESTING/INGESTED,IngestStatusenum) with aningestStatusAtstamp, kept separate fromingestDepth(enrichment tier).GraphRepository.claimIngesting/clearIngesting(CypherQueries.CLAIM_INGESTING/CLEAR_INGESTING) implement a best-effort DB claim — Neo4j locks only atSET, so it is not a hard mutex; correctness rests oningestModulebeing idempotent, with true intra-JVM exclusion still from the in-process monitor.DeepIngestCoordinatorsetsINGESTINGat the start of a by-name deep ingest (markIngestDepth(FULL)flips the tree toINGESTED; failure rolls back toNOT_INGESTED), and a cross-process loser coalesces by pollingmoduleIngestStateuntil the winner reachesFULL, re-claiming if the claim is released or goes stale (agenticcode.deep-ingest.ingesting-ttl-seconds, default 1800; poll boundclaim-wait-seconds, default 120). Covered byDeepIngestStatusITandCoalescingIT(concurrent deep ingests converge to a singleFULLnode).
- The Tier-1 coarse reference scan on project create: new
-
37. Tier 2 lazy deep-ingest on every call (2026-07-11) — every MCP/API call deep-ingests the nodes it touches, transitioning them to
ingested. Decision: this includes fan-out result sets (e.g.search_identifier,callers), not just the explicitly named node — so the node budget (item 39) is the primary cost bound and its default must be a real, tuned number. Traversal queries (call_tree,flow_*) deep-ingest along the path as they walk it.- Module-gated field-level queries auto-trigger:
DeepIngestCoordinator.ensureDeepruns a scopedingestModule(with a per-(project,module)coalescing lock) from thewithDeepModulechoke point in bothMcpQueryToolsandAnalysisResource;flow-forward/backward,field-flowetc. auto-deep-ingest instead of returning409, falling back to the hint only when the module does not resolve. - Fan-out result-set warm for
callers,search_identifier, andcall_tree: blocking-then-rerun — each query runs against the graph as-is,DeepIngestCoordinator.ensureDeepManydeep-ingests the surfaced result-set files (by path, via the multi-rootProjectIngestService.ingestFiles) under a fan-out node budget (agenticcode.deep-ingest.fanout-nodes, default 50), and the query re-runs only if the warm deepened something. Wired at thewithFanoutWarmchoke point in both surfaces. Note:callerswarms already-surfaced callers (downstream precision) but cannot reveal a caller invisible at the coarse tier. - Cross-module
flow_*path-ingest (37a) forflow-forward,flow-backward, andfield-flow: an ingest-and-re-traverse fixpoint at thewithFlowPathWarmchoke point — afterensureDeep(startModule)it runs the trace and, each round, deep-ingests the frontier (surfaced modules seeded with the start module, plus their direct callee modules) in one scope (DeepIngestCoordinator.ensureFlowFrontier→GraphRepository.flowFrontierSourceFiles→ProjectIngestService.ingestFiles), then re-traverses. Ingesting the whole frontier (callers included, even ifFULL) re-links each caller into the freshly-ingested callee params, letting a trace cross into a dynamically-dispatched callee. Bounded byagenticcode.deep-ingest.flow-rounds(default 3) + thefanout-nodesbudget; stops early at the fixpoint. Covered byAnalysisResourceIT.flowForwardPathWarmCrossesIntoDynamicallyDispatchedCallee. - Global concurrency cap: all deep-ingest/warm work in
DeepIngestCoordinator(by-nameensureDeep, fan-outensureDeepMany, flowensureFlowFrontier) is gated by a fairSemaphore(agenticcode.deep-ingest.max-concurrent-warms, default 2), acquired viatryAcquireonly around the actual ingest (never while a coalesce-waiter waits); on timeout (warm-acquire-timeout-seconds, default 10) the warm is skipped and the query returns its Tier-1 answer.
- Module-gated field-level queries auto-trigger:
-
38. Deep-ingest depth cap (2026-07-10) — the by-name deep-ingest BFS (
ProjectIngestService.ingestModule) is bounded bymaxDepthhops from the named module: default 5 (agenticcode.deep-ingest.default-depth), clamped to the ceiling 20 (agenticcode.deep-ingest.max-depth). Hitting the bound truncates + reports (not a hard error) — the reached modules are markedFULLand the response carries anIngestSummary.Truncation. Override via?maxDepth=(REST, now onrefresh/{name}), therefreshMCP tool'smaxDeptharg, andac refresh --max-depth(CLI). -
39. Deep-ingest node budget (2026-07-10) — alongside the depth cap, the BFS ingests at most
maxNodesfiles (default 300,agenticcode.deep-ingest.default-nodes); overflow truncates + reports the same way (reasonNODES/NODES_AND_DEPTH). Override via?maxNodes=(REST), therefreshMCP tool'smaxNodesarg, andac refresh --max-nodes(CLI). Defaults are@ConfigPropertyand should still be tuned against the realacproject. -
40. Unresolved-reference nodes (2026-07-12) — when Tier 1 sees a variable / dynamic call target it cannot resolve, store it as a deduped placeholder node/edge flagged
unresolved, so reverse queries (e.g.callers) surface dynamic call sites while still distinguishing them from resolved edges. Resolution pass runs during Tier 2.- Placeholder nodes (
sourceFile = "") were already deduped (MERGE ontype,name,sourceFile,project); this adds theunresolvedflag. New enrichment stepSTAMP_UNRESOLVED_PLACEHOLDERS(runs last infinalizeProject/finalizeProjectScoped, project-wide, cheap, idempotent, every mode) setsunresolved = (no real definition of that (type,name) is ingested)— the resolution pass: a reference readsunresolved=falseonce its target file is ingested (a real twin exists),truewhile genuinely dangling. Surfaced explicitly onsearch_identifier(IdentifierMatch.unresolved) and, for free, oninspect_node(nodes/{id}returns the whole node). Reverse call queries (callers/callees) already surface these targets as entries with a blanksourceFile. Covered byUnresolvedRefIT(dangling →true; ingest target →false).
- Placeholder nodes (
-
41. Tier 3: stop storing source on nodes (2026-07-12) — drop stored source text; serve
module_source/node_sourceby openingsourceFile(project-relative path + content hash) and slicingstartLine..endLine. Stale check: on read, compare the stored content hash against the current file; on mismatch, return a structuredSTALE_SOURCEerror ("run refresh") instead of slicing — never serve current lines against old line numbers.- Source text was already not stored on nodes (served off disk by
SourceSnippetServicesince item 28); this adds the mandatory stale check. SharedSourceHash(SHA-256) is stamped on each file'sMODULE/DATA_STRUCTUREshell at ingest — by the Tier-1 scanners and (for robustness) the deep full-parse paths (ProjectIngestService.withSourceHash), surviving re-ingest viaSET +=. On asourceread,GraphRepository.sourceHash(project, sourceFile)supplies the stored hash andSourceSnippetService.readrecomputes the current file's hash; on mismatch it throwsStaleSourceException, which both REST (409 STALE_SOURCE) and MCP (STALE_SOURCEtool error) return with a "re-ingest (refresh)" message. A legacy file with no stored hash skips the check (serves unchecked). Covered byStaleSourceIT(fresh→200, edit→409, re-ingest→200). Copycode/INCLUDE slices remain raw pre-expansion file text. Note: a fan-out warm query (e.g.search_identifier) re-ingests and re-hashes a surfaced module, clearing its staleness as a side effect.
- Source text was already not stored on nodes (served off disk by
-
42.
refreshtool (manual invalidation) + removeingest_all/ingest_module(2026-07-12) — remove the eager bulk/one-module ingest tools across all three surfaces (MCPMcpIngestTools, RESTAnalysisResource,acCLI) and replace with arefreshoperation that re-runs Tier 1 + deep ingest. Interim manual invalidation until item 43.refreshis the single (re-)ingest surface across REST + MCP + CLI;ingest_all/ingest_module/ingest-call-graphwere removed from all three. RESTPOST /refresh(whole root, call-graph tier;?deep=truefor field-level) andPOST /refresh/{name}?maxDepth=&maxNodes=(deep re-ingest a module tree); MCP onerefresh(project, module?, deep?, maxDepth?, maxNodes?)tool; CLIac refresh [name] [--deep] [--max-depth --max-nodes].ProjectIngestService.refreshProject/refreshModuledelegate to the existing (now internal) ingest primitives — a whole-root refresh runs the full-parse call-graph pass (a superset of the create-time coarse Tier-1 scan, so intra-module dynamicCALLNATstill resolves), and is the remedy for aSTALE_SOURCEread (re-stampssourceHash). It MERGEs current files (does not wipe; deletions await item 43). All ~20 ITs bootstrapping via the old endpoints were migrated (behavior- preserving URL swaps);CLAUDE.md+mcp-api-usagedogfooding steps now sayrefresh. Covered byRefreshIT(whole-project + module refresh work; removed endpoints 404).
-
43. Automatic invalidation (hash-based) + deleted-file sweep (2026-07-15) — the graph now self-heals when source files change or are deleted on disk, without a manual
refresh. Chosen approach: hash-based, lazy (on-access) — not a filesystem watcher (no background thread; reuses the item-41sourceHash). Two parts, gated byagenticcode.auto-invalidate.enabled(defaulttrue).- Part A — change detection → auto re-ingest.
DeepIngestCoordinator.ensureDeep/ensureDeepMany(the two deep-ingest choke points every field-level query routes through) no longer short-circuit aFULLmodule blindly: they compare the file's currentSourceHashto the stored one (same compare as the/sourcestale check). AFULL-but-changed module is invalidated (GraphRepository.invalidateModule→ shell reset toCALL_GRAPH/NOT_INGESTED, soisFull()is false and the coalesce loop re-ingests) and re-parsed — with the item-58 reconciliation purging its stale nodes. Conservativefalse(never thrash aFULLmodule) when the flag is off, no source file, no stored hash (legacy node), or the file is unreadable/missing (a deleted file is Part B's job). Per-module granularity: only the queried module's own file is checked, not its whole dependency tree (a changed dependency is caught when it is queried by name). - Part B — deleted-file sweep. A whole-project
refresh(ProjectIngestService.refreshProject) now reconciles against the filesystem:GraphRepository.distinctSourceFiles→ any realsourceFileno longer present on disk →deleteNodesForSourceFiles(DETACH DELETE). Closes the item-58 gap (a refresh MERGEd current files but left nodes for deleted files). Reconciles against disk existence, not the walked-candidate list, so on-demand dependency files (PDAs/LDAs/.cpy) that exist but aren't top-level candidates are never wrongly swept. - No new REST/MCP/CLI surface — Part A is internal to the query path; Part B changes
refreshsemantics (now also drops deleted-file orphans). Both are documented behaviour changes, so the three surfaces stay in sync by construction. Covered byAutoInvalidationIT(edit aFULLmodule on disk → a field query auto-re-ingests,409 STALE_SOURCE→200, no manual refresh) andDeletedFileSweepIT(delete a file →refreshremoves its module + field nodes, survivors kept).PlaceholderResolveNullLineIT(which seeds phantom nodes for files it never writes) opts out via a@TestProfiledisabling the sweep.
- Part A — change detection → auto re-ingest.
Layered / performance-aware ingest
-
P2-c.
ingest-alldefers field resolution AND dataflow by default (fast whole-root) (2026-06-21) — even with P2-b's scoped per-program resolution, a whole-rootingest-allstill ran the global field-placeholder resolution AND the argument→parameter dataflow — measured live onupms: the field WRITES pass took ~26 min andlink-args-to-params~25 min (each one transaction over ~100k+ edges). So/ingest-allnow defaults to the fast call-graph pass (EnrichmentLevel.CALL_GRAPH): placeholder resolution (CALLS/INCLUDES/USES_TYPE/EXTENDS/IMPLEMENTS) + intra-module dynamicCALLNAT+ polymorphic (CHA) fan-out. It defers the expensive field-placeholder resolution and the dataflow + cross-module dynamicCALLNATdispatch to a scoped per-program deep ingest (/ingest/{name}, P2-b) — which fans out top-down from the analyzed program (the user's insight: dynamic dispatch is a top-down concern, not needed globally bottom-up). The old full whole-root behaviour is available viaPOST /ingest-all?deep=true. Implementation:EnrichmentLevel { CALL_GRAPH, FULL }(each carryingdataflow/resolveFieldsgates),finalizeProject(project, EnrichmentLevel),ingestAll(project, deep),AnalysisResource.ingestAllgains?deep. The default tags modulesCALL_GRAPH, so field-level endpoints return the409 NOT_DEEPLY_INGESTEDdeep-ingest hint (P2-a). Covered byAnalysisResourceIT(ingestAllDefaultDefersDataflowAndFieldResolutionToDeepIngest: fast ingest → intra-dynamic resolved, cross-dynamic absent +409on field-flow →/ingest/{name}→ cross-dynamic resolved200); the IT'singestAll()helper uses?deep=trueso existing field/dataflow assertions still exercise the full path. An earlier attempt kept dataflow+cross-callnat in aCALL_GRAPH_DATAFLOWmiddle tier; liveupmstesting showedlink-args-to-paramsalone was ~25 min, so it was dropped.
-
P2-b. Scope deep field resolution to the program tree (2026-06-20) — in the accumulating one-project model (call-graph everything, then deep-ingest programs over time), a by-name deep ingest previously ran the unscoped field-placeholder resolution over the whole project (~110k edges → ~28 min once the full call graph is loaded). Added
$names-scoped variants of the field-resolution and dataflow queries (resolvePlaceholderFieldTargetsScoped,…ByNameScoped,resolveBareIncludedFieldTargetsScoped,LINK_ARGS_TO_PARAMS_SCOPED,…_JAVA_SCOPED) that start from the just-ingested program tree's modules instead of scanning all placeholder edges.GraphRepository.finalizeProjectScoped(project, moduleNames)runs them (call-graph resolution, placeholder cleanup, CHA fan-out stay project-wide/idempotent);ingestModulenow calls it with the BFS-collected module names. So deep ingest stays fast (≈ the small-project 8.5 s case) even when the project already holds the whole call graph. Correctness covered byIngestModuleITfield-flow/dataflow tests (now exercising the scoped path); all ingest/analysis ITs green. Live perf re-validated onupms(2026-06-21): whole-codebaseingest-call-graph= 6309 modules in 67 s (placeholders left intact), then a by-name deep ingest ofKDWWIFN0(56-module tree) with the full call graph already loaded ran in 5.2 s — confirming the scoped resolution avoids the old unscoped whole-graph path (~28 min). Depth guard verified end-to-end:flow-forwardreturns 200 for theFULLmodule,409 NOT_DEEPLY_INGESTEDfor a call-graph-only module (ACCNPE01), and409 NOT_INGESTEDfor an un-ingested name (JE999), each with an actionablenextAction. -
P2-a. Layered ingest: call-graph mode + ingest-depth guard (2026-06-20) — field-placeholder resolution is volume-bound (whole-codebase ≈110k edges → not viable even cycle-safe/APOC node-global; measured), while per-program deep ingest is fast (~8.5 s for
KDWWIFN0's 56-file tree). So ingest is now layered: (1)POST /api/projects/{p}/ingest-call-graph— whole-root, persists everything but runs only the cheap call-graph/module-level enrichment (CALLS/INCLUDES/USES_TYPE/EXTENDS/IMPLEMENTS + CHA fan-out), skips field-placeholder resolution, and leaves placeholders intact for later deep ingest. Tags modulesingestDepth=CALL_GRAPH. (2)POST /ingest/{name}(by-name) andingest-allrun full enrichment and tag the ingested modulesingestDepth=FULL(accumulates over time in one project). (3) Field-level endpoints (flow-forward/flow-backward/field-flow) check the target module viaGraphRepository.moduleIngestStateand, when notFULL, return an agent-actionable409 { status: NOT_DEEPLY_INGESTED|NOT_INGESTED, module, detail, nextAction:{method,path} }instead of a misleading empty result. NewIngestDepth/ModuleIngestState,finalizeProject(full)splittingenrichmentSteps(full)into call-graph vs field groups, depth-marking Cypher, per-step finalize logging (label/duration/rows/JVM heap). New ITcallGraphIngestEnablesCallGraphAndGuardsFieldFlow(409→200 after deep ingest); fullAnalysisResourceIT+ingest ITs green.
Parser / model
- 1. Persisted per-language source/module kind (done 2026-07-07,
scoped down after investigation) — add an
AstNodeproperty capturing each module's finer kind beyond the coarseNodeType. Delivered: JavamoduleKind=CLASS/INTERFACE(exact, fromtype.isInterface()); NaturalmoduleKind= a best-effortPROGRAM/SUBPROGRAMguess (has a top-levelPARAMETERsection →SUBPROGRAM).GET /modulesgained a?moduleKind=filter. Not attempted (found to be a separate, larger feature, or genuinely unrecoverable from source text alone): Javaenum/record—JavaParserdoesn't parse these asMODULEnodes at all today (onlyClassOrInterfaceDeclaration), so this needs a new node-family traversal, not just a property; NaturalCOPYCODE/MAP/GDA— pernatural-grammar.md§22.2,PROGRAM/SUBPROGRAM/SUBROUTINEare real SAG Natural catalog metadata, andmain-program/external-subprogram/subprogramare syntactically identical productions — no reliable source-level signal exists for these without the original catalog. Seex-docs/mcp-api-usage-ac-implementation.mdstep 0 for the heuristic's limits.
Foundation (schema, health, errors, ingest CLI)
- 1. Neo4j indexes & constraints — index
(:AstNode) (project, name)and(project, type, name), unique constraint on(:Project) (name). Created on application startup viaSchemaInitializer(idempotentIF NOT EXISTS). - 2. Health checks —
Neo4jHealthCheck(@Readiness) verifies Neo4j connectivity viaDriver.verifyConnectivity(). - 3. Structured error responses for
AnalysisResource— sharedErrorResponserecord ({error, code, details}), reused byProjectResource. - 4. Project-existence validation — ingest and all project-scoped query
endpoints return
404 PROJECT_NOT_FOUNDif the project hasn't been created. - P0-a. Uniqueness constraint/index on
AstNode.id—mergeEdgematches both endpoints byid(MATCH (a:AstNode {id: $sourceId}), (b:AstNode {id: $targetId})), butidwas not indexed (only(project, name)and(project, type, name)were). Every edge merge did a full label scan over allAstNodenodes accumulated so far, so ingest got progressively slower as more files/projects were ingested. AddedCREATE CONSTRAINT ast_node_id_unique IF NOT EXISTS FOR (n:AstNode) REQUIRE n.id IS UNIQUEtoSCHEMA_STATEMENTS, giving edge merges O(1) lookups regardless of graph size. - P0-b. Batch ingest writes with
UNWIND—GraphRepository.save()previously issued onetx.run()per node and per edge (N+M round trips per ingested file). Replaced withCypherQueries.MERGE_NODES(UNWIND $nodes AS n MERGE ...), one round trip for all nodes of a file, andCypherQueries.mergeEdgesBatch(EdgeType)(UNWIND $edges AS e MATCH ... MERGE (a)-[:<TYPE>]->(b)), one round trip per distinctEdgeTypepresent in the file (edges grouped viaCollectors.groupingBy(AstEdge::type)) — Cypher relationship types can't be parameterized, so true O(1) isn't possible, but this cuts round trips from N+M to 1 + (number of distinct edge types, usually 1-3). Same merge semantics, low risk. - P0-c.
--exclude-diroption foringestCLI command — recursive ingest now skips any file whose path (relative to the ingest root) contains a component matching one of the repeatable-x/--exclude-dir <name>values (case-insensitive), e.g.--exclude-dir user_exit --exclude-dir testto skip subprograms already represented ingenerated_sources. Excluded files are printed asSKIP (excluded) <file>and counted in a newexcludedsummary counter alongsideingested/skipped/failed. - P0-d.
ingest-module <folder> <moduleName>— dependency-driven ingest (2026-06-15) — new CLI command that ingests a single module and its transitiveCALLNAT/PERFORM-external/EXTENDS/IMPLEMENTStargets andINCLUDE/USING'd data areas, located by filename stem (case-insensitive) within a given folder.POST .../ingest/{java,natural}now returns202 {"dependencies": [{"name":..., "type": "MODULE"|"DATA_STRUCTURE"}]}(newAstIngestService/AnalysisResource.IngestResponse/DependencyRef), derived from placeholder nodes (sourceFile="") created by the parsers. The CLI does a BFS over these names: found files are ingested and their dependencies enqueued; names not found in the folder are printed asUNRESOLVED <name>and counted separately (not a failure).--exclude-dirapplies to the file index, so dependencies under excluded folders are reported as unresolved. Shared file-walk helpers (readSourceFile,endpointFor,isExcluded) extracted fromIngestCommandintoIngestSupport. New ITnaturalIngestReturnsUnresolvedDependencies. - P0-e.
.lda/.pda(Natural data area) parsing (2026-06-15) —NaturalParsernow recognizes.lda/.pdafiles (viaIngestSupport.endpointFor) and parses their wrapper-less field-export format (<TYPE><LENGTH><LEVELDIGIT><NAME> ... CONST<...>|INIT<...>, glued type/level/name prefixes,R <level><name>REDEFINE markers,*-comment lines, multiple top-level (level 1)DATA_STRUCTUREroots per file) into the sameDATA_STRUCTURE/VARIABLE/CONSTANTnode shape asDEFINE DATAfields, sodata-structure-fieldsworks unchanged. Combined with item 6 (placeholder resolution), an ingested LDA/PDA now links up with theDATA_STRUCTUREplaceholder created byLOCAL/PARAMETER USINGin referencing.natprograms. New unit tests (YFRAML01_SAMPLE.lda,WGEAGL01_SAMPLE.pda) and IT (ingestingLdaResolvesIncludePlaceholder). - P0-f.
GET /variables/{name}/writesand/reads(2026-06-15) — queries returning everyFUNCTION/MODULEthat has aWRITES/READSedge to aVARIABLE/CONSTANTwith the given name, withsourceFileand containingmodule(CypherQueries.variableReads(maxDepth)/variableWrites(maxDepth),GraphRepository.variableWrites/variableReads,VariableAccessLocationrecord). Optional?module=...&depth=Nquery params restrict results to that module or anything in its transitiveCALLStree (up todepthhops, sameagenticcode.call-tree.default-depth/max-depthconfig and clamping ascall-tree); the Cypher computes each writer/reader's containing module viaCONTAINS*0..and checksowner.name = $module OR EXISTS { (module)-[:CALLS*1..depth]->(owner) }. New CLIvariable-writes/variable-reads <name> [-m/--module <name>] [--depth N].
Ingest performance
- 24. Index the stale-file sweep — persist was a label scan per file (2026-07-16) — the
sibling defect to P1-m below, one statement further on: P1-m indexed the node MERGE key, but
DELETE_STALE_FILE_NODES(the item-58 reconcile sweep, which runs in the samemergeResultstransaction) matches on(project, sourceFile)— and a Neo4j composite index only applies when every one of its properties is constrained, so the 4-property(project, sourceFile, type, name)index does not cover that 2-property prefix. The planner therefore fell back toNodeByLabelScan+Filter, and because theUNWIND $filesbatch drives it through anApply, it scanned once per file: measured 564,451 db hits per file on a 282k-node graph, i.e. ~56M node reads per 200-file batch. The scan covers the wholeAstNodelabel — all projects — which is why per-batch cost grew with total graph size (2m29s early vs 2m48s late). Fix is one line inSCHEMA_STATEMENTS:ast_node_project_sourcefileon(project, sourceFile). The plan becomes aNodeIndexSeekat 137 db hits per file (~4000×). Verified that the new, less selective index does not displace the 4-property one for the MERGE keys (still 1–2 db hits). Also servesSOURCE_HASH(item 43 auto-invalidation) and thesourceFile:""placeholder sweeps, which had the same blocked shape. Index-only;ensureSchemaadds itIF NOT EXISTS, so existing deployments pick it up on the next restart — no migration. Measured live on a fullupmscall-graph refresh (6311 files, reconcile on): whole refresh 179 s end-to-end — parse ~63 s, persist 104.5 s across 32 batches (median 1.7 s/batch, min 0.2 s, max 10.7 s), finalize ~12 s — against ~2.5–2.8 min per batch logged before the fix, and with no per-batch growth left. A second refresh took 95 s and reproduced the graph exactly (454,255 nodes / 1,495,146 rels, no duplicate MERGE keys), i.e. idempotent. Note the pre-fix graph held only 216,492 upms nodes: the slow run evidently never completed, so the fix also produced the first complete upms graph rather than merely a faster one. Regression testStaleFileSweepIndexITasserts the cause — itEXPLAINs the realDELETE_STALE_FILE_NODESconstant and requires aNodeIndexSeekonAstNode(project, sourceFile)(the planner names indexes by properties, not by index name), afterCALL db.awaitIndexes()since aPOPULATINGindex is invisible to the planner and would make the assertion race startup. Vacuity-checked: with the schema line removed the plan falls back to the filtered label scan and the test fails. - 25.
ingest-allperformance: batch persist transactions — already implemented (closed 2026-07-16) — the roadmap item claimed "persiststill opens one transaction/session per file"; that was stale.ProjectIngestServicechunks files intopersistBatchcalls sized byagenticcode.ingest.batch-size(default 200), andGraphRepository.mergeResultsaggregates each chunk into batchedUNWINDMERGEs (one per node class, one per edge type) in a single transaction — delivered earlier as P1-k below. Closed as done; it was item 24 above, not batching, that actually held persist back. - P1-m. Index the node MERGE key to kill O(N²) persist (2026-06-20) —
on a whole-root ingest of
upms(~6309 files), per-batch persist time grew with the graph (batch 1 ≈ 1 s, batch 8 ≈ 4.5 min) — quadratic.MERGE_NODES/MERGE_POSITIONAL_NODESmatch on(type, name, sourceFile, project)but the only supporting index was(project, type, name); for low-selectivity names that recur project-wide (CONTROL_FLOWIF/FOR/REPEAT/DECIDE,VARIABLE/DATA_STRUCTUREFILLER/#CODE/SQLERR) that bucket grows with the graph and MERGE filteredsourceFile/startLinein memory over it. Added composite indexast_node_project_sourcefile_type_nameon(project, sourceFile, type, name)so MERGE seeks per-file (a handful of nodes). Index-only;ensureSchemaadds itIF NOT EXISTS. - P1-l. Split enrichment into per-statement transactions (2026-06-20) —
finalizeProjectran all ~15 project-wide enrichment statements in one transaction; on the fullupmsgraph that single transaction blew Neo4j'sdbms.memory.transaction.total.max(5.4 GiB) and aborted, so placeholders never resolved.finalizeProjectnow runs each ordered, idempotent statement (enrichmentStatements()) in its ownexecuteWriteWithoutResult, bounding peak per-transaction memory. Same graph result (ITs green); a lone over-large step would still needCALL { … } IN TRANSACTIONS(not yet required). - P1-k. Batch persistence in
ingest all(2026-06-19) —ingestAllpersisted one file per transaction (fresh session + ~8 statements + commit), sequentially — fine for a small slice but slow once the P1-j dedup fix made it ingest thousands of files (each round trip is fixed overhead × N files). NewGraphRepository.persistBatch/AstIngestService.persistBatchpersist a chunk of files in one transaction, aggregating all files' nodes/edges into batchedUNWINDwrites (one statement per node label / edge type per chunk instead of per file).ingestAllnow chunks the non-conflicting results byagenticcode.ingest.batch-size(default 200) and runs onefinalizeProjectafter. Correctness subtlety: different files emit the same placeholder (CALLNAT/USING target,sourceFile="") with distinct UUIDs but one MERGE key; persisting all nodes before all edges collapses them (last id wins) and would orphan earlier files' edges.mergeResultsdedupes nodes by MERGE key (canonical = first id) and remaps every edge endpoint onto the canonical id — the per-file transactions previously masked this. Caught byAnalysisResourceIT(interface fan-out + shared-PDA field-flow) during implementation; full ingest/analysis ITs green. - P1-j. Fix over-aggressive duplicate detection in
ingest all(2026-06-19) —ProjectIngestService.ingestAllflagged cross-file duplicates by scanning every realMODULE/DATA_STRUCTUREnode, so two unrelated Natural programs that each contain an inlineDEFINE DATAgroup with a common name (FILLER,SQLERR,#ERROR-GROUP, …) were treated as duplicates and both whole files skipped. On theupmscodebase this silently dropped 4379 files (3894 of 3895 "duplicate" identities were inlineDATA_STRUCTUREnames; only 1 was a realMODULE), so e.g.W-MNT-N0/KDWWIFN0never ingested and their callers/callees couldn't resolve. NewfileIdentities(Parsed)keys duplicate detection on the file's own identity only: realMODULEnodes (Java FQN-aware) for program/class files, and a single(DATA_STRUCTURE, stem)for.lda/.pdadata-area files — inline group nodes are excluded. RealMODULEand data-area-file duplicates are still reported. In-memory only (no schema change/re-ingest of node shape). New ITsharedInlineDataStructureNamesDoNotSkipModules; fullAnalysisResourceIT+IngestModuleITgreen. A persisted per-language source/module kind is tracked separately as future work. - P1-i. Defer enrichment in by-name ingest (2026-06-19) —
POST /ingest/{name}(ProjectIngestService.ingestModule, the CLIingest <module>path) calledsave()per file in its dependency BFS, which re-ran the full ~15-statement project-wide enrichment (placeholder resolution + dataflow + polymorphic fan-out) after every file — ≈O(N²) over a growing graph, the dominant cost for Natural modules that fan out throughCALLNAT/PERFORM/USING(observed oningest KDWWIFN0). Now mirrorsingestAll:persist()per file (merge only), then onefinalizeProject()after the BFS. Enrichment is idempotent/order-independent, so the end graph is identical (verified byIngestModuleIT, which exercises cross-module dataflow that depends on enrichment). Removed the now-unusedAstIngestService.save()/GraphRepository.save(). - P1-h. Run DB endpoints off the event loop (2026-06-19) — the Neo4j
blocking driver is wrapped in
Uni.createFrom().item(...)in everyGraphRepositorymethod, whose supplier runs on the subscribing thread. TheUni-returning JAX-RS query methods carried no@Blocking, so for those the driver ran on (and blocked) the Vert.x event loop — latent until a slow query tripped the 2 sBlockedThreadChecker(observed onDELETE /api/projects=CLEAR_ALLfull-graphDETACH DELETE, blocked ~3.3 s). Fixed by moving@Blockingto class level onAnalysisResourceandProjectResourceso all endpoints dispatch to a worker thread (the two now-redundant method-level@Blockingon the ingest endpoints were removed). Verified via access log (executor-thread-*instead ofvert.x-eventloop-thread-*);ProjectResourceIT+AnalysisResourceITgreen. - 23.
ingest-allperformance: enrich once, not per file (2026-06-19) —ingest-allran the project-wide enrichment block (placeholder resolution for allRESOLVABLE_EDGE_TYPES, field-target resolution, bare-field redirection, placeholder deletes,LINK_ARGS_TO_PARAMS/_JAVA,LINK_CALLS_TO_IMPLEMENTATIONS) inside everyGraphRepository.save, i.e. once per file — each pass scans the whole project graph, so cost grew ~O(N²) in the file count and dominated large Java scans. Split persistence from enrichment:GraphRepositoryrefactored intomergeResult/runEnrichmenthelpers, exposingpersist(project, result)(MERGE only) andfinalizeProject(project)(enrichment once), withsaveretained asmerge + enrichin one tx for single-file/by-name ingest.ProjectIngestService.ingestAllnowpersists each survivor then callsfinalizeProjectonce (skipped when nothing persisted). The enrichment is idempotent and order-independent, so the final graph is identical; verified by the existingAnalysisResourceIT(ingest-all-based) suite.ingestModuleunchanged (stillsaveper file).
Call graph, edges & provenance
- P1-g. Language-aware
edgeKind(2026-06-19) —edgeKindincallers/calleeswas always derived from the target node type as Natural'sCALLNAT/PERFORM, mislabelling Java calls (e.g. anew Foo()constructor showed asCALLNAT). Each parser now stamps acallKindproperty on theCALLSedge using the newCallKindenum (ac-parser-core): Natural →CALLNAT/PERFORM; Java →METHOD_CALL(intra- and cross-class invocations) /CONSTRUCTOR(new). Stamping at parse time lets Java distinguish constructors from method calls, which the previous target-type-only logic could not.CypherQueries.callers/calleesnow returncoalesce(r.callKind, <legacy CASE>)so graphs ingested before the stamp fall back to the old labels (no forced re-ingest; re-ingest needed for Java precision). Synthetic inheritance edges (LINK_CALLS_TO_IMPLEMENTATIONS) copys.callKind = r.callKind. New IT assertions for Java (METHOD_CALL/CONSTRUCTOR) and Natural (PERFORM/CALLNAT). - 6. Cross-file placeholder resolution (2026-06-15) — decided against a
generic
EnrichmentPipeline.run()(no second use case identified yet;enrichment/stays empty). Instead,GraphRepository.save()runsCypherQueries.resolvePlaceholderTargets(EdgeType)for each ofCALLS/INCLUDES/USES_TYPE/EXTENDS/IMPLEMENTS, thenDELETE_RESOLVED_PLACEHOLDERS, in the same transaction as the node/edge merges. This redirects edges pointing at an unresolved placeholder (MODULE/DATA_STRUCTURE,sourceFile="") onto a real node sharing(type, name, project)once one exists, and deletes the now-orphaned placeholder — works regardless of ingest order.
Natural parser — statements, dataflow, dispatch
- P1-a. SQL/ADABAS statement extraction for agentic Panache generation —
new
NodeType.DB_ACCESSnode per SQL/ADABAS statement occurrence (table, access mode, raw statement text, line span),CONTAINSedge from the containing function/module,USES_TYPEedge to the accessedDB_TABLEand (forSELECT ... INTO VIEW) the associatedDATA_STRUCTURE. NewGET /api/projects/{project}/modules/{name}/sql-statementsendpoint + CLI command, so an agent can read table/view/mode/statement text and the call-graph context and write the equivalent Panache query itself. Existing function ->DB_TABLEREADS/WRITESedges and/db-accessesstay unchanged. - P1-b.
MOVE/ASSIGN/COMPUTEvariable read/write edges — Natural construct #7 (CLAUDE.md priority list). DeclaredVARIABLE/CONSTANTnames fromDEFINE DATAare tracked case-insensitively; forMOVE <src> TO <target>{...}and(COMPUTE|ASSIGN) <target> (=|:=) <expr>,READS/WRITESedges are added from the current function to known variables/constants referenced as source/target/expression operands. ExoticMOVEvariants (BY NAME/BY POSITION,EDITED,SUBSTRING(...),ALL,ENCODED,NORMALIZED,*JUSTIFIED) are skipped. No new placeholder nodes for unknown identifiers/literals. - P1-c. Bare
:=assignment and qualified-target field lookup (2026-06-15) —NaturalParserpreviously required aCOMPUTE/ASSIGNkeyword to recognize an assignment; Natural's implicit-assignment form<target> := <expr>(no keyword) is now matched via a newBARE_ASSIGNpattern (fallback afterASSIGN_COMPUTE, restricted to:=to avoid colliding withIF/comparison=).lookupVariablenow also strips a<QUALIFIER>.prefix (e.g.CDBRPDA.SORT-KEY->SORT-KEY) when the fully qualified name isn't a known local variable, so qualified field references resolve to the declared field name. New unit testsbareAssignWithoutKeywordIsRecognized/qualifiedAssignTargetMatchesUnqualifiedDeclaredField. NewIngestModuleITingests theWGEAGB0Sfixture tree (100 files). - P1-d.
ADD/SUBTRACT/MULTIPLY/DIVIDEread/write edges (2026-06-15) — Natural's arithmetic statements read and write their operands but were not recognized at all byNaturalParser. New patternsADD_STATEMENT,SUBTRACT_STATEMENT,MULTIPLY_STATEMENT,DIVIDE_STATEMENTaddREADSedges for every identifier in the operand-list/expression operands (via newaddExpressionReadshelper) andREADS/WRITESedges for the accumulator/target (addOperandAccesshelper):ADD ... TO x→ reads+writesx;ADD ... GIVING x→ writes onlyx;SUBTRACT ... FROM x [GIVING y];MULTIPLY x BY y [GIVING z];DIVIDE x INTO y [GIVING z] [REMAINDER r]. New unit tests covering all four statements and theirGIVING/no-GIVINGvariants. - P1-e.
lineNoonAstEdge(2026-06-15) —AstEdgegained anint lineNofield (the source line of the relationship/statement), threaded through both parsers'edge()helpers and all call sites. Persisted as a relationship property:buildMergeEdgeQueries()now doesMERGE (a)-[:%s {lineNo: e.lineNo}]->(b)(so the same edge type between the same nodes at different lines becomes distinct relationships), andbuildResolvePlaceholderTargetQueries()preservesr.lineNowhen redirecting placeholder edges. Exposed vialineNoinVariableAccessLocationandCallReference(callers/callees now return one row per call site). - P1-f.
DECIDE FOR/DECIDE ONasCONTROL_FLOW(2026-06-15) — per the construct-coverage review,DECIDE FOR/DECIDE ON(Natural's switch/case) appears in 8 of 26WGEAGB0Sfixtures but was not recognized. NewDECIDE_STATEMENT/END_DECIDEpatterns add aCONTROL_FLOWnode (dataType="DECIDE",value= the full statement text) spanning toEND-DECIDE, mirroringIF/FOR/REPEAT. Statements inside the block still get normalREADS/WRITESedges. New unit testdecideForIsRecognizedAsControlFlowBlock. - P1-g. Resolve qualified
PARAMETER/LOCAL USINGfield references (2026-06-15) —MOVE/ASSIGN/etc. targets of the formSTRUCT.FIELD(e.g.CDBRPDA.SORT-KEY) whereSTRUCTis aPARAMETER USING/LOCAL USINGinclude now resolve to a placeholderVARIABLEnodeFIELD,CONTAINS-child of the placeholderSTRUCTDATA_STRUCTURE. Previously such references either fell back to an unrelated same-named local variable or produced no edge at all. New Neo4j post-ingest step (resolvePlaceholderFieldTargetsforREADS/WRITES,DELETE_RESOLVED_PLACEHOLDER_FIELD_CONTAINS) redirects these placeholder fields onto the real field of the same name in the resolved structure. New unit testqualifiedFieldOfIncludedDataAreaResolvesToPlaceholderUnderThatStructure. Known limitation: two different included structures defining a same-named field converge (merge key has no parent reference); not an issue in current fixtures. - 18. Cross-file bare-field resolution (
MOVE/ASSIGN/etc.) (2026-06-17) — closes the remaining half of the P1-c/P1-g gap: an unqualified reference (MOVE #X TO SORT-KEYwhereSORT-KEYis declared only in aLOCAL/PARAMETER USINGarea).lookupVariablegained anallowIncludePlaceholderflag (true only at explicit MOVE/ASSIGN/COMPUTE/ arithmetic operand sites). When set and the bare name is not a same-file variable, is identifier-shaped, and the module hasUSINGincludes, a module-level placeholderVARIABLE(sourceFile="") is created. NewresolveBareIncludedFieldTargetsredirects it onto a real included field only on a unique single match acrossm-[:INCLUDES]->(:DATA_STRUCTURE{sourceFile<>""})- [:CONTAINS*1..]->; orphan cleanup reaps unresolved placeholders so no spurious edge survives. NewNaturalParserTestcases + ITbareReferenceToIncludedFieldResolvesToRealField. - 19. Field-level dataflow for shared PDAs (
field-flow) (2026-06-17) — a shared PDA's fields are single nodes, so a field written in the caller and read in a transitively-called module is already connected through that one field node plus theCALLSedge — no new edge type needed. NewGET /api/projects/{project}/variables/{field}/field-flow?module=X&depth=Ncorrelates writers and downstream readers of a shared field across the call graph (wmod <> rmod, reachable viaCALLS*1..depth, clamped 10), returning "field F is produced inMOD-A:120and consumed in downstreamMOD-B:45". NewFieldFlowrecord + CLIfield-flow. Caveat: reachability-based, not order-precise. FixturesFF_SHARED.lda+FF_PROD.natFF_CONS.nat; ITfieldFlowTracesSharedPdaFieldAcrossCall.
- 15. Dataflow analysis through the call hierarchy (2026-06-16, scoped) —
captures which variables are passed at each call site. Parser (Phase 1):
CALLNAT 'MOD' ARG1 ARG2captures the positional argument list onto theCALLSedgeargsproperty; top-levelPARAMETERfields tagged withparamPosition. Enricher (Phase 2):LINK_ARGS_TO_PARAMSruns after placeholder resolution, positionally joiningsplit(r.args,',')[i]to the callee param withparamPosition = toString(i)andMERGE-ing anARG_TO_PARAM {callSite, position}edge (newEdgeType.ARG_TO_PARAM). Query (Phase 3):GET /variables/{name}/flow-forward|flow-backward?module=&depth=followARG_TO_PARAM*1..depth, returning{variable, variableType, module, depth}(DataflowStep); CLIflow-forward/flow-backward. Placeholder-target resolution carries edge properties viaSET r2 += properties(r)soargssurvive redirection. Deferred:PERFORM USING, whole-areaPARAMETER USINGposition mapping, Java method-argument dataflow. FixturesDF_CALLER.natDF_CALLEE.nat.
Java parser
-
J6. Ingest noise: JDK/framework filter + target/ exclusion (2026-07-07) — a by-name ingest (
POST /ingest/{name}) follows every referenced type; JDK/stdlib/framework types never resolve to a project file, so they dominated theunresolvedlist and ballooned the dependency fan-out (one job pulled ~1158 files). NewExternalTypesdenylist (uppercased simple names:java.lang/util/time/io/nio/math/concurrent/stream- CDI/JPA/Panache/Mutiny types) is consulted in the dependency BFS — matching
MODULErefs are neither chased nor reported asunresolved, so only genuine gaps remain. The file walk now excludestarget/by default (merged with the project'sexcludeDirs) so build-output generated sources don't create duplicate module/entity definitions. Documented heuristic (a project class named like a JDK type would also be skipped — vanishingly rare). NewJavaIngestNoiseIT(JDK types filtered while a genuine missing type is still reported;target/copy not flagged as a duplicate); all 80 server ITs green.
- CDI/JPA/Panache/Mutiny types) is consulted in the dependency BFS — matching
-
J5. Cross-class Java dataflow (precise arg→param) (2026-07-07) —
flow-forward/flow-backwardacross classes. Investigation found the genericLINK_ARGS_TO_PARAMSlinker already matched Java cross-class (MODULE→MODULE) calls but imprecisely —(callee)-[:CONTAINS*1..]->(param at position i)linked an argument to the position-i parameter of every method in the callee class (no method disambiguation). J5 makes it precise:JavaParserstampscallerFn(calling method) andcalleeMethod(invoked method) on cross-classMETHOD_CALLedges; new dataflow-gated stepsLINK_ARGS_TO_PARAMS_JAVA_CROSS(+_SCOPED) buildARG_TO_PARAMfrom each caller-scope argument to the named callee method's parameter at that position; and the generic linker (+ its scoped variant) is guarded withr.calleeMethod IS NULLso it no longer cross-matches Java.flow-forward/flow-backwardtraverseARG_TO_PARAMunchanged, so they now span classes correctly (deep-ingest only, like Natural). Overloads still match by name (over-approx); constructor-argument dataflow deferred. New fixturesfixtures/java/flow/*(CheckoutFlow.checkout(amount)→PriceCalculator.applyDiscount(basePrice)) andJavaCrossClassFlowIT(forward + backward); all 78 server ITs + 11 parser tests green. -
J4. Virtual/override (template-method) dispatch (Java) (2026-07-07) — a template method in an abstract base (
doProcessItem→clearTable/writeEntities) dead-ended at the base's abstract method. NewEdgeTypeOVERRIDDEN_BY(base-classFUNCTION→ same-named overridingFUNCTIONin a subclass), materialized by the always-run, idempotent enrichment stepBUILD_OVERRIDDEN_BY(name-based, transitive overEXTENDS; constructors excluded naturally since a super/sub pair never shares a name). Exposed via a newGET /modules/{name}/functions/{fn}/overridesendpoint (GraphRepository.functionOverrides→FUNCTION_OVERRIDES, returningFunctionOverride{module, name, sourceFile, startLine, endLine}) and thefunction_overridesMCP tool — kept function-level rather than threaded into the module-levelcall-tree. Name-based matching is heuristic (overloads over-match); documented. New fixturesfixtures/java/override/*(abstract template base + two concrete steps) andJavaOverrideIT(2 tests: all overrides for an overridden method, empty for a non-overridden one); all 76 server ITs + 11 parser tests green. -
J3. Interface → implementation resolution (Java) (2026-07-07) — new
EdgeTypeIMPLEMENTED_BY(interface → concrete impl), materialized by the always-run, idempotent enrichment stepBUILD_IMPLEMENTED_BY(the derived inverse of a realIMPLEMENTSedge). Java MODULE nodes now carry anisInterfaceproperty (a small slice of the future module-kind item). New?resolveInterfaces=trueoption oncalleesandcall-tree(REST + the MCP tools):calleeshops an interface callee to itsIMPLEMENTED_BYimplementation(s) viacoalesce(impl, callee)— a single impl is a clean deterministic hop, multiple impls expand, the interface is dropped;call-treedrops interface nodes that have a known implementation (their impls are already reached via the CHA syntheticCALLSedges). Flag-gated so the default query is byte-unchanged. New fixturesfixtures/java/iface/*(single-implNotifier/EmailNotifier) reusing the multi-impl gateway fixtures;JavaInterfaceResolutionIT(3 tests); all 74 server ITs + 11 parser tests green. Note: the interface-traversal dead-end J3 originally described was already fixed by the earlier polymorphic CHA step — this item adds the explicit edge and the caller-facing hop/suppress option. -
J2. DI + class-literal wiring edges (Java) (2026-07-07) —
call-tree/calleeson a job class returned empty because steps are wired by CDI injection and class literals, not method calls. Two newEdgeTypes:INJECTS(a class → an injected bean type:@Injectfields, and constructor params of an injection-point constructor —@Inject-annotated, or the sole constructor of a CDI-scoped class) andREFERENCES(a class → a type used asX.classin argument position, e.g.super(AccountKeyInitStep.class, …)/batchlet(refName(X.class))). Emitted byJavaParser.addWiringEdgesas class-level (MODULE→MODULE) placeholder edges; added toRESOLVABLE_EDGE_TYPESso the existingresolve-placeholderfinalize step resolves them cross-file. Surfaced incallers/callees(query match widened toCALLS|EXTENDS|IMPLEMENTS|INJECTS|REFERENCES) asedgeKind = INJECTS/REFERENCES, mirroring howEXTENDS/IMPLEMENTSalready appear; deliberately excluded fromcall-treeby default (kept CALLS-only; see J9 below for the opt-in traversal). New fixturesfixtures/java/wiring/*(a job with a class-literal step +@Injectcollaborator, a constructor-injected bean) andJavaWiringIT(3 tests); all 68 prior ITs green (shared callers/callees query change verified non-regressive), 11 parser tests green. Re-test 2026-07-07 confirms this works: all 3 probed PUR job digests now list their steps viacallees.REFERENCES(RiskImportJob→RiskInitStep/RiskProcessingStep/RiskEndStep; same forMultiTableImportJob,KeyTableExportJob), and the step'scallers.REFERENCESnames the job. The former job-root dead-end is fixed at digest level. Follow-up usability gap fixed by J9 (below). -
J9. Opt-in traversal that follows
REFERENCES/INJECTS(Java) (2026-07-07) — J2 wired theINJECTS/REFERENCESedges intocallees/callersbut kept them out ofcall-tree(CALLS-only), so there was no automatic transitive tree from a job to its steps to their repositories — an agent had to chaindigest/calleescalls by hand. NewfollowWiringboolean, mirroring the J3resolveInterfacespattern end-to-end (RESTcall-treequery param, MCPcall_treetool arg,GraphRepository.callTree,CypherQueries.callTree): whentrue, the transitive-traversal relationship pattern becomesCALLS|INJECTS|REFERENCESinstead ofCALLS(bothINJECTS/REFERENCESareMODULE→MODULE, same as theCALLSedgescall-treealready traverses, so mixing them into one variable-length path is schema-compatible). Composes withresolveInterfaces.callees/callersunchanged (already surfaced these edges at one hop since J2) — onlycall-tree's transitive traversal needed the flag. Off by default. New testcallTreeFollowsWiringOnlyWhenRequestedinJavaWiringIT(default call-tree excludesINJECTS/REFERENCEStargets;followWiring=trueincludes them), reusing the existingfixtures/java/wiring/*fixtures. -
J1a. JPA/Panache repository calls as Java DB accesses (2026-07-07) — Java
db-accesses/sql-statementswere always empty; now repository/EntityManager/ Panache active-record calls resolve toREADS/WRITESon the entity'sDB_TABLE. Parser (JavaParser): tags a repository class with its managed entity (repositoryEntity, from aPanacheRepository<E>/JpaRepository<E,Id>/… generic supertype); treats aPanacheEntity[Base]subclass as an entity; and emits aDB_ACCESScandidate node (contained under the calling function,dataType=READ/WRITE/DELETEby method-name prefix,value= call text, plusjavaReceiverType/javaMethod/javaArgType) for a persistence-shaped call — a write/delete verb on any non-JDK receiver, or a read verb on a repository-named / static-entity /EntityManagerreceiver. A small JDK denylist keeps candidate volume down (J6 will generalize it). Enrichment (RESOLVE_JAVA_DB_ACCESS, a new always-run, project-wide, idempotent finalize step): derives the entity — argument type forem.persist/merge/remove(x), the repository'srepositoryEntity, else the receiver itself — follows the entity'sMAPS_TOto theDB_TABLE, thenMERGEsDB_ACCESS-[:USES_TYPE]->DB_TABLE(forsql-statements) andfn-[:READS|WRITES]->DB_TABLE(fordb-accesses). No newEdgeType: DELETE shows asWRITESindb-accesses(edge mode) butDELETEinsql-statements(node mode), mirroring Natural. Unresolved candidates (entity not in graph) stay unlinked — never pollutingdb-accesses, incremental-ingest-safe (no deletion). New fixturesfixtures/java/jpa/*(Panache repo + entity, EntityManager service, active-record entity) andJavaDbAccessIT(3 tests); all 68 server ITs + 11 parser tests green. J1b deferred (roadmap):@Query/JPQL/native-SQL string parsing, derived-name filters, and no-generic custom repositories (need symbol resolution). Re-test 2026-07-07 fixed by J7 + J8 (below):JavaDbAccessInheritedRepoIT(repo → project base → Panache base, plus a constant-valued@Entity(name=…)) now passes end-to-end —db-accesses/sql-statementsresolve toRISK. A regression check against the actual 9 PUR jobs is still worth doing as a follow-up acceptance pass, but the two traced root causes are fixed. -
J7. Panache-ness inherited through a project base class (2026-07-07) — J1a only recovered
repositoryEntitywhen a repository directly extended a Panache/JPA base type; a repository extending a project-specific abstract base (e.g.RiskRepository extends AbstractPurRepository<Risk, String>, whereAbstractPurRepository<Entity, Id> implements PanacheRepositoryBase<Entity, Id>) got nothing, since the entity generic sits one inheritance hop away from the Panache marker. Parser (JavaParser):repositoryEntityTypenow returnsnull(not a bogus name) when the repository base type's first type argument is one of its own type parameters rather than a concrete class; a newpanacheEntityTypeParamtags such a project base class with the name of that type parameter, alongside its own orderedtypeParams; a newtypeArgsedge property onEXTENDSrecords the concrete arguments a subclass supplies (e.g.Risk,String). Enrichment (RESOLVE_PANACHE_INHERITED_ENTITY, a new project-wide, idempotent finalize step run beforeresolve-java-db-access): finds the base class'spanacheEntityTypeParamposition in itstypeParams, reads the subclass'sEXTENDStypeArgsat that position, andSETsrepositoryEntityon the subclass — after whichRESOLVE_JAVA_DB_ACCESS(J1a, unchanged) resolves its repository calls exactly as for a directly-Panache repository. Resolves one level of indirection (the observed real-world shape). New fixturesfixtures/java/jpa/inherited/*andJavaDbAccessInheritedRepoIT(2 tests); fullac-code-serverunit + integration suite green. -
J8.
@Entity(name=CONST)/@Tablewith a constant table name (verified 2026-07-07, no change needed) — the physical table name is not always a string literal on the annotation;RiskEntity-shaped entities use a same-class constant reference instead (@Entity(name = Risk.TABLE_NAME)withpublic static final String TABLE_NAME = "RISK";). Turns outresolveTableNamealready resolved this via existing same-class constant-value resolution (collectConstants/resolveAnnotationString, predating J1a) — confirmed by parsing theJavaDbAccessInheritedRepoITfixture in isolation before any code change. No fix required; J7 (above) was the actual blocker for that fixture'sdb-accesses. -
12. Java parser — constructors, parameters, field READS/WRITES (2026-06-16) — constructors →
FUNCTIONnodes named after the class; method/constructor parameters →VARIABLEnodes (CONTAINSfrom the function); field READS/WRITES fromthis.fieldand unshadowed bare-name references (AssignExprtarget → WRITES; compound-assign /++/--→ READS+WRITES; else READS). Parameter/local names shadow field references. Intra-classCALLSnow also scans constructor bodies./variables/{name}/reads|writesgainedFIELDto the matched node types. Known limitations (deferred to item 15): same-named params in one file and overloaded constructors converge on one node. New fixturesOrderService.java+BaseService.java. -
16. Java parser — constant value resolution (2026-06-16) —
JavaParser.literalValuedoes type-aware extraction (StringLiteralExpr.asString(),Integer/LongLiteralExpr.asNumber()— stripsL,Boolean/CharLiteralExpr); non-literal initializers fall through to anullvalue.collectConstantsresolves in-class references (bareNAMEandThisClass.NAME) transitively with cycle guard.resolveAnnotationStringresolves an annotation member to a string via the same-class constants (used by item 13 for@Entity(name = TABLE_NAME)). Deferred: cross-class constant resolution /ConstantRefEnricher. -
13. Java parser — Hibernate/JPA entity recognition (2026-06-16) — detects
@Entity/@Table(table name resolved through item 16, fallback to class name),@MappedSuperclass(noMAPS_TO), and per-@Columnfield metadata (columnName,columnDefinition,nullable,converterTypefrom@Convert,hibernateTypefrom@Type,isId,declaredIn).AstNodegained an optionalMap<String,String> properties(persisted viaSET node += n.properties). NewEdgeType.MAPS_TO. Inherited columns are collected at query time by walking(:MODULE)-[:EXTENDS*0..]->(:MODULE)-[:CONTAINS]->(:FIELD). NewEntityColumnrecord +ENTITY_COLUMNSquery +GET /api/projects/{project}/modules/{name}/columns+ CLIentity-columns;DB_TABLE_COLUMNSgained a 3rd UNION branch. FixturesSampleLegacyEntity+AbstractSampleHistorized+AbstractSampleBase. -
14. Java parser — general inheritance enrichment (all classes) (2026-06-16) — implemented query-time (no new edge types). New
GET /api/projects/{project}/modules/{name}/functions?includeInherited=endpoint + CLIfunctions [--include-inherited]: walks(:MODULE)-[:EXTENDS|IMPLEMENTS*0..]->(:MODULE)-[:CONTAINS]->(:FUNCTION)(MODULE_FUNCTIONS_INHERITED) when inherited is requested, else own only (MODULE_FUNCTIONS_OWN); each row tagged withdeclaredIn. NewInheritedFunctionrecord. Deferred: explicitINHERITS/OVERRIDESedge types. FixturesOrderService.java(overridesdescribe()) +BaseService.java. -
17. Java dataflow (extends item 15 to Java) (2026-06-16) —
JavaParsertags each method/constructor parameter VARIABLE node withparamPositionand captures the positional argument list of each intra-classMethodCallExpronto theCALLSedgeargsproperty. NewLINK_ARGS_TO_PARAMS_JAVAenricher mapsargs[i]to the callee function's parameter withparamPosition = i, where the caller-side variable is the caller function's own parameter or a field of its enclosing class. Theflow-forward/flow-backwardendpoints are language-agnostic. Deferred: cross-class Java calls (resolved in item 20). -
20. Cross-class Java call graph (2026-06-17) — Java
CALLSedges were intra-class only. Now resolved at the class (MODULE) level, like NaturalCALLNAT:JavaParserresolves a method-call receiver to a target class — a typed field/parameter/local (svc.method()), a capitalized bare name (staticFoo.bar()), or anew Foo(...)constructor — and emits aMODULE-[:CALLS]->MODULEedge to a placeholder for that class. The placeholder resolves viaresolvePlaceholderTargets(CALLS)and surfaces as aDependencyRef. Scope: class-level resolution only. FixtureOrderController.java. -
22. Polymorphic Java call resolution + implementation ingest (2026-06-18) — (1) Fan-out —
LINK_CALLS_TO_IMPLEMENTATIONS, run last in thesavepost-processing: for every(MODULE)-[:CALLS]->(base)wherebasehas incomingIMPLEMENTS/EXTENDS, itMERGEs aCALLSedge from the caller to each subtype reachable via anIMPLEMENTS|EXTENDS*1..chain (Class-Hierarchy- Analysis over-approximation). Synthetic edges carryresolvedVia:'INHERITANCE'; dataflow is intentionally not routed through them. (2) Implementation ingest —ingestModulebuilds a reverse index (buildImplementorIndex) so when the BFS ingests an interface/base, its implementations are enqueued too. Fixturesfixtures/java/gateway/*; ITjavaInterfaceCallsFanOutToImplementations+IngestModuleJavaIT. -
J1b. Parse
@Query/JPQL/native-SQL strings + derived-name filters (Java) (found 2026-07-06, done 2026-07-07) — motivated by a batch-job analysis pass over the PURpur-batchmodule (2026-07-06, 9 concreteAbstractPurBatchJobsubclasses). J1a resolves calls whose entity is syntactically recoverable (repository generic param,em.persist/merge/remove(arg), Panache active-record receiver). Added:@QueryJPQL/native-SQL string parsing (leading DML verb → mode,FROM/UPDATE/INTOtarget → entity/table, attached at the method declaration since abstract repository methods have no call site to scan); derived query-method name parsing (findAllByClientAndStatus→derivedFilterproperty, tagged on the existing call-site candidate); and a no-generic-entity fallback (repository interfaces with no resolvable generic type anywhere, e.g. a customIRiskRepository, guess the entity from a declared method's return type). Seex-docs/mcp-api-usage-ac-implementation.mdfor semantics/limits.
Project model & ingest endpoints
- 21. Per-project root folder + server-side scan ingest (2026-06-17) —
every project now carries a required
root(server-side path its sources live under) and optionalexcludeDirs, stored on theProjectnode. The server walks the root and parses files: newProjectIngestService+SourceFileshelper, withAstIngestServicesplit intoparse/save. Two new endpoints (@Blocking):POST .../ingest-all(scan the whole root) andPOST .../ingest/{name}(ingest one module + its transitive deps via a server-side BFS). Both return anIngestSummary({ingested, unresolved, duplicates, failed}). Strict duplicate detection (Java FQN; Natural(name, kind)from extension).sourceFilestored relative to the root. Old content-upload path removed. New CLI:project create,project update,project clear,ingest <name>,ingest all. - 12.
GET /api/projects/{project}/modules— list modules in a project (2026-06-15) — newCypherQueries.LIST_MODULES/GraphRepository.listModules/AnalysisResource.modules, returnsMODULEnodes (name,sourceFile), excluding unresolved placeholders. Optional?sourceFile=...filters to the module(s) defined in that file. Also addedtype=<NodeType>filter tosearch/identifier(400 INVALID_TYPEfor an unrecognized type). New IT tests.
Module analysis endpoints (P2 reengineering support)
-
72. A dispatch row reports its whole guard chain, not just the innermost
DECIDE(2026-07-17) — found by a manualVMULTMN4audit against the Natural source, the second such audit to pay for itself.DispatchEntry's contract is "guardField = guardValueroutes toassignedField := assignedValue" — a sufficient condition.NaturalParser.guardPropswalked the control-flow stack and returned on the first (innermost) value-DECIDEwith an active branch (its javadoc said so outright), so for a nestedDECIDEthe outer guard was discarded and a conjunction was served as one condition:128| DECIDE ON FIRST VALUE OF #SHORT-VIEW 165| VALUE 'TABL' 170| DECIDE ON FIRST VALUE OF #FIELD-NAME 171| VALUE 'TX-TABLA' 174| MOVE P-DESCRIPTION TO YTABLMA0.TX-TABLA reported: #FIELD-NAME = 'TX-TABLA' truth: #SHORT-VIEW = 'TABL' AND #FIELD-NAME = 'TX-TABLA'12 of that module's 69 rows (17 %) carried an incomplete condition. It over-generalises, which is the dangerous direction: this table exists to port
DECIDEs to Java, and a condition that is too weak ports into a branch firing where the Natural code never would. Corpus scale, measured on disk: 824 of 7,729 value-DECIDEblocks (10.7 %) are nested, across 178 modules (YFRAMN10.nat: 199). Additive, not breaking (correcting the roadmap's own note):whenField/whenValue/whenValueskeep their exact meaning — the innermost guard — so existing consumers see what they always saw; the chain travels in newwhenChainFields/whenChainValuesand surfaces asDispatchEntry.guards([{field, values}], outermost first, AND-joined; each link'svaluesOR-joined). Encoding needs two levels, so RS (U+001E) separates links above item 64's US (U+001F) for alternatives — neither can occur in Natural source. Decoding is in Java, not Cypher: zipping two nested splits back together needs index arithmetic that Cypher makes unreadable. Theac-uimigration dossier rendered the worst possible form —guardField+ the lossyguardValue(item 64 already said "preferguardValues"), i.e. the innermost guard in its ambiguous encoding, in the one screen meant for porting. It now renders the full conjunction. Live proof after refresh:VMULTMN4:174→#SHORT-VIEW = TABL AND #FIELD-NAME = TX-TABLA, legacy fields unchanged. Vacuity-checked: revertingguardPropsfails 3 of the 4 tests (guardscomes back[]) while the legacy-field test stays green — which is the additive claim, proven rather than asserted. The chain's order is asserted explicitly:ArrayDequeiterates innermost-first, so a missing reversal would silently yield an inside-out chain that a "contains both" assertion would accept. Left behind: #73 (aNONEbranch's condition is a negation no chain can express —guardsis strictly better, not total) and #74 (a duplication in the graph that item 72 exposed, not caused). -
67.
call-tree'sdepthcounts module hops, and says when it truncates (2026-07-17) — the last of the raw-hop siblings.callTreebounded on rawCALLSedges and returnedmin(length(p))as thedepthcolumn, so the number measured how deeply the calling statement happened to be nested rather than dependency distance. Measured live onupms:WGEAGB0S's seven direct module dependencies came back at depths 1..3 (raw path lengths 3,4,5,6,7,7,8) —BGEAGFN0reported 3 — although all seven are one module hop away. All seven now report 1. Depth comes from the path, not from ownership:min(size([n IN nodes(p) WHERE n.type='MODULE']) - 1). The decided semantics were "aFUNCTIONinherits the hop count of the module that owns it", but measurement killed the literal reading: a subroutine defined in a copycode is one shared nodeCONTAINSed by every including module —L4N-ENTER(L4NCOPY.cpy) has 137 owners, and 156 upms functions have more than one. "Its module" has no single answer. Counting module nodes on the traversed path yields one answer per route, sidesteps ownership entirely, and gives the intended result (a root's own subroutines are depth 0). The bound was the hard part, and the first design was wrong. A raw*1..Ncannot express a module-hop bound. The initial plan — a raw budget ofdepth × (1 + budget)— was killed in validation: atdepth=1a budget of 7 means 8 raw hops, and 8 raw hops reach 8 module levels when internal chains are short, so adepth=1request would have expanded ~8 module levels and discarded the rest (costing whatdepth=8costs today). Worse, it could report a wrong depth: a target reachable at module-depth 1 via a long internal chain and at module-depth 5 via a short one would be found only on the short route. Fix:moduleTree(item 65) supplies the modules withinmaxDepthmodule hops, and a QPP with a per-hop predicate prunes the traversal to them while expanding, so it can never wander beyond the requested depth. Verified against liveupmsbefore writing any code (QPP had already refuted one of my designs in item 65).truncated(new response field). The raw quantifier survives as a pure safety cap (agenticcode.call-tree.internal-budget, default 20). Because pruning bounds the search space, the quantifier is free — measured onupms, quantifiers 8/20/40 gave identical results at 2.0/2.6/2.1 s — so it is sized far above the observed maximum. Sizing it from a 300-module sample would have been wrong: that sample said "internal chains ≤ 6", butWGEAGB0Salone reachesISINDATEat 8 raw hops for one module hop, and the true longest path is 10. Since any cap can cut, exhausting it now setstruncated: truerather than returning a short list that reads as complete — the exact failure mode that made items 65 and 68 bugs instead of documented limits. Conservative:trueis possible for a complete result. A "known imprecision" I filed here as #71 was retracted 2026-07-17 — it was my measurement error, not a defect. The claim was that Natural external subroutines (module A performs a subroutine owned by module B) do not incrementdepth, "4,808 such calls inupms". That count applied the single-owner filter to the target but not the source, so every copycode-shared source function (L4N-ENTER: 137 owning modules) was counted once per owner. Source and target single-owner returns 0 — and it must, becauseresolvePlaceholderTargetsnever resolves aFUNCTIONplaceholder across modules, so the cross-moduleFUNCTIONedge the claim assumed cannot exist. Path-based depth has no gap here. Two regressions the existing suite caught, both mine.followWiring(item J9) returned an empty list: the bounding BFS followed onlyCALLS, so withCALLS|INJECTS|REFERENCEStraversal every wiring-only neighbour fell outside the module set and the predicate pruned it away — a bound must follow the same edge types as the traversal it bounds (MODULE_HOP_OUT_WIRING). AndAnalysisResourceITencoded the old semantics; checking the fixture instead of just relaxing the assertion showed the old expectation was the bug:INITIALIZATIONSandR-ADDRESS_SPare both subroutines ofYADDRBN0_SAMPLE.natitself, yetdepth=1omittedR-ADDRESS_SP— aDEFINE SUBROUTINEof the very module being asked about, hidden because it sat two raw edges away. Consequence to expect: a givendepthnow returns more, since the internals of modules within the bound are inside it. Both halves vacuity-checked: reverting the formula madeDEPTHLEAFvanish from adepth=1answer entirely (expected: <1> but was: null), and the truncation test is paired with an assertion thattruncatedis false on a normal query, so an always-true flag could not fake it. Dead code removed on the way: four unusedcallTreeoverloads (CypherQueries×2,GraphRepository×2). -
68.
field-flowbounds on module hops, via a derivedCALLS_MODULEedge (2026-07-17) — the last of the three raw-hop siblings item 65 uncovered.fieldFlowgated producer→consumer pairs onEXISTS { (wmod)-[:CALLS*1..%d]->(rmod) }, so itsdepthcounted a module's internalPERFORMjumps: a consumer called from two subroutines deep sat 3 raw edges away and fell outsidedepth=1. The endpoint then reported nothing downstream consumes this field — a confident false negative, the same failure mode as item 65's "no DB access". Unlike item 65 this is a pairwise reachability test between two arbitrary modules, and the producer is optional ($module IS NULL), so item 65's per-rootmoduleTreeBFS does not apply — it would have to run once per candidate producer. Fix: an enricher materialises(MODULE)-[:CALLS_MODULE]->(MODULE)(the persisted module-level projection of the call graph, same shape asEGO_NEIGHBORS_OUT), andfieldFlowbounds on that, which makes a plain variable-length bound correct again. (Correction: this was expected to supply item 67's bound too. It did not — item 67 turned out to need a set of module names to prune a QPP with, which is what item 65'smoduleTreeBFS already returns, soCALLS_MODULEis used byfield-flowalone. It earns its keep there:field-flowis a pairwise test between two arbitrary modules, where a per-root BFS does not apply.)DELETEbefore rebuild is the load-bearing part, notMERGE. The edge is derived, andMERGEis idempotent only for edges that still exist — it never removes ones that shouldn't. The item-58 sweep deletes stale nodes only, so a surviving module's relationships are never reaped. Proven by removing theDELETEstep: after deleting theCALLNATfrom the producer's source and refreshing,field-flowstill answered[STALECONS]— a dependency that no longer exists in the code. Ordering matters too: the projection runs last, after every step that addsCALLSedges (placeholder + dynamic-CALLNATresolution,link-calls-to-implementations) or removes them (the item-62 data-literal reaping), since a projection is only as correct as the graph at the moment it runs. Both fixes vacuity-checked: reverting the bound made the field-flow test fail with an empty producer list. -
69. The origin file is part of an edge's identity (2026-07-16) — data loss, found by item 66's own regression fixture rather than by review. An edge was identified as
MERGE (a)-[r:%s {lineNo: e.lineNo}]->(b)— (source, target, type, lineNo), with no file. Copycode expansion (item 46a) made that key ambiguous: a host statement on line 10 and a copycode statement on line 10 share it entirely, so the secondSET r += e.propertiesoverwrote the first. The endpoint then returned one row where two statements exist, and a real write was gone from the graph. Proven by reverting the fix: withMOVE 'Y' TO #KEYonCOPYHOST.nat:10andMOVE 'Z' TO &1&onMYTABLECOPY.cpy:10,variables/#KEY/writesreturned only[{sourceFile=MYTABLECOPY.cpy, lineNo=11, ...}, {sourceFile=MYTABLECOPY.cpy, lineNo=10, ...}]— the host's own write had vanished, and the surviving edge even claimedviaCopycode=MYTABLECOPY. Fix:MERGE (a)-[r:%s {lineNo: e.lineNo, originFile: coalesce(e.properties.originFile, a.sourceFile)}]->(b). The fallback isa.sourceFile, not''(as first sketched in the roadmap): for a host statement the edge really does originate in its source node's file, sor.originFileholds the true file on every discriminated edge and item 66'scoalesce(r.originFile, <src>.sourceFile)keeps returning exactly what it did before. An''default would have broken all four of those queries, sincecoalescereplaces onlynull. Scope was 20 MERGE sites, not the 1 the roadmap named: the batch merge plus 7 placeholder-resolution re-merges (which re-key the edge) and 6 dynamic-CALLNATmerges. In the dynamic ones the origin had to be threaded explicitly through theWITH DISTINCT caller, dyn, lit, which drops thesrccall-site node. Deliberately excluded:CONTAINS/INCLUDES/USES_TYPE(unique per node pair by construction — a discriminator cannot prevent a collision there and would add a string property to the most numerous edge type in the graph); the Java{lineNo: a.startLine}merges (copycode is Natural-only); andARG_TO_PARAM— its collisions are real but benign, since two colliding edges carry identical(callSite, position)and no per-site payload, so discriminating them would only add parallel edges for dataflow to walk. The type test is aswitch, not a staticSetfield: the query maps are static finals that call it from their initialisers, and a field declared after them is stillnullat that point (this bit — compiled clean, failed at runtime). Corpus incidence remains unmeasurable after the fact: the collision destroys the evidence one would count. Existing graphs are not migrated — the stale sweep deletes only nodes, so old edges would linger beside the new key; delete + re-ingest the project (~179 s forupms). -
70. Placeholder field resolution kept the copycode provenance (2026-07-16) — found while fixing item 69, by reading the queries around it. Four of the six resolution queries copied only two properties when redirecting an edge onto its real target (
SET r2.value = r.value; SET r2.lineNo = r.lineNo), while the other two usedSET r2 += properties(r). The four discardedviaCopycode,includedAtandoriginFile— the very provenance item 66 had just added. This made item 66 only half-effective, which itsupmsverification could not reveal: a bare field reference (#W-OPTIONS) resolves throughresolveBareIncludedFieldTargets, which copies everything — and that is the field item 66 was verified on. A qualified reference (MYLDA.Q-FIELD, a field of aLOCAL USINGdata area) takesresolvePlaceholderFieldTargetsinstead and silently came back withviaCopycode: null, i.e. indistinguishable from a host statement. The split is inNaturalParser.lookupVariable: bare names go toscope.bareFields(), qualified ones toscope.placeholderFields()under a placeholderDATA_STRUCTURE. Fix:SET r2 += properties(r)in all four. Proven by reverting it — the new fixture returnedexpected: <[QUALCOPY]> but was: <[null]>. Note the two fixes are complementary: item 69's key incidentally carriesoriginFilethrough resolution, butviaCopycode/includedAtneed the+=. -
66. Call sites and variable accesses name the file their line is in (2026-07-16) — found by the same manual
WGEAGB0Saudit as item 65. The graph was already right; the API threw the answer away. The parser stamps copycode-origin edges (item 46a) withviaCopycode+includedAtand gives copycode-origin nodes the real.cpyfile — exactly what this doc promised — butviaCopycodeappeared nowhere inac-neo4j-storeorac-code-server, so a line belonging to a.cpywas served as if it were a host line:before: #W-OPTIONS writes → sourceFile WGEAGB0S.nat, lineNo 18/20/22 truth: WGEAGB0S.nat:18/20/22 = the generated comment banner ("* System : Versis", ...) ISICINDI.cpy:18/20/22 = &1& := '0' / '1' / ' ' (&1& = '#W-OPTIONS', INCLUDE at host line 676)Host and copycode lines sat indistinguishably in one list (
lineNo: 670in the same response was a real host line). Severity differed per endpoint:variables/{name}/reads|writesmade an explicit false claim (sourceFile= host +lineNo= copycode line);callers/calleesgave unattributedlineNos(theirsourceFileIndexis the callee's file, so they never claimed a call-site file). Scale inupms: 11,008CALLS, 1,325WRITES, 996READSedges carryviaCopycode. Root cause was one missing line:CopycodePreprocessor.withViaalready heldorigin.sourceFile()where it stampedviaCopycode, butAstEdgehas nosourceFilefield, so the path was dropped — edges now carry it asoriginFile. On top of that:AggregatedCallRef.lineNos: [int]→sites: [CallSite](lineNo,callSiteFileIndex,viaCopycode,includedAt) — per site, because one target can be called from both the host and a copycode, which entry-level provenance cannot express. Deliberately namedcallSiteFileIndex, notsourceFileIndex: at entry level that word means the callee's file, and reusing it would have baked in the next misreading.variables/{name}/reads|writesreturncoalesce(r.originFile, f.sourceFile)plusviaCopycode/includedAt.payloadalready did this correctly (per-entrysourceFile+lineNo) and was the model. Breaking change: REST/MCP/CLI stayed in sync for free (MCP returns the record, the CLI prints the raw JSON);ac-ui'sIdentifierPopoverwas carrying the same confusion — keying PERFORM lines to the caller's definition file — and now reads each site's own file. Live:ADLML02inWGEAGB0S's callees →lineNo 26, callSiteFile src/manual/copycode/ISIYESNO.cpy, viaCopycode ISIYESNO, includedAt 673, whileBGEAGFN0→lineNo 497, WGEAGB0S.nat, no copycode; the#W-OPTIONSwrites above now nameISICINDI.cpywithincludedAt 676, line 670 unchanged. Tests inCopycodeExpansionIT(call site + a variable written from both host and copycode), both vacuity-checked: withoutoriginFilethey fail withexpected: <MYTABLECOPY.cpy> but was: <COPYHOST.nat>— the defect verbatim. Building that fixture also turned up roadmap item 69 (two edges on the same line number from different files collapse in the MERGE). -
65.
?depth=counts module hops, not rawCALLSedges (2026-07-16) — found by a manual endpoint audit ofWGEAGB0S(upms) against the Natural source.db-accesses?depth=1..4reported zero DB accesses for a module that reachesYGEAGBNH's tables through exactly two module calls. An empty list reads as "this module touches no database" — a confident false negative, the worst failure shape for an agent. Cause: aCALLSedge starts at the statement that makes the call, not at the enclosingMODULEnode, so(m)-[:CALLS*1..N]->(hop:MODULE)also steps through internalPERFORMjumps. The real path isMODULE:WGEAGB0S → FUNCTION:GET-DATA → FUNCTION:GET-MAIN-DATA → MODULE:BGEAGFN0 → FUNCTION:READ-FILE → MODULE:YGEAGBNH— 5 raw edges for 2 module hops, so the answer only appeared atdepth=5, and the required value depends unpredictably on the callee's subroutine nesting.EGO_NEIGHBORS_OUT(item 49) already defined a module hop correctly as(a)-[:CONTAINS*0..]->()-[:CALLS]->(b:MODULE)and BFS'd one hop at a time — soego_graph?depth=2anddb-accesses?depth=2disagreed about the same graph. Fixed by giving the transitive endpoints the same definition: newMODULE_HOP_OUT(batched over a whole BFS frontier — one query per hop, not per module) +GraphRepository.moduleTree, consumed byDB_ACCESSES_FOR_MODULES,SQL_STATEMENTS_FOR_MODULESand the?module=scope ofvariables/{name}/reads|writes(whoseEXISTS { (root)-[:CALLS*1..N]->(owner) }had the same flaw). Done in Java because Cypher cannot express the hop: a quantified path pattern rejects both the variable-lengthCONTAINS*0..inside it ("Variable length relationships cannot be part of a quantified path pattern") and nesting. Live:WGEAGB0Sdb-accessesnowdepth=1 → 0(correct — none of its 7 direct callees touches a table),depth=2 → 7incl.VDB2-VERSIS_GENAGREE via YGEAGBNHat line 2617 (matches the source'sFIND (1) VDB2-VERSIS_GENAGREE), consistent withego_graph. Regression testModuleHopDepthIT(fixturesDEPTHROOT/DEPTHLEAF: one module hop, but theCALLNATtwoPERFORMlevels deep); vacuity-checked — dropping theCONTAINS*0..step fails 2 of its 3 tests, while thedepth=0test correctly stays green. -
P2-a. Control-flow parsing (
IF/ELSE/END-IF,FOR/END-FOR,REPEAT/END-REPEAT) — Natural construct #6. NewNodeType.CONTROL_FLOWnode per block (dataType=IF/FOR/REPEAT,value= condition/loop-spec text, line span = full block incl. matchingEND-*), nested viaCONTAINSedges. -
P2-b. Module "context bundle" endpoint (
GET /modules/{name}/context) — aggregates functions, callers/callees, db-accesses/sql-statements, and a variable read/write summary for a module in a single response. NewGraphRepository.moduleContext()+ CLIcontextcommand. -
P2-c.
DATA_STRUCTUREfield schema — newGET /api/projects/{project}/data-structures/{name}/fieldsreturns the flattened field list (name, type, dataType/length, const value, immediate parent) of aDEFINE DATA/DDM structure. New CLIdata-structure-fields. -
P2-d.
DB_TABLEcolumn schema — newGET /api/projects/{project}/db-tables/{name}/columnsderives column info for aDB_TABLEfrom theDATA_STRUCTUREused as theINTO VIEWtarget ofSELECTs against that table. New CLIdb-table-columns. -
P2-e. Transitive call graph — new
GET /modules/{name}/call-tree?depth=Nendpoint returns all modules/functions transitively reachable viaCALLSedges from the module (each with its minimum hop-depth).depthdefaults to 3, clamped to 10. New CLIcall-tree --depth.
Correctness gaps (wrong/empty results)
-
77. Bare-field resolution ignored which module it was resolving for (2026-07-17) — found while root-causing item 74, which it does not fix (see the note there).
resolveBareIncludedFieldTargetsdocumented itself as redirecting a bare reference "only when exactly one field of that name is reachable through the module's resolvedINCLUDES", but resolved project-wide across all owners at once. An unresolved bare field is one node per(name, project)(item 76 —MERGE_NODESkeys onsourceFile,""here), so(m)-[:CONTAINS]->(ph)matched it once per owning module;WITH ph, collect(DISTINCT realv)then droppedm, and the redirect'sMATCH (src)-[r]->(ph)never boundsrctom. Two bugs in opposite directions, both measured inupms:- under-resolution — two owners disagreeing on the target made
size(matches) = 1fail for everyone, including owners for whom the name was unambiguous (38 placeholders); - misattribution — an owner with no matching include had its edges redirected onto another module's
field anyway (28 placeholders, 199 module-field pairs). Live example:
BMTABBP0was recorded writing##MSG-NRofCDPDA-M.pda, a data area it does not include, while the other 63 modules touching that node genuinely include it.
Both surface as a fabricated dataflow:
field-flowpairs producer and consumer only when both touch the same node, so two modules sharing nothing but a field name were reported as passing data between them — a dependency an agent would act on.variables/{name}/reads|writescannot see any of it (it matches every node with the name and reports only the accessing side), which is why the defect survived this long.Fixed by carrying
minto the aggregation and bindingsrcwith(m)-[:CONTAINS*0..1]->(src). The*0..1bound is exact, not a guess: a placeholder edge'ssrcis only ever theMODULE(35,572 edges) or aFUNCTIONdirectly under it (157,616; all 16,231 such functions are direct children) — never aCONTROL_FLOWnode, so the walk also stays clear of item 75'sCONTAINScycles. Also aligned the scoped and unscoped twins, which had silently disagreed (*1..10vs unbounded → the same module could resolve differently depending on which pass ran); the shared bound is nowINCLUDE_FIELD_DEPTH, and it is required, not an optimisation, becauseCONTAINSis not acyclic.DELETE_RESOLVED_BARE_PLACEHOLDER_CONTAINSbecame per-module in the same change — its global "no edges left" test would otherwise have kept a resolved module'sCONTAINSalive on the strength of another module's unresolved edges.Cost: the resolver is ~4.4× slower on
upms(34 s → 2 m 30 s forWRITES), paid on deep ingest only. Known residue (item 76): 132 of 18,539 source nodes are copycodeFUNCTIONs shared by several modules; their edge to the placeholder is a single edge, so per-module binding cannot separate what is one node. Needs per-module placeholder identity, not a better query. Verified byBareFieldModuleScopeIT(2 of 3 tests fail without the fix; the third is a control proving resolution still works, since resolution failing everywhere would also yield no flow). - under-resolution — two owners disagreeing on the target made
-
75. Dynamic-
CALLNATresolvers wedged on the cyclicCONTAINSgraph (code + ITs done 2026-07-17; corpus re-verify pending). A whole-rootdeeprefresh ofupmshung finalize step 17 (resolve-dynamic-callnat-intra-indirect) for ~2 h without completing, blocking every later step (including item 77's bare-field resolution — so item 77 could not be corpus-verified). Cause: that step joins three unbounded(caller:MODULE)-[:CONTAINS*0..]->anchors, and per-caller path enumeration over item 75's cyclic copycode containment is cubic (a read-only probe of the exact query for the single callerDAGNTFN0did not finish in 60 s). Fix: the sixRESOLVE_DYNAMIC_CALLNAT_*queries (INTRA/INTRA_INDIRECT/CROSS+_SCOPEDtwins) find a module's own statements bysourceFileequality (aMODULEis 1:1 with itssourceFile; itsCALLNATsites andWRITESstatements share it) instead of descendingCONTAINS— an index hash-join that cannot cycle. The cross-module resolver's dispatch variable may live in an included PDA, so it is scoped to the caller's own file or a data structure the callerINCLUDES(name-only match would pull 20958 unrelated same-named vars → the scope filter keeps 307). Proven equivalent onupms:resolve-dynamic-callnat-intrareturns the identical 31(caller, target, lineNo)triples project-wide; the rewritten indirect step runs project-wide in ~6 s instead of wedging. Existing dynamic-dispatch ITs (intra / indirect / cross / scoped / unresolved- survival, 8 tests) stay green. Does not remove the underlyingCONTAINScycles (still open inroadmap.md#75); it removes this family's dependence on traversing them. Corpus-verified onupmsv68 (2026-07-18): 251 resolved dynamicCALLS(107 indirect), cross-module 113 callers / 165 edges. -
75b.
link-args-to-params(dataflow step 27) rewritten offCONTAINS*(2026-07-18) — the same item-75 blow-up: three unboundedCONTAINS*anchors (src,cv,pv) over the cyclic copycode graph made it the single slowest finalize step onupms(~25 min). All three now resolve through the(project, sourceFile, …)index instead of descent: the call sitesrcbysourceFileequality (module ↔ file is 1:1), and the argument variablecv/ callee parameterpvover the file-set {module's own file} ∪ {files itINCLUDES}, withcvresolved beforepvis unwound so the file-lists are not cross-producted. Corpus-verified:ARG_TO_PARAMstill built (436 edges on the v68 recreate) and the step no longer registers as a running query across 45 s poll windows (≈ seconds, not 25 min). After this, a full deepupmsfinalize (~28 min) is dominated entirely by step 19 (resolve-field- placeholder WRITES), which is an item-76 cardinality problem (shared placeholder nodes), notCONTAINS*— tracked separately. -
76. Per-module identity for field placeholders (2026-07-18) — an unresolved field (
VARIABLE/CONSTANT,sourceFile="") used to be ONE shared node per(name, project), referenced by up to 916 modules. That sharing maderesolve-field-placeholder WRITES(finalize step 19) join(ph)-[:CONTAINS]->(phv)×(src)-[:WRITES]->(phv)into a ~28.7M-row cartesian → ~55 min onupms. Fix:GraphRepository.placeholderOwnerstamps anownerModule(the referencing file) onto field placeholders only; it joins both the in-memory dedup key (mergeKey) and the DB merge key (MERGE_NODES/MERGE_POSITIONAL_NODES).MODULE/DB_TABLE/DATA_STRUCTUREplaceholders keepownerModule=""and stay shared (aCALLNAT/USINGtarget still resolves once). A companion sweepDELETE_STALE_PLACEHOLDER_NODESreaps a re-ingested file's obsolete placeholders (the file sweep skipssourceFile=""). Corpus (v69): step 19 55 min → 55 s, whole deep finalize ~59 min → ~6 min, item-77 misattribution stays 0, graph grows only +2,207 nodes (resolved placeholders are deleted in step 26),ARG_TO_PARAMwell-formed (30,583 edges, 0 position mismatch). Field/dynamic ITs green. -
P1-h. Transitive DB access resolution (2026-06-16) —
/db-accessesand/sql-statementsreturned[]for modules whose SQL lives behind one or moreCALLNAThops (e.g.WGEAGB0S→BGEAGFN0→YGEAGBNH). Added?depth=Nparam; whendepth>0, newdbAccessesTransitive/sqlStatementsTransitivequeries walk up todepthCALLShops and annotate each result withvia(the intermediate module name). Clamped to 10. -
P1-i.
/db-tables/{name}/columnsis effectively broken (2026-06-16) — returned only one column for tables whose column schema is encoded in.pdafiles.parseDataAreanow detectsVIEW OF <table>and creates aUSES_TYPEedge from theDATA_STRUCTUREto aDB_TABLEplaceholder.DB_TABLE_COLUMNSupgraded to a UNION query covering both the PDA path and the SELECT INTO VIEW path. New unit testpdaViewOfCreatesUsesTypeEdgeToDbTable. -
P1-j. SQL statement text is truncated (2026-06-16) — every
sqlStatementsentry hadstatementcut off at...WHERE. TheDB_READhandler now reads ahead through subsequent lines until an empty line orEND-FIND/END-READ, accumulating all lines intoDB_ACCESS.value. New unit testmultiLineFindStatementTextIsCapturedFully. -
P1-k.
FIND (1)misparsed as a table named(1)(2026-06-16) —FIND (1) <view>produced a phantom DB-access entry. Fixed by adding(?:\\(\\d+\\)\\s+)?to theDB_READpattern. New unit testfindWithRecordLimitIsNotMisparsedAsTableName. -
P1-m. Dynamic
CALLNAT <var>calls are invisible in the call graph (2026-06-21) — the parser/enricher only recorded literalCALLNAT 'NAME'edges, so variable-target calls (CALLNAT #WIF ...) produced noCALLSedge. Real case:W-MNT-N0dispatches ~90Wxxxx*SXML-interface programs via a singleCALLNAT #WIF; the target is resolved byKDWWIFN0. Done — and v1 went further, doing both intra- and cross-module. Parser detectsCALLNAT <var>(newCallKind.CALLNAT_DYNAMIC), emits a variable-named placeholder + marker edge, captures multi-line argument lists;PARAMETER USINGadvancesparamPosition. Enrichment addsRESOLVE_DYNAMIC_CALLNAT_INTRAandRESOLVE_DYNAMIC_CALLNAT_CROSS(followsARG_TO_PARAMinto the callee — recovers theW-MNT-N0 → KDWWIFN0 → WGEAGB0Schain), plus scoped variants and placeholder cleanup. Required fixingLINK_ARGS_TO_PARAMSto source calls via(:MODULE)-[:CONTAINS*0..]->(src)-[:CALLS]so subroutine-nested CALLNATs match. Live-validated onupms: deep-ingestingW-MNT-N0(8.4 s) yieldscallers/WGEAGB0S=W-MNT-N0(edgeKind=CALLNAT_DYNAMIC) andcallees/W-MNT-N0= 124 resolved dynamic targets. -
P1-n. Data-structure fields lack
scopeand often have nulldataType(2026-06-21) — the parser now stamps everyDEFINE DATAfield/variable with ascopeproperty (PARAMETER|LOCAL|GLOBAL|INDEPENDENT), surfaced as ascopefield on bothDataStructureFieldandIdentifierMatch(/search/identifier) — the latter exposes the motivating inline param#P-CALLED-PROG. Covered byNaturalParserTest+AnalysisResourceIT. -
P1-o. No search by literal value (2026-06-21) — a program name like
WGEAGB0Scan exist in the graph only as a string literal assigned to a field. AddedGET /search/value?value={v}(SEARCH_BY_VALUE,ValueMatch): quote-insensitive, returning both nodes carrying the value as a constant (kind=NODE) and literal assignments to a variable (kind=ASSIGNMENT). Missingvalue→400 MISSING_VALUE. -
P1-p. Edge provenance on call results (2026-06-21) — done via the
edgeKind=CALLNAT_DYNAMICprovenance carrier: stamped on every inferred edge and surfaced through the existingedgeKind = coalesce(r.callKind, …)projection, so callers/callees distinguish dynamic (inferred) from literalCALLNAT/PERFORM(static) with zero DTO changes. -
P1-q.
/data-structures/{name}/fieldsover-returns across modules (field pollution) (2026-06-21) — for a structure name shared by many modules (e.g.W-WIF-A1, used by ~80Wxxxx0Ssubprograms), the endpoint returned a polluted list (269 rows spanning ~80 unrelated modules).DATA_STRUCTURE_FIELDSnow selects the canonical definition(s) — those with a non-emptysourceFile— falling back to empty-sourceFilestubs only when no real def was ingested, and constrains each field'sparentto lie within the chosen structure (p = s OR (s)-[:CONTAINS*1..]->(p)). Live:W-WIF-A1266→115 rows with 0 module-parented leak;YGEAGROW430→36. Covered bydataStructureFieldsAreScopedToCanonicalDefinition. -
P1-r. Module→data-structures endpoint (2026-06-21) — added
GET /modules/{name}/data-structures(MODULE_DATA_STRUCTURES,ModuleDataStructure) returning one row per referenced structure:name,relationship(USINGcopybook viaINCLUDES/INLINEgroup viaCONTAINS),area(PDA/LDA/GDA/INLINE/UNKNOWN),fieldCount,sourceFile. LiveWGEAGB0Snow exposes its whole interface (W-WIF-A1PDA/118,W-WIF-A2PDA/10,W-WIF-A4PDA/5, the LDAs) and flags unresolvedCDPDA-M/CDPDA-PasUNKNOWN/0. Note: precisePARAMETER/LOCAL/GLOBALUSING scope is not stored on theINCLUDESedge (areais the proxy). Covered bymoduleDataStructuresListsReferencedAreas. -
P1-s. Referenced PDAs/copybooks not fully ingested (empty field lists) (2026-06-21) —
WGEAGB0SdeclaresPARAMETER USING CDPDA-M, butGET /data-structures/CDPDA-M/fieldsreturned[]. Root cause: the file is ingested, butCDPDA-M.pda's sole top-level group is namedMSG-INFO, while a module references it by file/DDM name. Fix: when a data area has exactly one top-level group whose name differs from the file name,parseDataAreawraps it in a rootDATA_STRUCTUREnamed after the file. Multi-top-group areas and matching-name areas keep their existing shape. Covered byparsesPdaWithRedefineGroup+usingResolvesCopybookWhoseGroupNameDiffersFromFileName(fixturesMSGAREA.pda/MSGUSER.nat). Re-ingest required. -
P1-u. Surface a module
purpose/description(2026-06-21) — parsers stamp adescriptionproperty on theMODULEnode: Natural scans the leading comment banner (priority**SAG TITLE:→* Title :→**SAG DESCS(n):→* Function :); Java uses the class Javadoc's first line. Surfaced asModuleContext.description(/context) viaMODULE_SOURCE_FILE(fetched as aModuleHeader),nullwhen no banner. Covered byNaturalParserTest,JavaParserTest,AnalysisResourceIT. Re-ingest required. -
P1-x.
/callerssilently omits incomingEXTENDS/IMPLEMENTSedges (2026-06-21) — "who extends/implements this class?" returned nothing (live onpur,GET /modules/AbstractUPMFESvc/callerswas[]despite 22 controllers). Root cause:callers(scope)matched only[r:CALLS], whereascallees(scope)already traversed[r:CALLS|EXTENDS|IMPLEMENTS].callers(scope)now mirrorscallees: matchesCALLS|EXTENDS|IMPLEMENTSand tags inheritance callersedgeKind=EXTENDS/IMPLEMENTS.scope=internalstill excludes inheritance;scope=externalincludes it. Covered bycallersSurfaceIncomingInheritanceEdges. -
P1-y. Double-hash (
##) field names truncated to#(2026-06-22) — the data-area field regexDATA_AREA_FIELDaccepted only a single leading#, so a Natural variable named##MSGwas parsed withname = "#". WidenedDATA_AREA_FIELD's name class to[#A-Za-z][#\w-]*so a leading run of#is captured whole.LEVEL_FIELDandIDENTIFIER_TOKENalready handled##. Covered bydoubleHashFieldNamesAreNotTruncated. Re-ingest required for##names. -
P1-z. Dispatch table queryable —
DECIDE/IFvalue→assignment correlation (2026-06-22) — analyzing the routerKDWWIFN0could recover the set of 130 dispatched programs but not the mapping (#P-OBJECT-TYPE = 'genagree'→#P-CALLED-PROG := 'WGEAGB0S'). TheNaturalParsernow tracks the activeDECIDE ON [FIRST] VALUE OF <subject>branch: it records the subject per DECIDE block and the currentVALUE '<literal>'(handlingNONE/ANY VALUEresets and comma-separated value lists), and stamps every literal assignment made inside that branch — including ones nested in an innerIF— withwhenField/whenValueproperties on theWRITESedge. New endpointGET /modules/{name}/dispatch-table(DISPATCH_TABLE,DispatchEntry) returns rows{guardField, guardValue, assignedField, assignedValue, lineNo}. Covered bydecideValueAssignmentsCarryDispatchGuard+dispatchTableRecoversObjectTypeToProgramMapping(fixtureKDDISP.nat). Live-validated onupms(2026-06-22):GET /modules/KDWWIFN0/dispatch-tablereturned ~140 rows reproducing the hand-builtkdwwifn0-prog-routing.csvmapping (genagree → WGEAGB0S/WGEAGX0S, etc.). Re-ingest required. Known limitations: the innerIF #L-LIST(list-vs-detail) sub-guard is not separately captured (both branch assignments share the DECIDE'swhenValue); the field-placeholder resolution does not copy edge properties, so a guarded write to a copybook field would lose its guard on redirect.
Token efficiency (payload shape)
- P1-l. Repeated absolute
sourceFilepaths inflate payloads (2026-06-16) —callers/calleesandcall-treenow return a wrapper with a deduplicatedsourceFiles: [...]index and items that carrysourceFileIndex: int.ModuleContextgains a top-levelsourceFilefield. New DTO records:CallRefResponse,AggregatedCallRef,CallTreeResponse,CallTreeItem. - P1-m. Call-site aggregation (2026-06-16) —
callers/calleesqueries usecollect(r.lineNo) AS lineNoswith aGROUP BY (name, type, sourceFile, edgeKind);db-accessesgroups by(name, mode).lineNossorted in Java. - P1-n. Separate
PERFORM(intra) fromCALLNAT(inter) (2026-06-16) — addededgeKindfield (CALLNAT/PERFORM/EXTENDS/IMPLEMENTS) toAggregatedCallRef, and?scope=external/?scope=internalfilter on/callersand/callees. CLI gains--scope. - P1-o. Field projection + sub-array pagination on
/context(2026-06-16) — added?include=functions,dbAccesses,...projection and?limit=N&offset=Npagination applied per sub-array. CLIcontextgains--include,--limit,--offset. - 7. Pagination (2026-06-17) — added
?limit=N&offset=Ntocallers/callees/db-accesses/search-identifier(and the transitivedb-accessesvariant), defaultlimit=50,offset=0. CLI gains--limit/--offset. New ITcalleesPaginationLimitsAndOffsets. - P1-t.
/contextis heavy by default — make sub-arrays opt-in, return counts (2026-06-22) (found 2026-06-21) — the liveWGEAGB0S/contextwas ~50 KB, ~70% of it thevariableAccessesarray (347 entries) which the analysis never used.?include=projection existed but the default still returned every section fully expanded. Flipped the default to lean: heavy sub-arrays (variableAccesses, largesqlStatements) return a summary by default — e.g.variableAccesses: { count, byFunction: {...}, byMode: { READS, WRITES } }— and the full list is emitted only via?include=. A summary line replaces 347 objects; the biggest single token lever found in theWGEAGB0Sanalysis. - P1-v. Tiny
/modules/{name}/digesttriage endpoint (2026-06-22) (found 2026-06-21) —/contextis the "expand" call (tens of KB); there was no "should I dig deeper" call. AddedGET /modules/{name}/digestwith a deliberately small contract:description(P1-u), function count, callers/callees names only grouped byedgeKind, DB table names, referenced data-structure names + field counts (P1-r) — designed to a token budget, not a full dump. - P1-w. Names-only / field-projection mode on list endpoints (2026-06-22)
(found 2026-06-21) —
call-tree,callers,callees,search/identifierare often used only to enumerate names, yet each row still carriedsourceFile(Index), line ranges,dataType, etc. Added a?fields=namevariant that drops per-row detail to just the name (+ type) when the agent is enumerating, on top of the existingsourceFiles-index dedup (P1-l). Complements P1-t/P1-v. - P1-x. Dynamic
CALLNATvia lookup array + keep unresolved dynamic calls visible (2026-06-22) — aCALLNAT <var>whose dispatch variable is loaded from a lookup array (e.g.ASSIGN #TBL(1) = 'WPARTD2S',#W-ACT-PROG := #TBL(#I),CALLNAT #W-ACT-PROG— WPARTX2S L994) was dropped entirely: (1) the parser missed the subscripted literal write (ASSIGN #TBL (1) = …, space before(), and (2) the unresolved dynamic marker was deleted, erasing the call site. Fix: parser now captures subscripted assignment targets; a new intra-module indirect resolver follows the dispatch var's same-lineREADSto the source array and resolves its literals (taggedindirect); unresolved dynamic markers are now kept (only resolved-site markers are reaped) so an agent can still see/investigate the dynamic call incallees/context.
Agent API / MCP tooling gaps
-
78.
project recreate— re-initialise a project from its own stored config (2026-07-17) —POST /api/projects/{p}/recreate(+?deep=true) andac project recreate <name> [--deep]. There was no way to say "wipeupmsand set it up exactly as it was": recreating meant readingproject list, writing downroot/language/excludeDirs/generatedDir/userExitDir, deleting, and re-supplying them by hand. That is not hypothetical — on 2026-07-17 theacproject was deleted, the re-create failed with400 LANGUAGE_REQUIRED(mandatory since item 47), and the project was gone for a while. A typo ingeneratedDirwould not even error; the item-47 LoC split would just report nonsense. The(:Project)shell is not deleted, only itsAstNodes. The shell holds nothing but config (verified:keys(p)= name, root, language, excludeDirs, generatedDir, userExitDir), so keeping it is observably identical to delete-then-create while removing the window in which the config can be lost. The root is resolved before anything is deleted (withResolvedRoot, which already existed forrefresh): unlike create — where an unreachable root costs nothing — recreate would otherwise wipe a good graph and then find nothing to rebuild from. A moved root now fails400 ROOT_NOT_FOUNDwith the graph untouched. Not atomic and not claimed to be: a failure after the delete leaves the project with an empty graph (re-run to finish), and the root could vanish between check and scan. It turns the common silent failure loud; it does not make the operation transactional. No MCP tool — MCP is deliberately read-only + ingest, and this is destructive. Verified byProjectRecreateIT(3 tests); the missing-root guard was vacuity-checked by moving the delete ahead of the check, which fails exactly that test and no other. -
79. One
deleteverb, with the blast radius spelled out (2026-07-17) —clearused to be bound twice, at two levels, with drastically different reach:ac clearemptied the entire database whileac project clear <name>deleted one project — a forgotten word apart, and only the first prompted for confirmation.ProjectCommand.ClearCommandwas also a byte-for-byte duplicate ofDeleteCommand(same body, same endpoint), so it was dead weight on top of an overloaded name. Both are gone; deleting is now:ac project delete <name> one project ac project delete --all every project (prompts; -y skips) ac project delete usage error — never "delete everything"--alland a name are mutually exclusive and one is required, enforced by picocli'sArgGroup(multiplicity = "1")rather than a hand-rolled check. The confirmation prompt is inherited verbatim from the oldac clear. Covered byProjectDeleteArgsTest(6 tests, parse-level:apiClient()builds its client inside the call, so there is no seam to inject a fake and executing would fire real HTTP; what a valid parse does is covered byProjectRecreateITat the API level). Breaking change:ac clearandac project clearno longer exist. -
Batched deletes (2026-07-17, with item 78) —
DELETE_PROJECT/CLEAR_ALLwere a singleDETACH DELETEin one transaction. At the corpus's real size —upmsalone is 454,300AstNodes joined by ~1.3M relationships, 536,210 nodes across all projects — that is one transaction the heap must hold at once, with noserver.memory.heap.max_sizeconfigured (only a 512M pagecache). Deleting was rare enough to get away with; item 78 makes it routine. Both now useCALL { ... } IN TRANSACTIONS OF $batchSize ROWS(agenticcode.delete.batch-size, default 10000). This forced the delete path offsession.executeWriteonto implicit transactions — Neo4j rejectsCALL { } IN TRANSACTIONSinside an explicit one ("can only be executed in an implicit transaction") — which is why the existence check is now a separate statement. Trade accepted deliberately: an interrupted delete leaves a partially emptied project that re-running finishes, versus an unbatched delete that risks not completing at all. -
deploy.sh→manage-ac.sh(2026-07-17) — renamed (viagit mv, history preserved) and given a command list.manage-ac.sh deployis the oldup, unchanged. No argument now prints help instead of deploying — a full deploy bumpsagenticcode.versionand rebuilds everything, which a bare invocation should not trigger by accident. Noupalias: keeping two names for one action is the exact mistake item 79 removed. Addedrestart(restart the server without a build — also the way to abort a running server-side deep refresh, whose HTTP client can be killed without stopping the job),status(containers, server version and projects; every probe guarded so it reports a down stack instead of dying on it underset -e), anddown(whole stack incl. neo4j — deliberately not-v, which would delete the graph volume). All in-repo references updated, including the two user-facing CLI strings that told people to run./deploy.sh cli;features.md's historical entries below are left as they were written.
Found while dogfooding AgenticCode on its own codebase for the J1b task (2026-07-07): friction points where the agent-facing tools didn't answer a question the graph already had the data for.
-
27. Generic "inspect node" tool (done 2026-07-07) — added
GET /nodes/{id}(+ MCPinspect_node): every property of a node, viaNode.asMap()so new parser properties show up automatically.search/identifierandsearch/annotationnow also returnidso there's a way to get one — without that they'd have been unreachable in practice. Nodeids are regenerated on every re-ingest (merge key is(type, name, sourceFile, project);idis unconditionally overwritten) — documented as a limit, not fixed. -
28. Source-snippet-by-node tool (done 2026-07-07) — added
GET /nodes/{id}/sourceandGET /modules/{name}/source?startLine=&endLine=(+ MCPnode_source/module_source), reusing the ingest-time root resolution and the same UTF-8/ISO-8859-1 fallback decoding the parsers use. -
29. Text/string-literal search (done 2026-07-07) —
search_identifieronly matches identifier names; there was no way to search annotation names or string-literal node values (e.g. "does anything reference@Query", "does any SQL string containorders"). Added:search/valuegained acontainsparameter (case-insensitive substring, vs. the existing exact match) for literal/assigned string values; a newsearch/annotationendpoint (+ MCP toolsearch_annotation) finds Java classes/methods/constructors/fields carrying a matching annotation, backed by a new genericannotationsproperty captured at parse time on every such node (independent of any annotation's own specific interpretation elsewhere, e.g.@Entity/@Query). Seex-docs/mcp-api-usage-ac-implementation.mdsection 7-8 for usage. -
31. Resolve inherited
REFERENCES/wiring edges on concrete subclasses (found 2026-07-07/08, PURpur-batchre-evaluation, done 2026-07-09) —call_tree/module_digestonly show aREFERENCESedge on the class that syntactically declares it. Where the wiring (e.g. JBeret step construction) lives in a shared abstract base class rather than the concrete subclass —AbstractKeyTableImportJob.jobSteps()wiresKeyTableImport{Init,Processing, End,PassInit,Logging}Step— the concrete subclasses (KeyTableImportValidationJob/KeyTableImportCommitJob) show noREFERENCESat all andcall_tree(..., followWiring=true)on them returns empty, even thoughcall_tree(AbstractKeyTableImportJob, followWiring=true)reaches the full step tree. For 7 of 9 sibling job classes that declare their own wiring directly,followWiring=truealready worked well — this item was specifically about the case where the wiring is declared on an ancestor. Fixed by also followingREFERENCESedges declared onEXTENDSancestors (transitively) when traversingfollowWiringfrom a concrete class, the same way method calls already resolve inherited members. Materialized as a synthetic edge (resolvedVia: 'INHERITANCE'), same class-level over-approximation as the rest of the CHA-style resolution (doesn't know whether the subclass overrides the specific method that declares the wiring). Covered byJavaInheritedWiringIT.subclassInheritingWiringAlsoResolvesIt(classDeclaringItsOwnWiringResolvesTodayis the control test). -
32. Resolve DB-table access on the Repository/Entity itself, not only on the calling Logic class (found 2026-07-07/08, PUR
pur-batchre-evaluation, done 2026-07-09) —db_accesses/sql_statementscorrectly resolve a table when a Logic class calls a repository method (via: "RiskLogic"etc., per item J1a/J1b), butdb_accesses(RiskRepository)/db_accesses(RiskEntity)anddbTablesin their ownmodule_digeststayed empty — even though the repository interface statically carries its generic entity type (AbstractPurRepository<RiskEntity>/IRiskRepository) and the entity carries its@Entity(name = TABLE_NAME). Fixed by attaching the resolved table directly to the Repository/Entity module's owndbTables/db_accesses, taggedmode: "DECLARES"(not a read/write, just "this module maps to this table") — besides the existingREADS/WRITESfrom called functions. Lets a reference implementation's Repository/Entity pair reveal its table directly instead of requiring a call chain through whichever Logic class happens to use it first. Covered byJavaRepositoryOwnTableIT.repositoryOwnDigestListsItsTableWithoutAnyCallerand.entityOwnDigestListsItsTableWithoutAnyCaller. -
33. Hook-contract query for a base class (found 2026-07-07/08, PUR batch-job family comparison, done 2026-07-09) — when comparing/porting a template-method-style family (e.g.
AbstractSteuertabellenProcessingStepwith hooks likevalidate()/writeEntities()/clearTable()), there was no way to ask the graph which of a base class's methods areabstract(subclass must implement),final(fixed, subclass must not override), or a plain overridable default — this had to be read from the base class source by hand every time. AddedGET /modules/{name}/functions?kind=abstract| final|overridable, sourced from the modifiers already visible to the parser (each function also carries akindfield in the unfiltered response,nullfor a Natural subroutine or a constructor). Turns "what must a new sibling implement" into one call instead of reading the whole abstract class. Covered byJavaFunctionKindIT'skindAbstractReturnsOnlyValidate/kindFinalReturnsOnlyCommit/kindOverridableReturnsOnlyLog(unfilteredListsAllThreeMethodsTodayis the control test). -
34. Bulk
function_overridesfor all abstract methods of a base class (found 2026-07-07/08, PUR batch-job family comparison, done 2026-07-09) —function_overrides(base, function)took one method name per call; profiling how an entire family (5+ hook methods × 5+ sibling classes) implements its contract needed one call per hook method. Added an optionalfunctionparam — when omitted,GET /modules/{name}/functions/overridesreturns overrides for every abstract method ofbaseat once, grouped by method then by declaring subclass. Complements item 33 (which methods exist) with "how does each sibling implement them". Covered byJavaBulkFunctionOverridesIT.bulkEndpointGroupsOverridesByHookMethod(perMethodOverridesAlreadyWorkForEachHookis the control test). -
35.
modules?extends={name}filter (found 2026-07-07/08, PUR batch-job family comparison, done 2026-07-09) — listing every concrete subclass of a base class previously required readingcallers(base, ...)and filtering for theEXTENDSedge kind by hand. Added a direct filter on the existingGET /moduleslisting (?extends=AbstractSteuertabellenProcessingStep), making "show me every existing implementation of this pattern" a one-line query, consistent with the existing?moduleKind=/?sourceFile=filters. Restricts to directEXTENDSsubclasses only (one hop, not transitive). Covered byJavaModulesExtendsFilterIT.extendsFilterListsOnlyDirectSubclasses(unfilteredListingContainsAllFourClassesTodayis the control test).
Integration & ops
-
9.
docker-compose.ymlcommitted + wired into the README (2026-07-12) — the rootdocker-compose.yml(Neo4j 5 + theac-code-servercontainer, built fromsrc/main/docker/Dockerfile.jvm) is tracked in git and driven by thedeploy.shwrapper (up/stop/logs/cli). The README's "Getting Started" now leads with the Compose/deploy.shfull-stack path and documents a dev-mode variant that runs only the Neo4j service from Compose (docker compose up -d neo4j) alongsidemvn quarkus:dev, replacing the previous manualdocker run neo4j:5command. -
5. MCP endpoint (HTTP/SSE) (2026-07-07) — implemented the previously empty
ac-code-server/.../mcppackage as a Quarkus MCP server (io.quarkiverse.mcp:quarkus-mcp-server-sse1.9.1, augments cleanly against Quarkus 3.36.2), exposing the API to agents over HTTP/SSE at/mcp/sse(server nameagenticcode). Two@ApplicationScopedtool beans mirror the REST surface by delegating to the sameGraphRepository/ProjectIngestService— no query logic duplicated.McpQueryTools(22 read tools, all@Blocking, returning the same concise JSON as REST viaMcpSupport):list_projects,list_modules,module_digest,module_context,module_functions,module_data_structures,module_dispatch_table,module_columns,callers,callees,call_tree,db_accesses,sql_statements,data_structure_fields,db_table_columns,search_identifier,search_value,variable_reads,variable_writes,flow_forward,flow_backward,field_flow.McpIngestTools:ingest_all,ingest_module,ingest_call_graph. Deliberately read-only + ingest — destructive project create/update/delete/clearAll are not exposed. Errors reuse the REST{error,code,details}shape as MCP tool errors (PROJECT_NOT_FOUND,ROOT_NOT_SET,INVALID_TYPE, …); the deep-query tools return the same deep-ingest hint (pointing atingest_module) when a module isn't deep-ingested. Extracted the project-root guard shared with REST intoProjectRootResolverso the validation can't drift between transports. NewMcpToolsITdrives the whole pipeline through tools only (connect →ingest_all→list_modules/list_projects- a
PROJECT_NOT_FOUNDerror case); all 58 REST ITs still green after the resolver refactor. Docs:mcp-api-usage-ac-implementation.mdgained an "Access via MCP" section.
- a
-
30. Version number in startup log / API / MCP (done 2026-07-08) —
agenticcode.versionis a manually-bumped release counter inapplication.properties(deliberately independent of the Maven project version, which stays a build/packaging concern). Single source of truth (VersionInfo), exposed three ways: an explicitINFOstartup log line (VersionLogger),GET /api/version, and the MCPversiontool — plus the MCP protocol's ownserver-info.versionhandshake field, which was previously hardcoded to1.0.0(already drifted from the real build) and now referencesagenticcode.versioninstead of duplicating it. -
36.
ac-cliparity with the REST/MCP API (done 2026-07-09) — the CLI was missing wrappers for 12 endpoints that already had MCP tools:modules,module-data-structures,dispatch-table,digest,function-overrides(bulk + single),search-value,search-annotation,inspect-node,node-source,module-source, andingest call-graph. Added all as newac-clicommands. Also added a hard rule (CLAUDE.md §0) requiring every new/changed REST endpoint to ship with both an MCP tool and anac-clicommand in the same change, so the three stay in sync going forward. -
37.
ac versioncommand (done 2026-07-09) — closes the last REST/MCP-vs-CLI gap:GET /api/versionalready had an MCPversiontool but no CLI counterpart.deploy.shnow stamps ac-cli's bundledagenticcode.properties(version=) fromagenticcode.version(the same release counterVersionInfouses) at build time (stamp_cli_version, called from bothbuild_allandbuild_cli_only), so a jar built outsidedeploy.shreportsdevinstead of a stale number.ac versionprints both the ac-cli and connected server's version and exits 1 with an error if they differ (skipped when the CLI is adevbuild). The interactive shell also prints the ac-cli version on startup and the same mismatch error (VersionCheck, shared with theversioncommand). -
38. Log every MCP tool call (name + arguments) (done 2026-07-09) — new
@McpLogged/McpLoggingInterceptorCDI interceptor pair (com.agenticcode.codeserver.mcp), applied at class level toMcpQueryToolsandMcpIngestTools. LogsINFO com.agenticcode.mcp: MCP tool call: {toolName}({arg=value, ...})for every@Toolinvocation, reading the tool name off@Tool.name()and real parameter names via reflection (relies on the existing-parametersjavac flag). Verified live viaMcpToolsIT. -
54. Source-text regex search (2026-07-14) —
GET /api/projects/{p}/search/source?regex=&limit=&ignoreCase=greps the source text of all modules from disk (deduped by file, bounded by a matchlimit→truncated), returning{module, sourceFile, lineNo, line}. Case-insensitive by default (legacy Natural).SourceSearchServicereuses the parsers' UTF-8/ISO-8859-1 decoding;400 INVALID_REGEXon a bad pattern. REST + MCP (search_source) + CLI (ac search-source <regex> [--limit --case-sensitive]) in sync; Web-UI explorer gains a names / source mode toggle with a results list (each hit opens its module at the line). Complementssearch_identifier(declared names). Covered bye2e/module-explorer.spec.ts. -
56.
search_identifiersigil-insensitive name match (2026-07-14) — the name search now ignores a single leading Natural sigil (#user,&AIV,+GDA) on both sides, soname=K-OUT-MAXfinds the declared#K-OUT-MAX(and#K-OUT-MAXstill works). Implemented in the shared query path (CypherQueries.SEARCH_IDENTIFIERstripsleft(n.name,1);GraphRepositorystrips the search term) so REST + MCP (search_identifier) + CLI (ac search-identifier) inherit it by construction; exact matches for sigil-less names (Java, tables) are unchanged. Covered byAnalysisResourceIT.searchIdentifierIsLeadingSigilInsensitive. (Prompted by the Web-UI popover, where Ctrl-click always captures the#but the global search box did not.) -
52. Function-level callers ("who PERFORMs this subroutine") (2026-07-15) — new
GET /modules/{name}/functions/{function}/callersreturns theFUNCTIONnodes thatCALLSthe named subroutine/method — the intra-modulePERFORMsites (Natural) or cross-class method callers (Java) — with call-sitelineNos, in the sameCallRefResponseshape as module-level/callers.CypherQueries.FUNCTION_CALLERS(reusestoCallRefRow/buildCallRefResponse) + MCPfunction_callers+ CLIac function-callers <module> <function>. Covered byAnalysisResourceIT.functionCallersListsPerformSites(DYNAIDX: INIT-TBL ← DISPATCH; a subroutine called only from the module main body has 0 function callers). UI wiring: the identifier popover's definition-click picker now attributes each PERFORM site to the calling subroutine viafunction_callers(useFunctionCallers), falling back to "module body" for top-level PERFORMs — the complete site list still comes fromcallees?scope=internalso body-level PERFORMs (e.g.INITIALIZATION) are not lost. Covered bye2e/identifier-popover.spec.ts(ADD-XML-LINE ← GEN-XML-LINE attribution). -
53.
search_identifierscoping (sourceFile/module filter) (2026-07-15) — added optionalsourceFile=<relpath>andmodule=<name>filters tosearch_identifierso a caller can ask "this name, in this module" directly (previously the paginated, project-wide search could push the module-local declaration off the page).module=resolves to the module's source file via anEXISTS { MATCH (:MODULE {name}) WHERE .sourceFile = n.sourceFile }subquery;sourceFile=filters exactly. REST (?sourceFile=&module=) + MCP (search_identifierargs) + CLI (ac search-identifier --module --source-file, plus a previously-missing--type) in sync. Covered byAnalysisResourceIT.searchIdentifierCanBeScopedToAModule.
Tests & tooling
- 8.
ac-clitest coverage (2026-06-17) — module had zero tests. AddedVariableAccessQueryTest(query-string builder) andCommandResolutionTest(resolveProject()precedence,apiClient()URL normalization, picocli option-parsing). 12 tests. - P1-p. Fix pre-existing
AnalysisResourceITfailures (2026-06-16) — six IT tests were red onmainbut unnoticed because*ITis excluded from the default surefire run. (1) Five tests double-encoded the#in the path; switched to RestAssuredpathParamso#is encoded exactly once. (2)transitiveDbAccessesAndSqlStatementsIncludeCalleeResultspassed inline Natural source as theclasspathResourcearg; added aningestInline(...)helper. - 11. Natural/Java parser construct coverage review (2026-06-15) —
checked
NaturalParseragainst the CLAUDE.md priority construct list and theWGEAGB0Sfixture set (26 files): all 7 priority constructs are covered. Found and fixed one gap:DECIDE FOR/DECIDE ON(see P1-f). Other constructs (ESCAPE,RESET,EXAMINE,COMPRESS,SEPARATE,WRITE) have no graph-relevant effects and are intentionally out of scope.
Web UI — code understanding & navigation
A React/TypeScript web UI (ac-ui/) that makes the AgenticCode graph navigable
for humans — primary use case: understanding legacy Software AG Natural in
order to migrate it to Java (who calls a module, what it calls incl. dynamic
CALLNAT, which fields flow where, which DB tables it reads/writes, what breaks
on change). Built on the existing REST API; query-driven (never load the whole
graph — upms is ~1000–6000+ modules), WebGL graph rendering, OpenAPI-first
contract. Full vision/architecture: x-docs/ui-proposal.md. Stack: React + TS +
Vite, React Router (URL-driven, shareable deep-links), TanStack Query over the
generated OpenAPI client, Sigma.js/graphology for the large call-graph, CodeMirror
6 with a custom Natural language mode + built-in Java. Out of scope: parser/ingest
semantic changes (the UI is a consumer) and write access to code (read-only; notes
are UI-side). Item 51; M6 (scale & polish) remains open — see roadmap.md.
Backend prerequisites (Milestone M0):
- 48. OpenAPI spec + generated TS client (backend 2026-07-13) — added
quarkus-smallrye-openapiand annotated all REST endpoints (@APIResponse/@Schema, since they return rawResponse) so the spec is fully typed; served at/q/openapi(+ Swagger UI in dev). TS client generation (openapi-typescript+openapi-fetch) lands with the frontend unit. - 49. Ego-graph subgraph endpoint (2026-07-13) —
GET /api/projects/{p}/modules/{name}/graph?depth=&direction=&limit=returns a bounded module-level neighbourhood (nodes + edges,truncatedflag,unresolvedstyling) via a BFS inGraphRepository.egoGraph. REST + MCP (ego_graph) +ac-cli(ac ego-graph) in sync. - 50. SPA serving + CORS (2026-07-13) — CORS enabled
(
quarkus.http.cors.enabled=true), restricted to the Vite dev origins.ingestStatus/ingestDepthnow joined into theGET /moduleslist rows (was only oninspect_node) for the UI's status badges.
Frontend milestones (item 51):
- M0 Foundation + API contract (2026-07-13) — items 48–50 (backend) +
React/Vite frontend (
ac-ui/): project picker, virtualized module explorer (filter + search), ingest-status badges, project/module refresh, thin module-detail fromcontext, generated OpenAPI TS client (openapi-typescriptopenapi-fetch).
- M1 Navigation (2026-07-13) — whole-file source endpoint (REST + MCP
module_source+ CLIac module-source, omit range = whole file); CodeMirror 6 source viewer with a custom Natural StreamLanguage mode + built-in Java, Ctrl/⌘-click name-based identify (popover viasearch_identifier), caller/callee panels, lazy call-tree (expand-on-demand viacallees, cycle-guarded), URL-driven tabs +linedeep-links,STALE_SOURCErefresh prompt. - M2 Call-graph visualisation (2026-07-14) — interactive ego-graph
(Sigma v3 / graphology, WebGL) as a lazy-loaded "Graph" tab: seeds on the current
module via the item-49
/graphendpoint, ForceAtlas2 auto-layout, click-to-select, expand-on-demand (one hop, merged), direction/depth/limit controls +truncatedbadge,unresolved/dispatch (CALLNAT_DYNAMIC)/inheritance edge styling + legend, open-module (disabled for unresolved). No backend change (pure consumer). - M3 Migration dossier (2026-07-14) — a lazy per-module Dossier tab
(
MigrationDossier.tsx) bundling the "porting profile": payload (I/O contract), data structures (expand-on-demand → fields), DB-access matrix (READ/WRITE badges), SQL statements, and the dynamic-CALLNATdispatch table. Line numbers deep-link into the Source tab (?tab=source&line=). Pure consumer of existing endpoints (payload,data-structures/data-structures/{name}/fields,db-accesses,sql-statements,dispatch-table). Follow-ups: (a) a program-view "ingest +callers/callees" button that deep-ingests the program's whole call-graph neighbourhood (the module + its transitive callers and callees, and via each module's USING/INCLUDE fan-out their data structures) —POST /refresh/{name}?scope=neighborhood(seeds the multi-module BFS ingest with the both-direction ego-graph closure;ProjectIngestService.ingestModules)- CLI
ac refresh <name> --neighborhood(no MCP tool —refreshis deliberately REST+CLI only); nginx/apiproxy timeout raised so the long request survives; (b) source-by-path so aUSINGdata area's field line-links open its own file, not the current module — newGET /api/projects/{p}/source?file=&startLine=&endLine=(reusesSourceSnippetService, rejects root-escaping paths with400 INVALID_SOURCE_FILE) + MCPfile_source+ CLIac file-source; the Source tab gains a?src=<file>mode with an "included file … back to module" banner.
- CLI
- M4 Data-flow & impact (2026-07-14, M4.1–M4.3) — flow-forward/backward /
field-flow visualisation + "what breaks?" impact analysis.
- M4.1a subroutine navigation — Ctrl/⌘-click a subroutine name resolves via
the scoped
module_functions(reliable despite dozens of same-named subroutines across modules — the global search is capped): clicking a reference (PERFORM) jumps to the definition; clicking the definition goes to its caller(s) — the PERFORM sites fromcallees?scope=internallineNos(one → jump, many → a picker). Covered bye2e/identifier-popover.spec.ts(Playwright; 5 cases incl. the upms 60-match reproduction). - M4.1 actionable variable matches — identifier-popover non-
MODULEmatches now expand: showdataType/scope, a jump-to-definition (openssourceFile:startLine, via the M3 source-by-path infra so a field defined in aUSINGPDA opens its own file), and lazy reads/writes lists (variable_reads/variable_writes,VariableAccessLocation) with each access site clickable to itssourceFile:lineNo.MODULEmatches still navigate. - M4.2 flow visualisation — a dedicated Data-flow tab (
DataFlowView.tsx) for a selected variable (URL?tab=flow&flowvar=, seeded on the current module): backward (flow_backward) and forward (flow_forward)DataflowStepchains (indented by depth; variable re-targets the trace, module opens it) and field-flow producer→consumer pairs (field_flow, line numbers deep-link into Source). Reached via a "data-flow →" action in the identifier popover. The flow endpoints auto-deep-ingest the scoped module; a409(warm couldn't complete) shows a DEEP_INGEST_REQUIRED prompt wired to the neighbourhood-ingest button. - M4.3 impact ("what breaks?") — a per-module Impact tab (
ImpactView.tsx): the transitive callers (blast radius if the module changes), via the ego-graphdirection=in(useImpactCallers, depth 10 / limit 500), grouped by call distance (direct callers vs. N-hops-away), each dependent clickable to open; unresolved dynamic-dispatch callers shown but not navigable;truncatedbadge when the node cap is hit.
- M4.1a subroutine navigation — Ctrl/⌘-click a subroutine name resolves via
the scoped
- M5 Understanding boosters (2026-07-14, the consumer-feasible parts).
- M5.1 source + structure side by side — a filterable, collapsible outline
(
SourceOutline.tsx,module_functions) beside the Source editor; clicking a function scrolls the editor to it (SourceView re-scrolls onlinechange without a remount — also fixes repeat dossier line-links). Hidden while viewing an included file. - M5.2 notes + saved views — per-module migration notes (
ModuleNotes.tsx, in Overview) and header saved views (SavedViews.tsx), both persisted tolocalStorageviauseLocalStore(the API is read-only, so annotations stay browser-side). - Deferred (need more than a consumer): diff after refresh (backend must expose
before/after snapshots) and LLM summaries (no LLM in the stack; the existing
module_context.descriptionis shown in Overview as the current summary).
- M5.1 source + structure side by side — a filterable, collapsible outline
(
- M6 — Explorer regex filter (2026-07-14) — the module-list search box accepts a
case-insensitive regex over name/sourceFile (falls back to substring while the pattern is
incomplete). Covered by
e2e/module-explorer.spec.ts. (Rest of M6 — virtualisation/large-graph performance, multi-project, auth, theming, export — remains open inroadmap.md.)
UI-sweep bug fixes (2026-07-14/15)
Found via a UI test sweep + source cross-check.
- 55. Dispatch table: multi-value DECIDE branch guardValue artifact (2026-07-14) — a
DECIDE ON … VALUE 'X', ' 'branch (a real literal + a Natural "also match blank" catch) yielded adispatch-tableguardValueof"X, "(the blank' 'trimmed to empty, leaving a trailing", ") instead ofX. Fix:NaturalParser.quotedLiteralsnow drops whitespace-only alternatives (keeping one blank only if a branch has nothing but blanks, so the guard isn't lost); genuine multi-literal branches ('A', 'B') stay comma-joined. Verified live onWGEAGB0S(3 rows @533/537/545 now clean, 0 trailing-comma artifacts across all 11 rows). Covered byNaturalParserTest.multiValueBranchDropsBlankAlternativeFromGuard. - Dossier data-structure fields shown out of order (2026-07-14) — the
data-structures/{name}/fieldsAPI returns fields in graph order ([12,26,40,55,56,41,…]); the UI now sorts bystartLineso a data area reads top-to-bottom (this out-of-order display was what made a correct line number look "wrong" earlier). - 57. Field format glued to the name (no space) parsed as one token (2026-07-15) — a
DEFINE DATAfield whose format is attached with no space — e.g.01 KEY(A1/1:3,1:V),01 #DELIMITER(A1)(common in NATURAL-CONSTRUCT generated code likeCDRANGE) — was stored with the whole token as the identifier name (KEY(A1/1:3,1:V)) anddataType=null, and thus mis-typed as aDATA_STRUCTUREinstead of aVARIABLE. 234 of 376CDRANGEidentifiers were affected, making them unsearchable (a search forKEYfound nothing) and dataType-less. Root cause: theLEVEL_FIELDname group(\S+)greedily swallowed the attached(…). Fix: name group now stops at(([^\s(]+) — Natural identifiers never contain one — in bothNaturalParser.LEVEL_FIELD(deep) andNaturalCoarseScanner.LEVEL_FIELD(Tier-1 identifier index), which carried the identical bug. Covered byNaturalParserTest.fieldFormatAttachedWithoutSpaceIsSplitFromName+NaturalCoarseScannerTest.fieldFormatAttachedWithoutSpaceIsIndexedByBareName. Verification note (superseded by #58): the old glued nodes once required a project re-create to clear; since #58 a plainrefreshreconciles and purges them. - 58. Stale nodes survive
refresh— diff-based reconciliation (2026-07-15) — nodes MERGE on(type, name, sourceFile, project), so a re-parse that renames/removes a field (e.g. after the #57 fix) produced a sibling node and the old one lingered —refreshMERGEd but never deleted. On the liveac/legacy graphs this left Tier-1 identifier-index nodes created at project-create time coexisting with the deep parse's nodes (CDRANGE: 460 = 234 stale-glued + 226 clean), inflating counts and returning ghost matches; the only remedy was a project re-create. Fix (roadmap option b): after a batch of fresh nodes is merged,GraphRepository.mergeResultscollects each real source file's fresh (canonical) node ids and runsCypherQueries.DELETE_STALE_FILE_NODES—MATCH (n {project, sourceFile}) WHERE NOT n.id IN freshIds DETACH DELETE n. BecauseMERGE_NODESoverwritesnode.idto the fresh UUID, a surviving-key node keeps a fresh id (kept) while a renamed/removed field keeps its old id (deleted); moved positional nodes (DB_ACCESS/CONTROL_FLOW) are swept the same way. Gated by areconcileflag threaded throughpersist/persistBatch: true for every full parse (whole-rootrefresh— bothdeep=trueand the default call-graph pass — and the per-module deeprefresh/{name}), false for the coarse Tier-1 scan (subset node set; runs only on an empty graph at create, so nothing to delete).sourceFile=""placeholders (shared cross-file targets) are never swept; reconciliation runs at persist-time, before enrichment, so enrichment-derived nodes/edges are unaffected and cross-module edges are rebuilt byfinalizeProject. Does not remove nodes for deleted files (still roadmap #43). Covered byRefreshReconciliationIT(rename a declared field on disk →refresh→ old identifier gone, new one present); verified live (acwhole-root refresh, 373 files, clean). No REST/MCP/CLI surface change —refreshalready exists on all three; the behaviour change is internal. - 59. Shared Natural field-declaration tokenizer (2026-07-15) — the
LEVEL_FIELDline pattern and itsNN [REDEFINE] name (typeSpec) restdecode were duplicated verbatim inNaturalParser(deep) andNaturalCoarseScanner(Tier-1), so #57 had to be fixed twice and the two tiers could drift. ExtractedNaturalFieldTokenizer(recordNaturalField{level, redefine, name, typeSpec, rest}+ staticparse(line)), the single owner of the pattern;typeSpecis the raw parenthesised content (what the deep parser stores asdataType, byte-identical to before), with derivedbaseFormat()/arrayDims()splitting it andconstValue()/initValue()pulling the trailing clause. Both tiers now call the tokenizer instead of holding private copies; node-type/emission policy is unchanged per tier (a pure refactor — the #57 regression tests in bothNaturalParserTestandNaturalCoarseScannerTeststay green). NewNaturalFieldTokenizerTest(12 cases) covers the edge matrix: format with/without a leading space,A1/1:3,1:Vdims,REDEFINE,CONST<>/INIT<>,#/##/&/+sigils, dimension-only group arrays, and group (no-format) fields. Kills this class of tokenizing bug. - 61. Comments parsed as CALLNAT targets (2026-07-16) — found dogfooding
WGEAGB0S(upms): the unanchoredCALLNAT/CALLNAT_DYNAMICpatterns matched inside comments, socallees/unresolvedwere polluted by phantom modules. Evidence: tree-wide falseunresolvedWAS/RESULTED/DOES(from prose like* What the callnat does:,/* …last callnat resulted in end-of-data);WGEAGB0S'sISINDATEcall-sites included commented lines 526 & 600 (* CALLNAT 'ISINDATE'); a phantomADLML02at copycode lines 18 & 22 (* CALLNAT 'ADLML02'). Root cause: the deepNaturalParsermain loop matched on the raw line (no full-line*skip, no inline/*strip), and the coarseNaturalCoarseScannerstripped inline/*but not a leading*. (Anchored patterns —^\s*PERFORM,^\s*DECIDE,^\s*INCLUDE— were already immune; only the unanchored CALLNAT ones leaked.) Fix: both parsers now skip a full-line comment (first non-blank char*, incl.**SAG) and strip inline/*before statement matching. Verified live:WAS/RESULTED/DOESgone (WGEAGB0S treeunresolved21→17),ISINDATEnow[597,626,1267],ADLML02now[26](real call only). Covered byNaturalParserTest.commentedAndInlineCommentCallnatsAreNotParsedAsCallsandNaturalCoarseScannerTest.commentedCallnatIsNotIndexedAsACall. - 62. Data literals recorded as false MODULE call targets (2026-07-16) — found in a corpus-wide
sweep of
upms(6311 files): 45 distinct data names (browse keys / codes such asCO-TABLA,COD-EMISOR,AGENT-SP,NAME-DESC-SP) sat in the graph as placeholderMODULEcallees, carrying 282 falseCALLSedges — pollutingcallees/call-tree/unresolvedacross the corpus. Root cause: aCALLNAT <bareword>(a browse key reaching the call site through a copycode/macro argument, e.g.INCLUDE YFRAMGC2 'C-MOD-GET' '"CO-TABLA"'expanding toCALLNAT &3& …) is parsed as a dynamic call and gets a placeholder target. It never resolves — no module of that name exists — andDELETE_DYNAMIC_CALLNAT_PLACEHOLDER_EDGESonly drops markers that did resolve, so the false target was kept forever. Fix: a new project-wide enrichment stepdelete-data-literal-call-placeholders(CypherQueries.DELETE_DATA_LITERAL_CALL_PLACEHOLDERS), running after the dynamic-call resolution and marker cleanup and beforestamp-unresolved-placeholdersso the unresolved flags see the reaped graph. It reaps a placeholder only when all four hold: (1) the name carries no Natural sigil (#/&/+) — a genuine dispatch variable is always a sigil'd user variable, soCALLNAT #WIF-style markers stay; (2) the name is a realVARIABLE/CONSTANTof the project (DATA_STRUCTUREis deliberately excluded — a module and its interface PDA routinely share a stem name); (3) no realMODULEof that name exists (else it would simply resolve); (4) every incomingCALLSedge is inferred (CALLNAT_DYNAMIC/INCLUDE_MACRO) — a staticCALLNAT 'X'is trustworthy, which protects the real external modulesRPC-CNTXandUSIX081Xthat collide with field names. Covered byDataLiteralCallCleanupIT(barewordCALLNAT CO-TABLAreaped; sigil'dCALLNAT #DISPmarker kept; staticCALLNAT 'REALMOD'resolved). Verified live on a cleanupmsre-create (2026-07-16):delete-data-literal-call-placeholders: 139 ms; rels +0/-274, nodes +0/-42, and the only survivors of the name-based suspect query areRPC-CNTXandUSIX081X— both reached exclusively by staticCALLNATedges, i.e. gate 4 protecting them exactly as designed. - 63. CALLNAT matched inside a string literal (2026-07-16) — the last of the unanchored-
CALLNATfamily (#61 = comments, #62 = data literals). TheCALLNAT/CALLNAT_DYNAMICpatterns match the keyword anywhere on a line, so prose inside a quoted string fabricated a call. Filed as "low, 1 occurrence"; that was wrong on both counts — a corpus sweep ofupmsfound 3 distinct call sites (each doubled acrosssrc/andgenerated_src/):PRINT '==> callnat before DREQUFN0'(DREQUDN0) →before;WRITE(#MSG) 'NACH CALLNAT ISINGEAG:'(JE0012N0) →ISINGEAG;#ERR-TYPE := 'Callnat USIA008N'(JA0016N0) →USIA008N. Two of the three are the dangerous class:ISINGEAGandUSIA008Nare real modules, so the phantom was not placeholder noise but a real → realCALLSedge in the call graph (PROCESS-AGENT-CHANGE→ISINGEAG,MAIN-PART→USIA008N) — and one invisible as a problem, since onlybeforecarriedunresolved: TRUEwhile the other two wereNULLprecisely because a real module of that name exists. Item 62's reaper structurally cannot clean these (its gate 3 requires that no real MODULE share the name), so only a parse-time fix removes them. (The hundreds of'Start of Callnat'-style lines are harmless: a quote directly follows the keyword, so no identifier matches.) Fix: new sharedNaturalLines(theNaturalFieldTokenizerprecedent from item 59) holdingisInsideStringLiteral/findOutsideStringLiteralplus the formerly duplicatedstripInlineComment; both tiers now gate the two CALLNAT matches on the keyword start lying outside a quoted literal — testing the start, not the whole match, is what keeps a realCALLNAT 'MOD'working (keyword outside, argument inside). Natural's'/"delimiters and doubled-delimiter escapes ('IT''S') are honoured. Considered and rejected: handling a/*inside a literal (whichstripInlineCommentwould truncate, leaving an unbalanced quote) — measured 0 such lines that also containCALLNAT, so it stays out rather than buying speculative complexity. Covered byNaturalParserTest.callnatInsideAStringLiteralIsNotParsedAsACallandNaturalCoarseScannerTest.callnatInsideAStringLiteralIsNotIndexedAsACall, both verified to fail against pre-fix behaviour. Verified live on a cleanupmsre-create (2026-07-16): thebeforeMODULE node is gone entirely, and the fabricatedCALLNAT_DYNAMICedges intoISINGEAG/USIA008N(2 each) are gone while their genuine staticCALLNATedges (10 and 2) remain — the real → real corruption is removed without touching a single real call. - 64. Ingest summary contradicted the graph; dispatch guards lost their alternatives (2026-07-16)
— found by dogfooding a deep ingest of
WGEAGB0S(upms) and hand-checking every endpoint against the Natural source. Two independent defects: (a)unresolvedreported data fields as missing modules. The deep ingest listedMODULE CO-TABLA,MODULE COD-ENTIDAD,MODULE NAME-DESC-SP,MODULE NAME-VALUE-SP— all(A8)fields, no such module anywhere — although the graph was already clean (the scoped enrichment reaped them:rels +0/-7, nodes +0/-5). Root cause:IngestSummary.unresolvedis built during the BFS walk from a filename index (ProjectIngestService:581) and never consults the graph, so it applied none of item 62's gates. This made the item-62 fix incomplete: it cleaned the graph surface and missed the summary — an asymmetry item 63 doesn't share, since a parse-time fix stops the ref existing at all. Fix: filter the unresolved refs through item 62's gates — no Natural sigil, reached only by inferred (CALLNAT_DYNAMIC/INCLUDE_MACRO) edges (reconstructed from the parse result'sCALLSedges, sinceDependencyRefcarries no provenance), and the name is a realVARIABLE/CONSTANT. That last gate is asked of the graph, project-wide (CypherQueries.DATA_FIELD_NAMES), not of the ingested tree: a first, tree-local attempt fixed only 2 of 4 becauseNAME-DESC-SPis declared inBMTABBN2.nat, which is outsideWGEAGB0S's 164-file tree. The static-CALLNAT gate keeps genuinely-missing modules whose names collide with field names reported (theRPC-CNTX/USIX081Xclass). Verified live: WGEAGB0S's unresolved list went 18 → 13, all four false positives gone, while theUSR*modules, sigil'd#GETSHORT-MODUL,NDBERRand theVDB2-*views all stayed. Covered byDataLiteralUnresolvedSummaryIT(incl. theEXTMODcase: a declared field that is also a statically called missing module must stay reported), verified to fail pre-fix. (b) dispatch guards dropped theirVALUEalternatives.guardValueis a single comma-joined string, soVALUE 'GENAGREE-WOUT-SP', ' '(a Natural "also catch blank") silently lost the blank —quotedLiteralsdrops whitespace-only alternatives to avoid a trailing", "— and a multi-alternative branch yields a synthetic"A1, A2"the guarded field never equals. Fix: the parser now also recordswhenValues, every alternative in source order including blanks, andDISPATCH_TABLEsplits it into a realguardValueslist.guardValueis untouched, so REST/MCP/CLI/UI keep working (all three surfaces return the record directly, so the field propagates automatically). Encoded with U+001F rather than modelled as a list becauseAstEdge.propertiesisMap<String, String>; US cannot occur in Natural source, unlike the comma that madeguardValueambiguous. Verified live: WGEAGB0S now reports 3 branches that also catch blank (0 before), and correctly distinguishes theDECIDEat 531 (blank alternatives) from the one at 602 (none). Covered byNaturalParserTest.multiValueDecideBranchKeepsEveryAlternativeIncludingBlank. Scale, measured honestly: 121 blank-bearingVALUEclauses corpus-wide, 3 real in WGEAGB0S; 0 joined multi-value guards observed in the deep-ingested subset (238 guarded WRITES), so the joined-string flaw is real in the code path but unobserved in practice — a full deep ingest would be needed to settle its true rate.
Qualified-field access & re-ingest reconcile (items 78, 79, 74) — 2026-07-18
Three defects found dogfooding a deep WGEAGB0S audit of upms, all in the Natural field
READS/WRITES path, fixed together. All three verified with Testcontainers ITs (independent of the
live server); full ac-code-server IT suite green (190/0/0) with no regressions.
-
78 — group-name-qualified access dropped. A field written qualified by its data area's inner group name rather than the
USINGname (MSG-INFO.##MSG-PGM := *PROGRAM, whereMSG-INFOis the top group of aUSING CDPDA-M) produced no edge:NaturalParser.lookupVariablerecognised onlyUSING-name qualifiers and fell through to a local-only lookup. Fix: when the qualifier is not a known include and the field is not local, mint a bare included-field placeholder (gatedallowIncludePlaceholder && hasIncludes && identifier-shaped) soresolveBareIncludedFieldTargetsredirects it to the real PDA field. TestQualifiedFieldResolveIT.groupQualifiedFieldAccessIsCaptured. -
79 — qualified read inside an expression dropped. The read side of an assignment/expression/
IFtokenised onIDENTIFIER_TOKEN(no dot), so#C := QCPDA.QC-DESCandIF MSG-INFO.##MSG-NR EQ …split into two non-resolving tokens, andIFconditions were not read-scanned at all. Fix: newOPERAND_TOKENkeeps a dotted operand whole;addExpressionReadsresolves a qualified token withallowIncludePlaceholder(legitimate explicit field access) but a bare token without (no placeholder for the "expression soup", so item 74's by-name path is not fed); the assign-RHS loop and theIFhandler both call it. TestQualifiedFieldResolveIT.qualifiedFieldReadInsideAnExpressionIsCaptured. (The originally-filed "qualifier does not disambiguate a shared name" was disproved by a controlled fixture — the qualifier resolves correctly; the live "unresolved" observation was item-74 stale state.) -
74 —
USINGfield misattributed to a same-named local group in another subprogram.WGEAGB0S'sBXFRABA4.#L-FORWARD-VIA-PRIwas linked not only to the realBXFRABA4.pdabut also into unrelatedJX0124N6/JX0129N0. (First mis-diagnosed as a stale-edge coexistence surviving re-ingest — a cleanrecreatedisproved that: the misattribution is created fresh in a single pass.) True cause: a NaturalUSING Xreferences a data area (PDA/LDA/GDA) — always a top-level member of a data-area file — but the placeholder resolver (buildResolvePlaceholderTargetQueries+ the two by-name field resolvers) matchedUSING Xto anyDATA_STRUCTUREnamedX, including a1 BXFRABA4group those subprograms declare inline, and linked every field under it. Fix: aDATA_STRUCTUREplaceholder now resolves only to a real area not owned by aMODULE(NOT EXISTS { (:MODULE)-[:CONTAINS]->(real) }) — a file-level data area, never a program-internal group. Data-area files produce noMODULEnode, so real PDAs are kept and inline.natgroups dropped. TestUsingResolvesToDataAreaNotLocalGroupIT.- Secondary safeguard (separate scenario, kept):
mergeResultsalso runsDELETE_STALE_RESOLVED_FIELD_EDGESin thereconcile(deep-re-ingest-only) branch — deletes a re-parsed file's prior cross-fileREADS/WRITESto aVARIABLE/CONSTANTso finalize rebuilds them, so a module that changes which area it includes does not keep the old resolved edge. Scoped toVARIABLE/CONSTANTand cross-file targets; gated onreconcile. Two-phase testQualifiedWriteReconcileIT.reIngestDeletesTheStaleResolvedFieldEdge.
- Secondary safeguard (separate scenario, kept):
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.
Version bump moved from deploy into the build — 2026-07-19
agenticcode.version (the integer build counter reported by /api/version and used by ac version for
stale-CLI detection) used to be incremented by manage-ac.sh deploy (shell bump_version), so a plain
mvn clean install never bumped and only a deploy did. It now lives in the Maven build: a new
ac-mvn-plugins mojo bump-version, bound to ac-code-server's generate-resources phase, increments
the counter and stamps the same number into ac-cli's agenticcode.properties. Binding to
generate-resources (before process-resources) means the freshly built jar already reports the bumped
number, keeping the jar's baked version and the CLI stamp in lock-step. The mojo only acts when a
requested goal is package/install/deploy (read from MavenSession.getGoals()), so mvn test,
mvn compile and quarkus:dev do not bump; deploy bumps because it runs a full install. Every such
build bumps unconditionally (no source-change gating — high numbers are harmless). manage-ac.sh's
bump_version was removed; stamp_cli_version is kept only for the CLI-only rebuild path (cli), which
syncs the CLI to the current server version without bumping. Logic unit-tested (BumpVersionMojoTest,
7/0/0); live-verified: mvn generate-resources left the counter unchanged, mvn package bumped 77→78 and
stamped ac-cli 78, and target/classes/application.properties carried 78 (timing correct).