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
75 changes: 65 additions & 10 deletions src/history/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,67 @@ import { getHistoryPath, getConfigDir } from '../config/store.js';

const CONVENTIONAL_PREFIX_RE = /^(feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(\([^)]+\))?:\s*/;

const BASE_FORM_VERBS_WITH_SUFFIXES = new Set([
Comment thread
404-Page-Found marked this conversation as resolved.
'bed',
'bleed',
'breed',
'bring',
'cling',
'deed',
'ding',
'embed',
'exceed',
'feed',
'fling',
'heed',
'imbed',
'king',
'need',
'ping',
'proceed',
'ring',
'seed',
'shed',
'shred',
'sing',
'sled',
'sling',
'speed',
'spring',
'sting',
'string',
'succeed',
'swing',
'wed',
'weed',
'wing',
'wring',
'zing',
]);

const VERB_PREFIXES = ['counter', 'inter', 'over', 'under', 'fore', 'mis', 'pre', 'un', 're'];

// Full verbs that would falsely match a prefix + base-form stem (e.g. "resting" = "re" + "sting").
const VERB_PREFIX_STRIP_COLLISIONS = new Set(['resting']);

function stripVerbPrefix(verb: string): string {
for (const prefix of VERB_PREFIXES) {
if (verb.length > prefix.length && verb.startsWith(prefix)) {
const stem = verb.slice(prefix.length);
if (BASE_FORM_VERBS_WITH_SUFFIXES.has(stem) && !VERB_PREFIX_STRIP_COLLISIONS.has(verb)) {
return stem;
}
}
}
return verb;
}

function isDescriptiveVerb(verb: string): boolean {
const normalized = verb.toLowerCase();
const base = stripVerbPrefix(normalized);
return !BASE_FORM_VERBS_WITH_SUFFIXES.has(base) && (normalized.endsWith('ed') || normalized.endsWith('ing'));
}

const HISTORY_LOCK_RETRY_MS = 25;
const HISTORY_LOCK_STALE_MS = 60_000;
const HISTORY_LOCK_TIMEOUT_MS = HISTORY_LOCK_STALE_MS + 10_000;
Expand Down Expand Up @@ -309,17 +370,11 @@ export async function buildProfile(historySize: number): Promise<StyleProfile> {
const verbMatch = firstLine.match(
/^(?:feat|fix|chore|docs|style|refactor|perf|test|build|ci|revert)(?:\([^)]+\))?:\s*(\w+)/,
);
if (verbMatch) {
const verb = verbMatch[1]!;
if (!verb.endsWith('ed') && !verb.endsWith('ing')) {
imperativeCount++;
imperativeSampleCount++;
}
} else {
const firstWord = firstLine.match(/^\w+/);
if (firstWord && !firstWord[0]!.endsWith('ed') && !firstWord[0]!.endsWith('ing')) {
const verb = verbMatch?.[1] ?? /^\w+/.exec(firstLine)?.[0];
if (verb) {
imperativeSampleCount++;
if (!isDescriptiveVerb(verb)) {
imperativeCount++;
Comment thread
404-Page-Found marked this conversation as resolved.
imperativeSampleCount++;
}
}

Expand Down
76 changes: 74 additions & 2 deletions tests/history-profile.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ function restoreEnv(name, value) {
}
}

test('buildProfile excludes descriptive verb forms from the imperative-rate denominator', async () => {
test('buildProfile counts descriptive verb forms in the imperative-rate denominator', async () => {
const originalHome = process.env.HOME;
const originalAppData = process.env.APPDATA;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
Expand All @@ -58,7 +58,79 @@ test('buildProfile excludes descriptive verb forms from the imperative-rate deno
const profile = await buildProfile(10);

assert.equal(profile.totalCommits, 3);
assert.equal(profile.imperativeRate, 1);
assert.equal(profile.imperativeRate, 1 / 3);
} finally {
restoreEnv('HOME', originalHome);
restoreEnv('APPDATA', originalAppData);
restoreEnv('XDG_CONFIG_HOME', originalXdgConfigHome);
rmSync(tempHome, { recursive: true, force: true });
}
});

test('buildProfile recognizes base-form verbs that end in descriptive suffixes', async () => {
const originalHome = process.env.HOME;
const originalAppData = process.env.APPDATA;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const tempHome = mkdtempSync(join(tmpdir(), 'commit-echo-home-'));

try {
process.env.HOME = tempHome;
process.env.APPDATA = join(tempHome, 'AppData', 'Roaming');
process.env.XDG_CONFIG_HOME = join(tempHome, '.config');
writeHistory(tempHome, ['fix: add retries', 'fix: Bring retries', 'fix: added retries']);

const profile = await buildProfile(10);

assert.equal(profile.totalCommits, 3);
assert.equal(profile.imperativeRate, 2 / 3);
} finally {
restoreEnv('HOME', originalHome);
restoreEnv('APPDATA', originalAppData);
restoreEnv('XDG_CONFIG_HOME', originalXdgConfigHome);
rmSync(tempHome, { recursive: true, force: true });
}
});

test('buildProfile recognizes prefix-derived base-form verbs as imperative', async () => {
const originalHome = process.env.HOME;
const originalAppData = process.env.APPDATA;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const tempHome = mkdtempSync(join(tmpdir(), 'commit-echo-home-'));

try {
process.env.HOME = tempHome;
process.env.APPDATA = join(tempHome, 'AppData', 'Roaming');
process.env.XDG_CONFIG_HOME = join(tempHome, '.config');
writeHistory(tempHome, ['fix: reseed database', 'fix: preseed retries', 'fix: adding retries']);

const profile = await buildProfile(10);

assert.equal(profile.totalCommits, 3);
assert.equal(profile.imperativeRate, 2 / 3);
} finally {
restoreEnv('HOME', originalHome);
restoreEnv('APPDATA', originalAppData);
restoreEnv('XDG_CONFIG_HOME', originalXdgConfigHome);
rmSync(tempHome, { recursive: true, force: true });
}
});

test('buildProfile recognizes ed-suffix base-form verbs not in the base allowlist', async () => {
const originalHome = process.env.HOME;
const originalAppData = process.env.APPDATA;
const originalXdgConfigHome = process.env.XDG_CONFIG_HOME;
const tempHome = mkdtempSync(join(tmpdir(), 'commit-echo-home-'));

try {
process.env.HOME = tempHome;
process.env.APPDATA = join(tempHome, 'AppData', 'Roaming');
process.env.XDG_CONFIG_HOME = join(tempHome, '.config');
writeHistory(tempHome, ['fix: succeed after retry', 'fix: weed stale entries', 'fix: adding retries']);

const profile = await buildProfile(10);

assert.equal(profile.totalCommits, 3);
assert.equal(profile.imperativeRate, 2 / 3);
} finally {
restoreEnv('HOME', originalHome);
restoreEnv('APPDATA', originalAppData);
Expand Down