291 lines
10 KiB
JavaScript
291 lines
10 KiB
JavaScript
/**
|
|
* 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")
|
|
),
|
|
};
|
|
}
|