Framework for creating custom linting rules with AST visitor patterns, comprehensive testing utilities, and integration with the ember-template-lint ecosystem.
Base class that all custom linting rules must extend.
/**
* Base class for all linting rules
* Provides visitor pattern implementation and logging utilities
*/
class Rule {
constructor(options: RuleOptions): Rule;
/**
* Define AST visitor methods for rule implementation
* @returns Object with visitor methods for different AST node types
*/
visitor(): VisitorMethods;
/**
* Log a linting violation
* @param result - Details of the violation to report
*/
log(result: RuleResult): void;
/**
* Parse and validate rule configuration (can be overridden)
* @param config - Raw configuration from user
* @returns Processed configuration object
*/
parseConfig(config: any): any;
/**
* Extract source text for a specific AST node
* @param node - AST node to extract source for
* @returns Source text string
*/
sourceForNode(node: ASTNode): string;
/**
* Extract source text for a location range
* @param loc - Source location with start/end positions
* @returns Source text string
*/
sourceForLoc(loc: SourceLocation): string;
/**
* Check if a node refers to a local variable
* @param node - Path expression node to check
* @returns True if node is a local variable reference
*/
isLocal(node: PathExpression): boolean;
// Public properties
ruleName: string; // Name of the rule
config: any; // Parsed rule configuration
workingDir: string; // Working directory
mode: 'fix' | 'report'; // Current mode (fix or report)
severity: number; // Rule severity level
filePath: string; // Current file being linted
columnOffset: number; // Column offset for embedded templates
source: string[]; // Split lines of template source
isStrictMode: boolean; // Whether in strict mode
isEmbedded: boolean; // Whether in embedded template
scope: Scope; // Variable scope management
// Getters
get editorConfig(): Record<string, any>; // Access to editor configuration
}Configuration options passed to rule constructors.
interface RuleOptions {
/** Name of the rule */
name: string;
/** User configuration for this rule */
config: any;
/** Console object for logging */
console: Console;
/** Default severity level for this rule */
defaultSeverity: number;
/** Working directory for resolving paths */
workingDir: string;
/** List of all available rule names */
ruleNames: string[];
/** Whether inline configuration is allowed */
allowInlineConfig: boolean;
/** Whether to report unused disable directives */
reportUnusedDisableDirectives: boolean;
/** Whether rule should attempt to apply fixes */
shouldFix: boolean;
/** Whether template is in strict mode */
isStrictMode: boolean;
/** Whether template is embedded in JS/TS file */
isEmbedded: boolean;
/** Column offset for embedded templates */
columnOffset: number;
/** Raw template source code */
rawSource: string;
/** File path being linted */
filePath: string;
/** Module name (deprecated) */
moduleName?: string;
/** File-specific configuration */
fileConfig: any;
/** Configuration resolver for dynamic settings */
configResolver?: ConfigResolver;
}AST visitor pattern interface for rule implementation.
interface VisitorMethods {
/** Visit all AST nodes */
All?(node: ASTNode, path?: TraversalPath): void;
/** Visit template root */
Template?(node: Template, path?: TraversalPath): void;
/** Visit HTML elements - can use enter/exit pattern */
ElementNode?: VisitorFunction<ElementNode> | {
enter?(node: ElementNode, path?: TraversalPath): void;
exit?(node: ElementNode, path?: TraversalPath): void;
};
/** Visit block statements ({{#if}}, {{#each}}, etc.) - can use enter/exit pattern */
BlockStatement?: VisitorFunction<BlockStatement> | {
enter?(node: BlockStatement, path?: TraversalPath): void;
exit?(node: BlockStatement, path?: TraversalPath): void;
};
/** Visit block nodes (separate from BlockStatement) */
Block?: VisitorFunction<Block> | {
enter?(node: Block, path?: TraversalPath): void;
exit?(node: Block, path?: TraversalPath): void;
};
/** Visit mustache statements ({{expression}}) */
MustacheStatement?(node: MustacheStatement, path?: TraversalPath): void;
/** Visit sub-expressions */
SubExpression?(node: SubExpression, path?: TraversalPath): void;
/** Visit path expressions */
PathExpression?(node: PathExpression, path?: TraversalPath): void;
/** Visit string literals */
StringLiteral?(node: StringLiteral, path?: TraversalPath): void;
/** Visit number literals */
NumberLiteral?(node: NumberLiteral): void;
/** Visit boolean literals */
BooleanLiteral?(node: BooleanLiteral): void;
/** Visit HTML attributes */
AttrNode?(node: AttrNode): void;
/** Visit text nodes */
TextNode?(node: TextNode): void;
/** Visit HTML comments */
CommentStatement?(node: CommentStatement): void;
/** Visit mustache comments */
MustacheCommentStatement?(node: MustacheCommentStatement): void;
/** Visit element modifiers */
ElementModifierStatement?(node: ElementModifierStatement): void;
/** Visit partial statements */
PartialStatement?(node: PartialStatement): void;
}
// Helper types for visitor functions
type VisitorFunction<T> = (node: T, path?: TraversalPath) => void;
interface TraversalPath {
/** Parent node in the AST */
parent: ASTNode;
/** Get array of ancestor nodes */
parents(): ASTNode[];
}
interface Scope {
/** Check if a variable is in scope */
has(name: string): boolean;
/** Add a variable to the current scope */
set(name: string): void;
}Structure for reporting linting violations.
interface RuleResult {
/** Human-readable violation message */
message: string;
/** Line number (1-based) */
line?: number;
/** Column number (0-based) */
column?: number;
/** End line for multi-line violations */
endLine?: number;
/** End column for multi-character violations */
endColumn?: number;
/** Automatic fix information */
fix?: FixInfo;
/** Severity override for this specific violation */
severity?: number;
/** Source code context */
source?: string;
/** Whether this is a fatal error */
fatal?: boolean;
/** AST node where violation occurs */
node?: ASTNode;
}
interface FixInfo {
/** Character range to replace [start, end] */
range: [number, number];
/** Replacement text */
text: string;
}Testing framework for developing and validating custom rules.
/**
* Generate comprehensive tests for a linting rule
* @param options - Test configuration and test cases
*/
function generateRuleTests(options: RuleTestOptions): void;
interface RuleTestOptions {
/** Rule name */
name: string;
/** Rule class constructor */
Rule?: typeof Rule;
/** Rule configuration */
config?: any;
/** Plugin dependencies */
plugins?: Plugin[];
/** Test cases that should pass */
good: GoodTestCase[];
/** Test cases that should fail */
bad: BadTestCase[];
/** Custom error message for test failures */
error?: string;
/** Test method to use (default: 'it') */
testMethod?: string;
/** Grouping method (default: 'describe') */
groupingMethod?: string;
/** Focus method for debugging (default: 'fit') */
focusMethod?: string;
/** Whether to skip disabled tests */
skipDisabledTests?: boolean;
/** Metadata for test configuration */
meta?: TestMeta;
}Structure for good and bad test cases.
interface GoodTestCase {
/** Template that should not trigger the rule */
template: string;
/** Rule configuration for this test */
config?: any;
/** Test case name/description */
name?: string;
/** Focus this test case */
focus?: boolean;
/** Metadata for this test */
meta?: TestMeta;
}
interface BadTestCase {
/** Template that should trigger the rule */
template: string;
/** Expected violation results */
result: ExpectedResult | ExpectedResult[];
/** Rule configuration for this test */
config?: any;
/** Test case name/description */
name?: string;
/** Expected template after auto-fixing */
fixedTemplate?: string;
/** Focus this test case */
focus?: boolean;
/** Metadata for this test */
meta?: TestMeta;
}
interface ExpectedResult {
/** Expected violation message */
message: string;
/** Expected line number */
line?: number;
/** Expected column number */
column?: number;
/** Expected end line */
endLine?: number;
/** Expected end column */
endColumn?: number;
/** Expected source context */
source?: string;
}
interface TestMeta {
/** File path for the test template */
filePath?: string;
/** Working directory */
workingDir?: string;
/** Configuration resolver */
configResolver?: ConfigResolver;
}Usage Examples:
import { Rule, generateRuleTests } from "ember-template-lint";
// Custom rule implementation
class NoBarStringRule extends Rule {
visitor() {
return {
TextNode(node) {
if (node.chars.includes('bar')) {
this.log({
message: 'Do not use "bar" in templates',
line: node.loc.start.line,
column: node.loc.start.column,
node
});
}
}
};
}
}
// Rule tests
generateRuleTests({
name: 'no-bar-string',
Rule: NoBarStringRule,
good: [
'<div>Hello world</div>',
'<div>{{message}}</div>',
{
template: '<div>foo</div>',
name: 'allows foo string'
}
],
bad: [
{
template: '<div>bar</div>',
result: {
message: 'Do not use "bar" in templates',
line: 1,
column: 5
}
},
{
template: '<div>foobar</div>',
result: {
message: 'Do not use "bar" in templates',
line: 1,
column: 5
},
name: 'detects bar in longer strings'
}
]
});
// Rule with configuration
class NoSpecificStringRule extends Rule {
visitor() {
const forbiddenString = this.config.forbidden || 'default';
return {
TextNode(node) {
if (node.chars.includes(forbiddenString)) {
this.log({
message: `Do not use "${forbiddenString}" in templates`,
line: node.loc.start.line,
column: node.loc.start.column,
node
});
}
}
};
}
}
// Configurable rule tests
generateRuleTests({
name: 'no-specific-string',
Rule: NoSpecificStringRule,
config: { forbidden: 'secret' },
good: [
'<div>public info</div>'
],
bad: [
{
template: '<div>secret data</div>',
result: {
message: 'Do not use "secret" in templates'
}
}
]
});
// Rule with auto-fix capability
class AddClassRule extends Rule {
visitor() {
return {
ElementNode(node) {
if (node.tag === 'div' && !this.hasClass(node)) {
const fix = this.generateFix(node);
this.log({
message: 'div elements should have a class',
line: node.loc.start.line,
column: node.loc.start.column,
fix,
node
});
}
}
};
}
hasClass(node) {
return node.attributes.some(attr => attr.name === 'class');
}
generateFix(node) {
// Implementation depends on AST manipulation
return {
range: [node.loc.start.index, node.loc.start.index],
text: ' class="default"'
};
}
}Common patterns for implementing sophisticated rules.
// Helper utilities available in rules
class RuleHelpers {
/** Check if node is a specific helper/component */
static isHelper(node: ASTNode, helperName: string): boolean;
/** Get attribute value from element */
static getAttributeValue(element: ElementNode, attrName: string): string | null;
/** Check if element has specific attribute */
static hasAttribute(element: ElementNode, attrName: string): boolean;
/** Get node's source location info */
static getNodeInfo(node: ASTNode): NodeInfo;
}
interface NodeInfo {
line: number;
column: number;
endLine: number;
endColumn: number;
source: string;
}Complex rule example:
class NoInvalidAriaRule extends Rule {
visitor() {
return {
ElementNode(node) {
const ariaAttributes = this.getAriaAttributes(node);
for (const attr of ariaAttributes) {
if (!this.isValidAriaAttribute(attr.name, node.tag)) {
this.log({
message: `"${attr.name}" is not supported on <${node.tag}> elements`,
line: attr.loc.start.line,
column: attr.loc.start.column,
node: attr
});
}
}
}
};
}
getAriaAttributes(element) {
return element.attributes.filter(attr =>
attr.name.startsWith('aria-')
);
}
isValidAriaAttribute(attrName, tagName) {
// Implementation based on ARIA specification
const validCombinations = this.config.validCombinations || {};
return validCombinations[tagName]?.includes(attrName) ?? true;
}
}