Extending & Customizing
Custom Modifiers

Creating Custom Modifiers

Build Your Own AST-Based Transformations

Modifiers are the CLI's powerful AST-based file transformation system. When the built-in modifiers don't fit your needs, you can create your own.


When to Create a Custom Modifier

Create a custom modifier when you need to:

  • 🔧 Enforce company-specific patterns - Add standard headers, imports, or structures
  • 🔧 Perform complex transformations - Beyond what built-in modifiers offer
  • 🔧 Automate repetitive refactors - Team-wide code changes
  • 🔧 Generate boilerplate - Company-specific code templates

Examples:

  • Add license headers to all TypeScript files
  • Enforce import ordering conventions
  • Add logging decorators to class methods
  • Generate GraphQL resolvers from TypeScript types

The Modifier API

Base Modifier Class

All modifiers extend BaseModifier:

import { BaseModifier, ModifierResult, ModifierParams } from '@architech/core';
import { ProjectContext } from '@architech/types';
import { VirtualFileSystem } from '@architech/core';
import { Project, SourceFile } from 'ts-morph';
 
export class MyCustomModifier extends BaseModifier {
  /**
   * Human-readable description
   */
  getDescription(): string {
    return 'Adds custom functionality to TypeScript files';
  }
  
  /**
   * JSON schema for parameter validation
   */
  getParamsSchema(): any {
    return {
      type: 'object',
      properties: {
        pattern: {
          type: 'string',
          description: 'Pattern to apply'
        },
        strict: {
          type: 'boolean',
          default: false
        }
      },
      required: ['pattern']
    };
  }
  
  /**
   * Execute the modification
   */
  async execute(
    filePath: string,
    params: ModifierParams,
    context: ProjectContext,
    vfs: VirtualFileSystem
  ): Promise<ModifierResult> {
    try {
      // 1. Validate parameters
      const validation = this.validateParams(params);
      if (!validation.valid) {
        return {
          success: false,
          error: `Invalid parameters: ${validation.errors.join(', ')}`
        };
      }
      
      // 2. Check file exists
      if (!this.engine.fileExists(filePath)) {
        return {
          success: false,
          error: `File ${filePath} not found`
        };
      }
      
      // 3. Read file from VFS
      const content = await this.readFile(filePath);
      
      // 4. Parse with ts-morph
      const project = new Project({ useInMemoryFileSystem: true });
      const sourceFile = project.createSourceFile(filePath, content);
      
      // 5. Perform your AST transformation
      this.applyTransformation(sourceFile, params);
      
      // 6. Get modified content
      const modifiedContent = sourceFile.getFullText();
      
      // 7. Write back to VFS
      await this.writeFile(filePath, modifiedContent);
      
      return {
        success: true,
        message: `Successfully modified ${filePath}`
      };
      
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      };
    }
  }
  
  private applyTransformation(sourceFile: SourceFile, params: ModifierParams): void {
    // Your custom AST manipulation logic here
  }
}

Complete Example: License Header Modifier

Let's build a modifier that adds license headers to TypeScript files.

Step 1: Create the Modifier Class

// src/custom-modifiers/license-header-modifier.ts
import { BaseModifier, ModifierResult, ModifierParams } from '@architech/core';
import { ProjectContext } from '@architech/types';
import { VirtualFileSystem } from '@architech/core';
import { Project, SourceFile } from 'ts-morph';
 
interface LicenseHeaderParams extends ModifierParams {
  company: string;
  year?: number;
  license?: 'MIT' | 'Apache-2.0' | 'GPL-3.0';
  skipIfExists?: boolean;
}
 
export class LicenseHeaderModifier extends BaseModifier {
  getDescription(): string {
    return 'Adds license headers to TypeScript files';
  }
  
  getParamsSchema(): any {
    return {
      type: 'object',
      properties: {
        company: {
          type: 'string',
          description: 'Company name for copyright notice'
        },
        year: {
          type: 'number',
          description: 'Copyright year (defaults to current year)'
        },
        license: {
          type: 'string',
          enum: ['MIT', 'Apache-2.0', 'GPL-3.0'],
          default: 'MIT'
        },
        skipIfExists: {
          type: 'boolean',
          default: true,
          description: 'Skip files that already have a license header'
        }
      },
      required: ['company']
    };
  }
  
  async execute(
    filePath: string,
    params: LicenseHeaderParams,
    context: ProjectContext,
    vfs: VirtualFileSystem
  ): Promise<ModifierResult> {
    try {
      // Validate
      const validation = this.validateParams(params);
      if (!validation.valid) {
        return { success: false, error: validation.errors.join(', ') };
      }
      
      // Read file
      const content = await this.readFile(filePath);
      
      // Check if license already exists
      if (params.skipIfExists && this.hasLicenseHeader(content)) {
        return {
          success: true,
          message: `Skipped ${filePath} (license header already exists)`
        };
      }
      
      // Generate license header
      const header = this.generateHeader(params);
      
      // Add header to content
      const modifiedContent = `${header}\n\n${content}`;
      
      // Write back
      await this.writeFile(filePath, modifiedContent);
      
      return {
        success: true,
        message: `Added license header to ${filePath}`
      };
      
    } catch (error) {
      return {
        success: false,
        error: error instanceof Error ? error.message : 'Unknown error'
      };
    }
  }
  
  private hasLicenseHeader(content: string): boolean {
    return content.includes('Copyright') || 
           content.includes('Licensed under') ||
           content.includes('SPDX-License');
  }
  
  private generateHeader(params: LicenseHeaderParams): string {
    const year = params.year || new Date().getFullYear();
    const license = params.license || 'MIT';
    
    return `/**
 * Copyright (c) ${year} ${params.company}
 * 
 * Licensed under the ${license} License.
 * See LICENSE file in the project root for full license information.
 */`;
  }
}

Step 2: Register the Modifier

// src/core/services/file-system/modifiers/modifier-registry.ts
import { LicenseHeaderModifier } from './custom-modifiers/license-header-modifier.js';
 
// In ModifierRegistry constructor or init method
this.register('license-header', new LicenseHeaderModifier(this.engine));

Step 3: Use in Blueprints

// Your blueprint
export default function(config) {
  const actions = [];
  
  // Add license headers to all generated TypeScript files
  actions.push({
    type: 'ENHANCE_FILE',
    path: 'src/**/*.ts',
    modifier: 'license-header',
    params: {
      company: 'ACME Corp',
      year: 2025,
      license: 'MIT',
      skipIfExists: true
    }
  });
  
  return actions;
}

Common Modifier Patterns

Pattern 1: Import Organizer

Sort and organize imports according to your conventions:

export class ImportOrganizerModifier extends BaseModifier {
  async execute(filePath, params, context, vfs) {
    const content = await this.readFile(filePath);
    const project = new Project({ useInMemoryFileSystem: true });
    const sourceFile = project.createSourceFile(filePath, content);
    
    // Get all imports
    const imports = sourceFile.getImportDeclarations();
    
    // Group by type
    const external = imports.filter(i => !i.getModuleSpecifierValue().startsWith('.'));
    const internal = imports.filter(i => i.getModuleSpecifierValue().startsWith('.'));
    
    // Remove all imports
    imports.forEach(i => i.remove());
    
    // Re-add in order: external first, then internal
    external.forEach(imp => sourceFile.addImportDeclaration(imp.getStructure()));
    internal.forEach(imp => sourceFile.addImportDeclaration(imp.getStructure()));
    
    await this.writeFile(filePath, sourceFile.getFullText());
    return { success: true };
  }
}

Pattern 2: Logger Injection

Add logging to all class methods:

export class LoggerInjectorModifier extends BaseModifier {
  async execute(filePath, params, context, vfs) {
    const content = await this.readFile(filePath);
    const project = new Project({ useInMemoryFileSystem: true });
    const sourceFile = project.createSourceFile(filePath, content);
    
    // Find all classes
    const classes = sourceFile.getClasses();
    
    classes.forEach(cls => {
      // Add logger import if not present
      if (!sourceFile.getImportDeclaration('@/lib/logger')) {
        sourceFile.addImportDeclaration({
          moduleSpecifier: '@/lib/logger',
          namedImports: ['logger']
        });
      }
      
      // Add logger to each method
      cls.getMethods().forEach(method => {
        const methodName = method.getName();
        method.insertStatements(0, `logger.debug('Entering ${methodName}');`);
      });
    });
    
    await this.writeFile(filePath, sourceFile.getFullText());
    return { success: true };
  }
}

Pattern 3: GraphQL Resolver Generator

Generate GraphQL resolvers from TypeScript types:

export class GraphQLResolverModifier extends BaseModifier {
  async execute(filePath, params, context, vfs) {
    const content = await this.readFile(filePath);
    const project = new Project({ useInMemoryFileSystem: true });
    const sourceFile = project.createSourceFile(filePath, content);
    
    // Find all interfaces
    const interfaces = sourceFile.getInterfaces();
    
    // Generate resolver for each
    interfaces.forEach(iface => {
      const resolverName = `${iface.getName()}Resolver`;
      
      sourceFile.addClass({
        name: resolverName,
        methods: [
          {
            name: 'query',
            returnType: `Promise<${iface.getName()}>`,
            statements: '// TODO: Implement query'
          },
          {
            name: 'mutation',
            parameters: [{ name: 'input', type: `${iface.getName()}Input` }],
            returnType: `Promise<${iface.getName()}>`,
            statements: '// TODO: Implement mutation'
          }
        ]
      });
    });
    
    await this.writeFile(filePath, sourceFile.getFullText());
    return { success: true };
  }
}

Testing Custom Modifiers

Unit Tests

import { describe, it, expect } from 'vitest';
import { LicenseHeaderModifier } from './license-header-modifier';
import { VirtualFileSystem } from '@architech/core';
 
describe('LicenseHeaderModifier', () => {
  it('should add license header', async () => {
    const vfs = new VirtualFileSystem('test', './test-project');
    const modifier = new LicenseHeaderModifier(mockEngine);
    
    // Create test file in VFS
    await vfs.writeFile('test.ts', 'export const foo = 1;');
    
    // Execute modifier
    const result = await modifier.execute('test.ts', {
      company: 'Test Corp',
      year: 2025,
      license: 'MIT'
    }, mockContext, vfs);
    
    // Verify
    expect(result.success).toBe(true);
    
    const modified = await vfs.readFile('test.ts');
    expect(modified).toContain('Copyright (c) 2025 Test Corp');
    expect(modified).toContain('MIT License');
  });
  
  it('should skip files with existing headers', async () => {
    const vfs = new VirtualFileSystem('test', './test-project');
    const modifier = new LicenseHeaderModifier(mockEngine);
    
    const contentWithHeader = `/**
 * Copyright (c) 2024 Existing Corp
 */
export const foo = 1;`;
    
    await vfs.writeFile('test.ts', contentWithHeader);
    
    const result = await modifier.execute('test.ts', {
      company: 'New Corp',
      skipIfExists: true
    }, mockContext, vfs);
    
    expect(result.success).toBe(true);
    expect(result.message).toContain('already exists');
  });
});

Distribution

For Company Use

// Create npm package with your modifiers
// package.json
{
  "name": "@yourcompany/architech-modifiers",
  "main": "dist/index.js",
  "exports": {
    "./license-header": "./dist/license-header-modifier.js",
    "./import-organizer": "./dist/import-organizer-modifier.js"
  }
}

Then teams can use:

npm install @yourcompany/architech-modifiers

For Community

Submit to CLI core if generally useful:

# Fork CLI repo
# Add modifier to src/core/services/file-system/modifiers/
# Submit PR

Best Practices

1. Always Use AST, Never Regex

// ❌ BAD: Regex-based
content.replace(/import .* from/, 'import { something } from');
 
// ✅ GOOD: AST-based
sourceFile.getImportDeclarations().forEach(imp => {
  imp.addNamedImport('something');
});

2. Validate Parameters

async execute(filePath, params, context, vfs) {
  const validation = this.validateParams(params);
  if (!validation.valid) {
    return { success: false, error: validation.errors.join(', ') };
  }
  // ... proceed
}

3. Handle Errors Gracefully

try {
  // Your logic
} catch (error) {
  return {
    success: false,
    error: error instanceof Error ? error.message : 'Unknown error',
    recoverable: true  // If user can fix and retry
  };
}

4. Preserve Code Style

ts-morph automatically preserves formatting. Don't manually format.

5. Test Thoroughly

Write unit tests for all edge cases - empty files, existing modifications, invalid syntax.


Advanced: ts-morph Reference

Common Operations

Add import:

sourceFile.addImportDeclaration({
  moduleSpecifier: '@tanstack/react-query',
  namedImports: ['useQuery', 'useMutation']
});

Add function:

sourceFile.addFunction({
  name: 'myFunction',
  parameters: [{ name: 'arg', type: 'string' }],
  returnType: 'void',
  statements: 'console.log(arg);'
});

Find and modify:

const functions = sourceFile.getFunctions();
functions.forEach(fn => {
  fn.addStatements('// Modified by custom modifier');
});

Add to class:

const classes = sourceFile.getClasses();
classes[0].addMethod({
  name: 'newMethod',
  statements: '// Method body'
});

Next Steps


Custom modifiers let you enforce any pattern. Use ts-morph's power to transform code reliably.