Building a JIRA-Aware Commitlint Plugin with an Interactive CLI Prompter

A deep dive into writing a self-contained commitlint plugin, shareable config, and interactive commit CLI — all from one TypeScript package.
The Problem Worth Solving#
Every team that uses JIRA for issue tracking and conventional commits for version management inevitably
runs into the same friction: enforcing consistent commit message format that ties commits to tickets.
Off-the-shelf solutions exist (commitlint-plugin-jira-rules, commitizen, etc.) but they are fragmented:
one package for validation, another for the prompt, a third for configuration. Dependencies pile up,
configs diverge, and developers end up copy-pasting hook scripts between repos.
This post walks through building a single, self-contained package that ships three things together:
- Commitlint rules — custom validation logic wired into
commitlint's plugin API - Configuration factory — a
defineConfig()function that produces a fullcommitlintUserConfig - Interactive CLI — a
prepare-commit-msghook replacement that guides developers step-by-step
The target commit message format is:
type: PROJ-NNNN - Commit message subject
Optional body paragraph.
BREAKING CHANGE: Optional description.
For example:
feat: ACME-1234, ACME-5678 - Add centroid calculation support
Package Shape and Entry Points#
A key architectural decision is that the package ships three independent entry points from one codebase. Each has different consumers and different output requirements.
src/
├── index.ts ← Library barrel (ESM + CJS dual build)
├── config-export.ts ← Shareable config (esbuild, export default)
├── bin/
│ └── jira-commit.ts ← CLI binary (esbuild, standalone bundle)
├── rules/ ← Commitlint rule implementations
├── prompter/ ← Interactive prompt engine
└── ...
The relationships between these entry points and the internal modules they pull from:
The library (index.ts) is built with Vite/Rollup to produce dual ESM/CJS output with proper
type declarations. It exports createConfig, defineConfig, and the TypeScript types.
The shareable config (config-export.ts) uses export default — a pattern incompatible with the
dual-export library build. It gets its own esbuild bundle.
The CLI binary (bin/jira-commit.ts) is a standalone Node.js executable. It must include all runtime
dependencies inline to avoid import resolution issues when called by a git hook. esbuild handles this.
The package.json exports map ties it together:
{
"exports": {
".": {
"import": { "types": "./dist/commitlint-jira.d.ts", "default": "./dist/commitlint-jira.js" },
"require": { "types": "./dist/commitlint-jira.d.cts", "default": "./dist/commitlint-jira.cjs" }
},
"./config": {
"import": { "default": "./dist/config-export.mjs" },
"require": { "default": "./dist/config-export.cjs" }
}
},
"bin": {
"jira-commit": "./lib/jira-commit.js"
}
}
The bin field points to a thin stub (lib/jira-commit.js) that forwards to the bundled binary in
dist/. This way the stub can set #!/usr/bin/env node and remain trivial, while the actual logic
lives in the built artifact.
Types First#
Defining types before implementation serves as a contract. All modules refer to the same shared
interfaces from src/types.ts:
// src/types.ts
export type CommitType =
| 'feat' | 'fix' | 'docs' | 'style' | 'refactor'
| 'perf' | 'test' | 'build' | 'ci' | 'chore' | 'revert';
export type PromptMode = 'default' | 'extended';
export interface CommitlintJiraConfig {
/** JIRA project keys allowed in commit messages. Default: `['ACME']` */
jiraProjects: string[]
/** Allowed conventional commit types. */
types: CommitType[]
/** Separator between JIRA ID and description. Default: `' - '` */
separator: string
/** Minimum task ID character length. Default: `3` */
taskIdMinLength: number
/** Maximum task ID character length. Default: `15` */
taskIdMaxLength: number
/** Maximum commit header width. Default: `72` */
maxHeaderWidth: number
/** Prompt mode: `'default'` or `'extended'`. Default: `'default'` */
promptMode: PromptMode
}
/**
* A commitlint rule outcome: `[valid, errorMessage?]`.
*/
export type RuleOutcome = Readonly<[boolean, string?]>;
/**
* A synchronous commitlint rule function.
*/
export type CommitlintRule<T = unknown> = (
parsed: ParsedCommit,
when?: string,
value?: T
) => RuleOutcome;
Three things stand out here:
RuleOutcomemirrors commitlint's own convention for rule return values — a two-tuple where only the first element (validity) is required.CommitlintRule<T>is parameterized on the value type, allowing typed rule options.PromptModeas a union type (rather than an enum or plain string) keeps JSON-serializable config clean while maintaining type safety.
Defaults and Configuration Resolution#
Centralizing defaults in one module (src/defaults.ts) gives every consumer a single source of truth
and makes override logic trivial:
// src/defaults.ts
export const DEFAULT_TYPES: CommitType[] = [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
];
export const DEFAULT_PROJECTS: string[] = ['ACME'];
export const DEFAULT_SEPARATOR = ' - ';
export function resolveConfig(
overrides?: Partial<CommitlintJiraConfig>
): CommitlintJiraConfig {
return {
jiraProjects: overrides?.jiraProjects ?? DEFAULT_PROJECTS,
types: overrides?.types ?? DEFAULT_TYPES,
separator: overrides?.separator ?? DEFAULT_SEPARATOR,
taskIdMinLength: overrides?.taskIdMinLength ?? 3,
taskIdMaxLength: overrides?.taskIdMaxLength ?? 15,
maxHeaderWidth: overrides?.maxHeaderWidth ?? 72,
promptMode: overrides?.promptMode ?? 'default'
};
}
resolveConfig is intentionally pure — no side effects, no I/O. This makes it trivially testable and
safe to call from anywhere. The prompter, the rules, and the CLI all call resolveConfig as the first
step.
Writing Commitlint Rules#
Commitlint's plugin API is simple: a plugin is an object with a rules property, where each key is
a rule name and each value is a function (parsed, when, value) => [boolean, string?].
The most complex rule validates the full commit format: the type, the JIRA ID(s), and the separator all at once via a single composed regex.
// src/rules/jira-task-id.ts
export function jiraTaskId(
parsed: ParsedCommit,
_when?: string,
options?: JiraTaskIdRuleOptions
): RuleOutcome {
if (!options) {
return [false, 'jira-task-id rule requires options'];
}
const { types, projectKeys, separator = '-' } = options;
const rawValue = parsed?.raw ?? '';
if (!rawValue) {
return [false, 'Commit message should not be empty'];
}
// Escape regex special chars in the separator
const escapedSeparator = separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Single JIRA ID group: `ACME-1234` or `DRIVE-999`
const idGroup = `(?:${projectKeys.join('|')})${escapedSeparator}\\d+`;
// Full header pattern:
// feat: ACME-1234, ACME-5678 - message
// ^type: \s+ id(,id)* \s|$
const pattern =
`^(${types.join('|')}):\\s+${idGroup}(?:,\\s*${idGroup})*(?:\\s|$)`;
return [new RegExp(pattern).test(rawValue)];
}
The key insight is that having a single jira-task-id rule validate the overall shape (type + IDs
present and correctly formed) lets the more granular rules (jira-task-id-case,
jira-task-id-project-key, etc.) focus on a single concern each. This is the Single Responsibility
Principle applied to validation.
The simpler rules all follow the same three-step skeleton:
export function jiraTaskIdCase(
parsed: ParsedCommit,
_when?: string,
caseType: string = 'uppercase'
): RuleOutcome {
// 1. Guard: empty message
const rawValue = parsed?.raw ?? '';
if (!rawValue) {
return [false, 'Commit message should not be empty'];
}
// 2. Extract the thing being validated
const taskIds = extractTaskIds(rawValue);
if (taskIds.length === 0) {
return [false, 'Could not extract JIRA task ID from commit message'];
}
// 3. Validate and return outcome
for (const taskId of taskIds) {
const isValid = caseType === 'lowercase'
? taskId === taskId.toLowerCase()
: taskId === taskId.toUpperCase();
if (!isValid) {
return [false, `${taskId} taskId must be ${caseType} case`];
}
}
return [true];
}
Rules never throw — they always return a RuleOutcome tuple. This is critical: an exception
inside a rule will cause commitlint to crash with an unhelpful error. Always guard, always return.
Extracting JIRA IDs from a Raw String#
Reusing extraction logic across multiple rules without coupling them is the job of
src/rules/utils.ts. The problem is subtler than it looks: simply matching /[A-Z]+-\d+/ everywhere
in the string would also catch IDs in the body or footer. The extractor must be anchored to the
header's ID-list segment.
The approach: match the type: prefix, then scan forward consuming IDs separated by commas until a
non-ID token is encountered. The scanned index marks where the ID segment ends.
// src/rules/utils.ts (abbreviated)
function parseTaskIdSegment(raw: string): TaskIdSegment | null {
const prefixMatch = raw.match(/^\w+:\s*/);
if (!prefixMatch) return null;
let offset = prefixMatch[0].length;
let remaining = raw.slice(offset);
const ids: string[] = [];
const idPattern = /[A-Za-z]+-\d+/;
while (remaining.length > 0) {
// Skip leading whitespace
const leadingSpaces = remaining.length - remaining.trimStart().length;
offset += leadingSpaces;
remaining = remaining.trimStart();
const match = idPattern.exec(remaining);
if (!match || match.index !== 0) break; // Not an ID at current position
ids.push(match[0]);
offset += match[0].length;
remaining = remaining.slice(match[0].length);
// Advance past comma to continue, or break if no comma follows
const spacesAfter = remaining.length - remaining.trimStart().length;
remaining = remaining.trimStart();
if (remaining.startsWith(',')) {
offset += spacesAfter + 1;
remaining = remaining.slice(1);
} else {
break;
}
}
return ids.length > 0 ? { ids, endIndex: offset } : null;
}
export function extractTaskIds(raw: string): string[] {
return parseTaskIdSegment(raw)?.ids ?? [];
}
export function getTaskIdSegmentEnd(raw: string): number | null {
const segment = parseTaskIdSegment(raw);
return segment ? segment.endIndex : null;
}
getTaskIdSegmentEnd is used specifically by the separator rule, which checks the exact character
position where the ID segment ends to verify that the separator begins there.
Registering Rules as a Plugin#
All rules are collected into a map and returned from createPlugin:
// src/rules/index.ts
export const jiraRules = {
'jira-task-id': jiraTaskId,
'jira-task-id-case': jiraTaskIdCase,
'jira-task-id-max-length': jiraTaskIdMaxLength,
'jira-task-id-min-length': jiraTaskIdMinLength,
'jira-task-id-project-key': jiraTaskIdProjectKey,
'jira-commit-message-separator': jiraCommitMessageSeparator
} as const;
// src/plugin.ts
export function createPlugin(): { rules: typeof jiraRules } {
return { rules: { ...jiraRules } };
}
The Configuration Factory#
createConfig (aliased as defineConfig) is the primary public API. It combines resolveConfig,
createPlugin, and the commitlint UserConfig structure into a single call:
// src/config.ts
export function createConfig(
overrides?: Partial<CommitlintJiraConfig>
): CommitlintUserConfig {
const config = resolveConfig(overrides);
const plugin = createPlugin();
return {
extends: ['@commitlint/config-conventional'],
parserPreset: {
parserOpts: {
issuePrefixes: config.jiraProjects
}
},
plugins: [plugin],
rules: {
'type-enum': [2, 'always', config.types],
'subject-case': [0, 'always', 'sentence-case'], // disabled — JIRA rules cover this
'jira-task-id': [2, 'always', {
separator: '-',
types: config.types,
projectKeys: config.jiraProjects
}],
'jira-task-id-case': [2, 'always', 'uppercase'],
'jira-task-id-min-length': [2, 'always', config.taskIdMinLength],
'jira-task-id-max-length': [2, 'always', config.taskIdMaxLength],
'jira-task-id-project-key': [2, 'always', config.jiraProjects],
'jira-commit-message-separator': [2, 'always', config.separator]
},
promptMode: config.promptMode
};
}
export const defineConfig = createConfig;
The promptMode property is a non-standard addition to the commitlint UserConfig. Commitlint ignores
unknown top-level keys, so adding it here is safe — and it means the CLI can find its configuration
simply by importing the same commitlint.config.mjs file the user already has.
Usage in a project:
// commitlint.config.mjs
import { defineConfig } from '@myorg/commitlint-jira'
export default defineConfig({
jiraProjects: ['ACME', 'DRIVE'],
promptMode: 'extended'
})
The Interactive Commit Prompter#
The CLI prompter is the most user-visible part of the package. It replaces commitizen with a
focused, minimal prompt powered by @inquirer/prompts.
Here is the full flow from git commit through prompt to final validation:
Branch Intelligence#
Before showing any prompts the engine reads the current git branch to pre-fill the type and JIRA ID:
// src/prompter/branch-utils.ts
function getCurrentBranchName(): string | null {
try {
return execSync('git branch --show-current', { encoding: 'utf-8' }).trim();
} catch {
return null; // Not a git repo, detached HEAD, etc.
}
}
export function extractTypeFromBranch(): string {
const branchName = getCurrentBranchName();
const match = branchName?.match(/^(\w+)/);
return match?.[1] ?? 'chore'; // safe fallback
}
export function extractJiraFromBranch(): string | null {
const branchName = getCurrentBranchName();
const match = branchName?.match(/[A-Za-z]+-\d+/);
return match ? match[0].toUpperCase() : null;
}
A branch named feat/ACME-1234-add-centroid-support will auto-populate type feat and JIRA ID
ACME-1234 before the developer types a single character.
The Prompt Steps#
Each prompt step is its own function with a single responsibility — input type, validation, display. This keeps each one under 20 lines and independently testable:
// src/prompter/engine.ts (abbreviated)
async function promptType(
config: CommitlintJiraConfig,
prefill: PromptPrefill | undefined,
branchType: string
): Promise<string> {
const typeChoices = config.types
.filter(t => t in COMMIT_TYPES)
.map(t => ({
name: `${t.padEnd(10)} ${COMMIT_TYPES[t].description}`,
value: t
}));
const defaultType = prefill?.type ?? branchType;
return select({
message: "Select the type of change that you're committing:",
choices: typeChoices,
default: defaultType as CommitType
});
}
async function promptJiraIds(
config: CommitlintJiraConfig,
prefill: PromptPrefill | undefined,
branchJira: string | null
): Promise<string> {
const projectPattern = config.jiraProjects.join('|');
const singleIdRegex = new RegExp(`^(${projectPattern})-\\d+$`, 'i');
const defaultJira = prefill?.jiraIds ?? branchJira ?? undefined;
const jiraInput = await input({
message: `Enter JIRA issue(s), comma-separated (${config.jiraProjects[0]}-12345):`,
default: defaultJira,
validate: (value) => {
const ids = value.split(',').map(s => s.trim()).filter(Boolean);
if (ids.length === 0) return 'At least one JIRA issue is required';
for (const id of ids) {
if (!singleIdRegex.test(id)) {
return `Invalid JIRA ID "${id}". Must match: ${config.jiraProjects[0]}-12345`;
}
}
return true;
},
transformer: value => value.toUpperCase() // live uppercase as you type
});
return jiraInput
.split(',')
.map(s => s.trim().toUpperCase())
.filter(Boolean)
.join(', ');
}
async function promptSubject(
maxSubjectLength: number,
prefill: PromptPrefill | undefined
): Promise<string> {
const raw = await input({
message: `Write a short, imperative tense description (max ${maxSubjectLength} chars):`,
default: prefill?.subject,
validate: (value) => {
if (!value || value.trim().length < 2) return 'Subject must have at least 2 characters';
if (value.length > maxSubjectLength) {
return `Subject must not exceed ${maxSubjectLength} characters (${value.length}/${maxSubjectLength})`;
}
return true;
},
transformer: (value, { isFinal }) => {
// Live character counter while typing
if (!isFinal) return value;
const remaining = maxSubjectLength - value.length;
const counter = remaining >= 0
? pc.dim(pc.cyan(` [${remaining} left]`))
: pc.red(` [${Math.abs(remaining)} over]`);
return `${value}${counter}`;
}
});
return raw.trim().replace(/\.$/, ''); // strip trailing period
}
Assembling the Message#
Once all prompts are answered, assembly is a pure function:
export function assembleCommitMessage(
type: string,
jiraIds: string,
separator: string,
subject: string,
body?: string,
breakingChange?: string
): string {
const header = `${type}: ${jiraIds}${separator}${subject}`;
const parts = [header];
if (body) parts.push(body);
if (breakingChange) parts.push(`BREAKING CHANGE: ${breakingChange}`);
return parts.join('\n\n');
}
Making assembly a pure function rather than inline logic means it can be unit tested exhaustively without spinning up any prompts.
Skip Logic — Idempotency by Design#
A critical requirement is that git commit -m "..." and CI pipelines must not be blocked by an
interactive prompt. The default prompt mode implements a skip check before showing any UI:
export function shouldSkipPrompt(
hookFile: string | undefined,
config: CommitlintJiraConfig,
options: ShouldSkipOptions
): boolean {
if (options.dryRun) return false;
if (!hookFile || config.promptMode !== 'default') return false;
const existingMessage = readExistingMessage(hookFile);
if (!existingMessage) return false;
return validateCommitMessage(existingMessage, config);
}
validateCommitMessage runs a quick regex against the existing message. If it's already valid (e.g.,
from git commit -m "feat: ACME-1234 - Add feature"), the prompt is skipped entirely and the commit
proceeds without interaction.
Amend Support#
When the git hook is invoked with --source commit (an amend), the engine reads the existing commit
message and parses it into prefill values:
// src/prompter/message-parser.ts
export function parseCommitMessage(
message: string,
separator: string = ' - '
): ParsedMessage {
const [headerBlock, ...bodyParts] = message.split('\n\n');
const header = headerBlock.trim();
const typeMatch = header.match(/^(\w+):\s+/);
if (!typeMatch) return { type: undefined, jiraIds: undefined, subject: undefined, body: undefined };
const afterType = header.slice(typeMatch[0].length);
const idsMatch = afterType.match(/^([A-Za-z]+-\d+(?:\s*,\s*[A-Za-z]+-\d+)*)/);
if (!idsMatch) return { ...result, type: typeMatch[1] };
const afterIds = afterType.slice(idsMatch[0].length);
return {
type: typeMatch[1],
jiraIds: idsMatch[1],
subject: afterIds.startsWith(separator) ? afterIds.slice(separator.length) : undefined,
body: bodyParts.join('\n\n').trim() || undefined
};
}
The parsed fields are passed to each prompt* function as prefill, and each @inquirer/prompts
widget uses them as the default value — so amending feels natural.
The CLI Binary#
src/bin/jira-commit.ts is the entry point for the jira-commit command. It handles three
responsibilities: argument parsing, config loading, and orchestrating the prompt or exit.
// src/bin/jira-commit.ts (main, abbreviated)
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const configOverrides = await loadConfigOverrides();
const config = resolveConfig(configOverrides);
const prefill = resolveAmendPrefill(args.hookFile, args.source);
const skipPrompt = shouldSkipPrompt(args.hookFile, config, { dryRun: args.dryRun });
if (skipPrompt) {
const msg = readExistingMessage(args.hookFile!);
console.log(pc.green(`✓ Valid commit message found — skipping prompt.`));
console.log(pc.dim(msg ?? ''));
return;
}
const message = await runPrompt(config, prefill);
if (!message) {
console.log(pc.yellow('Commit prompt cancelled.'));
return;
}
if (args.dryRun) {
console.log(pc.cyan('\nDry run — message that would be committed:\n'));
console.log(message);
return;
}
if (args.hookFile) {
writeFileSync(args.hookFile, message);
} else {
console.log(message);
}
}
main().catch(err => {
if (isUserInterrupt(err)) {
console.log(pc.yellow('\nCommit prompt cancelled.'));
process.exit(0);
}
console.error(pc.red('jira-commit error:'), err);
process.exit(1);
});
isUserInterrupt detects @inquirer/prompts's ExitPromptError (thrown on Ctrl+C), converting a
hard crash into a clean yellow message. This is important UX detail — an interrupted prompt should not
leave the terminal in an error state.
Config Loading from the Project Root#
The CLI loads promptMode from the project's commitlint.config.mjs by dynamic import. This avoids
the CLI needing its own config argument while still respecting the user's preference:
async function loadConfigOverrides(): Promise<Partial<CommitlintJiraConfig> | undefined> {
const cwd = process.cwd();
const CONFIG_FILES = ['commitlint.config.mjs', 'commitlint.config.ts'];
for (const fileName of CONFIG_FILES) {
const filePath = resolve(cwd, fileName);
if (!existsSync(filePath)) continue;
try {
const mod = await import(pathToFileURL(filePath).href);
const exported = mod?.default ?? mod;
if (exported?.promptMode) {
return { promptMode: exported.promptMode };
}
} catch {
// Config file exists but failed to load — use defaults
}
return undefined;
}
}
Husky Integration#
Two hooks work together. The prompt hook fires first; the validation hook fires last, regardless. This
means even if someone bypasses the prompt (via -m, via SKIP_COMMIT_PROMPT, or via CI), their message
still gets validated.
.husky/prepare-commit-msg — runs the interactive prompt:
# Skip the prompt for CI pipelines and automated tools.
# Validation in commit-msg still runs regardless.
if [ -n "$SKIP_COMMIT_PROMPT" ] || [ -n "$CI" ]; then
exit 0
fi
exec < /dev/tty && npx jira-commit --hook "$1" --source "$2" || true
The exec < /dev/tty re-attaches stdin to the terminal — necessary because git hooks run in a
non-interactive context by default. The || true ensures a cancelled prompt (Ctrl+C) doesn't
abort the commit with an error.
.husky/commit-msg — validates the final message:
npx --no-install commitlint --edit "$1"
Environment Variable Escape Hatch#
# For automated tools, AI agents, and scripts:
SKIP_COMMIT_PROMPT=1 git commit -m "feat: ACME-1234 - Add feature"
# For CI pipelines, set CI=true (already standard in most CI environments)
Neither variable is read inside TypeScript. They are evaluated by the shell hook before the binary is invoked. This keeps the TypeScript surface clean and the shell hooks self-documenting.
Build Pipeline#
The build script runs four stages and each stage has a distinct concern:
{
"scripts": {
"clean": "rimraf dist dts",
"build": "npm run clean && vite build && npm run build:config && npm run build:bin",
"esbuild:node": "esbuild --bundle --platform=node --packages=external",
"build:config:esm": "npm run esbuild:node -- src/config-export.ts --format=esm --outfile=dist/config-export.mjs",
"build:config:cjs": "npm run esbuild:node -- src/config-export.ts --format=cjs --outfile=dist/config-export.cjs",
"build:bin": "npm run esbuild:node -- src/bin/jira-commit.ts --format=esm --outfile=dist/bin/jira-commit.mjs"
}
}
| Stage | Tool | Input | Output |
|---|---|---|---|
| Clean | rimraf | dist/, dts/ | — |
| Library | Vite/Rollup | src/index.ts | dist/commitlint-jira.{js,cjs,d.ts,d.cts} |
| Config export | esbuild | src/config-export.ts | dist/config-export.{mjs,cjs} |
| CLI binary | esbuild | src/bin/jira-commit.ts | dist/bin/jira-commit.mjs |
The --packages=external flag on the esbuild commands tells esbuild not to bundle node_modules —
the consumer's node_modules will resolve them at runtime. This keeps the output small and avoids
duplicating packages.
Error Handling Conventions#
A consistent error handling approach across all layers prevents surprises:
| Layer | Pattern | Fallback |
|---|---|---|
| Rules | Return [false, errorMessage] — never throw | Always returns RuleOutcome |
| Branch utils | try/catch around execSync | 'chore' (type), null (JIRA ID) |
| File I/O | try/catch around readFileSync | undefined |
| Config loading | try/catch around dynamic import() | undefined (uses defaults) |
| CLI main | Top-level catch; distinguish ExitPromptError from runtime errors | Exit 0 (cancel) vs exit 1 (error) |
The rule layer contract is the most important: rules must never throw. commitlint does not wrap
rule invocations in try/catch. An uncaught exception from a rule crashes the entire validation run.
Always guard parsed.raw, always return a tuple.
Testing Strategy#
The package lends itself to unit testing because of its functional design:
- Rules: Call with a
ParsedCommitshaped object — no hooks, no git, no filesystem needed. - Utilities (
extractTaskIds,parseTaskIdSegment): Pure functions, pure inputs, pure outputs. - Config factory: Call
createConfig(overrides)and assert the returned object's shape. - Prompt functions: Test
assembleCommitMessageandshouldSkipPromptdirectly; mock@inquirer/promptsfor integration-level prompt tests. - CLI utils:
parseArgs,resolveAmendPrefill,shouldSkipPromptare all testable without I/O. - Branch utils: Mock
execSyncto test fallback behavior without a real git repository.
Example rule test pattern:
describe('jiraTaskIdCase', () => {
it('should pass for uppercase JIRA IDs', () => {
const parsed = { raw: 'feat: ACME-1234 - Add feature' } as ParsedCommit;
expect(jiraTaskIdCase(parsed, 'always', 'uppercase')).toEqual([true]);
});
it('should fail for lowercase JIRA IDs', () => {
const parsed = { raw: 'feat: acme-1234 - Add feature' } as ParsedCommit;
const [valid, message] = jiraTaskIdCase(parsed, 'always', 'uppercase');
expect(valid).toBe(false);
expect(message).toContain('uppercase');
});
it('should fail for empty message', () => {
const parsed = { raw: '' } as ParsedCommit;
const [valid] = jiraTaskIdCase(parsed, 'always', 'uppercase');
expect(valid).toBe(false);
});
});
Summary#
Building this kind of tooling package rewards deliberate architecture upfront. A few principles proved most valuable:
Single responsibility down to the rule level. Each commitlint rule does exactly one thing.
jira-task-id checks format; jira-task-id-case checks case; jira-task-id-project-key checks the
project key. Common extraction logic lives in utils.ts, not duplicated across rules.
Pure functions wherever possible. resolveConfig, assembleCommitMessage, parseCommitMessage,
extractTaskIds — all pure. You can read them, test them, and reason about them in isolation with no
mocks needed.
Fail safely in all layers. Rules return outcomes, never throw. File I/O is wrapped. Git calls have fallbacks. The CLI distinguishes user cancellation from runtime errors. These are not edge cases — they are the normal conditions of a developer tool running in noisy environments.
One entrypoint for everything a project needs. One npm install, one defineConfig() call, one
set of hooks. When you own the full surface, you can enforce consistency and make the migration story
clean.
The combination of @inquirer/prompts for the interactive layer and @commitlint/config-conventional
as the base keeps runtime dependencies minimal (four packages total), while the esbuild/Vite dual-build
pipeline handles all the ESM/CJS compatibility work automatically.



