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 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.0.2";
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 r = validate(message);
30
+ const report = validate(message);
26
31
  const text = [
27
- r.valid ? "✅ Compliant with the Udacity style guide." : "❌ Not compliant.",
28
- ...r.problems.map((p) => ` • ${p}`),
29
- ...r.warnings.map((w) => ` ⚠ ${w}`),
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: r.valid,
33
- problems: r.problems,
34
- warnings: r.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((err) => {
79
- console.error(err);
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(([t, d]) => `- **${t}**: ${d}`)
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(s) {
91
- return [...s].length;
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(s) {
95
- const chars = [...s];
133
+ function capitalizeFirst(text) {
134
+ const chars = [...text];
96
135
  if (chars.length === 0)
97
- return s;
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 m = subject.match(/^(\w+): (.*)$/);
152
- if (!m) {
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, rest] = m;
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 (!rest) {
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(rest)) {
165
- problems.push(`Subject should begin with a capital letter (got "${[...rest][0]}").`);
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 = rest.split(/\s+/)[0];
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 i = 2; i < lines.length; i++) {
184
- const line = lines[i];
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 ${i + 1} is ${width(line)} chars; wrap body/footer at ${BODY_WRAP}.`);
225
+ problems.push(`Line ${lineIndex + 1} is ${width(line)} chars; wrap body/footer at ${BODY_WRAP}.`);
187
226
  }
188
- const fm = line.match(/^([A-Za-z][A-Za-z ]*?):\s*(.*)$/);
189
- if (fm && FOOTER_KEYS.some((k) => k.toLowerCase() === fm[1].toLowerCase())) {
190
- if (!/#\d+/.test(fm[2])) {
191
- warnings.push(`Footer "${fm[1]}" should reference an issue, e.g. "${fm[1]}: #123".`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-udacity-commit",
3
- "version": "1.0.2",
3
+ "version": "1.2.1",
4
4
  "description": "MCP server that validates and formats git commit messages per the Udacity Git Commit Message Style Guide.",
5
5
  "type": "module",
6
6
  "license": "MIT",