Flow views: Backend Flow, Frontend Flow, Full Flow How the three graph views are built, end to end, and where to change things.
This document exists to make you self-sufficient in this part of the codebase. It follows one URL from a source file to a drawn circle, names every method it passes through with its input and its output, and ends with a decision table for adding a framework the pipeline has never seen.
Asking "the UI is an SPA, so what is a page?" — that, and how a component's API call is attributed to the screen that renders it, is answered in detail in frontend-mapping.md.
New here? end-to-end-walkthrough.md follows one project through the whole sequence in order; this document goes deeper on one part of it.
Read architecture.md first if you want the shape of the whole service. Ignore ingestion-pipeline.md for anything on this page — it documents the retired regex/LLM path (SourceIngestionService.ingest), which is commented out at ProjectSourceService.java:124. The live path is ingestWithTreeSitter, described in §3.
Conventions, same as the rest of docs/: every class, method and constant named here exists in the source and is greppable. Code blocks are copied from the file they cite. Numbers and node values are real output from project 270 unless a fragment is explicitly labelled illustrative.
The three views are three questions about one graph:
View Endpoint The question it answers Backend Flow GET /api/v1/projects/{id}/backend/flows What does the server do? One tree per controller, down to the table. Frontend Flow GET /api/v1/projects/{id}/navigation Where can a user go? Page-to-page navigation only. Full Flow GET /api/v1/projects/{id}/user-journeys What happens when a user clicks? A page followed all the way to a table, across repos. The analogy: the pipeline is a postal sorting office. Extraction reads every envelope and writes down the address (endpoints) or the destination someone wrote on it (api_calls). Linking is the sorter that matches the two piles. The views are three different ways of laying the sorted mail out on a table — by sender, by recipient, or as a complete delivery route.
The important consequence, and the reason the system is framework-agnostic: the sorter never reads the language on the envelope. By the time linking happens, a Spring @GetMapping, a FastAPI @router.delete, a Django path() and an Express app.get() have all become the same thing — a string in the endpoints slot. Every framework-specific decision was made earlier, in one config table.
The pipeline in one diagram
View — GET, on every page load
Link — POST /api/v1/ingest/project/<id> (once per project)
Ingest — POST /api/v1/project-source (once per repo)
S3, key source_nodes.yaml
Project.linkedYmlPath
ProjectSourceService.cloneRepository temp-clones/<repo>-<rand>
FileDiscoveryService.discover
extractorMap.get(language) → extractor.extract(ctx)
YamlGraphWriter.write output/codekg/graph_<repo>.yaml
mergeAndWriteYaml all repos → source_nodes.yaml
structuralLinks resolveRouteRefs → linkBackendUi → linkPageNavigation → …
emitGraph knowledge_base.yaml → S3
BackendFlowService / NavigationService / UserJourneyService .build(nodes, depth)
FlowTree.jsx + layoutFlowTree
The two HTTP calls are separate, and this trips everyone up once. Ingesting a repo does not link it. IngestionController.java:48 is the only trigger for LinkageEngineService.initiateProjectIngestion, and every read API serves whatever that pass last produced. Ingest sets is_outdated_graph = true (ProjectSourceService.java:142) precisely to signal "linking is now stale".
3.1 The three steps that pick an extractor folder ──► FileDiscoveryService.discover ──► LanguageDetector.detect ──► extractorMap.get(lang) FileDiscoveryService.discover(String rootFolder, String languageFilter) → List<DiscoveredFile> where DiscoveredFile is {Path path; Language language;}.
// FileDiscoveryService.java:56-67 try (Stream<Path> walk = Files.walk(root)) { walk.filter(Files::isRegularFile) .filter(p -> !isIgnored(p, root, ignoreMatchers)) .forEach(p -> { Language lang = languageDetector.detect(p); if (lang == null) { countIfSourceLike(p, unparsed); return; } if (!matchesFilter(lang, languageFilter)) return; results.add(new DiscoveredFile(p, lang)); }); The language filter is a family, not an exact match (LANGUAGE_FAMILIES, :83-90): declaring a repo as react admits REACT, TYPESCRIPT and JAVASCRIPT, because a React app's API client lives in .ts and excluding it would lose the half of the journey that holds the URLs.
LanguageDetector.detect(Path) → Language or null. Pure extension lookup:
// LanguageDetector.java:258-263 public Language detect(Path file) { String name = file.getFileName().toString(); int dot = name.lastIndexOf('.'); if (dot < 0) return null; return EXT_MAP.get(name.substring(dot).toLowerCase()); } extractorMap is built once at startup, from every Spring bean implementing the interface:
// SourceIngestionService.java:98-102 @PostConstruct void initExtractorMap() { extractorMap = languageExtractors.stream() .collect(Collectors.toMap(LanguageAstExtractor::supports, e -> e)); } Collectors.toMap has no merge function, so two beans claiming the same Language fail at startup. One extractor per language, by construction. Six extractors hard-code supports(); MarkupAstExtractor and DeclarativeAstExtractor return an instance field and are registered once per language in SharedExtractorConfig.java.
3.2 What fires, per framework This table is the genericity story in one place. Every row ends in the same two slots — endpoints for a server, api_calls + screen_route for a screen — which is what lets §4 be framework-blind.
Stack File Extractor Detection mechanism Slot produced Spring MVC OrderController.java JavaAstExtractor annotation table, RouteRegistry.DEFAULT_JAVA_ANNOTATIONS endpoints JAX-RS / Quarkus OrderResource.java JavaAstExtractor same table — Path=ANY + GET=GET combined endpoints Micronaut OrderController.java JavaAstExtractor same table — Controller=ANY + Get=GET endpoints Servlet ReportServlet.java JavaAstExtractor WebServlet=ANY, via typeEndpoints fallback endpoints Kotlin + Spring OrderController.kt DeclarativeAstExtractor Kotlin annotation reader → the Java table endpoints FastAPI router.py PythonAstExtractor decorator table + APIRouter(prefix=) endpoints Flask views.py PythonAstExtractor decorator table + methods=[…] + Blueprint(url_prefix=) endpoints Django urls.py PythonAstExtractor call-routes table (path, re_path) endpoints on a MODULE node Express / Fastify orders.routes.js JavaScriptAstExtractor call-routes + the two-argument guard endpoints on a MODULE node ASP.NET OrdersController.cs CSharpAstExtractor attribute table, [controller] substitution endpoints .NET minimal API Program.cs CSharpAstExtractor call-routes (MapGet…) endpoints on a file node Go / Ruby / PHP main.go, routes.rb, web.php DeclarativeAstExtractor per-language call-routes endpoints on a MODULE node Next.js app/…/page.tsx ReactAstExtractor screen-patterns file routing screen_route, api_calls React Router App.tsx ReactAstExtractor route_decls, resolved at link time screen_route (stamped) Vue / Svelte .vue, .svelte MarkupAstExtractor markup attributes + <script> re-parsed as TypeScript nav_targets, api_calls 3.3 Worked: FastAPI (real, project 270) Source shape in app/api/v1/tenants/router.py:
router = APIRouter(prefix="/tenants", tags=["tenants"])
@router.delete("/{tenant_id}") def delete_tenant(tenant_id: UUID, ...): ... Method In Out PythonAstExtractor.routerPrefix(root, src) the file's tree "/tenants" — read from the argument nodes of APIRouter(...), so a nested Depends(...) before prefix= does not hide it decoratorCall(decorator) the decorator node the inner call node for router.delete("/{tenant_id}") calleeName(call, src) that call "delete" pathArgument(call, src) that call "/{tenant_id}" — null for an f-string, which is skipped rather than guessed at RouteRegistry.decoratorRoutes(PYTHON, "delete", "/{tenant_id}", [], "/tenants") above [Endpoint("DELETE", "/tenants/{tenant_id}")] Endpoint.render() that record "DELETE /tenants/{tenant_id}" Real resulting node, as the view later reports it:
{ "name": "delete_tenant", "type": "CONTROLLER", "language": "PYTHON", "repo": "api", "path": "app/api/v1/tenants/router.py", "endpoint": "DELETE /tenants/{tenant_id}" } Note what is not in that string: the /api/v1 under which the router is mounted in another file. Hold that thought — it is the whole of §4.3.
3.4 Worked: Spring (real, this repo) // BackendFlowController.java @RestController @RequestMapping("/api/v1/projects") public class BackendFlowController { @GetMapping("/{projectId}/backend/flows") public ResponseEntity<BackendFlowGraph> getBackendFlows(...) { ... } } Method In Out JavaAstExtractor.readAnnotations(decl, src) the class node [AnnotationUse("RestController", [], {}, {}), AnnotationUse("RequestMapping", ["/api/v1/projects"], {}, {})] RouteRegistry.tier(JAVA, those) above "ctrl" → entity_type readAnnotations on the method the method node [AnnotationUse("GetMapping", ["/{projectId}/backend/flows"], {}, {})] RouteRegistry.endpoints(JAVA, classAnns, methodAnns, "BackendFlowController") both [Endpoint("GET", "/api/v1/projects/{projectId}/backend/flows")] Annotations are read structurally — the annotation's name node and its string-literal argument nodes — not by matching text. That is what keeps produces = "application/json" out of the route table, and it is verified against this repo's own 31 endpoints by BackendEndpointProbeTest.
3.5 Worked: Next.js (real, project 270) web/src/app/(dashboard)/admin/tenants/page.tsx produces no annotation and no decorator. It is a screen because of where it is:
Method In Out LayerModel.screenRoute(path) web/src/app/(dashboard)/admin/tenants/page.tsx "/admin/tenants" The pattern that matched is the first entry of LayerModel.DEFAULT_SCREEN_PATTERNS, and the segment filter does the rest: (dashboard) is a route group and drops out, page is the file and is not part of the capture. An _-prefixed segment makes the whole file not a screen (return null), which is why pages/_app.tsx is not a second home page.
Meanwhile the component's fetch/axios calls become api_calls via AbstractAstExtractor.extractApiUrl → normalizeUrl, which collapses template substitutions:
// AbstractAstExtractor.java — normalizeUrl, illustrative of the transform
${API_BASE}/tenants/${id} ──► "/tenants/{}"
Real result, from the live graph:
{ "name": "AdminTenantsPage", "type": "PAGE", "language": "REACT", "repo": "frontend", "path": "web/src/app/(dashboard)/admin/tenants/page.tsx", "endpoint": "/admin/tenants" } 3.6 The rest of ingestion, briefly SourceIngestionService.parseFile (:525) is run 10-wide on a fixed pool. Then:
Method Adds SymbolTableBuilder.build + ReferenceResolver.resolve (:423-424) cross-file CALLS, EXTENDS, IMPLEMENTS, REFERENCES_TYPE edges ModuleImportResolver.resolve (:435) file-to-file IMPORTS edges — the chain a journey follows from a page to the module holding the URL dedupeById (:501) first-wins de-duplication the childrenByParent loop (:453-462) back-fills code_slots GraphEnricher.enrich (:473) rolls each method's calls up to its class as class_calls_entities YamlGraphWriter.write (:32) output/codekg/graph_<repo>.yaml A naming trap. On the tree-sitter path the file on disk is graph_<repo>.yaml. The name source_nodes.yaml is only the S3 key (ProjectSourceService.java:132). Grepping for source_nodes.yaml leads you to writeOutputs, which belongs to the retired path.
4.1 The order of the linking phases // LinkageEngineService.java:509-524 int count = 0; // Must precede linkBackendUi: it turns a route-table reference into the api_call that // linkBackendUi then matches. resolveRouteRefs(codeNodes); count += linkBackendUi(codeNodes, nodes); count += linkCodeIcepanel(codeNodes, icepanelNodes); ... count += linkPageNavigation(codeNodes, nameIndex); count += linkMethodCalls(codeNodes, nameIndex); return count; The ordering is load-bearing twice over: resolveRouteRefs must run first so that a page importing ROUTES.ORDERS has a real URL by the time matching happens, and linkPageNavigation must run after linkComponentUsage, whose renders_entities the navigation view folds on.
Phase Reads Writes
resolveRouteRefs :543 route_table, route_refs appends to api_calls on the referencing node
linkBackendUi :819 endpoints, api_calls calls_backend on the frontend, serves_ui on the backend
linkInheritance :919 extends_class, implements_interfaces extends_entities, implements_entities
linkComponentUsage :942 components_used renders_entities
linkPageNavigation :614 nav_targets, route_decls, route_decl_refs screen_route stamps, flows_to_entities, dangling_nav_targets
linkMethodCalls :976 field_injections (Java) calls_entities
4.2 linkBackendUi, step by step
// LinkageEngineService.java:818-847
private int linkBackendUi(List
int count = 0;
for (Map.Entry<String, Map<String, Object>> e : codeNodes) {
List<?> apiCalls = (List<?>) e.getValue().get("api_calls");
if (apiCalls == null) continue;
for (Object call : apiCalls) {
String path = normPath(String.valueOf(call));
String backendId = endpointIndex.get(path);
if (backendId == null) {
// Longest declared route wins, so /orders/{id}/items beats /orders.
backendId = endpointIndex.entrySet().stream()
.filter(en -> routeMatches(en.getKey(), path))
.max(Comparator.comparingInt(en -> en.getKey().length()))
.map(Map.Entry::getValue)
.orElse(null);
}
if (backendId == null || backendId.equals(e.getKey())) continue;
addStructuralEdge(e.getValue(), "calls_backend", backendId);
addStructuralEdge(allNodes.get(backendId), "serves_ui", e.getKey());
count++;
}
}
return count;
} The index. Key is the endpoint with its verb removed and its path normalised; value is the node that declared it. Any node with a non-null endpoints contributes — that is the whole of the language-independence.
// LinkageEngineService.java:791 endpointIndex.put(normPath(stripVerb(String.valueOf(ep))), e.getKey());
// :1056-1063 private String stripVerb(String endpoint) { // "POST /api/orders" -> "/api/orders" return endpoint == null ? "" : endpoint.replaceFirst("^[A-Z]+\s+", ""); } private String normPath(String path) { return path.replaceAll("[?#].*$", "").toLowerCase().replaceAll("/$", ""); } normPath is applied to both sides, and does exactly three things: drops the query string and fragment, lowercases, removes one trailing slash. It does not strip a scheme or host, and does not touch path parameters. Removing the origin is left entirely to tail matching.
The match is two attempts: an exact hash lookup, then a suffix match where the longest declared key wins. It is never a prefix match.
The write. One match produces two reciprocal list entries on two different nodes. There is no edge object — calls_backend on the frontend and serves_ui on the backend are the edge.
The early return matters. If no node anywhere declared an endpoint, the method returns 0 and a frontend-only project gets pages that call nothing, however many api_calls were extracted. There is nothing to link to, so this is correct — but it is the first thing to check when a graph has no cross-repo arrows.
4.3 matchesTail — why this is generic Here is the real project-270 pair. The frontend records:
via_url = "/api/v1/tenants/{}/mcp-connections/{}/test" The backend declares:
endpoint = "POST /tenants/{tenant_id}/mcp-connections/{connection_id}/test" These are two different strings. Neither is a prefix of the other. They are in different repos (frontend and api), written in different languages (REACT and PYTHON), and the frontend's copy carries an /api/v1 prefix that exists only because a third file mounts the router. No amount of string equality will join them.
// RoutePaths.java:69-84 public static boolean matchesTail(String declared, String called) { List<String> a = segments(declared); List<String> b = segments(called); if (a.isEmpty() || b.isEmpty()) return false;
int overlap = Math.min(a.size(), b.size());
boolean sawLiteral = false;
for (int i = 1; i <= overlap; i++) {
String d = a.get(a.size() - i);
String c = b.get(b.size() - i);
if (isParamSegment(d) || isParamSegment(c)) continue;
if (!d.equalsIgnoreCase(c)) return false;
sawLiteral = true;
}
return sawLiteral;
} Worked, from the right:
i declared segment called segment verdict 1 test test literal match → sawLiteral = true 2 {connection_id} {} both parameters → skipped 3 mcp-connections mcp-connections literal match 4 {tenant_id} {} both parameters → skipped 5 tenants tenants literal match — (exhausted) v1, api outside the overlap window; never compared Result true. The /api/v1 prefix is harmless because the comparison never reaches it.
A parameter is a wildcard, not a value. isParamSegment (RoutePaths.java:18-28) accepts : (React Router, Vue), { (Spring, FastAPI — and {}, the normaliser's output), [ (Next.js), and bare *. When either side is a parameter the pair is skipped entirely and does not set sawLiteral. So {tenant_id} against the literal 5 is neither a match nor a mismatch. The sawLiteral requirement is what stops a route of nothing but parameters matching every URL in the project.
Why symmetric. Which side is abbreviated depends on the stack, and nothing visible at this point tells you which:
a router mounted under a prefix declares only its own suffix → the call is longer (the case above); an axios instance with baseURL: "https://host/api/v1" sends only the suffix → the declaration is longer. An asymmetric version — "the declaration must be the shorter" — loses every call made through a configured client, which in a frontend that centralises its HTTP client is all of them.
Page navigation deliberately does not use this. RoutePaths.matchesExactly (:86-115) requires equal segment counts, because a page route is absolute: tail matching would claim /projects reaches /admin/projects, and / (zero segments) would match every page in the app.
4.4 So where is the framework knowledge? Nowhere in §4. linkBackendUi reads two slots and compares strings. It cannot tell Spring from FastAPI and never asks.
That is the design claim of this whole subsystem, and it is worth stating as a rule you can rely on when extending it:
Framework knowledge lives in exactly one place — a config table consulted during extraction. Everything downstream is framework-blind.
// UserJourneyService.java:217-219
tempFile = Files.createTempFile("journey-" + projectId + "-", ".yaml");
s3StorageService.downloadFile(linkedYmlPath, tempFile);
UserJourneyGraph graph = build(loadNodes(tempFile), clampDepth(depth));
Every stage below works on untyped Map
Backend Flow Frontend Flow Full Flow
Controller BackendFlowController NavigationController UserJourneyController
Default depth 6 4 6
repo filter yes yes no
Response BackendFlowGraph NavigationGraph UserJourneyGraph
5.1 Backend Flow — BackendFlowService.build
Index, drop tests only (:251-256). Language filtering is deliberately deferred.
collapseToModules (:264) → allModules.
LayerModel.serverSide(allModules, REACHABILITY_SLOTS) (:265) — walks out from every endpoint-declaring module over calls and imports. Must run after the collapse: collapsing re-keys every node, so a set of raw node ids would match nothing.
Drop browser code (:267-274) — a frontend-language module survives only if reachable from a route. This is what lets an Express backend be a backend.
layerOf (:283-286) — layers.classify(module, serverIds) per module, cached because a type-reference hop can promote its target.
callerCount (:291) — fan-in per module.
Hop table (:296-303) — callHops (rel CALLS) then typeRefHops (rel REFERENCES), deduped with calls winning.
findControllers (:305) — modules where layers.hasEndpoints. If none, fallbackRoots (:308) by layer name, and the response carries a warning.
expand per controller (:315-320), sharing one budget.
markReachesDb (:325) — post-order; sets reaches_db on every node.
groupUnderApps (:340) — rel EXPOSES.
Flat projection + warnIncomplete (:344-379).
5.2 Frontend Flow — NavigationService.build
Index; drop tests, backend and proxy handlers (:198-209) — the only view that discards backend nodes up front.
collapseToModules (:211).
Which modules are pages (:215-222) — layers.screenRouteOf(module): the linker's stamp wins over the path-derived route.
foldSharedComponents (:225) — a page inherits the links of the components it renders.
foldAppChrome (:228) — a navbar links to pages but is rendered by none, so its links are credited to every page.
Page→page adjacency (:236-248) from flows_to_entities; self-links dropped.
Dangling targets (:251-258) — a link to a page that does not exist is kept, not dropped.
orderPages + pageLabels (:260-261) — component name when unique, else the route.
expand per page (:269-273), with one slot reserved per page (:268) so a dense graph cannot report 26 pages and draw 21.
groupUnderApps (:277).
Flat projection (:280-321), including MISSING pseudo-nodes so the legend has a key for what the canvas draws.
5.3 Full Flow — UserJourneyService.build
Index, drop tests (:242-248).
collapseToModules (:256) — also fills ownerOf, raw id → module key.
markServerModules (:259) — stamps journey_layer on frontend-language modules that are reachable from a route, so an Express backend is not a PAGE.
handlersByModule (:265-271) — so a hop can expand one endpoint rather than a 57-handler router file.
foldFrontendChain (:280-281) — merges a screen's imported plumbing's api_calls and calls_backend into the screen. Iterated over a sorted key list, so the answer is deterministic.
backendCallerCount (:291).
Pass 1 — the cross-repo hop (:294-348). matchRoute picks which URL matched; endpointView synthesises an endpoint::<route> node; handlerView synthesises <moduleId>#<endpoint>. Emits INVOKES (screen → endpoint) and ROUTES_TO (endpoint → handler).
Pass 2 — in-repo hops (:354-365). nextLayerTargets walks through INTERNAL infrastructure but only emits a target whose rank is strictly greater — that is what forbids controller→controller and repository→service. Emits CALLS.
findEntryPoints (:367) — frontend, non-proxy, reaches the backend. keepScreens then narrows to real screens, unless nothing is a screen (a code-routed stack), in which case nothing is narrowed.
expand per root (:373-379).
groupUnderApps (:385) — rel RENDERS, app::<repo> wrappers.
journeyCount = number of INVOKES hops (:390-391) — deliberately not leaf paths.
Flat projection (:398-421) — only ids the trees actually placed.
5.4 collapseToModules — why the views work per file
// UserJourneyService.java:489-490
String key = layers.moduleKey(e.getValue());
moduleOf.put(e.getKey(), key);
// LayerModel.java:362-370
public String moduleKey(Map
Merge rules, which differ per slot and are easy to get wrong:
Slot Rule endpoints, api_calls order-preserving set union (mergeInto) screen_route first non-null wins, never overwritten entity_type highest layers.specificity — not first, not last name path-derived, then overwritten by principalSymbol if the file has an obvious subject repo, file_path, language from whichever node created the entry — safe only because they are file properties the edge slots in EDGE_SLOTS_TO_REMAP re-pointed module→module, intra-file edges dropped That last row is the point of the whole exercise, stated in the source:
"Collapse every node into the file that declares it. This is what makes the result an architecture view rather than a call graph: a private helper is absorbed into its module instead of appearing as a step, and it needs no per-language notion of 'private' — every language has files." — UserJourneyService.java:250-253
private, _leading_underscore, internal and package-private are four different concepts in four languages. "Same file" is universal.
All three views have their own variant. Backend Flow computes its remap list at runtime (:628-630) so the type-reference slots are included — omit them and the hops that depend on them vanish silently. Navigation merges the dangling-target slot and runs a page-name contest. Journey is the only one that exports ownerOf.
5.5 expand — depth, budget, and cycles // UserJourneyService.java:1075-1104 (abridged) while (!queue.isEmpty() && budget[0] > 0) { Frame frame = queue.poll(); if (frame.depth() >= maxDepth) continue; ... boolean seen = !placed.add(hop.targetId()); String key = seen ? MERGE_REF_PREFIX + budget[0] + ":" + hop.targetId() : hop.targetId(); JourneyNode child = toTreeNode(key, childData, hop.relType(), hop.viaUrl(), seen); budget[0]--; children.add(child); if (!seen) queue.add(new Frame(hop.targetId(), child, frame.depth() + 1)); } Depth counts hops from the root and is checked when a frame is dequeued. depth=6 gives at most 7 levels — plus one more, because groupUnderApps adds the app wrapper after expansion. int[] budget is a one-element array used as a mutable box, so MAX_TREE_NODES = 5_000 is a global allowance shared across every root. A project with 200 controllers and one very wide first controller renders the later ones shallow. Navigation works around this by pre-reserving a slot per page. Merge refs are what make a cyclic call graph render as a finite tree. A node already placed in this tree still gets a child object — it is not dropped — but its id becomes mergeref:<budget>:<realId>, it is flagged is_merge_ref: true, and it is not enqueued, so the subtree below it is never duplicated. The budget number is only a uniquifier. placed is per-tree, because the same module legitimately appears under two different roots. 5.6 The JSON the UI receives Field names are Jackson defaults except where @JsonProperty overrides them — note the snake_case ones, a common source of "why is this undefined".
Journey Backend Navigation Tree node id, name, type, rel, endpoint, via_url, language, repo, path, is_merge_ref, children same, plus endpoints, reaches_db, no via_url route instead of endpoint, plus is_entry_point Flat entity node_id, is_entry_point same plus outgoing_count, incoming_count Envelope roots, nodes, edges, node_count, edge_count, journey_count, frontend_to_backend_count controller_count, endpoint_count, db_reached_count, dangling_controller_count page_count, navigation_count, entry_page_count, unreachable_page_count, dangling_target_count 5.7 What the UI does with it These files are in a different repository. The frontend lives beside this one, not inside it — ../lineage/src/.... Every identifier in this section (FlowTree, layoutFlowTree, useUserJourneys, the VIEW_* constants) will not be found by grepping ucig-source.
KnowledgeGraphPage.jsx (../lineage/src/pages/) holds four toggles. The constant values are crossed over relative to their names — read this table, not the identifiers:
Button label viewMode constant Hook Endpoint Full Graph VIEW_FULL = "full" useKnowledgeGraph the knowledge-graph endpoint (NVL canvas, not FlowTree) Backend Flow VIEW_BACKEND = "backend" useBackendFlows …/backend/flows?depth=6 Full Flow VIEW_FULL_FLOW = "frontend" useUserJourneys …/user-journeys?depth=6 Frontend Flow VIEW_FRONTEND = "navigation" useNavigation …/navigation?depth=4 Each hook is gated on the active view, because every flow endpoint downloads the knowledge base from S3 and fetching all three up front would triple the cost of opening the page.
The pieces, all under ../lineage/src/: the hooks in hooks/useBackendFlows.js, hooks/useNavigation.js and hooks/useUserJourneys.js; the page in pages/KnowledgeGraphPage.jsx; the renderer in components/flows/FlowTree.jsx with its geometry in components/flows/flowGraphData.js and its colour vocabulary in components/flows/flowTaxonomy.js.
One shared FlowView component renders all three — the tree shape is identical, so only the strings differ. It consumes roots for drawing and nodes/edges only for the legend counts. layoutFlowTree (flowGraphData.js:139) keys positions by path, not node id, because the same module legitimately appears under two controllers and keying by id would tie their rows together. flowTaxonomy.js maps type → colour with a grey fallback, so a new backend layer degrades rather than breaks.
APP frontend └─ PAGE AdminTenantsPage /admin/tenants └─ ENDPOINT DELETE /tenants/{tenant_id} via_url /api/v1/tenants/{} └─ CONTROLLER delete_tenant rel ROUTES_TO ├─ SERVICE tenant_access_service │ └─ REPOSITORY user_tenant_access_repository │ └─ DB UserTenantAccess └─ REPOSITORY tenant_repository └─ DB Tenant
1 FileDiscoveryService.discover {path: web/src/app/(dashboard)/admin/tenants/page.tsx, language: REACT} 2 ReactAstExtractor.extract → LayerModel.screenRoute screen_route = "/admin/tenants" 3 AbstractAstExtractor.normalizeUrl api_calls = ["/api/v1/tenants/{}"] 4 PythonAstExtractor + Endpoint.render endpoints = ["DELETE /tenants/{tenant_id}"] 5 YamlGraphWriter.write ×2 graph_frontend.yaml, graph_api.yaml 6 LinkageEngineService.mergeAndWriteYaml one source_nodes.yaml holding both repos 7 declaredEndpointIndex "/tenants/{tenant_id}" → <api node id> 8 linkBackendUi → normPath "/api/v1/tenants/{}" — exact lookup misses 9 RoutePaths.matchesTail tenants≡tenants, {tenant_id}/{} skipped → true 10 addStructuralEdge ×2 calls_backend on the page, serves_ui on the router 11 emitGraph knowledge_base.yaml → S3 → Project.linkedYmlPath 12 UserJourneyService.collapseToModules id becomes frontend::web/src/app/(dashboard)/admin/tenants/page.tsx 13 principalSymbol name becomes AdminTenantsPage, not page 14 pass 1 · endpointView synthetic node endpoint::DELETE /tenants/{tenant_id} 15 pass 1 · handlerView synthetic node api::app/api/v1/tenants/router.py#DELETE /tenants/{tenant_id} 16 pass 2 · nextLayerTargets CONTROLLER → SERVICE → REPOSITORY → DB, each rank strictly greater 17 expand + groupUnderApps the tree above, root rel RENDERS 18 FlowTree · layoutFlowTree {key, depth, x, y, data} per visible node 19 colorForFlowNode(data.type) the drawn <circle> Step 13 is worth pausing on. Without principalSymbol, every Next.js App Router screen in the project would be called page, because that is the filename of all of them.
Project 270's totals, for calibration: 26 pages / 335 links (Frontend Flow), 17 controllers / 117 endpoints / 12 reaching a database (Backend Flow), 44 journeys / 60 frontend-to-backend hops, 114 leaf paths of which 56 reach a table (Full Flow).
The framework… You edit Java declares routes on annotations / attributes / decorators codekg.endpoints.<lang>.annotations (or .decorators) none registers routes by a function call codekg.endpoints.<lang>.call-routes none routes by file path codekg.journey.screen-patterns none routes in code (JSX, object literals) codekg.parser.route-path-attributes and friends none is a new language pom + Language + ParserFactory + EXT_MAP + one bean ~10 lines 7.1 Annotation-declared — one line The table is Name=VERB, where ANY means "contributes a path but commits to no method". That one shape covers every annotation-routed framework, because they differ only in how they split path from verb:
codekg: endpoints: java: annotations: "RequestMapping=ANY,GetMapping=GET,Path=ANY,GET=GET,Controller=ANY,Get=GET,..." tier-annotations: "RestController=ctrl,Service=service,Repository=data,Entity=data,..." Spring puts both in one name (@GetMapping("/orders")). JAX-RS splits them (@Path("/orders") plus a bare @GET) and RouteRegistry.endpoints combines them. Micronaut and ASP.NET follow Spring's shape with different spellings. RouteRegistryTest proves the claim with invented annotation names, so nothing there can pass by accident on a shipped default.
7.2 Call-registered — one line codekg: endpoints: ruby: call-routes: "get=GET,post=POST,put=PUT,patch=PATCH,delete=DELETE,match=ANY" Adding Rails is that one line, already shipped. get '/orders', to: 'orders#index' in a routes.rb yields GET /orders.
Two guards keep this honest, and you should understand them before loosening either:
at least two arguments — app.get("port") is Express reading a setting, not declaring a route; reject a dotted symbol shape (^\w+(.\w+)+$) — _.get(obj, "user.profile.name") is lodash, and @patch("app.services.order_service.charge") is unittest.mock. 7.3 A new language — five steps Worked, for a language the pipeline has never seen:
pom — io.github.bonede:tree-sitter-<lang>. Verify the artifact exists and ships natives for your platforms before writing any code. Language — add the enum constant. ParserFactory.loadGrammar — add the case returning new TreeSitter<Lang>(). LanguageDetector.EXT_MAP — map the extensions. SharedExtractorConfig — register a DeclarativeAstExtractor bean with its node-type shape and, if it has one, a route dialect. Then add it to GrammarLoadTest, which asserts every Language loads a grammar and parses a realistic sample. A grammar that cannot load then fails the build rather than failing inside an ingest, on one file type, silently.
Get the node-type names from the grammar, not from memory. Dump a realistic sample's tree and read them off. class_declaration has a name field in PHP and does not in Kotlin; C# exposes base types as a base_list child with no field name, which is why a getChildByFieldName("bases") lookup silently returned null for as long as that code existed.
7.4 Where framework knowledge is not allowed The rule the audit that produced this subsystem enforced, and the one to hold a reviewer to:
A convention goes in application.yml as an overridable pattern with a multi-framework default. It never goes in a Java literal.
LayerModel is the single reading of the shared conventions — layer order, layer patterns, screen patterns, proxy patterns, generic file names, frontend languages. It used to be duplicated in UserJourneyService, and every divergence that caused was a real defect: the same config value producing different blank-string semantics, a different classification order, and a different answer to "is this a screen".
// declaredEndpointIndex — instead of a bare put endpointIndex.merge(key, nodeId, (existing, candidate) -> isRollup(nodeOf(existing)) && !isRollup(nodeOf(candidate)) ? candidate : existing); Give each root its own budget share, as Navigation already does:
int perRoot = Math.max(MIN_PER_ROOT, MAX_TREE_NODES / Math.max(1, roots.size())); for (String root : roots) expand(root, ..., new int[]{ perRoot }); Cache the parsed knowledge base per linkedYmlPath. The object is immutable between linking runs, so a small Caffeine cache keyed on the S3 path removes two of every three downloads.
Type the slots. A record Slots(List<String> endpoints, List<String> apiCalls, …) built once after collapseToModules would turn today's silent empty results into compile errors.
Resolve mounted prefixes at link time — collect the prefix declarations during extraction into a new slot (route_prefix, which does not exist yet) and join them where the mounting module is visible, so labels read /api/v1/tenants/{tenant_id} rather than /tenants/{tenant_id}.
LinkageEngineService has two dead helpers that teach the wrong rule. segments (:1044) and isPathParam (:1052) are never called; isPathParam does not know Next.js [id] or *. The live rules are in RoutePaths. VIEW_FULL_FLOW = "frontend" and VIEW_FRONTEND = "navigation" in KnowledgeGraphPage.jsx. The constant names and their values are crossed over. source_nodes.yaml is an S3 key, not a filename. On disk the file is graph_<repo>.yaml. Omitting a slot from EDGE_SLOTS_TO_REMAP fails silently — the chain simply stops. LineageNavigationProbeTest skips silently. It resolves lineage/src relative to the backend repo, and lineage is now a sibling directory. The test passes by returning early, so it currently verifies nothing. docs/ingestion-pipeline.md describes the retired path. See the banner at the top of this page. A blank config value means "disable" for six of the seven convention keys — but not for layer-order, which has no meaningful "off" and keeps its default.