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
+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