@xulthekl/team-flow 0.30.0 → 0.32.1
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/.claude/always/phase-guard.md +1 -1
- package/.claude-plugin/marketplace.json +3 -3
- package/.claude-plugin/plugin.json +2 -2
- package/.codex-plugin/plugin.json +2 -2
- package/.cursor-plugin/marketplace.json +2 -2
- package/.cursor-plugin/plugin.json +2 -2
- package/.github/plugin/marketplace.json +2 -2
- package/AGENTS.md +5 -4
- package/CHANGELOG.md +85 -0
- package/GEMINI.md +1 -1
- package/HANDOFF.md +1 -1
- package/INSTALL.md +1 -1
- package/README.md +3 -3
- package/agents/build-executor.md +1 -0
- package/agents/contract-builder.md +6 -1
- package/docs/README_en.md +1 -1
- package/docs/solutions/INDEX.md +2 -0
- package/docs/solutions/cross-phase/2026-08-01-no-summary.md +17 -0
- package/docs/solutions/cross-phase/2026-08-03-no-summary.md +17 -0
- package/gemini-extension.json +2 -2
- package/hooks/session-start +2 -2
- package/llms.txt +1 -1
- package/package.json +6 -2
- package/plugin.json +2 -2
- package/scripts/guard/checks/test-gate-exemptions.mjs +53 -0
- package/scripts/guard/checks/test-matrix-complete.mjs +91 -0
- package/scripts/guard/checks/test-matrix-ready.mjs +60 -0
- package/scripts/guard/checks/tests-passing.mjs +112 -24
- package/scripts/guard/guard.mjs +10 -2
- package/scripts/lib/cmd-doctor.mjs +43 -1
- package/scripts/lib/cmd-execution.mjs +24 -1
- package/scripts/lib/cmd-state.mjs +33 -5
- package/scripts/lib/cmd-validate.mjs +42 -0
- package/scripts/lib/execution-plan.mjs +23 -0
- package/scripts/lib/hash.mjs +11 -0
- package/scripts/lib/state-loader.mjs +26 -1
- package/scripts/lib/test-matrix-export.mjs +231 -0
- package/scripts/lib/test-merge.mjs +540 -0
- package/scripts/lib/test-record.mjs +212 -0
- package/scripts/team-flow.mjs +9 -0
- package/skills/build-executor/implementer-prompt.md +38 -3
- package/skills/code-reviewer/SKILL.md +28 -1
- package/skills/code-reviewer/code-reviewer-prompt.md +10 -0
- package/skills/contract-builder/SKILL.md +75 -0
- package/skills/release-archivist/SKILL.md +50 -10
- package/skills/session-handoff/SKILL.md +1 -0
- package/skills/spec-writer/SKILL.md +3 -1
- package/skills/test-strategy/SKILL.md +70 -0
- package/skills/test-strategy/references/adversarial-patterns.md +0 -0
- package/skills/test-strategy/references/complexity-grading.md +137 -0
- package/skills/test-strategy/references/design-methods-detail.md +183 -0
- package/skills/workflow-orchestrator/references/s1-path-router.md +4 -0
- package/skills/workflow-start/SKILL.md +5 -3
- package/skills/workflow-start/references/routing-rules.md +21 -0
- package/tests/e2e.test.mjs +0 -266
- package/tests/lib/cmd-audit.test.mjs +0 -202
- package/tests/lib/cmd-checkpoint.test.mjs +0 -98
- package/tests/lib/cmd-config.test.mjs +0 -101
- package/tests/lib/cmd-doctor.test.mjs +0 -450
- package/tests/lib/cmd-execution.test.mjs +0 -647
- package/tests/lib/cmd-handoff.test.mjs +0 -137
- package/tests/lib/cmd-inject.test.mjs +0 -218
- package/tests/lib/cmd-install-workbuddy.test.mjs +0 -113
- package/tests/lib/cmd-install-zcode.test.mjs +0 -42
- package/tests/lib/cmd-isolate.test.mjs +0 -68
- package/tests/lib/cmd-list.test.mjs +0 -127
- package/tests/lib/cmd-runtime.test.mjs +0 -83
- package/tests/lib/cmd-state-missing.test.mjs +0 -65
- package/tests/lib/cmd-state.test.mjs +0 -365
- package/tests/lib/cmd-sync-paths.test.mjs +0 -84
- package/tests/lib/cmd-validate-paths.test.mjs +0 -90
- package/tests/lib/config-loader.test.mjs +0 -175
- package/tests/lib/ensure-branch.test.mjs +0 -110
- package/tests/lib/execution-control-plane.test.mjs +0 -168
- package/tests/lib/execution-plan.test.mjs +0 -474
- package/tests/lib/guard-compound-captured.test.mjs +0 -92
- package/tests/lib/guard-specs-merged.test.mjs +0 -110
- package/tests/lib/guard-tests-passing.test.mjs +0 -114
- package/tests/lib/guard-transitions.test.mjs +0 -262
- package/tests/lib/guard.test.mjs +0 -613
- package/tests/lib/hash.test.mjs +0 -138
- package/tests/lib/infer-workflow.test.mjs +0 -161
- package/tests/lib/install-git-hooks.test.mjs +0 -20
- package/tests/lib/marketplace-release-docs.test.mjs +0 -31
- package/tests/lib/minimality-discipline.test.mjs +0 -37
- package/tests/lib/model-profiles-docs.test.mjs +0 -33
- package/tests/lib/node20-compatibility.test.mjs +0 -73
- package/tests/lib/node20-test-entry.test.mjs +0 -17
- package/tests/lib/platform-runtime-distribution.test.mjs +0 -157
- package/tests/lib/raw-mode-smoke.test.mjs +0 -42
- package/tests/lib/sdd-overlay.test.mjs +0 -118
- package/tests/lib/solutions-capture.test.mjs +0 -108
- package/tests/lib/solutions-index-gen.test.mjs +0 -147
- package/tests/lib/solutions-inject.test.mjs +0 -115
- package/tests/lib/solutions-promote.test.mjs +0 -200
- package/tests/lib/spec-paths.test.mjs +0 -80
- package/tests/lib/state-loader.test.mjs +0 -251
- package/tests/lib/token-baseline.test.mjs +0 -72
- package/tests/lib/token-rules.test.mjs +0 -94
- package/tests/lib/verify-marketplace-release.test.mjs +0 -128
- package/tests/tsconfig.json +0 -12
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* test-merge — change 完成后将测试增量合并回全局 docs/test-ledger/
|
|
4
|
+
*
|
|
5
|
+
* v0.12 §43.3 定义,v0.31.0 实现为 CLI 子命令
|
|
6
|
+
* 用法:tf test-merge <change-dir> [--project-root <path>] [--dry-run]
|
|
7
|
+
*
|
|
8
|
+
* 功能(6 步流程):
|
|
9
|
+
* 1. preCheck — 校验 test-matrix.md 存在且非空
|
|
10
|
+
* 2. mergeBaselines — 按模块提取 case,增量合并到 baselines/{module}.md
|
|
11
|
+
* 3. resolveDeferred — 新 case 覆盖了旧 deferred 项 → 从 Deferred Items 移除
|
|
12
|
+
* 4. appendChangelog — 归档 test-matrix.md → changelog/{change-id}.md
|
|
13
|
+
* 5. rewriteIndex — 统计模块数/case 数/deferred 数,重写 INDEX.md
|
|
14
|
+
* 6. gitCommit — 单次原子提交
|
|
15
|
+
*
|
|
16
|
+
* 回写顺序:arch-merge → prototype-sync → test-merge → compound promotion
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, writeFileSync, existsSync, cpSync, mkdirSync, readdirSync } from 'node:fs';
|
|
20
|
+
import { join, basename, relative, resolve } from 'node:path';
|
|
21
|
+
import { execSync } from 'node:child_process';
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 解析 CLI 参数数组为结构化对象
|
|
25
|
+
*/
|
|
26
|
+
function parseArgv(argv) {
|
|
27
|
+
const parsed = { _: [] };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
if (argv[i] === '--project-root' && argv[i + 1] && !argv[i + 1].startsWith('--')) {
|
|
30
|
+
parsed.projectRoot = argv[++i];
|
|
31
|
+
} else if (argv[i] === '--dry-run') {
|
|
32
|
+
parsed.dryRun = true;
|
|
33
|
+
} else if (!argv[i].startsWith('--')) {
|
|
34
|
+
parsed._.push(argv[i]);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return parsed;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 从 change 目录提取 change name
|
|
42
|
+
*/
|
|
43
|
+
function extractChangeName(changeDir) {
|
|
44
|
+
return basename(changeDir.replace(/\/$/, ''));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Step 1: 预检 — 校验 test-matrix.md 存在且非空
|
|
49
|
+
*/
|
|
50
|
+
function preCheck(changeDir) {
|
|
51
|
+
const matrixPath = join(changeDir, 'test-matrix.md');
|
|
52
|
+
if (!existsSync(matrixPath)) {
|
|
53
|
+
return { pass: false, reason: 'test-matrix.md not found — skipping test-merge' };
|
|
54
|
+
}
|
|
55
|
+
const content = readFileSync(matrixPath, 'utf-8');
|
|
56
|
+
if (content.trim().length === 0) {
|
|
57
|
+
return { pass: false, reason: 'test-matrix.md is empty — skipping test-merge' };
|
|
58
|
+
}
|
|
59
|
+
return { pass: true, content };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 从 test-matrix.md 提取 Summary 统计信息
|
|
64
|
+
*/
|
|
65
|
+
function extractSummary(content) {
|
|
66
|
+
const summary = { totalCases: 0, modules: 0, unitCases: 0, integrationCases: 0, deferred: 0 };
|
|
67
|
+
const lines = content.split('\n');
|
|
68
|
+
for (const line of lines) {
|
|
69
|
+
const totalMatch = line.match(/Total cases:\s*(\d+)/i);
|
|
70
|
+
if (totalMatch) summary.totalCases = parseInt(totalMatch[1], 10);
|
|
71
|
+
const modulesMatch = line.match(/Modules covered:\s*(\d+)/i);
|
|
72
|
+
if (modulesMatch) summary.modules = parseInt(modulesMatch[1], 10);
|
|
73
|
+
const unitMatch = line.match(/Unit cases:\s*(\d+)/i);
|
|
74
|
+
if (unitMatch) summary.unitCases = parseInt(unitMatch[1], 10);
|
|
75
|
+
const intMatch = line.match(/Integration cases:\s*(\d+)/i);
|
|
76
|
+
if (intMatch) summary.integrationCases = parseInt(intMatch[1], 10);
|
|
77
|
+
const defMatch = line.match(/Deferred items:\s*(\d+)/i);
|
|
78
|
+
if (defMatch) summary.deferred = parseInt(defMatch[1], 10);
|
|
79
|
+
}
|
|
80
|
+
return summary;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 从 test-matrix.md 提取模块分段(## Cases 下的 ### 子段)
|
|
85
|
+
* 返回 { moduleName: sectionContent } 映射
|
|
86
|
+
*/
|
|
87
|
+
function extractModuleSections(content) {
|
|
88
|
+
const sections = {};
|
|
89
|
+
const lines = content.split('\n');
|
|
90
|
+
let currentModule = null;
|
|
91
|
+
let currentLines = [];
|
|
92
|
+
|
|
93
|
+
for (const line of lines) {
|
|
94
|
+
if (line.startsWith('### ') && !line.startsWith('### #')) {
|
|
95
|
+
if (currentModule) {
|
|
96
|
+
sections[currentModule] = currentLines.join('\n');
|
|
97
|
+
}
|
|
98
|
+
currentModule = line.replace(/^###\s+/, '').trim();
|
|
99
|
+
currentLines = [line];
|
|
100
|
+
} else if (currentModule) {
|
|
101
|
+
currentLines.push(line);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (currentModule) {
|
|
105
|
+
sections[currentModule] = currentLines.join('\n');
|
|
106
|
+
}
|
|
107
|
+
return sections;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 从 test-matrix.md 提取 Candidate Coverage Ledger
|
|
112
|
+
*/
|
|
113
|
+
function extractCandidateLedger(content) {
|
|
114
|
+
const candidates = [];
|
|
115
|
+
const lines = content.split('\n');
|
|
116
|
+
let inLedger = false;
|
|
117
|
+
|
|
118
|
+
for (const line of lines) {
|
|
119
|
+
if (line.match(/## Candidate Coverage Ledger/i)) {
|
|
120
|
+
inLedger = true;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
if (inLedger && line.startsWith('## ')) {
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
if (inLedger && line.startsWith('|') && !line.match(/^\|\s*[-:]+/)) {
|
|
127
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
128
|
+
if (cells.length >= 3 && cells[0] !== 'candidate') {
|
|
129
|
+
candidates.push({
|
|
130
|
+
candidate: cells[0],
|
|
131
|
+
category: cells[1] || 'unknown',
|
|
132
|
+
decision: cells[2] || 'unknown',
|
|
133
|
+
caseIds: cells[3] || '',
|
|
134
|
+
reason: cells[4] || '',
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return candidates;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 从 test-matrix.md 提取 Deferred Items
|
|
144
|
+
*/
|
|
145
|
+
function extractDeferredItems(content) {
|
|
146
|
+
const items = [];
|
|
147
|
+
const lines = content.split('\n');
|
|
148
|
+
let inDeferred = false;
|
|
149
|
+
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
if (line.match(/## Deferred Items/i)) {
|
|
152
|
+
inDeferred = true;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
if (inDeferred && line.startsWith('## ')) {
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
if (inDeferred && line.startsWith('|') && !line.match(/^\|\s*[-:]+/)) {
|
|
159
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
160
|
+
if (cells.length >= 3 && cells[0] !== 'case_id') {
|
|
161
|
+
items.push({
|
|
162
|
+
caseId: cells[0],
|
|
163
|
+
behavior: cells[1] || '',
|
|
164
|
+
designMethod: cells[2] || '',
|
|
165
|
+
reason: cells[3] || '',
|
|
166
|
+
deferredSince: cells[4] || new Date().toISOString().slice(0, 10),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return items;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Step 2: mergeBaselines — 增量合并到 baselines/{module}.md
|
|
176
|
+
*/
|
|
177
|
+
function mergeBaselines(ledgerDir, changeName, moduleSections, candidateLedger, deferredItems) {
|
|
178
|
+
const baselinesDir = join(ledgerDir, 'baselines');
|
|
179
|
+
if (!existsSync(baselinesDir)) {
|
|
180
|
+
mkdirSync(baselinesDir, { recursive: true });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const results = {};
|
|
184
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
185
|
+
|
|
186
|
+
for (const [moduleName, sectionContent] of Object.entries(moduleSections)) {
|
|
187
|
+
const safeName = moduleName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
|
188
|
+
const baselinePath = join(baselinesDir, `${safeName}.md`);
|
|
189
|
+
const moduleCandidates = candidateLedger.filter(c =>
|
|
190
|
+
c.candidate.toLowerCase().includes(moduleName.toLowerCase())
|
|
191
|
+
);
|
|
192
|
+
const moduleDeferred = deferredItems.filter(d =>
|
|
193
|
+
d.caseId.toLowerCase().startsWith(moduleName.toLowerCase().slice(0, 3))
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
if (existsSync(baselinePath)) {
|
|
197
|
+
// 增量合并:读取现有 baseline,追加新 case
|
|
198
|
+
const existing = readFileSync(baselinePath, 'utf-8');
|
|
199
|
+
const updated = mergeExistingBaseline(existing, sectionContent, changeName, today, moduleCandidates, moduleDeferred);
|
|
200
|
+
if (!updated) continue; // no changes
|
|
201
|
+
writeFileSync(baselinePath, updated, 'utf-8');
|
|
202
|
+
results[moduleName] = 'updated';
|
|
203
|
+
} else {
|
|
204
|
+
// 新建 baseline
|
|
205
|
+
const newBaseline = createNewBaseline(moduleName, sectionContent, changeName, today, moduleCandidates, moduleDeferred);
|
|
206
|
+
writeFileSync(baselinePath, newBaseline, 'utf-8');
|
|
207
|
+
results[moduleName] = 'created';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
return results;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 创建新 baseline 文件
|
|
215
|
+
*/
|
|
216
|
+
function createNewBaseline(moduleName, sectionContent, changeName, date, candidates, deferred) {
|
|
217
|
+
const lines = [];
|
|
218
|
+
lines.push(`# ${moduleName} Test Baseline`);
|
|
219
|
+
lines.push('');
|
|
220
|
+
lines.push(`> schema_version: 1`);
|
|
221
|
+
lines.push(`> last_updated_by_change: ${changeName}`);
|
|
222
|
+
lines.push(`> last_updated: ${date}`);
|
|
223
|
+
lines.push('');
|
|
224
|
+
lines.push('## Current Cases(当前态 — 覆盖式)');
|
|
225
|
+
lines.push('');
|
|
226
|
+
lines.push('| case_id | behavior | design_method | test_kind | test_tier | work_mode | test_file | test_method_name | source_change |');
|
|
227
|
+
lines.push('|---|---|---|---|---|---|---|---|---|');
|
|
228
|
+
|
|
229
|
+
// 从 sectionContent 提取 case 行并添加 source_change 列
|
|
230
|
+
const caseLines = sectionContent.split('\n').filter(l =>
|
|
231
|
+
l.startsWith('|') && !l.match(/^\|\s*[-:]+/) && !l.match(/\|\s*case_id/)
|
|
232
|
+
);
|
|
233
|
+
for (const line of caseLines) {
|
|
234
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
235
|
+
if (cells.length >= 10) {
|
|
236
|
+
lines.push(`| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${cells[5]} | ${cells[6]} | ${cells[8]} | ${cells[9]} | ${cells[10]} | ${changeName} |`);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (deferred.length > 0) {
|
|
241
|
+
lines.push('');
|
|
242
|
+
lines.push('## Deferred Items');
|
|
243
|
+
lines.push('');
|
|
244
|
+
lines.push('| case_id | behavior | design_method | reason | deferred_since |');
|
|
245
|
+
lines.push('|---|---|---|---|---|');
|
|
246
|
+
for (const d of deferred) {
|
|
247
|
+
lines.push(`| ${d.caseId} | ${d.behavior} | ${d.designMethod} | ${d.reason} | ${d.deferredSince} |`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
lines.push('');
|
|
252
|
+
lines.push('## Evolution Log(append-only)');
|
|
253
|
+
lines.push('');
|
|
254
|
+
const caseCount = caseLines.length;
|
|
255
|
+
lines.push(`- **${date}** [${changeName}]: 新增 ${caseCount} case, defer ${deferred.length}`);
|
|
256
|
+
|
|
257
|
+
return lines.join('\n') + '\n';
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 合并现有 baseline(增量更新 Current Cases + 追加 Evolution Log)
|
|
262
|
+
*/
|
|
263
|
+
function mergeExistingBaseline(existing, sectionContent, changeName, date, candidates, deferred) {
|
|
264
|
+
const lines = [];
|
|
265
|
+
const existingLines = existing.split('\n');
|
|
266
|
+
|
|
267
|
+
// 更新 frontmatter
|
|
268
|
+
let inFrontmatter = false;
|
|
269
|
+
let frontmatterDone = false;
|
|
270
|
+
for (const line of existingLines) {
|
|
271
|
+
if (line.startsWith('> last_updated_by_change:')) {
|
|
272
|
+
lines.push(`> last_updated_by_change: ${changeName}`);
|
|
273
|
+
} else if (line.startsWith('> last_updated:')) {
|
|
274
|
+
lines.push(`> last_updated: ${date}`);
|
|
275
|
+
} else if (line.startsWith('## Current Cases')) {
|
|
276
|
+
// 插入 Current Cases header,然后用新数据替换表格
|
|
277
|
+
lines.push(line);
|
|
278
|
+
lines.push('');
|
|
279
|
+
lines.push('| case_id | behavior | design_method | test_kind | test_tier | work_mode | test_file | test_method_name | source_change |');
|
|
280
|
+
lines.push('|---|---|---|---|---|---|---|---|---|');
|
|
281
|
+
inFrontmatter = true;
|
|
282
|
+
// 跳过旧表格
|
|
283
|
+
continue;
|
|
284
|
+
} else if (inFrontmatter && (line.startsWith('## ') || line.startsWith(''))) {
|
|
285
|
+
inFrontmatter = false;
|
|
286
|
+
// 插入新的 case 行
|
|
287
|
+
const caseLines = sectionContent.split('\n').filter(l =>
|
|
288
|
+
l.startsWith('|') && !l.match(/^\|\s*[-:]+/) && !l.match(/\|\s*case_id/)
|
|
289
|
+
);
|
|
290
|
+
for (const cl of caseLines) {
|
|
291
|
+
const cells = cl.split('|').map(c => c.trim()).filter(Boolean);
|
|
292
|
+
if (cells.length >= 10) {
|
|
293
|
+
lines.push(`| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${cells[5]} | ${cells[6]} | ${cells[8]} | ${cells[9]} | ${cells[10]} | ${changeName} |`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
lines.push(line);
|
|
297
|
+
} else if (inFrontmatter && line.startsWith('|')) {
|
|
298
|
+
// 跳过旧表格行(已替换)
|
|
299
|
+
continue;
|
|
300
|
+
} else if (line.startsWith('## Evolution Log')) {
|
|
301
|
+
lines.push(line);
|
|
302
|
+
lines.push('');
|
|
303
|
+
const caseCount = sectionContent.split('\n').filter(l =>
|
|
304
|
+
l.startsWith('|') && !l.match(/^\|\s*[-:]+/) && !l.match(/\|\s*case_id/)
|
|
305
|
+
).length;
|
|
306
|
+
lines.push(`- **${date}** [${changeName}]: 新增 ${caseCount} case, defer ${deferred.length}`);
|
|
307
|
+
frontmatterDone = true;
|
|
308
|
+
} else if (frontmatterDone && line.startsWith('- **')) {
|
|
309
|
+
// 保留旧的 evolution log entries
|
|
310
|
+
lines.push(line);
|
|
311
|
+
} else if (!inFrontmatter) {
|
|
312
|
+
lines.push(line);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return lines.join('\n') + '\n';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Step 3: resolveDeferred — 新 case 覆盖了旧 deferred 项
|
|
321
|
+
*/
|
|
322
|
+
function resolveDeferred(ledgerDir, changeName, newCaseIds) {
|
|
323
|
+
const baselinesDir = join(ledgerDir, 'baselines');
|
|
324
|
+
if (!existsSync(baselinesDir)) return { resolved: 0 };
|
|
325
|
+
|
|
326
|
+
let resolved = 0;
|
|
327
|
+
for (const file of readdirSync(baselinesDir)) {
|
|
328
|
+
if (!file.endsWith('.md')) continue;
|
|
329
|
+
const fp = join(baselinesDir, file);
|
|
330
|
+
let content = readFileSync(fp, 'utf-8');
|
|
331
|
+
let changed = false;
|
|
332
|
+
|
|
333
|
+
// 移除 Deferred Items 中被新 case 覆盖的行
|
|
334
|
+
for (const caseId of newCaseIds) {
|
|
335
|
+
const regex = new RegExp(`\\|\\s*${escapeRegex(caseId)}\\s*\\|[^\\n]*\\n`, 'g');
|
|
336
|
+
if (regex.test(content)) {
|
|
337
|
+
content = content.replace(regex, '');
|
|
338
|
+
changed = true;
|
|
339
|
+
resolved++;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
if (changed) {
|
|
344
|
+
writeFileSync(fp, content, 'utf-8');
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return { resolved };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function escapeRegex(str) {
|
|
351
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Step 4: appendChangelog — 归档 test-matrix.md
|
|
356
|
+
*/
|
|
357
|
+
function appendChangelog(ledgerDir, changeName, matrixContent) {
|
|
358
|
+
const changelogDir = join(ledgerDir, 'changelog');
|
|
359
|
+
if (!existsSync(changelogDir)) {
|
|
360
|
+
mkdirSync(changelogDir, { recursive: true });
|
|
361
|
+
}
|
|
362
|
+
const destPath = join(changelogDir, `${changeName}.md`);
|
|
363
|
+
writeFileSync(destPath, matrixContent, 'utf-8');
|
|
364
|
+
return destPath;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Step 5: rewriteIndex — 重写 INDEX.md
|
|
369
|
+
*/
|
|
370
|
+
function rewriteIndex(ledgerDir) {
|
|
371
|
+
const baselinesDir = join(ledgerDir, 'baselines');
|
|
372
|
+
const indexPath = join(ledgerDir, 'INDEX.md');
|
|
373
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
374
|
+
|
|
375
|
+
const modules = [];
|
|
376
|
+
if (existsSync(baselinesDir)) {
|
|
377
|
+
for (const file of readdirSync(baselinesDir)) {
|
|
378
|
+
if (!file.endsWith('.md')) continue;
|
|
379
|
+
const content = readFileSync(join(baselinesDir, file), 'utf-8');
|
|
380
|
+
const moduleName = file.replace('.md', '').replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
381
|
+
|
|
382
|
+
// 统计 case 数
|
|
383
|
+
const caseMatches = content.match(/\|\s*\S+-\S+-\S+-\d+/g) || [];
|
|
384
|
+
const caseCount = caseMatches.length;
|
|
385
|
+
|
|
386
|
+
// 统计 deferred 数
|
|
387
|
+
const deferredSection = content.split('## Deferred Items')[1] || '';
|
|
388
|
+
const deferredMatches = deferredSection.match(/\|\s*\S+-\S+-\S+-\d+/g) || [];
|
|
389
|
+
const deferredCount = deferredMatches.length;
|
|
390
|
+
|
|
391
|
+
// 提取 last_updated_by_change
|
|
392
|
+
const changeMatch = content.match(/last_updated_by_change:\s*(.+)/);
|
|
393
|
+
const lastChange = changeMatch ? changeMatch[1].trim() : 'unknown';
|
|
394
|
+
const dateMatch = content.match(/last_updated:\s*(.+)/);
|
|
395
|
+
const lastDate = dateMatch ? dateMatch[1].trim() : 'unknown';
|
|
396
|
+
|
|
397
|
+
// coverage_status
|
|
398
|
+
let status = '✅ baseline';
|
|
399
|
+
if (deferredCount > 0) status = '⚠️ partial';
|
|
400
|
+
if (caseCount === 0) status = '⏭️ not_applicable';
|
|
401
|
+
|
|
402
|
+
// test_tier_breakdown
|
|
403
|
+
const unitCount = (content.match(/unit/gi) || []).length;
|
|
404
|
+
const intCount = (content.match(/integration/gi) || []).length;
|
|
405
|
+
|
|
406
|
+
modules.push({ moduleName, caseCount, deferredCount, lastChange, lastDate, status, unitCount, intCount });
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const totalCases = modules.reduce((sum, m) => sum + m.caseCount, 0);
|
|
411
|
+
const totalDeferred = modules.reduce((sum, m) => sum + m.deferredCount, 0);
|
|
412
|
+
|
|
413
|
+
const lines = [];
|
|
414
|
+
lines.push('# Test Ledger Index');
|
|
415
|
+
lines.push('');
|
|
416
|
+
lines.push('> Auto-generated by `tf test-merge`. Do not edit manually.');
|
|
417
|
+
lines.push('');
|
|
418
|
+
lines.push(`**Modules**: ${modules.length} | **Total Cases**: ${totalCases} | **Deferred**: ${totalDeferred} | **Last Updated**: ${today}`);
|
|
419
|
+
lines.push('');
|
|
420
|
+
lines.push('| module | test_tier_breakdown | case_count | deferred | last_change | last_date | coverage_status |');
|
|
421
|
+
lines.push('|---|---|---|---|---|---|---|');
|
|
422
|
+
|
|
423
|
+
for (const m of modules) {
|
|
424
|
+
lines.push(`| ${m.moduleName} | unit:${m.unitCount}, integration:${m.intCount} | ${m.caseCount} | ${m.deferredCount} | ${m.lastChange} | ${m.lastDate} | ${m.status} |`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
writeFileSync(indexPath, lines.join('\n') + '\n', 'utf-8');
|
|
428
|
+
return { modules: modules.length, totalCases, totalDeferred };
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Step 6: gitCommit — 原子性提交
|
|
433
|
+
*/
|
|
434
|
+
function gitCommit(projectRoot, changeName, dryRun) {
|
|
435
|
+
const msg = `test-ledger: merge test results from ${changeName}`;
|
|
436
|
+
if (dryRun) {
|
|
437
|
+
console.log(`[DRY-RUN] Would commit: git add docs/test-ledger/ && git commit -m "${msg}"`);
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
try {
|
|
441
|
+
execSync('git add docs/test-ledger/', { cwd: projectRoot, stdio: 'pipe' });
|
|
442
|
+
execSync(`git commit -m "${msg}"`, { cwd: projectRoot, stdio: 'pipe' });
|
|
443
|
+
console.log(`✅ test-ledger committed: ${msg}`);
|
|
444
|
+
} catch (err) {
|
|
445
|
+
// Nothing to commit is acceptable (e.g., no changes)
|
|
446
|
+
if (err.message && err.message.includes('nothing to commit')) {
|
|
447
|
+
console.log('ℹ️ No changes to commit in test-ledger');
|
|
448
|
+
} else {
|
|
449
|
+
console.error(`⚠️ git commit warning: ${err.message}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* CLI entry point — export as `run()` for registration
|
|
456
|
+
*/
|
|
457
|
+
export async function run(args) {
|
|
458
|
+
const argv = parseArgv(args || process.argv.slice(2));
|
|
459
|
+
const projectRoot = resolve(argv.projectRoot || process.cwd());
|
|
460
|
+
|
|
461
|
+
// Delegate to main()
|
|
462
|
+
await main(argv, projectRoot);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* 主入口
|
|
467
|
+
*/
|
|
468
|
+
async function main(argv, projectRoot) {
|
|
469
|
+
const changeDir = argv._[0];
|
|
470
|
+
const dryRun = argv.dryRun || false;
|
|
471
|
+
|
|
472
|
+
if (!changeDir) {
|
|
473
|
+
console.error('Usage: tf test-merge <change-dir> [--project-root <path>] [--dry-run]');
|
|
474
|
+
process.exit(2);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const absChangeDir = resolve(changeDir);
|
|
478
|
+
const changeName = extractChangeName(absChangeDir);
|
|
479
|
+
const ledgerDir = join(projectRoot, 'docs', 'test-ledger');
|
|
480
|
+
|
|
481
|
+
console.log(`📋 test-merge: ${changeName} → ${ledgerDir}`);
|
|
482
|
+
|
|
483
|
+
// Step 1: preCheck
|
|
484
|
+
const preCheckResult = preCheck(absChangeDir);
|
|
485
|
+
if (!preCheckResult.pass) {
|
|
486
|
+
console.log(`⏭️ ${preCheckResult.reason}`);
|
|
487
|
+
process.exit(0);
|
|
488
|
+
}
|
|
489
|
+
console.log('✅ Step 1: preCheck passed');
|
|
490
|
+
|
|
491
|
+
const matrixContent = preCheckResult.content;
|
|
492
|
+
const summary = extractSummary(matrixContent);
|
|
493
|
+
const moduleSections = extractModuleSections(matrixContent);
|
|
494
|
+
const candidateLedger = extractCandidateLedger(matrixContent);
|
|
495
|
+
const deferredItems = extractDeferredItems(matrixContent);
|
|
496
|
+
|
|
497
|
+
console.log(` Summary: ${summary.totalCases} cases, ${summary.modules} modules, ${summary.deferred} deferred`);
|
|
498
|
+
|
|
499
|
+
// Ensure ledger directory exists
|
|
500
|
+
if (!existsSync(ledgerDir)) {
|
|
501
|
+
mkdirSync(ledgerDir, { recursive: true });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Step 2: mergeBaselines
|
|
505
|
+
const mergeResults = mergeBaselines(ledgerDir, changeName, moduleSections, candidateLedger, deferredItems);
|
|
506
|
+
console.log(`✅ Step 2: mergeBaselines — ${Object.keys(mergeResults).length} modules processed`);
|
|
507
|
+
|
|
508
|
+
// Step 3: resolveDeferred
|
|
509
|
+
const newCaseIds = [];
|
|
510
|
+
for (const section of Object.values(moduleSections)) {
|
|
511
|
+
const lines = section.split('\n');
|
|
512
|
+
for (const line of lines) {
|
|
513
|
+
if (line.startsWith('|') && !line.match(/^\|\s*[-:]+/) && !line.match(/\|\s*case_id/)) {
|
|
514
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
515
|
+
if (cells[0]) newCaseIds.push(cells[0]);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
const deferredResult = resolveDeferred(ledgerDir, changeName, newCaseIds);
|
|
520
|
+
console.log(`✅ Step 3: resolveDeferred — ${deferredResult.resolved} items resolved`);
|
|
521
|
+
|
|
522
|
+
// Step 4: appendChangelog
|
|
523
|
+
const changelogPath = appendChangelog(ledgerDir, changeName, matrixContent);
|
|
524
|
+
console.log(`✅ Step 4: appendChangelog — ${relative(projectRoot, changelogPath)}`);
|
|
525
|
+
|
|
526
|
+
// Step 5: rewriteIndex
|
|
527
|
+
const indexResult = rewriteIndex(ledgerDir);
|
|
528
|
+
console.log(`✅ Step 5: rewriteIndex — ${indexResult.modules} modules, ${indexResult.totalCases} total cases`);
|
|
529
|
+
|
|
530
|
+
// Step 6: gitCommit
|
|
531
|
+
gitCommit(projectRoot, changeName, dryRun);
|
|
532
|
+
console.log('✅ Step 6: gitCommit');
|
|
533
|
+
|
|
534
|
+
console.log(`\n🎉 test-merge complete for ${changeName}`);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
main().catch(err => {
|
|
538
|
+
console.error('test-merge error:', err.message);
|
|
539
|
+
process.exit(1);
|
|
540
|
+
});
|