From 9a274a4b47e2189452560f35bc6147c5d08ecacb Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 7 Oct 2026 00:33:38 +0000 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + __tests__/angular-router.test.ts | 258 +++++++++ docs/design/framework-coverage.md | 32 +- docs/viewer-launch-changelog.md | 2 + .../angular-template-synthesizer.ts | 492 +++++++++++++++++- 5 files changed, 773 insertions(+), 12 deletions(-) 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;