Make rules more type-safe

This commit is contained in:
Yury Bondarenko
2024-03-15 13:19:47 +01:00
parent b0758c23e5
commit 6e22b5376a
15 changed files with 314 additions and 158 deletions

View File

@@ -10,9 +10,9 @@ import {
existsSync, existsSync,
mkdirSync, mkdirSync,
readFileSync, readFileSync,
rmSync,
writeFileSync, writeFileSync,
} from 'fs'; } from 'fs';
import { rmSync } from 'node:fs';
import { join } from 'path'; import { join } from 'path';
import { default as htmlPlugin } from './src/rules/html'; import { default as htmlPlugin } from './src/rules/html';

View File

@@ -5,7 +5,7 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
/* eslint-disable import/no-namespace */
import { import {
bundle, bundle,
RuleExports, RuleExports,

View File

@@ -5,12 +5,23 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { TmplAstElement } from '@angular-eslint/bundled-angular-compiler';
import { getTemplateParserServices } from '@angular-eslint/utils';
import {
ESLintUtils,
TSESLint,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture'; import { fixture } from '../../../test/fixture';
import { DSpaceESLintRuleInfo } from '../../util/structure'; import {
DSpaceESLintRuleInfo,
NamedTests,
} from '../../util/structure';
import { import {
DISALLOWED_THEME_SELECTORS, DISALLOWED_THEME_SELECTORS,
fixSelectors, fixSelectors,
} from '../../util/theme-support'; } from '../../util/theme-support';
import { getFilename } from '../../util/typescript';
export enum Message { export enum Message {
WRONG_SELECTOR = 'mustUseThemedWrapperSelector', WRONG_SELECTOR = 'mustUseThemedWrapperSelector',
@@ -36,39 +47,38 @@ The only exception to this rule are unit tests, where we may want to use the bas
defaultOptions: [], defaultOptions: [],
} as DSpaceESLintRuleInfo; } as DSpaceESLintRuleInfo;
export const rule = { export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info, ...info,
create(context: any) { create(context: TSESLint.RuleContext<Message, unknown[]>) {
if (context.getFilename().includes('.spec.ts')) { if (getFilename(context).includes('.spec.ts')) {
// skip inline templates in unit tests // skip inline templates in unit tests
return {}; return {};
} }
const parserServices = getTemplateParserServices(context as any);
return { return {
[`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: any) { [`Element$1[name = /^${DISALLOWED_THEME_SELECTORS}/]`](node: TmplAstElement) {
const { startSourceSpan, endSourceSpan } = node;
const openStart = startSourceSpan.start.offset as number;
context.report({ context.report({
messageId: Message.WRONG_SELECTOR, messageId: Message.WRONG_SELECTOR,
node, loc: parserServices.convertNodeSourceSpanToLoc(startSourceSpan),
fix(fixer: any) { fix(fixer) {
const oldSelector = node.name; const oldSelector = node.name;
const newSelector = fixSelectors(oldSelector); const newSelector = fixSelectors(oldSelector);
const openTagRange = [
node.startSourceSpan.start.offset + 1,
node.startSourceSpan.start.offset + 1 + oldSelector.length,
];
const ops = [ const ops = [
fixer.replaceTextRange(openTagRange, newSelector), fixer.replaceTextRange([openStart + 1, openStart + 1 + oldSelector.length], newSelector),
]; ];
// make sure we don't mangle self-closing tags // make sure we don't mangle self-closing tags
if (node.startSourceSpan.end.offset !== node.endSourceSpan.end.offset) { if (endSourceSpan !== null && startSourceSpan.end.offset !== endSourceSpan.end.offset) {
const closeTagRange = [ const closeStart = endSourceSpan.start.offset as number;
node.endSourceSpan.start.offset + 2, const closeEnd = endSourceSpan.end.offset as number;
node.endSourceSpan.end.offset - 1,
]; ops.push(fixer.replaceTextRange([closeStart + 2, closeEnd - 1], newSelector));
ops.push(fixer.replaceTextRange(closeTagRange, newSelector));
} }
return ops; return ops;
@@ -77,7 +87,7 @@ export const rule = {
}, },
}; };
}, },
}; });
export const tests = { export const tests = {
plugin: info.name, plugin: info.name,
@@ -167,6 +177,6 @@ class Test {
`, `,
}, },
], ],
}; } as NamedTests;
export default rule; export default rule;

View File

@@ -1,9 +1,17 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { import {
bundle, bundle,
RuleExports, RuleExports,
} from '../../util/structure'; } from '../../util/structure';
import * as themedComponentUsages from './themed-component-usages'; /* eslint-disable import/no-namespace */
import * as themedComponentSelectors from './themed-component-selectors'; import * as themedComponentSelectors from './themed-component-selectors';
import * as themedComponentUsages from './themed-component-usages';
const index = [ const index = [
themedComponentUsages, themedComponentUsages,

View File

@@ -5,9 +5,13 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { ESLintUtils } from '@typescript-eslint/utils'; import {
import { fixture } from '../../../test/fixture'; ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture';
import { getComponentSelectorNode } from '../../util/angular'; import { getComponentSelectorNode } from '../../util/angular';
import { stringLiteral } from '../../util/misc'; import { stringLiteral } from '../../util/misc';
import { DSpaceESLintRuleInfo } from '../../util/structure'; import { DSpaceESLintRuleInfo } from '../../util/structure';
@@ -16,6 +20,7 @@ import {
isThemeableComponent, isThemeableComponent,
isThemedComponentWrapper, isThemedComponentWrapper,
} from '../../util/theme-support'; } from '../../util/theme-support';
import { getFilename } from '../../util/typescript';
export enum Message { export enum Message {
BASE = 'wrongSelectorUnthemedComponent', BASE = 'wrongSelectorUnthemedComponent',
@@ -53,41 +58,43 @@ Unit tests are exempt from this rule, because they may redefine components using
export const rule = ESLintUtils.RuleCreator.withoutDocs({ export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info, ...info,
create(context: any): any { create(context: TSESLint.RuleContext<Message, unknown[]>) {
if (context.getFilename()?.endsWith('.spec.ts')) { const filename = getFilename(context);
if (filename.endsWith('.spec.ts')) {
return {}; return {};
} }
function enforceWrapperSelector(selectorNode: any) { function enforceWrapperSelector(selectorNode: TSESTree.StringLiteral) {
if (selectorNode?.value.startsWith('ds-themed-')) { if (selectorNode?.value.startsWith('ds-themed-')) {
context.report({ context.report({
messageId: Message.WRAPPER, messageId: Message.WRAPPER,
node: selectorNode, node: selectorNode,
fix(fixer: any) { fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-'))); return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-themed-', 'ds-')));
}, },
}); });
} }
} }
function enforceBaseSelector(selectorNode: any) { function enforceBaseSelector(selectorNode: TSESTree.StringLiteral) {
if (!selectorNode?.value.startsWith('ds-base-')) { if (!selectorNode?.value.startsWith('ds-base-')) {
context.report({ context.report({
messageId: Message.BASE, messageId: Message.BASE,
node: selectorNode, node: selectorNode,
fix(fixer: any) { fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-'))); return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-base-')));
}, },
}); });
} }
} }
function enforceThemedSelector(selectorNode: any) { function enforceThemedSelector(selectorNode: TSESTree.StringLiteral) {
if (!selectorNode?.value.startsWith('ds-themed-')) { if (!selectorNode?.value.startsWith('ds-themed-')) {
context.report({ context.report({
messageId: Message.THEMED, messageId: Message.THEMED,
node: selectorNode, node: selectorNode,
fix(fixer: any) { fix(fixer) {
return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-'))); return fixer.replaceText(selectorNode, stringLiteral(selectorNode.value.replace('ds-', 'ds-themed-')));
}, },
}); });
@@ -95,11 +102,15 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
} }
return { return {
'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: any) { 'ClassDeclaration > Decorator[expression.callee.name = "Component"]'(node: TSESTree.Decorator) {
// keep track of all @Component nodes by their selector
const selectorNode = getComponentSelectorNode(node); const selectorNode = getComponentSelectorNode(node);
if (selectorNode === undefined) {
return;
}
const selector = selectorNode?.value; const selector = selectorNode?.value;
const classNode = node.parent; const classNode = node.parent as TSESTree.ClassDeclaration;
const className = classNode.id?.name; const className = classNode.id?.name;
if (selector === undefined || className === undefined) { if (selector === undefined || className === undefined) {
@@ -108,7 +119,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
if (isThemedComponentWrapper(node)) { if (isThemedComponentWrapper(node)) {
enforceWrapperSelector(selectorNode); enforceWrapperSelector(selectorNode);
} else if (inThemedComponentOverrideFile(context)) { } else if (inThemedComponentOverrideFile(filename)) {
enforceThemedSelector(selectorNode); enforceThemedSelector(selectorNode);
} else if (isThemeableComponent(className)) { } else if (isThemeableComponent(className)) {
enforceBaseSelector(selectorNode); enforceBaseSelector(selectorNode);
@@ -124,11 +135,11 @@ export const tests = {
{ {
name: 'Regular non-themeable component selector', name: 'Regular non-themeable component selector',
code: ` code: `
@Component({ @Component({
selector: 'ds-something', selector: 'ds-something',
}) })
class Something { class Something {
} }
`, `,
}, },
{ {

View File

@@ -5,9 +5,13 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { ESLintUtils } from '@typescript-eslint/utils'; import {
ESLintUtils,
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { fixture } from '../../../test/fixture'; import { fixture } from '../../../test/fixture';
import { findUsages } from '../../util/misc';
import { DSpaceESLintRuleInfo } from '../../util/structure'; import { DSpaceESLintRuleInfo } from '../../util/structure';
import { import {
allThemeableComponents, allThemeableComponents,
@@ -17,6 +21,10 @@ import {
inThemedComponentFile, inThemedComponentFile,
isAllowedUnthemedUsage, isAllowedUnthemedUsage,
} from '../../util/theme-support'; } from '../../util/theme-support';
import {
findUsages,
getFilename,
} from '../../util/typescript';
export enum Message { export enum Message {
WRONG_CLASS = 'mustUseThemedWrapperClass', WRONG_CLASS = 'mustUseThemedWrapperClass',
@@ -52,8 +60,10 @@ There are a few exceptions where the base class can still be used:
export const rule = ESLintUtils.RuleCreator.withoutDocs({ export const rule = ESLintUtils.RuleCreator.withoutDocs({
...info, ...info,
create(context: any, options: any): any { create(context: TSESLint.RuleContext<Message, unknown[]>) {
function handleUnthemedUsagesInTypescript(node: any) { const filename = getFilename(context);
function handleUnthemedUsagesInTypescript(node: TSESTree.Identifier) {
if (isAllowedUnthemedUsage(node)) { if (isAllowedUnthemedUsage(node)) {
return; return;
} }
@@ -68,24 +78,24 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
context.report({ context.report({
messageId: Message.WRONG_CLASS, messageId: Message.WRONG_CLASS,
node: node, node: node,
fix(fixer: any) { fix(fixer) {
return fixer.replaceText(node, entry.wrapperClass); return fixer.replaceText(node, entry.wrapperClass);
}, },
}); });
} }
function handleThemedSelectorQueriesInTests(node: any) { function handleThemedSelectorQueriesInTests(node: TSESTree.Literal) {
context.report({ context.report({
node, node,
messageId: Message.WRONG_SELECTOR, messageId: Message.WRONG_SELECTOR,
fix(fixer: any){ fix(fixer){
const newSelector = fixSelectors(node.raw); const newSelector = fixSelectors(node.raw);
return fixer.replaceText(node, newSelector); return fixer.replaceText(node, newSelector);
}, },
}); });
} }
function handleUnthemedImportsInTypescript(specifierNode: any) { function handleUnthemedImportsInTypescript(specifierNode: TSESTree.ImportSpecifier) {
const allUsages = findUsages(context, specifierNode.local); const allUsages = findUsages(context, specifierNode.local);
const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage)); const badUsages = allUsages.filter(usage => !isAllowedUnthemedUsage(usage));
@@ -94,7 +104,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
} }
const importedNode = specifierNode.imported; const importedNode = specifierNode.imported;
const declarationNode = specifierNode.parent; const declarationNode = specifierNode.parent as TSESTree.ImportDeclaration;
const entry = getThemeableComponentByBaseClass(importedNode.name); const entry = getThemeableComponentByBaseClass(importedNode.name);
if (entry === undefined) { if (entry === undefined) {
@@ -105,7 +115,7 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
context.report({ context.report({
messageId: Message.WRONG_IMPORT, messageId: Message.WRONG_IMPORT,
node: importedNode, node: importedNode,
fix(fixer: any) { fix(fixer) {
const ops = []; const ops = [];
const oldImportSource = declarationNode.source.value; const oldImportSource = declarationNode.source.value;
@@ -128,17 +138,17 @@ export const rule = ESLintUtils.RuleCreator.withoutDocs({
} }
// ignore tests and non-routing modules // ignore tests and non-routing modules
if (context.getFilename()?.endsWith('.spec.ts')) { if (filename.endsWith('.spec.ts')) {
return { return {
[`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, [`CallExpression[callee.object.name = "By"][callee.property.name = "css"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
}; };
} else if (context.getFilename()?.endsWith('.cy.ts')) { } else if (filename.endsWith('.cy.ts')) {
return { return {
[`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests, [`CallExpression[callee.object.name = "cy"][callee.property.name = "get"] > Literal:first-child[value = /.*${DISALLOWED_THEME_SELECTORS}.*/]`]: handleThemedSelectorQueriesInTests,
}; };
} else if ( } else if (
context.getFilename()?.match(/(?!routing).module.ts$/) filename.match(/(?!routing).module.ts$/)
|| context.getFilename()?.match(/themed-.+\.component\.ts$/) || filename.match(/themed-.+\.component\.ts$/)
|| inThemedComponentFile(context) || inThemedComponentFile(context)
) { ) {
// do nothing // do nothing

View File

@@ -5,12 +5,24 @@
* *
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { TSESTree } from '@typescript-eslint/utils';
export function getComponentSelectorNode(componentDecoratorNode: any): any | undefined { import { getObjectPropertyNodeByName } from './typescript';
for (const property of componentDecoratorNode.expression.arguments[0].properties) {
if (property.key?.name === 'selector') { export function getComponentSelectorNode(componentDecoratorNode: TSESTree.Decorator): TSESTree.StringLiteral | undefined {
return property?.value; const initializer = (componentDecoratorNode.expression as TSESTree.CallExpression).arguments[0] as TSESTree.ObjectExpression;
const property = getObjectPropertyNodeByName(initializer, 'selector');
if (property !== undefined) {
// todo: support template literals as well
if (property.type === TSESTree.AST_NODE_TYPES.Literal && typeof property.value === 'string') {
return property as TSESTree.StringLiteral;
} }
} }
return undefined; return undefined;
} }
export function isPartOfViewChild(node: TSESTree.Identifier): boolean {
return (node.parent as any)?.callee?.name === 'ViewChild';
}

View File

@@ -6,37 +6,11 @@
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
export function stringLiteral(value: string): string {
return `'${value}'`;
}
export function match(rangeA: number[], rangeB: number[]) { export function match(rangeA: number[], rangeB: number[]) {
return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1]; return rangeA[0] === rangeB[0] && rangeA[1] === rangeB[1];
} }
export function findUsages(context: any, localNode: any): any[] {
const ast = context.getSourceCode().ast;
const usages: any[] = []; export function stringLiteral(value: string): string {
return `'${value}'`;
for (const token of ast.tokens) {
if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) {
usages.push(context.getSourceCode().getNodeByRangeIndex(token.range[0]));
}
}
return usages;
}
export function isPartOfTypeExpression(node: any): boolean {
return node.parent.type.startsWith('TSType');
}
export function isClassDeclaration(node: any): boolean {
return node.parent.type === 'ClassDeclaration';
}
export function isPartOfViewChild(node: any): boolean {
return node.parent?.callee?.name === 'ViewChild';
} }

View File

@@ -10,53 +10,42 @@ import { RuleTester } from 'eslint';
import { EnumType } from 'typescript'; import { EnumType } from 'typescript';
export type Meta = TSESLint.RuleMetaData<string>; export type Meta = TSESLint.RuleMetaData<string>;
export type Valid = RuleTester.ValidTestCase | TSESLint.ValidTestCase<unknown[]>; export type Valid = TSESLint.ValidTestCase<unknown[]> | RuleTester.ValidTestCase;
export type Invalid = RuleTester.InvalidTestCase | TSESLint.InvalidTestCase<string, unknown[]>; export type Invalid = TSESLint.InvalidTestCase<string, unknown[]> | RuleTester.InvalidTestCase;
export interface DSpaceESLintRuleInfo { export interface DSpaceESLintRuleInfo {
name: string; name: string;
meta: Meta, meta: Meta,
defaultOptions: any[], defaultOptions: unknown[],
} }
export interface DSpaceESLintTestInfo { export interface NamedTests {
rule: string; plugin: string;
valid: Valid[]; valid: Valid[];
invalid: Invalid[]; invalid: Invalid[];
} }
export interface DSpaceESLintPluginInfo {
name: string;
description: string;
rules: DSpaceESLintRuleInfo;
tests: DSpaceESLintTestInfo;
}
export interface DSpaceESLintInfo {
html: DSpaceESLintPluginInfo;
ts: DSpaceESLintPluginInfo;
}
export interface RuleExports { export interface RuleExports {
Message: EnumType, Message: EnumType,
info: DSpaceESLintRuleInfo, info: DSpaceESLintRuleInfo,
rule: any, rule: TSESLint.RuleModule<string>,
tests: any, tests: NamedTests,
default: any, default: unknown,
}
export interface PluginExports {
name: string,
language: string,
rules: Record<string, unknown>,
index: RuleExports[],
} }
export function bundle( export function bundle(
name: string, name: string,
language: string, language: string,
index: RuleExports[], index: RuleExports[],
): { ): PluginExports {
name: string, return index.reduce((o: PluginExports, i: RuleExports) => {
language: string,
rules: Record<string, any>,
index: RuleExports[],
} {
return index.reduce((o: any, i: any) => {
o.rules[i.info.name] = i.rule; o.rules[i.info.name] = i.rule;
return o; return o;
}, { }, {

View File

@@ -6,17 +6,18 @@
* http://www.dspace.org/license/ * http://www.dspace.org/license/
*/ */
import { TSESTree } from '@typescript-eslint/utils';
import { readFileSync } from 'fs'; import { readFileSync } from 'fs';
import { basename } from 'path'; import { basename } from 'path';
import ts from 'typescript'; import ts, { Identifier } from 'typescript';
import { isPartOfViewChild } from './angular';
import { import {
isClassDeclaration, AnyRuleContext,
getFilename,
isPartOfClassDeclaration,
isPartOfTypeExpression, isPartOfTypeExpression,
isPartOfViewChild, } from './typescript';
} from './misc';
const glob = require('glob');
/** /**
* Couples a themeable Component to its ThemedComponent wrapper * Couples a themeable Component to its ThemedComponent wrapper
@@ -31,6 +32,42 @@ export interface ThemeableComponentRegistryEntry {
wrapperClass: string; wrapperClass: string;
} }
function isAngularComponentDecorator(node: ts.Node) {
if (node.kind === ts.SyntaxKind.Decorator && node.parent.kind === ts.SyntaxKind.ClassDeclaration) {
const decorator = node as ts.Decorator;
if (decorator.expression.kind === ts.SyntaxKind.CallExpression) {
const method = decorator.expression as ts.CallExpression;
if (method.expression.kind === ts.SyntaxKind.Identifier) {
return (method.expression as Identifier).escapedText === 'Component';
}
}
}
return false;
}
function findImportDeclaration(source: ts.SourceFile, identifierName: string): ts.ImportDeclaration | undefined {
return ts.forEachChild(source, (topNode: ts.Node) => {
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) {
const importDeclaration = topNode as ts.ImportDeclaration;
if (importDeclaration.importClause?.namedBindings?.kind === ts.SyntaxKind.NamedImports) {
const namedImports = importDeclaration.importClause?.namedBindings as ts.NamedImports;
for (const element of namedImports.elements) {
if (element.name.escapedText === identifierName) {
return importDeclaration;
}
}
}
}
return undefined;
});
}
/** /**
* Listing of all themeable Components * Listing of all themeable Components
*/ */
@@ -55,20 +92,37 @@ class ThemeableComponentRegistry {
function registerWrapper(path: string) { function registerWrapper(path: string) {
const source = getSource(path); const source = getSource(path);
function traverse(node: any) { function traverse(node: ts.Node) {
if (node.kind === ts.SyntaxKind.Decorator && node.expression.expression.escapedText === 'Component' && node.parent.kind === ts.SyntaxKind.ClassDeclaration) { if (node.parent !== undefined && isAngularComponentDecorator(node)) {
const wrapperClass = node.parent.name.escapedText; const classNode = node.parent as ts.ClassDeclaration;
for (const heritageClause of node.parent.heritageClauses) { if (classNode.name === undefined || classNode.heritageClauses === undefined) {
return;
}
const wrapperClass = classNode.name?.escapedText as string;
for (const heritageClause of classNode.heritageClauses) {
for (const type of heritageClause.types) { for (const type of heritageClause.types) {
if (type.expression.escapedText === 'ThemedComponent') { if ((type as any).expression.escapedText === 'ThemedComponent') {
const baseClass = type.typeArguments[0].typeName?.escapedText; if (type.kind !== ts.SyntaxKind.ExpressionWithTypeArguments || type.typeArguments === undefined) {
continue;
}
ts.forEachChild(source, (topNode: any) => { const firstTypeArg = type.typeArguments[0] as ts.TypeReferenceNode;
if (topNode.kind === ts.SyntaxKind.ImportDeclaration) { const baseClass = (firstTypeArg.typeName as ts.Identifier)?.escapedText;
for (const element of topNode.importClause.namedBindings.elements) {
if (element.name.escapedText === baseClass) { if (baseClass === undefined) {
const basePath = resolveLocalPath(topNode.moduleSpecifier.text, path); continue;
}
const importDeclaration = findImportDeclaration(source, baseClass);
if (importDeclaration === undefined) {
continue;
}
const basePath = resolveLocalPath((importDeclaration.moduleSpecifier as ts.StringLiteral).text, path);
themeableComponents.add({ themeableComponents.add({
baseClass, baseClass,
@@ -81,10 +135,6 @@ class ThemeableComponentRegistry {
} }
} }
} }
});
}
}
}
return; return;
} else { } else {
@@ -95,6 +145,8 @@ class ThemeableComponentRegistry {
traverse(source); traverse(source);
} }
const glob = require('glob');
const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found; const wrappers: string[] = glob.GlobSync(prefix + 'src/app/**/themed-*.component.ts', { ignore: 'node_modules/**' }).found;
for (const wrapper of wrappers) { for (const wrapper of wrappers) {
@@ -142,8 +194,16 @@ function resolveLocalPath(path: string, relativeTo: string) {
} }
} }
export function isThemedComponentWrapper(node: any): boolean { export function isThemedComponentWrapper(decoratorNode: TSESTree.Decorator): boolean {
return node.parent.superClass?.name === 'ThemedComponent'; if (decoratorNode.parent.type !== TSESTree.AST_NODE_TYPES.ClassDeclaration) {
return false;
}
if (decoratorNode.parent.superClass?.type !== TSESTree.AST_NODE_TYPES.Identifier) {
return false;
}
return (decoratorNode.parent.superClass as any)?.name === 'ThemedComponent';
} }
export function isThemeableComponent(className: string): boolean { export function isThemeableComponent(className: string): boolean {
@@ -151,8 +211,8 @@ export function isThemeableComponent(className: string): boolean {
return themeableComponents.byBaseClass.has(className); return themeableComponents.byBaseClass.has(className);
} }
export function inThemedComponentOverrideFile(context: any): boolean { export function inThemedComponentOverrideFile(filename: string): boolean {
const match = context.getFilename().match(/src\/themes\/[^\/]+\/(app\/.*)/); const match = filename.match(/src\/themes\/[^\/]+\/(app\/.*)/);
if (!match) { if (!match) {
return false; return false;
@@ -162,13 +222,14 @@ export function inThemedComponentOverrideFile(context: any): boolean {
return themeableComponents.byBasePath.has(`src/${match[1]}`); return themeableComponents.byBasePath.has(`src/${match[1]}`);
} }
export function inThemedComponentFile(context: any): boolean { export function inThemedComponentFile(context: AnyRuleContext): boolean {
themeableComponents.initialize(); themeableComponents.initialize();
const filename = getFilename(context);
return [ return [
() => themeableComponents.byBasePath.has(context.getFilename()), () => themeableComponents.byBasePath.has(filename),
() => themeableComponents.byWrapperPath.has(context.getFilename()), () => themeableComponents.byWrapperPath.has(filename),
() => inThemedComponentOverrideFile(context), () => inThemedComponentOverrideFile(filename),
].some(predicate => predicate()); ].some(predicate => predicate());
} }
@@ -182,8 +243,8 @@ export function getThemeableComponentByBaseClass(baseClass: string): ThemeableCo
return themeableComponents.byBaseClass.get(baseClass); return themeableComponents.byBaseClass.get(baseClass);
} }
export function isAllowedUnthemedUsage(usageNode: any) { export function isAllowedUnthemedUsage(usageNode: TSESTree.Identifier) {
return isClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode); return isPartOfClassDeclaration(usageNode) || isPartOfTypeExpression(usageNode) || isPartOfViewChild(usageNode);
} }
export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-'; export const DISALLOWED_THEME_SELECTORS = 'ds-(base|themed)-';

View File

@@ -0,0 +1,75 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import {
TSESLint,
TSESTree,
} from '@typescript-eslint/utils';
import { match } from './misc';
export type AnyRuleContext = TSESLint.RuleContext<string, unknown[]>;
export function getFilename(context: AnyRuleContext): string {
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
// eslint-disable-next-line deprecation/deprecation
return context.getFilename();
}
export function getSourceCode(context: AnyRuleContext): TSESLint.SourceCode {
// TSESLint claims this is deprecated, but the suggested alternative is undefined (could be a version mismatch between ESLint and TSESlint?)
// eslint-disable-next-line deprecation/deprecation
return context.getSourceCode();
}
export function getObjectPropertyNodeByName(objectNode: TSESTree.ObjectExpression, propertyName: string): TSESTree.Node | undefined {
for (const propertyNode of objectNode.properties) {
if (
propertyNode.type === TSESTree.AST_NODE_TYPES.Property
&& (
(
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Identifier
&& propertyNode.key?.name === propertyName
) || (
propertyNode.key?.type === TSESTree.AST_NODE_TYPES.Literal
&& propertyNode.key?.value === propertyName
)
)
) {
return propertyNode.value;
}
}
return undefined;
}
export function findUsages(context: AnyRuleContext, localNode: TSESTree.Identifier): TSESTree.Identifier[] {
const source = getSourceCode(context);
const usages: TSESTree.Identifier[] = [];
for (const token of source.ast.tokens) {
if (token.type === 'Identifier' && token.value === localNode.name && !match(token.range, localNode.range)) {
const node = source.getNodeByRangeIndex(token.range[0]);
if (node !== null) {
usages.push(node as TSESTree.Identifier);
}
}
}
return usages;
}
export function isPartOfTypeExpression(node: TSESTree.Identifier): boolean {
return node.parent.type.startsWith('TSType');
}
export function isPartOfClassDeclaration(node: TSESTree.Identifier): boolean {
if (node.parent === undefined) {
return false;
}
return node.parent.type === 'ClassDeclaration';
}

View File

@@ -15,7 +15,7 @@ import {
describe('TypeScript rules', () => { describe('TypeScript rules', () => {
for (const { info, rule, tests } of tsPlugin.index) { for (const { info, rule, tests } of tsPlugin.index) {
tsRuleTester.run(info.name, rule, tests); tsRuleTester.run(info.name, rule, tests as any);
} }
}); });

View File

@@ -8,13 +8,13 @@
import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester'; import { RuleTester as TypeScriptRuleTester } from '@typescript-eslint/rule-tester';
import { RuleTester } from 'eslint'; import { RuleTester } from 'eslint';
import { themeableComponents } from '../src/util/theme-support';
import { import {
FIXTURE, FIXTURE,
fixture, fixture,
} from './fixture'; } from './fixture';
import { themeableComponents } from '../src/util/theme-support';
// Register themed components from test fixture // Register themed components from test fixture
themeableComponents.initialize(FIXTURE); themeableComponents.initialize(FIXTURE);

View File

@@ -145,6 +145,7 @@
"@angular-builders/custom-webpack": "~15.0.0", "@angular-builders/custom-webpack": "~15.0.0",
"@angular-devkit/build-angular": "^15.2.6", "@angular-devkit/build-angular": "^15.2.6",
"@angular-eslint/builder": "15.2.1", "@angular-eslint/builder": "15.2.1",
"@angular-eslint/bundled-angular-compiler": "^17.2.1",
"@angular-eslint/eslint-plugin": "15.2.1", "@angular-eslint/eslint-plugin": "15.2.1",
"@angular-eslint/eslint-plugin-template": "15.2.1", "@angular-eslint/eslint-plugin-template": "15.2.1",
"@angular-eslint/schematics": "15.2.1", "@angular-eslint/schematics": "15.2.1",

View File

@@ -291,6 +291,11 @@
resolved "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-15.2.1.tgz" resolved "https://registry.npmjs.org/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-15.2.1.tgz"
integrity sha512-LO7Am8eVCr7oh6a0VmKSL7K03CnQEQhFO7Wt/YtbfYOxVjrbwmYLwJn+wZPOT7A02t/BttOD/WXuDrOWtSMQ/Q== integrity sha512-LO7Am8eVCr7oh6a0VmKSL7K03CnQEQhFO7Wt/YtbfYOxVjrbwmYLwJn+wZPOT7A02t/BttOD/WXuDrOWtSMQ/Q==
"@angular-eslint/bundled-angular-compiler@^17.2.1":
version "17.2.1"
resolved "https://registry.yarnpkg.com/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-17.2.1.tgz#d849b0845371b41856b9f598af81ce5bf799bca0"
integrity sha512-puC0itsZv2QlrDOCcWtq1KZH+DvfrpV+mV78HHhi6+h25R5iIhr8ARKcl3EQxFjvrFq34jhG8pSupxKvFbHVfA==
"@angular-eslint/eslint-plugin-template@15.2.1": "@angular-eslint/eslint-plugin-template@15.2.1":
version "15.2.1" version "15.2.1"
resolved "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-15.2.1.tgz" resolved "https://registry.npmjs.org/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-15.2.1.tgz"