Skip to content

Commit 4d3caa6

Browse files
committed
feat(core): implement Angular DI graph in-page AI tool
This creates a new `angular:di-graph` in-page tool which returns the entire dependency injection graph for the application. We use the following rough algorithm for discovering all element injectors: 1. Find all root `LView` objects by querying for `[ng-version]`. 2. Walk all the transitive `LView` descendants of the roots. 3. Filter these `LView` objects to just directives. 4. Find the injector for a given directive and walk up its ancestors to find all element injectors. Discovering environment injectors works mostly the same way, just following the environment injector graph instead. This approach has a few known limitations which are out of scope for the moment: 1. Any given component typically has both an element injector *and* an environment injector. The relationship of "component -> environment injector" is not expressed in the result as of now, meaning the AI doesn't really have any insight into _which_ environment injector is being used for a particular component, though the injector will be one of the returned values. 2. The implementation does not support MFE use cases of multiple applications on the page at the same time. 3. The performance is not ideal, as we walk `LView` descendants twice and walk up the injector tree for every directive, repeatedly covering the same scope (ideally we'd just walk up every *leaf* directive, which would cover the same result for less effort). However for a debug tool, this is likely fine for now and we can optimize later if/when it becomes necessary. I did consider reusing more of the existing implementation in `global_utils` which exists to support Angular DevTools (we are already using some of it), however the existing support in `@angular/core` is actually fairly limited, returning very primitive data structures and relying on Angular DevTools to do the heavier lifting of collapsing the code into a usable graph representation. There's a potential path in the future to converge these implementations and potentially have `global_utils` use some of this code instead, but I will leave that for a future cleanup effort.
1 parent a7d04c9 commit 4d3caa6

4 files changed

Lines changed: 977 additions & 1 deletion

File tree

packages/core/src/debug/ai/di.ts

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
9+
import {getLContext} from '../../render3/context_discovery';
10+
import {NodeInjector} from '../../render3/di';
11+
import {TDirectiveHostNode, TNode} from '../../render3/interfaces/node';
12+
import {INJECTOR, LView, T_HOST, TVIEW, TViewType} from '../../render3/interfaces/view';
13+
import {DiGraph, SerializedInjector, serializeInjector} from './tree';
14+
import {ChainedInjector} from '../../render3/chained_injector';
15+
import {Injector} from '../../di/injector';
16+
import {ToolDefinition} from './tool_definitions';
17+
import {getLViewParent} from '../../render3/util/view_utils';
18+
import {R3Injector} from '../../di/r3_injector';
19+
import {NullInjector} from '../../di/null_injector';
20+
import {walkLViewDirectives} from '../../render3/util/view_traversal_utils';
21+
22+
/** Tool that exposes Angular's DI graph to AI agents. */
23+
export const diGraphTool: ToolDefinition<{}, DiGraph> = {
24+
name: 'angular:di_graph',
25+
// tslint:disable-next-line:no-toplevel-property-access
26+
description: `
27+
Exposes the Angular Dependency Injection (DI) graph of the application.
28+
29+
This tool extracts both the element injector tree (associated with DOM elements and components)
30+
and the environment injector tree (associated with modules and standalone application roots).
31+
It captures the relationship structure and the providers resolved at each level.
32+
33+
Returns:
34+
- \`elementInjectorRoots\`: An array of root element injectors (one for each Angular application
35+
root found). Each node forms a tree hierarchy:
36+
- \`name\`: The constructor name of the injector.
37+
- \`type\`: 'element'.
38+
- \`providers\`: Array of providers configured on that specific node.
39+
- \`token\`: The DI token requested.
40+
- \`value\`: The resolved value of that provider if it was instantiated.
41+
- \`hostElement\`: The DOM element that this injector is associated with.
42+
- \`children\`: Array of child element injectors.
43+
- \`environmentInjectorRoot\`: The root environment injector. It forms a tree hierarchy of nodes
44+
representing all environment injectors:
45+
- \`name\`: The identifier for the environment injector.
46+
- \`type\`: 'environment' or 'null'.
47+
- \`providers\`: Array of providers configured on that injector.
48+
- \`children\`: Array of child environment injectors.
49+
`.trim(),
50+
inputSchema: {
51+
type: 'object',
52+
properties: {},
53+
},
54+
execute: async () => {
55+
const roots = Array.from(document.querySelectorAll('[ng-version]')) as HTMLElement[];
56+
if (roots.length === 0) {
57+
throw new Error('Could not find Angular root element ([ng-version]) on the page.');
58+
}
59+
return discoverDiGraph(roots);
60+
},
61+
};
62+
63+
/**
64+
* Traverses the Angular internal tree from the root to discover element and environment injectors.
65+
*/
66+
function discoverDiGraph(roots: HTMLElement[]): DiGraph {
67+
const rootLViews = roots.map((root) => {
68+
const lContext = getLContext(root);
69+
if (!lContext?.lView) {
70+
throw new Error(
71+
`Could not find an \`LView\` for root \`<${root.tagName.toLowerCase()}>\`, is it an Angular component?`,
72+
);
73+
}
74+
return lContext.lView;
75+
});
76+
77+
return {
78+
elementInjectorRoots: rootLViews.map((rootLView) => walkElementInjectors(rootLView)),
79+
environmentInjectorRoot: collectEnvInjectors(rootLViews),
80+
};
81+
}
82+
83+
/**
84+
* Traverses all directive-hosting nodes in the `rootLView` hierarchy and builds a tree of
85+
* serialized element injectors.
86+
*
87+
* This function uses `walkLViewDirectives` to visit nodes in depth-first order and a stack
88+
* to reconstruct the hierarchical tree of injectors, handling both same-view and cross-view
89+
* relationships.
90+
*
91+
* @param rootLView The root view to start traversal from.
92+
* @returns The root {@link SerializedInjector} object.
93+
*/
94+
function walkElementInjectors(rootLView: LView): SerializedInjector {
95+
// Assert that we were given a root `LView` rather than a random component.
96+
// A root component actually gets two `LView` objects, the "root `LView`" with
97+
// `type === TViewType.Root` and then an `LView` for the component itself as a child.
98+
if (rootLView[TVIEW].type !== TViewType.Root) {
99+
throw new Error(`Expected a root LView but got type: \`${rootLView[TVIEW].type}\`.`);
100+
}
101+
102+
// Track the injectors we're currently processing.
103+
const stack: Array<[TNode, LView, SerializedInjector]> = [];
104+
105+
// By constraining `rootLView` to only accepting root `LView` objects, we don't have to
106+
// process `rootLView` itself, knowing that it won't be a component or directive.
107+
// We can just check its descendants.
108+
for (const [tNode, lView] of walkLViewDirectives(rootLView)) {
109+
const injector = new NodeInjector(tNode as TDirectiveHostNode, lView);
110+
const serialized = serializeInjector(injector);
111+
112+
// Look for our nearest ancestor in the stack.
113+
while (stack.length > 0) {
114+
const [lastTNode, lastLView, lastInjector] = stack[stack.length - 1];
115+
116+
const isDescendantInSameView = isTNodeDescendant(tNode, lastTNode);
117+
const isDescendantInDifferentView = isLViewDescendantOfTNode(lView, lastLView, lastTNode);
118+
if (isDescendantInSameView || isDescendantInDifferentView) {
119+
// This injector is a child of the current last injector in the stack.
120+
lastInjector.children.push(serialized);
121+
break;
122+
} else {
123+
stack.pop();
124+
}
125+
}
126+
127+
// Future injectors might be children of this one.
128+
stack.push([tNode, lView, serialized]);
129+
}
130+
131+
// Since all component/directive LViews are descendants of the root LView, the first
132+
// item on the stack must still remain and will be the root injector.
133+
if (stack.length === 0) {
134+
throw new Error(`Expected at least one component/directive in the root \`LView\`.`);
135+
}
136+
const [, , rootInjector] = stack[0];
137+
return rootInjector;
138+
}
139+
140+
/**
141+
* Collects and serializes all environment injectors found in the hierarchy of the given
142+
* `rootLViews`.
143+
*
144+
* Injectors have pointers to their parents, but not their children, so walking "down" the
145+
* hierarchy is not a generally supported operation.
146+
*
147+
* The function walks down the `LView` hierarchy to find all the component/directive descendants.
148+
* For each one, it then walks back up the injector hierarchy to find the full set of environment
149+
* injectors.
150+
*
151+
* @param rootLViews The root views to start traversal from.
152+
* @returns The root {@link SerializedInjector} object containing the entire environment
153+
* injector tree.
154+
*/
155+
function collectEnvInjectors(rootLViews: LView[]): SerializedInjector {
156+
const serializedEnvInjectorMap = new Map<Injector, SerializedInjector>();
157+
let rootEnvInjector: SerializedInjector | undefined = undefined;
158+
159+
/**
160+
* Serialize all the ancestors of the given injector and return
161+
* its serialized version.
162+
*
163+
* @param injector The environment injector to start from.
164+
* @returns The serialized form of the input {@link Injector}.
165+
*/
166+
function serializeAncestors(injector: Injector): SerializedInjector {
167+
const existing = serializedEnvInjectorMap.get(injector);
168+
if (existing) return existing;
169+
170+
const serialized = serializeInjector(injector);
171+
serializedEnvInjectorMap.set(injector, serialized);
172+
173+
const parentInjector = getParentEnvInjector(injector);
174+
if (parentInjector) {
175+
// Recursively process the parent and attach ourselves as a child.
176+
const parentSerialized = serializeAncestors(parentInjector);
177+
parentSerialized.children.push(serialized);
178+
} else {
179+
// If there is no parent, this is a root environment injector.
180+
if (!rootEnvInjector) {
181+
rootEnvInjector = serialized;
182+
} else if (rootEnvInjector !== serialized) {
183+
throw new Error('Expected only one root environment injector, but found multiple.', {
184+
cause: {firstRoot: rootEnvInjector, secondRoot: serialized},
185+
});
186+
}
187+
}
188+
189+
return serialized;
190+
}
191+
192+
// Process all descendant environment injectors.
193+
for (const rootLView of rootLViews) {
194+
for (const [, lView] of walkLViewDirectives(rootLView)) {
195+
serializeAncestors(lView[INJECTOR]);
196+
}
197+
}
198+
199+
if (!rootEnvInjector) {
200+
throw new Error('Expected a root environment injector but did not find one.');
201+
}
202+
203+
return rootEnvInjector;
204+
}
205+
206+
/**
207+
* Checks if `node` is a descendant of `ancestor` within the SAME view.
208+
*
209+
* Since we are in the same view, we can safely use `tNode.parent` to determine
210+
* if `ancestor` is an ancestor of the current `node`.
211+
*/
212+
function isTNodeDescendant(node: TNode, ancestor: TNode): boolean {
213+
let curr: TNode | null = node;
214+
while (curr) {
215+
if (curr === ancestor) return true;
216+
curr = curr.parent;
217+
}
218+
return false;
219+
}
220+
221+
/**
222+
* Checks if `lView` is a descendant of `parentTNode` in `parentLView` (crossing view boundaries).
223+
*
224+
* `tNode.parent` is restricted to referring to nodes within the SAME view. When we cross
225+
* view boundaries (e.g., entering a component's internal view or an embedded view like `@if`),
226+
* `tNode.parent` becomes `null` or points to something inside that view, breaking the chain to the
227+
* outside.
228+
*
229+
* To solve this, we use the `LView` hierarchy to find if the current view is a descendant of the
230+
* `parentLView`.
231+
*/
232+
function isLViewDescendantOfTNode(lView: LView, parentLView: LView, parentTNode: TNode): boolean {
233+
let currentLView: LView | null = lView;
234+
let hostTNode: TNode | null = null;
235+
236+
while (currentLView && currentLView !== parentLView) {
237+
hostTNode = currentLView[T_HOST];
238+
currentLView = getLViewParent(currentLView);
239+
}
240+
241+
return (
242+
currentLView === parentLView && hostTNode !== null && isTNodeDescendant(hostTNode, parentTNode)
243+
);
244+
}
245+
246+
/** Find the parent environment injector of the given injector. */
247+
function getParentEnvInjector(injector: Injector): Injector | undefined {
248+
if (injector instanceof ChainedInjector) {
249+
// We skip `chainedInjector.injector` because that points at the parent element injector
250+
// which is handled by `walkElementInjectors`.
251+
const chainedInjector = injector;
252+
return chainedInjector.parentInjector;
253+
} else if (injector instanceof R3Injector) {
254+
return injector.parent;
255+
} else if (injector instanceof NullInjector) {
256+
return undefined;
257+
} else {
258+
throw new Error(`Unknown injector type: "${injector.constructor.name}".`);
259+
}
260+
}

packages/core/src/debug/ai/registration.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* found in the LICENSE file at https://angular.dev/license
77
*/
88

9+
import {diGraphTool} from './di';
910
import {signalGraphTool} from './signal_graph';
1011
import {DevtoolsToolDiscoveryEvent} from './tool_definitions';
1112

@@ -25,7 +26,7 @@ export function registerAiTools(): () => void {
2526
const event = inputEvent as DevtoolsToolDiscoveryEvent;
2627
event.respondWith({
2728
name: 'Angular',
28-
tools: [signalGraphTool],
29+
tools: [diGraphTool, signalGraphTool],
2930
});
3031
}
3132

0 commit comments

Comments
 (0)