kld-sdd 2.7.3 → 2.7.8-2
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 +12 -5
- package/USABILITY.md +104 -0
- package/bin/kld-sdd-init.js +1 -1
- package/kld-sdd-guide.html +16 -4
- package/lib/hook-gate-core.js +327 -0
- package/lib/init.js +228 -37
- package/lib/scale-thresholds.json +19 -0
- package/lib/skills-bundle.js +2 -1
- package/package.json +5 -3
- package/skywalk-sdd/context-client.cjs +50 -30
- package/skywalk-sdd/index.cjs +159 -40
- package/skywalk-sdd/kb-sync-identity.cjs +99 -451
- package/skywalk-sdd/kb-upload.cjs +67 -44
- package/skywalk-sdd/lib/check-review.cjs +41 -0
- package/skywalk-sdd/lib/test-execution.cjs +110 -0
- package/skywalk-sdd/lib/usage-contract.cjs +3 -2
- package/skywalk-sdd/lib/usage-reporter.cjs +27 -2
- package/skywalk-sdd/metrics-v3.cjs +2 -2
- package/skywalk-sdd/ontology/active-changes.cjs +2 -1
- package/skywalk-sdd/ontology/archive-package.cjs +1 -1
- package/skywalk-sdd/ontology/artifact-parser.cjs +7 -4
- package/skywalk-sdd/ontology/id.cjs +5 -4
- package/skywalk-sdd/ontology/identity-index.cjs +9 -4
- package/skywalk-sdd/ontology/list-changes.cjs +1 -1
- package/skywalk-sdd/ontology/runtime.cjs +7 -3
- package/skywalk-sdd/ontology/schema.cjs +2 -0
- package/skywalk-sdd/ontology/traceability-validator.cjs +74 -4
- package/skywalk-sdd/ontology/workspace-layout.cjs +25 -5
- package/skywalk-sdd/reporting/change-report-model.cjs +4 -3
- package/templates/git-hooks/commit-msg +39 -24
- package/templates/git-hooks/consistency-check-core.cjs +1097 -0
- package/templates/git-hooks/hooks.config +20 -1
- package/templates/git-hooks/pre-commit +39 -24
- package/templates/git-hooks/pre-commit-consistency-check.cjs +29 -332
- package/templates/git-hooks/pre-commit-sdd-check.cjs +98 -0
- package/templates/git-hooks/pre-push +39 -24
- package/templates/git-hooks/pre-push-consistency-check.cjs +58 -406
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +1 -1
- package/templates/openspec/tasks.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +44 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-apply/reference.md +20 -2
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +19 -21
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +55 -327
- package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -4
- package/templates/skills/kld-sdd/opsx-check/reference.md +60 -0
- package/templates/skills/kld-sdd/opsx-check/result-template.json +52 -0
- package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +100 -0
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +82 -451
- package/templates/skills/kld-sdd/opsx-consistency-check/{reference.md → references/reference.md} +0 -1
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +517 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +10 -30
- package/templates/skills/kld-sdd/opsx-design/checklist.md +3 -4
- package/templates/skills/kld-sdd/opsx-design/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +29 -154
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +12 -74
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +7 -5
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +29 -74
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +8 -8
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +35 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +12 -46
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +14 -61
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +3 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +40 -34
- package/templates/skills/kld-sdd/opsx-task/checklist.md +5 -6
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -0
- package/templates/skills/kld-sdd/tdd-rules/rules/tdd-strategy-selection.md +1 -1
|
@@ -33,19 +33,48 @@ const {
|
|
|
33
33
|
httpGetJson,
|
|
34
34
|
} = require('./lib/shared.cjs');
|
|
35
35
|
|
|
36
|
+
function resolveUploadTarget(state, options = {}) {
|
|
37
|
+
if (Boolean(options.spaceId) !== Boolean(options.kbId)) throw new Error('必须同时指定 --space-id 和 --kb-id');
|
|
38
|
+
const targets = state.targets || [];
|
|
39
|
+
if (options.spaceId && options.kbId) {
|
|
40
|
+
return targets.find(t => t.spaceId === options.spaceId && t.kbId === options.kbId) || options;
|
|
41
|
+
}
|
|
42
|
+
if (targets.length !== 1) throw new Error('请明确上传目标:配置一个知识库,或同时指定 --space-id 和 --kb-id;不会自动分发到多个 KB');
|
|
43
|
+
if (!targets[0].spaceId || !targets[0].kbId) throw new Error('知识库目标缺少 spaceId/kbId');
|
|
44
|
+
return targets[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function cacheKey(changeName, context) {
|
|
48
|
+
if (!context?.api || !context?.spaceId || !context?.kbId) return null;
|
|
49
|
+
return JSON.stringify([context.api.replace(/\/+$/, ''), context.spaceId, context.kbId, changeName]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertIngestionResult(result) {
|
|
53
|
+
if (result?.code != null && result.code !== 0) throw new Error(result.message || 'KB API 拒绝入库');
|
|
54
|
+
const job = result?.data || result;
|
|
55
|
+
if (!job || !['projecting', 'succeeded'].includes(job.status) || job.relationStatus !== 'committed') {
|
|
56
|
+
throw new Error(`INGEST_NOT_COMMITTED: ${job?.errorCode || job?.status || 'unknown'} ${job?.errorMessage || ''} job=${job?.jobId || 'unknown'}`);
|
|
57
|
+
}
|
|
58
|
+
if (Number(job.report?.version_conflicts || 0) > 0 || job.report?.publication_status === 'needs_review') {
|
|
59
|
+
throw new Error(`VERSION_CONFLICT: 事实包已保存为候选,当前事实未更新;请合并基线后重新发布。job=${job.jobId}`);
|
|
60
|
+
}
|
|
61
|
+
return job;
|
|
62
|
+
}
|
|
63
|
+
|
|
36
64
|
/**
|
|
37
65
|
* 读取 kb-state.json 中指定 change 的 archive 缓存。
|
|
38
66
|
* @param {string} specRoot
|
|
39
67
|
* @param {string} changeName
|
|
40
68
|
* @returns {object|null} { archive_id, content_hash, uploaded_at }
|
|
41
69
|
*/
|
|
42
|
-
function readArchiveCache(specRoot, changeName) {
|
|
70
|
+
function readArchiveCache(specRoot, changeName, context) {
|
|
43
71
|
const directPath = path.join(specRoot, 'kb-state.json');
|
|
44
72
|
const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
|
|
45
73
|
const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
|
|
46
74
|
const state = readJsonSafe(statePath);
|
|
47
75
|
if (!state || !state.archiveCache) return null;
|
|
48
|
-
|
|
76
|
+
const key = cacheKey(changeName, context);
|
|
77
|
+
return key ? state.archiveCache[key] || null : null;
|
|
49
78
|
}
|
|
50
79
|
|
|
51
80
|
/**
|
|
@@ -55,16 +84,19 @@ function readArchiveCache(specRoot, changeName) {
|
|
|
55
84
|
* @param {string} archiveId
|
|
56
85
|
* @param {string} contentHash
|
|
57
86
|
*/
|
|
58
|
-
function updateKbStateCache(specRoot, changeName, archiveId, contentHash) {
|
|
87
|
+
function updateKbStateCache(specRoot, changeName, archiveId, contentHash, context) {
|
|
59
88
|
const directPath = path.join(specRoot, 'kb-state.json');
|
|
60
89
|
const legacyPath = path.join(specRoot, '..', '.shared', 'kb-state.json');
|
|
61
90
|
const statePath = fs.existsSync(directPath) ? directPath : legacyPath;
|
|
62
91
|
const state = readJsonSafe(statePath) || {};
|
|
63
92
|
if (!state.archiveCache) state.archiveCache = {};
|
|
64
|
-
|
|
93
|
+
const key = cacheKey(changeName, context);
|
|
94
|
+
if (!key) throw new Error('缓存必须包含 API、Space 和 KB');
|
|
95
|
+
state.archiveCache[key] = {
|
|
65
96
|
archive_id: archiveId,
|
|
66
97
|
content_hash: contentHash,
|
|
67
98
|
uploaded_at: new Date().toISOString(),
|
|
99
|
+
target: context,
|
|
68
100
|
};
|
|
69
101
|
writeJsonAtomic(statePath, state);
|
|
70
102
|
console.log(`✓ 已更新本地缓存: ${changeName} → ${archiveId.slice(0, 24)}...`);
|
|
@@ -144,7 +176,11 @@ function readKbState(specRoot) {
|
|
|
144
176
|
*/
|
|
145
177
|
function validateRequiredFiles(folderPath, minimumLevel) {
|
|
146
178
|
const required = MINIMUM_LEVELS[minimumLevel] || MINIMUM_LEVELS[DEFAULT_LEVEL];
|
|
147
|
-
const
|
|
179
|
+
const specsDir = path.join(folderPath, 'specs');
|
|
180
|
+
const capabilityDirs = fs.existsSync(specsDir) ? fs.readdirSync(specsDir, { withFileTypes: true })
|
|
181
|
+
.filter(e => e.isDirectory()).map(e => path.join(specsDir, e.name)) : [];
|
|
182
|
+
const missing = required.filter(f => !fs.existsSync(path.join(folderPath, f))
|
|
183
|
+
&& !(f !== 'proposal.md' && capabilityDirs.length > 0 && capabilityDirs.every(d => fs.existsSync(path.join(d, f)))));
|
|
148
184
|
if (missing.length > 0) {
|
|
149
185
|
throw new Error(
|
|
150
186
|
`[${minimumLevel}] 缺少必需文件: ${missing.join(', ')}\n` +
|
|
@@ -162,11 +198,11 @@ function validateRequiredFiles(folderPath, minimumLevel) {
|
|
|
162
198
|
/**
|
|
163
199
|
* 本地预检 project_id === spaceKey
|
|
164
200
|
*/
|
|
165
|
-
function preCheckProjectId(projectId, state) {
|
|
201
|
+
function preCheckProjectId(projectId, state, selectedTarget) {
|
|
166
202
|
if (!state.targets || !state.targets[0]) {
|
|
167
203
|
throw new Error('KB 配置中无 targets,请运行 /opsx-kb-config 配置知识库。');
|
|
168
204
|
}
|
|
169
|
-
const spaceKey = state.
|
|
205
|
+
const spaceKey = (selectedTarget || resolveUploadTarget(state)).spaceKey;
|
|
170
206
|
if (!spaceKey) {
|
|
171
207
|
console.warn('⚠️ kb-state.json targets[0].spaceKey 为空,跳过本地预检');
|
|
172
208
|
return;
|
|
@@ -255,15 +291,9 @@ function buildPackageFromFolder(specRoot, changeName, options) {
|
|
|
255
291
|
* 上传 zip 到 KB(使用 Node.js HTTP,替代 curl)
|
|
256
292
|
*/
|
|
257
293
|
async function uploadZip(zipPath, state, options) {
|
|
258
|
-
const target =
|
|
259
|
-
? { spaceId: options.spaceId, kbId: options.kbId }
|
|
260
|
-
: state.targets[0];
|
|
294
|
+
const target = resolveUploadTarget(state, options);
|
|
261
295
|
|
|
262
|
-
|
|
263
|
-
throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
const apiUrl = state.api || 'http://localhost:8090/api';
|
|
296
|
+
const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
|
|
267
297
|
const uploadUrl = `${apiUrl}/v1/spaces/${target.spaceId}/knowledge-bases/${target.kbId}/ingestions`;
|
|
268
298
|
|
|
269
299
|
console.log(`→ 上传到 KB: ${target.spaceName || target.spaceId}/${target.kbName || target.kbId}`);
|
|
@@ -272,6 +302,7 @@ async function uploadZip(zipPath, state, options) {
|
|
|
272
302
|
|
|
273
303
|
try {
|
|
274
304
|
const result = await uploadFile(uploadUrl, zipPath, state.apiKey);
|
|
305
|
+
assertIngestionResult(result);
|
|
275
306
|
return result;
|
|
276
307
|
} catch (err) {
|
|
277
308
|
throw new Error(`上传失败: ${err.message}`);
|
|
@@ -339,6 +370,11 @@ async function main() {
|
|
|
339
370
|
changeName = path.basename(folderPath);
|
|
340
371
|
}
|
|
341
372
|
|
|
373
|
+
const expectedFolder = path.resolve(specRoot, 'openspec', 'changes', changeName);
|
|
374
|
+
if (path.resolve(folderPath) !== expectedFolder || !expectedFolder.startsWith(path.resolve(specRoot, 'openspec', 'changes') + path.sep)) {
|
|
375
|
+
throw new Error('文件夹上传仅接受当前 Spec 仓 openspec/changes/<change>;已归档内容请用 --zip 指定归档包');
|
|
376
|
+
}
|
|
377
|
+
|
|
342
378
|
if (!fs.existsSync(folderPath)) {
|
|
343
379
|
throw new Error(`变更目录不存在: ${folderPath}`);
|
|
344
380
|
}
|
|
@@ -362,7 +398,9 @@ async function main() {
|
|
|
362
398
|
if (!state.apiKey) {
|
|
363
399
|
throw new Error('KB 配置中无 apiKey,请运行 /opsx-kb-config 配置知识库。');
|
|
364
400
|
}
|
|
365
|
-
|
|
401
|
+
const target = resolveUploadTarget(state, { spaceId: args['space-id'], kbId: args['kb-id'] });
|
|
402
|
+
preCheckProjectId(projectId, state, target);
|
|
403
|
+
const cacheContext = { api: state.api || 'http://localhost:8090/api', spaceId: target.spaceId, kbId: target.kbId };
|
|
366
404
|
|
|
367
405
|
// 4. 仅校验模式(含语义校验)
|
|
368
406
|
if (args['validate-only']) {
|
|
@@ -390,26 +428,8 @@ async function main() {
|
|
|
390
428
|
});
|
|
391
429
|
|
|
392
430
|
try {
|
|
393
|
-
//
|
|
394
|
-
const
|
|
395
|
-
if (cache && cache.archive_id === manifest.archive_id) {
|
|
396
|
-
console.log('\n✓ 内容无变更(archive_id 与上次成功上传一致),跳过上传');
|
|
397
|
-
console.log(` cached archive_id: ${cache.archive_id}`);
|
|
398
|
-
console.log(` uploaded_at: ${cache.uploaded_at}`);
|
|
399
|
-
console.log('\n✓ 完成(本地缓存命中)');
|
|
400
|
-
return;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
// 解析上传目标
|
|
404
|
-
const target = (args['space-id'] && args['kb-id'])
|
|
405
|
-
? { spaceId: args['space-id'], kbId: args['kb-id'] }
|
|
406
|
-
: state.targets[0];
|
|
407
|
-
|
|
408
|
-
if (!target || !target.spaceId || !target.kbId) {
|
|
409
|
-
throw new Error('未指定目标 Space/KB,请运行 /opsx-kb-config 配置知识库。');
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
const apiUrl = state.api || 'http://localhost:8090/api';
|
|
431
|
+
// Local receipts are informational only. Always verify the selected destination remotely.
|
|
432
|
+
const apiUrl = (state.api || 'http://localhost:8090/api').replace(/\/+$/, '');
|
|
413
433
|
|
|
414
434
|
// === Step 5.5: KB 预检(网络级幂等确认)===
|
|
415
435
|
const archiveId = manifest.archive_id;
|
|
@@ -424,11 +444,13 @@ async function main() {
|
|
|
424
444
|
try {
|
|
425
445
|
const checkResult = await httpGetJson(checkUrl, state.apiKey);
|
|
426
446
|
|
|
427
|
-
if (checkResult && checkResult.data
|
|
447
|
+
if (checkResult?.data?.idempotent && checkResult.data.publicationStatus === 'published') {
|
|
428
448
|
console.log('\n✓ KB 中已存在相同 archive(幂等命中),跳过上传');
|
|
429
449
|
console.log(` snapshot_id: ${checkResult.data.snapshotId}`);
|
|
430
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash);
|
|
431
|
-
console.log(
|
|
450
|
+
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
451
|
+
console.log(` publication: ${checkResult.data.publicationStatus}; projection: ${checkResult.data.projectionStatus}`);
|
|
452
|
+
if (['failed', 'degraded'].includes(checkResult.data.projectionStatus)) console.log('⚠️ 检索准备尚未完成,请在知识库概览重试该任务的投影');
|
|
453
|
+
console.log('\n✓ 归档已存在(检索状态以上述回执为准)');
|
|
432
454
|
return;
|
|
433
455
|
}
|
|
434
456
|
|
|
@@ -467,8 +489,8 @@ async function main() {
|
|
|
467
489
|
console.log(` change_id: ${manifest.change_id}`);
|
|
468
490
|
console.log(` files: ${manifest.files.length}`);
|
|
469
491
|
|
|
470
|
-
//
|
|
471
|
-
updateKbStateCache(specRoot, changeName, archiveId, contentHash);
|
|
492
|
+
// Canonical commit has been verified; a receipt does not imply projection completion.
|
|
493
|
+
updateKbStateCache(specRoot, changeName, archiveId, contentHash, cacheContext);
|
|
472
494
|
|
|
473
495
|
// === 投影状态提示 ===
|
|
474
496
|
// 上传成功后,KB 后端异步执行 vector embedding + AGE graph 投影。
|
|
@@ -479,9 +501,9 @@ async function main() {
|
|
|
479
501
|
if (projStatus === 'complete') {
|
|
480
502
|
console.log('\n✓ 投影已完成,向量/图检索可用');
|
|
481
503
|
} else if (projStatus === 'degraded') {
|
|
482
|
-
console.log('\n⚠️
|
|
504
|
+
console.log('\n⚠️ 投影完成但有降级(部分通道不可用,召回结果会标记降级)');
|
|
483
505
|
} else if (projStatus === 'disabled') {
|
|
484
|
-
console.log('\nℹ️
|
|
506
|
+
console.log('\nℹ️ 投影已禁用(仅可使用当前已就绪的检索通道)');
|
|
485
507
|
} else if (projStatus && projStatus !== 'complete') {
|
|
486
508
|
console.log(`\nℹ️ 投影进行中 (${projStatus}),向量/图检索暂不可用`);
|
|
487
509
|
console.log(' → 正常 SDD 流程中阶段间间隔通常足够投影完成');
|
|
@@ -502,4 +524,5 @@ async function main() {
|
|
|
502
524
|
}
|
|
503
525
|
}
|
|
504
526
|
|
|
505
|
-
main();
|
|
527
|
+
if (require.main === module) main();
|
|
528
|
+
module.exports = { resolveUploadTarget, readArchiveCache, updateKbStateCache, assertIngestionResult, uploadZip, buildPackageFromFolder, validateRequiredFiles };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const CATEGORIES = ['completeness', 'consistency', 'executability', 'tdd_compliance'];
|
|
4
|
+
|
|
5
|
+
function assembleReview(review, taskCompletion, options = {}) {
|
|
6
|
+
if (!review || !Array.isArray(review.items) || !review.items.length) throw new Error('CHECK_ITEMS_REQUIRED: 提供实际评审项,不能提交空模板');
|
|
7
|
+
const categories = Object.fromEntries(CATEGORIES.map(key => [key, { passed: 0, total: 0 }]));
|
|
8
|
+
const warnings = [], suggestions = [], ids = new Set();
|
|
9
|
+
for (const item of review.items) {
|
|
10
|
+
if (!item.id || ids.has(item.id)) throw new Error('CHECK_ITEM_ID_INVALID: 检查项必须有唯一 ID');
|
|
11
|
+
ids.add(item.id);
|
|
12
|
+
if (!CATEGORIES.includes(item.category) || !['passed', 'error', 'warning', 'suggestion', 'na'].includes(item.status)) throw new Error('CHECK_ITEM_STATUS_INVALID');
|
|
13
|
+
if (typeof item.evidence !== 'string' || !item.evidence.trim()) throw new Error(`CHECK_ITEM_EVIDENCE_REQUIRED: ${item.id} 缺少来源证据或不适用原因`);
|
|
14
|
+
if (item.status === 'na') continue;
|
|
15
|
+
categories[item.category].total++;
|
|
16
|
+
if (item.status !== 'error') categories[item.category].passed++;
|
|
17
|
+
if (item.status === 'warning') warnings.push({ category: item.category, warning: item.id, description: item.description || item.evidence, target: item.evidence });
|
|
18
|
+
if (item.status === 'suggestion') suggestions.push(item);
|
|
19
|
+
}
|
|
20
|
+
const total = Object.values(categories).reduce((n, c) => n + c.total, 0);
|
|
21
|
+
const passed = Object.values(categories).reduce((n, c) => n + c.passed, 0);
|
|
22
|
+
if (!total) throw new Error('CHECK_ITEMS_REQUIRED: 至少有一个实际适用的评审项');
|
|
23
|
+
if (taskCompletion.has_incomplete) warnings.push({ category: 'task_completion', warning: 'TASKS_INCOMPLETE', description: `${taskCompletion.incomplete} 个任务尚未完成`, target: 'tasks.md' });
|
|
24
|
+
const result = total !== passed ? 'failure' : (warnings.length ? 'partial' : 'success');
|
|
25
|
+
return {
|
|
26
|
+
result,
|
|
27
|
+
details: { check_results: {
|
|
28
|
+
...review.reviewer,
|
|
29
|
+
total, errors: total - passed, warnings: warnings.length, suggestions: suggestions.length,
|
|
30
|
+
categories, warning_items: warnings, warning_dispositions: review.warning_dispositions || [],
|
|
31
|
+
// This field means items satisfied before implementation, not defect fixes.
|
|
32
|
+
fixed_before_apply: options.beforeApply ? passed : Math.min(total, options.previousBeforeApply || 0),
|
|
33
|
+
consistency_score: null,
|
|
34
|
+
task_completion: { ...taskCompletion, checked_for_archive_readiness: !options.beforeApply },
|
|
35
|
+
review_items: review.items,
|
|
36
|
+
review_revision: options.revision,
|
|
37
|
+
} },
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = { assembleReview };
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const crypto = require('node:crypto');
|
|
6
|
+
const { spawnSync } = require('node:child_process');
|
|
7
|
+
|
|
8
|
+
function parseCounts(output) {
|
|
9
|
+
// Prefer runner totals: counting TAP parent suites as tests double-counts nested tests.
|
|
10
|
+
const value = name => {
|
|
11
|
+
const matches = [...output.matchAll(new RegExp(`^(?:#|ℹ)\\s+${name}\\s+(\\d+)\\s*$`, 'gm'))];
|
|
12
|
+
return matches.length ? Number(matches[matches.length - 1][1]) : null;
|
|
13
|
+
};
|
|
14
|
+
const total = value('tests'), passed = value('pass'), failed = value('fail');
|
|
15
|
+
const skipped = value('skipped'), todo = value('todo');
|
|
16
|
+
if ([total, passed, failed].every(Number.isInteger)
|
|
17
|
+
&& total === passed + failed + (skipped || 0) + (todo || 0)) {
|
|
18
|
+
return { counts_known: true, passed, failed, skipped: (skipped || 0) + (todo || 0) };
|
|
19
|
+
}
|
|
20
|
+
return { counts_known: false };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function classifyFailure(output, exitCode, error, signal) {
|
|
24
|
+
if (error?.code === 'ETIMEDOUT') return 'timeout';
|
|
25
|
+
if (error || signal) return 'infrastructure';
|
|
26
|
+
if (exitCode === 0) return 'none';
|
|
27
|
+
// A missing executable/module/test path is not evidence of a failing business assertion.
|
|
28
|
+
if (/MODULE_NOT_FOUND|ERR_MODULE_NOT_FOUND|Cannot find module|Could not find|ENOENT|SyntaxError|ERR_TEST_FAILURE.*cancelled/i.test(output)) return 'infrastructure';
|
|
29
|
+
if (/ERR_ASSERTION|AssertionError|AssertionFailedError|org\.opentest4j\.AssertionFailedError/.test(output)) return 'assertion';
|
|
30
|
+
return 'infrastructure';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function captureTest(args, projectRoot, change) {
|
|
34
|
+
let command;
|
|
35
|
+
try { command = JSON.parse(args['command-json'] || 'null'); } catch { /* validated below */ }
|
|
36
|
+
if (!Array.isArray(command) || !command.length || command.some(x => typeof x !== 'string' || x.includes('\0')) || !command[0]) {
|
|
37
|
+
throw new Error('TEST_COMMAND_REQUIRED: --command-json 必须是非空命令参数数组,不通过 shell 执行');
|
|
38
|
+
}
|
|
39
|
+
const phase = args['tdd-phase'] || 'regression';
|
|
40
|
+
if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(phase)) throw new Error('TEST_PHASE_INVALID');
|
|
41
|
+
const taskIds = [...new Set(String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim()).filter(Boolean))];
|
|
42
|
+
if (!taskIds.length) throw new Error('TEST_TASKS_REQUIRED: 明确指定 --task-ids,工具不猜测测试覆盖的任务');
|
|
43
|
+
const timeout = Number(args['timeout-ms'] || 120000);
|
|
44
|
+
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) throw new Error('TEST_TIMEOUT_INVALID');
|
|
45
|
+
const cwd = path.resolve(args.cwd || projectRoot);
|
|
46
|
+
if (!fs.statSync(cwd).isDirectory()) throw new Error('TEST_CWD_INVALID');
|
|
47
|
+
const runId = args['run-id'] || crypto.randomUUID();
|
|
48
|
+
const key = crypto.createHash('sha256').update(`${change}:${runId}`).digest('hex');
|
|
49
|
+
const dir = path.join(projectRoot, 'skywalk-sdd', 'state', 'test-runs', key);
|
|
50
|
+
fs.mkdirSync(path.dirname(dir), { recursive: true });
|
|
51
|
+
// A run ID identifies one execution. Never re-execute a command under an old receipt.
|
|
52
|
+
try { fs.mkdirSync(dir); } catch (error) {
|
|
53
|
+
if (error.code === 'EEXIST') throw new Error('TEST_RUN_EXISTS: 此 run-id 已执行;使用保存的 details.json 重试记录,重新执行须使用新 run-id');
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
const startedAt = new Date().toISOString();
|
|
57
|
+
const started = process.hrtime.bigint();
|
|
58
|
+
const childEnv = { ...process.env };
|
|
59
|
+
// The wrapper can itself run inside node:test; its child is a fresh test runner.
|
|
60
|
+
delete childEnv.NODE_TEST_CONTEXT;
|
|
61
|
+
const child = spawnSync(command[0], command.slice(1), {
|
|
62
|
+
cwd, env: childEnv, shell: false, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
|
|
63
|
+
});
|
|
64
|
+
const duration = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
|
|
65
|
+
const stdout = child.stdout || '', stderr = child.stderr || '';
|
|
66
|
+
const output = stdout + '\n' + stderr;
|
|
67
|
+
const exitCode = Number.isInteger(child.status) ? child.status : (child.error?.code === 'ETIMEDOUT' ? 124 : 1);
|
|
68
|
+
const failureType = classifyFailure(output, exitCode, child.error, child.signal);
|
|
69
|
+
const counts = parseCounts(output);
|
|
70
|
+
const files = {};
|
|
71
|
+
for (const [name, content] of Object.entries({ 'stdout.log': stdout, 'stderr.log': stderr })) {
|
|
72
|
+
fs.writeFileSync(path.join(dir, name), content, { mode: 0o600 });
|
|
73
|
+
files[name] = crypto.createHash('sha256').update(content).digest('hex');
|
|
74
|
+
}
|
|
75
|
+
const execution = {
|
|
76
|
+
origin: 'command-execution', argv: command, cwd, started_at: startedAt,
|
|
77
|
+
finished_at: new Date().toISOString(), signal: child.signal || null,
|
|
78
|
+
error: child.error ? { code: child.error.code, message: child.error.message } : null,
|
|
79
|
+
output_files: Object.entries(files).map(([name, sha256]) => ({ path: path.join(dir, name), sha256 })),
|
|
80
|
+
task_association: 'explicit-caller-declaration',
|
|
81
|
+
receipt_path: path.join(dir, 'execution.json'),
|
|
82
|
+
};
|
|
83
|
+
const testResults = {
|
|
84
|
+
run_id: runId, verified_task_ids: taskIds, tdd_phase: phase,
|
|
85
|
+
command: JSON.stringify(command), result: exitCode === 0 ? 'success' : 'failure',
|
|
86
|
+
exit_code: exitCode, duration_ms: duration, ...counts, failure_type: failureType,
|
|
87
|
+
expected_failure: phase === 'red' && args['expected-failure'] === true,
|
|
88
|
+
execution,
|
|
89
|
+
};
|
|
90
|
+
const receipt = path.join(dir, 'details.json');
|
|
91
|
+
fs.writeFileSync(execution.receipt_path, JSON.stringify(testResults, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
|
92
|
+
fs.writeFileSync(receipt, JSON.stringify({ test_results: testResults }, null, 2) + '\n', { mode: 0o600 });
|
|
93
|
+
return { testResults, runId, receipt };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function verifyCapture(input, projectRoot) {
|
|
97
|
+
const base = fs.realpathSync(path.join(projectRoot, 'skywalk-sdd/state/test-runs')) + path.sep;
|
|
98
|
+
const receiptPath = fs.realpathSync(input.execution.receipt_path);
|
|
99
|
+
if (input.execution.origin !== 'command-execution' || !receiptPath.startsWith(base)) throw new Error('TEST_CAPTURE_INVALID: 执行回执不在当前项目');
|
|
100
|
+
const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
|
|
101
|
+
for (const key of Object.keys(receipt)) {
|
|
102
|
+
if (JSON.stringify(receipt[key]) !== JSON.stringify(input[key])) throw new Error(`TEST_CAPTURE_MISMATCH: ${key} 与真实执行回执不一致`);
|
|
103
|
+
}
|
|
104
|
+
for (const output of receipt.execution.output_files) {
|
|
105
|
+
const file = fs.realpathSync(output.path);
|
|
106
|
+
if (!file.startsWith(base) || crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== output.sha256) throw new Error('TEST_CAPTURE_HASH_MISMATCH: 原始测试输出已变化');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
module.exports = { captureTest, parseCounts, classifyFailure, verifyCapture };
|
|
@@ -112,7 +112,7 @@ function classifyUsageSubmitResponse(statusCode) {
|
|
|
112
112
|
* data: 若为 ApiResponse 信封且 code=0,则为解包后的 data;否则为 undefined
|
|
113
113
|
* error: 仅在 statusCode=null 时携带 Error;否则为 null
|
|
114
114
|
*/
|
|
115
|
-
function postJson(url, payload, options = {}) {
|
|
115
|
+
function postJson(url, payload = {}, options = {}) {
|
|
116
116
|
return new Promise((resolve, reject) => {
|
|
117
117
|
let target;
|
|
118
118
|
try {
|
|
@@ -132,7 +132,8 @@ function postJson(url, payload, options = {}) {
|
|
|
132
132
|
|
|
133
133
|
let body;
|
|
134
134
|
try {
|
|
135
|
-
|
|
135
|
+
// payload 缺省为 {}(默认参数),null 仍按 "null" 序列化,与原三元行为一致
|
|
136
|
+
body = JSON.stringify(payload);
|
|
136
137
|
} catch (err) {
|
|
137
138
|
reject(new Error(`payload serialization error: ${err.message}`));
|
|
138
139
|
return;
|
|
@@ -31,6 +31,27 @@ const SHARED_DEADLINE_MS = 3000;
|
|
|
31
31
|
// 单行事件 JSON 的安全上限(小于 4KB 以保证 appendFileSync 在 POSIX/Windows 都原子)
|
|
32
32
|
const SINGLE_EVENT_MAX_BYTES = 4096;
|
|
33
33
|
|
|
34
|
+
// drop 警告每进程最多一次的模块级标记
|
|
35
|
+
let dropWarned = false;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* drop(含 401)时输出一次 stderr 警告;每进程最多一次,简短带 [kld-sdd] 前缀。
|
|
39
|
+
*/
|
|
40
|
+
function warnUsageDropOnce() {
|
|
41
|
+
if (dropWarned) return;
|
|
42
|
+
dropWarned = true;
|
|
43
|
+
try {
|
|
44
|
+
process.stderr.write('[kld-sdd] usage 事件上报被拒绝,本次事件已丢弃\n');
|
|
45
|
+
} catch { /* stderr 不可用时静默 */ }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* 测试专用:重置 drop 警告的每进程一次标记。
|
|
50
|
+
*/
|
|
51
|
+
function _resetDropWarned() {
|
|
52
|
+
dropWarned = false;
|
|
53
|
+
}
|
|
54
|
+
|
|
34
55
|
function pendingPathFor(homeDir) {
|
|
35
56
|
const envHome = typeof process !== 'undefined' && process && process.env
|
|
36
57
|
? process.env.KLD_SDD_HOME
|
|
@@ -84,13 +105,14 @@ function resolveReportServer(options = {}) {
|
|
|
84
105
|
const normalized = normalizeServerUrl(config.server);
|
|
85
106
|
if (normalized) return normalized;
|
|
86
107
|
}
|
|
87
|
-
// 2. 项目级 kb-state.json 的 api
|
|
108
|
+
// 2. 项目级 kb-state.json 的 api(kb-state.api 是唯一带 /api 契约的来源,先剥离尾部 /api 再标准化)
|
|
88
109
|
try {
|
|
89
110
|
const kbStatePath = path.join(projectRoot, 'kb-state.json');
|
|
90
111
|
if (fs.existsSync(kbStatePath)) {
|
|
91
112
|
const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
|
|
92
113
|
if (kbState && typeof kbState.api === 'string') {
|
|
93
|
-
const
|
|
114
|
+
const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
|
|
115
|
+
const normalized = normalizeServerUrl(stripped);
|
|
94
116
|
if (normalized) return normalized;
|
|
95
117
|
}
|
|
96
118
|
}
|
|
@@ -300,6 +322,7 @@ async function flushPendingQueue(server, identity, pendingFile, deadlineStart) {
|
|
|
300
322
|
}
|
|
301
323
|
if (classification === 'drop') {
|
|
302
324
|
// drop 丢弃该条(含 401),不 retained、不 break,继续下一条
|
|
325
|
+
warnUsageDropOnce();
|
|
303
326
|
if (ev.id) droppedIds.add(ev.id);
|
|
304
327
|
continue;
|
|
305
328
|
}
|
|
@@ -421,6 +444,7 @@ async function reportUsageOnce(options = {}) {
|
|
|
421
444
|
currentOutcome = 'confirmed';
|
|
422
445
|
} else if (classification === 'drop') {
|
|
423
446
|
// 当前事件 drop(含 401)直接丢弃不入队,不清任何本地状态
|
|
447
|
+
warnUsageDropOnce();
|
|
424
448
|
currentOutcome = 'dropped';
|
|
425
449
|
} else {
|
|
426
450
|
currentOutcome = 'enqueued';
|
|
@@ -453,6 +477,7 @@ module.exports = {
|
|
|
453
477
|
readGitIdentity,
|
|
454
478
|
resolveReportServer,
|
|
455
479
|
_resetGitIdentityCache,
|
|
480
|
+
_resetDropWarned,
|
|
456
481
|
PENDING_FILE_NAME,
|
|
457
482
|
PENDING_MAX_BYTES,
|
|
458
483
|
FLUSH_MAX_COUNT,
|
|
@@ -1036,7 +1036,7 @@ function buildSchemeCompliance(input = {}) {
|
|
|
1036
1036
|
const semanticParity = input.semantic_parity_status || input.semantic_parity || null;
|
|
1037
1037
|
const multiFormatStatus = semanticParity === 'pass'
|
|
1038
1038
|
? 'implemented'
|
|
1039
|
-
:
|
|
1039
|
+
: 'provisional';
|
|
1040
1040
|
const alertLoopReady = Boolean(
|
|
1041
1041
|
input.alert_loop
|
|
1042
1042
|
&& input.alert_loop.root_cause
|
|
@@ -1050,7 +1050,7 @@ function buildSchemeCompliance(input = {}) {
|
|
|
1050
1050
|
|| null;
|
|
1051
1051
|
const reproducibleStatus = eventContractHealth === 'healthy' && Number(input.event_count) > 0
|
|
1052
1052
|
? 'implemented'
|
|
1053
|
-
:
|
|
1053
|
+
: 'provisional';
|
|
1054
1054
|
const statusLabel = status => ({
|
|
1055
1055
|
implemented: '已实现',
|
|
1056
1056
|
provisional: '部分可用',
|
|
@@ -571,7 +571,7 @@ function materializeArchivePackage(options = {}) {
|
|
|
571
571
|
const projectIdentity = ensureProjectIdentity(projectRoot);
|
|
572
572
|
const changeId = requiredText(snapshot.change_id, 'change_id', ONTOLOGY_PATH);
|
|
573
573
|
const archiveId = `urn:kld:sdd:archive:${sha256(
|
|
574
|
-
`${projectIdentity.project_id}\n${changeId}\n${snapshot.facts_hash}`,
|
|
574
|
+
`${projectIdentity.project_id}\n${changeId}\n${snapshot.facts_hash}\n${snapshot.source_revision || snapshot.facts_hash}`,
|
|
575
575
|
)}`;
|
|
576
576
|
const { canonicalFacts, warnings } = buildCanonicalFacts(
|
|
577
577
|
archiveDir,
|
|
@@ -246,7 +246,8 @@ function parseStructuredFrontmatter(lines) {
|
|
|
246
246
|
const nested = line.match(/^\s{2,}([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
|
247
247
|
|
|
248
248
|
if (topKey && !/^\s/.test(line)) {
|
|
249
|
-
|
|
249
|
+
flushRequirementRef(requirementRefs, currentRef);
|
|
250
|
+
currentRef = null;
|
|
250
251
|
section = topKey[1].toLowerCase();
|
|
251
252
|
const value = unwrapScalar(stripInlineYamlComment(topKey[2]));
|
|
252
253
|
flat[section] = value;
|
|
@@ -263,7 +264,7 @@ function parseStructuredFrontmatter(lines) {
|
|
|
263
264
|
}
|
|
264
265
|
|
|
265
266
|
if (section === 'requirement-refs' && listItem) {
|
|
266
|
-
|
|
267
|
+
flushRequirementRef(requirementRefs, currentRef);
|
|
267
268
|
currentRef = { [listItem[1].toLowerCase()]: unwrapScalar(stripInlineYamlComment(listItem[2])) };
|
|
268
269
|
continue;
|
|
269
270
|
}
|
|
@@ -668,7 +669,8 @@ function addStructuralEntities(target, files, options = {}) {
|
|
|
668
669
|
name: descriptor.file.relativePath,
|
|
669
670
|
entity_id: identity.entity_id,
|
|
670
671
|
version_id: identity.version_id,
|
|
671
|
-
delta_state: 'added',
|
|
672
|
+
delta_state: identity.delta_state || 'added',
|
|
673
|
+
predecessor_version_id: identity.predecessor_version_id || undefined,
|
|
672
674
|
content_hash: descriptor.content_hash,
|
|
673
675
|
attributes: { artifact_type: artifactType(descriptor.file.relativePath), path: descriptor.file.relativePath },
|
|
674
676
|
source: sourceFor(descriptor.file, 1, identity.anchor_id),
|
|
@@ -684,7 +686,8 @@ function addStructuralEntities(target, files, options = {}) {
|
|
|
684
686
|
name: `${owner.source.file}#${owner.id}`,
|
|
685
687
|
entity_id: identity.entity_id,
|
|
686
688
|
version_id: identity.version_id,
|
|
687
|
-
delta_state: 'added',
|
|
689
|
+
delta_state: identity.delta_state || 'added',
|
|
690
|
+
predecessor_version_id: identity.predecessor_version_id || undefined,
|
|
688
691
|
content_hash: descriptor.content_hash,
|
|
689
692
|
attributes: { artifact_path: owner.source.file, entity_anchor: owner.id },
|
|
690
693
|
source: { ...owner.source, anchor_id: identity.anchor_id },
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const { ID_PREFIX_TYPES, entityTypeForId } = require('./schema.cjs');
|
|
5
5
|
|
|
6
|
-
const ID_BYTE_LENGTH =
|
|
6
|
+
const ID_BYTE_LENGTH = 16;
|
|
7
7
|
const ID_HEX_LENGTH = ID_BYTE_LENGTH * 2;
|
|
8
8
|
|
|
9
9
|
function normalizeSlug(value) {
|
|
@@ -52,15 +52,16 @@ function isValidEntityId(entityId, expectedType = '') {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
function generateIdentityId() {
|
|
55
|
-
return crypto.
|
|
55
|
+
return crypto.randomUUID();
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
function generateUuidV7() {
|
|
59
|
+
// Compatibility name retained for existing callers; new identities are standard UUIDs.
|
|
59
60
|
return generateIdentityId();
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/**
|
|
63
|
-
*
|
|
64
|
+
* Preserve full UUIDs; collapse only the legacy zero-padded 8-hex representation.
|
|
64
65
|
*
|
|
65
66
|
* KB backend (PostgreSQL) serializes UUID columns as full 36-char format
|
|
66
67
|
* (e.g. "3518f551-0000-0000-0000-000000000000"). The SDD toolchain uses
|
|
@@ -69,7 +70,7 @@ function generateUuidV7() {
|
|
|
69
70
|
*/
|
|
70
71
|
function normalizeUuid(value) {
|
|
71
72
|
const v = String(value || '').trim().toLowerCase();
|
|
72
|
-
const fullUuidMatch = v.match(/^([0-9a-f]{8})-
|
|
73
|
+
const fullUuidMatch = v.match(/^([0-9a-f]{8})-0000-0000-0000-000000000000$/);
|
|
73
74
|
if (fullUuidMatch) {
|
|
74
75
|
return fullUuidMatch[1];
|
|
75
76
|
}
|
|
@@ -168,11 +168,11 @@ function buildCatalogDiagnostics(records) {
|
|
|
168
168
|
const byPredecessor = groupBy(records, 'predecessor_version_id');
|
|
169
169
|
|
|
170
170
|
for (const [entityId, items] of byEntityId.entries()) {
|
|
171
|
-
const signatures = new Set(items.map((item) =>
|
|
171
|
+
const signatures = new Set(items.map((item) => item.type));
|
|
172
172
|
if (signatures.size > 1) {
|
|
173
173
|
diagnostics.push(catalogDiagnostic(
|
|
174
174
|
DIAGNOSTIC_CODES.ENTITY_IDENTITY_CONFLICT,
|
|
175
|
-
`历史集合中同一 entity-id
|
|
175
|
+
`历史集合中同一 entity-id 对应多个类型: ${entityId}`,
|
|
176
176
|
{ entity_id: items[0].anchor_id, file: items[0].source && items[0].source.file },
|
|
177
177
|
));
|
|
178
178
|
}
|
|
@@ -206,7 +206,8 @@ function buildCatalogDiagnostics(records) {
|
|
|
206
206
|
diagnostics.push(catalogDiagnostic(
|
|
207
207
|
DIAGNOSTIC_CODES.VERSION_LINEAGE_CONFLICT,
|
|
208
208
|
`历史集合中同一 predecessor 存在多个后继: ${predecessorId}`,
|
|
209
|
-
{
|
|
209
|
+
{ severity: items.every(item => item.scope === "active") ? "warning" : "error",
|
|
210
|
+
entity_id: items[0].anchor_id, file: items[0].source && items[0].source.file },
|
|
210
211
|
));
|
|
211
212
|
}
|
|
212
213
|
}
|
|
@@ -235,7 +236,11 @@ function buildIdentityCatalog(projectRoot, options = {}) {
|
|
|
235
236
|
|
|
236
237
|
for (const candidate of identityRoots(root)) {
|
|
237
238
|
if (excluded && path.resolve(candidate.changeDir) === excluded) continue;
|
|
238
|
-
const
|
|
239
|
+
const snapshot = candidate.scope === 'archive'
|
|
240
|
+
? readJson(path.join(candidate.changeDir, 'archive-ontology.json')) : null;
|
|
241
|
+
// The archive directory gains a date prefix, but facts were frozen with the logical change name.
|
|
242
|
+
const logicalChange = snapshot && snapshot.change ? snapshot.change : candidate.change;
|
|
243
|
+
const facts = normalizeFacts(parseChangeArtifacts(root, logicalChange, {
|
|
239
244
|
changeDir: candidate.changeDir,
|
|
240
245
|
persistStructuralIdentities: false,
|
|
241
246
|
}));
|
|
@@ -15,7 +15,7 @@ function listActiveChanges(projectRoot) {
|
|
|
15
15
|
const full = path.join(changesDir, name);
|
|
16
16
|
return fs.statSync(full).isDirectory();
|
|
17
17
|
})
|
|
18
|
-
.sort();
|
|
18
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function describeChange(projectRoot, changeName, registry) {
|