@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 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
- ## Self-update (`cmint update`)
39
+ ## Providers
40
40
 
41
- `cmint update` checks the npm registry for a newer version and, if one exists,
42
- runs the appropriate global install command for your package manager (detected
43
- from `npm_config_user_agent` npm, pnpm, yarn, or bun; falls back to npm).
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
- - If you're already on the latest version, it exits silently.
46
- - Otherwise it prints `current latest` and asks for confirmation before
47
- running the install (live npm output is streamed to your terminal).
48
- - `cmint update -y` (or `--yes`) skips the confirmation prompt — useful in
49
- scripts.
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.7.6",
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", ["status", "--short"]);
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
- "- Group by feature, fix, or concern (e.g., 'Frontend refactor', 'API changes', 'Test updates')",
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
- validated.push({
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 validated;
1541
+ return reunited;
1372
1542
  }
1373
1543
  const EXIT_CODES = {
1374
1544
  SUCCESS: 0,