Skip to main content
Version: 1.0

ccme: the commit parser

github.com/yohimik/dispat/pkg/ccme is a Go parser for Conventional Commits, Monorepo Extension 1.0.0, a strict superset of Conventional Commits 1.0.0 that adds scopes as packages, propagation depth and prerelease channels. It parses commit messages and nothing else: no git, no workspace, no versions.

The parser uses no regular expressions. It is a single left-to-right index scan with one byte of lookahead, no backtracking and no recursion, which gives O(n) time and O(1) working space. That property is what matters when the input is untrusted commit messages arriving from a repository in CI.

The specification is vendored beside the code as SPEC.md, and every section reference in the package points into it.

go get github.com/yohimik/dispat/pkg/ccme

Parsing a message

p := ccme.DefaultParser()

res, err := p.Parse(message)
if err != nil {
// err is a *ccme.ParseError listing every error-severity diagnostic.
// res is still populated: an error invalidates only its own unit.
}

for _, u := range res.ValidUnits() {
fmt.Println(u.Header.Type, u.Scopes(), u.Bump, u.Directives.Depth)
}

A message can hold several units separated by ---, so the result is a list. An error in one unit never invalidates the others, which is why Parse returns both a result and an error and both are worth reading.

ParseSubject is the narrow entry point for commit-lint checks, taking the subject line on its own:

res, err := p.ParseSubject("feat(@acme/core)^^minor%beta!: streaming reader")
u := res.Units[0]
// u.Header.Type == "feat"
// u.Scopes().String() == "@acme/core"
// u.Breaking == true
// u.Bump == ccme.BumpMajor
// u.Directives.Propagate == ccme.PropagateMinor
// u.Directives.Depth == ccme.DepthAll
// u.Directives.Channel == ccme.ChannelValue{To: "beta"}

A Parser is immutable once constructed and safe for concurrent use, so one parser serves a whole history sweep.

Configuring the parser

Everything lives in one Config struct whose zero value is the specification default, so only the fields you want to change need setting:

p, err := ccme.NewParser(ccme.Config{
Separator: "%%%",
StrictTypes: true,
MaxDescriptionLength: 72,
Propagation: ccme.PropagationConfig{
Bump: ccme.PropagateInherit,
Depth: ccme.DepthAll,
},
})

DefaultParser() is shorthand for MustNewParser(Config{}), and DefaultConfig() returns the same values spelled out when starting from a populated struct reads better.

Two conventions carry most of the surprise. A nil slice or map selects the default while a non-nil empty one means none, so an empty AllowedChannels forbids every channel rather than allowing all of them. And both propagation depths default to a literal 0, which is the specification default rather than an unset marker: a unit reaches nobody until it says otherwise.

Diagnostics

Every finding carries a code, a severity and an exact byte position, so a caller can point at the offending character:

1:18: error E113: '+2' contradicts the depth of all asserted by '^^'

Errors are E codes and warnings are W codes. Result.Errors() and Result.Warnings() return them, and both return nil rather than an empty slice on a clean parse, so a successful call allocates nothing for the diagnostic path.

The package emits only the codes decidable from a message on its own. Anything needing a workspace, a dependency graph or git history belongs to the release engine instead, and the full numbered list of both sets is in Diagnostic codes.

Two warnings deserve their own handling. W155 and W156 mean the message says something other than what its author meant, most often a BREAKING CHANGE footer miscapitalised so that a major change would ship as a minor one. They cannot be suppressed by any configuration, and SilentFailureCodes() returns them so commit-lint tooling can reject what the release engine merely tolerates.

Versions

A SemVer 2.0.0 parser comes with the package, because an exact Release-As value has to be validated while the message is being read:

v, err := ccme.ParseVersion("1.4.0-rc.2")
v.Compare(other)

Bulk parsing

The parser holds no mutable state, so it scales across goroutines. Past roughly a million messages per second the limit becomes the garbage collector rather than the parser, since each message produces a couple of kilobytes of short-lived garbage. A tool that sweeps a history once and exits should raise GOGC, which costs nothing but peak memory and is by far the highest-value setting here.

Two consequences of the zero-copy design are worth knowing. A Result retains the whole message string, so keeping one description from a large message means copying it. And Directives.Kinds aliases the parser configuration, so treat it as read-only.

Further reading