fix(angular): a binding, interpolation or control-flow block links the member it calls (#2398)

The Angular template pass read own-member calls only in event bindings, so
a member a template calls from `[name]="icon()"`, `{{ label() }}`,
`*ngIf="isOpen()"` or `@if (loading())` had no incoming edge. With signals
that is the usual way a template reads state, and the dead-code derivation
listed such members as unused (CleanArchitecture's ThemeToggleComponent
icon and label).

Every render-time expression is now read: property bindings,
interpolations, structural-directive microsyntax, control-flow blocks and
@let. A call of one of the component's own members becomes an
`angular-binding` edge with the binding as `via` and no trigger. It runs at
render, and a trigger drew computed signals as user actions in Steps. A
read without a call counts only for a method-kind member: a getter is
called, a function handed to a child is a fnRef reference, and a field a
call filled is a plain reference. Property reads link nothing, as in
TypeScript. Pipes, object and microsyntax keys, members of other objects,
template locals, strings and comments link nothing either.

Validated on CleanArchitecture, angular-realworld, ngx-admin and Ghostfolio:
272 edges added, none removed, and every registeredAt line checked. The
proshop_mern and next-saas-starter dumps are byte-identical.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-07 00:33:38 +00:00
committed by GitHub
parent 24501fa66c
commit 9a274a4b47
5 changed files with 773 additions and 12 deletions
+1
View File
@@ -67,6 +67,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In C, C++ and Objective-C, a struct or class member declared as a pointer, reference, array or function, like `SharedState* shared;`, `jv elements[];` or `virtual Status Put(…) = 0;`, no longer makes the struct look like it inherits from the member's type. Before, `codegraph_explore` and impact listed those types as base classes, every struct holding a pointer to a type as one of its subclasses, and a method of that struct as an override of the type's method with the same name. Real base classes like `class Derived : public Base`, Objective-C superclasses and Go's embedded fields are unchanged. Re-index C, C++ and Objective-C projects after upgrading.
- Framework support now switches on when an app's `package.json` sits three or more folders deep, as with an ASP.NET solution's Angular or React app in `src/Web/ClientApp/`, or a React app in a workspace folder like `web/ui/mantine-ui/`. Before, only the project root and the first two folder levels were read, so frameworks such as Angular, React Router, Vue Router, SvelteKit and TanStack Router could go undetected there: an Angular app's routes, its `routerLink` and `router.navigate(…)` navigation and its template's event bindings were missing, so a handler used only from a `(click)` binding had no callers, and a React app's `navigate('/login')` calls didn't reach their route. Re-index projects laid out this way after upgrading.
- In Go, an interface that embeds another, like gin's `IRouter` embedding `IRoutes`, now links to it, so `codegraph_explore` and impact show the embedded interface as its base. Before, every embedded interface was dropped. An embedded field written through another package of your project, like `*refresh.Discovery` or `storage.Appender`, or with type arguments, like `List[int]`, now links too. One from the standard library or another module, like `io.Closer` or `sync.Mutex`, links to nothing rather than to a project type that shares its name, and a type-set constraint like `~int | ~float64` is not an embedding. An embedded type written without a package now always links to its own package's type: before, etcd's `Client`, which embeds its own `Lease` interface, was linked to a `Lease` struct in another package. Re-index Go projects after upgrading.
- In Angular templates, a property binding, an interpolation, a structural directive or a control-flow block that calls one of the component's own members, like `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` or `@if (loading()) {`, now links the component to that member, and so does reading a getter, like `[disabled]="!canSave"`, or handing a method to a child component, like `[displayWith]="displayFn"` or `trackBy: trackById`. Before, only event bindings such as `(click)="save()"` were read, so a signal, a `computed` value, a getter or a method used only from a template had no callers and looked unused. A pipe, a template variable, a call on another object such as `form.reset()`, and a plain field read like `[value]="title"` still link nothing. Re-index Angular projects after upgrading.
## [1.6.2] - 2026-10-03
+258
View File
@@ -14,6 +14,8 @@ import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
import { commandsHref, localizeDefault, parseAngularRoutes, staticString } from '../src/resolution/frameworks/angular-router';
import { readMicrosyntax, templateMemberUses } from '../src/resolution/angular-template-synthesizer';
import { buildDeadCodeReport } from '../src/graph/dead-code';
import { buildScreens } from '../src/ui-server/api/screens';
import { buildSteps } from '../src/ui-server/api/steps';
@@ -111,6 +113,83 @@ describe('Angular destinations', () => {
});
});
describe('what a template reads at render', () => {
const uses = (template: string) => templateMemberUses(template).map((u) => `${u.name}${u.call ? '()' : ''} ${u.via}`);
it('reads property bindings, interpolations, structural directives, control flow and @let', () => {
expect(uses(`<button (click)="cycle()" [attr.aria-label]="label()"><i [name]="icon()"></i></button>`)).toEqual([
'label() [attr.aria-label]',
'icon() [name]',
]);
expect(uses(`<p *ngIf="isOpen(); else closed">{{ count() }} {{ total }}</p><ng-template #closed></ng-template>`)).toEqual([
'isOpen() *ngIf',
'count() {{ }}',
'total {{ }}',
]);
expect(uses(`@if (loading()) { x } @else if (failed()) { y } @switch (mode()) { @case (Mode.A) { a } }`)).toEqual([
'loading() @if',
'failed() @else if',
'mode() @switch',
'Mode @case',
]);
// `on timer(5s)` is a trigger, not a call.
expect(uses(`@defer (on viewport; when ready(); prefetch on timer(5s)) { x }`)).toEqual(['ready() @defer']);
expect(uses(`@let total = price() * quantity; <input [(ngModel)]="title">`)).toEqual(['price() @let', 'quantity @let', 'title [(ngModel)]']);
});
it("leaves out a pipe, an object key, another object's member, a string, a comment and an event binding", () => {
expect(uses(`{{ total | currency: code() }} <i [ngClass]="{ active: isActive() }" [x]="form.reset() || this.own()"></i>`)).toEqual([
'total {{ }}',
'code() {{ }}',
'isActive() [ngClass]',
'form [x]',
'own() [x]',
]);
expect(uses(`<!-- {{ legacy() }} <b [x]="old()"></b> --> {{ 'quoted()' }} <b (click)="save()"></b>`)).toEqual([]);
// A template literal's text is a string; its holes are expressions.
expect(uses('<td [class.today]="isToday(`${key}-${formatDay(day)}`)" [title]="`quoted()`"></td>')).toEqual([
'isToday() [class.today]',
'key [class.today]',
'formatDay() [class.today]',
'day [class.today]',
]);
});
it('leaves out the names the template declares, where they are visible', () => {
// A microsyntax's locals, keys and context names belong to the directive.
expect(uses(`<li *ngFor="let item of items(); let i = index; trackBy: trackById" [title]="item.name">{{ i }}</li>`)).toEqual([
'items() *ngFor',
'trackById *ngFor',
]);
// An alias belongs to the body, not to the condition that names it.
expect(uses(`@if (user(); as user) { {{ user.name }} }`)).toEqual(['user() @if']);
expect(uses(`<div *ngIf="user() as user">{{ user.name }}</div>`)).toEqual(['user() *ngIf']);
// `track` sees the loop's own names.
expect(uses(`@for (item of items(); track item.id; let odd = $odd) { {{ item.name }} {{ odd }} }`)).toEqual(['items() @for']);
expect(uses(`@let total = price(); {{ total }}`)).toEqual(['price() @let']);
// A reference variable is seen across the template, even before it is declared.
expect(uses(`<b [x]="input.value"></b><input #input>`)).toEqual([]);
expect(uses(`<ng-template let-row let-i="index">{{ row.name }} {{ i }}</ng-template>`)).toEqual([]);
});
it('splits a microsyntax into its expressions and its locals', () => {
expect(readMicrosyntax('let item of items | async; index as i; trackBy: trackFn')).toEqual({
expressions: [
{ at: 12, text: 'items | async' },
{ at: 48, text: 'trackFn' },
],
locals: ['item', 'i'],
});
expect(readMicrosyntax('user$ | async as user; else loading')).toEqual({
expressions: [
{ at: 0, text: 'user$ | async ' },
{ at: 28, text: 'loading' },
],
locals: ['user'],
});
});
});
// ---------------------------------------------------------------------------
// End to end
// ---------------------------------------------------------------------------
@@ -318,6 +397,185 @@ export class AdminRoutingModule {}
});
});
describe("an Angular template's bindings, indexed", () => {
let cg: CodeGraph;
let root: string;
beforeAll(async () => {
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-angular-bindings-'));
projects.push(root);
const files: Record<string, string> = {
'package.json': JSON.stringify({ dependencies: { '@angular/core': '^19.0.0', '@angular/router': '^19.0.0' } }),
'src/app/app.routes.ts': `import { Routes } from '@angular/router';
import { TasksComponent } from './tasks/tasks.component';
export const routes: Routes = [{ path: 'tasks', component: TasksComponent }];
`,
// An inline template: its bindings are written in the class's own file.
'src/app/nav/nav.component.ts': `import { Component, signal } from '@angular/core';
@Component({
selector: 'app-nav',
template: \`<nav>
<app-theme-toggle [class.open]="isOpen()" />
</nav>\`,
})
export class NavComponent {
isOpen = signal(false);
}
`,
'src/app/theme-toggle/theme-toggle.component.ts': `import { Component, computed, signal } from '@angular/core';
@Component({ selector: 'app-theme-toggle', templateUrl: './theme-toggle.component.html' })
export class ThemeToggleComponent {
private readonly theme = signal<'light' | 'dark'>('light');
icon = computed(() => (this.theme() === 'dark' ? 'moon' : 'sun'));
label = computed(() => (this.theme() === 'dark' ? 'Dark' : 'Light'));
cycle(): void {
this.theme.set(this.theme() === 'dark' ? 'light' : 'dark');
}
unused(): void {}
}
`,
'src/app/theme-toggle/theme-toggle.component.html': `<button (click)="cycle()" [attr.aria-label]="label()">
<lucide-icon [name]="icon()"></lucide-icon>
</button>
`,
'src/app/tasks/tasks.component.ts': `import { Component, computed, signal } from '@angular/core';
@Component({ selector: 'app-tasks', templateUrl: './tasks.component.html' })
export class TasksComponent {
lists = signal<{ id: number; title: string; items: { done: boolean }[] }[]>([]);
selectedListId = signal<number | null>(null);
selected = computed(() => this.lists().find((l) => l.id === this.selectedListId()));
editing = signal(false);
title = 'Tasks';
form = { reset() {} };
days = Array.from({ length: 3 }, (_, i) => i + 1);
row = computed(() => 0);
nameInput = (value: string) => value;
get canSave(): boolean {
return this.lists().length > 0;
}
remainingItems(list: { items: { done: boolean }[] }): number {
return list.items.filter((i) => !i.done).length;
}
trackById(index: number, list: { id: number }): number {
return list.id;
}
displayFn(list: { title: string }): string {
return list.title;
}
format(value: string): string {
return value;
}
reset(): void {}
set(): void {}
legacy(): string {
return '';
}
quoted(): string {
return '';
}
save(): void {}
}
`,
'src/app/tasks/tasks.component.html': `<!-- {{ legacy() }} -->
<h1 [title]="title">{{ 'quoted()' }}</h1>
<input #nameInput [value]="nameInput.value" (blur)="selected() && save()" />
@if (editing()) {
<span>editing</span>
}
<ul>
@for (list of lists(); track trackById($index, list)) {
<li [class.active]="list.id === selectedListId()" (click)="selectedListId.set(list.id)">
{{ list.title | format }} <small>{{ remainingItems(list) }}</small>
</li>
}
</ul>
@for (row of [selected()]; track row) {
<b>{{ row?.title }}</b>
}
@for (day of days; track day) {
<i>{{ day }}</i>
}
<mat-autocomplete [displayWith]="displayFn"></mat-autocomplete>
<button [disabled]="!canSave" (click)="form.reset()">Reset</button>
`,
};
for (const [rel, content] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
fs.writeFileSync(path.join(root, rel), content);
}
cg = await CodeGraph.init(root, { index: true });
});
afterAll(() => cg.close());
/** `Source -> target [kind] via @ file:line` for every template edge of one mechanism. */
const templateEdges = (synthesizedBy: string) =>
cg
.getNodesByKind('class')
.flatMap((c) =>
cg
.getOutgoingEdgesFrom([c.id], ['calls', 'references'])
.filter((e) => (e.metadata as Record<string, unknown> | undefined)?.synthesizedBy === synthesizedBy)
.map((e) => {
const meta = e.metadata as Record<string, unknown>;
const target = cg.getNode(e.target)!;
return `${c.name} -> ${target.kind} ${target.name} [${e.kind}${meta.fnRef ? ' fnRef' : ''}] ${String(meta.via)} @ ${String(meta.registeredAt)}`;
})
)
.sort();
it("links each binding's own-member call — and a callable it only reads — to the component, at the binding's line", () => {
expect(templateEdges('angular-binding')).toEqual([
'NavComponent -> property isOpen [calls] [class.open] @ src/app/nav/nav.component.ts:5',
// A getter runs when it is read.
'TasksComponent -> method canSave [calls] [disabled] @ src/app/tasks/tasks.component.html:21',
// A field a call filled is read, not called.
'TasksComponent -> method days [references] @for @ src/app/tasks/tasks.component.html:17',
// A function handed to a child is a function reference.
'TasksComponent -> method displayFn [references fnRef] [displayWith] @ src/app/tasks/tasks.component.html:20',
'TasksComponent -> method remainingItems [calls] {{ }} @ src/app/tasks/tasks.component.html:10',
'TasksComponent -> method trackById [calls] @for @ src/app/tasks/tasks.component.html:8',
// A signal is a field, and calling it reads it.
'TasksComponent -> property editing [calls] @if @ src/app/tasks/tasks.component.html:4',
'TasksComponent -> property lists [calls] @for @ src/app/tasks/tasks.component.html:8',
'TasksComponent -> property selectedListId [calls] [class.active] @ src/app/tasks/tasks.component.html:9',
'ThemeToggleComponent -> method icon [calls] [name] @ src/app/theme-toggle/theme-toggle.component.html:2',
'ThemeToggleComponent -> method label [calls] [attr.aria-label] @ src/app/theme-toggle/theme-toggle.component.html:1',
]);
// An inline template's edge sits on its own line of the class's file.
const nav = cg.getNodesByKind('class').find((c) => c.name === 'NavComponent')!;
expect(cg.getOutgoingEdgesFrom([nav.id], ['calls']).find((e) => cg.getNode(e.target)?.name === 'isOpen')?.line).toBe(5);
});
it('links nothing for a field read, a pipe, a member of another object, a comment, a string or a name the template declares', () => {
const tasks = cg.getNodesByKind('class').find((c) => c.name === 'TasksComponent')!;
const linked = cg.getOutgoingEdgesFrom([tasks.id], ['calls', 'references']).map((e) => cg.getNode(e.target)?.name);
for (const name of ['title', 'format', 'reset', 'set', 'legacy', 'quoted', 'row', 'nameInput']) expect(linked).not.toContain(name);
// A member the event bindings already link keeps that edge: `selected()` is both.
expect(templateEdges('angular-event')).toEqual([
'TasksComponent -> method save [calls] (blur) @ src/app/tasks/tasks.component.html:3',
'TasksComponent -> method selected [calls] (blur) @ src/app/tasks/tasks.component.html:3',
'ThemeToggleComponent -> method cycle [calls] (click) @ src/app/theme-toggle/theme-toggle.component.html:1',
]);
});
it('takes members a template uses off the dead-code list, and keeps the ones it does not', () => {
const listed = buildDeadCodeReport(cg, { limit: 100 }).entries.map((e) => e.node.name);
expect(listed).toContain('unused');
expect(listed).not.toContain('icon');
expect(listed).not.toContain('label');
expect(listed).not.toContain('remainingItems');
expect(listed).not.toContain('canSave');
});
it('draws a binding as render-time work in Steps, not as something the user triggers', async () => {
const screen = cg.getNodesByKind('route').find((r) => r.name === '/tasks')!;
const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: screen.id }));
const labels = payload.steps.map((s) => s.label);
expect(payload.steps.find((s) => s.label === 'save')?.trigger).toMatchObject({ kind: 'prop', name: '(blur)', of: 'input' });
for (const name of ['remainingItems', 'canSave', 'trackById', 'lists']) expect(labels).not.toContain(name);
});
});
describe('an Angular workspace split across libraries', () => {
const roots: string[] = [];
afterAll(() => {
+27 -5
View File
@@ -1,6 +1,6 @@
# Framework & language coverage — what is done, what is left
**Last verified: 2026-08-29** (Angular row: 2026-09-29) against the build at that date. Re-verify with the
**Last verified: 2026-08-29** (Angular row: 2026-10-06) against the build at that date. Re-verify with the
queries in [Checking this file is still true](#checking-this-file-is-still-true)
before trusting a row; this is a snapshot, not a live view.
@@ -42,7 +42,7 @@ guessed.
| TanStack Router | `frameworks/tanstack-router.ts` | `tanstack-router-synthesizer.ts` | `tanstack-router.test.ts` | TanStack examples, fastapi-template frontend |
| Vue Router / Nuxt | `frameworks/vue-router.ts` (Nuxt file routes: `nuxtResolver` in `frameworks/vue.ts`) | `vue-router-synthesizer.ts` | `vue-router.test.ts` | vue-realworld (23 edges); vue-element-admin (62 routes), vue-admin-template (14), vben (192), halo console (34) — named tables, module files, `children` + layouts; Nuxt: mealie, elk, nuxt/movies |
| SvelteKit | `frameworks/sveltekit-router.ts` | `sveltekit-synthesizer.ts` | `sveltekit-router.test.ts`, `sveltekit-route-names.test.ts` | sveltekit-realworld (31 edges); shadcn-svelte and skeleton (`(group)` layouts: 13 and 23 edges), svelte.dev (74), kit's test apps (47) |
| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders), Ghostfolio (189 edges, 170 renders), ngx-admin (routes and renders; its menus are config), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) |
| Angular | `frameworks/angular-router.ts` | `angular-template-synthesizer.ts` | `angular-router.test.ts` | angular-realworld (31 edges, 18 renders, 32 bindings), Ghostfolio (189 edges, 170 renders, 215 bindings), ngx-admin (routes and renders; its menus are config; 13 bindings), CleanArchitecture's `src/Web/ClientApp` (signals; 12 bindings), angular-spotify (Nx libs behind barrels: 14 routes), jira-clone (class-constant paths, mount-only redirects), jhipster (60), ionic-conference (18), Angular-JumpStart (18) |
Shared machinery all seven use, in `frameworks/expo-router.ts`: `RouteTable` /
`RootedRouteTable`, `routesForFile`, `addRouteTo`, `matchRoute`, `appRootFor`,
@@ -59,13 +59,35 @@ 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. 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
at the edge's line (the binding is in the template, not the source there).
A property binding, an interpolation, a structural directive or a
control-flow block that calls one of the component's own members
(`[name]="icon()"`, `{{ label() }}`, `*matRowDef="let row; columns:
displayedColumns()"`, `@if (loading())`, `@let total = price();`) is a
`calls` edge too (`angular-binding`, the binding as `via`), with no trigger:
it runs when the template renders, so Steps folds it into the screen rather
than drawing a handler the user fires — a trigger there drew `computed`
signals as user actions. A member read without a call counts only when it is
method-kind: a getter is called, a method handed to a child
(`[displayWith]="displayFn"`, `trackBy: trackById`) is a `references` edge
with `fnRef`, and a field a call filled (`days = Array.from(…)`) a plain
`references`. A property read (`[value]="title"`) links nothing, as in
TypeScript, which records no property reads either; on angular-realworld,
ngx-admin, Ghostfolio and CleanArchitecture it would have added four times
the edges and taken nothing off the Dead code list, which never asks about
properties. A pipe, an object key, a
microsyntax key (`of`, `trackBy:`, `index`), a template's own names (`let
item`, `as user`, `#ref`, `@let x`, `let-row`) and a commented-out binding
link nothing. 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
route with a custom `matcher` has no static address, a relative navigation
(`relativeTo`) is left unresolved, and an edit to a template file alone is
picked up at the next sync of any source file (templates are not watched).
(`relativeTo`) is left unresolved, an edit to a template file alone is
picked up at the next sync of any source file (templates are not watched), a
member a component inherits from a base class is not linked from its
template, and a template's local hides a same-named member for the rest of
the template rather than only inside its block.
---
+2
View File
@@ -209,6 +209,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
- **A file's outside imports are listed in more languages.** The file screen lists the imports that name nothing in your project, like a third-party package or a system header. In C and C++, Objective-C, JavaScript and TypeScript, Python, C#, Lua, R, Solidity, Svelte and Vue, an import that named no file had been linked to its own import statement, so it was listed nowhere. Re-index after upgrading.
- **What an Angular template uses is no longer dead code.** A signal, `computed` value, getter or method that a component's template calls from a property binding, an interpolation, a structural directive or a control-flow block — `[name]="icon()"`, `{{ label() }}`, `*matRowDef="let row; columns: displayedColumns()"`, `@if (loading())` — or hands to a child, like `[displayWith]="displayFn"`, now counts as reached, so it leaves the Dead code tab. Steps draws it as part of what the screen renders, not as something the user does. Re-index Angular projects after upgrading.
## Entries whose graph and `codegraph_explore` half already shipped
The release notes carry a reworded version of each of these. Keep only the viewer half at launch.
+485 -7
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 three
* it, or an inline `template:` string — that the index never parses, so four
* things a reader relies on were missing from the graph:
*
* - **The component tree.** `<app-article-list [config]="listConfig">` in the
@@ -23,6 +23,21 @@
* (`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.
* - **Bindings.** `[name]="icon()"`, `{{ label() }}`, `*ngIf="isOpen()"` and
* `@if (loading()) {` call the component's own members at every render —
* with signals (`icon = computed(…)`), the usual way a template reads
* state, and often a member's only caller. Each is a `calls` edge
* (`synthesizedBy: 'angular-binding'`, the binding as `via`) WITHOUT a
* trigger: the call runs when the template renders, not when a user acts,
* the way a JSX attribute's `name={icon()}` carries none. Reading a member
* without calling it counts only when the member is method-kind: a getter
* (`[disabled]="!canSave"`) runs, so it is called; a method handed to a
* child (`[displayWith]="displayFn"`, `trackBy: trackById`) is a function
* reference (`references`, `fnRef`), as `this.handler` passed as an
* argument is in TypeScript; a field a call filled (`days =
* Array.from(…)`) is a plain `references`. A property read
* (`[value]="title"`) links nothing — TypeScript records no property reads
* either, and the dead-code list does not ask about properties.
*
* 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
@@ -186,6 +201,430 @@ function plainHref(value: string): HrefLiteral | null {
return withHoles.startsWith('/') ? namesSomewhere(toHref(withHoles)) : null;
}
// =============================================================================
// What a template reads at render
// =============================================================================
/** `[name]="…"`, `[attr.aria-label]="…"`, `[class.done]="…"`, `[(ngModel)]="…"`: a binding whose value is an expression. */
const PROPERTY_BINDING = /\[(\([A-Za-z][\w.-]*\)|[A-Za-z@][\w.@-]*)\]\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
/** `*ngIf="…"`, `*ngFor="let item of items; trackBy: trackById"`: a structural directive, in its microsyntax. */
const STRUCTURAL_DIRECTIVE = /(?<![\w$*-])\*([A-Za-z][\w.-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
/** `{{ … }}`, in text or inside an attribute's value. */
const INTERPOLATION = /\{\{([\s\S]*?)\}\}/g;
/** A control-flow block that takes an expression: `@if (…) {`, `@else if (…)`, `@for (…)`, `@switch (…)`, `@case (…)`, `@defer (…)`. */
const CONTROL_FLOW = /(?<![\w$@])@(if|else\s+if|for|switch|case|defer)\s*\(/g;
/** `@let total = price() * quantity();` — a template's own constant. */
const LET_DECLARATION = /(?<![\w$@])@let\s+([A-Za-z_$][\w$]*)\s*=/g;
/** A template reference variable — `#nameInput`, `ref-nameInput` — which the whole template sees. */
const REFERENCE_VARIABLE = /(?<=\s)(?:#|ref-)([A-Za-z_$][\w$]*)(?=[\s=/>])/g;
/** `let-row`, `let-i="index"` on an `<ng-template>`: a local of what it renders. */
const TEMPLATE_INPUT = /(?<=\s)let-([A-Za-z_$][\w$]*)(?=[\s=/>])/g;
const HTML_COMMENT = /<!--[\s\S]*?-->/g;
/** A name an expression writes: `this.` may lead it; a member of something else (`form.reset`) is not one. */
const EXPRESSION_NAME = /(?<![\w$.])(?:this\s*\.\s*)?([A-Za-z_$][\w$]*)/g;
const NAME_AT = /[A-Za-z_$][\w$]*/y;
/** A name a template expression calls (`icon()`) or only reads (`canSave`, `displayFn`). */
export interface TemplateMemberUse {
name: string;
/** Offset of the name in the template. */
at: number;
call: boolean;
/** The binding the expression belongs to, as written: `[name]`, `{{ }}`, `*ngIf`, `@if`, `@let`. */
via: string;
}
/**
* One expression, and the scope it is read in. `scope` is the directive or
* block that evaluates it — the locals that scope declares are visible to it
* only where Angular says so (`@for`'s `track` sees the loop variable; the
* `@if` condition does not see its own `as` alias).
*/
interface TemplateExpression {
via: string;
at: number;
text: string;
scope: number;
seesOwnLocals: boolean;
}
/** A name the template declares — a local, never the component's member. Visible from `from` on. */
interface TemplateLocal {
name: string;
from: number;
/** The directive or block that declared it; -1 for a reference variable or a `let-` input. */
scope: number;
}
/**
* Each string literal blanked to spaces, so a quoted `'save()'` names nothing
* and offsets survive. A template literal keeps its `${…}` holes: they are
* expressions (`isToday(\`${key}-${formatDay(day)}\`)`).
*/
function blankStrings(text: string): string {
let out = '';
let i = 0;
while (i < text.length) {
const ch = text[i]!;
if (ch === '"' || ch === "'") {
const end = skipString(text, i);
const close = end < 0 ? text.length : end + 1;
out += ' '.repeat(close - i);
i = close;
} else if (ch === '`') {
out += ' ';
i++;
while (i < text.length) {
if (text[i] === '`') {
out += ' ';
i++;
break;
}
const close = text[i] === '$' && text[i + 1] === '{' ? matchBracket(text, i + 1) : -1;
if (close >= 0) {
out += ` ${blankStrings(text.slice(i + 2, close))} `;
i = close + 1;
continue;
}
const step = text[i] === '\\' ? Math.min(2, text.length - i) : 1;
out += ' '.repeat(step);
i += step;
}
} else {
out += ch;
i++;
}
}
return out;
}
const isBlank = (ch: string | undefined): boolean => ch !== undefined && /\s/.test(ch);
/** The names an expression calls or reads, minus a pipe's name and an object literal's keys. */
function expressionUses(expression: string): Array<{ name: string; at: number; call: boolean }> {
const text = blankStrings(expression);
const out: Array<{ name: string; at: number; call: boolean }> = [];
EXPRESSION_NAME.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = EXPRESSION_NAME.exec(text)) !== null) {
const name = m[1]!;
const end = m.index + m[0].length;
let before = m.index - 1;
while (isBlank(text[before])) before--;
let after = end;
while (isBlank(text[after])) after++;
const prev = text[before];
const next = text[after];
// `form .reset()`: a member of something else, however it is spaced.
if (prev === '.') continue;
// A pipe's name: `total | currency: code()` — but `a || b` is no pipe.
if (prev === '|' && text[before - 1] !== '|') continue;
// An object literal's key: `[ngClass]="{ active: isActive() }"`.
if ((prev === '{' || prev === ',') && next === ':') continue;
out.push({ name, at: end - name.length, call: next === '(' });
}
return out;
}
/**
* Where the expression starting at `from` ends: a `;` or `,` outside any
* bracket, or the words `as` / `let` that open the next microsyntax binding.
*/
function expressionEnd(text: string, from: number): number {
let depth = 0;
for (let i = from; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"' || ch === "'" || ch === '`') {
const end = skipString(text, i);
if (end < 0) return text.length;
i = end;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') depth++;
else if (ch === ')' || ch === ']' || ch === '}') depth--;
else if (depth === 0 && (ch === ';' || ch === ',')) return i;
else if (depth === 0 && isBlank(text[i - 1]) && /^(?:as|let)(?![\w$])/.test(text.slice(i, i + 4))) return i;
}
return text.length;
}
/** `text` split at each `;` outside brackets and strings, with each part's offset. */
function topLevelParts(text: string): Array<{ at: number; text: string }> {
const parts: Array<{ at: number; text: string }> = [];
let start = 0;
let depth = 0;
for (let i = 0; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"' || ch === "'" || ch === '`') {
const end = skipString(text, i);
if (end < 0) break;
i = end;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') depth++;
else if (ch === ')' || ch === ']' || ch === '}') depth--;
else if (ch === ';' && depth === 0) {
parts.push({ at: start, text: text.slice(start, i) });
start = i + 1;
}
}
parts.push({ at: start, text: text.slice(start) });
return parts;
}
/**
* A structural directive's microsyntax, read into the expressions it
* evaluates and the locals it declares: `let item of items; let i = index;
* trackBy: trackById` evaluates `items` and `trackById` and declares `item`
* and `i`. A key (`of`, `trackBy:`, `else`) and the context name a binding
* takes (`index`, `index as i`) belong to the directive, never the component.
*/
export function readMicrosyntax(text: string): { expressions: Array<{ at: number; text: string }>; locals: string[] } {
const expressions: Array<{ at: number; text: string }> = [];
const locals: string[] = [];
let i = 0;
const skip = (separators: boolean) => {
while (i < text.length && (isBlank(text[i]) || (separators && (text[i] === ';' || text[i] === ',')))) i++;
};
const word = (): string | null => {
NAME_AT.lastIndex = i;
const m = NAME_AT.exec(text);
if (!m) return null;
i += m[0].length;
return m[0];
};
let first = true;
while (i < text.length) {
skip(true);
if (i >= text.length) break;
const start = i;
const key = word();
if (key === 'let' && isBlank(text[i])) {
// `let item`, `let i = index`: a local, and the context name it takes.
skip(false);
const local = word();
if (local) locals.push(local);
skip(false);
if (text[i] === '=') {
i++;
skip(false);
word();
}
first = false;
continue;
}
if (first || key === null) {
// The directive's own expression (`*ngIf="cond"`) has no key.
i = start;
} else {
skip(false);
if (text[i] === ':') i++;
skip(false);
}
const end = expressionEnd(text, i);
if (end > i) expressions.push({ at: i, text: text.slice(i, end) });
i = end;
skip(false);
if (/^as(?![\w$])/.test(text.slice(i, i + 3))) {
i += 2;
skip(false);
const alias = word();
if (alias) locals.push(alias);
}
// Nothing read (a stray character): step past it.
if (i === start) i++;
first = false;
}
return { expressions, locals };
}
/** The `;` that ends a `@let` declaration's expression, or -1. */
function statementEnd(text: string, from: number): number {
let depth = 0;
for (let i = from; i < text.length; i++) {
const ch = text[i]!;
if (ch === '"' || ch === "'" || ch === '`') {
const end = skipString(text, i);
if (end < 0) return -1;
i = end;
continue;
}
if (ch === '(' || ch === '[' || ch === '{') depth++;
else if (ch === ')' || ch === ']' || ch === '}') {
if (--depth < 0) return -1;
} else if (ch === ';' && depth === 0) return i;
}
return -1;
}
/**
* Every name a template's bindings call or read at render — property
* bindings, interpolations, structural directives, control flow and `@let` —
* in template order, minus what the template names itself: a pipe, an object
* key, a microsyntax key, and any local it declares (`let item`, `as user`,
* `#input`, `@let total`). Event bindings are not here: they run when the
* user acts, and are read on their own. A commented-out binding reads nothing.
*/
export function templateMemberUses(template: string): TemplateMemberUse[] {
const text = template.replace(HTML_COMMENT, (c) => c.replace(/[^\n]/g, ' '));
const expressions: TemplateExpression[] = [];
const locals: TemplateLocal[] = [];
let scope = 0;
const valueAt = (m: RegExpExecArray, value: string) => m.index + m[0].length - 1 - value.length;
const tagStart = (at: number) => Math.max(0, text.lastIndexOf('<', at));
PROPERTY_BINDING.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = PROPERTY_BINDING.exec(text)) !== null) {
const value = m[2] ?? m[3] ?? '';
expressions.push({ via: `[${m[1]!}]`, at: valueAt(m, value), text: value, scope: ++scope, seesOwnLocals: false });
}
STRUCTURAL_DIRECTIVE.lastIndex = 0;
while ((m = STRUCTURAL_DIRECTIVE.exec(text)) !== null) {
const value = m[2] ?? m[3] ?? '';
const at = valueAt(m, value);
const own = ++scope;
const read = readMicrosyntax(value);
for (const e of read.expressions) expressions.push({ via: `*${m[1]!}`, at: at + e.at, text: e.text, scope: own, seesOwnLocals: false });
// The host element and everything inside it see the directive's locals.
for (const name of read.locals) locals.push({ name, from: tagStart(m.index), scope: own });
}
INTERPOLATION.lastIndex = 0;
while ((m = INTERPOLATION.exec(text)) !== null) {
expressions.push({ via: '{{ }}', at: m.index + 2, text: m[1]!, scope: ++scope, seesOwnLocals: false });
}
CONTROL_FLOW.lastIndex = 0;
while ((m = CONTROL_FLOW.exec(text)) !== null) {
const open = m.index + m[0].length - 1;
const close = matchBracket(text, open);
if (close < 0) continue;
const kind = m[1]!.replace(/\s+/g, ' ');
const via = `@${kind}`;
const own = ++scope;
const parts = topLevelParts(text.slice(open + 1, close)).map((p) => ({ at: open + 1 + p.at, text: p.text }));
if (kind === 'switch' || kind === 'case') {
for (const p of parts) expressions.push({ via, at: p.at, text: p.text, scope: own, seesOwnLocals: false });
} else if (kind === 'defer') {
// `when cond` is an expression; `on viewport`, `on timer(5s)` are triggers.
for (const p of parts) {
const when = /^\s*(?:(?:prefetch|hydrate)\s+)?when(?![\w$])/.exec(p.text);
if (when) expressions.push({ via, at: p.at + when[0].length, text: p.text.slice(when[0].length), scope: own, seesOwnLocals: false });
}
} else if (kind === 'for') {
// `item of items(); track item.id; let i = $index, odd = $odd`
const [head, ...rest] = parts;
const loop = head ? /^(\s*)([A-Za-z_$][\w$]*)\s+of(?![\w$])/.exec(head.text) : null;
if (head && loop) {
locals.push({ name: loop[2]!, from: m.index, scope: own });
expressions.push({ via, at: head.at + loop[0].length, text: head.text.slice(loop[0].length), scope: own, seesOwnLocals: false });
}
for (const p of rest) {
const track = /^\s*track(?![\w$])/.exec(p.text);
if (track) {
expressions.push({ via, at: p.at + track[0].length, text: p.text.slice(track[0].length), scope: own, seesOwnLocals: true });
continue;
}
if (/^\s*let(?![\w$])/.test(p.text)) {
for (const alias of p.text.matchAll(/([A-Za-z_$][\w$]*)\s*=\s*\$?[\w$]+/g)) locals.push({ name: alias[1]!, from: m.index, scope: own });
}
}
} else {
// `@if (user(); as user)`: the condition, then the alias its body sees.
const [head, ...rest] = parts;
if (head) expressions.push({ via, at: head.at, text: head.text, scope: own, seesOwnLocals: false });
for (const p of rest) {
const alias = /^\s*as\s+([A-Za-z_$][\w$]*)/.exec(p.text);
if (alias) locals.push({ name: alias[1]!, from: m.index, scope: own });
}
}
}
LET_DECLARATION.lastIndex = 0;
while ((m = LET_DECLARATION.exec(text)) !== null) {
const at = m.index + m[0].length;
const end = statementEnd(text, at);
if (end < 0) continue;
const own = ++scope;
expressions.push({ via: '@let', at, text: text.slice(at, end), scope: own, seesOwnLocals: false });
locals.push({ name: m[1]!, from: m.index, scope: own });
}
REFERENCE_VARIABLE.lastIndex = 0;
while ((m = REFERENCE_VARIABLE.exec(text)) !== null) locals.push({ name: m[1]!, from: 0, scope: -1 });
TEMPLATE_INPUT.lastIndex = 0;
while ((m = TEMPLATE_INPUT.exec(text)) !== null) locals.push({ name: m[1]!, from: tagStart(m.index), scope: -1 });
const uses: TemplateMemberUse[] = [];
for (const e of expressions) {
for (const u of expressionUses(e.text)) {
const at = e.at + u.at;
const shadowed = locals.some((l) => l.name === u.name && l.from <= at && (l.scope !== e.scope || e.seesOwnLocals));
if (!shadowed) uses.push({ name: u.name, at, call: u.call, via: e.via });
}
}
return uses.sort((a, b) => a.at - b.at);
}
/**
* What a method-kind member's own declaration says it is: an accessor (`get
* canSave() {`), a function (`displayFn(p) {`, `onPick = (p) => …`), or a
* field holding what a call returned (`days = Array.from({ length: 31 }, (_,
* i) => i + 1)`, `total = computed(() => …)`) — a method only because a
* callback is written in its initializer, and as likely plain data.
*/
type MemberShape = 'get' | 'set' | 'function' | 'value';
function memberShape(lines: readonly string[], member: Node): MemberShape {
const head = lines
.slice(member.startLine - 1, member.startLine + 4)
.join('\n')
.slice(member.startColumn);
let from = 0;
for (;;) {
const at = head.indexOf(member.name, from);
if (at < 0) return 'function';
from = at + member.name.length;
// The name itself — not a longer name, and not a decorator's `'value'` argument.
if (/[\w$'"`]/.test(head[at - 1] ?? '') || /[\w$'"`]/.test(head[from] ?? '')) continue;
const accessor = /(?<![\w$])(get|set)\s+$/.exec(head.slice(0, at));
if (accessor) return accessor[1] as 'get' | 'set';
// `name = …`, `name: Type = …` (a function type's `=>` is not the `=`).
const field = /^[?!]?\s*(?::[^=;]*(?:=>[^=;]*)*)?=(?![=>])\s*/.exec(head.slice(from));
if (!field) return 'function';
const value = head.slice(from + field[0].length).replace(/^async\s+/, '');
if (/^(?:function(?![\w$])|[A-Za-z_$][\w$]*\s*=>)/.test(value)) return 'function';
const close = value.startsWith('(') ? matchBracket(value, 0) : -1;
return close > 0 && /^\s*(?::[^=]*)?=>/.test(value.slice(close + 1)) ? 'function' : 'value';
}
}
/**
* The member one use links, and how. A call links what it calls: a method,
* or a signal held in a field (`count = signal(0)` is a property). A name
* read without a call links only a method-kind member — a getter runs when
* it is read, so it is called; a function handed over uncalled
* (`[displayWith]="displayFn"`) is a function reference; a field a call
* filled (`@for (day of days)`) is a plain reference. Same-named methods
* resolve as the event bindings' do: the last declared (an overload's body).
*/
function bindingTarget(
candidates: readonly Node[] | undefined,
call: boolean,
shape: (n: Node) => MemberShape
): { node: Node; kind: 'calls' | 'references'; fnRef: boolean } | null {
if (!candidates) return null;
const methods = candidates.filter((n) => n.kind === 'method');
if (call) {
const target =
[...methods].reverse().find((n) => shape(n) === 'function' || shape(n) === 'value') ??
methods[0] ??
candidates.find((n) => n.kind === 'property');
return target ? { node: target, kind: 'calls', fnRef: false } : null;
}
const getter = methods.find((n) => shape(n) === 'get');
if (getter) return { node: getter, kind: 'calls', fnRef: false };
const read = [...methods].reverse().find((n) => shape(n) !== 'set');
return read ? { node: read, kind: 'references', fnRef: shape(read) === 'function' } : null;
}
// =============================================================================
// The pass
// =============================================================================
@@ -250,12 +689,12 @@ 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])
);
const own = ctx
.getNodesInFile(component.file)
.filter((n) => (n.kind === 'method' || n.kind === 'property') && n.qualifiedName.startsWith(`${component.node.qualifiedName}::`));
const members = new Map(own.filter((n) => n.kind === 'method').map((n) => [n.name, n]));
/** Members this template already links — only the first edge between two nodes survives the merge. */
const linked = new Set<string>();
let handlers = 0;
EVENT_BINDING.lastIndex = 0;
let ev: RegExpExecArray | null;
@@ -271,6 +710,7 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb
const key = `${component.node.id}>${method.id}>${event}`;
if (seen.has(key)) continue;
seen.add(key);
linked.add(method.id);
handlers++;
edges.push({
source: component.node.id,
@@ -288,6 +728,44 @@ export async function angularTemplateEdges(ctx: ResolutionContext, onYield: Mayb
}
}
// Bindings: `[name]="icon()"`, `{{ label() }}`, `@if (loading())` read the
// component every time it renders, so the hop carries no trigger. At most
// one edge per member, so the class's own size bounds them.
let declaration: string[] | null = null;
const shapes = new Map<string, MemberShape>();
const shape = (n: Node): MemberShape => {
const known = shapes.get(n.id);
if (known) return known;
declaration ??= (ctx.readFile(component.file) ?? '').split('\n');
const read = memberShape(declaration, n);
shapes.set(n.id, read);
return read;
};
const named = new Map<string, Node[]>();
for (const n of own) {
const list = named.get(n.name);
if (list) list.push(n);
else named.set(n.name, [n]);
}
for (const use of templateMemberUses(text)) {
const found = bindingTarget(named.get(use.name), use.call, shape);
if (!found || linked.has(found.node.id)) continue;
linked.add(found.node.id);
edges.push({
source: component.node.id,
target: found.node.id,
kind: found.kind,
line: siteLine(use.at),
provenance: 'heuristic',
metadata: {
synthesizedBy: 'angular-binding',
via: use.via,
registeredAt: registeredAt(use.at),
...(found.fnRef ? { fnRef: true } : {}),
},
});
}
const routes = angularRoutesFor(table, component.file);
if (!routes || routes.exact.size === 0) continue;
let links = 0;