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.
Files changed (91) hide show
  1. package/.cabloy-version +1 -1
  2. package/.github/workflows/agent-governance.yml +1 -0
  3. package/CHANGELOG.md +22 -0
  4. package/README.md +13 -24
  5. package/package.json +2 -1
  6. package/repo-agent-governance/managed-assets.json +138 -33
  7. package/repo-agent-governance/scripts/pack-check.mjs +17 -0
  8. package/repo-agent-governance/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +1 -0
  9. package/repo-agent-governance/skills/cabloy-domain-planning/SKILL.md +5 -3
  10. package/repo-agent-governance/skills/cabloy-spec-execution/SKILL.md +13 -8
  11. package/repo-agent-governance/skills/cabloy-spec-execution/evals/evals.json +108 -12
  12. package/repo-agent-governance/skills/cabloy-spec-execution/evals/files/scenarios.json +84 -0
  13. package/repo-agent-governance/skills/cabloy-spec-execution/evals/protocol.md +9 -0
  14. package/repo-agent-governance/skills/cabloy-spec-execution/references/execution-protocol.md +21 -5
  15. package/repo-agent-governance/skills/cabloy-spec-execution/references/status-and-evidence.md +5 -3
  16. package/repo-agent-governance/skills/cabloy-spec-generation/SKILL.md +78 -168
  17. package/repo-agent-governance/skills/cabloy-spec-generation/evals/evals.json +165 -17
  18. package/repo-agent-governance/skills/cabloy-spec-generation/evals/files/scenarios.json +114 -0
  19. package/repo-agent-governance/skills/cabloy-spec-generation/evals/protocol.md +44 -0
  20. package/repo-agent-governance/skills/cabloy-spec-generation/references/canonical-spec-input.md +145 -0
  21. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-aware-discovery.md +55 -57
  22. package/repo-agent-governance/skills/cabloy-spec-generation/references/repo-specs-document-set.md +6 -4
  23. package/repo-agent-governance/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +12 -8
  24. package/repo-agent-governance/tests/governance.test.mjs +10 -1
  25. package/repo-agent-governance/tests/spec-audit.test.mjs +255 -0
  26. package/repo-agent-governance/tools/spec-audit/audit.mjs +427 -0
  27. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +97 -408
  28. package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.test.mjs +462 -1
  29. package/repo-agent-governance/tools/spec-charts/spec-parser.mjs +561 -0
  30. package/repo-docs/.vitepress/config.mjs +54 -29
  31. package/repo-docs/ai/playbook-spec-execution.md +10 -4
  32. package/repo-docs/ai/playbook-spec-generation.md +40 -3
  33. package/repo-docs/ai/skills.md +3 -3
  34. package/repo-docs/backend/controller-aop-guide.md +10 -0
  35. package/repo-docs/backend/field-indexes.md +25 -0
  36. package/repo-docs/blogs/cabloy-fullstack-resource-addressing/index.md +1 -1
  37. package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
  38. package/repo-docs/fullstack/development-history.md +24 -0
  39. package/repo-docs/fullstack/introduction.md +25 -156
  40. package/repo-docs/fullstack/quickstart.md +1 -1
  41. package/repo-docs/fullstack/ssr-entry-modes.md +52 -0
  42. package/repo-docs/fullstack/ssr-site-and-flavor-setup.md +3 -1
  43. package/repo-docs/fullstack/tutorial-5-backend-contract-sharing.md +13 -7
  44. package/repo-docs/fullstack/tutorial-6-one-contract-four-uses.md +11 -15
  45. package/repo-docs/fullstack/tutorials-overview.md +2 -2
  46. package/repo-docs/index.md +23 -45
  47. package/repo-docs/public/cabloy.png +0 -0
  48. package/repo-docs/public/cabloy.svg +3 -0
  49. package/repo-docs/public/favicon.svg +3 -0
  50. package/repo-docs/reference/repo-scripts.md +6 -1
  51. package/repo-e2e/specs/home-user-account.spec.ts +311 -207
  52. package/scripts/bootstrapAgentGovernance.mjs +1 -0
  53. package/scripts/upgrade.ts +1 -0
  54. package/vona/packages-cli/cli/package.json +1 -1
  55. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudBasic/snippets/2-meta.index.ts +4 -10
  56. package/vona/packages-cli/cli-set-api/cli/templates/tools/crudStart/snippets/2-meta.index.ts +4 -10
  57. package/vona/packages-cli/cli-set-api/package.json +8 -2
  58. package/vona/packages-cli/cli-set-api/src/index.ts +1 -0
  59. package/vona/packages-cli/cli-set-api/src/lib/bean/cli.tools.masterDetail.ts +9 -12
  60. package/vona/packages-cli/cli-set-api/src/lib/mergeMetaIndex.ts +121 -0
  61. package/vona/packages-cli/cli-set-api/test/indexSnippets.test.ts +41 -0
  62. package/vona/packages-cli/cli-set-api/test/mergeMetaIndex.test.ts +78 -0
  63. package/vona/packages-vona/vona/package.json +1 -1
  64. package/vona/pnpm-lock.yaml +6 -6
  65. package/vona/src/suite/a-commerce/modules/commerce-catalog/src/service/sku.ts +25 -4
  66. package/vona/src/suite/a-commerce/modules/commerce-catalog/test/skuUniqueness.test.ts +161 -0
  67. package/vona/src/suite/a-commerce/modules/commerce-payment/src/bean/meta.index.ts +21 -20
  68. package/vona/src/suite/a-commerce/modules/commerce-payment/test/paymentIndexes.test.ts +165 -0
  69. package/vona/src/suite/a-commerce/modules/commerce-promotion/src/bean/meta.index.ts +16 -13
  70. package/vona/src/suite/a-commerce/modules/commerce-promotion/test/promotionIndexes.test.ts +82 -0
  71. package/vona/src/suite/a-commerce/modules/commerce-trade/src/bean/meta.index.ts +23 -21
  72. package/vona/src/suite/a-commerce/modules/commerce-trade/test/tradeIndexes.test.ts +86 -0
  73. package/vona/src/suite/a-home/modules/home-user/src/.metadata/index.ts +379 -376
  74. package/vona/src/suite/a-home/modules/home-user/src/controller/passportTest.ts +60 -0
  75. package/vona/src/suite/a-home/modules/home-user/test/passportTest.test.ts +164 -1
  76. package/vona/src/suite-vendor/a-cabloy/modules/a-rbac/package.json +1 -1
  77. package/vona/src/suite-vendor/a-cabloy/package.json +2 -2
  78. package/vona/src/suite-vendor/a-pay/modules/a-pay/package.json +1 -1
  79. package/vona/src/suite-vendor/a-pay/modules/a-pay/src/bean/meta.index.ts +35 -26
  80. package/vona/src/suite-vendor/a-pay/modules/pay-mock/package.json +1 -1
  81. package/vona/src/suite-vendor/a-pay/modules/pay-paypal/package.json +1 -1
  82. package/vona/src/suite-vendor/a-pay/modules/pay-stripe/package.json +1 -1
  83. package/vona/src/suite-vendor/a-pay/package.json +5 -5
  84. package/vona/src/suite-vendor/a-vona/modules/a-orm/package.json +1 -1
  85. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transactionFiber_.ts +6 -2
  86. package/vona/src/suite-vendor/a-vona/modules/a-orm/src/service/transaction_.ts +4 -1
  87. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/package.json +1 -1
  88. package/vona/src/suite-vendor/a-vona/modules/a-ormutils/src/lib/columns.ts +3 -1
  89. package/vona/src/suite-vendor/a-vona/modules/a-permission/package.json +1 -1
  90. package/vona/src/suite-vendor/a-vona/package.json +1 -1
  91. /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
- const STATUS_ORDER = [
6
- 'not-started',
7
- 'in-progress',
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: 'Approved WBS scope',
42
- noNext: 'No WBS item is ready to execute after the current scope.',
43
- noReady: 'No next WBS item is ready to execute.',
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 approved scope — not time',
59
+ notTime: 'Verified WBS items in recorded scope — not time',
48
60
  remainingAxis: 'WBS items remaining',
49
- logicalBaseline: 'Logical approved-scope baseline',
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: 'Next executable WBS item',
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: '已批准 WBS 范围',
82
- noNext: '选择其依赖均已满足的最早未核验 WBS 项。',
83
- noReady: '当前没有可执行的下一项 WBS 工作。',
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: '已核验的批准范围 WBS 项——不是时间',
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: '下一项 WBS 工作',
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
- task.status = statusRows.get(task.id);
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
- const labels = {
592
- en: {
593
- 'not-started': 'Not started',
594
- 'in-progress': 'In progress',
595
- 'implementation-complete': 'Implementation complete',
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.dependency, dependencyMaxWidth, value =>
336
+ const dependencyLines = wrapChartText(dependencyLabel(task, t), dependencyMaxWidth, value =>
658
337
  chartTextWidth(value, 9),
659
338
  );
660
- const contentLines = Math.max(workLines.length, dependencyLines.length);
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}; dependencies: ${task.dependency}; ${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)}<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>`;
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
- dependencyIds(task).every(id => completed.has(id)),
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} formal WBS tasks. Current derived status reviewed ${reviewed}. ${t.chartCaveat}`,
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 height = 1050;
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 = 805 + index * 28;
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(`${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>`;
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><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>
778
- <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}
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} active WBS items; ${verified} verified; ${remaining} remaining. ${t.noHistory}`,
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.split(/\r?\n/)) {
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', '内部规划']) {