diff --git a/apps/docs/src/content/agents/mcp-server.md b/apps/docs/src/content/agents/mcp-server.md
index 9bb6336c..884cbbe4 100644
--- a/apps/docs/src/content/agents/mcp-server.md
+++ b/apps/docs/src/content/agents/mcp-server.md
@@ -255,7 +255,7 @@ A tool answers with a JSON object, sent as text. Most tools answer `{ "markdown"
| ----------------------------------------------------------------------------------------------------------- | ------------------------------ |
| Every tool whose name starts with `list-` or `inspect-` | `{ "markdown": "..." }` |
| `analog-routes`, `analog-api-routes`, `analog-server-functions`, `analog-server-calls` and `analog-content` | `{ "markdown": "..." }` |
-| `wait-for` | `{ "markdown": "..." }` |
+| `wait-for` and `lint-templates` | `{ "markdown": "..." }` |
| `build-meta` | The project and version fields |
| `snapshot` | `{ "markdown": "..." }` |
diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md
index 1c90db47..9b11d0ef 100644
--- a/apps/docs/src/content/agents/tools.md
+++ b/apps/docs/src/content/agents/tools.md
@@ -4,7 +4,7 @@ description: Every agent tool the devtools expose, grouped by inspector, with wh
---
- Ninety-seven tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
+ Ninety-eight tools, grouped by inspector. Each one answers a question you would otherwise answer by clicking through the panel.
# Tools
@@ -231,6 +231,28 @@ The page reads the blocks with `ng.ɵgetControlFlowBlocks`, which Angular 22.0 a
Each page lists at most 50 blocks, with a note on how many more are not shown, and says when the page has more than the 500 blocks it reports. The whole answer stops at 20,000 characters. If several tabs report, pass `page` to read one. The **@for blocks** list on the [Components](../inspectors/components.md#for-blocks) page has every block.
+### lint-templates
+
+Checks the component templates in your source, inline `template` and `templateUrl` files, with the project's own `@angular/compiler`. Reads: source.
+
+| Argument | Required | Value |
+| ---------- | -------- | ---------------------------------------------------------------------- |
+| `rule` | no | Only findings of this rule, one of the ids below. |
+| `file` | no | Only findings in files whose path contains this text, such as `cart/`. |
+| `severity` | no | Only findings of this severity: `error`, `warning` or `info`. |
+| `limit` | no | How many findings to list, 1 to 200. Defaults to 50. |
+
+| Rule | Severity | Finds |
+| ---------------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `template-parse-error` | `error` | An error the template parser reports, such as an unclosed element, a broken expression or `@for` without `track`. |
+| `track-by-index` | `warning` | `@for` that tracks by `$index` (or an alias of it) while its body reads fields of the item, so the items are objects. Rows that bind a form directive, such as `[formControlName]="$index"` or `[formField]`, are skipped. |
+| `img-missing-alt` | `warning` | `` without `alt`, `[alt]` or `[attr.alt]`, unless it has `role="presentation"`, `role="none"` or `aria-hidden="true"`. |
+| `click-without-key` | `warning` | `(click)` on a plain element such as `
` or `` with no `keydown`, `keyup` or `keypress` handler. Buttons, links, form controls and components are skipped. |
+| `legacy-control-flow` | `info` | `*ngIf`, `*ngFor`, `*ngSwitchCase` or `*ngSwitchDefault` where `@if`, `@for` or `@switch` would do. Skipped before Angular 17. |
+| `ngclass-ngstyle` | `info` | `ngClass` or `ngStyle` where `[class]` or `[style]` bindings would do. |
+
+Each finding gives its severity, rule, `file:line` and a fix. Errors come first. A template whose text has not changed since the last call is not parsed again. A backtick template built with `${}` is skipped. If the project has no `@angular/compiler` installed, the answer says so and lists nothing.
+
### hydration Action
Hydration of each page that was rendered on the server: how many component hosts were hydrated, skipped with `ngSkipHydration`, mismatched or created on the client, and how many `@defer` blocks are still dehydrated. It names the skipped and client-created hosts and the dehydrated blocks, at most 30 of each, and each mismatch with a line diff of the server HTML Angular expected (`-`) and the client DOM it found (`+`). Reads: page.
diff --git a/apps/docs/src/content/inspectors/components.md b/apps/docs/src/content/inspectors/components.md
index 4d238a79..362da995 100644
--- a/apps/docs/src/content/inspectors/components.md
+++ b/apps/docs/src/content/inspectors/components.md
@@ -447,23 +447,24 @@ Press Escape in the filter box to clear it. When the filter matches nothing, **C
## Agent tools
-| Tool or resource | Kind | What it does |
-| ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
-| `pangular:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, change detection, file and line. |
-| `pangular:highlight` | tool | Highlights a component in the page and selects it. Takes an instance id, class name, host tag or CSS selector. Also retargets the Signals graph. Lists every instance a name matches. |
-| `pangular:inspect-component` | tool | Selects one instance and returns its live detail: inputs, outputs, properties, listeners, directives and injected services. With `path`, reads one nested value instead, with `depth` and `limit`. |
-| `pangular:list-components` | tool | Returns the live tree of one page as an indented outline with routed, directive host and hydration markers. Takes a `filter` with the search syntax, a `depth` and `directiveHosts`. |
-| `pangular:defer-blocks` | tool | Lists the `@defer` blocks of each page, and flags blocks that failed, blocks stuck on their placeholder and blocks still dehydrated. |
-| `pangular:for-blocks` | tool | Lists the `@for` blocks of each page with their item count, track kind and `@empty` block. |
-| `pangular:hydration` | tool | Returns the hydration status of each page rendered on the server, and turns the outline on the page on or off. Turning the outline on or off is an action. |
-| `pangular:change-detection` | tool | Starts, stops or clears a change detection recording, turns the live views on or off, and returns the slowest and most often checked components, hook, output and afterRender time, the latest cycles, or an export. |
-| `pangular:diagnose-change-detection` | tool | What started the recorded cycles, components that depend on zone.js, wasted checks, inputs that break OnPush, and OnPush hints. |
-| `pangular:set-component-property` | tool | Writes an input or property of one instance and runs change detection. An action. |
-| `pangular:set-signal` | tool | Sets a writable signal that one instance holds as a property. An action. |
-| `pangular:find-leaks` | tool | Lists retained destroyed instances, subscriptions still open after their component went away, and live effects and subscriptions per component. |
-| `pangular:track-subscriptions` | tool | Starts or stops subscription tracking on a page, like **Track subscriptions**. An action. |
-| `pangular:screenshot-component` | tool | Returns an image of one component's host element, drawn by the page from a copy of its DOM and computed styles, with secret fields and marked elements masked. Takes an instance id, class name, host tag or CSS selector. |
-| `pangular:component-tree` | resource | The live tree per page, with the detail of the selected instance and the defer blocks. |
+| Tool or resource | Kind | What it does |
+| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `pangular:get-components` | tool | Lists components and directives from source, with selector, kind, inputs, outputs, change detection, file and line. |
+| `pangular:highlight` | tool | Highlights a component in the page and selects it. Takes an instance id, class name, host tag or CSS selector. Also retargets the Signals graph. Lists every instance a name matches. |
+| `pangular:inspect-component` | tool | Selects one instance and returns its live detail: inputs, outputs, properties, listeners, directives and injected services. With `path`, reads one nested value instead, with `depth` and `limit`. |
+| `pangular:list-components` | tool | Returns the live tree of one page as an indented outline with routed, directive host and hydration markers. Takes a `filter` with the search syntax, a `depth` and `directiveHosts`. |
+| `pangular:defer-blocks` | tool | Lists the `@defer` blocks of each page, and flags blocks that failed, blocks stuck on their placeholder and blocks still dehydrated. |
+| `pangular:for-blocks` | tool | Lists the `@for` blocks of each page with their item count, track kind and `@empty` block. |
+| `pangular:lint-templates` | tool | Lints component templates in source with the project's Angular template parser: parse errors, `@for` tracking objects by `$index`, `` without `alt`, `(click)` without a key handler, `*ngIf`-style directives and `ngClass` or `ngStyle`. |
+| `pangular:hydration` | tool | Returns the hydration status of each page rendered on the server, and turns the outline on the page on or off. Turning the outline on or off is an action. |
+| `pangular:change-detection` | tool | Starts, stops or clears a change detection recording, turns the live views on or off, and returns the slowest and most often checked components, hook, output and afterRender time, the latest cycles, or an export. |
+| `pangular:diagnose-change-detection` | tool | What started the recorded cycles, components that depend on zone.js, wasted checks, inputs that break OnPush, and OnPush hints. |
+| `pangular:set-component-property` | tool | Writes an input or property of one instance and runs change detection. An action. |
+| `pangular:set-signal` | tool | Sets a writable signal that one instance holds as a property. An action. |
+| `pangular:find-leaks` | tool | Lists retained destroyed instances, subscriptions still open after their component went away, and live effects and subscriptions per component. |
+| `pangular:track-subscriptions` | tool | Starts or stops subscription tracking on a page, like **Track subscriptions**. An action. |
+| `pangular:screenshot-component` | tool | Returns an image of one component's host element, drawn by the page from a copy of its DOM and computed styles, with secret fields and marked elements masked. Takes an instance id, class name, host tag or CSS selector. |
+| `pangular:component-tree` | resource | The live tree per page, with the detail of the selected instance and the defer blocks. |
See [Tools](../agents/tools.md) and [Resources](../agents/resources.md).
diff --git a/apps/docs/src/content/security.md b/apps/docs/src/content/security.md
index 67164a7d..04386bc6 100644
--- a/apps/docs/src/content/security.md
+++ b/apps/docs/src/content/security.md
@@ -309,7 +309,7 @@ An app can put any text in a value, a URL, a label or an error message, includin
A tool answer that names a page the server does not know lists the pages that report, with their URLs. That answer starts with the first notice too.
-Answers that hold no page text carry no notice: `highlight` (instance ids, class names and tags), answers that only say there is no data yet, `lint-pipes` and the source scan tools such as `get-components` (they read your project files, not the page). The `component-tree`, `signal-graph`, `injector-tree`, `ngrx-store`, `forms` and `router` resources carry the notice in an `_untrusted` field at the top of their JSON, also when they return a truncated summary.
+Answers that hold no page text carry no notice: `highlight` (instance ids, class names and tags), answers that only say there is no data yet, `lint-pipes`, `lint-templates` and the source scan tools such as `get-components` (they read your project files, not the page). The `component-tree`, `signal-graph`, `injector-tree`, `ngrx-store`, `forms` and `router` resources carry the notice in an `_untrusted` field at the top of their JSON, also when they return a truncated summary.
## Checklist
diff --git a/packages/devtools/skills/pangular-inspector/SKILL.md b/packages/devtools/skills/pangular-inspector/SKILL.md
index 47f97e5d..77b5853a 100644
--- a/packages/devtools/skills/pangular-inspector/SKILL.md
+++ b/packages/devtools/skills/pangular-inspector/SKILL.md
@@ -33,6 +33,7 @@ Pangular Inspector reads a running Angular app through Angular's debug APIs and
| Why does `inject()` fail (NG0201, NullInjectorError)? | `explain-injection` with `selector` and `token` |
| Which route is active, and why did navigation fail? | `inspect-route`, `explain-navigation`, `export-navigation` |
| Is the route config wrong? | `lint-routes`, `list-routes` with `match` |
+| Do the templates have errors or a11y problems? | `lint-templates`, with `rule` or `file` to narrow it |
| Why is the form invalid or the submit dead? | `explain-form-invalid`, `explain-field`, `explain-submit` (see `pangular-forms`) |
| What errors and console messages happened? | `list-errors`, `explain-error`, `list-console` (see `pangular-errors`) |
| Which HTTP calls ran, and what did SSR do? | `list-http-calls`, `list-ssr-requests`, `explain-ssr-request` |
diff --git a/packages/devtools/src/__tests__/fixtures/agent-metadata-derived.json b/packages/devtools/src/__tests__/fixtures/agent-metadata-derived.json
index 0296dd9b..71b6260a 100644
--- a/packages/devtools/src/__tests__/fixtures/agent-metadata-derived.json
+++ b/packages/devtools/src/__tests__/fixtures/agent-metadata-derived.json
@@ -47,6 +47,7 @@
"lint-forms": "forms",
"lint-pipes": "pipes",
"lint-routes": "router",
+ "lint-templates": "components",
"list-capacitor-calls": "capacitor",
"list-capacitor-plugins": "capacitor",
"list-components": "components",
@@ -275,6 +276,7 @@
"lint-forms": ["forms"],
"lint-pipes": ["pipes"],
"lint-routes": ["router"],
+ "lint-templates": ["components"],
"list-capacitor-calls": ["capacitor"],
"list-capacitor-plugins": ["capacitor"],
"list-components": ["components"],
diff --git a/packages/devtools/src/__tests__/tool-metadata.test.ts b/packages/devtools/src/__tests__/tool-metadata.test.ts
index e49cb9ba..eef56480 100644
--- a/packages/devtools/src/__tests__/tool-metadata.test.ts
+++ b/packages/devtools/src/__tests__/tool-metadata.test.ts
@@ -214,6 +214,7 @@ describe('agent tool output schemas', () => {
'pangular_analog-server-calls',
'pangular_analog-content',
'pangular_build-meta',
+ 'pangular_lint-templates',
'pangular_wait-for',
'pangular_snapshot',
].includes(name);
diff --git a/packages/devtools/src/config.ts b/packages/devtools/src/config.ts
index 49d9a7e9..5475c335 100644
--- a/packages/devtools/src/config.ts
+++ b/packages/devtools/src/config.ts
@@ -925,6 +925,7 @@ export const AGENT_ENTRIES: Readonly> = {
// components
'get-components': { kind: 'rpc', inspectors: ['components'] },
+ 'lint-templates': { kind: 'tool', inspectors: ['components'] },
'component-tree': { kind: 'resource', inspectors: ['components'], page: true },
highlight: { kind: 'tool', inspectors: ['components'], page: true },
'inspect-component': { kind: 'tool', inspectors: ['components'], page: true },
diff --git a/packages/devtools/src/devframe.ts b/packages/devtools/src/devframe.ts
index dc23f7f1..08b33d26 100644
--- a/packages/devtools/src/devframe.ts
+++ b/packages/devtools/src/devframe.ts
@@ -39,6 +39,7 @@ import { sanitizeInjectorTree } from './rpc/injector-report.ts';
import { createFrameworkNames, markFramework } from './rpc/framework-tokens.ts';
import { lintPipes, lintPipesText } from './rpc/pipe-lint.ts';
import { cachedPipeLint } from './rpc/pipe-lint-cache.ts';
+import { templateLintTool } from './rpc/template-lint-tool.ts';
import { explainPipeOnPage, pipeRecordRequest, pipeRecordText } from './rpc/pipes-agent.ts';
import { createPageOwners } from './rpc/page-owners.ts';
import { trackPageSessions } from './rpc/page-sessions.ts';
@@ -2955,6 +2956,8 @@ const pangular = defineDevframe({
},
});
+ agent.registerTool(templateLintTool(ctx.cwd));
+
agent.registerTool({
id: 'pangular:for-blocks',
title: 'List @for blocks',
diff --git a/packages/devtools/src/rpc/__tests__/template-lint.test.ts b/packages/devtools/src/rpc/__tests__/template-lint.test.ts
new file mode 100644
index 00000000..d60a5e40
--- /dev/null
+++ b/packages/devtools/src/rpc/__tests__/template-lint.test.ts
@@ -0,0 +1,352 @@
+import { mkdirSync, writeFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { beforeAll, describe, expect, it } from 'vitest';
+import { fixtureDir } from './fixture-dir.ts';
+import {
+ lintTemplates,
+ projectCompiler,
+ templateLintText,
+ type TemplateCompiler,
+ type TemplateLintFinding,
+} from '../template-lint.ts';
+import { templateLintTool } from '../template-lint-tool.ts';
+
+const REPO = fileURLToPath(new URL('../../../../../', import.meta.url));
+
+let compiler: TemplateCompiler;
+beforeAll(async () => {
+ // The workspace's own @angular/compiler, loaded the way the tool loads a project's.
+ const found = await projectCompiler(REPO);
+ if (!found) throw new Error('@angular/compiler is not installed in the workspace');
+ compiler = found;
+});
+
+function project(files: Record): string {
+ const dir = fixtureDir('pangular-template-lint-');
+ for (const [path, content] of Object.entries(files)) {
+ const full = join(dir, path);
+ mkdirSync(join(full, '..'), { recursive: true });
+ writeFileSync(full, content);
+ }
+ return dir;
+}
+
+function component(template: string): string {
+ return `import { Component } from '@angular/core';
+
+@Component({
+ selector: 'app-view',
+ template: \`${template}\`,
+})
+export class View {}
+`;
+}
+
+async function lint(template: string, using: TemplateCompiler = compiler) {
+ const dir = project({ 'src/view.ts': component(template) });
+ return (await lintTemplates(dir, { compiler: using })).findings;
+}
+
+const rules = (findings: TemplateLintFinding[]) => findings.map((f) => f.rule);
+
+describe('lintTemplates', () => {
+ describe('template-parse-error', () => {
+ it('reports an expression error at its line in an inline template', async () => {
+ const findings = await lint('\n
ok
\n
{{ total( }}
\n');
+ expect(findings).toHaveLength(1);
+ // The template starts on line 5 of view.ts; the error is two lines in.
+ expect(findings[0]).toMatchObject({
+ rule: 'template-parse-error',
+ severity: 'error',
+ file: join('src', 'view.ts'),
+ line: 7,
+ });
+ expect(findings[0]!.message).not.toMatch(/@\d+:\d+$/);
+ });
+
+ it('reports @for without track', async () => {
+ const findings = await lint('@for (item of items) {
{{ item }}
}');
+ expect(findings).toEqual([
+ expect.objectContaining({
+ rule: 'template-parse-error',
+ message: expect.stringContaining('track'),
+ }),
+ ]);
+ });
+
+ it('reports an unclosed element in a templateUrl file at the html line', async () => {
+ const dir = project({
+ 'src/card.ts': `@Component({ selector: 'app-card', templateUrl: './card.html' })
+export class Card {}
+`,
+ 'src/card.html': '\n