wendkeep 0.86.0 → 0.87.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/.githooks/commit-msg +16 -0
- package/.githooks/prepare-commit-msg +16 -0
- package/CHANGELOG.md +14 -0
- package/README.en.md +2 -0
- package/README.md +2 -0
- package/docs/en/commands/commit.md +159 -0
- package/docs/pt-BR/commands/commit.md +159 -0
- package/package.json +5 -2
- package/packages/cli/src/index.mjs +11 -1
- package/packages/commit/package.json +6 -0
- package/packages/commit/src/cli.mjs +89 -0
- package/packages/commit/src/commit-input.mjs +181 -0
- package/packages/commit/src/commit-message.mjs +51 -0
- package/packages/commit/src/commit-policy.mjs +144 -0
- package/packages/commit/src/git-runtime.mjs +428 -0
- package/packages/commit/src/index.mjs +28 -0
- package/packages/commit/src/proof-validation.mjs +443 -0
- package/schema/commit-message-v1.schema.json +75 -0
- package/scripts/validate-commit-range.mjs +244 -0
- package/src/doctor.mjs +7 -0
- package/src/git-commit-hooks.mjs +112 -0
- package/src/init.mjs +13 -0
- package/src/skills-seed.mjs +79 -0
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
existsSync,
|
|
5
|
+
realpathSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from 'node:fs';
|
|
11
|
+
import { basename, isAbsolute, join, relative, resolve } from 'node:path';
|
|
12
|
+
|
|
13
|
+
import { assertPublicText, normalizeCommitInput } from './commit-input.mjs';
|
|
14
|
+
import { prepareCommitMessage, renderCommitMessage } from './commit-message.mjs';
|
|
15
|
+
import { nativeDesignReference, validateCommitMessage } from './commit-policy.mjs';
|
|
16
|
+
import {
|
|
17
|
+
collectCommitSensorProof,
|
|
18
|
+
commitTaskRequirementIds,
|
|
19
|
+
commitTaskSensorIds,
|
|
20
|
+
contentSha256,
|
|
21
|
+
validateCommitProofSet,
|
|
22
|
+
} from './proof-validation.mjs';
|
|
23
|
+
import { resolveProjectVault } from '../../vault/src/project-vault.mjs';
|
|
24
|
+
import {
|
|
25
|
+
captureGitSnapshot,
|
|
26
|
+
resolveEvidenceIdentity,
|
|
27
|
+
} from '../../../src/evidence-envelope.mjs';
|
|
28
|
+
import { loadSensorsDetailed } from '../../harness/src/sensors-core.mjs';
|
|
29
|
+
import { resolveCommandActiveContext } from '../../../src/active-context-runtime.mjs';
|
|
30
|
+
import { activeContextKey, resolveActiveContext } from '../../../hooks/active-context-store.mjs';
|
|
31
|
+
import { buildEffectiveRequirementPackage } from '../../../hooks/spec-core.mjs';
|
|
32
|
+
import { getLocale } from '../../../hooks/locale.mjs';
|
|
33
|
+
|
|
34
|
+
export const COMMIT_CONTEXT_FILE = 'wendkeep-commit-input.json';
|
|
35
|
+
|
|
36
|
+
function git(args, { cwd = process.cwd(), binary = false } = {}) {
|
|
37
|
+
const result = spawnSync('git', args, {
|
|
38
|
+
cwd,
|
|
39
|
+
encoding: binary ? null : 'utf8',
|
|
40
|
+
windowsHide: true,
|
|
41
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
42
|
+
});
|
|
43
|
+
if (result.status !== 0) {
|
|
44
|
+
const stderr = binary ? result.stderr?.toString('utf8') : result.stderr;
|
|
45
|
+
const error = new Error((stderr || `git ${args.join(' ')} failed`).trim());
|
|
46
|
+
error.code = 'WENDKEEP_COMMIT_GIT_FAILED';
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
return result.stdout;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function resolveCommitContextPath({ cwd = process.cwd() } = {}) {
|
|
53
|
+
const raw = String(git(['rev-parse', '--git-path', COMMIT_CONTEXT_FILE], { cwd })).trim();
|
|
54
|
+
return isAbsolute(raw) ? raw : resolve(cwd, raw);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function collectStagedDiff({ cwd = process.cwd() } = {}) {
|
|
58
|
+
const diff = git(['diff', '--cached', '--binary', '--no-ext-diff', '--no-color'], { cwd, binary: true });
|
|
59
|
+
const names = git(['diff', '--cached', '--name-only', '-z', '--no-renames'], { cwd, binary: true })
|
|
60
|
+
.toString('utf8')
|
|
61
|
+
.split('\0')
|
|
62
|
+
.filter(Boolean)
|
|
63
|
+
.map((file) => file.replaceAll('\\', '/'))
|
|
64
|
+
.sort((a, b) => a.localeCompare(b, 'en'));
|
|
65
|
+
if (!names.length) {
|
|
66
|
+
const error = new Error('staged diff is empty');
|
|
67
|
+
error.code = 'WENDKEEP_COMMIT_EMPTY_INDEX';
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
sha256: createHash('sha256').update(diff).digest('hex'),
|
|
72
|
+
files: names,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sameStagedDiff(left, right) {
|
|
77
|
+
return left?.sha256 === right?.sha256
|
|
78
|
+
&& JSON.stringify(left?.files || []) === JSON.stringify(right?.files || []);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function policyError(code, message) {
|
|
82
|
+
return Object.assign(new Error(message), { code });
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function canonicalRef(value, field = 'reference') {
|
|
86
|
+
const ref = String(value || '').replaceAll('\\', '/');
|
|
87
|
+
const segments = ref.split('/');
|
|
88
|
+
if (!ref || isAbsolute(ref) || segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
89
|
+
throw policyError('WENDKEEP_COMMIT_REFERENCE_INVALID', `${field} must be a canonical repository-relative path`);
|
|
90
|
+
}
|
|
91
|
+
assertPublicText(ref, field);
|
|
92
|
+
return ref;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function indexFile(ref, { cwd }) {
|
|
96
|
+
const path = canonicalRef(ref);
|
|
97
|
+
try {
|
|
98
|
+
git(['ls-files', '--error-unmatch', '--', path], { cwd });
|
|
99
|
+
return String(git(['show', `:${path}`], { cwd }));
|
|
100
|
+
} catch {
|
|
101
|
+
throw policyError('WENDKEEP_COMMIT_EVIDENCE_UNVERSIONED', `evidence is not versioned in the Git index: ${path}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function configuredVaultMarkers(cwd) {
|
|
106
|
+
const configPath = resolve(cwd, '.wendkeep.json');
|
|
107
|
+
if (!existsSync(configPath)) return [];
|
|
108
|
+
try {
|
|
109
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
110
|
+
const raw = typeof config?.vault === 'string' ? config.vault.trim() : '';
|
|
111
|
+
return raw ? [raw.replaceAll('\\', '/'), basename(raw.replaceAll('\\', '/'))].filter(Boolean) : [];
|
|
112
|
+
} catch {
|
|
113
|
+
throw policyError('WENDKEEP_COMMIT_PROJECT_CONFIG_INVALID', '.wendkeep.json is invalid');
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function assertConfiguredPrivacy(value, { cwd }) {
|
|
118
|
+
const source = String(value ?? '');
|
|
119
|
+
assertPublicText(source, 'commit input');
|
|
120
|
+
const lower = source.toLowerCase();
|
|
121
|
+
for (const marker of configuredVaultMarkers(cwd)) {
|
|
122
|
+
if (marker.length >= 3 && lower.includes(marker.toLowerCase())) {
|
|
123
|
+
throw policyError('WENDKEEP_COMMIT_PRIVATE_PATH', 'commit input references the configured project Vault');
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function resolveEvidence(input, { cwd }) {
|
|
129
|
+
const entries = input.evidence.map((item) => {
|
|
130
|
+
const content = indexFile(item.ref, { cwd });
|
|
131
|
+
return { ...item, path: item.ref, content, sha256: contentSha256(content) };
|
|
132
|
+
});
|
|
133
|
+
let project = {};
|
|
134
|
+
try { project = JSON.parse(readFileSync(resolve(cwd, '.wendkeep.json'), 'utf8')); } catch { /* authority gate reports invalid binding */ }
|
|
135
|
+
const snapshot = captureGitSnapshot(cwd);
|
|
136
|
+
const sensorIds = commitTaskSensorIds(entries);
|
|
137
|
+
const loaded = loadSensorsDetailed(cwd);
|
|
138
|
+
if (loaded.error) {
|
|
139
|
+
throw policyError('WENDKEEP_COMMIT_SENSOR_CONFIG_INVALID', `wendkeep.sensors.json is invalid: ${loaded.error}`);
|
|
140
|
+
}
|
|
141
|
+
const executionProof = sensorIds.length
|
|
142
|
+
? collectCommitSensorProof({ sensors: loaded.sensors, ids: sensorIds, cwd })
|
|
143
|
+
: null;
|
|
144
|
+
let governedBinding = {};
|
|
145
|
+
if (entries.some((entry) => entry.kind === 'evidence')) {
|
|
146
|
+
const vault = resolveProjectVault({ startDir: cwd });
|
|
147
|
+
const commandContext = resolveCommandActiveContext({
|
|
148
|
+
vaultBase: vault.base,
|
|
149
|
+
projectRoot: cwd,
|
|
150
|
+
requireExisting: true,
|
|
151
|
+
});
|
|
152
|
+
if (!commandContext) {
|
|
153
|
+
throw policyError('WENDKEEP_COMMIT_BINDING_INCOMPLETE', 'Evidence Envelope requires an active canonical context');
|
|
154
|
+
}
|
|
155
|
+
const active = resolveActiveContext(vault.base, commandContext);
|
|
156
|
+
const changeSlug = String(active.change_slug || '').trim();
|
|
157
|
+
if (!changeSlug) {
|
|
158
|
+
throw policyError('WENDKEEP_COMMIT_BINDING_INCOMPLETE', 'Evidence Envelope requires a causal active change');
|
|
159
|
+
}
|
|
160
|
+
const reqIds = commitTaskRequirementIds(entries);
|
|
161
|
+
const changeDir = join(vault.base, getLocale(vault.base).folders.changes, changeSlug);
|
|
162
|
+
const effective = buildEffectiveRequirementPackage(vault.base, changeDir, reqIds);
|
|
163
|
+
if (effective.errors?.length || effective.missing?.length) {
|
|
164
|
+
throw policyError('WENDKEEP_COMMIT_EFFECTIVE_SPEC_INVALID', 'canonical effective spec is invalid or incomplete');
|
|
165
|
+
}
|
|
166
|
+
const identity = resolveEvidenceIdentity({
|
|
167
|
+
vaultBase: vault.base,
|
|
168
|
+
projectRoot: cwd,
|
|
169
|
+
changeSlug,
|
|
170
|
+
context: commandContext,
|
|
171
|
+
});
|
|
172
|
+
governedBinding = {
|
|
173
|
+
projectId: identity.project_id,
|
|
174
|
+
repositoryId: identity.repository_id,
|
|
175
|
+
worktreeId: identity.worktree_id,
|
|
176
|
+
workSessionId: identity.work_session_id,
|
|
177
|
+
activeContextId: activeContextKey(commandContext),
|
|
178
|
+
changeSlug,
|
|
179
|
+
effectiveSpecSha256: `sha256:${effective.hash}`,
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
return validateCommitProofSet({
|
|
183
|
+
entries,
|
|
184
|
+
authority: input.authority,
|
|
185
|
+
stagedHash: input.staged_diff.sha256,
|
|
186
|
+
context: {
|
|
187
|
+
projectId: project.projectId || '',
|
|
188
|
+
changeSlug: input.authority.kind === 'adr' ? input.authority.adr.toLowerCase() : `issue-${input.authority.issue.slice(1)}`,
|
|
189
|
+
branch: snapshot.branch,
|
|
190
|
+
baseSha: snapshot.base_sha,
|
|
191
|
+
headSha: snapshot.head_sha,
|
|
192
|
+
indexTreeSha: snapshot.index_tree_sha,
|
|
193
|
+
worktreeDigest: snapshot.worktree_digest,
|
|
194
|
+
dirty: snapshot.dirty,
|
|
195
|
+
sensorConfigSha256: executionProof?.configSha256,
|
|
196
|
+
executionProof,
|
|
197
|
+
profile: String(project?.harness?.profile || 'OFF').toUpperCase(),
|
|
198
|
+
...governedBinding,
|
|
199
|
+
},
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function markdownFiles(root) {
|
|
204
|
+
if (!existsSync(root)) return [];
|
|
205
|
+
const result = [];
|
|
206
|
+
for (const entry of readdirSync(root, { withFileTypes: true })) {
|
|
207
|
+
const path = join(root, entry.name);
|
|
208
|
+
if (entry.isDirectory()) result.push(...markdownFiles(path));
|
|
209
|
+
else if (entry.isFile() && entry.name.toLowerCase().endsWith('.md')) result.push(path);
|
|
210
|
+
}
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function verifyNativeAuthority(input, { cwd }) {
|
|
215
|
+
const configPath = resolve(cwd, '.wendkeep.json');
|
|
216
|
+
let config;
|
|
217
|
+
try { config = JSON.parse(readFileSync(configPath, 'utf8')); }
|
|
218
|
+
catch { throw policyError('WENDKEEP_COMMIT_NATIVE_PROFILE_REQUIRED', 'native authority requires a valid project binding'); }
|
|
219
|
+
if (String(config?.harness?.profile || '').toUpperCase() !== 'OFF') {
|
|
220
|
+
throw policyError('WENDKEEP_COMMIT_NATIVE_PROFILE_REQUIRED', 'native authority is permitted only under observed profile OFF');
|
|
221
|
+
}
|
|
222
|
+
const branch = String(git(['branch', '--show-current'], { cwd })).trim();
|
|
223
|
+
let vault = null;
|
|
224
|
+
try { vault = resolveProjectVault({ startDir: cwd }); }
|
|
225
|
+
catch (error) {
|
|
226
|
+
if (!['WENDKEEP_VAULT_MARKER_MISSING', 'WENDKEEP_VAULT_UNCONFIGURED'].includes(error?.code)) throw error;
|
|
227
|
+
}
|
|
228
|
+
if (vault) {
|
|
229
|
+
const registryPath = join(vault.base, '.brain', 'SESSION_REGISTRY.json');
|
|
230
|
+
if (existsSync(registryPath)) {
|
|
231
|
+
let registry;
|
|
232
|
+
try { registry = JSON.parse(readFileSync(registryPath, 'utf8')); }
|
|
233
|
+
catch { throw policyError('WENDKEEP_COMMIT_CAUSAL_CONTEXT_INVALID', 'Keep Core active-context registry is invalid'); }
|
|
234
|
+
const contexts = Object.values(registry?.active_contexts || {}).filter((context) => (
|
|
235
|
+
context?.state === 'active' && context?.branch === branch
|
|
236
|
+
));
|
|
237
|
+
if (contexts.some((context) => context.change_slug || context.delivery_id
|
|
238
|
+
|| context?.operating_profile_task?.state === 'active')) {
|
|
239
|
+
throw policyError('WENDKEEP_COMMIT_CAUSAL_AUTHORITY_EXISTS', 'active causal change, delivery, or profile lease exists');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const decisions = join(vault.base, '04-Decisões');
|
|
243
|
+
for (const path of markdownFiles(decisions)) {
|
|
244
|
+
const text = readFileSync(path, 'utf8');
|
|
245
|
+
if (text.includes(input.authority.issue) && text.includes(basename(input.authority.design))) {
|
|
246
|
+
throw policyError('WENDKEEP_COMMIT_CAUSAL_AUTHORITY_EXISTS', 'a causal ADR already exists for the native issue/design');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
const adrPaths = String(git(['ls-files', '*ADR-*.md', '*adr-*.md'], { cwd })).split(/\r?\n/).filter(Boolean);
|
|
251
|
+
for (const path of adrPaths) {
|
|
252
|
+
const content = indexFile(path, { cwd });
|
|
253
|
+
if (content.includes(input.authority.issue) && content.includes(basename(input.authority.design))) {
|
|
254
|
+
throw policyError('WENDKEEP_COMMIT_CAUSAL_AUTHORITY_EXISTS', 'a versioned causal ADR already exists');
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function buildCommitInput(draft, { cwd = process.cwd() } = {}) {
|
|
260
|
+
const stagedDiff = collectStagedDiff({ cwd });
|
|
261
|
+
assertConfiguredPrivacy(JSON.stringify(draft), { cwd });
|
|
262
|
+
if (draft?.staged_diff && !sameStagedDiff(draft.staged_diff, stagedDiff)) {
|
|
263
|
+
const error = new Error('provided staged_diff does not match the current Git index');
|
|
264
|
+
error.code = 'WENDKEEP_COMMIT_STALE_INPUT';
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
const draftInput = normalizeCommitInput({ ...draft, staged_diff: stagedDiff }, { resolved: false });
|
|
268
|
+
if (draftInput.authority.kind === 'native') {
|
|
269
|
+
try { git(['ls-files', '--error-unmatch', '--', draftInput.authority.design], { cwd }); }
|
|
270
|
+
catch {
|
|
271
|
+
throw policyError('WENDKEEP_COMMIT_DESIGN_UNVERSIONED', `native authority design is not versioned: ${draftInput.authority.design}`);
|
|
272
|
+
}
|
|
273
|
+
verifyNativeAuthority(draftInput, { cwd });
|
|
274
|
+
}
|
|
275
|
+
const proofs = resolveEvidence(draftInput, { cwd });
|
|
276
|
+
const input = normalizeCommitInput({
|
|
277
|
+
...draftInput,
|
|
278
|
+
evidence: proofs.evidence,
|
|
279
|
+
tasks: proofs.tasks,
|
|
280
|
+
tests: proofs.tests,
|
|
281
|
+
}, { resolved: true });
|
|
282
|
+
assertConfiguredPrivacy(JSON.stringify(input), { cwd });
|
|
283
|
+
return input;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function writeCommitContext(draft, { cwd = process.cwd() } = {}) {
|
|
287
|
+
const input = buildCommitInput(draft, { cwd });
|
|
288
|
+
const path = resolveCommitContextPath({ cwd });
|
|
289
|
+
assertConfiguredPrivacy(JSON.stringify(input), { cwd });
|
|
290
|
+
writeFileSync(path, `${JSON.stringify(input, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
|
291
|
+
return { path, input };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function readCommitContext({ cwd = process.cwd(), required = false } = {}) {
|
|
295
|
+
const path = resolveCommitContextPath({ cwd });
|
|
296
|
+
if (!existsSync(path)) {
|
|
297
|
+
if (required) {
|
|
298
|
+
const error = new Error('commit context is missing; run `wendkeep commit context --input <file>`');
|
|
299
|
+
error.code = 'WENDKEEP_COMMIT_CONTEXT_MISSING';
|
|
300
|
+
throw error;
|
|
301
|
+
}
|
|
302
|
+
return { path, input: null };
|
|
303
|
+
}
|
|
304
|
+
const input = normalizeCommitInput(JSON.parse(readFileSync(path, 'utf8')), { resolved: true });
|
|
305
|
+
assertConfiguredPrivacy(JSON.stringify(input), { cwd });
|
|
306
|
+
const stagedDiff = collectStagedDiff({ cwd });
|
|
307
|
+
if (!sameStagedDiff(input.staged_diff, stagedDiff)) {
|
|
308
|
+
const error = new Error('commit context is stale for the current Git index');
|
|
309
|
+
error.code = 'WENDKEEP_COMMIT_STALE_INPUT';
|
|
310
|
+
throw error;
|
|
311
|
+
}
|
|
312
|
+
return { path, input };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
export function clearCommitContext({ cwd = process.cwd() } = {}) {
|
|
316
|
+
const path = resolveCommitContextPath({ cwd });
|
|
317
|
+
const existed = existsSync(path);
|
|
318
|
+
if (existed) rmSync(path, { force: true });
|
|
319
|
+
return { path, cleared: existed };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function isWithin(path, parent) {
|
|
323
|
+
const canonical = (value) => {
|
|
324
|
+
try { return realpathSync.native(resolve(value)); } catch { return resolve(value); }
|
|
325
|
+
};
|
|
326
|
+
const rel = relative(canonical(parent), canonical(path));
|
|
327
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function resolveMessageFile(messageFile, { cwd }) {
|
|
331
|
+
if (!messageFile) throw policyError('WENDKEEP_COMMIT_ARGUMENT', '--message-file is required');
|
|
332
|
+
const candidate = resolve(cwd, messageFile);
|
|
333
|
+
const root = String(git(['rev-parse', '--show-toplevel'], { cwd })).trim();
|
|
334
|
+
const gitDir = String(git(['rev-parse', '--absolute-git-dir'], { cwd })).trim();
|
|
335
|
+
if (!isWithin(candidate, root) && !isWithin(candidate, gitDir)) {
|
|
336
|
+
throw policyError('WENDKEEP_COMMIT_MESSAGE_PATH_OUTSIDE_REPOSITORY', '--message-file must stay inside the repository or Git directory');
|
|
337
|
+
}
|
|
338
|
+
return candidate;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function trivialMessageForFiles(message, files) {
|
|
342
|
+
const subject = String(message || '').split(/\r?\n/, 1)[0];
|
|
343
|
+
const docsPath = (path) => /^(?:docs\/|README(?:\.en)?\.md$|[^/]+\.md$)/.test(path);
|
|
344
|
+
const testPath = (path) => /^(?:tests?\/|fixtures?\/)/.test(path) || /(?:^|\/)__tests__\//.test(path);
|
|
345
|
+
if (/^docs(?:\([^)]*\))?:/.test(subject)) return files.length > 0 && files.every(docsPath);
|
|
346
|
+
if (/^test(?:\([^)]*\))?:/.test(subject)) return files.length > 0 && files.every(testPath);
|
|
347
|
+
if (/^chore(?:\([^)]*\))?:/.test(subject)) return files.length > 0 && files.every((file) => docsPath(file) || testPath(file));
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function unchangedGovernedAmend(message, { cwd }) {
|
|
352
|
+
const previous = git(['log', '-1', '--format=%B'], { cwd });
|
|
353
|
+
const diff = spawnSync('git', ['diff', '--cached', '--quiet', 'HEAD', '--'], { cwd, windowsHide: true });
|
|
354
|
+
return diff.status === 0
|
|
355
|
+
&& String(previous).replace(/\r\n?/g, '\n').trimEnd() === String(message).replace(/\r\n?/g, '\n').trimEnd();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function prepareCommitMessageFile({
|
|
359
|
+
messageFile,
|
|
360
|
+
source = '',
|
|
361
|
+
cwd = process.cwd(),
|
|
362
|
+
} = {}) {
|
|
363
|
+
const path = resolveMessageFile(messageFile, { cwd });
|
|
364
|
+
const current = readFileSync(path, 'utf8');
|
|
365
|
+
if (['merge', 'squash', 'commit'].includes(source)) {
|
|
366
|
+
const cleared = clearCommitContext({ cwd }).cleared;
|
|
367
|
+
return { changed: false, skipped: source, contextCleared: cleared };
|
|
368
|
+
}
|
|
369
|
+
const { input } = readCommitContext({ cwd, required: false });
|
|
370
|
+
if (!input) return { changed: false, skipped: 'no-context' };
|
|
371
|
+
const prepared = prepareCommitMessage(current, input, { source: '' });
|
|
372
|
+
if (prepared !== current) writeFileSync(path, prepared, 'utf8');
|
|
373
|
+
return { changed: prepared !== current, skipped: '' };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function validateCommitMessageFile({
|
|
377
|
+
messageFile,
|
|
378
|
+
consumeContext = false,
|
|
379
|
+
cwd = process.cwd(),
|
|
380
|
+
} = {}) {
|
|
381
|
+
const path = resolveMessageFile(messageFile, { cwd });
|
|
382
|
+
const message = readFileSync(path, 'utf8');
|
|
383
|
+
assertConfiguredPrivacy(message, { cwd });
|
|
384
|
+
const result = validateCommitMessage(message);
|
|
385
|
+
if (consumeContext && result.governed) {
|
|
386
|
+
try {
|
|
387
|
+
const { input } = readCommitContext({ cwd, required: true });
|
|
388
|
+
if (message.replace(/\r\n?/g, '\n') !== renderCommitMessage(input)) {
|
|
389
|
+
result.ok = false;
|
|
390
|
+
result.errors.push('WENDKEEP_COMMIT_CONTEXT_MISMATCH: message does not match the current staged context');
|
|
391
|
+
}
|
|
392
|
+
} catch (error) {
|
|
393
|
+
if (!unchangedGovernedAmend(message, { cwd })) {
|
|
394
|
+
result.ok = false;
|
|
395
|
+
result.errors.push(`${error.code || 'WENDKEEP_COMMIT_CONTEXT_INVALID'}: ${error.message}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
} else if (consumeContext && !result.governed && existsSync(resolveCommitContextPath({ cwd }))) {
|
|
399
|
+
result.ok = false;
|
|
400
|
+
result.errors.push('WENDKEEP_COMMIT_CONTEXT_UNUSED: non-governed commit cannot leave a prepared context pending');
|
|
401
|
+
}
|
|
402
|
+
if (consumeContext && !result.governed) {
|
|
403
|
+
const mergePath = String(git(['rev-parse', '--git-path', 'MERGE_HEAD'], { cwd })).trim();
|
|
404
|
+
const mergeInProgress = existsSync(isAbsolute(mergePath) ? mergePath : resolve(cwd, mergePath));
|
|
405
|
+
if (!mergeInProgress) {
|
|
406
|
+
try {
|
|
407
|
+
const staged = collectStagedDiff({ cwd });
|
|
408
|
+
if (!trivialMessageForFiles(message, staged.files)) {
|
|
409
|
+
result.ok = false;
|
|
410
|
+
result.errors.push('WENDKEEP_COMMIT_PRODUCT_CHANGE_UNGOVERNED');
|
|
411
|
+
}
|
|
412
|
+
} catch (error) {
|
|
413
|
+
if (error.code !== 'WENDKEEP_COMMIT_EMPTY_INDEX') throw error;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const design = nativeDesignReference(message);
|
|
418
|
+
if (result.ok && design) {
|
|
419
|
+
try {
|
|
420
|
+
git(['ls-files', '--error-unmatch', '--', design], { cwd });
|
|
421
|
+
} catch {
|
|
422
|
+
result.ok = false;
|
|
423
|
+
result.errors.push(`WENDKEEP_COMMIT_DESIGN_UNVERSIONED: ${design}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
if (result.ok && consumeContext && result.governed) clearCommitContext({ cwd });
|
|
427
|
+
return result;
|
|
428
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
COMMIT_INPUT_SCHEMA_VERSION,
|
|
3
|
+
CommitPolicyError,
|
|
4
|
+
assertPublicText,
|
|
5
|
+
normalizeCommitInput,
|
|
6
|
+
} from './commit-input.mjs';
|
|
7
|
+
export { prepareCommitMessage, renderCommitMessage } from './commit-message.mjs';
|
|
8
|
+
export {
|
|
9
|
+
assertValidCommitMessage,
|
|
10
|
+
isGovernedCommitMessage,
|
|
11
|
+
messageEvidence,
|
|
12
|
+
messageScope,
|
|
13
|
+
messageTasks,
|
|
14
|
+
messageTests,
|
|
15
|
+
nativeDesignReference,
|
|
16
|
+
validateCommitMessage,
|
|
17
|
+
} from './commit-policy.mjs';
|
|
18
|
+
export {
|
|
19
|
+
COMMIT_CONTEXT_FILE,
|
|
20
|
+
buildCommitInput,
|
|
21
|
+
clearCommitContext,
|
|
22
|
+
collectStagedDiff,
|
|
23
|
+
prepareCommitMessageFile,
|
|
24
|
+
readCommitContext,
|
|
25
|
+
resolveCommitContextPath,
|
|
26
|
+
validateCommitMessageFile,
|
|
27
|
+
writeCommitContext,
|
|
28
|
+
} from './git-runtime.mjs';
|