UNPKG

@raven-js/glean

Version:

Glean documentation gold from your codebase - JSDoc parsing, validation, and beautiful doc generation

340 lines (298 loc) 9.4 kB
/** * @author Anonyfox <max@anonyfox.com> * @license MIT * @see {@link https://github.com/Anonyfox/ravenjs} * @see {@link https://ravenjs.dev} * @see {@link https://anonyfox.com} */ /** * @file JSDoc validation orchestrator with comprehensive quality analysis. * * Transforms discovery and extraction models into validation reports, * exposing documentation inconsistencies and quality issues. */ import { discover } from "./discover/index.js"; import { extract } from "./extract/index.js"; /** * Validation issue from lib2 analysis * @typedef {Object} ValidationIssue * @property {string} type - Issue type (e.g., 'missing_jsdoc', 'param_mismatch') * @property {string} message - Human-readable issue description * @property {number} line - Line number where issue occurs * @property {string} severity - Issue severity ('error', 'warning', 'info') * @property {string} entity - Entity name where issue occurs * @property {string} file - File path where issue occurs */ /** * File validation result from lib2 * @typedef {Object} FileValidationResult * @property {string} file - File path * @property {ValidationIssue[]} issues - List of validation issues * @property {number} score - Quality score (0-100) */ /** * Complete validation report from lib2 * @typedef {Object} ValidationReport * @property {FileValidationResult[]} files - Per-file validation results * @property {Object} summary - Overall summary statistics * @property {number} summary.filesAnalyzed - Number of files analyzed * @property {number} summary.totalIssues - Total number of issues found * @property {number} summary.overallScore - Overall quality score (0-100) * @property {number} summary.filesWithIssues - Number of files with issues */ /** * Validate package documentation using lib2 architecture * @param {string} packagePath - Path to package root directory * @returns {ValidationReport} Comprehensive validation report */ export function validate(packagePath) { if (!packagePath || typeof packagePath !== "string") { throw new Error("validate() requires a valid package path"); } // Discover package structure const discoveryPackage = discover(packagePath); // Extract documentation entities const extractPackage = extract(discoveryPackage); // Validate extracted package const validationResults = validateExtractedPackage(extractPackage); return validationResults; } /** * Validate extracted package and generate comprehensive report * @param {import('./extract/models/package.js').Package} extractPackage - Extracted package * @returns {ValidationReport} Validation report */ function validateExtractedPackage(extractPackage) { const fileResults = []; let totalIssues = 0; // Validate each module in the package for (const module of extractPackage.modules) { // Get unique file paths from the module const filePaths = getUniqueFilePaths(module); for (const filePath of filePaths) { const issues = validateModuleFile(module, filePath); const score = calculateFileScore(issues); fileResults.push({ file: filePath, issues, score, }); totalIssues += issues.length; } } const filesAnalyzed = fileResults.length; const overallScore = filesAnalyzed > 0 ? Math.round( fileResults.reduce((sum, f) => sum + f.score, 0) / filesAnalyzed, ) : 100; return { files: fileResults, summary: { filesAnalyzed, totalIssues, overallScore, filesWithIssues: fileResults.filter((f) => f.issues.length > 0).length, }, }; } /** * Get unique file paths from a module * @param {import('./extract/models/module.js').Module} module - Module to analyze * @returns {string[]} Array of unique file paths */ function getUniqueFilePaths(module) { const filePaths = new Set(); // Add entity file paths for (const entity of module.entities) { if (entity.location && /** @type {any} */ (entity.location).file) { filePaths.add(/** @type {any} */ (entity.location).file); } } return Array.from(filePaths); } /** * Validate a specific file within a module * @param {import('./extract/models/module.js').Module} module - Module containing the file * @param {string} filePath - File path to validate * @returns {ValidationIssue[]} Array of validation issues */ function validateModuleFile(module, filePath) { const issues = []; // Find entities in this file const entitiesInFile = module.entities.filter( /** @param {any} entity */ (entity) => entity.location && entity.location.file === filePath, ); for (const entity of entitiesInFile) { // Validate entity JSDoc issues.push(...validateEntityJSDoc(entity, filePath)); // Validate entity structure issues.push(...validateEntityStructure(entity, filePath)); } return issues; } /** * Validate JSDoc documentation for an entity * @param {any} entity - Entity to validate * @param {string} filePath - File path for issue reporting * @returns {ValidationIssue[]} Array of JSDoc validation issues */ function validateEntityJSDoc(entity, filePath) { const issues = []; // Check if entity has JSDoc if (!entity.jsdoc || entity.jsdoc.length === 0) { issues.push({ type: "missing_jsdoc", message: `Entity '${entity.name}' is missing JSDoc documentation`, line: entity.location ? entity.location.line : 1, severity: "warning", entity: entity.name, file: filePath, }); return issues; } // Validate JSDoc structure for (const jsdocTag of entity.jsdoc) { // Check for description if ( jsdocTag.tag === "description" && (!jsdocTag.content || jsdocTag.content.trim().length === 0) ) { issues.push({ type: "empty_description", message: `Entity '${entity.name}' has empty description`, line: entity.location ? entity.location.line : 1, severity: "warning", entity: entity.name, file: filePath, }); } // Validate param tags for functions if (entity.type === "function" && jsdocTag.tag === "param") { if (!jsdocTag.name || !jsdocTag.type) { issues.push({ type: "incomplete_param", message: `Function '${entity.name}' has incomplete @param tag`, line: entity.location ? entity.location.line : 1, severity: "error", entity: entity.name, file: filePath, }); } } // Validate returns tags for functions if (entity.type === "function" && jsdocTag.tag === "returns") { if (!jsdocTag.type) { issues.push({ type: "incomplete_returns", message: `Function '${entity.name}' has @returns tag without type`, line: entity.location ? entity.location.line : 1, severity: "error", entity: entity.name, file: filePath, }); } } } return issues; } /** * Validate entity structure and consistency * @param {any} entity - Entity to validate * @param {string} filePath - File path for issue reporting * @returns {ValidationIssue[]} Array of structural validation issues */ function validateEntityStructure(entity, filePath) { const issues = []; // Check entity name if (!entity.name || entity.name.trim().length === 0) { issues.push({ type: "missing_name", message: "Entity is missing a name", line: entity.location ? entity.location.line : 1, severity: "error", entity: "unknown", file: filePath, }); } // Check entity type if (!entity.type || entity.type.trim().length === 0) { issues.push({ type: "missing_type", message: `Entity '${entity.name}' is missing a type`, line: entity.location ? entity.location.line : 1, severity: "error", entity: entity.name, file: filePath, }); } // Check location information if (!entity.location) { issues.push({ type: "missing_location", message: `Entity '${entity.name}' is missing location information`, line: 1, severity: "warning", entity: entity.name, file: filePath, }); } // Function-specific validation if (entity.type === "function") { // Check if function has parameters documented but no @param tags if (entity.parameters && entity.parameters.length > 0) { const paramTags = entity.jsdoc ? entity.jsdoc.filter( /** @param {any} tag */ (tag) => tag.tag === "param", ) : []; if (paramTags.length === 0) { issues.push({ type: "undocumented_params", message: `Function '${entity.name}' has parameters but no @param documentation`, line: entity.location ? entity.location.line : 1, severity: "warning", entity: entity.name, file: filePath, }); } else if (paramTags.length !== entity.parameters.length) { issues.push({ type: "param_count_mismatch", message: `Function '${entity.name}' has ${entity.parameters.length} parameters but ${paramTags.length} @param tags`, line: entity.location ? entity.location.line : 1, severity: "warning", entity: entity.name, file: filePath, }); } } } return issues; } /** * Calculate quality score for a file based on issues * @param {ValidationIssue[]} issues - Issues found in the file * @returns {number} Score from 0-100 */ function calculateFileScore(issues) { if (issues.length === 0) { return 100; } let deduction = 0; for (const issue of issues) { switch (issue.severity) { case "error": deduction += 20; break; case "warning": deduction += 10; break; case "info": deduction += 5; break; } } return Math.max(0, 100 - deduction); }