/** * PR-37 Part E — Domain Validator * * Validates a requirement package (epics, features, userStories) against * a domain from the registry, producing coverage, consistency, and * completeness scores, plus actionable recommendations. * * @module pr-37-domain-intelligence/domain-validator * @since PR-37 */ import { getDomain, listDomains } from "./domain-registry.mjs"; // ══════════════════════════════════════════════════════════ // Types / typedefs (JSDoc only) // ══════════════════════════════════════════════════════════ /** * @typedef {object} ValidationReport * @property {number} coverageScore - 0-100: % of domain epics covered by generated epics * @property {number} consistencyScore - 0-100: % of features matching domain expectations * @property {number} completenessScore - 0-100: % of domain coreEpics present * @property {string[]} missingEpics - Domain epics NOT present in generated set * @property {string[]} extraEpics - Generated epics NOT found in domain * @property {string[]} recommendations - Actionable suggestions * @property {"pass"|"conditional"|"fail"} overallVerdict - Final verdict */ // ══════════════════════════════════════════════════════════ // Internal helpers // ══════════════════════════════════════════════════════════ /** * Normalize text for fuzzy comparison. * * @param {string} text * @returns {string} */ function normalize(text) { return text.trim().toLowerCase().replace(/[\s\u3000]+/g, ""); } /** * Check if a generated epic title matches a domain epic title. * * @param {string} generatedTitle - Title from generated epics * @param {object[]} domainEpics - Domain coreEpics array * @returns {boolean} */ function matchesDomainEpic(generatedTitle, domainEpics) { const g = normalize(generatedTitle); return domainEpics.some( (de) => normalize(de.title) === g || normalize(de.title).includes(g) || g.includes(normalize(de.title)) ); } /** * Check if a generated feature title/name matches a domain feature name. * * @param {string} generatedFeatureTitle - Generated feature name * @param {object} domainFeatures - Domain coreFeatures object * @returns {boolean} */ function matchesDomainFeature(generatedFeatureTitle, domainFeatures) { const g = normalize(generatedFeatureTitle); const allDomainFeatureNames = Object.values(domainFeatures).flat(); return allDomainFeatureNames.some( (dfn) => normalize(dfn) === g || normalize(dfn).includes(g) || g.includes(normalize(dfn)) ); } // ══════════════════════════════════════════════════════════ // Public API // ══════════════════════════════════════════════════════════ /** * Validate a requirement package against a target domain. * * @param {object} requirementPackage - The requirement package to validate * @param {object[]} [requirementPackage.epics] - Generated epics * @param {object[]} [requirementPackage.features] - Generated features * @param {object[]} [requirementPackage.userStories] - Generated user stories * @param {string} domainName - Target domain name for validation * @returns {ValidationReport} * * @example * const report = validateRequirements({ epics, features, userStories }, "MES"); * // => { coverageScore: 80, consistencyScore: 75, completenessScore: 70, ... } */ export function validateRequirements(requirementPackage, domainName) { // ── Default empty result ────────────────────────────── const emptyResult = { coverageScore: 0, consistencyScore: 0, completenessScore: 0, missingEpics: [], extraEpics: [], recommendations: [], overallVerdict: "fail", }; if (!requirementPackage || typeof requirementPackage !== "object") { return { ...emptyResult, recommendations: ["Requirement package is empty or invalid"], }; } if (!domainName) { return { ...emptyResult, recommendations: ["No domain specified for validation"], }; } const domain = getDomain(domainName); if (!domain) { return { ...emptyResult, recommendations: [ `Domain "${domainName}" not found in registry. Available: ${listDomains().join(", ")}`, ], }; } // Extract inputs with defaults const generatedEpics = requirementPackage.epics || []; const generatedFeatures = requirementPackage.features || []; const generatedUserStories = requirementPackage.userStories || []; const domainEpics = domain.coreEpics || []; const domainFeatures = domain.coreFeatures || {}; // ══ 1. Completeness: % of domain coreEpics present ═══ const coveredDomainTitles = domainEpics.filter((de) => matchesDomainEpic(de.title, generatedEpics) ); const missingEpics = domainEpics .filter((de) => !matchesDomainEpic(de.title, generatedEpics)) .map((de) => de.title); const completenessScore = domainEpics.length > 0 ? Math.round((coveredDomainTitles.length / domainEpics.length) * 100) : 0; // ══ 2. Coverage: % of generated epics matching domain ═══ const coveredGeneratedEpics = generatedEpics.filter((ge) => matchesDomainEpic(ge.title, domainEpics) ); const extraEpics = generatedEpics .filter((ge) => !matchesDomainEpic(ge.title, domainEpics)) .map((ge) => ge.title); const coverageScore = generatedEpics.length > 0 ? Math.round((coveredGeneratedEpics.length / generatedEpics.length) * 100) : 0; // ══ 3. Consistency: features matching domain ════════ const consistentFeatures = generatedFeatures.filter((gf) => matchesDomainFeature(gf.title, domainFeatures) ); const consistencyScore = generatedFeatures.length > 0 ? Math.round((consistentFeatures.length / generatedFeatures.length) * 100) : 100; // If no features, consider it consistent (trivially) // ══ 4. Generate recommendations ═══════════════════════ const recommendations = []; if (missingEpics.length > 0) { recommendations.push( `Add missing epics from ${domain.displayName}: ${missingEpics.join(", ")}` ); } if (extraEpics.length > 0) { recommendations.push( `Remove or align extra epics not in ${domain.displayName}: ${extraEpics.join(", ")}` ); } if (consistencyScore < 70 && generatedFeatures.length > 0) { recommendations.push( `Feature consistency is low (${consistencyScore}%). Review feature titles against ${domain.displayName} core features.` ); } if (completenessScore < 50) { recommendations.push( `Completeness is critically low (${completenessScore}%). Consider increasing domain epic coverage for ${domain.displayName}.` ); } // Recommendations for user stories (if available) if (generatedUserStories.length > 0 && generatedEpics.length > 0) { const storyCountPerEpic = generatedUserStories.filter((s) => s.epicId).length; if (storyCountPerEpic < generatedEpics.length) { recommendations.push( "Ensure each epic has at least one associated user story." ); } } if (generatedEpics.length === 0) { recommendations.push( `No epics generated. Start by adding epics for ${domain.displayName}.` ); } if (!recommendations.length) { recommendations.push( `${domain.displayName} requirement package looks well-aligned with domain expectations.` ); } // ══ 5. Overall verdict ═══════════════════════════════ const overallVerdict = computeVerdict(completenessScore, coverageScore, consistencyScore, missingEpics.length); return { coverageScore, consistencyScore, completenessScore, missingEpics, extraEpics, recommendations, overallVerdict, }; } /** * Compute the overall verdict based on component scores. * * @param {number} completeness - Completeness score 0-100 * @param {number} coverage - Coverage score 0-100 * @param {number} consistency - Consistency score 0-100 * @param {number} missingCount - Number of missing epics * @returns {"pass"|"conditional"|"fail"} */ function computeVerdict(completeness, coverage, consistency, missingCount) { // Fail: critically missing domain content if (completeness < 30 || missingCount >= 5) return "fail"; // Pass: all scores high if (completeness >= 80 && coverage >= 80 && consistency >= 80) return "pass"; // Conditional: some gaps but not critical if (completeness >= 30) return "conditional"; return "fail"; } /** * Validate an epic array alone against a domain (lightweight). * * @param {object[]} epics - Generated epics * @param {string} domainName - Target domain * @returns {ValidationReport} */ export function validateEpics(epics, domainName) { return validateRequirements({ epics, features: [], userStories: [] }, domainName); } /** * Validate features alone against a domain (lightweight). * * @param {object[]} features - Generated features * @param {string} domainName - Target domain * @returns {object} Partial validation result with consistencyScore */ export function validateFeatures(features, domainName) { const result = validateRequirements( { epics: [], features, userStories: [] }, domainName ); return { consistencyScore: result.consistencyScore, consistencyDetails: `Feature consistency with ${domainName}: ${result.consistencyScore}%`, recommendations: result.recommendations.filter((r) => r.toLowerCase().includes("feature") || r.toLowerCase().includes("consistency") ), }; }