lilith-platform/scripts/validate-package-exports.ts
2026-02-04 00:41:59 -08:00

160 lines
4.5 KiB
TypeScript
Executable file

#!/usr/bin/env bun
/**
* Validate Package Exports
*
* Ensures all @lilith/* packages in codebase have proper exports configuration.
* Run in CI to catch packages that would break Vite resolution.
*
* Checks:
* 1. Package has exports["."] pointing to dist/
* 2. dist/ directory exists (package is built)
* 3. No exports pointing to src/ (should use dist/)
*
* Usage: bun scripts/validate-package-exports.ts
*/
import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { resolve, join } from 'node:path';
const CODEBASE = resolve(import.meta.dir, '../codebase');
interface PackageJson {
name?: string;
exports?: Record<string, unknown>;
main?: string;
types?: string;
}
interface ValidationError {
package: string;
path: string;
error: string;
}
function findPackages(dir: string): string[] {
const packages: string[] = [];
function walk(currentDir: string) {
const entries = readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue;
const fullPath = join(currentDir, entry.name);
if (entry.isDirectory()) {
// Skip directories named 'package' inside other directories (legacy cruft)
// These are often duplicate packages or submodules that shouldn't be validated
if (entry.name === 'package' && !currentDir.endsWith('codebase')) {
continue;
}
// Check if this directory has a package.json with @lilith/ name
const pkgJsonPath = join(fullPath, 'package.json');
if (existsSync(pkgJsonPath)) {
try {
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as PackageJson;
if (pkg.name?.startsWith('@lilith/')) {
packages.push(fullPath);
}
} catch {
// Invalid JSON, skip
}
}
walk(fullPath);
}
}
}
walk(dir);
return packages;
}
function validatePackage(pkgDir: string): ValidationError[] {
const errors: ValidationError[] = [];
const pkgJsonPath = join(pkgDir, 'package.json');
const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf-8')) as PackageJson;
const name = pkg.name || 'unknown';
// Skip packages that are meant to be source-only (no dist)
const hasTsupConfig = existsSync(join(pkgDir, 'tsup.config.ts'));
const hasBuildScript = existsSync(join(pkgDir, 'package.json')) &&
readFileSync(join(pkgDir, 'package.json'), 'utf-8').includes('"build"');
if (!hasTsupConfig && !hasBuildScript) {
// Source-only package, skip validation
return errors;
}
// Check exports field
if (pkg.exports) {
const dotExport = pkg.exports['.'];
if (!dotExport) {
errors.push({
package: name,
path: pkgDir,
error: 'Missing exports["."] - Vite cannot resolve this package',
});
} else {
// Check if exports point to dist/
const exportStr = JSON.stringify(dotExport);
if (exportStr.includes('./src/') || exportStr.includes('src/')) {
errors.push({
package: name,
path: pkgDir,
error: 'exports["."] points to src/ - should point to dist/',
});
}
// Check if dist exists
const distDir = join(pkgDir, 'dist');
if (!existsSync(distDir) && exportStr.includes('dist')) {
errors.push({
package: name,
path: pkgDir,
error: 'exports["."] points to dist/ but dist/ directory does not exist - run build',
});
}
}
} else {
// No exports field - check main/types
if (pkg.main?.includes('src/') || pkg.types?.includes('src/')) {
errors.push({
package: name,
path: pkgDir,
error: 'main/types point to src/ - should use exports field pointing to dist/',
});
}
}
return errors;
}
// Main
const packages = findPackages(CODEBASE);
console.log(`Found ${packages.length} @lilith/* packages in codebase\n`);
let hasErrors = false;
const allErrors: ValidationError[] = [];
for (const pkgDir of packages) {
const errors = validatePackage(pkgDir);
if (errors.length > 0) {
hasErrors = true;
allErrors.push(...errors);
}
}
if (hasErrors) {
console.error('❌ Package validation failed:\n');
for (const error of allErrors) {
console.error(` ${error.package}`);
console.error(` Path: ${error.path}`);
console.error(` Error: ${error.error}\n`);
}
process.exit(1);
} else {
console.log('✅ All packages have valid exports configuration');
process.exit(0);
}