cabloy 5.1.149 → 5.1.151
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.cabloy-version +1 -1
- package/.claude/commands/release.md +12 -1
- package/.claude/hooks/contract-loop-gate.ts +35 -17
- package/.claude/skills/cabloy-backend-scaffold/SKILL.md +4 -3
- package/.claude/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +3 -2
- package/.claude/skills/cabloy-contract-loop/SKILL.md +4 -3
- package/.claude/skills/cabloy-domain-planning/SKILL.md +4 -3
- package/.claude/skills/cabloy-frontend-scaffold/SKILL.md +5 -3
- package/.claude/skills/cabloy-master-detail/SKILL.md +4 -3
- package/.claude/skills/cabloy-module-removal/SKILL.md +5 -4
- package/.claude/skills/cabloy-resource-field-update/SKILL.md +8 -5
- package/.claude/skills/cabloy-spec-execution/SKILL.md +169 -0
- package/.claude/skills/cabloy-spec-execution/evals/evals.json +71 -0
- package/.claude/skills/cabloy-spec-execution/references/execution-protocol.md +137 -0
- package/.claude/skills/cabloy-spec-execution/references/status-and-evidence.md +117 -0
- package/.claude/skills/cabloy-spec-generation/SKILL.md +227 -0
- package/.claude/skills/cabloy-spec-generation/evals/evals.json +83 -0
- package/.claude/skills/cabloy-spec-generation/references/repo-aware-discovery.md +101 -0
- package/.claude/skills/cabloy-spec-generation/references/repo-specs-document-set.md +267 -0
- package/.claude/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +135 -0
- package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.mjs +350 -0
- package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.test.mjs +202 -0
- package/.claude/skills/cabloy-workflow/SKILL.md +21 -10
- package/.claude/skills/cabloy-workflow/evals/evals.json +8 -2
- package/.claude/skills/cabloy-workflow/references/edition-detection.md +5 -3
- package/.claude/skills/cabloy-zova-source-reading/SKILL.md +4 -3
- package/CHANGELOG.md +41 -0
- package/CLAUDE.md +12 -11
- package/package.json +4 -2
- package/repo-docs/.vitepress/config.mjs +4 -0
- package/repo-docs/ai/docs-skills-rules-mapping.md +6 -6
- package/repo-docs/ai/future-skill-roadmap.md +2 -2
- package/repo-docs/ai/introduction.md +5 -5
- package/repo-docs/ai/playbook-contract-regeneration.md +3 -9
- package/repo-docs/ai/repo-guidance.md +5 -4
- package/repo-docs/ai/rules-and-config.md +2 -2
- package/repo-docs/ai/virtual-decorator-guidance.md +1 -3
- package/repo-docs/backend/cache-guide.md +36 -0
- package/repo-docs/backend/dto-guide.md +7 -6
- package/repo-docs/backend/dto-infer-generation.md +25 -5
- package/repo-docs/backend/introduction.md +1 -1
- package/repo-docs/backend/migration-and-changes.md +27 -2
- package/repo-docs/backend/model-guide.md +1 -1
- package/repo-docs/backend/resource-field-update.md +156 -0
- package/repo-docs/frontend/a-model-under-the-hood.md +38 -0
- package/repo-docs/frontend/a-openapi-under-the-hood.md +13 -2
- package/repo-docs/frontend/form-layout-guide.md +26 -9
- package/repo-docs/frontend/model-resource-best-practices.md +2 -0
- package/repo-docs/frontend/model-resource-cookbook.md +7 -1
- package/repo-docs/frontend/router-tabs-introduction.md +2 -4
- package/repo-docs/frontend/use-state-data-best-practices.md +44 -0
- package/repo-docs/frontend/zova-form-source-reading-map.md +26 -9
- package/repo-docs/frontend/zova-form-under-the-hood.md +11 -0
- package/repo-docs/fullstack/a-pay-payment-suite.md +1 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +4 -0
- package/repo-docs/fullstack/one-to-one-companion-resource-guide.md +2 -2
- package/repo-docs/reference/package-map.md +1 -1
- package/repo-e2e/specs/a-commerce.spec.ts +40 -0
- package/scripts/release.ts +48 -1
- package/scripts/upgrade.ts +18 -7
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/boilerplate/src/controller/{{resourceName}}.ts_ +2 -2
- package/vona/packages-cli/cli-set-api/package.json +1 -1
- package/vona/pnpm-lock.yaml +83 -75
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/.metadata/index.ts +108 -108
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/dto/skuSelectResItem.tsx +12 -0
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +19 -1
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuPresentation.test.ts +53 -4
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +148 -148
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/dto/stockAdjust.tsx +37 -3
- package/vona/src/suite/a-training/modules/training-student/src/.metadata/index.ts +2 -2
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentCreate.tsx +7 -6
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentSummary.tsx +1 -1
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentUpdate.tsx +7 -6
- package/vona/src/suite/a-training/modules/training-student/src/dto/studentView.tsx +10 -7
- package/vona/src/suite/a-training/modules/training-student/src/model/student.ts +4 -4
- package/vona/src/suite/a-training/modules/training-student/src/service/student.ts +39 -67
- package/vona/src/suite/a-training/modules/training-student/test/student.test.ts +56 -37
- package/zova/packages-zova/zova/package.json +2 -2
- package/zova/pnpm-lock.yaml +5 -5
- package/zova/src/suite/a-commerce/modules/commerce-trade/cli/openapi.config.ts +1 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/package.json +6 -1
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/component/tableCellActionAdjustStock.ts +34 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/.metadata/index.ts +81 -6
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/commerceTradeStockBalance.ts +32 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/schemas.ts +44 -44
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/api/openapi/types.ts +356 -338
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/apiSchema/commerceTradeStockBalance.ts +17 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/bean/tableCell.actionAdjustStock.tsx +35 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/component/tableCellActionAdjustStock/controller.tsx +138 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/en-us.ts +3 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/config/locale/zh-cn.ts +3 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/model/stockBalance.ts +36 -0
- package/zova/src/suite/a-commerce/modules/commerce-trade/src/page/payment/controller.tsx +4 -4
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/schemas.ts +8 -8
- package/zova/src/suite/a-training/modules/training-student/src/api/openapi/types.ts +11 -11
- package/zova/src/suite/a-training/modules/training-student/src/bean/tableCell.actionSummary.tsx +2 -2
- package/zova/src/suite/cabloy-basic/modules/basic-app/src/bean/behavior.appModal.tsx +2 -0
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/package.json +1 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/src/model/resource.ts +2 -1
- package/zova/src/suite-vendor/a-cabloy/modules/rest-resource/test/model/resource.types.ts +11 -0
- package/zova/src/suite-vendor/a-cabloy/package.json +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-form/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-form/src/lib/formLayout.ts +58 -9
- package/zova/src/suite-vendor/a-zova/modules/a-form/test/lib/formLayout.test.ts +157 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.persister.ts +3 -1
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useQuery.ts +11 -13
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useState.ts +9 -7
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/bean/bean.model/bean.model.useStateGeneral.ts +5 -12
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/queryPersister.ts +17 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/lib/queryRefetch.ts +34 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/src/types/query.ts +27 -3
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/queryPersister.test.ts +115 -0
- package/zova/src/suite-vendor/a-zova/modules/a-model/test/lib/queryRefetch.test.ts +84 -0
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/package.json +1 -1
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/lib/schema.ts +27 -5
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/model/sdk.ts +2 -2
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/rest.ts +4 -0
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/src/types/schema.ts +2 -5
- package/zova/src/suite-vendor/a-zova/modules/a-openapi/test/lib/schema.test.ts +179 -0
- package/zova/src/suite-vendor/a-zova/package.json +4 -4
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { basename, resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
const STATUS_ORDER = ['not-started', 'in-progress', 'implementation-complete', 'verified', 'blocked', 'waived', 'deferred'];
|
|
6
|
+
const STATUS_COLORS = {
|
|
7
|
+
'not-started': 'var(--blue)',
|
|
8
|
+
'in-progress': 'var(--orange)',
|
|
9
|
+
'implementation-complete': 'var(--violet)',
|
|
10
|
+
verified: 'var(--aqua)',
|
|
11
|
+
blocked: 'var(--red)',
|
|
12
|
+
waived: 'var(--yellow)',
|
|
13
|
+
deferred: 'var(--muted)',
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const COPY = {
|
|
17
|
+
en: {
|
|
18
|
+
ganttTitle: 'Implementation Roadmap',
|
|
19
|
+
ganttSubtitle: 'WBS sequencing, dependencies, derived status, and illustrative relative timeboxes',
|
|
20
|
+
burndownTitle: 'WBS Scope-Count Burndown',
|
|
21
|
+
burndownSubtitle: 'Derived WBS scope snapshot — remaining-count reference, not a calendar or historical trend',
|
|
22
|
+
phase: 'Phase', wbs: 'WBS', work: 'Work package', dependency: 'Dependencies', status: 'Status',
|
|
23
|
+
weeks: 'Relative implementation order (illustrative)', current: 'Current reviewed state',
|
|
24
|
+
remaining: 'WBS items remaining', verified: 'verified', approved: 'Approved WBS scope',
|
|
25
|
+
noNext: 'No WBS item is ready to execute after the current scope.',
|
|
26
|
+
noReady: 'No next WBS item is ready to execute.',
|
|
27
|
+
source: 'Sources', reviewed: 'Last reviewed', scopeReference: 'Scope-count reference',
|
|
28
|
+
notTime: 'Verified WBS items in approved scope — not time', remainingAxis: 'WBS items remaining',
|
|
29
|
+
logicalBaseline: 'Logical approved-scope baseline', currentSnapshot: 'Current snapshot',
|
|
30
|
+
remainingShort: 'remaining', emptyScope: 'No active WBS items remain in scope.',
|
|
31
|
+
noHistory: 'No observed intermediate status points are available.',
|
|
32
|
+
formula: 'remaining = active scope − verified',
|
|
33
|
+
phaseScope: 'Scope by phase', active: 'Active WBS items', deferred: 'Deferred',
|
|
34
|
+
completion: 'Completion', howRead: 'How to read this chart',
|
|
35
|
+
caveat: 'Derived visualization only — not a schedule, velocity trend, or forecast',
|
|
36
|
+
chartCaveat: 'Relative positions and overlap are illustrative; they are not calendar dates, duration, staffing, or concurrency commitments.',
|
|
37
|
+
metadata: 'Derived from pdp-wbs.md, test-plan.md, and progress.md. This SVG is not planning authority.',
|
|
38
|
+
next: 'Next executable WBS item',
|
|
39
|
+
},
|
|
40
|
+
zh: {
|
|
41
|
+
ganttTitle: '实施路线图',
|
|
42
|
+
ganttSubtitle: 'WBS 顺序、依赖关系、派生状态和示意性相对时间框',
|
|
43
|
+
burndownTitle: 'WBS 范围燃尽图',
|
|
44
|
+
burndownSubtitle: '派生的 WBS 范围快照——剩余项参考,不是日历或历史趋势',
|
|
45
|
+
phase: '阶段', wbs: 'WBS', work: '工作包', dependency: '依赖', status: '状态',
|
|
46
|
+
weeks: '相对实施顺序(示意)', current: '当前审查状态',
|
|
47
|
+
remaining: '剩余 WBS 项', verified: '已核验', approved: '已批准 WBS 范围',
|
|
48
|
+
noNext: '选择其依赖均已满足的最早未核验 WBS 项。',
|
|
49
|
+
noReady: '当前没有可执行的下一项 WBS 工作。',
|
|
50
|
+
source: '来源', reviewed: '最后审查日期', scopeReference: '范围计数参考',
|
|
51
|
+
notTime: '已核验的批准范围 WBS 项——不是时间', remainingAxis: '剩余 WBS 项',
|
|
52
|
+
logicalBaseline: '逻辑上的批准范围基线', currentSnapshot: '当前快照',
|
|
53
|
+
remainingShort: '剩余', emptyScope: '当前范围内没有活跃 WBS 项。',
|
|
54
|
+
noHistory: '没有可用的中间历史状态点。',
|
|
55
|
+
formula: '剩余 = 活跃范围 − 已核验',
|
|
56
|
+
phaseScope: '按阶段统计范围', active: '活跃 WBS 项', deferred: '已延期',
|
|
57
|
+
completion: '完成情况', howRead: '图表说明',
|
|
58
|
+
caveat: '仅为派生可视化——不是计划、速度趋势或预测',
|
|
59
|
+
chartCaveat: '相对位置和重叠仅为示意;不代表日历日期、工期、人员或并发承诺。',
|
|
60
|
+
next: '下一项 WBS 工作',
|
|
61
|
+
metadata: '派生自 pdp-wbs.md、test-plan.md 和 progress.md。本 SVG 不是计划权威。',
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function escapeXml(value) {
|
|
66
|
+
return String(value).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function wrapChartText(value, maxCharacters) {
|
|
70
|
+
const words = String(value).trim().replace(/\s+/g, ' ').split(' ');
|
|
71
|
+
const lines = [];
|
|
72
|
+
let line = '';
|
|
73
|
+
for (const word of words) {
|
|
74
|
+
const characters = [...word];
|
|
75
|
+
if (characters.length > maxCharacters) {
|
|
76
|
+
if (line) {
|
|
77
|
+
lines.push(line);
|
|
78
|
+
line = '';
|
|
79
|
+
}
|
|
80
|
+
for (let index = 0; index < characters.length; index += maxCharacters) {
|
|
81
|
+
lines.push(characters.slice(index, index + maxCharacters).join(''));
|
|
82
|
+
}
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
86
|
+
if ([...candidate].length > maxCharacters) {
|
|
87
|
+
lines.push(line);
|
|
88
|
+
line = word;
|
|
89
|
+
} else {
|
|
90
|
+
line = candidate;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (line) lines.push(line);
|
|
94
|
+
return lines;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function languageFromReadme(readme) {
|
|
98
|
+
const cjk = (readme.match(/[㐀-鿿]/g) ?? []).length;
|
|
99
|
+
const latin = (readme.match(/[A-Za-z]/g) ?? []).length;
|
|
100
|
+
return cjk > latin / 3 ? 'zh' : 'en';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeStatus(value) {
|
|
104
|
+
const normalized = value.trim().replace(/`/g, '').toLowerCase();
|
|
105
|
+
if (!STATUS_ORDER.includes(normalized)) {
|
|
106
|
+
throw new Error(`Unsupported progress status: ${value.trim()}`);
|
|
107
|
+
}
|
|
108
|
+
return normalized;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function parseTableRow(line) {
|
|
112
|
+
if (!line.trim().startsWith('|') || /^\|\s*[-: ]+\|/.test(line)) return null;
|
|
113
|
+
return line.split('|').slice(1, -1).map(cell => cell.trim());
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function parseProgress(markdown) {
|
|
117
|
+
const rows = new Map();
|
|
118
|
+
let reviewed = markdown.match(/(?:Last reviewed|最后审查日期)\s*[::]?\s*(\d{4}-\d{2}-\d{2})/i)?.[1];
|
|
119
|
+
for (const line of markdown.split('\n')) {
|
|
120
|
+
const cells = parseTableRow(line);
|
|
121
|
+
if (!cells || cells.length < 2) continue;
|
|
122
|
+
const id = cells[0].replace(/`/g, '').match(/\bWBS-[A-Za-z0-9-]+\b/)?.[0];
|
|
123
|
+
if (!id) continue;
|
|
124
|
+
rows.set(id, normalizeStatus(cells[1]));
|
|
125
|
+
}
|
|
126
|
+
if (!reviewed) reviewed = 'not recorded';
|
|
127
|
+
return { rows, reviewed };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function parseWbs(markdown) {
|
|
131
|
+
const phaseMatches = [...markdown.matchAll(/^###\s+Phase\s+(\d+)\s*:\s*(.+)$/gim)];
|
|
132
|
+
if (!phaseMatches.length) throw new Error('No formal `### Phase <number>:` headings were found in pdp-wbs.md.');
|
|
133
|
+
const phases = [];
|
|
134
|
+
for (let phaseIndex = 0; phaseIndex < phaseMatches.length; phaseIndex++) {
|
|
135
|
+
const match = phaseMatches[phaseIndex];
|
|
136
|
+
const bodyStart = match.index + match[0].length;
|
|
137
|
+
const bodyEnd = phaseMatches[phaseIndex + 1]?.index ?? markdown.length;
|
|
138
|
+
const body = markdown.slice(bodyStart, bodyEnd);
|
|
139
|
+
const phaseDependency = body.match(/^Dependencies:\s*(.+)$/im)?.[1]?.trim() ?? 'none';
|
|
140
|
+
const taskMatches = [...body.matchAll(/^####\s+(WBS-[A-Za-z0-9-]+)\s*:\s*(.+)$/gim)];
|
|
141
|
+
const tasks = taskMatches.map((taskMatch, taskIndex) => {
|
|
142
|
+
const taskBodyStart = taskMatch.index + taskMatch[0].length;
|
|
143
|
+
const taskBodyEnd = taskMatches[taskIndex + 1]?.index ?? body.length;
|
|
144
|
+
const taskBody = body.slice(taskBodyStart, taskBodyEnd);
|
|
145
|
+
const dependency = taskBody.match(/^Dependencies:\s*(.+)$/im)?.[1]?.trim() ?? phaseDependency;
|
|
146
|
+
const deferred = /\b(?:status\s*:\s*`?deferred`?|deferred scope)\b/i.test(taskBody);
|
|
147
|
+
return {
|
|
148
|
+
id: taskMatch[1],
|
|
149
|
+
title: taskMatch[2].trim(),
|
|
150
|
+
dependency,
|
|
151
|
+
deferred,
|
|
152
|
+
atps: [...new Set([...taskBody.matchAll(/\bATP-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*\b/g)].map(value => value[0]))],
|
|
153
|
+
};
|
|
154
|
+
});
|
|
155
|
+
phases.push({ number: match[1], title: match[2].trim(), dependency: phaseDependency, tasks });
|
|
156
|
+
}
|
|
157
|
+
const tasks = phases.flatMap(phase => phase.tasks.map(task => {
|
|
158
|
+
task.phase = { number: phase.number, title: phase.title };
|
|
159
|
+
return task;
|
|
160
|
+
}));
|
|
161
|
+
if (!tasks.length) throw new Error('No formal `#### WBS-…:` tasks were found in pdp-wbs.md.');
|
|
162
|
+
const ids = new Set(tasks.map(task => task.id));
|
|
163
|
+
for (const task of tasks) {
|
|
164
|
+
for (const dependency of task.dependency.match(/\bWBS-(?:[A-Za-z0-9]+-)*[A-Za-z0-9]+(?:-\*)?/g) ?? []) {
|
|
165
|
+
if (dependency.endsWith('-*')) {
|
|
166
|
+
const prefix = dependency.slice(0, -1);
|
|
167
|
+
if (![...ids].some(id => id.startsWith(prefix))) throw new Error(`${task.id} depends on unknown WBS task group ${dependency}.`);
|
|
168
|
+
} else if (!ids.has(dependency)) {
|
|
169
|
+
throw new Error(`${task.id} depends on unknown WBS task ${dependency}.`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return { phases, tasks };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function parseAtpIds(markdown) {
|
|
177
|
+
const ids = new Set();
|
|
178
|
+
for (const line of markdown.split('\n')) {
|
|
179
|
+
const cells = parseTableRow(line);
|
|
180
|
+
if (cells?.[0]) {
|
|
181
|
+
const id = cells[0].match(/^`?(ATP-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)`?$/)?.[1];
|
|
182
|
+
if (id) ids.add(id);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const heading = line.match(/^#{2,}\s+`?(ATP-[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*)`?(?=\s|$)/);
|
|
186
|
+
if (heading) ids.add(heading[1]);
|
|
187
|
+
}
|
|
188
|
+
return ids;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function createChartModel({ readme, wbs, progress, testPlan }) {
|
|
192
|
+
const language = languageFromReadme(readme);
|
|
193
|
+
const atpIds = parseAtpIds(testPlan);
|
|
194
|
+
const { phases, tasks } = parseWbs(wbs);
|
|
195
|
+
const { rows: statusRows, reviewed } = parseProgress(progress);
|
|
196
|
+
const unknownProgress = [...statusRows.keys()].filter(id => !tasks.some(task => task.id === id));
|
|
197
|
+
if (unknownProgress.length) throw new Error(`progress.md contains WBS rows not defined in pdp-wbs.md: ${unknownProgress.join(', ')}.`);
|
|
198
|
+
for (const task of tasks) {
|
|
199
|
+
if (!statusRows.has(task.id)) throw new Error(`${task.id} has no corresponding progress.md status row.`);
|
|
200
|
+
task.status = statusRows.get(task.id);
|
|
201
|
+
if (task.deferred && task.status !== 'deferred') {
|
|
202
|
+
throw new Error(`${task.id} is marked deferred in pdp-wbs.md but has progress status ${task.status}.`);
|
|
203
|
+
}
|
|
204
|
+
for (const atp of task.atps) {
|
|
205
|
+
if (!atpIds.has(atp)) throw new Error(`${task.id} references undefined ${atp} in test-plan.md.`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
const activeTasks = tasks.filter(task => !task.deferred && task.status !== 'deferred');
|
|
209
|
+
const deferredTasks = tasks.filter(task => !activeTasks.includes(task));
|
|
210
|
+
const verified = activeTasks.filter(task => task.status === 'verified').length;
|
|
211
|
+
return { language, copy: COPY[language], phases, tasks, activeTasks, deferredTasks, verified, reviewed };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function style() {
|
|
215
|
+
return `<style>
|
|
216
|
+
:root { --surface:#fcfcfb;--page:#f9f9f7;--ink:#0b0b0b;--secondary:#52514e;--muted:#898781;--grid:#e1e0d9;--axis:#c3c2b7;--blue:#2a78d6;--orange:#eb6834;--aqua:#1baf7a;--violet:#4a3aa7;--red:#e34948;--yellow:#eda100;--blue-wash:#eaf3fd;--aqua-wash:#e7f7f0; }
|
|
217
|
+
@media (prefers-color-scheme:dark) { :root { --surface:#1a1a19;--page:#0d0d0d;--ink:#fff;--secondary:#c3c2b7;--muted:#a09f99;--grid:#2c2c2a;--axis:#383835;--blue:#3987e5;--orange:#d95926;--aqua:#199e70;--violet:#9085e9;--red:#e66767;--yellow:#c98500;--blue-wash:#172435;--aqua-wash:#122b23; } }
|
|
218
|
+
.canvas{fill:var(--page)}.card{fill:var(--surface);stroke:var(--grid);stroke-width:1}.title{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:27px;font-weight:700}.subtitle{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:14px}.heading{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:13px;font-weight:700}.phase{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px;font-weight:700}.task{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:10px}.small{fill:var(--muted);font-family:Arial,Helvetica,sans-serif;font-size:9px}.caption{fill:var(--secondary);font-family:Arial,Helvetica,sans-serif;font-size:11px}.num{fill:var(--ink);font-family:Arial,Helvetica,sans-serif;font-size:11px;font-weight:700;font-variant-numeric:tabular-nums}.grid{stroke:var(--grid);stroke-width:1;shape-rendering:crispEdges}.axis{stroke:var(--axis);stroke-width:1;shape-rendering:crispEdges}.row:focus{outline:2px solid var(--ink);outline-offset:2px}
|
|
219
|
+
</style>`;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function statusLabel(status, language) {
|
|
223
|
+
const labels = { en: { 'not-started': 'Not started', 'in-progress': 'In progress', 'implementation-complete': 'Implementation complete', verified: 'Verified', blocked: 'Blocked', waived: 'Waived', deferred: 'Deferred' }, zh: { 'not-started': '未开始', 'in-progress': '进行中', 'implementation-complete': '实施完成', verified: '已核验', blocked: '受阻', waived: '已豁免', deferred: '已延期' } };
|
|
224
|
+
return labels[language][status];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function statusIcon(x, y, status) {
|
|
228
|
+
if (status === 'verified') return `<circle cx="${x}" cy="${y}" r="6" fill="var(--surface)" stroke="var(--axis)"/><path d="M${x - 3} ${y} L${x - 1} ${y + 2} L${x + 4} ${y - 3}" fill="none" stroke="var(--ink)" stroke-width="1.1" stroke-linecap="round"/>`;
|
|
229
|
+
return `<circle cx="${x}" cy="${y}" r="5" fill="${STATUS_COLORS[status]}"/>`;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function svgDocument(title, desc, body, height, metadata) {
|
|
233
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
234
|
+
<svg xmlns="http://www.w3.org/2000/svg" width="1600" height="${height}" viewBox="0 0 1600 ${height}" role="img" aria-labelledby="chart-title chart-desc">
|
|
235
|
+
<title id="chart-title">${escapeXml(title)}</title>
|
|
236
|
+
<desc id="chart-desc">${escapeXml(desc)}</desc>
|
|
237
|
+
<metadata>${escapeXml(metadata)}</metadata>
|
|
238
|
+
${style()}
|
|
239
|
+
${body}
|
|
240
|
+
</svg>
|
|
241
|
+
`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function renderGantt(model, suiteName) {
|
|
245
|
+
const { copy: t, tasks, reviewed, language } = model;
|
|
246
|
+
const phaseX = 72; const wbsX = 265; const workX = 405; const dependencyX = 735; const statusX = 900;
|
|
247
|
+
const plotX = 980; const plotW = 520; const rowY = 270; const rowH = 34; const phaseLineH = 12;
|
|
248
|
+
let nextY = rowY;
|
|
249
|
+
const layout = tasks.map((task, index) => {
|
|
250
|
+
const phaseStart = index === 0 || tasks[index - 1].phase.number !== task.phase.number;
|
|
251
|
+
const descriptionLines = phaseStart ? wrapChartText(task.phase.title, 30) : [];
|
|
252
|
+
const phaseLabelY = phaseStart ? nextY : undefined;
|
|
253
|
+
const y = phaseStart ? nextY + 23 + descriptionLines.length * phaseLineH : nextY;
|
|
254
|
+
nextY = y + rowH;
|
|
255
|
+
return { task, index, y, phaseLabelY, descriptionLines };
|
|
256
|
+
});
|
|
257
|
+
const contentBottom = layout.at(-1)?.y + 14 ?? rowY;
|
|
258
|
+
const footerY = Math.max(772, contentBottom + 80);
|
|
259
|
+
const height = Math.max(850, footerY + 78);
|
|
260
|
+
const rows = layout.map(({ task, index, y, phaseLabelY, descriptionLines }) => {
|
|
261
|
+
const barX = plotX + (index / Math.max(tasks.length, 1)) * (plotW - 90);
|
|
262
|
+
const barW = task.status === 'verified' ? 58 : 42;
|
|
263
|
+
const phaseHeader = phaseLabelY === undefined ? '' : `<text class="phase" x="${phaseX}" y="${phaseLabelY}">${escapeXml(`${t.phase} ${task.phase.number}`)}</text>${descriptionLines.map((line, lineIndex) => `<text class="small" x="${phaseX}" y="${phaseLabelY + 13 + lineIndex * phaseLineH}">${escapeXml(line)}</text>`).join('')}`;
|
|
264
|
+
return `<g class="row" tabindex="0" aria-label="${escapeXml(`${task.id}: ${task.title}; ${statusLabel(task.status, language)}`)}"><title>${escapeXml(`${task.id}: ${task.title}; dependencies: ${task.dependency}; ${statusLabel(task.status, language)}`)}</title><line class="grid" x1="72" y1="${y + 14}" x2="1500" y2="${y + 14}"/>${phaseHeader}<text class="num" x="${wbsX}" y="${y}">${escapeXml(task.id)}</text><text class="task" x="${workX}" y="${y}">${escapeXml(task.title)}</text><text class="small" x="${dependencyX}" y="${y}">${escapeXml(task.dependency)}</text>${statusIcon(statusX + 5, y - 4, task.status)}<text class="small" x="${statusX + 17}" y="${y}">${escapeXml(statusLabel(task.status, language))}</text><rect x="${barX.toFixed(1)}" y="${y - 15}" width="${barW}" height="18" rx="4" fill="${STATUS_COLORS[task.status]}"/></g>`;
|
|
265
|
+
}).join('\n');
|
|
266
|
+
const completed = new Set(tasks.filter(task => task.status === 'verified').map(task => task.id));
|
|
267
|
+
const dependencyIds = task => [...task.dependency.matchAll(/\bWBS-(?:[A-Za-z0-9]+-)*[A-Za-z0-9]+(?:-\*)?/g)].flatMap(match => {
|
|
268
|
+
const value = match[0];
|
|
269
|
+
if (!value.endsWith('-*')) return [value];
|
|
270
|
+
const prefix = value.slice(0, -1);
|
|
271
|
+
return tasks.filter(candidate => candidate.id.startsWith(prefix)).map(candidate => candidate.id);
|
|
272
|
+
});
|
|
273
|
+
const nextTask = tasks.find(task =>
|
|
274
|
+
task.status !== 'verified' && task.status !== 'deferred' && task.status !== 'waived' && task.status !== 'blocked' &&
|
|
275
|
+
dependencyIds(task).every(id => completed.has(id))
|
|
276
|
+
);
|
|
277
|
+
const nextLabel = nextTask ? `${nextTask.id}: ${nextTask.title}` : (model.verified === model.activeTasks.length ? t.noReady : t.noNext);
|
|
278
|
+
const body = `<rect class="canvas" width="1600" height="${height}"/><rect class="card" x="32" y="28" width="1536" height="${height - 56}" rx="14"/>
|
|
279
|
+
<text class="title" x="72" y="84">${escapeXml(`${suiteName} ${t.ganttTitle}`)}</text><text class="subtitle" x="72" y="110">${escapeXml(t.ganttSubtitle)}</text><text class="small" x="1500" y="84" text-anchor="end">${escapeXml(`${t.reviewed}: ${reviewed}`)}</text>
|
|
280
|
+
<rect x="72" y="142" width="1428" height="78" rx="8" fill="var(--aqua-wash)"/><text class="heading" x="96" y="171">${escapeXml(t.current)}</text><text class="caption" x="96" y="195">${escapeXml(nextLabel)}</text><text class="heading" x="1320" y="171" text-anchor="end">${escapeXml(t.next)}</text><text class="heading" x="1320" y="195" text-anchor="end">${model.verified} / ${model.activeTasks.length} ${escapeXml(t.verified)}</text>
|
|
281
|
+
<text class="small" x="${phaseX}" y="246">${escapeXml(t.phase)}</text><text class="small" x="${wbsX}" y="246">${escapeXml(t.wbs)}</text><text class="small" x="${workX}" y="246">${escapeXml(t.work)}</text><text class="small" x="${dependencyX}" y="246">${escapeXml(t.dependency)}</text><text class="small" x="${statusX}" y="246">${escapeXml(t.status)}</text><text class="small" x="${plotX}" y="246">${escapeXml(t.weeks)}</text><line class="axis" x1="${plotX}" y1="254" x2="1500" y2="254"/>
|
|
282
|
+
${rows}
|
|
283
|
+
<line class="grid" x1="72" y1="${footerY}" x2="1500" y2="${footerY}"/><text class="small" x="72" y="${footerY + 28}">${escapeXml(`${t.source}: pdp-wbs.md · test-plan.md · progress.md (${t.reviewed}: ${reviewed})`)}</text><text class="small" x="1500" y="${footerY + 28}" text-anchor="end">${escapeXml(t.chartCaveat)}</text>`;
|
|
284
|
+
return svgDocument(`${suiteName} ${t.ganttTitle}`, `${t.ganttTitle}. ${tasks.length} formal WBS tasks. Current derived status reviewed ${reviewed}. ${t.chartCaveat}`, body, height, t.metadata);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function renderBurndown(model, suiteName) {
|
|
288
|
+
const { copy: t, phases, activeTasks, deferredTasks, verified, reviewed, language } = model;
|
|
289
|
+
const total = activeTasks.length; const remaining = total - verified;
|
|
290
|
+
const height = 1050; const x0 = 190; const x1 = 910; const y0 = 410; const y1 = 650;
|
|
291
|
+
const completion = total ? verified / total : 0;
|
|
292
|
+
const currentX = x0 + completion * (x1 - x0);
|
|
293
|
+
const currentY = y0 + completion * (y1 - y0);
|
|
294
|
+
const currentMarker = remaining === 0 && total > 0
|
|
295
|
+
? `<circle cx="${currentX.toFixed(1)}" cy="${currentY.toFixed(1)}" r="14" fill="var(--aqua)" stroke="var(--surface)" stroke-width="3"/><path d="M${(currentX - 6).toFixed(1)} ${currentY.toFixed(1)} L${(currentX - 2).toFixed(1)} ${(currentY + 4).toFixed(1)} L${(currentX + 6).toFixed(1)} ${(currentY - 5).toFixed(1)}" fill="none" stroke="var(--surface)" stroke-width="1.8" stroke-linecap="round"/>`
|
|
296
|
+
: `<circle cx="${currentX.toFixed(1)}" cy="${currentY.toFixed(1)}" r="8" fill="var(--orange)" stroke="var(--surface)" stroke-width="2"/>`;
|
|
297
|
+
const phaseRows = phases.map((phase, index) => {
|
|
298
|
+
const phaseTasks = phase.tasks.filter(task => !task.deferred && task.status !== 'deferred');
|
|
299
|
+
const phaseVerified = phaseTasks.filter(task => task.status === 'verified').length;
|
|
300
|
+
const y = 805 + index * 28;
|
|
301
|
+
const width = phaseTasks.length ? (phaseVerified / phaseTasks.length) * 210 : 0;
|
|
302
|
+
return `<g class="row" tabindex="0" aria-label="${escapeXml(`${t.phase} ${phase.number}: ${phaseVerified} of ${phaseTasks.length} ${t.verified}`)}"><title>${escapeXml(`${t.phase} ${phase.number} ${phase.title}: ${phaseVerified} of ${phaseTasks.length} ${t.verified}`)}</title><text class="task" x="96" y="${y}">${escapeXml(`${t.phase} ${phase.number} · ${phase.title}`)}</text><text class="num" x="730" y="${y}" text-anchor="end">${phaseVerified} / ${phaseTasks.length}</text><text class="num" x="850" y="${y}" text-anchor="end">${phaseTasks.length - phaseVerified}</text><rect x="930" y="${y - 11}" width="210" height="12" rx="6" fill="var(--blue-wash)"/><rect x="930" y="${y - 11}" width="${width}" height="12" rx="6" fill="var(--aqua)"/>${statusIcon(1215, y - 5, phaseVerified === phaseTasks.length ? 'verified' : 'in-progress')}<text class="small" x="1228" y="${y}">${escapeXml(phaseVerified === phaseTasks.length ? statusLabel('verified', language) : `${phaseVerified}/${phaseTasks.length}`)}</text></g>`;
|
|
303
|
+
}).join('\n');
|
|
304
|
+
const body = `<rect class="canvas" width="1600" height="${height}"/><rect class="card" x="32" y="28" width="1536" height="${height - 56}" rx="14"/>
|
|
305
|
+
<text class="title" x="72" y="84">${escapeXml(`${suiteName} ${t.burndownTitle}`)}</text><text class="subtitle" x="72" y="110">${escapeXml(t.burndownSubtitle)}</text><text class="small" x="1500" y="84" text-anchor="end">${escapeXml(`${t.reviewed}: ${reviewed}`)}</text>
|
|
306
|
+
<rect x="72" y="142" width="1428" height="116" rx="8" fill="var(--aqua-wash)"/><text class="heading" x="96" y="170">${escapeXml(t.current)}</text><text x="96" y="231" class="title" style="font-size:52px">${remaining}</text><text class="heading" x="155" y="215">${escapeXml(t.remaining)}</text><text class="caption" x="155" y="237">${escapeXml(`${verified} / ${total} ${t.verified}`)}</text><text class="caption" x="700" y="205">${escapeXml(t.approved)}</text><text class="heading" x="700" y="230">${total}</text><text class="caption" x="1030" y="205">${escapeXml(t.deferred)}</text><text class="heading" x="1030" y="230">${deferredTasks.length}</text>
|
|
307
|
+
<text class="heading" x="72" y="300">${escapeXml(t.scopeReference)}</text><text class="caption" x="72" y="321">${escapeXml(t.noHistory)}</text><rect class="card" x="72" y="340" width="940" height="390" rx="9"/><text class="small" x="116" y="372">${escapeXml(t.remainingAxis)}</text><text class="small" x="550" y="704" text-anchor="middle">${escapeXml(t.notTime)}</text>
|
|
308
|
+
<g class="grid"><line x1="${x0}" y1="${y0}" x2="${x1}" y2="${y0}"/><line x1="${x0}" y1="${y0 + 80}" x2="${x1}" y2="${y0 + 80}"/><line x1="${x0}" y1="${y0 + 160}" x2="${x1}" y2="${y0 + 160}"/><line x1="${x0}" y1="${y1}" x2="${x1}" y2="${y1}"/><line x1="${x0}" y1="${y0}" x2="${x0}" y2="${y1}"/><line x1="${x1}" y1="${y0}" x2="${x1}" y2="${y1}"/></g><line class="axis" x1="${x0}" y1="${y1}" x2="${x1}" y2="${y1}"/><line class="axis" x1="${x0}" y1="${y0}" x2="${x0}" y2="${y1}"/>
|
|
309
|
+
<line x1="${x0}" y1="${y0}" x2="${currentX.toFixed(1)}" y2="${currentY.toFixed(1)}" stroke="var(--blue)" stroke-width="2"/><line x1="${currentX.toFixed(1)}" y1="${currentY.toFixed(1)}" x2="${x1}" y2="${y1}" stroke="var(--blue)" stroke-width="2" stroke-dasharray="6 5"/><circle cx="${x0}" cy="${y0}" r="7" fill="var(--blue)" stroke="var(--surface)" stroke-width="2"/><text class="heading" x="${x0 + 22}" y="${y0 + 18}">${escapeXml(t.logicalBaseline)}</text><text class="caption" x="${x0 + 22}" y="${y0 + 37}">${total} ${escapeXml(t.remainingShort)}</text>${currentMarker}<text class="heading" x="${Math.max(x0 + 150, currentX - 22)}" y="${Math.max(y0 + 60, currentY - 49)}" text-anchor="end">${escapeXml(t.currentSnapshot)}</text><text class="caption" x="${Math.max(x0 + 150, currentX - 22)}" y="${Math.max(y0 + 79, currentY - 30)}" text-anchor="end">${escapeXml(`${remaining} ${t.remainingShort} · ${verified} ${t.verified}`)}</text>
|
|
310
|
+
<rect x="1040" y="340" width="460" height="390" rx="9" fill="var(--blue-wash)"/><text class="heading" x="1070" y="378">${escapeXml(t.howRead)}</text><text class="caption" x="1070" y="412">${escapeXml(t.formula)}</text><text class="caption" x="1070" y="448">${escapeXml(t.noHistory)}</text><text class="caption" x="1070" y="474">${escapeXml(t.caveat)}</text><text class="caption" x="1070" y="510">${escapeXml(t.chartCaveat)}</text>
|
|
311
|
+
<rect class="card" x="72" y="758" width="1428" height="${height - 846}" rx="9"/><text class="heading" x="96" y="786">${escapeXml(t.phaseScope)}</text><text class="small" x="96" y="800">${escapeXml(`${t.active}: ${total}; ${t.verified}: ${verified}; ${t.remaining}: ${remaining}`)}</text><text class="small" x="730" y="786" text-anchor="end">${escapeXml(t.verified)}</text><text class="small" x="850" y="786" text-anchor="end">${escapeXml(t.remaining)}</text><text class="small" x="930" y="786">${escapeXml(t.completion)}</text>${phaseRows}
|
|
312
|
+
<line class="grid" x1="72" y1="${height - 74}" x2="1500" y2="${height - 74}"/><text class="small" x="72" y="${height - 48}">${escapeXml(`${t.source}: pdp-wbs.md · progress.md (${t.reviewed}: ${reviewed})`)}</text><text class="small" x="1500" y="${height - 48}" text-anchor="end">${escapeXml(t.caveat)}</text>`;
|
|
313
|
+
return svgDocument(`${suiteName} ${t.burndownTitle}`, `${t.burndownTitle}. ${total} active WBS items; ${verified} verified; ${remaining} remaining. ${t.noHistory}`, body, height, t.metadata);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function generateCharts(directory, { check = false } = {}) {
|
|
317
|
+
const root = resolve(directory);
|
|
318
|
+
const [readme, wbs, progress, testPlan] = await Promise.all(['README.md', 'pdp-wbs.md', 'progress.md', 'test-plan.md'].map(name => readFile(resolve(root, name), 'utf8')));
|
|
319
|
+
const model = createChartModel({ readme, wbs, progress, testPlan });
|
|
320
|
+
const suiteName = readme.match(/^#\s+(.+?)(?:\s+(?:Internal Planning|内部规划))?\s*$/m)?.[1]?.trim() ?? basename(root);
|
|
321
|
+
const artifacts = new Map([
|
|
322
|
+
['implementation-gantt.svg', renderGantt(model, suiteName)],
|
|
323
|
+
['implementation-burndown.svg', renderBurndown(model, suiteName)],
|
|
324
|
+
]);
|
|
325
|
+
const stale = [];
|
|
326
|
+
for (const [name, content] of artifacts) {
|
|
327
|
+
const path = resolve(root, name);
|
|
328
|
+
let current;
|
|
329
|
+
try { current = await readFile(path, 'utf8'); } catch { current = undefined; }
|
|
330
|
+
if (current !== content) stale.push(name);
|
|
331
|
+
if (!check && current !== content) await writeFile(path, content);
|
|
332
|
+
}
|
|
333
|
+
if (check && stale.length) throw new Error(`Derived implementation charts are stale: ${stale.join(', ')}. Run \`npm run spec:charts -- ${basename(root)}\`.`);
|
|
334
|
+
return { model, artifacts, stale };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function main() {
|
|
338
|
+
const args = process.argv.slice(2);
|
|
339
|
+
const check = args.includes('--check');
|
|
340
|
+
const target = args.find(arg => !arg.startsWith('--'));
|
|
341
|
+
if (!target) throw new Error('Usage: generate-implementation-charts.mjs [--check] <repo-specs/<suite>|<suite>.');
|
|
342
|
+
const directory = target.includes('/') ? target : `repo-specs/${target}`;
|
|
343
|
+
const result = await generateCharts(directory, { check });
|
|
344
|
+
const mode = check ? 'checked' : 'generated';
|
|
345
|
+
console.log(`${mode} implementation-gantt.svg and implementation-burndown.svg for ${directory} (${result.model.language}; ${result.model.verified}/${result.model.activeTasks.length} verified).`);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
349
|
+
main().catch(error => { console.error(`spec chart generation failed: ${error.message}`); process.exitCode = 1; });
|
|
350
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { tmpdir } from 'node:os';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { test } from 'node:test';
|
|
5
|
+
import assert from 'node:assert/strict';
|
|
6
|
+
import {
|
|
7
|
+
createChartModel,
|
|
8
|
+
generateCharts,
|
|
9
|
+
parseAtpIds,
|
|
10
|
+
parseProgress,
|
|
11
|
+
parseWbs,
|
|
12
|
+
} from './generate-implementation-charts.mjs';
|
|
13
|
+
|
|
14
|
+
const files = {
|
|
15
|
+
'README.md': '# Demo Internal Planning\n\nEnglish planning records.\n',
|
|
16
|
+
'pdp-wbs.md': `# Delivery Plan
|
|
17
|
+
|
|
18
|
+
### Phase 10: Baseline
|
|
19
|
+
|
|
20
|
+
Dependencies: none.
|
|
21
|
+
|
|
22
|
+
#### WBS-10-01: Freeze the baseline
|
|
23
|
+
|
|
24
|
+
Tasks:
|
|
25
|
+
|
|
26
|
+
- confirm the baseline;
|
|
27
|
+
|
|
28
|
+
Acceptance checks:
|
|
29
|
+
|
|
30
|
+
- satisfy ATP-DEMO-01.
|
|
31
|
+
|
|
32
|
+
### Phase 20: Delivery
|
|
33
|
+
|
|
34
|
+
Dependencies: \`WBS-10-*\`.
|
|
35
|
+
|
|
36
|
+
#### WBS-20-01: Deliver <tenant> & review
|
|
37
|
+
|
|
38
|
+
Dependencies: \`WBS-10-01\`.
|
|
39
|
+
|
|
40
|
+
Tasks:
|
|
41
|
+
|
|
42
|
+
- implement the feature for <tenant> & review it;
|
|
43
|
+
|
|
44
|
+
Acceptance checks:
|
|
45
|
+
|
|
46
|
+
- satisfy ATP-DEMO-01.
|
|
47
|
+
|
|
48
|
+
#### WBS-20-02: Deferred follow-up
|
|
49
|
+
|
|
50
|
+
Status: \`deferred\`.
|
|
51
|
+
`,
|
|
52
|
+
'test-plan.md': `# Test Plan
|
|
53
|
+
|
|
54
|
+
## Acceptance Scenario Catalogue
|
|
55
|
+
|
|
56
|
+
| ID | Scenario |
|
|
57
|
+
| --- | --- |
|
|
58
|
+
| \`ATP-DEMO-01\` | Verify the baseline and feature. |
|
|
59
|
+
`,
|
|
60
|
+
'progress.md': `# Progress
|
|
61
|
+
|
|
62
|
+
> Last reviewed: 2026-08-29.
|
|
63
|
+
|
|
64
|
+
| WBS ID | Status |
|
|
65
|
+
| --- | --- |
|
|
66
|
+
| \`WBS-10-01\` | \`not-started\` |
|
|
67
|
+
| \`WBS-20-01\` | \`in-progress\` |
|
|
68
|
+
| \`WBS-20-02\` | \`deferred\` |
|
|
69
|
+
`,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
async function fixture(overrides = {}) {
|
|
73
|
+
const directory = await mkdtemp(resolve(tmpdir(), 'cabloy-spec-charts-'));
|
|
74
|
+
for (const [name, content] of Object.entries({ ...files, ...overrides })) {
|
|
75
|
+
await writeFile(resolve(directory, name), content);
|
|
76
|
+
}
|
|
77
|
+
return directory;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function removeFixture(directory) {
|
|
81
|
+
await rm(directory, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
test('parses task dependencies independently from phase dependencies', () => {
|
|
85
|
+
const model = parseWbs(files['pdp-wbs.md']);
|
|
86
|
+
assert.equal(model.tasks.find(task => task.id === 'WBS-20-01').dependency, '`WBS-10-01`.');
|
|
87
|
+
assert.equal(model.tasks.find(task => task.id === 'WBS-20-02').dependency, '`WBS-10-*`.');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('requires formal ATP definitions rather than prose mentions', () => {
|
|
91
|
+
assert.deepEqual([...parseAtpIds(files['test-plan.md'])], ['ATP-DEMO-01']);
|
|
92
|
+
assert.deepEqual([...parseAtpIds('A prose mention of ATP-DEMO-99.')], []);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('creates a mixed-status model with deferred scope separated', () => {
|
|
96
|
+
const model = createChartModel({
|
|
97
|
+
readme: files['README.md'],
|
|
98
|
+
wbs: files['pdp-wbs.md'],
|
|
99
|
+
progress: files['progress.md'],
|
|
100
|
+
testPlan: files['test-plan.md'],
|
|
101
|
+
});
|
|
102
|
+
assert.equal(model.language, 'en');
|
|
103
|
+
assert.equal(model.activeTasks.length, 2);
|
|
104
|
+
assert.equal(model.deferredTasks.length, 1);
|
|
105
|
+
assert.equal(model.verified, 0);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('selects the earliest dependency-ready WBS item', async t => {
|
|
109
|
+
const directory = await fixture({
|
|
110
|
+
'pdp-wbs.md': `${files['pdp-wbs.md']}\n\n### Phase 30: Independent follow-up\n\nDependencies: none.\n\n#### WBS-30-01: Start the independent follow-up\n`,
|
|
111
|
+
'progress.md': `${files['progress.md']}| \`WBS-30-01\` | \`in-progress\` |\n`,
|
|
112
|
+
});
|
|
113
|
+
t.after(() => removeFixture(directory));
|
|
114
|
+
await generateCharts(directory);
|
|
115
|
+
const gantt = await readFile(resolve(directory, 'implementation-gantt.svg'), 'utf8');
|
|
116
|
+
assert.match(gantt, /WBS-30-01: Start the independent follow-up/);
|
|
117
|
+
assert.match(gantt, /Next executable WBS item/);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('renders a phase description directly beneath its phase label', async t => {
|
|
121
|
+
const directory = await fixture({
|
|
122
|
+
'pdp-wbs.md': files['pdp-wbs.md'].replace(
|
|
123
|
+
'Phase 10: Baseline',
|
|
124
|
+
'Phase 10: A deliberately extended <phase> & description that wraps safely',
|
|
125
|
+
),
|
|
126
|
+
});
|
|
127
|
+
t.after(() => removeFixture(directory));
|
|
128
|
+
const gantt = (await generateCharts(directory)).artifacts.get('implementation-gantt.svg');
|
|
129
|
+
assert.match(gantt, /<text class="phase" x="72" y="270">Phase 10<\/text><text class="small" x="72" y="283">A deliberately extended<\/text><text class="small" x="72" y="295"><phase> & description that<\/text><text class="small" x="72" y="307">wraps safely<\/text><text class="num" x="265" y="329">WBS-10-01<\/text>/);
|
|
130
|
+
assert.match(gantt, /<text class="phase" x="72" y="363">Phase 20<\/text><text class="small" x="72" y="376">Delivery<\/text><text class="num" x="265" y="398">WBS-20-01<\/text>/);
|
|
131
|
+
assert.doesNotMatch(gantt, /y="432">Delivery<\/text>/);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('selects Chinese chart copy from a Chinese README', () => {
|
|
135
|
+
const model = createChartModel({
|
|
136
|
+
readme: '# 示例内部规划\n\n这是中文规划记录。\n',
|
|
137
|
+
wbs: files['pdp-wbs.md'],
|
|
138
|
+
progress: files['progress.md'],
|
|
139
|
+
testPlan: files['test-plan.md'],
|
|
140
|
+
});
|
|
141
|
+
assert.equal(model.language, 'zh');
|
|
142
|
+
assert.equal(model.copy.ganttTitle, '实施路线图');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('rejects missing progress rows and dangling ATP references', () => {
|
|
146
|
+
assert.throws(() => createChartModel({
|
|
147
|
+
readme: files['README.md'],
|
|
148
|
+
wbs: files['pdp-wbs.md'],
|
|
149
|
+
progress: files['progress.md'].replace('| `WBS-20-01` | `in-progress` |\n', ''),
|
|
150
|
+
testPlan: files['test-plan.md'],
|
|
151
|
+
}), /WBS-20-01 has no corresponding progress/);
|
|
152
|
+
assert.throws(() => createChartModel({
|
|
153
|
+
readme: files['README.md'],
|
|
154
|
+
wbs: files['pdp-wbs.md'].replace('ATP-DEMO-01', 'ATP-MISSING-01'),
|
|
155
|
+
progress: files['progress.md'],
|
|
156
|
+
testPlan: files['test-plan.md'],
|
|
157
|
+
}), /references undefined ATP-MISSING-01/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('renders partial burndown progress at its remaining-count position', async t => {
|
|
161
|
+
const directory = await fixture({
|
|
162
|
+
'progress.md': files['progress.md'].replace('`not-started`', '`verified`'),
|
|
163
|
+
});
|
|
164
|
+
t.after(() => removeFixture(directory));
|
|
165
|
+
const result = await generateCharts(directory);
|
|
166
|
+
const burndown = result.artifacts.get('implementation-burndown.svg');
|
|
167
|
+
assert.match(burndown, />1 remaining · 1 verified</);
|
|
168
|
+
assert.match(burndown, />2 remaining</);
|
|
169
|
+
assert.doesNotMatch(burndown, />0 \/ 2</);
|
|
170
|
+
assert.match(burndown, /<line x1="190" y1="410" x2="550\.0" y2="530\.0" stroke="var\(--blue\)" stroke-width="2"\/>/);
|
|
171
|
+
assert.match(burndown, /<line x1="550\.0" y1="530\.0" x2="910" y2="650" stroke="var\(--blue\)" stroke-width="2" stroke-dasharray="6 5"/);
|
|
172
|
+
assert.match(burndown, /<circle cx="550\.0" cy="530\.0" r="8"/);
|
|
173
|
+
assert.doesNotMatch(burndown, /r="14" fill="var\(--aqua\)"/);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test('renders a completion marker only when no WBS items remain', async t => {
|
|
177
|
+
const directory = await fixture({
|
|
178
|
+
'progress.md': files['progress.md']
|
|
179
|
+
.replace('`not-started`', '`verified`')
|
|
180
|
+
.replace('`in-progress`', '`verified`'),
|
|
181
|
+
});
|
|
182
|
+
t.after(() => removeFixture(directory));
|
|
183
|
+
const burndown = (await generateCharts(directory)).artifacts.get('implementation-burndown.svg');
|
|
184
|
+
assert.match(burndown, />0 remaining · 2 verified</);
|
|
185
|
+
assert.match(burndown, /<line x1="190" y1="410" x2="910\.0" y2="650\.0"/);
|
|
186
|
+
assert.match(burndown, /<circle cx="910\.0" cy="650\.0" r="14"/);
|
|
187
|
+
assert.match(burndown, /M904\.0 650\.0 L908\.0 654\.0 L916\.0 645\.0/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('writes deterministic XML-escaped artifacts and detects stale output', async t => {
|
|
191
|
+
const directory = await fixture();
|
|
192
|
+
t.after(() => removeFixture(directory));
|
|
193
|
+
const first = await generateCharts(directory);
|
|
194
|
+
assert.match(first.artifacts.get('implementation-gantt.svg'), /tenant/);
|
|
195
|
+
assert.match(first.artifacts.get('implementation-gantt.svg'), /role="img"/);
|
|
196
|
+
assert.match(first.artifacts.get('implementation-burndown.svg'), /role="img"/);
|
|
197
|
+
assert.equal((await generateCharts(directory, { check: true })).stale.length, 0);
|
|
198
|
+
await writeFile(resolve(directory, 'implementation-gantt.svg'), 'stale');
|
|
199
|
+
await assert.rejects(() => generateCharts(directory, { check: true }), /implementation-gantt\.svg/);
|
|
200
|
+
const second = await generateCharts(directory);
|
|
201
|
+
assert.equal(second.artifacts.get('implementation-gantt.svg'), first.artifacts.get('implementation-gantt.svg'));
|
|
202
|
+
});
|