-
Notifications
You must be signed in to change notification settings - Fork 64
Expand file tree
/
Copy pathcommit-format.mts
More file actions
146 lines (138 loc) · 5.58 KB
/
Copy pathcommit-format.mts
File metadata and controls
146 lines (138 loc) · 5.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
// Conventional Commits 1.0 header validation, shared by both enforcement
// surfaces:
// - the commit-message-format-guard PreToolUse hook (.claude/hooks/), which
// inspects `git commit -m` tool calls at Claude Bash time, and
// - the commit-msg git-stage backstop (.git-hooks/), which inspects the
// subject regardless of how the commit was made (subprocess / worktree /
// CI / test harness) — the layer the tool-call guard never sees.
// Canonical home: .git-hooks/_shared/; the .claude/hooks/ guard imports this
// cross-tree (the shared thing is this code, per the fleet "DRY across the two
// hook trees" rule). This module is side-effect-free — it reads no stdin, spawns
// nothing, and calls process.exit nowhere — so the git-stage hook can import it
// without triggering the guard's stdin-reading `main()`.
//
// Spec: https://www.conventionalcommits.org/en/v1.0.0/
// Real Claude-specific integration: "the Claude tool-call guard" names the
// .claude/hooks/ PreToolUse guard, which only exists for Claude Code, not
// generic agent guidance.
// oxlint-disable-next-line socket/no-agent-brand-assumption -- real
export const ALLOWED_TYPES = [
'build',
'chore',
'ci',
'docs',
'feat',
'fix',
'perf',
'refactor',
'revert',
'style',
'test',
] as const
export const ALLOWED_TYPE_SET: ReadonlySet<string> = new Set(ALLOWED_TYPES)
// Header form: <type>[(scope)][!]: <description>
// - type: lowercase letters
// - optional (scope) in parens
// - optional `!` breaking-change marker
// - `: ` separator (colon + space)
// - non-empty description
export const HEADER_RE = /^([a-z]+)(\([^)]+\))?(!)?: (.+)$/
// Subjects git itself writes for non-`-m` commits. These are never
// Conventional Commits and must not be format-blocked at the git-stage twin
// (the Claude tool-call guard never sees them — they carry no inline -m
// message). `git merge` → `Merge branch …`/`Merge pull request …`;
// `git revert` → `Revert "…"`; autosquash → `fixup! …`/`squash! …`/`amend! …`.
const AUTO_SUBJECT_RE = /^(?:(?:amend|fixup|squash)! |Merge\b|Revert\b)/
/**
* True when the subject is one git auto-generates for a merge / revert /
* autosquash commit. Those are exempt from Conventional Commits enforcement.
*/
export function isAutoGeneratedSubject(subject: string): boolean {
return AUTO_SUBJECT_RE.test(subject.trim())
}
/**
* Result of validating a single message header.
*
* - Kind: 'ok' — header passes
* - Kind: 'no-type' — first line has no `<type>: ` prefix at all
* - Kind: 'bad-type' — first line has a `<word>: ` prefix but word isn't
* lowercase / not in the type set
* - Kind: 'uppercase-type' — type letters are present but include uppercase
* - Kind: 'empty-description' — header has `<type>: ` but description is
* empty/whitespace
*/
export type HeaderCheck =
| { kind: 'ok' }
| { kind: 'no-type'; line: string }
| { kind: 'bad-type'; line: string; type: string }
| { kind: 'uppercase-type'; line: string; type: string }
| { kind: 'empty-description'; line: string; type: string }
export function validateHeader(line: string): HeaderCheck {
// Quick pre-check: does the line look like a Conventional header at all?
// We accept any leading word-token before `: ` for diagnosis even if the
// case is wrong; the strict HEADER_RE then refines.
const looseMatch = /^([A-Za-z]+)(\([^)]+\))?(!)?:\s*(.*)$/.exec(line)
if (!looseMatch) {
return { kind: 'no-type', line }
}
const type = looseMatch[1]!
const desc = looseMatch[4]!
// Type must be all-lowercase.
if (type !== type.toLowerCase()) {
return { kind: 'uppercase-type', line, type }
}
// Type must be in the allowed set.
if (!ALLOWED_TYPE_SET.has(type)) {
return { kind: 'bad-type', line, type }
}
// Strict format check (catches "feat:description" without space, etc.).
const strictMatch = HEADER_RE.exec(line)
if (!strictMatch) {
// The loose pattern matched but the strict one didn't — that means
// either the `: ` separator is missing the space, or the description
// is empty.
if (!desc.trim()) {
return { kind: 'empty-description', line, type }
}
return { kind: 'no-type', line }
}
const description = strictMatch[4]!
if (!description.trim()) {
return { kind: 'empty-description', line, type }
}
return { kind: 'ok' }
}
/**
* Build a context-appropriate suggestion for an invalid header. We look at the
* user's input and propose ONE example of a valid replacement based on what
* they typed.
*/
export function suggestReplacement(check: HeaderCheck): string {
if (check.kind === 'ok') {
return ''
}
const text = check.line.trim()
// Lowercase variant: try to recover the intent.
if (check.kind === 'uppercase-type') {
return `${check.type.toLowerCase()}: ${text.slice(text.indexOf(':') + 1).trim()}`
}
if (check.kind === 'bad-type') {
// Suggest 'feat' as a generic recoverable type, keep the rest.
const rest =
text.slice(text.indexOf(':') + 1).trim() || 'describe the change'
return `feat: ${rest}`
}
if (check.kind === 'empty-description') {
return `${check.type}: describe the change`
}
// no-type: try to fold whatever the user typed into a feat header.
const words = text.split(/\s+/).filter(Boolean)
const first = (words[0] ?? '').toLowerCase()
// If the first word looks like a noun (e.g. "parser", "extension"), use it
// as a scope and keep the rest as the description.
if (words.length >= 2 && /^[a-z][a-z0-9-]*$/.test(first)) {
const rest = words.slice(1).join(' ')
return `feat(${first}): ${rest}`
}
return `feat: ${text || 'describe the change'}`
}