From a217a9333112cee9fd53b6c677c44a82ea42bc92 Mon Sep 17 00:00:00 2001 From: Santosh Yadav Date: Wed, 4 Nov 2026 07:08:11 +0100 Subject: [PATCH] fix(mcp): load trusted compiler for template scans Parse project templates with the compiler bundled by the package, not code selected from scanned project dependencies. Keep Angular-version-specific findings aligned with the project's declared version. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- apps/docs/src/content/agents/tools.md | 4 +- apps/docs/src/content/getting-started/cli.md | 2 +- packages/devtools/package.json | 1 + .../src/__tests__/lint-export.test.ts | 13 +--- packages/devtools/src/lint-export.ts | 1 - .../src/rpc/__tests__/template-lint.test.ts | 50 +++++++++---- packages/devtools/src/rpc/template-lint.ts | 74 +++++++++---------- .../src/rpc/trusted-template-compiler.ts | 4 + packages/devtools/tsdown.config.ts | 3 + pnpm-lock.yaml | 3 + 10 files changed, 90 insertions(+), 65 deletions(-) create mode 100644 packages/devtools/src/rpc/trusted-template-compiler.ts diff --git a/apps/docs/src/content/agents/tools.md b/apps/docs/src/content/agents/tools.md index 51cb4ab4..0570e88f 100644 --- a/apps/docs/src/content/agents/tools.md +++ b/apps/docs/src/content/agents/tools.md @@ -285,7 +285,7 @@ Each page lists at most 50 blocks, with a note on how many more are not shown, a ### scan-templates -Checks the component templates in your source, inline `template` and `templateUrl` files, with the project's own `@angular/compiler`. Reads: source. +Checks the component templates in your source, inline `template` and `templateUrl` files, with the compiler bundled with Pangular Inspector. The scan does not load compiler code from the project. Reads: source. | Argument | Required | Value | | ---------- | -------- | ---------------------------------------------------------------------- | @@ -303,7 +303,7 @@ Checks the component templates in your source, inline `template` and `templateUr | `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. +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. ### hydration diff --git a/apps/docs/src/content/getting-started/cli.md b/apps/docs/src/content/getting-started/cli.md index 64976561..e7871d0a 100644 --- a/apps/docs/src/content/getting-started/cli.md +++ b/apps/docs/src/content/getting-started/cli.md @@ -231,7 +231,7 @@ npx pangular lint --format sarif --output reports/pangular.sarif --fail-on error | Lint | Agent tool | What it checks | | ----------- | ------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -| `templates` | [`scan-templates`](../agents/tools.md#scan-templates) | Component templates, parsed with your project's own `@angular/compiler`. Skipped when it is missing. | +| `templates` | [`scan-templates`](../agents/tools.md#scan-templates) | Component templates, parsed with the compiler bundled with Pangular Inspector. | | `pipes` | [`scan-pipe-usage`](../agents/tools.md#scan-pipe-usage) | Pipe use in templates and pipe declarations. | | `analog` | [`analog-lint`](../agents/tools.md#lint) | The static Analog checks: routes, API routes, server files, content and prerender. Skipped elsewhere. | diff --git a/packages/devtools/package.json b/packages/devtools/package.json index 67bfe4a2..4a1bf077 100644 --- a/packages/devtools/package.json +++ b/packages/devtools/package.json @@ -199,6 +199,7 @@ }, "devDependencies": { "@angular/common": "^22.1.0", + "@angular/compiler": "^22.1.0", "@angular/core": "^22.1.0", "@capacitor/core": "^8.5.3", "hono": "4.13.8", diff --git a/packages/devtools/src/__tests__/lint-export.test.ts b/packages/devtools/src/__tests__/lint-export.test.ts index 34645092..b4f05533 100644 --- a/packages/devtools/src/__tests__/lint-export.test.ts +++ b/packages/devtools/src/__tests__/lint-export.test.ts @@ -1,21 +1,14 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; import { createPangularCli } from '../cli.ts'; import { failsAt, runSourceLints, toLintJson, toSarif } from '../lint-export.ts'; -import { projectCompiler, type TemplateCompiler } from '../rpc/template-lint.ts'; +import * as angularCompiler from '@angular/compiler'; +import type { TemplateCompiler } from '../rpc/template-lint.ts'; import { fixtureDir } from '../rpc/__tests__/fixture-dir.ts'; import { BASE_FILES, BROKEN_FILES, makeProject } from './analog-fixture.ts'; -const REPO = fileURLToPath(new URL('../../../../', import.meta.url)); - -let compiler: TemplateCompiler; -beforeAll(async () => { - const found = await projectCompiler(REPO); - if (!found) throw new Error('@angular/compiler is not installed in the workspace'); - compiler = found; -}); +const compiler = angularCompiler as TemplateCompiler; afterEach(() => { vi.restoreAllMocks(); diff --git a/packages/devtools/src/lint-export.ts b/packages/devtools/src/lint-export.ts index 210f413d..90831ef5 100644 --- a/packages/devtools/src/lint-export.ts +++ b/packages/devtools/src/lint-export.ts @@ -105,7 +105,6 @@ export async function runSourceLints( message: f.message, fix: f.fix, })), - result.parsed ? undefined : 'The project has no @angular/compiler to parse templates with.', ); } if (chosen.has('pipes')) { diff --git a/packages/devtools/src/rpc/__tests__/template-lint.test.ts b/packages/devtools/src/rpc/__tests__/template-lint.test.ts index 1d319987..f1e51b78 100644 --- a/packages/devtools/src/rpc/__tests__/template-lint.test.ts +++ b/packages/devtools/src/rpc/__tests__/template-lint.test.ts @@ -1,25 +1,20 @@ -import { mkdirSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import * as angularCompiler from '@angular/compiler'; import { beforeAll, describe, expect, it } from 'vitest'; import { fixtureDir } from './fixture-dir.ts'; import { lintTemplates, - projectCompiler, templateLintText, + trustedCompiler, 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; +const compiler = angularCompiler as 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; + expect(await trustedCompiler()).toBe(compiler); }); function project(files: Record): string { @@ -141,6 +136,15 @@ export class V {} const old = { ...compiler, VERSION: { major: '16' } } as TemplateCompiler; expect(rules(await lint('
a
', old))).toEqual([]); }); + + it('uses the project Angular version when checking for control-flow support', async () => { + const dir = project({ + 'package.json': JSON.stringify({ dependencies: { '@angular/core': '^16.2.0' } }), + 'src/view.ts': component('
a
'), + }); + + expect(rules((await lintTemplates(dir)).findings)).not.toContain('legacy-control-flow'); + }); }); describe('track-by-index', () => { @@ -253,11 +257,31 @@ export class V {} expect(rules(findings).sort()).toEqual(['img-missing-alt', 'legacy-control-flow']); }); - it('says so when the project has no @angular/compiler', async () => { + it('uses the bundled compiler when the project has no @angular/compiler', async () => { const dir = project({ 'src/view.ts': component('

a

') }); const result = await lintTemplates(dir); - expect(result).toEqual({ parsed: false, templates: 0, findings: [] }); - expect(templateLintText(result)).toContain('no `@angular/compiler`'); + expect(result).toEqual({ parsed: true, templates: 1, findings: [] }); + expect(templateLintText(result)).toContain('No template problems found'); + }); + + it('does not execute a compiler selected from the scanned project', async () => { + const dir = project({ + 'package.json': JSON.stringify({ + type: 'module', + dependencies: { '@angular/compiler': '1.0.0' }, + }), + 'node_modules/@angular/compiler/package.json': JSON.stringify({ + name: '@angular/compiler', + type: 'module', + exports: { '.': './index.js' }, + }), + 'node_modules/@angular/compiler/index.js': + "import { writeFileSync } from 'node:fs';\nwriteFileSync(new URL('../../../compiler-loaded', import.meta.url), 'loaded');\n", + 'src/view.ts': component('

a

'), + }); + + expect((await lintTemplates(dir)).templates).toBe(1); + expect(existsSync(join(dir, 'compiler-loaded'))).toBe(false); }); it('does not parse an unchanged template again, and parses an edited one', async () => { diff --git a/packages/devtools/src/rpc/template-lint.ts b/packages/devtools/src/rpc/template-lint.ts index b673ac8a..4441352a 100644 --- a/packages/devtools/src/rpc/template-lint.ts +++ b/packages/devtools/src/rpc/template-lint.ts @@ -1,13 +1,9 @@ // Lints the project's component templates (inline `template:` and -// `templateUrl` files) with the project's own `@angular/compiler`, so a -// finding is read from the same AST Angular compiles, not guessed with a -// regex. The compiler is loaded from the project, never bundled: when the -// project has none, the tool says so and reports nothing. +// `templateUrl` files) with the bundled `@angular/compiler`, so a finding is +// read from Angular's AST, not guessed with a regex or loaded from the project. import { readFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { join, relative } from 'node:path'; -import { pathToFileURL } from 'node:url'; import { templatesIn, type TemplateSource } from './get-pipes.ts'; import { sourceRoots, walkFiles } from './source-scan.ts'; import type { LintSeverity } from './pipe-lint.ts'; @@ -35,8 +31,7 @@ export interface TemplateLintFinding { } export interface TemplateLintResult { - /** `false` when the project has no `@angular/compiler` to parse with. */ - parsed: boolean; + parsed: true; templates: number; findings: TemplateLintFinding[]; } @@ -103,27 +98,34 @@ interface ReadNode { receiver: unknown; } -const compilers = new Map>(); - -/** The project's own `@angular/compiler`, or `undefined` when it has none. A - * miss is not remembered, so installing it works without a restart. */ -export function projectCompiler(cwd: string): Promise { - const known = compilers.get(cwd); - if (known) return known; - const loading = (async () => { - try { - const path = createRequire(join(cwd, 'package.json')).resolve('@angular/compiler'); - const mod = (await import(pathToFileURL(path).href)) as Partial; - return typeof mod.parseTemplate === 'function' ? (mod as TemplateCompiler) : undefined; - } catch { - return undefined; - } - })(); - compilers.set(cwd, loading); - void loading.then((found) => { - if (!found) compilers.delete(cwd); - }); - return loading; +let compiler: Promise | undefined; + +/** Loads the compiler bundled with the package, never one selected by the scanned project. */ +export function trustedCompiler(): Promise { + compiler ??= import('./trusted-template-compiler.ts').then((module) => module.templateCompiler); + return compiler; +} + +function projectAngularMajor(cwd: string): number | undefined { + let manifest: unknown; + try { + manifest = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf-8')) as unknown; + } catch { + return undefined; + } + if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return undefined; + + const fields = manifest as Record; + const sections = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']; + for (const section of sections) { + const dependencies = fields[section]; + if (!dependencies || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue; + const version = (dependencies as Record)['@angular/core']; + if (typeof version !== 'string') continue; + const major = /^\D*(\d+)(?:\.|$)/.exec(version)?.[1]; + if (major) return Number(major); + } + return undefined; } /** Findings per template, keyed by compiler, then project, then the file, @@ -135,14 +137,13 @@ export async function lintTemplates( cwd: string, options: { compiler?: TemplateCompiler; rules?: Record } = {}, ): Promise { - const compiler = options.compiler ?? (await projectCompiler(cwd)); - if (!compiler) return { parsed: false, templates: 0, findings: [] }; + const using = options.compiler ?? (await trustedCompiler()); - const byProject = cache.get(compiler) ?? new Map>(); - cache.set(compiler, byProject); + const byProject = cache.get(using) ?? new Map>(); + cache.set(using, byProject); const previous = byProject.get(cwd) ?? new Map(); const next = new Map(); - const major = Number(compiler.VERSION?.major); + const major = projectAngularMajor(cwd) ?? Number(using.VERSION?.major); const hasControlFlow = !Number.isFinite(major) || major >= 17; let templates = 0; @@ -163,7 +164,7 @@ export async function lintTemplates( if (text === undefined) continue; templates++; const key = `${template.file}\0${template.baseOffset}\0${text}`; - const own = next.get(key) ?? previous.get(key) ?? lintTemplate(compiler, template, text); + const own = next.get(key) ?? previous.get(key) ?? lintTemplate(using, template, text); next.set(key, own); for (const finding of own) { if (options.rules && options.rules[finding.rule] === false) continue; @@ -456,9 +457,6 @@ const DEFAULT_LIMIT = 50; const MAX_LIMIT = 200; export function templateLintText(result: TemplateLintResult, args: TemplateLintArgs = {}): string { - if (!result.parsed) { - return 'Cannot lint templates: this project has no `@angular/compiler` installed to parse them with. Install dependencies and try again.'; - } const rules = Object.keys(TEMPLATE_LINT_RULES); if (args.rule && !rules.includes(args.rule)) { return `Unknown rule ${JSON.stringify(args.rule)}. Rules: ${rules.map((r) => `\`${r}\``).join(', ')}.`; diff --git a/packages/devtools/src/rpc/trusted-template-compiler.ts b/packages/devtools/src/rpc/trusted-template-compiler.ts new file mode 100644 index 00000000..8795128d --- /dev/null +++ b/packages/devtools/src/rpc/trusted-template-compiler.ts @@ -0,0 +1,4 @@ +import * as compiler from '@angular/compiler'; +import type { TemplateCompiler } from './template-lint.ts'; + +export const templateCompiler = compiler as TemplateCompiler; diff --git a/packages/devtools/tsdown.config.ts b/packages/devtools/tsdown.config.ts index b5528041..e6e92305 100644 --- a/packages/devtools/tsdown.config.ts +++ b/packages/devtools/tsdown.config.ts @@ -18,6 +18,8 @@ const shared = { outExtensions: () => ({ js: '.mjs', dts: '.d.mts' }), } satisfies UserConfig; +const nodeExternal = [/^@angular\/(?!compiler$)/, /^@nativescript\//, /^rxjs/]; + /** * The browser build. Its own build, so no chunk is shared with the Node * entries, and it fails when an entry reaches a Node builtin. @@ -34,6 +36,7 @@ export const browserConfig = { export const nodeConfig = { ...shared, + external: nodeExternal, name: 'node', entry: NODE_ENTRIES, platform: 'node', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 09cb0507..5fbdab99 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -388,6 +388,9 @@ importers: '@angular/common': specifier: ^22.1.0 version: 22.1.7(@angular/core@22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3))(rxjs@7.8.2) + '@angular/compiler': + specifier: ^22.1.0 + version: 22.1.7 '@angular/core': specifier: ^22.1.0 version: 22.1.7(@angular/compiler@22.1.7)(rxjs@7.8.2)(zone.js@0.16.3)