diff --git a/package.json b/package.json index 465f0d069..ba753b1f6 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "validate:json": "node scripts/validation/validate-json.mjs", "validate:configs": "node scripts/validation/validate-app-configs.mjs", "validate:ports": "tsx ../infrastructure/scripts/generate-ports.ts --check", + "validate:path-aliases": "node scripts/validation/check-path-aliases.mjs --all", "screenshots": "cd @packages/@utility/screenshot && pnpm exec tsx bin/screenshot-generator.ts", "screenshots:quick": "cd @packages/@utility/screenshot && pnpm exec tsx bin/screenshot-generator.ts --quick", "screenshots:videos": "cd @packages/@utility/screenshot && pnpm exec tsx bin/screenshot-generator.ts --videos", diff --git a/scripts/validation/check-path-aliases.mjs b/scripts/validation/check-path-aliases.mjs new file mode 100755 index 000000000..2f246535e --- /dev/null +++ b/scripts/validation/check-path-aliases.mjs @@ -0,0 +1,208 @@ +#!/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); +});