From ff423fe3ba1d65c1ae637fb586b168b9e29d8d13 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 11:45:15 +0200 Subject: [PATCH] feat(git): strict conventional-commit parsing --- src/lib/git/conventional.test.ts | 88 ++++++++++++++++++++++++++++++++ src/lib/git/conventional.ts | 53 +++++++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 src/lib/git/conventional.test.ts create mode 100644 src/lib/git/conventional.ts diff --git a/src/lib/git/conventional.test.ts b/src/lib/git/conventional.test.ts new file mode 100644 index 0000000..7aa5523 --- /dev/null +++ b/src/lib/git/conventional.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect } from "vitest"; +import { parseCommitMessage } from "@/lib/git/conventional"; + +describe("parseCommitMessage", () => { + it("pulls out type, scope and subject", () => { + expect(parseCommitMessage("feat(stock): inline qty editing")).toEqual({ + type: "feat", + scope: "stock", + subject: "inline qty editing", + breaking: false, + }); + }); + + it("handles a missing scope", () => { + expect(parseCommitMessage("fix: correct the total")).toEqual({ + type: "fix", + scope: "", + subject: "correct the total", + breaking: false, + }); + }); + + it("flags a breaking change with and without a scope", () => { + expect(parseCommitMessage("feat!: drop v1 API").breaking).toBe(true); + expect(parseCommitMessage("feat(api)!: drop v1").breaking).toBe(true); + expect(parseCommitMessage("feat(api)!: drop v1").scope).toBe("api"); + }); + + it("reads only the first line", () => { + const message = + "feat(customers): lift the toggle\n\nA long body that\nmentions fix(other): things\n"; + expect(parseCommitMessage(message)).toEqual({ + type: "feat", + scope: "customers", + subject: "lift the toggle", + breaking: false, + }); + }); + + it("lowercases the type and the scope", () => { + const parsed = parseCommitMessage("Feat(Stock): thing"); + expect(parsed.type).toBe("feat"); + expect(parsed.scope).toBe("stock"); + }); + + it("classifies an unparseable message as other, keeping the subject", () => { + expect(parseCommitMessage("just some words")).toEqual({ + type: "other", + scope: "", + subject: "just some words", + breaking: false, + }); + }); + + it("does not accept a space before the parenthesis", () => { + // "feat (stock): x" is not a conventional commit. Accepting it would make + // the scope breakdown depend on invisible whitespace. + expect(parseCommitMessage("feat (stock): x").type).toBe("other"); + }); + + it("accepts any single lowercase word as a type, but not a phrase", () => { + // "note" is not a conventional type, but the parser cannot tell an unknown + // type from prose shaped like one, and it must not police a whitelist — + // see the note after Step 4. + expect(parseCommitMessage("note: this is not conventional").type).toBe("note"); + // A space before the colon means it is a sentence, not a prefix. + expect(parseCommitMessage("TODO tomorrow: finish").type).toBe("other"); + }); + + it("rejects a type containing non-letters", () => { + expect(parseCommitMessage("feat2(x): thing").type).toBe("other"); + }); + + it("trims and caps an over-long subject", () => { + const parsed = parseCommitMessage(`feat(x): ${"s".repeat(400)}`); + expect(parsed.subject).toHaveLength(200); + }); + + it("returns other for an empty message", () => { + expect(parseCommitMessage("").type).toBe("other"); + expect(parseCommitMessage(" \n ").subject).toBe(""); + }); + + it("handles a scope containing a hyphen or slash", () => { + expect(parseCommitMessage("fix(delivery-notes): x").scope).toBe("delivery-notes"); + expect(parseCommitMessage("fix(api/v1): x").scope).toBe("api/v1"); + }); +}); \ No newline at end of file diff --git a/src/lib/git/conventional.ts b/src/lib/git/conventional.ts new file mode 100644 index 0000000..0bbb3f4 --- /dev/null +++ b/src/lib/git/conventional.ts @@ -0,0 +1,53 @@ +/** + * Conventional-commit parsing. + * + * Two dashboard figures depend on this — the type breakdown and the feature + * areas by scope — and Plan 2b links commits to cards by scope. So the rule + * has to be strict and predictable rather than forgiving: a parser that + * sometimes accepts "feat (stock):" makes the scope chart depend on invisible + * whitespace, and nobody would ever find that. + * + * Anything that does not match is type "other" and is counted, never dropped. + * A silently shrinking denominator is worse than an untidy bar. + */ + +export interface ParsedCommit { + /** Lowercased conventional type, or "other". */ + type: string; + /** Lowercased scope, or "" when absent. */ + scope: string; + /** First line after the prefix, trimmed and capped. */ + subject: string; + breaking: boolean; +} + +export const MAX_SUBJECT_LENGTH = 200; + +/** + * type, optional (scope), optional !, colon, space. + * The type is letters only — "feat2" is not a type, it is prose. + */ +const CONVENTIONAL = /^([a-zA-Z]+)(?:\(([^)]+)\))?(!?):\s+(.*)$/; + +export function parseCommitMessage(message: string): ParsedCommit { + const firstLine = (message ?? "").split("\n")[0].trim(); + + const match = CONVENTIONAL.exec(firstLine); + if (!match) { + return { + type: "other", + scope: "", + subject: firstLine.slice(0, MAX_SUBJECT_LENGTH), + breaking: false, + }; + } + + const [, type, scope, bang, subject] = match; + + return { + type: type.toLowerCase(), + scope: (scope ?? "").toLowerCase().trim(), + subject: subject.trim().slice(0, MAX_SUBJECT_LENGTH), + breaking: bang === "!", + }; +} \ No newline at end of file