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

A dark, minimalist background features four glowing amber circular nodes connected in a horizontal line—labeled branch, prompt, and rules, ending in a bright checkmark—forming a sleek, futuristic validation flow diagram.

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:

  1. Commitlint rules — custom validation logic wired into commitlint's plugin API
  2. Configuration factory — a defineConfig() function that produces a full commitlint UserConfig
  3. Interactive CLI — a prepare-commit-msg hook replacement that guides developers step-by-step

The target commit message format is:

Text
type: PROJ-NNNN - Commit message subject

Optional body paragraph.

BREAKING CHANGE: Optional description.

For example:

Text
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.

Text
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:

Rendering diagram...

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:

JSON
{
  "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:

TypeScript
// 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:

  • RuleOutcome mirrors 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.
  • PromptMode as 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:

TypeScript
// 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.

TypeScript
// 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:

TypeScript
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.

TypeScript
// 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:

TypeScript
// 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:

TypeScript
// 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:

JavaScript
// 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:

Rendering diagram...

Branch Intelligence#

Before showing any prompts the engine reads the current git branch to pre-fill the type and JIRA ID:

TypeScript
// 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:

TypeScript
// 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:

TypeScript
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:

TypeScript
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:

TypeScript
// 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.

TypeScript
// 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:

TypeScript
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.

Rendering diagram...

.husky/prepare-commit-msg — runs the interactive prompt:

Bash
# 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:

Bash
npx --no-install commitlint --edit "$1"

Environment Variable Escape Hatch#

Bash
# 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:

JSON
{
  "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"
  }
}
StageToolInputOutput
Cleanrimrafdist/, dts/
LibraryVite/Rollupsrc/index.tsdist/commitlint-jira.{js,cjs,d.ts,d.cts}
Config exportesbuildsrc/config-export.tsdist/config-export.{mjs,cjs}
CLI binaryesbuildsrc/bin/jira-commit.tsdist/bin/jira-commit.mjs
Rendering diagram...

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:

LayerPatternFallback
RulesReturn [false, errorMessage] — never throwAlways returns RuleOutcome
Branch utilstry/catch around execSync'chore' (type), null (JIRA ID)
File I/Otry/catch around readFileSyncundefined
Config loadingtry/catch around dynamic import()undefined (uses defaults)
CLI mainTop-level catch; distinguish ExitPromptError from runtime errorsExit 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 ParsedCommit shaped 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 assembleCommitMessage and shouldSkipPrompt directly; mock @inquirer/prompts for integration-level prompt tests.
  • CLI utils: parseArgs, resolveAmendPrefill, shouldSkipPrompt are all testable without I/O.
  • Branch utils: Mock execSync to test fallback behavior without a real git repository.

Example rule test pattern:

TypeScript
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.

Share:

Related Articles