mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user