@kyubiware/commit-mint 0.7.6 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -25
- package/dist/cli.mjs +176 -6
- package/dist/cli.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -36,17 +36,21 @@ cmint update # update cmint to the latest published version
|
|
|
36
36
|
|
|
37
37
|
See [all flags](#all-flags) for the full list.
|
|
38
38
|
|
|
39
|
-
##
|
|
39
|
+
## Providers
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
| Provider | Env var | Default model |
|
|
42
|
+
| -------- | ----------------- | -------------------- |
|
|
43
|
+
| Groq | `GROQ_API_KEY` | `openai/gpt-oss-20b` |
|
|
44
|
+
| Cerebras | `CEREBRAS_API_KEY` | `gpt-oss-120b` |
|
|
45
|
+
| Mistral | `MISTRAL_API_KEY` | `mistral-small` |
|
|
44
46
|
|
|
45
|
-
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
47
|
+
All three use OpenAI-compatible APIs and have a generous free tier. Groq uses the official SDK; Cerebras and
|
|
48
|
+
Mistral use a built-in fetch client. Per-provider model overrides: set
|
|
49
|
+
`model_groq`, `model_cerebras`, or `model_mistral` in `~/.commit-mint`.
|
|
50
|
+
Resolution order is `model_<provider>` → `model` → provider default.
|
|
51
|
+
|
|
52
|
+
`cmint config` walks you through provider, API key, model, locale, and
|
|
53
|
+
timeout.
|
|
50
54
|
|
|
51
55
|
## Pre-flight checks (`.cmintrc`)
|
|
52
56
|
|
|
@@ -112,6 +116,18 @@ What do you want to stage?
|
|
|
112
116
|
Run checks
|
|
113
117
|
```
|
|
114
118
|
|
|
119
|
+
## Self-update (`cmint update`)
|
|
120
|
+
|
|
121
|
+
`cmint update` checks the npm registry for a newer version and, if one exists,
|
|
122
|
+
runs the appropriate global install command for your package manager (detected
|
|
123
|
+
from `npm_config_user_agent` — npm, pnpm, yarn, or bun; falls back to npm).
|
|
124
|
+
|
|
125
|
+
- If you're already on the latest version, it exits silently.
|
|
126
|
+
- Otherwise it prints `current → latest` and asks for confirmation before
|
|
127
|
+
running the install (live npm output is streamed to your terminal).
|
|
128
|
+
- `cmint update -y` (or `--yes`) skips the confirmation prompt — useful in
|
|
129
|
+
scripts.
|
|
130
|
+
|
|
115
131
|
## AI Agent Mode
|
|
116
132
|
|
|
117
133
|
`cmint --agent` runs non-interactively with JSON output for AI coding agents.
|
|
@@ -194,22 +210,6 @@ they happen.
|
|
|
194
210
|
Errors are parsed from **lint-staged**, **biome**, **tsc**, **vitest**/**jest**,
|
|
195
211
|
and **eslint**. Unrecognized output falls back to a single raw-stderr entry.
|
|
196
212
|
|
|
197
|
-
## Providers
|
|
198
|
-
|
|
199
|
-
| Provider | Env var | Default model |
|
|
200
|
-
| -------- | ----------------- | -------------------- |
|
|
201
|
-
| Groq | `GROQ_API_KEY` | `openai/gpt-oss-20b` |
|
|
202
|
-
| Cerebras | `CEREBRAS_API_KEY` | `gpt-oss-120b` |
|
|
203
|
-
| Mistral | `MISTRAL_API_KEY` | `mistral-small` |
|
|
204
|
-
|
|
205
|
-
All three use OpenAI-compatible APIs. Groq uses the official SDK; Cerebras and
|
|
206
|
-
Mistral use a built-in fetch client. Per-provider model overrides: set
|
|
207
|
-
`model_groq`, `model_cerebras`, or `model_mistral` in `~/.commit-mint`.
|
|
208
|
-
Resolution order is `model_<provider>` → `model` → provider default.
|
|
209
|
-
|
|
210
|
-
`cmint config` walks you through provider, API key, model, locale, and
|
|
211
|
-
timeout.
|
|
212
|
-
|
|
213
213
|
## Configuration
|
|
214
214
|
|
|
215
215
|
`~/.commit-mint` (INI format). Run `cmint config` to edit.
|
package/dist/cli.mjs
CHANGED
|
@@ -29,7 +29,7 @@ var __exportAll = (all, no_symbols) => {
|
|
|
29
29
|
//#region package.json
|
|
30
30
|
var package_default = {
|
|
31
31
|
name: "@kyubiware/commit-mint",
|
|
32
|
-
version: "0.
|
|
32
|
+
version: "0.8.0",
|
|
33
33
|
description: "🌿 AI-powered git commit tool — auto-group changed files, generate messages, run pre-commit checks",
|
|
34
34
|
type: "module",
|
|
35
35
|
bin: { "cmint": "./dist/cli.mjs" },
|
|
@@ -1026,7 +1026,11 @@ async function getStatusShort() {
|
|
|
1026
1026
|
return stdout.trim();
|
|
1027
1027
|
}
|
|
1028
1028
|
async function getChangedFiles() {
|
|
1029
|
-
const { stdout } = await execa("git", [
|
|
1029
|
+
const { stdout } = await execa("git", [
|
|
1030
|
+
"status",
|
|
1031
|
+
"--short",
|
|
1032
|
+
"--untracked-files=all"
|
|
1033
|
+
]);
|
|
1030
1034
|
if (!stdout.trim()) return [];
|
|
1031
1035
|
const files = stdout.split("\n").filter(Boolean).map((line) => {
|
|
1032
1036
|
const indexStatus = line[0];
|
|
@@ -1167,6 +1171,168 @@ function parseGroupingResponse(content) {
|
|
|
1167
1171
|
throw new Error("AI response did not contain a JSON array");
|
|
1168
1172
|
}
|
|
1169
1173
|
//#endregion
|
|
1174
|
+
//#region src/services/grouping-reunite.ts
|
|
1175
|
+
/**
|
|
1176
|
+
* # Test/source reunification
|
|
1177
|
+
*
|
|
1178
|
+
* AI grouping prompts ask the model to keep a source file and its tests in the
|
|
1179
|
+
* same commit group, but the model frequently ignores that instruction (it
|
|
1180
|
+
* split `git.ts` from `git.test.ts` in the bug report that motivated this
|
|
1181
|
+
* module). The functions here provide a deterministic post-processing pass
|
|
1182
|
+
* that moves misplaced test files back into the group already containing
|
|
1183
|
+
* their source counterpart, regardless of what the model decided.
|
|
1184
|
+
*
|
|
1185
|
+
* Supported layouts (in priority order):
|
|
1186
|
+
* 1. Co-located: `src/foo.ts` ↔ `src/foo.test.ts`
|
|
1187
|
+
* 2. `__tests__/` mirror: `src/foo.ts` ↔ `src/__tests__/foo.test.ts`
|
|
1188
|
+
* 3. `tests/` or `test/` mirror: `src/foo.ts` ↔ `tests/foo.test.ts`
|
|
1189
|
+
*
|
|
1190
|
+
* Reunification runs inside `validateGroups()` (see `grouping.ts`) after
|
|
1191
|
+
* hallucinated-path filtering so only real files participate.
|
|
1192
|
+
*/
|
|
1193
|
+
/** Suffixes that mark a file as a test companion of a same-name source. */
|
|
1194
|
+
const TEST_SUFFIXES = [".test", ".spec"];
|
|
1195
|
+
/**
|
|
1196
|
+
* Extensions tried when looking for a source counterpart for a test file. The
|
|
1197
|
+
* test file's own extension is usually the right one, but we also try common
|
|
1198
|
+
* alternates (a `.test.tsx` may back a `.tsx` or a `.ts`).
|
|
1199
|
+
*/
|
|
1200
|
+
const SOURCE_EXTENSIONS = [
|
|
1201
|
+
".ts",
|
|
1202
|
+
".tsx",
|
|
1203
|
+
".js",
|
|
1204
|
+
".jsx",
|
|
1205
|
+
".mjs",
|
|
1206
|
+
".cjs",
|
|
1207
|
+
".mts",
|
|
1208
|
+
".cts"
|
|
1209
|
+
];
|
|
1210
|
+
/** Matches a test file by extension regardless of which suffix it uses. */
|
|
1211
|
+
const TEST_FILE_PATTERN = /\.(?:test|spec)\.(?:ts|tsx|js|jsx|mjs|cjs|mts|cts)$/;
|
|
1212
|
+
/** Directory prefixes/segments that mirror source layout for non-co-located tests. */
|
|
1213
|
+
const TEST_DIR_PREFIXES = ["tests/", "test/"];
|
|
1214
|
+
/** Marker segment for co-located `__tests__/` directories. */
|
|
1215
|
+
const TESTS_DIR_SEGMENT = "/__tests__/";
|
|
1216
|
+
function stripTestSuffix(filename) {
|
|
1217
|
+
for (const suffix of TEST_SUFFIXES) {
|
|
1218
|
+
const marker = `${suffix}.`;
|
|
1219
|
+
const idx = filename.lastIndexOf(marker);
|
|
1220
|
+
if (idx > 0) return filename.slice(0, idx);
|
|
1221
|
+
}
|
|
1222
|
+
return null;
|
|
1223
|
+
}
|
|
1224
|
+
function withEachExtension(base) {
|
|
1225
|
+
return SOURCE_EXTENSIONS.map((ext) => `${base}${ext}`);
|
|
1226
|
+
}
|
|
1227
|
+
/** Co-located: `dir/foo.test.ts` → `dir/foo.{ts,tsx,...}` */
|
|
1228
|
+
function colocatedCandidates(testPath) {
|
|
1229
|
+
const base = stripTestSuffix(testPath);
|
|
1230
|
+
return base === null ? [] : withEachExtension(base);
|
|
1231
|
+
}
|
|
1232
|
+
/** `__tests__` mirror: `src/__tests__/foo.test.ts` → `src/foo.{ts,tsx,...}` */
|
|
1233
|
+
function testsDirCandidates(testPath) {
|
|
1234
|
+
const segmentIdx = testPath.indexOf(TESTS_DIR_SEGMENT);
|
|
1235
|
+
if (segmentIdx < 0) return [];
|
|
1236
|
+
const parentDir = testPath.slice(0, segmentIdx);
|
|
1237
|
+
const base = stripTestSuffix(testPath.slice(segmentIdx + 11));
|
|
1238
|
+
if (base === null) return [];
|
|
1239
|
+
return withEachExtension(`${parentDir}/${base}`);
|
|
1240
|
+
}
|
|
1241
|
+
/** `tests/` or `test/` mirror: `tests/services/foo.test.ts` → `src/services/foo.{ts,tsx,...}` */
|
|
1242
|
+
function prefixedDirCandidates(testPath) {
|
|
1243
|
+
for (const prefix of TEST_DIR_PREFIXES) {
|
|
1244
|
+
if (!testPath.startsWith(prefix)) continue;
|
|
1245
|
+
const base = stripTestSuffix(testPath.slice(prefix.length));
|
|
1246
|
+
if (base === null) return [];
|
|
1247
|
+
return withEachExtension(`src/${base}`);
|
|
1248
|
+
}
|
|
1249
|
+
return [];
|
|
1250
|
+
}
|
|
1251
|
+
/**
|
|
1252
|
+
* Compute candidate source paths for a test file in priority order: co-located
|
|
1253
|
+
* first, then `__tests__/` mirror, then `tests/`/`test/` mirror. Each mirror
|
|
1254
|
+
* emits one entry per `SOURCE_EXTENSIONS` candidate so the caller can match
|
|
1255
|
+
* against any of them.
|
|
1256
|
+
*/
|
|
1257
|
+
function candidateSourcePaths(testPath) {
|
|
1258
|
+
return [
|
|
1259
|
+
...colocatedCandidates(testPath),
|
|
1260
|
+
...testsDirCandidates(testPath),
|
|
1261
|
+
...prefixedDirCandidates(testPath)
|
|
1262
|
+
];
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* Resolve the unambiguous target group for a single test file, or `null` when
|
|
1266
|
+
* no source counterpart exists / matches more than one group (ambiguous). A
|
|
1267
|
+
* test whose candidates span multiple groups (e.g. `foo.ts` and `foo.tsx` in
|
|
1268
|
+
* different groups) is intentionally left alone rather than guessed.
|
|
1269
|
+
*/
|
|
1270
|
+
function findTargetGroup(testFile, currentGi, fileToGroup) {
|
|
1271
|
+
const targetGroups = /* @__PURE__ */ new Set();
|
|
1272
|
+
for (const candidate of candidateSourcePaths(testFile)) {
|
|
1273
|
+
const targetGi = fileToGroup.get(candidate);
|
|
1274
|
+
if (targetGi !== void 0 && targetGi !== currentGi) targetGroups.add(targetGi);
|
|
1275
|
+
}
|
|
1276
|
+
return targetGroups.size === 1 ? [...targetGroups][0] : null;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* Find the unambiguous target group for each misplaced test file.
|
|
1280
|
+
*
|
|
1281
|
+
* Returns a map of `testFile → targetGroupIndex`. A test is only moved when
|
|
1282
|
+
* its candidate source matches exactly one other group; ambiguous matches are
|
|
1283
|
+
* skipped (see {@link findTargetGroup}).
|
|
1284
|
+
*/
|
|
1285
|
+
function findMoves(groups, fileToGroup) {
|
|
1286
|
+
const moves = /* @__PURE__ */ new Map();
|
|
1287
|
+
for (let gi = 0; gi < groups.length; gi++) for (const file of groups[gi].files) {
|
|
1288
|
+
if (moves.has(file) || !TEST_FILE_PATTERN.test(file)) continue;
|
|
1289
|
+
const target = findTargetGroup(file, gi, fileToGroup);
|
|
1290
|
+
if (target !== null) moves.set(file, target);
|
|
1291
|
+
}
|
|
1292
|
+
return moves;
|
|
1293
|
+
}
|
|
1294
|
+
/**
|
|
1295
|
+
* Apply queued moves to a fresh copy of the groups array. Tests are removed
|
|
1296
|
+
* from their original group and appended to the target group. Groups left
|
|
1297
|
+
* empty by moves are dropped.
|
|
1298
|
+
*/
|
|
1299
|
+
function applyMoves(groups, moves) {
|
|
1300
|
+
const result = groups.map((g) => ({
|
|
1301
|
+
...g,
|
|
1302
|
+
files: [...g.files]
|
|
1303
|
+
}));
|
|
1304
|
+
const movedFiles = new Set(moves.keys());
|
|
1305
|
+
const additionsByGroup = /* @__PURE__ */ new Map();
|
|
1306
|
+
for (const [file, toGroup] of moves) {
|
|
1307
|
+
const bucket = additionsByGroup.get(toGroup) ?? [];
|
|
1308
|
+
bucket.push(file);
|
|
1309
|
+
additionsByGroup.set(toGroup, bucket);
|
|
1310
|
+
}
|
|
1311
|
+
for (let gi = 0; gi < result.length; gi++) {
|
|
1312
|
+
if (movedFiles.size > 0) result[gi].files = result[gi].files.filter((f) => !movedFiles.has(f));
|
|
1313
|
+
const additions = additionsByGroup.get(gi);
|
|
1314
|
+
if (!additions) continue;
|
|
1315
|
+
const existing = new Set(result[gi].files);
|
|
1316
|
+
for (const file of additions) if (!existing.has(file)) result[gi].files.push(file);
|
|
1317
|
+
}
|
|
1318
|
+
return result.filter((g) => g.files.length > 0);
|
|
1319
|
+
}
|
|
1320
|
+
/**
|
|
1321
|
+
* Move test files (`.test.*` / `.spec.*`) into the group that already contains
|
|
1322
|
+
* their source counterpart. Returns the same array reference if no moves were
|
|
1323
|
+
* needed; otherwise returns a fresh array of new group objects.
|
|
1324
|
+
*
|
|
1325
|
+
* See module doc for the matching algorithm.
|
|
1326
|
+
*/
|
|
1327
|
+
function reuniteTestsWithSources(groups) {
|
|
1328
|
+
if (groups.length === 0) return groups;
|
|
1329
|
+
const fileToGroup = /* @__PURE__ */ new Map();
|
|
1330
|
+
for (let gi = 0; gi < groups.length; gi++) for (const file of groups[gi].files) fileToGroup.set(file, gi);
|
|
1331
|
+
const moves = findMoves(groups, fileToGroup);
|
|
1332
|
+
if (moves.size === 0) return groups;
|
|
1333
|
+
return applyMoves(groups, moves);
|
|
1334
|
+
}
|
|
1335
|
+
//#endregion
|
|
1170
1336
|
//#region src/services/grouping.ts
|
|
1171
1337
|
function matchesExcludePattern(filePath, pattern) {
|
|
1172
1338
|
if (pattern === filePath) return true;
|
|
@@ -1227,7 +1393,8 @@ function buildGroupingSystemPrompt() {
|
|
|
1227
1393
|
"You are analyzing changed files in a git repository. Group them into logical commits based on what changed and why. Each group should be a coherent unit of work.",
|
|
1228
1394
|
"",
|
|
1229
1395
|
"Rules:",
|
|
1230
|
-
"-
|
|
1396
|
+
"- ALWAYS keep a test file in the same group as the source file it tests. Examples: `foo.test.ts` stays with `foo.ts`; `__tests__/foo.test.ts` stays with `foo.ts` in the parent directory; `tests/foo.test.ts` stays with `src/foo.ts`. Never put source and its tests in separate groups.",
|
|
1397
|
+
"- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes')",
|
|
1231
1398
|
"- Keep related files together (e.g., a component + its test, a model + its migration)",
|
|
1232
1399
|
"- Separate documentation changes (*.md files, docs/) from code changes — put docs in their own group",
|
|
1233
1400
|
"- Do not split a single logical change across multiple groups",
|
|
@@ -1255,12 +1422,13 @@ function buildRetryGroupingPrompt() {
|
|
|
1255
1422
|
"You MUST split the files into at least 2 groups based on what changed and why.",
|
|
1256
1423
|
"",
|
|
1257
1424
|
"Look for these natural split points:",
|
|
1258
|
-
"- Source code vs tests",
|
|
1259
1425
|
"- Different features or modules (e.g., different directories)",
|
|
1260
1426
|
"- New files vs modified files vs deleted files",
|
|
1261
1427
|
"- Configuration changes vs code changes",
|
|
1262
1428
|
"- Documentation vs implementation",
|
|
1263
1429
|
"",
|
|
1430
|
+
"Do NOT split a source file from its tests — keep `foo.ts` and `foo.test.ts` in the same group.",
|
|
1431
|
+
"",
|
|
1264
1432
|
"If unsure, err on the side of MORE groups, not fewer.",
|
|
1265
1433
|
"",
|
|
1266
1434
|
"Output format: JSON array of objects with keys 'name', 'description', 'files'.",
|
|
@@ -1359,16 +1527,18 @@ function validateGroups(groups, allFiles) {
|
|
|
1359
1527
|
files: uniqueFiles
|
|
1360
1528
|
});
|
|
1361
1529
|
}
|
|
1530
|
+
const reunited = reuniteTestsWithSources(validated);
|
|
1531
|
+
if (reunited !== validated) debug("validateGroups: reunited %d groups after test/source merge", reunited.length);
|
|
1362
1532
|
const ungrouped = allFiles.filter((f) => !seen.has(f.path));
|
|
1363
1533
|
if (ungrouped.length > 0) {
|
|
1364
1534
|
debug("validateGroups: %d ungrouped files added to 'Other changes'", ungrouped.length);
|
|
1365
|
-
|
|
1535
|
+
reunited.push({
|
|
1366
1536
|
name: "Other changes",
|
|
1367
1537
|
description: "Miscellaneous changes that did not fit into other groups",
|
|
1368
1538
|
files: ungrouped.map((f) => f.path)
|
|
1369
1539
|
});
|
|
1370
1540
|
}
|
|
1371
|
-
return
|
|
1541
|
+
return reunited;
|
|
1372
1542
|
}
|
|
1373
1543
|
const EXIT_CODES = {
|
|
1374
1544
|
SUCCESS: 0,
|