Files
2026-06-06 10:40:48 +08:00

386 lines
12 KiB
JavaScript

/**
* PR-37 Delivery Planning — Release Planner (Part G)
*
* Maps MVP/V1/V2/Enterprise/Global phases to concrete releases:
* - Release 1.0 (MVP)
* - Release 1.1 (MVP polish)
* - Release 2.0 (V1)
* - Enterprise Edition
* - Global Edition
*
* Each release includes version info, feature counts, validation gates,
* release goals, dependency checks, and relative timing.
*
* @module pr-37-delivery-planning/release-planner
* @since PR-37
*/
/**
* Standard validation gates applied to every release.
*
* @type {string[]}
*/
const DEFAULT_VALIDATION_GATES = [
"All ACs pass",
"Regression test pass",
"Performance benchmark meet",
"Security scan pass",
"UAT sign-off",
];
/**
* Generate a release plan from SF-02 output.
*
* @param {object} opts
* @param {object} opts.mvpScope — MVP scope from SF-02 (mvp, v1, v2, enterprise, global phases)
* @param {object[]} opts.priorities — Priority matrix entries (from SF-02)
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
* @param {object[]} opts.stories — User stories (from SF-02 userStories)
* @param {object[]} opts.epics — Epics (from SF-02)
* @param {object[]} opts.features — Features (from SF-02)
* @returns {object} Release plan
*
* @example
* ```js
* const plan = generateReleasePlan({
* mvpScope: sf02MvpScope,
* priorities: sf02Priorities,
* dependencies: sf02Graph,
* stories: sf02Stories,
* epics: sf02Epics,
* features: sf02Features,
* });
* ```
*/
export function generateReleasePlan({ mvpScope, priorities, dependencies, stories, epics, features }) {
// ── Input validation ──
if (!stories || stories.length === 0) {
return {
releases: [],
warnings: ["No stories provided; no releases generated"],
};
}
const warnings = [];
const priorityMap = new Map();
for (const p of (priorities || [])) {
if (p.itemType === "story") {
priorityMap.set(p.itemId, p.category);
}
}
// Build lookup maps
const storyMap = new Map();
for (const s of (stories || [])) storyMap.set(s.id, s);
const featureMap = new Map();
for (const f of (features || [])) featureMap.set(f.id, f);
const epicMap = new Map();
for (const e of (epics || [])) epicMap.set(e.id, e);
const dependencyEdges = (dependencies && dependencies.edges) ? dependencies.edges : [];
/**
* Map phase scope from MVP scope → actual story/feature/epic IDs.
*/
function resolvePhaseStories(phase) {
if (!phase || !phase.mustHaveStories) return [];
return phase.mustHaveStories.map(s => s.id || s);
}
function resolvePhaseEpics(phase, epics) {
if (!phase || !phase.epics) return [];
// If epics is a count, we can't resolve IDs; return empty
if (typeof phase.epics === "number") return [];
return [...phase.epics].slice(0);
}
function resolvePhaseFeatures(phase, features) {
if (!phase || !phase.features) return [];
if (typeof phase.features === "number") return [];
return [...phase.features].slice(0);
}
// ── Helper: filter stories by priority ──
function filterStoriesByPriority(categories) {
const ids = [];
for (const s of (stories || [])) {
const cat = priorityMap.get(s.id) || s.priority || "Could Have";
if (categories.includes(cat)) ids.push(s.id);
}
return ids;
}
// ── Helper: find stories with critical unresolved deps ──
function findCriticalUnresolved(storyIds, availableIds) {
const available = new Set(availableIds);
const issues = [];
for (const sid of storyIds) {
const story = storyMap.get(sid);
if (!story) continue;
const deps = story.dependencies || [];
for (const depId of deps) {
if (!available.has(depId)) {
const depStory = storyMap.get(depId);
issues.push({
storyId: sid,
storyStatement: story.statement || sid,
unresolvedDepId: depId,
unresolvedDepStatement: depStory ? depStory.statement : depId,
});
}
}
}
// Also check dependency graph edges
const edgeIssues = [];
const storyIdSet = new Set(storyIds);
for (const edge of dependencyEdges) {
if (storyIdSet.has(edge.to)) {
const fromStory = storyMap.get(edge.from);
const toStory = storyMap.get(edge.to);
if (!available.has(edge.from) && fromStory && toStory) {
edgeIssues.push({
storyId: edge.to,
storyStatement: toStory.statement || edge.to,
unresolvedDepId: edge.from,
unresolvedDepStatement: fromStory.statement || edge.from,
});
}
}
}
return [...issues, ...edgeIssues].slice(0, 5); // limit to 5
}
// ── Helper: build release object ──
function buildRelease({ version, name, description, storyIds, releaseNumber }) {
const uniqueEpicIds = new Set();
const uniqueFeatureIds = new Set();
let storyCount = 0;
let storyPoints = 0;
for (const sid of storyIds) {
const s = storyMap.get(sid);
if (!s) continue;
storyCount++;
storyPoints += s.storyPoints || 1;
if (s.epicId) uniqueEpicIds.add(s.epicId);
if (s.featureId) uniqueFeatureIds.add(s.featureId);
}
// Resolve epic/feature names
const epicNames = [...uniqueEpicIds].map(eid => {
const epic = epicMap.get(eid);
return epic ? epic.title : eid;
}).filter(Boolean);
const featureNames = [...uniqueFeatureIds].map(fid => {
const feat = featureMap.get(fid);
return feat ? feat.title : fid;
}).filter(Boolean);
// Determine all resolved IDs for dependency check
const resolvedIds = new Set(storyIds);
const depIssues = findCriticalUnresolved(storyIds, resolvedIds);
return {
version,
name,
description,
features: {
epicCount: uniqueEpicIds.size,
epicNames,
featureCount: uniqueFeatureIds.size,
featureNames: featureNames.slice(0, 10), // top 10
storyCount,
storyPoints,
},
validationGates: [...DEFAULT_VALIDATION_GATES],
releaseGoals: deriveReleaseGoals(name, version, storyIds, storyMap),
dependencyCheck: depIssues,
releaseDate: `Release ${releaseNumber}`,
};
}
// ── 1. Release 1.0 (MVP) ──
// Must Have stories from MVP phase
const mvpPhase = mvpScope && mvpScope.mvp;
let mvpStoryIds = [];
if (mvpPhase && mvpPhase.mustHaveStories && Array.isArray(mvpPhase.mustHaveStories)) {
mvpStoryIds = mvpPhase.mustHaveStories.map(s => s.id || s);
}
// Fallback: filter Must Have from all stories
if (mvpStoryIds.length === 0) {
mvpStoryIds = filterStoriesByPriority(["Must Have"]);
}
// ── 2. Release 1.1 (MVP polish) ──
// Remaining Must Have + critical Should Have fixes
const shouldHaveIds = filterStoriesByPriority(["Should Have"]);
// Take first ~30% of Should Have for critical fixes
const criticalShouldHave = shouldHaveIds.slice(0, Math.max(1, Math.ceil(shouldHaveIds.length * 0.3)));
// Remaining Must Have stories not in MVP
const remainingMustHave = mvpStoryIds.filter(id => !mvpStoryIds.includes(id));
// ── 3. Release 2.0 (V1) ──
// All Should Have stories that aren't in 1.1
const v1StoryIds = shouldHaveIds.filter(id => !criticalShouldHave.includes(id));
// ── 4. Enterprise Edition ──
const enterprisePhase = mvpScope && mvpScope.enterprise;
let enterpriseStoryIds = [];
if (enterprisePhase && enterprisePhase.stories) {
enterpriseStoryIds = [...enterprisePhase.stories];
}
// ── 5. Global Edition ──
const globalPhase = mvpScope && mvpScope.global;
let globalStoryIds = [];
if (globalPhase && globalPhase.stories) {
globalStoryIds = [...globalPhase.stories];
}
// Remove duplicates: Enterprise/Global should contain stories not in earlier releases
const v2CouldIds = filterStoriesByPriority(["Could Have"]);
if (enterpriseStoryIds.length === 0) {
// Fallback: Could Have stories for Enterprise
enterpriseStoryIds = v2CouldIds.slice(0, Math.ceil(v2CouldIds.length * 0.6));
}
if (globalStoryIds.length === 0) {
globalStoryIds = v2CouldIds.slice(Math.ceil(v2CouldIds.length * 0.6));
}
// ── Assemble releases ──
const releases = [];
if (mvpStoryIds.length > 0) {
releases.push(buildRelease({
version: "1.0.0",
name: "MVP",
description: "Minimum Viable Product — core Must Have functionalities for initial market validation",
storyIds: mvpStoryIds,
releaseNumber: 1,
}));
}
if (criticalShouldHave.length > 0 || remainingMustHave.length > 0) {
const polishIds = [...new Set([...criticalShouldHave, ...remainingMustHave])];
releases.push(buildRelease({
version: "1.1.0",
name: "MVP Polish",
description: "MVP refinement — remaining Must Have items and critical Should Have fixes",
storyIds: polishIds,
releaseNumber: 2,
}));
}
if (v1StoryIds.length > 0) {
releases.push(buildRelease({
version: "2.0.0",
name: "V1",
description: "First full version — complete Should Have feature set for competitive feature parity",
storyIds: v1StoryIds,
releaseNumber: 3,
}));
}
if (enterpriseStoryIds.length > 0) {
releases.push(buildRelease({
version: "3.0.0",
name: "Enterprise Edition",
description: "Enterprise-grade features — advanced security, multi-tenancy, compliance, and admin capabilities",
storyIds: enterpriseStoryIds,
releaseNumber: 4,
}));
}
if (globalStoryIds.length > 0) {
releases.push(buildRelease({
version: "4.0.0",
name: "Global Edition",
description: "Global-scale features — i18n, multi-region, compliance, and localization",
storyIds: globalStoryIds,
releaseNumber: 5,
}));
}
// ── Generate warnings ──
if (mvpStoryIds.length === 0) {
warnings.push("No MVP stories found; Release 1.0 will be empty");
}
if (releases.length === 0) {
warnings.push("No releases could be generated from the provided data");
}
for (const rel of releases) {
if (rel.dependencyCheck.length > 0) {
warnings.push(`${rel.name} (${rel.version}): ${rel.dependencyCheck.length} stories have unresolved critical dependencies`);
}
}
return {
releases,
warnings,
};
}
/**
* Derive business-oriented release goals from the story set.
*
* @param {string} name — Release name
* @param {string} version — Semver
* @param {string[]} storyIds — Stories in this release
* @param {Map<string, object>} storyMap — Story lookup
* @returns {string[]} Goals
*/
function deriveReleaseGoals(name, version, storyIds, storyMap) {
const goals = [];
switch (name) {
case "MVP":
goals.push("Deliver core value proposition to early adopters");
goals.push("Validate product-market fit with real users");
goals.push("Establish foundational user identity and access system");
break;
case "MVP Polish":
goals.push("Address critical feedback from MVP users");
goals.push("Stabilize core features for broader adoption");
goals.push("Improve reliability and performance to production standards");
break;
case "V1":
goals.push("Achieve feature parity with market competitors");
goals.push("Provide comprehensive user experience for mainstream adoption");
goals.push("Enable self-service onboarding and administration");
break;
case "Enterprise Edition":
goals.push("Meet enterprise security and compliance requirements");
goals.push("Support multi-tenant deployment at scale");
goals.push("Provide advanced audit, governance, and admin tools");
break;
case "Global Edition":
goals.push("Support internationalization and localization");
goals.push("Meet global data residency and privacy regulations");
goals.push("Enable multi-region deployment and failover");
break;
default:
goals.push(`Deliver ${name} (${version}) features to users`);
break;
}
// Add story-specific goal from top stories
const topStories = storyIds.slice(0, 3);
for (const sid of topStories) {
const s = storyMap.get(sid);
if (s && s.value) {
goals.push(`Enable: ${s.value.length > 60 ? s.value.slice(0, 60) + "…" : s.value}`);
}
}
return goals;
}
export default { generateReleasePlan };