kld-sdd 2.6.11 → 2.6.13
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/README.md +20 -0
- package/kld-sdd-guide.html +429 -159
- package/lib/hook-gate-core.js +41 -5
- package/lib/init.js +101 -95
- package/package.json +5 -2
- package/skywalk-sdd/index.cjs +2668 -415
- package/skywalk-sdd/metrics-v3.cjs +1153 -0
- package/skywalk-sdd/ontology/archive-package.cjs +114 -3
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -80
- package/skywalk-sdd/ontology/working-artifacts.cjs +2 -1
- package/templates/hooks/claude/hooks/sdd-apply-test-gate.cjs +39 -9
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +87 -8
- package/templates/hooks/claude/hooks/sdd-prompt.cjs +25 -5
- package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +39 -9
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +87 -9
- package/templates/hooks/codebuddy/hooks/sdd-prompt.cjs +24 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +17 -16
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +22 -22
- package/templates/skills/kld-sdd/opsx-apply/implementer-prompt.md +12 -12
- package/templates/skills/kld-sdd/opsx-apply/reference.md +43 -42
- package/templates/skills/kld-sdd/opsx-apply/worktree-setup.md +3 -3
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +58 -20
- package/templates/skills/kld-sdd/opsx-archive/checklist.md +6 -3
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +30 -22
- package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -6
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +5 -5
- package/templates/skills/kld-sdd/opsx-design/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-design/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +5 -5
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +20 -12
- package/templates/skills/kld-sdd/opsx-propose/reference.md +31 -5
- package/templates/skills/kld-sdd/opsx-rules/SKILL.md +3 -3
- package/templates/skills/kld-sdd/opsx-rules/reference.md +3 -1
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +6 -6
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-spec/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +14 -14
- package/templates/skills/kld-sdd/opsx-task/checklist.md +3 -3
- package/templates/skills/kld-sdd/opsx-task/reference.md +5 -5
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +11 -9
- package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/SKILL.md +4 -4
- package/templates/skills/kld-sdd/{opsx-tdd-anti-patterns → tdd-anti-patterns}/reference.md +4 -4
- package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/SKILL.md +13 -13
- package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/checklist.md +5 -5
- package/templates/skills/kld-sdd/{opsx-tdd-core → tdd-core}/reference.md +12 -12
- package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/SKILL.md +3 -3
- package/templates/skills/kld-sdd/{opsx-tdd-metrics → tdd-metrics}/checklist.md +2 -2
- package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/SKILL.md +2 -2
- package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/parameterized-testing.md +1 -1
- package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/SKILL.md +5 -5
- package/templates/skills/kld-sdd/{opsx-tdd-review → tdd-review}/checklist.md +5 -5
- package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/SKILL.md +3 -3
- package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/non-tdd-modules.md +1 -1
- package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/refactor-checklist.md +1 -1
- package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +19 -0
- package/templates/skills/kld-sdd/opsx-tdd-rules/rules/test-skeleton-telemetry.md +0 -19
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/cause-effect-clarity.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/clean-test-data.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/existing-test-awareness.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/given-when-then.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/good-test-qualities.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/mock-boundary.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/naming-conventions.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/no-logic-in-tests.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/one-test-one-scenario.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/prefer-public-apis.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/general/test-behaviors-not-methods.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/argument-matching.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/controller-test-rules.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/domain-service-rules.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/java-test-template.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/json-serialization.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/java/logging-rules.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/compilation-verification.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/post-generation/execution-verification.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/python/py-test-template.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-quality → tdd-quality}/rules/typescript/ts-test-template.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/controller-strategy.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/dag-generation-rules.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/des-step-annotation.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/exception-path-coverage.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-scope-declaration.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/green-yagni-fence.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/multi-validation-split.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/task-type-definitions.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/tdd-strategy-selection.md +0 -0
- /package/templates/skills/kld-sdd/{opsx-tdd-rules → tdd-rules}/rules/test-execution-gate.md +0 -0
package/skywalk-sdd/index.cjs
CHANGED
|
@@ -16,6 +16,7 @@ const path = require('path');
|
|
|
16
16
|
const { resolveProjectArg } = require('./ontology/resolve-spec-root.cjs');
|
|
17
17
|
const crypto = require('crypto');
|
|
18
18
|
const { execFileSync } = require('child_process');
|
|
19
|
+
const metricsV3 = require('./metrics-v3.cjs');
|
|
19
20
|
|
|
20
21
|
const SCHEMA_VERSION = 2;
|
|
21
22
|
|
|
@@ -53,6 +54,8 @@ function getStateDir(projectRoot) {
|
|
|
53
54
|
// SDD 标准阶段顺序
|
|
54
55
|
const STAGE_ORDER = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
|
|
55
56
|
const CORE_STAGES = ['propose', 'spec', 'design', 'apply', 'test'];
|
|
57
|
+
const MEASUREMENT_STAGES = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'archive'];
|
|
58
|
+
const AUXILIARY_STAGES = ['test', 'explore'];
|
|
56
59
|
|
|
57
60
|
// ── 工具函数 ──────────────────────────────────────────
|
|
58
61
|
function ensureDir(dirPath) {
|
|
@@ -149,6 +152,37 @@ function safeChangeName(name) {
|
|
|
149
152
|
return name.replace(/[^a-zA-Z0-9_-]/g, '-').replace(/^-+|-+$/g, '') || 'general';
|
|
150
153
|
}
|
|
151
154
|
|
|
155
|
+
function atomicWriteText(filePath, content) {
|
|
156
|
+
ensureDir(path.dirname(filePath));
|
|
157
|
+
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
158
|
+
let fd;
|
|
159
|
+
try {
|
|
160
|
+
fd = fs.openSync(tempPath, 'w');
|
|
161
|
+
fs.writeFileSync(fd, content, 'utf8');
|
|
162
|
+
fs.fsyncSync(fd);
|
|
163
|
+
fs.closeSync(fd);
|
|
164
|
+
fd = null;
|
|
165
|
+
fs.renameSync(tempPath, filePath);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (fd != null) {
|
|
168
|
+
try { fs.closeSync(fd); } catch {}
|
|
169
|
+
}
|
|
170
|
+
try { fs.rmSync(tempPath, { force: true }); } catch {}
|
|
171
|
+
throw error;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function requireValidChangeName(name) {
|
|
176
|
+
if (typeof name === 'string'
|
|
177
|
+
&& (path.isAbsolute(name) || /[\\/]/.test(name) || name === '.' || name === '..')) {
|
|
178
|
+
fail('E_PATH_OUTSIDE_CHANGE: change 路径不能超出 openspec/changes');
|
|
179
|
+
}
|
|
180
|
+
if (typeof name !== 'string' || !name || safeChangeName(name) !== name) {
|
|
181
|
+
fail(`E_CHANGE_NAME_INVALID: change 名称仅允许字母、数字、下划线和连字符,且不能以连字符开头或结尾`);
|
|
182
|
+
}
|
|
183
|
+
return name;
|
|
184
|
+
}
|
|
185
|
+
|
|
152
186
|
function getActiveStageKey(criteria) {
|
|
153
187
|
if (criteria.session_id) {
|
|
154
188
|
return `session-${safeChangeName(criteria.session_id)}`;
|
|
@@ -188,7 +222,7 @@ function findActiveStage(projectRoot, criteria) {
|
|
|
188
222
|
const exactFile = getActiveStageFile(projectRoot, criteria);
|
|
189
223
|
if (fs.existsSync(exactFile)) {
|
|
190
224
|
const exact = readStateFile(exactFile);
|
|
191
|
-
if (exact) return exact;
|
|
225
|
+
if (exact && activeStageMatches(exact, criteria)) return exact;
|
|
192
226
|
}
|
|
193
227
|
|
|
194
228
|
const stateDir = getStateDir(projectRoot);
|
|
@@ -246,6 +280,16 @@ function findActiveStage(projectRoot, criteria) {
|
|
|
246
280
|
return null;
|
|
247
281
|
}
|
|
248
282
|
|
|
283
|
+
function activeStageMatches(event, criteria = {}) {
|
|
284
|
+
if (!event) return false;
|
|
285
|
+
if (criteria.session_id && event.session_id !== criteria.session_id) return false;
|
|
286
|
+
if (criteria.change && event.change !== criteria.change) return false;
|
|
287
|
+
if (criteria.command && event.command !== criteria.command) return false;
|
|
288
|
+
if (criteria.agent_type && event.agent_type !== criteria.agent_type) return false;
|
|
289
|
+
if (criteria.capability && event.capability !== criteria.capability) return false;
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
|
|
249
293
|
function clearActiveStage(projectRoot, event) {
|
|
250
294
|
const files = [
|
|
251
295
|
getActiveStageFile(projectRoot, event),
|
|
@@ -301,7 +345,28 @@ function nextAvailableDir(baseDir, preferredName) {
|
|
|
301
345
|
}
|
|
302
346
|
|
|
303
347
|
function getChangeDir(projectRoot, changeName) {
|
|
304
|
-
|
|
348
|
+
const changesRoot = path.resolve(projectRoot, 'openspec', 'changes');
|
|
349
|
+
const changeDir = path.resolve(changesRoot, changeName);
|
|
350
|
+
if (changeDir === changesRoot || !changeDir.startsWith(`${changesRoot}${path.sep}`)) {
|
|
351
|
+
throw new Error('E_PATH_OUTSIDE_CHANGE: change 路径超出 openspec/changes');
|
|
352
|
+
}
|
|
353
|
+
for (const candidate of [
|
|
354
|
+
path.resolve(projectRoot, 'openspec'),
|
|
355
|
+
changesRoot,
|
|
356
|
+
changeDir,
|
|
357
|
+
]) {
|
|
358
|
+
if (fs.existsSync(candidate) && fs.lstatSync(candidate).isSymbolicLink()) {
|
|
359
|
+
throw new Error(`E_PATH_OUTSIDE_CHANGE: change 路径不能经过符号链接或 junction: ${candidate}`);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (fs.existsSync(changesRoot) && fs.existsSync(changeDir)) {
|
|
363
|
+
const canonicalRoot = fs.realpathSync.native(changesRoot);
|
|
364
|
+
const canonicalChange = fs.realpathSync.native(changeDir);
|
|
365
|
+
if (!canonicalChange.startsWith(`${canonicalRoot}${path.sep}`)) {
|
|
366
|
+
throw new Error('E_PATH_OUTSIDE_CHANGE: change 的真实路径超出 openspec/changes');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
return changeDir;
|
|
305
370
|
}
|
|
306
371
|
|
|
307
372
|
function getArchiveRoot(projectRoot) {
|
|
@@ -316,36 +381,142 @@ function discoverTaskFiles(projectRoot, changeName) {
|
|
|
316
381
|
}).sort();
|
|
317
382
|
}
|
|
318
383
|
|
|
384
|
+
function checklistEntriesOutsideCodeFences(lines) {
|
|
385
|
+
const entries = [];
|
|
386
|
+
let inCodeFence = false;
|
|
387
|
+
lines.forEach((line, index) => {
|
|
388
|
+
if (/^\s*```/.test(line)) {
|
|
389
|
+
inCodeFence = !inCodeFence;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (inCodeFence) return;
|
|
393
|
+
const match = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
|
|
394
|
+
if (!match || match[2].trim() === '无') return;
|
|
395
|
+
entries.push({
|
|
396
|
+
checked: /[xX]/.test(match[1]),
|
|
397
|
+
line: index + 1,
|
|
398
|
+
text: match[2].trim(),
|
|
399
|
+
raw: line,
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
return entries;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function checkboxTokensOutsideCodeFences(lines) {
|
|
406
|
+
const entries = [];
|
|
407
|
+
let inCodeFence = false;
|
|
408
|
+
lines.forEach((line, index) => {
|
|
409
|
+
if (/^\s*```/.test(line)) {
|
|
410
|
+
inCodeFence = !inCodeFence;
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (inCodeFence) return;
|
|
414
|
+
if (/\[[xX]\]/.test(line)) {
|
|
415
|
+
entries.push({ checked: true, line: index + 1, raw: line });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (/\[\s\]/.test(line)) {
|
|
419
|
+
const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
|
|
420
|
+
if (afterCheckbox !== '无') {
|
|
421
|
+
entries.push({ checked: false, line: index + 1, raw: line });
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
});
|
|
425
|
+
return entries;
|
|
426
|
+
}
|
|
427
|
+
|
|
319
428
|
function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
320
429
|
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
321
430
|
const files = [];
|
|
322
431
|
let completed = 0;
|
|
323
432
|
let incomplete = 0;
|
|
324
433
|
const incompleteItems = [];
|
|
434
|
+
const primaryTasks = [];
|
|
435
|
+
const acceptanceItems = [];
|
|
436
|
+
let structuredTaskCount = 0;
|
|
325
437
|
|
|
326
438
|
for (const filePath of taskFiles) {
|
|
327
439
|
const relPath = path.relative(normalizedRoot, filePath).replace(/\\/g, '/');
|
|
328
440
|
const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
|
|
329
441
|
let fileCompleted = 0;
|
|
330
442
|
let fileIncomplete = 0;
|
|
443
|
+
let currentTask = null;
|
|
444
|
+
let inCodeFence = false;
|
|
445
|
+
|
|
446
|
+
const finishTask = () => {
|
|
447
|
+
if (!currentTask) return;
|
|
448
|
+
if (currentTask.statuses.length > 0) structuredTaskCount += 1;
|
|
449
|
+
const completedStatus = currentTask.statuses.length === 1 && currentTask.statuses[0].checked;
|
|
450
|
+
primaryTasks.push({
|
|
451
|
+
task_id: currentTask.taskId,
|
|
452
|
+
file: relPath,
|
|
453
|
+
line: currentTask.line,
|
|
454
|
+
completed: completedStatus,
|
|
455
|
+
ambiguous: currentTask.statuses.length !== 1,
|
|
456
|
+
status_count: currentTask.statuses.length,
|
|
457
|
+
});
|
|
458
|
+
currentTask = null;
|
|
459
|
+
};
|
|
331
460
|
|
|
332
|
-
|
|
333
|
-
|
|
461
|
+
const checkboxTokens = checkboxTokensOutsideCodeFences(lines);
|
|
462
|
+
for (const entry of checkboxTokens) {
|
|
463
|
+
if (entry.checked) {
|
|
334
464
|
completed += 1;
|
|
335
465
|
fileCompleted += 1;
|
|
336
|
-
} else
|
|
337
|
-
// P2-1: `- [ ] 无` 是占位符(表示"无文档更新/无风险/无遗留"),不计入 incomplete
|
|
338
|
-
const afterCheckbox = line.replace(/^\s*-\s*\[\s\]\s*/, '').trim();
|
|
339
|
-
if (afterCheckbox === '无') return;
|
|
466
|
+
} else {
|
|
340
467
|
incomplete += 1;
|
|
341
468
|
fileIncomplete += 1;
|
|
342
469
|
incompleteItems.push({
|
|
343
470
|
file: relPath,
|
|
471
|
+
line: entry.line,
|
|
472
|
+
text: entry.raw.trim(),
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
lines.forEach((line, index) => {
|
|
478
|
+
if (/^\s*```/.test(line)) {
|
|
479
|
+
inCodeFence = !inCodeFence;
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
if (inCodeFence) return;
|
|
483
|
+
const taskHeader = line.match(/^###\s*\[([^\]]+)\]/);
|
|
484
|
+
if (taskHeader) {
|
|
485
|
+
finishTask();
|
|
486
|
+
currentTask = {
|
|
487
|
+
taskId: taskHeader[1].trim(),
|
|
488
|
+
line: index + 1,
|
|
489
|
+
statuses: [],
|
|
490
|
+
};
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
if (/^###\s/.test(line)) {
|
|
494
|
+
finishTask();
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (!currentTask) return;
|
|
498
|
+
|
|
499
|
+
const statusMatch = line.match(/^\s*(?:-\s*)?\*\*状态\*\*\s*[::]\s*\[([ xX])\]\s*(.*)$/);
|
|
500
|
+
if (statusMatch) {
|
|
501
|
+
currentTask.statuses.push({
|
|
502
|
+
checked: /[xX]/.test(statusMatch[1]),
|
|
344
503
|
line: index + 1,
|
|
345
504
|
text: line.trim(),
|
|
346
505
|
});
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
508
|
+
const acceptanceMatch = line.match(/^\s*-\s*\[([ xX])\]\s*(.*)$/);
|
|
509
|
+
if (acceptanceMatch && acceptanceMatch[2].trim() !== '无') {
|
|
510
|
+
acceptanceItems.push({
|
|
511
|
+
task_id: currentTask.taskId,
|
|
512
|
+
file: relPath,
|
|
513
|
+
line: index + 1,
|
|
514
|
+
confirmed: /[xX]/.test(acceptanceMatch[1]),
|
|
515
|
+
text: acceptanceMatch[2].trim(),
|
|
516
|
+
});
|
|
347
517
|
}
|
|
348
518
|
});
|
|
519
|
+
finishTask();
|
|
349
520
|
|
|
350
521
|
files.push({
|
|
351
522
|
path: relPath,
|
|
@@ -355,6 +526,37 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
|
355
526
|
});
|
|
356
527
|
}
|
|
357
528
|
|
|
529
|
+
const taskIdCounts = new Map();
|
|
530
|
+
for (const task of primaryTasks) {
|
|
531
|
+
taskIdCounts.set(task.task_id, (taskIdCounts.get(task.task_id) || 0) + 1);
|
|
532
|
+
}
|
|
533
|
+
for (const task of primaryTasks) {
|
|
534
|
+
if ((taskIdCounts.get(task.task_id) || 0) > 1) {
|
|
535
|
+
task.completed = false;
|
|
536
|
+
task.ambiguous = true;
|
|
537
|
+
task.ambiguity_reason = 'duplicate-task-id';
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const useLegacyFallback = structuredTaskCount === 0;
|
|
542
|
+
const normalizedPrimaryTasks = useLegacyFallback
|
|
543
|
+
? files.flatMap((file) => {
|
|
544
|
+
const sourceLines = fs.readFileSync(path.resolve(normalizedRoot, file.path), 'utf8').split(/\r?\n/);
|
|
545
|
+
return checklistEntriesOutsideCodeFences(sourceLines).map(entry => ({
|
|
546
|
+
task_id: null,
|
|
547
|
+
file: file.path,
|
|
548
|
+
line: entry.line,
|
|
549
|
+
completed: entry.checked,
|
|
550
|
+
ambiguous: false,
|
|
551
|
+
status_count: 1,
|
|
552
|
+
}));
|
|
553
|
+
})
|
|
554
|
+
: primaryTasks;
|
|
555
|
+
const primaryCompleted = normalizedPrimaryTasks.filter(task => task.completed).length;
|
|
556
|
+
const primaryIncomplete = normalizedPrimaryTasks.length - primaryCompleted;
|
|
557
|
+
const acceptanceConfirmed = acceptanceItems.filter(item => item.confirmed).length;
|
|
558
|
+
const acceptanceUnconfirmed = acceptanceItems.length - acceptanceConfirmed;
|
|
559
|
+
|
|
358
560
|
return {
|
|
359
561
|
change: changeName,
|
|
360
562
|
task_files: files,
|
|
@@ -363,6 +565,23 @@ function scanTaskCompletionFromFiles(projectRoot, changeName, taskFiles) {
|
|
|
363
565
|
total: completed + incomplete,
|
|
364
566
|
has_incomplete: incomplete > 0,
|
|
365
567
|
incomplete_items: incompleteItems,
|
|
568
|
+
primary_tasks: {
|
|
569
|
+
completed: primaryCompleted,
|
|
570
|
+
incomplete: primaryIncomplete,
|
|
571
|
+
total: normalizedPrimaryTasks.length,
|
|
572
|
+
has_incomplete: primaryIncomplete > 0,
|
|
573
|
+
status: primaryIncomplete > 0 ? 'incomplete' : 'complete',
|
|
574
|
+
ambiguous: normalizedPrimaryTasks.filter(task => task.ambiguous).length,
|
|
575
|
+
parser: useLegacyFallback ? 'legacy-fallback' : 'structured-status',
|
|
576
|
+
tasks: normalizedPrimaryTasks,
|
|
577
|
+
},
|
|
578
|
+
acceptance_evidence: {
|
|
579
|
+
confirmed: acceptanceConfirmed,
|
|
580
|
+
unconfirmed: acceptanceUnconfirmed,
|
|
581
|
+
total: acceptanceItems.length,
|
|
582
|
+
status: acceptanceUnconfirmed > 0 ? 'pending' : 'confirmed',
|
|
583
|
+
items: acceptanceItems,
|
|
584
|
+
},
|
|
366
585
|
};
|
|
367
586
|
}
|
|
368
587
|
|
|
@@ -395,25 +614,60 @@ function scanTaskCompletionForArchiveDir(projectRoot, changeName, archiveDir) {
|
|
|
395
614
|
|
|
396
615
|
// ── check-task 子命令辅助函数 ─────────────────────────────
|
|
397
616
|
|
|
398
|
-
|
|
399
|
-
function checkTaskInFile(filePath, taskId, dryRun = false) {
|
|
617
|
+
function inspectTaskInFile(filePath, taskId) {
|
|
400
618
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
401
619
|
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
402
620
|
const lines = content.split(/\r?\n/);
|
|
403
621
|
const escapedTaskId = taskId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
404
622
|
const taskHeaderRe = new RegExp(`^###\\s*\\[?${escapedTaskId}\\]?(?:\\s|$|\\])`);
|
|
405
623
|
let inTaskSection = false;
|
|
406
|
-
let
|
|
407
|
-
const
|
|
624
|
+
let inCodeFence = false;
|
|
625
|
+
const headerIndexes = [];
|
|
626
|
+
const statusIndexes = [];
|
|
627
|
+
lines.forEach((line, index) => {
|
|
628
|
+
if (/^\s*```/.test(line)) {
|
|
629
|
+
inCodeFence = !inCodeFence;
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
if (inCodeFence) return;
|
|
408
633
|
if (/^###\s/.test(line)) {
|
|
409
634
|
inTaskSection = taskHeaderRe.test(line);
|
|
635
|
+
if (inTaskSection) headerIndexes.push(index);
|
|
410
636
|
}
|
|
411
|
-
if (inTaskSection &&
|
|
412
|
-
|
|
413
|
-
return line.replace(/\[ \]/, '[x]');
|
|
637
|
+
if (inTaskSection && /^\s*(?:-\s*)?\*\*状态\*\*\s*[::]\s*\[[ xX]\]/.test(line)) {
|
|
638
|
+
statusIndexes.push(index);
|
|
414
639
|
}
|
|
415
|
-
return line;
|
|
416
640
|
});
|
|
641
|
+
return { content, eol, lines, headerIndexes, statusIndexes };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** 在 tasks.md 文件中定位并勾选指定 task_id 的唯一状态行 */
|
|
645
|
+
function checkTaskInFile(filePath, taskId, dryRun = false) {
|
|
646
|
+
const inspection = inspectTaskInFile(filePath, taskId);
|
|
647
|
+
const { content, eol, lines, headerIndexes, statusIndexes } = inspection;
|
|
648
|
+
if (headerIndexes.length !== 1) {
|
|
649
|
+
return {
|
|
650
|
+
changed: false,
|
|
651
|
+
linesChanged: 0,
|
|
652
|
+
reason: headerIndexes.length === 0 ? 'task-id-not-found' : 'task-id-ambiguous',
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
if (statusIndexes.length !== 1) {
|
|
656
|
+
return {
|
|
657
|
+
changed: false,
|
|
658
|
+
linesChanged: 0,
|
|
659
|
+
reason: statusIndexes.length === 0 ? 'status-line-missing' : 'status-line-ambiguous',
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const statusIndex = statusIndexes[0];
|
|
663
|
+
if (/\[[xX]\]/.test(lines[statusIndex])) {
|
|
664
|
+
return { changed: false, linesChanged: 0, reason: 'already-completed' };
|
|
665
|
+
}
|
|
666
|
+
const newLines = lines.slice();
|
|
667
|
+
newLines[statusIndex] = newLines[statusIndex]
|
|
668
|
+
.replace(/\[ \]/, '[x]')
|
|
669
|
+
.replace(/(\[x\])(?:\s+未完成)?\s*$/, '$1 已完成');
|
|
670
|
+
const changed = true;
|
|
417
671
|
if (changed && !dryRun) {
|
|
418
672
|
fs.writeFileSync(filePath, newLines.join(eol), 'utf8');
|
|
419
673
|
}
|
|
@@ -427,16 +681,42 @@ function runCheckTaskSync(projectRoot, changeName, taskId, dryRun = false) {
|
|
|
427
681
|
if (taskFiles.length === 0) {
|
|
428
682
|
throw new Error(`未找到 change ${changeName} 的 tasks.md`);
|
|
429
683
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
684
|
+
const inspections = taskFiles.map(filePath => ({
|
|
685
|
+
filePath,
|
|
686
|
+
inspection: inspectTaskInFile(filePath, taskId),
|
|
687
|
+
}));
|
|
688
|
+
const headerCount = inspections.reduce(
|
|
689
|
+
(total, item) => total + item.inspection.headerIndexes.length,
|
|
690
|
+
0,
|
|
691
|
+
);
|
|
692
|
+
if (headerCount === 0) {
|
|
693
|
+
const error = new Error(`TASK_ID_NOT_FOUND: 未找到任务 "${taskId}"`);
|
|
694
|
+
error.code = 'TASK_ID_NOT_FOUND';
|
|
695
|
+
throw error;
|
|
696
|
+
}
|
|
697
|
+
if (headerCount !== 1) {
|
|
698
|
+
const error = new Error(`TASK_ID_AMBIGUOUS: 任务 "${taskId}" 在 change 中出现 ${headerCount} 次`);
|
|
699
|
+
error.code = 'TASK_ID_AMBIGUOUS';
|
|
700
|
+
throw error;
|
|
701
|
+
}
|
|
702
|
+
const target = inspections.find(item => item.inspection.headerIndexes.length === 1);
|
|
703
|
+
if (target.inspection.statusIndexes.length !== 1) {
|
|
704
|
+
const code = target.inspection.statusIndexes.length === 0
|
|
705
|
+
? 'TASK_STATUS_MISSING'
|
|
706
|
+
: 'TASK_STATUS_AMBIGUOUS';
|
|
707
|
+
const error = new Error(`${code}: 任务 "${taskId}" 必须恰好有一条状态行`);
|
|
708
|
+
error.code = code;
|
|
709
|
+
throw error;
|
|
710
|
+
}
|
|
711
|
+
const result = checkTaskInFile(target.filePath, taskId, dryRun);
|
|
712
|
+
return {
|
|
713
|
+
changed: result.changed,
|
|
714
|
+
changed_files: result.changed
|
|
715
|
+
? [path.relative(normalizedRoot, target.filePath).replace(/\\/g, '/')]
|
|
716
|
+
: [],
|
|
717
|
+
task_files_checked: taskFiles.length,
|
|
718
|
+
reason: result.reason,
|
|
719
|
+
};
|
|
440
720
|
}
|
|
441
721
|
|
|
442
722
|
/** check-task 子命令 CLI */
|
|
@@ -494,13 +774,146 @@ function findArchivedChangeDir(projectRoot, changeName) {
|
|
|
494
774
|
const archiveRoot = getArchiveRoot(projectRoot);
|
|
495
775
|
if (!fs.existsSync(archiveRoot)) return null;
|
|
496
776
|
|
|
497
|
-
const
|
|
777
|
+
const escapedChangeName = String(changeName).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
778
|
+
const exactArchiveName = new RegExp(`^(\\d{4}-\\d{2}-\\d{2})-${escapedChangeName}(?:-(\\d+))?$`);
|
|
498
779
|
const candidates = fs.readdirSync(archiveRoot, { withFileTypes: true })
|
|
499
|
-
.filter(entry => entry.isDirectory()
|
|
500
|
-
.map(entry =>
|
|
501
|
-
|
|
780
|
+
.filter(entry => entry.isDirectory())
|
|
781
|
+
.map(entry => {
|
|
782
|
+
const match = entry.name.match(exactArchiveName);
|
|
783
|
+
if (!match) return null;
|
|
784
|
+
const archivePath = path.join(archiveRoot, entry.name);
|
|
785
|
+
return {
|
|
786
|
+
path: archivePath,
|
|
787
|
+
date: match[1],
|
|
788
|
+
suffix: match[2] ? Number(match[2]) : 1,
|
|
789
|
+
mtimeMs: fs.statSync(archivePath).mtimeMs,
|
|
790
|
+
};
|
|
791
|
+
})
|
|
792
|
+
.filter(Boolean)
|
|
793
|
+
.sort((a, b) => (
|
|
794
|
+
b.date.localeCompare(a.date)
|
|
795
|
+
|| b.suffix - a.suffix
|
|
796
|
+
|| b.mtimeMs - a.mtimeMs
|
|
797
|
+
));
|
|
798
|
+
|
|
799
|
+
return candidates[0]?.path || null;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
// V3: 统一解析变更文档根目录(active 优先,找不到再找 archive),供场景计数/变更类型读取复用
|
|
803
|
+
function resolveChangeDocumentRoot(projectRoot, changeName) {
|
|
804
|
+
const activeDir = getChangeDir(projectRoot, changeName);
|
|
805
|
+
if (fs.existsSync(activeDir)) {
|
|
806
|
+
return { root: activeDir, source: 'active' };
|
|
807
|
+
}
|
|
808
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
809
|
+
if (archiveDir) {
|
|
810
|
+
return { root: archiveDir, source: 'archive' };
|
|
811
|
+
}
|
|
812
|
+
return { root: null, source: null };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// V3: 收集一个变更目录下的 spec.md 文件(优先 specs/**/spec.md,Simple 模式 fallback 根 spec.md)
|
|
816
|
+
function collectSpecFiles(changeRoot) {
|
|
817
|
+
if (!changeRoot || !fs.existsSync(changeRoot)) return [];
|
|
818
|
+
const specsDir = path.join(changeRoot, 'specs');
|
|
819
|
+
if (fs.existsSync(specsDir) && fs.statSync(specsDir).isDirectory()) {
|
|
820
|
+
return walkFiles(specsDir, filePath => path.basename(filePath).toLowerCase() === 'spec.md').sort();
|
|
821
|
+
}
|
|
822
|
+
const rootSpec = path.join(changeRoot, 'spec.md');
|
|
823
|
+
if (fs.existsSync(rootSpec)) return [rootSpec];
|
|
824
|
+
return [];
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
// V3 E1: 统计变更规约文档中的场景数(##### 场景/Scenario:标题计数),按绝对路径去重
|
|
828
|
+
function countSpecScenarios(projectRoot, changeName) {
|
|
829
|
+
const { root, source } = resolveChangeDocumentRoot(projectRoot, changeName);
|
|
830
|
+
if (!root) {
|
|
831
|
+
return { scenario_count: 0, files: [], source: null, root: null, availability_reason: 'change-not-found' };
|
|
832
|
+
}
|
|
833
|
+
const specFiles = collectSpecFiles(root);
|
|
834
|
+
if (specFiles.length === 0) {
|
|
835
|
+
return {
|
|
836
|
+
scenario_count: 0,
|
|
837
|
+
files: [],
|
|
838
|
+
source,
|
|
839
|
+
root: toProjectRelative(projectRoot, root),
|
|
840
|
+
availability_reason: 'no-spec-files',
|
|
841
|
+
};
|
|
842
|
+
}
|
|
843
|
+
const seen = new Set();
|
|
844
|
+
const files = [];
|
|
845
|
+
let total = 0;
|
|
846
|
+
for (const filePath of specFiles) {
|
|
847
|
+
const abs = path.resolve(filePath);
|
|
848
|
+
if (seen.has(abs)) continue;
|
|
849
|
+
seen.add(abs);
|
|
850
|
+
let text = '';
|
|
851
|
+
try { text = fs.readFileSync(abs, 'utf8'); } catch { text = ''; }
|
|
852
|
+
const count = metricsV3.countScenariosInMarkdown(text);
|
|
853
|
+
total += count;
|
|
854
|
+
files.push({ path: toProjectRelative(projectRoot, abs), count });
|
|
855
|
+
}
|
|
856
|
+
return { scenario_count: total, files, source, root: toProjectRelative(projectRoot, root) };
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// 极简 yaml-like frontmatter 解析:仅支持 --- 围栏内的单行 key: value(够用,不引入外部 yaml 依赖)
|
|
860
|
+
function parseSimpleFrontmatter(text) {
|
|
861
|
+
if (typeof text !== 'string' || !text) return {};
|
|
862
|
+
const normalized = text.replace(/\r\n/g, '\n');
|
|
863
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(normalized);
|
|
864
|
+
if (!match) return {};
|
|
865
|
+
const result = {};
|
|
866
|
+
for (const line of match[1].split('\n')) {
|
|
867
|
+
const kv = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line.trim());
|
|
868
|
+
if (!kv) continue;
|
|
869
|
+
let value = kv[2].trim();
|
|
870
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
871
|
+
value = value.slice(1, -1);
|
|
872
|
+
}
|
|
873
|
+
result[kv[1]] = value;
|
|
874
|
+
}
|
|
875
|
+
return result;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// V3: 从 proposal.md frontmatter 读取 change-type(active 优先,缺失/非法/找不到 → unknown)
|
|
879
|
+
function readChangeType(projectRoot, changeName) {
|
|
880
|
+
const { root } = resolveChangeDocumentRoot(projectRoot, changeName);
|
|
881
|
+
if (!root) return 'unknown';
|
|
882
|
+
const proposalPath = path.join(root, 'proposal.md');
|
|
883
|
+
if (!fs.existsSync(proposalPath)) return 'unknown';
|
|
884
|
+
let text = '';
|
|
885
|
+
try { text = fs.readFileSync(proposalPath, 'utf8'); } catch { return 'unknown'; }
|
|
886
|
+
const frontmatter = parseSimpleFrontmatter(text);
|
|
887
|
+
return metricsV3.validateChangeType(frontmatter['change-type']);
|
|
888
|
+
}
|
|
502
889
|
|
|
503
|
-
|
|
890
|
+
// V3: 组装喂给 metricsV3.computeV3ChangeMetrics 的 context;executionSummary 提供阶段耗时兜底
|
|
891
|
+
function buildV3ChangeContext(events, executionSummary, options = {}) {
|
|
892
|
+
const summary = executionSummary?.summary || {};
|
|
893
|
+
return {
|
|
894
|
+
change_name: options.change_name || null,
|
|
895
|
+
change_type: options.change_type || 'unknown',
|
|
896
|
+
total_stage_duration_ms: options.total_stage_duration_ms ?? summary.total_stage_duration_ms ?? null,
|
|
897
|
+
canonical_stage_duration_ms: options.canonical_stage_duration_ms ?? summary.effective_stage_duration_ms ?? null,
|
|
898
|
+
rework_stage_duration_ms: options.rework_stage_duration_ms ?? summary.rework_stage_duration_ms ?? null,
|
|
899
|
+
scenario_count: options.scenario_count ?? 0,
|
|
900
|
+
scenario_sources: options.scenario_sources || [],
|
|
901
|
+
check_results: options.check_results || null,
|
|
902
|
+
check_meta: options.check_meta || {},
|
|
903
|
+
q3: options.q3 || undefined,
|
|
904
|
+
coding_duration_ms: options.coding_duration_ms ?? 0,
|
|
905
|
+
spec_duration_ms: options.spec_duration_ms ?? 0,
|
|
906
|
+
rework_coding_duration_ms: options.rework_coding_duration_ms ?? 0,
|
|
907
|
+
rework_spec_duration_ms: options.rework_spec_duration_ms ?? 0,
|
|
908
|
+
p4_quality_gate_enforcement_rate: options.p4_quality_gate_enforcement_rate ?? null,
|
|
909
|
+
p_c_stage_closure_rate: options.p_c_stage_closure_rate ?? null,
|
|
910
|
+
p_h_telemetry_health_score: options.p_h_telemetry_health_score ?? null,
|
|
911
|
+
rework_summary: options.rework_summary || {},
|
|
912
|
+
q1_spec_conformance: options.q1_spec_conformance ?? null,
|
|
913
|
+
q1_source: options.q1_source || null,
|
|
914
|
+
q4_tdd_coverage: options.q4_tdd_coverage ?? null,
|
|
915
|
+
q4_source: options.q4_source || null,
|
|
916
|
+
};
|
|
504
917
|
}
|
|
505
918
|
|
|
506
919
|
function syncArchivedSpecs(projectRoot, archiveDir, changeName) {
|
|
@@ -601,43 +1014,85 @@ function assertConfirmedArchive(projectRoot, changeName, archiveDir, options = {
|
|
|
601
1014
|
}
|
|
602
1015
|
|
|
603
1016
|
function ensureArchiveManifest(projectRoot, changeName, archiveDir, options = {}) {
|
|
604
|
-
const copiedSpecs = Array.isArray(options.copiedSpecs)
|
|
605
|
-
? options.copiedSpecs
|
|
606
|
-
: syncArchivedSpecs(projectRoot, archiveDir, changeName);
|
|
607
1017
|
const committedArchiveDir = options.archivePath || archiveDir;
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
evidenceEventsPath =
|
|
1018
|
+
const prepare = () => {
|
|
1019
|
+
const copiedSpecs = Array.isArray(options.copiedSpecs)
|
|
1020
|
+
? options.copiedSpecs
|
|
1021
|
+
: syncArchivedSpecs(projectRoot, archiveDir, changeName);
|
|
1022
|
+
// U4: 归档时拷 events jsonl 进 archive/evidence/events/,报告可从归档目录重建(源数据与产物不再分离)
|
|
1023
|
+
const eventsSrcDir = path.join(getDataDir(projectRoot), 'events', safeChangeName(changeName));
|
|
1024
|
+
let evidenceEventsPath = null;
|
|
1025
|
+
if (fs.existsSync(eventsSrcDir)) {
|
|
1026
|
+
const eventsDstDir = path.join(archiveDir, 'evidence', 'events');
|
|
1027
|
+
copyDirSync(eventsSrcDir, eventsDstDir);
|
|
1028
|
+
evidenceEventsPath = toProjectRelative(
|
|
1029
|
+
projectRoot,
|
|
1030
|
+
path.join(committedArchiveDir, 'evidence', 'events'),
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
return {
|
|
1034
|
+
archiveDir,
|
|
1035
|
+
archivePath: committedArchiveDir,
|
|
1036
|
+
packagePath: Object.prototype.hasOwnProperty.call(options, 'packagePath')
|
|
1037
|
+
? options.packagePath
|
|
1038
|
+
: `${committedArchiveDir}.zip`,
|
|
1039
|
+
archivedAt: options.archivedAt,
|
|
1040
|
+
reason: options.reason || '变更已完成实施',
|
|
1041
|
+
method: options.method || 'skywalk-full-spec-archive',
|
|
1042
|
+
sourcePath: `openspec/changes/${changeName}`,
|
|
1043
|
+
copiedSpecs,
|
|
1044
|
+
evidenceEventsPath,
|
|
1045
|
+
};
|
|
1046
|
+
};
|
|
1047
|
+
const {
|
|
1048
|
+
finalizeArchivePackageAfterReports,
|
|
1049
|
+
materializeArchivePackage,
|
|
1050
|
+
} = require('./ontology/archive-package.cjs');
|
|
1051
|
+
const produced = options.finalizeAfterReports
|
|
1052
|
+
? finalizeArchivePackageAfterReports({
|
|
615
1053
|
projectRoot,
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
? options.packagePath
|
|
627
|
-
: `${committedArchiveDir}.zip`,
|
|
628
|
-
archivedAt: options.archivedAt,
|
|
629
|
-
reason: options.reason || '变更已完成实施',
|
|
630
|
-
method: options.method || 'skywalk-full-spec-archive',
|
|
631
|
-
sourcePath: `openspec/changes/${changeName}`,
|
|
632
|
-
copiedSpecs,
|
|
633
|
-
evidenceEventsPath,
|
|
634
|
-
});
|
|
1054
|
+
changeName,
|
|
1055
|
+
runIdentity: options.runIdentity,
|
|
1056
|
+
lockOptions: options.lockOptions,
|
|
1057
|
+
prepare,
|
|
1058
|
+
})
|
|
1059
|
+
: materializeArchivePackage({
|
|
1060
|
+
projectRoot,
|
|
1061
|
+
changeName,
|
|
1062
|
+
...prepare(),
|
|
1063
|
+
});
|
|
635
1064
|
return {
|
|
636
1065
|
...produced,
|
|
637
|
-
copied_specs:
|
|
1066
|
+
copied_specs: produced.manifest.copied_specs || [],
|
|
638
1067
|
};
|
|
639
1068
|
}
|
|
640
1069
|
|
|
1070
|
+
function normalizeKnownReportExtension(reportPath, expectedExtension) {
|
|
1071
|
+
const currentExtension = path.extname(reportPath).toLowerCase();
|
|
1072
|
+
if (['.md', '.html', '.json'].includes(currentExtension) && currentExtension !== expectedExtension) {
|
|
1073
|
+
return reportPath.slice(0, -currentExtension.length) + expectedExtension;
|
|
1074
|
+
}
|
|
1075
|
+
return reportPath;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
function taskCompletionArchiveReason(taskCompletion, fallbackReason) {
|
|
1079
|
+
const primary = taskCompletion?.primary_tasks;
|
|
1080
|
+
if (primary?.parser === 'structured-status') {
|
|
1081
|
+
if (primary.has_incomplete) {
|
|
1082
|
+
return `尚有 ${primary.incomplete} 个主任务未完成`;
|
|
1083
|
+
}
|
|
1084
|
+
const pendingEvidence = taskCompletion?.acceptance_evidence?.unconfirmed || 0;
|
|
1085
|
+
if (pendingEvidence > 0) {
|
|
1086
|
+
return `任务已完成;仍有 ${pendingEvidence} 项验收证据待确认`;
|
|
1087
|
+
}
|
|
1088
|
+
return fallbackReason;
|
|
1089
|
+
}
|
|
1090
|
+
if (taskCompletion?.has_incomplete) {
|
|
1091
|
+
return `部分完成(${taskCompletion.incomplete} 项验收未勾选)`;
|
|
1092
|
+
}
|
|
1093
|
+
return fallbackReason;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
641
1096
|
function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, options = {}) {
|
|
642
1097
|
const normalizedRoot = normalizeProjectRoot(projectRoot);
|
|
643
1098
|
const activeChangeDir = getChangeDir(normalizedRoot, changeName);
|
|
@@ -666,14 +1121,15 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
|
|
|
666
1121
|
assertConfirmedArchive(normalizedRoot, changeName, archiveDir);
|
|
667
1122
|
|
|
668
1123
|
// 显式 reportOutput 优先;否则默认落归档后 archive 目录的 reports/ 子目录
|
|
669
|
-
const
|
|
1124
|
+
const requestedReportPath = options.reportOutput
|
|
670
1125
|
? (path.isAbsolute(options.reportOutput) ? options.reportOutput : path.resolve(normalizedRoot, options.reportOutput))
|
|
671
1126
|
: path.join(archiveDir, 'reports', `${safeChangeName(changeName)}-report.md`);
|
|
1127
|
+
const reportPath = normalizeKnownReportExtension(requestedReportPath, '.md');
|
|
672
1128
|
|
|
673
1129
|
// P2-2: task_completion 提前计算,has_incomplete 时调整归档原因
|
|
674
1130
|
const taskCompletion = details.archive_result?.task_completion || scanTaskCompletionForArchiveDir(normalizedRoot, changeName, archiveDir);
|
|
675
|
-
if (
|
|
676
|
-
archiveReason =
|
|
1131
|
+
if (!details.archive_result?.reason) {
|
|
1132
|
+
archiveReason = taskCompletionArchiveReason(taskCompletion, archiveReason);
|
|
677
1133
|
}
|
|
678
1134
|
const manifestInfo = ensureArchiveManifest(normalizedRoot, changeName, archiveDir, {
|
|
679
1135
|
reason: archiveReason,
|
|
@@ -692,9 +1148,6 @@ function ensureArchiveSuccessArtifacts(projectRoot, changeName, details = {}, op
|
|
|
692
1148
|
created_at: manifestInfo.manifest.created_at,
|
|
693
1149
|
archive_path: manifestInfo.manifest.archive_path,
|
|
694
1150
|
report_path: toProjectRelative(normalizedRoot, reportPath),
|
|
695
|
-
// report_html_path 为预期路径:由 cmdEnd 落盘阶段写入(try/catch 容错,失败时不阻塞 md 主产物)。
|
|
696
|
-
// 此处无条件派生,html 实际落盘失败时该路径可能不存在;语义为"预期路径",消费者不应假定文件已存在。
|
|
697
|
-
report_html_path: toProjectRelative(normalizedRoot, reportPath.replace(/\.md$/i, '.html')),
|
|
698
1151
|
manifest_path: toProjectRelative(normalizedRoot, manifestInfo.manifest_path),
|
|
699
1152
|
canonical_facts_path: toProjectRelative(normalizedRoot, manifestInfo.canonical_facts_path),
|
|
700
1153
|
conversion_report_path: toProjectRelative(normalizedRoot, manifestInfo.conversion_report_path),
|
|
@@ -772,9 +1225,7 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
|
|
|
772
1225
|
|
|
773
1226
|
const tcForReason = scanTaskCompletionForArchiveDir(normalizedRoot, changeName, stagingDir);
|
|
774
1227
|
let archiveReason = options.reason || '变更已完成实施';
|
|
775
|
-
|
|
776
|
-
archiveReason = `部分完成(${tcForReason.incomplete} 项验收未勾选)`;
|
|
777
|
-
}
|
|
1228
|
+
archiveReason = taskCompletionArchiveReason(tcForReason, archiveReason);
|
|
778
1229
|
specTransaction = prepareArchivedSpecSync(
|
|
779
1230
|
normalizedRoot,
|
|
780
1231
|
stagingDir,
|
|
@@ -852,8 +1303,85 @@ function archiveChangeDocs(projectRoot, changeName, options = {}) {
|
|
|
852
1303
|
}
|
|
853
1304
|
}
|
|
854
1305
|
|
|
855
|
-
|
|
856
|
-
|
|
1306
|
+
const EVENT_LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
|
|
1307
|
+
|
|
1308
|
+
function isProcessAlive(pid) {
|
|
1309
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
1310
|
+
try {
|
|
1311
|
+
process.kill(pid, 0);
|
|
1312
|
+
return true;
|
|
1313
|
+
} catch (error) {
|
|
1314
|
+
return error?.code === 'EPERM';
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function acquireEventWriteLock(dataDir, changeName, options = {}) {
|
|
1319
|
+
const locksDir = path.join(dataDir, 'events', '.locks');
|
|
1320
|
+
ensureDir(locksDir);
|
|
1321
|
+
const lockPath = path.join(locksDir, `${safeChangeName(changeName)}.lock`);
|
|
1322
|
+
const timeoutMs = Math.max(1000, Number(options.timeoutMs || 10000));
|
|
1323
|
+
const staleMs = Math.max(timeoutMs, Number(options.staleMs || 5 * 60 * 1000));
|
|
1324
|
+
const deadline = Date.now() + timeoutMs;
|
|
1325
|
+
|
|
1326
|
+
while (Date.now() <= deadline) {
|
|
1327
|
+
const token = crypto.randomUUID();
|
|
1328
|
+
const payload = {
|
|
1329
|
+
token,
|
|
1330
|
+
pid: process.pid,
|
|
1331
|
+
created_at: nowISO(),
|
|
1332
|
+
change: safeChangeName(changeName),
|
|
1333
|
+
};
|
|
1334
|
+
let descriptor;
|
|
1335
|
+
try {
|
|
1336
|
+
descriptor = fs.openSync(lockPath, 'wx');
|
|
1337
|
+
fs.writeFileSync(descriptor, JSON.stringify(payload), 'utf8');
|
|
1338
|
+
fs.closeSync(descriptor);
|
|
1339
|
+
return { ...payload, path: lockPath };
|
|
1340
|
+
} catch (error) {
|
|
1341
|
+
if (descriptor !== undefined) fs.closeSync(descriptor);
|
|
1342
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
1343
|
+
let existing = null;
|
|
1344
|
+
try {
|
|
1345
|
+
existing = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
1346
|
+
} catch {}
|
|
1347
|
+
const createdAt = Date.parse(existing?.created_at || '');
|
|
1348
|
+
const stale = !Number.isFinite(createdAt)
|
|
1349
|
+
|| Date.now() - createdAt > staleMs
|
|
1350
|
+
|| !isProcessAlive(Number(existing?.pid));
|
|
1351
|
+
if (stale) {
|
|
1352
|
+
try { fs.rmSync(lockPath, { force: true }); } catch {}
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
Atomics.wait(EVENT_LOCK_WAIT_BUFFER, 0, 0, 10);
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
const error = new Error(`EVENT_WRITE_LOCK_TIMEOUT: 等待 change "${changeName}" 的事件写锁超时`);
|
|
1359
|
+
error.code = 'EVENT_WRITE_LOCK_TIMEOUT';
|
|
1360
|
+
throw error;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
function releaseEventWriteLock(lock) {
|
|
1364
|
+
if (!lock?.path || !lock.token) return false;
|
|
1365
|
+
try {
|
|
1366
|
+
const current = JSON.parse(fs.readFileSync(lock.path, 'utf8'));
|
|
1367
|
+
if (current.token !== lock.token) return false;
|
|
1368
|
+
fs.rmSync(lock.path, { force: true });
|
|
1369
|
+
return true;
|
|
1370
|
+
} catch {
|
|
1371
|
+
return false;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
function withEventWriteLock(dataDir, changeName, action) {
|
|
1376
|
+
const lock = acquireEventWriteLock(dataDir, changeName);
|
|
1377
|
+
try {
|
|
1378
|
+
return action(lock);
|
|
1379
|
+
} finally {
|
|
1380
|
+
releaseEventWriteLock(lock);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
function appendEventUnlocked(dataDir, changeName, event) {
|
|
857
1385
|
const dir = path.join(dataDir, 'events', safeChangeName(changeName));
|
|
858
1386
|
ensureDir(dir);
|
|
859
1387
|
const file = path.join(dir, `${today()}.jsonl`);
|
|
@@ -865,6 +1393,31 @@ function appendEvent(dataDir, changeName, event) {
|
|
|
865
1393
|
appendMarkdownLog(dataDir, changeName, event);
|
|
866
1394
|
}
|
|
867
1395
|
|
|
1396
|
+
/** 追加一行 JSONL 到事件文件;同一 change 的事实写入和日志投影共享跨进程锁。 */
|
|
1397
|
+
function appendEvent(dataDir, changeName, event, options = {}) {
|
|
1398
|
+
if (options.lockHeld) {
|
|
1399
|
+
return appendEventUnlocked(dataDir, changeName, event);
|
|
1400
|
+
}
|
|
1401
|
+
return withEventWriteLock(dataDir, changeName, () => {
|
|
1402
|
+
if (event?.type === 'stage_end') {
|
|
1403
|
+
const existing = readAllEventEndsById(dataDir, event.event_id, changeName);
|
|
1404
|
+
if (existing.length > 0) {
|
|
1405
|
+
const latest = existing.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
|
|
1406
|
+
if (latest.result !== event.result) {
|
|
1407
|
+
const error = new Error(
|
|
1408
|
+
`E_STAGE_END_CONFLICT: event_id "${event.event_id}" 已记录 result=${latest.result},不能改为 ${event.result}`,
|
|
1409
|
+
);
|
|
1410
|
+
error.code = 'E_STAGE_END_CONFLICT';
|
|
1411
|
+
throw error;
|
|
1412
|
+
}
|
|
1413
|
+
return { idempotent: true, event: latest };
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
appendEventUnlocked(dataDir, changeName, event);
|
|
1417
|
+
return { idempotent: false, event };
|
|
1418
|
+
});
|
|
1419
|
+
}
|
|
1420
|
+
|
|
868
1421
|
/** 把 ISO 时间戳格式化为本地时区 `YYYY-MM-DD HH:mm:ss (UTC±HH:MM)`。
|
|
869
1422
|
* execution-log 面向人读,统一用本地时区 + 显式偏移标注,避免 UTC 裸值与用户体感差 8 小时(N9)。 */
|
|
870
1423
|
function formatLocalTimestamp(iso) {
|
|
@@ -881,66 +1434,75 @@ function formatLocalTimestamp(iso) {
|
|
|
881
1434
|
|
|
882
1435
|
/** 把事件同步追加为人读 markdown 行到 openspec/changes/<change>/logs/execution-log.md
|
|
883
1436
|
* (telemetry 副本;change 已归档时写入 archive 目录;失败不阻塞 telemetry 主流程) */
|
|
1437
|
+
function executionLogEventMarker(event) {
|
|
1438
|
+
return event?.event_id ? `<!-- event:${event.type || 'event'}:${event.event_id} -->` : '';
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1441
|
+
function formatExecutionLogEntry(event) {
|
|
1442
|
+
const ts = formatLocalTimestamp(event.timestamp || nowISO());
|
|
1443
|
+
const stage = event.command || event.stage || 'unknown';
|
|
1444
|
+
const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
|
|
1445
|
+
const marker = executionLogEventMarker(event);
|
|
1446
|
+
if (event.type === 'process_note') {
|
|
1447
|
+
const kind = event.details?.kind || event.details?.process_note?.kind || 'note';
|
|
1448
|
+
const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
|
|
1449
|
+
const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
|
|
1450
|
+
return `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}${marker ? ` ${marker}` : ''}\n`;
|
|
1451
|
+
}
|
|
1452
|
+
if (event.type === 'check_result') {
|
|
1453
|
+
const dispositions = event.details?.check_results?.warning_dispositions;
|
|
1454
|
+
if (Array.isArray(dispositions) && dispositions.length > 0) {
|
|
1455
|
+
const counts = dispositions.reduce((result, item) => {
|
|
1456
|
+
const key = item.disposition || 'open';
|
|
1457
|
+
result[key] = (result[key] || 0) + 1;
|
|
1458
|
+
return result;
|
|
1459
|
+
}, {});
|
|
1460
|
+
const dispositionText = Object.entries(counts)
|
|
1461
|
+
.map(([key, count]) => `${key}=${count}`)
|
|
1462
|
+
.join(', ');
|
|
1463
|
+
return `- [${ts}] ${stage} check_result → ${RESULT_MARK[event.result] || '📝'}${event.result ? `(${event.result})` : ''} | ${summary} | 告警处置:${dispositionText}${marker ? ` ${marker}` : ''}\n`;
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
const type = event.type || 'event';
|
|
1467
|
+
const result = event.result || '';
|
|
1468
|
+
const mark = RESULT_MARK[result] || '📝';
|
|
1469
|
+
return `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}${marker ? ` ${marker}` : ''}\n`;
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function renderExecutionLog(changeName, events) {
|
|
1473
|
+
const sorted = events.slice().sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
1474
|
+
const created = formatLocalTimestamp(sorted[0]?.timestamp || nowISO());
|
|
1475
|
+
return `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`
|
|
1476
|
+
+ sorted.map(formatExecutionLogEntry).join('');
|
|
1477
|
+
}
|
|
1478
|
+
|
|
884
1479
|
function appendMarkdownLog(dataDir, changeName, event) {
|
|
885
1480
|
try {
|
|
886
1481
|
const projectRoot = path.dirname(dataDir);
|
|
887
1482
|
const activeChangeDir = getChangeDir(projectRoot, changeName);
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
894
|
-
if (!archiveDir) return; // 未 init 且未归档 → 跳过 logs/ 创建
|
|
895
|
-
const archiveLogsDir = path.join(archiveDir, 'logs');
|
|
896
|
-
ensureDir(archiveLogsDir);
|
|
897
|
-
const file = path.join(archiveLogsDir, 'execution-log.md');
|
|
898
|
-
if (!fs.existsSync(file) || fs.statSync(file).size === 0) {
|
|
899
|
-
const created = formatLocalTimestamp(event.timestamp || nowISO());
|
|
900
|
-
fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
|
|
901
|
-
}
|
|
902
|
-
const ts = formatLocalTimestamp(event.timestamp || nowISO());
|
|
903
|
-
const stage = event.command || event.stage || 'unknown';
|
|
904
|
-
const summary = String(event.summary || '').replace(/\|/g, '/').replace(/\n/g, ' ');
|
|
905
|
-
if (event.type === 'process_note') {
|
|
906
|
-
const kind = event.details?.kind || 'note';
|
|
907
|
-
const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
|
|
908
|
-
const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
|
|
909
|
-
fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
const type = event.type || 'event';
|
|
913
|
-
const result = event.result || '';
|
|
914
|
-
const mark = RESULT_MARK[result] || '📝';
|
|
915
|
-
fs.appendFileSync(file, `- [${ts}] ${stage} ${type} → ${mark}${result ? '(' + result + ')' : ''} | ${summary}\n`, 'utf8');
|
|
916
|
-
return;
|
|
917
|
-
}
|
|
918
|
-
let logsDir = path.join(activeChangeDir, 'logs');
|
|
919
|
-
if (!fs.existsSync(activeChangeDir)) {
|
|
920
|
-
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
921
|
-
if (archiveDir) logsDir = path.join(archiveDir, 'logs');
|
|
922
|
-
}
|
|
1483
|
+
const archiveDir = findArchivedChangeDir(projectRoot, changeName);
|
|
1484
|
+
if (!fs.existsSync(activeChangeDir) && !archiveDir) return;
|
|
1485
|
+
|
|
1486
|
+
const targetChangeDir = fs.existsSync(activeChangeDir) ? activeChangeDir : archiveDir;
|
|
1487
|
+
const logsDir = path.join(targetChangeDir, 'logs');
|
|
923
1488
|
ensureDir(logsDir);
|
|
924
1489
|
const file = path.join(logsDir, 'execution-log.md');
|
|
925
|
-
if (
|
|
926
|
-
|
|
927
|
-
fs.writeFileSync(file, `# 执行日志 — ${changeName}\n\n> 变更创建时间:${created} | 变更名:${changeName}\n\n---\n\n`, 'utf8');
|
|
1490
|
+
if (fs.existsSync(file) && !fs.statSync(file).isFile()) {
|
|
1491
|
+
throw new Error(`execution-log 路径不是文件: ${file}`);
|
|
928
1492
|
}
|
|
929
|
-
|
|
930
|
-
const
|
|
931
|
-
const
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const target = event.details?.target ? ` → ${String(event.details.target).replace(/\|/g, '/')}` : '';
|
|
936
|
-
const round = Number.isFinite(event.details?.round) ? ` R${event.details.round}` : '';
|
|
937
|
-
fs.appendFileSync(file, `- [${ts}] ${stage} 📝 ${kind}${round} | ${summary}${target}\n`, 'utf8');
|
|
1493
|
+
|
|
1494
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
|
|
1495
|
+
const hasMarkers = /<!-- event:[^>]+ -->/.test(current);
|
|
1496
|
+
if (!current || !hasMarkers) {
|
|
1497
|
+
const allEvents = readEvents(dataDir, changeName);
|
|
1498
|
+
fs.writeFileSync(file, renderExecutionLog(changeName, allEvents), 'utf8');
|
|
938
1499
|
return;
|
|
939
1500
|
}
|
|
940
|
-
|
|
941
|
-
const
|
|
942
|
-
|
|
943
|
-
|
|
1501
|
+
|
|
1502
|
+
const marker = executionLogEventMarker(event);
|
|
1503
|
+
if (!marker || !current.includes(marker)) {
|
|
1504
|
+
fs.appendFileSync(file, formatExecutionLogEntry(event), 'utf8');
|
|
1505
|
+
}
|
|
944
1506
|
} catch (err) {
|
|
945
1507
|
// markdown 副本失败不影响 telemetry 主流程,但输出 stderr 提示保留可观测性(避免持续性失败长期静默)
|
|
946
1508
|
try { console.error(`[telemetry] execution-log 副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
@@ -948,7 +1510,7 @@ function appendMarkdownLog(dataDir, changeName, event) {
|
|
|
948
1510
|
}
|
|
949
1511
|
|
|
950
1512
|
/** 读取指定 change 的所有事件 */
|
|
951
|
-
function
|
|
1513
|
+
function readEventsRaw(dataDir, changeName) {
|
|
952
1514
|
try {
|
|
953
1515
|
const dir = path.join(dataDir, 'events', safeChangeName(changeName));
|
|
954
1516
|
if (!fs.existsSync(dir)) return [];
|
|
@@ -1505,76 +2067,143 @@ function compactReworkSummary(summary, reasons) {
|
|
|
1505
2067
|
};
|
|
1506
2068
|
}
|
|
1507
2069
|
|
|
1508
|
-
// report-time
|
|
1509
|
-
// -
|
|
1510
|
-
// - completed_rework_success
|
|
1511
|
-
//
|
|
1512
|
-
//
|
|
1513
|
-
//
|
|
1514
|
-
//
|
|
1515
|
-
|
|
2070
|
+
// report-time 推导返工原因:对 summarizeStageExecutions 产出的 reworkAttempts 细化
|
|
2071
|
+
// - incomplete/prev-failed/pre-archive-recheck 为确定性判定(不依赖 git/telemetry),保持不变
|
|
2072
|
+
// - completed_rework_success(上次成功却重跑)按窗口 [本次 end, 同 key 下一次 start) 推导证据:
|
|
2073
|
+
// 1) agent override(stage_end.rework_reason 合法枚举且非占位符 completed_rework_success)优先
|
|
2074
|
+
// 2) git log --all --since=window_start [--until=window_end]:窗口内有 commit → code-changed-after-pass
|
|
2075
|
+
// 3) 无 git 证据时退化到 metricsV3.findDevelopmentActivities 扫描 scopedEvents 同窗口的开发活动事件
|
|
2076
|
+
// 4) 都无证据 → recheck-no-change;委托 metricsV3.deriveSuccessfulReworkReason 统一裁决
|
|
2077
|
+
// 注:不再用 working tree 是否 dirty 作为全局证据(当前 dirty 状态无法归因到具体某次窗口,误报风险高)
|
|
2078
|
+
function readGitCommitTimeline(projectRoot) {
|
|
2079
|
+
if (!projectRoot || runGit(projectRoot, ['rev-parse', '--is-inside-work-tree']) !== 'true') {
|
|
2080
|
+
return null;
|
|
2081
|
+
}
|
|
2082
|
+
const log = runGit(projectRoot, ['log', '--all', '--format=%H%x09%cI%x09%s']);
|
|
2083
|
+
if (!log) return [];
|
|
2084
|
+
return log.split(/\r?\n/).filter(Boolean).map(line => {
|
|
2085
|
+
const parts = line.split('\t');
|
|
2086
|
+
return {
|
|
2087
|
+
sha: parts[0] || '',
|
|
2088
|
+
timestamp: parts[1] || null,
|
|
2089
|
+
timestamp_ms: Date.parse(parts[1]),
|
|
2090
|
+
subject: parts.slice(2).join('\t'),
|
|
2091
|
+
};
|
|
2092
|
+
}).filter(commit => Number.isFinite(commit.timestamp_ms));
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
function findGitCommitInWindow(timeline, windowStart, windowEnd) {
|
|
2096
|
+
if (!Array.isArray(timeline) || !windowStart) return null;
|
|
2097
|
+
const startMs = Date.parse(windowStart);
|
|
2098
|
+
const endMs = windowEnd ? Date.parse(windowEnd) : null;
|
|
2099
|
+
if (!Number.isFinite(startMs)) return null;
|
|
2100
|
+
return timeline.find(commit => (
|
|
2101
|
+
commit.timestamp_ms >= startMs
|
|
2102
|
+
&& (!Number.isFinite(endMs) || commit.timestamp_ms <= endMs)
|
|
2103
|
+
)) || null;
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
function resolveGitCommitTimeline(events, projectRoot, providedTimeline) {
|
|
2107
|
+
if (providedTimeline !== undefined) return providedTimeline;
|
|
2108
|
+
const startCounts = new Map();
|
|
2109
|
+
let hasRepeatedStage = false;
|
|
2110
|
+
for (const event of events) {
|
|
2111
|
+
if (event.orphan || event.type !== 'stage_start') continue;
|
|
2112
|
+
const key = `${event.change || ''}|${event.command || ''}|${event.capability || ''}`;
|
|
2113
|
+
const count = (startCounts.get(key) || 0) + 1;
|
|
2114
|
+
startCounts.set(key, count);
|
|
2115
|
+
if (count > 1) {
|
|
2116
|
+
hasRepeatedStage = true;
|
|
2117
|
+
break;
|
|
2118
|
+
}
|
|
2119
|
+
}
|
|
2120
|
+
return hasRepeatedStage ? readGitCommitTimeline(projectRoot) : null;
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
function deriveReworkReasons(executionSummary, projectRoot, scopedEvents = [], options = {}) {
|
|
1516
2124
|
const byCategory = createReasonCategory();
|
|
1517
2125
|
const details = [];
|
|
1518
2126
|
const reworkAttempts = executionSummary.reworkAttempts || [];
|
|
1519
|
-
const
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
};
|
|
2127
|
+
const auxiliaryExecutionSummary = summarizeStageExecutions(
|
|
2128
|
+
scopedEvents.filter(event => AUXILIARY_STAGES.includes(event.command || event.stage)),
|
|
2129
|
+
);
|
|
2130
|
+
const needsGitEvidence = reworkAttempts.some(attempt => attempt.rework_reason === 'completed_rework_success');
|
|
2131
|
+
const gitCommitTimeline = options.gitCommitTimeline !== undefined
|
|
2132
|
+
? options.gitCommitTimeline
|
|
2133
|
+
: (needsGitEvidence ? readGitCommitTimeline(projectRoot) : null);
|
|
2134
|
+
|
|
1528
2135
|
const bucketByKey = new Map();
|
|
1529
2136
|
for (const bucket of executionSummary.summary.by_stage || []) {
|
|
1530
2137
|
if (!bucket.reasons_by_category) bucket.reasons_by_category = createReasonCategory();
|
|
1531
2138
|
bucketByKey.set(`${bucket.command}|${bucket.capability || ''}`, bucket);
|
|
1532
2139
|
}
|
|
2140
|
+
|
|
2141
|
+
const attemptsByKey = new Map();
|
|
2142
|
+
for (const attempt of executionSummary.attempts || []) {
|
|
2143
|
+
const key = attempt.key || stageAttemptKey(attempt.start);
|
|
2144
|
+
if (!attemptsByKey.has(key)) attemptsByKey.set(key, []);
|
|
2145
|
+
attemptsByKey.get(key).push(attempt);
|
|
2146
|
+
}
|
|
2147
|
+
for (const list of attemptsByKey.values()) {
|
|
2148
|
+
list.sort((a, b) => timestampMs(a.start) - timestampMs(b.start));
|
|
2149
|
+
}
|
|
2150
|
+
|
|
1533
2151
|
for (const attempt of reworkAttempts) {
|
|
1534
2152
|
let reason = attempt.rework_reason;
|
|
1535
2153
|
let detail = null;
|
|
1536
|
-
let
|
|
2154
|
+
let reasonSource = 'deterministic';
|
|
2155
|
+
let windowStart = null;
|
|
2156
|
+
let windowEnd = null;
|
|
2157
|
+
let evidenceEventIds = [];
|
|
2158
|
+
let evidenceTypes = [];
|
|
2159
|
+
|
|
1537
2160
|
if (reason === 'completed_rework_success') {
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
const
|
|
1555
|
-
|
|
1556
|
-
reason = 'code-changed-after-pass';
|
|
1557
|
-
detail = recentCommitMessage;
|
|
1558
|
-
hasUncommittedFlag = uncommitted;
|
|
1559
|
-
} else if (uncommitted) {
|
|
1560
|
-
reason = 'code-changed-after-pass';
|
|
1561
|
-
hasUncommittedFlag = true;
|
|
1562
|
-
} else {
|
|
1563
|
-
reason = 'recheck-no-change';
|
|
1564
|
-
}
|
|
2161
|
+
const endIso = attempt.end && attempt.end.timestamp ? attempt.end.timestamp : null;
|
|
2162
|
+
const endMs = endIso ? Date.parse(endIso) : null;
|
|
2163
|
+
windowStart = Number.isFinite(endMs) ? endIso : null;
|
|
2164
|
+
|
|
2165
|
+
const key = attempt.key || stageAttemptKey(attempt.start);
|
|
2166
|
+
const siblings = attemptsByKey.get(key) || [];
|
|
2167
|
+
const idx = siblings.indexOf(attempt);
|
|
2168
|
+
const nextAttempt = idx >= 0 ? siblings[idx + 1] : null;
|
|
2169
|
+
windowEnd = nextAttempt ? nextAttempt.start.timestamp : null;
|
|
2170
|
+
|
|
2171
|
+
const commit = findGitCommitInWindow(gitCommitTimeline, windowStart, windowEnd);
|
|
2172
|
+
const gitHasCommit = Boolean(commit);
|
|
2173
|
+
const commitMessage = commit?.subject || null;
|
|
2174
|
+
|
|
2175
|
+
let devActivities = [];
|
|
2176
|
+
if (!gitHasCommit) {
|
|
2177
|
+
const endMsForScan = windowEnd ? Date.parse(windowEnd) : null;
|
|
2178
|
+
devActivities = metricsV3.findDevelopmentActivities(scopedEvents, endMs, endMsForScan);
|
|
1565
2179
|
}
|
|
2180
|
+
|
|
2181
|
+
const derived = metricsV3.deriveSuccessfulReworkReason({
|
|
2182
|
+
base_reason: 'completed_rework_success',
|
|
2183
|
+
agent_override_reason: attempt.end?.rework_reason || null,
|
|
2184
|
+
git_has_commit_in_window: gitHasCommit,
|
|
2185
|
+
dev_activities: devActivities,
|
|
2186
|
+
window: { start: windowStart, end: windowEnd },
|
|
2187
|
+
});
|
|
2188
|
+
|
|
2189
|
+
reason = derived.reason;
|
|
2190
|
+
reasonSource = derived.reason_source;
|
|
2191
|
+
evidenceEventIds = derived.evidence_event_ids || [];
|
|
2192
|
+
evidenceTypes = devActivities.map(e => e.type || e.event_type).filter(Boolean);
|
|
2193
|
+
detail = commitMessage;
|
|
1566
2194
|
}
|
|
1567
|
-
|
|
1568
|
-
//
|
|
2195
|
+
|
|
2196
|
+
// T3.6 + Q6: check 在 test 之后重跑(归档前强制复检)→ pre-archive-recheck(不论原 reason,确定性,优先级最高)
|
|
1569
2197
|
if (attempt.command === 'check') {
|
|
1570
2198
|
const canonicalCheck = getCanonicalAttempt(executionSummary, 'check', attempt.capability);
|
|
1571
|
-
|
|
1572
|
-
const canonicalTest = getCanonicalAttempt(executionSummary, 'test', attempt.capability);
|
|
2199
|
+
const canonicalTest = getCanonicalAttempt(auxiliaryExecutionSummary, 'test', attempt.capability);
|
|
1573
2200
|
if (canonicalCheck && canonicalCheck.start && canonicalTest && canonicalTest.end
|
|
1574
2201
|
&& timestampMs(canonicalCheck.start) > timestampMs(canonicalTest.end)) {
|
|
1575
2202
|
reason = 'pre-archive-recheck';
|
|
2203
|
+
reasonSource = 'deterministic';
|
|
1576
2204
|
}
|
|
1577
2205
|
}
|
|
2206
|
+
|
|
1578
2207
|
attempt.rework_reason = reason;
|
|
1579
2208
|
byCategory[reason] = (byCategory[reason] || 0) + 1;
|
|
1580
2209
|
const bucket = bucketByKey.get(`${attempt.command}|${attempt.capability || ''}`);
|
|
@@ -1585,8 +2214,12 @@ function deriveReworkReasons(executionSummary, projectRoot) {
|
|
|
1585
2214
|
command: attempt.command,
|
|
1586
2215
|
attempt_event_id: (attempt.start && attempt.start.event_id) || null,
|
|
1587
2216
|
reason,
|
|
2217
|
+
reason_source: reasonSource,
|
|
2218
|
+
window_start: windowStart,
|
|
2219
|
+
window_end: windowEnd,
|
|
2220
|
+
evidence_event_ids: evidenceEventIds,
|
|
2221
|
+
evidence_types: evidenceTypes,
|
|
1588
2222
|
detail,
|
|
1589
|
-
has_uncommitted: hasUncommittedFlag,
|
|
1590
2223
|
});
|
|
1591
2224
|
}
|
|
1592
2225
|
return { by_category: byCategory, details };
|
|
@@ -1615,6 +2248,13 @@ function getCheckResults(event) {
|
|
|
1615
2248
|
fixed_before_apply: Number.isFinite(results.fixed_before_apply) ? results.fixed_before_apply : null,
|
|
1616
2249
|
consistency_score: Number.isFinite(results.consistency_score) ? results.consistency_score : null,
|
|
1617
2250
|
categories: results.categories || {},
|
|
2251
|
+
// V3 Q3: reviewer 元信息,用于评估 reviewer_independence/score_status(缺失时保持 null,不影响旧字段)
|
|
2252
|
+
reviewer: results.reviewer || null,
|
|
2253
|
+
review_session_id: results.review_session_id || null,
|
|
2254
|
+
author_session_id: results.author_session_id || null,
|
|
2255
|
+
reviewer_independence: results.reviewer_independence || null,
|
|
2256
|
+
warning_items: Array.isArray(results.warning_items) ? results.warning_items : [],
|
|
2257
|
+
warning_dispositions: Array.isArray(results.warning_dispositions) ? results.warning_dispositions : [],
|
|
1618
2258
|
};
|
|
1619
2259
|
}
|
|
1620
2260
|
|
|
@@ -2293,10 +2933,18 @@ function computeGitDocumentMetrics(projectRoot, changeName) {
|
|
|
2293
2933
|
function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
2294
2934
|
const scopedEvents = events
|
|
2295
2935
|
.filter(e => !e.orphan)
|
|
2296
|
-
.filter(e => !options.change
|
|
2936
|
+
.filter(e => !options.change
|
|
2937
|
+
|| e.change === options.change
|
|
2938
|
+
|| e.attribution?.target_change === options.change)
|
|
2297
2939
|
.filter(e => !options.capability || e.capability === options.capability);
|
|
2298
|
-
const
|
|
2299
|
-
|
|
2940
|
+
const measurementEvents = scopedEvents.filter((event) => {
|
|
2941
|
+
const stage = event.command || event.stage;
|
|
2942
|
+
return MEASUREMENT_STAGES.includes(stage);
|
|
2943
|
+
});
|
|
2944
|
+
const executionSummary = summarizeStageExecutions(measurementEvents);
|
|
2945
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot, scopedEvents, {
|
|
2946
|
+
gitCommitTimeline: options.gitCommitTimeline,
|
|
2947
|
+
});
|
|
2300
2948
|
const starts = executionSummary.canonicalAttempts.map(attempt => attempt.start);
|
|
2301
2949
|
const ends = executionSummary.canonicalAttempts.map(attempt => attempt.end).filter(Boolean);
|
|
2302
2950
|
|
|
@@ -2306,11 +2954,13 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2306
2954
|
? new Date(archiveEnd.timestamp).getTime() - new Date(proposeStart.timestamp).getTime()
|
|
2307
2955
|
: null;
|
|
2308
2956
|
|
|
2309
|
-
const totalStageDuration = executionSummary.summary.
|
|
2957
|
+
const totalStageDuration = executionSummary.summary.total_stage_duration_ms;
|
|
2310
2958
|
const codingDuration = sumDurations(ends, ['apply']);
|
|
2311
2959
|
const specDuration = sumDurations(ends, ['propose', 'spec', 'design', 'task']);
|
|
2312
2960
|
const reworkCodingDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['apply']);
|
|
2313
2961
|
const reworkSpecDuration = sumAttemptDurations(executionSummary.reworkAttempts, ['propose', 'spec', 'design', 'task']);
|
|
2962
|
+
const codingDurationIncludingRework = codingDuration + reworkCodingDuration;
|
|
2963
|
+
const specDurationIncludingRework = specDuration + reworkSpecDuration;
|
|
2314
2964
|
|
|
2315
2965
|
const formalApplyAttempt = getCanonicalAttempt(executionSummary, 'apply', options.capability);
|
|
2316
2966
|
const formalApplyEvents = formalApplyAttempt
|
|
@@ -2342,9 +2992,20 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2342
2992
|
: null;
|
|
2343
2993
|
const specIterationSource = gitDocumentMetrics?.source || (gitDocumentMetrics?.git_available ? 'git' : null);
|
|
2344
2994
|
|
|
2345
|
-
|
|
2995
|
+
// P4 的边界必须是第一次实际进入 apply,而不是“最终 canonical apply”。
|
|
2996
|
+
// 否则先 apply 失败、后补 check、再 apply 成功会被误判为门禁前置。
|
|
2997
|
+
const firstApply = firstByTimestamp(
|
|
2998
|
+
executionSummary.attempts
|
|
2999
|
+
.filter(attempt => attempt.command === 'apply')
|
|
3000
|
+
.map(attempt => attempt.start),
|
|
3001
|
+
);
|
|
2346
3002
|
const qualityGateBeforeApply = firstApply
|
|
2347
|
-
?
|
|
3003
|
+
? executionSummary.attempts.some(attempt => (
|
|
3004
|
+
attempt.command === 'check'
|
|
3005
|
+
&& attempt.end
|
|
3006
|
+
&& ['success', 'partial'].includes(attempt.result)
|
|
3007
|
+
&& timestampMs(attempt.end) <= timestampMs(firstApply)
|
|
3008
|
+
))
|
|
2348
3009
|
: null;
|
|
2349
3010
|
const latestCheckWithFixRate = latestByTimestamp(checkEvents.filter(e => {
|
|
2350
3011
|
const results = getCheckResults(e);
|
|
@@ -2360,11 +3021,133 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2360
3021
|
const aiFirstPassMetrics = computeAiFirstPassMetrics(formalApplyEvents.length > 0 ? formalApplyEvents : scopedEvents);
|
|
2361
3022
|
const specTestCoverageMetrics = computeSpecTestCoverageMetrics(scopedEvents);
|
|
2362
3023
|
|
|
3024
|
+
// ── V3 ────────────────────────────────────────────────────────────
|
|
3025
|
+
const scenarioData = (options.projectRoot && options.change)
|
|
3026
|
+
? countSpecScenarios(options.projectRoot, options.change)
|
|
3027
|
+
: { scenario_count: 0, files: [], source: null, root: null, availability_reason: 'no-change-scope' };
|
|
3028
|
+
const changeType = (options.projectRoot && options.change)
|
|
3029
|
+
? readChangeType(options.projectRoot, options.change)
|
|
3030
|
+
: 'unknown';
|
|
3031
|
+
|
|
3032
|
+
// Q3: 优先取最新一次 total>0 的 check(而非仅要求带 consistency_score,兼容旧 Q5 逻辑保持不变)
|
|
3033
|
+
const latestCheckWithTotal = latestByTimestamp(checkEvents.filter(e => (getCheckResults(e)?.total ?? 0) > 0));
|
|
3034
|
+
const latestCheckResultsV3 = getCheckResults(latestCheckWithTotal);
|
|
3035
|
+
const checkMeta = {
|
|
3036
|
+
reviewer: latestCheckResultsV3?.reviewer || null,
|
|
3037
|
+
author: formalApplyAttempt?.start?.session_id || formalApplyAttempt?.start?.agent_type || null,
|
|
3038
|
+
reviewer_independence: latestCheckResultsV3?.reviewer_independence || null,
|
|
3039
|
+
review_session_id: latestCheckResultsV3?.review_session_id || null,
|
|
3040
|
+
author_session_id: latestCheckResultsV3?.author_session_id || formalApplyAttempt?.start?.session_id || null,
|
|
3041
|
+
};
|
|
3042
|
+
const q3Result = metricsV3.computeSpecQualityScore(latestCheckResultsV3, checkMeta);
|
|
3043
|
+
|
|
3044
|
+
const telemetryHealth = computeTelemetryHealthMetrics(measurementEvents);
|
|
3045
|
+
const pairedMeasurementAttempts = executionSummary.attempts.filter(attempt => attempt.end).length;
|
|
3046
|
+
const observedPairRate = executionSummary.attempts.length > 0
|
|
3047
|
+
? pairedMeasurementAttempts / executionSummary.attempts.length
|
|
3048
|
+
: null;
|
|
3049
|
+
const completedStages = MEASUREMENT_STAGES.filter(stage => (
|
|
3050
|
+
executionSummary.attempts.some(attempt => (
|
|
3051
|
+
attempt.command === stage
|
|
3052
|
+
&& attempt.end
|
|
3053
|
+
&& ['success', 'partial'].includes(attempt.result)
|
|
3054
|
+
))
|
|
3055
|
+
));
|
|
3056
|
+
const missingRequiredStages = MEASUREMENT_STAGES.filter(stage => !completedStages.includes(stage));
|
|
3057
|
+
const requiredStageCompleteness = {
|
|
3058
|
+
completed: completedStages.length,
|
|
3059
|
+
required: MEASUREMENT_STAGES.length,
|
|
3060
|
+
rate: completedStages.length / MEASUREMENT_STAGES.length,
|
|
3061
|
+
completed_stages: completedStages,
|
|
3062
|
+
missing: missingRequiredStages,
|
|
3063
|
+
};
|
|
3064
|
+
const pHTelemetryHealthScore = Number.isFinite(telemetryHealth.telemetry_health_score)
|
|
3065
|
+
? metricsV3.round(telemetryHealth.telemetry_health_score * 100, 2)
|
|
3066
|
+
: null;
|
|
3067
|
+
|
|
3068
|
+
const reworkSummary = compactReworkSummary(executionSummary.summary, reworkReasons);
|
|
3069
|
+
|
|
3070
|
+
const v3Context = buildV3ChangeContext(scopedEvents, executionSummary, {
|
|
3071
|
+
change_name: options.change || null,
|
|
3072
|
+
change_type: changeType,
|
|
3073
|
+
scenario_count: scenarioData.scenario_count,
|
|
3074
|
+
scenario_sources: scenarioData.files,
|
|
3075
|
+
check_results: latestCheckResultsV3,
|
|
3076
|
+
check_meta: checkMeta,
|
|
3077
|
+
q3: q3Result,
|
|
3078
|
+
total_stage_duration_ms: totalStageDuration,
|
|
3079
|
+
coding_duration_ms: codingDurationIncludingRework,
|
|
3080
|
+
spec_duration_ms: specDurationIncludingRework,
|
|
3081
|
+
rework_coding_duration_ms: 0,
|
|
3082
|
+
rework_spec_duration_ms: 0,
|
|
3083
|
+
p4_quality_gate_enforcement_rate: qualityGateRate,
|
|
3084
|
+
p_c_stage_closure_rate: observedPairRate,
|
|
3085
|
+
p_h_telemetry_health_score: pHTelemetryHealthScore,
|
|
3086
|
+
rework_summary: reworkSummary,
|
|
3087
|
+
q1_spec_conformance: conformanceMetrics.q1_spec_conformance_score,
|
|
3088
|
+
q1_source: 'conformance_review',
|
|
3089
|
+
q4_tdd_coverage: specTestCoverageMetrics.q4_spec_driven_test_coverage,
|
|
3090
|
+
q4_source: specTestCoverageMetrics.source,
|
|
3091
|
+
});
|
|
3092
|
+
const v3 = metricsV3.computeV3ChangeMetrics(v3Context);
|
|
3093
|
+
const e1AvailabilityReason = v3.efficiency.e1_availability_reason === 'no-scenarios' && scenarioData.availability_reason
|
|
3094
|
+
? scenarioData.availability_reason
|
|
3095
|
+
: v3.efficiency.e1_availability_reason;
|
|
3096
|
+
const completeMeasurementJourney = missingRequiredStages.length === 0;
|
|
3097
|
+
const incompleteJourneyReason = completeMeasurementJourney
|
|
3098
|
+
? null
|
|
3099
|
+
: `missing-required-stages:${missingRequiredStages.join(',')}`;
|
|
3100
|
+
const metricEvidence = {
|
|
3101
|
+
e1: {
|
|
3102
|
+
status: !v3.efficiency.e1_availability
|
|
3103
|
+
? 'missing'
|
|
3104
|
+
: (completeMeasurementJourney ? 'trusted' : 'provisional'),
|
|
3105
|
+
reason: e1AvailabilityReason || incompleteJourneyReason,
|
|
3106
|
+
source_event_count: measurementEvents.length,
|
|
3107
|
+
},
|
|
3108
|
+
q3: {
|
|
3109
|
+
status: v3.quality.q3_score_status === 'verified'
|
|
3110
|
+
&& v3.quality.q3_reviewer_independence === 'independent-review'
|
|
3111
|
+
? 'trusted'
|
|
3112
|
+
: (v3.quality.q3_availability ? 'provisional' : 'missing'),
|
|
3113
|
+
reason: v3.quality.q3_availability_reason,
|
|
3114
|
+
source_event_count: checkEvents.length,
|
|
3115
|
+
},
|
|
3116
|
+
p4: {
|
|
3117
|
+
status: !firstApply
|
|
3118
|
+
? 'missing'
|
|
3119
|
+
: (completeMeasurementJourney ? 'trusted' : 'provisional'),
|
|
3120
|
+
reason: firstApply ? incompleteJourneyReason : 'missing-apply-stage',
|
|
3121
|
+
source_event_count: executionSummary.attempts.filter(attempt => attempt.command === 'check').length,
|
|
3122
|
+
},
|
|
3123
|
+
p_r: {
|
|
3124
|
+
status: executionSummary.attempts.length === 0
|
|
3125
|
+
? 'missing'
|
|
3126
|
+
: (completeMeasurementJourney ? 'trusted' : 'provisional'),
|
|
3127
|
+
reason: executionSummary.attempts.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
|
|
3128
|
+
source_event_count: executionSummary.attempts.length,
|
|
3129
|
+
},
|
|
3130
|
+
p_h: {
|
|
3131
|
+
status: measurementEvents.length === 0
|
|
3132
|
+
? 'missing'
|
|
3133
|
+
: (completeMeasurementJourney ? 'trusted' : 'provisional'),
|
|
3134
|
+
reason: measurementEvents.length > 0 ? incompleteJourneyReason : 'missing-stage-events',
|
|
3135
|
+
source_event_count: measurementEvents.length,
|
|
3136
|
+
},
|
|
3137
|
+
};
|
|
3138
|
+
|
|
2363
3139
|
return {
|
|
3140
|
+
measurement_scope: {
|
|
3141
|
+
stages: MEASUREMENT_STAGES,
|
|
3142
|
+
excluded_auxiliary_stages: AUXILIARY_STAGES,
|
|
3143
|
+
source_event_count: measurementEvents.length,
|
|
3144
|
+
attribution: 'direct-or-explicit-link',
|
|
3145
|
+
},
|
|
3146
|
+
metric_evidence: metricEvidence,
|
|
2364
3147
|
efficiency: {
|
|
2365
3148
|
e1_lead_time_ms: leadTime,
|
|
2366
|
-
e2_coding_time_ratio: totalStageDuration > 0 ? roundMetric(
|
|
2367
|
-
e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(
|
|
3149
|
+
e2_coding_time_ratio: totalStageDuration > 0 ? roundMetric(codingDurationIncludingRework / totalStageDuration, 4) : null,
|
|
3150
|
+
e3_spec_time_ratio: totalStageDuration > 0 ? roundMetric(specDurationIncludingRework / totalStageDuration, 4) : null,
|
|
2368
3151
|
e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
|
|
2369
3152
|
effective_stage_duration_ms: executionSummary.summary.effective_stage_duration_ms,
|
|
2370
3153
|
idle_time_ms: leadTime != null && executionSummary.summary.effective_stage_duration_ms != null
|
|
@@ -2372,12 +3155,19 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2372
3155
|
: null,
|
|
2373
3156
|
rework_stage_duration_ms: executionSummary.summary.rework_stage_duration_ms,
|
|
2374
3157
|
total_stage_duration_ms: executionSummary.summary.total_stage_duration_ms,
|
|
3158
|
+
measurement_stage_duration_ms: totalStageDuration,
|
|
2375
3159
|
e2_coding_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
|
|
2376
3160
|
? roundMetric((codingDuration + reworkCodingDuration) / executionSummary.summary.total_stage_duration_ms)
|
|
2377
3161
|
: null,
|
|
2378
3162
|
e3_spec_time_ratio_including_rework: executionSummary.summary.total_stage_duration_ms > 0
|
|
2379
3163
|
? roundMetric((specDuration + reworkSpecDuration) / executionSummary.summary.total_stage_duration_ms)
|
|
2380
3164
|
: null,
|
|
3165
|
+
// V3 主指标:场景交付效率(ms/场景)
|
|
3166
|
+
e1_scenario_delivery_efficiency_ms: v3.efficiency.e1_scenario_delivery_efficiency_ms,
|
|
3167
|
+
scenario_count: v3.efficiency.scenario_count,
|
|
3168
|
+
scenario_sources: v3.efficiency.scenario_sources,
|
|
3169
|
+
e1_availability: v3.efficiency.e1_availability,
|
|
3170
|
+
e1_availability_reason: e1AvailabilityReason,
|
|
2381
3171
|
},
|
|
2382
3172
|
quality: {
|
|
2383
3173
|
q1_spec_conformance_score: conformanceMetrics.q1_spec_conformance_score,
|
|
@@ -2391,6 +3181,15 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2391
3181
|
conformance_reviewer: conformanceMetrics.reviewer,
|
|
2392
3182
|
conformance_reviewer_independence: conformanceMetrics.reviewer_independence,
|
|
2393
3183
|
spec_test_scenario_counts: specTestCoverageMetrics.scenario_counts,
|
|
3184
|
+
// V3 主指标:规约质量评分(0-100)
|
|
3185
|
+
q3_spec_quality_score: v3.quality.q3_spec_quality_score,
|
|
3186
|
+
q3_score_status: v3.quality.q3_score_status,
|
|
3187
|
+
q3_reviewer_independence: v3.quality.q3_reviewer_independence,
|
|
3188
|
+
q3_category_scores: v3.quality.q3_category_scores,
|
|
3189
|
+
q3_availability: v3.quality.q3_availability,
|
|
3190
|
+
// Q1/Q4 上游信号未就绪时用 pending-upstream 标注,不把 null 误转成 0
|
|
3191
|
+
q1_status: v3.quality.q1_spec_conformance.status,
|
|
3192
|
+
q4_status: v3.quality.q4_tdd_coverage.status,
|
|
2394
3193
|
},
|
|
2395
3194
|
process: {
|
|
2396
3195
|
p1_spec_iteration_count: specIterationCount,
|
|
@@ -2401,10 +3200,34 @@ function computeSinglePdfMvpMetrics(events, options = {}) {
|
|
|
2401
3200
|
p4_quality_gate_enforcement_rate: qualityGateRate,
|
|
2402
3201
|
p4b_check_fixed_rate: fixRate,
|
|
2403
3202
|
git_document_metrics: gitDocumentMetrics,
|
|
2404
|
-
rework_summary:
|
|
3203
|
+
rework_summary: reworkSummary,
|
|
3204
|
+
// V3: 阶段闭环率别名(来自 telemetry_health.matched_stage_rate)
|
|
3205
|
+
p_c_stage_closure_rate: observedPairRate,
|
|
3206
|
+
observed_stage_pairing: {
|
|
3207
|
+
paired: pairedMeasurementAttempts,
|
|
3208
|
+
starts: executionSummary.attempts.length,
|
|
3209
|
+
rate: observedPairRate,
|
|
3210
|
+
},
|
|
3211
|
+
required_stage_completeness: requiredStageCompleteness,
|
|
2405
3212
|
},
|
|
2406
3213
|
manual_insights: computeManualInsightMetrics(scopedEvents),
|
|
2407
|
-
telemetry_health:
|
|
3214
|
+
telemetry_health: {
|
|
3215
|
+
...telemetryHealth,
|
|
3216
|
+
p_h_telemetry_health_score: pHTelemetryHealthScore,
|
|
3217
|
+
},
|
|
3218
|
+
change_type: changeType,
|
|
3219
|
+
change_name: options.change || null,
|
|
3220
|
+
v3,
|
|
3221
|
+
legacy_metrics: {
|
|
3222
|
+
e1_lead_time_ms: leadTime,
|
|
3223
|
+
e4_ai_code_first_pass_rate: aiFirstPassMetrics.e4_ai_code_first_pass_rate,
|
|
3224
|
+
p1_spec_iteration_count: specIterationCount,
|
|
3225
|
+
p2_ai_code_adoption_rate: aiAdoptionMetrics.p2_ai_code_adoption_rate,
|
|
3226
|
+
q3_build_first_pass_rate: firstBuildSuccess == null ? null : (firstBuildSuccess ? 1 : 0),
|
|
3227
|
+
q5_cross_doc_consistency_score: consistencyScore,
|
|
3228
|
+
deprecated: true,
|
|
3229
|
+
notes: '旧版指标,保留用于兼容。新版主指标见 v3 字段(E1=场景交付效率 ms/场景,Q3=规约质量评分 0-100)。',
|
|
3230
|
+
},
|
|
2408
3231
|
};
|
|
2409
3232
|
}
|
|
2410
3233
|
|
|
@@ -2431,6 +3254,7 @@ function computeCapabilityMetrics(capabilityName, events, options = {}) {
|
|
|
2431
3254
|
function computeTelemetryHealthMetrics(events, options = {}) {
|
|
2432
3255
|
const report = computeDoctorReport(events, options);
|
|
2433
3256
|
return {
|
|
3257
|
+
...report,
|
|
2434
3258
|
telemetry_health_score: report.telemetry_health_score,
|
|
2435
3259
|
matched_stage_rate: report.matched_stage_rate,
|
|
2436
3260
|
open_stages: report.open_stages,
|
|
@@ -2448,20 +3272,52 @@ function computePdfMvpMetrics(events, options = {}) {
|
|
|
2448
3272
|
}
|
|
2449
3273
|
|
|
2450
3274
|
if (options.level === 'change' || options.change) {
|
|
3275
|
+
const gitCommitTimeline = resolveGitCommitTimeline(
|
|
3276
|
+
events,
|
|
3277
|
+
options.projectRoot,
|
|
3278
|
+
options.gitCommitTimeline,
|
|
3279
|
+
);
|
|
2451
3280
|
return computeSinglePdfMvpMetrics(events, {
|
|
2452
3281
|
change: options.change,
|
|
2453
3282
|
projectRoot: options.projectRoot,
|
|
3283
|
+
gitCommitTimeline,
|
|
2454
3284
|
});
|
|
2455
3285
|
}
|
|
2456
3286
|
|
|
3287
|
+
const gitCommitTimeline = resolveGitCommitTimeline(
|
|
3288
|
+
events,
|
|
3289
|
+
options.projectRoot,
|
|
3290
|
+
options.gitCommitTimeline,
|
|
3291
|
+
);
|
|
2457
3292
|
const changeNames = [...new Set(events.map(e => e.change).filter(Boolean))];
|
|
2458
3293
|
const changeMetrics = changeNames
|
|
2459
3294
|
.map(change => computeSinglePdfMvpMetrics(events, {
|
|
2460
3295
|
change,
|
|
2461
3296
|
projectRoot: options.projectRoot,
|
|
3297
|
+
gitCommitTimeline,
|
|
2462
3298
|
}))
|
|
2463
3299
|
.filter(Boolean);
|
|
2464
3300
|
|
|
3301
|
+
const byChange = changeMetrics.map(m => ({
|
|
3302
|
+
change_name: m.change_name,
|
|
3303
|
+
change_type: m.change_type,
|
|
3304
|
+
e1_scenario_delivery_efficiency_ms: m.efficiency.e1_scenario_delivery_efficiency_ms,
|
|
3305
|
+
q3_spec_quality_score: m.quality.q3_spec_quality_score,
|
|
3306
|
+
p4_quality_gate_enforcement_rate: m.process.p4_quality_gate_enforcement_rate,
|
|
3307
|
+
p_c_stage_closure_rate: m.process.p_c_stage_closure_rate,
|
|
3308
|
+
p_h_telemetry_health_score: m.telemetry_health.p_h_telemetry_health_score,
|
|
3309
|
+
rework_summary: m.process.rework_summary,
|
|
3310
|
+
}));
|
|
3311
|
+
const v3ChangeMetrics = changeMetrics.map(m => m.v3).filter(Boolean);
|
|
3312
|
+
const aggregated = metricsV3.aggregateV3Metrics(v3ChangeMetrics);
|
|
3313
|
+
const totalReworkAttempts = sumMetric(
|
|
3314
|
+
changeMetrics,
|
|
3315
|
+
m => m.process.rework_summary?.total_rework_attempts,
|
|
3316
|
+
);
|
|
3317
|
+
const avgReworkCount = changeMetrics.length > 0
|
|
3318
|
+
? metricsV3.round(totalReworkAttempts / changeMetrics.length)
|
|
3319
|
+
: null;
|
|
3320
|
+
|
|
2465
3321
|
return {
|
|
2466
3322
|
efficiency: {
|
|
2467
3323
|
e1_lead_time_ms: averageMetric(changeMetrics, m => m.efficiency.e1_lead_time_ms),
|
|
@@ -2469,20 +3325,42 @@ function computePdfMvpMetrics(events, options = {}) {
|
|
|
2469
3325
|
e3_spec_time_ratio: averageMetric(changeMetrics, m => m.efficiency.e3_spec_time_ratio),
|
|
2470
3326
|
e4_ai_code_first_pass_rate: averageMetric(changeMetrics, m => m.efficiency.e4_ai_code_first_pass_rate),
|
|
2471
3327
|
idle_time_ms: averageMetric(changeMetrics, m => m.efficiency.idle_time_ms),
|
|
3328
|
+
// V3 主指标:场景交付效率均值(跨变更)
|
|
3329
|
+
e1_scenario_delivery_efficiency_ms: averageMetric(changeMetrics, m => m.efficiency.e1_scenario_delivery_efficiency_ms),
|
|
2472
3330
|
},
|
|
2473
3331
|
quality: {
|
|
2474
3332
|
q1_spec_conformance_score: averageMetric(changeMetrics, m => m.quality.q1_spec_conformance_score),
|
|
2475
3333
|
q3_build_first_pass_rate: averageMetric(changeMetrics, m => m.quality.q3_build_first_pass_rate),
|
|
2476
3334
|
q4_spec_driven_test_coverage: averageMetric(changeMetrics, m => m.quality.q4_spec_driven_test_coverage),
|
|
2477
3335
|
q5_cross_doc_consistency_score: averageMetric(changeMetrics, m => m.quality.q5_cross_doc_consistency_score),
|
|
3336
|
+
// V3 主指标:规约质量评分均值(跨变更)
|
|
3337
|
+
q3_spec_quality_score: averageMetric(changeMetrics, m => m.quality.q3_spec_quality_score),
|
|
2478
3338
|
},
|
|
2479
3339
|
process: {
|
|
2480
3340
|
p1_spec_iteration_count: sumMetric(changeMetrics, m => m.process.p1_spec_iteration_count),
|
|
2481
3341
|
p2_ai_code_adoption_rate: averageMetric(changeMetrics, m => m.process.p2_ai_code_adoption_rate),
|
|
2482
3342
|
p4_quality_gate_enforcement_rate: averageMetric(changeMetrics, m => m.process.p4_quality_gate_enforcement_rate),
|
|
3343
|
+
p_c_stage_closure_rate: averageMetric(changeMetrics, m => m.process.p_c_stage_closure_rate),
|
|
3344
|
+
rework_summary: {
|
|
3345
|
+
total_rework_attempts: totalReworkAttempts,
|
|
3346
|
+
avg_rework_count: avgReworkCount,
|
|
3347
|
+
},
|
|
2483
3348
|
},
|
|
2484
3349
|
manual_insights: computeManualInsightMetrics(events.filter(e => !e.orphan)),
|
|
2485
|
-
telemetry_health:
|
|
3350
|
+
telemetry_health: (() => {
|
|
3351
|
+
const health = computeTelemetryHealthMetrics(events);
|
|
3352
|
+
return {
|
|
3353
|
+
...health,
|
|
3354
|
+
p_h_telemetry_health_score: Number.isFinite(health.telemetry_health_score)
|
|
3355
|
+
? metricsV3.round(health.telemetry_health_score * 100, 2)
|
|
3356
|
+
: null,
|
|
3357
|
+
};
|
|
3358
|
+
})(),
|
|
3359
|
+
by_change: byChange,
|
|
3360
|
+
by_change_type: aggregated.by_change_type,
|
|
3361
|
+
distribution: aggregated.distribution,
|
|
3362
|
+
unknown_change_type_count: aggregated.unknown_change_type_count,
|
|
3363
|
+
change_count: aggregated.change_count,
|
|
2486
3364
|
};
|
|
2487
3365
|
}
|
|
2488
3366
|
|
|
@@ -2573,24 +3451,146 @@ function formatReworkReasonDetails(reasons) {
|
|
|
2573
3451
|
return reasons.details.map(d => `- ${d.command} → ${REWORK_REASON_CN[d.reason] || d.reason}${d.detail ? ` [${String(d.detail).replace(/[\r\n\]]/g, ' ')}]` : ''}${d.has_uncommitted ? '(含未提交变更)' : ''}`);
|
|
2574
3452
|
}
|
|
2575
3453
|
|
|
3454
|
+
// V3: 最小指标集完整性 section
|
|
3455
|
+
function formatMinimumMetricSetMarkdown(minSet) {
|
|
3456
|
+
if (!minSet) return ['- 数据不可用'];
|
|
3457
|
+
const available = Array.isArray(minSet.available) ? minSet.available : [];
|
|
3458
|
+
const missing = Array.isArray(minSet.missing) ? minSet.missing : [];
|
|
3459
|
+
const minimumMetrics = Array.isArray(minSet.minimum_metrics) ? minSet.minimum_metrics : [];
|
|
3460
|
+
const labels = {
|
|
3461
|
+
e1: '每个场景平均用时(E1)',
|
|
3462
|
+
q3: '规约检查得分(Q3)',
|
|
3463
|
+
p4: '编码前已完成检查(P4)',
|
|
3464
|
+
p_r: '阶段重复次数(P-R)',
|
|
3465
|
+
p_h: '执行记录完整度(P-H)',
|
|
3466
|
+
};
|
|
3467
|
+
return [
|
|
3468
|
+
`- 覆盖率:${formatRatioToPercent(minSet.coverage_rate)}(${available.length}/${minimumMetrics.length})`,
|
|
3469
|
+
`- 已覆盖:${available.map(k => labels[k] || k).join(', ') || '无'}`,
|
|
3470
|
+
`- 缺失:${missing.map(k => labels[k] || k).join(', ') || '无'}`,
|
|
3471
|
+
];
|
|
3472
|
+
}
|
|
3473
|
+
|
|
3474
|
+
function formatAlertsMarkdown(alerts) {
|
|
3475
|
+
if (!Array.isArray(alerts) || alerts.length === 0) return ['- 无预警'];
|
|
3476
|
+
return alerts.map(a => `- [${(a.level || 'info').toUpperCase()}] ${a.metric || ''}:${a.reason || ''}`);
|
|
3477
|
+
}
|
|
3478
|
+
|
|
3479
|
+
function formatMaturityMarkdown(maturity) {
|
|
3480
|
+
if (!maturity) return ['- 数据不可用'];
|
|
3481
|
+
return [
|
|
3482
|
+
`- 当前成熟度等级:${maturity.current_level}`,
|
|
3483
|
+
`- 可达成熟度上限:${maturity.eligible_max_level}`,
|
|
3484
|
+
`- 未满足条件:${(maturity.unmet_criteria || []).join(', ') || '无'}`,
|
|
3485
|
+
];
|
|
3486
|
+
}
|
|
3487
|
+
|
|
3488
|
+
function reportStatusLabel(status) {
|
|
3489
|
+
return ({
|
|
3490
|
+
implemented: '已实现',
|
|
3491
|
+
provisional: '部分可用',
|
|
3492
|
+
'pending-upstream': '待补数据',
|
|
3493
|
+
'not-applicable': '本报告不适用',
|
|
3494
|
+
verified: '已验证',
|
|
3495
|
+
trusted: '可信',
|
|
3496
|
+
missing: '缺失',
|
|
3497
|
+
}[status] || status || '未知');
|
|
3498
|
+
}
|
|
3499
|
+
|
|
2576
3500
|
function renderExecutiveReportMarkdown(report) {
|
|
3501
|
+
if (report?.level === 'change' && report.change) {
|
|
3502
|
+
report = require('./reporting/change-report-model.cjs').buildChangeReportModel(report);
|
|
3503
|
+
}
|
|
2577
3504
|
const metrics = report.metrics;
|
|
2578
|
-
const health = report.doctor;
|
|
3505
|
+
const health = report.doctor || {};
|
|
2579
3506
|
const archive = report.archive_result || null;
|
|
2580
3507
|
const taskCompletion = archive?.task_completion || null;
|
|
3508
|
+
const v3 = metrics?.v3 || null;
|
|
3509
|
+
const eff = metrics?.efficiency || {};
|
|
3510
|
+
const qual = metrics?.quality || {};
|
|
3511
|
+
const proc = metrics?.process || {};
|
|
3512
|
+
const th = metrics?.telemetry_health || {};
|
|
3513
|
+
const e2IncludingRework = eff.e2_coding_time_ratio_including_rework ?? eff.e2_coding_time_ratio;
|
|
3514
|
+
const e3IncludingRework = eff.e3_spec_time_ratio_including_rework ?? eff.e3_spec_time_ratio;
|
|
3515
|
+
const q1Status = qual.q1_status || (qual.q1_spec_conformance_score != null ? 'available' : 'pending-upstream');
|
|
3516
|
+
const q4Status = qual.q4_status || (qual.q4_spec_driven_test_coverage != null ? 'available' : 'pending-upstream');
|
|
2581
3517
|
return [
|
|
2582
3518
|
`# SDD 效果度量报告${report.change ? ` - ${report.change}` : ''}`,
|
|
2583
3519
|
'',
|
|
2584
3520
|
`- 生成时间:${formatLocalTimestamp(report.generated_at)}`,
|
|
2585
3521
|
`- 项目路径:${report.project_root ? String(report.project_root).replace(/[\\/]+$/, '').split(/[\\/]/).pop() : 'null'}`,
|
|
2586
3522
|
`- 统计范围:${report.change ? `change/${report.change}` : 'project'}`,
|
|
3523
|
+
`- 报告 Schema:${report.schema_version || metricsV3.REPORT_SCHEMA_VERSION}(指标契约 ${report.metrics_contract || metricsV3.METRICS_CONTRACT_VERSION})`,
|
|
3524
|
+
...(report.window?.period ? [`- 统计周期:${report.window.period}(${report.window.date_from || ''} ~ ${report.window.date_to || ''})`] : []),
|
|
3525
|
+
'',
|
|
3526
|
+
'## 一句话结论',
|
|
3527
|
+
`> ${report.conclusion?.text || '当前证据不足,暂不能形成可信结论。'}`,
|
|
3528
|
+
'',
|
|
3529
|
+
'## 摘要',
|
|
3530
|
+
`- 任务完成:${report.conclusion?.task_statement || '尚无结构化主任务结论'}`,
|
|
3531
|
+
`- 证据可信度:${report.conclusion?.trust_statement || '尚无可信度结论'}`,
|
|
3532
|
+
`- 方案覆盖:${report.scheme_compliance?.counts?.implemented ?? 0}/${report.scheme_compliance?.items?.length ?? 0} 项已完整实现`,
|
|
3533
|
+
...(report.change_summary ? [
|
|
3534
|
+
`- 文档任务完成:${report.change_summary.tasks?.document?.completed ?? '暂无数据'}/${report.change_summary.tasks?.document?.total ?? '暂无数据'}`,
|
|
3535
|
+
`- 事件可追溯:${report.change_summary.tasks?.traceability?.verified ?? '暂无数据'}/${report.change_summary.tasks?.traceability?.total ?? '暂无数据'}`,
|
|
3536
|
+
`- 最终测试:${report.change_summary.tests ? `${report.change_summary.tests.passed}/${report.change_summary.tests.total}(${report.change_summary.tests.status})` : '暂无数据'}`,
|
|
3537
|
+
`- 问题与证据:${report.change_summary.evidence?.category_count ?? 0} 类问题,${report.change_summary.evidence?.occurrence_count ?? 0} 次规则命中`,
|
|
3538
|
+
] : []),
|
|
3539
|
+
'',
|
|
3540
|
+
'## 需要处理',
|
|
3541
|
+
...(report.evidence_alerts?.open?.length > 0
|
|
3542
|
+
? report.evidence_alerts.open.map(alert => `- [${alert.severity === 'error' ? '阻断' : '提醒'}] ${alert.warning}(${alert.subject || '本次变更'},出现 ${alert.occurrences} 次)`)
|
|
3543
|
+
: ['- 当前没有未解决的证据问题。']),
|
|
3544
|
+
...((report.metric_alerts || []).map(alert => `- [指标] ${alert.metric || ''}:${alert.reason || ''}`)),
|
|
3545
|
+
...((report.warning_dispositions || []).map(item => (
|
|
3546
|
+
`- [告警处置] ${item.warning}:${item.disposition}${item.reason ? `(${item.reason})` : ''}`
|
|
3547
|
+
))),
|
|
3548
|
+
'',
|
|
3549
|
+
'## 本次变更的关键指标',
|
|
3550
|
+
...formatMinimumMetricSetMarkdown(report.minimum_metric_set),
|
|
3551
|
+
`- 数值覆盖:${formatRatioToPercent(report.minimum_metric_set?.numeric_coverage_rate)}(${report.minimum_metric_set?.numeric_available ?? 0}/${report.minimum_metric_set?.numeric_total ?? 5})`,
|
|
3552
|
+
`- 可信覆盖:${formatRatioToPercent(report.minimum_metric_set?.trusted_coverage_rate)}(${report.minimum_metric_set?.trusted_available ?? 0}/${report.minimum_metric_set?.numeric_total ?? 5};临时 ${report.minimum_metric_set?.provisional ?? 0} 项)`,
|
|
3553
|
+
'',
|
|
3554
|
+
'## 核心指标',
|
|
3555
|
+
'## 效率 E1/E2/E3',
|
|
3556
|
+
`- 每个场景平均用时(E1):${eff.e1_scenario_delivery_efficiency_ms != null ? `${formatMsHuman(eff.e1_scenario_delivery_efficiency_ms)}/场景` : '暂无数据'}(场景数:${eff.scenario_count ?? 0}${eff.e1_availability === false ? `,原因:${eff.e1_availability_reason || 'unknown'}` : ''})`,
|
|
3557
|
+
`- 编码时间占比(E2,含流程修正):${formatRatioToPercent(e2IncludingRework)}(不含流程修正:${formatRatioToPercent(eff.e2_coding_time_ratio)})`,
|
|
3558
|
+
`- 写需求、方案和任务的时间占比(E3,含流程修正):${formatRatioToPercent(e3IncludingRework)}(不含流程修正:${formatRatioToPercent(eff.e3_spec_time_ratio)})`,
|
|
3559
|
+
'',
|
|
3560
|
+
'## 质量 Q1/Q3/Q4',
|
|
3561
|
+
`- Q1 规约符合度:${qual.q1_spec_conformance_score ?? 'null'}(状态:${q1Status})`,
|
|
3562
|
+
`- 规约检查得分(Q3):${qual.q3_spec_quality_score ?? '暂无数据'}${qual.q3_spec_quality_score != null ? '/100' : ''}(${qual.q3_score_status === 'verified' ? '已验证' : '临时'},评审独立性:${qual.q3_reviewer_independence || 'unknown'})`,
|
|
3563
|
+
`- Q4 规约驱动测试覆盖率:${formatRatioToPercent(qual.q4_spec_driven_test_coverage)}(状态:${q4Status})`,
|
|
3564
|
+
'',
|
|
3565
|
+
'## 过程 P4/P-R/P-C',
|
|
3566
|
+
`- 编码前是否先检查(P4):${formatRatioToPercent(proc.p4_quality_gate_enforcement_rate)}`,
|
|
3567
|
+
`- 阶段重复次数(P-R):${proc.rework_summary?.total_rework_attempts ?? '暂无数据'}`,
|
|
3568
|
+
`- 步骤是否有始有终(P-C):${formatRatioToPercent(proc.p_c_stage_closure_rate)}`,
|
|
3569
|
+
'',
|
|
3570
|
+
'## 遥测健康 P-H',
|
|
3571
|
+
`- 执行记录是否完整(P-H):${th.p_h_telemetry_health_score ?? '暂无数据'}${th.p_h_telemetry_health_score != null ? '/100' : ''}`,
|
|
3572
|
+
...(Array.isArray(metrics?.by_change_type) || metrics?.by_change_type
|
|
3573
|
+
? ['', '## 分层统计与趋势', `- 按变更类型分布:${metrics.by_change_type ? JSON.stringify(metrics.by_change_type) : 'null'}`, `- 未分类变更数:${metrics.unknown_change_type_count ?? 'null'}`]
|
|
3574
|
+
: []),
|
|
3575
|
+
'',
|
|
3576
|
+
'## 预警与成熟度',
|
|
3577
|
+
'### 预警',
|
|
3578
|
+
...formatAlertsMarkdown(report.alerts),
|
|
3579
|
+
'### 成熟度',
|
|
3580
|
+
...formatMaturityMarkdown(report.maturity),
|
|
3581
|
+
'',
|
|
3582
|
+
'## 3.3.4.2 方案对齐',
|
|
3583
|
+
...(report.scheme_compliance?.items || []).map(item => `- ${item.label}:${reportStatusLabel(item.status)}(${item.evidence})`),
|
|
2587
3584
|
'',
|
|
2588
3585
|
'## 执行摘要',
|
|
2589
3586
|
`- Telemetry 健康分:${formatRatioToPercent(health.telemetry_health_score)}(0-100%,反映遥测数据的完整性,扣分项包括未闭环阶段、孤儿事件等)`,
|
|
2590
3587
|
`- 阶段闭环率:${formatRatioToPercent(health.matched_stage_rate)}(已配对 start/end 的阶段占有效阶段总数的比例)`,
|
|
2591
3588
|
`- 严重问题数:${health.severe_issues?.length ?? '无数据'}(存在未闭环阶段、孤儿事件或未知命令等阻断级问题的数量)`,
|
|
2592
3589
|
'',
|
|
2593
|
-
'
|
|
3590
|
+
'<details>',
|
|
3591
|
+
'<summary>历史兼容数据(旧版指标,仅供已有消费者对照)</summary>',
|
|
3592
|
+
'',
|
|
3593
|
+
'## Legacy 效率指标(v2,兼容展示,非主指标)',
|
|
2594
3594
|
`- E1 需求到归档总时长:${formatMsToSeconds(metrics.efficiency?.e1_lead_time_ms)}`,
|
|
2595
3595
|
`- E2 编码时间占比:${formatRatioToPercent(metrics.efficiency?.e2_coding_time_ratio)}(仅 apply 阶段;test 阶段耗时另见有效阶段总耗时)`,
|
|
2596
3596
|
`- E3 规约时间占比:${formatRatioToPercent(metrics.efficiency?.e3_spec_time_ratio)}`,
|
|
@@ -2599,13 +3599,13 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
2599
3599
|
`- 阶段间等待:${formatMsToSeconds(metrics.efficiency?.idle_time_ms)}`,
|
|
2600
3600
|
`- 流程修正阶段总耗时:${formatMsToSeconds(metrics.efficiency?.rework_stage_duration_ms)}`,
|
|
2601
3601
|
'',
|
|
2602
|
-
'##
|
|
3602
|
+
'## Legacy 质量指标(v2,兼容展示,非主指标)',
|
|
2603
3603
|
`- Q1 规约符合度:${metrics.quality?.q1_spec_conformance_score ?? 'null'}${metrics.quality?.q1_human_status === 'unverified' ? '(自评,待人工确认)' : ''}`,
|
|
2604
3604
|
`- Q3 构建一次通过率:${formatRatioToPercent(metrics.quality?.q3_build_first_pass_rate)}`,
|
|
2605
3605
|
`- Q4 规约驱动测试覆盖率:${formatRatioToPercent(metrics.quality?.q4_spec_driven_test_coverage)}${metrics.quality?.q4_source === 'conformance-fallback' ? '(近似自 conformance_review)' : ''}`,
|
|
2606
3606
|
`- Q5 跨文档一致性得分:${metrics.quality?.q5_cross_doc_consistency_score ?? 'null'}(取最新 check 的 consistency_score)`,
|
|
2607
3607
|
'',
|
|
2608
|
-
'##
|
|
3608
|
+
'## Legacy 过程指标(v2,兼容展示,非主指标)',
|
|
2609
3609
|
`- P1 文档迭代次数:${metrics.process?.p1_spec_iteration_count ?? 'null'}${metrics.process?.p1_spec_iteration_source ? `(来源:${metrics.process.p1_spec_iteration_source})` : ''}`,
|
|
2610
3610
|
`- P2 AI 代码保留率:${formatRatioToPercent(metrics.process?.p2_ai_code_adoption_rate)}`,
|
|
2611
3611
|
`- P4 质量门前置率:${formatRatioToPercent(metrics.process?.p4_quality_gate_enforcement_rate)}`,
|
|
@@ -2614,6 +3614,8 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
2614
3614
|
`- 重复执行原因分布:${formatReworkReasons(metrics.process?.rework_summary?.reasons)}`,
|
|
2615
3615
|
...formatReworkReasonDetails(metrics.process?.rework_summary?.reasons),
|
|
2616
3616
|
'',
|
|
3617
|
+
'</details>',
|
|
3618
|
+
'',
|
|
2617
3619
|
'## 变更文件',
|
|
2618
3620
|
`- 数据可用:${report.changed_files?._available ? '是' : '否'}`,
|
|
2619
3621
|
`- 变更文件数:${report.changed_files?.files_changed ?? 'null'}${report.changed_files?.files_diff_note ? `(${report.changed_files.files_diff_note})` : ''}`,
|
|
@@ -2656,8 +3658,12 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
2656
3658
|
'',
|
|
2657
3659
|
'## 产物清单',
|
|
2658
3660
|
`- 数据可用:${report.artifacts?._available ? '是' : '否'}`,
|
|
2659
|
-
|
|
2660
|
-
|
|
3661
|
+
...((report.artifacts?.files && Object.keys(report.artifacts.files).length > 0)
|
|
3662
|
+
? Object.entries(report.artifacts.files).map(([name, item]) => `- ${name}:${item.path ?? 'null'}(${item.exists ? '存在' : '缺失'})`)
|
|
3663
|
+
: [
|
|
3664
|
+
`- 归档目录:${report.artifacts?.archive_path ?? 'null'}`,
|
|
3665
|
+
`- 归档清单:${report.artifacts?.manifest_path ?? 'null'}`,
|
|
3666
|
+
]),
|
|
2661
3667
|
`- 迁移文件数:${Array.isArray(report.artifacts?.copied_specs) ? report.artifacts.copied_specs.length : 'null'}`,
|
|
2662
3668
|
'',
|
|
2663
3669
|
'## 归档结果',
|
|
@@ -2683,15 +3689,35 @@ function renderExecutiveReportMarkdown(report) {
|
|
|
2683
3689
|
}
|
|
2684
3690
|
|
|
2685
3691
|
function renderExecutiveReportHtml(report) {
|
|
3692
|
+
if (report?.level === 'change' && report?.change) {
|
|
3693
|
+
return require('./reporting/change-report-renderer.cjs').renderChangeReportHtml(report);
|
|
3694
|
+
}
|
|
2686
3695
|
const metrics = report.metrics || {};
|
|
2687
3696
|
const eff = metrics.efficiency || {};
|
|
2688
3697
|
const qual = metrics.quality || {};
|
|
2689
3698
|
const proc = metrics.process || {};
|
|
3699
|
+
const th = metrics.telemetry_health || {};
|
|
2690
3700
|
const health = report.doctor || {};
|
|
2691
3701
|
const archive = report.archive_result || null;
|
|
2692
3702
|
const taskCompletion = archive?.task_completion || null;
|
|
2693
3703
|
const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
2694
3704
|
|
|
3705
|
+
// V3 主指标衍生值
|
|
3706
|
+
const v3Eff = metrics.v3?.efficiency || eff;
|
|
3707
|
+
const e2IncludingRework = eff.e2_coding_time_ratio_including_rework ?? eff.e2_coding_time_ratio;
|
|
3708
|
+
const e3IncludingRework = eff.e3_spec_time_ratio_including_rework ?? eff.e3_spec_time_ratio;
|
|
3709
|
+
const q1Status = qual.q1_status || (qual.q1_spec_conformance_score != null ? 'available' : 'pending-upstream');
|
|
3710
|
+
const q4Status = qual.q4_status || (qual.q4_spec_driven_test_coverage != null ? 'available' : 'pending-upstream');
|
|
3711
|
+
const minMetricSet = report.minimum_metric_set || null;
|
|
3712
|
+
const alertsList = Array.isArray(report.alerts) ? report.alerts : [];
|
|
3713
|
+
const alertsHtml = alertsList.length > 0
|
|
3714
|
+
? `<h3 style="margin:0 0 6px">预警</h3><div class="reasons">${alertsList.map(a => `<div class="reason-item"><span class="reason-tag ${a.level === 'red' ? 'unfinished' : 'rework'}">${esc((a.level || 'info').toUpperCase())}</span><span class="reason-stage">${esc(a.metric || '')}</span><span class="reason-note">${esc(a.reason || '')}</span></div>`).join('')}</div>`
|
|
3715
|
+
: `<h3 style="margin:0 0 6px">预警</h3><p class="muted">无预警</p>`;
|
|
3716
|
+
const maturityInfo = report.maturity || null;
|
|
3717
|
+
const maturityHtml = maturityInfo
|
|
3718
|
+
? `<h3 style="margin:14px 0 6px">成熟度</h3><p>当前等级 <strong>${esc(maturityInfo.current_level)}</strong>(可达上限 ${esc(maturityInfo.eligible_max_level)})</p>`
|
|
3719
|
+
: `<h3 style="margin:14px 0 6px">成熟度</h3><p class="muted">数据不可用</p>`;
|
|
3720
|
+
|
|
2695
3721
|
// T2.5: 分钟单位时长(<60 分无副,≥60 分加「≈X小时Y分钟」副)
|
|
2696
3722
|
const durHtml = (ms) => {
|
|
2697
3723
|
const d = formatDuration(ms);
|
|
@@ -2702,12 +3728,27 @@ function renderExecutiveReportHtml(report) {
|
|
|
2702
3728
|
if (ratio == null || !Number.isFinite(ratio)) return '<span class="metric-val muted-null">null</span>';
|
|
2703
3729
|
return `${(ratio * 100).toFixed(1)}<span class="unit">%</span>${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
|
|
2704
3730
|
};
|
|
3731
|
+
const pctHtmlFriendly = (ratio, sub) => (
|
|
3732
|
+
ratio == null || !Number.isFinite(ratio)
|
|
3733
|
+
? '<span class="metric-val muted-null">暂无数据</span>'
|
|
3734
|
+
: pctHtml(ratio, sub)
|
|
3735
|
+
);
|
|
2705
3736
|
const numHtml = (v, unit, sub) => {
|
|
2706
3737
|
if (v == null) return '<span class="metric-val muted-null">null</span>';
|
|
2707
3738
|
return `${esc(v)}${unit ? `<span class="unit">${esc(unit)}</span>` : ''}${sub ? `<span class="sub">${esc(sub)}</span>` : ''}`;
|
|
2708
3739
|
};
|
|
2709
|
-
//
|
|
2710
|
-
const metricCell = (name, valHtml, info) =>
|
|
3740
|
+
// 指标卡:真实 button 打开可访问对话框;打印/无 JS 时 title 仍保留关键解释。
|
|
3741
|
+
const metricCell = (name, valHtml, info) => {
|
|
3742
|
+
const explanation = info
|
|
3743
|
+
? `${info} 本次可信状态会根据数据来源、复核独立性和缺失项标记为“已验证 / 临时 / 缺失”。关注异常下降或长期缺失;“查看证据”可定位到对应阶段和事件。`
|
|
3744
|
+
: '';
|
|
3745
|
+
return `<div class="metric"><div class="metric-head"><span class="metric-name">${esc(name)}</span>${explanation ? `<button type="button" class="info-btn" aria-haspopup="dialog" data-title="${esc(name)}" data-info="${esc(explanation)}" title="${esc(explanation)}">?</button>` : ''}</div><div class="metric-val">${valHtml}</div></div>`;
|
|
3746
|
+
};
|
|
3747
|
+
const numHtmlFriendly = (v, unit, sub) => (
|
|
3748
|
+
v == null
|
|
3749
|
+
? '<span class="metric-val muted-null">暂无数据</span>'
|
|
3750
|
+
: numHtml(v, unit, sub)
|
|
3751
|
+
);
|
|
2711
3752
|
|
|
2712
3753
|
// 健康分环形仪表盘
|
|
2713
3754
|
const hs = health.telemetry_health_score;
|
|
@@ -2735,7 +3776,7 @@ function renderExecutiveReportHtml(report) {
|
|
|
2735
3776
|
const barCls = failed ? 'bad' : (short ? 'short' : 'ok');
|
|
2736
3777
|
const round = n.round || 1;
|
|
2737
3778
|
const nameSuffix = round > 1 ? '²' : '';
|
|
2738
|
-
const nameCls = failed ? ' style="color:#
|
|
3779
|
+
const nameCls = failed ? ' style="color:#a72c2c"' : '';
|
|
2739
3780
|
const durMin = dur > 0 ? Math.round(dur / 60000) : 0;
|
|
2740
3781
|
const durLabel = durMin > 0 ? `${durMin}分` : '<1分';
|
|
2741
3782
|
const taskLabel = n.task_count > 0 ? `·${n.task_count}任务` : '';
|
|
@@ -2760,11 +3801,11 @@ function renderExecutiveReportHtml(report) {
|
|
|
2760
3801
|
const resolved = w.resolved === true;
|
|
2761
3802
|
const statusText = resolved ? '已修复' : '待确认';
|
|
2762
3803
|
const statusStyle = resolved
|
|
2763
|
-
? 'background
|
|
2764
|
-
: 'background
|
|
3804
|
+
? 'background:#e9f7ee;color:#116b32'
|
|
3805
|
+
: 'background:#fff3db;color:#92530a';
|
|
2765
3806
|
return `<div class="warn-item"><div class="warn-icon">!</div><div class="warn-body"><div class="warn-title">${esc(w.command || 'check')} 阶段 ${count} 个待确认</div><div class="warn-detail">${esc(detail)}</div></div><span class="warn-status" style="${statusStyle}">${statusText}</span></div>`;
|
|
2766
3807
|
}).join('')
|
|
2767
|
-
: `<div class="warn-item"><div class="warn-icon" style="background
|
|
3808
|
+
: `<div class="warn-item"><div class="warn-icon" style="background:#edf4ff;color:#2459a8">i</div><div class="warn-body"><div class="warn-title">严重问题:${health.severe_issues?.length || 0} 个</div><div class="warn-detail">无未闭环阶段、无孤儿事件、无未知命令(阻断级问题)</div></div><span class="warn-status" style="background:#e9f7ee;color:#116b32">无需修复</span></div>`;
|
|
2768
3809
|
|
|
2769
3810
|
// 过程:重复执行原因
|
|
2770
3811
|
const rs = proc.rework_summary || {};
|
|
@@ -2811,35 +3852,53 @@ function renderExecutiveReportHtml(report) {
|
|
|
2811
3852
|
const cf = report.changed_files || {};
|
|
2812
3853
|
const arts = report.artifacts || {};
|
|
2813
3854
|
const movedCount = Array.isArray(arts.copied_specs) ? arts.copied_specs.length : null;
|
|
3855
|
+
const trustRate = minMetricSet?.trusted_coverage_rate ?? 0;
|
|
3856
|
+
const numericRate = minMetricSet?.numeric_coverage_rate ?? minMetricSet?.coverage_rate ?? 0;
|
|
3857
|
+
const evidenceOpen = report.evidence_alerts?.open || [];
|
|
3858
|
+
const evidenceLabel = (warning) => ({
|
|
3859
|
+
apply_test_missing: '缺少任务测试证据',
|
|
3860
|
+
tdd_red_missing: 'TDD 缺少有效 RED 证据',
|
|
3861
|
+
process_note_missing: '缺少过程记录',
|
|
3862
|
+
task_update_reuse: '测试结果被重复引用',
|
|
3863
|
+
TASK_UPDATE_MISSING: '已完成任务缺少状态事件',
|
|
3864
|
+
TASK_TEST_EVIDENCE_MISSING: '已完成任务缺少有效验证',
|
|
3865
|
+
TDD_RED_GREEN_INCOMPLETE: 'TDD 缺少 RED→GREEN 证据',
|
|
3866
|
+
LEGACY_TASK_PARSER: '任务文档仍使用旧格式',
|
|
3867
|
+
}[warning] || warning || '证据问题');
|
|
3868
|
+
const needAttentionHtml = evidenceOpen.length > 0
|
|
3869
|
+
? evidenceOpen.map(alert => `<li><span class="status-text ${alert.severity === 'error' ? 'bad' : 'warn'}">${alert.severity === 'error' ? '阻断' : '提醒'}</span><strong>${esc(evidenceLabel(alert.warning))}</strong><span>${esc(alert.message || alert.subject || '本次变更')}${alert.occurrences > 1 ? ` · 出现 ${esc(alert.occurrences)} 次` : ''}</span></li>`).join('')
|
|
3870
|
+
: '<li class="empty-state">当前没有未解决的证据问题。</li>';
|
|
3871
|
+
const complianceItems = report.scheme_compliance?.items || [];
|
|
3872
|
+
const complianceHtml = complianceItems.map(item => `<tr><th>${esc(item.label)}</th><td><span class="status-text ${item.status === 'implemented' ? 'ok' : (item.status === 'provisional' ? 'warn' : '')}">${esc(reportStatusLabel(item.status))}</span><div class="muted-2">${esc(item.evidence)}</div></td></tr>`).join('');
|
|
2814
3873
|
|
|
2815
3874
|
return `<!doctype html>
|
|
2816
|
-
<html lang="zh-CN">
|
|
3875
|
+
<html lang="zh-CN" data-theme="light">
|
|
2817
3876
|
<head>
|
|
2818
3877
|
<meta charset="utf-8" />
|
|
2819
3878
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2820
3879
|
<title>SDD 效果度量报告${report.change ? ` - ${esc(report.change)}` : ''}</title>
|
|
2821
3880
|
<style>
|
|
2822
3881
|
:root{
|
|
2823
|
-
--bg:#
|
|
2824
|
-
--border
|
|
2825
|
-
--text:#
|
|
2826
|
-
--ok:#
|
|
3882
|
+
--bg:#f3f6fb;--card-bg:#fff;--card-bg-2:#f8fafc;
|
|
3883
|
+
--border:#dbe3ef;--border-2:#e8edf4;
|
|
3884
|
+
--text:#172033;--muted:#5e6c82;--muted-2:#7b8798;
|
|
3885
|
+
--ok:#16803a;--warn:#b66a08;--bad:#c73737;--accent:#2768d7;
|
|
2827
3886
|
}
|
|
2828
3887
|
*{box-sizing:border-box}
|
|
2829
|
-
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.
|
|
2830
|
-
main{max-width:
|
|
2831
|
-
.card{background:var(--card-bg);border:1px solid var(--border);border-radius:
|
|
2832
|
-
h1{margin:0;font-size:
|
|
2833
|
-
h2{margin:0 0 14px;font-size:
|
|
2834
|
-
h2::before{content:"";width:
|
|
2835
|
-
h3{margin:0;font-size:12px;font-weight:
|
|
3888
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI','Microsoft YaHei','PingFang SC',sans-serif;margin:0;background:var(--bg);color:var(--text);line-height:1.6;font-size:14px}
|
|
3889
|
+
main{max-width:1180px;margin:0 auto;padding:28px 22px 48px}
|
|
3890
|
+
.card{background:var(--card-bg);border:1px solid var(--border);border-radius:16px;padding:20px 22px;margin:14px 0;position:relative;box-shadow:0 8px 28px rgba(41,58,86,.06)}
|
|
3891
|
+
h1{margin:0;font-size:26px;font-weight:720;letter-spacing:-.02em}
|
|
3892
|
+
h2{margin:0 0 14px;font-size:17px;font-weight:680;color:#25324a;display:flex;align-items:center;gap:8px}
|
|
3893
|
+
h2::before{content:"";width:4px;height:16px;background:var(--accent);border-radius:3px}
|
|
3894
|
+
h3{margin:0;font-size:12px;font-weight:600;color:var(--muted);letter-spacing:.03em}
|
|
2836
3895
|
.muted{color:var(--muted)}
|
|
2837
3896
|
.muted-2{color:var(--muted-2);font-size:11px}
|
|
2838
|
-
code{color:#
|
|
3897
|
+
code{color:#2459a8;background:#edf4ff;padding:1px 5px;border-radius:4px;font-size:12px;word-break:break-all}
|
|
2839
3898
|
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-weight:600;font-size:11px}
|
|
2840
|
-
.badge.ok{background
|
|
2841
|
-
.badge.warn{background
|
|
2842
|
-
.badge.bad{background
|
|
3899
|
+
.badge.ok{background:#e9f7ee;color:#116b32}
|
|
3900
|
+
.badge.warn{background:#fff3db;color:#92530a}
|
|
3901
|
+
.badge.bad{background:#fdecec;color:#a72c2c}
|
|
2843
3902
|
table{width:100%;border-collapse:collapse;font-size:13px}
|
|
2844
3903
|
th,td{border-bottom:1px solid var(--border-2);padding:7px 8px;text-align:left;vertical-align:top}
|
|
2845
3904
|
th{color:var(--muted);width:38%;font-weight:500}
|
|
@@ -2850,11 +3909,11 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2850
3909
|
.summary .cell{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:14px;display:flex;flex-direction:column;justify-content:center;align-items:center;text-align:center;min-height:110px}
|
|
2851
3910
|
.gauge{width:96px;height:96px;position:relative}
|
|
2852
3911
|
.gauge svg{transform:rotate(-90deg);width:100%;height:100%}
|
|
2853
|
-
.gauge-track{fill:none;stroke
|
|
3912
|
+
.gauge-track{fill:none;stroke:#e5eaf1;stroke-width:7}
|
|
2854
3913
|
.gauge-fill{fill:none;stroke-width:7;stroke-linecap:round}
|
|
2855
3914
|
.gauge-text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:24px;font-weight:700}
|
|
2856
3915
|
.big-num{font-size:30px;font-weight:700;line-height:1}
|
|
2857
|
-
.big-num.ok{color
|
|
3916
|
+
.big-num.ok{color:var(--ok)}
|
|
2858
3917
|
.cell-label{font-size:11px;color:var(--muted);margin-top:6px;text-transform:uppercase;letter-spacing:.04em}
|
|
2859
3918
|
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:10px}
|
|
2860
3919
|
.metric{background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;padding:12px 12px 10px;position:relative;min-height:84px;display:flex;flex-direction:column;justify-content:space-between}
|
|
@@ -2864,15 +3923,15 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2864
3923
|
.metric-val .unit{font-size:12px;font-weight:400;color:var(--muted);margin-left:2px}
|
|
2865
3924
|
.metric-val .sub{display:block;font-size:11px;font-weight:400;color:var(--muted-2);margin-top:2px}
|
|
2866
3925
|
.metric-val.muted-null{color:var(--muted-2);font-size:16px}
|
|
2867
|
-
.info-btn{cursor:pointer;color:var(--accent);font-size:12px;background:
|
|
2868
|
-
.info-btn:hover{background:rgba(
|
|
3926
|
+
.info-btn{cursor:pointer;color:var(--accent);font-size:12px;font-weight:700;background:#edf4ff;border:1px solid #cfe0fb;border-radius:50%;width:20px;height:20px;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;transition:background .15s,box-shadow .15s;line-height:1}
|
|
3927
|
+
.info-btn:hover,.info-btn:focus-visible{background:#dceafe;box-shadow:0 0 0 3px rgba(39,104,215,.16);outline:none}
|
|
2869
3928
|
.modal-mask{position:fixed;inset:0;background:rgba(0,0,0,.55);display:none;align-items:center;justify-content:center;z-index:100;padding:20px}
|
|
2870
3929
|
.modal-mask.show{display:flex}
|
|
2871
|
-
.modal{background:#
|
|
2872
|
-
.modal h4{margin:0 0 8px;font-size:
|
|
3930
|
+
.modal{background:#fff;border:1px solid var(--border);border-radius:16px;max-width:560px;width:100%;padding:22px 24px;box-shadow:0 24px 80px rgba(17,32,58,.22)}
|
|
3931
|
+
.modal h4{margin:0 0 8px;font-size:18px;color:#25324a}
|
|
2873
3932
|
.modal p{margin:0;font-size:13px;color:var(--text);line-height:1.6}
|
|
2874
3933
|
.modal .close{margin-top:14px;text-align:right}
|
|
2875
|
-
.modal .close button{background
|
|
3934
|
+
.modal .close button{background:#2768d7;color:#fff;border:none;border-radius:8px;padding:8px 18px;font-size:13px;cursor:pointer}
|
|
2876
3935
|
.timeline-h{display:flex;align-items:stretch;gap:0;overflow-x:auto;padding:4px 0 6px}
|
|
2877
3936
|
.tl-node{flex:0 0 auto;min-width:88px;position:relative;display:flex;flex-direction:column;align-items:center;padding:0 2px}
|
|
2878
3937
|
.tl-bar-wrap{width:100%;height:54px;display:flex;align-items:flex-end;justify-content:center}
|
|
@@ -2882,7 +3941,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2882
3941
|
.tl-bar.short{background:rgba(34,197,94,.35);min-height:6px}
|
|
2883
3942
|
.tl-dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:var(--ok);border:2px solid var(--bg)}
|
|
2884
3943
|
.tl-dot.bad{background:var(--bad)}
|
|
2885
|
-
.tl-name{font-size:11px;color:#
|
|
3944
|
+
.tl-name{font-size:11px;color:#34425a;margin-top:6px;font-weight:600;text-align:center;line-height:1.2}
|
|
2886
3945
|
.tl-dur{font-size:10px;color:var(--muted);margin-top:2px;text-align:center}
|
|
2887
3946
|
.tl-time{font-size:9px;color:var(--muted-2);margin-top:1px;text-align:center;white-space:nowrap;letter-spacing:-.02em}
|
|
2888
3947
|
.tl-repeat{position:absolute;top:-6px;right:50%;transform:translateX(20px);font-size:10px;background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;cursor:pointer;line-height:14px;min-width:14px;text-align:center}
|
|
@@ -2894,29 +3953,46 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2894
3953
|
.tl-legend .r-demo{background:var(--warn);color:#1a1f2e;border-radius:999px;padding:0 5px;font-weight:700;font-size:10px}
|
|
2895
3954
|
.reasons{display:flex;flex-direction:column;gap:6px;margin-top:10px}
|
|
2896
3955
|
.reason-item{display:flex;align-items:center;gap:8px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:8px;padding:8px 10px;font-size:12px}
|
|
2897
|
-
.reason-stage{font-weight:600;color:#
|
|
3956
|
+
.reason-stage{font-weight:600;color:#34425a;min-width:60px}
|
|
2898
3957
|
.reason-arrow{color:var(--muted-2)}
|
|
2899
3958
|
.reason-tag{padding:2px 8px;border-radius:999px;font-size:11px;font-weight:500}
|
|
2900
|
-
.reason-tag.unfinished{background
|
|
2901
|
-
.reason-tag.rework{background
|
|
3959
|
+
.reason-tag.unfinished{background:#fff3db;color:#92530a}
|
|
3960
|
+
.reason-tag.rework{background:#eaf2ff;color:#2459a8}
|
|
2902
3961
|
.reason-note{color:var(--muted);margin-left:auto;font-size:11px}
|
|
2903
3962
|
.warn-item{display:flex;align-items:flex-start;gap:10px;padding:8px 0;border-bottom:1px solid var(--border-2);font-size:12px}
|
|
2904
3963
|
.warn-item:last-child{border-bottom:none}
|
|
2905
|
-
.warn-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background
|
|
3964
|
+
.warn-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#fff3db;color:#92530a;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:700;margin-top:1px}
|
|
2906
3965
|
.warn-body{flex:1}
|
|
2907
|
-
.warn-title{color:#
|
|
3966
|
+
.warn-title{color:#25324a;font-weight:600}
|
|
2908
3967
|
.warn-detail{color:var(--muted);margin-top:2px}
|
|
2909
3968
|
.warn-status{font-size:11px;padding:2px 8px;border-radius:999px;flex-shrink:0}
|
|
2910
|
-
.warn-status.fixed{background
|
|
3969
|
+
.warn-status.fixed{background:#e9f7ee;color:#116b32}
|
|
2911
3970
|
.two-col{display:grid;grid-template-columns:1fr 1fr;gap:12px}
|
|
2912
|
-
@media(max-width:720px){.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:
|
|
3971
|
+
@media(max-width:720px){main{padding:14px}.summary{grid-template-columns:1fr}.two-col{grid-template-columns:1fr}.grid{grid-template-columns:1fr}.header-meta{text-align:left}.card{overflow:hidden}table{display:block;overflow-x:auto;white-space:normal}}
|
|
2913
3972
|
.quality-alert{display:flex;align-items:flex-start;gap:10px;margin-top:12px;padding:10px 12px;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.4);border-radius:8px;font-size:12px}
|
|
2914
|
-
.quality-alert .qa-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background
|
|
2915
|
-
.quality-alert .qa-title{font-weight:600;color:#
|
|
2916
|
-
.quality-alert .qa-items div{color:#
|
|
3973
|
+
.quality-alert .qa-icon{flex-shrink:0;width:18px;height:18px;border-radius:50%;background:#fff3db;color:#92530a;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:11px;margin-top:1px}
|
|
3974
|
+
.quality-alert .qa-title{font-weight:600;color:#8b520b;margin-bottom:2px}
|
|
3975
|
+
.quality-alert .qa-items div{color:#473719;line-height:1.6}
|
|
2917
3976
|
.footer-note{font-size:11px;color:var(--muted-2);margin-top:16px;padding:12px 18px;background:var(--card-bg-2);border:1px solid var(--border-2);border-radius:10px;line-height:1.7}
|
|
2918
3977
|
.footer-note strong{color:var(--muted)}
|
|
2919
3978
|
.section-tag{font-size:10px;color:var(--muted-2);font-weight:400;margin-left:6px}
|
|
3979
|
+
.conclusion{background:linear-gradient(135deg,#f7fbff,#eef5ff);border-color:#cfe0fb}
|
|
3980
|
+
.conclusion h2{margin-bottom:8px}.conclusion-text{font-size:18px;font-weight:650;color:#203b69;margin:0}
|
|
3981
|
+
.summary-cards{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}
|
|
3982
|
+
.summary-card{background:#fff;border:1px solid var(--border);border-radius:13px;padding:16px}
|
|
3983
|
+
.summary-card strong{display:block;font-size:20px;font-variant-numeric:tabular-nums;margin-top:5px}
|
|
3984
|
+
.status-text{display:inline-block;border-radius:999px;padding:2px 8px;margin-right:8px;font-size:11px;font-weight:700;background:#eef2f7;color:#516078}
|
|
3985
|
+
.status-text.ok{background:#e9f7ee;color:#116b32}.status-text.warn{background:#fff3db;color:#92530a}.status-text.bad{background:#fdecec;color:#a72c2c}
|
|
3986
|
+
.attention-list{list-style:none;padding:0;margin:0}.attention-list li{display:flex;align-items:center;gap:8px;padding:10px 0;border-bottom:1px solid var(--border-2)}.attention-list li:last-child{border-bottom:0}.attention-list .empty-state{color:var(--muted)}
|
|
3987
|
+
.legacy-appendix{margin:14px 0;border:1px solid var(--border);border-radius:16px;background:#fff}
|
|
3988
|
+
.legacy-appendix>summary{cursor:pointer;padding:16px 22px;font-weight:650;color:#42516a}.legacy-appendix[open]>summary{border-bottom:1px solid var(--border)}
|
|
3989
|
+
.legacy-appendix .card{border:0;border-radius:0;box-shadow:none;margin:0;border-bottom:1px solid var(--border-2)}
|
|
3990
|
+
@media(max-width:720px){.summary-cards{grid-template-columns:1fr}}
|
|
3991
|
+
@media print{
|
|
3992
|
+
body{background:#fff;color:#111;font-size:11pt}main{max-width:none;padding:0}.card,.legacy-appendix{box-shadow:none;break-inside:avoid;border-color:#bbb}
|
|
3993
|
+
.info-btn,.modal-mask{display:none!important}.legacy-appendix>summary{display:none}.legacy-appendix:not([open])>*{display:block}.legacy-appendix:not([open])>summary{display:none}
|
|
3994
|
+
.muted,.muted-2{color:#444}
|
|
3995
|
+
}
|
|
2920
3996
|
</style>
|
|
2921
3997
|
</head>
|
|
2922
3998
|
<body><main>
|
|
@@ -2931,6 +4007,78 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2931
4007
|
</div>
|
|
2932
4008
|
</div>
|
|
2933
4009
|
|
|
4010
|
+
<div class="card conclusion">
|
|
4011
|
+
<h2>一句话结论</h2>
|
|
4012
|
+
<p class="conclusion-text">${esc(report.conclusion?.text || '当前证据不足,暂不能形成可信结论。')}</p>
|
|
4013
|
+
</div>
|
|
4014
|
+
|
|
4015
|
+
<div class="summary-cards" aria-label="报告摘要">
|
|
4016
|
+
<div class="summary-card"><span class="muted">任务完成</span><strong>${esc(report.conclusion?.task_statement || '尚无结论')}</strong></div>
|
|
4017
|
+
<div class="summary-card"><span class="muted">证据可信度</span><strong>${(trustRate * 100).toFixed(0)}%</strong><span class="muted-2">数值覆盖 ${(numericRate * 100).toFixed(0)}%</span></div>
|
|
4018
|
+
<div class="summary-card"><span class="muted">方案覆盖</span><strong>${esc(report.scheme_compliance?.counts?.implemented ?? 0)}/${esc(complianceItems.length)}</strong><span class="muted-2">按 3.3.4.2 V3.0 逐项检查</span></div>
|
|
4019
|
+
</div>
|
|
4020
|
+
|
|
4021
|
+
<div class="card">
|
|
4022
|
+
<h2>需要处理</h2>
|
|
4023
|
+
<ul class="attention-list">${needAttentionHtml}</ul>
|
|
4024
|
+
</div>
|
|
4025
|
+
|
|
4026
|
+
<div class="card">
|
|
4027
|
+
<h2>最小指标集完整性 <span class="section-tag">E1 · Q3 · P4 · P-R · P-H</span></h2>
|
|
4028
|
+
<div class="grid">
|
|
4029
|
+
${metricCell('数值覆盖', pctHtml(numericRate, `${minMetricSet?.numeric_available ?? 0}/${minMetricSet?.numeric_total ?? 5}`), '五个最小指标中,已经能计算出数字的比例;有数字不等于已经独立验证。')}
|
|
4030
|
+
${metricCell('可信覆盖', pctHtml(trustRate, `${minMetricSet?.trusted_available ?? 0}/${minMetricSet?.numeric_total ?? 5}`), '五个最小指标中,证据来源和复核条件均满足要求的比例。')}
|
|
4031
|
+
${metricCell('临时数据', numHtml(minMetricSet?.provisional ?? 0, '项'), '已有数值但仍需独立复核或补充证据的指标数量。')}
|
|
4032
|
+
${metricCell('缺失数据', numHtml((minMetricSet?.missing || []).length, '项'), (minMetricSet?.missing || []).join(', ') || '无')}
|
|
4033
|
+
</div>
|
|
4034
|
+
<noscript><div class="footer-note"><strong>指标怎么读:</strong>“数值覆盖”只表示已经算出数字;“可信覆盖”还要求证据来源和独立复核都满足要求;“临时”表示可参考但仍需补证据,“缺失”表示当前不能下结论。</div></noscript>
|
|
4035
|
+
</div>
|
|
4036
|
+
|
|
4037
|
+
<div class="card">
|
|
4038
|
+
<h2>效率 E1/E2/E3 <span class="section-tag">V3 主指标</span></h2>
|
|
4039
|
+
<div class="grid">
|
|
4040
|
+
${metricCell('每个场景平均用时(E1)', v3Eff.e1_scenario_delivery_efficiency_ms != null ? `${esc(formatMsHuman(v3Eff.e1_scenario_delivery_efficiency_ms))}<span class="unit">/场景</span>` : '<span class="metric-val muted-null">暂无数据</span>', `有效阶段耗时除以场景数。场景数:${v3Eff.scenario_count ?? 0}。没有历史基线时只显示当前值,不推断提效。`)}
|
|
4041
|
+
${metricCell('编码时间占比(E2)', pctHtmlFriendly(e2IncludingRework, '含流程修正'), '实际编码阶段(含流程修正)占总耗时的比例。')}
|
|
4042
|
+
${metricCell('写需求、方案和任务的时间占比(E3)', pctHtmlFriendly(e3IncludingRework, '含流程修正'), '需求规划到任务拆解阶段(含流程修正)占总耗时的比例。')}
|
|
4043
|
+
</div>
|
|
4044
|
+
</div>
|
|
4045
|
+
|
|
4046
|
+
<div class="card">
|
|
4047
|
+
<h2>质量 Q1/Q3/Q4 <span class="section-tag">V3 主指标</span></h2>
|
|
4048
|
+
<div class="grid">
|
|
4049
|
+
${metricCell('Q1 规约符合度', numHtmlFriendly(qual.q1_spec_conformance_score, '/1', q1Status === 'pending-upstream' ? '待补数据' : null), '实现与规格的符合程度。')}
|
|
4050
|
+
${metricCell('规约检查得分(Q3)', qual.q3_spec_quality_score != null ? numHtml(qual.q3_spec_quality_score, '/100', `${qual.q3_score_status === 'verified' ? '已验证' : '临时'}·${qual.q3_reviewer_independence || 'unknown'}`) : '<span class="metric-val muted-null">暂无数据</span>', '基于最新一次检查结果计算;只有独立 reviewer 且复核会话与执行会话不同,才计为已验证。')}
|
|
4051
|
+
${metricCell('Q4 测试覆盖率', pctHtmlFriendly(qual.q4_spec_driven_test_coverage, q4Status === 'pending-upstream' ? '待补数据' : null), '规格中的每条断言被测试用例覆盖的比例。')}
|
|
4052
|
+
</div>
|
|
4053
|
+
</div>
|
|
4054
|
+
|
|
4055
|
+
<div class="card">
|
|
4056
|
+
<h2>过程 P4/P-R/P-C <span class="section-tag">V3 主指标</span></h2>
|
|
4057
|
+
<div class="grid">
|
|
4058
|
+
${metricCell('编码前是否先检查(P4)', pctHtmlFriendly(proc.p4_quality_gate_enforcement_rate), '进入编码前是否已经完成质量检查。')}
|
|
4059
|
+
${metricCell('阶段重复次数(P-R)', numHtml(proc.rework_summary?.total_rework_attempts ?? null, '次'), '同一阶段被重复执行的次数。')}
|
|
4060
|
+
${metricCell('步骤是否有始有终(P-C)', pctHtmlFriendly(proc.p_c_stage_closure_rate), '有开始且有结束记录的步骤,占所有有效步骤的比例。')}
|
|
4061
|
+
</div>
|
|
4062
|
+
</div>
|
|
4063
|
+
|
|
4064
|
+
<div class="card">
|
|
4065
|
+
<h2>遥测健康 P-H <span class="section-tag">V3 主指标</span></h2>
|
|
4066
|
+
<div class="grid">
|
|
4067
|
+
${metricCell('执行记录是否完整(P-H)', th.p_h_telemetry_health_score != null ? numHtml(th.p_h_telemetry_health_score, '/100') : '<span class="metric-val muted-null">暂无数据</span>', '检查阶段是否闭环、事件是否能配对、命令是否可识别的综合完整性评分。')}
|
|
4068
|
+
</div>
|
|
4069
|
+
</div>
|
|
4070
|
+
|
|
4071
|
+
<div class="card">
|
|
4072
|
+
<h2>预警与成熟度</h2>
|
|
4073
|
+
${alertsHtml}
|
|
4074
|
+
${maturityHtml}
|
|
4075
|
+
</div>
|
|
4076
|
+
|
|
4077
|
+
<div class="card">
|
|
4078
|
+
<h2>3.3.4.2 方案对齐</h2>
|
|
4079
|
+
<table>${complianceHtml || '<tr><td class="muted">暂无合规检查结果</td></tr>'}</table>
|
|
4080
|
+
</div>
|
|
4081
|
+
|
|
2934
4082
|
<div class="card">
|
|
2935
4083
|
<h2>执行摘要</h2>
|
|
2936
4084
|
<div class="summary">
|
|
@@ -2939,19 +4087,21 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2939
4087
|
<div class="cell-label">健康分</div>
|
|
2940
4088
|
</div>
|
|
2941
4089
|
<div class="cell">
|
|
2942
|
-
<div class="big-num ok">${matchedRate == null ? '
|
|
4090
|
+
<div class="big-num ok">${matchedRate == null ? '暂无数据' : `${(matchedRate * 100).toFixed(0)}<span style="font-size:18px">%</span>`}</div>
|
|
2943
4091
|
<div class="cell-label">阶段闭环率</div>
|
|
2944
4092
|
</div>
|
|
2945
4093
|
<div class="cell">
|
|
2946
|
-
<div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#
|
|
4094
|
+
<div class="big-num ${severeCount > 0 ? 'bad' : 'ok'}" style="${severeCount > 0 ? 'color:#a72c2c' : ''}">${severeCount}</div>
|
|
2947
4095
|
<div class="cell-label">严重问题</div>
|
|
2948
4096
|
</div>
|
|
2949
4097
|
</div>
|
|
2950
4098
|
${qualityAlertHtml}
|
|
2951
4099
|
</div>
|
|
2952
4100
|
|
|
4101
|
+
<details class="legacy-appendix">
|
|
4102
|
+
<summary>历史兼容数据(旧版指标,仅供已有消费者对照)</summary>
|
|
2953
4103
|
<div class="card">
|
|
2954
|
-
<h2
|
|
4104
|
+
<h2>Legacy 效率指标 <span class="section-tag">v2,兼容展示</span></h2>
|
|
2955
4105
|
<div class="grid">
|
|
2956
4106
|
${metricCell('E1 总时长', durHtml(eff.e1_lead_time_ms), '从需求规划到归档完成的总耗时,包含阶段间等待和你确认的时间。')}
|
|
2957
4107
|
${metricCell('E2 编码时间占比', pctHtml(eff.e2_coding_time_ratio), '实际编码阶段占总有效耗时的比例。')}
|
|
@@ -2964,7 +4114,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2964
4114
|
</div>
|
|
2965
4115
|
|
|
2966
4116
|
<div class="card">
|
|
2967
|
-
<h2
|
|
4117
|
+
<h2>Legacy 质量指标 <span class="section-tag">v2,兼容展示</span></h2>
|
|
2968
4118
|
<div class="grid">
|
|
2969
4119
|
${metricCell('Q1 规约符合度', numHtml(qual.q1_spec_conformance_score, '/1', qual.q1_human_status === 'unverified' ? '自评·待人工确认' : null), '实现与规格的符合程度,由 AI 评审断言判定。仅供参考,不作强制门禁。')}
|
|
2970
4120
|
${metricCell('Q3 构建一次通过率', pctHtml(qual.q3_build_first_pass_rate), '编码阶段第一次构建就成功的比例。')}
|
|
@@ -2974,7 +4124,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2974
4124
|
</div>
|
|
2975
4125
|
|
|
2976
4126
|
<div class="card">
|
|
2977
|
-
<h2
|
|
4127
|
+
<h2>Legacy 过程指标 <span class="section-tag">v2,兼容展示 · 含过程记录状态</span></h2>
|
|
2978
4128
|
<div class="grid">
|
|
2979
4129
|
${metricCell('P1 文档迭代次数', proc.p1_spec_iteration_count == null ? '<span class="metric-val muted-null">null<span class="unit">·无git</span></span>' : numHtml(proc.p1_spec_iteration_count, '次'), '文档被修改的次数。需要 Git 提交记录来统计,本次没有 Git 提交,所以缺失。')}
|
|
2980
4130
|
${metricCell('P2 AI 代码保留率', pctHtml(proc.p2_ai_code_adoption_rate), 'AI 生成的代码最终保留了多少比例。需要 Git 对比来计算,本次没有 Git 提交,所以缺失。')}
|
|
@@ -2985,6 +4135,7 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
2985
4135
|
</div>
|
|
2986
4136
|
${reasonHtml ? `<h3 style="margin:14px 0 4px">重复执行原因分布</h3>${reasonHtml}<div class="muted-2" style="margin-top:8px">其余归类:${esc(catLine)}</div>` : ''}
|
|
2987
4137
|
</div>
|
|
4138
|
+
</details>
|
|
2988
4139
|
|
|
2989
4140
|
<div class="card">
|
|
2990
4141
|
<h2>时间线 <span class="section-tag">横向 · 含起止时间${tlNodes.length > 0 ? ` · ${tlNodes.length} 个阶段执行` : ''}</span></h2>
|
|
@@ -3005,7 +4156,9 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
3005
4156
|
<tr><th>归档原因</th><td>${esc(archive?.reason || 'null')}</td></tr>
|
|
3006
4157
|
<tr><th>归档方式</th><td>${esc(archive?.method || 'null')}</td></tr>
|
|
3007
4158
|
<tr><th>归档目录</th><td><code>${esc(archive?.archive_path || arts.archive_path || 'null')}</code></td></tr>
|
|
3008
|
-
<tr><th
|
|
4159
|
+
<tr><th>最终报告 Markdown</th><td><code>${esc(archive?.report_path || 'null')}</code></td></tr>
|
|
4160
|
+
<tr><th>最终报告 HTML</th><td><code>${esc(archive?.report_html_path || 'null')}</code></td></tr>
|
|
4161
|
+
<tr><th>最终报告 JSON</th><td><code>${esc(archive?.report_json_path || 'null')}</code></td></tr>
|
|
3009
4162
|
</table>
|
|
3010
4163
|
</div>
|
|
3011
4164
|
</div>
|
|
@@ -3022,9 +4175,10 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
3022
4175
|
</main>
|
|
3023
4176
|
|
|
3024
4177
|
<div class="modal-mask" id="modalMask">
|
|
3025
|
-
<div class="modal">
|
|
4178
|
+
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle" aria-describedby="modalBody" tabindex="-1">
|
|
3026
4179
|
<h4 id="modalTitle">说明</h4>
|
|
3027
4180
|
<p id="modalBody"></p>
|
|
4181
|
+
<p class="muted-2" style="margin-top:12px">查看证据:请在“时间线”“需要处理”及 JSON 报告的事件引用中定位对应阶段。</p>
|
|
3028
4182
|
<div class="close"><button id="modalClose">关闭</button></div>
|
|
3029
4183
|
</div>
|
|
3030
4184
|
</div>
|
|
@@ -3034,15 +4188,32 @@ tr:last-child td,tr:last-child th{border-bottom:none}
|
|
|
3034
4188
|
var mask=document.getElementById('modalMask');
|
|
3035
4189
|
var titleEl=document.getElementById('modalTitle');
|
|
3036
4190
|
var bodyEl=document.getElementById('modalBody');
|
|
3037
|
-
|
|
3038
|
-
|
|
4191
|
+
var modal=mask.querySelector('.modal');
|
|
4192
|
+
var lastFocus=null;
|
|
4193
|
+
function open(title,body){lastFocus=document.activeElement;titleEl.textContent=title||'说明';bodyEl.textContent=body||'';mask.classList.add('show');modal.focus();}
|
|
4194
|
+
function close(){if(!mask.classList.contains('show'))return;mask.classList.remove('show');if(lastFocus&&lastFocus.focus)lastFocus.focus();}
|
|
3039
4195
|
document.addEventListener('click',function(e){
|
|
3040
4196
|
var btn=e.target.closest('.info-btn,.tl-repeat');
|
|
3041
4197
|
if(btn){e.stopPropagation();open(btn.dataset.title,btn.dataset.info);return;}
|
|
3042
4198
|
if(!e.target.closest('.modal')) close();
|
|
3043
4199
|
});
|
|
3044
4200
|
document.getElementById('modalClose').addEventListener('click',close);
|
|
3045
|
-
document.addEventListener('keydown',function(e){
|
|
4201
|
+
document.addEventListener('keydown',function(e){
|
|
4202
|
+
if(e.key==='Escape'){close();return;}
|
|
4203
|
+
if(e.key==='Tab'&&mask.classList.contains('show')){
|
|
4204
|
+
var focusable=modal.querySelectorAll('button,[href],input,select,textarea,[tabindex]:not([tabindex="-1"])');
|
|
4205
|
+
if(!focusable.length){e.preventDefault();modal.focus();return;}
|
|
4206
|
+
var first=focusable[0],last=focusable[focusable.length-1];
|
|
4207
|
+
if(e.shiftKey&&document.activeElement===first){e.preventDefault();last.focus();}
|
|
4208
|
+
else if(!e.shiftKey&&document.activeElement===last){e.preventDefault();first.focus();}
|
|
4209
|
+
}
|
|
4210
|
+
});
|
|
4211
|
+
var printOpened=[];
|
|
4212
|
+
window.addEventListener('beforeprint',function(){
|
|
4213
|
+
printOpened=[];
|
|
4214
|
+
document.querySelectorAll('details').forEach(function(item){if(!item.open){printOpened.push(item);item.open=true;}});
|
|
4215
|
+
});
|
|
4216
|
+
window.addEventListener('afterprint',function(){printOpened.forEach(function(item){item.open=false;});printOpened=[];});
|
|
3046
4217
|
})();
|
|
3047
4218
|
</script>
|
|
3048
4219
|
</body></html>`;
|
|
@@ -3054,53 +4225,6 @@ function extractChangedFiles(events, projectRoot) {
|
|
|
3054
4225
|
const latest = latestByTimestamp(adoptionEvents);
|
|
3055
4226
|
const review = latest ? getAiAdoptionReview(latest) : null;
|
|
3056
4227
|
const aiDiff = review?.ai_diff || null;
|
|
3057
|
-
const hasFiles = Array.isArray(aiDiff?.files) && aiDiff.files.length > 0;
|
|
3058
|
-
// P0-4: ai_diff 无效时,从 assertions[].files 聚合兜底(补救存量 ai_adoption_review 无 ai_diff 的事件)
|
|
3059
|
-
if (!aiDiff || (aiDiff.files_changed == null && aiDiff.added_lines == null && !hasFiles)) {
|
|
3060
|
-
if (latest?.details) {
|
|
3061
|
-
const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
|
|
3062
|
-
const rawAssertions = rawReview.assertions;
|
|
3063
|
-
if (Array.isArray(rawAssertions)) {
|
|
3064
|
-
const filesFromAssertions = new Set();
|
|
3065
|
-
for (const a of rawAssertions) {
|
|
3066
|
-
if (Array.isArray(a.files)) {
|
|
3067
|
-
for (const f of a.files) {
|
|
3068
|
-
if (typeof f === 'string' && f.trim()) filesFromAssertions.add(f.trim());
|
|
3069
|
-
}
|
|
3070
|
-
}
|
|
3071
|
-
}
|
|
3072
|
-
if (filesFromAssertions.size > 0) {
|
|
3073
|
-
return { _available: true, files_changed: filesFromAssertions.size, added_lines: null, files: [...filesFromAssertions] };
|
|
3074
|
-
}
|
|
3075
|
-
}
|
|
3076
|
-
}
|
|
3077
|
-
return { _available: false, files_changed: null, added_lines: null, files: null };
|
|
3078
|
-
}
|
|
3079
|
-
let addedLines = Number.isFinite(aiDiff.added_lines) ? aiDiff.added_lines : null;
|
|
3080
|
-
const files = hasFiles ? aiDiff.files.slice() : null;
|
|
3081
|
-
// T3.3: ai_git_sha=null(无 git)时,用 fs 读 files 行数作 added_lines fallback(DC-104 无 git/路径缺失不崩溃)
|
|
3082
|
-
if (addedLines == null && review && review.ai_git_sha == null && hasFiles && projectRoot) {
|
|
3083
|
-
let sum = 0;
|
|
3084
|
-
let counted = 0;
|
|
3085
|
-
const projectRootResolved = path.resolve(projectRoot);
|
|
3086
|
-
for (const rel of aiDiff.files) {
|
|
3087
|
-
try {
|
|
3088
|
-
// Y1+Y6 修复:校验路径不穿越 projectRoot + 用 split 替代 match 减少正则数组开销
|
|
3089
|
-
const abs = path.isAbsolute(rel) ? path.resolve(rel) : path.resolve(projectRoot, rel);
|
|
3090
|
-
if (!abs.startsWith(projectRootResolved + path.sep) && abs !== projectRootResolved) continue;
|
|
3091
|
-
const content = fs.readFileSync(abs, 'utf8');
|
|
3092
|
-
const newlines = content.split('\n').length - 1;
|
|
3093
|
-
sum += newlines + (content.length > 0 && !content.endsWith('\n') ? 1 : 0);
|
|
3094
|
-
counted += 1;
|
|
3095
|
-
} catch {
|
|
3096
|
-
// 文件不存在/不可读,跳过
|
|
3097
|
-
}
|
|
3098
|
-
}
|
|
3099
|
-
if (counted > 0) addedLines = sum;
|
|
3100
|
-
}
|
|
3101
|
-
// A6(v3): task_update 汇总文件数优先于 ai_diff 自报值(ai_diff 是 agent 一次性快照,
|
|
3102
|
-
// task_update 累加去重更准);两者不一致时 ai_diff 作辅助并标注差异。
|
|
3103
|
-
// 兼容 task_update.details.files / details.test_results.files / details.files_changed(数组形态)
|
|
3104
4228
|
const taskUpdateFiles = new Set();
|
|
3105
4229
|
for (const e of events) {
|
|
3106
4230
|
if (e?.type !== 'task_update') continue;
|
|
@@ -3113,27 +4237,81 @@ function extractChangedFiles(events, projectRoot) {
|
|
|
3113
4237
|
}
|
|
3114
4238
|
}
|
|
3115
4239
|
}
|
|
4240
|
+
const finalOutputFiles = new Set(
|
|
4241
|
+
Array.isArray(aiDiff?.files)
|
|
4242
|
+
? aiDiff.files.filter(file => typeof file === 'string' && file.trim()).map(file => file.trim())
|
|
4243
|
+
: [],
|
|
4244
|
+
);
|
|
4245
|
+
if (finalOutputFiles.size === 0 && latest?.details) {
|
|
4246
|
+
const rawReview = latest.details.ai_adoption || latest.details.ai_adoption_review || latest.details.ai_code_adoption || {};
|
|
4247
|
+
for (const assertion of rawReview.assertions || []) {
|
|
4248
|
+
for (const file of assertion.files || []) {
|
|
4249
|
+
if (typeof file === 'string' && file.trim()) finalOutputFiles.add(file.trim());
|
|
4250
|
+
}
|
|
4251
|
+
}
|
|
4252
|
+
}
|
|
4253
|
+
|
|
3116
4254
|
const taskUpdateFileCount = taskUpdateFiles.size;
|
|
3117
|
-
const
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
4255
|
+
const finalOutputFileCount = Number.isFinite(aiDiff?.files_changed)
|
|
4256
|
+
? aiDiff.files_changed
|
|
4257
|
+
: (finalOutputFiles.size > 0 ? finalOutputFiles.size : null);
|
|
4258
|
+
const snapshotKind = aiDiff?.snapshot_kind || null;
|
|
4259
|
+
const finalOutputStatus = snapshotKind === 'final' ? 'final' : (finalOutputFileCount == null ? 'unavailable' : 'provisional');
|
|
4260
|
+
const addedLines = Number.isFinite(aiDiff?.added_lines)
|
|
4261
|
+
? aiDiff.added_lines
|
|
4262
|
+
: (Number.isFinite(review?.final_diff?.added_lines) ? review.final_diff.added_lines : null);
|
|
4263
|
+
const deletedLines = Number.isFinite(aiDiff?.deleted_lines)
|
|
4264
|
+
? aiDiff.deleted_lines
|
|
4265
|
+
: (Number.isFinite(review?.final_diff?.deleted_lines) ? review.final_diff.deleted_lines : null);
|
|
4266
|
+
let lineCountEstimate = null;
|
|
4267
|
+
if (addedLines == null && review?.ai_git_sha == null && finalOutputFiles.size > 0 && projectRoot) {
|
|
4268
|
+
let lines = 0;
|
|
4269
|
+
let countedFiles = 0;
|
|
4270
|
+
const root = path.resolve(projectRoot);
|
|
4271
|
+
for (const relativePath of finalOutputFiles) {
|
|
4272
|
+
try {
|
|
4273
|
+
const absolutePath = path.isAbsolute(relativePath)
|
|
4274
|
+
? path.resolve(relativePath)
|
|
4275
|
+
: path.resolve(root, relativePath);
|
|
4276
|
+
if (absolutePath !== root && !absolutePath.startsWith(`${root}${path.sep}`)) continue;
|
|
4277
|
+
const content = fs.readFileSync(absolutePath, 'utf8');
|
|
4278
|
+
lines += content.split('\n').length - (content.endsWith('\n') ? 1 : 0);
|
|
4279
|
+
countedFiles += 1;
|
|
4280
|
+
} catch {
|
|
4281
|
+
// 无 Git 时只统计可安全读取的当前文件;不可读文件不伪造行数。
|
|
4282
|
+
}
|
|
4283
|
+
}
|
|
4284
|
+
if (countedFiles > 0) {
|
|
4285
|
+
lineCountEstimate = {
|
|
4286
|
+
current_lines: lines,
|
|
4287
|
+
counted_files: countedFiles,
|
|
4288
|
+
note: '无 Git diff,仅统计当前文件总行数;不能解释为新增行数。',
|
|
4289
|
+
};
|
|
3126
4290
|
}
|
|
3127
|
-
} else {
|
|
3128
|
-
filesChanged = aiDiffFileCount;
|
|
3129
4291
|
}
|
|
4292
|
+
const filesChanged = taskUpdateFileCount > 0 ? taskUpdateFileCount : finalOutputFileCount;
|
|
4293
|
+
const countsDiffer = taskUpdateFileCount > 0
|
|
4294
|
+
&& finalOutputFileCount != null
|
|
4295
|
+
&& taskUpdateFileCount !== finalOutputFileCount;
|
|
4296
|
+
const reconciliationNote = countsDiffer
|
|
4297
|
+
? `任务事件累计涉及 ${taskUpdateFileCount} 个文件;最终输出快照包含 ${finalOutputFileCount} 个文件。两者用途不同,不应合并为一个口径。`
|
|
4298
|
+
: (filesChanged == null ? '没有可用的文件变更证据。' : '任务事件与最终输出文件口径一致。');
|
|
3130
4299
|
return {
|
|
3131
|
-
_available:
|
|
4300
|
+
_available: filesChanged != null,
|
|
3132
4301
|
files_changed: filesChanged,
|
|
3133
4302
|
added_lines: addedLines,
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
4303
|
+
deleted_lines: deletedLines,
|
|
4304
|
+
files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
|
|
4305
|
+
task_event_files: taskUpdateFileCount > 0 ? [...taskUpdateFiles] : null,
|
|
4306
|
+
task_event_files_changed: taskUpdateFileCount > 0 ? taskUpdateFileCount : null,
|
|
4307
|
+
final_output_files: finalOutputFiles.size > 0 ? [...finalOutputFiles] : null,
|
|
4308
|
+
final_output_files_changed: finalOutputFileCount,
|
|
4309
|
+
final_output_status: finalOutputStatus,
|
|
4310
|
+
diff_source: snapshotKind === 'final' ? 'ai-diff-final' : (aiDiff ? 'ai-diff-provisional' : 'task-events'),
|
|
4311
|
+
line_count_estimate: lineCountEstimate,
|
|
4312
|
+
reconciliation_note: reconciliationNote,
|
|
4313
|
+
ai_diff_files_changed: countsDiffer ? finalOutputFileCount : null,
|
|
4314
|
+
files_diff_note: countsDiffer ? reconciliationNote : null,
|
|
3137
4315
|
};
|
|
3138
4316
|
}
|
|
3139
4317
|
|
|
@@ -3217,19 +4395,34 @@ function extractStageStatus(metrics) {
|
|
|
3217
4395
|
function extractKnownRisks(events, doctor, archiveResult) {
|
|
3218
4396
|
const severeIssues = doctor?.severe_issues || [];
|
|
3219
4397
|
const warnings = [];
|
|
3220
|
-
// Q7: 从 archive task_completion 判断 warn 是否已修复(incomplete=0 → resolved)
|
|
3221
|
-
const taskCompletion = archiveResult?.task_completion;
|
|
3222
|
-
const allResolved = taskCompletion && Number.isFinite(taskCompletion.incomplete) && taskCompletion.incomplete === 0;
|
|
3223
4398
|
for (const e of events) {
|
|
3224
4399
|
const cr = e.details?.check_results;
|
|
3225
4400
|
if (cr && Number.isFinite(cr.warnings) && cr.warnings > 0) {
|
|
3226
4401
|
// T2.1: 携带 warning_items 明细(若有);旧事件无该字段时降级为仅数量(DC-101/UT-202)
|
|
3227
4402
|
const entry = { command: e.command || e.stage || null, warnings: cr.warnings };
|
|
3228
4403
|
if (Array.isArray(cr.warning_items) && cr.warning_items.length > 0) {
|
|
3229
|
-
|
|
4404
|
+
const dispositionsByWarning = new Map((cr.warning_dispositions || []).map(item => [
|
|
4405
|
+
item.warning || item.id || item.code,
|
|
4406
|
+
item,
|
|
4407
|
+
]));
|
|
4408
|
+
entry.items = cr.warning_items.map(item => {
|
|
4409
|
+
const disposition = dispositionsByWarning.get(item.warning || item.id || item.code);
|
|
4410
|
+
return disposition
|
|
4411
|
+
? {
|
|
4412
|
+
...item,
|
|
4413
|
+
disposition: disposition.disposition,
|
|
4414
|
+
disposition_reason: disposition.reason || null,
|
|
4415
|
+
}
|
|
4416
|
+
: item;
|
|
4417
|
+
});
|
|
4418
|
+
}
|
|
4419
|
+
const dispositions = Array.isArray(cr.warning_dispositions) ? cr.warning_dispositions : [];
|
|
4420
|
+
if (dispositions.length > 0) {
|
|
4421
|
+
entry.warning_dispositions = dispositions;
|
|
4422
|
+
entry.resolved = dispositions.every(item => (
|
|
4423
|
+
['fixed', 'accepted', 'waived'].includes(item.disposition)
|
|
4424
|
+
));
|
|
3230
4425
|
}
|
|
3231
|
-
// Q7: 归档后 incomplete=0 → 标已修复
|
|
3232
|
-
if (allResolved) entry.resolved = true;
|
|
3233
4426
|
warnings.push(entry);
|
|
3234
4427
|
}
|
|
3235
4428
|
const conf = getConformanceReview(e);
|
|
@@ -3238,7 +4431,6 @@ function extractKnownRisks(events, doctor, archiveResult) {
|
|
|
3238
4431
|
const failed = conf.assertions.filter(a => a.status === 'missed');
|
|
3239
4432
|
if (failed.length > 0) {
|
|
3240
4433
|
const confEntry = { command: e.command || 'conformance', failed_assertions: failed.length };
|
|
3241
|
-
if (allResolved) confEntry.resolved = true;
|
|
3242
4434
|
warnings.push(confEntry);
|
|
3243
4435
|
}
|
|
3244
4436
|
}
|
|
@@ -3307,23 +4499,254 @@ function extractTelemetryWarnings(events) {
|
|
|
3307
4499
|
return { _available: true, total: warnings.length, by_type: byType, items };
|
|
3308
4500
|
}
|
|
3309
4501
|
|
|
4502
|
+
function validateMaturityEvidenceDetails(details) {
|
|
4503
|
+
const evidence = details?.maturity_evidence;
|
|
4504
|
+
const invalid = error => ({ valid: false, evidence: null, error });
|
|
4505
|
+
if (!evidence || typeof evidence !== 'object' || Array.isArray(evidence)) {
|
|
4506
|
+
return invalid('details.maturity_evidence 必须是对象');
|
|
4507
|
+
}
|
|
4508
|
+
|
|
4509
|
+
const changeType = String(evidence.change_type || '').trim().toLowerCase();
|
|
4510
|
+
if (!metricsV3.CHANGE_TYPES.includes(changeType)) {
|
|
4511
|
+
return invalid(`change_type 必须是 ${metricsV3.CHANGE_TYPES.join('|')} 之一`);
|
|
4512
|
+
}
|
|
4513
|
+
if (!Number.isFinite(evidence.e1_actual_ms) || evidence.e1_actual_ms < 0) {
|
|
4514
|
+
return invalid('e1_actual_ms 必须是大于等于 0 的有限数字');
|
|
4515
|
+
}
|
|
4516
|
+
if (!Number.isFinite(evidence.e1_target_ms) || evidence.e1_target_ms <= 0) {
|
|
4517
|
+
return invalid('e1_target_ms 必须是大于 0 的有限数字');
|
|
4518
|
+
}
|
|
4519
|
+
if (typeof evidence.e1_type_target_met !== 'boolean') {
|
|
4520
|
+
return invalid('e1_type_target_met 必须是布尔值');
|
|
4521
|
+
}
|
|
4522
|
+
const derivedTargetMet = evidence.e1_actual_ms <= evidence.e1_target_ms;
|
|
4523
|
+
if (evidence.e1_type_target_met !== derivedTargetMet) {
|
|
4524
|
+
return invalid('e1_type_target_met 必须与 e1_actual_ms <= e1_target_ms 的计算结果一致');
|
|
4525
|
+
}
|
|
4526
|
+
if (typeof evidence.code_fully_spec_generated !== 'boolean') {
|
|
4527
|
+
return invalid('code_fully_spec_generated 必须是布尔值');
|
|
4528
|
+
}
|
|
4529
|
+
|
|
4530
|
+
if (evidence.code_fully_spec_generated) {
|
|
4531
|
+
const generatedFiles = Array.isArray(evidence.generated_files)
|
|
4532
|
+
? evidence.generated_files.filter(file => typeof file === 'string' && file.trim())
|
|
4533
|
+
: [];
|
|
4534
|
+
if (generatedFiles.length === 0) {
|
|
4535
|
+
return invalid('code_fully_spec_generated=true 时 generated_files 必须是非空文件路径数组');
|
|
4536
|
+
}
|
|
4537
|
+
if (evidence.manual_code_lines !== 0) {
|
|
4538
|
+
return invalid('code_fully_spec_generated=true 时 manual_code_lines 必须严格等于 0');
|
|
4539
|
+
}
|
|
4540
|
+
if (typeof evidence.evidence_summary !== 'string' || !evidence.evidence_summary.trim()) {
|
|
4541
|
+
return invalid('code_fully_spec_generated=true 时 evidence_summary 必须非空');
|
|
4542
|
+
}
|
|
4543
|
+
if (typeof evidence.reviewer !== 'string' || !evidence.reviewer.trim()) {
|
|
4544
|
+
return invalid('code_fully_spec_generated=true 时 reviewer 必须非空');
|
|
4545
|
+
}
|
|
4546
|
+
if (evidence.reviewer_independence !== 'independent-review') {
|
|
4547
|
+
return invalid('code_fully_spec_generated=true 时 reviewer_independence 必须是 independent-review');
|
|
4548
|
+
}
|
|
4549
|
+
const reviewSessionId = String(evidence.review_session_id || '').trim();
|
|
4550
|
+
const authorSessionId = String(evidence.author_session_id || '').trim();
|
|
4551
|
+
if (!reviewSessionId || !authorSessionId || reviewSessionId === authorSessionId) {
|
|
4552
|
+
return invalid('独立评审要求 review_session_id 与 author_session_id 均非空且不同');
|
|
4553
|
+
}
|
|
4554
|
+
}
|
|
4555
|
+
|
|
4556
|
+
return {
|
|
4557
|
+
valid: true,
|
|
4558
|
+
error: '',
|
|
4559
|
+
evidence: {
|
|
4560
|
+
...evidence,
|
|
4561
|
+
change_type: changeType,
|
|
4562
|
+
e1_type_target_met: derivedTargetMet,
|
|
4563
|
+
},
|
|
4564
|
+
};
|
|
4565
|
+
}
|
|
4566
|
+
|
|
3310
4567
|
function buildReport(projectRoot, events, options = {}) {
|
|
3311
4568
|
const archiveEvent = latestByTimestamp(events.filter(e => {
|
|
3312
4569
|
return e.command === 'archive' && e.type === 'stage_end' && !e.orphan;
|
|
3313
4570
|
}));
|
|
4571
|
+
const gitCommitTimeline = resolveGitCommitTimeline(
|
|
4572
|
+
events,
|
|
4573
|
+
projectRoot,
|
|
4574
|
+
options.gitCommitTimeline,
|
|
4575
|
+
);
|
|
3314
4576
|
const metrics = computePdfMvpMetrics(events, {
|
|
3315
4577
|
level: options.level || (options.change ? 'change' : 'project'),
|
|
3316
4578
|
change: options.change,
|
|
3317
4579
|
capability: options.capability,
|
|
3318
4580
|
projectRoot,
|
|
4581
|
+
gitCommitTimeline,
|
|
3319
4582
|
});
|
|
3320
|
-
const doctor = computeDoctorReport(events, {
|
|
3321
|
-
|
|
4583
|
+
const doctor = computeDoctorReport(events, {
|
|
4584
|
+
change: options.change,
|
|
4585
|
+
projectRoot,
|
|
4586
|
+
gitCommitTimeline,
|
|
4587
|
+
});
|
|
4588
|
+
const level = options.level || (options.change ? 'change' : 'project');
|
|
4589
|
+
const isProjectLevel = level === 'project';
|
|
4590
|
+
|
|
4591
|
+
let alerts = [];
|
|
4592
|
+
let maturity = null;
|
|
4593
|
+
if (isProjectLevel && options.trackingPeriods && options.trackingPeriods.length > 0) {
|
|
4594
|
+
alerts = metricsV3.evaluateAlerts(options.trackingPeriods);
|
|
4595
|
+
}
|
|
4596
|
+
const maturityEvidence = events
|
|
4597
|
+
.filter(e => (
|
|
4598
|
+
!e.orphan
|
|
4599
|
+
&& e.type === 'maturity_evidence'
|
|
4600
|
+
&& (!options.change || e.change === options.change)
|
|
4601
|
+
))
|
|
4602
|
+
.sort((a, b) => timestampMs(b) - timestampMs(a))
|
|
4603
|
+
.map(event => validateMaturityEvidenceDetails(event.details).evidence)
|
|
4604
|
+
.find(Boolean) || {};
|
|
4605
|
+
try {
|
|
4606
|
+
maturity = metricsV3.evaluateMaturity({
|
|
4607
|
+
p4_quality_gate_enforcement_rate: metrics.process?.p4_quality_gate_enforcement_rate ?? null,
|
|
4608
|
+
p_c_stage_closure_rate: metrics.process?.p_c_stage_closure_rate ?? null,
|
|
4609
|
+
q1_spec_conformance: metrics.quality?.q1_spec_conformance_score ?? null,
|
|
4610
|
+
q3_spec_quality_score: metrics.quality?.q3_spec_quality_score ?? null,
|
|
4611
|
+
p_r_avg_rework_count: metrics.process?.rework_summary?.avg_rework_count
|
|
4612
|
+
?? metrics.process?.rework_summary?.total_rework_attempts
|
|
4613
|
+
?? null,
|
|
4614
|
+
}, {
|
|
4615
|
+
has_sdd_flow: (metrics.change_count ?? (options.change ? 1 : (metrics.by_change?.length || 0))) !== 0,
|
|
4616
|
+
e1_type_target_met: maturityEvidence.e1_type_target_met === true,
|
|
4617
|
+
code_fully_spec_generated: maturityEvidence.code_fully_spec_generated === true,
|
|
4618
|
+
});
|
|
4619
|
+
} catch { maturity = null; }
|
|
4620
|
+
|
|
4621
|
+
const archiveTaskCompletion = archiveEvent?.details?.archive_result?.task_completion || null;
|
|
4622
|
+
const applyEvidenceAudit = options.change && archiveTaskCompletion
|
|
4623
|
+
? buildApplyEvidenceAudit(options.change, archiveTaskCompletion, events, { mode: 'report-only' })
|
|
4624
|
+
: null;
|
|
4625
|
+
const baseEvidenceAlerts = metricsV3.buildEvidenceAlerts(events);
|
|
4626
|
+
const auditAlerts = (applyEvidenceAudit?.findings || []).map((finding) => ({
|
|
4627
|
+
key: `${finding.code}:${finding.task_id || options.change || 'change'}`,
|
|
4628
|
+
category: 'evidence',
|
|
4629
|
+
warning: finding.code,
|
|
4630
|
+
subject: finding.task_id || options.change || null,
|
|
4631
|
+
message: finding.message,
|
|
4632
|
+
severity: finding.severity || 'warning',
|
|
4633
|
+
status: 'open',
|
|
4634
|
+
occurrences: 1,
|
|
4635
|
+
opened_by_event_id: null,
|
|
4636
|
+
first_seen_at: null,
|
|
4637
|
+
last_seen_event_id: null,
|
|
4638
|
+
last_seen_at: null,
|
|
4639
|
+
resolved_by_event_id: null,
|
|
4640
|
+
resolved_at: null,
|
|
4641
|
+
}));
|
|
4642
|
+
if (archiveTaskCompletion?.primary_tasks?.parser === 'legacy-fallback') {
|
|
4643
|
+
auditAlerts.push({
|
|
4644
|
+
key: `LEGACY_TASK_PARSER:${options.change || 'change'}`,
|
|
4645
|
+
category: 'evidence',
|
|
4646
|
+
warning: 'LEGACY_TASK_PARSER',
|
|
4647
|
+
subject: options.change || null,
|
|
4648
|
+
message: '任务文档仍使用旧复选框格式,报告已兼容读取;建议迁移为每个任务一条“状态”行。',
|
|
4649
|
+
severity: 'warning',
|
|
4650
|
+
status: 'open',
|
|
4651
|
+
occurrences: 1,
|
|
4652
|
+
opened_by_event_id: null,
|
|
4653
|
+
first_seen_at: null,
|
|
4654
|
+
last_seen_event_id: null,
|
|
4655
|
+
last_seen_at: null,
|
|
4656
|
+
resolved_by_event_id: null,
|
|
4657
|
+
resolved_at: null,
|
|
4658
|
+
});
|
|
4659
|
+
}
|
|
4660
|
+
const evidenceHistory = [...baseEvidenceAlerts.history, ...auditAlerts];
|
|
4661
|
+
const evidenceOpen = evidenceHistory.filter(alert => alert.status === 'open');
|
|
4662
|
+
const evidenceResolved = evidenceHistory.filter(alert => alert.status === 'resolved');
|
|
4663
|
+
const evidenceAlerts = {
|
|
4664
|
+
open: evidenceOpen,
|
|
4665
|
+
resolved: evidenceResolved,
|
|
4666
|
+
history: evidenceHistory,
|
|
4667
|
+
counts: {
|
|
4668
|
+
open: evidenceOpen.length,
|
|
4669
|
+
resolved: evidenceResolved.length,
|
|
4670
|
+
total: evidenceHistory.length,
|
|
4671
|
+
},
|
|
4672
|
+
};
|
|
4673
|
+
const latestCheckWithDispositions = latestByTimestamp(
|
|
4674
|
+
getCheckEvents(events).filter(event => (
|
|
4675
|
+
Array.isArray(event.details?.check_results?.warning_dispositions)
|
|
4676
|
+
&& event.details.check_results.warning_dispositions.length > 0
|
|
4677
|
+
)),
|
|
4678
|
+
);
|
|
4679
|
+
const checkWarningDispositions = latestCheckWithDispositions
|
|
4680
|
+
? latestCheckWithDispositions.details.check_results.warning_dispositions.map(item => ({
|
|
4681
|
+
warning: item.warning || item.id || item.code || 'UNKNOWN',
|
|
4682
|
+
disposition: item.disposition || 'open',
|
|
4683
|
+
reason: item.reason || null,
|
|
4684
|
+
evidence_event_id: latestCheckWithDispositions.event_id || null,
|
|
4685
|
+
}))
|
|
4686
|
+
: [];
|
|
4687
|
+
const minimumMetricSet = metricsV3.buildMinimumMetricStatus(metrics);
|
|
4688
|
+
const metricAlerts = alerts;
|
|
4689
|
+
const latestArchiveResult = [...events].reverse()
|
|
4690
|
+
.map(item => item?.details?.archive_result)
|
|
4691
|
+
.find(Boolean) || {};
|
|
4692
|
+
const observedFormats = options.formats || [
|
|
4693
|
+
latestArchiveResult.report_path && 'md',
|
|
4694
|
+
latestArchiveResult.report_html_path && 'html',
|
|
4695
|
+
latestArchiveResult.report_json_path && 'json',
|
|
4696
|
+
].filter(Boolean);
|
|
4697
|
+
const schemeCompliance = metricsV3.buildSchemeCompliance({
|
|
4698
|
+
minimum_metric_set: minimumMetricSet,
|
|
4699
|
+
metrics,
|
|
4700
|
+
level,
|
|
4701
|
+
period: options.period || null,
|
|
4702
|
+
formats: observedFormats,
|
|
4703
|
+
evidence_alerts: evidenceAlerts,
|
|
4704
|
+
check_warning_dispositions: checkWarningDispositions,
|
|
4705
|
+
event_count: events.length,
|
|
4706
|
+
});
|
|
4707
|
+
const primaryTasks = archiveTaskCompletion?.primary_tasks || null;
|
|
4708
|
+
const taskStatement = primaryTasks
|
|
4709
|
+
? (primaryTasks.has_incomplete
|
|
4710
|
+
? `仍有 ${primaryTasks.incomplete} 个主任务未完成`
|
|
4711
|
+
: `主任务已完成 ${primaryTasks.completed}/${primaryTasks.total}`)
|
|
4712
|
+
: '尚未读取到结构化任务状态';
|
|
4713
|
+
const trustRate = minimumMetricSet.trusted_coverage_rate;
|
|
4714
|
+
const trustStatement = trustRate === 1
|
|
4715
|
+
? '核心指标证据已全部验证'
|
|
4716
|
+
: `核心指标可信覆盖 ${Math.round((trustRate || 0) * 100)}%`;
|
|
4717
|
+
const alertStatement = evidenceAlerts.open.length > 0
|
|
4718
|
+
? `有 ${evidenceAlerts.open.length} 个证据问题需要处理`
|
|
4719
|
+
: '没有未解决的证据问题';
|
|
4720
|
+
|
|
4721
|
+
const baseReport = {
|
|
4722
|
+
schema_version: metricsV3.REPORT_SCHEMA_VERSION,
|
|
4723
|
+
metrics_contract: metricsV3.METRICS_CONTRACT_VERSION,
|
|
3322
4724
|
generated_at: nowISO(),
|
|
3323
4725
|
project_root: projectRoot,
|
|
3324
4726
|
change: options.change || null,
|
|
3325
|
-
level
|
|
4727
|
+
level,
|
|
4728
|
+
window: {
|
|
4729
|
+
period: options.period || null,
|
|
4730
|
+
date_from: options.dateFrom || null,
|
|
4731
|
+
date_to: options.dateTo || null,
|
|
4732
|
+
},
|
|
4733
|
+
minimum_metric_set: minimumMetricSet,
|
|
4734
|
+
measurement_scope: metrics.measurement_scope || null,
|
|
4735
|
+
metric_evidence: metrics.metric_evidence || null,
|
|
4736
|
+
attribution_audit: options.attributionAudit || {
|
|
4737
|
+
linked_source_event_count: 0,
|
|
4738
|
+
active_links: [],
|
|
4739
|
+
unassigned_candidate_event_ids: [],
|
|
4740
|
+
},
|
|
4741
|
+
conclusion: {
|
|
4742
|
+
status: evidenceAlerts.open.length > 0 || trustRate < 1 || primaryTasks?.has_incomplete ? 'attention' : 'trusted',
|
|
4743
|
+
text: `${taskStatement};${trustStatement};${alertStatement}。`,
|
|
4744
|
+
task_statement: taskStatement,
|
|
4745
|
+
trust_statement: trustStatement,
|
|
4746
|
+
alert_statement: alertStatement,
|
|
4747
|
+
},
|
|
3326
4748
|
metrics,
|
|
4749
|
+
legacy_metrics: metrics.legacy_metrics || null,
|
|
3327
4750
|
doctor,
|
|
3328
4751
|
archive_result: archiveEvent?.details?.archive_result || null,
|
|
3329
4752
|
changed_files: extractChangedFiles(events, projectRoot),
|
|
@@ -3334,7 +4757,18 @@ function buildReport(projectRoot, events, options = {}) {
|
|
|
3334
4757
|
artifacts: extractArtifacts(archiveEvent),
|
|
3335
4758
|
process_notes: extractProcessNotes(events),
|
|
3336
4759
|
telemetry_warnings: extractTelemetryWarnings(events),
|
|
4760
|
+
distribution: isProjectLevel ? (metrics.distribution || null) : null,
|
|
4761
|
+
by_change_type: isProjectLevel ? (metrics.by_change_type || null) : null,
|
|
4762
|
+
by_change: isProjectLevel ? (metrics.by_change || null) : null,
|
|
4763
|
+
alerts,
|
|
4764
|
+
metric_alerts: metricAlerts,
|
|
4765
|
+
evidence_alerts: evidenceAlerts,
|
|
4766
|
+
check_warning_dispositions: checkWarningDispositions,
|
|
4767
|
+
apply_evidence_audit: applyEvidenceAudit,
|
|
4768
|
+
scheme_compliance: schemeCompliance,
|
|
4769
|
+
maturity,
|
|
3337
4770
|
};
|
|
4771
|
+
return require('./reporting/change-report-model.cjs').buildChangeReportModel(baseReport, events);
|
|
3338
4772
|
}
|
|
3339
4773
|
|
|
3340
4774
|
function filterEventsByDate(events, dateFrom, dateTo) {
|
|
@@ -3353,7 +4787,9 @@ function computeDoctorReport(events, options = {}) {
|
|
|
3353
4787
|
? events.filter(e => e.change === options.change)
|
|
3354
4788
|
: events;
|
|
3355
4789
|
const executionSummary = summarizeStageExecutions(scopedEvents);
|
|
3356
|
-
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot
|
|
4790
|
+
const reworkReasons = deriveReworkReasons(executionSummary, options.projectRoot, scopedEvents, {
|
|
4791
|
+
gitCommitTimeline: options.gitCommitTimeline,
|
|
4792
|
+
});
|
|
3357
4793
|
const stageEvents = scopedEvents.filter(e => e.type === 'stage_start' || e.type === 'stage_end');
|
|
3358
4794
|
const starts = stageEvents.filter(e => e.type === 'stage_start');
|
|
3359
4795
|
const ends = stageEvents.filter(e => e.type === 'stage_end');
|
|
@@ -3480,6 +4916,15 @@ function cmdStart(args) {
|
|
|
3480
4916
|
process.exit(1);
|
|
3481
4917
|
}
|
|
3482
4918
|
|
|
4919
|
+
if (!changeName) {
|
|
4920
|
+
fail('E_CHANGE_REQUIRED: start 必须显式提供 --change');
|
|
4921
|
+
}
|
|
4922
|
+
requireValidChangeName(changeName);
|
|
4923
|
+
if (changeName === 'general'
|
|
4924
|
+
&& (command !== 'explore' || !isTrueOption(args['allow-general'] || args.allow_general))) {
|
|
4925
|
+
fail('E_GENERAL_SCOPE_FORBIDDEN: general 仅允许 explore 配合 --allow-general 显式使用');
|
|
4926
|
+
}
|
|
4927
|
+
|
|
3483
4928
|
const validCommands = ['propose', 'spec', 'design', 'task', 'check', 'apply', 'test', 'archive', 'explore'];
|
|
3484
4929
|
if (!validCommands.includes(command)) {
|
|
3485
4930
|
console.error(`错误: 无效的 command "${command}",有效值: ${validCommands.join(', ')}`);
|
|
@@ -3510,7 +4955,7 @@ function cmdStart(args) {
|
|
|
3510
4955
|
source,
|
|
3511
4956
|
command,
|
|
3512
4957
|
stage: command,
|
|
3513
|
-
change: changeName
|
|
4958
|
+
change: changeName,
|
|
3514
4959
|
capability,
|
|
3515
4960
|
task_id: taskId,
|
|
3516
4961
|
agent_type: agentType,
|
|
@@ -3519,7 +4964,10 @@ function cmdStart(args) {
|
|
|
3519
4964
|
git_sha: gitSha,
|
|
3520
4965
|
timestamp,
|
|
3521
4966
|
context,
|
|
3522
|
-
details:
|
|
4967
|
+
details: {
|
|
4968
|
+
...(worktreeDetails || {}),
|
|
4969
|
+
...(changeName === 'general' ? { scope: 'explicit-general' } : {}),
|
|
4970
|
+
},
|
|
3523
4971
|
});
|
|
3524
4972
|
|
|
3525
4973
|
appendEvent(dataDir, event.change, event);
|
|
@@ -3599,14 +5047,47 @@ function cmdEnd(args, options = {}) {
|
|
|
3599
5047
|
if (!eventId && startEvent) {
|
|
3600
5048
|
eventId = startEvent.event_id;
|
|
3601
5049
|
}
|
|
5050
|
+
const requestedChange = args.change || args['change-name'];
|
|
5051
|
+
if (requestedChange) {
|
|
5052
|
+
requireValidChangeName(requestedChange);
|
|
5053
|
+
}
|
|
5054
|
+
if (!startEvent && !requestedChange) {
|
|
5055
|
+
fail('E_CHANGE_REQUIRED: 无法解析 start 的 end 必须显式提供 --change');
|
|
5056
|
+
}
|
|
5057
|
+
if (startEvent && requestedChange && startEvent.change !== requestedChange) {
|
|
5058
|
+
fail(`E_CHANGE_CONFLICT: end 指定 change "${requestedChange}",但 active stage 属于 "${startEvent.change}"`);
|
|
5059
|
+
}
|
|
3602
5060
|
// stage_end 幂等性保护:同一 event_id 已存在 stage_end 时,结果相同则跳过,不同则更新(archive 命令只要存在即跳过)
|
|
3603
5061
|
const command = startEvent?.command || args.command || 'unknown';
|
|
3604
5062
|
const change = startEvent?.change || args.change || args['change-name'] || 'general';
|
|
5063
|
+
requireValidChangeName(change);
|
|
5064
|
+
if (change === 'general'
|
|
5065
|
+
&& (command !== 'explore' || !isTrueOption(args['allow-general'] || args.allow_general))) {
|
|
5066
|
+
fail('E_GENERAL_SCOPE_FORBIDDEN: general 仅允许 explore 配合 --allow-general 显式使用');
|
|
5067
|
+
}
|
|
3605
5068
|
const existingEnds = readAllEventEndsById(dataDir, eventId, change);
|
|
3606
5069
|
if (existingEnds.length > 0) {
|
|
3607
5070
|
const latestExisting = existingEnds.sort((a, b) => timestampMs(b) - timestampMs(a))[0];
|
|
5071
|
+
if (latestExisting.result !== result) {
|
|
5072
|
+
fail(`E_STAGE_END_CONFLICT: event_id "${eventId}" 已记录 result=${latestExisting.result},不能改为 ${result}`);
|
|
5073
|
+
}
|
|
3608
5074
|
// archive 命令已存在 stage_end 时直接跳过,避免 result 突变后重复写入
|
|
3609
5075
|
if (command === 'archive') {
|
|
5076
|
+
const archiveResult = latestExisting.details?.archive_result || {};
|
|
5077
|
+
if (archiveResult.archive_path) {
|
|
5078
|
+
const archiveDirAbs = path.isAbsolute(archiveResult.archive_path)
|
|
5079
|
+
? archiveResult.archive_path
|
|
5080
|
+
: path.resolve(projectRoot, archiveResult.archive_path);
|
|
5081
|
+
ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
|
|
5082
|
+
reason: archiveResult.reason || '',
|
|
5083
|
+
method: archiveResult.method || 'skywalk-full-spec-archive',
|
|
5084
|
+
archivePath: archiveDirAbs,
|
|
5085
|
+
archivedAt: archiveResult.created_at || archiveResult.archived_at,
|
|
5086
|
+
copiedSpecs: archiveResult.copied_specs,
|
|
5087
|
+
finalizeAfterReports: true,
|
|
5088
|
+
runIdentity: latestExisting.event_id,
|
|
5089
|
+
});
|
|
5090
|
+
}
|
|
3610
5091
|
const output = {
|
|
3611
5092
|
event_id: eventId,
|
|
3612
5093
|
duration_ms: latestExisting.duration_ms,
|
|
@@ -3633,7 +5114,6 @@ function cmdEnd(args, options = {}) {
|
|
|
3633
5114
|
}
|
|
3634
5115
|
return output;
|
|
3635
5116
|
}
|
|
3636
|
-
// 结果不同:允许继续写入新的 stage_end(覆盖/更新)
|
|
3637
5117
|
}
|
|
3638
5118
|
const orphan = !startEvent;
|
|
3639
5119
|
if (!eventId) {
|
|
@@ -3700,7 +5180,7 @@ function cmdEnd(args, options = {}) {
|
|
|
3700
5180
|
};
|
|
3701
5181
|
}
|
|
3702
5182
|
}
|
|
3703
|
-
// archive
|
|
5183
|
+
// archive 归档完成后,仅主任务未完成时降为 partial;验收证据单独提示,不再冒充任务进度。
|
|
3704
5184
|
if (command === 'archive' && result === 'success') {
|
|
3705
5185
|
try {
|
|
3706
5186
|
const archiveDirRel = repaired?.archive_path || details.archive_result?.archive_path || null;
|
|
@@ -3708,7 +5188,13 @@ function cmdEnd(args, options = {}) {
|
|
|
3708
5188
|
const taskCompletion = archiveDirAbs
|
|
3709
5189
|
? scanTaskCompletionForArchiveDir(projectRoot, change, archiveDirAbs)
|
|
3710
5190
|
: scanTaskCompletion(projectRoot, change);
|
|
3711
|
-
|
|
5191
|
+
const primary = taskCompletion.primary_tasks;
|
|
5192
|
+
if (primary?.has_incomplete) {
|
|
5193
|
+
result = 'partial';
|
|
5194
|
+
summary = `${summary}(尚有 ${primary.incomplete} 个主任务未完成)`.trim();
|
|
5195
|
+
} else if ((taskCompletion.acceptance_evidence?.unconfirmed || 0) > 0) {
|
|
5196
|
+
summary = `${summary}(主任务已完成;仍有 ${taskCompletion.acceptance_evidence.unconfirmed} 项验收证据待确认)`.trim();
|
|
5197
|
+
} else if (!primary && taskCompletion.has_incomplete) {
|
|
3712
5198
|
result = 'partial';
|
|
3713
5199
|
summary = `${summary}(${taskCompletion.incomplete} 项验收标准未勾选)`.trim();
|
|
3714
5200
|
}
|
|
@@ -3739,76 +5225,131 @@ function cmdEnd(args, options = {}) {
|
|
|
3739
5225
|
orphan: orphan ? true : undefined,
|
|
3740
5226
|
});
|
|
3741
5227
|
|
|
3742
|
-
appendEvent(dataDir, event.change, event);
|
|
3743
|
-
if (startEvent) {
|
|
3744
|
-
clearActiveStage(projectRoot, startEvent);
|
|
3745
|
-
}
|
|
3746
|
-
|
|
3747
|
-
// archive 时追加"变更总结"段到 execution-log.md(T1.3:移到 appendEvent 之后,确保总结出现在 archive_end 行之后)
|
|
3748
|
-
if (command === 'archive') {
|
|
3749
|
-
try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
|
|
3750
|
-
try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
|
|
3751
|
-
}
|
|
3752
|
-
}
|
|
3753
|
-
|
|
3754
5228
|
let resolvedReportOutput = '';
|
|
5229
|
+
const writtenReportPaths = [];
|
|
3755
5230
|
if (reportOutput) {
|
|
3756
5231
|
resolvedReportOutput = path.isAbsolute(reportOutput) ? reportOutput : path.resolve(projectRoot, reportOutput);
|
|
3757
5232
|
} else if (repaired && repaired.reportPath) {
|
|
3758
5233
|
resolvedReportOutput = repaired.reportPath;
|
|
3759
5234
|
}
|
|
3760
5235
|
if (resolvedReportOutput) {
|
|
3761
|
-
|
|
5236
|
+
// archive 的 stage_end 尚未入库时,先用待写事件生成报告;实际成功路径随后写回事件。
|
|
5237
|
+
const events = [...readEvents(dataDir, event.change), event];
|
|
3762
5238
|
const report = buildReport(projectRoot, events, {
|
|
3763
5239
|
level: args['report-level'] || 'change',
|
|
3764
5240
|
change: event.change,
|
|
3765
5241
|
capability: args.capability || args['capability-name'] || startEvent?.capability,
|
|
3766
5242
|
});
|
|
3767
|
-
const
|
|
5243
|
+
const requestedReportFormat = args['report-format'] || 'markdown';
|
|
5244
|
+
const reportFormat = command === 'archive' ? 'markdown' : requestedReportFormat;
|
|
5245
|
+
const expectedExtension = reportFormat === 'json' ? '.json' : reportFormat === 'html' ? '.html' : '.md';
|
|
5246
|
+
resolvedReportOutput = normalizeKnownReportExtension(resolvedReportOutput, expectedExtension);
|
|
3768
5247
|
const isHtmlPrimary = reportFormat === 'html';
|
|
3769
5248
|
const rendered = reportFormat === 'json'
|
|
3770
5249
|
? JSON.stringify(report, null, 2)
|
|
3771
5250
|
: (isHtmlPrimary ? renderExecutiveReportHtml(report) : renderExecutiveReportMarkdown(report));
|
|
3772
|
-
|
|
3773
|
-
|
|
5251
|
+
atomicWriteText(resolvedReportOutput, rendered + '\n');
|
|
5252
|
+
writtenReportPaths.push(resolvedReportOutput);
|
|
3774
5253
|
|
|
3775
|
-
//
|
|
5254
|
+
// 默认三输出:md + html + json(同源 Report Model);显式 format=json 时只写用户指定文件
|
|
5255
|
+
// 仅对 .md/.html 主路径派生 companion,防止非标准路径被静默覆盖
|
|
3776
5256
|
if (reportFormat !== 'json') {
|
|
3777
|
-
|
|
3778
|
-
|
|
3779
|
-
|
|
3780
|
-
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
5257
|
+
const primaryExt = path.extname(resolvedReportOutput).toLowerCase();
|
|
5258
|
+
const isMdPrimary = primaryExt === '.md';
|
|
5259
|
+
const isHtmlExt = primaryExt === '.html';
|
|
5260
|
+
if (isMdPrimary || isHtmlExt) {
|
|
5261
|
+
const companionSuffix = isHtmlExt ? '.md' : '.html';
|
|
5262
|
+
const companionRender = isHtmlExt ? renderExecutiveReportMarkdown(report) : renderExecutiveReportHtml(report);
|
|
5263
|
+
const companionPath = resolvedReportOutput.replace(/\.[^.]+$/, companionSuffix);
|
|
5264
|
+
if (companionPath !== resolvedReportOutput) {
|
|
5265
|
+
try {
|
|
5266
|
+
atomicWriteText(companionPath, companionRender + '\n');
|
|
5267
|
+
writtenReportPaths.push(companionPath);
|
|
5268
|
+
} catch (err) {
|
|
5269
|
+
if (command === 'archive') {
|
|
5270
|
+
throw new Error(`E_REPORT_ARTIFACT_WRITE: ${companionSuffix.slice(1)} 报告写入失败: ${err.message}`);
|
|
5271
|
+
}
|
|
5272
|
+
try { console.error(`[telemetry] 报告 companion 写入失败(${companionSuffix.slice(1)},不阻塞主流程): ${err.message}`); } catch {}
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
const jsonPath = resolvedReportOutput.replace(/\.[^.]+$/, '.json');
|
|
5276
|
+
if (jsonPath !== resolvedReportOutput) {
|
|
5277
|
+
try {
|
|
5278
|
+
atomicWriteText(jsonPath, JSON.stringify(report, null, 2) + '\n');
|
|
5279
|
+
writtenReportPaths.push(jsonPath);
|
|
5280
|
+
} catch (err) {
|
|
5281
|
+
if (command === 'archive') {
|
|
5282
|
+
throw new Error(`E_REPORT_ARTIFACT_WRITE: json 报告写入失败: ${err.message}`);
|
|
5283
|
+
}
|
|
5284
|
+
try { console.error(`[telemetry] 报告 companion 写入失败(json,不阻塞主流程): ${err.message}`); } catch {}
|
|
3787
5285
|
}
|
|
3788
5286
|
}
|
|
3789
|
-
} catch (err) {
|
|
3790
|
-
try { console.error(`[telemetry] ${isHtmlPrimary ? 'md' : 'html'} 报告副本写入失败(不阻塞主流程): ${err.message}`); } catch {}
|
|
3791
5287
|
}
|
|
3792
5288
|
}
|
|
3793
5289
|
}
|
|
3794
5290
|
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
5291
|
+
if (command === 'archive' && event.details?.archive_result) {
|
|
5292
|
+
const byExtension = Object.fromEntries(writtenReportPaths.map((filePath) => [path.extname(filePath).toLowerCase(), filePath]));
|
|
5293
|
+
const actualPaths = {};
|
|
5294
|
+
if (byExtension['.md']) actualPaths.report_path = toProjectRelative(projectRoot, byExtension['.md']);
|
|
5295
|
+
if (byExtension['.html']) actualPaths.report_html_path = toProjectRelative(projectRoot, byExtension['.html']);
|
|
5296
|
+
if (byExtension['.json']) actualPaths.report_json_path = toProjectRelative(projectRoot, byExtension['.json']);
|
|
5297
|
+
event.details.archive_result = {
|
|
5298
|
+
...event.details.archive_result,
|
|
5299
|
+
...actualPaths,
|
|
5300
|
+
};
|
|
5301
|
+
}
|
|
5302
|
+
|
|
5303
|
+
appendEvent(dataDir, event.change, event);
|
|
5304
|
+
if (startEvent) {
|
|
5305
|
+
clearActiveStage(projectRoot, startEvent);
|
|
5306
|
+
}
|
|
5307
|
+
|
|
5308
|
+
// 终态摘要必须基于已经落库的 archive stage_end。
|
|
5309
|
+
if (command === 'archive') {
|
|
5310
|
+
try { appendChangeSummary(projectRoot, change, readEvents(dataDir, change)); } catch (err) {
|
|
5311
|
+
try { console.error(`[telemetry] 变更总结追加失败(不阻塞): ${err.message}`); } catch {}
|
|
3809
5312
|
}
|
|
3810
5313
|
}
|
|
3811
5314
|
|
|
5315
|
+
// 报告初稿生成时 archive stage_end 尚未入库。事件落库后使用同一模型重写已成功
|
|
5316
|
+
// 生成的格式,使 md/html/json 与最终 JSONL 保持一致;失败的 companion 不会被虚构。
|
|
5317
|
+
if (command === 'archive' && writtenReportPaths.length > 0) {
|
|
5318
|
+
const finalEvents = readEvents(dataDir, event.change);
|
|
5319
|
+
const finalReport = buildReport(projectRoot, finalEvents, {
|
|
5320
|
+
level: args['report-level'] || 'change',
|
|
5321
|
+
change: event.change,
|
|
5322
|
+
capability: args.capability || args['capability-name'] || startEvent?.capability,
|
|
5323
|
+
});
|
|
5324
|
+
for (const filePath of writtenReportPaths) {
|
|
5325
|
+
const extension = path.extname(filePath).toLowerCase();
|
|
5326
|
+
const content = extension === '.json'
|
|
5327
|
+
? JSON.stringify(finalReport, null, 2)
|
|
5328
|
+
: (extension === '.html'
|
|
5329
|
+
? renderExecutiveReportHtml(finalReport)
|
|
5330
|
+
: renderExecutiveReportMarkdown(finalReport));
|
|
5331
|
+
atomicWriteText(filePath, content + '\n');
|
|
5332
|
+
}
|
|
5333
|
+
}
|
|
5334
|
+
|
|
5335
|
+
// 最终封装统一发生在事件、摘要和三格式报告都落盘之后,保证 manifest/ZIP
|
|
5336
|
+
// 收录的是同一次变更的真实终态。
|
|
5337
|
+
if (command === 'archive' && repaired && event.details?.archive_result?.archive_path) {
|
|
5338
|
+
const finalArchiveResult = event.details.archive_result;
|
|
5339
|
+
const archiveDirAbs = path.isAbsolute(finalArchiveResult.archive_path)
|
|
5340
|
+
? finalArchiveResult.archive_path
|
|
5341
|
+
: path.resolve(projectRoot, finalArchiveResult.archive_path);
|
|
5342
|
+
ensureArchiveManifest(projectRoot, change, archiveDirAbs, {
|
|
5343
|
+
reason: finalArchiveResult.reason || '',
|
|
5344
|
+
method: finalArchiveResult.method || 'skywalk-full-spec-archive',
|
|
5345
|
+
archivePath: archiveDirAbs,
|
|
5346
|
+
archivedAt: finalArchiveResult.created_at || finalArchiveResult.archived_at,
|
|
5347
|
+
copiedSpecs: finalArchiveResult.copied_specs,
|
|
5348
|
+
finalizeAfterReports: true,
|
|
5349
|
+
runIdentity: event.event_id,
|
|
5350
|
+
});
|
|
5351
|
+
}
|
|
5352
|
+
|
|
3812
5353
|
const output = {
|
|
3813
5354
|
event_id: eventId,
|
|
3814
5355
|
duration_ms: durationMs,
|
|
@@ -3826,11 +5367,456 @@ function cmdEnd(args, options = {}) {
|
|
|
3826
5367
|
/**
|
|
3827
5368
|
* log record: 记录非阶段类结构化事件
|
|
3828
5369
|
*/
|
|
5370
|
+
function isTrueOption(value) {
|
|
5371
|
+
return value === true || String(value || '').toLowerCase() === 'true';
|
|
5372
|
+
}
|
|
5373
|
+
|
|
5374
|
+
function requireStrictString(value, code, fieldName) {
|
|
5375
|
+
const normalized = String(value || '').trim();
|
|
5376
|
+
if (!normalized) fail(`${code}: strict 事件缺少 ${fieldName}`);
|
|
5377
|
+
return normalized;
|
|
5378
|
+
}
|
|
5379
|
+
|
|
5380
|
+
function strictInteger(value, code, fieldName, minimum = 0) {
|
|
5381
|
+
const number = Number(value);
|
|
5382
|
+
if (!Number.isInteger(number) || number < minimum) {
|
|
5383
|
+
fail(`${code}: ${fieldName} 必须是大于等于 ${minimum} 的整数`);
|
|
5384
|
+
}
|
|
5385
|
+
return number;
|
|
5386
|
+
}
|
|
5387
|
+
|
|
5388
|
+
function normalizeStrictTestResult(args, details) {
|
|
5389
|
+
const input = details?.test_results;
|
|
5390
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
5391
|
+
fail('TEST_RESULT_REQUIRED: strict test_result 缺少 details.test_results');
|
|
5392
|
+
}
|
|
5393
|
+
const tddPhase = requireStrictString(input.tdd_phase, 'TEST_RESULT_TDD_PHASE_REQUIRED', 'tdd_phase');
|
|
5394
|
+
if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(tddPhase)) {
|
|
5395
|
+
fail(`TEST_RESULT_TDD_PHASE_INVALID: 非法 tdd_phase "${tddPhase}"`);
|
|
5396
|
+
}
|
|
5397
|
+
const command = requireStrictString(input.command || args.command, 'TEST_RESULT_COMMAND_REQUIRED', 'command');
|
|
5398
|
+
const result = requireStrictString(input.result || args.result, 'E_STRICT_FIELD_REQUIRED', 'result');
|
|
5399
|
+
if (!['success', 'failure'].includes(result)) {
|
|
5400
|
+
fail(`TEST_RESULT_INVALID: 非法 result "${result}"`);
|
|
5401
|
+
}
|
|
5402
|
+
const exitCode = strictInteger(input.exit_code, 'TEST_RESULT_EXIT_CODE_INVALID', 'exit_code', 0);
|
|
5403
|
+
const durationMs = strictInteger(input.duration_ms, 'TEST_RESULT_DURATION_INVALID', 'duration_ms', 0);
|
|
5404
|
+
if (typeof input.counts_known !== 'boolean') {
|
|
5405
|
+
fail('TEST_RESULT_COUNTS_KNOWN_REQUIRED: counts_known 必须是 boolean');
|
|
5406
|
+
}
|
|
5407
|
+
const failureType = requireStrictString(input.failure_type, 'TEST_RESULT_FAILURE_TYPE_REQUIRED', 'failure_type');
|
|
5408
|
+
if (!['assertion', 'contract', 'compile', 'infrastructure', 'timeout', 'none'].includes(failureType)) {
|
|
5409
|
+
fail(`TEST_RESULT_FAILURE_TYPE_INVALID: 非法 failure_type "${failureType}"`);
|
|
5410
|
+
}
|
|
5411
|
+
const expectedFailure = input.expected_failure === true;
|
|
5412
|
+
if (expectedFailure && tddPhase !== 'red') {
|
|
5413
|
+
fail('TEST_RESULT_EXPECTED_FAILURE_INVALID: expected_failure 仅允许用于 red 阶段');
|
|
5414
|
+
}
|
|
5415
|
+
if (exitCode === 0 && failureType !== 'none') {
|
|
5416
|
+
fail('TEST_RESULT_FAILURE_TYPE_CONFLICT: exit_code=0 时 failure_type 必须为 none');
|
|
5417
|
+
}
|
|
5418
|
+
if (exitCode !== 0 && failureType === 'none') {
|
|
5419
|
+
fail('TEST_RESULT_FAILURE_TYPE_CONFLICT: exit_code 非 0 时 failure_type 不能为 none');
|
|
5420
|
+
}
|
|
5421
|
+
if ((exitCode === 0 && result !== 'success') || (exitCode !== 0 && result !== 'failure')) {
|
|
5422
|
+
fail(`E_RESULT_EXIT_CONFLICT: result=${result} 与 exit_code=${exitCode} 不一致`);
|
|
5423
|
+
}
|
|
5424
|
+
|
|
5425
|
+
const taskId = String(args['task-id'] || args.task_id || input.task_id || '').trim();
|
|
5426
|
+
const verifiedTaskIds = Array.isArray(input.verified_task_ids)
|
|
5427
|
+
? [...new Set(input.verified_task_ids.map(value => String(value || '').trim()).filter(Boolean))].sort()
|
|
5428
|
+
: [];
|
|
5429
|
+
if (!taskId && verifiedTaskIds.length === 0) {
|
|
5430
|
+
fail('E_STRICT_FIELD_REQUIRED: strict test_result 必须提供 task_id 或 verified_task_ids');
|
|
5431
|
+
}
|
|
5432
|
+
if (taskId && !verifiedTaskIds.includes(taskId)) verifiedTaskIds.push(taskId);
|
|
5433
|
+
verifiedTaskIds.sort();
|
|
5434
|
+
|
|
5435
|
+
const normalized = {
|
|
5436
|
+
run_id: requireStrictString(args['run-id'] || args.run_id || input.run_id, 'EVENT_RUN_ID_REQUIRED', 'run_id'),
|
|
5437
|
+
task_id: taskId || undefined,
|
|
5438
|
+
verified_task_ids: verifiedTaskIds,
|
|
5439
|
+
tdd_phase: tddPhase,
|
|
5440
|
+
command,
|
|
5441
|
+
result,
|
|
5442
|
+
exit_code: exitCode,
|
|
5443
|
+
counts_known: input.counts_known,
|
|
5444
|
+
duration_ms: durationMs,
|
|
5445
|
+
failure_type: failureType,
|
|
5446
|
+
expected_failure: expectedFailure,
|
|
5447
|
+
evidence_tier: 'strict',
|
|
5448
|
+
valid_red: tddPhase === 'red'
|
|
5449
|
+
? expectedFailure && exitCode !== 0 && ['assertion', 'contract'].includes(failureType)
|
|
5450
|
+
: false,
|
|
5451
|
+
};
|
|
5452
|
+
if (input.counts_known) {
|
|
5453
|
+
normalized.passed = strictInteger(input.passed, 'TEST_RESULT_COUNTS_INVALID', 'passed', 0);
|
|
5454
|
+
normalized.failed = strictInteger(input.failed, 'TEST_RESULT_COUNTS_INVALID', 'failed', 0);
|
|
5455
|
+
normalized.skipped = strictInteger(input.skipped, 'TEST_RESULT_COUNTS_INVALID', 'skipped', 0);
|
|
5456
|
+
if (normalized.exit_code === 0 && normalized.failed > 0) {
|
|
5457
|
+
fail('TEST_RESULT_COUNTS_CONFLICT: exit_code=0 时 failed 不能大于 0');
|
|
5458
|
+
}
|
|
5459
|
+
}
|
|
5460
|
+
return normalized;
|
|
5461
|
+
}
|
|
5462
|
+
|
|
5463
|
+
function isValidCompletionTestResults(testResults) {
|
|
5464
|
+
if (!testResults || typeof testResults !== 'object') return false;
|
|
5465
|
+
if (!['green', 'refactor', 'regression'].includes(testResults.tdd_phase)) return false;
|
|
5466
|
+
if (testResults.exit_code !== 0 || testResults.failure_type !== 'none') return false;
|
|
5467
|
+
if (testResults.counts_known === true && Number(testResults.failed) !== 0) return false;
|
|
5468
|
+
return true;
|
|
5469
|
+
}
|
|
5470
|
+
|
|
5471
|
+
function normalizeStrictProcessNote(args, details) {
|
|
5472
|
+
const input = details?.process_note && typeof details.process_note === 'object'
|
|
5473
|
+
? details.process_note
|
|
5474
|
+
: details;
|
|
5475
|
+
const kind = requireStrictString(input?.kind, 'PROCESS_NOTE_KIND_REQUIRED', 'kind');
|
|
5476
|
+
if (!['user_decision', 'scope_change', 'api_error', 'model_error', 'recovery', 'test_exception'].includes(kind)) {
|
|
5477
|
+
fail(`PROCESS_NOTE_KIND_INVALID: 非法 kind "${kind}"`);
|
|
5478
|
+
}
|
|
5479
|
+
const summary = String(args.summary || input?.summary || '').trim();
|
|
5480
|
+
if (!summary) {
|
|
5481
|
+
fail('PROCESS_NOTE_SUMMARY_REQUIRED: strict process_note 必须提供 summary');
|
|
5482
|
+
}
|
|
5483
|
+
return {
|
|
5484
|
+
details: {
|
|
5485
|
+
...details,
|
|
5486
|
+
kind,
|
|
5487
|
+
process_note: {
|
|
5488
|
+
...(details.process_note || {}),
|
|
5489
|
+
kind,
|
|
5490
|
+
summary,
|
|
5491
|
+
},
|
|
5492
|
+
},
|
|
5493
|
+
summary,
|
|
5494
|
+
runId: requireStrictString(args['run-id'] || args.run_id || input?.run_id, 'EVENT_RUN_ID_REQUIRED', 'run_id'),
|
|
5495
|
+
};
|
|
5496
|
+
}
|
|
5497
|
+
|
|
5498
|
+
function normalizeStrictTaskUpdate(args, details, dataDir, change) {
|
|
5499
|
+
const input = details?.task_update && typeof details.task_update === 'object'
|
|
5500
|
+
? details.task_update
|
|
5501
|
+
: details;
|
|
5502
|
+
const taskId = requireStrictString(
|
|
5503
|
+
args['task-id'] || args.task_id || input?.task_id,
|
|
5504
|
+
'EVENT_TASK_ID_REQUIRED',
|
|
5505
|
+
'task_id',
|
|
5506
|
+
);
|
|
5507
|
+
const status = requireStrictString(args.status || input?.status, 'TASK_STATUS_REQUIRED', 'status');
|
|
5508
|
+
if (!['started', 'blocked', 'completed', 'reopened'].includes(status)) {
|
|
5509
|
+
fail(`TASK_STATUS_INVALID: 非法任务状态 "${status}"`);
|
|
5510
|
+
}
|
|
5511
|
+
const runId = requireStrictString(
|
|
5512
|
+
args['run-id'] || args.run_id || input?.run_id,
|
|
5513
|
+
'EVENT_RUN_ID_REQUIRED',
|
|
5514
|
+
'run_id',
|
|
5515
|
+
);
|
|
5516
|
+
const testEventId = input?.test_event_id || details?.test_event_id || null;
|
|
5517
|
+
const verificationNotApplicable = input?.verification_not_applicable === true
|
|
5518
|
+
|| details?.verification_not_applicable === true;
|
|
5519
|
+
const verificationReason = String(
|
|
5520
|
+
input?.verification_not_applicable_reason
|
|
5521
|
+
|| input?.verification_reason
|
|
5522
|
+
|| details?.verification_not_applicable_reason
|
|
5523
|
+
|| details?.verification_reason
|
|
5524
|
+
|| '',
|
|
5525
|
+
).trim();
|
|
5526
|
+
|
|
5527
|
+
if (status === 'completed') {
|
|
5528
|
+
if (!testEventId && !verificationNotApplicable) {
|
|
5529
|
+
fail('TASK_TEST_REFERENCE_MISSING: completed 任务必须引用 test_event_id,或声明 verification_not_applicable=true 并说明原因');
|
|
5530
|
+
}
|
|
5531
|
+
if (verificationNotApplicable && !verificationReason) {
|
|
5532
|
+
fail('TASK_VERIFICATION_REASON_REQUIRED: verification_not_applicable 必须说明原因');
|
|
5533
|
+
}
|
|
5534
|
+
if (testEventId) {
|
|
5535
|
+
const referenced = readEvents(dataDir, change).find(event => event.event_id === testEventId);
|
|
5536
|
+
if (!referenced || referenced.type !== 'test_result') {
|
|
5537
|
+
fail(`TASK_TEST_REFERENCE_MISSING: 同一 change 中不存在 test_result "${testEventId}"`);
|
|
5538
|
+
}
|
|
5539
|
+
const testResults = referenced.details?.test_results || {};
|
|
5540
|
+
if (!isValidCompletionTestResults(testResults)) {
|
|
5541
|
+
fail(`TASK_TEST_REFERENCE_INVALID: test_result "${testEventId}" 不能证明任务完成`);
|
|
5542
|
+
}
|
|
5543
|
+
const verifiedTaskIds = new Set([
|
|
5544
|
+
referenced.task_id,
|
|
5545
|
+
testResults.task_id,
|
|
5546
|
+
...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
|
|
5547
|
+
].filter(Boolean));
|
|
5548
|
+
if (testResults.evidence_tier !== 'strict' || !verifiedTaskIds.has(taskId)) {
|
|
5549
|
+
fail(`E_TEST_IDENTITY_MISMATCH: test_result "${testEventId}" 未严格验证任务 "${taskId}"`);
|
|
5550
|
+
}
|
|
5551
|
+
if (typeof input?.tdd_required !== 'boolean') {
|
|
5552
|
+
fail('TASK_TDD_REQUIRED_MISSING: completed 任务引用测试时必须明确 tdd_required=true/false');
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
}
|
|
5556
|
+
|
|
5557
|
+
return {
|
|
5558
|
+
taskId,
|
|
5559
|
+
status,
|
|
5560
|
+
runId,
|
|
5561
|
+
details: {
|
|
5562
|
+
...details,
|
|
5563
|
+
task_update: {
|
|
5564
|
+
...(details.task_update || {}),
|
|
5565
|
+
task_id: taskId,
|
|
5566
|
+
status,
|
|
5567
|
+
test_event_id: testEventId,
|
|
5568
|
+
tdd_required: typeof input?.tdd_required === 'boolean' ? input.tdd_required : undefined,
|
|
5569
|
+
test_requirement: input?.test_requirement
|
|
5570
|
+
|| (verificationNotApplicable ? 'not_applicable' : 'required'),
|
|
5571
|
+
test_waiver_reason: input?.test_waiver_reason
|
|
5572
|
+
|| (verificationNotApplicable ? verificationReason : undefined),
|
|
5573
|
+
verification_not_applicable: verificationNotApplicable,
|
|
5574
|
+
verification_not_applicable_reason: verificationNotApplicable ? verificationReason : undefined,
|
|
5575
|
+
},
|
|
5576
|
+
},
|
|
5577
|
+
};
|
|
5578
|
+
}
|
|
5579
|
+
|
|
5580
|
+
function eventIdempotencyFingerprint(event) {
|
|
5581
|
+
return JSON.stringify({
|
|
5582
|
+
type: event.type,
|
|
5583
|
+
source: event.source,
|
|
5584
|
+
command: event.command,
|
|
5585
|
+
stage: event.stage,
|
|
5586
|
+
change: event.change,
|
|
5587
|
+
capability: event.capability,
|
|
5588
|
+
task_id: event.task_id,
|
|
5589
|
+
run_id: event.run_id,
|
|
5590
|
+
result: event.result,
|
|
5591
|
+
status: event.status,
|
|
5592
|
+
summary: event.summary,
|
|
5593
|
+
details: event.details,
|
|
5594
|
+
});
|
|
5595
|
+
}
|
|
5596
|
+
|
|
5597
|
+
function buildApplyEvidenceAudit(change, taskModel, events = [], options = {}) {
|
|
5598
|
+
const strict = options.mode !== 'report-only';
|
|
5599
|
+
const completedTasks = (taskModel?.primary_tasks?.tasks || []).filter(task => task.completed);
|
|
5600
|
+
const latestTaskUpdates = new Map();
|
|
5601
|
+
const testEvents = new Map();
|
|
5602
|
+
const testsByTask = new Map();
|
|
5603
|
+
const uniqueTestRuns = new Set();
|
|
5604
|
+
for (const event of events) {
|
|
5605
|
+
if (event.change !== change) continue;
|
|
5606
|
+
if (event.type === 'task_update'
|
|
5607
|
+
&& (event.status === 'completed' || event.details?.task_update?.status === 'completed')) {
|
|
5608
|
+
const existing = latestTaskUpdates.get(event.task_id);
|
|
5609
|
+
if (!existing || timestampMs(event) > timestampMs(existing)) {
|
|
5610
|
+
latestTaskUpdates.set(event.task_id, event);
|
|
5611
|
+
}
|
|
5612
|
+
continue;
|
|
5613
|
+
}
|
|
5614
|
+
if (event.type === 'test_result') {
|
|
5615
|
+
testEvents.set(event.event_id, event);
|
|
5616
|
+
uniqueTestRuns.add(event.event_id);
|
|
5617
|
+
const testResults = event.details?.test_results || {};
|
|
5618
|
+
const verifiedTaskIds = [...new Set([
|
|
5619
|
+
event.task_id,
|
|
5620
|
+
testResults.task_id,
|
|
5621
|
+
...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
|
|
5622
|
+
].filter(Boolean))];
|
|
5623
|
+
for (const verifiedTaskId of verifiedTaskIds) {
|
|
5624
|
+
const related = testsByTask.get(verifiedTaskId) || [];
|
|
5625
|
+
related.push(event);
|
|
5626
|
+
testsByTask.set(verifiedTaskId, related);
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5630
|
+
for (const related of testsByTask.values()) {
|
|
5631
|
+
related.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
5632
|
+
}
|
|
5633
|
+
let withUpdate = 0;
|
|
5634
|
+
let withTestEvidence = 0;
|
|
5635
|
+
let tddRequired = 0;
|
|
5636
|
+
let redGreenComplete = 0;
|
|
5637
|
+
const findings = [];
|
|
5638
|
+
|
|
5639
|
+
for (const task of completedTasks) {
|
|
5640
|
+
const update = latestTaskUpdates.get(task.task_id);
|
|
5641
|
+
if (!update) {
|
|
5642
|
+
findings.push({
|
|
5643
|
+
code: 'TASK_UPDATE_MISSING',
|
|
5644
|
+
severity: strict ? 'error' : 'warning',
|
|
5645
|
+
task_id: task.task_id,
|
|
5646
|
+
message: '主任务已标完成,但缺少 completed task_update 事件',
|
|
5647
|
+
});
|
|
5648
|
+
continue;
|
|
5649
|
+
}
|
|
5650
|
+
withUpdate += 1;
|
|
5651
|
+
const updateDetails = update.details?.task_update || update.details || {};
|
|
5652
|
+
const notApplicable = updateDetails.verification_not_applicable === true
|
|
5653
|
+
&& Boolean(String(updateDetails.verification_not_applicable_reason || '').trim());
|
|
5654
|
+
const testEvent = updateDetails.test_event_id ? testEvents.get(updateDetails.test_event_id) : null;
|
|
5655
|
+
const testResults = testEvent?.details?.test_results || {};
|
|
5656
|
+
const verifiedTaskIds = new Set([
|
|
5657
|
+
testEvent?.task_id,
|
|
5658
|
+
testResults.task_id,
|
|
5659
|
+
...(Array.isArray(testResults.verified_task_ids) ? testResults.verified_task_ids : []),
|
|
5660
|
+
].filter(Boolean));
|
|
5661
|
+
const validTest = Boolean(testEvent)
|
|
5662
|
+
&& testResults.evidence_tier === 'strict'
|
|
5663
|
+
&& verifiedTaskIds.has(task.task_id)
|
|
5664
|
+
&& isValidCompletionTestResults(testResults);
|
|
5665
|
+
if (validTest || notApplicable) {
|
|
5666
|
+
withTestEvidence += 1;
|
|
5667
|
+
} else {
|
|
5668
|
+
findings.push({
|
|
5669
|
+
code: 'TASK_TEST_EVIDENCE_MISSING',
|
|
5670
|
+
severity: strict ? 'error' : 'warning',
|
|
5671
|
+
task_id: task.task_id,
|
|
5672
|
+
message: 'completed task_update 没有有效测试引用或不适用理由',
|
|
5673
|
+
});
|
|
5674
|
+
}
|
|
5675
|
+
|
|
5676
|
+
if (updateDetails.tdd_required === true) {
|
|
5677
|
+
tddRequired += 1;
|
|
5678
|
+
const related = testsByTask.get(task.task_id) || [];
|
|
5679
|
+
const redIndex = related.findIndex(event => event.details?.test_results?.valid_red === true);
|
|
5680
|
+
const greenIndex = related.findIndex((event, index) => (
|
|
5681
|
+
index > redIndex
|
|
5682
|
+
&& event.details?.test_results?.exit_code === 0
|
|
5683
|
+
&& ['green', 'regression'].includes(event.details?.test_results?.tdd_phase)
|
|
5684
|
+
));
|
|
5685
|
+
if (redIndex >= 0 && greenIndex > redIndex) {
|
|
5686
|
+
redGreenComplete += 1;
|
|
5687
|
+
} else {
|
|
5688
|
+
findings.push({
|
|
5689
|
+
code: 'TDD_RED_GREEN_INCOMPLETE',
|
|
5690
|
+
severity: strict ? 'error' : 'warning',
|
|
5691
|
+
task_id: task.task_id,
|
|
5692
|
+
message: 'TDD 任务缺少有效 RED→GREEN 顺序证据',
|
|
5693
|
+
});
|
|
5694
|
+
}
|
|
5695
|
+
}
|
|
5696
|
+
}
|
|
5697
|
+
|
|
5698
|
+
return {
|
|
5699
|
+
mode: strict ? 'strict' : 'report-only',
|
|
5700
|
+
status: findings.some(finding => finding.severity === 'error') ? 'blocked' : 'pass',
|
|
5701
|
+
tasks: {
|
|
5702
|
+
completed: completedTasks.length,
|
|
5703
|
+
with_update: withUpdate,
|
|
5704
|
+
with_test_evidence: withTestEvidence,
|
|
5705
|
+
},
|
|
5706
|
+
tdd: {
|
|
5707
|
+
required: tddRequired,
|
|
5708
|
+
red_green_complete: redGreenComplete,
|
|
5709
|
+
},
|
|
5710
|
+
tests: {
|
|
5711
|
+
unique_runs: uniqueTestRuns.size,
|
|
5712
|
+
},
|
|
5713
|
+
findings,
|
|
5714
|
+
};
|
|
5715
|
+
}
|
|
5716
|
+
|
|
5717
|
+
function readEvents(dataDir, changeName) {
|
|
5718
|
+
return readEventsRaw(dataDir, changeName)
|
|
5719
|
+
.filter(event => event.change === changeName);
|
|
5720
|
+
}
|
|
5721
|
+
|
|
5722
|
+
function scopeAttributionKey(sourceChange, targetEventId) {
|
|
5723
|
+
return `${sourceChange}:${targetEventId}`;
|
|
5724
|
+
}
|
|
5725
|
+
|
|
5726
|
+
function buildScopeAttributionState(events) {
|
|
5727
|
+
const active = new Map();
|
|
5728
|
+
const history = events
|
|
5729
|
+
.filter(event => event.type === 'scope_link' || event.type === 'scope_unlink')
|
|
5730
|
+
.sort((a, b) => timestampMs(a) - timestampMs(b));
|
|
5731
|
+
for (const event of history) {
|
|
5732
|
+
const details = event.details?.scope_attribution || {};
|
|
5733
|
+
const sourceChange = details.source_change;
|
|
5734
|
+
const targetEventId = details.target_event_id;
|
|
5735
|
+
if (!sourceChange || !targetEventId) continue;
|
|
5736
|
+
const key = scopeAttributionKey(sourceChange, targetEventId);
|
|
5737
|
+
if (event.type === 'scope_link') {
|
|
5738
|
+
active.set(key, {
|
|
5739
|
+
source_change: sourceChange,
|
|
5740
|
+
target_event_id: targetEventId,
|
|
5741
|
+
target_change: event.change,
|
|
5742
|
+
link_event_id: event.event_id,
|
|
5743
|
+
});
|
|
5744
|
+
} else {
|
|
5745
|
+
const current = active.get(key);
|
|
5746
|
+
if (current && current.target_change === event.change) active.delete(key);
|
|
5747
|
+
}
|
|
5748
|
+
}
|
|
5749
|
+
return { active, history };
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
function resolveChangeEventsWithAttribution(dataDir, change) {
|
|
5753
|
+
const allEvents = readAllEvents(dataDir);
|
|
5754
|
+
const rawDirect = readEventsRaw(dataDir, change);
|
|
5755
|
+
const direct = rawDirect.filter(event => event.change === change);
|
|
5756
|
+
const rejectedDirectCollisions = rawDirect
|
|
5757
|
+
.filter(event => event.change !== change)
|
|
5758
|
+
.map(event => event.event_id);
|
|
5759
|
+
const attribution = buildScopeAttributionState(allEvents);
|
|
5760
|
+
const links = [...attribution.active.values()]
|
|
5761
|
+
.filter(link => link.target_change === change);
|
|
5762
|
+
const linked = [];
|
|
5763
|
+
for (const link of links) {
|
|
5764
|
+
linked.push(...readEvents(dataDir, link.source_change)
|
|
5765
|
+
.filter(event => event.event_id === link.target_event_id)
|
|
5766
|
+
.map(event => ({
|
|
5767
|
+
...event,
|
|
5768
|
+
attribution: {
|
|
5769
|
+
provenance: 'explicit-link',
|
|
5770
|
+
source_change: link.source_change,
|
|
5771
|
+
target_change: link.target_change,
|
|
5772
|
+
link_event_id: link.link_event_id,
|
|
5773
|
+
},
|
|
5774
|
+
})));
|
|
5775
|
+
}
|
|
5776
|
+
const seen = new Set();
|
|
5777
|
+
const events = [...direct, ...linked].filter((event) => {
|
|
5778
|
+
const key = `${event.type}:${event.event_id}:${event.timestamp}`;
|
|
5779
|
+
if (seen.has(key)) return false;
|
|
5780
|
+
seen.add(key);
|
|
5781
|
+
return true;
|
|
5782
|
+
});
|
|
5783
|
+
const assignedSourceIds = new Set(
|
|
5784
|
+
[...attribution.active.values()].map(link => scopeAttributionKey(
|
|
5785
|
+
link.source_change,
|
|
5786
|
+
link.target_event_id,
|
|
5787
|
+
)),
|
|
5788
|
+
);
|
|
5789
|
+
const unassignedCandidates = readEvents(dataDir, 'general')
|
|
5790
|
+
.filter(event => !assignedSourceIds.has(scopeAttributionKey('general', event.event_id)))
|
|
5791
|
+
.map(event => event.event_id);
|
|
5792
|
+
return {
|
|
5793
|
+
events,
|
|
5794
|
+
audit: {
|
|
5795
|
+
linked_source_event_count: new Set(linked.map(event => event.event_id)).size,
|
|
5796
|
+
active_links: links,
|
|
5797
|
+
rejected_direct_collision_event_ids: [...new Set(rejectedDirectCollisions)],
|
|
5798
|
+
unassigned_candidate_event_ids: [...new Set(unassignedCandidates)],
|
|
5799
|
+
},
|
|
5800
|
+
};
|
|
5801
|
+
}
|
|
5802
|
+
|
|
3829
5803
|
function cmdRecord(args) {
|
|
3830
5804
|
const type = args.type || args['event-type'];
|
|
3831
5805
|
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
3832
|
-
const
|
|
3833
|
-
const
|
|
5806
|
+
const strict = isTrueOption(args.strict);
|
|
5807
|
+
const requestedChange = args.change || args['change-name'];
|
|
5808
|
+
if (strict && !requestedChange) {
|
|
5809
|
+
fail('EVENT_CHANGE_REQUIRED: strict 流程事件必须显式提供 --change');
|
|
5810
|
+
}
|
|
5811
|
+
if (requestedChange) {
|
|
5812
|
+
requireValidChangeName(requestedChange);
|
|
5813
|
+
}
|
|
5814
|
+
if (strict && requestedChange === 'general') {
|
|
5815
|
+
fail('E_GENERAL_SCOPE_FORBIDDEN: strict 流程事件不能写入 general');
|
|
5816
|
+
}
|
|
5817
|
+
const change = requestedChange || 'general';
|
|
5818
|
+
const requestedSource = args.source;
|
|
5819
|
+
const source = requestedSource || 'opsx-command';
|
|
3834
5820
|
const agentType = inferAgentType(args);
|
|
3835
5821
|
const allowedTypes = [
|
|
3836
5822
|
'task_update',
|
|
@@ -3847,6 +5833,9 @@ function cmdRecord(args) {
|
|
|
3847
5833
|
'worktree_finish',
|
|
3848
5834
|
'process_note',
|
|
3849
5835
|
'doc_update',
|
|
5836
|
+
'maturity_evidence',
|
|
5837
|
+
'scope_link',
|
|
5838
|
+
'scope_unlink',
|
|
3850
5839
|
];
|
|
3851
5840
|
|
|
3852
5841
|
if (!type) {
|
|
@@ -3880,19 +5869,6 @@ function cmdRecord(args) {
|
|
|
3880
5869
|
fail(`details JSON 解析失败: ${err.message}`);
|
|
3881
5870
|
}
|
|
3882
5871
|
|
|
3883
|
-
// task_update 成功时自动同步 tasks.md checkbox
|
|
3884
|
-
if (type === 'task_update' && args.result === 'success') {
|
|
3885
|
-
try {
|
|
3886
|
-
const taskId = args['task-id'] || args.task_id || details?.task_id || null;
|
|
3887
|
-
if (taskId) {
|
|
3888
|
-
runCheckTaskSync(projectRoot, change, taskId);
|
|
3889
|
-
}
|
|
3890
|
-
} catch (taskErr) {
|
|
3891
|
-
// checkbox 同步失败不阻塞原流程
|
|
3892
|
-
try { console.error(`[telemetry] tasks.md checkbox 自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
|
|
3893
|
-
}
|
|
3894
|
-
}
|
|
3895
|
-
|
|
3896
5872
|
// conformance_review evidence 校验——assertion 必须有可验证证据:
|
|
3897
5873
|
// files 含测试文件路径(test/ | .test. | .spec.)→ 强证据;
|
|
3898
5874
|
// 或 evidence 文本非空 → 降级证据(浏览器自动化/手动验收等无测试文件场景,与 SKILL.md schema 对齐:空 files[] 须配 evidence);
|
|
@@ -3929,7 +5905,95 @@ function cmdRecord(args) {
|
|
|
3929
5905
|
}
|
|
3930
5906
|
}
|
|
3931
5907
|
|
|
5908
|
+
if (type === 'maturity_evidence') {
|
|
5909
|
+
const maturityValidation = validateMaturityEvidenceDetails(details);
|
|
5910
|
+
if (!maturityValidation.valid) {
|
|
5911
|
+
fail(`maturity_evidence 校验失败: ${maturityValidation.error}`);
|
|
5912
|
+
}
|
|
5913
|
+
details = {
|
|
5914
|
+
...details,
|
|
5915
|
+
maturity_evidence: maturityValidation.evidence,
|
|
5916
|
+
};
|
|
5917
|
+
}
|
|
5918
|
+
|
|
3932
5919
|
const dataDir = getDataDir(projectRoot);
|
|
5920
|
+
let strictRunId = args['run-id'] || args.run_id || details?.run_id;
|
|
5921
|
+
let strictTaskId = args['task-id'] || args.task_id || details?.task_id;
|
|
5922
|
+
let eventSummary = args.summary;
|
|
5923
|
+
let eventStatus = args.status;
|
|
5924
|
+
if (strict) {
|
|
5925
|
+
const missingIdentity = [
|
|
5926
|
+
['session_id', args['session-id'] || args.session_id],
|
|
5927
|
+
['stage', args.stage || args.command],
|
|
5928
|
+
['source', requestedSource],
|
|
5929
|
+
['summary', args.summary],
|
|
5930
|
+
['run_id', strictRunId],
|
|
5931
|
+
].find(([, value]) => !String(value || '').trim());
|
|
5932
|
+
if (missingIdentity) {
|
|
5933
|
+
fail(`E_STRICT_FIELD_REQUIRED: strict 事件缺少 ${missingIdentity[0]}`);
|
|
5934
|
+
}
|
|
5935
|
+
}
|
|
5936
|
+
if (type === 'scope_link' || type === 'scope_unlink') {
|
|
5937
|
+
if (!strict) fail('E_SCOPE_ATTRIBUTION_STRICT_REQUIRED: scope 归属事件必须使用 --strict');
|
|
5938
|
+
const sourceChange = requireStrictString(
|
|
5939
|
+
args['source-change'] || args.source_change,
|
|
5940
|
+
'E_STRICT_FIELD_REQUIRED',
|
|
5941
|
+
'source_change',
|
|
5942
|
+
);
|
|
5943
|
+
requireValidChangeName(sourceChange);
|
|
5944
|
+
const targetEventId = requireStrictString(
|
|
5945
|
+
args['target-event-id'] || args.target_event_id,
|
|
5946
|
+
'E_STRICT_FIELD_REQUIRED',
|
|
5947
|
+
'target_event_id',
|
|
5948
|
+
);
|
|
5949
|
+
const attributionReason = requireStrictString(args.reason, 'E_STRICT_FIELD_REQUIRED', 'reason');
|
|
5950
|
+
const sourceEvents = readEvents(dataDir, sourceChange)
|
|
5951
|
+
.filter(event => event.event_id === targetEventId);
|
|
5952
|
+
if (sourceEvents.length === 0) {
|
|
5953
|
+
fail(`E_SCOPE_SOURCE_NOT_FOUND: ${sourceChange} 中不存在事件 "${targetEventId}"`);
|
|
5954
|
+
}
|
|
5955
|
+
const attribution = buildScopeAttributionState(readAllEvents(dataDir));
|
|
5956
|
+
const key = scopeAttributionKey(sourceChange, targetEventId);
|
|
5957
|
+
const current = attribution.active.get(key);
|
|
5958
|
+
if (type === 'scope_link' && current && current.target_change !== change) {
|
|
5959
|
+
fail(`E_SCOPE_LINK_CONFLICT: 事件已关联到 "${current.target_change}",须先追加 scope_unlink`);
|
|
5960
|
+
}
|
|
5961
|
+
if (type === 'scope_unlink' && (!current || current.target_change !== change)) {
|
|
5962
|
+
fail(`E_SCOPE_UNLINK_NOT_ACTIVE: 事件当前未关联到 "${change}"`);
|
|
5963
|
+
}
|
|
5964
|
+
details = {
|
|
5965
|
+
...details,
|
|
5966
|
+
scope_attribution: {
|
|
5967
|
+
source_change: sourceChange,
|
|
5968
|
+
target_event_id: targetEventId,
|
|
5969
|
+
target_change: change,
|
|
5970
|
+
reason: attributionReason,
|
|
5971
|
+
},
|
|
5972
|
+
};
|
|
5973
|
+
}
|
|
5974
|
+
if (strict && type === 'test_result') {
|
|
5975
|
+
const testResults = normalizeStrictTestResult(args, details);
|
|
5976
|
+
strictRunId = testResults.run_id;
|
|
5977
|
+
strictTaskId = testResults.task_id;
|
|
5978
|
+
details = {
|
|
5979
|
+
...details,
|
|
5980
|
+
test_results: testResults,
|
|
5981
|
+
};
|
|
5982
|
+
} else if (strict && type === 'process_note') {
|
|
5983
|
+
const processNote = normalizeStrictProcessNote(args, details);
|
|
5984
|
+
strictRunId = processNote.runId;
|
|
5985
|
+
eventSummary = processNote.summary;
|
|
5986
|
+
details = processNote.details;
|
|
5987
|
+
} else if (strict && type === 'task_update') {
|
|
5988
|
+
const taskUpdate = normalizeStrictTaskUpdate(args, details, dataDir, change);
|
|
5989
|
+
strictRunId = taskUpdate.runId;
|
|
5990
|
+
strictTaskId = taskUpdate.taskId;
|
|
5991
|
+
eventStatus = taskUpdate.status;
|
|
5992
|
+
details = taskUpdate.details;
|
|
5993
|
+
} else if (strict) {
|
|
5994
|
+
strictRunId = requireStrictString(strictRunId, 'EVENT_RUN_ID_REQUIRED', 'run_id');
|
|
5995
|
+
}
|
|
5996
|
+
|
|
3933
5997
|
const activeStage = findActiveStage(projectRoot, {
|
|
3934
5998
|
session_id: args['session-id'] || args.session_id,
|
|
3935
5999
|
change,
|
|
@@ -3973,25 +6037,103 @@ function cmdRecord(args) {
|
|
|
3973
6037
|
stage: args.stage || args.command,
|
|
3974
6038
|
change,
|
|
3975
6039
|
capability: args.capability || args['capability-name'],
|
|
3976
|
-
task_id:
|
|
6040
|
+
task_id: strictTaskId,
|
|
6041
|
+
run_id: strictRunId,
|
|
3977
6042
|
agent_type: agentType,
|
|
3978
6043
|
project_root: projectRoot,
|
|
3979
6044
|
session_id: activeStage?.session_id || args['session-id'] || args.session_id,
|
|
3980
6045
|
git_sha: args['git-sha'] || args.git_sha,
|
|
3981
6046
|
timestamp,
|
|
3982
6047
|
result: args.result,
|
|
3983
|
-
status:
|
|
3984
|
-
summary:
|
|
6048
|
+
status: eventStatus,
|
|
6049
|
+
summary: eventSummary,
|
|
3985
6050
|
details,
|
|
3986
6051
|
});
|
|
3987
6052
|
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
6053
|
+
const syncTaskProjection = () => {
|
|
6054
|
+
if (type !== 'task_update' || (args.result !== 'success' && eventStatus !== 'completed')) return;
|
|
6055
|
+
try {
|
|
6056
|
+
const taskId = strictTaskId || details?.task_id || null;
|
|
6057
|
+
if (taskId) runCheckTaskSync(projectRoot, change, taskId);
|
|
6058
|
+
} catch (taskErr) {
|
|
6059
|
+
try { console.error(`[telemetry] tasks.md 主任务状态自动同步失败(不阻塞): ${taskErr.message}`); } catch {}
|
|
6060
|
+
}
|
|
6061
|
+
};
|
|
6062
|
+
|
|
6063
|
+
const persistEvent = (lockHeld = false) => {
|
|
6064
|
+
if (strict && event.run_id) {
|
|
6065
|
+
const existing = readEvents(dataDir, change)
|
|
6066
|
+
.find(candidate => candidate.type === event.type && candidate.run_id === event.run_id);
|
|
6067
|
+
if (existing) {
|
|
6068
|
+
if (eventIdempotencyFingerprint(existing) !== eventIdempotencyFingerprint(event)) {
|
|
6069
|
+
const error = new Error(
|
|
6070
|
+
`EVENT_RUN_ID_CONFLICT: ${event.type} 的 run_id "${event.run_id}" 已存在且内容不同`,
|
|
6071
|
+
);
|
|
6072
|
+
error.code = 'EVENT_RUN_ID_CONFLICT';
|
|
6073
|
+
throw error;
|
|
6074
|
+
}
|
|
6075
|
+
syncTaskProjection();
|
|
6076
|
+
return {
|
|
6077
|
+
event_id: existing.event_id,
|
|
6078
|
+
type,
|
|
6079
|
+
recorded_at: existing.timestamp,
|
|
6080
|
+
idempotent: true,
|
|
6081
|
+
message: `SDD ${type} 事件已存在,幂等返回(change: ${existing.change})`,
|
|
6082
|
+
};
|
|
6083
|
+
}
|
|
6084
|
+
}
|
|
6085
|
+
|
|
6086
|
+
// scope_link/scope_unlink 的冲突判断必须与追加事实共用同一事件锁;
|
|
6087
|
+
// 否则两个不同 run_id 的并发 link 都可能在锁外看到“未关联”并同时写入。
|
|
6088
|
+
if (type === 'scope_link' || type === 'scope_unlink') {
|
|
6089
|
+
const scopeDetails = event.details?.scope_attribution || {};
|
|
6090
|
+
const key = scopeAttributionKey(scopeDetails.source_change, scopeDetails.target_event_id);
|
|
6091
|
+
const current = buildScopeAttributionState(readAllEvents(dataDir)).active.get(key);
|
|
6092
|
+
if (type === 'scope_link' && current && current.target_change !== change) {
|
|
6093
|
+
const error = new Error(
|
|
6094
|
+
`E_SCOPE_LINK_CONFLICT: 事件已关联到 "${current.target_change}",须先追加 scope_unlink`,
|
|
6095
|
+
);
|
|
6096
|
+
error.code = 'E_SCOPE_LINK_CONFLICT';
|
|
6097
|
+
throw error;
|
|
6098
|
+
}
|
|
6099
|
+
if (type === 'scope_unlink' && (!current || current.target_change !== change)) {
|
|
6100
|
+
const error = new Error(`E_SCOPE_UNLINK_NOT_ACTIVE: 事件当前未关联到 "${change}"`);
|
|
6101
|
+
error.code = 'E_SCOPE_UNLINK_NOT_ACTIVE';
|
|
6102
|
+
throw error;
|
|
6103
|
+
}
|
|
6104
|
+
}
|
|
6105
|
+
|
|
6106
|
+
appendEvent(dataDir, event.change, event, { lockHeld });
|
|
6107
|
+
// 事实先持久化,任务文档作为可重放投影;幂等重试会再次尝试补齐投影。
|
|
6108
|
+
syncTaskProjection();
|
|
6109
|
+
return {
|
|
6110
|
+
event_id: eventId,
|
|
6111
|
+
type,
|
|
6112
|
+
recorded_at: timestamp,
|
|
6113
|
+
idempotent: false,
|
|
6114
|
+
message: `SDD ${type} 事件已记录(change: ${event.change})`,
|
|
6115
|
+
};
|
|
6116
|
+
};
|
|
6117
|
+
|
|
6118
|
+
let output;
|
|
6119
|
+
try {
|
|
6120
|
+
if (type === 'scope_link' || type === 'scope_unlink') {
|
|
6121
|
+
const scopeDetails = event.details.scope_attribution;
|
|
6122
|
+
const scopeLockName = `scope-${crypto.createHash('sha256')
|
|
6123
|
+
.update(scopeAttributionKey(scopeDetails.source_change, scopeDetails.target_event_id))
|
|
6124
|
+
.digest('hex')
|
|
6125
|
+
.slice(0, 24)}`;
|
|
6126
|
+
output = withEventWriteLock(dataDir, scopeLockName, () => persistEvent(false));
|
|
6127
|
+
} else {
|
|
6128
|
+
output = strict && event.run_id
|
|
6129
|
+
? withEventWriteLock(dataDir, change, () => persistEvent(true))
|
|
6130
|
+
: persistEvent(false);
|
|
6131
|
+
}
|
|
6132
|
+
} catch (error) {
|
|
6133
|
+
fail(error.message || String(error));
|
|
6134
|
+
}
|
|
6135
|
+
console.log(JSON.stringify(output, null, 2));
|
|
6136
|
+
return output;
|
|
3995
6137
|
}
|
|
3996
6138
|
|
|
3997
6139
|
/** 解析 node --test 的 TAP 输出为 {passed, failed, skipped}(opsx-apply agent 跑测试后传 --test-stdout) */
|
|
@@ -4135,7 +6277,7 @@ function cmdSnapshot(args) {
|
|
|
4135
6277
|
return result;
|
|
4136
6278
|
}
|
|
4137
6279
|
|
|
4138
|
-
/** archive
|
|
6280
|
+
/** archive 时从统一 Report Model 生成终态摘要,并通过受控 marker 幂等替换。 */
|
|
4139
6281
|
function appendChangeSummary(projectRoot, changeName, events) {
|
|
4140
6282
|
try {
|
|
4141
6283
|
const changeDir = getChangeDir(projectRoot, changeName);
|
|
@@ -4146,34 +6288,49 @@ function appendChangeSummary(projectRoot, changeName, events) {
|
|
|
4146
6288
|
}
|
|
4147
6289
|
ensureDir(logsDir);
|
|
4148
6290
|
const file = path.join(logsDir, 'execution-log.md');
|
|
4149
|
-
|
|
4150
|
-
const
|
|
4151
|
-
const
|
|
4152
|
-
const
|
|
4153
|
-
|
|
4154
|
-
|
|
4155
|
-
|
|
4156
|
-
|
|
4157
|
-
|
|
4158
|
-
|
|
4159
|
-
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
4163
|
-
|
|
4164
|
-
|
|
6291
|
+
const report = buildReport(projectRoot, events, { level: 'change', change: changeName });
|
|
6292
|
+
const execution = computeChangeMetrics(changeName, events)?.change_summary || {};
|
|
6293
|
+
const tests = report.change_summary?.tests || {};
|
|
6294
|
+
const legacyTestEvent = tests.total == null
|
|
6295
|
+
? [...events].reverse().find((event) => {
|
|
6296
|
+
const result = event?.details?.test_results;
|
|
6297
|
+
return event?.type === 'test_result'
|
|
6298
|
+
&& Number.isFinite(Number(result?.passed))
|
|
6299
|
+
&& Number.isFinite(Number(result?.failed));
|
|
6300
|
+
})
|
|
6301
|
+
: null;
|
|
6302
|
+
const documentTasks = report.change_summary?.tasks?.document || {};
|
|
6303
|
+
const traceability = report.change_summary?.tasks?.traceability || {};
|
|
6304
|
+
const evidence = report.change_summary?.evidence || {};
|
|
6305
|
+
const legacyTests = legacyTestEvent?.details?.test_results;
|
|
6306
|
+
const testPassStr = tests.total != null
|
|
6307
|
+
? `${tests.passed}/${tests.total}`
|
|
6308
|
+
: (legacyTests
|
|
6309
|
+
? `${Number(legacyTests.passed)}/${Number(legacyTests.passed) + Number(legacyTests.failed) + Number(legacyTests.skipped || 0)}(历史事件兼容口径)`
|
|
6310
|
+
: '无权威测试数据');
|
|
6311
|
+
const markerStart = '<!-- change-summary:start -->';
|
|
6312
|
+
const markerEnd = '<!-- change-summary:end -->';
|
|
4165
6313
|
const summary = [
|
|
4166
6314
|
'',
|
|
6315
|
+
markerStart,
|
|
4167
6316
|
'## 变更总结',
|
|
4168
|
-
`- 阶段数:${
|
|
4169
|
-
`- 阶段重复执行次数:${
|
|
4170
|
-
`-
|
|
4171
|
-
`-
|
|
6317
|
+
`- 阶段数:${execution.stage_start_count ?? 0} start / ${execution.stage_end_count ?? 0} end`,
|
|
6318
|
+
`- 阶段重复执行次数:${execution.stage_repeat_count ?? 0}`,
|
|
6319
|
+
`- 最终测试(测试通过率):${testPassStr}`,
|
|
6320
|
+
`- 文档任务完成:${documentTasks.completed ?? '无数据'}/${documentTasks.total ?? '无数据'}`,
|
|
6321
|
+
`- 事件可追溯:${traceability.verified ?? '无数据'}/${traceability.total ?? '无数据'}`,
|
|
6322
|
+
`- 证据问题:${evidence.category_count ?? 0} 类,${evidence.occurrence_count ?? 0} 次规则命中`,
|
|
6323
|
+
`- 结论:${report.conclusion?.text || '证据不足'}`,
|
|
6324
|
+
markerEnd,
|
|
4172
6325
|
'',
|
|
4173
6326
|
].join('\n');
|
|
4174
|
-
fs.
|
|
6327
|
+
const current = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : renderExecutionLog(changeName, events);
|
|
6328
|
+
const escapedStart = markerStart.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6329
|
+
const escapedEnd = markerEnd.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
6330
|
+
const withoutOldSummary = current.replace(new RegExp(`\\n?${escapedStart}[\\s\\S]*?${escapedEnd}\\n?`, 'g'), '\n');
|
|
6331
|
+
fs.writeFileSync(file, `${withoutOldSummary.trimEnd()}\n${summary}\n`, 'utf8');
|
|
4175
6332
|
} catch (err) {
|
|
4176
|
-
try { console.error(`[telemetry]
|
|
6333
|
+
try { console.error(`[telemetry] 变更总结更新失败(不阻塞): ${err.message}`); } catch {}
|
|
4177
6334
|
}
|
|
4178
6335
|
}
|
|
4179
6336
|
|
|
@@ -4271,7 +6428,9 @@ function cmdMetrics(args) {
|
|
|
4271
6428
|
const pdfMvp = Boolean(args['pdf-mvp']);
|
|
4272
6429
|
|
|
4273
6430
|
const dataDir = getDataDir(projectRoot);
|
|
4274
|
-
let events = changeName
|
|
6431
|
+
let events = changeName
|
|
6432
|
+
? resolveChangeEventsWithAttribution(dataDir, changeName).events
|
|
6433
|
+
: readAllEvents(dataDir);
|
|
4275
6434
|
|
|
4276
6435
|
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
4277
6436
|
|
|
@@ -4303,25 +6462,105 @@ function cmdMetrics(args) {
|
|
|
4303
6462
|
/**
|
|
4304
6463
|
* log report: 生成只读度量报告
|
|
4305
6464
|
*/
|
|
6465
|
+
function buildPeriodMetricSnapshot(metrics) {
|
|
6466
|
+
return {
|
|
6467
|
+
q3: metrics?.quality?.q3_spec_quality_score ?? null,
|
|
6468
|
+
p4: metrics?.process?.p4_quality_gate_enforcement_rate ?? null,
|
|
6469
|
+
p_h: metrics?.telemetry_health?.p_h_telemetry_health_score ?? null,
|
|
6470
|
+
p_c: metrics?.process?.p_c_stage_closure_rate ?? null,
|
|
6471
|
+
q1: metrics?.quality?.q1_spec_conformance_score ?? null,
|
|
6472
|
+
};
|
|
6473
|
+
}
|
|
6474
|
+
|
|
4306
6475
|
function cmdReport(args) {
|
|
4307
6476
|
const projectRoot = normalizeProjectRoot(args.project || args['project-root'] || process.cwd());
|
|
4308
6477
|
const changeName = args.change || args['change-name'];
|
|
4309
6478
|
const capability = args.capability || args['capability-name'];
|
|
4310
6479
|
const dateFrom = args['date-from'];
|
|
4311
6480
|
const dateTo = args['date-to'];
|
|
6481
|
+
const period = args.period || null;
|
|
4312
6482
|
const format = args.format || 'html';
|
|
4313
6483
|
const outputPath = args.output || args['output-file'];
|
|
4314
6484
|
const level = args.level || (capability ? 'capability' : (changeName ? 'change' : 'project'));
|
|
4315
6485
|
|
|
4316
6486
|
const dataDir = getDataDir(projectRoot);
|
|
4317
|
-
|
|
4318
|
-
|
|
6487
|
+
if (level === 'change' && !changeName) {
|
|
6488
|
+
fail('E_CHANGE_REQUIRED: change 级报告必须显式提供 --change');
|
|
6489
|
+
}
|
|
6490
|
+
if (changeName) {
|
|
6491
|
+
requireValidChangeName(changeName);
|
|
6492
|
+
const hasChangeData = fs.existsSync(getChangeDir(projectRoot, changeName))
|
|
6493
|
+
|| Boolean(findArchivedChangeDir(projectRoot, changeName))
|
|
6494
|
+
|| readEvents(dataDir, changeName).length > 0;
|
|
6495
|
+
if (!hasChangeData) {
|
|
6496
|
+
fail(`E_CHANGE_NOT_FOUND: 未找到变更 "${changeName}" 的目录、归档或事件`);
|
|
6497
|
+
}
|
|
6498
|
+
}
|
|
6499
|
+
// 周期比较前保留完整事件集;日期过滤只作用于主报告窗口
|
|
6500
|
+
const attributionResolution = changeName
|
|
6501
|
+
? resolveChangeEventsWithAttribution(dataDir, changeName)
|
|
6502
|
+
: null;
|
|
6503
|
+
const allEvents = attributionResolution ? attributionResolution.events : readAllEvents(dataDir);
|
|
6504
|
+
let events = allEvents;
|
|
6505
|
+
let trackingPeriods = null;
|
|
6506
|
+
|
|
6507
|
+
if (period && period !== 'change') {
|
|
6508
|
+
if (period !== 'biweekly' && period !== 'monthly') {
|
|
6509
|
+
console.error(JSON.stringify({
|
|
6510
|
+
error: 'invalid-period',
|
|
6511
|
+
message: `不支持的 period=${period},仅支持 change|biweekly|monthly`,
|
|
6512
|
+
}, null, 2));
|
|
6513
|
+
process.exitCode = 1;
|
|
6514
|
+
return;
|
|
6515
|
+
}
|
|
6516
|
+
if (!dateFrom || !dateTo) {
|
|
6517
|
+
console.error(JSON.stringify({
|
|
6518
|
+
error: 'invalid-window',
|
|
6519
|
+
message: `${period} 报告需要同时提供 --date-from 与 --date-to`,
|
|
6520
|
+
}, null, 2));
|
|
6521
|
+
process.exitCode = 1;
|
|
6522
|
+
return;
|
|
6523
|
+
}
|
|
6524
|
+
let windows;
|
|
6525
|
+
try {
|
|
6526
|
+
windows = metricsV3.buildTrackingPeriods({ period, dateFrom, dateTo });
|
|
6527
|
+
} catch (err) {
|
|
6528
|
+
console.error(JSON.stringify({
|
|
6529
|
+
error: err.code || 'invalid-window',
|
|
6530
|
+
message: err.message || String(err),
|
|
6531
|
+
}, null, 2));
|
|
6532
|
+
process.exitCode = 1;
|
|
6533
|
+
return;
|
|
6534
|
+
}
|
|
6535
|
+
trackingPeriods = windows.map((window) => {
|
|
6536
|
+
const windowEvents = filterEventsByDate(allEvents, window.date_from, window.date_to);
|
|
6537
|
+
const windowMetrics = computePdfMvpMetrics(windowEvents, {
|
|
6538
|
+
level: 'project',
|
|
6539
|
+
projectRoot,
|
|
6540
|
+
});
|
|
6541
|
+
return {
|
|
6542
|
+
...window,
|
|
6543
|
+
metrics: buildPeriodMetricSnapshot(windowMetrics),
|
|
6544
|
+
};
|
|
6545
|
+
});
|
|
6546
|
+
events = filterEventsByDate(allEvents, dateFrom, dateTo);
|
|
6547
|
+
} else {
|
|
6548
|
+
events = filterEventsByDate(allEvents, dateFrom, dateTo);
|
|
6549
|
+
}
|
|
4319
6550
|
|
|
4320
6551
|
const report = buildReport(projectRoot, events, {
|
|
4321
6552
|
level,
|
|
4322
6553
|
change: changeName,
|
|
4323
6554
|
capability,
|
|
6555
|
+
period: period || null,
|
|
6556
|
+
dateFrom: dateFrom || null,
|
|
6557
|
+
dateTo: dateTo || null,
|
|
6558
|
+
trackingPeriods,
|
|
6559
|
+
attributionAudit: attributionResolution?.audit || null,
|
|
4324
6560
|
});
|
|
6561
|
+
if (trackingPeriods) {
|
|
6562
|
+
report.tracking_periods = trackingPeriods;
|
|
6563
|
+
}
|
|
4325
6564
|
const rendered = format === 'json'
|
|
4326
6565
|
? JSON.stringify(report, null, 2)
|
|
4327
6566
|
: format === 'html'
|
|
@@ -4355,7 +6594,9 @@ function cmdDoctor(args) {
|
|
|
4355
6594
|
const dateTo = args['date-to'];
|
|
4356
6595
|
|
|
4357
6596
|
const dataDir = getDataDir(projectRoot);
|
|
4358
|
-
let events = changeName
|
|
6597
|
+
let events = changeName
|
|
6598
|
+
? resolveChangeEventsWithAttribution(dataDir, changeName).events
|
|
6599
|
+
: readAllEvents(dataDir);
|
|
4359
6600
|
events = filterEventsByDate(events, dateFrom, dateTo);
|
|
4360
6601
|
|
|
4361
6602
|
const report = computeDoctorReport(events, { change: changeName, projectRoot });
|
|
@@ -4553,6 +6794,8 @@ function cmdArchiveDocs(args) {
|
|
|
4553
6794
|
archivePath: archiveDir,
|
|
4554
6795
|
archivedAt: result.created_at || result.archived_at,
|
|
4555
6796
|
copiedSpecs: result.copied_specs,
|
|
6797
|
+
finalizeAfterReports: true,
|
|
6798
|
+
runIdentity: stageEnd?.event_id || `archive-docs:${changeName}`,
|
|
4556
6799
|
});
|
|
4557
6800
|
result = {
|
|
4558
6801
|
...result,
|
|
@@ -4665,7 +6908,7 @@ SDD Telemetry CLI - 流程度量采集工具
|
|
|
4665
6908
|
node skywalk-sdd/log.cjs start --command=<cmd> --project=<path> [--change=<name>] [--agent=<type>]
|
|
4666
6909
|
node skywalk-sdd/log.cjs end --event-id=<id> --result=<success|failure|partial> --summary="..."
|
|
4667
6910
|
node skywalk-sdd/log.cjs metrics --project=<path> [--change=<name>] [--pdf-mvp] [--format=json|markdown]
|
|
4668
|
-
node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>](默认 html)
|
|
6911
|
+
node skywalk-sdd/log.cjs report --project=<path> [--change=<name>] [--format=html|markdown|json] [--output=<file>] [--period=change|biweekly|monthly] [--date-from=YYYY-MM-DD] [--date-to=YYYY-MM-DD](默认 html)
|
|
4669
6912
|
node skywalk-sdd/log.cjs tasks-status --project=<path> --change=<name> [--require-complete]
|
|
4670
6913
|
node skywalk-sdd/log.cjs check-task --project=<path> --change=<name> --task-id=<id>
|
|
4671
6914
|
node skywalk-sdd/log.cjs archive-docs --project=<path> --change=<name> [--reason=<text>] [--event-id=<id>] [--report-output=<file>]
|
|
@@ -4700,6 +6943,7 @@ SDD Telemetry CLI - 流程度量采集工具
|
|
|
4700
6943
|
node skywalk-sdd/log.cjs record --type=task_update --command=apply --project=/my/project --change=user-auth --task-id=TASK-01 --status=completed --keep-details
|
|
4701
6944
|
node skywalk-sdd/log.cjs record --type=conformance_review --command=check --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-conformance-review.json --keep-details
|
|
4702
6945
|
node skywalk-sdd/log.cjs record --type=ai_adoption_review --command=apply --project=/my/project --change=user-auth --status=final --details-file=skywalk-sdd/state/user-auth-ai-adoption.json --keep-details
|
|
6946
|
+
node skywalk-sdd/log.cjs record --type=maturity_evidence --command=archive --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-maturity-evidence.json
|
|
4703
6947
|
node skywalk-sdd/log.cjs record --type=survey_result --project=/my/project --change=user-auth --source=manual --details-file=skywalk-sdd/state/user-auth-survey.json --keep-details
|
|
4704
6948
|
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth
|
|
4705
6949
|
node skywalk-sdd/log.cjs metrics --project=/my/project --change=user-auth --pdf-mvp --format=markdown
|
|
@@ -4750,6 +6994,7 @@ module.exports = {
|
|
|
4750
6994
|
formatDuration,
|
|
4751
6995
|
formatReworkReasons,
|
|
4752
6996
|
buildReport,
|
|
6997
|
+
buildApplyEvidenceAudit,
|
|
4753
6998
|
computeGitDocumentMetrics,
|
|
4754
6999
|
getCheckResults,
|
|
4755
7000
|
getBuildResults,
|
|
@@ -4791,9 +7036,17 @@ module.exports = {
|
|
|
4791
7036
|
parseJsonOption,
|
|
4792
7037
|
writeActiveStage,
|
|
4793
7038
|
findActiveStage,
|
|
7039
|
+
resolveChangeEventsWithAttribution,
|
|
4794
7040
|
clearActiveStage,
|
|
4795
7041
|
findStartEvent,
|
|
4796
7042
|
runCheckTaskSync,
|
|
7043
|
+
resolveChangeDocumentRoot,
|
|
7044
|
+
collectSpecFiles,
|
|
7045
|
+
countSpecScenarios,
|
|
7046
|
+
readChangeType,
|
|
7047
|
+
buildV3ChangeContext,
|
|
7048
|
+
parseSimpleFrontmatter,
|
|
7049
|
+
buildPeriodMetricSnapshot,
|
|
4797
7050
|
};
|
|
4798
7051
|
|
|
4799
7052
|
// 直接运行时执行
|