kld-sdd 2.7.8-2 → 2.7.8-3

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.
@@ -1,104 +1,22 @@
1
1
  #!/bin/sh
2
- # KLD SDD Commit-MSG Hook
3
- # 在下方 SCRIPTS 列表中声明要加载的 .cjs 脚本
4
- # 删除某行 = 禁用该插件
2
+ # KLD SDD provenance hook v2
5
3
  # marker: KLD SDD quality gate
6
-
7
- # Git supplies the working tree context; this also supports worktrees and core.hooksPath.
8
- git_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
9
- echo "[SDD] 无法定位当前 Git 工作区"
10
- exit 1
11
- }
12
- hooks_dir=""
13
- # 统一反斜杠为正斜杠,保证 Windows 盘符路径(P:\a\b → P:/a/b)能被 case 判定为绝对路径
14
- normalize_path() {
15
- printf '%s' "$1" | tr '\\' '/'
16
- }
17
- # 绝对路径(/ 开头或盘符 X: 开头)直接使用;相对路径基于 git 根拼接
18
- resolve_spec_dir() {
19
- raw=$(normalize_path "$1")
20
- case "$raw" in
21
- /*|[A-Za-z]:*) printf '%s' "$raw" ;;
22
- *) printf '%s' "$git_root/$raw" ;;
23
- esac
24
- }
25
- spec_path=$(git -C "$git_root" config --local sdd.specPath 2>/dev/null)
26
- if [ -n "$spec_path" ]; then
27
- hooks_dir="$(resolve_spec_dir "$spec_path")/skywalk-sdd/git-hooks"
28
- elif [ -f "$git_root/.sdd-spec-root" ]; then
29
- spec_rel=$(head -1 "$git_root/.sdd-spec-root" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
30
- hooks_dir="$(resolve_spec_dir "$spec_rel")/skywalk-sdd/git-hooks"
4
+ # Ordinary commits save work; delivery-check verifies readiness at Apply completion.
5
+ git_root=$(git rev-parse --show-toplevel 2>/dev/null) || exit 0
6
+ spec_path=$(git config --local sdd.specPath 2>/dev/null)
7
+ if [ -z "$spec_path" ] && [ -f "$git_root/.sdd-spec-root" ]; then
8
+ IFS= read -r spec_path < "$git_root/.sdd-spec-root"
31
9
  fi
32
- if [ -n "$hooks_dir" ] && [ ! -d "$hooks_dir" ]; then
33
- echo "[SDD] 已配置的 Spec 路径失效:$hooks_dir"
34
- echo "[SDD] 请恢复 Spec 仓,或修正 sdd.specPath / .sdd-spec-root 后重试"
35
- exit 1
36
- fi
37
-
38
- # 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
39
- if [ -z "$hooks_dir" ]; then
40
- search_dir="$git_root"
41
- while [ -n "$search_dir" ] && [ "$search_dir" != "/" ]; do
42
- if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
43
- hooks_dir="$search_dir/skywalk-sdd/git-hooks"
44
- break
45
- fi
46
- # Windows 盘符根(P:/)dirname 后不变,需显式收敛,避免死循环
47
- parent_dir="$(dirname "$search_dir")"
48
- [ "$parent_dir" = "$search_dir" ] && break
49
- search_dir="$parent_dir"
50
- done
51
- fi
52
-
53
- if [ -z "$hooks_dir" ]; then
54
- echo "[SDD] 未找到 spec 仓的 skywalk-sdd/git-hooks 目录,跳过 commit-msg"
55
- exit 0
56
- fi
57
-
58
- # 从配置文件读取插件列表
59
- config_file="$hooks_dir/hooks.config"
60
- if [ ! -f "$config_file" ]; then
61
- echo "[SDD] ⚠️ 配置文件不存在:hooks.config,使用默认插件"
62
- SCRIPTS="commit-msg-sdd-trailer.cjs"
63
- else
64
- # 读取 [commit-msg] 段落的配置
65
- in_section=0
66
- SCRIPTS=""
67
- CR=$(printf '\r')
68
- while IFS= read -r line; do
69
- line=${line%"$CR"}
70
- case "$line" in
71
- "[commit-msg]") in_section=1 ;;
72
- "["*) in_section=0 ;;
73
- "#"*|"") ;;
74
- *)
75
- if [ "$in_section" -eq 1 ]; then
76
- SCRIPTS="$SCRIPTS $line"
77
- fi
78
- ;;
79
- esac
80
- done < "$config_file"
81
- fi
82
-
83
- executed=0
84
- failed=0
85
- for script_name in $SCRIPTS; do
86
- script="$hooks_dir/$script_name"
87
- if [ ! -f "$script" ]; then
88
- echo "[SDD] ❌ 已配置的插件不存在:$script_name"
89
- failed=$((failed + 1))
90
- continue
91
- fi
92
- executed=$((executed + 1))
93
- echo "[SDD] 执行 commit-msg 插件:$script_name"
94
- node "$script" "$1" --project="$git_root" || failed=$((failed + 1))
95
- done
96
-
97
- if [ "$failed" -gt 0 ]; then
98
- echo "[SDD] ❌ 共执行 $executed 个 commit-msg 插件,$failed 个失败"
99
- exit 1
100
- elif [ "$executed" -eq 0 ]; then
101
- echo "[SDD] 配置已禁用 commit-msg 插件"
10
+ if [ -z "$spec_path" ]; then spec_path="$git_root"; fi
11
+ spec_path=$(printf '%s' "$spec_path" | tr '\\' '/' | tr -d '\r')
12
+ case "$spec_path" in
13
+ /*|[A-Za-z]:*) ;;
14
+ *) spec_path="$git_root/$spec_path" ;;
15
+ esac
16
+ runner="$spec_path/skywalk-sdd/git-hooks/commit-msg-runner.cjs"
17
+ if [ -f "$runner" ]; then
18
+ node "$runner" "$1" --project="$git_root" || echo '[SDD] 关联待补充,本次提交继续' >&2
102
19
  else
103
- echo "[SDD] ✅ 共执行 $executed 个 commit-msg 插件,全部通过"
20
+ echo '[SDD] Spec 运行时未就绪,关联待补充;可重装或修正 sdd.specPath,本次提交继续' >&2
104
21
  fi
22
+ exit 0
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ // Only the managed provenance worker is advisory. User hooks are never run here.
9
+ const original = process.argv[2];
10
+ let tempDir;
11
+ try {
12
+ const config = path.join(__dirname, 'hooks.config');
13
+ if (fs.existsSync(config)) {
14
+ const text = fs.readFileSync(config, 'utf8');
15
+ const section = text.split(/\[commit-msg\]\s*\r?\n/)[1]?.split(/^\[/m)[0] || '';
16
+ if (!section.split(/\r?\n/).some(line => line.trim() === 'commit-msg-sdd-trailer.cjs')) process.exit(0);
17
+ }
18
+ if (!String(process.env.KLD_SDD_CHANGE || '').trim()
19
+ && !fs.existsSync(path.join(__dirname, '../state/delivery-associations'))) {
20
+ // No explicit or recorded association exists. Avoid starting a second Node
21
+ // process and loading ontology just to discover that there is nothing to add.
22
+ const message = fs.readFileSync(original, 'utf8').replace(/^Spec-(?:Change|Revision):[^\n]*\n?/gim, '');
23
+ fs.writeFileSync(original, message);
24
+ console.error('[SDD] 本次提交的 Change 待关联;保存不受影响');
25
+ process.exit(0);
26
+ }
27
+ tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdd-msg-'));
28
+ const copy = path.join(tempDir, 'message');
29
+ fs.copyFileSync(original, copy);
30
+ const result = spawnSync(process.execPath, [path.join(__dirname, 'commit-msg-sdd-trailer.cjs'), copy, ...process.argv.slice(3)], {
31
+ encoding: 'utf8', timeout: 1500, maxBuffer: 256 * 1024,
32
+ });
33
+ if (result.stderr) process.stderr.write(result.stderr);
34
+ if (result.status === 0 && !result.error) fs.copyFileSync(copy, original);
35
+ else {
36
+ const message = fs.readFileSync(original, 'utf8').replace(/^Spec-(?:Change|Revision):[^\n]*\n?/gim, '');
37
+ fs.writeFileSync(original, message);
38
+ console.error('[SDD] 关联待补充(超时或运行失败),本次提交继续');
39
+ }
40
+ } catch (error) {
41
+ console.error(`[SDD] 关联待补充:${error.message};本次提交继续`);
42
+ } finally {
43
+ if (tempDir) fs.rmSync(tempDir, { recursive: true, force: true });
44
+ }
@@ -1,19 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
- /**
5
- * commit-msg hook,两种布局:
6
- *
7
- * - 多仓(代码仓有 .sdd.yaml,spec 是另一个 clone):
8
- * 写 Spec-Revision(spec 干净 HEAD 的完整 SHA)+ Spec-Change(活动 change,可多条)。
9
- * - 单仓(openspec 与代码同仓):
10
- * spec 与代码在同一次 commit 里,指针自指无意义 → 只写 Spec-Change,不写 Spec-Revision,
11
- * 且不做「spec 工作区干净」检查(提交进行中必然是脏的)。
12
- *
13
- * Spec-Change 来源:KLD_SDD_CHANGE(显式覆盖)> sdd.config.yaml active_changes。
14
- * Not from branch name, ever.
15
- *
16
- * Authority: 10-最小化多仓库关联与变更命名方案.md
4
+ /** Local provenance only. Explicit Change or exact reviewed index match; failures are advisory.
5
+ * Spec-Revision is written only for a clean, matching, separate Spec repository.
17
6
  */
18
7
 
19
8
  const fs = require('fs');
@@ -30,7 +19,7 @@ function resolveOntologyDir() {
30
19
  const ontologyDir = resolveOntologyDir();
31
20
  const changeKey = require(path.join(ontologyDir, 'change-key.cjs'));
32
21
  const sddConfig = require(path.join(ontologyDir, 'sdd-config.cjs'));
33
- const activeChanges = require(path.join(ontologyDir, 'active-changes.cjs'));
22
+ const snap = require('./delivery-snapshot.cjs');
34
23
  const { parseFrontmatter } = require(path.join(ontologyDir, 'artifact-parser.cjs'));
35
24
 
36
25
  function projectRootFromHook() {
@@ -40,8 +29,7 @@ function projectRootFromHook() {
40
29
  }
41
30
 
42
31
  function fail(message) {
43
- console.error(`[kld-sdd commit-msg] ${message}`);
44
- process.exit(1);
32
+ throw new Error(message);
45
33
  }
46
34
 
47
35
  function isSddLinkedCodeRepo(codeRepoRoot) {
@@ -67,9 +55,6 @@ function resolveLayout(repoRoot, env = process.env) {
67
55
  if (!context.specPath) {
68
56
  fail(context.message || '无法解析 spec clone 路径。请执行 kld-sdd link-spec --path=<spec>');
69
57
  }
70
- if (context.code === sddConfig.CODES.SPEC_PATH_INVALID) {
71
- fail(context.message);
72
- }
73
58
  // specPath 指回本仓根:等同单仓(兼容旧平铺);若指向仓内包裹包仍按 mono
74
59
  const resolvedSpec = path.resolve(context.specPath);
75
60
  const resolvedRoot = path.resolve(repoRoot);
@@ -79,6 +64,7 @@ function resolveLayout(repoRoot, env = process.env) {
79
64
  if (resolvedSpec.startsWith(`${resolvedRoot}${path.sep}`)) {
80
65
  return { mode: 'mono', specPath: resolvedSpec, context };
81
66
  }
67
+ if (context.code === sddConfig.CODES.SPEC_PATH_INVALID) fail(context.message);
82
68
  return { mode: 'multi', specPath: context.specPath, context };
83
69
  }
84
70
 
@@ -101,24 +87,42 @@ function parseEnvChangeKeys(env = process.env) {
101
87
  }
102
88
 
103
89
  /**
104
- * Spec-Change values: explicit env wins, otherwise every active change
105
- * registered in the spec repo's sdd.config.yaml.
90
+ * Spec-Change values: explicit env wins, otherwise an exact reviewed index match.
106
91
  */
107
- function resolveChangeKeys(specRoot, env = process.env) {
92
+ function resolveChangeKeys(specRoot, env = process.env, repoRoot = projectRootFromHook()) {
108
93
  const fromEnv = parseEnvChangeKeys(env);
109
94
  if (fromEnv) {
110
- for (const key of fromEnv) {
111
- requireChangeDir(specRoot, key, { strict: true });
112
- }
95
+ for (const key of fromEnv) requireChangeDir(specRoot, key, { strict: true });
113
96
  return { keys: fromEnv, source: 'env' };
114
97
  }
115
- const registered = activeChanges.activeChangeKeys(specRoot);
116
- const keys = registered.filter((key) => requireChangeDir(specRoot, key, { strict: false }));
117
- return { keys, source: 'sdd.config.yaml' };
98
+ // The active registry describes ongoing work, not the ownership of this commit.
99
+ const identity = snap.repoIdentity(repoRoot);
100
+ const file = path.join(specRoot, 'skywalk-sdd/state/delivery-associations', `${snap.digest(identity)}.json`);
101
+ if (!fs.existsSync(file)) return { keys: [], source: 'pending' };
102
+ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
103
+ const staged = snap.git(repoRoot, ['diff', '--cached', '--name-only', '--no-renames', '-z']).split('\0').filter(Boolean);
104
+ if (!staged.length || staged.length > 200 || saved.repository !== identity) return { keys: [], source: 'pending' };
105
+ const knownFiles = new Set(Object.values(saved.changes || {}).flatMap(item => (item.coveredFiles || []).map(f => f.path)));
106
+ if (staged.some(file => !knownFiles.has(file))) return { keys: [], source: 'pending' };
107
+ const candidates = [];
108
+ const entries = snap.entriesAt(repoRoot, 'index').filter(e => staged.includes(e.path));
109
+ const blobs = snap.readBlobs(repoRoot, entries);
110
+ for (const [key, association] of Object.entries(saved.changes || {})) {
111
+ const covered = new Map((association.coveredFiles || []).map(f => [f.path, f]));
112
+ if (staged.every(file => {
113
+ const meta = covered.get(file);
114
+ if (!meta) return false;
115
+ if (meta.deleted) return !entries.some(e => e.path === file);
116
+ const content = blobs.get(file);
117
+ return content && snap.fileMeta(file, content, meta.mode).sha256 === meta.sha256 && entries.find(e => e.path === file)?.mode === meta.mode;
118
+ }) && requireChangeDir(specRoot, key, { strict: false })) candidates.push(key);
119
+ }
120
+ // Multiple matching reviews are ambiguous too; explicit selection supports multi-change commits.
121
+ return { keys: candidates.length === 1 ? candidates : [], source: candidates.length === 1 ? 'review' : 'pending' };
118
122
  }
119
123
 
120
124
  /**
121
- * @param {{ strict: boolean }} options strict=true 阻断提交;false 仅警告并跳过该条
125
+ * @param {{ strict: boolean }} options strict=true 抛出关联错误;false 仅警告并跳过该条(外层均允许保存)
122
126
  */
123
127
  function requireChangeDir(specRoot, key, options = { strict: true }) {
124
128
  const proposalPath = path.join(specRoot, 'openspec', 'changes', key, 'proposal.md');
@@ -181,52 +185,39 @@ function resolveMsgFile(argv = process.argv) {
181
185
 
182
186
  function main(env = process.env) {
183
187
  const msgFile = resolveMsgFile();
184
- if (!msgFile) {
185
- fail('缺少 commit message 文件参数');
186
- }
187
-
188
+ if (!msgFile) fail('缺少 commit message 文件参数');
188
189
  const repoRoot = projectRootFromHook();
189
- const layout = resolveLayout(repoRoot, env);
190
-
191
- // 未接入 SDD 的普通代码仓:不写 Trailer
192
- if (layout.mode === 'unlinked') {
193
- process.exit(0);
194
- }
195
-
196
- const trailers = {
197
- // 多个活动 change 时写多条 Spec-Change
198
- 'Spec-Change': resolveChangeKeys(layout.specPath, env).keys,
199
- };
200
-
201
- if (layout.mode === 'multi') {
202
- const context = layout.context;
203
- // remote mismatch:仍允许写本地 Revision(溯源本地快照);仅警告
204
- if (context.code === sddConfig.CODES.REMOTE_MISMATCH) {
205
- console.error(`[kld-sdd commit-msg] 警告: ${context.message}`);
206
- }
207
-
208
- if (!sddConfig.gitWorkingTreeClean(layout.specPath)) {
209
- fail('spec 工作区不干净。请先提交或暂存 spec 变更后再提交代码。');
210
- }
211
-
212
- const revision = sddConfig.gitHeadSha(layout.specPath);
213
- if (!revision || revision.length < 40) {
214
- fail(`无法读取完整 Spec-Revision(需要完整 SHA): ${revision}`);
190
+ // Keep only the index context for the code repo (git commit -a uses a temporary index).
191
+ for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_OBJECT_DIRECTORY', 'GIT_ALTERNATE_OBJECT_DIRECTORIES']) delete process.env[key];
192
+ const original = fs.readFileSync(msgFile, 'utf8');
193
+ let trailers = { 'Spec-Change': [], 'Spec-Revision': [] };
194
+ try {
195
+ const layout = resolveLayout(repoRoot, env);
196
+ if (layout.mode === 'unlinked') return;
197
+ const association = resolveChangeKeys(layout.specPath, env, repoRoot);
198
+ trailers['Spec-Change'] = association.keys;
199
+ if (!association.keys.length) console.error('[SDD] 本次提交的 Change 待关联;未从活动列表猜测归属');
200
+ if (layout.mode === 'multi' && association.keys.length) {
201
+ if (layout.context.code === sddConfig.CODES.REMOTE_MISMATCH) fail(layout.context.message);
202
+ if (!layout.context.sddYaml?.ok || !layout.context.specRemote || !sddConfig.remotesMatch(layout.context.sddYaml.spec_repository, layout.context.specRemote)) fail('Spec 来源仓库尚未确认,不能写入精确 Revision');
203
+ const dirty = snap.git(layout.specPath, ['status', '--porcelain', '--untracked-files=normal']).trim();
204
+ if (dirty) console.error('[SDD] Spec 尚有未提交内容,Spec-Revision 待绑定;本次提交继续');
205
+ else {
206
+ const revision = snap.git(layout.specPath, ['rev-parse', '--verify', 'HEAD']).trim();
207
+ if (!/^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(revision)) fail('无法读取完整 Spec-Revision');
208
+ trailers['Spec-Revision'] = revision;
209
+ }
215
210
  }
216
- trailers['Spec-Revision'] = revision;
211
+ } catch (error) {
212
+ trailers = { 'Spec-Change': [], 'Spec-Revision': [] };
213
+ console.error(`[SDD] 关联待补充:${error.message};本次提交继续`);
217
214
  }
218
-
219
- const original = fs.readFileSync(msgFile, 'utf8');
220
- const updated = upsertTrailers(original, trailers);
221
- fs.writeFileSync(msgFile, updated, 'utf8');
215
+ fs.writeFileSync(msgFile, upsertTrailers(original, trailers), 'utf8');
222
216
  }
223
217
 
224
218
  if (require.main === module) {
225
- try {
226
- main();
227
- } catch (error) {
228
- fail(error.message || String(error));
229
- }
219
+ try { main(); }
220
+ catch (error) { console.error(`[SDD] 关联待补充:${error.message};本次提交继续`); }
230
221
  }
231
222
 
232
223
  module.exports = {