feat(git): strict conventional-commit parsing

This commit is contained in:
Dave 2026-09-16 11:45:15 +02:00
parent 3f9bbb783a
commit ff423fe3ba
2 changed files with 141 additions and 0 deletions

View file

@ -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");
});
});

View file

@ -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 === "!",
};
}