This repository has been archived on 2025-08-03. You can view files and clone it, but cannot push or open issues or pull requests.
network-orbit/docs/scripts/doc-test-runner.ts
anonpenguin f3d5096d1c feat: Add migration guide and documentation test runner
- Introduced a comprehensive migration guide for DebrosFramework 0.5.x, detailing breaking changes, upgrade procedures, and best practices.
- Implemented a documentation test runner to validate code examples in documentation files, ensuring accuracy and consistency with the current implementation.
- Enhanced BlogAPIServer to handle potential null values in API responses, improving robustness and error handling.
2025-07-12 14:22:21 +03:00

552 lines
16 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env ts-node
/**
* Documentation Test Runner
*
* This script validates that all code examples in the documentation
* are accurate and work with the current implementation.
*/
import * as fs from 'fs';
import * as path from 'path';
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
interface CodeBlock {
language: string;
content: string;
file: string;
lineNumber: number;
}
interface TestResult {
file: string;
passed: number;
failed: number;
errors: string[];
}
interface ValidationError {
type: 'syntax' | 'api' | 'import' | 'type';
message: string;
file: string;
line?: number;
}
class DocumentationTestRunner {
private docsPath: string;
private results: TestResult[] = [];
private validationErrors: ValidationError[] = [];
constructor(docsPath: string = './docs') {
this.docsPath = docsPath;
}
async run(): Promise<void> {
console.log('🚀 Starting documentation validation...\n');
try {
// Find all markdown files
const mdFiles = await this.findMarkdownFiles();
console.log(`📄 Found ${mdFiles.length} documentation files\n`);
// Extract and validate code blocks
for (const file of mdFiles) {
await this.validateFile(file);
}
// Generate report
this.generateReport();
} catch (error) {
console.error('❌ Documentation validation failed:', error);
process.exit(1);
}
}
private async findMarkdownFiles(): Promise<string[]> {
const files: string[] = [];
const scanDirectory = (dir: string) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scanDirectory(fullPath);
} else if (item.endsWith('.md') || item.endsWith('.mdx')) {
files.push(fullPath);
}
}
};
scanDirectory(this.docsPath);
return files;
}
private async validateFile(filePath: string): Promise<void> {
console.log(`📝 Validating: ${path.relative(this.docsPath, filePath)}`);
const content = fs.readFileSync(filePath, 'utf-8');
const codeBlocks = this.extractCodeBlocks(content, filePath);
const result: TestResult = {
file: filePath,
passed: 0,
failed: 0,
errors: []
};
for (const block of codeBlocks) {
try {
await this.validateCodeBlock(block);
result.passed++;
console.log(` ✅ Code block at line ${block.lineNumber}`);
} catch (error) {
result.failed++;
result.errors.push(`Line ${block.lineNumber}: ${error.message}`);
console.log(` ❌ Code block at line ${block.lineNumber}: ${error.message}`);
}
}
this.results.push(result);
console.log();
}
private extractCodeBlocks(content: string, filePath: string): CodeBlock[] {
const blocks: CodeBlock[] = [];
const lines = content.split('\n');
let inCodeBlock = false;
let currentBlock: string[] = [];
let language = '';
let startLine = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
if (line.startsWith('```')) {
if (inCodeBlock) {
// End of code block
if (language === 'typescript' || language === 'ts' || language === 'javascript' || language === 'js') {
blocks.push({
language,
content: currentBlock.join('\n'),
file: filePath,
lineNumber: startLine
});
}
inCodeBlock = false;
currentBlock = [];
} else {
// Start of code block
language = line.slice(3).trim();
startLine = i + 1;
inCodeBlock = true;
}
} else if (inCodeBlock) {
currentBlock.push(line);
}
}
return blocks;
}
private async validateCodeBlock(block: CodeBlock): Promise<void> {
// Skip non-executable blocks
if (this.shouldSkipBlock(block.content)) {
return;
}
// Check for syntax errors
await this.checkSyntax(block);
// Check for API consistency
this.checkAPIConsistency(block);
// Check imports
this.checkImports(block);
// Check types
this.checkTypes(block);
}
private shouldSkipBlock(content: string): boolean {
const skipPatterns = [
/\/\/ Skip test/,
/\/\* Skip test/,
/interface\s+\w+/,
/type\s+\w+\s*=/,
/declare\s+/,
/export\s+interface/,
/export\s+type/,
/^\s*\/\//, // Comment-only blocks
/^\s*\*\//, // Comment blocks
/Configuration/i, // Configuration examples
/\.\.\.$/m, // Incomplete examples
];
return skipPatterns.some(pattern => pattern.test(content));
}
private async checkSyntax(block: CodeBlock): Promise<void> {
// Create temporary file
const tempFile = path.join('/tmp', `doc-test-${Date.now()}.ts`);
try {
// Add necessary imports for framework code
const fullCode = this.addNecessaryImports(block.content);
fs.writeFileSync(tempFile, fullCode);
// Check syntax with TypeScript compiler
await execAsync(`npx tsc --noEmit --target es2020 --moduleResolution node ${tempFile}`);
} catch (error) {
// Clean up syntax error messages
const cleanError = this.cleanCompilerError(error.message);
throw new Error(`Syntax error: ${cleanError}`);
} finally {
// Clean up temp file
if (fs.existsSync(tempFile)) {
fs.unlinkSync(tempFile);
}
}
}
private addNecessaryImports(code: string): string {
const imports = [
"import { BaseModel, Model, Field, HasMany, BelongsTo, HasOne, ManyToMany } from '../../../src/framework/models/decorators';",
"import { BeforeCreate, AfterCreate, BeforeUpdate, AfterUpdate, BeforeDelete, AfterDelete } from '../../../src/framework/models/decorators/hooks';",
"import { DebrosFramework } from '../../../src/framework/DebrosFramework';",
"import { QueryBuilder } from '../../../src/framework/query/QueryBuilder';",
"",
"// Mock types for documentation examples",
"interface ValidationError extends Error { field: string; constraint: string; }",
"interface DatabaseError extends Error { }",
"interface ValidationResult { valid: boolean; errors: ValidationError[]; }",
"interface PaginatedResult<T> { data: T[]; total: number; page: number; perPage: number; totalPages: number; hasNext: boolean; hasPrev: boolean; }",
"",
"// Mock functions for examples",
"async function setupOrbitDB(): Promise<any> { return {}; }",
"async function setupIPFS(): Promise<any> { return {}; }",
"",
].join('\n');
return imports + '\n' + code;
}
private cleanCompilerError(error: string): string {
return error
.replace(/\/tmp\/doc-test-\d+\.ts/g, 'example')
.replace(/error TS\d+:/g, '')
.split('\n')
.filter(line => line.trim() && !line.includes('Found'))
.slice(0, 3) // Take first few error lines
.join(' ')
.trim();
}
private checkAPIConsistency(block: CodeBlock): void {
const problematicPatterns = [
{
pattern: /User\.where\(/,
message: 'Use User.query().where() instead of static User.where()',
fix: 'Replace with User.query().where()'
},
{
pattern: /User\.orderBy\(/,
message: 'Use User.query().orderBy() instead of static User.orderBy()',
fix: 'Replace with User.query().orderBy()'
},
{
pattern: /User\.limit\(/,
message: 'Use User.query().limit() instead of static User.limit()',
fix: 'Replace with User.query().limit()'
},
{
pattern: /@Field\(\s*\{\s*type:\s*(String|Number|Boolean|Array|Object)/,
message: 'Field types should be strings, not constructors',
fix: 'Use @Field({ type: "string" }) instead of @Field({ type: String })'
},
{
pattern: /getQueryExecutor\(\)/,
message: 'getQueryExecutor() method does not exist in current implementation',
fix: 'Remove or replace with available methods'
}
];
for (const { pattern, message, fix } of problematicPatterns) {
if (pattern.test(block.content)) {
this.validationErrors.push({
type: 'api',
message: `${message}. ${fix}`,
file: block.file,
line: block.lineNumber
});
throw new Error(message);
}
}
}
private checkImports(block: CodeBlock): void {
const importLines = block.content
.split('\n')
.filter(line => line.trim().startsWith('import'));
for (const importLine of importLines) {
// Check for non-existent exports
if (importLine.includes('from \'@debros/network\'')) {
const invalidImports = [
'QueryExecutor',
'ValidationError',
'DatabaseError',
'PaginatedResult'
];
for (const invalidImport of invalidImports) {
if (importLine.includes(invalidImport)) {
this.validationErrors.push({
type: 'import',
message: `${invalidImport} is not exported from @debros/network`,
file: block.file,
line: block.lineNumber
});
throw new Error(`Invalid import: ${invalidImport}`);
}
}
}
}
}
private checkTypes(block: CodeBlock): void {
// Check for undefined types used in examples
const undefinedTypes = [
/: QueryPlan/,
/: ComponentStatus/,
/: MigrationContext/,
/: SlowQuery/,
/: QueryStats/
];
for (const pattern of undefinedTypes) {
if (pattern.test(block.content)) {
const match = block.content.match(pattern);
if (match) {
this.validationErrors.push({
type: 'type',
message: `Type ${match[0].slice(2)} is not defined`,
file: block.file,
line: block.lineNumber
});
throw new Error(`Undefined type: ${match[0].slice(2)}`);
}
}
}
}
private generateReport(): void {
console.log('\n' + '='.repeat(60));
console.log('📊 DOCUMENTATION VALIDATION REPORT');
console.log('='.repeat(60));
let totalPassed = 0;
let totalFailed = 0;
for (const result of this.results) {
totalPassed += result.passed;
totalFailed += result.failed;
const status = result.failed === 0 ? '✅' : '❌';
const filename = path.relative(this.docsPath, result.file);
console.log(`${status} ${filename}: ${result.passed} passed, ${result.failed} failed`);
if (result.errors.length > 0) {
result.errors.forEach(error => {
console.log(`${error}`);
});
}
}
console.log('\n' + '-'.repeat(60));
console.log(`📈 SUMMARY: ${totalPassed} passed, ${totalFailed} failed`);
if (this.validationErrors.length > 0) {
console.log(`\n⚠ ${this.validationErrors.length} validation issues found:`);
const errorsByType = this.groupErrorsByType();
for (const [type, errors] of Object.entries(errorsByType)) {
console.log(`\n${type.toUpperCase()} ERRORS (${errors.length}):`);
errors.forEach(error => {
const filename = path.relative(this.docsPath, error.file);
console.log(` - ${filename}${error.line ? `:${error.line}` : ''}: ${error.message}`);
});
}
}
if (totalFailed > 0) {
console.log('\n❌ Documentation validation failed!');
console.log('Please fix the errors above before proceeding.');
process.exit(1);
} else {
console.log('\n✅ All documentation examples are valid!');
}
}
private groupErrorsByType(): Record<string, ValidationError[]> {
const groups: Record<string, ValidationError[]> = {};
for (const error of this.validationErrors) {
if (!groups[error.type]) {
groups[error.type] = [];
}
groups[error.type].push(error);
}
return groups;
}
}
// CLI Interface
async function main() {
const args = process.argv.slice(2);
const docsPath = args[0] || './docs';
console.log('🔍 DebrosFramework Documentation Test Runner');
console.log(`📁 Documentation path: ${docsPath}\n`);
if (!fs.existsSync(docsPath)) {
console.error(`❌ Documentation path not found: ${docsPath}`);
process.exit(1);
}
const runner = new DocumentationTestRunner(docsPath);
await runner.run();
}
// Auto-fix script
class DocumentationAutoFixer {
private fixes: Array<{ file: string; pattern: RegExp; replacement: string; description: string }> = [
{
file: '*',
pattern: /User\.where\(/g,
replacement: 'User.query().where(',
description: 'Convert static where calls to query builder'
},
{
file: '*',
pattern: /User\.orderBy\(/g,
replacement: 'User.query().orderBy(',
description: 'Convert static orderBy calls to query builder'
},
{
file: '*',
pattern: /User\.limit\(/g,
replacement: 'User.query().limit(',
description: 'Convert static limit calls to query builder'
},
{
file: '*',
pattern: /@Field\(\s*\{\s*type:\s*String/g,
replacement: '@Field({ type: \'string\'',
description: 'Convert String type to string'
},
{
file: '*',
pattern: /@Field\(\s*\{\s*type:\s*Number/g,
replacement: '@Field({ type: \'number\'',
description: 'Convert Number type to number'
},
{
file: '*',
pattern: /@Field\(\s*\{\s*type:\s*Boolean/g,
replacement: '@Field({ type: \'boolean\'',
description: 'Convert Boolean type to boolean'
},
{
file: '*',
pattern: /@Field\(\s*\{\s*type:\s*Array/g,
replacement: '@Field({ type: \'array\'',
description: 'Convert Array type to array'
},
{
file: '*',
pattern: /@Field\(\s*\{\s*type:\s*Object/g,
replacement: '@Field({ type: \'object\'',
description: 'Convert Object type to object'
}
];
async fixDocumentation(docsPath: string): Promise<void> {
console.log('🔧 Auto-fixing documentation issues...\n');
const mdFiles = await this.findMarkdownFiles(docsPath);
let totalFixes = 0;
for (const file of mdFiles) {
const fixes = await this.fixFile(file);
totalFixes += fixes;
}
console.log(`\n✅ Applied ${totalFixes} automatic fixes`);
}
private async findMarkdownFiles(docsPath: string): Promise<string[]> {
const files: string[] = [];
const scanDirectory = (dir: string) => {
const items = fs.readdirSync(dir);
for (const item of items) {
const fullPath = path.join(dir, item);
const stat = fs.statSync(fullPath);
if (stat.isDirectory()) {
scanDirectory(fullPath);
} else if (item.endsWith('.md') || item.endsWith('.mdx')) {
files.push(fullPath);
}
}
};
scanDirectory(docsPath);
return files;
}
private async fixFile(filePath: string): Promise<number> {
let content = fs.readFileSync(filePath, 'utf-8');
let fixes = 0;
for (const fix of this.fixes) {
const matches = content.match(fix.pattern);
if (matches) {
content = content.replace(fix.pattern, fix.replacement);
fixes += matches.length;
console.log(`${path.relative('./docs', filePath)}: ${fix.description} (${matches.length} fixes)`);
}
}
if (fixes > 0) {
fs.writeFileSync(filePath, content);
}
return fixes;
}
}
// Add CLI command for auto-fix
if (process.argv.includes('--fix')) {
const docsPath = process.argv[process.argv.indexOf('--fix') + 1] || './docs';
const fixer = new DocumentationAutoFixer();
fixer.fixDocumentation(docsPath).catch(console.error);
} else {
main().catch(console.error);
}
export { DocumentationTestRunner, DocumentationAutoFixer };