UNPKG

@redpanda-data/docs-extensions-and-macros

Version:

Antora extensions and macros developed for Redpanda documentation.

741 lines (646 loc) 28.5 kB
/** * MCP Tools - Generated Documentation Review * * Programmatic validation for auto-generated documentation. * Checks for: missing descriptions, invalid xrefs, DRY violations, quality scoring. * * Note: Style and tone review is handled by the style-guide skill in docs-team-standards. * This tool focuses on structural and technical validation only. */ const fs = require('fs'); const path = require('path'); const { findRepoRoot, formatDate } = require('./utils'); /** * Sanitize version string to prevent path traversal * @param {string} version - Version string to sanitize * @returns {string} Sanitized version string * @throws {Error} If version contains invalid characters */ function sanitizeVersion(version) { if (!version || typeof version !== 'string') { throw new Error('Version must be a non-empty string'); } // Reject path separators if (version.includes('/') || version.includes('\\')) { throw new Error('Version cannot contain path separators (/ or \\)'); } // Reject path traversal sequences if (version.includes('..')) { throw new Error('Version cannot contain path traversal sequences (..)'); } // Reject null bytes if (version.includes('\0')) { throw new Error('Version cannot contain null bytes'); } // Whitelist: only allow alphanumeric, dots, dashes, and underscores const sanitized = version.replace(/[^a-zA-Z0-9._-]/g, '_'); // Ensure the sanitized version is not empty and not too long if (sanitized.length === 0) { throw new Error('Version contains only invalid characters'); } if (sanitized.length > 100) { throw new Error('Version string is too long (max 100 characters)'); } return sanitized; } /** * Generate an AsciiDoc report from review results * @param {Object} results - Review results * @param {string} outputPath - Path to save the report * @returns {string} Generated report content */ function generateReviewReport(results, outputPath) { const { doc_type, version, quality_score, issues, suggestions, files_analyzed } = results; const errorIssues = issues.filter(i => i.severity === 'error'); const warningIssues = issues.filter(i => i.severity === 'warning'); const infoIssues = issues.filter(i => i.severity === 'info'); let report = `= Documentation Review Report\n\n`; report += `[cols="1,3"]\n`; report += `|===\n`; report += `| Type | ${doc_type}\n`; report += `| Version | ${version || 'N/A'}\n`; report += `| Date | ${formatDate()}\n`; report += `| Files Analyzed | ${files_analyzed}\n`; report += `|===\n\n`; report += `== Quality Score: ${quality_score}/100\n\n`; // Score interpretation if (quality_score >= 90) { report += `[NOTE]\n====\n*Excellent* - Documentation quality is very high.\n====\n\n`; } else if (quality_score >= 75) { report += `[WARNING]\n====\n*Good* - Documentation quality is acceptable but has room for improvement.\n====\n\n`; } else if (quality_score >= 50) { report += `[WARNING]\n====\n*Fair* - Documentation needs improvement in several areas.\n====\n\n`; } else { report += `[CAUTION]\n====\n*Poor* - Documentation requires significant improvements.\n====\n\n`; } // Scoring breakdown with detailed calculation report += `=== Scoring Breakdown\n\n`; report += `*How the score is calculated:*\n\n`; let runningScore = 100; report += `. Starting score: *${runningScore}*\n`; if (errorIssues.length > 0) { const errorDeduction = errorIssues.reduce((sum, issue) => { if (issue.issue.includes('enterprise license') || issue.issue.includes('cloud-specific')) return sum + 5; if (issue.issue.includes('invalid xref')) return sum + 3; return sum + 3; }, 0); runningScore -= errorDeduction; report += `. Errors: ${errorIssues.length} issues × 3-5 points = -*${errorDeduction}* (now ${runningScore})\n`; } const missingDescCount = warningIssues.filter(i => i.issue === 'Missing description').length; const shortDescCount = infoIssues.filter(i => i.issue.includes('Very short description')).length; const exampleCount = infoIssues.filter(i => i.issue.includes('would benefit from an example')).length; const otherWarningCount = warningIssues.length - missingDescCount; if (missingDescCount > 0) { const deduction = Math.min(20, missingDescCount * 2); runningScore -= deduction; report += `. Missing descriptions: ${missingDescCount} issues × 2 points = -*${deduction}* (capped at 20, now ${runningScore})\n`; } if (shortDescCount > 0) { const deduction = Math.min(10, shortDescCount); runningScore -= deduction; report += `. Short descriptions: ${shortDescCount} issues × 1 point = -*${deduction}* (capped at 10, now ${runningScore})\n`; } if (exampleCount > 0) { const deduction = Math.min(5, Math.floor(exampleCount / 5)); runningScore -= deduction; report += `. Missing examples: ${exampleCount} complex properties = -*${deduction}* (1 point per 5 properties, capped at 5, now ${runningScore})\n`; } if (otherWarningCount > 0) { const deduction = Math.min(otherWarningCount * 2, 10); runningScore -= deduction; report += `. Other warnings: ${otherWarningCount} issues × 1-2 points = -*${deduction}* (now ${runningScore})\n`; } report += `\n*Final Score: ${quality_score}/100*\n\n`; // Summary report += `== Summary\n\n`; report += `* *Total Issues:* ${issues.length}\n`; report += `** Errors: ${errorIssues.length}\n`; report += `** Warnings: ${warningIssues.length}\n`; report += `** Info: ${infoIssues.length}\n\n`; // General suggestions if (suggestions.length > 0) { report += `=== Key Findings\n\n`; suggestions.forEach(s => { report += `* ${s}\n`; }); report += `\n`; } // Errors (highest priority) if (errorIssues.length > 0) { report += `== Errors (high priority)\n\n`; report += `These issues violate documentation standards and should be fixed immediately.\n\n`; errorIssues.forEach((issue, idx) => { report += `=== ${idx + 1}. ${issue.property || issue.path || 'General'}\n\n`; report += `*Issue:* ${issue.issue}\n\n`; if (issue.suggestion) { report += `*Fix:* ${issue.suggestion}\n\n`; } report += `*File:* \`${issue.file}\`\n\n`; // Add specific instructions based on issue type if (issue.issue.includes('enterprise license')) { report += `*Action:*\n\n`; report += `. Open \`docs-data/property-overrides.json\`\n`; report += `. Find property \`${issue.property}\`\n`; report += `. Remove the \`include::reference:partial$enterprise-licensed-property.adoc[]\` from the description\n`; report += `. Regenerate docs\n\n`; } else if (issue.issue.includes('cloud-specific conditional')) { report += `*Action:*\n\n`; report += `. Open \`docs-data/property-overrides.json\`\n`; report += `. Find property \`${issue.property}\`\n`; report += `. Remove the \`ifdef::env-cloud\` blocks from the description\n`; report += `. Cloud-specific info will appear in metadata automatically\n`; report += `. Regenerate docs\n\n`; } else if (issue.issue.includes('invalid xref')) { report += `*Action:*\n\n`; report += `. Open \`docs-data/property-overrides.json\`\n`; report += `. Find property \`${issue.property}\`\n`; report += `. Update xref links to use full Antora resource IDs\n`; report += `. Example: \`xref:reference:properties/cluster-properties.adoc[Link]\`\n`; report += `. Regenerate docs\n\n`; } else if (issue.issue.includes('Invalid $ref')) { report += `*Action:*\n\n`; report += `. Open \`docs-data/overrides.json\`\n`; report += `. Find the reference at \`${issue.path}\`\n`; report += `. Either add the missing definition or fix the reference\n`; report += `. Regenerate docs\n\n`; } }); } // Warnings if (warningIssues.length > 0) { report += `== Warnings\n\n`; report += `These issues should be addressed to improve documentation quality.\n\n`; // Group warnings by issue type const warningsByType = {}; warningIssues.forEach(issue => { const issueType = issue.issue.split(':')[0] || issue.issue; if (!warningsByType[issueType]) { warningsByType[issueType] = []; } warningsByType[issueType].push(issue); }); Object.entries(warningsByType).forEach(([type, typeIssues]) => { report += `=== ${type} (${typeIssues.length})\n\n`; if (type === 'Missing description') { report += `*Fix:* Add descriptions to these properties in \`docs-data/property-overrides.json\`\n\n`; report += `*Properties needing descriptions:*\n\n`; typeIssues.forEach(issue => { report += `* \`${issue.property}\`\n`; }); report += `\n`; } else { typeIssues.forEach(issue => { report += `* *${issue.property || issue.path}*: ${issue.issue}\n`; }); report += `\n`; } }); } // Info items if (infoIssues.length > 0) { report += `== Info\n\n`; report += `These are suggestions for enhancement.\n\n`; // Group by issue type const infoByType = {}; infoIssues.forEach(issue => { const issueType = issue.issue.split('(')[0].trim(); if (!infoByType[issueType]) { infoByType[issueType] = []; } infoByType[issueType].push(issue); }); Object.entries(infoByType).forEach(([type, typeIssues]) => { report += `=== ${type}\n\n`; typeIssues.forEach(issue => { report += `* *${issue.property || issue.path}*: ${issue.issue}\n`; }); report += `\n`; }); } // Next steps report += `== Next Steps\n\n`; if (errorIssues.length > 0) { report += `. *Fix errors first* - Address the ${errorIssues.length} errors above\n`; } if (warningIssues.length > 0) { report += `${errorIssues.length > 0 ? '. ' : '. '}*Review warnings* - Prioritize the ${warningIssues.length} warnings\n`; } const step = errorIssues.length > 0 && warningIssues.length > 0 ? 3 : errorIssues.length > 0 || warningIssues.length > 0 ? 2 : 1; report += `${step > 1 ? '. ' : '. '}*Regenerate documentation* - After making changes, regenerate the docs\n`; report += `. *Review again* - Run the review tool again to verify fixes\n\n`; // Write report fs.writeFileSync(outputPath, report, 'utf8'); return report; } /** * Review generated documentation for quality issues * @param {Object} args - Arguments * @param {string} args.doc_type - Type of docs to review (properties, metrics, rpk, rpcn_connectors) * @param {string} args.version - Version of the docs to review (for properties, metrics, rpk) * @param {boolean} [args.generate_report] - Whether to generate a markdown report file * @param {string} [args.properties_dir] - Custom path to properties directory (default: modules/reference) * @param {string} [args.metrics_file] - Custom path to metrics file (default: modules/reference/pages/public-metrics-reference.adoc) * @param {string} [args.rpk_dir] - Custom path to RPK docs directory (default: autogenerated/{version}/rpk) * @param {string} [args.overrides_file] - Custom path to overrides.json for RPCN connectors (default: docs-data/overrides.json) * @returns {Object} Review results with issues and suggestions */ function reviewGeneratedDocs(args) { const repoRoot = findRepoRoot(); const { doc_type, version, generate_report } = args; if (!doc_type) { return { success: false, error: 'doc_type is required', suggestion: 'Provide one of: properties, metrics, rpk, rpcn_connectors' }; } const issues = []; const suggestions = []; let filesAnalyzed = 0; let qualityScore = 100; try { switch (doc_type) { case 'properties': { if (!version) { return { success: false, error: 'version is required for property docs review' }; } // Sanitize version to prevent path traversal let sanitizedVersion; try { sanitizedVersion = sanitizeVersion(version); } catch (err) { return { success: false, error: `Invalid version: ${err.message}` }; } // Normalize version let normalizedVersion = sanitizedVersion; if (!normalizedVersion.startsWith('v') && normalizedVersion !== 'latest') { normalizedVersion = `v${normalizedVersion}`; } // Check for generated JSON file (use custom path or default) const propertiesBaseDir = args.properties_dir || path.join('modules', 'reference'); const jsonPath = path.join(repoRoot.root, propertiesBaseDir, 'attachments', `redpanda-properties-${normalizedVersion}.json`); if (!fs.existsSync(jsonPath)) { return { success: false, error: `Properties JSON not found at ${jsonPath}`, suggestion: 'Generate property docs first using generate_property_docs tool' }; } filesAnalyzed++; // Read and parse the properties JSON const propertiesData = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); const allProperties = Object.values(propertiesData.properties || {}); // Properties that typically benefit from examples const shouldHaveExample = (prop) => { const name = prop.name.toLowerCase(); // Properties with specific formats, complex values, or commonly misconfigured return name.includes('pattern') || name.includes('regex') || name.includes('format') || name.includes('template') || name.includes('config') || name.includes('override') || name.includes('mapping') || name.includes('filter') || name.includes('selector') || (prop.type && prop.type.includes('array')) || (prop.type && prop.type.includes('object')); }; // Check for missing or short descriptions let missingDescriptions = 0; let shortDescriptions = 0; let emptyDefaults = 0; let missingExamples = 0; allProperties.forEach(prop => { if (!prop.description || prop.description.trim() === '') { missingDescriptions++; if (!prop.is_deprecated) { issues.push({ severity: 'warning', file: jsonPath, property: prop.name, issue: 'Missing description' }); } } else if (prop.description.length < 20 && !prop.is_deprecated) { shortDescriptions++; issues.push({ severity: 'info', file: jsonPath, property: prop.name, issue: `Very short description (${prop.description.length} chars): "${prop.description}"` }); } if ((!prop.default || (typeof prop.default === 'string' && prop.default.trim() === '')) && !prop.is_deprecated && prop.config_scope !== 'broker') { emptyDefaults++; } // Track properties that should have examples if (shouldHaveExample(prop) && !prop.is_deprecated) { missingExamples++; } }); // Check ALL properties for missing examples const propertiesNeedingExamples = []; const overridesPath = path.join(repoRoot.root, 'docs-data', 'property-overrides.json'); const overrides = fs.existsSync(overridesPath) ? JSON.parse(fs.readFileSync(overridesPath, 'utf8')) : { properties: {} }; allProperties.forEach(prop => { if (shouldHaveExample(prop) && !prop.is_deprecated) { const override = overrides.properties && overrides.properties[prop.name]; if (!override || !override.example) { propertiesNeedingExamples.push(prop.name); } } }); // Read property overrides to check for quality issues if (fs.existsSync(overridesPath)) { filesAnalyzed++; if (overrides.properties) { Object.entries(overrides.properties).forEach(([propName, override]) => { let propData = allProperties.find(p => p.name === propName); // Check for enterprise license includes (should not be in descriptions) if (override.description && override.description.includes('include::reference:partial$enterprise-licensed-property.adoc')) { issues.push({ severity: 'error', file: overridesPath, property: propName, issue: 'Description contains enterprise license include (should be in metadata only)', suggestion: 'Remove the include statement from the description' }); qualityScore -= 5; } // Check for cloud-specific conditional blocks if (override.description && (override.description.includes('ifdef::env-cloud') || override.description.includes('ifndef::env-cloud'))) { issues.push({ severity: 'error', file: overridesPath, property: propName, issue: 'Description contains cloud-specific conditional blocks', suggestion: 'Remove cloud conditionals - this info belongs in metadata' }); qualityScore -= 5; } // Check for deprecated properties with descriptions (should not have overrides) if (!propData) propData = allProperties.find(p => p.name === propName); if (propData && propData.is_deprecated && override.description) { issues.push({ severity: 'warning', file: overridesPath, property: propName, issue: 'Override exists for deprecated property', suggestion: 'Remove override for deprecated properties' }); qualityScore -= 2; } // Check for invalid xref links (not using full Antora resource IDs) if (override.description) { const invalidXrefPattern = /xref:\.\/|xref:(?![\w-]+:)/g; const invalidXrefs = override.description.match(invalidXrefPattern); if (invalidXrefs) { issues.push({ severity: 'error', file: overridesPath, property: propName, issue: 'Description contains invalid xref links (not using full Antora resource IDs)', suggestion: 'Use full resource IDs like xref:reference:path/to/doc.adoc[Link]' }); qualityScore -= 3; } } // Check for duplicate links in related_topics if (override.related_topics && Array.isArray(override.related_topics)) { const uniqueLinks = new Set(override.related_topics); if (uniqueLinks.size < override.related_topics.length) { issues.push({ severity: 'warning', file: overridesPath, property: propName, issue: 'Duplicate links in related_topics', suggestion: 'Remove duplicate links' }); qualityScore -= 1; } } }); } } // Add summary suggestions if (missingDescriptions > 0) { suggestions.push(`${missingDescriptions} properties have missing descriptions`); qualityScore -= Math.min(20, missingDescriptions * 2); } if (shortDescriptions > 0) { suggestions.push(`${shortDescriptions} properties have very short descriptions (< 20 chars)`); qualityScore -= Math.min(10, shortDescriptions); } if (emptyDefaults > 0) { suggestions.push(`${emptyDefaults} non-deprecated properties have no default value listed`); } if (propertiesNeedingExamples.length > 0) { suggestions.push(`${propertiesNeedingExamples.length} complex properties would benefit from examples`); // Add info-level issues for properties that should have examples propertiesNeedingExamples.forEach(propName => { issues.push({ severity: 'info', file: overridesPath, property: propName, issue: 'Complex property would benefit from an example', suggestion: 'Add an example array to the property override showing typical usage' }); }); qualityScore -= Math.min(5, Math.floor(propertiesNeedingExamples.length / 5)); } break; } case 'rpcn_connectors': { // Read overrides.json (use custom path or default) const overridesPath = args.overrides_file ? path.join(repoRoot.root, args.overrides_file) : path.join(repoRoot.root, 'docs-data', 'overrides.json'); if (!fs.existsSync(overridesPath)) { return { success: false, error: `overrides.json not found at ${overridesPath}`, suggestion: 'Generate RPCN connector docs first using generate_rpcn_connector_docs tool, or specify custom path with overrides_file parameter' }; } filesAnalyzed++; const overrides = JSON.parse(fs.readFileSync(overridesPath, 'utf8')); // Validate $ref references const definitions = overrides.definitions || {}; const allRefs = new Set(); const invalidRefs = []; const findRefs = (obj, path = '') => { if (typeof obj !== 'object' || obj === null) return; if (obj.$ref) { allRefs.add(obj.$ref); // Check if ref is valid const refPath = obj.$ref.replace('#/definitions/', ''); if (!definitions[refPath]) { invalidRefs.push({ ref: obj.$ref, path }); } } for (const [key, value] of Object.entries(obj)) { if (key !== '$ref') { findRefs(value, path ? `${path}.${key}` : key); } } }; ['inputs', 'outputs', 'processors', 'caches'].forEach(section => { if (overrides[section]) { findRefs(overrides[section], section); } }); invalidRefs.forEach(({ ref, path }) => { issues.push({ severity: 'error', file: overridesPath, path, issue: `Invalid $ref: ${ref}`, suggestion: 'Ensure the reference exists in the definitions section' }); qualityScore -= 5; }); // Check for duplicate descriptions (DRY violations) const descriptions = new Map(); const checkDuplicates = (obj, path = '') => { if (typeof obj !== 'object' || obj === null) return; if (obj.description && !obj.$ref && typeof obj.description === 'string' && obj.description.length > 30) { const key = obj.description.trim().toLowerCase(); if (descriptions.has(key)) { descriptions.get(key).push(path); } else { descriptions.set(key, [path]); } } for (const [key, value] of Object.entries(obj)) { checkDuplicates(value, path ? `${path}.${key}` : key); } }; ['inputs', 'outputs', 'processors', 'caches'].forEach(section => { if (overrides[section]) { checkDuplicates(overrides[section], section); } }); const duplicates = Array.from(descriptions.entries()).filter(([_, paths]) => paths.length > 1); duplicates.forEach(([desc, paths]) => { suggestions.push(`Duplicate description found at: ${paths.join(', ')}. Consider creating a definition and using $ref`); qualityScore -= 3; }); if (invalidRefs.length === 0 && duplicates.length === 0) { suggestions.push('All $ref references are valid and DRY principles are maintained'); } break; } case 'metrics': case 'rpk': { // For metrics and RPK, check files exist if (!version) { return { success: false, error: 'version is required for metrics/rpk docs review' }; } // Sanitize version to prevent path traversal let sanitizedVersion; try { sanitizedVersion = sanitizeVersion(version); } catch (err) { return { success: false, error: `Invalid version: ${err.message}` }; } if (doc_type === 'metrics') { // Use custom path or default const filePath = args.metrics_file ? path.join(repoRoot.root, args.metrics_file) : path.join(repoRoot.root, 'modules', 'reference', 'pages', 'public-metrics-reference.adoc'); if (!fs.existsSync(filePath)) { return { success: false, error: `Generated docs not found at ${filePath}`, suggestion: 'Generate metrics docs first using generate_metrics_docs tool, or specify custom path with metrics_file parameter' }; } } else { // RPK files are version-specific const normalizedVersion = sanitizedVersion.startsWith('v') ? sanitizedVersion : `v${sanitizedVersion}`; // Use custom path or default const rpkDir = args.rpk_dir ? path.join(repoRoot.root, args.rpk_dir) : path.join(repoRoot.root, 'autogenerated', normalizedVersion, 'rpk'); if (!fs.existsSync(rpkDir)) { return { success: false, error: `RPK docs directory not found at ${rpkDir}`, suggestion: 'Generate RPK docs first using generate_rpk_docs tool, or specify custom path with rpk_dir parameter' }; } } filesAnalyzed++; suggestions.push(`${doc_type} documentation generated successfully`); break; } default: return { success: false, error: `Unknown doc_type: ${doc_type}`, suggestion: 'Use one of: properties, metrics, rpk, rpcn_connectors' }; } // Ensure quality score doesn't go below 0 qualityScore = Math.max(0, qualityScore); const results = { success: true, doc_type, version: version || 'N/A', files_analyzed: filesAnalyzed, issues, quality_score: qualityScore, suggestions, summary: `Reviewed ${doc_type} documentation. Quality score: ${qualityScore}/100. Found ${issues.length} issues.`, // Note: Style review is handled by the style-guide skill in docs-team-standards next_steps: 'This tool validates structural issues (missing descriptions, invalid refs, DRY violations). ' + 'For style and tone review, use the content-reviewer agent from docs-team-standards plugin.' }; // Generate AsciiDoc report if requested if (generate_report) { // Sanitize version for filename if provided let safeVersion = ''; if (version) { try { safeVersion = `-${sanitizeVersion(version)}`; } catch (err) { // If version sanitization fails, skip it in filename safeVersion = ''; } } const reportFilename = `review-${doc_type}${safeVersion}-${formatDate()}.adoc`; const reportPath = path.join(repoRoot.root, reportFilename); generateReviewReport(results, reportPath); results.report_path = reportPath; results.report_generated = true; } return results; } catch (err) { return { success: false, error: err.message, suggestion: 'Check that the documentation has been generated and files exist' }; } } module.exports = { generateReviewReport, reviewGeneratedDocs };