Cascade

A code-to-column change-impact knowledge graph for AI coding agents.

View the Project on GitHub alexsoft-hq/Cascade

Web lane (frontend) setup

The web lane reads the screen side of the round trip. The rest of the engine starts at the HTTP endpoint and goes down (endpoint → service → mapper → SQL → table → column); this lane reads the code above the endpoint: the frontend that calls it, and the routes that decide which screen a user is on.

Read this first, because it is the whole shape of this version: the lane attaches a frontend call to the endpoint this pack serves, as a graded CALLS_HTTP edge, and it turns the router’s own declarations into screens, joined to the functions of the component each one mounts. So a column-impact answer reaches up past the controller, through the api function that calls the route and the view that calls that function, to the screen a user is looking at. A browser recording (--har) can be laid over the same edges as runtime evidence, shown and never walked.

Every edge it writes says what it rested on, and the web axis is shipped only when nothing about the frontend had to be guessed. A prefix this engine worked out by counting matches, or a path alias it assumed, makes the axis degraded and names what to declare.

What it needs

Node, and nothing else. The parser is vendored (adapters/web/vendor/babel-parser.cjs, @babel/parser 7.29.8, MIT), so there is no npm install, no lockfile, and no network at analysis time. The repository NOTICE credits it and adapters/web/vendor/README.md documents the exact bytes, the sha256 they are pinned to, and how to update them. cascade doctor has a web lane parser (vendored) line that loads the parser and parses one statement with it, so a truncated checkout is a named failure rather than “0 frontend calls”.

What it reads

Under each source root, recursively: .js, .mjs, .cjs, .jsx, .ts, .tsx, and the <script> blocks of .vue single-file components. A Vue file’s line numbers are the lines in the .vue file, template included, so a fact points where you would put your cursor.

It skips:

skipped where why
node_modules, .git, .cascade any depth never first-party source
__tests__, __mocks__ any depth a test is a different program, wherever it sits
dist, build, coverage, public only as a direct child of a source root, or of that root’s package directory there they are output; deeper down they are ordinary names
*.d.ts any a type declaration has no call in it
*.test.*, *.spec.* any same reason as __tests__
*.min.js any a bundle, not a source
files over 2 MB any recorded as skipped: "too-large", never dropped silently

The position rule on the third row is load-bearing, not a nicety. A blanket “skip anything called build” silently drops src/views/tool/build/ — a form BUILDER, six real screens in one of the frontends this lane was measured on. A build directory is a build directory where output goes: at the top of the package, or at the top of a source root. Anywhere else the word is just a word.

One consequence worth knowing: cascade init’s discovery uses a blanket skip list (src/core/discover.mjs), because the same list protects the Java lane from Gradle’s build/ output, and widening it there would let a Gradle build directory into the Java lane. So the webFiles and vueFiles counts discovery reports, and the counts cascade estimate prints from them, can be a few files below what the lane reads: on one of the measured frontends discovery counts 91 .vue files where the lane reads 97. The lane line printed after a run is the measured number; the estimate’s is a lower bound.

A file the parser cannot read at all is recorded as a parse_error with its line, its column and the message, and it is printed on the lane line. A file that fails to parse contributes no call and no route, and nothing else in the run would say so.

Beside the source roots it also reads, once per package directory (the directory holding the nearest package.json): the dotenv files (.env, .env.local, .env.<mode>, .env.<mode>.local), vue.config.js and vite.config.* for the dev-server proxy table, and tsconfig.json / jsconfig.json / the bundler config for path aliases. Those three between them decide what '/api' in a call actually reaches, and the bridge uses all of them.

What it records

One JSONL record per fact, all of them file-local (the worker resolves nothing across files; that is the bridge’s job):

record what it says
file the language, the Vue script blocks, how many errors the parser recovered from
import / export what this file takes and gives, dynamic import() included
function the named functions, with the rule that a callback gets no name of its own and is attributed to the nearest named function
constant an enum or object literal of string members, and export const X = '/x'
binding a top-level const whose initializer is a call, a new, or another name, plus the baseURL when one is built there
class a class, with the methods and fields it declares (a client written as a class is as common as one written as a function)
assign this.<field> = … anywhere in a class body, with the same init shape a binding carries — this is where a class puts the client it sends through
call a call site that goes through an import or a local binding, carries a URL-looking argument, or is fetch / XMLHttpRequest.open
route a route declaration, with its path AS WRITTEN, its component, its parent and its child count
config the env values, the proxy rules and the aliases described above

A function also carries what it RETURNS, when the last return at the top level of its body is a call or a new — that is how a factory (return new Client(opts)) and a forwarding method (return this.request(…)) are followed. A this-rooted callee inside a class body says which class it belongs to (binding: {kind: "this", class: "…"}), so this.inner.request(cfg) can be traced to the field the constructor assigned.

A call carries the URL argument resolved as far as one file allows: a literal, a template ('/things/' + id and `/things/${id}` both become /things/{*}), a member of a constant declared in the same file, or a local const followed once. Anything it cannot resolve says why: parameter, expression or imported-constant, and an imported constant keeps the binding so the bridge can go and look.

The flags

cascade analyze [--web-src <dir>... | --no-web] [--openapi <file>... | --no-openapi]
                [--har <file>...]

With no flag, the lane runs over the roots discovery found, but only when the profile declares the web framework pack. cascade init declares it whenever it finds a package.json with a vue, react, @angular/core or svelte dependency, and adds vue-router / react-router when the same package depends on one. The documents follow the same three-way rule with no pack to declare: --openapi first, then the profile’s openapi.documents, then whatever discovery found.

Router declaration packs

A route object has no syntax of its own: it is a plain object whose KEY NAMES a framework decided on. Those names live in adapters/web/packs/*.json, one file per convention, and the worker loads every file in that directory at start. Two ship today:

{
  "pack": "vue-router",
  "routeObject": {
    "pathKey": "path",
    "componentKeys": ["component", "components"],
    "childrenKey": "children",
    "nameKey": "name",
    "metaKey": "meta",
    "titleKey": "title",
    "redirectKey": "redirect",
    "hiddenKey": "hidden"
  },
  "registrars": ["createRouter", "VueRouter", "Router"],
  "routesKey": "routes"
}

An object literal is a route when it has a string pathKey and at least one of componentKeys, childrenKey, redirectKey, or indexKey set to true, and it sits inside an array literal, inside a registrar call’s routesKey, or is a top-level exported object. A file with none of the registrars still yields routes, because plenty of projects export a plain array and register it somewhere else.

A jsx block (as react-router.json has) says which JSX element is a route and which attributes name its path and its component, so a <Routes><Route …> tree is read as the same tree.

When two packs could both claim one object, the one whose distinctive keys the object carries wins (meta/hidden/redirect/name against element/lazy/index), and a registrar the file actually calls breaks a tie. To add a convention, drop a third JSON file in that directory. There is no code to change.

What the bridge does with them, and its honest grade

src/adapters/web_bridge.mjs runs after the Java bridge (it needs the routes) and turns each call site into one edge per route it can be shown to reach.

Evidence Grade Why
an OpenAPI document that declares the route EXACT, as a declaration the document is the project’s own statement that the route exists, so the endpoint node is exact about THAT and about nothing else. A route the code also serves is corroborated and keeps the grade the code lane gave it; a route only the document names gets no handler edge, so a frontend call reaches the endpoint and stops there, and the code axis says degraded with that reason. See OpenAPI documents below
a platform sink (fetch, XMLHttpRequest.open) with a URL that matches a route this pack serves SOUND_SET the browser sends the request itself and the URL argument is the URL by contract; nothing had to decide that this is an HTTP call
an HTTP client library instance (a declaration pack names the library) called through one of its verbs SOUND_SET the library sends the request, and the URL is the argument the library reads
a wrapper traced back to one of those, by following what each name is bound to SOUND_SET every hop is a binding the lane read, and the hops are on the edge (evidence.sink.chain)
a URL-shaped argument handed to a call the lane could not trace to any sink HEURISTIC the call may send this URL or may only build it: a rule guessed
any of the above where the prefix was chosen by match count, a path alias was assumed, or the call has no method at all HEURISTIC one part of the answer is a guess, so the whole edge is
a browser recording (HAR) RUNTIME_ONLY a recording proves a request happened once and proves nothing about what the code can do, so the edge sits below every query mode’s floor: it is shown (observed: true) and never walked, and it never raises the grade of the static edge beside it. An APM trace or an access log is still not read. See Recordings (HAR) below
a URL that resolved but no route here answers, or one naming another host UNRESOLVED the edge is below every mode’s floor, so no walk follows it. The route is a node marked outbound, source: "web", exactly as a Feign call that leaves the pack is

A call whose URL never resolved at all gets no edge and is counted by the reason the worker gave (parameter, expression, importedConstant). Two more reasons come from the bridge: noMatch (it resolved, nothing here serves it), outsidePack (another host) and allHoles (see below). Nothing is dropped in silence.

What a wrapper is

A frontend almost never calls axios directly. It calls its own function, which calls another, which eventually calls the library. This lane follows that chain by SHAPE, never by name:

So a class whose get(config) returns this.request({ …config, method: 'GET' }) and whose request(config) returns this.inner.request(config) is followed all the way to axios.create, and the edge records the chain and the depth. The method comes from the wrapper’s own verb where it has one, then from the call’s config, then from the library’s documented default (method.from says which).

The prefix, and how to declare it

The URL in the source is almost never the URL the backend serves. Between them sit the client’s baseURL and the dev server’s proxy. The bridge settles it per client instance, in this order, and the answer is on every edge as evidence.prefix.from:

  1. declared — the profile’s gatewayRoutes names it. Nothing is guessed; the edge keeps its grade.
  2. derived — the base URL was read from the source (a literal, an env value in a .env file, an absolute address’s path part) and a dev-proxy rule explains what of it reaches the server: a rule with a rewrite strips it, a rule without one keeps it. A client built with no base URL is derived with an empty prefix, because that is what the library does, not a guess.
  3. auto — nothing in the source states it: the base URL names an env value no .env file declares, the modes disagree with no proxy rule to settle them, or a relative prefix has no proxy rule at all. Every candidate is then matched against the routes this pack serves and the one with the most exact hits wins. That is a guess, so every edge through it is HEURISTIC and the candidate counts are on the edge.
  4. none — even that matched nothing, so the URL is used as written.

To stop the guessing, declare the mapping in .cascade/profile.json:

{ "gatewayRoutes": { "/dev-api": "" } }

The key is the prefix the FRONTEND writes, the value the prefix the BACKEND serves. "/dev-api": "" says “the dev server strips it”. A key of "*" applies to every call in the project. Declaring it moves the axis from degraded to shipped and the edges from HEURISTIC to SOUND_SET.

gatewayRoutes is read in exactly one place (src/adapters/web_bridge.mjs). Declared with no web lane to read it, cascade analyze says so as a RECORDED_NOT_ACTED diagnostic rather than silently ignoring it.

The HTTP client pack

Which libraries send requests, and which of their methods are verbs, is a DECLARATION, not code: adapters/web/packs/http-clients.json.

{ "module": "axios",
  "instanceFactories": ["create"],
  "verbs": { "get": "GET", "post": "POST", "put": "PUT", "delete": "DELETE" },
  "generic": ["request", "(call)"],
  "configUrlKey": "url", "configMethodKey": "method", "configBaseUrlKey": "baseURL",
  "defaultMethod": "GET" }

"(call)" means the instance itself is callable (service({ url })). To teach the lane a library it does not know, add a row to that file. There is no code to change, and nothing in the bridge names a library.

What a URL has to look like to count

Two rules keep the graph from filling up with things that are not routes, and both are counted rather than silently applied:

Matching a call to a route

The route path is a template ({id}, {key:.+} and * each take one segment, ** takes the rest); the call’s {*} takes one whole segment when the segment is nothing else, and part of a segment otherwise. Exact string equality is tried first (match: "exact"), then the template match (match: "template"). The method has to agree, unless the route is ANY; a call with no method at all matches by path and is HEURISTIC for it.

A call that matches several routes gets an edge to each, and every one of them carries evidence.candidates saying how many.

The lane lines

Web lane: 120 file(s) (65 .vue, 0 .ts/.tsx, 55 .js/.jsx), 0 parse error(s); 132 call site(s) carry a URL
  (128 literal, 2 template, 1 constant, 1 unresolved), 57 route declaration(s), 2 alias(es), 1 proxy rule(s)
Web lane: 127 call site(s), 121 resolved (121 sound, 0 heuristic), 6 unresolved (noMatch 5, expression 1),
  4 outside-pack; prefix front: /admin (derived)
Web lane: 1 client instance(s), 0 wrapper(s) (deepest 0), 121 exact and 0 template match(es),
  0 call(s) through an assumed alias; bridge 6 ms

The first line is the worker’s, the other two the bridge’s. The same counts go into pack.meta.laneStats.web, so what was printed and what was recorded cannot disagree.

Incremental: what is cached, and what never is

The lane’s facts are content-addressed per file, exactly like the Java lane’s. A shard is one frontend source file’s records; its name is sha256(file bytes) + the webfacts worker version + the file's root-relative path, so “is this still valid?” is a name lookup and never a judgement. Edit one .vue and the next run re-reads that one file and reassembles the rest from the cache.

That is safe for the same reason it is safe on the Java side: the worker resolves nothing across files. It records what one file imports, exports, binds and calls. Every cross-file step — an import resolved through an alias, a wrapper traced to the client library it forwards to, a URL matched against a route — happens afterwards in the bridge, over the whole assembled set, which every run rebuilds in full.

What is never cached is the package configuration: the .env* values, the dev-server proxy rules and the path aliases. Those describe a PACKAGE, not the file they happen to be written in, so no file’s shard could hold them honestly — and they reshape every URL the frontend sends, so answering from a stale copy would be the one mistake this lane cannot afford. Every run re-reads them with node adapters/web/webfacts.mjs --configs-only …, which walks no source file and costs one process start.

What forces a cold run:

what moved why every shard is dropped
the webfacts worker version shards from two generations of a worker mean different things
the engine’s assembly version (cascade-incremental/N) the shard layout or the assembly order changed
the lane selection (a web root added, removed or moved) cold and incremental must analyze the same inputs
--cold you asked

The invariant this buys is I-9: an incremental run’s pack digest equals a cold run’s on the same tree. test/incremental.test.mjs proves it by mutating a random subset of a synthetic project — frontend files included — and comparing the two packs after every round.

The working-tree overlay

changed_impact (and cascade impact) answers over the bytes on disk, not the last analysis, and that includes the frontend.

You edited a frontend file. The overlay re-reads that file only, re-reads the package configuration, splices the result over the cached shards and rebuilds the graph. The answer runs DOWN:

A function no certified run has seen is marked provisional: true beside its grade. That is a MARKER, not a grade: the lattice is untouched.

You edited a backend file. Each affected route in upstreamEndpoints carries frontendCalls: how many frontend functions call it. That is the half of the blast radius that is not below the edit.

Measured on the integration fixture, one edited .vue: 67 ms total for the lanes (web 63, sql 1, graph 3), against a one-second gate.

OpenAPI documents

A document is an evidence layer, not a lane over source: the project wrote it to say what it serves, and this engine reads it as a declaration.

What is read. A .json, .yaml or .yml file, at most 2 MB, whose first 4 KB carry a top-level openapi: / "openapi" or swagger: / "swagger" key. Swagger 2 takes its prefix from basePath; OpenAPI 3 from the path part of servers[0].url (a {variable} in the path is left exactly as written, because substituting its default would invent a base path the document did not state). Each (method, path) becomes an endpoint; a path item with no verb at all still declares the path, with the method ANY.

The YAML subset. This engine has no runtime dependencies, so the YAML reader is written here (src/adapters/openapi_bridge.mjs) and it is deliberately small:

accepted refused, by name
block mappings and sequences, by indentation anchors (&x) and aliases (*x)
plain, single-quoted and double-quoted scalars explicit tags (!!str, !Ref)
# comments, whole-line and trailing a second document in one file (---)
flow sequences [a, b] and flow mappings {k: v} of scalars a tab in the indentation
\| and > block scalars, kept as text an unterminated quote or flow collection

A refusal names the line and the construct and the whole document is then unread — no half of it enters the graph. A YAML feature silently mis-parsed would put routes in the pack that the file does not declare, which is worse than reading no routes at all. Convert the document to JSON, or write the routes without the construct.

With a Java lane. The document and the code are two independent statements about the same routes. A route both name is CORROBORATED: the endpoint node gains declaredBy (the documents that declare it, sorted) plus operationId and summary when the document carries them, and keeps whatever grade the code lane gave it. A route only the document names is added with no handler edge. The drift census is on meta.laneStats.openapi and in the overview’s openapi-drift gap, in both directions:

This engine reports both and judges neither.

Without a Java lane. This is what the layer is really for. A backend written in something this engine has no lane for — Node, Go, Python, .NET — still publishes a document, and the frontend’s calls then land on real endpoint nodes instead of on nothing. The pack says how far that gets you: the code axis is degraded, with the reason “endpoints come from an OpenAPI document, not from source: the routes exist, but nothing below them is walked, so a frontend call reaches an endpoint and stops there”, and a column question answers not-shipped rather than an empty list that would read as “we looked”.

Screens

A route declaration is not a screen. A screen is the path a user is really on, and that path is composed rather than read: a router nests, so {path: '/panel', children: [{path: 'rows'}]} is one screen at /panel/rows. The composition rules, in full:

The node id is screen:<the composed path>, which is also what flow screen= and browse kind=screen name it by.

What is on a screen

field where it comes from
path the composed path
name the route’s own name
title the route’s meta.title, only when screenAxis.nameSource is route-meta
label the last path segment when screenAxis.pathRule is last-segment, otherwise the whole path
code the first match of screenAxis.codeRegex against the name, then the title, then the path
group the first moduleAttribution.codeLength characters of the code when both exist, otherwise the first path segment
component the root-relative file the route’s component resolved to
file / line / pack the declaration itself, and which router pack recognized it
params true when the composed path has a :x or a * in it
hidden present only when the declaration says so
source router, or har for a page only a recording found

RENDERS: what a screen actually runs

screen --RENDERS--> symbol joins a screen to the frontend functions it can run. Nothing about it is guessed from a name:

The frontend’s own calls

Between a component’s function and the route it hits there is normally one more hop: the api module. symbol --CALLS--> symbol is that hop, and its grade says how the name was followed:

grade when
EXACT a static import with a named or default specifier, followed through a relative path or a DECLARED alias to a function this lane read; or a call by name inside one file (getList(), this.getList())
SOUND_SET the same, but the name came through an export * barrel or a re-export chain, so WHICH file it came from was a choice
SOUND_SET the function was never called here at all: it was PASSED AS A VALUE to some other call, and the receiver may call it
HEURISTIC an ASSUMED alias was on the path

A call onto an imported name that is not a function (a constant, a component) makes no edge and is counted as calls.notAFunction.

A function handed over as a value. Plenty of frontends never call their api function from the view at all. They hand it to a hook:

const { rows, reload } = usePagedList({ api: listRows })
useSubmit(saveRow, { immediate: false })

No call site names listRows, so a rule that follows calls stops at the view. The worker records what was handed over (fnRefs on the call record): an identifier in any argument position, or the value of an object argument’s property one level deep under any key, and only when the file binds that identifier to an import or to a function it declares itself. A member of a namespace import (api.list) keeps its root and its path. A string is not a reference, a call is already a call, and an inline arrow’s body is already attributed to the function around it.

The bridge resolves that name exactly as it resolves a callee and places symbol --CALLS--> symbol with evidence.rule: "passed-as-value", the via (argument or property), the key when there was one, the specifier and the origin. The grade is SOUND_SET and never EXACT, because nothing here looked at whether usePagedList calls its api: that would mean following a value into another module’s body. An assumed alias on the path lowers it to HEURISTIC, and a pair that is both called and passed keeps the call’s EXACT answer. The target joins the same fixpoint as a called function, so a screen can reach it. laneStats.web.calls.passedAsValue counts the references that resolved and laneStats.web.callsByRule splits the edges by the rule that found them, because a grade alone cannot tell a followed call from a handed-over function.

Only functions that reach HTTP get a node. A function that sends a request, or that reaches one through these edges, is in the graph; a formatter or a date helper is counted (laneStats.web.functions) and left out, because otherwise the pack doubles in size for code no question is ever about. A function in a component file (.vue, .tsx, .jsx) carries component: true, so a tool can tell a function in a screen from an api function. A .ts or .js module that exports a function returning JSX is a component too, and this version does NOT catch that: the fact stream carries no JSX marker, so the rule is the file extension and nothing else.

When the router is filled in by the server

Plenty of admin products fetch their menu from the backend when the app starts. The screens this lane can see are then not the app, and the axis says so rather than letting the ones in the source read as the whole product.

The rule is the call: one of the frontend’s own calls resolved to a route this pack serves whose path ends in /getRouters, /menu, /menus, /routes or /nav. Nothing else is required. The census records serverDriven: {detected, detectedBy: "menu-call", routes, ceiling, menuEndpoints}, so a reader can check the rule rather than take it.

The route count does not decide, it only chooses the sentence. Under the 30-route ceiling the reason says most screens arrive when the app runs; over it, screens beyond the N declared arrive when the app runs. That split replaced a rule which needed both halves — a big frontend that fetches its menu is server driven exactly as a small one is.

What this still does not catch, measured and left alone: a product whose menu rides on an endpoint that is not spelled like one. The largest frontend measured declares 173 routes — 87 of them the framework’s own demo pages — and fetches every business screen at run time from a route named after permissions rather than after menus. No suffix above matches it, its screen axis reads shipped, and “19 of 166 screens reach a table” reads as a shortfall rather than as a description. Adding that project’s spelling here would be a rule that works on that project and nowhere else, so the spelling table stays generic and this is written down instead.

When it fires the screen axis is degraded with that sentence and the numbers behind it, and overview carries a screens-from-server gap. It is about what is MISSING, not about whether to build: a profile with screenAxis.enabled: true still builds every declared screen. Set screenAxis.enabled: false if you would rather have no screen axis than a partial one.

The profile keys

{
  "screenAxis": {
    "enabled": true,
    "nameSource": "route-meta",
    "pathRule": "last-segment",
    "codeRegex": "([A-Z]{2}\\d{4})"
  },
  "moduleAttribution": { "codeLength": 2 }
}

When the axis is shipped

shipped needs all three: the gate is on, at least one screen has a RENDERS edge, and nothing about the reading was a guess. It is degraded when the app fetches its menu from the server, when more than a fifth of the declared routes name a component this lane could not resolve, when nameSource asks for something not shipped, or when screens were built and not one of them reaches a function. It is not-shipped only when the gate is off or no route was read at all. When it is off, the axis reason names which of the three rules above turned it off.

Recordings (HAR)

A HAR file is what the browser saw: which page was open, and every request it sent. cascade analyze --har <file> (repeatable) reads one, and the profile’s runtimeEvidence.har names them for an unflagged run. Nothing is discovered: a recording is something you made on purpose, and picking one up because it happens to be in the tree would let an unrelated capture decide what this pack claims was observed.

How to record one

In Chrome, open the app, press F12, go to Network, tick Preserve log, walk through the screens you care about, then right-click the request list and choose Save all as HAR with content (the content is not read here, only the URLs and the page each one belongs to). Firefox and Edge write the same format.

What is matched

What RUNTIME_ONLY means

Each matched (screen, route) pair becomes ONE screen --CALLS_HTTP--> endpoint edge graded RUNTIME_ONLY, carrying {rule: 'har', file, count, firstSeen, lastSeen, methods}. That grade sits below the floor of every query mode, so:

observed: true lands on the screen node and on the endpoint node, and on the rows of flow, browse kind=screen and screen_impact that name them. The census is on meta.laneStats.har: {files, entries, matched, unmatched, assets, pagesWithoutScreen, pairs, screensObserved, endpointsObserved, unmatchedPaths}.

What the generality gate measures

scripts/generality-gate.mjs runs this engine, unchanged and with nothing configured, over a pinned corpus of real repositories, and the numbers it reaches are a floor the test suite defends. Four of those entries are a backend AND a frontend, because a backend alone says nothing about whether a screen reaches a column. Two of the four carry their frontend inside the backend repository, so an unconfigured cascade init declares the web pack and the run reads it with no flag; the other two have a frontend in a repository of its own, which the corpus pins the same way it pins the backend (front: {url, sha, dir}) and the runner clones beside it, passing one --web-src. Nothing else is given: no profile edit, no gatewayRoutes, no document and no recording.

Two of the guarded counts are this lane’s: webCallsResolved (the call sites that reached a route this pack serves, over the ones that carry a URL at all) and screensReachingATable. Both are real numbers for all four pairs, including the two whose frontend is a repository of its own: the screen axis is decided by what the run reads (the three states above), so an unconfigured run over a backend plus --web-src builds screens without anybody editing a profile.

What it does NOT do, in this version