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
|
@@ -0,0 +1,561 @@
|
|
|
1
|
+
// Shared formal planning-record parsers. Source locations use one-based Markdown lines.
|
|
2
|
+
export const STATUS_ORDER = [
|
|
3
|
+
'not-started',
|
|
4
|
+
'in-progress',
|
|
5
|
+
'implementation-complete',
|
|
6
|
+
'verified',
|
|
7
|
+
'blocked',
|
|
8
|
+
'waived',
|
|
9
|
+
'deferred',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function isAsciiLetter(character) {
|
|
13
|
+
if (!character || character.length !== 1) return false;
|
|
14
|
+
const codePoint = character.codePointAt(0);
|
|
15
|
+
return (codePoint >= 0x41 && codePoint <= 0x5a) || (codePoint >= 0x61 && codePoint <= 0x7a);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function isAsciiDigit(character) {
|
|
19
|
+
if (!character || character.length !== 1) return false;
|
|
20
|
+
const codePoint = character.codePointAt(0);
|
|
21
|
+
return codePoint >= 0x30 && codePoint <= 0x39;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isAsciiIdentifierCharacter(character) {
|
|
25
|
+
return isAsciiLetter(character) || isAsciiDigit(character);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isAsciiWordCharacter(character) {
|
|
29
|
+
return isAsciiIdentifierCharacter(character) || character === '_';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isTokenStart(value, index) {
|
|
33
|
+
return index === 0 || !isAsciiWordCharacter(value[index - 1]);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function readIdentifier(value, index, prefix, { wildcard = false } = {}) {
|
|
37
|
+
if (!value.startsWith(prefix, index) || !isTokenStart(value, index)) return null;
|
|
38
|
+
let cursor = index + prefix.length;
|
|
39
|
+
let segmentStart = cursor;
|
|
40
|
+
while (isAsciiIdentifierCharacter(value[cursor])) cursor++;
|
|
41
|
+
if (cursor === segmentStart) return null;
|
|
42
|
+
while (value[cursor] === '-') {
|
|
43
|
+
if (wildcard && value[cursor + 1] === '*') {
|
|
44
|
+
return { start: index, end: cursor + 2, value: value.slice(index, cursor + 2) };
|
|
45
|
+
}
|
|
46
|
+
const hyphen = cursor;
|
|
47
|
+
segmentStart = ++cursor;
|
|
48
|
+
while (isAsciiIdentifierCharacter(value[cursor])) cursor++;
|
|
49
|
+
if (cursor === segmentStart) {
|
|
50
|
+
return { start: index, end: hyphen, value: value.slice(index, hyphen) };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return { start: index, end: cursor, value: value.slice(index, cursor) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function findIdentifierMatches(value, prefix, options) {
|
|
57
|
+
const identifiers = [];
|
|
58
|
+
let index = 0;
|
|
59
|
+
while (index < value.length) {
|
|
60
|
+
const found = value.indexOf(prefix, index);
|
|
61
|
+
if (found === -1) break;
|
|
62
|
+
const identifier = readIdentifier(value, found, prefix, options);
|
|
63
|
+
if (
|
|
64
|
+
identifier &&
|
|
65
|
+
!isAsciiWordCharacter(value[identifier.end]) &&
|
|
66
|
+
value[identifier.end] !== '-'
|
|
67
|
+
) {
|
|
68
|
+
identifiers.push(identifier);
|
|
69
|
+
index = identifier.end;
|
|
70
|
+
} else {
|
|
71
|
+
index = found + prefix.length;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return identifiers;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function findIdentifiers(value, prefix, options) {
|
|
78
|
+
return findIdentifierMatches(value, prefix, options).map(identifier => identifier.value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function markdownLines(markdown) {
|
|
82
|
+
const lines = [];
|
|
83
|
+
let fence = null;
|
|
84
|
+
let comment = false;
|
|
85
|
+
for (const [index, raw] of markdown.split(/\r?\n/).entries()) {
|
|
86
|
+
if (fence) {
|
|
87
|
+
const close = raw.match(/^ {0,3}(`+|~+)\s*$/);
|
|
88
|
+
if (close && close[1][0] === fence.character && close[1].length >= fence.length) fence = null;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
let text = '';
|
|
92
|
+
let cursor = 0;
|
|
93
|
+
while (cursor < raw.length) {
|
|
94
|
+
if (comment) {
|
|
95
|
+
const end = raw.indexOf('-->', cursor);
|
|
96
|
+
if (end === -1) break;
|
|
97
|
+
cursor = end + 3;
|
|
98
|
+
comment = false;
|
|
99
|
+
} else {
|
|
100
|
+
const start = raw.indexOf('<!--', cursor);
|
|
101
|
+
if (start === -1) {
|
|
102
|
+
text += raw.slice(cursor);
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
text += `${raw.slice(cursor, start)} `;
|
|
106
|
+
cursor = start + 4;
|
|
107
|
+
comment = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const open = text.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
111
|
+
if (open && !(open[1][0] === '`' && text.slice(open[0].length).includes('`'))) {
|
|
112
|
+
fence = { character: open[1][0], length: open[1].length };
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const heading = text.match(/^ {0,3}(#{1,6})[ \t]/);
|
|
116
|
+
let headingText = heading ? text.slice(heading[0].length).trim() : '';
|
|
117
|
+
if (headingText.endsWith('#')) headingText = headingText.replace(/#+$/, '').trimEnd();
|
|
118
|
+
lines.push({
|
|
119
|
+
text,
|
|
120
|
+
line: index + 1,
|
|
121
|
+
heading: heading && headingText ? { level: heading[1].length, text: headingText } : null,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function location(record, file) {
|
|
128
|
+
return { line: record.line, ...(file ? { file } : {}) };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function atLocation(record, file) {
|
|
132
|
+
return `${file ?? 'Markdown'}:${record.line}`;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseTableRow(line) {
|
|
136
|
+
const trimmed = line.trim();
|
|
137
|
+
if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return null;
|
|
138
|
+
const cells = trimmed
|
|
139
|
+
.split('|')
|
|
140
|
+
.slice(1, -1)
|
|
141
|
+
.map(cell => cell.trim());
|
|
142
|
+
return cells;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isTableDivider(cells) {
|
|
146
|
+
return cells.length > 0 && cells.every(cell => /^:?-+:?$/.test(cell.replace(/\s/g, '')));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function strictIdentifier(value, prefix) {
|
|
150
|
+
const unwrapped = value.trim().replace(/^(`|\*\*)(.*?)\1$/, '$2');
|
|
151
|
+
const identifier = readIdentifier(unwrapped, 0, prefix);
|
|
152
|
+
return identifier?.end === unwrapped.length ? identifier.value : null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function leadingDefinition(value, prefix) {
|
|
156
|
+
let token;
|
|
157
|
+
let end;
|
|
158
|
+
const delimiter = value.startsWith('**') ? '**' : value.startsWith('`') ? '`' : null;
|
|
159
|
+
if (delimiter) {
|
|
160
|
+
const close = value.indexOf(delimiter, delimiter.length);
|
|
161
|
+
if (close === -1) return null;
|
|
162
|
+
token = value.slice(delimiter.length, close);
|
|
163
|
+
end = close + delimiter.length;
|
|
164
|
+
} else {
|
|
165
|
+
token = value.match(/^[^\s::]+/)?.[0];
|
|
166
|
+
if (!token) return null;
|
|
167
|
+
end = token.length;
|
|
168
|
+
}
|
|
169
|
+
const id = strictIdentifier(token, prefix);
|
|
170
|
+
const tail = value.slice(end);
|
|
171
|
+
if (!id || (tail && !/^[\s::]/.test(tail))) return null;
|
|
172
|
+
return { id, tail };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function scenarioSection(title) {
|
|
176
|
+
return (
|
|
177
|
+
/^(?:acceptance\s+)?scenarios?(?:\s+(?:catalogue|catalog))?$/i.test(title) ||
|
|
178
|
+
/^(?:验收|接受)?(?:测试)?场景(?:目录|清单)?$/.test(title)
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function catalogueColumns(cells) {
|
|
183
|
+
const labels = cells.map(cell => cell.replace(/[`*]/g, '').toLowerCase().trim());
|
|
184
|
+
const id = labels.findIndex(
|
|
185
|
+
label => /^(?:atp\s+)?id$/.test(label) || /^(?:atp\s*)?编号$/.test(label),
|
|
186
|
+
);
|
|
187
|
+
const scenario = labels.findIndex(
|
|
188
|
+
label => /^(?:acceptance\s+)?scenario\b/.test(label) || /^(?:验收)?场景/.test(label),
|
|
189
|
+
);
|
|
190
|
+
return id !== -1 && scenario !== -1 && id !== scenario ? { id, scenario } : null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function parseDefinitions(markdown, prefix, { file } = {}) {
|
|
194
|
+
const definitions = new Map();
|
|
195
|
+
const atp = prefix === 'ATP-';
|
|
196
|
+
let scenarioLevel = null;
|
|
197
|
+
let columns = null;
|
|
198
|
+
const add = (id, record, kind) => {
|
|
199
|
+
if (definitions.has(id)) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Duplicate ${id} definition at ${atLocation(record, file)} (first at line ${definitions.get(id).line}).`,
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
definitions.set(id, { id, ...location(record, file), kind });
|
|
205
|
+
};
|
|
206
|
+
for (const record of markdownLines(markdown)) {
|
|
207
|
+
const { text, heading } = record;
|
|
208
|
+
if (heading) {
|
|
209
|
+
columns = null;
|
|
210
|
+
if (scenarioLevel !== null && heading.level <= scenarioLevel) scenarioLevel = null;
|
|
211
|
+
if (scenarioSection(heading.text)) scenarioLevel = heading.level;
|
|
212
|
+
const definition = leadingDefinition(heading.text, prefix);
|
|
213
|
+
if (definition) {
|
|
214
|
+
const beforeTitle = definition.tail.split(/[::]/, 1)[0];
|
|
215
|
+
const additionalDefinitions = [
|
|
216
|
+
...definition.tail.matchAll(/(?:^|\s)(`?[^\s::`]+`?)\s*[::]/g),
|
|
217
|
+
].some(match => strictIdentifier(match[1], prefix));
|
|
218
|
+
if (findIdentifiers(beforeTitle, prefix).length || additionalDefinitions) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`Multiple ${prefix} IDs in one definition at ${atLocation(record, file)}.`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
add(definition.id, record, 'heading');
|
|
224
|
+
}
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const bullet = text.match(/^\s*[-+*]\s+\*\*([^*]*)\*\*(.*)$/);
|
|
228
|
+
if (bullet && (!atp || scenarioLevel !== null)) {
|
|
229
|
+
const id = strictIdentifier(bullet[1], prefix);
|
|
230
|
+
const ids = findIdentifiers(bullet[1], prefix);
|
|
231
|
+
const otherBoldDefinitions = [...bullet[2].matchAll(/\*\*(.*?)\*\*\s*[::]/g)].some(match =>
|
|
232
|
+
strictIdentifier(match[1], prefix),
|
|
233
|
+
);
|
|
234
|
+
if (
|
|
235
|
+
ids.length > 1 ||
|
|
236
|
+
(id &&
|
|
237
|
+
(otherBoldDefinitions || /\*\*`?(?:PRD-|SRS-|ATP-)/.test(bullet[2].split(/[::]/, 1)[0])))
|
|
238
|
+
) {
|
|
239
|
+
throw new Error(`Multiple ${prefix} IDs in one definition at ${atLocation(record, file)}.`);
|
|
240
|
+
}
|
|
241
|
+
if (id) add(id, record, 'bullet');
|
|
242
|
+
}
|
|
243
|
+
if (!atp) continue;
|
|
244
|
+
const cells = parseTableRow(text);
|
|
245
|
+
if (!cells) {
|
|
246
|
+
if (text.trim()) columns = null;
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (isTableDivider(cells)) continue;
|
|
250
|
+
const header = catalogueColumns(cells);
|
|
251
|
+
if (header) {
|
|
252
|
+
columns = scenarioLevel !== null ? header : null;
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (!columns || scenarioLevel === null) continue;
|
|
256
|
+
const cell = cells[columns.id] ?? '';
|
|
257
|
+
const ids = findIdentifiers(cell, prefix);
|
|
258
|
+
if (ids.length > 1)
|
|
259
|
+
throw new Error(`Multiple ATP IDs in one catalogue row at ${atLocation(record, file)}.`);
|
|
260
|
+
const id = strictIdentifier(cell, prefix);
|
|
261
|
+
if (id) add(id, record, 'catalogue');
|
|
262
|
+
}
|
|
263
|
+
const records = markdownLines(markdown);
|
|
264
|
+
const definitionLines = new Set(Array.from(definitions.values(), definition => definition.line));
|
|
265
|
+
for (const definition of definitions.values()) {
|
|
266
|
+
const start = records.findIndex(record => record.line === definition.line);
|
|
267
|
+
const ownHeading = records[start].heading;
|
|
268
|
+
let end = start + 1;
|
|
269
|
+
if (definition.kind !== 'catalogue') {
|
|
270
|
+
while (end < records.length) {
|
|
271
|
+
const record = records[end];
|
|
272
|
+
const formalBullet = record.text.match(/^\s*[-+*]\s+\*\*(.*?)\*\*/);
|
|
273
|
+
const anotherDefinition = ['PRD-', 'SRS-', 'ATP-'].some(
|
|
274
|
+
otherPrefix =>
|
|
275
|
+
(record.heading && leadingDefinition(record.heading.text, otherPrefix)) ||
|
|
276
|
+
(formalBullet && strictIdentifier(formalBullet[1], otherPrefix)),
|
|
277
|
+
);
|
|
278
|
+
if (
|
|
279
|
+
definitionLines.has(record.line) ||
|
|
280
|
+
anotherDefinition ||
|
|
281
|
+
(record.heading && (!ownHeading || record.heading.level <= ownHeading.level))
|
|
282
|
+
) {
|
|
283
|
+
break;
|
|
284
|
+
}
|
|
285
|
+
end++;
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
definition.body = records
|
|
289
|
+
.slice(start, end)
|
|
290
|
+
.map(record => record.text)
|
|
291
|
+
.join('\n');
|
|
292
|
+
definition.endLine = records[end - 1]?.line ?? definition.line;
|
|
293
|
+
}
|
|
294
|
+
return definitions;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function parseAtpIds(markdown, options) {
|
|
298
|
+
return new Set(parseDefinitions(markdown, 'ATP-', options).keys());
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function normalizeStatus(value) {
|
|
302
|
+
const normalized = value.trim().replace(/`/g, '').replace(/\.$/, '').toLowerCase();
|
|
303
|
+
if (!STATUS_ORDER.includes(normalized))
|
|
304
|
+
throw new Error(`Unsupported progress status: ${value.trim()}`);
|
|
305
|
+
return normalized;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function reviewDateFromLine(line) {
|
|
309
|
+
return (
|
|
310
|
+
line.match(/(?:last reviewed|最后审查日期)\s*(?:[::]\s*)?(\d{4}-\d{2}-\d{2})/i)?.[1] ?? null
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function parseProgress(markdown, { file } = {}) {
|
|
315
|
+
const rows = new Map();
|
|
316
|
+
const locations = new Map();
|
|
317
|
+
let reviewed;
|
|
318
|
+
let reviewedLocation;
|
|
319
|
+
let columns = null;
|
|
320
|
+
for (const record of markdownLines(markdown)) {
|
|
321
|
+
const date = reviewDateFromLine(record.text);
|
|
322
|
+
if (date && !reviewed) {
|
|
323
|
+
reviewed = date;
|
|
324
|
+
reviewedLocation = location(record, file);
|
|
325
|
+
}
|
|
326
|
+
const cells = parseTableRow(record.text);
|
|
327
|
+
if (!cells) {
|
|
328
|
+
if (record.text.trim()) columns = null;
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
if (isTableDivider(cells)) continue;
|
|
332
|
+
const labels = cells.map(cell => cell.replace(/[`*]/g, '').toLowerCase().trim());
|
|
333
|
+
const idColumn = labels.indexOf('wbs id');
|
|
334
|
+
const statusColumn = labels.indexOf('status');
|
|
335
|
+
if (idColumn !== -1 && statusColumn !== -1) {
|
|
336
|
+
columns = { id: idColumn, status: statusColumn };
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
if (!columns) continue;
|
|
340
|
+
const cell = cells[columns.id] ?? '';
|
|
341
|
+
const ids = findIdentifiers(cell, 'WBS-');
|
|
342
|
+
if (ids.length > 1)
|
|
343
|
+
throw new Error(`Multiple WBS IDs in one progress row at ${atLocation(record, file)}.`);
|
|
344
|
+
const id = strictIdentifier(cell, 'WBS-');
|
|
345
|
+
if (!id) {
|
|
346
|
+
if (cell.includes('WBS-'))
|
|
347
|
+
throw new Error(`Unparseable WBS ID in progress row at ${atLocation(record, file)}.`);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
if (rows.has(id)) {
|
|
351
|
+
throw new Error(
|
|
352
|
+
`Duplicate ${id} progress row at ${atLocation(record, file)} (first at line ${locations.get(id).line}).`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
rows.set(id, normalizeStatus(cells[columns.status] ?? ''));
|
|
356
|
+
locations.set(id, location(record, file));
|
|
357
|
+
}
|
|
358
|
+
return { rows, reviewed: reviewed ?? 'not recorded', locations, reviewedLocation };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function labeledValue(record, labels) {
|
|
362
|
+
const match = record.text.trim().match(/^([A-Z]+)\s*:(.*)$/i);
|
|
363
|
+
return match && labels.includes(match[1].toLowerCase()) ? match[2].trim() : null;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function dependencyFromLines(lines, file) {
|
|
367
|
+
let dependency;
|
|
368
|
+
for (const record of lines) {
|
|
369
|
+
const value = labeledValue(record, ['dependency', 'dependencies']);
|
|
370
|
+
if (value === null) continue;
|
|
371
|
+
if (!value) throw new Error(`Unparseable empty WBS dependency at ${atLocation(record, file)}.`);
|
|
372
|
+
if (dependency)
|
|
373
|
+
throw new Error(`Multiple WBS dependency fields at ${atLocation(record, file)}.`);
|
|
374
|
+
dependency = { value, ...location(record, file) };
|
|
375
|
+
}
|
|
376
|
+
return dependency;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function expandDependencies(task, tasks) {
|
|
380
|
+
const dependency = task.dependency;
|
|
381
|
+
if (/^`?none`?\.?$/i.test(dependency.trim())) return { ids: [], gate: '' };
|
|
382
|
+
const matches = findIdentifierMatches(dependency, 'WBS-', { wildcard: true });
|
|
383
|
+
// Every written WBS token must parse, including malformed wildcard/range endpoints.
|
|
384
|
+
if (
|
|
385
|
+
!matches.length ||
|
|
386
|
+
dependency.split('WBS-').length - 1 !== matches.length ||
|
|
387
|
+
matches.some(match => dependency[match.end] === '*')
|
|
388
|
+
) {
|
|
389
|
+
throw new Error(`${task.id} has an unparseable WBS dependency: ${dependency}`);
|
|
390
|
+
}
|
|
391
|
+
const expand = value => {
|
|
392
|
+
const ids = value.endsWith('-*')
|
|
393
|
+
? tasks
|
|
394
|
+
.filter(candidate => candidate.id.startsWith(value.slice(0, -1)))
|
|
395
|
+
.map(candidate => candidate.id)
|
|
396
|
+
: tasks.filter(candidate => candidate.id === value).map(candidate => candidate.id);
|
|
397
|
+
if (!ids.length) {
|
|
398
|
+
throw new Error(
|
|
399
|
+
`${task.id} depends on unknown WBS task${value.endsWith('-*') ? ' group' : ''} ${value}.`,
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
return ids;
|
|
403
|
+
};
|
|
404
|
+
const ids = new Set();
|
|
405
|
+
const rangeSpans = [];
|
|
406
|
+
for (let index = 0; index < matches.length; index++) {
|
|
407
|
+
const match = matches[index];
|
|
408
|
+
const next = matches[index + 1];
|
|
409
|
+
const between = next ? dependency.slice(match.end, next.start).replace(/[`*]/g, '').trim() : '';
|
|
410
|
+
if (next && /^(?:through|to|[至到\-–—…]|\.\.)$/i.test(between)) {
|
|
411
|
+
const first = expand(match.value);
|
|
412
|
+
const last = expand(next.value);
|
|
413
|
+
const start = tasks.findIndex(candidate => candidate.id === first[0]);
|
|
414
|
+
const end = tasks.findIndex(candidate => candidate.id === last.at(-1));
|
|
415
|
+
if (start > end) {
|
|
416
|
+
throw new Error(
|
|
417
|
+
`${task.id} has an unparseable reversed WBS dependency range: ${match.value} through ${next.value}.`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
tasks.slice(start, end + 1).forEach(candidate => ids.add(candidate.id));
|
|
421
|
+
rangeSpans.push({ start: match.end, end: next.start });
|
|
422
|
+
index++;
|
|
423
|
+
} else {
|
|
424
|
+
const remainder = dependency.slice(match.end, next?.start).replace(/`/g, '').trim();
|
|
425
|
+
if (/^(?:through|to)\b/i.test(remainder) || /^(?:[至到\-–—…]|\.\.)\s*$/.test(remainder)) {
|
|
426
|
+
throw new Error(`${task.id} has an unparseable WBS dependency range: ${dependency}`);
|
|
427
|
+
}
|
|
428
|
+
expand(match.value).forEach(id => ids.add(id));
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
let gate = '';
|
|
432
|
+
let cursor = 0;
|
|
433
|
+
const spans = [...matches, ...rangeSpans].sort((a, b) => a.start - b.start);
|
|
434
|
+
for (const span of spans) {
|
|
435
|
+
if (span.start >= cursor) gate += dependency.slice(cursor, span.start);
|
|
436
|
+
cursor = Math.max(cursor, span.end);
|
|
437
|
+
}
|
|
438
|
+
gate += dependency.slice(cursor);
|
|
439
|
+
gate = gate
|
|
440
|
+
.replace(/[`*,;./()[\],;、]/g, ' ')
|
|
441
|
+
.replace(/\b(?:and|or)\b/gi, ' ')
|
|
442
|
+
.replace(/\s+/g, ' ')
|
|
443
|
+
.trim();
|
|
444
|
+
if (/^through$/i.test(gate) || (gate && !/[A-Z\p{Script=Han}]/iu.test(gate))) {
|
|
445
|
+
throw new Error(`${task.id} has an unparseable WBS dependency range: ${dependency}`);
|
|
446
|
+
}
|
|
447
|
+
return { ids: [...ids], gate };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
export function parseWbs(markdown, { file } = {}) {
|
|
451
|
+
const records = markdownLines(markdown);
|
|
452
|
+
const phases = [];
|
|
453
|
+
let phase;
|
|
454
|
+
let task;
|
|
455
|
+
let inPhase = false;
|
|
456
|
+
const seen = new Map();
|
|
457
|
+
for (const record of records) {
|
|
458
|
+
const heading = record.heading;
|
|
459
|
+
const phaseMatch = heading?.level === 3 ? heading.text.match(/^Phase\s+(\d+)\s*:(.*\S)/) : null;
|
|
460
|
+
if (phaseMatch) {
|
|
461
|
+
phase = {
|
|
462
|
+
number: phaseMatch[1],
|
|
463
|
+
title: phaseMatch[2].trim(),
|
|
464
|
+
line: record.line,
|
|
465
|
+
tasks: [],
|
|
466
|
+
preamble: [],
|
|
467
|
+
};
|
|
468
|
+
phases.push(phase);
|
|
469
|
+
task = null;
|
|
470
|
+
inPhase = true;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
const taskMatch = heading?.level === 4 ? leadingDefinition(heading.text, 'WBS-') : null;
|
|
474
|
+
if (taskMatch && /^\s*[::]\s*\S/.test(taskMatch.tail) && inPhase) {
|
|
475
|
+
if (
|
|
476
|
+
[...taskMatch.tail.matchAll(/(?:^|\s)(`?[^\s::`]+`?)\s*[::]/g)].some(match =>
|
|
477
|
+
strictIdentifier(match[1], 'WBS-'),
|
|
478
|
+
)
|
|
479
|
+
) {
|
|
480
|
+
throw new Error(`Multiple WBS IDs in one definition at ${atLocation(record, file)}.`);
|
|
481
|
+
}
|
|
482
|
+
if (seen.has(taskMatch.id)) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`Duplicate ${taskMatch.id} definition at ${atLocation(record, file)} (first at line ${seen.get(taskMatch.id)}).`,
|
|
485
|
+
);
|
|
486
|
+
}
|
|
487
|
+
seen.set(taskMatch.id, record.line);
|
|
488
|
+
task = {
|
|
489
|
+
id: taskMatch.id,
|
|
490
|
+
title: taskMatch.tail.replace(/^\s*[::]\s*/, ''),
|
|
491
|
+
...location(record, file),
|
|
492
|
+
body: [],
|
|
493
|
+
};
|
|
494
|
+
phase.tasks.push(task);
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (heading && heading.level <= 4) {
|
|
498
|
+
if (inPhase && heading.level === 4 && findIdentifiers(heading.text, 'WBS-').length > 1) {
|
|
499
|
+
throw new Error(`Multiple WBS IDs in one definition at ${atLocation(record, file)}.`);
|
|
500
|
+
}
|
|
501
|
+
task = null;
|
|
502
|
+
inPhase = false;
|
|
503
|
+
}
|
|
504
|
+
if (!inPhase) continue;
|
|
505
|
+
if (task) task.body.push(record);
|
|
506
|
+
else if (!phase.tasks.length) phase.preamble.push(record);
|
|
507
|
+
}
|
|
508
|
+
if (!phases.length)
|
|
509
|
+
throw new Error('No formal `### Phase <number>:` headings were found in pdp-wbs.md.');
|
|
510
|
+
const tasks = phases.flatMap(phase => {
|
|
511
|
+
const defaultDependency = dependencyFromLines(phase.preamble, file);
|
|
512
|
+
phase.dependency = defaultDependency?.value ?? 'none';
|
|
513
|
+
delete phase.preamble;
|
|
514
|
+
return phase.tasks.map(task => {
|
|
515
|
+
const dependency = dependencyFromLines(task.body, file) ?? defaultDependency;
|
|
516
|
+
task.dependency = dependency?.value ?? 'none';
|
|
517
|
+
task.dependencyLocation = dependency
|
|
518
|
+
? { line: dependency.line, ...(file ? { file } : {}) }
|
|
519
|
+
: undefined;
|
|
520
|
+
task.phase = { number: phase.number, title: phase.title };
|
|
521
|
+
const statusRecords = task.body.filter(record => labeledValue(record, ['status']) !== null);
|
|
522
|
+
if (statusRecords.length > 1) {
|
|
523
|
+
throw new Error(
|
|
524
|
+
`Multiple ${task.id} Status fields at ${atLocation(statusRecords[1], file)}.`,
|
|
525
|
+
);
|
|
526
|
+
}
|
|
527
|
+
task.status = statusRecords.length
|
|
528
|
+
? normalizeStatus(labeledValue(statusRecords[0], ['status']))
|
|
529
|
+
: undefined;
|
|
530
|
+
task.statusLocation = statusRecords.length ? location(statusRecords[0], file) : undefined;
|
|
531
|
+
task.deferred =
|
|
532
|
+
task.status === 'deferred' ||
|
|
533
|
+
task.body.some(record => /^deferred scope\.?$/i.test(record.text.trim().replace(/`/g, '')));
|
|
534
|
+
task.atps = [...new Set(task.body.flatMap(record => findIdentifiers(record.text, 'ATP-')))];
|
|
535
|
+
task.endLine = task.body.at(-1)?.line ?? task.line;
|
|
536
|
+
task.body = task.body.map(record => record.text).join('\n');
|
|
537
|
+
return task;
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
if (!tasks.length) throw new Error('No formal `#### WBS-…:` tasks were found in pdp-wbs.md.');
|
|
541
|
+
const expandedEdges = new Map();
|
|
542
|
+
for (const task of tasks) {
|
|
543
|
+
const expanded = expandDependencies(task, tasks);
|
|
544
|
+
task.dependencyIds = expanded.ids;
|
|
545
|
+
task.dependencyGate = expanded.gate;
|
|
546
|
+
if (task.dependencyIds.includes(task.id)) throw new Error(`${task.id} has a self dependency.`);
|
|
547
|
+
expandedEdges.set(task.id, task.dependencyIds);
|
|
548
|
+
}
|
|
549
|
+
const visiting = new Set();
|
|
550
|
+
const visited = new Set();
|
|
551
|
+
function visit(id, path) {
|
|
552
|
+
if (visiting.has(id)) throw new Error(`WBS dependency cycle: ${[...path, id].join(' -> ')}.`);
|
|
553
|
+
if (visited.has(id)) return;
|
|
554
|
+
visiting.add(id);
|
|
555
|
+
for (const dependency of expandedEdges.get(id)) visit(dependency, [...path, id]);
|
|
556
|
+
visiting.delete(id);
|
|
557
|
+
visited.add(id);
|
|
558
|
+
}
|
|
559
|
+
for (const task of tasks) visit(task.id, []);
|
|
560
|
+
return { phases, tasks, expandedEdges };
|
|
561
|
+
}
|