🎉 init: 小龙的工作空间

This commit is contained in:
大海
2026-06-06 10:40:48 +08:00
commit a188ee1426
3201 changed files with 231817 additions and 0 deletions
@@ -0,0 +1,241 @@
# PR-37 Delivery Planning — Reference
> Sprint Plan / Release Plan / Batch Plan / Capacity Plan / Roadmap Alignment
---
## Sprint Plan (Part F)
### Input
```js
generateSprintPlan({
stories, // SF-02 user stories
dependencies, // SF-02 dependency graph { nodes, edges }
priorities, // SF-02 priority entries [{ itemId, category, score }]
config, // { sprintDurationWeeks, teamVelocity, maxStoryPointsPerSprint }
})
```
### Output
```js
{
sprints: [{
sprintNumber: 1,
sprintGoal: "Sprint 1: 浏览用户注册列表 / 查看用户注册详情 / ...",
startDate: "Week 1",
endDate: "Week 2",
storyIds: ["us-1", "us-2", ...],
totalStoryPoints: 20,
epicIds: ["ep-1", "ep-2"],
featureIds: ["ft-1", "ft-2", "ft-3"],
criticalPath: ["us-1"],
risks: []
}, ...],
metrics: {
totalSprints: 6,
totalPoints: 138,
avgPointsPerSprint: 23,
estimatedDurationWeeks: 12
},
unplannedStories: [],
warnings: []
}
```
### Algorithm
1. Filter Must Have → Should Have → Could Have stories
2. Build dependency map from edges: `edge.from depends on edge.to`
3. Topological sort: Kahn's algorithm, deps come first
4. Assign to sprints by capacity (maxStoryPointsPerSprint / teamVelocity)
5. Dependency constraint: dependent story cannot be in earlier sprint than prerequisite
---
## Release Plan (Part G)
### Input
```js
generateReleasePlan({
mvpScope, // SF-02 MVP scope
priorities, // SF-02 priority entries
dependencies, // Dependency graph
stories, // User stories
epics, // Epics
features, // Features
})
```
### Output
```js
{
releases: [
{
version: "1.0.0",
name: "MVP — 最小可行产品",
features: { epicCount: 2, epicNames: [...], featureCount: 2, featureNames: [...], storyCount: 10, storyPoints: 30 },
description: "...",
releaseGoals: "...",
validationGates: ["All ACs pass", "Regression test pass", "Performance benchmark meet", "Security scan pass", "UAT sign-off"],
dependencyCheck: { unresolvedIds: [], allResolved: true },
releaseDate: "Week 8"
},
{ version: "1.1.0", name: "MVP Polish", ... },
{ version: "2.0.0", name: "V1 — 第一完整版本", ... },
{ version: "3.0.0", name: "V2 — 功能增强版本", ... },
{ version: "4.0.0", name: "Global — 全球化版本", ... }
],
warnings: []
}
```
---
## Batch Plan (Part H)
### Batch Types
| Batch Type | Description |
|------------|-------------|
| DevelopmentBatch L0 | 无依赖,可并行开发 |
| DevelopmentBatch L1 | 依赖 L0 完成 |
| DevelopmentBatch L2 | 依赖 L1 完成 |
| DevelopmentBatch L3+ | 深层依赖 |
| TestingBatch | 开发完成的进入测试 |
| ReleaseBatch | 测试通过的进入发布队列 |
| DeliveryBatch | 最终客户交付批次 |
### Output
```js
{
batches: [{
batchId: "batch-dev-l0-1",
batchType: "DevelopmentBatch",
name: "Development Level 0",
storyIds: ["us-1", "us-7", "us-13", ...],
featureIds: [...],
epicIds: [...],
parallelGroups: [["us-1", "us-7", "us-13"], ["us-19", "us-25"]],
prerequisiteBatches: []
}, ...]
}
```
---
## Capacity Plan (Part I)
### Configuration
```js
{
teamSize: 5, // 团队人数
velocity: 20, // 每 Sprint 完成点数
availabilityFactor: 0.8, // 可用率(扣除会议/假期)
complexityFactors: {
high: 2.0, // 高复杂度工作量倍率
medium: 1.0, // 中复杂度
low: 0.5 // 低复杂度
},
riskBufferPercent: 20 // 风险缓冲百分比
}
```
### Output
```js
{
totalEffort: 60, // 复杂度调整后总工作量(pts)
totalCapacity: 240, // 团队总容量(pts)
capacityUtilization: "25%",
sprintBySprint: [{
sprintNumber: 1,
capacity: 16,
allocated: 20,
utilization: "125%",
overCapacity: true
}, ...],
riskAdjustedEffort: 72,
deliveryForecast: {
optimistic: "Week 8 from project start",
realistic: "Week 10 from project start",
pessimistic: "Week 12 from project start"
},
resourceRecommendations: [
"Sprint 1 is over capacity. Consider adding 1 team member or splitting stories."
],
warnings: [...]
}
```
---
## Roadmap Alignment (Part J)
### Algorithm
1. **Phase Coverage**: 每个 Roadmap Phase 是否被 Release Plan 覆盖
2. **Story Coverage**: MVP/V1 Stories 是否被 Sprint Plan 完整分配
3. **Timeline Consistency**: Sprint 总周数是否在 Roadmap 时间范围内
4. **Budget Alignment**: Phase 预算与实际 Story Points × $/pt 是否一致(偏差 < 20%
5. **Dependency Integrity**: Dev→Test→Release→Delivery 批次链是否完整
### Output
```js
{
overallVerdict: "aligned", // "aligned" / "partial" / "misaligned"
alignmentScore: 85, // 0-100
phaseCoverage: { ... },
storyCoverage: { ... },
timelineCheck: { ... },
budgetCheck: { ... },
dependencyCheck: { ... },
warnings: [...],
recommendations: [...],
summary: "## Roadmap Alignment: ALIGNED (85/100) ..."
}
```
---
## Factory Integration (Part K)
### Full Pipeline
```
SF-01 (Strategy) → SF-02 (Requirements) → PR-37 (Delivery Planning) → SF-03 (Design)
│ │
┌──────┘ ┌───────────┴────────────┐
│ │ │
Domain Intelligence Delivery Planning
│ │
┌────┴─────┐ ┌───────┼───────┬──────────┐
Classifier Epics Sprint Release Batch Capacity → Align
│ │ Plan Plan Plan Plan Report
│ Features
Validation Validation
```
### API
```js
// Domain Intelligence
const di = createDomainIntelligenceFactory(
{ productName: "ERP系统" },
{ domainOverride: null, includeValidation: true }
);
// → { classification, epics, features, validation }
// Delivery Planning
const dp = createDeliveryPlanningFactory(
sf02Output, // upstreamArtifacts from SF-02
{ sprint: { teamVelocity: 20 }, capacity: { teamSize: 5 } }
);
// → { sprintPlan, releasePlan, batchPlan, capacityPlan, alignmentReport }
```
@@ -0,0 +1,281 @@
# PR-37 Domain Intelligence & Delivery Planning
> **Version:** 1.0 | **Date:** 2026-06-05 | **Status:** ✅ Complete
>
> 增强 SF-02 的行业知识能力和交付规划能力,使之从通用需求工厂升级为行业级需求工厂。
---
## Architecture Overview
```
PR-37
┌─────────────────────────────────┐
│ │
│ ┌─ Domain Intelligence ──────┐ │
SF-01 ──┤ │ A. Domain Registry │ │
│ │ │ B. Domain Classifier │ │
SF-02 ──┤ │ C. Industry Epic Gen │ │
│ │ │ D. Industry Feature Gen │ │
│ │ │ E. Domain Validator │ │
│ │ └────────────────────────────┘ │
│ │ │
└─────┤ ┌─ Delivery Planning ────────┐ │
│ │ F. Sprint Planner │ │
│ │ G. Release Planner │ │
│ │ H. Batch Planner │ │──► SF-03
│ │ I. Capacity Planner │ │
│ │ J. Roadmap Aligner │ │
│ └────────────────────────────┘ │
│ │
│ K. Factory Integration │
│ (PR-35/PR-36/SF-01/SF-02) │
└─────────────────────────────────┘
```
## Module Map
| Part | Module | File | Lines |
|------|--------|------|-------|
| A | Domain Registry | `domain-registry.mjs` | 1,278 |
| B | Domain Classifier | `domain-classifier.mjs` | 456 |
| C | Industry Epic Generator | `industry-epic-generator.mjs` | 265 |
| D | Industry Feature Generator | `industry-feature-generator.mjs` | 263 |
| E | Domain Validator | `domain-validator.mjs` | 290 |
| F | Sprint Planner | `sprint-planner.mjs` | 472 |
| G | Release Planner | `release-planner.mjs` | 385 |
| H | Batch Planner | `batch-planner.mjs` | 284 |
| I | Capacity Planner | `capacity-planner.mjs` | 276 |
| J | Roadmap Aligner | `roadmap-aligner.mjs` | 384 |
| — | Index (Domain Int.) | `index.mjs` | 179 |
| — | Index (Delivery) | `index.mjs` | 178 |
| **Total** | | **12 files** | **4,710** |
## Domain Registry (Part A)
16 个行业领域,每个行业 10 个知识维度:
| Domain | 中文名 | Epics | Features | Category |
|--------|--------|-------|----------|----------|
| ERP | 企业资源计划 | 10 | 67 | enterprise-software |
| MES | 制造执行系统 | 10 | 67 | industrial |
| CRM | 客户关系管理 | 9 | 63 | enterprise-software |
| WMS | 仓库管理系统 | 7 | 49 | enterprise-software |
| SCM | 供应链管理 | 7 | 42 | enterprise-software |
| HRM | 人力资源管理 | 8 | 56 | management |
| PLM | 产品生命周期管理 | 7 | 42 | engineering |
| CAD | CAD设计平台 | 8 | 56 | engineering |
| EDA | 电子设计自动化 | 8 | 56 | engineering |
| AI Platform | AI平台 | 8 | 56 | intelligence |
| E-Commerce | 电子商务平台 | 10 | 80 | commerce |
| SaaS | SaaS平台 | 8 | 56 | platform |
| Project Management | 项目管理 | 8 | 56 | management |
| Knowledge Base | 知识管理平台 | 8 | 56 | management |
| BI | 商业智能平台 | 8 | 56 | intelligence |
| IoT Platform | 物联网/工业互联网 | 9 | 72 | industrial |
### Knowledge Dimensions per Domain
每个行业包含:
1. `name` / `displayName` — 中英文名
2. `description` — 行业描述
3. `coreProcesses` — 核心业务流程 (3-8)
4. `coreEntities` — 核心业务实体 (10-30)
5. `coreEpics` — 核心Epic模板 (7-10)
6. `coreFeatures` — 核心Feature模板 (40-80 features)
7. `coreKPIs` — 核心KPI (5-10)
8. `coreRoles` — 核心角色 (5-12)
9. `terminology` — 行业术语表 (5-10)
10. `constraints` — 行业约束
11. `regulations` — 行业法规
## Domain Classification (Part B)
### Algorithm
1. 从产品名称和描述中提取关键词
2. 过滤停用词(系统、平台、管理 等)
3. 与 16 个领域的 `keywords` / `aliases` 匹配
4. 对每个领域计算匹配分:关键词 × 3 + 别名 × 5 + 名称直接匹配 × 10
5. 应用交叉匹配映射(如 "跨境电商" → E-Commerce/SCM
6. 返回 primaryDomain(最高分)、secondaryDomain(次高分)、confidenceScore
### Examples
| Input | Primary | Secondary | Confidence |
|-------|---------|-----------|------------|
| AI客服平台 | AI Platform | E-Commerce | 75% |
| ERP系统 | ERP | — | 48% |
| 工业MES系统 | MES | — | 75% |
| 跨境电商平台 | E-Commerce | SCM | 83% |
| 工业AI质检 | MES | AI Platform | 87% |
## Delivery Planning (Parts F-J)
### Sprint Planning (Part F)
输入 SF-02 的 stories / dependencies / priorities → 输出 Sprint Plan
```
Sprint 1: Week 1-2, 7 stories, 20 pts, "Sprint 1: 浏览用户注册列表 / ..."
Sprint 2: Week 3-4, 11 stories, 25 pts, "Sprint 2: ..."
Sprint 3: Week 5-6, 8 stories, 20 pts, "Sprint 3: ..."
```
- 基于拓扑排序的依赖约束(依赖的先执行)
- Sprint 容量控制(velocity / maxStoryPointsPerSprint
- 自动计算 metricstotalSprints, avgPointsPerSprint, estimatedDurationWeeks
### Release Planning (Part G)
MVP/V1/V2/Enterprise/Global → 5 个 Release
```
Release 1.0.0 (MVP): 10 stories, 验证门禁 x5
Release 1.1.0 (MVP Polish): important patches
Release 2.0.0 (V1): 20 stories
Release 3.0.0 (V2): 30 stories
Release 4.0.0 (Global): Global features
```
每个 Release 包含:features, validationGates, releaseGoals, dependencyCheck, releaseDate
### Batch Planning (Part H)
按依赖层级分组:
- **DevelopmentBatch L0** — 无依赖,可并行开发
- **DevelopmentBatch L1** — 依赖 L0,第二批开发
- **TestingBatch** — 开发完成的批次进入测试
- **ReleaseBatch** — 测试通过的进入发布
- **DeliveryBatch** — 客户交付批次
### Capacity Planning (Part I)
- 复杂度因子(high/medium/low)调整工作量
- 团队容量计算:teamSize × velocity × sprintCount × availabilityFactor
- 容量利用率百分比
- 风险缓冲(riskBufferPercent
- 交付预测(乐观/现实/悲观)
### Roadmap Alignment (Part J)
验证 SF-01 Roadmap 与 PR-37 计划一致性:
- Phase coverage check
- Story coverage check
- Timeline consistency
- Budget alignment
- Dependency integrity
- Alignment Score 0-100
## Factory Integration (Part K)
### createDomainIntelligenceFactory(strategyPackage, options?)
Returns: `{ classification, epics, features, validation, warnings }`
Options: `{ domainOverride, includeValidation }`
### createDeliveryPlanningFactory(upstreamArtifacts, config?)
Accepts SF-02 output as upstreamArtifacts.
Returns: `{ factoryId, status, sprintPlan, releasePlan, batchPlan, capacityPlan, alignmentReport, warnings, metadata }`
Config: `{ sprint: {...}, capacity: {...} }`
## Artifacts (Part L)
```js
PR37_ARTIFACTS = {
SPRINT_PLAN: "sprint-plan",
RELEASE_PLAN: "release-plan",
BATCH_PLAN: "batch-plan",
CAPACITY_PLAN: "capacity-plan",
ALIGNMENT_REPORT: "alignment-report",
}
SF02_ARTIFACTS (新增from Domain Intelligence):
DOMAIN_REGISTRY
INDUSTRY_EPIC_CATALOG
INDUSTRY_FEATURE_CATALOG
DOMAIN_VALIDATION_REPORT
```
## Testing (Part M)
57 个测试,12 个 suites0 个失败:
| Suite | Tests | Coverage |
|-------|-------|----------|
| Part A — Domain Registry | 9 | 16 domains, 10 dims each |
| Part B — Domain Classifier | 9 | All test products + edge cases |
| Part C — Industry Epic Gen | 5 | ERP/MES/AI/fallback |
| Part D — Industry Feature Gen | 4 | ERP features + type detection |
| Part E — Domain Validator | 5 | ERP/MES/generic/unknown |
| Part F — Sprint Planner | 6 | Capacity, deps, edge cases |
| Part G — Release Planner | 4 | 5 releases, gates, null |
| Part H — Batch Planner | 2 | Dep levels, batch types |
| Part I — Capacity Planner | 3 | Effort, forecast, empty |
| Part J — Roadmap Aligner | 1 | Full alignment |
| Part K — Factory Integration | 5 | DI + DP factories |
| Part Z — Regression | 4 | SF-01, SF-02, pipeline, topo |
| **Total** | **57** | **0 failures** |
### Regression Suite
- SF-01: 77 tests → all pass
- SF-02: 89 tests → all pass
- PR-37: 57 tests → all pass
- **Total: 223 tests, 0 failures**
## Quick Start
```js
import { classifyProduct, createDomainIntelligenceFactory } from './src/pr-37-domain-intelligence/index.mjs';
import { createDeliveryPlanningFactory } from './src/pr-37-delivery-planning/index.mjs';
// 1. Classify product
const classification = classifyProduct({ productName: 'ERP系统' });
console.log(classification.primaryDomain); // "ERP"
// 2. Generate industry-specific epics and features
const diFactory = createDomainIntelligenceFactory({ productName: 'ERP系统' });
console.log(diFactory.epics.length); // 10 (vs 6 generic)
console.log(diFactory.features.length); // 67 (vs 21 generic)
console.log(diFactory.validation.overallVerdict); // "pass"
// 3. Generate delivery plans from SF-02 output
const dpFactory = createDeliveryPlanningFactory(sf02Output, {
sprint: { teamVelocity: 20 },
capacity: { teamSize: 5 },
});
console.log(dpFactory.sprintPlan.sprints.length); // e.g., 6
console.log(dpFactory.releasePlan.releases.length); // 5
console.log(dpFactory.alignmentReport.overallVerdict); // "aligned" / "partial"
```
## Files
```
src/pr-37-domain-intelligence/
├── domain-registry.mjs # Part A: 16 industry domain definitions
├── domain-classifier.mjs # Part B: product-to-domain classification
├── industry-epic-generator.mjs # Part C: domain-specific epic generation
├── industry-feature-generator.mjs # Part D: domain-specific feature generation
├── domain-validator.mjs # Part E: domain coverage validation
└── index.mjs # Barrel + createDomainIntelligenceFactory()
src/pr-37-delivery-planning/
├── sprint-planner.mjs # Part F: Sprint plan from stories + deps
├── release-planner.mjs # Part G: Release plan from MVP scope
├── batch-planner.mjs # Part H: Dev/Test/Release/Delivery batches
├── capacity-planner.mjs # Part I: Team capacity + delivery forecast
├── roadmap-aligner.mjs # Part J: Sprint/Release/Roadmap alignment
└── index.mjs # Barrel + createDeliveryPlanningFactory()
test/
└── pr-37-domain-intelligence-delivery-planning.test.mjs # 57 tests
docs/pr-37/
├── pr-37-domain-intelligence.md # This doc
└── pr-37-delivery-planning.md # Delivery planning details
```
@@ -0,0 +1,284 @@
/**
* PR-37 Delivery Planning — Batch Planner (Part H)
*
* Generates 4 batch types based on dependency analysis:
* - DevelopmentBatch: Group stories by dependency level for parallel dev
* - TestingBatch: Group completed dev batches ready for testing
* - ReleaseBatch: Group features ready for staged deployment
* - DeliveryBatch: Customer-facing delivery increments
*
* Each batch captures parallel groups, prerequisite batches, and story/feature/epic references.
*
* @module pr-37-delivery-planning/batch-planner
* @since PR-37
*/
let _batchSeq = 0;
/**
* Generate a complete batch plan from SF-02 output and sprint/release plans.
*
* @param {object} opts
* @param {object[]} opts.stories — User stories (from SF-02)
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
* @param {object[]} opts.priorities — Priority matrix entries
* @param {object} opts.sprints — Sprint plan (from sprint-planner)
* @param {object} opts.releases — Release plan (from release-planner)
* @returns {object} Batch plan { batches, summary }
*
* @example
* ```js
* const batchPlan = generateBatchPlan({
* stories: myStories,
* dependencies: myGraph,
* priorities: myPriorities,
* sprints: sprintPlan,
* releases: releasePlan,
* });
* ```
*/
export function generateBatchPlan({ stories, dependencies, priorities, sprints, releases }) {
// ── Input validation ──
if (!stories || stories.length === 0) {
return {
batches: [],
summary: { totalBatches: 0, devBatches: 0, testBatches: 0, releaseBatches: 0, deliveryBatches: 0 },
warnings: ["No stories provided"],
};
}
const warnings = [];
const batches = [];
const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] });
const storyMap = new Map();
for (const s of stories) storyMap.set(s.id, s);
// Build dependency map: storyId → set of dependency IDs
const depMap = new Map();
for (const s of stories) {
depMap.set(s.id, new Set(s.dependencies || []));
}
for (const edge of (graph.edges || [])) {
// edge.from depends on edge.to (SF-02 semantics: from REQUIRES to)
if (!depMap.has(edge.from)) depMap.set(edge.from, new Set());
depMap.get(edge.from).add(edge.to);
}
// ── Compute dependency levels ──
const depLevels = new Map();
function computeLevel(id, visited = new Set()) {
if (visited.has(id)) return 0;
visited.add(id);
const deps = depMap.get(id) || new Set();
if (deps.size === 0) {
depLevels.set(id, 0);
return 0;
}
let max = 0;
for (const depId of deps) {
max = Math.max(max, computeLevel(depId, visited) + 1);
}
depLevels.set(id, max);
return max;
}
for (const s of stories) {
if (!depLevels.has(s.id)) computeLevel(s.id);
}
// ── Priority map ──
const priorityMap = new Map();
for (const p of (priorities || [])) {
if (p.itemType === "story") priorityMap.set(p.itemId, p.category);
}
// ── 1. Development Batches ──
// Group by dependency level
const levelGroups = new Map(); // level → [storyIds]
for (const s of stories) {
const level = depLevels.get(s.id) ?? 0;
// Bucket: L0, L1, L2, L3+
const bucket = level >= 3 ? "L3" : `L${level}`;
if (!levelGroups.has(bucket)) levelGroups.set(bucket, []);
levelGroups.get(bucket).push(s.id);
}
const levelOrder = ["L0", "L1", "L2", "L3"];
for (const bucket of levelOrder) {
const ids = levelGroups.get(bucket) || [];
if (ids.length === 0) continue;
// Stories at same level can be developed in parallel — group them
const batchId = `dev-batch-${++_batchSeq}`;
const batchName = `Development ${bucket.replace("L", "Level ")}`;
// Collect feature and epic IDs
const featureIds = new Set();
const epicIds = new Set();
for (const id of ids) {
const s = storyMap.get(id);
if (s) {
if (s.featureId) featureIds.add(s.featureId);
if (s.epicId) epicIds.add(s.epicId);
}
}
// Prerequisite: all lower-level batches
const levelNum = parseInt(bucket.slice(1));
const prerequisiteBatches = [];
for (let l = 0; l < levelNum; l++) {
const lowerBucket = `L${l}`;
const lowerIds = levelGroups.get(lowerBucket);
if (lowerIds && lowerIds.length > 0) {
prerequisiteBatches.push(`dev-batch-${lowerBucket}`);
}
}
batches.push({
batchId,
batchType: "DevelopmentBatch",
name: batchName,
storyIds: ids,
featureIds: [...featureIds],
epicIds: [...epicIds],
parallelGroups: groupForParallel(ids, depMap, depLevels, storyMap),
prerequisiteBatches,
});
}
// ── 2. Testing Batches ──
// Group completed dev batches ready for testing (by sprint boundaries)
if (sprints && sprints.sprints) {
for (const sprint of sprints.sprints) {
const testBatchId = `test-batch-${++_batchSeq}`;
const batchName = `Testing Sprint ${sprint.sprintNumber}`;
const featureIds = new Set();
const epicIds = new Set();
for (const sid of (sprint.storyIds || [])) {
const s = storyMap.get(sid);
if (s) {
if (s.featureId) featureIds.add(s.featureId);
if (s.epicId) epicIds.add(s.epicId);
}
}
batches.push({
batchId: testBatchId,
batchType: "TestingBatch",
name: batchName,
storyIds: sprint.storyIds || [],
featureIds: [...featureIds],
epicIds: [...epicIds],
parallelGroups: [sprint.storyIds || []], // All stories from a sprint can be tested together
prerequisiteBatches: [`dev-batch-${batchName}`],
});
}
}
// ── 3. Release Batches ──
// Group features ready for staged deployment (by release)
if (releases && releases.releases) {
for (const rel of releases.releases) {
const releaseBatchId = `release-batch-${++_batchSeq}`;
const featureIds = new Set();
const epicIds = new Set();
for (const sid of (rel.storyIds || [])) {
const s = storyMap.get(sid);
if (s) {
if (s.featureId) featureIds.add(s.featureId);
if (s.epicId) epicIds.add(s.epicId);
}
}
batches.push({
batchId: releaseBatchId,
batchType: "ReleaseBatch",
name: `Release ${rel.name} (${rel.version})`,
storyIds: rel.storyIds || [],
featureIds: [...featureIds],
epicIds: [...epicIds],
parallelGroups: [rel.storyIds || []],
prerequisiteBatches: [],
});
}
}
// ── 4. Delivery Batches ──
// Customer-facing delivery increments — group by release
if (releases && releases.releases) {
for (const rel of releases.releases) {
const deliveryBatchId = `delivery-batch-${++_batchSeq}`;
const featureIds = new Set();
const epicIds = new Set();
for (const sid of (rel.storyIds || [])) {
const s = storyMap.get(sid);
if (s) {
if (s.featureId) featureIds.add(s.featureId);
if (s.epicId) epicIds.add(s.epicId);
}
}
batches.push({
batchId: deliveryBatchId,
batchType: "DeliveryBatch",
name: `Delivery ${rel.name} (${rel.version})`,
storyIds: rel.storyIds || [],
featureIds: [...featureIds],
epicIds: [...epicIds],
parallelGroups: [],
prerequisiteBatches: [`release-batch-${rel.name}`],
});
}
}
// ── Generate warnings ──
if (levelGroups.size === 0) {
warnings.push("No development batches generated — empty story list");
}
if (batches.length === 0) {
warnings.push("No batches of any type could be generated");
}
const summary = {
totalBatches: batches.length,
devBatches: batches.filter(b => b.batchType === "DevelopmentBatch").length,
testBatches: batches.filter(b => b.batchType === "TestingBatch").length,
releaseBatches: batches.filter(b => b.batchType === "ReleaseBatch").length,
deliveryBatches: batches.filter(b => b.batchType === "DeliveryBatch").length,
};
return { batches, summary, warnings };
}
/**
* Group story IDs into parallel work groups based on dependency analysis.
* Stories with no interdependency at the same level can be done in parallel.
*
* @param {string[]} ids — Story IDs at the same dependency level
* @param {Map<string, Set<string>>} depMap — Dependency map
* @param {Map<string, number>} depLevels — Computed levels
* @param {Map<string, object>} storyMap — Story lookup
* @returns {string[][]} Groups of stories that can be developed in parallel
*/
function groupForParallel(ids, depMap, depLevels, storyMap) {
if (ids.length === 0) return [];
if (ids.length === 1) return [ids];
// Simple heuristic: group by epic ID so stories from same epic are together,
// but different epics can proceed in parallel
const epicGroups = new Map();
for (const id of ids) {
const s = storyMap.get(id);
const epicId = s ? s.epicId || "unknown" : "unknown";
if (!epicGroups.has(epicId)) epicGroups.set(epicId, []);
epicGroups.get(epicId).push(id);
}
// Return epic-based groups as parallel units
return [...epicGroups.values()];
}
export default { generateBatchPlan };
@@ -0,0 +1,277 @@
/**
* PR-37 Delivery Planning — Capacity Planner (Part I)
*
* Calculates total effort (with complexity factors), total capacity,
* capacity utilization, risk-adjusted effort, delivery forecast dates,
* and resource recommendations when utilization exceeds 100%.
*
* @module pr-37-delivery-planning/capacity-planner
* @since PR-37
*/
/**
* Default capacity planning configuration.
*
* @param {object} [cfg]
* @param {number} [cfg.teamSize=5] — Number of team members
* @param {number} [cfg.velocity=20] — Team velocity in story points per sprint
* @param {number} [cfg.availabilityFactor=0.8] — Team availability (80% = 1 day lost/week)
* @param {object} [cfg.complexityFactors] — Story point multiplier by complexity
* @param {number} [cfg.complexityFactors.high=2.0]
* @param {number} [cfg.complexityFactors.medium=1.0]
* @param {number} [cfg.complexityFactors.low=0.5]
* @param {number} [cfg.riskBufferPercent=20] — Risk buffer as percentage of effort
* @returns {object} Normalized config
*/
export function defaultCapacityConfig(cfg = {}) {
return {
teamSize: cfg.teamSize ?? 5,
velocity: cfg.velocity ?? 20,
availabilityFactor: cfg.availabilityFactor ?? 0.8,
complexityFactors: {
high: (cfg.complexityFactors && cfg.complexityFactors.high) ?? 2.0,
medium: (cfg.complexityFactors && cfg.complexityFactors.medium) ?? 1.0,
low: (cfg.complexityFactors && cfg.complexityFactors.low) ?? 0.5,
},
riskBufferPercent: cfg.riskBufferPercent ?? 20,
};
}
/**
* Generate a comprehensive capacity plan from sprint and release plans.
*
* @param {object} opts
* @param {object} opts.sprints — Sprint plan { sprints, metrics }
* @param {object} opts.releases — Release plan { releases }
* @param {object} [opts.config] — Capacity planning config
* @returns {object} Capacity plan
*
* @example
* ```js
* const plan = generateCapacityPlan({
* sprints: sprintPlan,
* releases: releasePlan,
* config: { teamSize: 5, velocity: 20, availabilityFactor: 0.8, riskBufferPercent: 20 },
* });
* ```
*/
export function generateCapacityPlan({ sprints, releases, config }) {
const cfg = defaultCapacityConfig(config);
const warnings = [];
// ── 1. Validate inputs ──
// Accept both { sprints: [...] } (array) and { sprints: { sprints: [...] } } (plan object)
const sprintList = (sprints && (Array.isArray(sprints) ? sprints : sprints.sprints)) || [];
const releaseList = (releases && (Array.isArray(releases) ? releases : releases.releases)) || [];
const totalSprints = sprintList.length;
if (totalSprints === 0) {
return {
totalEffort: 0,
totalCapacity: 0,
capacityUtilization: "0.0%",
sprintBySprint: [],
riskAdjustedEffort: 0,
deliveryForecast: { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" },
resourceRecommendations: ["No sprints to plan; provide a sprint plan first"],
warnings: ["No sprints available for capacity planning"],
};
}
// ── 2. Calculate total effort with complexity factors ──
// Each sprint's total story points serve as effort, adjusted by complexity
let totalRawEffort = 0;
const sprintEffortMap = new Map();
for (const sprint of sprintList) {
const rawPoints = sprint.totalStoryPoints || 0;
totalRawEffort += rawPoints;
sprintEffortMap.set(sprint.sprintNumber, rawPoints);
}
// Estimate complexity distribution (in real scenarios, stories would have complexity tags)
// Default heuristic: 20% high, 60% medium, 20% low
const highRatio = 0.2;
const mediumRatio = 0.6;
const lowRatio = 0.2;
const highEffort = totalRawEffort * highRatio * cfg.complexityFactors.high;
const mediumEffort = totalRawEffort * mediumRatio * cfg.complexityFactors.medium;
const lowEffort = totalRawEffort * lowRatio * cfg.complexityFactors.low;
const totalEffort = Math.round(highEffort + mediumEffort + lowEffort);
// ── 3. Calculate total capacity ──
const totalCapacity = Math.round(
cfg.teamSize * cfg.velocity * totalSprints * cfg.availabilityFactor
);
// ── 4. Sprint-by-sprint capacity vs allocation ──
const sprintBySprint = sprintList.map((sprint) => {
const sprintCapacity = Math.round(cfg.velocity * cfg.availabilityFactor);
const allocated = sprintEffortMap.get(sprint.sprintNumber) || 0;
const utilization = sprintCapacity > 0
? ((allocated / sprintCapacity) * 100).toFixed(1) + "%"
: "0.0%";
return {
sprintNumber: sprint.sprintNumber,
sprintGoal: sprint.sprintGoal || `Sprint ${sprint.sprintNumber}`,
capacity: sprintCapacity,
allocated,
utilization,
overCapacity: allocated > sprintCapacity,
};
});
// ── 5. Capacity utilization ──
const capacityUtilization = totalCapacity > 0
? ((totalEffort / totalCapacity) * 100).toFixed(1) + "%"
: "0.0%";
// ── 6. Risk-adjusted effort ──
const riskBuffer = totalEffort * (cfg.riskBufferPercent / 100);
const riskAdjustedEffort = Math.round(totalEffort + riskBuffer);
// ── 7. Delivery forecast ──
const deliveryForecast = computeDeliveryForecast(
sprintList, totalEffort, totalCapacity, cfg, releaseList
);
// ── 8. Resource recommendations ──
const resourceRecommendations = computeResourceRecommendations(
totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints
);
// ── 9. Warnings ──
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
if (utilizationNum > 100) {
warnings.push(`Overall capacity utilization is ${utilizationNum.toFixed(1)}% — team is over capacity`);
}
for (const ss of sprintBySprint) {
if (ss.overCapacity) {
warnings.push(`Sprint ${ss.sprintNumber} is over capacity (${ss.allocated} > ${ss.capacity} pts)`);
}
}
return {
totalEffort,
totalCapacity,
capacityUtilization,
sprintBySprint,
riskAdjustedEffort,
deliveryForecast,
resourceRecommendations,
warnings,
};
}
/**
* Compute optimistic, realistic, and pessimistic delivery dates.
*
* @param {object[]} sprintList — Sprints from sprint plan
* @param {number} totalEffort — Adjusted total effort
* @param {number} totalCapacity — Calculated total capacity
* @param {object} cfg — Capacity config
* @param {object[]} releaseList — Releases from release plan
* @returns {object} { optimistic: string, realistic: string, pessimistic: string }
*/
function computeDeliveryForecast(sprintList, totalEffort, totalCapacity, cfg, releaseList) {
const sprintCount = sprintList.length;
if (sprintCount === 0) {
return { optimistic: "N/A", realistic: "N/A", pessimistic: "N/A" };
}
const weeksPerSprint = 2; // Standard 2-week sprints
// Optimistic: current velocity continues, no blockers
const optimisticWeeks = Math.ceil(
(totalEffort / Math.max(1, cfg.velocity)) * weeksPerSprint
);
// Realistic: with availability factor
const realisticWeeks = Math.ceil(
(totalEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) * weeksPerSprint
);
// Pessimistic: risk-adjusted effort / lower availability
const pessimisticEffort = totalEffort * 1.3; // 30% buffer for worst case
const pessimisticWeeks = Math.ceil(
(pessimisticEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor * 0.85)) * weeksPerSprint
);
// Reference: last major release determines baseline
const lastRelease = releaseList.length > 0
? releaseList[releaseList.length - 1]
: null;
const releaseLabel = lastRelease
? `after ${lastRelease.name} start`
: "from project start";
return {
optimistic: `Week ${optimisticWeeks} ${releaseLabel}`,
realistic: `Week ${realisticWeeks} ${releaseLabel}`,
pessimistic: `Week ${pessimisticWeeks} ${releaseLabel}`,
};
}
/**
* Compute resource recommendations when capacity is exceeded.
*
* @param {number} totalEffort — Adjusted total effort
* @param {number} totalCapacity — Total capacity
* @param {number} riskAdjustedEffort — Effort with risk buffer
* @param {object} cfg — Capacity config
* @param {number} totalSprints — Number of sprints
* @returns {string[]} Recommendations
*/
function computeResourceRecommendations(totalEffort, totalCapacity, riskAdjustedEffort, cfg, totalSprints) {
const recommendations = [];
const utilizationNum = totalCapacity > 0 ? (totalEffort / totalCapacity) * 100 : 0;
if (utilizationNum <= 100) {
recommendations.push("Team capacity is sufficient for planned work");
recommendations.push(`Keep team size at ${cfg.teamSize} with current velocity of ${cfg.velocity} pts/sprint`);
return recommendations;
}
// Over-capacity: suggest fixes
const excessPct = utilizationNum - 100;
const requiredSprintIncrease = Math.ceil(
(riskAdjustedEffort / Math.max(1, cfg.velocity * cfg.availabilityFactor)) - totalSprints
);
const requiredTeamSize = Math.ceil(
riskAdjustedEffort / (totalSprints * cfg.velocity * cfg.availabilityFactor)
);
if (requiredSprintIncrease > 0) {
recommendations.push(
`Extend by ${requiredSprintIncrease} sprint(s) to accommodate ${riskAdjustedEffort} risk-adjusted points`
);
}
if (requiredTeamSize > cfg.teamSize) {
recommendations.push(
`Increase team size from ${cfg.teamSize} to ${requiredTeamSize} to stay within ${totalSprints} sprints`
);
}
if (excessPct > 50) {
recommendations.push(
"Consider reducing scope or deferring non-critical features to later releases"
);
recommendations.push(
`Explore outsourcing ${Math.ceil(totalEffort * 0.2)} story points to external teams`
);
}
recommendations.push(
`Reduce complexity factor application by simplifying high-complexity stories`
);
return recommendations;
}
export default { generateCapacityPlan, defaultCapacityConfig };
@@ -0,0 +1,195 @@
/**
* PR-37 Delivery Planning — Entry Point
*
* Parts F, G, H, I:
* - F: Sprint Planner
* - G: Release Planner
* - H: Batch Planner
* - I: Capacity Planner
*
* Consumes SF-02 Requirement Package output and generates delivery
* planning artifacts. Compatible with PR-36 Factory Registry.
*
* @module pr-37-delivery-planning
* @since PR-37
*/
import { generateSprintPlan, defaultSprintConfig } from "./sprint-planner.mjs";
import { generateReleasePlan } from "./release-planner.mjs";
import { generateBatchPlan } from "./batch-planner.mjs";
import { generateCapacityPlan, defaultCapacityConfig } from "./capacity-planner.mjs";
import { alignRoadmapWithPlans } from "./roadmap-aligner.mjs";
// ═══════════════════════════════════════════════════════
// PR-37 Artifact Constants
// ═══════════════════════════════════════════════════════
export const PR37_ARTIFACTS = Object.freeze({
SPRINT_PLAN: "sprint-plan",
RELEASE_PLAN: "release-plan",
BATCH_PLAN: "batch-plan",
CAPACITY_PLAN: "capacity-plan",
ALIGNMENT_REPORT: "alignment-report",
});
// ═══════════════════════════════════════════════════════
// Factory Integration
// ═══════════════════════════════════════════════════════
/**
* Create a delivery planning factory that consumes SF-02 upstream artifacts
* and produces all 4 delivery plans.
*
* The factory accepts a PR-36 FactoryInput object (or a simplified upstream
* artifacts object) and returns all plans bundled as a FactoryOutput.
*
* @param {object} upstreamArtifacts — SF-02 output containing:
* - epics: object[]
* - features: object[]
* - userStories: object[]
* - dependencies / graph: dependency graph
* - priorities: priority matrix entries
* - mvpScope: MVP scope definition
* @param {object} [config] — Optional config overrides
* @param {object} [config.sprint] — Sprint planner config
* @param {object} [config.capacity] — Capacity planner config
* @returns {object} FactoryOutput with all delivery plans
*
* @example
* ```js
* const factory = createDeliveryPlanningFactory();
* const output = factory(sf02Output, {
* sprint: { teamVelocity: 20, maxStoryPointsPerSprint: 25 },
* capacity: { teamSize: 5, availabilityFactor: 0.8 },
* });
* // output.sprintPlan, output.releasePlan, output.batchPlan, output.capacityPlan
* ```
*/
export function createDeliveryPlanningFactory(upstreamArtifacts, config = {}) {
if (!upstreamArtifacts) {
return {
factoryId: "PR-37",
status: "failed",
error: "No upstream artifacts provided. Provide SF-02 output.",
};
}
const warnings = [];
// ── 1. Extract SF-02 artifacts ──
const stories = upstreamArtifacts.userStories || upstreamArtifacts.stories || [];
const epics = upstreamArtifacts.epics || [];
const features = upstreamArtifacts.features || [];
// Support both { graph: { nodes, edges } } and flat { nodes, edges }
const dependencies = upstreamArtifacts.dependencies
|| upstreamArtifacts.graph
|| { nodes: [], edges: [] };
const priorities = upstreamArtifacts.priorities || [];
const mvpScope = upstreamArtifacts.mvpScope || null;
// ── 2. Validate data ──
if (stories.length === 0) {
warnings.push("No user stories found in upstream artifacts");
}
// ── 3. Generate Sprint Plan (Part F) ──
const sprintConfig = defaultSprintConfig(config.sprint || {});
const sprintPlan = generateSprintPlan({
stories,
dependencies,
priorities,
config: sprintConfig,
});
if (sprintPlan.warnings && sprintPlan.warnings.length > 0) {
warnings.push(...sprintPlan.warnings.map(w => `[Sprint] ${w}`));
}
// ── 4. Generate Release Plan (Part G) ──
const releasePlan = generateReleasePlan({
mvpScope,
priorities,
dependencies,
stories,
epics,
features,
});
if (releasePlan.warnings && releasePlan.warnings.length > 0) {
warnings.push(...releasePlan.warnings.map(w => `[Release] ${w}`));
}
// ── 5. Generate Batch Plan (Part H) ──
const batchPlan = generateBatchPlan({
stories,
dependencies,
priorities,
sprints: sprintPlan,
releases: releasePlan,
});
if (batchPlan.warnings && batchPlan.warnings.length > 0) {
warnings.push(...batchPlan.warnings.map(w => `[Batch] ${w}`));
}
// ── 6. Generate Capacity Plan (Part I) ──
const capacityConfig = defaultCapacityConfig(config.capacity || {});
const capacityPlan = generateCapacityPlan({
sprints: sprintPlan,
releases: releasePlan,
config: capacityConfig,
});
if (capacityPlan.warnings && capacityPlan.warnings.length > 0) {
warnings.push(...capacityPlan.warnings.map(w => `[Capacity] ${w}`));
}
// ── 7. Align with Roadmap (Part J) ──
const roadmap = upstreamArtifacts.roadmap || mvpScope?.phases;
const alignmentReport = roadmap
? alignRoadmapWithPlans({
roadmap: { phases: Array.isArray(roadmap) ? roadmap : (roadmap.phases || []) },
sprintPlan,
releasePlan,
batchPlan,
mvpScope,
})
: null;
// ── 8. Assemble output ──
const output = {
factoryId: "PR-37",
status: warnings.length > 0 && stories.length === 0 ? "blocked" : "passed",
sprintPlan,
releasePlan,
batchPlan,
capacityPlan,
alignmentReport,
warnings: warnings.length > 0 ? warnings : undefined,
metadata: {
factoryId: "PR-37",
upstreamArtifactId: upstreamArtifacts.id || "SF-02",
generatedAt: new Date().toISOString(),
totalStories: stories.length,
totalEpics: epics.length,
totalFeatures: features.length,
totalSprints: sprintPlan.metrics.totalSprints,
totalReleases: releasePlan.releases ? releasePlan.releases.length : 0,
},
};
return output;
}
// ═══════════════════════════════════════════════════════
// Barrel Re-exports
// ═══════════════════════════════════════════════════════
export { generateSprintPlan, defaultSprintConfig } from "./sprint-planner.mjs";
export { generateReleasePlan } from "./release-planner.mjs";
export { generateBatchPlan } from "./batch-planner.mjs";
export { generateCapacityPlan, defaultCapacityConfig } from "./capacity-planner.mjs";
export { alignRoadmapWithPlans } from "./roadmap-aligner.mjs";
@@ -0,0 +1,385 @@
/**
* 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 };
@@ -0,0 +1,375 @@
/**
* PR-37 Part J — Roadmap Alignment Engine
*
* 验证 SF-01 Roadmap 与 PR-37 交付规划(Sprint Plan / Release Plan / Batch Plan)的一致性。
*
* 检查:
* - Phase coverage: Roadmap 的阶段是否被 Release Plan 完整覆盖
* - Story coverage: MVP/V1/V2 的 Story 是否被 Sprint Plan 完整分配
* - Dependency integrity: 关键依赖链是否在 Release 和 Batch 中被正确处理
* - Budget alignment: 每个 Phase 的预算是否与实际工作量(Story Points)匹配
* - Timeline consistency: Sprint Plan 的总周数是否在 Roadmap 的 Phase 时间范围内
*
* @module pr-37-delivery-planning/roadmap-aligner
* @since PR-37
*/
/**
* @typedef {object} RoadmapPhase
* @property {string} name — Phase name (mvp / v1 / v2 / enterprise / global)
* @property {string} scope — Phase description
* @property {number} duration — Duration in months
* @property {number} budget — Budget in currency units
*/
/**
* @typedef {object} AlignmentReport
* @property {string} overallVerdict — "aligned" / "misaligned" / "partial"
* @property {number} alignmentScore — 0-100
* @property {object} phaseCoverage — Per-phase coverage assessment
* @property {object} timelineCheck — Sprint vs Roadmap timeline comparison
* @property {object} budgetCheck — Budget vs Effort comparison
* @property {object} dependencyCheck — Critical dependency path verification
* @property {string[]} warnings — Alignment warnings
* @property {string[]} recommendations — Actionable recommendations
*/
// ══════════════════════════════════════════════════════════
// Main Alignment Function
// ══════════════════════════════════════════════════════════
/**
* Align SF-01 Roadmap with PR-37 delivery plans.
*
* @param {object} opts
* @param {object} opts.roadmap — SF-01 Roadmap (phases array)
* @param {object} opts.sprintPlan — PR-37 Sprint Plan
* @param {object} opts.releasePlan — PR-37 Release Plan
* @param {object} opts.batchPlan — PR-37 Batch Plan
* @param {object} opts.mvpScope — SF-02 MVP Scope
* @returns {AlignmentReport}
*/
export function alignRoadmapWithPlans({ roadmap, sprintPlan, releasePlan, batchPlan, mvpScope }) {
const warnings = [];
const recommendations = [];
let alignmentScore = 100;
// ── 1. Phase Coverage Check ──
const phaseCoverage = checkPhaseCoverage(roadmap, releasePlan, mvpScope);
if (!phaseCoverage.allCovered) {
alignmentScore -= 15;
warnings.push(...phaseCoverage.warnings);
recommendations.push("Ensure all roadmap phases have corresponding releases");
}
// ── 2. Story Coverage Check ──
const storyCoverage = checkStoryCoverage(sprintPlan, mvpScope);
if (storyCoverage.uncoveredCount > 0) {
alignmentScore -= Math.min(25, storyCoverage.uncoveredCount * 2);
warnings.push(`⚠️ ${storyCoverage.uncoveredCount} MVP/V1 stories not assigned to any Sprint`);
recommendations.push(`Assign ${storyCoverage.uncoveredCount} unplanned stories to sprints or backlog`);
}
// ── 3. Timeline Consistency ──
const timelineCheck = checkTimelineConsistency(roadmap, sprintPlan);
if (timelineCheck.misalignment) {
alignmentScore -= 20;
warnings.push(timelineCheck.message);
recommendations.push(timelineCheck.recommendation);
}
// ── 4. Budget Alignment ──
const budgetCheck = checkBudgetAlignment(roadmap, sprintPlan, mvpScope);
if (budgetCheck.misalignment) {
alignmentScore -= 15;
warnings.push(budgetCheck.message);
recommendations.push(budgetCheck.recommendation);
}
// ── 5. Dependency Integrity ──
const dependencyCheck = checkDependencyIntegrity(batchPlan, releasePlan);
if (dependencyCheck.misalignment) {
alignmentScore -= 15;
warnings.push(...dependencyCheck.warnings);
recommendations.push(...dependencyCheck.recommendations);
}
const overallVerdict = alignmentScore >= 90 ? "aligned"
: alignmentScore >= 60 ? "partial"
: "misaligned";
return {
overallVerdict,
alignmentScore: Math.max(0, alignmentScore),
phaseCoverage,
storyCoverage,
timelineCheck,
budgetCheck,
dependencyCheck,
warnings,
recommendations,
summary: generateAlignmentSummary(overallVerdict, alignmentScore, roadmap, sprintPlan),
};
}
// ══════════════════════════════════════════════════════════
// Phase Coverage
// ══════════════════════════════════════════════════════════
function checkPhaseCoverage(roadmap, releasePlan, mvpScope) {
const phases = roadmap?.phases || [];
const releases = releasePlan?.releases || [];
const covered = [];
const uncovered = [];
const warnings = [];
// Map phase names to release versions
const phaseReleaseMap = {
mvp: ["1.0.0"],
v1: ["2.0.0", "1.1.0"],
v2: ["3.0.0"],
enterprise: ["Enterprise Edition", "3.0.0"],
global: ["Global Edition", "4.0.0"],
};
for (const phase of phases) {
const expectedReleases = phaseReleaseMap[phase.name] || [];
const found = releases.filter(r => expectedReleases.includes(r.version));
if (found.length > 0) {
covered.push({ phase: phase.name, releases: found.map(r => r.version) });
} else {
uncovered.push({ phase: phase.name, expected: expectedReleases });
warnings.push(`⛔ Phase "${phase.name}" has no matching release in Release Plan (expected: ${expectedReleases.join(", ")})`);
}
}
// Also check that all releases map to a phase
const allReleaseVersions = releases.map(r => r.version);
const allExpectedVersions = Object.values(phaseReleaseMap).flat();
const extraReleases = allReleaseVersions.filter(v => !allExpectedVersions.includes(v));
if (extraReleases.length > 0) {
warnings.push(`⚠️ Release Plan contains ${extraReleases.length} release(s) without roadmap phase mapping: ${extraReleases.join(", ")}`);
}
return {
allCovered: uncovered.length === 0,
covered,
uncovered,
extraReleases,
warnings,
summary: `${covered.length}/${phases.length} roadmap phases covered by Release Plan`,
};
}
// ══════════════════════════════════════════════════════════
// Story Coverage
// ══════════════════════════════════════════════════════════
function checkStoryCoverage(sprintPlan, mvpScope) {
const allocatedIds = new Set();
for (const sprint of (sprintPlan?.sprints || [])) {
for (const sid of (sprint.storyIds || [])) {
allocatedIds.add(sid);
}
}
const unallocated = new Set();
const unplanned = sprintPlan?.unplannedStories || [];
// MVP + V1 stories should all be allocated
const mvpStoryIds = new Set(
(mvpScope?.mvp?.mustHaveStories || []).map(s => s.id)
);
let uncoveredCount = 0;
for (const sid of mvpStoryIds) {
if (!allocatedIds.has(sid)) {
unallocated.add(sid);
uncoveredCount++;
}
}
for (const sid of unplanned) {
if (!allocatedIds.has(sid)) {
unallocated.add(sid);
uncoveredCount++;
}
}
return {
uncoveredCount,
unallocatedStories: [...unallocated],
totalAllocated: allocatedIds.size,
};
}
// ══════════════════════════════════════════════════════════
// Timeline Consistency
// ══════════════════════════════════════════════════════════
function checkTimelineConsistency(roadmap, sprintPlan) {
const phases = roadmap?.phases || [];
const sprints = sprintPlan?.sprints || [];
const totalSprintWeeks = (sprintPlan?.metrics?.estimatedDurationWeeks) || 0;
// Roadmap total in months → weeks (approximate 4 weeks/month)
const totalRoadmapMonths = phases.reduce((sum, p) => sum + (p.duration || p.durationMonths || 0), 0);
const totalRoadmapWeeks = totalRoadmapMonths * 4;
// MVP phase duration
const mvpPhase = phases.find(p => p.name === "mvp");
const mvpPhaseWeeks = (mvpPhase?.duration || mvpPhase?.durationMonths || 0) * 4;
// MVP sprint weeks
const mvpSprints = sprints.filter(s => {
// Heuristic: first few sprints that cover the MVP points
const running = sprints
.slice(0, sprints.indexOf(s) + 1)
.reduce((sum, sp) => sum + (sp.totalStoryPoints || 0), 0);
const mvpTotal = (sprintPlan?.metrics?.totalPoints || 0) * 0.35; // ~35% of total is MVP
return running <= mvpTotal;
});
const mvpSprintWeeks = mvpSprints.length * 2; // 2 weeks/sprint
const misalignment = mvpSprintWeeks > mvpPhaseWeeks * 1.2 || totalSprintWeeks > totalRoadmapWeeks * 1.2;
const message = misalignment
? `⚠️ Timeline misalignment: Sprint plan (${totalSprintWeeks}w) exceeds roadmap (${totalRoadmapWeeks}w) by ${totalSprintWeeks - totalRoadmapWeeks}w`
: `✅ Timeline aligned: Sprint plan (${totalSprintWeeks}w) fits within roadmap (${totalRoadmapWeeks}w)`;
const recommendation = misalignment
? `Consider reducing Sprint scope by ${Math.round((totalSprintWeeks - totalRoadmapWeeks) / 2)} sprints, or extend roadmap timeline by ${Math.ceil((totalSprintWeeks - totalRoadmapWeeks) / 4)} months`
: "No action needed — timeline is consistent";
return {
misalignment,
message,
recommendation,
sprintWeeks: totalSprintWeeks,
roadmapWeeks: totalRoadmapWeeks,
mvpSprintWeeks,
mvpPhaseWeeks,
};
}
// ══════════════════════════════════════════════════════════
// Budget Alignment
// ══════════════════════════════════════════════════════════
function checkBudgetAlignment(roadmap, sprintPlan, mvpScope) {
const phases = roadmap?.phases || [];
const totalBudget = phases.reduce((sum, p) => sum + (p.budget || 0), 0);
const totalPoints = sprintPlan?.metrics?.totalPoints || 0;
if (totalBudget === 0 || totalPoints === 0) {
return {
misalignment: false,
message: "Insufficient data for budget alignment check",
recommendation: "Ensure roadmap has budget estimates and sprint plan has story points",
costPerPoint: 0,
};
}
const costPerPoint = Math.round(totalBudget / totalPoints);
const mvpBudget = phases.find(p => p.name === "mvp")?.budget || 0;
const mvpPoints = mvpScope?.mvp?.totalStoryPoints || 0;
const mvpCostAtRate = mvpPoints * costPerPoint;
// More than 20% deviation = misalignment
const deviation = mvpBudget > 0 ? Math.abs(mvpCostAtRate - mvpBudget) / mvpBudget : 0;
const misalignment = deviation > 0.2;
const message = misalignment
? `⚠️ Budget misalignment: MVP estimated cost at rate ($${mvpCostAtRate.toLocaleString()}) deviates ${(deviation * 100).toFixed(0)}% from roadmap budget ($${mvpBudget.toLocaleString()})`
: `✅ Budget aligned: MVP cost at $${costPerPoint}/point (${(deviation * 100).toFixed(0)}% deviation)`;
const recommendation = misalignment
? `Review story point estimates or adjust roadmap budget. Current rate: $${costPerPoint}/point, roadmap implies $${Math.round(mvpBudget / Math.max(1, mvpPoints))}/point`
: "No action needed — budget is consistent";
return {
misalignment,
message,
recommendation,
costPerPoint,
mvpCostAtRate,
mvpBudget,
deviation: (deviation * 100).toFixed(1) + "%",
};
}
// ══════════════════════════════════════════════════════════
// Dependency Integrity
// ══════════════════════════════════════════════════════════
function checkDependencyIntegrity(batchPlan, releasePlan) {
const batches = batchPlan?.batches || [];
const releases = releasePlan?.releases || [];
const warnings = [];
const recommendations = [];
// Check: Development batches should feed into testing batches
const devBatches = batches.filter(b => b.batchType === "DevelopmentBatch");
const testBatches = batches.filter(b => b.batchType === "TestingBatch");
const releaseBatches = batches.filter(b => b.batchType === "ReleaseBatch");
const deliveryBatches = batches.filter(b => b.batchType === "DeliveryBatch");
if (devBatches.length > 0 && testBatches.length === 0) {
warnings.push("⚠️ Development batches exist but no TestingBatch defined");
recommendations.push("Add TestingBatch(s) that consume DevelopmentBatch output");
}
if (releaseBatches.length === 0 && releases.length > 0) {
warnings.push("⚠️ Releases defined but no ReleaseBatch for deployment coordination");
recommendations.push("Add ReleaseBatch(s) to map features to deployment stages");
}
// Check: Release dependencies should be satisfied
for (let i = 1; i < releases.length; i++) {
const prevIds = new Set();
for (let j = 0; j < i; j++) {
(releases[j]?.features?.featureNames || []).forEach(n => prevIds.add(n));
}
}
const allBatchTypes = [...new Set(batches.map(b => b.batchType))];
const expectedTypes = ["DevelopmentBatch", "TestingBatch", "ReleaseBatch", "DeliveryBatch"];
const missingTypes = expectedTypes.filter(t => !allBatchTypes.includes(t));
if (missingTypes.length > 0) {
warnings.push(`⚠️ Missing batch types: ${missingTypes.join(", ")}`);
recommendations.push(
"Ensure full batch pipeline: DevelopmentBatch → TestingBatch → ReleaseBatch → DeliveryBatch"
);
}
return {
misalignment: warnings.length > 0,
warnings,
recommendations,
batchTypeCoverage: `${allBatchTypes.length}/${expectedTypes.length}`,
missingTypes,
};
}
// ══════════════════════════════════════════════════════════
// Summary
// ══════════════════════════════════════════════════════════
function generateAlignmentSummary(verdict, score, roadmap, sprintPlan) {
const phaseCount = roadmap?.phases?.length || 0;
const sprintCount = sprintPlan?.sprints?.length || 0;
const totalWeeks = sprintPlan?.metrics?.estimatedDurationWeeks || 0;
return [
`## Roadmap Alignment: ${verdict.toUpperCase()} (${score}/100)`,
"",
`- **Roadmap Phases:** ${phaseCount}`,
`- **Delivery Sprints:** ${sprintCount}`,
`- **Estimated Duration:** ${totalWeeks} weeks`,
verdict === "aligned"
? "✅ All delivery plans are consistent with the product roadmap."
: verdict === "partial"
? "⚠️ Delivery plans partially align with the roadmap — see recommendations."
: "❌ Significant misalignment between delivery plans and roadmap.",
].join("\n");
}
@@ -0,0 +1,473 @@
/**
* PR-37 Delivery Planning — Sprint Planner (Part F)
*
* Consumes SF-02 output (epics, features, userStories, dependency graph,
* priority matrix, MVP scope) and generates a sprint plan.
*
* Algorithm:
* 1. Filter Must Have stories from priority matrix
* 2. Topological sort by dependency graph
* 3. Assign stories to sprints based on capacity + dependencies + priority
* 4. Compute sprint-level critical path and risks
* 5. Aggregate sprint metrics
*
* @module pr-37-delivery-planning/sprint-planner
* @since PR-37
*/
/**
* Default sprint planning configuration.
*
* @param {object} [cfg]
* @param {number} [cfg.sprintDurationWeeks=2] — Length of each sprint in weeks
* @param {number} [cfg.teamVelocity=20] — Team velocity in story points per sprint
* @param {number} [cfg.maxStoryPointsPerSprint=25] — Hard cap on story points per sprint
* @param {number} [cfg.teamSize=5] — Team size (informational)
* @returns {object} Normalized config
*/
export function defaultSprintConfig(cfg = {}) {
return {
sprintDurationWeeks: cfg.sprintDurationWeeks ?? 2,
teamVelocity: cfg.teamVelocity ?? 20,
maxStoryPointsPerSprint: cfg.maxStoryPointsPerSprint ?? 25,
teamSize: cfg.teamSize ?? 5,
};
}
/**
* Build a dependency map { storyId → set of dependency storyIds }.
*
* @param {object[]} stories — User stories with optional .dependencies array
* @param {object} graph — Dependency graph { nodes, edges } from SF-02
* @returns {Map<string, Set<string>>}
*/
export function buildDependencyMap(stories, graph) {
const depMap = new Map();
// Initialize every story with an empty set
for (const s of stories) {
if (!depMap.has(s.id)) depMap.set(s.id, new Set());
}
// Process explicit story .dependencies arrays
for (const s of stories) {
if (s.dependencies && Array.isArray(s.dependencies)) {
const deps = depMap.get(s.id) || new Set();
for (const depId of s.dependencies) {
deps.add(depId);
}
depMap.set(s.id, deps);
}
}
// Process edges from dependency graph (story → story edges)
// In SF-02 edge semantics: edge.from depends on edge.to
// (from = the thing that REQUIRES, to = the PREREQUISITE)
if (graph && graph.edges) {
for (const edge of graph.edges) {
// edge.from depends on edge.to → add edge.to as a dependency of edge.from
const deps = depMap.get(edge.from) || new Set();
deps.add(edge.to);
depMap.set(edge.from, deps);
}
}
return depMap;
}
/**
* Compute dependency levels (L0 = no deps, L1 = 1 level deep, etc.)
* using Kahn's algorithm topological sort but also computing depth.
*
* @param {string[]} storyIds — Stories to sort
* @param {Map<string, Set<string>>} depMap — Dependency map
* @returns {string[]} Topologically sorted story IDs
*/
export function topologicalSort(storyIds, depMap) {
const sorted = [];
const visited = new Set();
const visiting = new Set();
function visit(id) {
if (visited.has(id)) return;
if (visiting.has(id)) {
// Circular dependency detected; resolve by including anyway
return;
}
visiting.add(id);
const deps = depMap.get(id);
if (deps) {
for (const depId of deps) {
visit(depId);
}
}
visiting.delete(id);
visited.add(id);
sorted.push(id);
}
for (const id of storyIds) {
if (!visited.has(id)) visit(id);
}
return sorted;
}
/**
* Compute dependency level (0 = no unresolved upstream deps) for each story.
*
* @param {string[]} sortedIds — Topologically sorted IDs
* @param {Map<string, Set<string>>} depMap
* @returns {Map<string, number>} storyId → dependency level
*/
export function computeDependencyLevels(sortedIds, depMap) {
const levels = new Map();
for (const id of sortedIds) {
const deps = depMap.get(id);
if (!deps || deps.size === 0) {
levels.set(id, 0);
} else {
let maxDepLevel = 0;
for (const depId of deps) {
const dl = levels.get(depId);
if (dl !== undefined && dl + 1 > maxDepLevel) {
maxDepLevel = dl + 1;
}
}
levels.set(id, maxDepLevel);
}
}
return levels;
}
/**
* Find the longest dependency chain within a set of story IDs.
*
* @param {string[]} storyIds — IDs in a sprint
* @param {Map<string, Set<string>>} depMap — Full dependency map
* @param {Map<string, number>} depLevels — Computed dependency levels
* @returns {string[]} Chain of story IDs (longest path found)
*/
export function findCriticalPath(storyIds, depMap, depLevels) {
if (storyIds.length === 0) return [];
// Build reverse map: depId → set of dependents (in this sprint)
const dependents = new Map();
const idSet = new Set(storyIds);
for (const id of storyIds) {
const deps = depMap.get(id);
if (deps) {
for (const depId of deps) {
if (idSet.has(depId)) {
if (!dependents.has(depId)) dependents.set(depId, []);
dependents.get(depId).push(id);
}
}
}
}
// Find root nodes (no deps in this sprint) and do DFS for longest path
const roots = storyIds.filter(id => {
const deps = depMap.get(id);
return !deps || ![...deps].some(d => idSet.has(d));
});
let longestPath = [];
function dfs(currentId, path) {
const newPath = [...path, currentId];
const kids = dependents.get(currentId) || [];
if (kids.length === 0) {
if (newPath.length > longestPath.length) {
longestPath = newPath;
}
return;
}
for (const kid of kids) {
dfs(kid, newPath);
}
}
for (const root of roots) {
dfs(root, []);
}
return longestPath.length > 0 ? longestPath : storyIds.slice(0, 1);
}
/**
* Identify risk stories — those with dependencies on stories outside this sprint
* that aren't yet completed in earlier sprints.
*
* @param {string[]} sprintStoryIds — Stories assigned to this sprint
* @param {Map<string, Set<string>>} depMap — Full dependency map
* @param {Set<string>} completedIds — Stories assigned to earlier sprints
* @returns {string[]} Story IDs with unresolved external dependencies
*/
export function identifyRisks(sprintStoryIds, depMap, completedIds) {
const risks = [];
for (const id of sprintStoryIds) {
const deps = depMap.get(id);
if (deps) {
for (const depId of deps) {
if (!completedIds.has(depId)) {
risks.push(id);
break;
}
}
}
}
return risks;
}
/**
* Generate a full sprint plan from SF-02 output.
*
* @param {object} opts
* @param {object[]} opts.stories — User stories (from SF-02 userStories)
* @param {object} opts.dependencies — Dependency graph { nodes, edges }
* @param {object[]} opts.priorities — Priority matrix entries (from SF-02)
* @param {object} [opts.config] — Sprint planning config
* @returns {object} Sprint plan
*
* @example
* ```js
* const plan = generateSprintPlan({
* stories: myStories,
* dependencies: myGraph,
* priorities: myPriorities,
* config: { sprintDurationWeeks: 2, teamVelocity: 20, maxStoryPointsPerSprint: 25 }
* });
* ```
*/
export function generateSprintPlan({ stories, dependencies, priorities, config }) {
const cfg = defaultSprintConfig(config);
const warnings = [];
const unplannedStories = [];
// ── 1. Input validation ──
if (!stories || stories.length === 0) {
return {
sprints: [],
metrics: { totalSprints: 0, totalPoints: 0, avgPointsPerSprint: 0, estimatedDurationWeeks: 0 },
unplannedStories: [],
warnings: ["No stories provided"],
};
}
const graph = dependencies && dependencies.graph ? dependencies.graph : (dependencies || { nodes: [], edges: [] });
// ── 2. Build story map ──
const storyMap = new Map();
for (const s of stories) storyMap.set(s.id, s);
// ── 3. Filter Must Have stories from priorities ──
const priorityMap = new Map();
for (const p of (priorities || [])) {
if (p.itemType === "story") {
priorityMap.set(p.itemId, p.category);
}
}
const mustHaveIds = new Set();
const shouldHaveIds = new Set();
const couldHaveIds = new Set();
for (const s of stories) {
const cat = priorityMap.get(s.id) || s.priority || "Could Have";
if (cat === "Must Have") mustHaveIds.add(s.id);
else if (cat === "Should Have") shouldHaveIds.add(s.id);
else if (cat === "Could Have") couldHaveIds.add(s.id);
}
// ── 4. Build dependency map ──
const allDepMap = buildDependencyMap(stories, graph);
// ── 5. Topological sort Must Have stories ──
const mustHaveList = [...mustHaveIds];
const sortedMustHave = topologicalSort(mustHaveList, allDepMap);
// Also include Should Have and Could Have for later allocation
const shouldHaveList = [...shouldHaveIds];
const couldHaveList = [...couldHaveIds];
const allSorted = [
...sortedMustHave,
...topologicalSort(shouldHaveList, allDepMap).filter(id => !mustHaveIds.has(id)),
...topologicalSort(couldHaveList, allDepMap).filter(id => !mustHaveIds.has(id) && !shouldHaveIds.has(id)),
];
// ── 6. Compute dependency levels ──
const depLevels = computeDependencyLevels(allSorted, allDepMap);
// ── 7. Assign stories to sprints ──
const sprints = [];
const allocated = new Set();
const allocatedInSprints = new Set();
let sprintNumber = 0;
// Sort all stories by: dependency level (ascending), then priority
const queue = allSorted
.filter(id => storyMap.has(id))
.sort((a, b) => {
// Base sort: lower dep level first, then Must > Should > Could
const levelA = depLevels.get(a) || 0;
const levelB = depLevels.get(b) || 0;
if (levelA !== levelB) return levelA - levelB;
// Priority tiebreaker
const priA = priorityMap.get(a) || "Could Have";
const priB = priorityMap.get(b) || "Could Have";
const priOrder = { "Must Have": 0, "Should Have": 1, "Could Have": 2 };
return (priOrder[priA] ?? 3) - (priOrder[priB] ?? 3);
});
let idx = 0;
while (idx < queue.length) {
sprintNumber++;
const sprintCapacity = Math.min(cfg.maxStoryPointsPerSprint, cfg.teamVelocity);
const sprintStories = [];
let sprintPoints = 0;
while (idx < queue.length && sprintPoints < sprintCapacity) {
const sid = queue[idx];
const story = storyMap.get(sid);
if (!story) { idx++; continue; }
const points = story.storyPoints || 1;
// Check all dependencies are allocated to earlier sprints or this sprint
const deps = allDepMap.get(sid);
const allDepsResolved = !deps || [...deps].every(depId => allocatedInSprints.has(depId) || depId === sid);
// Don't include stories whose deps aren't resolved yet
if (!allDepsResolved) {
idx++;
continue;
}
// Check capacity
if (sprintPoints + points > sprintCapacity) {
// Try next story (could be smaller)
idx++;
continue;
}
sprintStories.push(sid);
sprintPoints += points;
allocated.add(sid);
allocatedInSprints.add(sid);
idx++;
}
if (sprintStories.length === 0) {
// No stories fit in this sprint — break to avoid infinite loop
break;
}
const sprintGoal = buildSprintGoal(sprintStories, stories, sprintNumber);
// Determine epics and features covered
const epicIds = new Set();
const featureIds = new Set();
for (const sid of sprintStories) {
const st = storyMap.get(sid);
if (st) {
if (st.epicId) epicIds.add(st.epicId);
if (st.featureId) featureIds.add(st.featureId);
}
}
// Critical path within this sprint
const criticalPath = findCriticalPath(sprintStories, allDepMap, depLevels);
// Identify risks
const completedIds = new Set();
for (let i = 0; i < sprints.length; i++) {
for (const sid of sprints[i].storyIds) completedIds.add(sid);
}
const risks = identifyRisks(sprintStories, allDepMap, completedIds);
const startWeek = 1 + (sprintNumber - 1) * cfg.sprintDurationWeeks;
const endWeek = startWeek + cfg.sprintDurationWeeks - 1;
sprints.push({
sprintNumber,
sprintGoal,
startDate: `Week ${startWeek}`,
endDate: `Week ${endWeek}`,
storyIds: sprintStories,
totalStoryPoints: sprintPoints,
epicIds: [...epicIds],
featureIds: [...featureIds],
criticalPath,
risks,
});
if (sprintPoints > cfg.teamVelocity) {
warnings.push(`Sprint ${sprintNumber} exceeds team velocity (${sprintPoints} > ${cfg.teamVelocity} pts)`);
}
}
// ── 8. Identify unplanned stories ──
for (const id of allSorted) {
if (!allocated.has(id) && storyMap.has(id)) {
unplannedStories.push(id);
}
}
// Also check for stories not in the sorted queue at all
for (const s of stories) {
if (!allocated.has(s.id) && !unplannedStories.includes(s.id)) {
unplannedStories.push(s.id);
}
}
// ── 9. Calculate metrics ──
const totalPoints = sprints.reduce((acc, sp) => acc + sp.totalStoryPoints, 0);
const totalSprints = sprints.length;
const estimatedDurationWeeks = totalSprints * cfg.sprintDurationWeeks;
return {
sprints,
metrics: {
totalSprints,
totalPoints,
avgPointsPerSprint: totalSprints > 0 ? Math.round(totalPoints / totalSprints) : 0,
estimatedDurationWeeks,
},
unplannedStories,
warnings,
};
}
/**
* Build a human-readable sprint goal from the assigned stories.
*
* @param {string[]} storyIds — Stories in this sprint
* @param {object[]} stories — All stories
* @param {number} sprintNumber — Sprint number
* @returns {string} Sprint goal description
*/
function buildSprintGoal(storyIds, stories, sprintNumber) {
const storyMap = new Map();
for (const s of stories) storyMap.set(s.id, s);
// Extract the most common goal themes
const goals = [];
const seen = new Set();
for (const id of storyIds) {
const s = storyMap.get(id);
if (s && s.goal) {
const shortGoal = s.goal.length > 40 ? s.goal.slice(0, 40) + "…" : s.goal;
if (!seen.has(shortGoal)) {
seen.add(shortGoal);
goals.push(shortGoal);
}
}
}
const topGoals = goals.slice(0, 3);
return `Sprint ${sprintNumber}: ${topGoals.join(" / ")}`;
}
export default { generateSprintPlan, defaultSprintConfig, buildDependencyMap, topologicalSort };
@@ -0,0 +1,830 @@
/**
* PR-37 Domain Intelligence & Delivery Planning — Test Suite
*
* Part M:全覆盖测试
*
* 覆盖:
* A — Domain Registry
* B — Domain Classification
* C — Industry Epic Generation
* D — Industry Feature Generation
* E — Domain Validation
* F — Sprint Planning
* G — Release Planning
* H — Batch Planning
* I — Capacity Planning
* J — Roadmap Alignment
* K — Factory Integration
* Z — Regression (SF-01, SF-02, PR-35, PR-36)
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
// ─── Domain Registry (Part A) ──────────────────────
import {
getDomainRegistry, getDomain, listDomains,
getDomainsByCategory, searchDomains, DOMAINS, DOMAIN_CATEGORY,
} from "../src/pr-37-domain-intelligence/domain-registry.mjs";
// ─── Domain Intelligence (Parts B, C, D, E) ────────
import {
classifyProduct,
} from "../src/pr-37-domain-intelligence/domain-classifier.mjs";
import {
generateIndustryEpics, resetEpicCounter, describeDomain, listDomainEpics,
} from "../src/pr-37-domain-intelligence/industry-epic-generator.mjs";
import {
generateIndustryFeatures, resetFeatureCounter, detectFeatureType,
} from "../src/pr-37-domain-intelligence/industry-feature-generator.mjs";
import {
validateEpics, validateFeatures,
} from "../src/pr-37-domain-intelligence/domain-validator.mjs";
import { createDomainIntelligenceFactory } from "../src/pr-37-domain-intelligence/index.mjs";
// ─── Delivery Planning (Parts F, G, H, I, J) ──────
import {
generateSprintPlan, defaultSprintConfig,
} from "../src/pr-37-delivery-planning/sprint-planner.mjs";
import {
generateReleasePlan,
} from "../src/pr-37-delivery-planning/release-planner.mjs";
import {
generateBatchPlan,
} from "../src/pr-37-delivery-planning/batch-planner.mjs";
import {
generateCapacityPlan, defaultCapacityConfig,
} from "../src/pr-37-delivery-planning/capacity-planner.mjs";
import {
alignRoadmapWithPlans,
} from "../src/pr-37-delivery-planning/roadmap-aligner.mjs";
import {
createDeliveryPlanningFactory, PR37_ARTIFACTS,
} from "../src/pr-37-delivery-planning/index.mjs";
// ─── Regression Suite ──────────────────────────────
import { createFactoryRegistry } from "../src/software-factory-core/factory-registry.mjs";
import { createFactoryInput } from "../src/software-factory-core/factory-contract.mjs";
import { registerSF01 } from "../src/sf-01-product-strategy/index.mjs";
import { registerSF02 } from "../src/sf-02-requirement-engineering/index.mjs";
// ═════════════════════════════════════════════════════
// Test Fixtures
// ═════════════════════════════════════════════════════
function mockStories(count = 30) {
return Array.from({ length: count }, (_, i) => ({
id: `us-${i + 1}`,
statement: `As a user, I want feature ${i + 1}, so that I can work better.`,
role: "用户",
goal: `feature ${i + 1}`,
value: "better work",
priority: i < 10 ? "Must Have" : i < 20 ? "Should Have" : "Could Have",
storyPoints: i < 10 ? 3 : i < 20 ? 2 : 1,
featureId: `ft-${Math.floor(i / 5) + 1}`,
epicId: `ep-${Math.floor(i / 8) + 1}`,
}));
}
function mockEpics(count = 4) {
return Array.from({ length: count }, (_, i) => ({
id: `ep-${i + 1}`,
title: ["财务管理", "采购管理", "库存管理", "销售管理"][i] || `Epic ${i + 1}`,
description: `Description for epic ${i + 1}`,
category: "管理",
objective: `Objective ${i + 1}`,
}));
}
function mockFeatures(count = 6) {
return Array.from({ length: count }, (_, i) => ({
id: `ft-${i + 1}`,
title: `Feature ${i + 1}`,
description: `Description for feature ${i + 1}`,
epicId: `ep-${Math.floor(i / 2) + 1}`,
featureType: "CRUD",
}));
}
function mockMVPScope() {
return {
mvp: { label: "MVP", epics: 2, features: 2, userStories: 10, totalStoryPoints: 30 },
v1: { label: "V1", epics: 4, features: 4, userStories: 20, totalStoryPoints: 50 },
v2: { label: "V2", epics: 4, features: 6, userStories: 30, totalStoryPoints: 60 },
enterprise: { label: "Enterprise", epics: 4, features: 6, userStories: 30 },
global: { label: "Global", epics: 4, features: 6, userStories: 30 },
phases: [
{ name: "mvp", scope: "最小可行产品", duration: 3, budget: 300000 },
{ name: "v1", scope: "第一完整版本", duration: 4, budget: 500000 },
{ name: "v2", scope: "功能增强版本", duration: 3, budget: 300000 },
],
};
}
function mockPriors(stories) {
return stories.map(s => ({
itemId: s.id, itemType: "story", category: s.priority,
score: s.priority === "Must Have" ? 100 : s.priority === "Should Have" ? 70 : 40,
}));
}
// ═════════════════════════════════════════════════════
// A — Domain Registry
// ═════════════════════════════════════════════════════
describe("PR-37 Part A — Domain Registry", () => {
it("registers exactly 16 domains", () => {
const domains = listDomains();
assert.equal(domains.length, 16);
});
it("each domain has all 10 knowledge dimensions", () => {
for (const name of listDomains()) {
const d = getDomain(name);
assert.ok(d.name);
assert.ok(d.displayName);
assert.ok(d.description);
assert.ok(d.coreProcesses.length >= 3, `${name}: coreProcesses`);
assert.ok(d.coreEntities.length >= 3, `${name}: coreEntities`);
assert.ok(d.coreEpics.length >= 5, `${name}: coreEpics`);
assert.ok(Object.keys(d.coreFeatures).length >= 3, `${name}: coreFeatures`);
assert.ok(d.coreKPIs.length >= 3, `${name}: coreKPIs`);
assert.ok(d.coreRoles.length >= 3, `${name}: coreRoles`);
assert.ok(d.terminology.length >= 2, `${name}: terminology`);
assert.ok(d.constraints.length >= 1, `${name}: constraints`);
}
});
it("ERP domain has 10 core epics", () => {
const erp = getDomain("ERP");
assert.equal(erp.coreEpics.length, 10);
const titles = erp.coreEpics.map(e => e.title);
assert.ok(titles.includes("财务管理"));
assert.ok(titles.includes("采购管理"));
assert.ok(titles.includes("库存管理"));
assert.ok(titles.includes("生产管理"));
});
it("MES domain has 10 core epics", () => {
const mes = getDomain("MES");
assert.equal(mes.coreEpics.length, 10);
const titles = mes.coreEpics.map(e => e.title);
assert.ok(titles.includes("生产执行"));
assert.ok(titles.includes("质量管理"));
assert.ok(titles.includes("设备管理"));
assert.ok(titles.includes("工单管理"));
});
it("CRM domain has 9 core epics", () => {
const crm = getDomain("CRM");
assert.ok(crm.coreEpics.length >= 7);
const titles = crm.coreEpics.map(e => e.title);
assert.ok(titles.includes("客户管理"));
assert.ok(titles.includes("商机管理"));
assert.ok(titles.includes("销售漏斗"));
});
it("AI Platform domain has 8 core epics", () => {
const ai = getDomain("AI Platform");
assert.ok(ai.coreEpics.length >= 6);
const titles = ai.coreEpics.map(e => e.title);
assert.ok(titles.includes("知识库管理"));
assert.ok(titles.includes("Agent管理"));
});
it("getDomain returns null for unknown domain", () => {
assert.equal(getDomain("UnknownDomain"), null);
});
it("searchDomains finds by keyword", () => {
const results = searchDomains("MES");
assert.ok(results.length >= 1);
assert.equal(results[0].name, "MES");
});
it("getDomainsByCategory filters correctly", () => {
const enterprise = getDomainsByCategory(DOMAIN_CATEGORY.ENTERPRISE_SOFTWARE);
assert.ok(enterprise.length >= 3);
});
});
// ═════════════════════════════════════════════════════
// B — Domain Classification
// ═════════════════════════════════════════════════════
describe("PR-37 Part B — Domain Classification", () => {
it("classifies ERP system correctly", () => {
const result = classifyProduct({ productName: "ERP系统", description: "企业资源计划" });
assert.equal(result.primaryDomain, "ERP");
assert.ok(result.confidenceScore >= 0.3);
assert.ok(result.matchedKeywords.length >= 1);
});
it("classifies MES system correctly", () => {
const result = classifyProduct({ productName: "工业MES系统", description: "制造执行系统" });
assert.equal(result.primaryDomain, "MES");
assert.ok(result.confidenceScore >= 0.5);
});
it("classifies AI customer service platform", () => {
const result = classifyProduct({ productName: "AI客服平台", description: "智能客服系统" });
assert.equal(result.primaryDomain, "AI Platform");
});
it("classifies CRM system", () => {
const result = classifyProduct({ productName: "CRM系统", description: "客户关系管理" });
assert.equal(result.primaryDomain, "CRM");
});
it("classifies cross-border ecommerce with secondary", () => {
const result = classifyProduct({ productName: "跨境电商平台", description: "跨境电商" });
assert.equal(result.primaryDomain, "E-Commerce");
assert.ok(result.secondaryDomain);
});
it("classifies industrial AI quality inspection", () => {
const result = classifyProduct({ productName: "工业AI质检", description: "智能制造AI质检" });
assert.equal(result.primaryDomain, "MES");
});
it("classifies WMS warehouse", () => {
const result = classifyProduct({ productName: "WMS仓库", description: "仓储管理" });
assert.equal(result.primaryDomain, "WMS");
});
it("returns null for empty input", () => {
const result = classifyProduct({ productName: "", description: "" });
assert.equal(result.primaryDomain, null);
assert.equal(result.confidenceScore, 0);
assert.ok(result.classificationReason);
});
it("returns null for null input", () => {
const result = classifyProduct(null);
assert.equal(result.primaryDomain, null);
assert.equal(result.confidenceScore, 0);
});
});
// ═════════════════════════════════════════════════════
// C — Industry Epic Generation
// ═════════════════════════════════════════════════════
describe("PR-37 Part C — Industry Epic Generation", () => {
it("generates ERP-specific epics with domain vocabulary", () => {
resetEpicCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
assert.equal(epics.length, 10);
const titles = epics.map(e => e.title);
assert.ok(titles.includes("财务管理"));
assert.ok(titles.includes("采购管理"));
assert.ok(titles.includes("生产管理"));
// Should NOT have generic "内容管理"
assert.ok(!titles.includes("内容管理"));
});
it("generates MES-specific epics", () => {
resetEpicCounter();
const epics = generateIndustryEpics("MES", { productName: "工业MES系统" });
assert.equal(epics.length, 10);
const titles = epics.map(e => e.title);
assert.ok(titles.includes("质量管理"));
assert.ok(titles.includes("设备管理"));
assert.ok(titles.includes("追溯管理"));
});
it("generates AI Platform epics", () => {
resetEpicCounter();
const epics = generateIndustryEpics("AI Platform", { productName: "AI客服平台" });
assert.ok(epics.length >= 6);
const titles = epics.map(e => e.title);
assert.ok(titles.includes("知识库管理"));
assert.ok(titles.includes("Agent管理"));
});
it("each epic has all required fields", () => {
resetEpicCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
for (const epic of epics) {
assert.ok(epic.id.startsWith("ep-"));
assert.ok(epic.title);
assert.ok(epic.description);
assert.ok(epic.category);
assert.ok(epic.objective);
assert.ok(epic.successMetric);
}
});
it("falls back to generic template for unknown domain", () => {
resetEpicCounter();
const epics = generateIndustryEpics("量子计算", { productName: "量子计算机" });
assert.ok(epics.length >= 5);
// Generic epics shouldn't have ERP-specific ones
const titles = epics.map(e => e.title);
assert.ok(!titles.includes("财务管理"));
});
});
// ═════════════════════════════════════════════════════
// D — Industry Feature Generation
// ═════════════════════════════════════════════════════
describe("PR-37 Part D — Industry Feature Generation", () => {
it("generates ERP features from domain registry", () => {
resetEpicCounter(); resetFeatureCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
const features = generateIndustryFeatures("ERP", epics);
assert.ok(features.length >= 50);
const titles = features.map(f => f.title);
assert.ok(titles.includes("总账管理"));
assert.ok(titles.includes("供应商管理"));
});
it("detects feature types correctly", () => {
assert.equal(detectFeatureType("数据分析"), "analytics");
assert.equal(detectFeatureType("审批流程"), "workflow");
assert.equal(detectFeatureType("统计分析"), "analytics");
assert.equal(detectFeatureType("消息推送"), "notification");
assert.equal(detectFeatureType("搜索"), "search");
assert.equal(detectFeatureType("系统设置"), "configuration");
assert.equal(detectFeatureType("用户管理"), "CRUD");
});
it("each feature references correct epicId", () => {
resetEpicCounter(); resetFeatureCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
const features = generateIndustryFeatures("ERP", epics);
const epicIds = new Set(epics.map(e => e.id));
for (const f of features) {
assert.ok(epicIds.has(f.epicId), `Feature ${f.title} epicId ${f.epicId} not in epics`);
}
});
it("each feature has id with ft- prefix", () => {
resetEpicCounter(); resetFeatureCounter();
const epics = generateIndustryEpics("MES", { productName: "MES系统" });
const features = generateIndustryFeatures("MES", epics);
for (const f of features) {
assert.ok(f.id.startsWith("ft-"));
}
});
});
// ═════════════════════════════════════════════════════
// E — Domain Validation
// ═════════════════════════════════════════════════════
describe("PR-37 Part E — Domain Validation", () => {
it("validates ERP epics with 100% coverage", () => {
resetEpicCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
const result = validateEpics(epics, "ERP");
assert.equal(result.overallVerdict, "pass");
assert.equal(result.coverageScore, 100);
assert.equal(result.completenessScore, 100);
});
it("validates MES epics with 100% coverage", () => {
resetEpicCounter();
const epics = generateIndustryEpics("MES", { productName: "MES系统" });
const result = validateEpics(epics, "MES");
assert.equal(result.overallVerdict, "pass");
assert.equal(result.coverageScore, 100);
});
it("detects generic epics as low coverage for ERP", () => {
const genericEpics = [
{ title: "用户管理" }, { title: "内容管理" }, { title: "业务流程" },
];
const result = validateEpics(genericEpics, "ERP");
assert.ok(result.coverageScore < 100);
assert.ok(result.missingEpics.length > 0);
assert.equal(result.overallVerdict, "fail");
});
it("validates features consistency", () => {
resetEpicCounter(); resetFeatureCounter();
const epics = generateIndustryEpics("ERP", { productName: "ERP系统" });
const features = generateIndustryFeatures("ERP", epics);
const result = validateFeatures(features, "ERP");
assert.ok(result.consistencyScore >= 70);
});
it("handles unknown domain gracefully", () => {
const result = validateEpics([{ title: "Test" }], "UnknownDomain");
assert.equal(result.coverageScore, 0);
assert.equal(result.overallVerdict, "fail");
});
});
// ═════════════════════════════════════════════════════
// F — Sprint Planning
// ═════════════════════════════════════════════════════
describe("PR-37 Part F — Sprint Planning", () => {
it("generates sprints from Must Have stories", () => {
const stories = mockStories(30);
const priors = mockPriors(stories);
const result = generateSprintPlan({
stories, dependencies: [], priorities: priors,
config: defaultSprintConfig({ teamVelocity: 20 }),
});
assert.ok(result.sprints.length >= 1, `Expected >=1 sprints, got ${result.sprints.length}`);
assert.ok(result.metrics.totalSprints >= 1);
});
it("respects sprint capacity", () => {
const stories = mockStories(30);
const priors = mockPriors(stories);
const result = generateSprintPlan({
stories, dependencies: [], priorities: priors,
config: defaultSprintConfig({ teamVelocity: 15, maxStoryPointsPerSprint: 15 }),
});
for (const sprint of result.sprints) {
assert.ok(sprint.totalStoryPoints <= 15 + 5, // allow small overflow
`Sprint ${sprint.sprintNumber}: ${sprint.totalStoryPoints} pts > 20`);
}
});
it("each sprint has required fields", () => {
const result = generateSprintPlan({
stories: mockStories(20), dependencies: [], priorities: mockPriors(mockStories(20)),
});
for (const s of result.sprints) {
assert.ok(s.sprintNumber);
assert.ok(s.sprintGoal);
assert.ok(s.startDate);
assert.ok(s.endDate);
assert.ok(Array.isArray(s.storyIds));
assert.ok(s.totalStoryPoints >= 0);
assert.ok(Array.isArray(s.risks));
}
});
it("handles single story", () => {
const stories = [{ id: "us-1", statement: "test", priority: "Must Have", storyPoints: 3, featureId: "ft-1", epicId: "ep-1" }];
const priors = [{ itemId: "us-1", itemType: "story", category: "Must Have", score: 100 }];
const result = generateSprintPlan({ stories, dependencies: [], priorities: priors });
assert.equal(result.sprints.length, 1);
assert.equal(result.sprints[0].storyIds.length, 1);
});
it("handles empty stories", () => {
const result = generateSprintPlan({ stories: [], dependencies: [], priorities: [] });
assert.equal(result.sprints.length, 0);
assert.equal(result.metrics.totalSprints, 0);
});
it("respects dependency order", () => {
const stories = [
{ id: "us-1", statement: "base", priority: "Must Have", storyPoints: 5, featureId: "ft-1", epicId: "ep-1" },
{ id: "us-2", statement: "dependent", priority: "Must Have", storyPoints: 5, featureId: "ft-1", epicId: "ep-1" },
{ id: "us-3", statement: "filler", priority: "Should Have", storyPoints: 3, featureId: "ft-1", epicId: "ep-1" },
];
const priors = [
{ itemId: "us-1", itemType: "story", category: "Must Have", score: 100 },
{ itemId: "us-2", itemType: "story", category: "Must Have", score: 95 },
{ itemId: "us-3", itemType: "story", category: "Should Have", score: 70 },
];
// us-2 depends on us-1 (us-2 → us-1 means us-2 requires us-1 first)
const deps = { edges: [{ from: "us-2", to: "us-1", critical: true }] };
const result = generateSprintPlan({ stories, dependencies: deps, priorities: priors, config: defaultSprintConfig({ teamVelocity: 8 }) });
// Both us-1 and us-2 should be in the sprint plan
const allIds = result.sprints.flatMap(s => s.storyIds);
assert.ok(allIds.includes("us-1"), "us-1 should be planned");
assert.ok(allIds.includes("us-2"), "us-2 should be planned");
// us-2 must not be assigned before us-1 (dependency constraint)
const s1 = result.sprints.find(s => s.storyIds.includes("us-1"));
const s2 = result.sprints.find(s => s.storyIds.includes("us-2"));
assert.ok(s1.sprintNumber <= s2.sprintNumber,
`us-1 (Sprint ${s1.sprintNumber}) must not be after us-2 (Sprint ${s2.sprintNumber})`);
});
});
// ═════════════════════════════════════════════════════
// G — Release Planning
// ═════════════════════════════════════════════════════
describe("PR-37 Part G — Release Planning", () => {
it("generates 5 releases from mvpScope", () => {
const result = generateReleasePlan({
mvpScope: mockMVPScope(),
priorities: mockPriors(mockStories(30)),
dependencies: [],
stories: mockStories(30),
epics: mockEpics(4),
features: mockFeatures(6),
});
assert.equal(result.releases.length, 5);
const versions = result.releases.map(r => r.version);
assert.ok(versions.includes("1.0.0"));
assert.ok(versions.includes("2.0.0"));
});
it("R1.0 (MVP) contains Must Have stories", () => {
const stories = mockStories(30);
const result = generateReleasePlan({
mvpScope: mockMVPScope(),
priorities: mockPriors(stories),
dependencies: [],
stories,
epics: mockEpics(4),
features: mockFeatures(6),
});
const r1 = result.releases[0];
assert.ok(r1.features.storyCount > 0);
assert.ok(r1.validationGates.length >= 3);
});
it("each release has validation gates and goals", () => {
const result = generateReleasePlan({
mvpScope: mockMVPScope(),
priorities: mockPriors(mockStories(30)),
dependencies: [],
stories: mockStories(30),
epics: mockEpics(4),
features: mockFeatures(6),
});
for (const release of result.releases) {
assert.ok(release.version);
assert.ok(release.name);
assert.ok(Array.isArray(release.validationGates));
assert.ok(release.releaseGoals);
}
});
it("handles null mvpScope gracefully", () => {
const result = generateReleasePlan({
mvpScope: null,
priorities: [],
dependencies: [],
stories: [],
epics: [],
features: [],
});
assert.ok(result.releases);
});
});
// ═════════════════════════════════════════════════════
// H — Batch Planning
// ═════════════════════════════════════════════════════
describe("PR-37 Part H — Batch Planning", () => {
it("generates batches with dependency levels", () => {
const stories = [
{ id: "us-1", statement: "no deps", priority: "Must Have", storyPoints: 3, featureId: "ft-1", epicId: "ep-1" },
{ id: "us-2", statement: "dep on 1", priority: "Must Have", storyPoints: 2, featureId: "ft-1", epicId: "ep-1" },
{ id: "us-3", statement: "dep on 2", priority: "Should Have", storyPoints: 1, featureId: "ft-1", epicId: "ep-1" },
];
const deps = {
edges: [
{ from: "us-2", to: "us-1", critical: true },
{ from: "us-3", to: "us-2", critical: true },
],
};
const result = generateBatchPlan({
stories,
dependencies: deps,
priorities: mockPriors(stories),
sprints: { sprints: [] },
releases: { releases: [] },
});
assert.ok(result.batches.length >= 1);
// Should have at least a DevelopmentBatch with dependency levels
const devBatch = result.batches.find(b => b.batchType === "DevelopmentBatch");
assert.ok(devBatch);
});
it("generates all 4 batch types when data is sufficient", () => {
const stories = mockStories(20);
const sp = generateSprintPlan({ stories, dependencies: [], priorities: mockPriors(stories) });
const rp = generateReleasePlan({
mvpScope: mockMVPScope(), priorities: mockPriors(stories),
dependencies: [], stories, epics: mockEpics(4), features: mockFeatures(6),
});
const result = generateBatchPlan({
stories, dependencies: [], priorities: mockPriors(stories),
sprints: sp, releases: rp.releases,
});
assert.ok(result.batches.length >= 1);
const types = new Set(result.batches.map(b => b.batchType));
assert.ok(types.has("DevelopmentBatch"));
});
});
// ═════════════════════════════════════════════════════
// I — Capacity Planning
// ═════════════════════════════════════════════════════
describe("PR-37 Part I — Capacity Planning", () => {
it("calculates capacity from sprint plan", () => {
const stories = mockStories(20);
const sp = generateSprintPlan({ stories, dependencies: [], priorities: mockPriors(stories) });
const cp = generateCapacityPlan({ sprints: sp.sprints, releases: [] });
assert.ok(cp.totalEffort > 0);
assert.ok(cp.totalCapacity > 0);
assert.ok(cp.capacityUtilization.includes("%"));
});
it("delivery forecast provides realistic date", () => {
const stories = mockStories(20);
const sp = generateSprintPlan({ stories, dependencies: [], priorities: mockPriors(stories) });
const cp = generateCapacityPlan({ sprints: sp.sprints, releases: [] });
assert.ok(cp.deliveryForecast.realistic);
assert.notEqual(cp.deliveryForecast.realistic, "N/A");
});
it("handles empty sprint list gracefully", () => {
const cp = generateCapacityPlan({ sprints: [], releases: [] });
assert.equal(cp.totalEffort, 0);
});
});
// ═════════════════════════════════════════════════════
// J — Roadmap Alignment
// ═════════════════════════════════════════════════════
describe("PR-37 Part J — Roadmap Alignment", () => {
it("aligns roadmap with sprint and release plans", () => {
const stories = mockStories(30);
const sp = generateSprintPlan({ stories, dependencies: [], priorities: mockPriors(stories) });
const rp = generateReleasePlan({
mvpScope: mockMVPScope(), priorities: mockPriors(stories),
dependencies: [], stories, epics: mockEpics(4), features: mockFeatures(6),
});
const bp = generateBatchPlan({ stories, dependencies: [], priorities: mockPriors(stories), sprints: sp.sprints, releases: rp.releases });
const result = alignRoadmapWithPlans({
roadmap: mockMVPScope(),
sprintPlan: sp,
releasePlan: rp,
batchPlan: bp,
mvpScope: mockMVPScope(),
});
assert.ok(["aligned", "partial", "misaligned"].includes(result.overallVerdict));
assert.ok(result.alignmentScore >= 0 && result.alignmentScore <= 100);
assert.ok(result.summary);
});
});
// ═════════════════════════════════════════════════════
// K — Factory Integration
// ═════════════════════════════════════════════════════
describe("PR-37 Part K — Factory Integration", () => {
it("createDomainIntelligenceFactory produces epics + features + validation", () => {
const result = createDomainIntelligenceFactory({ productName: "ERP系统" });
assert.equal(result.classification.primaryDomain, "ERP");
assert.ok(result.epics.length >= 8);
assert.ok(result.features.length >= 50);
assert.ok(result.validation);
assert.equal(result.validation.overallVerdict, "pass");
});
it("createDomainIntelligenceFactory accepts domainOverride", () => {
const result = createDomainIntelligenceFactory(
{ productName: "神秘系统" },
{ domainOverride: "MES" }
);
assert.equal(result.classification.primaryDomain, "MES");
assert.equal(result.classification.confidenceScore, 1.0);
});
it("createDeliveryPlanningFactory produces 4 plans", () => {
const stories = mockStories(30);
const sf02Data = {
userStories: stories,
epics: mockEpics(4),
features: mockFeatures(6),
dependencies: { nodes: [], edges: [] },
priorities: mockPriors(stories),
mvpScope: mockMVPScope(),
roadmap: mockMVPScope().phases,
};
const result = createDeliveryPlanningFactory(sf02Data);
assert.equal(result.status, "passed");
assert.ok(result.sprintPlan.sprints.length >= 1);
assert.ok(result.releasePlan.releases.length >= 1);
assert.ok(result.batchPlan.batches.length >= 1);
assert.ok(result.capacityPlan.totalEffort > 0);
assert.ok(result.alignmentReport);
assert.ok(["aligned", "partial", "misaligned"].includes(result.alignmentReport.overallVerdict));
});
it("createDeliveryPlanningFactory fails gracefully with no data", () => {
const result = createDeliveryPlanningFactory(null);
assert.equal(result.status, "failed");
assert.ok(result.error);
});
it("PR37_ARTIFACTS has all artifact types", () => {
const types = Object.values(PR37_ARTIFACTS);
assert.ok(types.includes("sprint-plan"));
assert.ok(types.includes("release-plan"));
assert.ok(types.includes("batch-plan"));
assert.ok(types.includes("capacity-plan"));
assert.ok(types.includes("alignment-report"));
});
});
// ═════════════════════════════════════════════════════
// Z — Regression: SF-01, SF-02, PR-35, PR-36
// ═════════════════════════════════════════════════════
describe("PR-37 Part Z — Regression", () => {
it("SF-01 factory still works after PR-37", () => {
const registry = createFactoryRegistry();
registerSF01(registry);
const input = createFactoryInput({ factoryId: "SF-01", parameters: { idea: "AI客服平台" } });
const output = registry.execute("SF-01", input);
assert.equal(output.status, "passed");
});
it("SF-02 factory still works after PR-37", () => {
const registry = createFactoryRegistry();
registerSF01(registry);
registerSF02(registry);
const sf01Out = registry.execute("SF-01", createFactoryInput({ factoryId: "SF-01", parameters: { idea: "AI客服平台" } }));
const sf02Out = registry.execute("SF-02", createFactoryInput({ factoryId: "SF-02", upstreamArtifacts: sf01Out.artifacts }));
assert.equal(sf02Out.status, "passed");
assert.ok(sf02Out.artifacts.length >= 10);
});
it("SF-01 → SF-02 → PR-37 full pipeline", () => {
const registry = createFactoryRegistry();
registerSF01(registry);
registerSF02(registry);
const sf01Out = registry.execute("SF-01", createFactoryInput({ factoryId: "SF-01", parameters: { idea: "ERP系统" } }));
const sf02Out = registry.execute("SF-02", createFactoryInput({ factoryId: "SF-02", upstreamArtifacts: sf01Out.artifacts }));
// Extract SF-02 data
const stories = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "user-story-catalog").content);
const epics = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "epic-catalog").content);
const features = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "feature-catalog").content);
const depGraph = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "dependency-graph").content);
const priMatrix = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "priority-matrix").content);
const mvpScope = JSON.parse(sf02Out.artifacts.find(a => a.metadata?.artifactType === "mvp-scope").content);
// Build pri entries from priMatrix
const priEntries = [];
const catMap = { mustHave: "Must Have", shouldHave: "Should Have", couldHave: "Could Have", wontHave: "Won't Have" };
if (priMatrix?.story) {
for (const [cat, ids] of Object.entries(priMatrix.story)) {
if (cat === "summary") continue;
for (const id of ids) priEntries.push({ itemId: id, itemType: "story", category: catMap[cat] || cat, score: cat === "mustHave" ? 100 : cat === "shouldHave" ? 70 : 40 });
}
}
// PR-37 Domain Intelligence
const diResult = createDomainIntelligenceFactory({ productName: "ERP系统" });
assert.equal(diResult.classification.primaryDomain, "ERP");
assert.ok(diResult.epics.length >= 8);
assert.equal(diResult.validation.overallVerdict, "pass");
// PR-37 Delivery Planning
const dpResult = createDeliveryPlanningFactory({
userStories: stories, epics, features,
dependencies: depGraph,
priorities: priEntries,
mvpScope,
roadmap: mvpScope.phases,
});
assert.equal(dpResult.status, "passed");
assert.ok(dpResult.sprintPlan.sprints.length >= 1);
assert.ok(dpResult.releasePlan.releases.length >= 1);
assert.ok(dpResult.alignmentReport);
});
it("PR-35/PR-36 factory registry topological order preserved", () => {
const registry = createFactoryRegistry();
registerSF01(registry);
registerSF02(registry);
const order = registry.getTopologicalOrder();
const sf01Idx = order.indexOf("SF-01");
const sf02Idx = order.indexOf("SF-02");
assert.ok(sf01Idx < sf02Idx, "SF-01 must come before SF-02 in topological order");
});
});
@@ -0,0 +1,456 @@
/**
* PR-37 Part B — Domain Classifier
*
* Classifies product strategy descriptions into primary/secondary domains
* by matching keywords against the domain registry.
*
* @module pr-37-domain-intelligence/domain-classifier
* @since PR-37
*/
import {
getDomain,
getDomainRegistry,
searchDomains,
} from "./domain-registry.mjs";
// ══════════════════════════════════════════════════════════
// Constants
// ══════════════════════════════════════════════════════════
/** Score bonus when the domain name appears directly in input. */
const NAME_MATCH_BONUS = 10;
/** Score per keyword matched. */
const KEYWORD_SCORE = 3;
/** Score per alias matched. */
const ALIAS_SCORE = 5;
/** Score per core-process phrase matched. */
const PROCESS_SCORE = 4;
/**
* Known cross-domain edges for specific Chinese phrases.
*
* When these phrases appear in input, they trigger additional domain-specific
* boosts so that commonly-expected secondary domains surface correctly even
* when no direct keyword match exists.
*
* @type {object<string, Array<{domain: string, boost: number, reason: string}>>}
*/
const CROSS_MATCH_MAP = Object.freeze({
// 跨境电商 → E-Commerce primary, SCM secondary
"跨境电商": [
{ domain: "E-Commerce", boost: 20, reason: "Cross-border e-commerce" },
{ domain: "SCM", boost: 18, reason: "Cross-border logistics / supply chain" },
],
"跨境": [
{ domain: "E-Commerce", boost: 12, reason: "Cross-border trade" },
{ domain: "SCM", boost: 14, reason: "Cross-border supply chain" },
],
// 工业AI质检 → MES primary, AI Platform secondary
"工业ai质检": [
{ domain: "MES", boost: 20, reason: "Industrial AI QA on factory floor" },
{ domain: "AI Platform", boost: 14, reason: "AI/ML inspection component" },
],
"工业ai": [
{ domain: "MES", boost: 12, reason: "Industrial AI / smart manufacturing" },
{ domain: "AI Platform", boost: 8, reason: "AI capability" },
],
"智能质检": [
{ domain: "MES", boost: 18, reason: "Smart QA on factory floor" },
{ domain: "AI Platform", boost: 12, reason: "AI-powered inspection" },
],
"ai质检": [
{ domain: "MES", boost: 12, reason: "AI quality inspection on production line" },
{ domain: "AI Platform", boost: 10, reason: "AI vision / ML detection" },
],
});
/**
* Terms that are too generic (e.g. "平台", "系统", "管理") and should be
* excluded from keyword scoring to avoid inflating scores for unrelated domains.
*
* @type {Set<string>}
*/
const STOPWORDS = new Set([
"平台", "系统", "管理", "服务", "引擎", "引擎", "平台",
"在线", "智能", "数字", "自动",
]);
/**
* Category affinity matrix (lower is closer).
* 1 = same category, 2 = related, 3 = unrelated (penalty).
*/
const CATEGORY_AFFINITY = Object.freeze({
"enterprise-software": { "enterprise-software": 1, industrial: 2, commerce: 2, platform: 2, intelligence: 2, engineering: 3, management: 2 },
industrial: { "enterprise-software": 2, industrial: 1, commerce: 3, platform: 3, intelligence: 2, engineering: 2, management: 3 },
commerce: { "enterprise-software": 2, industrial: 3, commerce: 1, platform: 2, intelligence: 2, engineering: 3, management: 2 },
platform: { "enterprise-software": 2, industrial: 3, commerce: 2, platform: 1, intelligence: 2, engineering: 3, management: 2 },
intelligence: { "enterprise-software": 2, industrial: 2, commerce: 2, platform: 2, intelligence: 1, engineering: 3, management: 2 },
engineering: { "enterprise-software": 3, industrial: 2, commerce: 3, platform: 3, intelligence: 3, engineering: 1, management: 3 },
management: { "enterprise-software": 2, industrial: 3, commerce: 2, platform: 2, intelligence: 2, engineering: 3, management: 1 },
});
// ══════════════════════════════════════════════════════════
// Internal helpers
// ══════════════════════════════════════════════════════════
/**
* Tokenize a string into lowercase keyword tokens.
*
* @param {string} text - Input text
* @returns {{ tokens: string[], rawLower: string }} Tokenization result
*/
function tokenize(text) {
if (!text || typeof text !== "string") return { tokens: [], rawLower: "" };
const rawLower = text.toLowerCase();
// Split on whitespace, punctuation
const rawTokens = rawLower.split(/[\s,,。、;:;!?!?()()【】\[\]{}"''":·/\\\-–—]+/).filter(Boolean);
const result = new Set(rawTokens);
// Extract CJK bigrams for Chinese compound matching
for (const tok of rawTokens) {
if (/[\u4e00-\u9fff]/.test(tok) && tok.length >= 2) {
result.add(tok);
// Generate overlapping 2-char windows for compounds like "跨境电商"
for (let i = 0; i <= tok.length - 2; i++) {
const bigram = tok.substring(i, i + 2);
// Only add meaningful CJK bigrams, not common stopwords
if (!STOPWORDS.has(bigram)) result.add(bigram);
}
}
}
return { tokens: [...result], rawLower };
}
/**
* Check if a term is a stopword (too generic to match on).
*
* @param {string} term - The term to check
* @returns {boolean}
*/
function isStopword(term) {
const t = term.replace(/^(keyword:|alias:|domain:|process:)/, "").trim();
return STOPWORDS.has(t);
}
/**
* Score a single domain against tokenized input.
*
* @param {object} domain - Domain definition object
* @param {string[]} tokens - Tokenized input tokens
* @param {string} rawLower - Original input text (lowered)
* @returns {{ score: number, matchedKeywords: string[] }}
*/
function scoreDomain(domain, tokens, rawLower) {
const matched = new Set();
/** Check if a term exists in the raw text (case-insensitive). */
const matchIfFound = (term) => {
const t = typeof term === "string" ? term.toLowerCase() : "";
if (!t || isStopword(t)) return false;
if (rawLower.includes(t)) return true;
// Partial CJK match (e.g., keyword "电商" matches token "跨境电商")
if (/[\u4e00-\u9fff]/.test(t)) {
return tokens.some((tok) => tok.includes(t) || t.includes(tok));
}
// English token check (e.g., "SCM")
const normTok = t.replace(/[\s-]/g, "");
return tokens.some((tok) => tok === normTok || tok.includes(normTok) || normTok.includes(tok));
};
// 1. Name / displayName direct match
if (rawLower.includes(domain.name.toLowerCase())) {
matched.add(`domain:${domain.name}`);
}
// 2. Aliases
for (const alias of domain.aliases) {
if (matchIfFound(alias)) matched.add(`alias:${alias}`);
}
// 3. Keywords
for (const kw of domain.keywords) {
if (matchIfFound(kw)) matched.add(`keyword:${kw}`);
}
// 4. Core process phrases
for (const proc of domain.coreProcesses) {
const procPhrase = proc.replace(/\(.*?\)/g, "").trim().toLowerCase();
if (rawLower.includes(procPhrase)) {
matched.add(`process:${procPhrase.substring(0, 20)}`);
} else {
for (const tok of tokens) {
if (tok.length >= 2 && procPhrase.includes(tok)) {
matched.add(`process:${procPhrase.substring(0, 20)}`);
break;
}
}
}
}
// Compute score
let score = 0;
for (const m of matched) {
if (isStopword(m)) continue;
if (m.startsWith("domain:")) score += NAME_MATCH_BONUS;
else if (m.startsWith("alias:")) score += ALIAS_SCORE;
else if (m.startsWith("keyword:")) score += KEYWORD_SCORE;
else if (m.startsWith("process:")) score += PROCESS_SCORE;
}
return { score, matchedKeywords: [...matched].filter((m) => !isStopword(m)) };
}
/**
* Apply cross-match map boosts for well-known phrases.
* Mutates scored array in-place.
*
* @param {Array<{name: string, score: number, matchedKeywords: string[], category: string}>} scored
* @param {string} rawLower - Raw lowered input
*/
function applyCrossMatchBoosts(scored, rawLower) {
const shortened = rawLower.replace(/[\s-]/g, "");
for (const [phrase, crossRefs] of Object.entries(CROSS_MATCH_MAP)) {
const phraseNorm = phrase.toLowerCase().replace(/[\s-]/g, "");
if (shortened.includes(phraseNorm)) {
for (const ref of crossRefs) {
const existing = scored.find((s) => s.name === ref.domain);
if (existing) {
existing.score += ref.boost;
existing.matchedKeywords.push(`cross-ref:${ref.reason}`);
} else {
// Auto-create entry for this domain if it doesn't exist yet
scored.push({
name: ref.domain,
score: ref.boost,
matchedKeywords: [`cross-ref:${ref.reason}`],
category: "cross-reference",
});
}
}
}
}
}
// ══════════════════════════════════════════════════════════
// Public API
// ══════════════════════════════════════════════════════════
/**
* Classify a product strategy into primary and secondary domains.
*
* @param {object|string} productInput - Product strategy package containing
* `productName` / `description`, or a plain product name string
* @returns {object} Classification result
* @returns {string|null} .primaryDomain - Best-fit domain name
* @returns {string|null} .secondaryDomain - Runner-up domain name (or null)
* @returns {number} .confidenceScore - 0-1 confidence value
* @returns {string[]} .matchedKeywords - All matched keyword references
* @returns {string} .classificationReason - Human-readable explanation
*
* @example
* classifyProduct("跨境电商平台")
* // => { primaryDomain: "E-Commerce", secondaryDomain: "SCM", confidenceScore: 0.92, ... }
*
* @example
* classifyProduct({ productName: "工业AI质检系统", description: "基于机器学习的AOI" })
* // => { primaryDomain: "MES", secondaryDomain: "AI Platform", ... }
*/
export function classifyProduct(productInput) {
// Normalize input
if (!productInput) {
return {
primaryDomain: null,
secondaryDomain: null,
confidenceScore: 0,
matchedKeywords: [],
classificationReason: "Empty input — no classification possible",
};
}
const productName =
typeof productInput === "string"
? productInput
: productInput.productName || "";
const description =
typeof productInput === "string"
? productInput
: productInput.description || productInput.productDescription || "";
const rawText = `${productName} ${description}`.trim();
if (!rawText) {
return {
primaryDomain: null,
secondaryDomain: null,
confidenceScore: 0,
matchedKeywords: [],
classificationReason: "No product name or description provided",
};
}
const { tokens, rawLower } = tokenize(rawText);
const registry = getDomainRegistry();
const scored = [];
for (const [name, domain] of Object.entries(registry)) {
const { score, matchedKeywords } = scoreDomain(domain, tokens, rawLower);
if (score > 0) {
scored.push({ name, score, matchedKeywords, category: domain.category });
}
}
// Apply cross-match boosts for well-known edge cases
applyCrossMatchBoosts(scored, rawLower);
// Sort by score descending
scored.sort((a, b) => b.score - a.score);
if (scored.length === 0) {
return {
primaryDomain: null,
secondaryDomain: null,
confidenceScore: 0,
matchedKeywords: [],
classificationReason:
"No domain matched the provided product description",
};
}
const primary = scored[0];
const secondaryCandidate = scored[1];
// Calculate confidence: normalize to 0-1
const maxObservedScore = scored[0].score;
const maxExpectedScore = 60;
const rawConfidence = Math.min(maxObservedScore / maxExpectedScore, 1.0);
// If there's a clear gap between #1 and #2, boost confidence
let confidence = rawConfidence;
if (
scored.length >= 2 &&
primary.score > secondaryCandidate.score * 2
) {
confidence = Math.max(confidence, 0.75);
}
confidence = Math.round(Math.min(Math.max(confidence, 0), 1) * 100) / 100;
// Secondary domain: only if score is positive and not the same as primary
const secondary =
secondaryCandidate && secondaryCandidate.score > 0
? secondaryCandidate.name
: null;
// Build matched keywords (all unique)
const allMatched = [...new Set(scored.flatMap((s) => s.matchedKeywords))];
// Build reason
const reason = [
`Primary: ${primary.name} (score=${primary.score})`,
secondary
? `Secondary: ${secondary} (score=${secondaryCandidate.score})`
: null,
`Confidence: ${confidence}`,
`Matched: ${
allMatched.length > 0 ? allMatched.slice(0, 8).join(", ") : "none"
}`,
...(allMatched.length > 8 ? [`... and ${allMatched.length - 8} more`] : []),
]
.filter(Boolean)
.join(" | ");
return {
primaryDomain: primary.name,
secondaryDomain: secondary,
confidenceScore: confidence,
matchedKeywords: allMatched,
classificationReason: reason,
};
}
/**
* Classify and return only the domain name (convenience shortcut).
*
* @param {object|string} productInput - Product strategy input
* @returns {string|null} Primary domain name or null
*/
export function classifyDomain(productInput) {
return classifyProduct(productInput).primaryDomain;
}
/**
* List all supported domain names.
*
* @returns {string[]}
*/
export function listSupportedDomains() {
return Object.keys(getDomainRegistry());
}
// ══════════════════════════════════════════════════════════
// Fuzzy classifier (lightweight n-gram matching)
// ══════════════════════════════════════════════════════════
/**
* Soft-match a phrase against domain descriptions using n-gram overlap.
*
* @param {string} text - Input text
* @param {number} [threshold=0.15] - Minimum match ratio (lower = more permissive)
* @returns {object[]} Sorted domain matches with relevance score
*/
export function fuzzyClassify(text, threshold = 0.15) {
if (!text || typeof text !== "string" || !text.trim()) return [];
const { tokens, rawLower } = tokenize(text.trim());
if (tokens.length === 0) return [];
const tokensSet = new Set(tokens);
const registry = getDomainRegistry();
const results = [];
for (const [name, domain] of Object.entries(registry)) {
const combinedText = [
domain.name,
domain.displayName,
...domain.keywords,
...domain.aliases,
domain.description,
]
.join(" ")
.toLowerCase();
const domainTokens = new Set(
combinedText
.split(/[\s,,。、;:;!?!?()()【】\[\]{}"''":·/\\\-–—]+/)
.filter(Boolean)
);
// Compute overlap: how many input tokens partially match domain tokens
let overlap = 0;
for (const tok of tokens) {
if (tok.length < 2) continue;
for (const dt of domainTokens) {
const dtNorm = dt.replace(/[\s-]/g, "");
const tokNorm = tok.replace(/[\s-]/g, "");
if (dtNorm.includes(tokNorm) || tokNorm.includes(dtNorm)) {
overlap++;
break;
}
}
}
const union = new Set([...tokensSet, ...domainTokens]);
const ratio = union.size > 0 ? overlap / union.size : 0;
if (ratio >= threshold && overlap > 0) {
results.push({ domain: name, relevance: Math.round(ratio * 100) / 100 });
}
}
return results.sort((a, b) => b.relevance - a.relevance);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,290 @@
/**
* 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")
),
};
}
@@ -0,0 +1,179 @@
/**
* PR-37 Domain Intelligence — Barrel Export
*
* Re-exports all modules (Parts BE) and provides the `createDomainIntelligenceFactory`
* factory function for pipeline integration.
*
* @module pr-37-domain-intelligence
* @since PR-37
*/
// ══════════════════════════════════════════════════════════
// Imports for factory function
// ══════════════════════════════════════════════════════════
import { classifyProduct } from "./domain-classifier.mjs";
import { generateIndustryEpics } from "./industry-epic-generator.mjs";
import { generateIndustryFeatures } from "./industry-feature-generator.mjs";
import { validateRequirements } from "./domain-validator.mjs";
// ══════════════════════════════════════════════════════════
// Re-exports from Part B — Domain Classifier
// ══════════════════════════════════════════════════════════
export {
classifyProduct,
classifyDomain,
listSupportedDomains,
fuzzyClassify,
} from "./domain-classifier.mjs";
// ══════════════════════════════════════════════════════════
// Re-exports from Part C — Industry Epic Generator
// ══════════════════════════════════════════════════════════
export {
generateIndustryEpics,
resetEpicCounter as resetEpicIdCounter,
describeDomain,
listDomainEpics,
} from "./industry-epic-generator.mjs";
// ══════════════════════════════════════════════════════════
// Re-exports from Part D — Industry Feature Generator
// ══════════════════════════════════════════════════════════
export {
generateIndustryFeatures,
resetFeatureCounter as resetFeatureIdCounter,
detectFeatureType,
FEATURE_TYPE,
} from "./industry-feature-generator.mjs";
// ══════════════════════════════════════════════════════════
// Re-exports from Part E — Domain Validator
// ══════════════════════════════════════════════════════════
export {
validateRequirements,
validateEpics,
validateFeatures,
} from "./domain-validator.mjs";
// ══════════════════════════════════════════════════════════
// Factory Function
// ══════════════════════════════════════════════════════════
/**
* Factory output shape.
*
* @typedef {object} FactoryOutput
* @property {object} classification - Result from classifyProduct
* @property {object[]} epics - Generated epics
* @property {object[]} features - Generated features
* @property {object} validation - Validation report
* @property {string[]} warnings - Any warnings encountered
*/
/**
* Create a domain intelligence factory that runs the full pipeline:
* classification → epic generation → feature generation → validation.
*
* The returned object is compatible with the FactoryOutput contract used
* by PLM pipeline orchestration.
*
* @param {object|string} strategyPackage - Product strategy (object or string)
* @param {object} [options={}] - Additional options
* @param {string} [options.domainOverride] - Skip classification and use this domain explicitly
* @param {boolean} [options.includeValidation=true] - Whether to run validation
* @returns {FactoryOutput} Complete domain intelligence output
*
* @example
* const factory = createDomainIntelligenceFactory({ productName: "智能仓储WMS", description: "自动化仓库" });
* console.log(factory.classification.primaryDomain); // "WMS"
* console.log(factory.epics.length); // 7
* console.log(factory.validation.overallVerdict); // "pass" | "conditional" | "fail"
*
* @example
* // With explicit domain override
* const factory = createDomainIntelligenceFactory({ productName: "My Product" }, { domainOverride: "ERP" });
*/
export function createDomainIntelligenceFactory(strategyPackage, options = {}) {
const {
domainOverride = null,
includeValidation = true,
} = options || {};
const warnings = [];
// ── 1. Classification ───────────────────────────────
let classification;
let domainName;
if (domainOverride) {
domainName = domainOverride;
classification = {
primaryDomain: domainName,
secondaryDomain: null,
confidenceScore: 1.0,
matchedKeywords: [],
classificationReason: `Domain manually overridden to "${domainName}"`,
};
} else {
classification = classifyProduct(strategyPackage);
domainName = classification.primaryDomain;
if (!domainName) {
return {
classification,
epics: [],
features: [],
validation: null,
warnings: [
"Could not classify product into any known domain. Provide a domainOverride or more specific product description.",
],
};
}
if (classification.confidenceScore < 0.4) {
warnings.push(
`Low confidence (${classification.confidenceScore}) for domain "${domainName}". Consider manual review or domainOverride.`
);
}
}
// ── 2. Epic Generation ───────────────────────────────
const epics = generateIndustryEpics(domainName, strategyPackage);
if (epics.length === 0) {
warnings.push(
`No epics generated for domain "${domainName}".`
);
}
// ── 3. Feature Generation ────────────────────────────
const features = generateIndustryFeatures(domainName, epics);
if (features.length === 0) {
warnings.push(
`No features generated for domain "${domainName}".`
);
}
// ── 4. Validation ────────────────────────────────────
let validation = null;
if (includeValidation) {
validation = validateRequirements(
{ epics, features, userStories: [] },
domainName
);
}
return {
classification,
epics,
features,
validation,
warnings,
};
}
@@ -0,0 +1,265 @@
/**
* PR-37 Part C — Industry Epic Generator
*
* Generates domain-specific epics for a given strategy package.
* Replaces the generic epic generation in SF-02's strategy-to-requirement.mjs
* with domain-aware templates from the registry.
*
* @module pr-37-domain-intelligence/industry-epic-generator
* @since PR-37
*/
import { getDomain, listDomains } from "./domain-registry.mjs";
// ══════════════════════════════════════════════════════════
// Internal: ID counter
// ══════════════════════════════════════════════════════════
let _epicCounter = 0;
/**
* Reset the internal epic ID counter (useful in tests).
*/
export function resetEpicCounter() {
_epicCounter = 0;
}
/**
* Generate the next sequential epic ID.
*
* @returns {string} ID like "ep-001"
*/
function nextEpicId() {
_epicCounter++;
return `ep-${String(_epicCounter).padStart(3, "0")}`;
}
// ══════════════════════════════════════════════════════════
// Generic fallback template
// ══════════════════════════════════════════════════════════
/**
* Default generic epics used when no domain is found or for unknown domains.
*
* @type {object[]}
*/
const GENERIC_EPIC_TEMPLATES = Object.freeze([
{
title: "系统管理",
category: "系统管理",
objective: "基础系统配置与用户管理",
successMetric: "系统可配置率 ≥ 90%",
},
{
title: "数据管理",
category: "数据管理",
objective: "核心数据维护与管理",
successMetric: "数据完整率 ≥ 95%",
},
{
title: "用户管理",
category: "用户管理",
objective: "用户身份与权限管控",
successMetric: "用户管理效率提升 50%",
},
{
title: "流程管理",
category: "流程管理",
objective: "核心业务流程数字化",
successMetric: "流程处理时间缩短 40%",
},
{
title: "报表分析",
category: "报表分析",
objective: "多维度数据分析与报表",
successMetric: "报表生成时间 < 30s",
},
{
title: "安全管理",
category: "安全管理",
objective: "数据安全与合规管控",
successMetric: "安全事件为零",
},
{
title: "接口集成",
category: "接口集成",
objective: "系统间数据集成与同步",
successMetric: "集成成功率 ≥ 99.5%",
},
{
title: "运维管理",
category: "运维管理",
objective: "系统运维与监控",
successMetric: "系统可用性 ≥ 99.9%",
},
]);
// ══════════════════════════════════════════════════════════
// Public API
// ══════════════════════════════════════════════════════════
/**
* Generate industry-specific epics from a strategy package for a given domain.
*
* @param {string} domainName - Target domain (e.g., "ERP", "MES", "CRM")
* @param {object|string} strategyPackage - Strategy context; can be:
* - An object with `productName`, `description`, and/or `goals`
* - A plain string used as product name
* @returns {object[]} Array of epic objects, each with:
* - id (string): "ep-xxx"
* - title (string): Epic title in Chinese
* - description (string): Context-enriched description
* - category (string): Category grouping
* - objective (string): What this epic aims to achieve
* - successMetric (string): Measurable success criterion
*
* @example
* generateIndustryEpics("MES", { productName: "智能车间MES", description: "半导体封测", goals: ["良率提升"] })
* // => [ { id: "ep-001", title: "生产执行", ... }, ... ]
*/
export function generateIndustryEpics(domainName, strategyPackage) {
// Normalize strategyPackage
const strategyName =
typeof strategyPackage === "string"
? strategyPackage
: strategyPackage?.productName || "";
const strategyDesc =
typeof strategyPackage === "string"
? strategyPackage
: strategyPackage?.description || "";
const strategyGoals =
typeof strategyPackage === "object" && !Array.isArray(strategyPackage)
? strategyPackage.goals || []
: [];
const domain = getDomain(domainName);
if (!domain) {
// Fall back to generic template
return generateGenericEpics(domainName, strategyName, strategyDesc, strategyGoals);
}
// Use domain's coreEpics as templates
return domain.coreEpics.map((template) => {
const enriched = enrichEpic(template, domain, strategyName, strategyDesc, strategyGoals);
return {
id: nextEpicId(),
...enriched,
};
});
}
// ══════════════════════════════════════════════════════════
// Internal helpers
// ══════════════════════════════════════════════════════════
/**
* Enrich a template epic with strategy context.
*
* @param {object} template - Core epic template { title, category, objective, successMetric }
* @param {object} domain - Domain definition
* @param {string} strategyName - Product strategy name
* @param {string} strategyDesc - Strategy description
* @param {string[]} strategyGoals - Strategy goals
* @returns {object} Enriched epic
*/
function enrichEpic(template, domain, strategyName, strategyDesc, strategyGoals) {
const title = template.title;
const category = template.category;
// Build a contextual description
const contextParts = [
`基于 ${domain.displayName} (${domain.name}) 领域`,
strategyName ? `为 "${strategyName}" 产品` : "",
`实现 ${template.objective}`,
];
// Add strategy goals context if applicable
if (strategyGoals.length > 0) {
contextParts.push(`目标: ${strategyGoals.join("、")}`);
}
// Add any relevant process context
const relatedProcesses = domain.coreProcesses.filter((p) =>
p.toLowerCase().includes(title.slice(0, 4).toLowerCase())
);
if (relatedProcesses.length > 0) {
contextParts.push(`相关流程: ${relatedProcesses.join("; ")}`);
}
const description = contextParts.filter(Boolean).join("。") + "。";
return {
title,
description,
category,
objective: template.objective,
successMetric: strategyGoals.length > 0 ? enrichMetric(template.successMetric, strategyGoals) : template.successMetric,
};
}
/**
* Optionally enrich a success metric with strategy goals.
*
* @param {string} metric - Original success metric
* @param {string[]} goals - Strategy goals
* @returns {string}
*/
function enrichMetric(metric, goals) {
const extraGoals = goals
.filter((g) => !metric.toLowerCase().includes(g.toLowerCase()))
.slice(0, 2);
if (extraGoals.length === 0) return metric;
return `${metric},同时支撑 ${extraGoals.join("、")}`;
}
/**
* Generate generic epics for unknown domains.
*
* @param {string} domainName - Original domain name (may be null/unknown)
* @param {string} strategyName - Product strategy name
* @param {string} strategyDesc - Strategy description
* @param {string[]} strategyGoals - Strategy goals
* @returns {object[]}
*/
function generateGenericEpics(domainName, strategyName, strategyDesc, strategyGoals) {
const domainLabel = domainName || "通用系统";
const prefix = strategyName ? `${strategyName} ` : "";
return GENERIC_EPIC_TEMPLATES.map((template) => ({
id: nextEpicId(),
title: template.title,
description: `${prefix}${domainLabel}领域 — ${template.objective}${
strategyDesc ? `背景: ${strategyDesc}` : ""
}${strategyGoals.length > 0 ? `目标: ${strategyGoals.join("、")}` : ""}`,
category: template.category,
objective: template.objective,
successMetric: template.successMetric,
}));
}
/**
* Describe a domain briefly (utility function).
*
* @param {string} domainName - Domain name
* @returns {string} Brief domain description
*/
export function describeDomain(domainName) {
const domain = getDomain(domainName);
if (!domain) return `${domainName || "Unknown"}: domain not found in registry`;
return `${domain.displayName} (${domain.name}): ${domain.description}`;
}
/**
* List epic titles available for a given domain.
*
* @param {string} domainName - Domain name
* @returns {string[]} Epic title array, or generic titles if domain unknown
*/
export function listDomainEpics(domainName) {
const domain = getDomain(domainName);
if (!domain) return GENERIC_EPIC_TEMPLATES.map((e) => e.title);
return domain.coreEpics.map((e) => e.title);
}
@@ -0,0 +1,263 @@
/**
* PR-37 Part D — Industry Feature Generator
*
* Generates domain-specific Feature objects for each epic, using the
* domain registry's `coreFeatures` as a template. Falls back to generic
* feature generation for unknown domains or epics without registered features.
*
* @module pr-37-domain-intelligence/industry-feature-generator
* @since PR-37
*/
import { getDomain } from "./domain-registry.mjs";
// ══════════════════════════════════════════════════════════
// Internal: ID counter
// ══════════════════════════════════════════════════════════
let _featureCounter = 0;
/**
* Reset the internal feature ID counter (useful in tests).
*/
export function resetFeatureCounter() {
_featureCounter = 0;
}
/**
* Generate the next sequential feature ID.
*
* @returns {string} ID like "ft-001"
*/
function nextFeatureId() {
_featureCounter++;
return `ft-${String(_featureCounter).padStart(3, "0")}`;
}
// ══════════════════════════════════════════════════════════
// Feature type auto-detection
// ══════════════════════════════════════════════════════════
/**
* Feature type constants.
*
* @readonly
* @enum {string}
*/
export const FEATURE_TYPE = Object.freeze({
CRUD: "CRUD",
WORKFLOW: "workflow",
ANALYTICS: "analytics",
CONFIGURATION: "configuration",
INTEGRATION: "integration",
SEARCH: "search",
NOTIFICATION: "notification",
});
/**
* Keywords that hint at each feature type.
*
* @type {object<string, RegExp[]>}
*/
const TYPE_PATTERNS = Object.freeze({
[FEATURE_TYPE.ANALYTICS]: [/分析/i, /报表/i, /看板/i, /统计/i, /趋势/i, /洞察/i, /仪表盘/i, /report/i, /dashboard/i, /analysis/i],
[FEATURE_TYPE.CONFIGURATION]: [/配置/i, /参数/i, /设置/i, /规则/i, /策略/i, /模板/i, /setting/i, /config/i],
[FEATURE_TYPE.INTEGRATION]: [/集成/i, /接口/i, /对接/i, /同步/i, /API/i, /导入/i, /导出/i, /import/i, /export/i, /webhook/i],
[FEATURE_TYPE.SEARCH]: [/搜索/i, /检索/i, /查询/i, /查找/i, /search/i, /query/i],
[FEATURE_TYPE.NOTIFICATION]: [/通知/i, /消息/i, /告警/i, /提醒/i, /预警/i, /notification/i, /alert/i, /remind/i],
[FEATURE_TYPE.WORKFLOW]: [/流程/i, /审批/i, /审核/i, /流转/i, /工单/i, /workflow/i, /approval/i, /process/i],
[FEATURE_TYPE.CRUD]: [/管理/i, /维护/i, /档案/i, /台账/i, /登记/i, /录入/i, /编辑/i, /创建/i],
});
/**
* Auto-detect the feature type based on the feature title/description.
*
* @param {string} title - Feature title
* @param {string} [description=""] - Feature description
* @returns {string} Detected feature type
*/
export function detectFeatureType(title, description = "") {
const text = `${title} ${description}`;
// Check patterns in priority order (CRUD last as fallback)
const ordered = [
FEATURE_TYPE.ANALYTICS,
FEATURE_TYPE.CONFIGURATION,
FEATURE_TYPE.INTEGRATION,
FEATURE_TYPE.SEARCH,
FEATURE_TYPE.NOTIFICATION,
FEATURE_TYPE.WORKFLOW,
FEATURE_TYPE.CRUD,
];
for (const type of ordered) {
const patterns = TYPE_PATTERNS[type];
for (const pattern of patterns) {
if (pattern.test(text)) return type;
}
}
// Default
return FEATURE_TYPE.CRUD;
}
// ══════════════════════════════════════════════════════════
// Public API
// ══════════════════════════════════════════════════════════
/**
* Generate features for a set of epics within a given domain.
*
* @param {string} domainName - Target domain (e.g., "ERP", "MES", "CRM")
* @param {object[]} epics - Array of epic objects (each must have at least `title`)
* @returns {object[]} Array of feature objects, each with:
* - id (string): "ft-xxx"
* - title (string): Feature title
* - description (string): Feature description
* - epicId (string): ID of the parent epic
* - featureType (string): Auto-detected feature type
*
* @example
* const epics = generateIndustryEpics("WMS", "智能仓储");
* const features = generateIndustryFeatures("WMS", epics);
* // => [ { id: "ft-001", title: "ASN收货", description: "...", epicId: "ep-001", featureType: "workflow" }, ... ]
*/
export function generateIndustryFeatures(domainName, epics) {
if (!domainName || !epics || !Array.isArray(epics) || epics.length === 0) {
return [];
}
const domain = getDomain(domainName);
const domainFeatures = domain?.coreFeatures || {};
const allFeatures = [];
for (const epic of epics) {
const { id: epicId, title: epicTitle } = epic;
// Look up domain-specific features for this epic title
const featureNames = domainFeatures[epicTitle];
if (featureNames && Array.isArray(featureNames) && featureNames.length > 0) {
// Domain-specific features
for (const name of featureNames) {
allFeatures.push({
id: nextFeatureId(),
title: name,
description: buildFeatureDescription(name, epicTitle, domain),
epicId,
featureType: detectFeatureType(name, epicTitle),
});
}
} else {
// Fallback: generate generic features for this epic
const generic = generateGenericFeaturesForEpic(epicTitle, epicId, domain);
allFeatures.push(...generic);
}
}
return allFeatures;
}
// ══════════════════════════════════════════════════════════
// Internal helpers
// ══════════════════════════════════════════════════════════
/**
* Build a meaningful description for a domain-specific feature.
*
* @param {string} featureName - Feature name
* @param {string} epicTitle - Parent epic title
* @param {object|null} domain - Domain definition (may be null)
* @returns {string}
*/
function buildFeatureDescription(featureName, epicTitle, domain) {
const domainLabel = domain
? `${domain.displayName} (${domain.name})`
: "系统";
return `${domainLabel}${epicTitle} 模块下的「${featureName}」功能,支持相关业务操作与数据管理。`;
}
/**
* Generate generic features for an epic when no domain-specific features exist.
*
* @param {string} epicTitle - Epic title
* @param {string} epicId - Epic ID
* @param {object|null} domain - Domain definition (may be null)
* @returns {object[]}
*/
function generateGenericFeaturesForEpic(epicTitle, epicId, domain) {
const domainLabel = domain
? `${domain.displayName} (${domain.name})`
: "系统";
// Build generic CRUD + basic features
const genericNames = detectGenericFeatures(epicTitle);
return genericNames.map((name) => {
const type = detectFeatureType(name, epicTitle);
return {
id: nextFeatureId(),
title: name,
description: `${domainLabel}${epicTitle} 模块提供「${name}」功能,支持相关业务数据的维护与管理。`,
epicId,
featureType: type,
};
});
}
/**
* Detect sensible generic feature names for an epic based on its title.
*
* @param {string} epicTitle - Epic title
* @returns {string[]}
*/
function detectGenericFeatures(epicTitle) {
const titleLower = epicTitle.toLowerCase();
const featureMap = {
// Match based on common patterns
default: [
`${epicTitle}数据维护`,
`${epicTitle}配置管理`,
`${epicTitle}报表`,
],
};
// Add analytics for most epics
const hasAnalytics = /分析|报表|统计|看板|dashboard|report/i.test(titleLower);
if (hasAnalytics) {
return [
`${epicTitle}配置`,
`${epicTitle}监控`,
`${epicTitle}趋势分析`,
`${epicTitle}导出`,
];
}
return featureMap.default;
}
/**
* Add domain-specific features for an epic by keyword matching.
* This is a fallback enrichment when coreFeatures doesn't have a direct entry
* but the epic title matches domain terminology.
*
* @param {string} title - Epic title
* @param {object} domain - Domain definition
* @returns {string[]|null} Matched feature names or null
*/
export function matchDomainFeaturesByKeyword(title, domain) {
if (!domain || !domain.coreFeatures) return null;
// Try partial title matching against registered epic titles
const matchedEpicKey = Object.keys(domain.coreFeatures).find((key) =>
title.toLowerCase().includes(key.toLowerCase()) ||
key.toLowerCase().includes(title.toLowerCase())
);
if (matchedEpicKey) {
return domain.coreFeatures[matchedEpicKey];
}
return null;
}