feat(git): strict conventional-commit parsing
This commit is contained in:
parent
3f9bbb783a
commit
ff423fe3ba
2 changed files with 141 additions and 0 deletions
88
src/lib/git/conventional.test.ts
Normal file
88
src/lib/git/conventional.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
53
src/lib/git/conventional.ts
Normal file
53
src/lib/git/conventional.ts
Normal 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 === "!",
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue