kld-sdd 2.7.3 → 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/kld-sdd-guide.html +16 -4
- package/lib/hook-gate-core.js +327 -0
- package/lib/init.js +221 -37
- 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 +36 -16
- package/skywalk-sdd/kb-sync-identity.cjs +99 -451
- package/skywalk-sdd/kb-upload.cjs +67 -44
- package/skywalk-sdd/lib/usage-contract.cjs +3 -2
- package/skywalk-sdd/lib/usage-reporter.cjs +27 -2
- 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 +7 -4
- 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 +22 -21
- package/templates/git-hooks/consistency-check-core.cjs +1087 -0
- package/templates/git-hooks/hooks.config +20 -1
- package/templates/git-hooks/pre-commit +22 -21
- 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 +22 -21
- package/templates/git-hooks/pre-push-consistency-check.cjs +58 -406
- 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
|
@@ -0,0 +1,1087 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* consistency-check-core.cjs
|
|
6
|
+
*
|
|
7
|
+
* pre-commit / pre-push 一致性校验 hook 的共享核心(非独立插件,不在 hooks.config 中登记)。
|
|
8
|
+
* 两个入口脚本通过 require 引用本模块,注入各自差异配置:
|
|
9
|
+
* - diff 采集方式(pre-commit: git diff HEAD;pre-push: 待推送范围 @{push}..HEAD)
|
|
10
|
+
* - 是否检查置信度(pre-commit 检查;pre-push 轻量跳过)
|
|
11
|
+
* - strict 模式(仅 pre-commit 支持 SDD_STRICT_CONSISTENCY)
|
|
12
|
+
* - 过期比对来源(pre-commit: 工作区内容;pre-push: HEAD 已提交内容,staleFromHead)
|
|
13
|
+
* - 提示文案(提交 / 推送)
|
|
14
|
+
*
|
|
15
|
+
* 核心行为(简化版):
|
|
16
|
+
* - diff 驱动:仅检查 diff 文件与报告 coveredFiles 有交集的 change,无关 change 跳过
|
|
17
|
+
* - 多仓归属过滤:报告 repositories 已声明覆盖仓库且不含当前仓的 change 静默跳过(不输出、不拦截)
|
|
18
|
+
* - 纯注释/空行/空格变更过滤:diff 中所有变更行均为注释/空行/空格的文件直接跳过,不参与校验
|
|
19
|
+
* - 报告存在性:活跃 change 无报告 → 阻止
|
|
20
|
+
* - 报告过期:diff 业务代码与 coveredFiles 交集文件内容变化(size 短路 + SHA-256)→ 阻止
|
|
21
|
+
* - 置信度门禁(仅 pre-commit):low → 阻止;strict 时 medium 也阻止
|
|
22
|
+
* - 无活跃 change 但 diff 有业务代码 → 阻止(提示先执行 opsx-propose)
|
|
23
|
+
* - 变更范围采集失败 → 阻止(避免静默放行的 fail-open)
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const path = require('path');
|
|
28
|
+
const crypto = require('crypto');
|
|
29
|
+
const { execFileSync } = require('child_process');
|
|
30
|
+
|
|
31
|
+
function readArg(name) {
|
|
32
|
+
const prefix = `--${name}=`;
|
|
33
|
+
const found = process.argv.find(a => a.startsWith(prefix));
|
|
34
|
+
return found ? found.slice(prefix.length) : '';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// --- Spec 根路径解析 ---
|
|
38
|
+
|
|
39
|
+
function resolveSpecRoot() {
|
|
40
|
+
return path.resolve(__dirname, '..', '..');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 内置 sdd.config.yaml active_changes 解析。
|
|
45
|
+
*/
|
|
46
|
+
function parseActiveChangesFromYaml(content) {
|
|
47
|
+
const keys = [];
|
|
48
|
+
let inActive = false;
|
|
49
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
50
|
+
const line = raw.replace(/#.*$/, '').replace(/\s+$/, '');
|
|
51
|
+
if (!line.trim()) continue;
|
|
52
|
+
if (/^active_changes:\s*(\[\s*\])?\s*$/.test(line)) { inActive = true; continue; }
|
|
53
|
+
if (inActive) {
|
|
54
|
+
const m = /^\s+-\s+change-key:\s*(\S+)/.exec(line);
|
|
55
|
+
if (m) keys.push(m[1]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { keys, sawSection: inActive };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 发现活跃 change:以 sdd.config.yaml 注册表为权威源,
|
|
63
|
+
* 注册表缺失/损坏时降级为扫描 openspec/changes/ 目录。
|
|
64
|
+
*/
|
|
65
|
+
function discoverActiveChanges(specRoot) {
|
|
66
|
+
const configPath = path.join(specRoot, 'sdd.config.yaml');
|
|
67
|
+
if (fs.existsSync(configPath)) {
|
|
68
|
+
try {
|
|
69
|
+
const { keys, sawSection } = parseActiveChangesFromYaml(fs.readFileSync(configPath, 'utf8'));
|
|
70
|
+
if (sawSection) return keys;
|
|
71
|
+
} catch {
|
|
72
|
+
// 降级为目录扫描
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const changesDir = path.join(specRoot, 'openspec', 'changes');
|
|
77
|
+
if (!fs.existsSync(changesDir)) return [];
|
|
78
|
+
|
|
79
|
+
return fs.readdirSync(changesDir).filter(name => {
|
|
80
|
+
const p = path.join(changesDir, name);
|
|
81
|
+
return fs.statSync(p).isDirectory() && !name.startsWith('.') && name !== 'archive';
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- 报告查找与解析 ---
|
|
86
|
+
|
|
87
|
+
const REPORT_JSON_NAME = 'consistency-report-result.json';
|
|
88
|
+
|
|
89
|
+
const VALID_CONFIDENCE = new Set(['high', 'medium', 'low']);
|
|
90
|
+
|
|
91
|
+
function findReportJson(specRoot, changeName) {
|
|
92
|
+
const p = path.join(specRoot, 'openspec', 'changes', changeName, REPORT_JSON_NAME);
|
|
93
|
+
return fs.existsSync(p) ? p : null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function parseReport(reportPath) {
|
|
97
|
+
try {
|
|
98
|
+
const data = JSON.parse(fs.readFileSync(reportPath, 'utf8'));
|
|
99
|
+
let generatedAtMs = null;
|
|
100
|
+
let generatedAtFormatError = null;
|
|
101
|
+
if (data.generatedAt) {
|
|
102
|
+
const raw = String(data.generatedAt);
|
|
103
|
+
const normalized = /T\d{2}:\d{2}/.test(raw)
|
|
104
|
+
? raw.replace('T', ' ').replace(/(Z|[+-]\d{2}:?\d{2})$/, '')
|
|
105
|
+
: raw;
|
|
106
|
+
const t = new Date(normalized.replace(/-/g, '/')).getTime();
|
|
107
|
+
if (!isNaN(t)) {
|
|
108
|
+
generatedAtMs = t + 1000;
|
|
109
|
+
} else {
|
|
110
|
+
generatedAtFormatError = `generatedAt 无法解析(实际: ${raw},应为 YYYY-MM-DD HH:mm:ss 本地时间)`;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
confidence: (data.confidence || '').toLowerCase(),
|
|
115
|
+
coveredFiles: Array.isArray(data.coveredFiles) ? data.coveredFiles : null,
|
|
116
|
+
repositories: Array.isArray(data.repositories) ? data.repositories : [],
|
|
117
|
+
generatedAtMs,
|
|
118
|
+
generatedAtFormatError,
|
|
119
|
+
reportPath,
|
|
120
|
+
};
|
|
121
|
+
} catch (err) {
|
|
122
|
+
return { confidence: '', coveredFiles: null, repositories: [], generatedAtMs: null, generatedAtFormatError: null, reportPath, parseError: err.message };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* 报告字段合法性校验(仅校验 confidence 枚举 + generatedAt 格式)。
|
|
128
|
+
* @returns {string[]} 错误描述列表;空数组表示合法
|
|
129
|
+
*/
|
|
130
|
+
function validateReportFields(report) {
|
|
131
|
+
const errors = [];
|
|
132
|
+
|
|
133
|
+
if (!VALID_CONFIDENCE.has(report.confidence)) {
|
|
134
|
+
errors.push(`confidence 非法(只能是 high/medium/low,实际: ${report.confidence || '缺失'})`);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (report.generatedAtFormatError) {
|
|
138
|
+
errors.push(report.generatedAtFormatError);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return errors;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// --- 变更文件采集 ---
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 简易 YAML 解析器:仅提取 code_repos 列表
|
|
148
|
+
*/
|
|
149
|
+
function parseCodeRepos(yamlPath) {
|
|
150
|
+
try {
|
|
151
|
+
const content = fs.readFileSync(yamlPath, 'utf8');
|
|
152
|
+
const match = content.match(/code_repos:\s*\n((?:[ \t]*(?:#[^\n]*\n)?[ \t]*-[ \t]*\S+[ \t]*(?:#[^\n]*)?\n?)+)/);
|
|
153
|
+
if (!match) return null;
|
|
154
|
+
return match[1]
|
|
155
|
+
.split('\n')
|
|
156
|
+
.map(line => line.replace(/#.*$/, '').replace(/^\s+-\s+/, '').trim())
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
} catch {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function gitUntracked(repoRoot) {
|
|
164
|
+
try {
|
|
165
|
+
return execFileSync('git', ['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard'], {
|
|
166
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
167
|
+
}).split(/\r?\n/).filter(Boolean);
|
|
168
|
+
} catch {
|
|
169
|
+
return [];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// --- 注释/空行/空格变更过滤 ---
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* 判断 diff 行是否为"无实质意义"变更(注释、空行、纯空格)。
|
|
177
|
+
* 仅对 diff 中新增(+)和删除(-)的行内容做判断。
|
|
178
|
+
* @param {string} line 去掉 +/- 前缀后的行内容
|
|
179
|
+
* @returns {boolean} true=无实质意义(可跳过)
|
|
180
|
+
*/
|
|
181
|
+
function isTrivialDiffLine(line) {
|
|
182
|
+
const trimmed = line.trim();
|
|
183
|
+
if (trimmed === '') return true; // 空行/纯空格
|
|
184
|
+
if (trimmed.startsWith('//')) return true; // 行注释 (JS/TS/Java/C/C++ 等)
|
|
185
|
+
if (trimmed.startsWith('#')) {
|
|
186
|
+
// 排除 C/C++ 预处理指令
|
|
187
|
+
const rest = trimmed.slice(1).trimStart();
|
|
188
|
+
if (/^(include|define|ifdef|ifndef|endif|else|elif|undef|pragma|error|warning|line|region|endregion)\b/.test(rest)) {
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
return true; // Python/Shell/YAML 注释
|
|
192
|
+
}
|
|
193
|
+
if (trimmed.startsWith('/*') || trimmed.endsWith('*/')) return true; // 块注释开始/结束
|
|
194
|
+
if (trimmed.startsWith('*')) return true; // 块注释续行 (* ... 或 ** ...)
|
|
195
|
+
if (trimmed.startsWith('<!--')) return true; // HTML/XML 注释
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* 从 diff 头部行解析文件路径。
|
|
201
|
+
* @param {string} headerLine 如 "+++ b/src/Svc.java" 或 "+++ \"b/src/文件.java\""
|
|
202
|
+
* @returns {string|null} 归一化后的文件路径(去掉 a/ b/ 前缀),/dev/null 返回 null
|
|
203
|
+
*/
|
|
204
|
+
function parseDiffPath(headerLine) {
|
|
205
|
+
let p = headerLine.slice(4); // 去掉 "+++ " 或 "--- "
|
|
206
|
+
if (p === '/dev/null') return null;
|
|
207
|
+
if (p.startsWith('"') && p.endsWith('"')) {
|
|
208
|
+
p = p.slice(1, -1).replace(/\\(.)/g, '$1');
|
|
209
|
+
}
|
|
210
|
+
if (p.startsWith('a/') || p.startsWith('b/')) {
|
|
211
|
+
p = p.slice(2);
|
|
212
|
+
}
|
|
213
|
+
return p;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* 解析 git diff --unified=0 输出,返回所有变更仅为注释/空行/空格的文件集合。
|
|
218
|
+
* @param {string} diffOutput git diff --unified=0 的完整输出
|
|
219
|
+
* @returns {Set<string>} 文件路径集合(已归一化,去掉 b/ 前缀)
|
|
220
|
+
*/
|
|
221
|
+
function parseDiffForTrivialFiles(diffOutput) {
|
|
222
|
+
const trivialFiles = new Set();
|
|
223
|
+
let currentFile = null;
|
|
224
|
+
let hasSubstantive = false;
|
|
225
|
+
let hasAnyChange = false;
|
|
226
|
+
|
|
227
|
+
for (const line of diffOutput.split(/\r?\n/)) {
|
|
228
|
+
if (line.startsWith('--- ')) {
|
|
229
|
+
if (currentFile !== null && hasAnyChange && !hasSubstantive) {
|
|
230
|
+
trivialFiles.add(currentFile);
|
|
231
|
+
}
|
|
232
|
+
currentFile = null;
|
|
233
|
+
hasSubstantive = false;
|
|
234
|
+
hasAnyChange = false;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (line.startsWith('+++ ')) {
|
|
238
|
+
currentFile = parseDiffPath(line);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (line.startsWith('\\')) continue; // ""
|
|
242
|
+
if (line.startsWith('Binary files')) {
|
|
243
|
+
hasSubstantive = true;
|
|
244
|
+
hasAnyChange = true;
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
248
|
+
hasAnyChange = true;
|
|
249
|
+
if (!isTrivialDiffLine(line.slice(1))) hasSubstantive = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
253
|
+
hasAnyChange = true;
|
|
254
|
+
if (!isTrivialDiffLine(line.slice(1))) hasSubstantive = true;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
if (currentFile !== null && hasAnyChange && !hasSubstantive) {
|
|
259
|
+
trivialFiles.add(currentFile);
|
|
260
|
+
}
|
|
261
|
+
return trivialFiles;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* 获取变更仅为注释/空行/空格的文件集合(仅对候选文件做 patch 逐行分析)。
|
|
266
|
+
* 多级降级保证兼容性:--ignore-blank-lines 需 git ≥ 2.30,老版本自动降级。
|
|
267
|
+
* @param {string} repoRoot 代码仓根目录
|
|
268
|
+
* @param {string[]} baseArgs git diff 基础参数(如 ['diff', 'HEAD'])
|
|
269
|
+
* @param {string[]} files 候选文件(已由 -w 预筛排除纯空白/空行变更)
|
|
270
|
+
* @returns {Set<string>} 文件路径集合
|
|
271
|
+
*/
|
|
272
|
+
function getTrivialDiffFiles(repoRoot, baseArgs, files) {
|
|
273
|
+
if (!files || files.length === 0) return new Set();
|
|
274
|
+
const variants = [
|
|
275
|
+
[...baseArgs, '-w', '--ignore-blank-lines', '--unified=0', '--', ...files], // 首选:忽略空白+空行
|
|
276
|
+
[...baseArgs, '-w', '--unified=0', '--', ...files], // 老 git:仅忽略空白
|
|
277
|
+
[...baseArgs, '--unified=0', '--', ...files], // 最老:不忽略(保守)
|
|
278
|
+
];
|
|
279
|
+
for (const args of variants) {
|
|
280
|
+
try {
|
|
281
|
+
const out = execFileSync('git', ['-c', 'core.quotePath=false', ...args], {
|
|
282
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
283
|
+
});
|
|
284
|
+
return parseDiffForTrivialFiles(out);
|
|
285
|
+
} catch {
|
|
286
|
+
// 尝试下一个变体;全部失败时返回空 Set(不过滤,保守策略)
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return new Set();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* 单仓 diff 采集(一次 git 调用拿 modified + deleted)。
|
|
294
|
+
* 纯注释/空行/空格变更的文件会被过滤掉,不参与一致性校验。
|
|
295
|
+
*
|
|
296
|
+
* 性能优化(上百文件批量加注释/调格式场景):
|
|
297
|
+
* 1. 用 git 原生 `-w --ignore-blank-lines --name-status` 预筛:纯空白/空行变更的文件
|
|
298
|
+
* 根本不会出现在输出中(一次轻量调用同时拿列表+删除+过滤空白),
|
|
299
|
+
* 避免先把全量 patch 读入内存再逐行丢弃;
|
|
300
|
+
* 2. 全部变更均为纯空白/空行时输出为空 → 直接放行,零 patch 解析;
|
|
301
|
+
* 3. 仅对剩余候选文件(可能是注释变更)执行 --unified=0 patch 分析,
|
|
302
|
+
* patch 也带 -w 预筛,体积大幅缩小。
|
|
303
|
+
* @returns {{files: string[], deleted: string[], ok: boolean}}
|
|
304
|
+
*/
|
|
305
|
+
function gitDiffNameStatus(repoRoot, baseArgs) {
|
|
306
|
+
const run = (args) => execFileSync('git', ['-c', 'core.quotePath=false', ...args], {
|
|
307
|
+
cwd: repoRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'],
|
|
308
|
+
});
|
|
309
|
+
try {
|
|
310
|
+
// -w 预筛 + 列表采集一次完成;--ignore-blank-lines 老 git 不支持时降级为纯 -w
|
|
311
|
+
let out;
|
|
312
|
+
try {
|
|
313
|
+
out = run([...baseArgs, '-w', '--ignore-blank-lines', '--name-status']);
|
|
314
|
+
} catch {
|
|
315
|
+
out = run([...baseArgs, '--name-status']);
|
|
316
|
+
}
|
|
317
|
+
const files = [];
|
|
318
|
+
const deleted = [];
|
|
319
|
+
for (const line of out.split(/\r?\n/)) {
|
|
320
|
+
if (!line) continue;
|
|
321
|
+
const tab = line.indexOf('\t');
|
|
322
|
+
if (tab < 0) continue;
|
|
323
|
+
const status = line[0].toUpperCase();
|
|
324
|
+
const rest = line.slice(tab + 1);
|
|
325
|
+
const parts = rest.split('\t');
|
|
326
|
+
const filePath = parts.length > 1 ? parts[parts.length - 1] : parts[0];
|
|
327
|
+
if (!filePath) continue;
|
|
328
|
+
if (status === 'D') {
|
|
329
|
+
deleted.push(filePath);
|
|
330
|
+
} else {
|
|
331
|
+
files.push(filePath);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
// 无修改文件(仅删除)或全部为纯空白变更(-w 后无输出)→ 无需 patch 分析
|
|
335
|
+
if (files.length === 0) return { files, deleted, ok: true };
|
|
336
|
+
|
|
337
|
+
// 过滤纯注释/空行/空格变更的文件(patch 已 -w 预筛,仅候选文件)
|
|
338
|
+
const trivialFiles = getTrivialDiffFiles(repoRoot, baseArgs, files);
|
|
339
|
+
const filtered = files.filter(f => !trivialFiles.has(f));
|
|
340
|
+
return { files: filtered, deleted, ok: true };
|
|
341
|
+
} catch {
|
|
342
|
+
return { files: [], deleted: [], ok: false };
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 采集变更文件:对每个代码仓执行入口注入的 diffFn,按需合并 untracked,归一化为工作区相对路径。
|
|
348
|
+
* @param {string} projectRoot
|
|
349
|
+
* @param {(repoRoot: string) => {files: string[], deleted: string[], ok: boolean}} diffFn
|
|
350
|
+
* @param {object} [options]
|
|
351
|
+
* @param {boolean} [options.includeUntracked=true]
|
|
352
|
+
* @returns {{files: string[], deleted: string[], failed: boolean}}
|
|
353
|
+
*/
|
|
354
|
+
function getModifiedFiles(projectRoot, diffFn, options = {}) {
|
|
355
|
+
const includeUntracked = options.includeUntracked !== false;
|
|
356
|
+
const files = [];
|
|
357
|
+
const deleted = [];
|
|
358
|
+
let failed = false;
|
|
359
|
+
|
|
360
|
+
const collect = (repoRoot, prefix) => {
|
|
361
|
+
const r = diffFn(repoRoot);
|
|
362
|
+
if (!r || r.ok === false) {
|
|
363
|
+
failed = true;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const repoFiles = includeUntracked
|
|
367
|
+
? [...(r.files || []), ...gitUntracked(repoRoot)]
|
|
368
|
+
: (r.files || []);
|
|
369
|
+
for (const f of repoFiles) {
|
|
370
|
+
files.push(prefix ? `${prefix}/${f}` : f);
|
|
371
|
+
}
|
|
372
|
+
for (const f of (r.deleted || [])) {
|
|
373
|
+
deleted.push(prefix ? `${prefix}/${f}` : f);
|
|
374
|
+
}
|
|
375
|
+
};
|
|
376
|
+
|
|
377
|
+
// 工作区模式:读 .sdd-workspace.yaml 的 code_repos
|
|
378
|
+
const wsYaml = path.join(projectRoot, '.sdd-workspace.yaml');
|
|
379
|
+
if (fs.existsSync(wsYaml)) {
|
|
380
|
+
const repos = parseCodeRepos(wsYaml);
|
|
381
|
+
if (repos && repos.length > 0) {
|
|
382
|
+
for (const repo of repos) {
|
|
383
|
+
const prefix = String(repo).replace(/\\/g, '/').replace(/\/+$/, '');
|
|
384
|
+
collect(path.join(projectRoot, repo), prefix);
|
|
385
|
+
}
|
|
386
|
+
return {
|
|
387
|
+
files: [...new Set(files.map(f => f.replace(/\\/g, '/')))],
|
|
388
|
+
deleted: [...new Set(deleted.map(f => f.replace(/\\/g, '/')))],
|
|
389
|
+
failed,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// 单仓模式 / 兜底
|
|
395
|
+
collect(projectRoot, '');
|
|
396
|
+
return {
|
|
397
|
+
files: [...new Set(files.map(f => f.replace(/\\/g, '/')))],
|
|
398
|
+
deleted: [...new Set(deleted.map(f => f.replace(/\\/g, '/')))],
|
|
399
|
+
failed,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// --- 配置加载(hooks.config [consistency] 段,单一实现) ---
|
|
404
|
+
|
|
405
|
+
/**
|
|
406
|
+
* 解析 INI 风格的 [consistency] 段,返回段内所有 key=value。
|
|
407
|
+
*/
|
|
408
|
+
function parseConsistencySection() {
|
|
409
|
+
const configPath = path.join(__dirname, 'hooks.config');
|
|
410
|
+
const values = {};
|
|
411
|
+
try {
|
|
412
|
+
const content = fs.readFileSync(configPath, 'utf8');
|
|
413
|
+
let inSection = false;
|
|
414
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
415
|
+
const line = raw.trim();
|
|
416
|
+
if (/^\[.+\]$/.test(line)) {
|
|
417
|
+
if (inSection) break;
|
|
418
|
+
inSection = line.toLowerCase() === '[consistency]';
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
if (!inSection) continue;
|
|
422
|
+
if (line.startsWith('#') || !line) continue;
|
|
423
|
+
const m = /^(\w+)\s*=\s*(.*)$/.exec(line);
|
|
424
|
+
if (!m) continue;
|
|
425
|
+
values[m[1].toLowerCase()] = m[2].trim();
|
|
426
|
+
}
|
|
427
|
+
} catch {
|
|
428
|
+
// 配置缺失:返回空对象,由调用方判定缺失语义
|
|
429
|
+
}
|
|
430
|
+
return { configPath, values };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const CONSISTENCY_CONFIG = parseConsistencySection();
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* 业务代码扩展名白名单(business_code_exts,唯一来源为 hooks.config,无内置默认值)。
|
|
437
|
+
* @returns {{exts: Set<string>, source: 'config'|'missing'|'empty', configPath: string|null}}
|
|
438
|
+
*/
|
|
439
|
+
function loadBusinessCodeExts() {
|
|
440
|
+
const { configPath, values } = CONSISTENCY_CONFIG;
|
|
441
|
+
const rawValue = Object.prototype.hasOwnProperty.call(values, 'business_code_exts')
|
|
442
|
+
? values.business_code_exts : null;
|
|
443
|
+
|
|
444
|
+
if (rawValue === null) {
|
|
445
|
+
return { exts: new Set(), source: 'missing', configPath };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const exts = new Set();
|
|
449
|
+
for (const token of rawValue.split(/[,,\s]+/).filter(Boolean)) {
|
|
450
|
+
const ext = token.toLowerCase().startsWith('.') ? token.toLowerCase() : `.${token.toLowerCase()}`;
|
|
451
|
+
if (/^\.[a-z0-9]+$/.test(ext)) exts.add(ext);
|
|
452
|
+
}
|
|
453
|
+
if (!exts.size) {
|
|
454
|
+
return { exts: new Set(), source: 'empty', configPath };
|
|
455
|
+
}
|
|
456
|
+
return { exts, source: 'config', configPath };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 布尔配置项(skip / strict / warn_only)。
|
|
461
|
+
* @returns {{skip: boolean, strict: boolean, warnOnly: boolean}}
|
|
462
|
+
*/
|
|
463
|
+
function loadConsistencyMode() {
|
|
464
|
+
const { values } = CONSISTENCY_CONFIG;
|
|
465
|
+
const isTrue = v => v === '1' || v === 'true' || v === 'yes';
|
|
466
|
+
return {
|
|
467
|
+
skip: isTrue((values.skip || '').toLowerCase()),
|
|
468
|
+
strict: isTrue((values.strict || '').toLowerCase()),
|
|
469
|
+
warnOnly: isTrue((values.warn_only || values.warnonly || '').toLowerCase()),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
const BUSINESS_CODE_EXTS_INFO = loadBusinessCodeExts();
|
|
474
|
+
const BUSINESS_CODE_EXTS = BUSINESS_CODE_EXTS_INFO.exts;
|
|
475
|
+
const CONSISTENCY_MODE = loadConsistencyMode();
|
|
476
|
+
|
|
477
|
+
// 工具链配置文件排除规则
|
|
478
|
+
const TOOLCHAIN_CONFIG_RE = /^(?:vite|vitest|next|nuxt|astro|svelte|remix|webpack|rollup|babel|jest|playwright|cypress|eslint|prettier|tailwind|postcss|karma|wrangler|nest-cli|angular|vue|craco|rspack|metro|tsconfig|jsconfig|turbo|nx)\.(?:config|conf)\.[a-z]+$/i;
|
|
479
|
+
const DOTFILE_CONFIG_RE = /^\.?(?:eslintrc|babelrc|prettierrc|stylelintrc)\.[a-z]+$/i;
|
|
480
|
+
|
|
481
|
+
function isBusinessCode(file) {
|
|
482
|
+
const normalized = String(file).replace(/\\/g, '/');
|
|
483
|
+
const base = normalized.split('/').pop() || '';
|
|
484
|
+
|
|
485
|
+
const dot = base.lastIndexOf('.');
|
|
486
|
+
if (dot < 0) return false;
|
|
487
|
+
const ext = base.slice(dot).toLowerCase();
|
|
488
|
+
if (!BUSINESS_CODE_EXTS.has(ext)) return false;
|
|
489
|
+
|
|
490
|
+
if (/\.d\.tsx?$/i.test(base)) return false;
|
|
491
|
+
if (/(^|\/)(dist|build|target|out|node_modules)\//.test(normalized)) return false;
|
|
492
|
+
if (/\.min\.(js|css)$/i.test(base)) return false;
|
|
493
|
+
if (/(^|\/)(__tests__|tests?|e2e|cypress|fixtures|mocks?)(\/|$)/i.test(normalized)) return false;
|
|
494
|
+
if (/(^|\/)[^/]*(?:\.test|\.spec|_test)\.[a-z]+$/i.test(normalized)) return false;
|
|
495
|
+
if (TOOLCHAIN_CONFIG_RE.test(base)) return false;
|
|
496
|
+
if (DOTFILE_CONFIG_RE.test(base)) return false;
|
|
497
|
+
|
|
498
|
+
return true;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// --- 路径归一化:报告 coveredFiles → 代码仓相对 ---
|
|
502
|
+
|
|
503
|
+
/**
|
|
504
|
+
* 多仓布局下,用报告 repositories[].path 做前缀剥离,归一化到代码仓相对路径。
|
|
505
|
+
* 单仓布局两者天然一致,直接返回。
|
|
506
|
+
* 返回 Map<path, Array<{size, sha256}>>(key 小写)。
|
|
507
|
+
*
|
|
508
|
+
* 兜底:repositories 未声明(旧报告)时,用 currentRepoName 做前缀剥离。
|
|
509
|
+
* 场景:多仓布局下旧报告缺 repositories,coveredFiles 带仓前缀(如 user-info-mgmt/src/...),
|
|
510
|
+
* hook 在代码仓根运行采集无前缀 diff(src/...),需剥离前缀才能命中交集。
|
|
511
|
+
* currentRepoName 是客观的代码仓目录名,不会误剥普通路径段。
|
|
512
|
+
*/
|
|
513
|
+
function normalizeCoveredFiles(report, currentRepoName) {
|
|
514
|
+
if (!report.coveredFiles) return null;
|
|
515
|
+
const repoPrefixes = (report.repositories || [])
|
|
516
|
+
.map(r => (r.path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase())
|
|
517
|
+
.filter(Boolean);
|
|
518
|
+
|
|
519
|
+
const fallbackPrefix = (repoPrefixes.length === 0 && currentRepoName)
|
|
520
|
+
? currentRepoName.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
|
|
521
|
+
: null;
|
|
522
|
+
|
|
523
|
+
const result = new Map();
|
|
524
|
+
for (const item of report.coveredFiles) {
|
|
525
|
+
if (!item || typeof item !== 'object') continue; // 仅接受对象条目(path+size+sha256)
|
|
526
|
+
const normalized = String(item.path || '').replace(/\\/g, '/');
|
|
527
|
+
if (!normalized) continue;
|
|
528
|
+
const meta = { size: item.size, sha256: item.sha256 };
|
|
529
|
+
|
|
530
|
+
const originalLower = normalized.toLowerCase();
|
|
531
|
+
let stripped = originalLower;
|
|
532
|
+
for (const prefix of repoPrefixes) {
|
|
533
|
+
if (stripped === prefix) { stripped = ''; break; }
|
|
534
|
+
if (stripped.startsWith(prefix + '/')) { stripped = stripped.slice(prefix.length + 1); break; }
|
|
535
|
+
}
|
|
536
|
+
// 兜底剥离:repositories 为空且未命中任何显式前缀时,用 currentRepoName 剥离
|
|
537
|
+
if (fallbackPrefix && stripped === originalLower) {
|
|
538
|
+
if (stripped === fallbackPrefix) { stripped = ''; }
|
|
539
|
+
else if (stripped.startsWith(fallbackPrefix + '/')) { stripped = stripped.slice(fallbackPrefix.length + 1); }
|
|
540
|
+
}
|
|
541
|
+
if (!stripped) continue;
|
|
542
|
+
|
|
543
|
+
const existing = result.get(stripped) || [];
|
|
544
|
+
existing.push(meta);
|
|
545
|
+
result.set(stripped, existing);
|
|
546
|
+
}
|
|
547
|
+
return result;
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// --- 过期判断(size 短路 + SHA-256) ---
|
|
551
|
+
|
|
552
|
+
function getReportTime(report) {
|
|
553
|
+
if (typeof report.generatedAtMs === 'number') return report.generatedAtMs;
|
|
554
|
+
return fs.statSync(report.reportPath).mtimeMs;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function readWorkingFile(fp) {
|
|
558
|
+
try {
|
|
559
|
+
return fs.readFileSync(fp);
|
|
560
|
+
} catch {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 批量读取 HEAD 已提交版本的文件内容(pre-push 过期检查用)。
|
|
567
|
+
* 用 cat-file --batch --filters 一次进程读取 N 个文件。
|
|
568
|
+
* 单个文件读取失败(不在 HEAD / git 版本过老不支持 --batch --filters)返回 null,
|
|
569
|
+
* 由过期判断按"内容已变"处理(fail-closed)。
|
|
570
|
+
* @param {string} projectRoot
|
|
571
|
+
* @param {string[]} relFiles 代码仓相对路径列表
|
|
572
|
+
* @returns {Map<string, Buffer|null>} key=原始路径,value=内容(null=不可读)
|
|
573
|
+
*/
|
|
574
|
+
function gitShowHeadContentBatch(projectRoot, relFiles) {
|
|
575
|
+
const result = new Map();
|
|
576
|
+
if (!relFiles.length) return result;
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const input = relFiles.map(f => `HEAD:${String(f).replace(/\\/g, '/')}`).join('\n') + '\n';
|
|
580
|
+
const out = execFileSync('git', ['cat-file', '--batch', '--filters'], {
|
|
581
|
+
cwd: projectRoot, encoding: 'buffer', input, stdio: ['pipe', 'pipe', 'ignore'],
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
let offset = 0;
|
|
585
|
+
for (const relFile of relFiles) {
|
|
586
|
+
const nlIdx = out.indexOf(0x0a, offset);
|
|
587
|
+
if (nlIdx < 0) { result.set(relFile, null); break; }
|
|
588
|
+
const header = out.slice(offset, nlIdx).toString('ascii');
|
|
589
|
+
offset = nlIdx + 1;
|
|
590
|
+
|
|
591
|
+
const parts = header.split(' ');
|
|
592
|
+
if (parts.length < 2 || parts[1] === 'missing') {
|
|
593
|
+
result.set(relFile, null);
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const size = parseInt(parts[parts.length - 1], 10);
|
|
598
|
+
if (isNaN(size)) {
|
|
599
|
+
result.set(relFile, null);
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (offset + size > out.length) {
|
|
604
|
+
result.set(relFile, null);
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
result.set(relFile, out.slice(offset, offset + size));
|
|
608
|
+
offset += size + 1;
|
|
609
|
+
}
|
|
610
|
+
return result;
|
|
611
|
+
} catch {
|
|
612
|
+
// --batch --filters 整体失败(如极老版本 git):全部判不可读 → 过期(fail-closed)
|
|
613
|
+
for (const relFile of relFiles) {
|
|
614
|
+
result.set(relFile, null);
|
|
615
|
+
}
|
|
616
|
+
return result;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function normalizeEolLf(buf) {
|
|
621
|
+
const str = buf.toString('binary');
|
|
622
|
+
return Buffer.from(str.replace(/\r\n/g, '\n'), 'binary');
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* 单文件过期判断(mtime 快速跳过 + size 短路 + SHA-256,行尾归一化)。
|
|
627
|
+
* mtime 快速路径:文件修改时间早于报告生成时间 → 报告生成后从未被碰过 → 内容必然未变,
|
|
628
|
+
* 跳过读取和哈希(大变更场景省 100-300ms/百文件)。mtime 晚于报告时间才做精确哈希判断,
|
|
629
|
+
* 因此 git pull/checkout 重置 mtime(内容未变)只会多算一次哈希,不会误判过期。
|
|
630
|
+
*/
|
|
631
|
+
function isFileChangedOnDisk(fp, metas, reportTime, readContent = readWorkingFile, relFile = '') {
|
|
632
|
+
const list = Array.isArray(metas) ? metas : (metas ? [metas] : []);
|
|
633
|
+
const hashed = list.filter(m => m && m.sha256);
|
|
634
|
+
|
|
635
|
+
if (hashed.length) {
|
|
636
|
+
// mtime 快速跳过:仅在能拿到本地文件时(readContent 为工作区读取)且 mtime 可信时启用
|
|
637
|
+
if (readContent === readWorkingFile) {
|
|
638
|
+
try {
|
|
639
|
+
if (fs.statSync(fp).mtimeMs <= reportTime) return false;
|
|
640
|
+
} catch {
|
|
641
|
+
return true; // 文件不存在 → 过期
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
const content = readContent(fp, relFile);
|
|
645
|
+
if (content === null) return true;
|
|
646
|
+
const normalized = normalizeEolLf(content);
|
|
647
|
+
const candidates = hashed.filter(m =>
|
|
648
|
+
!(typeof m.size === 'number' && normalized.length !== m.size));
|
|
649
|
+
if (!candidates.length) return true;
|
|
650
|
+
const hash = crypto.createHash('sha256').update(normalized).digest('hex');
|
|
651
|
+
return !candidates.some(m => hash === String(m.sha256).toLowerCase());
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// 无哈希条目:mtime 比较
|
|
655
|
+
let stat;
|
|
656
|
+
try {
|
|
657
|
+
stat = fs.statSync(fp);
|
|
658
|
+
} catch {
|
|
659
|
+
return true;
|
|
660
|
+
}
|
|
661
|
+
return stat.mtimeMs > reportTime;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* 报告过期判断:仅对 coveredFiles 与 diff(含删除文件)的交集做内容比对。
|
|
666
|
+
*/
|
|
667
|
+
function isReportStale(report, covered, diffFiles, projectRoot, readContent = readWorkingFile) {
|
|
668
|
+
const reportTime = getReportTime(report);
|
|
669
|
+
|
|
670
|
+
return diffFiles.some(file => {
|
|
671
|
+
if (!covered.has(file.toLowerCase())) return false;
|
|
672
|
+
const fp = path.join(projectRoot, file);
|
|
673
|
+
return isFileChangedOnDisk(fp, covered.get(file.toLowerCase()), reportTime, readContent, file);
|
|
674
|
+
});
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// --- 提示信息 ---
|
|
678
|
+
|
|
679
|
+
function generateInstructions(opts, result, warnOnly) {
|
|
680
|
+
const { label, actionNoun, checkConfidence } = opts;
|
|
681
|
+
const { parseError, invalid, lowConfidence, stale, missing } = result;
|
|
682
|
+
const lines = ['', '=========================================='];
|
|
683
|
+
|
|
684
|
+
if (warnOnly) {
|
|
685
|
+
lines.push(`${label} ⚠️ 仅告警模式(SDD_WARN_ONLY=1),以下问题不会阻止${actionNoun}:`);
|
|
686
|
+
} else {
|
|
687
|
+
lines.push(`${label} 以下变更存在问题,拒绝${actionNoun}:`);
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
lines.push('');
|
|
691
|
+
|
|
692
|
+
if (missing.length) {
|
|
693
|
+
lines.push(' 以下变更缺少一致性校验报告:');
|
|
694
|
+
missing.forEach(c => lines.push(` - ${c}`));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
if (parseError.length) {
|
|
698
|
+
lines.push(' 报告解析失败(JSON 格式损坏):');
|
|
699
|
+
parseError.forEach(c => lines.push(` - ${c}`));
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (invalid.length) {
|
|
703
|
+
lines.push(' 报告字段不合法:');
|
|
704
|
+
invalid.forEach(({ cn, errors }) => {
|
|
705
|
+
lines.push(` - ${cn}`);
|
|
706
|
+
errors.forEach(e => lines.push(` · ${e}`));
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
if (stale.length) {
|
|
711
|
+
lines.push(' 报告已过期(代码在报告生成后被修改或删除):');
|
|
712
|
+
stale.forEach(c => lines.push(` - ${c}`));
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (checkConfidence && lowConfidence.length) {
|
|
716
|
+
lines.push(' 报告置信度不达标:');
|
|
717
|
+
lowConfidence.forEach(c => lines.push(` - ${c}`));
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
const needsRegenerate = [...new Set([
|
|
721
|
+
...missing, ...parseError, ...invalid.map(i => i.cn),
|
|
722
|
+
...(checkConfidence ? lowConfidence : []),
|
|
723
|
+
...stale,
|
|
724
|
+
])];
|
|
725
|
+
|
|
726
|
+
if (needsRegenerate.length > 0) {
|
|
727
|
+
lines.push('');
|
|
728
|
+
lines.push(warnOnly
|
|
729
|
+
? '建议执行 opsx-consistency-check skill 更新报告:'
|
|
730
|
+
: `请执行 opsx-consistency-check skill 更新报告后再${actionNoun}:`);
|
|
731
|
+
lines.push('');
|
|
732
|
+
|
|
733
|
+
if (needsRegenerate.length === 1) {
|
|
734
|
+
lines.push(` skill: opsx-consistency-check ${needsRegenerate[0]}`);
|
|
735
|
+
} else {
|
|
736
|
+
lines.push(' skill(逐个执行):');
|
|
737
|
+
needsRegenerate.forEach(c => lines.push(` opsx-consistency-check ${c}`));
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
lines.push(
|
|
741
|
+
'',
|
|
742
|
+
' 💡 建议:新开对话窗口执行校验,以获得独立、客观的审核结果',
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
lines.push(
|
|
747
|
+
'',
|
|
748
|
+
' 报告路径: openspec/changes/<change-name>/consistency-report.md',
|
|
749
|
+
' JSON 路径: openspec/changes/<change-name>/consistency-report-result.json',
|
|
750
|
+
);
|
|
751
|
+
|
|
752
|
+
lines.push('==========================================', '');
|
|
753
|
+
|
|
754
|
+
return lines.join('\n');
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
// --- 主流程 ---
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* 一致性校验主流程(由入口脚本调用)。
|
|
761
|
+
* @param {object} opts 入口差异配置
|
|
762
|
+
*/
|
|
763
|
+
function runConsistencyCheck(opts) {
|
|
764
|
+
const {
|
|
765
|
+
label, actionNoun, diffFn,
|
|
766
|
+
checkConfidence = false, includeUntracked = true,
|
|
767
|
+
staleFromHead = false,
|
|
768
|
+
} = opts;
|
|
769
|
+
const projectRoot = path.resolve(readArg('project') || process.env.SDD_PROJECT || process.cwd());
|
|
770
|
+
|
|
771
|
+
// 模式解析
|
|
772
|
+
const envSkip = process.env.SDD_SKIP_CONSISTENCY_CHECK;
|
|
773
|
+
const envStrict = process.env.SDD_STRICT_CONSISTENCY;
|
|
774
|
+
const envWarnOnly = process.env.SDD_WARN_ONLY;
|
|
775
|
+
const skip = envSkip !== undefined ? envSkip === '1' : CONSISTENCY_MODE.skip;
|
|
776
|
+
const strict = envStrict !== undefined ? envStrict === '1' : CONSISTENCY_MODE.strict;
|
|
777
|
+
const warnOnly = envWarnOnly !== undefined ? envWarnOnly === '1' : CONSISTENCY_MODE.warnOnly;
|
|
778
|
+
|
|
779
|
+
// 互斥检查
|
|
780
|
+
const activeModes = [skip, strict, warnOnly].filter(Boolean).length;
|
|
781
|
+
if (activeModes > 1) {
|
|
782
|
+
const names = [
|
|
783
|
+
...(skip ? ['SDD_SKIP_CONSISTENCY_CHECK' + (envSkip === undefined ? '(hooks.config)' : '')] : []),
|
|
784
|
+
...(strict ? ['SDD_STRICT_CONSISTENCY' + (envStrict === undefined ? '(hooks.config)' : '')] : []),
|
|
785
|
+
...(warnOnly ? ['SDD_WARN_ONLY' + (envWarnOnly === undefined ? '(hooks.config)' : '')] : []),
|
|
786
|
+
];
|
|
787
|
+
console.error(`${label} 错误:${names.join('、')} 互斥,只能启用一个`);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (skip) {
|
|
792
|
+
console.log(`${label} 跳过校验`);
|
|
793
|
+
process.exit(0);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (warnOnly) console.log(`${label} 仅告警模式`);
|
|
797
|
+
|
|
798
|
+
// 配置缺失/空清单显式告警
|
|
799
|
+
if (BUSINESS_CODE_EXTS_INFO.source !== 'config') {
|
|
800
|
+
const reason = BUSINESS_CODE_EXTS_INFO.source === 'missing'
|
|
801
|
+
? '未配置 business_code_exts(hooks.config 缺失或无 [consistency] 段)'
|
|
802
|
+
: 'business_code_exts 为空清单';
|
|
803
|
+
console.warn(`${label} ⚠️ ${reason},一致性校验不拦截任何业务代码文件(仅保留报告存在性/过期检查)`);
|
|
804
|
+
console.warn(`${label} ℹ️ 请在 ${BUSINESS_CODE_EXTS_INFO.configPath || path.join(__dirname, 'hooks.config')} 的 [consistency] 段配置 business_code_exts`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const specRoot = resolveSpecRoot();
|
|
808
|
+
|
|
809
|
+
// 1. 采集 diff 文件
|
|
810
|
+
const { files: modifiedFiles, deleted: deletedFiles, failed: diffFailed } =
|
|
811
|
+
getModifiedFiles(projectRoot, diffFn, { includeUntracked });
|
|
812
|
+
|
|
813
|
+
if (diffFailed) {
|
|
814
|
+
console.log(`${label} ❌ 无法确定本次${actionNoun}的变更范围(git diff 采集失败),拒绝${actionNoun}`);
|
|
815
|
+
if (warnOnly) {
|
|
816
|
+
console.log(`${label} 仅告警模式,放行${actionNoun}`);
|
|
817
|
+
process.exit(0);
|
|
818
|
+
}
|
|
819
|
+
process.exit(1);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
// 2. 筛选业务代码文件
|
|
823
|
+
const diffBusinessFiles = modifiedFiles.filter(isBusinessCode);
|
|
824
|
+
const deletedBusinessFiles = deletedFiles.filter(isBusinessCode);
|
|
825
|
+
|
|
826
|
+
// 3. 无业务代码文件 → 放行
|
|
827
|
+
if (diffBusinessFiles.length === 0 && deletedBusinessFiles.length === 0) {
|
|
828
|
+
console.log(`${label} 本次${actionNoun}无业务代码文件,跳过一致性校验`);
|
|
829
|
+
process.exit(0);
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// 4. 活跃 change 发现
|
|
833
|
+
const changes = discoverActiveChanges(specRoot);
|
|
834
|
+
if (!changes.length) {
|
|
835
|
+
console.log([
|
|
836
|
+
'', '==========================================',
|
|
837
|
+
`${label} 存在业务代码变更但无活跃 change 可归属,拒绝${actionNoun}:`,
|
|
838
|
+
'',
|
|
839
|
+
' 请先执行 opsx-propose 创建变更(登记到 sdd.config.yaml 的 active_changes),',
|
|
840
|
+
' 再执行 opsx-consistency-check 生成一致性报告。',
|
|
841
|
+
'==========================================', '',
|
|
842
|
+
].join('\n'));
|
|
843
|
+
if (warnOnly) {
|
|
844
|
+
console.log(`${label} 仅告警模式,放行${actionNoun}`);
|
|
845
|
+
process.exit(0);
|
|
846
|
+
}
|
|
847
|
+
process.exit(1);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// 5. 逐 change 解析报告
|
|
851
|
+
const parseError = [];
|
|
852
|
+
const invalid = [];
|
|
853
|
+
const lowConfidence = [];
|
|
854
|
+
const stale = [];
|
|
855
|
+
const missing = [];
|
|
856
|
+
const parsedReports = new Map();
|
|
857
|
+
|
|
858
|
+
// 当前代码仓目录名(多仓布局下用于报告归属过滤 + normalizeCoveredFiles 兜底前缀剥离)
|
|
859
|
+
const currentRepoName = path.basename(projectRoot).toLowerCase();
|
|
860
|
+
|
|
861
|
+
for (const cn of changes) {
|
|
862
|
+
const rp = findReportJson(specRoot, cn);
|
|
863
|
+
if (!rp) {
|
|
864
|
+
missing.push(cn);
|
|
865
|
+
continue;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const report = parseReport(rp);
|
|
869
|
+
if (report.parseError) {
|
|
870
|
+
console.warn(`${label} ⚠️ ${cn} 报告解析失败`);
|
|
871
|
+
parseError.push(cn);
|
|
872
|
+
continue;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
// 旧格式报告(无 coveredFiles)
|
|
876
|
+
if (!report.coveredFiles) {
|
|
877
|
+
console.log(`${label} ⚠️ ${cn}: 报告为旧格式(缺少 coveredFiles),建议重新生成`);
|
|
878
|
+
missing.push(cn);
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
// 字段合法性校验(仅 confidence + generatedAt)
|
|
883
|
+
const fieldErrors = validateReportFields(report);
|
|
884
|
+
if (fieldErrors.length) {
|
|
885
|
+
console.warn(`${label} ⚠️ ${cn} 报告字段不合法:${fieldErrors.join(';')}`);
|
|
886
|
+
invalid.push({ cn, errors: fieldErrors });
|
|
887
|
+
continue;
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
const covered = normalizeCoveredFiles(report, currentRepoName);
|
|
891
|
+
parsedReports.set(cn, { report, covered });
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// 6. 对 diff(含删除)与 coveredFiles 有交集的 change 检查报告
|
|
895
|
+
const checkFiles = [...new Set([...diffBusinessFiles, ...deletedBusinessFiles])];
|
|
896
|
+
const checkFilesLower = checkFiles.map(f => f.toLowerCase());
|
|
897
|
+
|
|
898
|
+
let headContentCache = null;
|
|
899
|
+
const readContent = staleFromHead
|
|
900
|
+
? (fp, relFile) => {
|
|
901
|
+
if (headContentCache === null) {
|
|
902
|
+
headContentCache = gitShowHeadContentBatch(projectRoot, checkFiles);
|
|
903
|
+
}
|
|
904
|
+
const fromHead = headContentCache.get(relFile);
|
|
905
|
+
return fromHead !== undefined && fromHead !== null ? fromHead : readWorkingFile(fp);
|
|
906
|
+
}
|
|
907
|
+
: readWorkingFile;
|
|
908
|
+
|
|
909
|
+
for (const [cn, { report, covered }] of parsedReports) {
|
|
910
|
+
// 多仓归属过滤:报告显式声明了覆盖仓库且不含当前仓 → 与本仓无关,静默跳过
|
|
911
|
+
// (repositories 为空视为未声明,保持原交集判断行为,兼容单仓与旧报告)
|
|
912
|
+
const declaredRepos = (report.repositories || [])
|
|
913
|
+
.map(r => String(r && r.path || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase())
|
|
914
|
+
.filter(Boolean);
|
|
915
|
+
if (declaredRepos.length && !declaredRepos.includes(currentRepoName)) {
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
const hasIntersection = checkFilesLower.some(f => covered.has(f));
|
|
920
|
+
if (!hasIntersection) {
|
|
921
|
+
console.log(`${label} ⏭️ ${cn}: 与本次${actionNoun}无交集,跳过`);
|
|
922
|
+
continue;
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (isReportStale(report, covered, checkFiles, projectRoot, readContent)) {
|
|
926
|
+
console.log(`${label} ⚠️ ${cn}: 报告已过期`);
|
|
927
|
+
stale.push(cn);
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if (checkConfidence) {
|
|
932
|
+
const c = report.confidence;
|
|
933
|
+
if (c === 'low' || (c === 'medium' && strict)) {
|
|
934
|
+
console.log(`${label} ❌ ${cn}: 置信度 ${c}`);
|
|
935
|
+
lowConfidence.push(cn);
|
|
936
|
+
continue;
|
|
937
|
+
}
|
|
938
|
+
console.log(`${label} ✅ ${cn}: 置信度 ${c}`);
|
|
939
|
+
} else {
|
|
940
|
+
console.log(`${label} ✅ ${cn}: 报告有效`);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
const result = {
|
|
945
|
+
missing, parseError, invalid, lowConfidence, stale,
|
|
946
|
+
};
|
|
947
|
+
|
|
948
|
+
const hasBlocking = missing.length || parseError.length || invalid.length || lowConfidence.length
|
|
949
|
+
|| stale.length;
|
|
950
|
+
|
|
951
|
+
if (hasBlocking) {
|
|
952
|
+
console.log(generateInstructions(opts, result, warnOnly));
|
|
953
|
+
|
|
954
|
+
if (warnOnly) {
|
|
955
|
+
console.log(`${label} 仅告警模式,放行${actionNoun}`);
|
|
956
|
+
process.exit(0);
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
process.exit(1);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
console.log(`\n${label} ✅ 本次${actionNoun}涉及的业务代码均已通过一致性校验,放行${actionNoun}`);
|
|
963
|
+
process.exit(0);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// --- CLI 筛选模式(skill 调用,机械筛选与 hook 单一实现) ---
|
|
967
|
+
|
|
968
|
+
function runFilterMode() {
|
|
969
|
+
const filesArg = readArg('files');
|
|
970
|
+
|
|
971
|
+
let input = [];
|
|
972
|
+
if (filesArg) {
|
|
973
|
+
if (filesArg.startsWith('@')) {
|
|
974
|
+
input = fs.readFileSync(filesArg.slice(1), 'utf8').split(/\r?\n/);
|
|
975
|
+
} else {
|
|
976
|
+
input = filesArg.split(',');
|
|
977
|
+
}
|
|
978
|
+
} else {
|
|
979
|
+
input = fs.readFileSync(0, 'utf8').split(/\r?\n/);
|
|
980
|
+
}
|
|
981
|
+
|
|
982
|
+
const normalized = [...new Set(input.map(f => f.trim().replace(/\\/g, '/')).filter(Boolean))];
|
|
983
|
+
const businessFiles = normalized.filter(isBusinessCode);
|
|
984
|
+
const excluded = normalized.filter(f => !isBusinessCode(f));
|
|
985
|
+
|
|
986
|
+
process.stdout.write(JSON.stringify({ businessFiles, excluded }, null, 2));
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
if (process.argv.includes('--filter')) {
|
|
990
|
+
runFilterMode();
|
|
991
|
+
process.exit(0);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// --- CLI 采集模式(skill 调用:diff 采集 + 多仓合并 + 机械筛选一体化) ---
|
|
995
|
+
// 可选 flag:
|
|
996
|
+
// - --business-only:精简输出,仅 businessFiles + deletedFiles(excluded 长清单会撑爆工具输出导致截断重试)
|
|
997
|
+
// - --hash:businessFiles 就地计算 size/sha256(LF 归一化,与 hook 过期判断同规则),
|
|
998
|
+
// 采集+筛选+哈希一次调用完成,skill 无需再单独跑 batch-hash
|
|
999
|
+
|
|
1000
|
+
function runCollectMode() {
|
|
1001
|
+
const projectRoot = path.resolve(readArg('project') || process.cwd());
|
|
1002
|
+
const includeUntracked = readArg('include-untracked') !== '0';
|
|
1003
|
+
const businessOnly = process.argv.includes('--business-only');
|
|
1004
|
+
const withHash = process.argv.includes('--hash');
|
|
1005
|
+
|
|
1006
|
+
const diffFn = (repoRoot) => {
|
|
1007
|
+
const bases = [['diff', 'HEAD'], ['diff', '--cached']];
|
|
1008
|
+
for (const base of bases) {
|
|
1009
|
+
const r = gitDiffNameStatus(repoRoot, base);
|
|
1010
|
+
if (r.ok) return r;
|
|
1011
|
+
}
|
|
1012
|
+
return { files: [], deleted: [], ok: false };
|
|
1013
|
+
};
|
|
1014
|
+
|
|
1015
|
+
const { files, deleted, failed } = getModifiedFiles(projectRoot, diffFn, { includeUntracked });
|
|
1016
|
+
if (failed) {
|
|
1017
|
+
process.stderr.write('collect failed: unable to determine changed files (git diff failed)\n');
|
|
1018
|
+
process.exit(1);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
const businessFiles = files.filter(isBusinessCode);
|
|
1022
|
+
const excluded = files.filter(f => !isBusinessCode(f));
|
|
1023
|
+
|
|
1024
|
+
let hashedBusiness = null;
|
|
1025
|
+
let unreadableFiles = null;
|
|
1026
|
+
if (withHash) {
|
|
1027
|
+
hashedBusiness = [];
|
|
1028
|
+
unreadableFiles = [];
|
|
1029
|
+
for (const rel of businessFiles) {
|
|
1030
|
+
const content = readWorkingFile(path.join(projectRoot, rel));
|
|
1031
|
+
if (content === null) {
|
|
1032
|
+
unreadableFiles.push(rel);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
const buf = normalizeEolLf(content);
|
|
1036
|
+
hashedBusiness.push({
|
|
1037
|
+
path: rel,
|
|
1038
|
+
size: buf.length,
|
|
1039
|
+
sha256: crypto.createHash('sha256').update(buf).digest('hex'),
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
const output = businessOnly
|
|
1045
|
+
? { businessFiles: withHash ? hashedBusiness : businessFiles, deletedFiles: deleted }
|
|
1046
|
+
: {
|
|
1047
|
+
allChangedFiles: files,
|
|
1048
|
+
deletedFiles: deleted,
|
|
1049
|
+
businessFiles: withHash ? hashedBusiness : businessFiles,
|
|
1050
|
+
excluded,
|
|
1051
|
+
};
|
|
1052
|
+
if (withHash && unreadableFiles.length) output.unreadableFiles = unreadableFiles;
|
|
1053
|
+
|
|
1054
|
+
process.stdout.write(JSON.stringify(output, null, 2));
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
if (process.argv.includes('--collect')) {
|
|
1058
|
+
runCollectMode();
|
|
1059
|
+
process.exit(0);
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
module.exports = {
|
|
1063
|
+
resolveSpecRoot,
|
|
1064
|
+
discoverActiveChanges,
|
|
1065
|
+
findReportJson,
|
|
1066
|
+
parseReport,
|
|
1067
|
+
validateReportFields,
|
|
1068
|
+
parseCodeRepos,
|
|
1069
|
+
gitDiffNameStatus,
|
|
1070
|
+
getModifiedFiles,
|
|
1071
|
+
isBusinessCode,
|
|
1072
|
+
normalizeCoveredFiles,
|
|
1073
|
+
getReportTime,
|
|
1074
|
+
readWorkingFile,
|
|
1075
|
+
gitShowHeadContentBatch,
|
|
1076
|
+
isFileChangedOnDisk,
|
|
1077
|
+
isReportStale,
|
|
1078
|
+
generateInstructions,
|
|
1079
|
+
loadBusinessCodeExts,
|
|
1080
|
+
loadConsistencyMode,
|
|
1081
|
+
runConsistencyCheck,
|
|
1082
|
+
runFilterMode,
|
|
1083
|
+
runCollectMode,
|
|
1084
|
+
isTrivialDiffLine,
|
|
1085
|
+
parseDiffForTrivialFiles,
|
|
1086
|
+
getTrivialDiffFiles,
|
|
1087
|
+
};
|