kld-sdd 2.7.8 → 2.8.0

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 (73) hide show
  1. package/README.md +26 -0
  2. package/USABILITY.md +72 -0
  3. package/bin/kld-sdd-init.js +718 -34
  4. package/lib/command-bridge.js +26 -17
  5. package/lib/deploy-codebuddy-hooks.js +19 -26
  6. package/lib/deploy-strategy.js +293 -0
  7. package/lib/hook-gate-core.js +7 -8
  8. package/lib/hook-install.js +75 -0
  9. package/lib/init.js +507 -726
  10. package/lib/maintenance.js +221 -0
  11. package/lib/managed-hook-hashes.json +24 -0
  12. package/lib/settings-merge.js +102 -25
  13. package/lib/skills-bundle.js +5 -5
  14. package/lib/spec-choice.js +85 -0
  15. package/lib/tool-profiles.js +2 -2
  16. package/lib/uninstall.js +453 -0
  17. package/package.json +7 -4
  18. package/skywalk-sdd/context-client.cjs +38 -7
  19. package/skywalk-sdd/index.cjs +153 -27
  20. package/skywalk-sdd/kb-upload.cjs +224 -82
  21. package/skywalk-sdd/lib/check-review.cjs +41 -0
  22. package/skywalk-sdd/lib/shared.cjs +62 -41
  23. package/skywalk-sdd/lib/test-execution.cjs +130 -0
  24. package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
  25. package/skywalk-sdd/ontology/archive-package.cjs +141 -0
  26. package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
  27. package/skywalk-sdd/ontology/cli.cjs +53 -0
  28. package/skywalk-sdd/ontology/id.cjs +1 -1
  29. package/skywalk-sdd/ontology/identity-index.cjs +5 -1
  30. package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
  31. package/skywalk-sdd/ontology/schema.cjs +6 -0
  32. package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
  33. package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
  34. package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
  35. package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
  36. package/templates/git-hooks/commit-msg +17 -85
  37. package/templates/git-hooks/commit-msg-runner.cjs +44 -0
  38. package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
  39. package/templates/git-hooks/consistency-check-core.cjs +92 -261
  40. package/templates/git-hooks/delivery-check.cjs +231 -0
  41. package/templates/git-hooks/delivery-snapshot.cjs +181 -0
  42. package/templates/git-hooks/hooks.config +8 -6
  43. package/templates/git-hooks/pre-commit +19 -5
  44. package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
  45. package/templates/git-hooks/pre-push +19 -5
  46. package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
  47. package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
  48. package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
  49. package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
  50. package/templates/openspec/proposal.md +1 -1
  51. package/templates/openspec/spec.md +8 -0
  52. package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
  53. package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
  54. package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
  55. package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
  56. package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
  57. package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
  58. package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
  59. package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
  60. package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
  61. package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
  62. package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
  63. package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
  64. package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
  65. package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
  66. package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
  67. package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
  68. package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
  69. package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
  70. package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
  71. package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
  72. package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
  73. package/index.js +0 -13
@@ -0,0 +1,130 @@
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
+ // Surefire/Failsafe print per-class counts and a Results section. Count only
21
+ // the latter, once per module/execution, to avoid double-counting classes.
22
+ const summaries = [];
23
+ let inResults = false;
24
+ for (const raw of output.replace(/\u001b\[[0-9;]*m/g, '').split(/\r?\n/)) {
25
+ const line = raw.replace(/^\[(?:INFO|WARNING|ERROR)\]\s*/, '').trim();
26
+ if (line === 'Results:') { inResults = true; continue; }
27
+ if (!inResults) continue;
28
+ const match = /^Tests run:\s*(\d+),\s*Failures:\s*(\d+),\s*Errors:\s*(\d+),\s*Skipped:\s*(\d+)\b/.exec(line);
29
+ if (!match) continue;
30
+ const [, count, failures, errors, ignored] = match.map(Number);
31
+ if (count < failures + errors + ignored) return { counts_known: false };
32
+ summaries.push({ passed: count - failures - errors - ignored, failed: failures + errors, skipped: ignored });
33
+ inResults = false;
34
+ }
35
+ if (summaries.length) {
36
+ return summaries.reduce((sum, item) => ({ counts_known: true, passed: sum.passed + item.passed,
37
+ failed: sum.failed + item.failed, skipped: sum.skipped + item.skipped }),
38
+ { counts_known: true, passed: 0, failed: 0, skipped: 0 });
39
+ }
40
+ return { counts_known: false };
41
+ }
42
+
43
+ function classifyFailure(output, exitCode, error, signal) {
44
+ if (error?.code === 'ETIMEDOUT') return 'timeout';
45
+ if (error || signal) return 'infrastructure';
46
+ if (exitCode === 0) return 'none';
47
+ // A missing executable/module/test path is not evidence of a failing business assertion.
48
+ 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';
49
+ if (/ERR_ASSERTION|AssertionError|AssertionFailedError|org\.opentest4j\.AssertionFailedError/.test(output)) return 'assertion';
50
+ return 'infrastructure';
51
+ }
52
+
53
+ function captureTest(args, projectRoot, change) {
54
+ let command;
55
+ try { command = JSON.parse(args['command-json'] || 'null'); } catch { /* validated below */ }
56
+ if (!Array.isArray(command) || !command.length || command.some(x => typeof x !== 'string' || x.includes('\0')) || !command[0]) {
57
+ throw new Error('TEST_COMMAND_REQUIRED: --command-json 必须是非空命令参数数组,不通过 shell 执行');
58
+ }
59
+ const phase = args['tdd-phase'] || 'regression';
60
+ if (!['red', 'green', 'refactor', 'regression', 'not-applicable'].includes(phase)) throw new Error('TEST_PHASE_INVALID');
61
+ const taskIds = [...new Set(String(args['task-ids'] || args['task-id'] || '').split(',').map(x => x.trim()).filter(Boolean))];
62
+ if (!taskIds.length) throw new Error('TEST_TASKS_REQUIRED: 明确指定 --task-ids,工具不猜测测试覆盖的任务');
63
+ const timeout = Number(args['timeout-ms'] || 120000);
64
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 3600000) throw new Error('TEST_TIMEOUT_INVALID');
65
+ const cwd = path.resolve(args.cwd || projectRoot);
66
+ if (!fs.statSync(cwd).isDirectory()) throw new Error('TEST_CWD_INVALID');
67
+ const runId = args['run-id'] || crypto.randomUUID();
68
+ const key = crypto.createHash('sha256').update(`${change}:${runId}`).digest('hex');
69
+ const dir = path.join(projectRoot, 'skywalk-sdd', 'state', 'test-runs', key);
70
+ fs.mkdirSync(path.dirname(dir), { recursive: true });
71
+ // A run ID identifies one execution. Never re-execute a command under an old receipt.
72
+ try { fs.mkdirSync(dir); } catch (error) {
73
+ if (error.code === 'EEXIST') throw new Error('TEST_RUN_EXISTS: 此 run-id 已执行;使用保存的 details.json 重试记录,重新执行须使用新 run-id');
74
+ throw error;
75
+ }
76
+ const startedAt = new Date().toISOString();
77
+ const started = process.hrtime.bigint();
78
+ const childEnv = { ...process.env };
79
+ // The wrapper can itself run inside node:test; its child is a fresh test runner.
80
+ delete childEnv.NODE_TEST_CONTEXT;
81
+ const child = spawnSync(command[0], command.slice(1), {
82
+ cwd, env: childEnv, shell: false, encoding: 'utf8', timeout, maxBuffer: 16 * 1024 * 1024,
83
+ });
84
+ const duration = Math.round(Number(process.hrtime.bigint() - started) / 1e6);
85
+ const stdout = child.stdout || '', stderr = child.stderr || '';
86
+ const output = stdout + '\n' + stderr;
87
+ const exitCode = Number.isInteger(child.status) ? child.status : (child.error?.code === 'ETIMEDOUT' ? 124 : 1);
88
+ const failureType = classifyFailure(output, exitCode, child.error, child.signal);
89
+ const counts = parseCounts(output);
90
+ const files = {};
91
+ for (const [name, content] of Object.entries({ 'stdout.log': stdout, 'stderr.log': stderr })) {
92
+ fs.writeFileSync(path.join(dir, name), content, { mode: 0o600 });
93
+ files[name] = crypto.createHash('sha256').update(content).digest('hex');
94
+ }
95
+ const execution = {
96
+ origin: 'command-execution', argv: command, cwd, started_at: startedAt,
97
+ finished_at: new Date().toISOString(), signal: child.signal || null,
98
+ error: child.error ? { code: child.error.code, message: child.error.message } : null,
99
+ output_files: Object.entries(files).map(([name, sha256]) => ({ path: path.join(dir, name), sha256 })),
100
+ task_association: 'explicit-caller-declaration',
101
+ receipt_path: path.join(dir, 'execution.json'),
102
+ };
103
+ const testResults = {
104
+ run_id: runId, verified_task_ids: taskIds, tdd_phase: phase,
105
+ command: JSON.stringify(command), result: exitCode === 0 ? 'success' : 'failure',
106
+ exit_code: exitCode, duration_ms: duration, ...counts, failure_type: failureType,
107
+ expected_failure: phase === 'red' && args['expected-failure'] === true,
108
+ execution,
109
+ };
110
+ const receipt = path.join(dir, 'details.json');
111
+ fs.writeFileSync(execution.receipt_path, JSON.stringify(testResults, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
112
+ fs.writeFileSync(receipt, JSON.stringify({ test_results: testResults }, null, 2) + '\n', { mode: 0o600 });
113
+ return { testResults, runId, receipt };
114
+ }
115
+
116
+ function verifyCapture(input, projectRoot) {
117
+ const base = fs.realpathSync(path.join(projectRoot, 'skywalk-sdd/state/test-runs')) + path.sep;
118
+ const receiptPath = fs.realpathSync(input.execution.receipt_path);
119
+ if (input.execution.origin !== 'command-execution' || !receiptPath.startsWith(base)) throw new Error('TEST_CAPTURE_INVALID: 执行回执不在当前项目');
120
+ const receipt = JSON.parse(fs.readFileSync(receiptPath, 'utf8'));
121
+ for (const key of Object.keys(receipt)) {
122
+ if (JSON.stringify(receipt[key]) !== JSON.stringify(input[key])) throw new Error(`TEST_CAPTURE_MISMATCH: ${key} 与真实执行回执不一致`);
123
+ }
124
+ for (const output of receipt.execution.output_files) {
125
+ const file = fs.realpathSync(output.path);
126
+ if (!file.startsWith(base) || crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex') !== output.sha256) throw new Error('TEST_CAPTURE_HASH_MISMATCH: 原始测试输出已变化');
127
+ }
128
+ }
129
+
130
+ module.exports = { captureTest, parseCounts, classifyFailure, verifyCapture };
@@ -23,6 +23,8 @@ const {
23
23
  readUserConfig,
24
24
  } = require('./user-config.cjs');
25
25
  const gitIdentity = require('./git-identity.cjs');
26
+ // kb-state.json 的唯一读取入口(含「必须位于 spec 包裹包根」的位置校验)
27
+ const { loadKbState } = require('./shared.cjs');
26
28
 
27
29
  const PENDING_FILE_NAME = 'pending-usage.jsonl';
28
30
  const PENDING_MAX_BYTES = 1024 * 1024; // 1 MB
@@ -93,6 +95,16 @@ function _resetGitIdentityCache() {
93
95
  * 四级解析上报地址:config.server → 项目 kb-state.json api → env KLD_SDD_SERVER → 内置默认。
94
96
  * 每级均经 normalizeServerUrl;结果永远非空(默认地址兜底)。
95
97
  *
98
+ * kb-state.json 的读取走 `loadKbState`(唯一 kb-state 读取入口),因此沿用
99
+ * 其位置校验:只有位于 spec 包裹包根的那份才被采用。位置不符时行为与
100
+ * 「文件不存在」一致 —— 静默落到下一级(env → 内置默认)。
101
+ *
102
+ * 这里刻意**不**把 loadKbState 的 warnings 打到 stderr:上报链路是
103
+ * 「永不抛错、静默兜底」的后台路径,配置告警应由 KB 技能链路(kb-config /
104
+ * ontology-query / kb-ingest)呈现,避免每次 log start 都刷屏。
105
+ * 且宁可上报到默认地址,也不能因为某处残留的陈旧 kb-state.json
106
+ * 把使用数据发到错误服务器。
107
+ *
96
108
  * @param {{homeDir?:string, projectRoot?:string, env?:object}} [options]
97
109
  * @returns {string}
98
110
  */
@@ -107,14 +119,11 @@ function resolveReportServer(options = {}) {
107
119
  }
108
120
  // 2. 项目级 kb-state.json 的 api(kb-state.api 是唯一带 /api 契约的来源,先剥离尾部 /api 再标准化)
109
121
  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
- }
122
+ const { config: kbState } = loadKbState(projectRoot);
123
+ if (kbState && typeof kbState.api === 'string') {
124
+ const stripped = kbState.api.trim().replace(/\/api(\/v\d+)?\/?$/i, '');
125
+ const normalized = normalizeServerUrl(stripped);
126
+ if (normalized) return normalized;
118
127
  }
119
128
  } catch {
120
129
  // kb-state 读取失败时静默,继续下一级
@@ -4,6 +4,13 @@ const crypto = require('crypto');
4
4
  const fs = require('fs');
5
5
  const path = require('path');
6
6
  const { generateUuidV7 } = require('./id.cjs');
7
+ const {
8
+ SPEC_ONTOLOGY_SCHEMA_VERSION,
9
+ SPEC_ONTOLOGY_PATH,
10
+ buildSpecOntology,
11
+ validateSpecOntology,
12
+ specOntologyContentSignature,
13
+ } = require('./spec-ontology.cjs');
7
14
  const { resolveRuntimeMetadata } = require('../runtime-metadata.cjs');
8
15
  const {
9
16
  readJson,
@@ -581,6 +588,18 @@ function materializeArchivePackage(options = {}) {
581
588
  );
582
589
  const canonicalPath = path.join(archiveDir, CANONICAL_FACTS_PATH);
583
590
  const canonicalBytes = writeJsonAtomic(canonicalPath, canonicalFacts);
591
+ const specOntology = buildSpecOntology(snapshot, {
592
+ projectId: projectIdentity.project_id,
593
+ archiveId,
594
+ compatibilityMode: 'ADAPTED_V2',
595
+ reviewStatus: 'confirmed',
596
+ });
597
+ const specOntologyValidation = validateSpecOntology(specOntology);
598
+ if (!specOntologyValidation.valid) {
599
+ throw new Error(`Spec 本体事实包校验失败: ${specOntologyValidation.errors.join('; ')}`);
600
+ }
601
+ const specOntologyPath = path.join(archiveDir, SPEC_ONTOLOGY_PATH);
602
+ writeJsonAtomic(specOntologyPath, specOntology);
584
603
 
585
604
  const conversionReport = {
586
605
  schema_version: CONVERSION_REPORT_SCHEMA_VERSION,
@@ -589,6 +608,9 @@ function materializeArchivePackage(options = {}) {
589
608
  source_schema: String(snapshot.schema_version || 'unknown'),
590
609
  target_file: CANONICAL_FACTS_PATH,
591
610
  target_schema: CANONICAL_FACTS_SCHEMA_VERSION,
611
+ spec_ontology_file: SPEC_ONTOLOGY_PATH,
612
+ spec_ontology_schema: SPEC_ONTOLOGY_SCHEMA_VERSION,
613
+ compatibility_mode: specOntology.compatibility_mode,
592
614
  status: 'converted',
593
615
  entity_count: canonicalFacts.entities.length,
594
616
  relation_count: canonicalFacts.relations.length,
@@ -618,6 +640,9 @@ function materializeArchivePackage(options = {}) {
618
640
  change_id: changeId,
619
641
  content_hash: `sha256:${sha256(canonicalBytes)}`,
620
642
  canonical_facts_path: CANONICAL_FACTS_PATH,
643
+ spec_ontology_path: SPEC_ONTOLOGY_PATH,
644
+ spec_ontology_schema: SPEC_ONTOLOGY_SCHEMA_VERSION,
645
+ compatibility_mode: specOntology.compatibility_mode,
621
646
  conversion_report_path: CONVERSION_REPORT_PATH,
622
647
  created_at: createdAt,
623
648
  files,
@@ -647,6 +672,121 @@ function materializeArchivePackage(options = {}) {
647
672
  conversion_report: conversionReport,
648
673
  manifest_path: existingManifestPath,
649
674
  canonical_facts_path: canonicalPath,
675
+ spec_ontology_path: specOntologyPath,
676
+ conversion_report_path: path.join(archiveDir, CONVERSION_REPORT_PATH),
677
+ package_path: packagePath,
678
+ };
679
+ }
680
+
681
+ function materializeCompatibilityPackage(options = {}) {
682
+ const projectRoot = path.resolve(options.projectRoot || process.cwd());
683
+ const archiveDir = path.resolve(requiredText(options.archiveDir, 'archiveDir', 'Compatibility Package'));
684
+ const facts = options.facts;
685
+ if (!facts || !Array.isArray(facts.entities)) {
686
+ throw new Error('Compatibility Package 缺少已解析 facts');
687
+ }
688
+ const projectIdentity = ensureProjectIdentity(projectRoot);
689
+ const changeName = requiredText(options.changeName || facts.change, 'changeName', 'Compatibility Package');
690
+ const changeId = String(facts.change_id || `CHG-LEGACY-${sha256(changeName).slice(0, 12)}`).toUpperCase();
691
+ const publicationVariant = options.specOntology?.review_status === 'confirmed' ? 'reviewed' : 'candidate';
692
+ const archiveId = `urn:kld:sdd:archive:${sha256(
693
+ `${projectIdentity.project_id}\n${changeId}\n${facts.facts_hash || sha256(JSON.stringify(facts))}\n${publicationVariant}`,
694
+ )}`;
695
+ let specOntology;
696
+ if (options.specOntology) {
697
+ specOntology = JSON.parse(JSON.stringify(options.specOntology));
698
+ if (specOntology.project_id !== projectIdentity.project_id) {
699
+ throw new Error('已审核 Spec 本体的 project_id 与当前项目不一致,请重新物化并审核');
700
+ }
701
+ if (specOntology.compatibility_mode !== 'LEGACY_MATERIALIZED') {
702
+ throw new Error('兼容包只接受 LEGACY_MATERIALIZED 本体');
703
+ }
704
+ if (specOntology.review_status !== 'confirmed' || specOntology.publication_eligible !== true) {
705
+ throw new Error('历史 Spec 本体尚未审核,不能发布为 current 事实');
706
+ }
707
+ const currentOntology = buildSpecOntology({ ...facts, change_id: changeId }, {
708
+ projectId: projectIdentity.project_id,
709
+ compatibilityMode: 'LEGACY_MATERIALIZED',
710
+ reviewStatus: 'pending',
711
+ });
712
+ if (specOntologyContentSignature(specOntology) !== specOntologyContentSignature(currentOntology)) {
713
+ throw new Error('已审核 Spec 本体与当前 Markdown 内容不一致,请重新物化并审核');
714
+ }
715
+ specOntology.archive_id = archiveId;
716
+ specOntology.change = changeName;
717
+ specOntology.change_id = changeId;
718
+ delete specOntology.ontology_hash;
719
+ specOntology.ontology_hash = `sha256:${sha256(JSON.stringify(specOntology))}`;
720
+ } else {
721
+ specOntology = buildSpecOntology({ ...facts, change_id: changeId }, {
722
+ projectId: projectIdentity.project_id,
723
+ archiveId,
724
+ compatibilityMode: 'LEGACY_MATERIALIZED',
725
+ reviewStatus: 'pending',
726
+ });
727
+ }
728
+ const validation = validateSpecOntology(specOntology);
729
+ if (!validation.valid) {
730
+ throw new Error(`历史 Spec 本体物化失败: ${validation.errors.join('; ')}`);
731
+ }
732
+ const toleratedDiagnostics = (options.toleratedDiagnostics || [])
733
+ .map((item) => `LEGACY_DIAGNOSTIC_TOLERATED: ${item.code}: ${item.message}`);
734
+ const specOntologyPath = path.join(archiveDir, SPEC_ONTOLOGY_PATH);
735
+ const specOntologyBytes = writeJsonAtomic(specOntologyPath, specOntology);
736
+ const conversionReport = {
737
+ schema_version: CONVERSION_REPORT_SCHEMA_VERSION,
738
+ producer: PRODUCER,
739
+ source_file: 'legacy-markdown',
740
+ source_schema: String(facts.schema_version || 'legacy-markdown'),
741
+ target_file: SPEC_ONTOLOGY_PATH,
742
+ target_schema: SPEC_ONTOLOGY_SCHEMA_VERSION,
743
+ compatibility_mode: 'LEGACY_MATERIALIZED',
744
+ status: specOntology.publication_eligible ? 'materialized_reviewed' : 'materialized_pending_review',
745
+ entity_count: specOntology.entities.length,
746
+ relation_count: specOntology.relations.length,
747
+ warnings: [...new Set([...(specOntology.warnings || []), ...toleratedDiagnostics])],
748
+ };
749
+ writeJsonAtomic(path.join(archiveDir, CONVERSION_REPORT_PATH), conversionReport);
750
+
751
+ const createdAt = options.archivedAt || new Date().toISOString();
752
+ const filePaths = collectPackageFilePaths(archiveDir);
753
+ const files = filePaths.map((relativePath) => ({
754
+ path: relativePath,
755
+ content_hash: `sha256:${sha256(fs.readFileSync(path.join(archiveDir, relativePath)))}`,
756
+ }));
757
+ const manifest = {
758
+ schema_version: MANIFEST_SCHEMA_VERSION,
759
+ producer: PRODUCER,
760
+ project_id: projectIdentity.project_id,
761
+ archive_id: archiveId,
762
+ change_id: changeId,
763
+ content_hash: `sha256:${sha256(specOntologyBytes)}`,
764
+ canonical_facts_path: null,
765
+ spec_ontology_path: SPEC_ONTOLOGY_PATH,
766
+ spec_ontology_schema: SPEC_ONTOLOGY_SCHEMA_VERSION,
767
+ compatibility_mode: 'LEGACY_MATERIALIZED',
768
+ conversion_report_path: CONVERSION_REPORT_PATH,
769
+ created_at: createdAt,
770
+ files,
771
+ change: changeName,
772
+ archived_at: createdAt,
773
+ reason: options.reason || '历史 Spec 本体兼容入库',
774
+ method: options.method || 'legacy-spec-ontology-materialization',
775
+ source_path: options.sourcePath || '',
776
+ archive_path: toPosix(path.relative(projectRoot, archiveDir)),
777
+ copied_specs: [],
778
+ evidence_events_path: null,
779
+ };
780
+ const manifestPath = path.join(archiveDir, MANIFEST_PATH);
781
+ writeJsonAtomic(manifestPath, manifest);
782
+ const packagePath = path.resolve(options.packagePath || `${archiveDir}.zip`);
783
+ writeArchiveZip(archiveDir, packagePath, manifest);
784
+ return {
785
+ manifest,
786
+ conversion_report: conversionReport,
787
+ validation,
788
+ manifest_path: manifestPath,
789
+ spec_ontology_path: specOntologyPath,
650
790
  conversion_report_path: path.join(archiveDir, CONVERSION_REPORT_PATH),
651
791
  package_path: packagePath,
652
792
  };
@@ -707,5 +847,6 @@ module.exports = {
707
847
  readStoredZipEntries,
708
848
  verifyArchivePackage,
709
849
  materializeArchivePackage,
850
+ materializeCompatibilityPackage,
710
851
  finalizeArchivePackageAfterReports,
711
852
  };
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const { DIAGNOSTIC_CODES, entityTypeForId } = require('./schema.cjs');
7
7
  const { ensureStructuralIdentities } = require('./structural-identity.cjs');
8
8
 
9
- const ENTITY_ID_PATTERN = /\b(?:CHG|CAP|STMT|AC|CON|DES|TASK|ART|SEC|SNAP)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi;
9
+ const ENTITY_ID_PATTERN = /\b(?:CHG|OBJ|CAP|STMT|AC|CON|DES|TASK|ART|SEC|SNAP)-[A-Z0-9]+(?:-[A-Z0-9]+)*\b/gi;
10
10
  const IDENTITY_META_PATTERN = /^\s*-\s+\*\*(entity-id|version-id|predecessor-version|delta-state|external-ref)\*\*\s*[::]\s*(.*?)\s*$/i;
11
11
 
12
12
  function toPosix(value) {
@@ -444,7 +444,7 @@ function parseSpec(target, file, lines, frontmatter) {
444
444
  });
445
445
  continue;
446
446
  }
447
- const inheritedRelationMatch = line.match(/^\s*-\s+\*\*relation\*\*\s*[::]\s*`?((?:CHG|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)\s+(contains|acceptedBy|constrainedBy|realizes|implements|covers|dependsOn)\s+((?:CHG|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)`?\s*$/i);
447
+ const inheritedRelationMatch = line.match(/^\s*-\s+\*\*relation\*\*\s*[::]\s*`?((?:CHG|OBJ|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)\s+(contains|usesObject|acceptedBy|constrainedBy|realizes|implements|covers|dependsOn)\s+((?:CHG|OBJ|CAP|STMT|AC|CON|DES|TASK)-[A-Z0-9-]+)`?\s*$/i);
448
448
  if (inheritedRelationMatch) {
449
449
  let source = '';
450
450
  for (let offset = index + 1; offset < Math.min(lines.length, index + 6); offset += 1) {
@@ -461,7 +461,28 @@ function parseSpec(target, file, lines, frontmatter) {
461
461
  });
462
462
  continue;
463
463
  }
464
- let match = line.match(/^\s*####\s+需求项[::]\s*[【\[]?(STMT-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
464
+ let match = line.match(/^\s*####\s+业务对象[::]\s*[【\[]?(OBJ-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
465
+ if (match) {
466
+ const objectId = match[1].toUpperCase();
467
+ const endIndex = sectionEnd(lines, index, 4);
468
+ addEntity(
469
+ target,
470
+ 'BusinessObject',
471
+ objectId,
472
+ match[2],
473
+ file,
474
+ index + 1,
475
+ { capability_id: capabilityId || undefined },
476
+ parseIdentityBlock(lines, index + 1, endIndex),
477
+ lines.slice(index, endIndex).join('\n'),
478
+ );
479
+ if (capabilityId) {
480
+ addRelation(target, 'usesObject', capabilityId, objectId, file, index + 1, 'asserted');
481
+ }
482
+ continue;
483
+ }
484
+
485
+ match = line.match(/^\s*####\s+需求项[::]\s*[【\[]?(STMT-[A-Z0-9-]+)[】\]]?\s*(.*?)\s*$/i);
465
486
  if (match) {
466
487
  currentStatement = match[1].toUpperCase();
467
488
  currentConstraint = '';
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ const fs = require('fs');
4
5
  const path = require('path');
5
6
  const {
6
7
  scanChange,
@@ -18,6 +19,11 @@ const listChanges = require('./list-changes.cjs');
18
19
  const sddConfig = require('./sdd-config.cjs');
19
20
  const activeChanges = require('./active-changes.cjs');
20
21
  const { resolveProjectArg } = require('./resolve-spec-root.cjs');
22
+ const {
23
+ materializeChangeSpecOntology,
24
+ validateSpecOntology,
25
+ approveSpecOntologyFile,
26
+ } = require('./spec-ontology.cjs');
21
27
 
22
28
  function parseArgs(argv) {
23
29
  const result = { _: [] };
@@ -77,6 +83,9 @@ function showHelp() {
77
83
  node skywalk-sdd/ontology/cli.cjs status --project=. --change=<name>
78
84
  node skywalk-sdd/ontology/cli.cjs observe --project=. --change=<name> [--interval=1500]
79
85
  node skywalk-sdd/ontology/cli.cjs snapshot --project=. --change=<name> --archive=<path>
86
+ node skywalk-sdd/ontology/cli.cjs materialize-ontology --project=. --change=<name> [--output=<path>]
87
+ node skywalk-sdd/ontology/cli.cjs validate-spec-ontology --file=<spec-ontology.json>
88
+ node skywalk-sdd/ontology/cli.cjs approve-spec-ontology --file=<spec-ontology.json>
80
89
 
81
90
  observe 只负责采集和同步;check/archive 前仍必须执行 reconcile。`);
82
91
  }
@@ -254,11 +263,55 @@ function main(argv = process.argv.slice(2)) {
254
263
  if (!result.ok) process.exitCode = 1;
255
264
  return;
256
265
  }
266
+ if (command === 'validate-spec-ontology') {
267
+ const filePath = path.resolve(args.file || args._[1] || 'spec-ontology.json');
268
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, ''));
269
+ const result = validateSpecOntology(payload);
270
+ console.log(JSON.stringify({ file: filePath, ...result }, null, 2));
271
+ if (!result.valid) process.exitCode = 1;
272
+ return;
273
+ }
274
+ if (command === 'approve-spec-ontology') {
275
+ const filePath = path.resolve(args.file || args._[1] || 'spec-ontology.json');
276
+ const result = approveSpecOntologyFile(filePath, {
277
+ allowCollisions: args['allow-collisions'] === true,
278
+ });
279
+ console.log(JSON.stringify({
280
+ file: result.file,
281
+ compatibility_mode: result.payload.compatibility_mode,
282
+ review_status: result.payload.review_status,
283
+ publication_eligible: result.payload.publication_eligible,
284
+ validation: result.validation,
285
+ warnings: result.payload.warnings,
286
+ }, null, 2));
287
+ if (!result.validation.valid) process.exitCode = 1;
288
+ return;
289
+ }
257
290
  const projectRoot = resolveCliProject(args);
258
291
  const changeName = args.change;
259
292
  if (!changeName) throw new Error('缺少 --change 参数');
260
293
  const profile = args.profile || 'auto';
261
294
 
295
+ if (command === 'materialize-ontology' || command === 'materialize') {
296
+ const result = materializeChangeSpecOntology(projectRoot, changeName, {
297
+ outputPath: args.output,
298
+ mappingPath: args.mapping,
299
+ reviewStatus: args['review-status'],
300
+ });
301
+ console.log(JSON.stringify({
302
+ change: changeName,
303
+ compatibility_mode: result.payload.compatibility_mode,
304
+ review_status: result.payload.review_status,
305
+ publication_eligible: result.payload.publication_eligible,
306
+ output_path: result.output_path,
307
+ mapping_path: result.mapping_path,
308
+ validation: result.validation,
309
+ warnings: result.payload.warnings,
310
+ }, null, 2));
311
+ if (!result.validation.valid) process.exitCode = 1;
312
+ return;
313
+ }
314
+
262
315
  if (command === 'scan') {
263
316
  const result = scanChange(projectRoot, changeName, { profile });
264
317
  console.log(JSON.stringify(summary({ ...result, changed: false, paths: undefined }), null, 2));
@@ -42,7 +42,7 @@ function nextEntityId(prefix, scope, existingIds = []) {
42
42
 
43
43
  function isValidEntityId(entityId, expectedType = '') {
44
44
  const value = String(entityId || '').trim().toUpperCase();
45
- if (!/^(?:CHG|CAP|STMT|AC|CON|DES|TASK|ART|SEC|SNAP)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(value)) {
45
+ if (!/^(?:CHG|OBJ|CAP|STMT|AC|CON|DES|TASK|ART|SEC|SNAP)-[A-Z0-9]+(?:-[A-Z0-9]+)*$/.test(value)) {
46
46
  return false;
47
47
  }
48
48
  if (expectedType && entityTypeForId(value) !== expectedType) {
@@ -236,7 +236,11 @@ function buildIdentityCatalog(projectRoot, options = {}) {
236
236
 
237
237
  for (const candidate of identityRoots(root)) {
238
238
  if (excluded && path.resolve(candidate.changeDir) === excluded) continue;
239
- const facts = normalizeFacts(parseChangeArtifacts(root, candidate.change, {
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, {
240
244
  changeDir: candidate.changeDir,
241
245
  persistStructuralIdentities: false,
242
246
  }));
@@ -4,6 +4,8 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const sddConfig = require('./sdd-config.cjs');
6
6
  const workspaceLayout = require('./workspace-layout.cjs');
7
+ // spec 判据的唯一真相源(spec-detect 对本模块无依赖,不构成循环)
8
+ const specDetect = require('./spec-detect.cjs');
7
9
 
8
10
  /**
9
11
  * 从 anchorRoot 向上遍历目录树,查找 .sdd-spec-root 配置文件。
@@ -28,6 +30,34 @@ function resolveHint(anchorRoot) {
28
30
  return null;
29
31
  }
30
32
 
33
+ /**
34
+ * 从 `.sdd-workspace.yaml` 的 `spec_path` 解析 spec 根(向上逐级查找)。
35
+ *
36
+ * 委托 `spec-detect.resolveWorkspaceYamlUpward` —— 该解析在 spec-detect 中
37
+ * 同时用于「候选枚举的 tier 0 来源」,两处共用一份实现,避免漂移。
38
+ */
39
+ function resolveWorkspaceYamlSpecPath(anchorRoot) {
40
+ return specDetect.resolveWorkspaceYamlUpward(anchorRoot);
41
+ }
42
+
43
+ /**
44
+ * 解析 spec 包裹包根目录。
45
+ *
46
+ * 优先级(权威声明 → 结构判据 → 回落):
47
+ * 1. `.sdd-spec-root` 指针文件(init / 运行时写入)
48
+ * 2. `.sdd.yaml` layout: mono(单仓,仓内包裹包)
49
+ * 3. `.sdd-workspace.yaml` spec_path(多仓工作区编排)
50
+ * 4. 工作区探测:子仓结构判据命中的 spec 包
51
+ * 5. 非 Git 子目录包裹包
52
+ * 6. 回落为入参本身(调用方用 isSpecPackageRoot 区分「就是 spec 根」与「解析失败」)
53
+ *
54
+ * 第 3 步是**新增**的:多仓工作区根执行文档类命令时,权威链此前不读
55
+ * `.sdd-workspace.yaml`,只能靠第 4 步的结构判据猜;而 skills 的
56
+ * `opsx-consistency-check/scripts.cjs` 早已自己解析该文件 —— 两处不一致。
57
+ * 现由权威链直接支持,该文件成为与 `.sdd.yaml` 同级的显式声明来源。
58
+ *
59
+ * 前 3 步均**向上逐级查找**,故从工作区子目录(如某个代码仓内)调用也能命中。
60
+ */
31
61
  function resolveSpecProjectRoot(projectRoot = process.cwd()) {
32
62
  const root = path.resolve(projectRoot);
33
63
  const hinted = resolveHint(root);
@@ -37,6 +67,9 @@ function resolveSpecProjectRoot(projectRoot = process.cwd()) {
37
67
  return sddConfig.resolveMonoSpecRoot(root);
38
68
  }
39
69
 
70
+ const fromWorkspaceYaml = resolveWorkspaceYamlSpecPath(root);
71
+ if (fromWorkspaceYaml) return fromWorkspaceYaml;
72
+
40
73
  const layout = workspaceLayout.detectWorkspaceLayout(root);
41
74
  if (layout.specRepo) return layout.specRepo.abs;
42
75
 
@@ -50,7 +83,23 @@ function resolveProjectArg(projectArg = '.', cwd = process.cwd()) {
50
83
  return resolveSpecProjectRoot(path.resolve(cwd, projectArg));
51
84
  }
52
85
 
86
+ /**
87
+ * 判断 dir 本身是否就是一个 SDD spec 包裹包根。
88
+ *
89
+ * 用于 `log spec-root` 的 resolved 判定:当 resolveSpecProjectRoot 因找不到
90
+ * 任何线索而回落为入参本身时,需区分「dir 就是 spec 根」与「解析失败」。
91
+ *
92
+ * ⚠️ 判据已收敛到 `spec-detect.cjs#isSpecPackageRoot`(全仓唯一判据入口)。
93
+ * 该判据比 `isSpecCandidate` 宽一档 —— 额外接受仅含 `openspec/` 的半成品
94
+ * (`--skip-openspec` 产物),因为本函数的语义是「能否作为 spec 根使用」
95
+ * 而非「是否是当前项目的 spec 仓」。
96
+ */
97
+ function isSpecPackageRoot(dir) {
98
+ return specDetect.isSpecPackageRoot(dir);
99
+ }
100
+
53
101
  module.exports = {
54
102
  resolveProjectArg,
55
103
  resolveSpecProjectRoot,
104
+ isSpecPackageRoot,
56
105
  };
@@ -4,6 +4,7 @@ const SCHEMA_VERSION = 'kld-sdd-ontology/v2';
4
4
 
5
5
  const ENTITY_TYPES = Object.freeze([
6
6
  'Change',
7
+ 'BusinessObject',
7
8
  'Capability',
8
9
  'SpecificationStatement',
9
10
  'AcceptanceCriterion',
@@ -17,6 +18,7 @@ const ENTITY_TYPES = Object.freeze([
17
18
 
18
19
  const ID_PREFIX_TYPES = Object.freeze({
19
20
  CHG: 'Change',
21
+ OBJ: 'BusinessObject',
20
22
  CAP: 'Capability',
21
23
  STMT: 'SpecificationStatement',
22
24
  AC: 'AcceptanceCriterion',
@@ -37,6 +39,10 @@ const RELATION_TYPES = Object.freeze({
37
39
  Object.freeze(['Capability', 'SpecificationStatement']),
38
40
  ]),
39
41
  }),
42
+ usesObject: Object.freeze({
43
+ domain: Object.freeze(['Capability']),
44
+ range: Object.freeze(['BusinessObject']),
45
+ }),
40
46
  acceptedBy: Object.freeze({
41
47
  domain: Object.freeze(['SpecificationStatement']),
42
48
  range: Object.freeze(['AcceptanceCriterion']),