From 5697907cf73aca4b22ae57c81d7d9981176b57ef Mon Sep 17 00:00:00 2001 From: 4gray Date: Tue, 13 Oct 2026 08:53:50 +0200 Subject: [PATCH] test(ui): check form-field providers per component on the TypeScript AST The guard matched templates and providers with regular expressions over the whole file: a quoted inline template went unread, one component's provider covered another in the same file, an earlier array-valued provider hid the call, and provideFormFieldDefaults() in app.config or a route passed although it loads Material's form field on startup again. It now reads each @Component decorator's own template and providers array, and reports any provideFormFieldDefaults() call outside one. Co-Authored-By: Claude Opus 5.5 --- tools/nx/check-form-field-defaults.mjs | 133 +++++++++++++++----- tools/nx/check-form-field-defaults.test.mjs | 81 +++++++++++- 2 files changed, 179 insertions(+), 35 deletions(-) diff --git a/tools/nx/check-form-field-defaults.mjs b/tools/nx/check-form-field-defaults.mjs index ad8536f90..624d66be9 100644 --- a/tools/nx/check-form-field-defaults.mjs +++ b/tools/nx/check-form-field-defaults.mjs @@ -2,6 +2,7 @@ import { execFileSync } from 'node:child_process'; import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import ts from 'typescript'; /** * The app's `` defaults (outline, dynamic subscript) are @@ -10,12 +11,13 @@ import { fileURLToPath } from 'node:url'; * `@angular/material/form-field` and, since Material 22.2, `@angular/forms` * on startup, although every form field lives in a lazy chunk. A component * that renders a form field without the provider silently falls back to the - * `fill` appearance, so this guard requires it on each such component and - * keeps the token itself inside the shared provider. + * `fill` appearance, so this guard requires it on each such component, and + * allows the provider and the token nowhere else. */ export const DEFAULTS_FILE = 'libs/ui/components/src/lib/form-field-defaults/form-field-defaults.ts'; const TOKEN = 'MAT_FORM_FIELD_DEFAULT_OPTIONS'; +const PROVIDER = 'provideFormFieldDefaults'; const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts']; @@ -23,59 +25,122 @@ function isScanned(file) { return !/\.(spec|test)\.[cm]?[jt]s$/.test(file); } -function lineOf(source, index) { - return source.slice(0, index).split('\n').length; +/** The object literal of an `@Component({...})` decorator, if `node` is one. */ +function componentMetadata(node) { + if ( + !ts.isDecorator(node) || + !ts.isCallExpression(node.expression) || + !ts.isIdentifier(node.expression.expression) || + node.expression.expression.text !== 'Component' + ) { + return undefined; + } + const [metadata] = node.expression.arguments; + return metadata && ts.isObjectLiteralExpression(metadata) + ? metadata + : undefined; } -/** Inline template text, or the path of the `templateUrl` file. */ -export function componentTemplates(source) { - const inline = [...source.matchAll(/\btemplate:\s*`([\s\S]*?)`/g)].map( - (match) => ({ inline: match[1], index: match.index }) - ); - const urls = [...source.matchAll(/\btemplateUrl:\s*['"]([^'"]+)['"]/g)].map( - (match) => ({ url: match[1], index: match.index }) - ); - return [...inline, ...urls].sort((a, b) => a.index - b.index); +function property(metadata, name) { + return metadata.properties.find( + (entry) => + ts.isPropertyAssignment(entry) && + (ts.isIdentifier(entry.name) || ts.isStringLiteral(entry.name)) && + entry.name.text === name + )?.initializer; } -function providesDefaults(source) { - return /\bproviders:\s*\[[^\]]*\bprovideFormFieldDefaults\(\)/.test(source); +/** Literal text of a quoted, backticked or interpolated template. */ +function literalText(node) { + if (ts.isStringLiteralLike(node)) return node.text; + if (ts.isTemplateExpression(node)) { + return [ + node.head.text, + ...node.templateSpans.map((span) => span.literal.text), + ].join(''); + } + return undefined; +} + +function isProviderCall(node) { + return ( + ts.isCallExpression(node) && + ts.isIdentifier(node.expression) && + node.expression.text === PROVIDER + ); } /** - * Findings for one component source. `readTemplate` resolves a + * Findings for one TypeScript source. `readTemplate` resolves a * `templateUrl` relative to the component and returns its text. */ export async function checkComponentSource(file, source, readTemplate) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true + ); + const lineOf = (node) => + sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)) + .line + 1; const findings = []; - if (file !== DEFAULTS_FILE) { - for (const match of source.matchAll( - new RegExp(`\\b${TOKEN}\\b`, 'g') - )) { + const components = []; + const componentProviderCalls = new Set(); + + const visit = (node) => { + const metadata = componentMetadata(node); + if (metadata) { + const providers = property(metadata, 'providers'); + const calls = + providers && ts.isArrayLiteralExpression(providers) + ? providers.elements.filter(isProviderCall) + : []; + calls.forEach((call) => componentProviderCalls.add(call)); + components.push({ node, metadata, provides: calls.length > 0 }); + } + if (ts.isIdentifier(node) && node.text === TOKEN) { + if (file !== DEFAULTS_FILE) { + findings.push({ + file, + line: lineOf(node), + reason: `uses ${TOKEN}; provide the app defaults with ${PROVIDER}() from @iptvnator/ui/components/form-field-defaults`, + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + + const visitCalls = (node) => { + if (isProviderCall(node) && !componentProviderCalls.has(node)) { findings.push({ file, - line: lineOf(source, match.index), - reason: `uses ${TOKEN}; provide the app defaults with provideFormFieldDefaults() from @iptvnator/ui/components/form-field-defaults`, + line: lineOf(node), + reason: `calls ${PROVIDER}() outside a component's providers; a root or route provider loads Material's form field on startup`, }); } - } + ts.forEachChild(node, visitCalls); + }; + visitCalls(sourceFile); - for (const template of componentTemplates(source)) { - const text = - template.inline ?? - (await readTemplate( - path.posix.join(path.posix.dirname(file), template.url) - )); - if (!/ without providers: [provideFormFieldDefaults()]', + line: lineOf(node), + reason: `renders without providers: [${PROVIDER}()]`, }); } - return findings; + return findings.sort((a, b) => a.line - b.line); } /** Tracked renderer TypeScript files the guard reads, at any depth. */ diff --git a/tools/nx/check-form-field-defaults.test.mjs b/tools/nx/check-form-field-defaults.test.mjs index 8651be43b..b3301622a 100644 --- a/tools/nx/check-form-field-defaults.test.mjs +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -58,7 +58,7 @@ test('reports a templateUrl form field without the provider', async () => { assert.deepEqual(await check('libs/a/field.component.ts', source), [ { - line: 3, + line: 1, reason: 'renders without providers: [provideFormFieldDefaults()]', }, ]); @@ -76,6 +76,85 @@ test('reports an inline template form field without the provider', async () => { assert.equal((await check('libs/a/inline.component.ts', source)).length, 1); }); +test('reads quoted inline templates', async () => { + const source = [ + '@Component({', + " template: '',", + '})', + 'class Single {}', + '@Component({', + ' template: "",', + '})', + 'class Double {}', + ].join('\n'); + + assert.deepEqual( + (await check('libs/a/quoted.component.ts', source)).map( + ({ line }) => line + ), + [1, 5] + ); +}); + +test('checks each component of a file on its own providers', async () => { + const source = [ + '@Component({', + ' providers: [provideFormFieldDefaults()],', + " templateUrl: './field.component.html',", + '})', + 'class WithDefaults {}', + '@Component({', + " templateUrl: './field.component.html',", + '})', + 'class WithoutDefaults {}', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), [ + { + line: 6, + reason: 'renders without providers: [provideFormFieldDefaults()]', + }, + ]); +}); + +test('finds the provider after an earlier array-valued provider', async () => { + const source = [ + '@Component({', + ' providers: [', + ' { provide: LIST, useValue: [] },', + ' provideFormFieldDefaults(),', + ' ],', + " templateUrl: './field.component.html',", + '})', + ].join('\n'); + + assert.deepEqual(await check('libs/a/field.component.ts', source), []); +}); + +test('reports the provider outside a component', async () => { + const appConfig = [ + 'export const appConfig = {', + ' providers: [provideRouter(routes), provideFormFieldDefaults()],', + '};', + ].join('\n'); + const routes = [ + 'export const routes = [', + " { path: 'settings', providers: [provideFormFieldDefaults()] },", + '];', + ].join('\n'); + + assert.deepEqual(await check('apps/web/src/app/app.config.ts', appConfig), [ + { + line: 2, + reason: "calls provideFormFieldDefaults() outside a component's providers; a root or route provider loads Material's form field on startup", + }, + ]); + assert.equal( + (await check('apps/web/src/app/app.routes.ts', routes)).length, + 1 + ); +}); + test('ignores templates without a form field', async () => { const source = [ '@Component({',