Files
2026-07-13 12:38:34 +08:00

223 lines
6.5 KiB
TypeScript

import * as fs from 'node:fs';
import * as path from 'node:path';
import ts from 'typescript';
const repoRoot = process.cwd();
const examplesRoot = path.join(repoRoot, 'ts', 'examples');
const allowedSourceExtensions = new Set(['.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx', '.json']);
type ValidationIssue = {
example: string;
file: string;
message: string;
};
const issues: ValidationIssue[] = [];
const addIssue = (example: string, file: string, message: string) => {
issues.push({ example, file, message });
};
const collectFiles = (dir: string): string[] => {
const entries = fs.readdirSync(dir, { withFileTypes: true });
return entries.flatMap(entry => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
return collectFiles(fullPath);
}
return [fullPath];
});
};
const validatePackageJson = (exampleName: string, exampleDir: string) => {
const packageJsonPath = path.join(exampleDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
addIssue(exampleName, packageJsonPath, 'Missing package.json');
return;
}
try {
const raw = fs.readFileSync(packageJsonPath, 'utf8');
const pkg = JSON.parse(raw) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
name?: string;
private?: boolean;
scripts?: Record<string, string>;
};
if (!pkg.name?.endsWith('-example')) {
addIssue(exampleName, packageJsonPath, 'Package name should end with "-example"');
}
if (pkg.private !== true) {
addIssue(exampleName, packageJsonPath, 'Examples must be marked private');
}
if (!pkg.scripts || (!pkg.scripts.start && !pkg.scripts.dev)) {
addIssue(exampleName, packageJsonPath, 'Examples must expose a start or dev script');
}
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
if (!Object.keys(allDeps).some(dep => dep.startsWith('@composio/'))) {
addIssue(
exampleName,
packageJsonPath,
'Examples must depend on at least one @composio package'
);
}
} catch (error) {
addIssue(
exampleName,
packageJsonPath,
`Invalid package.json: ${error instanceof Error ? error.message : String(error)}`
);
}
};
const validateTsconfig = (exampleName: string, exampleDir: string) => {
const tsconfigPath = path.join(exampleDir, 'tsconfig.json');
if (!fs.existsSync(tsconfigPath)) {
addIssue(exampleName, tsconfigPath, 'Missing tsconfig.json');
return;
}
try {
const raw = fs.readFileSync(tsconfigPath, 'utf8');
const parsed = ts.parseConfigFileTextToJson(tsconfigPath, raw);
if (parsed.error) {
const message = ts.flattenDiagnosticMessageText(parsed.error.messageText, '\n');
addIssue(exampleName, tsconfigPath, `Invalid tsconfig.json: ${message}`);
return;
}
const config = parsed.config as { include?: string[] } | undefined;
if (!Array.isArray(config?.include) || config.include.length === 0) {
addIssue(exampleName, tsconfigPath, 'tsconfig.json should define a non-empty include array');
}
} catch (error) {
addIssue(
exampleName,
tsconfigPath,
`Invalid tsconfig.json: ${error instanceof Error ? error.message : String(error)}`
);
}
};
const validateSourceFile = (exampleName: string, filePath: string) => {
const extension = path.extname(filePath);
const relativePath = path.relative(repoRoot, filePath);
if (extension === '.json') {
try {
JSON.parse(fs.readFileSync(filePath, 'utf8'));
} catch (error) {
addIssue(
exampleName,
relativePath,
`Invalid JSON: ${error instanceof Error ? error.message : String(error)}`
);
}
return;
}
const sourceText = fs.readFileSync(filePath, 'utf8');
const isTsFile = ['.ts', '.tsx', '.mts'].includes(extension);
const scriptKind =
extension === '.tsx'
? ts.ScriptKind.TSX
: extension === '.jsx'
? ts.ScriptKind.JSX
: extension === '.js'
? ts.ScriptKind.JS
: extension === '.mjs'
? ts.ScriptKind.JS
: ts.ScriptKind.TS;
const transpiled = ts.transpileModule(sourceText, {
compilerOptions: {
allowJs: true,
module: ts.ModuleKind.ESNext,
target: ts.ScriptTarget.ES2022,
},
fileName: filePath,
reportDiagnostics: true,
});
for (const diagnostic of transpiled.diagnostics ?? []) {
if (diagnostic.category !== ts.DiagnosticCategory.Error) {
continue;
}
const message = ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n');
const location =
diagnostic.start !== undefined
? ts.getLineAndCharacterOfPosition(
ts.createSourceFile(filePath, sourceText, ts.ScriptTarget.ES2022, true, scriptKind),
diagnostic.start
)
: undefined;
const formattedLocation = location ? `:${location.line + 1}:${location.character + 1}` : '';
addIssue(exampleName, `${relativePath}${formattedLocation}`, `Syntax error: ${message}`);
}
if (isTsFile && sourceText.trim().length === 0) {
addIssue(exampleName, relativePath, 'Source file must not be empty');
}
};
const validateExample = (exampleDirName: string) => {
const exampleDir = path.join(examplesRoot, exampleDirName);
const srcDir = path.join(exampleDir, 'src');
validatePackageJson(exampleDirName, exampleDir);
validateTsconfig(exampleDirName, exampleDir);
if (!fs.existsSync(srcDir) || !fs.statSync(srcDir).isDirectory()) {
addIssue(exampleDirName, path.relative(repoRoot, srcDir), 'Missing src directory');
return;
}
const sourceFiles = collectFiles(srcDir).filter(file =>
allowedSourceExtensions.has(path.extname(file))
);
if (sourceFiles.length === 0) {
addIssue(exampleDirName, path.relative(repoRoot, srcDir), 'No source files found');
return;
}
for (const filePath of sourceFiles) {
validateSourceFile(exampleDirName, filePath);
}
};
const exampleDirs = fs
.readdirSync(examplesRoot, { withFileTypes: true })
.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.sort();
for (const exampleDirName of exampleDirs) {
validateExample(exampleDirName);
}
if (issues.length > 0) {
for (const issue of issues) {
console.error(`[${issue.example}] ${issue.file}: ${issue.message}`);
}
console.error(`\nExample validation failed with ${issues.length} issue(s).`);
process.exit(1);
}
console.log(`Validated ${exampleDirs.length} example packages.`);