mcp-udacity-commit 1.0.2 → 1.2.1
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 +13 -0
- package/build/index.js +41 -11
- package/build/lint.js +127 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -48,8 +48,10 @@ claude mcp add udacity-commit -- node "$(pwd)/build/index.js"
|
|
|
48
48
|
| Primitive | Name | Purpose |
|
|
49
49
|
| --- | --- | --- |
|
|
50
50
|
| Resource | `udacity://commit-styleguide` | The style-guide rules, as markdown |
|
|
51
|
+
| Resource | `udacity://branch-naming` | The companion `type/kebab-case` branch-naming rules, as markdown |
|
|
51
52
|
| Tool | `validate_commit_message` | Checks a message against every rule (type, ≤50-char subject, capitalization, no trailing period, blank line, ≤72-char body wrap) |
|
|
52
53
|
| Tool | `format_commit_message` | Builds a compliant message from `type` + `subject` + optional `body`/`footer` |
|
|
54
|
+
| Tool | `validate_branch_name` | Checks a branch name against the companion `type/kebab-case` convention (e.g. `feat/add-dark-mode`); `release/*` is a typed branch with a version-style description (`release/1.2.0`), and base branches like `main` are exempt |
|
|
53
55
|
|
|
54
56
|
## Example
|
|
55
57
|
|
|
@@ -78,6 +80,17 @@ Resolves: #142
|
|
|
78
80
|
• Subject must not end with a period.
|
|
79
81
|
```
|
|
80
82
|
|
|
83
|
+
`validate_branch_name` enforces the companion `type/kebab-case` convention:
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
"feat/add-dark-mode" → ✅ Compliant branch name.
|
|
87
|
+
"release/1.2.0" → ✅ Compliant branch name.
|
|
88
|
+
"Feature/Add_Dark_Mode" → ❌ Not compliant.
|
|
89
|
+
• Unknown type "Feature". Use one of: feat, fix, docs, style, refactor, test, chore, release.
|
|
90
|
+
• Description must be lowercase kebab-case. Got: "Add_Dark_Mode".
|
|
91
|
+
"main" → ✅ (base branch — feature-branch rules don't apply)
|
|
92
|
+
```
|
|
93
|
+
|
|
81
94
|
## Develop
|
|
82
95
|
|
|
83
96
|
```bash
|
package/build/index.js
CHANGED
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { STYLE_GUIDE, validate, formatMessage } from "./lint.js";
|
|
6
|
-
const VERSION = "1.
|
|
5
|
+
import { STYLE_GUIDE, BRANCH_GUIDE, validate, formatMessage, validateBranch } from "./lint.js";
|
|
6
|
+
const VERSION = "1.2.1";
|
|
7
7
|
export function createServer() {
|
|
8
8
|
const server = new McpServer({ name: "udacity-commit", version: VERSION });
|
|
9
9
|
server.registerResource("styleguide", "udacity://commit-styleguide", {
|
|
@@ -11,6 +11,11 @@ export function createServer() {
|
|
|
11
11
|
description: "The commit-message rules (types, subject, body, footer).",
|
|
12
12
|
mimeType: "text/markdown",
|
|
13
13
|
}, async (uri) => ({ contents: [{ uri: uri.href, text: STYLE_GUIDE }] }));
|
|
14
|
+
server.registerResource("branch-naming", "udacity://branch-naming", {
|
|
15
|
+
title: "Branch Naming (companion convention)",
|
|
16
|
+
description: "type/kebab-case branch-naming rules that pair with the commit style.",
|
|
17
|
+
mimeType: "text/markdown",
|
|
18
|
+
}, async (uri) => ({ contents: [{ uri: uri.href, text: BRANCH_GUIDE }] }));
|
|
14
19
|
server.registerTool("validate_commit_message", {
|
|
15
20
|
title: "Validate a commit message",
|
|
16
21
|
description: "Check a commit message against the Udacity Git Commit Message Style Guide. " +
|
|
@@ -22,16 +27,16 @@ export function createServer() {
|
|
|
22
27
|
warnings: z.array(z.string()),
|
|
23
28
|
},
|
|
24
29
|
}, async ({ message }) => {
|
|
25
|
-
const
|
|
30
|
+
const report = validate(message);
|
|
26
31
|
const text = [
|
|
27
|
-
|
|
28
|
-
...
|
|
29
|
-
...
|
|
32
|
+
report.valid ? "✅ Compliant with the Udacity style guide." : "❌ Not compliant.",
|
|
33
|
+
...report.problems.map((problem) => ` • ${problem}`),
|
|
34
|
+
...report.warnings.map((warning) => ` ⚠ ${warning}`),
|
|
30
35
|
].join("\n");
|
|
31
36
|
const structuredContent = {
|
|
32
|
-
valid:
|
|
33
|
-
problems:
|
|
34
|
-
warnings:
|
|
37
|
+
valid: report.valid,
|
|
38
|
+
problems: report.problems,
|
|
39
|
+
warnings: report.warnings,
|
|
35
40
|
};
|
|
36
41
|
return { content: [{ type: "text", text }], structuredContent };
|
|
37
42
|
});
|
|
@@ -69,13 +74,38 @@ export function createServer() {
|
|
|
69
74
|
structuredContent,
|
|
70
75
|
};
|
|
71
76
|
});
|
|
77
|
+
server.registerTool("validate_branch_name", {
|
|
78
|
+
title: "Validate a git branch name",
|
|
79
|
+
description: "Check a git branch name against the companion type/kebab-case convention " +
|
|
80
|
+
'(e.g. "feat/add-dark-mode"). Returns whether it is compliant plus any problems ' +
|
|
81
|
+
"(violations) and warnings (hints). Base branches like main/master are exempt.",
|
|
82
|
+
inputSchema: { name: z.string().describe('The branch name to check, e.g. "feat/add-dark-mode"') },
|
|
83
|
+
outputSchema: {
|
|
84
|
+
valid: z.boolean(),
|
|
85
|
+
problems: z.array(z.string()),
|
|
86
|
+
warnings: z.array(z.string()),
|
|
87
|
+
},
|
|
88
|
+
}, async ({ name }) => {
|
|
89
|
+
const report = validateBranch(name);
|
|
90
|
+
const text = [
|
|
91
|
+
report.valid ? "✅ Compliant branch name." : "❌ Not compliant.",
|
|
92
|
+
...report.problems.map((problem) => ` • ${problem}`),
|
|
93
|
+
...report.warnings.map((warning) => ` ⚠ ${warning}`),
|
|
94
|
+
].join("\n");
|
|
95
|
+
const structuredContent = {
|
|
96
|
+
valid: report.valid,
|
|
97
|
+
problems: report.problems,
|
|
98
|
+
warnings: report.warnings,
|
|
99
|
+
};
|
|
100
|
+
return { content: [{ type: "text", text }], structuredContent };
|
|
101
|
+
});
|
|
72
102
|
return server;
|
|
73
103
|
}
|
|
74
104
|
async function main() {
|
|
75
105
|
const server = createServer();
|
|
76
106
|
await server.connect(new StdioServerTransport());
|
|
77
107
|
}
|
|
78
|
-
main().catch((
|
|
79
|
-
console.error(
|
|
108
|
+
main().catch((error) => {
|
|
109
|
+
console.error(error);
|
|
80
110
|
process.exit(1);
|
|
81
111
|
});
|
package/build/lint.js
CHANGED
|
@@ -10,6 +10,13 @@
|
|
|
10
10
|
*/
|
|
11
11
|
export const SUBJECT_MAX = 50;
|
|
12
12
|
export const BODY_WRAP = 72;
|
|
13
|
+
export const BRANCH_MAX = 50;
|
|
14
|
+
/**
|
|
15
|
+
* Long-lived / protected branches that are exempt from the feature-branch
|
|
16
|
+
* `type/description` rule. `release/*` is NOT here — it is validated as a
|
|
17
|
+
* typed branch with a version-style description (e.g. `release/1.2.0`).
|
|
18
|
+
*/
|
|
19
|
+
export const BASE_BRANCHES = new Set(["main", "master", "dev", "develop", "trunk"]);
|
|
13
20
|
export const TYPES = {
|
|
14
21
|
feat: "A new feature",
|
|
15
22
|
fix: "A bug fix",
|
|
@@ -19,6 +26,12 @@ export const TYPES = {
|
|
|
19
26
|
test: "Adding tests, refactoring tests; no production code change",
|
|
20
27
|
chore: "Updating build tasks, package configs, etc; no production code change",
|
|
21
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* Types allowed as a branch prefix: the commit types plus `release`, which is
|
|
31
|
+
* a branch-only type (release branches are named, not committed). `release`
|
|
32
|
+
* takes a version-style description; every other type takes kebab-case.
|
|
33
|
+
*/
|
|
34
|
+
export const BRANCH_TYPES = [...Object.keys(TYPES), "release"];
|
|
22
35
|
/** Footer keywords recognized for issue-reference validation. */
|
|
23
36
|
export const FOOTER_KEYS = ["Resolves", "Closes", "Fixes", "Fix", "See also", "Refs", "Ref"];
|
|
24
37
|
/**
|
|
@@ -65,7 +78,7 @@ A commit message has three parts separated by blank lines: **subject**, optional
|
|
|
65
78
|
|
|
66
79
|
## Types
|
|
67
80
|
${Object.entries(TYPES)
|
|
68
|
-
.map(([
|
|
81
|
+
.map(([typeName, typeDescription]) => `- **${typeName}**: ${typeDescription}`)
|
|
69
82
|
.join("\n")}
|
|
70
83
|
|
|
71
84
|
## Subject
|
|
@@ -86,15 +99,41 @@ ${Object.entries(TYPES)
|
|
|
86
99
|
|
|
87
100
|
_Lengths are counted in Unicode code points._
|
|
88
101
|
`;
|
|
102
|
+
export const BRANCH_GUIDE = `# Branch naming (companion convention)
|
|
103
|
+
|
|
104
|
+
Not part of the official Udacity *commit-message* guide, but a natural
|
|
105
|
+
companion: name feature branches after the change they carry, reusing the
|
|
106
|
+
same commit \`type\` set.
|
|
107
|
+
|
|
108
|
+
type/kebab-case-description
|
|
109
|
+
|
|
110
|
+
## Rules
|
|
111
|
+
- \`type/\` prefix — one of: ${BRANCH_TYPES.join(", ")}
|
|
112
|
+
- A single \`/\` separates the type from the description
|
|
113
|
+
- Description is **kebab-case**: lowercase letters and digits joined by single
|
|
114
|
+
hyphens (\`feat/add-dark-mode\`, not \`feat/Add_Dark_Mode\`)
|
|
115
|
+
- \`release/\` branches take a **version-style** description instead: lowercase
|
|
116
|
+
words/digits joined by dots or hyphens (\`release/1.2.0\`, \`release/2024-q1\`)
|
|
117
|
+
- No spaces, underscores, uppercase, or leading/trailing/double hyphens
|
|
118
|
+
- Keep it short — ${BRANCH_MAX} characters or fewer (a hint, not a hard limit)
|
|
119
|
+
|
|
120
|
+
## Examples
|
|
121
|
+
- \`feat/add-dark-mode\`
|
|
122
|
+
- \`fix/duplicate-auth-refresh\`
|
|
123
|
+
- \`chore/bump-deps\`
|
|
124
|
+
- \`release/1.2.0\`
|
|
125
|
+
|
|
126
|
+
Base branches (${[...BASE_BRANCHES].join(", ")}) are exempt.
|
|
127
|
+
`;
|
|
89
128
|
/** Length in Unicode code points (not UTF-16 code units). */
|
|
90
|
-
export function width(
|
|
91
|
-
return [...
|
|
129
|
+
export function width(text) {
|
|
130
|
+
return [...text].length;
|
|
92
131
|
}
|
|
93
132
|
/** Uppercase the first code point of a string (astral-safe). */
|
|
94
|
-
function capitalizeFirst(
|
|
95
|
-
const chars = [...
|
|
133
|
+
function capitalizeFirst(text) {
|
|
134
|
+
const chars = [...text];
|
|
96
135
|
if (chars.length === 0)
|
|
97
|
-
return
|
|
136
|
+
return text;
|
|
98
137
|
return chars[0].toUpperCase() + chars.slice(1).join("");
|
|
99
138
|
}
|
|
100
139
|
/**
|
|
@@ -148,23 +187,23 @@ export function validate(message) {
|
|
|
148
187
|
lines.pop();
|
|
149
188
|
const rawSubject = lines[0] ?? "";
|
|
150
189
|
const subject = rawSubject.replace(/\s+$/, "");
|
|
151
|
-
const
|
|
152
|
-
if (!
|
|
190
|
+
const subjectMatch = subject.match(/^(\w+): (.*)$/);
|
|
191
|
+
if (!subjectMatch) {
|
|
153
192
|
problems.push(`Subject must follow "type: Subject". Got: "${subject}".`);
|
|
154
193
|
}
|
|
155
194
|
else {
|
|
156
|
-
const [, type,
|
|
195
|
+
const [, type, subjectText] = subjectMatch;
|
|
157
196
|
if (!TYPES[type]) {
|
|
158
197
|
problems.push(`Unknown type "${type}". Use one of: ${Object.keys(TYPES).join(", ")}.`);
|
|
159
198
|
}
|
|
160
|
-
if (!
|
|
199
|
+
if (!subjectText) {
|
|
161
200
|
problems.push("Subject text is empty after the type.");
|
|
162
201
|
}
|
|
163
202
|
else {
|
|
164
|
-
if (/^\p{Ll}/u.test(
|
|
165
|
-
problems.push(`Subject should begin with a capital letter (got "${[...
|
|
203
|
+
if (/^\p{Ll}/u.test(subjectText)) {
|
|
204
|
+
problems.push(`Subject should begin with a capital letter (got "${[...subjectText][0]}").`);
|
|
166
205
|
}
|
|
167
|
-
const firstWord =
|
|
206
|
+
const firstWord = subjectText.split(/\s+/)[0];
|
|
168
207
|
if (NON_IMPERATIVE.has(firstWord.toLowerCase())) {
|
|
169
208
|
warnings.push(`"${firstWord}" looks past-tense/gerund — use the imperative mood ("Add", not "Added").`);
|
|
170
209
|
}
|
|
@@ -180,15 +219,15 @@ export function validate(message) {
|
|
|
180
219
|
if (lines.length > 1 && lines[1].trim() !== "") {
|
|
181
220
|
problems.push("Leave a blank line between the subject and the body.");
|
|
182
221
|
}
|
|
183
|
-
for (let
|
|
184
|
-
const line = lines[
|
|
222
|
+
for (let lineIndex = 2; lineIndex < lines.length; lineIndex++) {
|
|
223
|
+
const line = lines[lineIndex];
|
|
185
224
|
if (width(line) > BODY_WRAP) {
|
|
186
|
-
problems.push(`Line ${
|
|
225
|
+
problems.push(`Line ${lineIndex + 1} is ${width(line)} chars; wrap body/footer at ${BODY_WRAP}.`);
|
|
187
226
|
}
|
|
188
|
-
const
|
|
189
|
-
if (
|
|
190
|
-
if (!/#\d+/.test(
|
|
191
|
-
warnings.push(`Footer "${
|
|
227
|
+
const footerMatch = line.match(/^([A-Za-z][A-Za-z ]*?):\s*(.*)$/);
|
|
228
|
+
if (footerMatch && FOOTER_KEYS.some((footerKey) => footerKey.toLowerCase() === footerMatch[1].toLowerCase())) {
|
|
229
|
+
if (!/#\d+/.test(footerMatch[2])) {
|
|
230
|
+
warnings.push(`Footer "${footerMatch[1]}" should reference an issue, e.g. "${footerMatch[1]}: #123".`);
|
|
192
231
|
}
|
|
193
232
|
}
|
|
194
233
|
}
|
|
@@ -209,3 +248,71 @@ export function formatMessage(input) {
|
|
|
209
248
|
const message = parts.join("\n");
|
|
210
249
|
return { message, report: validate(message) };
|
|
211
250
|
}
|
|
251
|
+
/** Kebab-case: lowercase words (letters/digits) joined by single hyphens. */
|
|
252
|
+
const KEBAB = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
253
|
+
/**
|
|
254
|
+
* Version-style (for `release/` branches): lowercase words/digits joined by
|
|
255
|
+
* dots or hyphens, so `1.2.0`, `2.0.0-rc1`, and `2024-q1` all pass.
|
|
256
|
+
*/
|
|
257
|
+
const RELEASE_DESC = /^[a-z0-9]+([.-][a-z0-9]+)*$/;
|
|
258
|
+
/**
|
|
259
|
+
* Validate a git branch name against the companion `type/kebab-case`
|
|
260
|
+
* convention. Base/long-lived branches (main, master, dev, develop, trunk)
|
|
261
|
+
* are accepted as-is with a note. `release/` is a typed branch and takes a
|
|
262
|
+
* version-style description (e.g. `release/1.2.0`).
|
|
263
|
+
*/
|
|
264
|
+
export function validateBranch(name) {
|
|
265
|
+
const problems = [];
|
|
266
|
+
const warnings = [];
|
|
267
|
+
const branch = name.trim();
|
|
268
|
+
if (!branch) {
|
|
269
|
+
return { valid: false, problems: ["Branch name is empty."], warnings };
|
|
270
|
+
}
|
|
271
|
+
if (branch !== name) {
|
|
272
|
+
problems.push("Branch name has leading/trailing whitespace.");
|
|
273
|
+
}
|
|
274
|
+
// Base / long-lived branches are exempt from the feature-branch rule.
|
|
275
|
+
// (`release/*` is NOT exempt — it is validated as a typed branch below.)
|
|
276
|
+
if (BASE_BRANCHES.has(branch)) {
|
|
277
|
+
warnings.push(`"${branch}" is a base branch — feature-branch naming rules don't apply.`);
|
|
278
|
+
return { valid: problems.length === 0, problems, warnings };
|
|
279
|
+
}
|
|
280
|
+
const slashIndex = branch.indexOf("/");
|
|
281
|
+
if (slashIndex === -1) {
|
|
282
|
+
problems.push(`Branch must follow "type/description". Got: "${branch}".`);
|
|
283
|
+
return { valid: false, problems, warnings };
|
|
284
|
+
}
|
|
285
|
+
const type = branch.slice(0, slashIndex);
|
|
286
|
+
const description = branch.slice(slashIndex + 1);
|
|
287
|
+
if (!BRANCH_TYPES.includes(type)) {
|
|
288
|
+
problems.push(`Unknown type "${type}". Use one of: ${BRANCH_TYPES.join(", ")}.`);
|
|
289
|
+
}
|
|
290
|
+
// `release/` takes a version-style description; every other type is kebab-case.
|
|
291
|
+
const isRelease = type === "release";
|
|
292
|
+
const pattern = isRelease ? RELEASE_DESC : KEBAB;
|
|
293
|
+
if (!description) {
|
|
294
|
+
problems.push("Description after the type is empty.");
|
|
295
|
+
}
|
|
296
|
+
else if (description.includes("/")) {
|
|
297
|
+
problems.push(`Use a single "/" after the type; the description must not contain "/". Got: "${description}".`);
|
|
298
|
+
}
|
|
299
|
+
else if (!pattern.test(description)) {
|
|
300
|
+
// Give the most specific reason we can, else a general shape message.
|
|
301
|
+
if (/[A-Z]/.test(description)) {
|
|
302
|
+
problems.push(`Description must be lowercase ${isRelease ? "version-style (e.g. 1.2.0)" : "kebab-case"}. Got: "${description}".`);
|
|
303
|
+
}
|
|
304
|
+
else if (/[_ ]/.test(description)) {
|
|
305
|
+
problems.push("Use hyphens, not spaces or underscores, to separate words.");
|
|
306
|
+
}
|
|
307
|
+
else if (isRelease) {
|
|
308
|
+
problems.push(`Release description must be version-style: lowercase words/digits joined by dots or hyphens (e.g. "1.2.0", "2024-q1"). Got: "${description}".`);
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
problems.push(`Description must be kebab-case: lowercase words joined by single hyphens. Got: "${description}".`);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
if (width(branch) > BRANCH_MAX) {
|
|
315
|
+
warnings.push(`Branch name is ${width(branch)} chars; keep it ${BRANCH_MAX} or fewer.`);
|
|
316
|
+
}
|
|
317
|
+
return { valid: problems.length === 0, problems, warnings };
|
|
318
|
+
}
|
package/package.json
CHANGED