mcp-udacity-commit 1.0.1 → 1.2.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/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,158 +2,110 @@
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
- const SUBJECT_MAX = 50;
6
- const BODY_WRAP = 72;
7
- const TYPES = {
8
- feat: "A new feature",
9
- fix: "A bug fix",
10
- docs: "Changes to documentation",
11
- style: "Formatting, missing semicolons, etc; no code change",
12
- refactor: "Refactoring production code",
13
- test: "Adding tests, refactoring tests; no production code change",
14
- chore: "Updating build tasks, package configs, etc; no production code change",
15
- };
16
- const STYLE_GUIDE = `# Udacity Git Commit Message Style Guide
17
-
18
- A commit message has three parts separated by blank lines: **subject**, optional **body**, optional **footer**.
19
-
20
- type: Subject
21
-
22
- Body the what and why, not the how.
23
-
24
- Resolves: #123
25
- See also: #456, #789
26
-
27
- ## Types
28
- ${Object.entries(TYPES)
29
- .map(([t, d]) => `- **${t}**: ${d}`)
30
- .join("\n")}
31
-
32
- ## Subject
33
- - \`type: Subject\` format
34
- - No more than ${SUBJECT_MAX} characters
35
- - Begins with a capital letter
36
- - Imperative mood ("Add", not "Added")
37
- - No trailing period
38
- - Blank line separates it from the body
39
-
40
- ## Body (optional)
41
- - Only when the commit needs explanation
42
- - Explains the **what** and **why**, not the how
43
- - Wrap each line at ${BODY_WRAP} characters
44
-
45
- ## Footer (optional)
46
- - References issue-tracker IDs: \`Resolves: #123\`, \`See also: #456, #789\`
47
- `;
48
- /** Greedy word-wrap that preserves existing paragraph breaks. */
49
- function wrap(text, width) {
50
- return text
51
- .split("\n")
52
- .map((para) => {
53
- const words = para.split(/\s+/).filter(Boolean);
54
- const lines = [];
55
- let line = "";
56
- for (const w of words) {
57
- if (!line)
58
- line = w;
59
- else if ((line + " " + w).length <= width)
60
- line += " " + w;
61
- else {
62
- lines.push(line);
63
- line = w;
64
- }
65
- }
66
- if (line)
67
- lines.push(line);
68
- return lines.join("\n");
69
- })
70
- .join("\n");
5
+ import { STYLE_GUIDE, BRANCH_GUIDE, validate, formatMessage, validateBranch } from "./lint.js";
6
+ const VERSION = "1.2.0";
7
+ export function createServer() {
8
+ const server = new McpServer({ name: "udacity-commit", version: VERSION });
9
+ server.registerResource("styleguide", "udacity://commit-styleguide", {
10
+ title: "Udacity Git Commit Style Guide",
11
+ description: "The commit-message rules (types, subject, body, footer).",
12
+ mimeType: "text/markdown",
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 }] }));
19
+ server.registerTool("validate_commit_message", {
20
+ title: "Validate a commit message",
21
+ description: "Check a commit message against the Udacity Git Commit Message Style Guide. " +
22
+ "Returns whether it is compliant plus any problems (violations) and warnings (hints).",
23
+ inputSchema: { message: z.string().describe("The full commit message to check") },
24
+ outputSchema: {
25
+ valid: z.boolean(),
26
+ problems: z.array(z.string()),
27
+ warnings: z.array(z.string()),
28
+ },
29
+ }, async ({ message }) => {
30
+ const r = validate(message);
31
+ const text = [
32
+ r.valid ? "✅ Compliant with the Udacity style guide." : "❌ Not compliant.",
33
+ ...r.problems.map((p) => ` • ${p}`),
34
+ ...r.warnings.map((w) => ` ⚠ ${w}`),
35
+ ].join("\n");
36
+ const structuredContent = {
37
+ valid: r.valid,
38
+ problems: r.problems,
39
+ warnings: r.warnings,
40
+ };
41
+ return { content: [{ type: "text", text }], structuredContent };
42
+ });
43
+ server.registerTool("format_commit_message", {
44
+ title: "Format a Udacity-style commit message",
45
+ description: "Compose a compliant commit message from its parts. The subject is capitalized, " +
46
+ "a trailing period is removed, and the body is wrapped at 72 characters.",
47
+ inputSchema: {
48
+ type: z.enum(["feat", "fix", "docs", "style", "refactor", "test", "chore"]),
49
+ subject: z.string().describe("Imperative subject; auto-capitalized, trailing period removed"),
50
+ body: z.string().optional().describe("What & why; auto-wrapped at 72 chars"),
51
+ footer: z.string().optional().describe('Issue refs, e.g. "Resolves: #123"'),
52
+ },
53
+ outputSchema: {
54
+ message: z.string(),
55
+ valid: z.boolean(),
56
+ problems: z.array(z.string()),
57
+ warnings: z.array(z.string()),
58
+ },
59
+ }, async (input) => {
60
+ const { message, report } = formatMessage(input);
61
+ const note = !report.valid
62
+ ? "❌ " + [...report.problems, ...report.warnings].join("; ")
63
+ : report.warnings.length
64
+ ? "✅ compliant (hint: " + report.warnings.join("; ") + ")"
65
+ : "✅ compliant";
66
+ const structuredContent = {
67
+ message,
68
+ valid: report.valid,
69
+ problems: report.problems,
70
+ warnings: report.warnings,
71
+ };
72
+ return {
73
+ content: [{ type: "text", text: `${message}\n\n--- ${note}` }],
74
+ structuredContent,
75
+ };
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 r = validateBranch(name);
90
+ const text = [
91
+ r.valid ? "✅ Compliant branch name." : "❌ Not compliant.",
92
+ ...r.problems.map((p) => ` • ${p}`),
93
+ ...r.warnings.map((w) => ` ⚠ ${w}`),
94
+ ].join("\n");
95
+ const structuredContent = {
96
+ valid: r.valid,
97
+ problems: r.problems,
98
+ warnings: r.warnings,
99
+ };
100
+ return { content: [{ type: "text", text }], structuredContent };
101
+ });
102
+ return server;
71
103
  }
72
- function validate(message) {
73
- const problems = [];
74
- const warnings = [];
75
- const lines = message.replace(/\s+$/, "").split("\n");
76
- const subject = lines[0] ?? "";
77
- const m = subject.match(/^(\w+): (.*)$/);
78
- if (!m) {
79
- problems.push(`Subject must follow "type: Subject". Got: "${subject}".`);
80
- }
81
- else {
82
- const [, type, rest] = m;
83
- if (!TYPES[type]) {
84
- problems.push(`Unknown type "${type}". Use one of: ${Object.keys(TYPES).join(", ")}.`);
85
- }
86
- if (!rest) {
87
- problems.push("Subject text is empty after the type.");
88
- }
89
- else {
90
- if (!/^[A-Z]/.test(rest)) {
91
- problems.push(`Subject should begin with a capital letter (got "${rest[0]}").`);
92
- }
93
- const first = rest.split(/\s+/)[0];
94
- if (/(ed|ing)$/i.test(first)) {
95
- warnings.push(`"${first}" looks past-tense/gerund — use imperative mood ("Add", not "Added").`);
96
- }
97
- }
98
- }
99
- if (/\.$/.test(subject))
100
- problems.push("Subject must not end with a period.");
101
- if (subject.length > SUBJECT_MAX) {
102
- problems.push(`Subject is ${subject.length} chars; max is ${SUBJECT_MAX}.`);
103
- }
104
- if (lines.length > 1 && lines[1].trim() !== "") {
105
- problems.push("Leave a blank line between the subject and the body.");
106
- }
107
- for (let i = 2; i < lines.length; i++) {
108
- if (lines[i].length > BODY_WRAP) {
109
- problems.push(`Line ${i + 1} is ${lines[i].length} chars; wrap body/footer at ${BODY_WRAP}.`);
110
- }
111
- }
112
- return { valid: problems.length === 0, problems, warnings };
104
+ async function main() {
105
+ const server = createServer();
106
+ await server.connect(new StdioServerTransport());
113
107
  }
114
- const server = new McpServer({ name: "udacity-commit", version: "1.0.1" });
115
- server.registerResource("styleguide", "udacity://commit-styleguide", {
116
- title: "Udacity Git Commit Style Guide",
117
- description: "The commit-message rules (types, subject, body, footer).",
118
- mimeType: "text/markdown",
119
- }, async (uri) => ({ contents: [{ uri: uri.href, text: STYLE_GUIDE }] }));
120
- server.registerTool("validate_commit_message", {
121
- title: "Validate a commit message",
122
- description: "Check a commit message against the Udacity Git Commit Message Style Guide.",
123
- inputSchema: { message: z.string().describe("The full commit message to check") },
124
- }, async ({ message }) => {
125
- const r = validate(message);
126
- const out = [
127
- r.valid ? "✅ Compliant with the Udacity style guide." : "❌ Not compliant.",
128
- ...r.problems.map((p) => ` • ${p}`),
129
- ...r.warnings.map((w) => ` ⚠ ${w}`),
130
- ].join("\n");
131
- return { content: [{ type: "text", text: out }] };
108
+ main().catch((err) => {
109
+ console.error(err);
110
+ process.exit(1);
132
111
  });
133
- server.registerTool("format_commit_message", {
134
- title: "Format a Udacity-style commit message",
135
- description: "Compose a compliant commit message from its parts.",
136
- inputSchema: {
137
- type: z.enum(["feat", "fix", "docs", "style", "refactor", "test", "chore"]),
138
- subject: z
139
- .string()
140
- .describe("Imperative subject; auto-capitalized, trailing period removed"),
141
- body: z.string().optional().describe("What & why; auto-wrapped at 72 chars"),
142
- footer: z.string().optional().describe('Issue refs, e.g. "Resolves: #123"'),
143
- },
144
- }, async ({ type, subject, body, footer }) => {
145
- let s = subject.trim().replace(/\.+$/, "");
146
- s = s.charAt(0).toUpperCase() + s.slice(1);
147
- const parts = [`${type}: ${s}`];
148
- if (body?.trim())
149
- parts.push("", wrap(body.trim(), BODY_WRAP));
150
- if (footer?.trim())
151
- parts.push("", footer.trim());
152
- const msg = parts.join("\n");
153
- const v = validate(msg);
154
- const note = v.valid ? "✅ compliant" : "❌ " + v.problems.join("; ");
155
- return { content: [{ type: "text", text: `${msg}\n\n--- ${note}` }] };
156
- });
157
- const transport = new StdioServerTransport();
158
- await server.connect(transport);
159
- //# sourceMappingURL=index.js.map
package/build/lint.js ADDED
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Pure, side-effect-free linting logic for the Udacity Git Commit Message
3
+ * Style Guide. Kept separate from the MCP server (index.ts) so it can be
4
+ * unit-tested in isolation.
5
+ *
6
+ * Length semantics: all limits are measured in Unicode code points
7
+ * (`width()`), not UTF-16 code units, so emoji / CJK / combining marks are
8
+ * counted the way a human reads them. The 50-char subject limit applies to
9
+ * the WHOLE `type: Subject` line, including the `type: ` prefix.
10
+ */
11
+ export const SUBJECT_MAX = 50;
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"]);
20
+ export const TYPES = {
21
+ feat: "A new feature",
22
+ fix: "A bug fix",
23
+ docs: "Changes to documentation",
24
+ style: "Formatting, missing semicolons, etc; no code change",
25
+ refactor: "Refactoring production code",
26
+ test: "Adding tests, refactoring tests; no production code change",
27
+ chore: "Updating build tasks, package configs, etc; no production code change",
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"];
35
+ /** Footer keywords recognized for issue-reference validation. */
36
+ export const FOOTER_KEYS = ["Resolves", "Closes", "Fixes", "Fix", "See also", "Refs", "Ref"];
37
+ /**
38
+ * Common non-imperative first words (past tense / gerund). We match against
39
+ * this allow-known-bad list rather than a broad `/(ed|ing)$/` regex so that
40
+ * legitimate imperatives like "Bring", "Embed", "Ring" are never flagged.
41
+ * This favors precision (no false positives) over recall.
42
+ */
43
+ const NON_IMPERATIVE = new Set([
44
+ // past tense
45
+ "added", "fixed", "updated", "changed", "removed", "deleted", "created",
46
+ "refactored", "implemented", "improved", "renamed", "moved", "merged",
47
+ "reverted", "bumped", "cleaned", "corrected", "adjusted", "enabled",
48
+ "disabled", "introduced", "resolved", "replaced", "converted", "migrated",
49
+ "dropped", "extracted", "wrapped", "tweaked", "optimized", "simplified",
50
+ "formatted", "documented", "tested", "released", "handled", "allowed",
51
+ "prevented", "ensured", "avoided", "unified", "applied", "upgraded",
52
+ "downgraded", "patched", "hardened", "restructured", "reorganized",
53
+ "deprecated", "exposed", "integrated", "validated", "normalized", "cached",
54
+ "supported", "added", "wired", "hooked",
55
+ // gerund
56
+ "adding", "fixing", "updating", "changing", "removing", "deleting",
57
+ "creating", "refactoring", "implementing", "improving", "renaming",
58
+ "moving", "merging", "reverting", "bumping", "cleaning", "correcting",
59
+ "adjusting", "enabling", "disabling", "introducing", "resolving",
60
+ "replacing", "converting", "migrating", "dropping", "extracting",
61
+ "wrapping", "tweaking", "optimizing", "simplifying", "formatting",
62
+ "documenting", "testing", "releasing", "handling", "allowing",
63
+ "preventing", "ensuring", "avoiding", "unifying", "applying", "upgrading",
64
+ "patching", "hardening", "restructuring", "reorganizing", "deprecating",
65
+ "exposing", "integrating", "validating", "normalizing", "caching",
66
+ "supporting",
67
+ ]);
68
+ export const STYLE_GUIDE = `# Udacity Git Commit Message Style Guide
69
+
70
+ A commit message has three parts separated by blank lines: **subject**, optional **body**, optional **footer**.
71
+
72
+ type: Subject
73
+
74
+ Body — the what and why, not the how.
75
+
76
+ Resolves: #123
77
+ See also: #456, #789
78
+
79
+ ## Types
80
+ ${Object.entries(TYPES)
81
+ .map(([t, d]) => `- **${t}**: ${d}`)
82
+ .join("\n")}
83
+
84
+ ## Subject
85
+ - \`type: Subject\` format
86
+ - The whole line (including the \`type: \` prefix) is no more than ${SUBJECT_MAX} characters
87
+ - Begins with a capital letter
88
+ - Imperative mood ("Add", not "Added")
89
+ - No trailing period
90
+ - Blank line separates it from the body
91
+
92
+ ## Body (optional)
93
+ - Only when the commit needs explanation
94
+ - Explains the **what** and **why**, not the how
95
+ - Wrap each line at ${BODY_WRAP} characters
96
+
97
+ ## Footer (optional)
98
+ - References issue-tracker IDs: \`Resolves: #123\`, \`See also: #456, #789\`
99
+
100
+ _Lengths are counted in Unicode code points._
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
+ `;
128
+ /** Length in Unicode code points (not UTF-16 code units). */
129
+ export function width(s) {
130
+ return [...s].length;
131
+ }
132
+ /** Uppercase the first code point of a string (astral-safe). */
133
+ function capitalizeFirst(s) {
134
+ const chars = [...s];
135
+ if (chars.length === 0)
136
+ return s;
137
+ return chars[0].toUpperCase() + chars.slice(1).join("");
138
+ }
139
+ /**
140
+ * Greedy word-wrap that preserves paragraph breaks AND hard-breaks any single
141
+ * token longer than `max` (URLs, long paths), so no output line ever exceeds
142
+ * the limit. This guarantees `format`'s output always passes `validate`.
143
+ */
144
+ export function wrap(text, max = BODY_WRAP) {
145
+ return text
146
+ .split("\n")
147
+ .map((para) => {
148
+ const lines = [];
149
+ let line = "";
150
+ const flush = () => {
151
+ if (line) {
152
+ lines.push(line);
153
+ line = "";
154
+ }
155
+ };
156
+ for (let word of para.split(/\s+/).filter(Boolean)) {
157
+ // hard-break an over-long token across multiple lines
158
+ while (width(word) > max) {
159
+ flush();
160
+ const chars = [...word];
161
+ lines.push(chars.slice(0, max).join(""));
162
+ word = chars.slice(max).join("");
163
+ }
164
+ if (!line)
165
+ line = word;
166
+ else if (width(line) + 1 + width(word) <= max)
167
+ line += " " + word;
168
+ else {
169
+ flush();
170
+ line = word;
171
+ }
172
+ }
173
+ flush();
174
+ return lines.join("\n");
175
+ })
176
+ .join("\n");
177
+ }
178
+ /** Validate a full commit message against the Udacity style guide. */
179
+ export function validate(message) {
180
+ const problems = [];
181
+ const warnings = [];
182
+ // Normalize CRLF / lone CR so line-based checks are reliable.
183
+ const normalized = message.replace(/\r\n?/g, "\n");
184
+ const lines = normalized.split("\n");
185
+ // Drop trailing blank lines (a trailing newline shouldn't count as a body).
186
+ while (lines.length > 1 && lines[lines.length - 1].trim() === "")
187
+ lines.pop();
188
+ const rawSubject = lines[0] ?? "";
189
+ const subject = rawSubject.replace(/\s+$/, "");
190
+ const m = subject.match(/^(\w+): (.*)$/);
191
+ if (!m) {
192
+ problems.push(`Subject must follow "type: Subject". Got: "${subject}".`);
193
+ }
194
+ else {
195
+ const [, type, rest] = m;
196
+ if (!TYPES[type]) {
197
+ problems.push(`Unknown type "${type}". Use one of: ${Object.keys(TYPES).join(", ")}.`);
198
+ }
199
+ if (!rest) {
200
+ problems.push("Subject text is empty after the type.");
201
+ }
202
+ else {
203
+ if (/^\p{Ll}/u.test(rest)) {
204
+ problems.push(`Subject should begin with a capital letter (got "${[...rest][0]}").`);
205
+ }
206
+ const firstWord = rest.split(/\s+/)[0];
207
+ if (NON_IMPERATIVE.has(firstWord.toLowerCase())) {
208
+ warnings.push(`"${firstWord}" looks past-tense/gerund — use the imperative mood ("Add", not "Added").`);
209
+ }
210
+ }
211
+ }
212
+ if (rawSubject !== subject)
213
+ problems.push("Subject has trailing whitespace.");
214
+ if (/\.$/.test(subject))
215
+ problems.push("Subject must not end with a period.");
216
+ if (width(subject) > SUBJECT_MAX) {
217
+ problems.push(`Subject line is ${width(subject)} chars (incl. the "type: " prefix); max is ${SUBJECT_MAX}.`);
218
+ }
219
+ if (lines.length > 1 && lines[1].trim() !== "") {
220
+ problems.push("Leave a blank line between the subject and the body.");
221
+ }
222
+ for (let i = 2; i < lines.length; i++) {
223
+ const line = lines[i];
224
+ if (width(line) > BODY_WRAP) {
225
+ problems.push(`Line ${i + 1} is ${width(line)} chars; wrap body/footer at ${BODY_WRAP}.`);
226
+ }
227
+ const fm = line.match(/^([A-Za-z][A-Za-z ]*?):\s*(.*)$/);
228
+ if (fm && FOOTER_KEYS.some((k) => k.toLowerCase() === fm[1].toLowerCase())) {
229
+ if (!/#\d+/.test(fm[2])) {
230
+ warnings.push(`Footer "${fm[1]}" should reference an issue, e.g. "${fm[1]}: #123".`);
231
+ }
232
+ }
233
+ }
234
+ return { valid: problems.length === 0, problems, warnings };
235
+ }
236
+ /**
237
+ * Compose a compliant commit message from parts. Capitalizes the subject,
238
+ * strips a trailing period, and hard-wraps the body so the result always
239
+ * passes `validate`.
240
+ */
241
+ export function formatMessage(input) {
242
+ const subject = capitalizeFirst(input.subject.trim().replace(/\.+$/, ""));
243
+ const parts = [`${input.type}: ${subject}`];
244
+ if (input.body?.trim())
245
+ parts.push("", wrap(input.body.trim(), BODY_WRAP));
246
+ if (input.footer?.trim())
247
+ parts.push("", input.footer.trim());
248
+ const message = parts.join("\n");
249
+ return { message, report: validate(message) };
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 slash = branch.indexOf("/");
281
+ if (slash === -1) {
282
+ problems.push(`Branch must follow "type/description". Got: "${branch}".`);
283
+ return { valid: false, problems, warnings };
284
+ }
285
+ const type = branch.slice(0, slash);
286
+ const description = branch.slice(slash + 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.1",
3
+ "version": "1.2.0",
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",
@@ -29,9 +29,11 @@
29
29
  "node": ">=18"
30
30
  },
31
31
  "scripts": {
32
- "build": "tsc && chmod 755 build/index.js",
32
+ "build": "rm -rf build && tsc && chmod 755 build/index.js",
33
33
  "start": "node build/index.js",
34
- "prepublishOnly": "npm run build",
34
+ "pretest": "npm run build",
35
+ "test": "node --test test/*.test.mjs",
36
+ "prepublishOnly": "npm test",
35
37
  "test:client": "node test-client.mjs"
36
38
  },
37
39
  "dependencies": {
package/build/index.d.ts DELETED
@@ -1,2 +0,0 @@
1
- #!/usr/bin/env node
2
- export {};
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,SAAS,EAAE,MAAM,yCAAyC,CAAC;AACpE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AACjF,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB,MAAM,WAAW,GAAG,EAAE,CAAC;AACvB,MAAM,SAAS,GAAG,EAAE,CAAC;AAErB,MAAM,KAAK,GAA2B;IACpC,IAAI,EAAE,eAAe;IACrB,GAAG,EAAE,WAAW;IAChB,IAAI,EAAE,0BAA0B;IAChC,KAAK,EAAE,qDAAqD;IAC5D,QAAQ,EAAE,6BAA6B;IACvC,IAAI,EAAE,4DAA4D;IAClE,KAAK,EAAE,uEAAuE;CAC/E,CAAC;AAEF,MAAM,WAAW,GAAG;;;;;;;;;;;;EAYlB,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC;KACpB,GAAG,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC;KACnC,IAAI,CAAC,IAAI,CAAC;;;;iBAII,WAAW;;;;;;;;;sBASN,SAAS;;;;CAI9B,CAAC;AAEF,iEAAiE;AACjE,SAAS,IAAI,CAAC,IAAY,EAAE,KAAa;IACvC,OAAO,IAAI;SACR,KAAK,CAAC,IAAI,CAAC;SACX,GAAG,CAAC,CAAC,IAAI,EAAE,EAAE;QACZ,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,CAAC;QAChD,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,IAAI,IAAI,GAAG,EAAE,CAAC;QACd,KAAK,MAAM,CAAC,IAAI,KAAK,EAAE,CAAC;YACtB,IAAI,CAAC,IAAI;gBAAE,IAAI,GAAG,CAAC,CAAC;iBACf,IAAI,CAAC,IAAI,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,MAAM,IAAI,KAAK;gBAAE,IAAI,IAAI,GAAG,GAAG,CAAC,CAAC;iBACtD,CAAC;gBACJ,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;gBACjB,IAAI,GAAG,CAAC,CAAC;YACX,CAAC;QACH,CAAC;QACD,IAAI,IAAI;YAAE,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC3B,OAAO,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC1B,CAAC,CAAC;SACD,IAAI,CAAC,IAAI,CAAC,CAAC;AAChB,CAAC;AAQD,SAAS,QAAQ,CAAC,OAAe;IAC/B,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,QAAQ,GAAa,EAAE,CAAC;IAC9B,MAAM,KAAK,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACtD,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC;IAE/B,MAAM,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,eAAe,CAAC,CAAC;IACzC,IAAI,CAAC,CAAC,EAAE,CAAC;QACP,QAAQ,CAAC,IAAI,CAAC,8CAA8C,OAAO,IAAI,CAAC,CAAC;IAC3E,CAAC;SAAM,CAAC;QACN,MAAM,CAAC,EAAE,IAAI,EAAE,IAAI,CAAC,GAAG,CAAC,CAAC;QACzB,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACjB,QAAQ,CAAC,IAAI,CAAC,iBAAiB,IAAI,kBAAkB,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACzF,CAAC;QACD,IAAI,CAAC,IAAI,EAAE,CAAC;YACV,QAAQ,CAAC,IAAI,CAAC,uCAAuC,CAAC,CAAC;QACzD,CAAC;aAAM,CAAC;YACN,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;gBACzB,QAAQ,CAAC,IAAI,CAAC,oDAAoD,IAAI,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAClF,CAAC;YACD,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;YACnC,IAAI,YAAY,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC;gBAC7B,QAAQ,CAAC,IAAI,CACX,IAAI,KAAK,uEAAuE,CACjF,CAAC;YACJ,CAAC;QACH,CAAC;IACH,CAAC;IAED,IAAI,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC;QAAE,QAAQ,CAAC,IAAI,CAAC,qCAAqC,CAAC,CAAC;IAC9E,IAAI,OAAO,CAAC,MAAM,GAAG,WAAW,EAAE,CAAC;QACjC,QAAQ,CAAC,IAAI,CAAC,cAAc,OAAO,CAAC,MAAM,kBAAkB,WAAW,GAAG,CAAC,CAAC;IAC9E,CAAC;IACD,IAAI,KAAK,CAAC,MAAM,GAAG,CAAC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QAC/C,QAAQ,CAAC,IAAI,CAAC,sDAAsD,CAAC,CAAC;IACxE,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,EAAE,EAAE,CAAC;QACtC,IAAI,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,GAAG,SAAS,EAAE,CAAC;YAChC,QAAQ,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,CAAC,OAAO,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,+BAA+B,SAAS,GAAG,CAAC,CAAC;QAChG,CAAC;IACH,CAAC;IAED,OAAO,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,KAAK,CAAC,EAAE,QAAQ,EAAE,QAAQ,EAAE,CAAC;AAC9D,CAAC;AAED,MAAM,MAAM,GAAG,IAAI,SAAS,CAAC,EAAE,IAAI,EAAE,gBAAgB,EAAE,OAAO,EAAE,OAAO,EAAE,CAAC,CAAC;AAE3E,MAAM,CAAC,gBAAgB,CACrB,YAAY,EACZ,6BAA6B,EAC7B;IACE,KAAK,EAAE,gCAAgC;IACvC,WAAW,EAAE,0DAA0D;IACvE,QAAQ,EAAE,eAAe;CAC1B,EACD,KAAK,EAAE,GAAG,EAAE,EAAE,CAAC,CAAC,EAAE,QAAQ,EAAE,CAAC,EAAE,GAAG,EAAE,GAAG,CAAC,IAAI,EAAE,IAAI,EAAE,WAAW,EAAE,CAAC,EAAE,CAAC,CACtE,CAAC;AAEF,MAAM,CAAC,YAAY,CACjB,yBAAyB,EACzB;IACE,KAAK,EAAE,2BAA2B;IAClC,WAAW,EAAE,4EAA4E;IACzF,WAAW,EAAE,EAAE,OAAO,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,CAAC,kCAAkC,CAAC,EAAE;CAClF,EACD,KAAK,EAAE,EAAE,OAAO,EAAE,EAAE,EAAE;IACpB,MAAM,CAAC,GAAG,QAAQ,CAAC,OAAO,CAAC,CAAC;IAC5B,MAAM,GAAG,GAAG;QACV,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,2CAA2C,CAAC,CAAC,CAAC,kBAAkB;QAC1E,GAAG,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC;QACpC,GAAG,CAAC,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC;KACrC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACb,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC,EAAE,CAAC;AACpD,CAAC,CACF,CAAC;AAEF,MAAM,CAAC,YAAY,CACjB,uBAAuB,EACvB;IACE,KAAK,EAAE,uCAAuC;IAC9C,WAAW,EAAE,oDAAoD;IACjE,WAAW,EAAE;QACX,IAAI,EAAE,CAAC,CAAC,IAAI,CAAC,CAAC,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,OAAO,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,CAAC,CAAC;QAC3E,OAAO,EAAE,CAAC;aACP,MAAM,EAAE;aACR,QAAQ,CAAC,+DAA+D,CAAC;QAC5E,IAAI,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,sCAAsC,CAAC;QAC5E,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE,CAAC,QAAQ,EAAE,CAAC,QAAQ,CAAC,mCAAmC,CAAC;KAC5E;CACF,EACD,KAAK,EAAE,EAAE,IAAI,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE;IACxC,IAAI,CAAC,GAAG,OAAO,CAAC,IAAI,EAAE,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;IAC3C,CAAC,GAAG,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,CAAC,GAAG,IAAI,KAAK,CAAC,EAAE,CAAC,CAAC;IAChC,IAAI,IAAI,EAAE,IAAI,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,EAAE,SAAS,CAAC,CAAC,CAAC;IAC/D,IAAI,MAAM,EAAE,IAAI,EAAE;QAAE,KAAK,CAAC,IAAI,CAAC,EAAE,EAAE,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC;IAClD,MAAM,GAAG,GAAG,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC7B,MAAM,CAAC,GAAG,QAAQ,CAAC,GAAG,CAAC,CAAC;IACxB,MAAM,IAAI,GAAG,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,QAAQ,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACpE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,GAAG,GAAG,WAAW,IAAI,EAAE,EAAE,CAAC,EAAE,CAAC;AACxE,CAAC,CACF,CAAC;AAEF,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;AAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC"}