@kyubiware/commit-mint 0.7.5 → 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.5",
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,
@@ -2354,7 +2524,8 @@ async function agentCommand(flags) {
2354
2524
  //#endregion
2355
2525
  //#region src/services/update-check.ts
2356
2526
  const REGISTRY_URL = "https://registry.npmjs.org/-/package/@kyubiware/commit-mint/dist-tags";
2357
- const TTL_MS = 1440 * 60 * 1e3;
2527
+ const FRESH_MS = 3600 * 1e3;
2528
+ const STALE_MS = 1440 * 60 * 1e3;
2358
2529
  const FETCH_TIMEOUT_MS = 5e3;
2359
2530
  let cachePath = join(os.homedir(), ".cache", "commit-mint", "update-check.json");
2360
2531
  let fetchImpl = globalThis.fetch;
@@ -2442,6 +2613,70 @@ function displayNag(current, latest) {
2442
2613
  log.warn(message);
2443
2614
  }
2444
2615
  /**
2616
+ * Fire-and-forget cache refresh used by the stale-while-revalidate (SWR) band
2617
+ * (cache age in [FRESH_MS, STALE_MS)). Runs the registry fetch and writes a
2618
+ * fresh cache entry without awaiting — the caller returns immediately with
2619
+ * the cached `latest` so the nag decision is fast.
2620
+ *
2621
+ * Wrapped in try/catch and `void`-ed so the floating promise NEVER rejects
2622
+ * (vitest fails the suite on unhandled rejections). On any failure (network,
2623
+ * HTTP non-ok, malformed JSON, fs write error) the cache file is left
2624
+ * unchanged and the failure is logged via {@link debug} only.
2625
+ *
2626
+ * No abort signal is wired here — the cancellable spinner only runs on the
2627
+ * STALE blocking-fetch path, and FRESH/SWR callers explicitly want this to
2628
+ * complete in the background regardless of user keystrokes.
2629
+ */
2630
+ function refreshCacheInBackground() {
2631
+ debug("refreshCacheInBackground: starting fire-and-forget refresh");
2632
+ (async () => {
2633
+ try {
2634
+ const latest = await fetchLatest();
2635
+ if (latest === null) {
2636
+ debug("refreshCacheInBackground: fetch returned null, leaving cache unchanged");
2637
+ return;
2638
+ }
2639
+ await saveCache({
2640
+ latest,
2641
+ checkedAt: Date.now()
2642
+ });
2643
+ debug("refreshCacheInBackground: refreshed cache to latest=%s", latest);
2644
+ } catch (err) {
2645
+ debug("refreshCacheInBackground: failed — %s", err instanceof Error ? err.message : String(err));
2646
+ }
2647
+ })();
2648
+ }
2649
+ /**
2650
+ * Resolve the cache-hit branch (FRESH / SWR / stale-or-missing). Returns
2651
+ * `null` when the caller must fall through to the blocking-fetch path; returns
2652
+ * a {@link UpdateCheckStatus} when the cache hit is terminal.
2653
+ *
2654
+ * Side effect: kicks off a fire-and-forget {@link refreshCacheInBackground}
2655
+ * when age is in the SWR band [FRESH_MS, STALE_MS).
2656
+ */
2657
+ function resolveCacheHit(cached, currentVersion, onNag) {
2658
+ if (cached === null) {
2659
+ debug("runUpdateCheck: no cache, fetching");
2660
+ return null;
2661
+ }
2662
+ const age = Date.now() - cached.checkedAt;
2663
+ if (age >= STALE_MS) {
2664
+ debug("runUpdateCheck: cache stale (>=%dh), refetching", STALE_MS / 36e5);
2665
+ return null;
2666
+ }
2667
+ if (age < FRESH_MS) debug("runUpdateCheck: cache fresh (<%dh), serving from cache", FRESH_MS / 36e5);
2668
+ else {
2669
+ debug("runUpdateCheck: cache in SWR band (<%dh), serving + background refresh", STALE_MS / 36e5);
2670
+ refreshCacheInBackground();
2671
+ }
2672
+ if (semver.gt(cached.latest, currentVersion)) {
2673
+ onNag(currentVersion, cached.latest);
2674
+ return "cache-update";
2675
+ }
2676
+ debug("runUpdateCheck: current >= latest, no nag");
2677
+ return "cache-current";
2678
+ }
2679
+ /**
2445
2680
  * Run the full update check. Exported for tests; the public surface is
2446
2681
  * {@link checkForUpdatesUpfront}. Accepts an optional AbortSignal that
2447
2682
  * propagates to the underlying fetch — used by the cancellable spinner.
@@ -2462,18 +2697,8 @@ async function runUpdateCheck(currentVersion, parentSignal, onNag = displayNag)
2462
2697
  return "skipped";
2463
2698
  }
2464
2699
  try {
2465
- const cached = await loadCache();
2466
- if (cached && Date.now() - cached.checkedAt < TTL_MS) {
2467
- debug("runUpdateCheck: cache fresh (<%dh), skipping fetch", TTL_MS / 36e5);
2468
- if (semver.gt(cached.latest, currentVersion)) {
2469
- onNag(currentVersion, cached.latest);
2470
- return "cache-update";
2471
- }
2472
- debug("runUpdateCheck: current >= latest, no nag");
2473
- return "cache-current";
2474
- }
2475
- if (cached) debug("runUpdateCheck: cache stale, refetching");
2476
- else debug("runUpdateCheck: no cache, fetching");
2700
+ const cacheStatus = resolveCacheHit(await loadCache(), currentVersion, onNag);
2701
+ if (cacheStatus !== null) return cacheStatus;
2477
2702
  const latest = await fetchLatest(parentSignal);
2478
2703
  if (latest === null) {
2479
2704
  debug("runUpdateCheck: fetch returned null, not saving cache");
@@ -2515,13 +2740,11 @@ async function checkForUpdatesUpfront(currentVersion) {
2515
2740
  return;
2516
2741
  }
2517
2742
  const cached = await loadCache();
2518
- if (cached && Date.now() - cached.checkedAt < TTL_MS) {
2519
- debug("checkForUpdatesUpfront: cache fresh (<%dh), skipping fetch", TTL_MS / 36e5);
2520
- if (semver.gt(cached.latest, currentVersion)) displayNag(currentVersion, cached.latest);
2521
- else debug("checkForUpdatesUpfront: current >= latest, no nag");
2743
+ if (cached && Date.now() - cached.checkedAt < STALE_MS) {
2744
+ await runUpdateCheck(currentVersion);
2522
2745
  return;
2523
2746
  }
2524
- if (cached) debug("checkForUpdatesUpfront: cache stale, refetching");
2747
+ if (cached) debug("checkForUpdatesUpfront: cache stale (>=%dh), refetching", STALE_MS / 36e5);
2525
2748
  else debug("checkForUpdatesUpfront: no cache, fetching");
2526
2749
  const stdin = process.stdin;
2527
2750
  if (stdin.isTTY !== true || typeof stdin.setRawMode !== "function") {