Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/commit-msg
Original file line number Diff line number Diff line change
@@ -1 +1 @@
pnpm exec commitlint --edit $1
git stripspace --strip-comments < "$1" | node scripts/lint-git-commit-msg.mts
10 changes: 6 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,20 @@
"format": "pnpm oxfmt",
"format:check": "pnpm oxfmt --check",
"install:chromium": "pnpm --filter=@solidmaterial/material exec playwright install chromium --with-deps",
"install:webkit": "pnpm --filter=@solidmaterial/material exec playwright install webkit --with-deps"
"install:webkit": "pnpm --filter=@solidmaterial/material exec playwright install webkit --with-deps",
"scripts:test": "vitest run --coverage --project scripts"
},
"devDependencies": {
"@commitlint/cli": "21.2.1",
"@commitlint/config-conventional": "21.2.0",
"@tsconfig/strictest": "2.0.8",
"@types/node": "24.13.2",
"@vitest/coverage-istanbul": "catalog:vitest",
"husky": "9.1.7",
"oxfmt": "0.56.0",
"oxlint": "1.71.0",
"oxlint-tsgolint": "0.23.0",
"turbo": "2.10.11",
"typescript": "6.0.2"
"typescript": "6.0.2",
"vitest": "catalog:vitest"
},
"devEngines": {
"packageManager": {
Expand Down
626 changes: 13 additions & 613 deletions pnpm-lock.yaml

Large diffs are not rendered by default.

74 changes: 74 additions & 0 deletions scripts/helpers/lint-git-commit-msg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const isLowerCase = (value: string): boolean => value.toLowerCase() === value;

// Written by Gemma 4 31B, modified to fix lint warnings

const HEADER_REGEX = /^(?<type>[a-zA-Z]+)(?:\((?<scope>[a-zA-Z0-9-]+)\))?(?<breaking>!)?: (?<description>.+)$/u;

export type Config = {
readonly MAX_SUBJECT_LENGTH: number;
readonly MAX_BODY_LINE_LENGTH: number;
readonly ALLOWED_TYPES: Set<string>;
readonly ALLOWED_SCOPES: Set<string>;
};

export type ProcessExit = {
exit: 0 | 1;
error?: string;
};

const error = (message: string): ProcessExit => ({
exit: 1,
error: message
});

export const validate = (content: string, config: Config): ProcessExit => {
const lines = content.split(/\n/u).map(l => l.trimEnd());
const [header, blank, ...body] = lines;

// oxlint-disable-next-line typescript/strict-boolean-expressions
if (!header) {
return error('commit message is empty');
}

if (header.length > config.MAX_SUBJECT_LENGTH) {
return error(`header exceeds ${config.MAX_SUBJECT_LENGTH} characters`);
}

const groups = HEADER_REGEX.exec(header)?.groups;

if (!groups) {
return error("header must match '<type>[(<scope>)][!]: <description>'");
}

const { type, scope, description } = groups;

if (type !== undefined && !config.ALLOWED_TYPES.has(type)) {
return error(`type '${type}' is not allowed; allowed types: ${[...config.ALLOWED_TYPES].join(', ')}`);
}

if (scope !== undefined && !config.ALLOWED_SCOPES.has(scope)) {
return error(`scope '${scope}' is not allowed; allowed scopes: ${[...config.ALLOWED_SCOPES].join(', ')}`);
}

if (description !== undefined) {
if (description.endsWith('.')) {
return error('description must not end with a period');
}

if (description.length > 0 && !isLowerCase(description[0]!)) {
return error('description must start with a lower case character');
}
}

if (blank !== undefined && blank !== '') {
return error('blank line required between header and body');
}

for (const [index, line] of body.entries()) {
if (line && line.length > config.MAX_BODY_LINE_LENGTH) {
return error(`body line ${index + 3} exceeds ${config.MAX_BODY_LINE_LENGTH} characters`);
}
}

return { exit: 0 };
};
40 changes: 40 additions & 0 deletions scripts/lint-git-commit-msg.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/// <reference types="node" />

// oxlint-disable-next-line import/no-nodejs-modules
import fs from 'node:fs';
// oxlint-disable-next-line import/no-nodejs-modules
import process from 'node:process';

import type { Config } from './helpers/lint-git-commit-msg';

import { validate } from './helpers/lint-git-commit-msg.ts';

const getPackageNames = (): string[] => ['apps', 'packages'].flatMap(name => fs.readdirSync(name));

const CONFIG: Config = {
MAX_SUBJECT_LENGTH: 72,
MAX_BODY_LINE_LENGTH: 79,
ALLOWED_TYPES: new Set([
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert'
]),
ALLOWED_SCOPES: new Set(getPackageNames())
} as const;

const result = validate(fs.readFileSync(0, 'utf8'), CONFIG);

if (result.error !== undefined) {
process.stderr.write(`ERROR: ${result.error}\n`);
}

// oxlint-disable-next-line unicorn/no-process-exit
process.exit(result.exit);
159 changes: 159 additions & 0 deletions scripts/lint-git-commit-msg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { describe, expect, it } from 'vitest';

import type { Config } from './helpers/lint-git-commit-msg';

import { validate } from './helpers/lint-git-commit-msg';

const CONFIG: Config = {
MAX_SUBJECT_LENGTH: 72,
MAX_BODY_LINE_LENGTH: 79,
ALLOWED_TYPES: new Set([
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert'
]),
ALLOWED_SCOPES: new Set(['valid-scope'])
} as const;

// Written by Gemma 4 31B, modified to fix lint warnings

describe('Lint Git commit message', () => {
describe('Happy Path', () => {
it('should accept a simple valid header', () => {
const { exit } = validate('feat: add user authentication', CONFIG);
expect(exit).toBe(0);
});

it('should accept a header with a valid scope', () => {
const { exit } = validate(`fix(valid-scope): resolve memory leak`, CONFIG);
expect(exit).toBe(0);
});

it('should accept a breaking change with scope', () => {
const { exit } = validate(`refactor(valid-scope)!: rewrite button API`, CONFIG);
expect(exit).toBe(0);
});

it('should accept a breaking change without scope', () => {
const { exit } = validate('feat!: remove deprecated endpoints', CONFIG);
expect(exit).toBe(0);
});

it('should accept a valid header and body', () => {
const { exit } = validate('chore: update dependencies\n\nBump package to v1.2.3', CONFIG);
expect(exit).toBe(0);
});

it('should accept a multi-line body', () => {
const { exit } = validate('docs: update readme\n\nLine 1\nLine 2', CONFIG);
expect(exit).toBe(0);
});
});

describe('Header Validation', () => {
it('should fail on empty input', () => {
const { exit, error } = validate('', CONFIG);
expect(exit).toBe(1);
expect(error).toContain('commit message is empty');
});

it('should fail if header exceeds 72 characters', () => {
const { exit, error } = validate(`feat: ${'a'.repeat(67)}`, CONFIG);
expect(exit).toBe(1);
expect(error).toContain('header exceeds 72 characters');
});

it('should fail on invalid format (missing colon)', () => {
const { exit, error } = validate('this is a random commit message', CONFIG);
expect(exit).toBe(1);
expect(error).toContain('header must match');
});

it('should fail on unsupported type', () => {
const { exit, error } = validate('update(web): change logic', CONFIG);
expect(exit).toBe(1);
expect(error).toContain("type 'update' is not allowed");
});

it('should fail on unsupported scope', () => {
const { exit, error } = validate('feat(nonexistent-scope): add endpoint', CONFIG);
expect(exit).toBe(1);
expect(error).toContain("scope 'nonexistent-scope' is not allowed");
});

it('should fail on uppercase type', () => {
const { exit, error } = validate('Feat: add login', CONFIG);
expect(exit).toBe(1);
expect(error).toContain("type 'Feat' is not allowed");
});
});

describe('Description Rules', () => {
it('should fail if description ends with a period', () => {
const { exit, error } = validate('fix: resolve bug.', CONFIG);
expect(exit).toBe(1);
expect(error).toContain('description must not end with a period');
});

it('should fail if description starts with upper case character', () => {
const { exit, error } = validate('feat: Add new feature', CONFIG);
expect(exit).toBe(1);
expect(error).toContain('description must start with a lower case character');
});

it('should succeed if description starts with a number', () => {
const { exit } = validate('feat: 123 fix issue', CONFIG);
expect(exit).toBe(0);
});
});

describe('Body and Spacing', () => {
it('should fail if blank line is missing between header and body', () => {
const { exit, error } = validate('feat: add login\nImmediately starting body', CONFIG);
expect(exit).toBe(1);
expect(error).toContain('blank line required');
});

it('should fail if body line exceeds 79 characters', () => {
const { exit, error } = validate(`feat: add login\n\n${'a'.repeat(80)}`, CONFIG);
expect(exit).toBe(1);
expect(error).toContain('body line 3 exceeds 79 characters');
});

it('should report correct line number for body errors', () => {
const { exit, error } = validate(`feat: add login\n\nShort line\n${'a'.repeat(80)}`, CONFIG);
expect(exit).toBe(1);
expect(error).toContain('body line 4 exceeds 79 characters');
});
});

describe('Edge Cases', () => {
it('should handle unicode/emojis in description', () => {
const { exit } = validate(`feat: add 🚀 emoji support`, CONFIG);
expect(exit).toBe(0);
});

it('should ignore trailing whitespace in header for length checks', () => {
const { exit } = validate(`feat: add login${' '.repeat(70)}`, CONFIG);
expect(exit).toBe(0);
});

it('should accept a commit with only a header', () => {
const { exit } = validate('feat: add login', CONFIG);
expect(exit).toBe(0);
});

it('should allow empty lines within the body', () => {
const { exit } = validate('feat: add login\n\nLine 1\n\nLine 3', CONFIG);
expect(exit).toBe(0);
});
});
});
10 changes: 10 additions & 0 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
{
"extends": "./tsconfig.base.json",
"compilerOptions": {
"types": [],
"rootDir": "scripts",
"allowImportingTsExtensions": true,
"noEmit": true
},
"include": ["scripts"]
}
25 changes: 25 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { defineConfig } from 'vitest/config';

// oxlint-disable-next-line import/no-default-export
export default defineConfig({
test: {
environment: 'node',
globals: true,
reporters: ['default', 'junit'],
outputFile: 'coverage/tests.junit.xml',
coverage: {
provider: 'istanbul',
reporter: [['cobertura', { file: 'coverage.xml' }], ['text']],
reportOnFailure: true
},
projects: [
{
extends: true,
test: {
name: 'scripts',
include: ['scripts/*.test.ts']
}
}
]
}
});
Loading