/** * Telegraf Plugin Documentation Auditor * * Compares Telegraf repository plugins with docs-v2 documentation to identify: * - Plugins with README.md that are missing from docs-v2 * - Documentation in docs-v2 that no longer has a source in Telegraf * * @module telegraf-auditor */ import { promises as fs } from 'fs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; import { tmpdir } from 'os'; import { spawn } from 'child_process'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); /** * Plugin category configuration * Maps Telegraf source paths to docs-v2 paths */ const PLUGIN_CATEGORIES = { inputs: { sourcePath: 'plugins/inputs', docsPath: 'content/telegraf/v1/input-plugins', displayName: 'Input Plugins', singular: 'input plugin', }, outputs: { sourcePath: 'plugins/outputs', docsPath: 'content/telegraf/v1/output-plugins', displayName: 'Output Plugins', singular: 'output plugin', }, processors: { sourcePath: 'plugins/processors', docsPath: 'content/telegraf/v1/processor-plugins', displayName: 'Processor Plugins', singular: 'processor plugin', }, aggregators: { sourcePath: 'plugins/aggregators', docsPath: 'content/telegraf/v1/aggregator-plugins', displayName: 'Aggregator Plugins', singular: 'aggregator plugin', }, }; /** * Data format category configuration * Parsers and serializers have different structure */ const DATA_FORMAT_CATEGORIES = { parsers: { sourcePath: 'plugins/parsers', docsPath: 'content/telegraf/v1/data_formats/input', displayName: 'Input Data Formats (Parsers)', singular: 'parser', // Data formats use .md files directly, not directories isFlat: true, }, serializers: { sourcePath: 'plugins/serializers', docsPath: 'content/telegraf/v1/data_formats/output', displayName: 'Output Data Formats (Serializers)', singular: 'serializer', isFlat: true, }, }; /** * Directories to exclude from scanning (not actual plugins) */ const EXCLUDED_DIRS = new Set([ 'all', // Meta-package that imports all plugins 'common', // Shared utilities ]); /** * Normalize plugin/format ID for consistent comparison * Converts kebab-case to snake_case to handle naming variations * @param {string} id - The plugin or format ID * @returns {string} Normalized ID */ function normalizeId(id) { return id.replace(/-/g, '_'); } /** * Main Telegraf Plugin Auditor class */ export class TelegrafAuditor { constructor(version = 'master', docsBranch = 'master') { this.version = version; this.docsBranch = docsBranch; this.outputDir = join(dirname(__dirname), '..', 'output', 'telegraf-audit'); this.telegrafRepoPath = null; this.docsRepoPath = null; } /** * Run the full audit */ async run() { console.log('\n๐Ÿ” Telegraf Plugin Documentation Audit'); console.log('=========================================='); console.log(`Telegraf version: ${this.version}`); console.log(`Docs branch: ${this.docsBranch}`); console.log(''); // Ensure output directory exists await fs.mkdir(this.outputDir, { recursive: true }); // Setup repository paths this.telegrafRepoPath = join(this.outputDir, 'telegraf-clone'); this.docsRepoPath = join(tmpdir(), `docs-v2-telegraf-audit-${Date.now()}`); try { // Clone/checkout repositories await this.ensureTelegrafRepo(); await this.cloneDocsRepo(); // Scan both repositories const sourcePlugins = await this.scanTelegrafPlugins(); const documentedPlugins = await this.scanDocsPlugins(); // Compare and generate report const comparison = this.comparePlugins(sourcePlugins, documentedPlugins); await this.generateReport(comparison); console.log('\nโœ… Telegraf plugin documentation audit complete!'); console.log(`๐Ÿ“„ Report saved to: ${join(this.outputDir, 'telegraf-audit-report.md')}`); return comparison; } finally { // Cleanup temp docs repo console.log('๐Ÿงน Cleaning up temporary docs repository...'); await fs.rm(this.docsRepoPath, { recursive: true, force: true }); console.log('โœ… Cleanup complete'); } } /** * Ensure Telegraf repository is cloned and at correct version */ async ensureTelegrafRepo() { const exists = await fs.access(this.telegrafRepoPath).then(() => true).catch(() => false); if (exists) { console.log('๐Ÿ“ Using existing Telegraf repository clone'); console.log(`๐Ÿ”„ Fetching and checking out version: ${this.version}`); // Unshallow the repository first if it's a shallow clone, then fetch tags // This ensures tags are available even if the repo was initially cloned with --depth 1 try { await this.runCommand('git', ['fetch', '--unshallow'], this.telegrafRepoPath); } catch { // Already unshallowed or not a shallow clone, ignore the error } await this.runCommand('git', ['fetch', '--tags', '--force'], this.telegrafRepoPath); await this.runCommand('git', ['checkout', this.version], this.telegrafRepoPath); } else { console.log('๐Ÿ“ฅ Cloning Telegraf repository...'); await this.runCommand('git', [ 'clone', '--depth', '1', '--branch', this.version, 'https://github.com/influxdata/telegraf.git', this.telegrafRepoPath, ]); } console.log('โœ… Telegraf repository ready'); } /** * Clone docs-v2 repository with sparse checkout */ async cloneDocsRepo() { console.log(`๐Ÿ“ฅ Cloning docs-v2 repository (branch: ${this.docsBranch}) with sparse-checkout...`); // Clone with no-checkout await this.runCommand('git', [ 'clone', '--no-checkout', '--depth', '1', '--branch', this.docsBranch, 'https://github.com/influxdata/docs-v2.git', this.docsRepoPath, ]); // Configure sparse-checkout await this.runCommand('git', ['sparse-checkout', 'init', '--cone'], this.docsRepoPath); // Set sparse-checkout patterns const patterns = [ 'content/telegraf/v1/input-plugins', 'content/telegraf/v1/output-plugins', 'content/telegraf/v1/processor-plugins', 'content/telegraf/v1/aggregator-plugins', 'content/telegraf/v1/data_formats', ]; await this.runCommand('git', ['sparse-checkout', 'set', ...patterns], this.docsRepoPath); // Checkout the files await this.runCommand('git', ['checkout', this.docsBranch], this.docsRepoPath); console.log('โœ… docs-v2 repository cloned successfully'); } /** * Scan Telegraf repository for plugins with README.md files */ async scanTelegrafPlugins() { console.log('\n๐Ÿ“‚ Scanning Telegraf repository for plugins...'); const results = { plugins: {}, dataFormats: {}, }; // Scan plugin categories for (const [category, config] of Object.entries(PLUGIN_CATEGORIES)) { const sourcePath = join(this.telegrafRepoPath, config.sourcePath); results.plugins[category] = await this.scanPluginDirectory(sourcePath, category); console.log(` Found ${results.plugins[category].length} ${config.displayName.toLowerCase()}`); } // Scan data format categories for (const [category, config] of Object.entries(DATA_FORMAT_CATEGORIES)) { const sourcePath = join(this.telegrafRepoPath, config.sourcePath); results.dataFormats[category] = await this.scanPluginDirectory(sourcePath, category); console.log(` Found ${results.dataFormats[category].length} ${config.displayName.toLowerCase()}`); } return results; } /** * Scan a plugin directory for plugins with README.md files */ async scanPluginDirectory(dirPath, category) { const plugins = []; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; if (EXCLUDED_DIRS.has(entry.name)) continue; const pluginPath = join(dirPath, entry.name); const readmePath = join(pluginPath, 'README.md'); // Check if README.md exists const hasReadme = await fs.access(readmePath).then(() => true).catch(() => false); if (hasReadme) { plugins.push({ id: normalizeId(entry.name), originalName: entry.name, category, path: pluginPath, readmePath, }); } } } catch (error) { console.warn(` Warning: Could not scan ${dirPath}: ${error.message}`); } return plugins.sort((a, b) => a.id.localeCompare(b.id)); } /** * Scan docs-v2 repository for Telegraf plugin documentation */ async scanDocsPlugins() { console.log('\n๐Ÿ“‚ Scanning docs-v2 repository for Telegraf documentation...'); const results = { plugins: {}, dataFormats: {}, }; // Scan plugin categories (directories with _index.md) for (const [category, config] of Object.entries(PLUGIN_CATEGORIES)) { const docsPath = join(this.docsRepoPath, config.docsPath); results.plugins[category] = await this.scanDocsDirectory(docsPath, category, false); console.log(` Found ${results.plugins[category].length} documented ${config.displayName.toLowerCase()}`); } // Scan data format categories (flat .md files) for (const [category, config] of Object.entries(DATA_FORMAT_CATEGORIES)) { const docsPath = join(this.docsRepoPath, config.docsPath); results.dataFormats[category] = await this.scanDocsDirectory(docsPath, category, true); console.log(` Found ${results.dataFormats[category].length} documented ${config.displayName.toLowerCase()}`); } return results; } /** * Scan a docs directory for documentation files * @param {string} dirPath - Path to scan * @param {string} category - Category name * @param {boolean} isFlat - If true, look for .md files; if false, look for directories with _index.md */ async scanDocsDirectory(dirPath, category, isFlat) { const docs = []; try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { // Skip _index.md files (they're category indexes, not plugin docs) if (entry.name === '_index.md') continue; if (isFlat) { // For data formats: look for .md files if (entry.isFile() && entry.name.endsWith('.md')) { // Extract ID from filename (remove .md extension) // Normalize to handle both snake_case and kebab-case consistently const baseName = entry.name.replace('.md', ''); docs.push({ id: normalizeId(baseName), originalName: baseName, category, path: join(dirPath, entry.name), }); } } else { // For plugins: look for directories if (entry.isDirectory()) { const indexPath = join(dirPath, entry.name, '_index.md'); const hasIndex = await fs.access(indexPath).then(() => true).catch(() => false); if (hasIndex) { docs.push({ id: normalizeId(entry.name), originalName: entry.name, category, path: join(dirPath, entry.name), indexPath, }); } } } } } catch (error) { console.warn(` Warning: Could not scan ${dirPath}: ${error.message}`); } return docs.sort((a, b) => a.id.localeCompare(b.id)); } /** * Compare source plugins with documented plugins */ comparePlugins(source, docs) { const comparison = { plugins: {}, dataFormats: {}, summary: { totalSourcePlugins: 0, totalDocumentedPlugins: 0, totalMissingDocs: 0, totalOrphanedDocs: 0, totalSourceDataFormats: 0, totalDocumentedDataFormats: 0, totalMissingDataFormatDocs: 0, totalOrphanedDataFormatDocs: 0, }, }; // Compare plugin categories for (const category of Object.keys(PLUGIN_CATEGORIES)) { const sourceSet = new Set(source.plugins[category].map(p => p.id)); const docsSet = new Set(docs.plugins[category].map(p => p.id)); const missing = source.plugins[category].filter(p => !docsSet.has(p.id)); const orphaned = docs.plugins[category].filter(p => !sourceSet.has(p.id)); const documented = source.plugins[category].filter(p => docsSet.has(p.id)); comparison.plugins[category] = { config: PLUGIN_CATEGORIES[category], source: source.plugins[category], docs: docs.plugins[category], missing, orphaned, documented, }; comparison.summary.totalSourcePlugins += source.plugins[category].length; comparison.summary.totalDocumentedPlugins += documented.length; comparison.summary.totalMissingDocs += missing.length; comparison.summary.totalOrphanedDocs += orphaned.length; } // Compare data format categories for (const category of Object.keys(DATA_FORMAT_CATEGORIES)) { const sourceSet = new Set(source.dataFormats[category].map(p => p.id)); const docsSet = new Set(docs.dataFormats[category].map(p => p.id)); const missing = source.dataFormats[category].filter(p => !docsSet.has(p.id)); const orphaned = docs.dataFormats[category].filter(p => !sourceSet.has(p.id)); const documented = source.dataFormats[category].filter(p => docsSet.has(p.id)); comparison.dataFormats[category] = { config: DATA_FORMAT_CATEGORIES[category], source: source.dataFormats[category], docs: docs.dataFormats[category], missing, orphaned, documented, }; comparison.summary.totalSourceDataFormats += source.dataFormats[category].length; comparison.summary.totalDocumentedDataFormats += documented.length; comparison.summary.totalMissingDataFormatDocs += missing.length; comparison.summary.totalOrphanedDataFormatDocs += orphaned.length; } return comparison; } /** * Generate markdown audit report */ async generateReport(comparison) { const { generateTelegrafAuditReport } = await import('./telegraf-audit-reporter.js'); await generateTelegrafAuditReport( comparison, this.version, this.docsBranch, this.outputDir ); } /** * Run a shell command */ runCommand(command, args, cwd = process.cwd()) { return new Promise((resolve, reject) => { const proc = spawn(command, args, { cwd, stdio: ['inherit', 'pipe', 'pipe'], }); let stdout = ''; let stderr = ''; if (proc.stdout) { proc.stdout.on('data', (data) => { stdout += data.toString(); }); } if (proc.stderr) { proc.stderr.on('data', (data) => { stderr += data.toString(); }); } proc.on('close', (code) => { if (code === 0) { resolve({ code, stdout, stderr }); } else { reject(new Error(`Command failed with code ${code}: ${stderr || stdout}`)); } }); proc.on('error', (error) => { reject(error); }); }); } } /** * Run Telegraf plugin documentation audit * @param {string} version - Telegraf version/branch/tag * @param {string} docsBranch - docs-v2 branch * @param {string} outputFormat - Output format (currently only 'report' supported) */ export async function runTelegrafAudit(version = 'master', docsBranch = 'master', outputFormat = 'report') { const auditor = new TelegrafAuditor(version, docsBranch); return auditor.run(); }