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-modifiersFor Community
Submit to CLI core if generally useful:
# Fork CLI repo
# Add modifier to src/core/services/file-system/modifiers/
# Submit PRBest 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
- Advanced Configuration → - Configure custom behavior
- Creating Marketplace → - Build complete marketplace
- CLI Architecture → - Understand the modifier system
Custom modifiers let you enforce any pattern. Use ts-morph's power to transform code reliably.