feat(angular): a template's event bindings call the component's methods (#2114)

An Angular handler's only caller is its template: `(click)="toggleFavorite()"`,
`(ngSubmit)="submitForm()"`. With no edge, the method showed no callers,
impact stopped at it, and the Steps picture of a screen reached its
handlers only through class containment, with nothing to say what fires
them.

The template pass now reads each `(event)="…"` binding — not
`[(ngModel)]`'s two-way half — and links the component to every method of
its own the statement calls (`save()`, `this.toggle(x)`; not
`closed.emit()` or `form.reset()`), as a `calls` edge
(`synthesizedBy: 'angular-event'`) carrying the binding as
`metadata.trigger`: `{ kind: 'prop', name: '(click)', of: 'button' }`.
The binding is in the template, so it cannot be read back from the source
at the edge's line; Steps now takes a trigger an edge carries before
reading one at the site.

angular-realworld: 21 binding sites → 17 edges (3 repeats of a method and
event, 1 `delete.emit(true)`); Steps for /article/:slug draws
deleteArticle on (click) · <button> → DELETE and back to /, addComment on
(ngSubmit) · <form>, the favorite button's toggle → POST/DELETE or
/register. Ghostfolio 225 edges of 302 sites, ngx-admin 75 of 100 — the
rest are `$event.stopPropagation()`, output emits, assignments and
repeats. Controls (koel, proshop) byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 02:51:30 +00:00
committed by GitHub
parent 761611c18e
commit c3efbbf4e7
6 changed files with 108 additions and 4 deletions
+1
View File
@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
- In an Angular app, a method a template calls, like `(click)="toggleFavorite()"` or `(ngSubmit)="submitForm()"`, is now linked from its component. These methods used to show no callers at all, so impact and callers questions stopped at them, and dead-code checks could list them as unused. Re-index Angular projects after upgrading.
- Angular apps now have routes and navigation in the graph. Each screen in a `Routes` array is a route named by its full path, including children, lazily loaded `loadChildren` files and NgModules, paths written as route constants or `$localize` strings, and redirects. Each route is linked to its component. `router.navigate([...])`, `navigateByUrl`, a guard's `createUrlTree` and every `routerLink` in a component's template link to the screen they open, and a template's child components (`<app-article-list>`) are linked to the component that renders them. Questions like "where does this button go" and "what renders this component" now have answers in `codegraph_explore`. Re-index Angular projects after upgrading.
- A SwiftUI view, a `@main` app, a view controller and a UIView subclass are now indexed once. Each used to get a second, one-line copy with no callers or calls, which showed up next to the real type in search and `codegraph_explore` answers as a dead end. Re-index Swift projects after upgrading.
- A Swift class or struct that declares a protocol composition across lines, like `typealias Client = AutocompleteService.Client` followed by `& PostingService.Client` on the next line, is now indexed whole. That line used to break the parse of the entire enclosing type, so its methods, initializer and properties went missing, and calls into them landed on same-named methods elsewhere. Re-index Swift projects after upgrading.
+29 -1
View File
@@ -15,6 +15,7 @@ import * as path from 'path';
import { CodeGraph } from '../src';
import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router';
import { buildScreens } from '../src/ui-server/api/screens';
import { buildSteps } from '../src/ui-server/api/steps';
describe('parseAngularRoutes', () => {
const file = (body: string) => `import { Routes } from '@angular/router';\n${body}`;
@@ -189,7 +190,13 @@ export class AdminRoutingModule {}
<a [routerLink]="['/account', 'access', 7]">Access</a>
<a routerLink="relative">Relative</a>
`,
'src/app/shared/card.component.ts': component('CardComponent', 'app-card', `<button (click)="save()">Save</button>`, ` save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`).replace(
'src/app/shared/card.component.ts': component(
'CardComponent',
'app-card',
`<button (click)="save()">Save</button><input [(ngModel)]="name" /><a (click)="closed.emit(true)">x</a>`,
` name = '';
save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`
).replace(
"import { Router } from '@angular/router';",
"import { Router } from '@angular/router';\nimport { internalRoutes } from '../routes.constants';"
),
@@ -270,6 +277,27 @@ export class AdminRoutingModule {}
expect(renders).toEqual(['HomeComponent -> CardComponent <app-card>']);
});
it("links a template's event bindings to the component's own methods, the binding as the trigger", async () => {
const bindings = cg
.getNodesByKind('class')
.flatMap((c) =>
cg
.getOutgoingEdgesFrom([c.id], ['calls'])
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === 'angular-event')
.map((e) => {
const meta = e.metadata as Record<string, unknown>;
return `${c.name} -> ${cg.getNode(e.target)?.name} ${JSON.stringify(meta.trigger)}`;
})
);
// `[(ngModel)]` is a two-way binding and `closed.emit()` raises the component's own output: neither calls a method.
expect(bindings).toEqual(['CardComponent -> save {"kind":"prop","name":"(click)","of":"button"}']);
const home = cg.getNodesByKind('route').find((r) => r.name === '/home')!;
const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: home.id }));
const save = payload.steps.find((st) => st.label === 'save');
expect(save?.trigger).toMatchObject({ kind: 'prop', name: '(click)', of: 'button', in: 'CardComponent' });
});
it("draws the Screens picture: a child component's navigation on its screen, a layout's on each screen inside it", async () => {
const payload = await buildScreens(cg, root);
expect(payload.routed).toBe(true);
+4 -1
View File
@@ -56,7 +56,10 @@ Angular is the one whose markup is not indexed: a component's template is a
which also yields the component tree (`<app-foo>` by element selector) — the
edge a navigation in a child component rides to its screen. A `routerLink:`
field written in a component's class (a tab bar's or a menu's config, bound
in a loop elsewhere) counts as a link from that component. A route with
in a loop elsewhere) counts as a link from that component. An event binding
(`(click)="save()"`) is a `calls` edge from the component to its own method
carrying `metadata.trigger`, which Steps uses in place of reading a trigger
at the edge's line (the binding is in the template, not the source there). A route with
`children` is a layout: its component carries a `references` edge marked
`layout: true` from each screen nested in it, and `routeLayouts` in
`route-roots.ts` gives Screens every screen a layout serves. Known limits: a
+2
View File
@@ -15,6 +15,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
## New Features
- **The Steps tab draws what an Angular screen does.** Each handler a template binds is drawn with the event that fires it, like `submitForm` on the form's `(ngSubmit)` or `toggleFavorite` on a button's `(click)`, followed by the requests it sends and the screen it opens. That includes handlers in child components the screen renders. Re-index Angular projects after upgrading.
- **The Screens tab draws Angular apps.** Every screen in an Angular app's routes, with the navigation between them: `router.navigate(…)`, a guard's redirect and each template's `routerLink`, under the condition the code checks first. A button in a child component, like an article's favorite button, is drawn from the screen that renders it. A layout's tabs and buttons are drawn from every screen inside that layout. Re-index Angular projects after upgrading.
- **A big screen's picture stops wrapping into a column.** How wide a screen's lines run before they wrap was worked out with a formula, and the formula was wrong for the way these pictures are actually drawn: a part of a screen spends lines on its own structure — a step that fires things gets a line to itself, and what it fires starts another — so estimating the lines from the boxes alone badly undercounted them, and one screen's 98 boxes wrapped into a 4,356px column. Laying a picture out is cheap and exact, so the widths are now simply tried and the one that comes out closest to the shape of a window is kept. Across one app's 51 screens the tallest picture went from 4,356px to 3,796px, total height fell 8%, and — because a shorter picture is also a picture whose lines have less far to go — lines running over other boxes fell by a third and lines crossing each other went from 13 to 5.
+61 -1
View File
@@ -2,7 +2,7 @@
* Angular templates: what a component renders and where it links.
*
* An Angular component's markup is a template — a `templateUrl` file beside
* it, or an inline `template:` string — that the index never parses, so two
* it, or an inline `template:` string — that the index never parses, so three
* things a reader relies on were missing from the graph:
*
* - **The component tree.** `<app-article-list [config]="listConfig">` in the
@@ -18,6 +18,12 @@
* `routerLinkAbout = publicRoutes.about.routerLink`) each become a
* `navigates` edge from the component to the route it names.
*
* - **Event bindings.** `(click)="toggleFavorite()"` is the only caller a
* handler method has. A `calls` edge from the component to its own method
* (`synthesizedBy: 'angular-event'`) carries the binding as its
* `trigger`, the label Steps puts on the hop — read from the template, so
* it cannot be read back from the source at the edge's line.
*
* A child is matched by its element selector (`selector: 'app-article-list'`),
* in the same app first; a selector two components share there is left
* unmatched. A relative link and a destination no route serves draw nothing.
@@ -132,6 +138,19 @@ export function angularComponents(ctx: ResolutionContext): AngularComponent[] {
/** `routerLink="…"` (plain) and `[routerLink]="…"` (bound), either quote. `routerLinkActive` is neither. */
const ROUTER_LINK = /(\[routerLink\]|\brouterLink)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
/** `(click)="save()"` / `(ngSubmit)="submitForm()"` — an event binding, not `[(ngModel)]`'s two-way half. */
const EVENT_BINDING = /(?<!\[)\(([A-Za-z][\w.-]*)\)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
/** A call a template statement makes on its component: `save()`, `this.toggle(item)` — not `form.reset()`. */
const OWN_CALL = /(?<![\w$.])(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)\s*\(/g;
/** The element a binding at `at` is written on: the last opening tag before it. */
function elementAt(text: string, at: number): string | null {
const open = text.lastIndexOf('<', at);
if (open < 0) return null;
const tag = /^<([a-zA-Z][\w-]*)/.exec(text.slice(open, open + 64));
return tag ? tag[1]! : null;
}
/** A `routerLink:` field of an object literal written in a class. */
const ROUTER_LINK_FIELD = /(?<![\w$.])routerLink\s*:\s*/g;
@@ -228,6 +247,47 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb
});
}
// Event bindings: `(click)="toggleFavorite()"` runs the component's own
// method when the user acts. The binding is the trigger Steps draws —
// known only from the template, so it rides on the edge.
const members = new Map(
ctx
.getNodesInFile(component.file)
.filter((n) => n.kind === 'method' && n.qualifiedName.startsWith(`${component.node.qualifiedName}::`))
.map((n) => [n.name, n])
);
let handlers = 0;
EVENT_BINDING.lastIndex = 0;
let ev: RegExpExecArray | null;
while ((ev = EVENT_BINDING.exec(text)) !== null && handlers < MAX_CHILDREN_PER_COMPONENT) {
const statement = ev[2] ?? ev[3] ?? '';
const event = `(${ev[1]!})`;
const element = elementAt(text, ev.index);
OWN_CALL.lastIndex = 0;
let c: RegExpExecArray | null;
while ((c = OWN_CALL.exec(statement)) !== null) {
const method = members.get(c[1]!);
if (!method) continue;
const key = `${component.node.id}>${method.id}>${event}`;
if (seen.has(key)) continue;
seen.add(key);
handlers++;
edges.push({
source: component.node.id,
target: method.id,
kind: 'calls',
line: siteLine(ev.index),
provenance: 'heuristic',
metadata: {
synthesizedBy: 'angular-event',
via: event,
registeredAt: registeredAt(ev.index),
trigger: { kind: 'prop', name: event, of: element },
},
});
}
}
const routes = angularRoutesFor(table, component.file);
if (!routes || routes.exact.size === 0) continue;
let links = 0;
+11 -1
View File
@@ -394,6 +394,13 @@ interface StepRecord extends WireStep {
root: Node | null;
}
/** A trigger an edge carries in its metadata (a template binding), when it has one. */
function siteTriggerOf(meta: Record<string, unknown>): SiteTrigger | null {
const t = meta.trigger as Partial<SiteTrigger> | undefined;
if (!t || typeof t !== 'object' || t.kind !== 'prop' || typeof t.name !== 'string') return null;
return { kind: 'prop', name: t.name, of: typeof t.of === 'string' ? t.of : null };
}
/**
* The string a Java / Kotlin return expression builds, when it starts with a
* literal: `"owners/ownerDetails"`, `"redirect:/owners/" + owner.getId()` →
@@ -1296,7 +1303,10 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
// every call-shaped hop, so a store action or an effect fired by
// a tap says so on its link too.
const isCall = e.kind === 'calls' || e.kind === 'instantiates' || (e.kind === 'references' && meta.fnRef === true);
const trigger = isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
// An Angular template's `(click)` binding rides on its edge: the
// template is a file of its own, not the source at `e.line`.
const carried = siteTriggerOf(meta);
const trigger = carried ? { ...carried, in: fold.node.name } : isCall ? await triggerAt(fold.node, { line: e.line, column: e.column }) : null;
// A server action, by its directive: a function in a `'use server'`
// file (or opening with the directive) called from a file that is