🎉 init: 小龙的工作空间
This commit is contained in:
@@ -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)
|
||||
- 自动计算 metrics(totalSprints, 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 个 suites,0 个失败:
|
||||
|
||||
| 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 B–E) 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;
|
||||
}
|
||||
Reference in New Issue
Block a user