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.
- package/README.md +26 -0
- package/USABILITY.md +72 -0
- package/bin/kld-sdd-init.js +718 -34
- package/lib/command-bridge.js +26 -17
- package/lib/deploy-codebuddy-hooks.js +19 -26
- package/lib/deploy-strategy.js +293 -0
- package/lib/hook-gate-core.js +7 -8
- package/lib/hook-install.js +75 -0
- package/lib/init.js +507 -726
- package/lib/maintenance.js +221 -0
- package/lib/managed-hook-hashes.json +24 -0
- package/lib/settings-merge.js +102 -25
- package/lib/skills-bundle.js +5 -5
- package/lib/spec-choice.js +85 -0
- package/lib/tool-profiles.js +2 -2
- package/lib/uninstall.js +453 -0
- package/package.json +7 -4
- package/skywalk-sdd/context-client.cjs +38 -7
- package/skywalk-sdd/index.cjs +153 -27
- package/skywalk-sdd/kb-upload.cjs +224 -82
- package/skywalk-sdd/lib/check-review.cjs +41 -0
- package/skywalk-sdd/lib/shared.cjs +62 -41
- package/skywalk-sdd/lib/test-execution.cjs +130 -0
- package/skywalk-sdd/lib/usage-reporter.cjs +17 -8
- package/skywalk-sdd/ontology/archive-package.cjs +141 -0
- package/skywalk-sdd/ontology/artifact-parser.cjs +24 -3
- package/skywalk-sdd/ontology/cli.cjs +53 -0
- package/skywalk-sdd/ontology/id.cjs +1 -1
- package/skywalk-sdd/ontology/identity-index.cjs +5 -1
- package/skywalk-sdd/ontology/resolve-spec-root.cjs +49 -0
- package/skywalk-sdd/ontology/schema.cjs +6 -0
- package/skywalk-sdd/ontology/sdd-config.cjs +33 -5
- package/skywalk-sdd/ontology/spec-detect.cjs +421 -0
- package/skywalk-sdd/ontology/spec-ontology.cjs +507 -0
- package/skywalk-sdd/ontology/workspace-layout.cjs +65 -17
- package/templates/git-hooks/commit-msg +17 -85
- package/templates/git-hooks/commit-msg-runner.cjs +44 -0
- package/templates/git-hooks/commit-msg-sdd-trailer.cjs +60 -69
- package/templates/git-hooks/consistency-check-core.cjs +92 -261
- package/templates/git-hooks/delivery-check.cjs +231 -0
- package/templates/git-hooks/delivery-snapshot.cjs +181 -0
- package/templates/git-hooks/hooks.config +8 -6
- package/templates/git-hooks/pre-commit +19 -5
- package/templates/git-hooks/pre-commit-consistency-check.cjs +13 -31
- package/templates/git-hooks/pre-push +19 -5
- package/templates/git-hooks/pre-push-consistency-check.cjs +36 -83
- package/templates/hooks/claude/hooks/sdd-post-tool.cjs +2 -2
- package/templates/hooks/codebuddy/hooks/hook-gate-core.cjs +7 -8
- package/templates/hooks/codebuddy/hooks/sdd-post-tool.cjs +2 -2
- package/templates/openspec/proposal.md +1 -1
- package/templates/openspec/spec.md +8 -0
- package/templates/skills/kld-sdd/opsx-apply/SKILL.md +28 -24
- package/templates/skills/kld-sdd/opsx-apply/checklist.md +12 -3
- package/templates/skills/kld-sdd/opsx-apply/reference.md +21 -1
- package/templates/skills/kld-sdd/opsx-archive/SKILL.md +20 -18
- package/templates/skills/kld-sdd/opsx-check/SKILL.md +18 -13
- package/templates/skills/kld-sdd/opsx-check/reference.md +3 -1
- package/templates/skills/kld-sdd/opsx-check/review-template.json +22 -0
- package/templates/skills/kld-sdd/opsx-check/reviewer.md +27 -6
- package/templates/skills/kld-sdd/opsx-consistency-check/SKILL.md +34 -112
- package/templates/skills/kld-sdd/opsx-consistency-check/scripts/scripts.cjs +15 -14
- package/templates/skills/kld-sdd/opsx-kb-config/SKILL.md +82 -61
- package/templates/skills/kld-sdd/opsx-kb-config/reference.md +8 -11
- package/templates/skills/kld-sdd/opsx-kb-ingest/SKILL.md +3 -2
- package/templates/skills/kld-sdd/opsx-kb-ingest/reference.md +2 -2
- package/templates/skills/kld-sdd/opsx-ontology-ingest/SKILL.md +77 -0
- package/templates/skills/kld-sdd/opsx-ontology-query/SKILL.md +5 -4
- package/templates/skills/kld-sdd/opsx-ontology-query/reference.md +14 -1
- package/templates/skills/kld-sdd/opsx-propose/SKILL.md +1 -1
- package/templates/skills/kld-sdd/opsx-propose/interaction-policy.md +9 -0
- package/templates/skills/kld-sdd/opsx-spec/SKILL.md +5 -3
- package/templates/skills/kld-sdd/opsx-task/SKILL.md +2 -2
- package/index.js +0 -13
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Deterministic evidence checking. This command never performs or invents an AI review.
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const snap = require('./delivery-snapshot.cjs');
|
|
8
|
+
const DIMENSIONS = ['interfaceContract', 'businessRules', 'validationRules', 'errorCodes'];
|
|
9
|
+
|
|
10
|
+
function readJson(file) { return JSON.parse(fs.readFileSync(file, 'utf8')); }
|
|
11
|
+
function optionalJson(file) { try { return readJson(file); } catch { return null; } }
|
|
12
|
+
|
|
13
|
+
function context(args) {
|
|
14
|
+
const project = fs.realpathSync(path.resolve(args.project || process.cwd()));
|
|
15
|
+
const specRoot = fs.realpathSync(path.resolve(args['spec-root'] || path.join(__dirname, '..', '..')));
|
|
16
|
+
const projectTop = fs.realpathSync(snap.git(project, ['rev-parse', '--show-toplevel']).trim());
|
|
17
|
+
if (projectTop !== project) throw new Error('PROJECT_ROOT_REQUIRED: --project 请指定代码仓 Git 根');
|
|
18
|
+
const change = args.change;
|
|
19
|
+
if (!change || !/^[a-z0-9][a-z0-9-]+$/.test(change)) throw new Error('CHANGE_REQUIRED: --change 必须明确指定');
|
|
20
|
+
const scope = args.scope || 'worktree';
|
|
21
|
+
const ref = args.ref || 'HEAD';
|
|
22
|
+
const identity = snap.repoIdentity(project);
|
|
23
|
+
const key = snap.hashJson({ identity, change });
|
|
24
|
+
const dir = path.join(specRoot, 'skywalk-sdd/state/delivery', key);
|
|
25
|
+
const prepared = optionalJson(path.join(dir, 'prepared.json'));
|
|
26
|
+
let base = args.base || prepared?.base;
|
|
27
|
+
if (scope === 'commit' && !args.base) throw new Error('BASE_REQUIRED: --scope=commit 必须明确提供 --base');
|
|
28
|
+
if (!base) {
|
|
29
|
+
try { base = snap.git(project, ['rev-parse', '--verify', 'HEAD']).trim(); }
|
|
30
|
+
catch { base = snap.git(project, ['mktree'], { input: '' }).trim(); }
|
|
31
|
+
}
|
|
32
|
+
base = snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${base}^{object}`]).trim();
|
|
33
|
+
const baseType = snap.git(project, ['cat-file', '-t', base]).trim();
|
|
34
|
+
if (!['tree', 'commit'].includes(baseType)) throw new Error('BASE_INVALID');
|
|
35
|
+
// Freeze named refs before reading any files, including a CI target that is not HEAD.
|
|
36
|
+
const resolvedRef = scope === 'commit' ? snap.git(project, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim() : ref;
|
|
37
|
+
const relative = path.relative(project, specRoot);
|
|
38
|
+
let specRepository = `local:${specRoot}`, specTop = '';
|
|
39
|
+
try { specTop = fs.realpathSync(snap.git(specRoot, ['rev-parse', '--show-toplevel']).trim()); specRepository = snap.repoIdentity(specTop); } catch { /* uncommitted standalone Spec folder */ }
|
|
40
|
+
const mono = specTop === project;
|
|
41
|
+
if (!mono && fs.existsSync(path.join(project, '.sdd.yaml'))) {
|
|
42
|
+
const configModule = [path.join(__dirname, '../ontology/sdd-config.cjs'), path.join(__dirname, '../../skywalk-sdd/ontology/sdd-config.cjs')].find(fs.existsSync);
|
|
43
|
+
if (!configModule) throw new Error('SDD_CONFIG_RUNTIME_MISSING');
|
|
44
|
+
const sdd = require(configModule);
|
|
45
|
+
const declared = sdd.loadSddYaml(project);
|
|
46
|
+
if (!declared.ok) throw new Error('SDD_CONFIG_INVALID');
|
|
47
|
+
const actual = snap.git(specRoot, ['remote', 'get-url', 'origin']).trim();
|
|
48
|
+
if (!sdd.remotesMatch(declared.spec_repository, actual)) throw new Error('SPEC_REMOTE_MISMATCH');
|
|
49
|
+
}
|
|
50
|
+
let specOptions = {};
|
|
51
|
+
if (scope !== 'worktree') {
|
|
52
|
+
if (mono) specOptions = { root: project, scope, ref: resolvedRef };
|
|
53
|
+
else {
|
|
54
|
+
if (scope === 'commit' && !args['spec-ref']) throw new Error('SPEC_REF_REQUIRED: 多仓提交校验必须提供 --spec-ref 的完整提交 SHA');
|
|
55
|
+
const specRef = args['spec-ref'] || 'HEAD';
|
|
56
|
+
if (scope === 'commit' && !/^[a-f0-9]{40}$|^[a-f0-9]{64}$/.test(specRef)) throw new Error('SPEC_REF_INVALID: 需要完整 SHA');
|
|
57
|
+
specOptions = { root: specRoot, scope: 'commit', ref: specRef };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const code = snap.codeSnapshot(project, specRoot, scope, resolvedRef);
|
|
61
|
+
const spec = snap.specSnapshot(specRoot, change, specOptions);
|
|
62
|
+
const core = require('./consistency-check-core.cjs');
|
|
63
|
+
const configFile = path.join(specRoot, 'skywalk-sdd/git-hooks/hooks.config');
|
|
64
|
+
const config = core.loadBusinessCodeExts(configFile);
|
|
65
|
+
if (config.source !== 'config') throw new Error('BUSINESS_SCOPE_UNCONFIGURED: hooks.config 缺少业务文件扩展名');
|
|
66
|
+
const inputs = { ruleVersion: snap.RULE_VERSION, ruleConfig: snap.fileMeta('hooks.config', fs.readFileSync(configFile)).sha256, repository: identity, specRepository, change, code, spec };
|
|
67
|
+
const fingerprint = snap.hashJson(inputs);
|
|
68
|
+
const required = snap.changedFiles(project, scope, base, resolvedRef).filter(file => core.isBusinessCode(file, config.exts) && !snap.excluded(file, relative.startsWith('..') ? '' : relative.replace(/\\/g, '/')));
|
|
69
|
+
return { project, specRoot, change, scope, ref: resolvedRef, mono, identity, dir, base, inputs, fingerprint, required };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reviewPath(ctx) {
|
|
73
|
+
return path.join(ctx.specRoot, 'openspec/changes', ctx.change, 'consistency-reviews', `${snap.digest(ctx.identity)}.json`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function reviewErrors(ctx, review) {
|
|
77
|
+
if (!review) return ['REVIEW_MISSING: 缺少代码与 Spec 一致性检查回执'];
|
|
78
|
+
const errors = [];
|
|
79
|
+
if (review.inputFingerprint !== ctx.fingerprint || !review.inputs || snap.hashJson(review.inputs) !== ctx.fingerprint) errors.push('REVIEW_STALE: 代码、Spec、依赖或规则版本已变化');
|
|
80
|
+
const covered = new Set(Array.isArray(review.reviewedFiles) ? review.reviewedFiles : []);
|
|
81
|
+
const uncovered = ctx.required.filter(file => !covered.has(file));
|
|
82
|
+
if (uncovered.length) errors.push(`UNCOVERED_FILES: ${uncovered.join(', ')}`);
|
|
83
|
+
for (const name of DIMENSIONS) {
|
|
84
|
+
const item = review.dimensions?.[name];
|
|
85
|
+
if (!item || !['pass', 'na'].includes(item.status)) errors.push(`REVIEW_INCOMPLETE: ${name}=${item?.status || 'not_checked'}`);
|
|
86
|
+
else if (item.status === 'na' && !String(item.reason || '').trim()) errors.push(`NA_REASON_REQUIRED: ${name}`);
|
|
87
|
+
else if (item.status === 'pass' && (!Array.isArray(item.evidence) || !item.evidence.length || item.evidence.some(e => typeof e !== 'string' || !e.trim()))) errors.push(`EVIDENCE_REQUIRED: ${name}`);
|
|
88
|
+
}
|
|
89
|
+
if (review.issues != null && !Array.isArray(review.issues)) errors.push('REVIEW_ISSUES_INVALID');
|
|
90
|
+
if (Array.isArray(review.issues) && review.issues.length) errors.push('REVIEW_ISSUES: 存在未解决问题');
|
|
91
|
+
return errors;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function saveAssociation(ctx, review) {
|
|
95
|
+
const file = path.join(ctx.specRoot, 'skywalk-sdd/state/delivery-associations', `${snap.digest(ctx.identity)}.json`);
|
|
96
|
+
const previous = optionalJson(file) || { repository: ctx.identity, changes: {} };
|
|
97
|
+
const byPath = new Map(ctx.inputs.code.map(f => [f.path, f]));
|
|
98
|
+
previous.changes[ctx.change] = {
|
|
99
|
+
coveredFiles: review.reviewedFiles.map(file => byPath.get(file) || { path: file, deleted: true }),
|
|
100
|
+
};
|
|
101
|
+
snap.atomicJson(file, previous);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function prepare(args) {
|
|
105
|
+
const ctx = context(args);
|
|
106
|
+
const old = optionalJson(reviewPath(ctx));
|
|
107
|
+
const errors = reviewErrors(ctx, old);
|
|
108
|
+
const value = {
|
|
109
|
+
base: ctx.base, inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
|
|
110
|
+
requiredFiles: ctx.required, scope: ctx.scope, ref: ctx.ref,
|
|
111
|
+
};
|
|
112
|
+
const file = path.join(ctx.dir, 'prepared.json');
|
|
113
|
+
snap.atomicJson(file, value);
|
|
114
|
+
return { ...value, preparedFile: file, reviewFile: reviewPath(ctx), reuseReview: errors.length === 0, reviewErrors: errors };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function recordReview(args) {
|
|
118
|
+
const ctx = context(args);
|
|
119
|
+
const prepared = readJson(path.join(ctx.dir, 'prepared.json'));
|
|
120
|
+
const input = readJson(args.input);
|
|
121
|
+
if (prepared.inputFingerprint !== ctx.fingerprint || input.inputFingerprint !== ctx.fingerprint) throw new Error('REVIEW_STALE: 取证后输入已改变;重新 prepare 并复核变化');
|
|
122
|
+
if (!Array.isArray(input.reviewedFiles) || input.reviewedFiles.some(f => typeof f !== 'string')) throw new Error('REVIEW_FILES_REQUIRED');
|
|
123
|
+
const allowed = new Set([...ctx.inputs.code.map(f => f.path), ...ctx.required]);
|
|
124
|
+
if (input.reviewedFiles.some(f => !allowed.has(f))) throw new Error('REVIEW_FILE_OUT_OF_SCOPE');
|
|
125
|
+
const review = {
|
|
126
|
+
changeName: ctx.change, generatedAt: new Date().toISOString(),
|
|
127
|
+
origin: 'agent-review', inputFingerprint: ctx.fingerprint, inputs: ctx.inputs,
|
|
128
|
+
reviewedFiles: [...new Set(input.reviewedFiles)], dimensions: input.dimensions,
|
|
129
|
+
issues: input.issues || [], tests: input.tests || null, base: ctx.base,
|
|
130
|
+
};
|
|
131
|
+
// Save incomplete/failed reviews as such; never relabel them as passed.
|
|
132
|
+
snap.atomicJson(reviewPath(ctx), review);
|
|
133
|
+
const errors = reviewErrors(ctx, review);
|
|
134
|
+
if (!errors.length) saveAssociation(ctx, review);
|
|
135
|
+
return { ok: errors.length === 0, status: errors.length ? 'pending' : 'reviewed', errors, reviewFile: reviewPath(ctx) };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function testRuntime() {
|
|
139
|
+
for (const candidate of [path.join(__dirname, '../lib/test-execution.cjs'), path.join(__dirname, '../../skywalk-sdd/lib/test-execution.cjs')]) {
|
|
140
|
+
if (fs.existsSync(candidate)) return require(candidate);
|
|
141
|
+
}
|
|
142
|
+
throw new Error('TEST_RUNTIME_MISSING');
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function runTests(args) {
|
|
146
|
+
if ((args.scope || 'worktree') !== 'worktree') throw new Error('TEST_WORKTREE_REQUIRED: 请在待测提交的独立 checkout/worktree 执行测试');
|
|
147
|
+
const ctx = context(args);
|
|
148
|
+
const command = args['command-file'] ? JSON.stringify(readJson(args['command-file'])) : args['command-json'];
|
|
149
|
+
if (!args['session-id']) throw new Error('TEST_SESSION_REQUIRED: --session-id');
|
|
150
|
+
const logger = [path.join(__dirname, '../log.cjs'), path.join(__dirname, '../../skywalk-sdd/index.cjs')].find(fs.existsSync);
|
|
151
|
+
if (!logger) throw new Error('TEST_LOGGER_MISSING');
|
|
152
|
+
const { spawnSync } = require('child_process');
|
|
153
|
+
const child = spawnSync(process.execPath, [logger, 'test-run', `--project=${ctx.specRoot}`,
|
|
154
|
+
`--change=${ctx.change}`, `--session-id=${args['session-id']}`, `--cwd=${ctx.project}`,
|
|
155
|
+
`--task-ids=${args['task-ids'] || ''}`, `--command-json=${command}`,
|
|
156
|
+
`--timeout-ms=${args['timeout-ms'] || 120000}`], {
|
|
157
|
+
cwd: ctx.project, encoding: 'utf8', maxBuffer: 32 * 1024 * 1024,
|
|
158
|
+
timeout: Number(args['timeout-ms'] || 120000) + 30000,
|
|
159
|
+
});
|
|
160
|
+
let output;
|
|
161
|
+
try { output = JSON.parse(child.stdout); }
|
|
162
|
+
catch { throw new Error(`TEST_RECORD_FAILED: ${(child.stderr || child.error?.message || '').slice(-2000)}`); }
|
|
163
|
+
if (!output.test_results || !output.event_id) throw new Error('TEST_RECORD_FAILED: 未保存严格测试事件');
|
|
164
|
+
const result = { testResults: output.test_results, receipt: output.receipt };
|
|
165
|
+
const after = context(args);
|
|
166
|
+
const stable = ctx.fingerprint === after.fingerprint;
|
|
167
|
+
const changedInputs = {};
|
|
168
|
+
for (const kind of ['code', 'spec']) {
|
|
169
|
+
const beforeFiles = kind === 'code' ? ctx.inputs.code : ctx.inputs.spec.files;
|
|
170
|
+
const afterFiles = kind === 'code' ? after.inputs.code : after.inputs.spec.files;
|
|
171
|
+
const before = new Map(beforeFiles.map(f => [f.path, snap.hashJson(f)]));
|
|
172
|
+
const next = new Map(afterFiles.map(f => [f.path, snap.hashJson(f)]));
|
|
173
|
+
changedInputs[kind] = [...new Set([...before.keys(), ...next.keys()])].filter(p => before.get(p) !== next.get(p));
|
|
174
|
+
}
|
|
175
|
+
const value = { inputFingerprint: ctx.fingerprint, stable, changedInputs, testResults: result.testResults, receipt: result.receipt };
|
|
176
|
+
snap.atomicJson(path.join(ctx.dir, 'tests.json'), value);
|
|
177
|
+
const ok = stable && result.testResults.exit_code === 0 && result.testResults.failure_type === 'none'
|
|
178
|
+
&& result.testResults.counts_known && result.testResults.passed > 0;
|
|
179
|
+
return { ok, status: ok ? 'tested' : 'pending', ...value, errors: stable ? [] : ['INPUT_CHANGED_DURING_TEST'] };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function verify(args) {
|
|
183
|
+
const ctx = context(args);
|
|
184
|
+
const review = optionalJson(reviewPath(ctx));
|
|
185
|
+
const errors = reviewErrors(ctx, review);
|
|
186
|
+
const tests = optionalJson(path.join(ctx.dir, 'tests.json'));
|
|
187
|
+
const testsNotApplicable = ctx.required.length === 0 && review?.tests?.status === 'na'
|
|
188
|
+
&& typeof review.tests.reason === 'string' && review.tests.reason.trim().length > 0
|
|
189
|
+
&& DIMENSIONS.every(name => review.dimensions?.[name]?.status === 'na');
|
|
190
|
+
if (testsNotApplicable) { /* explicitly reviewed as no implementation/contract change */ }
|
|
191
|
+
else if (!tests || tests.inputFingerprint !== ctx.fingerprint || !tests.stable) errors.push('TESTS_MISSING_OR_STALE: 需要对当前输入执行测试');
|
|
192
|
+
else {
|
|
193
|
+
try { testRuntime().verifyCapture(tests.testResults, ctx.specRoot); }
|
|
194
|
+
catch (error) { errors.push(error.message); }
|
|
195
|
+
if (tests.testResults.exit_code !== 0 || tests.testResults.failure_type !== 'none') errors.push('TESTS_FAILED');
|
|
196
|
+
if (!tests.testResults.counts_known || tests.testResults.passed < 1) errors.push('NO_PASSING_TESTS');
|
|
197
|
+
}
|
|
198
|
+
const result = {
|
|
199
|
+
ok: !errors.length, status: errors.length ? 'pending' : 'ready', errors,
|
|
200
|
+
change: ctx.change, repository: ctx.identity, scope: ctx.scope,
|
|
201
|
+
codeRevision: ctx.scope === 'commit' ? ctx.ref : null,
|
|
202
|
+
specRevision: ctx.scope === 'commit' ? (ctx.mono ? ctx.ref : args['spec-ref']) : null,
|
|
203
|
+
inputFingerprint: ctx.fingerprint,
|
|
204
|
+
testStatus: testsNotApplicable ? 'not_applicable' : errors.some(e => /TEST|CAPTURE/.test(e)) ? 'pending' : 'passed',
|
|
205
|
+
testNotApplicableReason: testsNotApplicable ? review.tests.reason : null,
|
|
206
|
+
testReceipt: testsNotApplicable ? null : tests?.receipt || null, reviewFile: reviewPath(ctx),
|
|
207
|
+
};
|
|
208
|
+
snap.atomicJson(path.join(ctx.dir, 'verification.json'), result);
|
|
209
|
+
return result;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function main(argv = process.argv.slice(2)) {
|
|
213
|
+
const command = argv.shift();
|
|
214
|
+
const args = {};
|
|
215
|
+
for (const arg of argv) {
|
|
216
|
+
const match = /^--([\w-]+)=(.*)$/s.exec(arg);
|
|
217
|
+
if (!match) throw new Error(`ARG_INVALID: 使用 --参数=值: ${arg}`);
|
|
218
|
+
args[match[1]] = match[2];
|
|
219
|
+
}
|
|
220
|
+
const handlers = { prepare, 'record-review': recordReview, test: runTests, verify };
|
|
221
|
+
if (!handlers[command]) throw new Error('用法: delivery-check.cjs prepare|record-review|test|verify --project=<代码仓> --change=<变更> [--scope=worktree|index|commit]');
|
|
222
|
+
const result = handlers[command](args);
|
|
223
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
224
|
+
if (result.ok === false) process.exitCode = 1;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (require.main === module) {
|
|
228
|
+
try { main(); }
|
|
229
|
+
catch (error) { console.error(`[SDD delivery] ${error.message}`); process.exitCode = 1; }
|
|
230
|
+
}
|
|
231
|
+
module.exports = { context, prepare, recordReview, runTests, verify, reviewPath, reviewErrors, DIMENSIONS };
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { execFileSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
const RULE_VERSION = 'sdd-delivery-v1';
|
|
9
|
+
const digest = value => crypto.createHash('sha256').update(value).digest('hex');
|
|
10
|
+
const hashJson = value => digest(JSON.stringify(value));
|
|
11
|
+
const slash = value => value.replace(/\\/g, '/');
|
|
12
|
+
|
|
13
|
+
// Git exports these during hooks. A different cwd alone does not switch repositories.
|
|
14
|
+
function gitEnv() {
|
|
15
|
+
const env = { ...process.env };
|
|
16
|
+
for (const key of Object.keys(env)) {
|
|
17
|
+
if (/^GIT_(DIR|WORK_TREE|INDEX_FILE|COMMON_DIR|OBJECT_DIRECTORY|ALTERNATE_OBJECT_DIRECTORIES|PREFIX)$/.test(key)) delete env[key];
|
|
18
|
+
}
|
|
19
|
+
return env;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function git(root, args, options = {}) {
|
|
23
|
+
return execFileSync('git', args, {
|
|
24
|
+
cwd: root, env: { ...gitEnv(), ...(fs.realpathSync(root) === fs.realpathSync(process.cwd()) && process.env.GIT_INDEX_FILE ? { GIT_INDEX_FILE: path.resolve(process.env.GIT_INDEX_FILE) } : {}) }, encoding: 'utf8', timeout: 10000,
|
|
25
|
+
maxBuffer: 64 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'], ...options,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function repoIdentity(root) {
|
|
30
|
+
let remote = '';
|
|
31
|
+
try { remote = git(root, ['remote', 'get-url', 'origin']).trim(); } catch { /* local repo */ }
|
|
32
|
+
// Do not persist credentials from a remote URL.
|
|
33
|
+
remote = remote.replace(/(https?:\/\/)[^/@]+@/i, '$1').replace(/\.git$/, '').replace(/\/$/, '');
|
|
34
|
+
remote = remote.replace(/^git@([^:]+):/, '$1/').replace(/^https?:\/\//i, '');
|
|
35
|
+
const common = git(root, ['rev-parse', '--git-common-dir']).trim();
|
|
36
|
+
return remote || `local:${fs.realpathSync(path.resolve(root, common))}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function entriesAt(root, scope = 'worktree', ref = 'HEAD') {
|
|
40
|
+
if (scope === 'commit') {
|
|
41
|
+
const sha = git(root, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
|
|
42
|
+
return git(root, ['ls-tree', '-r', '-z', '--full-tree', sha]).split('\0').filter(Boolean).map(row => {
|
|
43
|
+
const tab = row.indexOf('\t'), [mode, type, oid] = row.slice(0, tab).split(' ');
|
|
44
|
+
return { path: row.slice(tab + 1), mode, oid, type };
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
const entries = git(root, ['ls-files', '--stage', '-z']).split('\0').filter(Boolean).map(row => {
|
|
48
|
+
const tab = row.indexOf('\t'), [mode, oid, stage] = row.slice(0, tab).split(' ');
|
|
49
|
+
if (stage !== '0') throw new Error('UNMERGED_INDEX: 请先解决 Git 冲突');
|
|
50
|
+
return { path: row.slice(tab + 1), mode, oid, type: mode === '160000' ? 'commit' : 'blob' };
|
|
51
|
+
});
|
|
52
|
+
if (scope === 'worktree') {
|
|
53
|
+
for (const file of git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean)) {
|
|
54
|
+
entries.push({ path: file, mode: '100644', type: 'blob' });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return entries;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function readBlobs(root, entries) {
|
|
61
|
+
const blobs = entries.filter(e => e.type === 'blob');
|
|
62
|
+
const result = new Map();
|
|
63
|
+
if (!blobs.length) return result;
|
|
64
|
+
const out = git(root, ['cat-file', '--batch'], { input: blobs.map(e => e.oid).join('\n') + '\n', encoding: null });
|
|
65
|
+
let offset = 0;
|
|
66
|
+
for (const entry of blobs) {
|
|
67
|
+
const end = out.indexOf(10, offset);
|
|
68
|
+
const header = out.subarray(offset, end).toString('ascii').split(' ');
|
|
69
|
+
if (end < 0 || header[1] !== 'blob' || !/^\d+$/.test(header[2])) throw new Error('GIT_BLOB_UNREADABLE');
|
|
70
|
+
const size = Number(header[2]);
|
|
71
|
+
offset = end + 1;
|
|
72
|
+
if (offset + size >= out.length) throw new Error('GIT_BLOB_TRUNCATED');
|
|
73
|
+
result.set(entry.path, out.subarray(offset, offset + size));
|
|
74
|
+
offset += size + 1;
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function fileMeta(file, content, mode = '100644') {
|
|
80
|
+
// Git autocrlf changes text line endings between the worktree and index.
|
|
81
|
+
// Normalize valid UTF-8 text only; binary bytes and symlink targets stay exact.
|
|
82
|
+
const text = content.toString('utf8');
|
|
83
|
+
const isText = mode !== '120000' && !content.includes(0) && Buffer.from(text, 'utf8').equals(content);
|
|
84
|
+
const bytes = isText ? Buffer.from(text.replace(/\r\n/g, '\n'), 'utf8') : content;
|
|
85
|
+
return { path: file, mode, normalization: isText ? 'utf8-lf' : 'bytes', size: bytes.length, sha256: digest(bytes) };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function excluded(file, specRelative) {
|
|
89
|
+
if (specRelative && specRelative !== '.' && (file === specRelative || file.startsWith(specRelative + '/'))) return true;
|
|
90
|
+
return /(^|\/)(\.git|node_modules|target|dist|build|coverage|skywalk-sdd)(\/|$)/.test(file)
|
|
91
|
+
|| /(^|\/)(consistency-report(?:-result)?\.(md|json)|consistency-reviews)(\/|$)/.test(file)
|
|
92
|
+
|| /^(openspec|\.claude|\.cursor|\.codex|\.agents|\.qoder)\//.test(file);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function codeSnapshot(root, specRoot, scope = 'worktree', ref = 'HEAD') {
|
|
96
|
+
if (!['worktree', 'index', 'commit'].includes(scope)) throw new Error('SCOPE_INVALID');
|
|
97
|
+
const relative = slash(path.relative(root, specRoot));
|
|
98
|
+
const entries = entriesAt(root, scope, ref).filter(e => !excluded(e.path, relative.startsWith('..') ? '' : relative));
|
|
99
|
+
const blobs = scope === 'worktree' ? null : readBlobs(root, entries);
|
|
100
|
+
let trustFileMode = true;
|
|
101
|
+
try { trustFileMode = git(root, ['config', '--bool', 'core.filemode']).trim() !== 'false'; } catch { /* Git default */ }
|
|
102
|
+
const files = [];
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (entry.type === 'commit') {
|
|
105
|
+
// Submodules require a separate delivery check in that repository.
|
|
106
|
+
files.push({ path: entry.path, mode: entry.mode, gitlink: entry.oid });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
let content, mode = entry.mode;
|
|
110
|
+
if (scope === 'worktree') {
|
|
111
|
+
const fp = path.join(root, entry.path);
|
|
112
|
+
if (!fs.existsSync(fp) && !fs.lstatSync(fp, { throwIfNoEntry: false })) continue;
|
|
113
|
+
const stat = fs.lstatSync(fp);
|
|
114
|
+
if (stat.isSymbolicLink()) { content = Buffer.from(fs.readlinkSync(fp)); mode = '120000'; }
|
|
115
|
+
else { content = fs.readFileSync(fp); mode = trustFileMode ? (stat.mode & 0o111 ? '100755' : '100644') : entry.mode; }
|
|
116
|
+
} else content = blobs.get(entry.path);
|
|
117
|
+
if (!content) throw new Error(`UNREADABLE: ${entry.path}`);
|
|
118
|
+
files.push(fileMeta(entry.path, content, mode));
|
|
119
|
+
}
|
|
120
|
+
return files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isSpecInput(file, change) {
|
|
124
|
+
if (file.startsWith(`openspec/changes/${change}/logs/`) || file.startsWith(`openspec/changes/${change}/reports/`)) return false;
|
|
125
|
+
if (/(^|\/)(consistency-report(?:-result)?\.(json|md)|consistency-reviews)(\/|$)/.test(file)) return false;
|
|
126
|
+
return file === 'modules.yaml' || file === 'sdd.config.yaml' || file === 'openspec/config.yaml'
|
|
127
|
+
|| file.startsWith('openspec/specs/') || file.startsWith(`openspec/changes/${change}/`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function specSnapshot(specRoot, change, options = {}) {
|
|
131
|
+
if (!/^[a-z0-9][a-z0-9-]+$/.test(change)) throw new Error('CHANGE_REQUIRED: 必须明确指定 change');
|
|
132
|
+
const files = [];
|
|
133
|
+
if (options.scope === 'commit' || options.scope === 'index') {
|
|
134
|
+
const root = options.root || specRoot;
|
|
135
|
+
const prefix = slash(path.relative(root, specRoot));
|
|
136
|
+
const start = prefix ? prefix + '/' : '';
|
|
137
|
+
const entries = entriesAt(root, options.scope, options.ref).filter(e => e.path.startsWith(start) && isSpecInput(e.path.slice(start.length), change));
|
|
138
|
+
const blobs = readBlobs(root, entries);
|
|
139
|
+
for (const entry of entries) {
|
|
140
|
+
if (entry.mode === '120000' || entry.type !== 'blob') throw new Error('SPEC_INPUT_SYMLINK_OR_SUBMODULE');
|
|
141
|
+
files.push(fileMeta(entry.path.slice(start.length), blobs.get(entry.path), entry.mode));
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
const walk = (relative) => {
|
|
145
|
+
const fp = path.join(specRoot, relative);
|
|
146
|
+
if (!fs.existsSync(fp)) return;
|
|
147
|
+
const stat = fs.lstatSync(fp);
|
|
148
|
+
if (stat.isSymbolicLink()) throw new Error(`SPEC_INPUT_SYMLINK: ${relative}`);
|
|
149
|
+
if (stat.isDirectory()) {
|
|
150
|
+
for (const name of fs.readdirSync(fp)) walk(relative ? `${relative}/${name}` : name);
|
|
151
|
+
} else if (isSpecInput(relative, change)) files.push(fileMeta(relative, fs.readFileSync(fp)));
|
|
152
|
+
};
|
|
153
|
+
for (const relative of ['modules.yaml', 'sdd.config.yaml', 'openspec/config.yaml', 'openspec/specs', `openspec/changes/${change}`]) walk(relative);
|
|
154
|
+
}
|
|
155
|
+
if (!files.some(f => f.path === `openspec/changes/${change}/proposal.md`)) throw new Error(`CHANGE_MISSING: ${change}`);
|
|
156
|
+
files.sort((a, b) => a.path.localeCompare(b.path, 'en'));
|
|
157
|
+
return { files, sha256: hashJson(files) };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function changedFiles(root, scope, base, ref = 'HEAD') {
|
|
161
|
+
const args = ['diff', '--name-only', '--no-renames', '-z'];
|
|
162
|
+
if (scope === 'index') args.push('--cached');
|
|
163
|
+
if (base) args.push(base);
|
|
164
|
+
if (scope === 'commit') {
|
|
165
|
+
if (!base) throw new Error('BASE_REQUIRED: 提交校验必须明确提供比较基线');
|
|
166
|
+
args.push(ref);
|
|
167
|
+
}
|
|
168
|
+
args.push('--');
|
|
169
|
+
const result = git(root, args).split('\0').filter(Boolean);
|
|
170
|
+
if (scope === 'worktree') result.push(...git(root, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0').filter(Boolean));
|
|
171
|
+
return [...new Set(result)].sort();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function atomicJson(file, value) {
|
|
175
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
176
|
+
const temp = `${file}.${process.pid}.${crypto.randomBytes(6).toString('hex')}.tmp`;
|
|
177
|
+
fs.writeFileSync(temp, JSON.stringify(value, null, 2) + '\n');
|
|
178
|
+
fs.renameSync(temp, file);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
module.exports = { RULE_VERSION, digest, hashJson, git, gitEnv, repoIdentity, entriesAt, readBlobs, fileMeta, codeSnapshot, specSnapshot, changedFiles, atomicJson, excluded };
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# 默认仅安装 commit-msg;pre-commit/pre-push 段仅供已有显式集成使用。
|
|
2
|
+
# Apply 自动运行 delivery-check;安装不会替团队开启 CI 门禁。
|
|
1
3
|
# KLD SDD Hook 插件配置文件
|
|
2
4
|
# 每行一个脚本名,空行和 # 开头的行会被忽略
|
|
3
5
|
# 注释某行 = 禁用该插件,取消注释 = 启用该插件
|
|
@@ -9,13 +11,13 @@ commit-msg-sdd-trailer.cjs
|
|
|
9
11
|
[pre-commit]
|
|
10
12
|
# 检查 tasks.md 中是否有未完成的归档任务
|
|
11
13
|
pre-commit-sdd-check.cjs
|
|
12
|
-
# 检查 Spec
|
|
14
|
+
# 检查 Spec 一致性校验报告(输入指纹、覆盖与实际检查结果)
|
|
13
15
|
pre-commit-consistency-check.cjs
|
|
14
16
|
|
|
15
17
|
[pre-push]
|
|
16
18
|
# 运行 doctor 完整诊断(配置、Trailer 协议、活动变更等)
|
|
17
19
|
pre-push-sdd-check.cjs
|
|
18
|
-
#
|
|
20
|
+
# 检查待推送范围的一致性报告(实际 push refs;需显式 Change 和真实测试回执)
|
|
19
21
|
pre-push-consistency-check.cjs
|
|
20
22
|
|
|
21
23
|
[consistency]
|
|
@@ -24,15 +26,15 @@ pre-push-consistency-check.cjs
|
|
|
24
26
|
# Go / Kotlin 等项目按需修改:
|
|
25
27
|
# 注意:新增语言的语言特定构建文件(如 Rust 的 build.rs)不在内置排除规则内,需自行确认
|
|
26
28
|
# 本配置同时被 git hook(isBusinessCode)与 opsx-consistency-check skill 读取,单一来源
|
|
27
|
-
#
|
|
29
|
+
# 清空本行时严格交付检查会失败,不能将范围未知视为通过。
|
|
28
30
|
business_code_exts = .java,.ts,.tsx,.js,.jsx,.vue,.py
|
|
29
31
|
|
|
30
32
|
# 一致性校验模式(项目级默认值,环境变量优先覆盖;三个互斥,只能启用一个)
|
|
31
33
|
# 环境变量:SDD_SKIP_CONSISTENCY_CHECK=1 / SDD_STRICT_CONSISTENCY=1 / SDD_WARN_ONLY=1
|
|
32
34
|
# - skip = 1:跳过一致性校验(紧急绕过,如 hotfix 仓不需要 spec 校验)
|
|
33
|
-
# - strict = 1
|
|
35
|
+
# - strict = 1:保留旧项目显式门禁选择;交付检查始终要求完整事实证据
|
|
34
36
|
# - warn_only = 1:仅告警不拦截(CI 调试期,问题全列但不阻断提交/推送)
|
|
35
|
-
# 三项均不设或均设为 0 =
|
|
37
|
+
# 三项均不设或均设为 0 = 检查回执、输入指纹与覆盖;不按置信度放行
|
|
36
38
|
skip = 0
|
|
37
39
|
strict = 0
|
|
38
|
-
warn_only = 0
|
|
40
|
+
warn_only = 0
|
|
@@ -10,13 +10,24 @@ git_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
|
10
10
|
exit 1
|
|
11
11
|
}
|
|
12
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
|
+
}
|
|
13
25
|
spec_path=$(git -C "$git_root" config --local sdd.specPath 2>/dev/null)
|
|
14
26
|
if [ -n "$spec_path" ]; then
|
|
15
|
-
|
|
16
|
-
hooks_dir="$spec_path/skywalk-sdd/git-hooks"
|
|
27
|
+
hooks_dir="$(resolve_spec_dir "$spec_path")/skywalk-sdd/git-hooks"
|
|
17
28
|
elif [ -f "$git_root/.sdd-spec-root" ]; then
|
|
18
29
|
spec_rel=$(head -1 "$git_root/.sdd-spec-root" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
19
|
-
|
|
30
|
+
hooks_dir="$(resolve_spec_dir "$spec_rel")/skywalk-sdd/git-hooks"
|
|
20
31
|
fi
|
|
21
32
|
if [ -n "$hooks_dir" ] && [ ! -d "$hooks_dir" ]; then
|
|
22
33
|
echo "[SDD] 已配置的 Spec 路径失效:$hooks_dir"
|
|
@@ -27,12 +38,15 @@ fi
|
|
|
27
38
|
# 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
|
|
28
39
|
if [ -z "$hooks_dir" ]; then
|
|
29
40
|
search_dir="$git_root"
|
|
30
|
-
while [ "$search_dir"
|
|
41
|
+
while [ -n "$search_dir" ] && [ "$search_dir" != "/" ]; do
|
|
31
42
|
if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
|
|
32
43
|
hooks_dir="$search_dir/skywalk-sdd/git-hooks"
|
|
33
44
|
break
|
|
34
45
|
fi
|
|
35
|
-
|
|
46
|
+
# Windows 盘符根(P:/)dirname 后不变,需显式收敛,避免死循环
|
|
47
|
+
parent_dir="$(dirname "$search_dir")"
|
|
48
|
+
[ "$parent_dir" = "$search_dir" ] && break
|
|
49
|
+
search_dir="$parent_dir"
|
|
36
50
|
done
|
|
37
51
|
fi
|
|
38
52
|
|
|
@@ -1,45 +1,27 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* Pre-commit 门禁:检查本次提交 diff 涉及的 Spec 一致性校验报告。
|
|
6
|
-
* - diff 驱动:仅检查 diff 文件与报告 coveredFiles 有交集的 change,无关 change 跳过
|
|
7
|
-
* - 报告存在性:活跃 change 无报告 → 阻止提交
|
|
8
|
-
* - 报告过期:代码在报告生成后被修改 → 阻止提交
|
|
9
|
-
* - 置信度门禁:confidence=low → 阻止提交;strict 模式下 medium 也阻止
|
|
10
|
-
*
|
|
11
|
-
* 通过环境变量控制:
|
|
12
|
-
* - SDD_SKIP_CONSISTENCY_CHECK=1 → 跳过校验
|
|
13
|
-
* - SDD_STRICT_CONSISTENCY=1 → 严格模式(medium 也阻止)
|
|
14
|
-
* - SDD_WARN_ONLY=1 → 仅告警不拦截
|
|
15
|
-
* - 注意:三个环境变量互斥,只能启用一个
|
|
16
|
-
*
|
|
17
|
-
* 共享逻辑见同目录 consistency-check-core.cjs。
|
|
2
|
+
/** Optional strict index verification. Not installed on daily commits by default.
|
|
3
|
+
* Checks complete coverage, input fingerprints and actual review dimensions.
|
|
4
|
+
* SDD_SKIP_CONSISTENCY_CHECK / SDD_WARN_ONLY remain explicit legacy overrides.
|
|
18
5
|
*/
|
|
19
6
|
'use strict';
|
|
20
7
|
|
|
21
8
|
const core = require('./consistency-check-core.cjs');
|
|
22
|
-
const { gitDiffNameStatus } = core;
|
|
23
9
|
|
|
24
|
-
/**
|
|
25
|
-
* 单仓 diff 采集:工作区相对 HEAD 的变更(含 staged 与未 staged)。
|
|
26
|
-
* files=修改/新增(排除删除);deleted=删除文件。
|
|
27
|
-
* 空仓(无任何 commit)时 HEAD 不存在:降级用 staged 文件列表。
|
|
28
|
-
* @returns {{files: string[], deleted: string[], ok: boolean}} ok=false 表示无法确定变更范围
|
|
29
|
-
*/
|
|
10
|
+
/** Inspect the actual staged content, including an unborn repository and deletions. */
|
|
30
11
|
function gitDiffHead(repoRoot) {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
return { files: [], deleted: [], ok: false };
|
|
12
|
+
try {
|
|
13
|
+
const snap = require('./delivery-snapshot.cjs');
|
|
14
|
+
const changed = snap.changedFiles(repoRoot, 'index');
|
|
15
|
+
const indexed = new Set(snap.entriesAt(repoRoot, 'index').map(e => e.path));
|
|
16
|
+
return { files: changed.filter(f => indexed.has(f)), deleted: changed.filter(f => !indexed.has(f)), ok: true };
|
|
17
|
+
} catch { return { files: [], deleted: [], ok: false }; }
|
|
37
18
|
}
|
|
38
19
|
|
|
39
20
|
core.runConsistencyCheck({
|
|
40
21
|
label: '[SDD commit-consistency-check]',
|
|
41
22
|
actionNoun: '提交',
|
|
42
23
|
diffFn: gitDiffHead,
|
|
43
|
-
checkConfidence: true, //
|
|
44
|
-
includeUntracked:
|
|
24
|
+
checkConfidence: true, // Legacy option; factual review dimensions now decide readiness.
|
|
25
|
+
includeUntracked: false, // Only the index is part of this commit.
|
|
26
|
+
staleFromIndex: true,
|
|
45
27
|
});
|
|
@@ -10,13 +10,24 @@ git_root=$(git rev-parse --show-toplevel 2>/dev/null) || {
|
|
|
10
10
|
exit 1
|
|
11
11
|
}
|
|
12
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
|
+
}
|
|
13
25
|
spec_path=$(git -C "$git_root" config --local sdd.specPath 2>/dev/null)
|
|
14
26
|
if [ -n "$spec_path" ]; then
|
|
15
|
-
|
|
16
|
-
hooks_dir="$spec_path/skywalk-sdd/git-hooks"
|
|
27
|
+
hooks_dir="$(resolve_spec_dir "$spec_path")/skywalk-sdd/git-hooks"
|
|
17
28
|
elif [ -f "$git_root/.sdd-spec-root" ]; then
|
|
18
29
|
spec_rel=$(head -1 "$git_root/.sdd-spec-root" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
|
19
|
-
|
|
30
|
+
hooks_dir="$(resolve_spec_dir "$spec_rel")/skywalk-sdd/git-hooks"
|
|
20
31
|
fi
|
|
21
32
|
if [ -n "$hooks_dir" ] && [ ! -d "$hooks_dir" ]; then
|
|
22
33
|
echo "[SDD] 已配置的 Spec 路径失效:$hooks_dir"
|
|
@@ -27,12 +38,15 @@ fi
|
|
|
27
38
|
# 方法3: 从 git 根向上搜索 skywalk-sdd/git-hooks(spec 仓本身或父级)
|
|
28
39
|
if [ -z "$hooks_dir" ]; then
|
|
29
40
|
search_dir="$git_root"
|
|
30
|
-
while [ "$search_dir"
|
|
41
|
+
while [ -n "$search_dir" ] && [ "$search_dir" != "/" ]; do
|
|
31
42
|
if [ -d "$search_dir/skywalk-sdd/git-hooks" ]; then
|
|
32
43
|
hooks_dir="$search_dir/skywalk-sdd/git-hooks"
|
|
33
44
|
break
|
|
34
45
|
fi
|
|
35
|
-
|
|
46
|
+
# Windows 盘符根(P:/)dirname 后不变,需显式收敛,避免死循环
|
|
47
|
+
parent_dir="$(dirname "$search_dir")"
|
|
48
|
+
[ "$parent_dir" = "$search_dir" ] && break
|
|
49
|
+
search_dir="$parent_dir"
|
|
36
50
|
done
|
|
37
51
|
fi
|
|
38
52
|
|