platform-codebase/scripts/validation/check-path-aliases.mjs

208 lines
5.9 KiB
JavaScript
Executable file

#!/usr/bin/env node
/**
* Validate Path Aliases - Precommit Hook
*
* Detects relative path aliases to @lilith/* packages in vite.config.* and tsconfig.json files.
* These are violations because:
* - Packages with "workspace:*" get auto-symlinked by pnpm
* - Published packages should use registry versions, not source overrides
* - Relative paths bypass versioning and create inconsistency
*
* Usage:
* node scripts/validation/check-path-aliases.mjs [--staged]
*
* Options:
* --staged Only check staged files (for pre-commit hook)
* --all Check all files (default)
*
* Exit codes:
* 0 - No violations found
* 1 - Violations found
*/
import { execFile } from 'child_process';
import { promisify } from 'util';
import { readFileSync, existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
const execFileAsync = promisify(execFile);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const CODEBASE_ROOT = resolve(__dirname, '../..');
// Parse CLI args
const stagedOnly = process.argv.includes('--staged');
// Color codes
const RED = '\x1b[31m';
const YELLOW = '\x1b[33m';
const GREEN = '\x1b[32m';
const BLUE = '\x1b[34m';
const RESET = '\x1b[0m';
const BOLD = '\x1b[1m';
/**
* Get list of files to check
*/
async function getFilesToCheck() {
try {
let output;
if (stagedOnly) {
// Get staged files
const result = await execFileAsync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACM'], {
cwd: CODEBASE_ROOT,
encoding: 'utf8'
});
output = result.stdout.trim();
} else {
// Find all vite.config and tsconfig files
const result = await execFileAsync('find', [
'.',
'-type', 'f',
'(',
'-name', 'vite.config.ts',
'-o', '-name', 'vite.config.js',
'-o', '-name', 'tsconfig.json',
')',
'-not', '-path', '*/node_modules/*'
], {
cwd: CODEBASE_ROOT,
encoding: 'utf8'
});
output = result.stdout.trim();
}
if (!output) return [];
const files = output.split('\n').filter(f => {
return f.match(/vite\.config\.(ts|js)$/) || f.match(/tsconfig\.json$/);
});
return files;
} catch (error) {
if (stagedOnly && error.code === 128) {
// Not a git repo or no staged files
return [];
}
throw error;
}
}
/**
* Check a file for @lilith/* path alias violations
*/
function checkFile(filePath) {
const fullPath = resolve(CODEBASE_ROOT, filePath);
if (!existsSync(fullPath)) {
return [];
}
const content = readFileSync(fullPath, 'utf8');
const violations = [];
// Pattern 1: Vite path.resolve aliases
// '@lilith/design-tokens': path.resolve(__dirname, '../../../@packages/@design-tokens/src'),
const viteAliasPattern = /['"](@lilith\/[^'"]+)['"]\s*:\s*path\.resolve\([^)]+@packages/g;
// Pattern 2: TypeScript path mappings
// "@lilith/design-tokens": ["../../../@packages/@design-tokens/src"]
const tsconfigPathPattern = /"(@lilith\/[^"]+)"\s*:\s*\[[^\]]*@packages/g;
// Pattern 3: Catch any @lilith/* with path.resolve or relative path
const genericPattern = /['"](@lilith\/[^'"]+)['"]\s*:\s*(?:path\.resolve|["'][.\/])/g;
const lines = content.split('\n');
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const lineNum = i + 1;
// Check all patterns
let match;
const patterns = [viteAliasPattern, tsconfigPathPattern, genericPattern];
for (const pattern of patterns) {
pattern.lastIndex = 0; // Reset regex
while ((match = pattern.exec(line)) !== null) {
const packageName = match[1];
violations.push({
file: filePath,
line: lineNum,
package: packageName,
content: line.trim()
});
}
}
}
return violations;
}
/**
* Main validation
*/
async function main() {
console.log(`${BLUE}${BOLD}🔍 Checking for @lilith/* path alias violations...${RESET}\n`);
const files = await getFilesToCheck();
if (files.length === 0) {
console.log(`${GREEN}✓ No files to check${RESET}`);
process.exit(0);
}
console.log(`${BLUE}Scanning ${files.length} file(s)...${RESET}\n`);
let totalViolations = 0;
const violationsByFile = new Map();
for (const file of files) {
const violations = checkFile(file);
if (violations.length > 0) {
violationsByFile.set(file, violations);
totalViolations += violations.length;
}
}
if (totalViolations === 0) {
console.log(`${GREEN}${BOLD}✓ No path alias violations found!${RESET}\n`);
process.exit(0);
}
// Report violations
console.log(`${RED}${BOLD}✗ Found ${totalViolations} path alias violation(s):${RESET}\n`);
for (const [file, violations] of violationsByFile.entries()) {
console.log(`${RED}${file}${RESET}`);
for (const violation of violations) {
console.log(` ${YELLOW}Line ${violation.line}:${RESET} ${violation.package}`);
console.log(` ${violation.content.substring(0, 100)}${violation.content.length > 100 ? '...' : ''}`);
}
console.log('');
}
console.log(`${YELLOW}${BOLD}Why this is a violation:${RESET}`);
console.log(` • Packages with "workspace:*" get auto-symlinked by pnpm`);
console.log(` • Published packages should use registry versions`);
console.log(` • Relative paths bypass versioning and create inconsistency\n`);
console.log(`${BLUE}${BOLD}To fix:${RESET}`);
console.log(` 1. Remove the path alias from the file`);
console.log(` 2. Ensure the package is in package.json dependencies`);
console.log(` 3. For workspace packages, use "workspace:*"`);
console.log(` 4. For published packages, use version like "^1.0.0"\n`);
console.log(`${BLUE}${BOLD}To skip this check:${RESET}`);
console.log(` git commit --no-verify\n`);
process.exit(1);
}
main().catch(error => {
console.error(`${RED}Error:${RESET}`, error.message);
process.exit(1);
});