cabloy 5.1.194 → 5.1.196
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/.github/workflows/agent-governance.yml +1 -0
- package/CHANGELOG.md +22 -0
- package/README.md +13 -24
- package/package.json +2 -1
- package/repo-agent-governance/managed-assets.json +138 -33
- package/repo-agent-governance/scripts/pack-check.mjs +17 -0
- package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +1 -0
- package/repo-agent-governance/skills/cabloy-domain-planning/SKILL.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +13 -8
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/evals.json +108 -12
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/files/scenarios.json +84 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/evals/protocol.md +9 -0
- package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +21 -5
- package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +5 -3
- package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +78 -168
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/evals.json +165 -17
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/files/scenarios.json +114 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/evals/protocol.md +44 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +145 -0
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-aware-discovery.md +55 -57
- package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +6 -4
- package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +12 -8
- package/repo-agent-governance/tests/governance.test.mjs +10 -1
- package/repo-agent-governance/tests/spec-audit.test.mjs +255 -0
- package/repo-agent-governance/tools/spec-audit/audit.mjs +427 -0
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +97 -408
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +462 -1
- package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +561 -0
- package/repo-docs/.vitepress/config.mjs +54 -29
- package/repo-docs/ai/playbook-spec-execution.md +10 -4
- package/repo-docs/ai/playbook-spec-generation.md +40 -3
- package/repo-docs/ai/skills.md +3 -3
- package/repo-docs/backend/controller-aop-guide.md +10 -0
- package/repo-docs/backend/field-indexes.md +25 -0
- package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
- package/repo-docs/fullstack/development-history.md +24 -0
- package/repo-docs/fullstack/introduction.md +25 -156
- package/repo-docs/fullstack/quickstart.md +1 -1
- package/repo-docs/fullstack/ssr-entry-modes.md +52 -0
- package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +3 -1
- package/repo-docs/fullstack/tutorial-5-backend-contract-sharing.md +13 -7
- package/repo-docs/fullstack/tutorial-6-one-contract-four-uses.md +11 -15
- package/repo-docs/fullstack/tutorials-overview.md +2 -2
- package/repo-docs/index.md +23 -45
- package/repo-docs/public/cabloy.png +0 -0
- package/repo-docs/public/cabloy.svg +3 -0
- package/repo-docs/public/favicon.svg +3 -0
- package/repo-docs/reference/repo-scripts.md +6 -1
- package/repo-e2e/specs/home-user-account.spec.ts +311 -207
- package/scripts/bootstrapAgentGovernance.mjs +1 -0
- package/scripts/upgrade.ts +1 -0
- package/vona/packages-cli/cli/package.json +1 -1
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts +4 -10
- package/vona/packages-cli/cli-set-api/package.json +8 -2
- package/vona/packages-cli/cli-set-api/src/index.ts +1 -0
- package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -12
- package/vona/packages-cli/cli-set-api/src/lib/mergeMetaIndex.ts +121 -0
- package/vona/packages-cli/cli-set-api/test/indexSnippets.test.ts +41 -0
- package/vona/packages-cli/cli-set-api/test/mergeMetaIndex.test.ts +78 -0
- package/vona/packages-vona/vona/package.json +1 -1
- package/vona/pnpm-lock.yaml +6 -6
- package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +25 -4
- package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuUniqueness.test.ts +161 -0
- package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +21 -20
- package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +165 -0
- package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +16 -13
- package/vona/src/suite/a-commerce/modules/commerce-promotion/test/promotionIndexes.test.ts +82 -0
- package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +23 -21
- package/vona/src/suite/a-commerce/modules/commerce-trade/test/tradeIndexes.test.ts +86 -0
- package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +379 -376
- package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +60 -0
- package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +164 -1
- package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
- package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
- package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +35 -26
- package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
- package/vona/src/suite-vendor/a-pay/package.json +5 -5
- package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionFiber_.ts +6 -2
- package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transaction_.ts +4 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +3 -1
- package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
- package/vona/src/suite-vendor/a-vona/package.json +1 -1
- /package/repo-docs/{.vitepress/public → public}/CNAME +0 -0
|
@@ -2,15 +2,9 @@
|
|
|
2
2
|
import { readFile, writeFile } from 'node:fs/promises';
|
|
3
3
|
import { basename, resolve } from 'node:path';
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
'implementation-complete',
|
|
9
|
-
'verified',
|
|
10
|
-
'blocked',
|
|
11
|
-
'waived',
|
|
12
|
-
'deferred',
|
|
13
|
-
];
|
|
5
|
+
import { markdownLines, parseAtpIds, parseProgress, parseWbs } from './spec-parser.mjs';
|
|
6
|
+
export { parseAtpIds, parseProgress, parseWbs } from './spec-parser.mjs';
|
|
7
|
+
|
|
14
8
|
const STATUS_COLORS = {
|
|
15
9
|
'not-started': 'var(--blue)',
|
|
16
10
|
'in-progress': 'var(--orange)',
|
|
@@ -38,15 +32,33 @@ const COPY = {
|
|
|
38
32
|
current: 'Current reviewed state',
|
|
39
33
|
remaining: 'WBS items remaining',
|
|
40
34
|
verified: 'verified',
|
|
41
|
-
approved: '
|
|
42
|
-
noNext: 'No WBS
|
|
43
|
-
noReady: 'No
|
|
35
|
+
approved: 'Recorded WBS scope',
|
|
36
|
+
noNext: 'No dependency-ready WBS candidate is recorded.',
|
|
37
|
+
noReady: 'No dependency-ready WBS candidate is recorded.',
|
|
38
|
+
notRecorded: 'not recorded',
|
|
39
|
+
none: 'none',
|
|
40
|
+
candidateCaveat:
|
|
41
|
+
'Dependency readiness is not execution approval; descriptive gates still require review.',
|
|
42
|
+
ganttDescription: (count, reviewed) =>
|
|
43
|
+
`${count} formal WBS tasks. Current derived status reviewed ${reviewed}.`,
|
|
44
|
+
burndownDescription: (total, verified, remaining) =>
|
|
45
|
+
`${total} active WBS items; ${verified} verified; ${remaining} remaining.`,
|
|
46
|
+
phaseSummary: (number, verified, total) => `Phase ${number}: ${verified} / ${total} verified`,
|
|
47
|
+
statuses: {
|
|
48
|
+
'not-started': 'Not started',
|
|
49
|
+
'in-progress': 'In progress',
|
|
50
|
+
'implementation-complete': 'Implementation complete',
|
|
51
|
+
'verified': 'Verified',
|
|
52
|
+
'blocked': 'Blocked',
|
|
53
|
+
'waived': 'Waived',
|
|
54
|
+
'deferred': 'Deferred',
|
|
55
|
+
},
|
|
44
56
|
source: 'Sources',
|
|
45
57
|
reviewed: 'Last reviewed',
|
|
46
58
|
scopeReference: 'Scope-count reference',
|
|
47
|
-
notTime: 'Verified WBS items in
|
|
59
|
+
notTime: 'Verified WBS items in recorded scope — not time',
|
|
48
60
|
remainingAxis: 'WBS items remaining',
|
|
49
|
-
logicalBaseline: 'Logical
|
|
61
|
+
logicalBaseline: 'Logical recorded-scope baseline',
|
|
50
62
|
currentSnapshot: 'Current snapshot',
|
|
51
63
|
remainingShort: 'remaining',
|
|
52
64
|
emptyScope: 'No active WBS items remain in scope.',
|
|
@@ -62,7 +74,7 @@ const COPY = {
|
|
|
62
74
|
'Relative positions and overlap are illustrative; they are not calendar dates, duration, staffing, or concurrency commitments.',
|
|
63
75
|
metadata:
|
|
64
76
|
'Derived from pdp-wbs.md, test-plan.md, and progress.md. This SVG is not planning authority.',
|
|
65
|
-
next: '
|
|
77
|
+
next: 'Dependency-ready WBS candidate',
|
|
66
78
|
},
|
|
67
79
|
zh: {
|
|
68
80
|
ganttTitle: '实施路线图',
|
|
@@ -78,15 +90,32 @@ const COPY = {
|
|
|
78
90
|
current: '当前审查状态',
|
|
79
91
|
remaining: '剩余 WBS 项',
|
|
80
92
|
verified: '已核验',
|
|
81
|
-
approved: '
|
|
82
|
-
noNext: '
|
|
83
|
-
noReady: '
|
|
93
|
+
approved: '已记录 WBS 范围',
|
|
94
|
+
noNext: '当前没有已记录的依赖就绪 WBS 候选项。',
|
|
95
|
+
noReady: '当前没有已记录的依赖就绪 WBS 候选项。',
|
|
96
|
+
notRecorded: '未记录',
|
|
97
|
+
none: '无',
|
|
98
|
+
candidateCaveat: '依赖就绪不代表实施批准;描述性门槛仍需审查。',
|
|
99
|
+
ganttDescription: (count, reviewed) =>
|
|
100
|
+
`${count} 项正式 WBS 工作。当前派生状态的审查日期:${reviewed}。`,
|
|
101
|
+
burndownDescription: (total, verified, remaining) =>
|
|
102
|
+
`${total} 项活跃 WBS 工作;${verified} 项已核验;${remaining} 项剩余。`,
|
|
103
|
+
phaseSummary: (number, verified, total) => `阶段 ${number}:${verified} / ${total} 项已核验`,
|
|
104
|
+
statuses: {
|
|
105
|
+
'not-started': '未开始',
|
|
106
|
+
'in-progress': '进行中',
|
|
107
|
+
'implementation-complete': '实施完成',
|
|
108
|
+
'verified': '已核验',
|
|
109
|
+
'blocked': '受阻',
|
|
110
|
+
'waived': '已豁免',
|
|
111
|
+
'deferred': '已延期',
|
|
112
|
+
},
|
|
84
113
|
source: '来源',
|
|
85
114
|
reviewed: '最后审查日期',
|
|
86
115
|
scopeReference: '范围计数参考',
|
|
87
|
-
notTime: '
|
|
116
|
+
notTime: '已核验的记录范围 WBS 项——不是时间',
|
|
88
117
|
remainingAxis: '剩余 WBS 项',
|
|
89
|
-
logicalBaseline: '
|
|
118
|
+
logicalBaseline: '逻辑上的记录范围基线',
|
|
90
119
|
currentSnapshot: '当前快照',
|
|
91
120
|
remainingShort: '剩余',
|
|
92
121
|
emptyScope: '当前范围内没有活跃 WBS 项。',
|
|
@@ -99,7 +128,7 @@ const COPY = {
|
|
|
99
128
|
howRead: '图表说明',
|
|
100
129
|
caveat: '仅为派生可视化——不是计划、速度趋势或预测',
|
|
101
130
|
chartCaveat: '相对位置和重叠仅为示意;不代表日历日期、工期、人员或并发承诺。',
|
|
102
|
-
next: '
|
|
131
|
+
next: '依赖就绪 WBS 候选项',
|
|
103
132
|
metadata: '派生自 pdp-wbs.md、test-plan.md 和 progress.md。本 SVG 不是计划权威。',
|
|
104
133
|
},
|
|
105
134
|
};
|
|
@@ -119,37 +148,6 @@ function isAsciiLetter(character) {
|
|
|
119
148
|
return (codePoint >= 0x41 && codePoint <= 0x5a) || (codePoint >= 0x61 && codePoint <= 0x7a);
|
|
120
149
|
}
|
|
121
150
|
|
|
122
|
-
function isAsciiDigit(character) {
|
|
123
|
-
if (!character || character.length !== 1) return false;
|
|
124
|
-
const codePoint = character.codePointAt(0);
|
|
125
|
-
return codePoint >= 0x30 && codePoint <= 0x39;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function isAsciiIdentifierCharacter(character) {
|
|
129
|
-
return isAsciiLetter(character) || isAsciiDigit(character);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function isAsciiWordCharacter(character) {
|
|
133
|
-
return isAsciiIdentifierCharacter(character) || character === '_';
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function isHorizontalWhitespace(character) {
|
|
137
|
-
return character === ' ' || character === '\t';
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function skipHorizontalWhitespace(value, index) {
|
|
141
|
-
let cursor = index;
|
|
142
|
-
while (isHorizontalWhitespace(value[cursor])) {
|
|
143
|
-
cursor++;
|
|
144
|
-
}
|
|
145
|
-
return cursor;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function consumeHorizontalWhitespace(value, index) {
|
|
149
|
-
const cursor = skipHorizontalWhitespace(value, index);
|
|
150
|
-
return cursor === index ? null : cursor;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
151
|
function isWideChartCharacter(character) {
|
|
154
152
|
const codePoint = character.codePointAt(0);
|
|
155
153
|
return (
|
|
@@ -165,100 +163,6 @@ function isCjkUnifiedIdeograph(character) {
|
|
|
165
163
|
return codePoint >= 0x3400 && codePoint <= 0x9fff;
|
|
166
164
|
}
|
|
167
165
|
|
|
168
|
-
function isTokenStart(value, index) {
|
|
169
|
-
return index === 0 || !isAsciiWordCharacter(value[index - 1]);
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
function readIdentifier(value, index, prefix, { wildcard = false } = {}) {
|
|
173
|
-
if (!value.startsWith(prefix, index) || !isTokenStart(value, index)) return null;
|
|
174
|
-
let cursor = index + prefix.length;
|
|
175
|
-
let segmentStart = cursor;
|
|
176
|
-
while (isAsciiIdentifierCharacter(value[cursor])) {
|
|
177
|
-
cursor++;
|
|
178
|
-
}
|
|
179
|
-
if (cursor === segmentStart) return null;
|
|
180
|
-
while (value[cursor] === '-') {
|
|
181
|
-
if (wildcard && value[cursor + 1] === '*') {
|
|
182
|
-
return { end: cursor + 2, value: value.slice(index, cursor + 2) };
|
|
183
|
-
}
|
|
184
|
-
const hyphen = cursor;
|
|
185
|
-
segmentStart = ++cursor;
|
|
186
|
-
while (isAsciiIdentifierCharacter(value[cursor])) {
|
|
187
|
-
cursor++;
|
|
188
|
-
}
|
|
189
|
-
if (cursor === segmentStart) {
|
|
190
|
-
return { end: hyphen, value: value.slice(index, hyphen) };
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
return { end: cursor, value: value.slice(index, cursor) };
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
function findIdentifierMatches(value, prefix, options) {
|
|
197
|
-
const identifiers = [];
|
|
198
|
-
let index = 0;
|
|
199
|
-
while (index < value.length) {
|
|
200
|
-
const found = value.indexOf(prefix, index);
|
|
201
|
-
if (found === -1) break;
|
|
202
|
-
const identifier = readIdentifier(value, found, prefix, options);
|
|
203
|
-
if (identifier) {
|
|
204
|
-
identifiers.push(identifier);
|
|
205
|
-
index = identifier.end;
|
|
206
|
-
} else {
|
|
207
|
-
index = found + prefix.length;
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return identifiers;
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function findIdentifiers(value, prefix, options) {
|
|
214
|
-
return findIdentifierMatches(value, prefix, options).map(identifier => identifier.value);
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function parseHeadingPrefix(line, hashes) {
|
|
218
|
-
if (!line.startsWith('#'.repeat(hashes))) return null;
|
|
219
|
-
if (line[hashes] === '#') return null;
|
|
220
|
-
return consumeHorizontalWhitespace(line, hashes);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function parseLabeledValue(line, label) {
|
|
224
|
-
if (line.slice(0, label.length).toLowerCase() !== label.toLowerCase()) return null;
|
|
225
|
-
if (line[label.length] !== ':') return null;
|
|
226
|
-
const value = line.slice(label.length + 1).trim();
|
|
227
|
-
return value || null;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
function readDate(value, index) {
|
|
231
|
-
const date = value.slice(index, index + 10);
|
|
232
|
-
if (
|
|
233
|
-
date.length !== 10 ||
|
|
234
|
-
date[4] !== '-' ||
|
|
235
|
-
date[7] !== '-' ||
|
|
236
|
-
![...date.slice(0, 4)].every(isAsciiDigit) ||
|
|
237
|
-
![...date.slice(5, 7)].every(isAsciiDigit) ||
|
|
238
|
-
![...date.slice(8)].every(isAsciiDigit)
|
|
239
|
-
) {
|
|
240
|
-
return null;
|
|
241
|
-
}
|
|
242
|
-
return date;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function reviewDateFromLine(line) {
|
|
246
|
-
const normalized = line.toLowerCase();
|
|
247
|
-
const labels = [
|
|
248
|
-
{ index: normalized.indexOf('last reviewed'), length: 'last reviewed'.length },
|
|
249
|
-
{ index: line.indexOf('最后审查日期'), length: '最后审查日期'.length },
|
|
250
|
-
];
|
|
251
|
-
for (const label of labels) {
|
|
252
|
-
if (label.index === -1) continue;
|
|
253
|
-
let cursor = skipHorizontalWhitespace(line, label.index + label.length);
|
|
254
|
-
if (line[cursor] === ':' || line[cursor] === ':') cursor++;
|
|
255
|
-
cursor = skipHorizontalWhitespace(line, cursor);
|
|
256
|
-
const date = readDate(line, cursor);
|
|
257
|
-
if (date) return date;
|
|
258
|
-
}
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
166
|
function chartTextWidth(value, fontSize) {
|
|
263
167
|
return [...String(value)].reduce((width, character) => {
|
|
264
168
|
const characterWidth = isWideChartCharacter(character)
|
|
@@ -322,227 +226,10 @@ function languageFromReadme(readme) {
|
|
|
322
226
|
return cjk > latin / 3 ? 'zh' : 'en';
|
|
323
227
|
}
|
|
324
228
|
|
|
325
|
-
function normalizeStatus(value) {
|
|
326
|
-
const normalized = value.trim().replace(/`/g, '').toLowerCase();
|
|
327
|
-
if (!STATUS_ORDER.includes(normalized)) {
|
|
328
|
-
throw new Error(`Unsupported progress status: ${value.trim()}`);
|
|
329
|
-
}
|
|
330
|
-
return normalized;
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function isTableDividerCell(value) {
|
|
334
|
-
const cell = value.trim();
|
|
335
|
-
return Boolean(cell) && [...cell].every(character => character === '-' || character === ':');
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function parseTableRow(line) {
|
|
339
|
-
const trimmed = line.trim();
|
|
340
|
-
if (!trimmed.startsWith('|')) return null;
|
|
341
|
-
const cells = trimmed
|
|
342
|
-
.split('|')
|
|
343
|
-
.slice(1, -1)
|
|
344
|
-
.map(cell => cell.trim());
|
|
345
|
-
return isTableDividerCell(cells[0] ?? '') ? null : cells;
|
|
346
|
-
}
|
|
347
|
-
|
|
348
|
-
function readProgressId(value) {
|
|
349
|
-
return findIdentifiers(value.replace(/`/g, ''), 'WBS-')[0] ?? null;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
function progressColumnIndexes(cells) {
|
|
353
|
-
let id;
|
|
354
|
-
let status;
|
|
355
|
-
for (const [index, cell] of cells.entries()) {
|
|
356
|
-
const label = cell.replace(/`/g, '').trim().toLowerCase();
|
|
357
|
-
if (label === 'wbs id') id = index;
|
|
358
|
-
if (label === 'status') status = index;
|
|
359
|
-
}
|
|
360
|
-
return id === undefined || status === undefined ? null : { id, status };
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
export function parseProgress(markdown) {
|
|
364
|
-
const rows = new Map();
|
|
365
|
-
let reviewed;
|
|
366
|
-
let columns = null;
|
|
367
|
-
for (const line of markdown.split(/\r?\n/)) {
|
|
368
|
-
reviewed ??= reviewDateFromLine(line);
|
|
369
|
-
const cells = parseTableRow(line);
|
|
370
|
-
if (!cells) {
|
|
371
|
-
if (line.trim() && !line.trim().startsWith('|')) columns = null;
|
|
372
|
-
continue;
|
|
373
|
-
}
|
|
374
|
-
const headerColumns = progressColumnIndexes(cells);
|
|
375
|
-
if (headerColumns) {
|
|
376
|
-
columns = headerColumns;
|
|
377
|
-
continue;
|
|
378
|
-
}
|
|
379
|
-
if (!columns) continue;
|
|
380
|
-
const id = readProgressId(cells[columns.id] ?? '');
|
|
381
|
-
if (!id) continue;
|
|
382
|
-
rows.set(id, normalizeStatus(cells[columns.status] ?? ''));
|
|
383
|
-
}
|
|
384
|
-
return { rows, reviewed: reviewed ?? 'not recorded' };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
function parsePhaseHeading(line) {
|
|
388
|
-
const contentStart = parseHeadingPrefix(line, 3);
|
|
389
|
-
if (contentStart === null || !line.startsWith('Phase', contentStart)) return null;
|
|
390
|
-
let cursor = consumeHorizontalWhitespace(line, contentStart + 'Phase'.length);
|
|
391
|
-
if (cursor === null) return null;
|
|
392
|
-
const numberStart = cursor;
|
|
393
|
-
while (isAsciiDigit(line[cursor])) {
|
|
394
|
-
cursor++;
|
|
395
|
-
}
|
|
396
|
-
if (cursor === numberStart) return null;
|
|
397
|
-
cursor = skipHorizontalWhitespace(line, cursor);
|
|
398
|
-
if (line[cursor] !== ':') return null;
|
|
399
|
-
const title = line.slice(cursor + 1).trim();
|
|
400
|
-
if (!title) return null;
|
|
401
|
-
return { number: line.slice(numberStart, cursor).trim(), title };
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
function parseTaskHeading(line) {
|
|
405
|
-
const contentStart = parseHeadingPrefix(line, 4);
|
|
406
|
-
if (contentStart === null) return null;
|
|
407
|
-
const identifier = readIdentifier(line, contentStart, 'WBS-');
|
|
408
|
-
if (!identifier || hasMalformedIdentifierTail(line, identifier.end)) return null;
|
|
409
|
-
const cursor = skipHorizontalWhitespace(line, identifier.end);
|
|
410
|
-
if (line[cursor] !== ':') return null;
|
|
411
|
-
const title = line.slice(cursor + 1).trim();
|
|
412
|
-
if (!title) return null;
|
|
413
|
-
return { id: identifier.value, title };
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function dependencyFromLines(lines) {
|
|
417
|
-
for (const line of lines) {
|
|
418
|
-
const dependency = parseLabeledValue(line.trim(), 'Dependencies');
|
|
419
|
-
if (dependency) return dependency;
|
|
420
|
-
}
|
|
421
|
-
return null;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function isDeferred(lines) {
|
|
425
|
-
for (const line of lines) {
|
|
426
|
-
const normalized = line.trim().replaceAll('`', '').toLowerCase();
|
|
427
|
-
if (normalized === 'deferred scope' || parseLabeledValue(normalized, 'status') === 'deferred') {
|
|
428
|
-
return true;
|
|
429
|
-
}
|
|
430
|
-
}
|
|
431
|
-
return false;
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
function isIdentifierContinuation(character) {
|
|
435
|
-
return character === '-' || isAsciiIdentifierCharacter(character);
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function hasMalformedIdentifierTail(value, end) {
|
|
439
|
-
return value[end] === '-' && (value[end + 1] === ':' || isHorizontalWhitespace(value[end + 1]));
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
function atpReferences(lines) {
|
|
443
|
-
const ids = new Set();
|
|
444
|
-
for (const line of lines) {
|
|
445
|
-
for (const identifier of findIdentifierMatches(line, 'ATP-')) {
|
|
446
|
-
if (!isIdentifierContinuation(line[identifier.end])) ids.add(identifier.value);
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
return [...ids];
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
export function parseWbs(markdown) {
|
|
453
|
-
const lines = markdown.split(/\r?\n/);
|
|
454
|
-
const phaseStarts = [];
|
|
455
|
-
for (const [index, line] of lines.entries()) {
|
|
456
|
-
const phase = parsePhaseHeading(line);
|
|
457
|
-
if (phase) phaseStarts.push({ ...phase, index });
|
|
458
|
-
}
|
|
459
|
-
if (!phaseStarts.length) {
|
|
460
|
-
throw new Error('No formal `### Phase <number>:` headings were found in pdp-wbs.md.');
|
|
461
|
-
}
|
|
462
|
-
const phases = phaseStarts.map((phase, phaseIndex) => {
|
|
463
|
-
const bodyLines = lines.slice(phase.index + 1, phaseStarts[phaseIndex + 1]?.index);
|
|
464
|
-
const phaseDependency = dependencyFromLines(bodyLines) ?? 'none';
|
|
465
|
-
const taskStarts = [];
|
|
466
|
-
for (const [index, line] of bodyLines.entries()) {
|
|
467
|
-
const task = parseTaskHeading(line);
|
|
468
|
-
if (task) taskStarts.push({ ...task, index });
|
|
469
|
-
}
|
|
470
|
-
const tasks = taskStarts.map((task, taskIndex) => {
|
|
471
|
-
const taskBody = bodyLines.slice(task.index + 1, taskStarts[taskIndex + 1]?.index);
|
|
472
|
-
return {
|
|
473
|
-
id: task.id,
|
|
474
|
-
title: task.title,
|
|
475
|
-
dependency: dependencyFromLines(taskBody) ?? phaseDependency,
|
|
476
|
-
deferred: isDeferred(taskBody),
|
|
477
|
-
atps: atpReferences(taskBody),
|
|
478
|
-
};
|
|
479
|
-
});
|
|
480
|
-
return { number: phase.number, title: phase.title, dependency: phaseDependency, tasks };
|
|
481
|
-
});
|
|
482
|
-
const tasks = phases.flatMap(phase =>
|
|
483
|
-
phase.tasks.map(task => {
|
|
484
|
-
task.phase = { number: phase.number, title: phase.title };
|
|
485
|
-
return task;
|
|
486
|
-
}),
|
|
487
|
-
);
|
|
488
|
-
if (!tasks.length) {
|
|
489
|
-
throw new Error('No formal `#### WBS-…:` tasks were found in pdp-wbs.md.');
|
|
490
|
-
}
|
|
491
|
-
const ids = new Set(tasks.map(task => task.id));
|
|
492
|
-
for (const task of tasks) {
|
|
493
|
-
for (const dependency of findIdentifiers(task.dependency, 'WBS-', { wildcard: true })) {
|
|
494
|
-
if (dependency.endsWith('-*')) {
|
|
495
|
-
const prefix = dependency.slice(0, -1);
|
|
496
|
-
if (![...ids].some(id => id.startsWith(prefix))) {
|
|
497
|
-
throw new Error(`${task.id} depends on unknown WBS task group ${dependency}.`);
|
|
498
|
-
}
|
|
499
|
-
} else if (!ids.has(dependency)) {
|
|
500
|
-
throw new Error(`${task.id} depends on unknown WBS task ${dependency}.`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
return { phases, tasks };
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function readStrictAtpId(value) {
|
|
508
|
-
const trimmed = value.trim();
|
|
509
|
-
const unwrapped =
|
|
510
|
-
trimmed.startsWith('`') && trimmed.endsWith('`') ? trimmed.slice(1, -1) : trimmed;
|
|
511
|
-
const identifier = readIdentifier(unwrapped, 0, 'ATP-');
|
|
512
|
-
return identifier?.end === unwrapped.length ? identifier.value : null;
|
|
513
|
-
}
|
|
514
|
-
|
|
515
|
-
export function parseAtpIds(markdown) {
|
|
516
|
-
const ids = new Set();
|
|
517
|
-
for (const line of markdown.split(/\r?\n/)) {
|
|
518
|
-
const cells = parseTableRow(line);
|
|
519
|
-
if (cells?.[0]) {
|
|
520
|
-
const id = readStrictAtpId(cells[0]);
|
|
521
|
-
if (id) ids.add(id);
|
|
522
|
-
continue;
|
|
523
|
-
}
|
|
524
|
-
for (let hashes = 2; hashes <= line.length; hashes++) {
|
|
525
|
-
const contentStart = parseHeadingPrefix(line, hashes);
|
|
526
|
-
if (contentStart === null) continue;
|
|
527
|
-
const content = line.slice(contentStart).trimStart();
|
|
528
|
-
const id = readIdentifier(content, content.startsWith('`') ? 1 : 0, 'ATP-');
|
|
529
|
-
if (!id) break;
|
|
530
|
-
const after = content.slice(id.end);
|
|
531
|
-
if (content.startsWith('`')) {
|
|
532
|
-
if (after.startsWith('`')) ids.add(id.value);
|
|
533
|
-
} else if (!after || isHorizontalWhitespace(after[0])) {
|
|
534
|
-
ids.add(id.value);
|
|
535
|
-
}
|
|
536
|
-
break;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
539
|
-
return ids;
|
|
540
|
-
}
|
|
541
|
-
|
|
542
229
|
export function createChartModel({ readme, wbs, progress, testPlan }) {
|
|
543
230
|
const language = languageFromReadme(readme);
|
|
544
231
|
const atpIds = parseAtpIds(testPlan);
|
|
545
|
-
const { phases, tasks } = parseWbs(wbs);
|
|
232
|
+
const { phases, tasks, expandedEdges } = parseWbs(wbs);
|
|
546
233
|
const { rows: statusRows, reviewed } = parseProgress(progress);
|
|
547
234
|
const unknownProgress = [...statusRows.keys()].filter(id => !tasks.some(task => task.id === id));
|
|
548
235
|
if (unknownProgress.length) {
|
|
@@ -553,7 +240,14 @@ export function createChartModel({ readme, wbs, progress, testPlan }) {
|
|
|
553
240
|
for (const task of tasks) {
|
|
554
241
|
if (!statusRows.has(task.id))
|
|
555
242
|
throw new Error(`${task.id} has no corresponding progress.md status row.`);
|
|
556
|
-
|
|
243
|
+
const progressStatus = statusRows.get(task.id);
|
|
244
|
+
if (task.status !== undefined && task.status !== progressStatus) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
`${task.id} has explicit pdp-wbs.md Status ${task.status} but progress status ${progressStatus}.`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
task.recordedStatus = task.status;
|
|
250
|
+
task.status = progressStatus;
|
|
557
251
|
if (task.deferred && task.status !== 'deferred') {
|
|
558
252
|
throw new Error(
|
|
559
253
|
`${task.id} is marked deferred in pdp-wbs.md but has progress status ${task.status}.`,
|
|
@@ -574,8 +268,9 @@ export function createChartModel({ readme, wbs, progress, testPlan }) {
|
|
|
574
268
|
tasks,
|
|
575
269
|
activeTasks,
|
|
576
270
|
deferredTasks,
|
|
271
|
+
expandedEdges,
|
|
577
272
|
verified,
|
|
578
|
-
reviewed,
|
|
273
|
+
reviewed: reviewed === 'not recorded' ? COPY[language].notRecorded : reviewed,
|
|
579
274
|
};
|
|
580
275
|
}
|
|
581
276
|
|
|
@@ -588,27 +283,11 @@ function style() {
|
|
|
588
283
|
}
|
|
589
284
|
|
|
590
285
|
function statusLabel(status, language) {
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
'verified': 'Verified',
|
|
597
|
-
'blocked': 'Blocked',
|
|
598
|
-
'waived': 'Waived',
|
|
599
|
-
'deferred': 'Deferred',
|
|
600
|
-
},
|
|
601
|
-
zh: {
|
|
602
|
-
'not-started': '未开始',
|
|
603
|
-
'in-progress': '进行中',
|
|
604
|
-
'implementation-complete': '实施完成',
|
|
605
|
-
'verified': '已核验',
|
|
606
|
-
'blocked': '受阻',
|
|
607
|
-
'waived': '已豁免',
|
|
608
|
-
'deferred': '已延期',
|
|
609
|
-
},
|
|
610
|
-
};
|
|
611
|
-
return labels[language][status];
|
|
286
|
+
return COPY[language].statuses[status];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function dependencyLabel(task, copy) {
|
|
290
|
+
return /^`?none`?\.?$/i.test(task.dependency.trim()) ? copy.none : task.dependency;
|
|
612
291
|
}
|
|
613
292
|
|
|
614
293
|
function statusIcon(x, y, status) {
|
|
@@ -654,10 +333,15 @@ export function renderGantt(model, suiteName) {
|
|
|
654
333
|
const phaseStart = index === 0 || tasks[index - 1].phase.number !== task.phase.number;
|
|
655
334
|
const descriptionLines = phaseStart ? wrapChartText(task.phase.title, 30) : [];
|
|
656
335
|
const workLines = wrapChartText(task.title, workMaxWidth, value => chartTextWidth(value, 10));
|
|
657
|
-
const dependencyLines = wrapChartText(task
|
|
336
|
+
const dependencyLines = wrapChartText(dependencyLabel(task, t), dependencyMaxWidth, value =>
|
|
658
337
|
chartTextWidth(value, 9),
|
|
659
338
|
);
|
|
660
|
-
const
|
|
339
|
+
const statusLines = wrapChartText(
|
|
340
|
+
statusLabel(task.status, language),
|
|
341
|
+
plotX - statusX - 27,
|
|
342
|
+
value => chartTextWidth(value, 9),
|
|
343
|
+
);
|
|
344
|
+
const contentLines = Math.max(workLines.length, dependencyLines.length, statusLines.length);
|
|
661
345
|
const taskRowH = Math.max(rowH, contentLines * phaseLineH + 22);
|
|
662
346
|
const phaseLabelY = phaseStart ? nextY : undefined;
|
|
663
347
|
const y = phaseStart ? nextY + 23 + descriptionLines.length * phaseLineH : nextY;
|
|
@@ -672,6 +356,7 @@ export function renderGantt(model, suiteName) {
|
|
|
672
356
|
descriptionLines,
|
|
673
357
|
workLines,
|
|
674
358
|
dependencyLines,
|
|
359
|
+
statusLines,
|
|
675
360
|
};
|
|
676
361
|
});
|
|
677
362
|
const contentBottom = layout.at(-1)?.separatorY ?? rowY;
|
|
@@ -688,6 +373,7 @@ export function renderGantt(model, suiteName) {
|
|
|
688
373
|
descriptionLines,
|
|
689
374
|
workLines,
|
|
690
375
|
dependencyLines,
|
|
376
|
+
statusLines,
|
|
691
377
|
}) => {
|
|
692
378
|
const barX = plotX + (index / Math.max(tasks.length, 1)) * (plotW - 90);
|
|
693
379
|
const barW = task.status === 'verified' ? 58 : 42;
|
|
@@ -702,26 +388,18 @@ export function renderGantt(model, suiteName) {
|
|
|
702
388
|
dependencyLines,
|
|
703
389
|
phaseLineH,
|
|
704
390
|
);
|
|
705
|
-
return `<g class="row" tabindex="0" aria-label="${escapeXml(`${task.id}: ${task.title}; ${statusLabel(task.status, language)}`)}"><title>${escapeXml(`${task.id}: ${task.title};
|
|
391
|
+
return `<g class="row" tabindex="0" aria-label="${escapeXml(`${task.id}: ${task.title}; ${t.dependency}: ${dependencyLabel(task, t)}; ${statusLabel(task.status, language)}`)}"><title>${escapeXml(`${task.id}: ${task.title}; ${t.dependency}: ${dependencyLabel(task, t)}; ${statusLabel(task.status, language)}`)}</title><line class="grid" x1="72" y1="${separatorY}" x2="1500" y2="${separatorY}"/>${phaseHeader}<text class="num" x="${wbsX}" y="${y}">${escapeXml(task.id)}</text>${renderChartTextLines('task', workX, y, workLines, phaseLineH)}${dependencyText}${statusIcon(statusX + 5, y - 4, task.status)}${renderChartTextLines('small', statusX + 17, y, statusLines, phaseLineH)}<rect x="${barX.toFixed(1)}" y="${y - 15}" width="${barW}" height="18" rx="4" fill="${STATUS_COLORS[task.status]}"/></g>`;
|
|
706
392
|
},
|
|
707
393
|
)
|
|
708
394
|
.join('\n');
|
|
709
395
|
const completed = new Set(tasks.filter(task => task.status === 'verified').map(task => task.id));
|
|
710
|
-
const dependencyIds = task =>
|
|
711
|
-
findIdentifiers(task.dependency, 'WBS-', { wildcard: true }).flatMap(value => {
|
|
712
|
-
if (!value.endsWith('-*')) return [value];
|
|
713
|
-
const prefix = value.slice(0, -1);
|
|
714
|
-
return tasks
|
|
715
|
-
.filter(candidate => candidate.id.startsWith(prefix))
|
|
716
|
-
.map(candidate => candidate.id);
|
|
717
|
-
});
|
|
718
396
|
const nextTask = tasks.find(
|
|
719
397
|
task =>
|
|
720
398
|
task.status !== 'verified' &&
|
|
721
399
|
task.status !== 'deferred' &&
|
|
722
400
|
task.status !== 'waived' &&
|
|
723
401
|
task.status !== 'blocked' &&
|
|
724
|
-
|
|
402
|
+
model.expandedEdges.get(task.id).every(id => completed.has(id)),
|
|
725
403
|
);
|
|
726
404
|
const nextLabel = nextTask
|
|
727
405
|
? `${nextTask.id}: ${nextTask.title}`
|
|
@@ -733,10 +411,10 @@ export function renderGantt(model, suiteName) {
|
|
|
733
411
|
<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>
|
|
734
412
|
<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"/>
|
|
735
413
|
${rows}
|
|
736
|
-
<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>`;
|
|
414
|
+
<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><text class="small" x="72" y="${footerY + 48}">${escapeXml(t.candidateCaveat)}</text>`;
|
|
737
415
|
return svgDocument(
|
|
738
416
|
`${suiteName} ${t.ganttTitle}`,
|
|
739
|
-
`${t.ganttTitle}. ${tasks.length
|
|
417
|
+
`${t.ganttTitle}. ${t.ganttDescription(tasks.length, reviewed)} ${t.chartCaveat} ${t.candidateCaveat}`,
|
|
740
418
|
body,
|
|
741
419
|
height,
|
|
742
420
|
t.metadata,
|
|
@@ -747,7 +425,9 @@ export function renderBurndown(model, suiteName) {
|
|
|
747
425
|
const { copy: t, phases, activeTasks, deferredTasks, verified, reviewed, language } = model;
|
|
748
426
|
const total = activeTasks.length;
|
|
749
427
|
const remaining = total - verified;
|
|
750
|
-
const
|
|
428
|
+
const phaseBottom = 836 + Math.max(0, phases.length - 1) * 28;
|
|
429
|
+
const footerY = Math.max(976, phaseBottom + 50);
|
|
430
|
+
const height = footerY + 74;
|
|
751
431
|
const x0 = 190;
|
|
752
432
|
const x1 = 910;
|
|
753
433
|
const y0 = 410;
|
|
@@ -763,23 +443,32 @@ export function renderBurndown(model, suiteName) {
|
|
|
763
443
|
.map((phase, index) => {
|
|
764
444
|
const phaseTasks = phase.tasks.filter(task => !task.deferred && task.status !== 'deferred');
|
|
765
445
|
const phaseVerified = phaseTasks.filter(task => task.status === 'verified').length;
|
|
766
|
-
const y =
|
|
446
|
+
const y = 832 + index * 28;
|
|
767
447
|
const width = phaseTasks.length ? (phaseVerified / phaseTasks.length) * 210 : 0;
|
|
768
|
-
return `<g class="row" tabindex="0" aria-label="${escapeXml(
|
|
448
|
+
return `<g class="row" tabindex="0" aria-label="${escapeXml(t.phaseSummary(phase.number, phaseVerified, phaseTasks.length))}"><title>${escapeXml(`${t.phaseSummary(phase.number, phaseVerified, phaseTasks.length)} · ${phase.title}`)}</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>`;
|
|
769
449
|
})
|
|
770
450
|
.join('\n');
|
|
451
|
+
let explanationY = 412;
|
|
452
|
+
const explanation = [t.formula, t.noHistory, t.caveat, t.chartCaveat]
|
|
453
|
+
.map(value => {
|
|
454
|
+
const lines = wrapChartText(value, 370, text => chartTextWidth(text, 11));
|
|
455
|
+
const text = renderChartTextLines('caption', 1070, explanationY, lines, 18);
|
|
456
|
+
explanationY += lines.length * 18 + 18;
|
|
457
|
+
return text;
|
|
458
|
+
})
|
|
459
|
+
.join('');
|
|
771
460
|
const body = `<rect class="canvas" width="1600" height="${height}"/><rect class="card" x="32" y="28" width="1536" height="${height - 56}" rx="14"/>
|
|
772
461
|
<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>
|
|
773
462
|
<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>
|
|
774
463
|
<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>
|
|
775
464
|
<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}"/>
|
|
776
465
|
<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>
|
|
777
|
-
<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
|
|
778
|
-
<rect class="card" x="72" y="758" width="1428" height="${
|
|
466
|
+
<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>${explanation}
|
|
467
|
+
<rect class="card" x="72" y="758" width="1428" height="${phaseBottom + 24 - 758}" 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}
|
|
779
468
|
<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>`;
|
|
780
469
|
return svgDocument(
|
|
781
470
|
`${suiteName} ${t.burndownTitle}`,
|
|
782
|
-
`${t.burndownTitle}. ${total
|
|
471
|
+
`${t.burndownTitle}. ${t.burndownDescription(total, verified, remaining)} ${t.noHistory}`,
|
|
783
472
|
body,
|
|
784
473
|
height,
|
|
785
474
|
t.metadata,
|
|
@@ -787,7 +476,7 @@ export function renderBurndown(model, suiteName) {
|
|
|
787
476
|
}
|
|
788
477
|
|
|
789
478
|
function suiteNameFromReadme(readme, fallback) {
|
|
790
|
-
for (const line of readme
|
|
479
|
+
for (const { text: line } of markdownLines(readme)) {
|
|
791
480
|
if (!line.startsWith('# ') || line.startsWith('## ')) continue;
|
|
792
481
|
let title = line.slice(2).trim();
|
|
793
482
|
for (const suffix of ['Internal Planning', '内部规划']) {
|