kld-sdd 2.6.21 → 2.7.8
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 +84 -0
- package/bin/kld-sdd-init.js +3 -9
- package/kld-sdd-guide.html +16 -4
- package/lib/hook-gate-core.js +327 -0
- package/lib/init.js +241 -94
- 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 +49 -16
- package/skywalk-sdd/kb-sync-identity.cjs +99 -451
- package/skywalk-sdd/kb-upload.cjs +67 -44
- package/skywalk-sdd/lib/git-identity.cjs +270 -0
- package/skywalk-sdd/lib/usage-contract.cjs +13 -81
- package/skywalk-sdd/lib/usage-reporter.cjs +235 -104
- package/skywalk-sdd/lib/user-config.cjs +21 -46
- 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 +8 -6
- package/skywalk-sdd/ontology/id.cjs +5 -4
- package/skywalk-sdd/ontology/identity-index.cjs +4 -3
- 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 +71 -27
- package/templates/git-hooks/consistency-check-core.cjs +1087 -0
- package/templates/git-hooks/hooks.config +38 -0
- package/templates/git-hooks/pre-commit +62 -27
- 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 +70 -27
- package/templates/git-hooks/pre-push-consistency-check.cjs +61 -299
- 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 +43 -6
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
- package/templates/skills/kld-sdd/opsx-apply/reference.md +1 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +9 -13
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +54 -328
- package/templates/skills/kld-sdd/opsx-check/checklist.md +7 -4
- package/templates/skills/kld-sdd/opsx-check/reference.md +58 -0
- package/templates/skills/kld-sdd/opsx-check/result-template.json +52 -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-ingest/SKILL.md +12 -74
- 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 +28 -73
- package/templates/skills/kld-sdd/opsx-propose/checklist.md +8 -8
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +28 -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 +38 -32
- 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
- package/lib/device-auth-cli.js +0 -130
- package/lib/device-auth.js +0 -345
- package/lib/init-account-binding.js +0 -108
|
@@ -1,6 +1,10 @@
|
|
|
1
|
-
// kld-T04 — 使用事件 reporter(
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// kld-T04 — 使用事件 reporter(git 身份缓存化韧性版)
|
|
2
|
+
// 职责:git 身份取自 ~/.kld-sdd/git-identity.json 持久缓存(git 仅由 git-identity.cjs
|
|
3
|
+
// 后台刷新读取,慢 git 环境前台零调用);无身份当前事件入 pending 队列不跳过,
|
|
4
|
+
// 身份就绪后 flush 时注入身份补报;
|
|
5
|
+
// server 四级解析(config > kb-state.api > KLD_SDD_SERVER > 内置默认);
|
|
6
|
+
// 单一 3s deadline;pending JSONL 原子追加;100 条上限;1MB 淘汰;
|
|
7
|
+
// HTTP 状态分类(confirmed/retry/drop,401 归入 drop 不清任何本地状态);
|
|
4
8
|
// 并发安全(appendFileSync O_APPEND + 事件 id 差集提交);不改原业务退出码。
|
|
5
9
|
'use strict';
|
|
6
10
|
|
|
@@ -13,11 +17,12 @@ const {
|
|
|
13
17
|
classifyUsageSubmitResponse,
|
|
14
18
|
postJson,
|
|
15
19
|
mapCommandToUsageSkill,
|
|
20
|
+
DEFAULT_SERVER_URL,
|
|
16
21
|
} = require('./usage-contract.cjs');
|
|
17
22
|
const {
|
|
18
23
|
readUserConfig,
|
|
19
|
-
clearUserToken,
|
|
20
24
|
} = require('./user-config.cjs');
|
|
25
|
+
const gitIdentity = require('./git-identity.cjs');
|
|
21
26
|
|
|
22
27
|
const PENDING_FILE_NAME = 'pending-usage.jsonl';
|
|
23
28
|
const PENDING_MAX_BYTES = 1024 * 1024; // 1 MB
|
|
@@ -26,6 +31,27 @@ const SHARED_DEADLINE_MS = 3000;
|
|
|
26
31
|
// 单行事件 JSON 的安全上限(小于 4KB 以保证 appendFileSync 在 POSIX/Windows 都原子)
|
|
27
32
|
const SINGLE_EVENT_MAX_BYTES = 4096;
|
|
28
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
|
+
|
|
29
55
|
function pendingPathFor(homeDir) {
|
|
30
56
|
const envHome = typeof process !== 'undefined' && process && process.env
|
|
31
57
|
? process.env.KLD_SDD_HOME
|
|
@@ -34,6 +60,74 @@ function pendingPathFor(homeDir) {
|
|
|
34
60
|
return path.join(home, '.kld-sdd', PENDING_FILE_NAME);
|
|
35
61
|
}
|
|
36
62
|
|
|
63
|
+
// git 身份进程内缓存:仅缓存成功读取;失败不缓存,下次重试
|
|
64
|
+
let gitIdentityCache = null;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读取本机 git 身份。
|
|
68
|
+
* - 来源:~/.kld-sdd/git-identity.json 持久缓存(git-identity.cjs 维护,
|
|
69
|
+
* git 仅由 detached 后台刷新进程读取——慢 git 环境(7-34s 启动)前台零调用)
|
|
70
|
+
* - 进程内缓存:首次成功后复用;失败不缓存
|
|
71
|
+
* - 无缓存 / 缓存缺字段 / tombstone → 返回 null(调用方将当前事件入队待补报,
|
|
72
|
+
* 并已由 ensureIdentity 触发一次后台刷新)
|
|
73
|
+
*
|
|
74
|
+
* @param {string} [homeDir] — 测试注入 home;缺省走 KLD_SDD_HOME/os.homedir
|
|
75
|
+
* @returns {{userName:string, email:string}|null}
|
|
76
|
+
*/
|
|
77
|
+
function readGitIdentity(homeDir) {
|
|
78
|
+
if (gitIdentityCache) return gitIdentityCache;
|
|
79
|
+
const identity = gitIdentity.ensureIdentity({ homeDir });
|
|
80
|
+
if (!identity) return null;
|
|
81
|
+
gitIdentityCache = identity;
|
|
82
|
+
return gitIdentityCache;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 测试专用:重置进程内 git 身份缓存。
|
|
87
|
+
*/
|
|
88
|
+
function _resetGitIdentityCache() {
|
|
89
|
+
gitIdentityCache = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 四级解析上报地址:config.server → 项目 kb-state.json api → env KLD_SDD_SERVER → 内置默认。
|
|
94
|
+
* 每级均经 normalizeServerUrl;结果永远非空(默认地址兜底)。
|
|
95
|
+
*
|
|
96
|
+
* @param {{homeDir?:string, projectRoot?:string, env?:object}} [options]
|
|
97
|
+
* @returns {string}
|
|
98
|
+
*/
|
|
99
|
+
function resolveReportServer(options = {}) {
|
|
100
|
+
const env = options.env || process.env;
|
|
101
|
+
const projectRoot = options.projectRoot || process.cwd();
|
|
102
|
+
// 1. 用户级 config.server
|
|
103
|
+
const config = readUserConfig(options.homeDir);
|
|
104
|
+
if (config && typeof config.server === 'string') {
|
|
105
|
+
const normalized = normalizeServerUrl(config.server);
|
|
106
|
+
if (normalized) return normalized;
|
|
107
|
+
}
|
|
108
|
+
// 2. 项目级 kb-state.json 的 api(kb-state.api 是唯一带 /api 契约的来源,先剥离尾部 /api 再标准化)
|
|
109
|
+
try {
|
|
110
|
+
const kbStatePath = path.join(projectRoot, 'kb-state.json');
|
|
111
|
+
if (fs.existsSync(kbStatePath)) {
|
|
112
|
+
const kbState = JSON.parse(fs.readFileSync(kbStatePath, 'utf8'));
|
|
113
|
+
if (kbState && typeof kbState.api === 'string') {
|
|
114
|
+
const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
|
|
115
|
+
const normalized = normalizeServerUrl(stripped);
|
|
116
|
+
if (normalized) return normalized;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch {
|
|
120
|
+
// kb-state 读取失败时静默,继续下一级
|
|
121
|
+
}
|
|
122
|
+
// 3. env KLD_SDD_SERVER
|
|
123
|
+
if (env && typeof env.KLD_SDD_SERVER === 'string') {
|
|
124
|
+
const normalized = normalizeServerUrl(env.KLD_SDD_SERVER);
|
|
125
|
+
if (normalized) return normalized;
|
|
126
|
+
}
|
|
127
|
+
// 4. 内置默认地址兜底
|
|
128
|
+
return normalizeServerUrl(DEFAULT_SERVER_URL);
|
|
129
|
+
}
|
|
130
|
+
|
|
37
131
|
/**
|
|
38
132
|
* 生成事件唯一 id(基于时间戳 + pid + 随机字节)。
|
|
39
133
|
* 用于 flush 提交时按 id 差集删除已确认行,避免 read-modify-write 覆盖并发 append。
|
|
@@ -174,21 +268,113 @@ function appendPending(event, filePath) {
|
|
|
174
268
|
}
|
|
175
269
|
|
|
176
270
|
/**
|
|
177
|
-
*
|
|
178
|
-
* @returns {Promise<'confirmed'|'
|
|
271
|
+
* 提交单条事件;发送前注入 git 身份;返回分类。
|
|
272
|
+
* @returns {Promise<'confirmed'|'retry'|'drop'>}
|
|
179
273
|
*/
|
|
180
|
-
async function submitOne(server,
|
|
181
|
-
// 提交时剔除内部 id
|
|
182
|
-
|
|
274
|
+
async function submitOne(server, identity, event, timeoutMs) {
|
|
275
|
+
// 提交时剔除内部 id(不上传给服务端),统一注入当前 git 身份
|
|
276
|
+
// (兼容 pending 队列里无身份字段的遗留事件)
|
|
277
|
+
const outgoing = Object.assign({}, event, {
|
|
278
|
+
userName: identity.userName,
|
|
279
|
+
email: identity.email,
|
|
280
|
+
});
|
|
183
281
|
delete outgoing.id;
|
|
184
282
|
const result = await postJson(
|
|
185
283
|
`${server}/api/v1/usage-events`,
|
|
186
284
|
outgoing,
|
|
187
|
-
{
|
|
285
|
+
{ timeoutMs },
|
|
188
286
|
);
|
|
189
287
|
return classifyUsageSubmitResponse(result.statusCode);
|
|
190
288
|
}
|
|
191
289
|
|
|
290
|
+
/**
|
|
291
|
+
* 共享冲刷执行体:读 pending → 逐条提交 → 按 id 差集原子提交队列变化。
|
|
292
|
+
* reportUsageOnce(冲刷后发当前事件)与 flushPendingUsage(只冲刷)共用。
|
|
293
|
+
*
|
|
294
|
+
* @returns {Promise<{submitted:number, confirmedIds:Set, droppedIds:Set}>}
|
|
295
|
+
*/
|
|
296
|
+
async function flushPendingQueue(server, identity, pendingFile, deadlineStart) {
|
|
297
|
+
const deadline = deadlineStart || (Date.now() + SHARED_DEADLINE_MS);
|
|
298
|
+
const confirmedIds = new Set(); // 已成功的事件 id
|
|
299
|
+
const droppedIds = new Set(); // 4xx 永久失败的事件 id
|
|
300
|
+
let submitted = 0;
|
|
301
|
+
|
|
302
|
+
const { events: pendingEvents } = readPending(pendingFile);
|
|
303
|
+
let flushed = 0;
|
|
304
|
+
for (let i = 0; i < pendingEvents.length; i++) {
|
|
305
|
+
const ev = pendingEvents[i];
|
|
306
|
+
const remaining = deadline - Date.now();
|
|
307
|
+
if (flushed >= FLUSH_MAX_COUNT || remaining <= 0) break;
|
|
308
|
+
|
|
309
|
+
const timeoutMs = Math.max(50, remaining);
|
|
310
|
+
let classification;
|
|
311
|
+
try {
|
|
312
|
+
classification = await submitOne(server, identity, ev, timeoutMs);
|
|
313
|
+
} catch {
|
|
314
|
+
classification = 'retry';
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (classification === 'confirmed') {
|
|
318
|
+
flushed += 1;
|
|
319
|
+
submitted += 1;
|
|
320
|
+
if (ev.id) confirmedIds.add(ev.id);
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
if (classification === 'drop') {
|
|
324
|
+
// drop 丢弃该条(含 401),不 retained、不 break,继续下一条
|
|
325
|
+
warnUsageDropOnce();
|
|
326
|
+
if (ev.id) droppedIds.add(ev.id);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
// retry:网络/5xx/429/timeout,本次及后续保留
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// 原子提交队列变化(按 id 差集删除已确认 + 已丢弃)
|
|
334
|
+
// 关键:rewrite 前必须重新读文件,把并发 append 的新行合并进来
|
|
335
|
+
if (confirmedIds.size > 0 || droppedIds.size > 0) {
|
|
336
|
+
const { events: latestEvents } = readPending(pendingFile);
|
|
337
|
+
const retained = latestEvents.filter((e) => {
|
|
338
|
+
if (e.id && confirmedIds.has(e.id)) return false;
|
|
339
|
+
if (e.id && droppedIds.has(e.id)) return false;
|
|
340
|
+
return true;
|
|
341
|
+
});
|
|
342
|
+
writePendingAtomic(pendingFile, retained);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return { submitted, confirmedIds, droppedIds };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 只冲刷 pending 队列,不产生新事件(cmdEnd / git 身份就绪后的补报入口)。
|
|
350
|
+
* 无身份返回 skipped 零请求;任何失败均不抛。
|
|
351
|
+
*
|
|
352
|
+
* @param {object} [options]
|
|
353
|
+
* @param {string} [options.homeDir] — 测试注入
|
|
354
|
+
* @param {string} [options.projectRoot] — kb-state.json 查找目录,缺省 process.cwd()
|
|
355
|
+
* @returns {Promise<{submitted:number,enqueued:boolean,skipped:boolean}>}
|
|
356
|
+
*/
|
|
357
|
+
async function flushPendingUsage(options = {}) {
|
|
358
|
+
const result = { submitted: 0, enqueued: false, skipped: false };
|
|
359
|
+
try {
|
|
360
|
+
const identity = readGitIdentity(options.homeDir);
|
|
361
|
+
if (!identity) {
|
|
362
|
+
result.skipped = true;
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
const pendingFile = pendingPathFor(options.homeDir);
|
|
366
|
+
const { events: pendingEvents } = readPending(pendingFile);
|
|
367
|
+
if (pendingEvents.length === 0) return result;
|
|
368
|
+
const server = resolveReportServer({ homeDir: options.homeDir, projectRoot: options.projectRoot });
|
|
369
|
+
const flushed = await flushPendingQueue(server, identity, pendingFile);
|
|
370
|
+
result.submitted = flushed.submitted;
|
|
371
|
+
return result;
|
|
372
|
+
} catch {
|
|
373
|
+
// 兜底:永不抛到调用方
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
192
378
|
/**
|
|
193
379
|
* 上报一次使用事件(主入口)。任何失败均不抛。
|
|
194
380
|
*
|
|
@@ -196,119 +382,69 @@ async function submitOne(server, token, event, timeoutMs) {
|
|
|
196
382
|
* @param {string} options.skill — 七值之一;非七值立即返回
|
|
197
383
|
* @param {string} [options.startedAt] — ISO8601;缺省取当前
|
|
198
384
|
* @param {string} [options.homeDir] — 测试注入
|
|
199
|
-
* @param {
|
|
200
|
-
* @returns {Promise<{submitted:number,enqueued:boolean,
|
|
385
|
+
* @param {string} [options.projectRoot] — kb-state.json 查找目录,缺省 process.cwd()
|
|
386
|
+
* @returns {Promise<{submitted:number,enqueued:boolean,skipped:boolean}>}
|
|
201
387
|
*/
|
|
202
388
|
async function reportUsageOnce(options = {}) {
|
|
203
|
-
const result = { submitted: 0, enqueued: false,
|
|
389
|
+
const result = { submitted: 0, enqueued: false, skipped: false };
|
|
204
390
|
const skill = mapCommandToUsageSkill(options.skill);
|
|
205
391
|
if (!skill) {
|
|
206
392
|
result.skipped = true;
|
|
207
393
|
return result;
|
|
208
394
|
}
|
|
209
|
-
|
|
395
|
+
|
|
396
|
+
const startedAt = typeof options.startedAt === 'string' && options.startedAt
|
|
397
|
+
? options.startedAt
|
|
398
|
+
: new Date().toISOString();
|
|
399
|
+
const currentEvent = { skill, startedAt, id: generateEventId() };
|
|
400
|
+
const pendingFile = pendingPathFor(options.homeDir);
|
|
210
401
|
|
|
211
402
|
try {
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
403
|
+
// 无 git 身份:当前事件入队(无身份字段,与遗留事件同构),本次冲刷整体跳过、
|
|
404
|
+
// 队列原有内容保留;身份就绪后由后续 flush 注入身份补报(慢 git 环境不静默丢失)。
|
|
405
|
+
const identity = readGitIdentity(options.homeDir);
|
|
406
|
+
if (!identity) {
|
|
407
|
+
try {
|
|
408
|
+
appendPending(currentEvent, pendingFile);
|
|
409
|
+
result.enqueued = true;
|
|
410
|
+
} catch { /* append 失败静默,不改退出码 */ }
|
|
220
411
|
return result;
|
|
221
412
|
}
|
|
222
|
-
const
|
|
223
|
-
|
|
224
|
-
const startedAt = typeof options.startedAt === 'string' && options.startedAt
|
|
225
|
-
? options.startedAt
|
|
226
|
-
: new Date().toISOString();
|
|
227
|
-
const currentEvent = { skill, startedAt, id: generateEventId() };
|
|
413
|
+
const server = resolveReportServer({ homeDir: options.homeDir, projectRoot: options.projectRoot });
|
|
228
414
|
|
|
229
|
-
const pendingFile = pendingPathFor(options.homeDir);
|
|
230
|
-
const deadline = Date.now() + SHARED_DEADLINE_MS;
|
|
231
415
|
const MAX_TIMEOUT_SKEW_MS = 500; // 调度容差
|
|
416
|
+
// 共享 3s deadline:冲刷与当前事件共用同一预算
|
|
417
|
+
const deadline = Date.now() + SHARED_DEADLINE_MS;
|
|
232
418
|
|
|
233
|
-
// 1. 先冲刷 pending(最多 100
|
|
234
|
-
const
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
const failedEarly =
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const ev = pendingEvents[i];
|
|
242
|
-
const remaining = deadline - Date.now();
|
|
243
|
-
if (flushed >= FLUSH_MAX_COUNT || remaining <= 0) break;
|
|
244
|
-
|
|
245
|
-
const timeoutMs = Math.max(50, remaining);
|
|
246
|
-
let classification;
|
|
247
|
-
try {
|
|
248
|
-
classification = await submitOne(server, token, ev, timeoutMs);
|
|
249
|
-
} catch {
|
|
250
|
-
classification = 'retry';
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
if (classification === 'confirmed') {
|
|
254
|
-
flushed += 1;
|
|
255
|
-
result.submitted += 1;
|
|
256
|
-
if (ev.id) confirmedIds.add(ev.id);
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
if (classification === 'unauthorized') {
|
|
260
|
-
// 401:原子清除 token;本次及后续不再请求
|
|
261
|
-
try {
|
|
262
|
-
if (clearUserToken(options.homeDir)) {
|
|
263
|
-
result.tokenCleared = true;
|
|
264
|
-
onNotify('检测到平台凭证已失效,请运行 `kld-sdd auth login` 重新绑定。');
|
|
265
|
-
}
|
|
266
|
-
} catch { /* ignore */ }
|
|
267
|
-
failedEarly.hit = true;
|
|
268
|
-
failedEarly.kind = 'unauthorized';
|
|
269
|
-
break;
|
|
270
|
-
}
|
|
271
|
-
if (classification === 'drop') {
|
|
272
|
-
// kld-fixback YELLOW#4: drop 丢弃该条,不 retained、不 break,继续下一条
|
|
273
|
-
if (ev.id) droppedIds.add(ev.id);
|
|
274
|
-
continue;
|
|
275
|
-
}
|
|
276
|
-
// retry:网络/5xx/429/timeout,本次及后续保留
|
|
277
|
-
failedEarly.hit = true;
|
|
278
|
-
failedEarly.kind = 'retry';
|
|
279
|
-
break;
|
|
280
|
-
}
|
|
419
|
+
// 1. 先冲刷 pending(最多 100 条;retry 提前终止时剩余保留)
|
|
420
|
+
const flushOutcome = await flushPendingQueue(server, identity, pendingFile, deadline);
|
|
421
|
+
result.submitted += flushOutcome.submitted;
|
|
422
|
+
// 冲刷提前失败判定:零提交、零 drop,且队列仍有残留(retry 语义)
|
|
423
|
+
const failedEarly = flushOutcome.submitted === 0
|
|
424
|
+
&& flushOutcome.confirmedIds.size === 0
|
|
425
|
+
&& flushOutcome.droppedIds.size === 0
|
|
426
|
+
&& readPending(pendingFile).events.length > 0;
|
|
281
427
|
|
|
282
428
|
// 2. 发当前事件(若仍有预算且未提前失败)
|
|
283
|
-
//
|
|
284
|
-
// 『网络/超时/429/5xx 将事件追加或保留在 pending 队列』一致),不得静默丢弃。
|
|
429
|
+
// flush 提前失败时当前事件必须入队,不得静默丢弃。
|
|
285
430
|
let currentOutcome = 'pending'; // 'confirmed' | 'enqueued' | 'dropped' | 'pending'
|
|
286
|
-
if (failedEarly
|
|
431
|
+
if (failedEarly) {
|
|
287
432
|
currentOutcome = 'enqueued';
|
|
288
433
|
} else {
|
|
289
434
|
const remaining = deadline - Date.now();
|
|
290
435
|
if (remaining > MAX_TIMEOUT_SKEW_MS) {
|
|
291
436
|
let classification;
|
|
292
437
|
try {
|
|
293
|
-
classification = await submitOne(server,
|
|
438
|
+
classification = await submitOne(server, identity, currentEvent, remaining);
|
|
294
439
|
} catch {
|
|
295
440
|
classification = 'retry';
|
|
296
441
|
}
|
|
297
442
|
if (classification === 'confirmed') {
|
|
298
443
|
result.submitted += 1;
|
|
299
444
|
currentOutcome = 'confirmed';
|
|
300
|
-
} else if (classification === 'unauthorized') {
|
|
301
|
-
try {
|
|
302
|
-
if (clearUserToken(options.homeDir)) {
|
|
303
|
-
result.tokenCleared = true;
|
|
304
|
-
onNotify('检测到平台凭证已失效,请运行 `kld-sdd auth login` 重新绑定。');
|
|
305
|
-
}
|
|
306
|
-
} catch { /* ignore */ }
|
|
307
|
-
currentOutcome = 'enqueued';
|
|
308
|
-
failedEarly.hit = true;
|
|
309
|
-
failedEarly.kind = 'unauthorized';
|
|
310
445
|
} else if (classification === 'drop') {
|
|
311
|
-
//
|
|
446
|
+
// 当前事件 drop(含 401)直接丢弃不入队,不清任何本地状态
|
|
447
|
+
warnUsageDropOnce();
|
|
312
448
|
currentOutcome = 'dropped';
|
|
313
449
|
} else {
|
|
314
450
|
currentOutcome = 'enqueued';
|
|
@@ -318,18 +454,8 @@ async function reportUsageOnce(options = {}) {
|
|
|
318
454
|
}
|
|
319
455
|
}
|
|
320
456
|
|
|
321
|
-
// 3.
|
|
322
|
-
// 关键:rewrite 前必须重新读文件,把并发 append 的新行合并进来
|
|
457
|
+
// 3. append 当前事件(若需要)
|
|
323
458
|
try {
|
|
324
|
-
if (confirmedIds.size > 0 || droppedIds.size > 0) {
|
|
325
|
-
const { events: latestEvents } = readPending(pendingFile);
|
|
326
|
-
const retained = latestEvents.filter((e) => {
|
|
327
|
-
if (e.id && confirmedIds.has(e.id)) return false;
|
|
328
|
-
if (e.id && droppedIds.has(e.id)) return false;
|
|
329
|
-
return true;
|
|
330
|
-
});
|
|
331
|
-
writePendingAtomic(pendingFile, retained);
|
|
332
|
-
}
|
|
333
459
|
if (currentOutcome === 'enqueued') {
|
|
334
460
|
appendPending(currentEvent, pendingFile);
|
|
335
461
|
result.enqueued = true;
|
|
@@ -345,8 +471,13 @@ async function reportUsageOnce(options = {}) {
|
|
|
345
471
|
|
|
346
472
|
module.exports = {
|
|
347
473
|
reportUsageOnce,
|
|
474
|
+
flushPendingUsage,
|
|
348
475
|
appendPending,
|
|
349
476
|
readPending,
|
|
477
|
+
readGitIdentity,
|
|
478
|
+
resolveReportServer,
|
|
479
|
+
_resetGitIdentityCache,
|
|
480
|
+
_resetDropWarned,
|
|
350
481
|
PENDING_FILE_NAME,
|
|
351
482
|
PENDING_MAX_BYTES,
|
|
352
483
|
FLUSH_MAX_COUNT,
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
// kld-T02 — ~/.kld-sdd/config.json 用户级配置管理
|
|
2
|
-
// 职责:读取、规范化、原子写、0600/Windows ACL
|
|
3
|
-
//
|
|
2
|
+
// 职责:读取、规范化、原子写、0600/Windows ACL。
|
|
3
|
+
// schema v2 = {version, server};使用统计按 git 身份上报,不再保存任何 token。
|
|
4
|
+
// 旧版含 token/userName/boundAt 的 config 读取时 sanitize 为 {version, server},回写即淘汰。
|
|
4
5
|
'use strict';
|
|
5
6
|
|
|
6
7
|
const fs = require('node:fs');
|
|
7
8
|
const os = require('node:os');
|
|
8
9
|
const path = require('node:path');
|
|
9
10
|
|
|
10
|
-
const USER_CONFIG_VERSION =
|
|
11
|
+
const USER_CONFIG_VERSION = 2;
|
|
11
12
|
const CONFIG_DIR_NAME = '.kld-sdd';
|
|
12
13
|
const CONFIG_FILE_NAME = 'config.json';
|
|
13
14
|
|
|
@@ -32,9 +33,23 @@ function getConfigPath(homeDir) {
|
|
|
32
33
|
return path.join(getConfigDir(homeDir), CONFIG_FILE_NAME);
|
|
33
34
|
}
|
|
34
35
|
|
|
36
|
+
/**
|
|
37
|
+
* 将任意已解析对象规范化为 v2 config:只保留 {version, server}。
|
|
38
|
+
* @param {object} parsed
|
|
39
|
+
* @returns {{version: number, server?: string}}
|
|
40
|
+
*/
|
|
41
|
+
function sanitizeConfig(parsed) {
|
|
42
|
+
const config = { version: USER_CONFIG_VERSION };
|
|
43
|
+
if (typeof parsed.server === 'string') {
|
|
44
|
+
config.server = parsed.server;
|
|
45
|
+
}
|
|
46
|
+
return config;
|
|
47
|
+
}
|
|
48
|
+
|
|
35
49
|
/**
|
|
36
50
|
* 读取用户配置。
|
|
37
51
|
* - 文件不存在 / 损坏 JSON / 非对象 → 返回 null(不抛)
|
|
52
|
+
* - 旧版多余字段(token/userName/boundAt 等)读取即忽略,不回传
|
|
38
53
|
* - 返回新引用;调用方修改不影响后续读取
|
|
39
54
|
*
|
|
40
55
|
* @param {string} [homeDir]
|
|
@@ -55,12 +70,7 @@ function readUserConfig(homeDir) {
|
|
|
55
70
|
return null;
|
|
56
71
|
}
|
|
57
72
|
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
|
-
}
|
|
73
|
+
return sanitizeConfig(parsed);
|
|
64
74
|
}
|
|
65
75
|
|
|
66
76
|
/**
|
|
@@ -83,6 +93,7 @@ function hardenPermissions(target, kind) {
|
|
|
83
93
|
|
|
84
94
|
/**
|
|
85
95
|
* 原子写入用户配置:先 temp 后 rename,目录与文件均设置权限。
|
|
96
|
+
* 只持久化 {version, server},多余字段写入即淘汰。
|
|
86
97
|
* @param {object} config — 必须含 version 字段
|
|
87
98
|
* @param {string} [homeDir]
|
|
88
99
|
* @throws {Error} config 非法 / 缺 version
|
|
@@ -100,7 +111,7 @@ function writeUserConfig(config, homeDir) {
|
|
|
100
111
|
hardenPermissions(dir, 'dir');
|
|
101
112
|
|
|
102
113
|
const tmp = path.join(dir, `.${CONFIG_FILE_NAME}.${process.pid}.${Date.now()}.tmp`);
|
|
103
|
-
const payload = JSON.stringify(config, null, 2);
|
|
114
|
+
const payload = JSON.stringify(sanitizeConfig(config), null, 2);
|
|
104
115
|
fs.writeFileSync(tmp, payload, { encoding: 'utf8', mode: 0o600 });
|
|
105
116
|
hardenPermissions(tmp, 'file');
|
|
106
117
|
try {
|
|
@@ -112,46 +123,10 @@ function writeUserConfig(config, homeDir) {
|
|
|
112
123
|
hardenPermissions(file, 'file');
|
|
113
124
|
}
|
|
114
125
|
|
|
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
126
|
module.exports = {
|
|
150
127
|
USER_CONFIG_VERSION,
|
|
151
128
|
getConfigDir,
|
|
152
129
|
getConfigPath,
|
|
153
130
|
readUserConfig,
|
|
154
131
|
writeUserConfig,
|
|
155
|
-
clearUserToken,
|
|
156
|
-
redactToken,
|
|
157
132
|
};
|
|
@@ -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,
|
|
@@ -200,7 +200,7 @@ function identityFromValues(values = {}) {
|
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
function flushRequirementRef(requirementRefs, currentRef) {
|
|
203
|
-
if (!currentRef) return
|
|
203
|
+
if (!currentRef) return;
|
|
204
204
|
const { normalize, validate } = require('./external-key.cjs');
|
|
205
205
|
const system = currentRef.system;
|
|
206
206
|
const objectType = currentRef['object-type'] || currentRef.object_type;
|
|
@@ -224,7 +224,6 @@ function flushRequirementRef(requirementRefs, currentRef) {
|
|
|
224
224
|
}
|
|
225
225
|
requirementRefs.push(entry);
|
|
226
226
|
}
|
|
227
|
-
return null;
|
|
228
227
|
}
|
|
229
228
|
|
|
230
229
|
function parseStructuredFrontmatter(lines) {
|
|
@@ -247,7 +246,8 @@ function parseStructuredFrontmatter(lines) {
|
|
|
247
246
|
const nested = line.match(/^\s{2,}([A-Za-z0-9_-]+):\s*(.*?)\s*$/);
|
|
248
247
|
|
|
249
248
|
if (topKey && !/^\s/.test(line)) {
|
|
250
|
-
|
|
249
|
+
flushRequirementRef(requirementRefs, currentRef);
|
|
250
|
+
currentRef = null;
|
|
251
251
|
section = topKey[1].toLowerCase();
|
|
252
252
|
const value = unwrapScalar(stripInlineYamlComment(topKey[2]));
|
|
253
253
|
flat[section] = value;
|
|
@@ -264,7 +264,7 @@ function parseStructuredFrontmatter(lines) {
|
|
|
264
264
|
}
|
|
265
265
|
|
|
266
266
|
if (section === 'requirement-refs' && listItem) {
|
|
267
|
-
|
|
267
|
+
flushRequirementRef(requirementRefs, currentRef);
|
|
268
268
|
currentRef = { [listItem[1].toLowerCase()]: unwrapScalar(stripInlineYamlComment(listItem[2])) };
|
|
269
269
|
continue;
|
|
270
270
|
}
|
|
@@ -669,7 +669,8 @@ function addStructuralEntities(target, files, options = {}) {
|
|
|
669
669
|
name: descriptor.file.relativePath,
|
|
670
670
|
entity_id: identity.entity_id,
|
|
671
671
|
version_id: identity.version_id,
|
|
672
|
-
delta_state: 'added',
|
|
672
|
+
delta_state: identity.delta_state || 'added',
|
|
673
|
+
predecessor_version_id: identity.predecessor_version_id || undefined,
|
|
673
674
|
content_hash: descriptor.content_hash,
|
|
674
675
|
attributes: { artifact_type: artifactType(descriptor.file.relativePath), path: descriptor.file.relativePath },
|
|
675
676
|
source: sourceFor(descriptor.file, 1, identity.anchor_id),
|
|
@@ -685,7 +686,8 @@ function addStructuralEntities(target, files, options = {}) {
|
|
|
685
686
|
name: `${owner.source.file}#${owner.id}`,
|
|
686
687
|
entity_id: identity.entity_id,
|
|
687
688
|
version_id: identity.version_id,
|
|
688
|
-
delta_state: 'added',
|
|
689
|
+
delta_state: identity.delta_state || 'added',
|
|
690
|
+
predecessor_version_id: identity.predecessor_version_id || undefined,
|
|
689
691
|
content_hash: descriptor.content_hash,
|
|
690
692
|
attributes: { artifact_path: owner.source.file, entity_anchor: owner.id },
|
|
691
693
|
source: { ...owner.source, anchor_id: identity.anchor_id },
|