kld-sdd 2.6.7 → 2.6.9

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.
Files changed (39) hide show
  1. package/bin/kld-sdd-init.js +39 -3
  2. package/lib/init.js +320 -45
  3. package/lib/workspace-layout.js +2 -0
  4. package/package.json +2 -2
  5. package/skywalk-sdd/context-client.cjs +59 -5
  6. package/skywalk-sdd/ontology/active-changes.cjs +297 -0
  7. package/skywalk-sdd/ontology/change-key.cjs +241 -0
  8. package/skywalk-sdd/ontology/cli.cjs +135 -0
  9. package/skywalk-sdd/ontology/list-changes.cjs +110 -0
  10. package/skywalk-sdd/ontology/modules.cjs +167 -0
  11. package/skywalk-sdd/ontology/naming-diagnose.cjs +594 -0
  12. package/skywalk-sdd/ontology/sdd-config.cjs +335 -0
  13. package/skywalk-sdd/ontology/workspace-layout.cjs +194 -0
  14. package/templates/dot-sdd.yaml +8 -0
  15. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +224 -0
  16. package/templates/modules.yaml +13 -0
  17. package/templates/openspec/proposal.md +7 -1
  18. package/templates/sdd.config.yaml +12 -0
  19. package/templates/skills/kld-sdd/openspec-sync-specs/SKILL.md +148 -0
  20. package/templates/skills/kld-sdd/openspec-update-change/SKILL.md +86 -0
  21. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +3 -3
  22. package/templates/skills/kld-sdd/opsx-apply/checklist.md +1 -1
  23. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +11 -1
  24. package/templates/skills/kld-sdd/opsx-check/SKILL.md +73 -3
  25. package/templates/skills/kld-sdd/opsx-design/SKILL.md +9 -0
  26. package/templates/skills/kld-sdd/opsx-explore/SKILL.md +37 -17
  27. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +9 -14
  28. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +83 -109
  29. package/templates/skills/kld-sdd/opsx-ontology-query/phase-1-prechange.md +276 -0
  30. package/templates/skills/kld-sdd/opsx-ontology-query/phase-2-during.md +354 -0
  31. package/templates/skills/kld-sdd/opsx-ontology-query/phase-3-postchange.md +223 -0
  32. package/templates/skills/kld-sdd/opsx-ontology-query/phase-4-explore.md +240 -0
  33. package/templates/skills/kld-sdd/opsx-ontology-query/phase-5-governance.md +232 -0
  34. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +92 -4
  35. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +87 -16
  36. package/templates/skills/kld-sdd/opsx-propose/checklist.md +1 -0
  37. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +33 -3
  38. package/templates/skills/kld-sdd/opsx-task/SKILL.md +10 -0
  39. package/templates/skills/kld-sdd/opsx-tdd-core/checklist.md +1 -1
@@ -0,0 +1,594 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execFileSync } = require('child_process');
6
+ const changeKey = require('./change-key.cjs');
7
+ const modules = require('./modules.cjs');
8
+ const sddConfig = require('./sdd-config.cjs');
9
+ const activeChanges = require('./active-changes.cjs');
10
+ const { parseFrontmatter } = require('./artifact-parser.cjs');
11
+ const workspaceLayout = require('./workspace-layout.cjs');
12
+
13
+ const STATUS = {
14
+ PASS: 'PASS',
15
+ FIXABLE: 'FIXABLE',
16
+ NEEDS_INPUT: 'NEEDS_INPUT',
17
+ FAIL: 'FAIL',
18
+ SKIP: 'SKIP',
19
+ };
20
+
21
+ function parseAffectedModules(lines) {
22
+ const result = [];
23
+ if (!lines.length || lines[0].trim() !== '---') return result;
24
+ let inList = false;
25
+ for (let index = 1; index < lines.length; index += 1) {
26
+ const line = lines[index].replace(/\r$/, '');
27
+ if (line.trim() === '---') break;
28
+ if (/^affected-modules:\s*$/i.test(line)) {
29
+ inList = true;
30
+ continue;
31
+ }
32
+ if (inList) {
33
+ const item = /^\s*-\s+(.+?)\s*$/.exec(line);
34
+ if (item) {
35
+ result.push(item[1].trim().toLowerCase());
36
+ continue;
37
+ }
38
+ if (/^\S/.test(line)) inList = false;
39
+ }
40
+ }
41
+ return result;
42
+ }
43
+
44
+ function readProposalFrontmatter(changeDir) {
45
+ const proposalPath = path.join(changeDir, 'proposal.md');
46
+ if (!fs.existsSync(proposalPath)) {
47
+ return { ok: false, message: `proposal.md 不存在: ${proposalPath}`, path: proposalPath };
48
+ }
49
+ const lines = fs.readFileSync(proposalPath, 'utf8').split(/\r?\n/);
50
+ const flat = parseFrontmatter(lines);
51
+ flat['affected-modules'] = parseAffectedModules(lines);
52
+ return {
53
+ ok: true,
54
+ path: proposalPath,
55
+ frontmatter: flat,
56
+ };
57
+ }
58
+
59
+ function detectEntryMode(projectRoot) {
60
+ const hasModules = fs.existsSync(path.join(projectRoot, 'modules.yaml'));
61
+ const hasOpenspecChanges = fs.existsSync(path.join(projectRoot, 'openspec', 'changes'));
62
+ const hasSddYaml = fs.existsSync(path.join(projectRoot, '.sdd.yaml'));
63
+ if (hasSddYaml && !hasOpenspecChanges) return 'code-repo';
64
+ if (hasModules || hasOpenspecChanges) return 'spec-repo';
65
+ if (hasSddYaml) return 'code-repo';
66
+ return 'unknown';
67
+ }
68
+
69
+ function checkHookInstalled(projectRoot) {
70
+ const hookPath = path.join(projectRoot, '.git', 'hooks', 'commit-msg');
71
+ const scriptPath = path.join(projectRoot, 'skywalk-sdd', 'git-hooks', 'commit-msg-sdd-trailer.cjs');
72
+ if (!fs.existsSync(path.join(projectRoot, '.git'))) {
73
+ return { status: STATUS.SKIP, message: '非 Git 仓库,跳过 Hook 检查' };
74
+ }
75
+ if (!fs.existsSync(scriptPath)) {
76
+ return {
77
+ status: STATUS.FIXABLE,
78
+ code: 'HOOK_SCRIPT_MISSING',
79
+ message: 'commit-msg 脚本未部署,可重新执行 kld-sdd-init 或手动复制模板',
80
+ fix: { type: 'deploy-hook-script' },
81
+ };
82
+ }
83
+ if (!fs.existsSync(hookPath)) {
84
+ return {
85
+ status: STATUS.FIXABLE,
86
+ code: 'HOOK_NOT_INSTALLED',
87
+ message: 'commit-msg Hook 未安装',
88
+ fix: { type: 'install-commit-msg-hook' },
89
+ };
90
+ }
91
+ const content = fs.readFileSync(hookPath, 'utf8');
92
+ if (!content.includes('commit-msg-sdd-trailer') && !content.includes('KLD SDD')) {
93
+ return {
94
+ status: STATUS.NEEDS_INPUT,
95
+ code: 'HOOK_UNKNOWN',
96
+ message: '已存在自定义 commit-msg Hook,需确认是否接入 SDD Trailer',
97
+ };
98
+ }
99
+ return { status: STATUS.PASS, message: 'commit-msg Hook 已安装' };
100
+ }
101
+
102
+ function diagnoseChangeNaming(projectRoot, changeName, registry) {
103
+ const changeDir = path.join(projectRoot, 'openspec', 'changes', changeName);
104
+ if (!fs.existsSync(changeDir)) {
105
+ return {
106
+ status: STATUS.FAIL,
107
+ code: 'CHANGE_DIR_MISSING',
108
+ message: `change 目录不存在: ${changeName}`,
109
+ };
110
+ }
111
+
112
+ const proposal = readProposalFrontmatter(changeDir);
113
+ if (!proposal.ok) {
114
+ return { status: STATUS.FAIL, code: 'PROPOSAL_MISSING', message: proposal.message };
115
+ }
116
+
117
+ const fm = proposal.frontmatter;
118
+ const title = fm.title || '';
119
+ const module = (fm.module || '').toLowerCase();
120
+ const key = (fm['change-key'] || '').toLowerCase();
121
+ const changeId = fm['change-id'] || '';
122
+ const affected = Array.isArray(fm['affected-modules']) ? fm['affected-modules'] : [];
123
+
124
+ const issues = [];
125
+ const legacy = !key && !module && !title;
126
+
127
+ if (legacy) {
128
+ return {
129
+ status: STATUS.PASS,
130
+ code: 'LEGACY_CHANGE',
131
+ message: '存量 change 缺少 change-key/title/module,归入未分类(兼容)',
132
+ legacy: true,
133
+ frontmatter: fm,
134
+ };
135
+ }
136
+
137
+ if (!title) issues.push({ status: STATUS.FAIL, code: 'TITLE_MISSING', message: 'proposal 缺少 title' });
138
+ if (!module) issues.push({ status: STATUS.FAIL, code: 'MODULE_MISSING', message: 'proposal 缺少 module' });
139
+ if (!key) issues.push({ status: STATUS.FAIL, code: 'CHANGE_KEY_MISSING', message: 'proposal 缺少 change-key' });
140
+ if (!changeId) issues.push({ status: STATUS.FAIL, code: 'CHANGE_ID_MISSING', message: 'proposal 缺少 change-id' });
141
+
142
+ if (key && key !== changeName.toLowerCase()) {
143
+ issues.push({
144
+ status: STATUS.FAIL,
145
+ code: 'CHANGE_KEY_DIR_MISMATCH',
146
+ message: `目录名与 change-key 不一致: ${changeName} vs ${key}`,
147
+ });
148
+ }
149
+
150
+ if (key) {
151
+ const registered = registry && registry.ok ? registry.codes : null;
152
+ const validated = changeKey.validate(key, { registeredModules: registered });
153
+ if (!validated.ok) {
154
+ issues.push({
155
+ status: STATUS.FAIL,
156
+ code: validated.code,
157
+ message: validated.message,
158
+ });
159
+ } else if (module && validated.parts.module !== module) {
160
+ issues.push({
161
+ status: STATUS.NEEDS_INPUT,
162
+ code: 'MODULE_PREFIX_CONFLICT',
163
+ message: `change-key 模块前缀 (${validated.parts.module}) 与 proposal module (${module}) 冲突`,
164
+ });
165
+ }
166
+ }
167
+
168
+ if (module && registry && registry.ok && !modules.hasModule(registry, module)) {
169
+ issues.push({
170
+ status: STATUS.NEEDS_INPUT,
171
+ code: 'MODULE_UNREGISTERED',
172
+ message: `module=${module} 未在 modules.yaml 注册`,
173
+ candidates: Object.keys(registry.modules),
174
+ });
175
+ }
176
+
177
+ if (module === changeKey.RESERVED_CROSS) {
178
+ const list = affected.filter(Boolean);
179
+ if (list.length < 2) {
180
+ issues.push({
181
+ status: STATUS.NEEDS_INPUT,
182
+ code: 'AFFECTED_MODULES_REQUIRED',
183
+ message: 'module=cross 时 affected-modules 至少需要两个已注册模块',
184
+ });
185
+ } else if (registry && registry.ok) {
186
+ for (const code of list) {
187
+ if (!modules.hasModule(registry, code)) {
188
+ issues.push({
189
+ status: STATUS.NEEDS_INPUT,
190
+ code: 'AFFECTED_MODULE_UNREGISTERED',
191
+ message: `affected-modules 含未注册模块: ${code}`,
192
+ });
193
+ }
194
+ }
195
+ }
196
+ }
197
+
198
+ if (key && changeId) {
199
+ const expected = `CHG-${key.toUpperCase()}`;
200
+ if (changeId !== expected && !/^CHG-/i.test(changeId)) {
201
+ issues.push({
202
+ status: STATUS.FAIL,
203
+ code: 'CHANGE_ID_FORMAT',
204
+ message: `change-id 必须以 CHG- 开头: ${changeId}`,
205
+ });
206
+ }
207
+ // 创建后 change-id 可与当前 key 解耦(迁移保留),仅在两者可派生且不一致时提示,不自动改
208
+ if (changeId !== expected && /^CHG-/i.test(changeId)) {
209
+ issues.push({
210
+ status: STATUS.PASS,
211
+ code: 'CHANGE_ID_IMMUTABLE',
212
+ message: `change-id 与当前 key 派生值不同(允许迁移保留): ${changeId}`,
213
+ });
214
+ }
215
+ }
216
+
217
+ const hard = issues.filter((item) => item.status === STATUS.FAIL || item.status === STATUS.NEEDS_INPUT);
218
+ if (hard.length === 0) {
219
+ return {
220
+ status: STATUS.PASS,
221
+ message: 'Change 命名配置完整',
222
+ frontmatter: fm,
223
+ notes: issues,
224
+ };
225
+ }
226
+ const needsInput = hard.some((item) => item.status === STATUS.NEEDS_INPUT);
227
+ return {
228
+ status: needsInput ? STATUS.NEEDS_INPUT : STATUS.FAIL,
229
+ message: hard.map((item) => item.message).join('; '),
230
+ issues: hard,
231
+ frontmatter: fm,
232
+ };
233
+ }
234
+
235
+ function diagnoseSpecRepo(projectRoot, changeName) {
236
+ const checks = [];
237
+ const isGit = fs.existsSync(path.join(projectRoot, '.git'));
238
+ checks.push({
239
+ id: 'spec-git',
240
+ status: isGit ? STATUS.PASS : STATUS.FAIL,
241
+ message: isGit ? 'spec 仓库是 Git 仓库' : '当前目录不是 Git 仓库',
242
+ });
243
+
244
+ if (isGit) {
245
+ const remote = sddConfig.gitRemoteUrl(projectRoot);
246
+ checks.push({
247
+ id: 'spec-remote',
248
+ status: remote ? STATUS.PASS : STATUS.NEEDS_INPUT,
249
+ message: remote ? `remote 可识别: ${remote}` : '无法识别 git remote',
250
+ remote,
251
+ });
252
+ }
253
+
254
+ const registry = modules.loadModulesYaml(projectRoot);
255
+ checks.push({
256
+ id: 'modules-yaml',
257
+ status: registry.ok ? STATUS.PASS : (registry.code === modules.CODES.MISSING ? STATUS.NEEDS_INPUT : STATUS.FAIL),
258
+ message: registry.ok ? `modules.yaml 合法(${registry.codes.size} 个模块)` : registry.message,
259
+ registry,
260
+ });
261
+
262
+ let naming = null;
263
+ if (changeName) {
264
+ naming = diagnoseChangeNaming(projectRoot, changeName, registry.ok ? registry : null);
265
+ checks.push({
266
+ id: 'change-naming',
267
+ status: naming.status,
268
+ message: naming.message,
269
+ detail: naming,
270
+ });
271
+ } else {
272
+ checks.push({
273
+ id: 'change-naming',
274
+ status: STATUS.SKIP,
275
+ message: '未指定 --change,跳过命名一致性检查',
276
+ });
277
+ }
278
+
279
+ checks.push({
280
+ id: 'active-changes',
281
+ ...diagnoseActiveChanges(projectRoot, changeName),
282
+ });
283
+
284
+ // 单仓(openspec 与代码同仓):Hook 装在本仓,应就地检查
285
+ if (isMonoRepoLayout(projectRoot)) {
286
+ checks.push({
287
+ id: 'commit-hook',
288
+ ...checkHookInstalled(projectRoot),
289
+ });
290
+ checks.push({
291
+ id: 'code-repo-association',
292
+ status: STATUS.PASS,
293
+ message: '单仓布局(.sdd.yaml layout: mono):commit 写 Spec-Change,不写 Spec-Revision',
294
+ });
295
+ return {
296
+ mode: 'mono-repo',
297
+ checks,
298
+ summary: summarize(checks),
299
+ };
300
+ }
301
+
302
+ checks.push({
303
+ id: 'code-repo-association',
304
+ status: STATUS.SKIP,
305
+ message: '当前位于 spec 仓库:代码仓 .sdd.yaml / Hook 请在各代码仓库或工作目录诊断',
306
+ });
307
+
308
+ return {
309
+ mode: 'spec-repo',
310
+ checks,
311
+ summary: summarize(checks),
312
+ };
313
+ }
314
+
315
+ /**
316
+ * 单仓:openspec 与代码同仓,由 .sdd.yaml 的 layout: mono 显式声明。
317
+ * 不用启发式,否则独立的 spec 仓 clone 会被误判成单仓。
318
+ */
319
+ function isMonoRepoLayout(projectRoot) {
320
+ return sddConfig.isMonoLayout(path.resolve(projectRoot));
321
+ }
322
+
323
+ /**
324
+ * sdd.config.yaml 活动变更登记表:存在性、格式、当前 change 是否登记、
325
+ * 以及已归档但仍登记的残留项(会被 Hook 误写成 Spec-Change)。
326
+ */
327
+ function diagnoseActiveChanges(specRoot, changeName) {
328
+ const config = activeChanges.loadSddConfig(specRoot);
329
+
330
+ if (!config.ok) {
331
+ if (config.code === activeChanges.CODES.MISSING) {
332
+ return {
333
+ status: STATUS.FIXABLE,
334
+ code: config.code,
335
+ message: `${activeChanges.FILE_NAME} 不存在:代码仓无法获知活动 change,commit 将缺少 Spec-Change`,
336
+ fix: { type: 'deploy-sdd-config' },
337
+ };
338
+ }
339
+ return { status: STATUS.FAIL, code: config.code, message: config.message };
340
+ }
341
+
342
+ const stale = config.activeChanges.filter(
343
+ (entry) => !fs.existsSync(path.join(specRoot, 'openspec', 'changes', entry.changeKey)),
344
+ );
345
+ if (stale.length > 0) {
346
+ return {
347
+ status: STATUS.FIXABLE,
348
+ code: 'ACTIVE_CHANGE_STALE',
349
+ message: `登记项对应目录已不存在(应已归档): ${stale.map((e) => e.changeKey).join(', ')}`,
350
+ fix: { type: 'remove-active-change', changes: stale.map((e) => e.changeKey) },
351
+ activeChanges: config.activeChanges,
352
+ };
353
+ }
354
+
355
+ if (changeName) {
356
+ const key = String(changeName).toLowerCase();
357
+ const registered = config.activeChanges.some((entry) => entry.changeKey === key);
358
+ if (!registered) {
359
+ return {
360
+ status: STATUS.FIXABLE,
361
+ code: 'ACTIVE_CHANGE_UNREGISTERED',
362
+ message: `当前 change 未登记到 ${activeChanges.FILE_NAME}: ${key}`,
363
+ fix: { type: 'register-active-change', change: key },
364
+ activeChanges: config.activeChanges,
365
+ };
366
+ }
367
+ }
368
+
369
+ return {
370
+ status: STATUS.PASS,
371
+ message: `活动变更登记表合法(${config.activeChanges.length} 个活动 change)`,
372
+ activeChanges: config.activeChanges,
373
+ };
374
+ }
375
+
376
+ function diagnoseWorkspaceRepos(workspaceRoot) {
377
+ const root = path.resolve(workspaceRoot);
378
+ const layout = workspaceLayout.detectWorkspaceLayout(root);
379
+ if (!layout.isWorkspace) {
380
+ return {
381
+ id: 'workspace-repos',
382
+ status: STATUS.SKIP,
383
+ message: '非工作区布局,跳过子仓发现',
384
+ };
385
+ }
386
+
387
+ const unattached = layout.codeRepos.filter((repo) => {
388
+ const hasYaml = fs.existsSync(path.join(repo.abs, '.sdd.yaml'));
389
+ const hasHook = fs.existsSync(path.join(repo.abs, '.git', 'hooks', 'commit-msg'));
390
+ return !hasYaml || !hasHook;
391
+ });
392
+
393
+ if (unattached.length === 0) {
394
+ return {
395
+ id: 'workspace-repos',
396
+ status: STATUS.PASS,
397
+ message: `工作区代码仓均已轻量接入(${layout.codeRepos.length})`,
398
+ layout,
399
+ unattached: [],
400
+ };
401
+ }
402
+
403
+ return {
404
+ id: 'workspace-repos',
405
+ status: STATUS.NEEDS_INPUT,
406
+ message: `发现未接入代码仓: ${unattached.map((r) => r.name).join(', ')}。确认后执行 kld-sdd sync-repos`,
407
+ fix: { type: 'sync-repos', repos: unattached.map((r) => r.name) },
408
+ layout,
409
+ unattached,
410
+ };
411
+ }
412
+
413
+ function diagnoseCodeRepo(projectRoot) {
414
+ const checks = [];
415
+ const sddYaml = sddConfig.loadSddYaml(projectRoot);
416
+ checks.push({
417
+ id: 'sdd-yaml',
418
+ status: sddYaml.ok ? STATUS.PASS : (sddYaml.code === sddConfig.CODES.MISSING ? STATUS.NEEDS_INPUT : STATUS.FAIL),
419
+ message: sddYaml.ok ? `spec_repository=${sddYaml.spec_repository}` : sddYaml.message,
420
+ detail: sddYaml,
421
+ });
422
+
423
+ const context = sddConfig.resolveCodeRepoContext(projectRoot);
424
+ if (!context.specPath) {
425
+ checks.push({
426
+ id: 'spec-path',
427
+ status: STATUS.FIXABLE,
428
+ code: context.code,
429
+ message: context.message,
430
+ fix: { type: 'set-spec-path' },
431
+ });
432
+ } else if (context.code === sddConfig.CODES.REMOTE_MISMATCH) {
433
+ checks.push({
434
+ id: 'spec-path',
435
+ status: STATUS.PASS,
436
+ message: `本地路径有效: ${context.specPath} (${context.specPathSource})`,
437
+ });
438
+ checks.push({
439
+ id: 'remote-match',
440
+ status: STATUS.NEEDS_INPUT,
441
+ message: context.message,
442
+ });
443
+ } else if (!context.ok && context.code === sddConfig.CODES.SPEC_PATH_INVALID) {
444
+ checks.push({
445
+ id: 'spec-path',
446
+ status: STATUS.FAIL,
447
+ message: context.message,
448
+ });
449
+ } else {
450
+ checks.push({
451
+ id: 'spec-path',
452
+ status: STATUS.PASS,
453
+ message: `本地路径有效: ${context.specPath} (${context.specPathSource})`,
454
+ });
455
+ checks.push({
456
+ id: 'remote-match',
457
+ status: context.remoteMatches === false ? STATUS.NEEDS_INPUT : STATUS.PASS,
458
+ message: context.remoteMatches === false
459
+ ? context.message
460
+ : 'spec_repository 与本地 remote 一致或暂无可比对象',
461
+ });
462
+ }
463
+
464
+ checks.push({
465
+ id: 'commit-hook',
466
+ ...checkHookInstalled(projectRoot),
467
+ });
468
+
469
+ checks.push({
470
+ id: 'trailer-trace',
471
+ status: STATUS.SKIP,
472
+ message: 'Trailer 可追溯性:请对具体 commit 使用 diagnose-trailer(默认不扫描全历史)',
473
+ });
474
+
475
+ return {
476
+ mode: 'code-repo',
477
+ checks,
478
+ context,
479
+ summary: summarize(checks),
480
+ };
481
+ }
482
+
483
+ function summarize(checks) {
484
+ const counts = {
485
+ PASS: 0,
486
+ FIXABLE: 0,
487
+ NEEDS_INPUT: 0,
488
+ FAIL: 0,
489
+ SKIP: 0,
490
+ };
491
+ for (const item of checks) {
492
+ counts[item.status] = (counts[item.status] || 0) + 1;
493
+ }
494
+ let overall = STATUS.PASS;
495
+ if (counts.FAIL > 0) overall = STATUS.FAIL;
496
+ else if (counts.NEEDS_INPUT > 0) overall = STATUS.NEEDS_INPUT;
497
+ else if (counts.FIXABLE > 0) overall = STATUS.FIXABLE;
498
+ return { overall, counts };
499
+ }
500
+
501
+ function formatReport(result) {
502
+ const lines = [];
503
+ lines.push('多仓库关联配置');
504
+ lines.push(`入口: ${result.mode}`);
505
+ lines.push(`总体: ${result.summary.overall}`);
506
+ lines.push('');
507
+ for (const item of result.checks) {
508
+ lines.push(`${item.id}: ${item.status}`);
509
+ if (item.message) lines.push(` ${item.message}`);
510
+ }
511
+ return lines.join('\n');
512
+ }
513
+
514
+ function diagnose(projectRoot, options = {}) {
515
+ const root = path.resolve(projectRoot || '.');
516
+ const mode = options.mode || detectEntryMode(root);
517
+
518
+ // 个人工作目录:优先做子仓发现
519
+ const layout = workspaceLayout.detectWorkspaceLayout(root);
520
+ if (layout.isWorkspace && mode !== 'code-repo') {
521
+ const checks = [];
522
+ checks.push(diagnoseWorkspaceRepos(root));
523
+ if (layout.specRepo) {
524
+ const specResult = diagnoseSpecRepo(layout.specRepo.abs, options.change);
525
+ checks.push(...specResult.checks.map((item) => ({ ...item, scope: 'spec-repo' })));
526
+ }
527
+ return {
528
+ mode: 'workspace',
529
+ checks,
530
+ layout,
531
+ summary: summarize(checks),
532
+ };
533
+ }
534
+
535
+ if (mode === 'code-repo') {
536
+ return diagnoseCodeRepo(root);
537
+ }
538
+ return diagnoseSpecRepo(root, options.change);
539
+ }
540
+
541
+ function diagnoseTrailer(specRoot, changeKeyValue, revision) {
542
+ const validated = changeKey.validate(changeKeyValue);
543
+ if (!validated.ok) {
544
+ return { ok: false, code: validated.code, message: validated.message };
545
+ }
546
+ if (!sddConfig.objectExists(specRoot, revision)) {
547
+ return {
548
+ ok: false,
549
+ code: 'REVISION_MISSING',
550
+ message: `Spec-Revision 在 spec 仓库中不存在: ${revision}`,
551
+ };
552
+ }
553
+ let treePath;
554
+ try {
555
+ treePath = execFileSync(
556
+ 'git',
557
+ ['ls-tree', '-r', '--name-only', revision, `openspec/changes/${validated.normalized}/proposal.md`],
558
+ {
559
+ cwd: specRoot,
560
+ encoding: 'utf8',
561
+ stdio: ['ignore', 'pipe', 'ignore'],
562
+ },
563
+ ).trim();
564
+ } catch {
565
+ treePath = '';
566
+ }
567
+ if (!treePath) {
568
+ return {
569
+ ok: false,
570
+ code: 'CHANGE_MISSING_AT_REVISION',
571
+ message: `revision ${revision} 下不存在 openspec/changes/${validated.normalized}/proposal.md`,
572
+ };
573
+ }
574
+ return {
575
+ ok: true,
576
+ changeKey: validated.normalized,
577
+ revision,
578
+ proposalPath: treePath,
579
+ };
580
+ }
581
+
582
+ module.exports = {
583
+ STATUS,
584
+ detectEntryMode,
585
+ readProposalFrontmatter,
586
+ diagnoseChangeNaming,
587
+ diagnoseActiveChanges,
588
+ diagnoseWorkspaceRepos,
589
+ isMonoRepoLayout,
590
+ diagnose,
591
+ diagnoseTrailer,
592
+ formatReport,
593
+ summarize,
594
+ };