diff --git a/CHANGELOG.md b/CHANGELOG.md
index a474c4953..700436a78 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/__tests__/angular-router.test.ts b/__tests__/angular-router.test.ts
index 52c13ca47..20f9e236a 100644
--- a/__tests__/angular-router.test.ts
+++ b/__tests__/angular-router.test.ts
@@ -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(``)).toEqual([
+ 'label() [attr.aria-label]',
+ 'icon() [name]',
+ ]);
+ expect(uses(`
{{ count() }} {{ total }}
`)).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; `)).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() }} `)).toEqual([
+ 'total {{ }}',
+ 'code() {{ }}',
+ 'isActive() [ngClass]',
+ 'form [x]',
+ 'own() [x]',
+ ]);
+ expect(uses(` {{ 'quoted()' }} `)).toEqual([]);
+ // A template literal's text is a string; its holes are expressions.
+ expect(uses('
')).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(`
{{ i }}
`)).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(`
{{ user.name }}
`)).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(``)).toEqual([]);
+ expect(uses(`{{ row.name }} {{ i }}`)).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 = {
+ '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: \`\`,
+})
+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': `
+`,
+ '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(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': `
+
{{ 'quoted()' }}
+
+@if (editing()) {
+ editing
+}
+
+ @for (list of lists(); track trackById($index, list)) {
+
+ {{ list.title | format }} {{ remainingItems(list) }}
+
+ }
+
+@for (row of [selected()]; track row) {
+ {{ row?.title }}
+}
+@for (day of days; track day) {
+ {{ day }}
+}
+
+
+`,
+ };
+ 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 | undefined)?.synthesizedBy === synthesizedBy)
+ .map((e) => {
+ const meta = e.metadata as Record;
+ 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(() => {
diff --git a/docs/design/framework-coverage.md b/docs/design/framework-coverage.md
index cbd66ffef..e08bb4426 100644
--- a/docs/design/framework-coverage.md
+++ b/docs/design/framework-coverage.md
@@ -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.
---
diff --git a/docs/viewer-launch-changelog.md b/docs/viewer-launch-changelog.md
index 034246279..f7a417413 100644
--- a/docs/viewer-launch-changelog.md
+++ b/docs/viewer-launch-changelog.md
@@ -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.
diff --git a/src/resolution/angular-template-synthesizer.ts b/src/resolution/angular-template-synthesizer.ts
index 9ec76c1e5..b5b7ce206 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 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.** `` 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 = /(?])/g;
+/** `let-row`, `let-i="index"` on an ``: a local of what it renders. */
+const TEMPLATE_INPUT = /(?<=\s)let-([A-Za-z_$][\w$]*)(?=[\s=/>])/g;
+const HTML_COMMENT = //g;
+/** A name an expression writes: `this.` may lead it; a member of something else (`form.reset`) is not one. */
+const EXPRESSION_NAME = /(?= 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 = /(?` 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();
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();
+ 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();
+ 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;