or run

npx @tessl/cli init
Log in

Version

Tile

Overview

Evals

Files

docs

ast-helpers.mdconfiguration.mdformatters.mdindex.mdlinter.mdrule-development.mdrules.md
tile.json

rule-development.mddocs/

Rule Development

Framework for creating custom linting rules with AST visitor patterns, comprehensive testing utilities, and integration with the ember-template-lint ecosystem.

Capabilities

Rule Base Class

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
}

Rule Options

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;
}

Visitor Methods

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;
}

Rule Result

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;
}

Rule Test Harness

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;
}

Test Cases

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"'
    };
  }
}

Advanced Rule Patterns

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;
  }
}