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,181 @@
|
|
|
1
|
+
const IMPLEMENTATION_TYPES = new Set(['feat', 'fix', 'refactor', 'perf']);
|
|
2
|
+
const EVIDENCE_STATUSES = new Set(['fresh', 'verified']);
|
|
3
|
+
const EVIDENCE_KINDS = new Set(['adr', 'design', 'evidence', 'receipt', 'spec', 'task', 'verdict']);
|
|
4
|
+
|
|
5
|
+
const PRIVATE_PATH = /(?:^|[\\/])(?:\.[^\\/\s]+-vault|\.brain|02-Sess(?:ões|oes|ions)|SESSION_REGISTRY\.json)(?:[\\/]|$)/i;
|
|
6
|
+
const ABSOLUTE_PATH = /(?:^|[\s"'=(])(?:[a-z]:[\\/]|\\\\[^\\/]|\/(?!\/))/i;
|
|
7
|
+
const SECRET_PATTERNS = [
|
|
8
|
+
/\b(?:gh[oprsu]|github_pat)_[A-Za-z0-9_]{16,}\b/,
|
|
9
|
+
/\b(?:Bearer|Basic)\s+[A-Za-z0-9._~+\/-]{12,}/i,
|
|
10
|
+
/\b(?:api[_-]?key|access[_-]?token|secret|password)\s*[:=]\s*\S+/i,
|
|
11
|
+
/-----BEGIN [A-Z ]*PRIVATE KEY-----/,
|
|
12
|
+
/\b\d{3}\.?\d{3}\.?\d{3}-?\d{2}\b/,
|
|
13
|
+
/\b[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}\b/,
|
|
14
|
+
/(?:\+55\s*\(?\d{2}\)?\s*9\d{4}[-\s]\d{4}|\(\d{2}\)\s*9?\d{4}-\d{4})/,
|
|
15
|
+
];
|
|
16
|
+
|
|
17
|
+
export class CommitPolicyError extends Error {
|
|
18
|
+
constructor(message, code = 'WENDKEEP_COMMIT_POLICY') {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = 'CommitPolicyError';
|
|
21
|
+
this.code = code;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function text(value, field, { required = true, max = 500 } = {}) {
|
|
26
|
+
if (typeof value !== 'string') {
|
|
27
|
+
if (!required && (value === undefined || value === null)) return '';
|
|
28
|
+
throw new CommitPolicyError(`${field} must be a string`);
|
|
29
|
+
}
|
|
30
|
+
const normalized = value.trim().replace(/\r\n?/g, '\n');
|
|
31
|
+
if (required && !normalized) throw new CommitPolicyError(`${field} is required`);
|
|
32
|
+
if (normalized.includes('\n')) throw new CommitPolicyError(`${field} must be a single line`);
|
|
33
|
+
if (normalized.length > max) throw new CommitPolicyError(`${field} exceeds ${max} characters`);
|
|
34
|
+
assertPublicText(normalized, field);
|
|
35
|
+
return normalized;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function assertPublicText(value, field = 'value') {
|
|
39
|
+
const source = String(value ?? '');
|
|
40
|
+
if (PRIVATE_PATH.test(source) || ABSOLUTE_PATH.test(source)) {
|
|
41
|
+
throw new CommitPolicyError(`${field} contains a private or absolute path`, 'WENDKEEP_COMMIT_PRIVATE_PATH');
|
|
42
|
+
}
|
|
43
|
+
if (SECRET_PATTERNS.some((pattern) => pattern.test(source))) {
|
|
44
|
+
throw new CommitPolicyError(`${field} contains a possible secret`, 'WENDKEEP_COMMIT_SECRET');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function uniqueSorted(values) {
|
|
49
|
+
return [...new Set(values)].sort((a, b) => a.localeCompare(b, 'en'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function assertKnownFields(value, allowed, field) {
|
|
53
|
+
const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
|
|
54
|
+
if (unknown.length) throw new CommitPolicyError(`${field} has unsupported field(s): ${unknown.join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stringList(value, field, { required = true } = {}) {
|
|
58
|
+
if (!Array.isArray(value)) throw new CommitPolicyError(`${field} must be an array`);
|
|
59
|
+
const normalized = uniqueSorted(value.map((item, index) => text(item, `${field}[${index}]`)));
|
|
60
|
+
if (required && !normalized.length) throw new CommitPolicyError(`${field} must not be empty`);
|
|
61
|
+
return normalized;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function normalizeSubject(subject) {
|
|
65
|
+
if (!subject || typeof subject !== 'object' || Array.isArray(subject)) {
|
|
66
|
+
throw new CommitPolicyError('subject must be an object');
|
|
67
|
+
}
|
|
68
|
+
assertKnownFields(subject, ['type', 'scope', 'summary'], 'subject');
|
|
69
|
+
const type = text(subject.type, 'subject.type').toLowerCase();
|
|
70
|
+
if (!IMPLEMENTATION_TYPES.has(type)) {
|
|
71
|
+
throw new CommitPolicyError(`subject.type must be one of ${[...IMPLEMENTATION_TYPES].join(', ')}`);
|
|
72
|
+
}
|
|
73
|
+
const scope = text(subject.scope, 'subject.scope', { required: false, max: 40 });
|
|
74
|
+
if (scope && !/^[a-z0-9][a-z0-9._/-]*$/.test(scope)) {
|
|
75
|
+
throw new CommitPolicyError('subject.scope must use lowercase Conventional Commit characters');
|
|
76
|
+
}
|
|
77
|
+
const summary = text(subject.summary, 'subject.summary', { max: 120 });
|
|
78
|
+
if (/\.$/.test(summary)) throw new CommitPolicyError('subject.summary must not end with a period');
|
|
79
|
+
return { type, ...(scope ? { scope } : {}), summary };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeEvidence(value, { resolved = true } = {}) {
|
|
83
|
+
if (!Array.isArray(value) || !value.length) {
|
|
84
|
+
throw new CommitPolicyError('evidence must be a non-empty array');
|
|
85
|
+
}
|
|
86
|
+
const normalized = value.map((item, index) => {
|
|
87
|
+
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
|
88
|
+
throw new CommitPolicyError(`evidence[${index}] must be an object`);
|
|
89
|
+
}
|
|
90
|
+
assertKnownFields(item, resolved ? ['kind', 'ref', 'status'] : ['kind', 'ref'], `evidence[${index}]`);
|
|
91
|
+
const kind = text(item.kind, `evidence[${index}].kind`).toLowerCase();
|
|
92
|
+
const ref = text(item.ref, `evidence[${index}].ref`);
|
|
93
|
+
if (!EVIDENCE_KINDS.has(kind)) throw new CommitPolicyError(`evidence[${index}].kind is unsupported`);
|
|
94
|
+
if (!resolved) return { kind, ref };
|
|
95
|
+
const status = text(item.status, `evidence[${index}].status`).toLowerCase();
|
|
96
|
+
if (!EVIDENCE_STATUSES.has(status)) {
|
|
97
|
+
throw new CommitPolicyError(`evidence[${index}].status must be derived as fresh or verified`);
|
|
98
|
+
}
|
|
99
|
+
return { kind, ref, status };
|
|
100
|
+
});
|
|
101
|
+
const keyed = new Map(normalized.map((item) => [`${item.status || ''}\0${item.kind}\0${item.ref}`, item]));
|
|
102
|
+
return [...keyed.values()].sort((a, b) => (
|
|
103
|
+
`${a.status}\0${a.kind}\0${a.ref}`.localeCompare(`${b.status}\0${b.kind}\0${b.ref}`, 'en')
|
|
104
|
+
));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeStagedDiff(value) {
|
|
108
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
109
|
+
throw new CommitPolicyError('staged_diff must be an object');
|
|
110
|
+
}
|
|
111
|
+
assertKnownFields(value, ['sha256', 'files'], 'staged_diff');
|
|
112
|
+
const sha256 = text(value.sha256, 'staged_diff.sha256').toLowerCase();
|
|
113
|
+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new CommitPolicyError('staged_diff.sha256 must be a SHA-256 digest');
|
|
114
|
+
const files = stringList(value.files, 'staged_diff.files').map((file) => file.replaceAll('\\', '/'));
|
|
115
|
+
for (const [index, file] of files.entries()) assertPublicText(file, `staged_diff.files[${index}]`);
|
|
116
|
+
return { sha256, files: uniqueSorted(files) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function normalizeIdentity(value) {
|
|
120
|
+
if (!value) return { agent: '' };
|
|
121
|
+
if (typeof value !== 'object' || Array.isArray(value)) throw new CommitPolicyError('identity must be an object');
|
|
122
|
+
assertKnownFields(value, ['agent'], 'identity');
|
|
123
|
+
const agent = text(value.agent, 'identity.agent', { required: false, max: 80 });
|
|
124
|
+
return { agent };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function normalizeAuthority(value) {
|
|
128
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
129
|
+
throw new CommitPolicyError('authority must be an object');
|
|
130
|
+
}
|
|
131
|
+
const kind = text(value.kind, 'authority.kind').toLowerCase();
|
|
132
|
+
if (kind === 'adr') {
|
|
133
|
+
assertKnownFields(value, ['kind', 'adr', 'ref', 'issue'], 'authority');
|
|
134
|
+
const adr = text(value.adr, 'authority.adr').toUpperCase();
|
|
135
|
+
if (!/^ADR-\d{4,}$/.test(adr)) throw new CommitPolicyError('authority.adr must match ADR-NNNN');
|
|
136
|
+
const ref = text(value.ref, 'authority.ref').replaceAll('\\', '/');
|
|
137
|
+
const issue = text(value.issue, 'authority.issue', { required: false, max: 40 });
|
|
138
|
+
if (issue && !/^#\d+$/.test(issue)) throw new CommitPolicyError('authority.issue must match #NNN');
|
|
139
|
+
return { kind, adr, ref, ...(issue ? { issue } : {}) };
|
|
140
|
+
}
|
|
141
|
+
if (kind === 'native') {
|
|
142
|
+
assertKnownFields(value, ['kind', 'issue', 'design'], 'authority');
|
|
143
|
+
const issue = text(value.issue, 'authority.issue', { max: 40 });
|
|
144
|
+
if (!/^#\d+$/.test(issue)) throw new CommitPolicyError('authority.issue must match #NNN');
|
|
145
|
+
const design = text(value.design, 'authority.design').replaceAll('\\', '/');
|
|
146
|
+
const segments = design.split('/');
|
|
147
|
+
if (!/^(?:docs\/superpowers\/specs|plans)\/[a-zA-Z0-9._/-]+\.md$/.test(design)
|
|
148
|
+
|| segments.some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
149
|
+
throw new CommitPolicyError('authority.design must be a versioned design under docs/superpowers/specs or plans');
|
|
150
|
+
}
|
|
151
|
+
return { kind, issue, design };
|
|
152
|
+
}
|
|
153
|
+
throw new CommitPolicyError('authority.kind must be adr or native');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function normalizeCommitInput(input, { resolved = false } = {}) {
|
|
157
|
+
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
|
158
|
+
throw new CommitPolicyError('commit input must be an object');
|
|
159
|
+
}
|
|
160
|
+
assertKnownFields(input, [
|
|
161
|
+
'schema_version', 'subject', 'capability', 'authority', 'staged_diff',
|
|
162
|
+
'evidence', ...(resolved ? ['tasks', 'tests'] : []), 'limits', 'identity',
|
|
163
|
+
], 'commit input');
|
|
164
|
+
if (input.schema_version !== 1) throw new CommitPolicyError('schema_version must be 1');
|
|
165
|
+
return {
|
|
166
|
+
schema_version: 1,
|
|
167
|
+
subject: normalizeSubject(input.subject),
|
|
168
|
+
capability: text(input.capability, 'capability'),
|
|
169
|
+
authority: normalizeAuthority(input.authority),
|
|
170
|
+
staged_diff: normalizeStagedDiff(input.staged_diff),
|
|
171
|
+
evidence: normalizeEvidence(input.evidence, { resolved }),
|
|
172
|
+
...(resolved ? {
|
|
173
|
+
tasks: stringList(input.tasks, 'tasks'),
|
|
174
|
+
tests: stringList(input.tests, 'tests'),
|
|
175
|
+
} : {}),
|
|
176
|
+
limits: stringList(input.limits ?? [], 'limits', { required: false }),
|
|
177
|
+
identity: normalizeIdentity(input.identity),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const COMMIT_INPUT_SCHEMA_VERSION = 1;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { normalizeCommitInput } from './commit-input.mjs';
|
|
2
|
+
|
|
3
|
+
function section(name, values) {
|
|
4
|
+
if (!values.length) return '';
|
|
5
|
+
return `${name}:\n${values.map((value) => `- ${value}`).join('\n')}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function subjectLine(input) {
|
|
9
|
+
const scope = input.subject.scope ? `(${input.subject.scope})` : '';
|
|
10
|
+
const authority = input.authority.kind === 'adr' ? input.authority.adr : input.authority.issue;
|
|
11
|
+
return `${input.subject.type}${scope}: ${input.subject.summary} (${authority})`;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function renderCommitMessage(value) {
|
|
15
|
+
const input = normalizeCommitInput(value, { resolved: true });
|
|
16
|
+
const body = [
|
|
17
|
+
subjectLine(input),
|
|
18
|
+
section('Capability', [input.capability]),
|
|
19
|
+
section('Evidence', input.evidence.map((item) => `[${item.status}] ${item.kind}: ${item.ref}`)),
|
|
20
|
+
section('Tasks', input.tasks),
|
|
21
|
+
section('Tests', input.tests),
|
|
22
|
+
section('Scope', [
|
|
23
|
+
...input.staged_diff.files,
|
|
24
|
+
`staged-diff-sha256: ${input.staged_diff.sha256}`,
|
|
25
|
+
]),
|
|
26
|
+
section('Limits', input.limits),
|
|
27
|
+
[
|
|
28
|
+
'WendKeep-Commit: v1',
|
|
29
|
+
'Remote-Proof-Scope: git,authority,tasks,spec,sensors',
|
|
30
|
+
'Local-Causal-Proof: unpublished',
|
|
31
|
+
...(input.authority.kind === 'adr'
|
|
32
|
+
? [
|
|
33
|
+
`ADR: ${input.authority.adr}`,
|
|
34
|
+
...(input.authority.issue ? [`Refs: ${input.authority.issue}`] : []),
|
|
35
|
+
]
|
|
36
|
+
: [
|
|
37
|
+
'Authority: native-no-causal-change',
|
|
38
|
+
`Issue: ${input.authority.issue}`,
|
|
39
|
+
`Design: ${input.authority.design}`,
|
|
40
|
+
]),
|
|
41
|
+
].join('\n'),
|
|
42
|
+
].filter(Boolean);
|
|
43
|
+
return `${body.join('\n\n')}\n`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function prepareCommitMessage(current, input, { source = '' } = {}) {
|
|
47
|
+
const existing = String(current ?? '').replace(/\r\n?/g, '\n');
|
|
48
|
+
if (source || /^WendKeep-Commit:\s*v1$/m.test(existing)) return existing;
|
|
49
|
+
if (!input) return existing;
|
|
50
|
+
return renderCommitMessage(input);
|
|
51
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { assertPublicText } from './commit-input.mjs';
|
|
2
|
+
|
|
3
|
+
const IMPLEMENTATION_SUBJECT = /^(feat|fix|refactor|perf)(?:\([a-z0-9][a-z0-9._/-]*\))?: .+ \((?:ADR-\d{4,}|#\d+)\)$/;
|
|
4
|
+
const REQUIRED_SECTIONS = ['Capability', 'Evidence', 'Tasks', 'Tests', 'Scope'];
|
|
5
|
+
|
|
6
|
+
function occurrences(message, pattern) {
|
|
7
|
+
return [...message.matchAll(pattern)].length;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function sectionItems(message, name) {
|
|
11
|
+
const lines = message.split('\n');
|
|
12
|
+
const index = lines.indexOf(`${name}:`);
|
|
13
|
+
if (index < 0) return [];
|
|
14
|
+
const items = [];
|
|
15
|
+
for (let cursor = index + 1; cursor < lines.length && lines[cursor].startsWith('- '); cursor += 1) {
|
|
16
|
+
items.push(lines[cursor]);
|
|
17
|
+
}
|
|
18
|
+
return items;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isGovernedCommitMessage(message) {
|
|
22
|
+
const source = String(message ?? '').replace(/\r\n?/g, '\n');
|
|
23
|
+
const first = source.split('\n', 1)[0];
|
|
24
|
+
return /^WendKeep-Commit:\s*v1$/m.test(source)
|
|
25
|
+
|| /^(?:feat|fix|refactor|perf)(?:\([^)]*\))?!?:/.test(first);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function validateCommitMessage(message) {
|
|
29
|
+
const source = String(message ?? '').replace(/\r\n?/g, '\n').trimEnd();
|
|
30
|
+
const governed = isGovernedCommitMessage(source);
|
|
31
|
+
const errors = [];
|
|
32
|
+
try {
|
|
33
|
+
assertPublicText(source, 'commit message');
|
|
34
|
+
} catch (error) {
|
|
35
|
+
errors.push(error.code === 'WENDKEEP_COMMIT_SECRET'
|
|
36
|
+
? 'commit message contains a possible secret'
|
|
37
|
+
: 'commit message contains a private or absolute path');
|
|
38
|
+
}
|
|
39
|
+
if (!governed) return { ok: errors.length === 0, governed: false, errors };
|
|
40
|
+
|
|
41
|
+
const first = source.split('\n', 1)[0];
|
|
42
|
+
if (!IMPLEMENTATION_SUBJECT.test(first)) {
|
|
43
|
+
errors.push('implementation subject must be Conventional Commit and end with (ADR-NNNN) or (#NNN)');
|
|
44
|
+
}
|
|
45
|
+
for (const name of REQUIRED_SECTIONS) {
|
|
46
|
+
const count = occurrences(source, new RegExp(`^${name}:$`, 'gm'));
|
|
47
|
+
if (count !== 1) errors.push(`${name} section must appear exactly once`);
|
|
48
|
+
if (count === 1 && !new RegExp(`^${name}:\\n- \\S`, 'm').test(source)) {
|
|
49
|
+
errors.push(`${name} section must contain at least one item`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const evidence = sectionItems(source, 'Evidence');
|
|
53
|
+
if (new Set(evidence).size !== evidence.length) errors.push('Evidence items must be unique');
|
|
54
|
+
for (const item of evidence) {
|
|
55
|
+
if (!/^- \[verified\] (?:adr|design|spec|task): \S/.test(item)) {
|
|
56
|
+
errors.push('Evidence items must be remotely verified public artifact references');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (occurrences(source, /^WendKeep-Commit:\s*v1$/gm) !== 1) {
|
|
60
|
+
errors.push('WendKeep-Commit: v1 trailer must appear exactly once');
|
|
61
|
+
}
|
|
62
|
+
if (occurrences(source, /^Remote-Proof-Scope:/gm) !== 1
|
|
63
|
+
|| !/^Remote-Proof-Scope:\s*git,authority,tasks,spec,sensors$/m.test(source)) {
|
|
64
|
+
errors.push('Remote-Proof-Scope trailer must declare the canonical observable proof set exactly once');
|
|
65
|
+
}
|
|
66
|
+
if (occurrences(source, /^Local-Causal-Proof:/gm) !== 1
|
|
67
|
+
|| !/^Local-Causal-Proof:\s*unpublished$/m.test(source)) {
|
|
68
|
+
errors.push('Local-Causal-Proof trailer must remain unpublished exactly once');
|
|
69
|
+
}
|
|
70
|
+
const subjectAuthority = first.match(/\((ADR-\d{4,}|#\d+)\)$/)?.[1] || '';
|
|
71
|
+
if (subjectAuthority.startsWith('ADR-')) {
|
|
72
|
+
const adrTrailers = source.match(/^ADR:/gm) || [];
|
|
73
|
+
const trailerAdr = source.match(/^ADR:\s*(ADR-\d{4,})$/m)?.[1];
|
|
74
|
+
if (adrTrailers.length !== 1) errors.push('ADR trailer must appear exactly once');
|
|
75
|
+
if (!trailerAdr || trailerAdr !== subjectAuthority) errors.push('ADR trailer must match the subject ADR');
|
|
76
|
+
if ((source.match(/^Refs:/gm) || []).length > 1) errors.push('Refs trailer must not be ambiguous');
|
|
77
|
+
if (/^Authority:/m.test(source)) {
|
|
78
|
+
errors.push('ADR authority cannot also claim native-no-causal-change');
|
|
79
|
+
}
|
|
80
|
+
} else if (subjectAuthority.startsWith('#')) {
|
|
81
|
+
const native = source.match(/^Authority:/gm) || [];
|
|
82
|
+
const issues = source.match(/^Issue:/gm) || [];
|
|
83
|
+
const designs = source.match(/^Design:/gm) || [];
|
|
84
|
+
const issue = source.match(/^Issue:\s*(#\d+)$/m)?.[1];
|
|
85
|
+
const design = source.match(/^Design:\s*(\S+)$/m)?.[1];
|
|
86
|
+
if (native.length !== 1) errors.push('native authority trailer must appear exactly once');
|
|
87
|
+
if (issues.length !== 1) errors.push('Issue trailer must appear exactly once');
|
|
88
|
+
if (designs.length !== 1) errors.push('Design trailer must appear exactly once');
|
|
89
|
+
if (issue !== subjectAuthority) errors.push('Issue trailer must match the subject issue');
|
|
90
|
+
if (!design || !/^(?:docs\/superpowers\/specs|plans)\/[a-zA-Z0-9._/-]+\.md$/.test(design)
|
|
91
|
+
|| design.split('/').some((segment) => !segment || segment === '.' || segment === '..')) {
|
|
92
|
+
errors.push('Design trailer must reference a versioned design path');
|
|
93
|
+
}
|
|
94
|
+
if (/^ADR:/m.test(source)) errors.push('native authority cannot also claim an ADR');
|
|
95
|
+
}
|
|
96
|
+
if (!/^Scope:\n(?:- .*\n)*- staged-diff-sha256: [a-f0-9]{64}(?:\n|$)/m.test(`${source}\n`)) {
|
|
97
|
+
errors.push('Scope must contain the staged diff SHA-256');
|
|
98
|
+
}
|
|
99
|
+
if (/^Co-Authored-By:/mi.test(source)) errors.push('Co-Authored-By is omitted unless resolved from a trusted identity registry');
|
|
100
|
+
return { ok: errors.length === 0, governed: true, errors };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function messageScope(message) {
|
|
104
|
+
const source = String(message ?? '').replace(/\r\n?/g, '\n');
|
|
105
|
+
const items = sectionItems(source, 'Scope').map((line) => line.slice(2));
|
|
106
|
+
const hashItem = items.find((item) => item.startsWith('staged-diff-sha256: ')) || '';
|
|
107
|
+
return {
|
|
108
|
+
sha256: hashItem.slice('staged-diff-sha256: '.length),
|
|
109
|
+
files: items.filter((item) => !item.startsWith('staged-diff-sha256: ')),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function messageEvidence(message) {
|
|
114
|
+
return sectionItems(String(message ?? '').replace(/\r\n?/g, '\n'), 'Evidence').map((line) => {
|
|
115
|
+
const match = line.match(/^- \[(fresh|verified)\] (adr|design|evidence|receipt|spec|task|verdict): (\S.*)$/);
|
|
116
|
+
return match ? { status: match[1], kind: match[2], ref: match[3] } : null;
|
|
117
|
+
}).filter(Boolean);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function messageTasks(message) {
|
|
121
|
+
return sectionItems(String(message ?? '').replace(/\r\n?/g, '\n'), 'Tasks').map((line) => line.slice(2));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function messageTests(message) {
|
|
125
|
+
return sectionItems(String(message ?? '').replace(/\r\n?/g, '\n'), 'Tests').map((line) => line.slice(2));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function nativeDesignReference(message) {
|
|
129
|
+
const source = String(message ?? '').replace(/\r\n?/g, '\n');
|
|
130
|
+
if (!/^Authority:\s*native-no-causal-change$/m.test(source)) return '';
|
|
131
|
+
return source.match(/^Design:\s*(\S+)$/m)?.[1] || '';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function assertValidCommitMessage(message) {
|
|
135
|
+
const result = validateCommitMessage(message);
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
const error = new Error(result.errors.join('\n'));
|
|
138
|
+
error.name = 'CommitMessageValidationError';
|
|
139
|
+
error.code = 'WENDKEEP_COMMIT_MESSAGE_INVALID';
|
|
140
|
+
error.errors = result.errors;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
return result;
|
|
144
|
+
}
|