kld-sdd 2.6.16 → 2.6.21
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/bin/kld-sdd-init.js +10 -0
- package/kld-sdd-guide.html +22 -0
- package/lib/device-auth-cli.js +130 -0
- package/lib/device-auth.js +345 -0
- package/lib/init-account-binding.js +108 -0
- package/lib/init.js +238 -55
- package/lib/skills-bundle.js +20 -1
- package/lib/tool-profiles.js +8 -0
- package/package.json +7 -3
- package/skywalk-sdd/apply-worktree-finish.cjs +2 -23
- package/skywalk-sdd/context-client.cjs +38 -87
- package/skywalk-sdd/index.cjs +860 -132
- package/skywalk-sdd/kb-sync-identity.cjs +780 -0
- package/skywalk-sdd/kb-upload.cjs +505 -0
- package/skywalk-sdd/lib/shared.cjs +811 -0
- package/skywalk-sdd/lib/usage-contract.cjs +276 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +354 -0
- package/skywalk-sdd/lib/user-config.cjs +157 -0
- package/skywalk-sdd/metrics-v3.cjs +138 -8
- package/skywalk-sdd/ontology/archive-package.cjs +19 -34
- package/skywalk-sdd/ontology/change-lock.cjs +3 -7
- package/skywalk-sdd/ontology/external-key.cjs +18 -4
- package/skywalk-sdd/ontology/id.cjs +26 -5
- package/skywalk-sdd/ontology/identity-index.cjs +3 -7
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +20 -6
- package/skywalk-sdd/ontology/runtime.cjs +16 -12
- package/skywalk-sdd/ontology/traceability-validator.cjs +7 -4
- package/skywalk-sdd/reporting/change-report-markdown.cjs +137 -19
- package/skywalk-sdd/reporting/change-report-model.cjs +993 -14
- package/skywalk-sdd/reporting/change-report-renderer.cjs +106 -41
- package/skywalk-sdd/reporting/change-report-view-model.cjs +272 -43
- package/skywalk-sdd/reporting/core-metric-definitions.cjs +192 -0
- package/skywalk-sdd/spec-root.cjs +31 -0
- package/templates/git-hooks/pre-commit-consistency-check.cjs +271 -116
- package/templates/git-hooks/pre-push-consistency-check.cjs +252 -123
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +327 -0
- package/templates/hooks/codebuddy/hooks/sdd-apply-test-gate.cjs +54 -9
- package/templates/hooks/codebuddy/hooks/sdd-mid-checkpoint.cjs +63 -6
- package/templates/hooks/codebuddy/hooks/sdd-tdd-rhythm-gate.cjs +113 -65
- package/templates/openspec/proposal.md +7 -3
- package/templates/openspec/spec.md +3 -3
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +8 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-apply/reference.md +29 -7
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +6 -5
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +49 -17
- package/templates/skills/kld-sdd/opsx-check/checklist.md +4 -2
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +187 -257
- package/templates/skills/kld-sdd/opsx-consistency-check/reference.md +129 -0
- package/templates/skills/kld-sdd/opsx-design/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-explore/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +185 -0
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +127 -0
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +218 -53
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +51 -9
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +12 -50
- package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +35 -23
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-propose/reference.md +22 -17
- package/templates/skills/kld-sdd/opsx-rules/SKILL.md +2 -2
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +19 -15
- package/templates/skills/kld-sdd/opsx-spec/checklist.md +2 -0
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -4
- package/templates/skills/kld-sdd/opsx-test/SKILL.md +2 -2
- package/templates/skills/kld-sdd/tdd-core/reference.md +1 -1
- package/templates/skills/kld-sdd/tdd-rules/rules/test-skeleton-telemetry.md +1 -1
- package/templates/skills/kld-sdd/opsx-kb-ingest/state.example.json +0 -7
- package/templates/skills/kld-sdd/opsx-ontology-query/state.example.json +0 -7
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// kld-T02 — ~/.kld-sdd/config.json 用户级配置管理
|
|
2
|
+
// 职责:读取、规范化、原子写、0600/Windows ACL、401 token 清除、敏感信息脱敏。
|
|
3
|
+
// 与 kb-sdd 跨仓契约对齐:cli_token 仅存于此,不进入 Archive/Knowledge 请求。
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('node:fs');
|
|
7
|
+
const os = require('node:os');
|
|
8
|
+
const path = require('node:path');
|
|
9
|
+
|
|
10
|
+
const USER_CONFIG_VERSION = 1;
|
|
11
|
+
const CONFIG_DIR_NAME = '.kld-sdd';
|
|
12
|
+
const CONFIG_FILE_NAME = 'config.json';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* 返回用户配置目录路径。
|
|
16
|
+
* @param {string} [homeDir] — 注入的 home 目录(测试用);缺省取 env.KLD_SDD_HOME 或 os.homedir()
|
|
17
|
+
*/
|
|
18
|
+
function getConfigDir(homeDir) {
|
|
19
|
+
const envHome = typeof process !== 'undefined' && process && process.env
|
|
20
|
+
? process.env.KLD_SDD_HOME
|
|
21
|
+
: undefined;
|
|
22
|
+
const home = (typeof homeDir === 'string' && homeDir)
|
|
23
|
+
|| (typeof envHome === 'string' && envHome)
|
|
24
|
+
|| os.homedir();
|
|
25
|
+
return path.join(home, CONFIG_DIR_NAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 返回用户配置文件完整路径。
|
|
30
|
+
*/
|
|
31
|
+
function getConfigPath(homeDir) {
|
|
32
|
+
return path.join(getConfigDir(homeDir), CONFIG_FILE_NAME);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 读取用户配置。
|
|
37
|
+
* - 文件不存在 / 损坏 JSON / 非对象 → 返回 null(不抛)
|
|
38
|
+
* - 返回新引用;调用方修改不影响后续读取
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [homeDir]
|
|
41
|
+
* @returns {object|null}
|
|
42
|
+
*/
|
|
43
|
+
function readUserConfig(homeDir) {
|
|
44
|
+
const file = getConfigPath(homeDir);
|
|
45
|
+
let raw;
|
|
46
|
+
try {
|
|
47
|
+
raw = fs.readFileSync(file, 'utf8');
|
|
48
|
+
} catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
let parsed;
|
|
52
|
+
try {
|
|
53
|
+
parsed = JSON.parse(raw);
|
|
54
|
+
} catch {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null;
|
|
58
|
+
// 深拷贝隔离
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(JSON.stringify(parsed));
|
|
61
|
+
} catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* POSIX 平台设置 0600/0700;Windows 平台尝试通过 icacls 收紧 ACL(best-effort)。
|
|
68
|
+
* @param {string} target — 文件或目录路径
|
|
69
|
+
* @param {'file'|'dir'} kind
|
|
70
|
+
*/
|
|
71
|
+
function hardenPermissions(target, kind) {
|
|
72
|
+
if (process.platform === 'win32') {
|
|
73
|
+
// Windows:依赖 Node 默认 ACL 通常已限当前用户,无需显式 icacls;
|
|
74
|
+
// 实际收紧在更高层以同步子进程完成,避免引入异步依赖。
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
fs.chmodSync(target, kind === 'dir' ? 0o700 : 0o600);
|
|
79
|
+
} catch {
|
|
80
|
+
// best-effort:无法 chmod 时静默(例如某些 FS 不支持)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 原子写入用户配置:先 temp 后 rename,目录与文件均设置权限。
|
|
86
|
+
* @param {object} config — 必须含 version 字段
|
|
87
|
+
* @param {string} [homeDir]
|
|
88
|
+
* @throws {Error} config 非法 / 缺 version
|
|
89
|
+
*/
|
|
90
|
+
function writeUserConfig(config, homeDir) {
|
|
91
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
|
92
|
+
throw new Error('user config must be an object');
|
|
93
|
+
}
|
|
94
|
+
if (typeof config.version !== 'number') {
|
|
95
|
+
throw new Error('user config requires numeric version');
|
|
96
|
+
}
|
|
97
|
+
const dir = getConfigDir(homeDir);
|
|
98
|
+
const file = getConfigPath(homeDir);
|
|
99
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
100
|
+
hardenPermissions(dir, 'dir');
|
|
101
|
+
|
|
102
|
+
const tmp = path.join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
|
|
103
|
+
const payload = JSON.stringify(config, null, 2);
|
|
104
|
+
fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
105
|
+
hardenPermissions(tmp, 'file');
|
|
106
|
+
try {
|
|
107
|
+
fs.renameSync(tmp, file);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
|
110
|
+
throw err;
|
|
111
|
+
}
|
|
112
|
+
hardenPermissions(file, 'file');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* 原子清除 token 字段,保留其他所有字段。
|
|
117
|
+
* - 文件不存在 / 损坏 / 已无 token → 幂等返回 false
|
|
118
|
+
* - 成功清除返回 true
|
|
119
|
+
*
|
|
120
|
+
* @param {string} [homeDir]
|
|
121
|
+
* @returns {boolean}
|
|
122
|
+
*/
|
|
123
|
+
function clearUserToken(homeDir) {
|
|
124
|
+
const file = getConfigPath(homeDir);
|
|
125
|
+
if (!fs.existsSync(file)) return false;
|
|
126
|
+
const current = readUserConfig(homeDir);
|
|
127
|
+
if (!current) return false;
|
|
128
|
+
if (!Object.prototype.hasOwnProperty.call(current, 'token')) return false;
|
|
129
|
+
delete current.token;
|
|
130
|
+
writeUserConfig(current, homeDir);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 敏感信息脱敏:任何非空字符串 token 一律返回 '***'。
|
|
136
|
+
* - 空字符串保持空字符串
|
|
137
|
+
* - 非字符串原样返回
|
|
138
|
+
* - 永不抛出
|
|
139
|
+
*
|
|
140
|
+
* @param {*} value
|
|
141
|
+
* @returns {*}
|
|
142
|
+
*/
|
|
143
|
+
function redactToken(value) {
|
|
144
|
+
if (typeof value !== 'string') return value;
|
|
145
|
+
if (value.length === 0) return '';
|
|
146
|
+
return '***';
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
USER_CONFIG_VERSION,
|
|
151
|
+
getConfigDir,
|
|
152
|
+
getConfigPath,
|
|
153
|
+
readUserConfig,
|
|
154
|
+
writeUserConfig,
|
|
155
|
+
clearUserToken,
|
|
156
|
+
redactToken,
|
|
157
|
+
};
|
|
@@ -4,7 +4,8 @@ const REPORT_SCHEMA_VERSION = 'sdd-efficiency-report/v4';
|
|
|
4
4
|
const METRICS_CONTRACT_VERSION = '4.0';
|
|
5
5
|
const SCHEME_VERSION = 'V3.0';
|
|
6
6
|
/** Writable change types (scheme 5.3). Legacy document/other read as unknown. */
|
|
7
|
-
const
|
|
7
|
+
const BUSINESS_CHANGE_TYPES = ['config', 'transaction', 'report', 'composite'];
|
|
8
|
+
const CHANGE_TYPES = [...BUSINESS_CHANGE_TYPES, 'unknown'];
|
|
8
9
|
const LEGACY_CHANGE_TYPES = ['document', 'other'];
|
|
9
10
|
const MINIMUM_METRICS = ['e1', 'q3', 'p4', 'p_r', 'p_h'];
|
|
10
11
|
|
|
@@ -55,7 +56,7 @@ const AGENT_OVERRIDE_REASONS = new Set([
|
|
|
55
56
|
'unspecified',
|
|
56
57
|
]);
|
|
57
58
|
|
|
58
|
-
const SCENARIO_HEADING_RE = /^#####\s+(
|
|
59
|
+
const SCENARIO_HEADING_RE = /^#####\s+(?:场景|Scenario)\s*[::]\s*(.+?)\s*$/gim;
|
|
59
60
|
|
|
60
61
|
function createInvalidArgError(code, message) {
|
|
61
62
|
const err = new Error(message);
|
|
@@ -74,6 +75,26 @@ function round(n, digits = 4) {
|
|
|
74
75
|
return Math.round(n * factor) / factor;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function computeTelemetryHealthScore(input = {}) {
|
|
79
|
+
const weightedIssueValue = Number(input.weighted_issue_value);
|
|
80
|
+
const stageEventCount = Number(input.stage_event_count);
|
|
81
|
+
const safeWeightedIssueValue = Number.isFinite(weightedIssueValue) && weightedIssueValue >= 0
|
|
82
|
+
? weightedIssueValue
|
|
83
|
+
: 0;
|
|
84
|
+
const safeStageEventCount = Number.isFinite(stageEventCount) && stageEventCount >= 0
|
|
85
|
+
? stageEventCount
|
|
86
|
+
: 0;
|
|
87
|
+
const denominator = Math.max(safeStageEventCount, 1);
|
|
88
|
+
const ratio = Math.max(0, round(1 - safeWeightedIssueValue / denominator, 2));
|
|
89
|
+
return {
|
|
90
|
+
weighted_issue_value: safeWeightedIssueValue,
|
|
91
|
+
stage_event_count: safeStageEventCount,
|
|
92
|
+
denominator,
|
|
93
|
+
ratio,
|
|
94
|
+
score: round(ratio * 100, 2),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
77
98
|
function percentile(sortedOrUnsortedValues, p) {
|
|
78
99
|
if (!Array.isArray(sortedOrUnsortedValues)) {
|
|
79
100
|
throw createInvalidArgError('invalid-arg', 'percentile expects an array');
|
|
@@ -137,10 +158,44 @@ function summarizeDistribution(values) {
|
|
|
137
158
|
}
|
|
138
159
|
|
|
139
160
|
function countScenariosInMarkdown(text) {
|
|
140
|
-
|
|
161
|
+
return extractScenariosInMarkdown(text).length;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function maskMarkdownFencedCode(text) {
|
|
165
|
+
const lines = text.split('\n');
|
|
166
|
+
let fence = null;
|
|
167
|
+
return lines.map((line) => {
|
|
168
|
+
const marker = line.match(/^ {0,3}(`{3,}|~{3,})/);
|
|
169
|
+
if (!fence && marker) {
|
|
170
|
+
fence = {
|
|
171
|
+
character: marker[1][0],
|
|
172
|
+
length: marker[1].length,
|
|
173
|
+
};
|
|
174
|
+
return ' '.repeat(line.length);
|
|
175
|
+
}
|
|
176
|
+
if (!fence) return line;
|
|
177
|
+
|
|
178
|
+
const closing = line.match(/^ {0,3}(`{3,}|~{3,})[ \t]*$/);
|
|
179
|
+
const isClosing = closing
|
|
180
|
+
&& closing[1][0] === fence.character
|
|
181
|
+
&& closing[1].length >= fence.length;
|
|
182
|
+
if (isClosing) fence = null;
|
|
183
|
+
return ' '.repeat(line.length);
|
|
184
|
+
}).join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function extractScenariosInMarkdown(text) {
|
|
188
|
+
if (typeof text !== 'string' || !text) return [];
|
|
141
189
|
const normalized = text.replace(/\r\n/g, '\n');
|
|
142
|
-
const
|
|
143
|
-
|
|
190
|
+
const searchable = maskMarkdownFencedCode(normalized);
|
|
191
|
+
const scenarios = [];
|
|
192
|
+
for (const match of searchable.matchAll(SCENARIO_HEADING_RE)) {
|
|
193
|
+
scenarios.push({
|
|
194
|
+
title: String(match[1] || '').trim(),
|
|
195
|
+
line: searchable.slice(0, match.index).split('\n').length,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
return scenarios;
|
|
144
199
|
}
|
|
145
200
|
|
|
146
201
|
function validateChangeType(value) {
|
|
@@ -185,11 +240,33 @@ function resolveReviewerIndependence(meta = {}) {
|
|
|
185
240
|
return 'unknown';
|
|
186
241
|
}
|
|
187
242
|
|
|
243
|
+
const REVIEW_IDENTITY_PLACEHOLDERS = new Set([
|
|
244
|
+
'unknown',
|
|
245
|
+
'none',
|
|
246
|
+
'null',
|
|
247
|
+
'n/a',
|
|
248
|
+
'na',
|
|
249
|
+
'未记录',
|
|
250
|
+
'未知',
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
function isMeaningfulReviewIdentity(value) {
|
|
254
|
+
const normalized = String(value || '').trim();
|
|
255
|
+
if (!normalized) return false;
|
|
256
|
+
if (REVIEW_IDENTITY_PLACEHOLDERS.has(normalized.toLowerCase())) return false;
|
|
257
|
+
if (/^<[^>]+>$/.test(normalized) || /^\[[^\]]+\]$/.test(normalized)) return false;
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
|
|
188
261
|
function resolveScoreStatus(reviewerIndependence, meta = {}) {
|
|
189
262
|
if (reviewerIndependence !== 'independent-review') return 'provisional';
|
|
263
|
+
if (meta.review_execution_mode !== 'subagent') return 'provisional';
|
|
264
|
+
if (!isMeaningfulReviewIdentity(meta.reviewer_agent_id)) return 'provisional';
|
|
190
265
|
const reviewSession = meta.review_session_id || meta.reviewer_session_id || null;
|
|
191
266
|
const authorSession = meta.author_session_id || null;
|
|
192
|
-
if (reviewSession
|
|
267
|
+
if (isMeaningfulReviewIdentity(reviewSession)
|
|
268
|
+
&& isMeaningfulReviewIdentity(authorSession)
|
|
269
|
+
&& reviewSession !== authorSession) {
|
|
193
270
|
return 'verified';
|
|
194
271
|
}
|
|
195
272
|
return 'provisional';
|
|
@@ -210,7 +287,7 @@ function computeSpecQualityScore(checkResults, meta = {}) {
|
|
|
210
287
|
}
|
|
211
288
|
|
|
212
289
|
const total = Number.isFinite(checkResults.total) ? checkResults.total : null;
|
|
213
|
-
const errors = Number.isFinite(checkResults.errors) ? checkResults.errors :
|
|
290
|
+
const errors = Number.isFinite(checkResults.errors) ? checkResults.errors : null;
|
|
214
291
|
const warnings = Number.isFinite(checkResults.warnings) ? checkResults.warnings : 0;
|
|
215
292
|
const suggestions = Number.isFinite(checkResults.suggestions) ? checkResults.suggestions : 0;
|
|
216
293
|
|
|
@@ -227,6 +304,24 @@ function computeSpecQualityScore(checkResults, meta = {}) {
|
|
|
227
304
|
suggestions,
|
|
228
305
|
};
|
|
229
306
|
}
|
|
307
|
+
if (!Number.isInteger(total)
|
|
308
|
+
|| !Number.isInteger(errors)
|
|
309
|
+
|| errors < 0
|
|
310
|
+
|| errors > total) {
|
|
311
|
+
const reviewerIndependence = resolveReviewerIndependence(meta);
|
|
312
|
+
return {
|
|
313
|
+
q3_spec_quality_score: null,
|
|
314
|
+
category_scores: {},
|
|
315
|
+
reviewer_independence: reviewerIndependence,
|
|
316
|
+
score_status: resolveScoreStatus(reviewerIndependence, meta),
|
|
317
|
+
availability: false,
|
|
318
|
+
availability_reason: 'invalid-counts',
|
|
319
|
+
warnings,
|
|
320
|
+
suggestions,
|
|
321
|
+
total: Number.isInteger(total) ? total : null,
|
|
322
|
+
errors: Number.isInteger(errors) ? errors : null,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
230
325
|
|
|
231
326
|
const categoryScores = {};
|
|
232
327
|
const categories = checkResults.categories && typeof checkResults.categories === 'object'
|
|
@@ -380,6 +475,33 @@ function computeV3ChangeMetrics(context = {}) {
|
|
|
380
475
|
q3_score_status: q3Result.score_status,
|
|
381
476
|
q3_availability: q3Result.availability,
|
|
382
477
|
q3_availability_reason: q3Result.availability_reason,
|
|
478
|
+
q3_total: q3Result.total ?? null,
|
|
479
|
+
q3_errors: q3Result.errors ?? null,
|
|
480
|
+
q3_warnings: q3Result.warnings ?? 0,
|
|
481
|
+
q3_suggestions: q3Result.suggestions ?? 0,
|
|
482
|
+
q3_review: {
|
|
483
|
+
reviewer_independence: q3Result.reviewer_independence,
|
|
484
|
+
review_execution_mode: q3Result.review_execution_mode
|
|
485
|
+
|| context.check_meta?.review_execution_mode
|
|
486
|
+
|| null,
|
|
487
|
+
reviewer_agent_id: q3Result.reviewer_agent_id
|
|
488
|
+
|| context.check_meta?.reviewer_agent_id
|
|
489
|
+
|| null,
|
|
490
|
+
review_session_id: q3Result.review_session_id
|
|
491
|
+
|| q3Result.reviewer_session_id
|
|
492
|
+
|| context.check_meta?.review_session_id
|
|
493
|
+
|| context.check_meta?.reviewer_session_id
|
|
494
|
+
|| null,
|
|
495
|
+
author_session_id: q3Result.author_session_id
|
|
496
|
+
|| context.check_meta?.author_session_id
|
|
497
|
+
|| null,
|
|
498
|
+
fallback_reason_code: q3Result.fallback_reason_code
|
|
499
|
+
|| context.check_meta?.fallback_reason_code
|
|
500
|
+
|| null,
|
|
501
|
+
fallback_reason: q3Result.fallback_reason
|
|
502
|
+
|| context.check_meta?.fallback_reason
|
|
503
|
+
|| null,
|
|
504
|
+
},
|
|
383
505
|
q1_spec_conformance: buildPendingUpstreamMetric(context.q1_spec_conformance, context.q1_source),
|
|
384
506
|
q4_tdd_coverage: buildPendingUpstreamMetric(context.q4_tdd_coverage, context.q4_source),
|
|
385
507
|
},
|
|
@@ -456,8 +578,12 @@ function aggregateV3Metrics(changeMetrics, options = {}) {
|
|
|
456
578
|
const distribution = {};
|
|
457
579
|
|
|
458
580
|
for (const key of metricKeys) {
|
|
459
|
-
byChangeType[key] = aggregateByChangeType(changeMetrics, key,
|
|
581
|
+
byChangeType[key] = aggregateByChangeType(changeMetrics, key, {
|
|
582
|
+
...options,
|
|
583
|
+
includeUnknown: false,
|
|
584
|
+
});
|
|
460
585
|
const allValues = changeMetrics
|
|
586
|
+
.filter(m => validateChangeType(m.change_type) !== 'unknown')
|
|
461
587
|
.map(m => pickMetricValue(m, key))
|
|
462
588
|
.filter(v => Number.isFinite(v));
|
|
463
589
|
distribution[key] = summarizeDistribution(allValues);
|
|
@@ -1210,12 +1336,15 @@ module.exports = {
|
|
|
1210
1336
|
REPORT_SCHEMA_VERSION,
|
|
1211
1337
|
METRICS_CONTRACT_VERSION,
|
|
1212
1338
|
SCHEME_VERSION,
|
|
1339
|
+
BUSINESS_CHANGE_TYPES,
|
|
1213
1340
|
CHANGE_TYPES,
|
|
1214
1341
|
LEGACY_CHANGE_TYPES,
|
|
1215
1342
|
ALERT_THRESHOLDS,
|
|
1216
1343
|
MINIMUM_METRICS,
|
|
1217
1344
|
clamp,
|
|
1218
1345
|
round,
|
|
1346
|
+
computeTelemetryHealthScore,
|
|
1347
|
+
resolveScoreStatus,
|
|
1219
1348
|
percentile,
|
|
1220
1349
|
summarizeDistribution,
|
|
1221
1350
|
detectIqrOutliers,
|
|
@@ -1238,4 +1367,5 @@ module.exports = {
|
|
|
1238
1367
|
formatArtifactSize,
|
|
1239
1368
|
createInvalidArgError,
|
|
1240
1369
|
countScenariosInMarkdown,
|
|
1370
|
+
extractScenariosInMarkdown,
|
|
1241
1371
|
};
|
|
@@ -5,6 +5,14 @@ const fs = require('fs');
|
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { generateUuidV7 } = require('./id.cjs');
|
|
7
7
|
const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
|
|
8
|
+
const {
|
|
9
|
+
readJson,
|
|
10
|
+
writeJsonAtomic,
|
|
11
|
+
writeTextAtomic,
|
|
12
|
+
toPosix,
|
|
13
|
+
sha256,
|
|
14
|
+
ensureDir,
|
|
15
|
+
} = require('../lib/shared.cjs');
|
|
8
16
|
|
|
9
17
|
const RUNTIME_METADATA = resolveRuntimeMetadata();
|
|
10
18
|
const PRODUCER_VERSION = RUNTIME_METADATA.version;
|
|
@@ -19,30 +27,7 @@ const CANONICAL_FACTS_PATH = 'canonical-facts.json';
|
|
|
19
27
|
const CONVERSION_REPORT_PATH = 'conversion-report.json';
|
|
20
28
|
const PACKAGE_SUFFIXES = new Set(['.md', '.html', '.json', '.jsonl', '.yaml', '.yml']);
|
|
21
29
|
|
|
22
|
-
|
|
23
|
-
return crypto.createHash('sha256').update(content).digest('hex');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function readJson(filePath) {
|
|
27
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
function atomicWrite(filePath, content) {
|
|
31
|
-
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
32
|
-
const tempPath = `${filePath}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
|
33
|
-
fs.writeFileSync(tempPath, content);
|
|
34
|
-
fs.renameSync(tempPath, filePath);
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeJson(filePath, value) {
|
|
38
|
-
const bytes = Buffer.from(`${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
39
|
-
atomicWrite(filePath, bytes);
|
|
40
|
-
return bytes;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function toPosix(value) {
|
|
44
|
-
return String(value || '').replace(/\\/g, '/');
|
|
45
|
-
}
|
|
30
|
+
// readJson, atomicWrite, writeJson, toPosix, sha256 已迁移至 lib/shared.cjs
|
|
46
31
|
|
|
47
32
|
function safePackagePath(value) {
|
|
48
33
|
const input = toPosix(value).trim();
|
|
@@ -65,18 +50,18 @@ function ensureProjectIdentity(projectRoot) {
|
|
|
65
50
|
}
|
|
66
51
|
return existing;
|
|
67
52
|
}
|
|
68
|
-
// 兜底:project-identity.json 未通过 opsx-
|
|
53
|
+
// 兜底:project-identity.json 未通过 opsx-kb-config 配置 KB 时创建
|
|
69
54
|
// 此时 project_id 为随机值,KB 入库时会因 project_id !== spaceKey 而失败
|
|
70
|
-
console.warn('⚠️ project-identity.json 不存在 — 未通过 opsx-
|
|
55
|
+
console.warn('⚠️ project-identity.json 不存在 — 未通过 opsx-kb-config 配置 KB。');
|
|
71
56
|
console.warn('⚠️ 将生成临时 project_id,KB 入库时需确保与 Space spaceKey 一致。');
|
|
72
|
-
console.warn('⚠️
|
|
57
|
+
console.warn('⚠️ 建议运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey。');
|
|
73
58
|
const identity = {
|
|
74
59
|
schema_version: PROJECT_IDENTITY_SCHEMA_VERSION,
|
|
75
60
|
project_id: `urn:kld:sdd:project:${generateUuidV7()}`,
|
|
76
61
|
created_at: new Date().toISOString(),
|
|
77
|
-
_warning: 'UNCONFIGURED —
|
|
62
|
+
_warning: 'UNCONFIGURED — 请运行 /opsx-kb-config 配置 KB 后更新 project_id 为 Space spaceKey',
|
|
78
63
|
};
|
|
79
|
-
|
|
64
|
+
writeJsonAtomic(identityPath, identity);
|
|
80
65
|
return identity;
|
|
81
66
|
}
|
|
82
67
|
|
|
@@ -188,7 +173,7 @@ function assertExternalKeyFinalGate(entities) {
|
|
|
188
173
|
const parts = parseScenario(ref.external_id);
|
|
189
174
|
if (!parts || !packageRequirementIds.has(parts.requirementId)) {
|
|
190
175
|
throw new Error(
|
|
191
|
-
`${CODES.SCOPE_MISMATCH}:
|
|
176
|
+
`${CODES.SCOPE_MISMATCH}: 场景键需求编号前缀不在同包 requirement 绑定集合中: ${ref.external_id} @ ${entity.anchor_id}`,
|
|
192
177
|
);
|
|
193
178
|
}
|
|
194
179
|
}
|
|
@@ -499,7 +484,7 @@ function writeArchiveZip(archiveDir, packagePath, manifest) {
|
|
|
499
484
|
end.writeUInt32LE(centralDirectory.length, 12);
|
|
500
485
|
end.writeUInt32LE(offset, 16);
|
|
501
486
|
end.writeUInt16LE(0, 20);
|
|
502
|
-
|
|
487
|
+
writeTextAtomic(packagePath, Buffer.concat([...localParts, centralDirectory, end]));
|
|
503
488
|
return packagePath;
|
|
504
489
|
}
|
|
505
490
|
|
|
@@ -595,7 +580,7 @@ function materializeArchivePackage(options = {}) {
|
|
|
595
580
|
archiveId,
|
|
596
581
|
);
|
|
597
582
|
const canonicalPath = path.join(archiveDir, CANONICAL_FACTS_PATH);
|
|
598
|
-
const canonicalBytes =
|
|
583
|
+
const canonicalBytes = writeJsonAtomic(canonicalPath, canonicalFacts);
|
|
599
584
|
|
|
600
585
|
const conversionReport = {
|
|
601
586
|
schema_version: CONVERSION_REPORT_SCHEMA_VERSION,
|
|
@@ -609,7 +594,7 @@ function materializeArchivePackage(options = {}) {
|
|
|
609
594
|
relation_count: canonicalFacts.relations.length,
|
|
610
595
|
warnings,
|
|
611
596
|
};
|
|
612
|
-
|
|
597
|
+
writeJsonAtomic(path.join(archiveDir, CONVERSION_REPORT_PATH), conversionReport);
|
|
613
598
|
|
|
614
599
|
const existingManifestPath = path.join(archiveDir, MANIFEST_PATH);
|
|
615
600
|
const existingManifest = fs.existsSync(existingManifestPath)
|
|
@@ -649,7 +634,7 @@ function materializeArchivePackage(options = {}) {
|
|
|
649
634
|
? (existingManifest.evidence_events_path || null)
|
|
650
635
|
: options.evidenceEventsPath,
|
|
651
636
|
};
|
|
652
|
-
|
|
637
|
+
writeJsonAtomic(existingManifestPath, manifest);
|
|
653
638
|
|
|
654
639
|
const packagePath = options.packagePath === false
|
|
655
640
|
? ''
|
|
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const { safeChangeName } = require('./artifact-parser.cjs');
|
|
7
|
+
const { readJsonSafe } = require('../lib/shared.cjs');
|
|
7
8
|
|
|
8
9
|
const DEFAULT_STALE_MS = 5 * 60 * 1000;
|
|
9
10
|
|
|
@@ -27,13 +28,8 @@ function isProcessAlive(pid) {
|
|
|
27
28
|
}
|
|
28
29
|
}
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
33
|
-
} catch (error) {
|
|
34
|
-
return null;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
31
|
+
// readLock 改用 shared.cjs 的 readJsonSafe(带 BOM 剥离)
|
|
32
|
+
const readLock = readJsonSafe;
|
|
37
33
|
|
|
38
34
|
function isStaleLock(lock, staleMs) {
|
|
39
35
|
if (!lock || !lock.created_at) return true;
|
|
@@ -4,20 +4,26 @@
|
|
|
4
4
|
* External key grammar for requirement / feature / scenario.
|
|
5
5
|
* Authority: kb-sdd docs/superpowers/specs/2026-07-24-external-numbering-kb-sdd-design.md §2.
|
|
6
6
|
* Regex and normalization MUST stay byte-for-byte aligned with kb-sdd ExternalKeyFormat.java.
|
|
7
|
+
*
|
|
8
|
+
* 格式放宽(2026-08-04):requirement / feature 不再强制 REQ-/FEAT- 前缀,
|
|
9
|
+
* 允许外部系统(客户 ERP、Jira 等)的自有编号格式。
|
|
10
|
+
* 白名单:大写字母 + 数字 + 连字符(-) + 下划线(_),1~50 字符。
|
|
11
|
+
* 示例:KLERP-001, KLERP001, 1010000, KLERP_001, REQ-FI-2024-001
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
const REGEX = {
|
|
10
|
-
requirement: /^
|
|
11
|
-
feature: /^
|
|
12
|
-
scenario: /^
|
|
15
|
+
requirement: /^[A-Z0-9_-]+$/,
|
|
16
|
+
feature: /^[A-Z0-9_-]+$/,
|
|
17
|
+
scenario: /^[A-Z0-9_-]+:SCN-[a-z0-9]+(-[a-z0-9]+)*-[0-9]{3}$/,
|
|
13
18
|
};
|
|
14
19
|
|
|
15
20
|
const SCENARIO_PARTS =
|
|
16
|
-
/^(
|
|
21
|
+
/^([A-Z0-9_-]+):SCN-([a-z0-9]+(?:-[a-z0-9]+)*)-([0-9]{3})$/;
|
|
17
22
|
|
|
18
23
|
const SLUG_MAX_LEN = 40;
|
|
19
24
|
const SCENARIO_KEY_MAX_LEN = 120;
|
|
20
25
|
const SCN_SEQ_MAX = 999;
|
|
26
|
+
const EXTERNAL_ID_MAX_LEN = 50;
|
|
21
27
|
|
|
22
28
|
const CODES = {
|
|
23
29
|
FORMAT_INVALID: 'EXTERNAL_KEY_FORMAT_INVALID',
|
|
@@ -76,6 +82,13 @@ function validate(id, objectType) {
|
|
|
76
82
|
message: `编号格式不合规 (${type}): ${id} → ${normalized}`,
|
|
77
83
|
};
|
|
78
84
|
}
|
|
85
|
+
if ((type === 'requirement' || type === 'feature') && normalized.length > EXTERNAL_ID_MAX_LEN) {
|
|
86
|
+
return {
|
|
87
|
+
ok: false,
|
|
88
|
+
code: CODES.FORMAT_INVALID,
|
|
89
|
+
message: `编号超过 ${EXTERNAL_ID_MAX_LEN} 字符: ${normalized}`,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
79
92
|
if (type === 'scenario') {
|
|
80
93
|
const parts = parseScenario(normalized);
|
|
81
94
|
if (!parts) {
|
|
@@ -150,6 +163,7 @@ module.exports = {
|
|
|
150
163
|
SLUG_MAX_LEN,
|
|
151
164
|
SCENARIO_KEY_MAX_LEN,
|
|
152
165
|
SCN_SEQ_MAX,
|
|
166
|
+
EXTERNAL_ID_MAX_LEN,
|
|
153
167
|
normalize,
|
|
154
168
|
validate,
|
|
155
169
|
parseScenario,
|
|
@@ -59,19 +59,39 @@ function generateUuidV7() {
|
|
|
59
59
|
return generateIdentityId();
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/**
|
|
63
|
+
* Normalize a UUID value to the internal 8-hex short format.
|
|
64
|
+
*
|
|
65
|
+
* KB backend (PostgreSQL) serializes UUID columns as full 36-char format
|
|
66
|
+
* (e.g. "3518f551-0000-0000-0000-000000000000"). The SDD toolchain uses
|
|
67
|
+
* an 8-hex short format internally (e.g. "3518f551"). This function
|
|
68
|
+
* accepts both and returns the short form.
|
|
69
|
+
*/
|
|
70
|
+
function normalizeUuid(value) {
|
|
71
|
+
const v = String(value || '').trim().toLowerCase();
|
|
72
|
+
const fullUuidMatch = v.match(/^([0-9a-f]{8})-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
73
|
+
if (fullUuidMatch) {
|
|
74
|
+
return fullUuidMatch[1];
|
|
75
|
+
}
|
|
76
|
+
return v;
|
|
77
|
+
}
|
|
78
|
+
|
|
62
79
|
function isValidUuid(value) {
|
|
63
|
-
|
|
80
|
+
const v = String(value || '').trim().toLowerCase();
|
|
81
|
+
// Accept 8-hex short format (internal) OR 36-char full UUID (from KB API)
|
|
82
|
+
return /^[0-9a-f]{8}$/.test(v)
|
|
83
|
+
|| /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(v);
|
|
64
84
|
}
|
|
65
85
|
|
|
66
86
|
const isValidUuidV7 = isValidUuid;
|
|
67
87
|
|
|
68
88
|
function allocateIdentity(options = {}) {
|
|
69
89
|
const deltaState = String(options.deltaState || options['delta-state'] || 'added').trim().toLowerCase();
|
|
70
|
-
const entityId =
|
|
71
|
-
const versionId =
|
|
72
|
-
const predecessorVersionId =
|
|
90
|
+
const entityId = normalizeUuid(options.entityId || options['entity-id'] || '');
|
|
91
|
+
const versionId = normalizeUuid(options.versionId || options['version-id'] || '');
|
|
92
|
+
const predecessorVersionId = normalizeUuid(
|
|
73
93
|
options.predecessorVersionId || options['predecessor-version'] || '',
|
|
74
|
-
)
|
|
94
|
+
);
|
|
75
95
|
|
|
76
96
|
if (!['added', 'modified', 'unchanged', 'removed'].includes(deltaState)) {
|
|
77
97
|
throw new Error(`不支持的 delta-state: ${deltaState}`);
|
|
@@ -119,5 +139,6 @@ module.exports = {
|
|
|
119
139
|
generateUuidV7,
|
|
120
140
|
isValidUuid,
|
|
121
141
|
isValidUuidV7,
|
|
142
|
+
normalizeUuid,
|
|
122
143
|
allocateIdentity,
|
|
123
144
|
};
|
|
@@ -9,6 +9,7 @@ const {
|
|
|
9
9
|
ONTOLOGY_STATE_FILES,
|
|
10
10
|
resolveOntologyStateFile,
|
|
11
11
|
} = require('./ontology-paths.cjs');
|
|
12
|
+
const { readJsonSafe } = require('../lib/shared.cjs');
|
|
12
13
|
|
|
13
14
|
function catalogDiagnostic(code, message, context = {}) {
|
|
14
15
|
return {
|
|
@@ -22,13 +23,8 @@ function catalogDiagnostic(code, message, context = {}) {
|
|
|
22
23
|
};
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
28
|
-
} catch {
|
|
29
|
-
return null;
|
|
30
|
-
}
|
|
31
|
-
}
|
|
26
|
+
// readJson 已迁移至 lib/shared.cjs(readJsonSafe 版本,返回 null 而非抛出)
|
|
27
|
+
const readJson = readJsonSafe;
|
|
32
28
|
|
|
33
29
|
function identityRoots(projectRoot) {
|
|
34
30
|
const root = path.resolve(projectRoot || process.cwd());
|
|
@@ -5,13 +5,27 @@ const path = require('path');
|
|
|
5
5
|
const sddConfig = require('./sdd-config.cjs');
|
|
6
6
|
const workspaceLayout = require('./workspace-layout.cjs');
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* 从 anchorRoot 向上遍历目录树,查找 .sdd-spec-root 配置文件。
|
|
10
|
+
* 修复:当 shim 从子目录(如 sdd-sdd-specs/)运行时,cwd 不是 workspace 根目录,
|
|
11
|
+
* 需要向上查找到 .sdd-spec-root 所在的祖先目录。
|
|
12
|
+
*/
|
|
8
13
|
function resolveHint(anchorRoot) {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
14
|
+
let current = path.resolve(anchorRoot);
|
|
15
|
+
const root = path.parse(current).root;
|
|
16
|
+
for (;;) {
|
|
17
|
+
const hintPath = path.join(current, '.sdd-spec-root');
|
|
18
|
+
if (fs.existsSync(hintPath)) {
|
|
19
|
+
const hint = fs.readFileSync(hintPath, 'utf8').trim();
|
|
20
|
+
if (hint) {
|
|
21
|
+
const resolved = path.resolve(current, hint);
|
|
22
|
+
if (fs.existsSync(resolved)) return resolved;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (current === root) break;
|
|
26
|
+
current = path.dirname(current);
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
function resolveSpecProjectRoot(projectRoot = process.cwd()) {
|