From c3efbbf4e735a69bd5feedec3e0749b43b410c29 Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 02:51:30 +0000 Subject: [PATCH] feat(angular): a template's event bindings call the component's methods (#2114) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) · `, ` save() { this.router.navigate(internalRoutes.account.subRoutes.access.routerLink('me')); }`).replace( + 'src/app/shared/card.component.ts': component( + 'CardComponent', + 'app-card', + `x`, + ` 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 ']); }); + 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 | undefined)?.synthesizedBy === 'angular-event') + .map((e) => { + const meta = e.metadata as Record; + 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); diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md index 8e71012c9..febc60a30 100644 --- a/docs/design/framework-coverage.md +++ b/docs/design/framework-coverage.md @@ -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 (`` 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 diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md index d4f6de462..79a50d451 100644 --- a/docs/viewer-launch-changelog.md +++ b/docs/viewer-launch-changelog.md @@ -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. diff --git a/src/resolution/angular-template-synthesizer.ts b/src/resolution/angular-template-synthesizer.ts index 936f34098..9ec76c1e5 100644 --- a/src/resolution/angular-template-synthesizer.ts +++ b/src/resolution/angular-template-synthesizer.ts @@ -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.** `` 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 = /(? 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; diff --git a/src/ui-server/api/steps.ts b/src/ui-server/api/steps.ts index 24c3724dc..7dea2f7d3 100644 --- a/src/ui-server/api/steps.ts +++ b/src/ui-server/api/steps.ts @@ -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): SiteTrigger | null { + const t = meta.trigger as Partial | 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