Linter for Ember or Handlebars templates.
—
Quality
Pending
Does it follow best practices?
Impact
Pending
No eval scenarios have been run
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;
}
}Install with Tessl CLI
npx tessl i tessl/npm-ember-template-lint