@webywz/vibe-commit 0.1.3 → 0.1.4

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
@@ -56,6 +56,11 @@ vibe-commit install # optional prepare-commit-msg hook
56
56
  | `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
57
57
  | `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set · 即使有 Key 也不走 AI |
58
58
  | `--breaking` | Force breaking change (`!` + `BREAKING CHANGE:` footer) · 强制破坏性变更(`!` + footer) |
59
+
60
+ Offline heuristic also marks breaking when the staged diff removes public `export`s or bumps a `package.json` major version, and always attaches a `BREAKING CHANGE:` footer when `breaking` is true (so `--json` body matches the formatted message).
61
+
62
+ 离线启发式在删掉公开 `export`、或 `package.json` 主版本升级时也会标 breaking;只要 `breaking` 为真就会补上 `BREAKING CHANGE:` footer(`--json` 的 body 与格式化输出一致)。
63
+
59
64
  | `--json` | Machine-readable proposal · 机器可读提案 |
60
65
  | `install [--path]` | Install the prepare-commit-msg hook · 安装钩子 |
61
66
  | `uninstall [--path]` | Remove the managed hook · 卸载钩子 |
@@ -4,6 +4,8 @@ export interface HeuristicInput {
4
4
  type?: CommitType;
5
5
  scope?: string;
6
6
  vibe: Vibe;
7
+ /** Force breaking: true (ORs with inferBreaking). */
8
+ breaking?: boolean;
7
9
  }
8
10
  export declare function proposeHeuristic(input: HeuristicInput): ProposedMessage;
9
11
  export declare function inferType(diff: StagedDiff): CommitType;
@@ -11,5 +13,13 @@ export declare function primaryRole(path: string): CommitType;
11
13
  export declare function classifyPath(path: string): CommitType[];
12
14
  export declare function inferScope(files: StagedFile[]): string | undefined;
13
15
  export declare function inferSubject(diff: StagedDiff, type: CommitType, scope?: string): string;
16
+ export declare function inferBreaking(diff: StagedDiff): boolean;
17
+ /** Short reason for a BREAKING CHANGE footer, or undefined when not breaking. */
18
+ export declare function breakingReason(diff: StagedDiff): string | undefined;
19
+ /**
20
+ * Ensure body carries a conventional BREAKING CHANGE footer when breaking.
21
+ * Skips if the body already mentions one. Uses subject as a last-resort reason.
22
+ */
23
+ export declare function ensureBreakingFooter(message: ProposedMessage, reason?: string): ProposedMessage;
14
24
  export declare function collectAddedIdentifiers(hunks: DiffHunk[]): string[];
15
25
  export declare function stem(path: string): string;
package/dist/heuristic.js CHANGED
@@ -28,9 +28,10 @@ export function proposeHeuristic(input) {
28
28
  const scope = input.scope ?? inferScope(diff.files);
29
29
  const subject = inferSubject(diff, type, scope);
30
30
  const body = inferBody(diff, type);
31
- const breaking = inferBreaking(diff);
31
+ const breaking = Boolean(input.breaking) || inferBreaking(diff);
32
32
  const styled = applyVibe({ type, scope, subject, body, breaking }, vibe);
33
- return { ...styled, engine: "heuristic" };
33
+ const message = { ...styled, engine: "heuristic" };
34
+ return ensureBreakingFooter(message, breakingReason(diff));
34
35
  }
35
36
  export function inferType(diff) {
36
37
  const files = diff.files;
@@ -216,8 +217,133 @@ function inferBody(diff, type) {
216
217
  : "Changed files:";
217
218
  return `${lead}\n${listed}${extra}`;
218
219
  }
219
- function inferBreaking(diff) {
220
- return diff.hunks.some((hunk) => hunk.lines.some((line) => /^\+\s*BREAKING CHANGE:/i.test(line)));
220
+ export function inferBreaking(diff) {
221
+ return Boolean(breakingReason(diff));
222
+ }
223
+ /** Short reason for a BREAKING CHANGE footer, or undefined when not breaking. */
224
+ export function breakingReason(diff) {
225
+ const footer = detectBreakingFooter(diff);
226
+ if (footer)
227
+ return footer;
228
+ const removed = detectRemovedExports(diff);
229
+ if (removed.length > 0) {
230
+ const names = removed.slice(0, 3).join(", ");
231
+ return removed.length === 1
232
+ ? `remove public export ${names}`
233
+ : `remove public exports ${names}`;
234
+ }
235
+ const bump = detectMajorVersionBump(diff);
236
+ if (bump)
237
+ return `bump major version to ${bump.to}`;
238
+ return undefined;
239
+ }
240
+ /**
241
+ * Ensure body carries a conventional BREAKING CHANGE footer when breaking.
242
+ * Skips if the body already mentions one. Uses subject as a last-resort reason.
243
+ */
244
+ export function ensureBreakingFooter(message, reason) {
245
+ if (!message.breaking)
246
+ return message;
247
+ const body = message.body?.trim() ?? "";
248
+ if (/BREAKING CHANGE:/i.test(body)) {
249
+ return body === (message.body ?? "") ? message : { ...message, body };
250
+ }
251
+ const detail = (reason ?? message.subject).replace(/\s+/g, " ").trim() ||
252
+ "incompatible API change";
253
+ const footer = `BREAKING CHANGE: ${detail}`;
254
+ return {
255
+ ...message,
256
+ body: body ? `${body}\n\n${footer}` : footer,
257
+ };
258
+ }
259
+ function detectBreakingFooter(diff) {
260
+ for (const hunk of diff.hunks) {
261
+ for (const line of hunk.lines) {
262
+ const match = line.match(/^\+\s*BREAKING CHANGE:\s*(.+)$/i);
263
+ if (match?.[1])
264
+ return match[1].trim();
265
+ }
266
+ }
267
+ // Presence of the token alone still counts as breaking.
268
+ if (diff.hunks.some((hunk) => hunk.lines.some((line) => /^\+\s*BREAKING CHANGE:/i.test(line)))) {
269
+ return "incompatible API change";
270
+ }
271
+ return undefined;
272
+ }
273
+ const DELETED_EXPORT_RE = /^-\s*export(?:\s+default\b|\s*\{|\s+)/;
274
+ function detectRemovedExports(diff) {
275
+ const names = [];
276
+ const seen = new Set();
277
+ for (const hunk of diff.hunks) {
278
+ for (const line of hunk.lines) {
279
+ if (line.startsWith("---"))
280
+ continue;
281
+ if (!DELETED_EXPORT_RE.test(line))
282
+ continue;
283
+ const name = exportNameFromDeletedLine(line);
284
+ const key = name ?? line.slice(1).trim();
285
+ if (seen.has(key))
286
+ continue;
287
+ seen.add(key);
288
+ if (name)
289
+ names.push(name);
290
+ else
291
+ names.push("export");
292
+ }
293
+ }
294
+ return names;
295
+ }
296
+ function exportNameFromDeletedLine(line) {
297
+ const content = line.slice(1);
298
+ const named = content.match(/export\s+(?:default\s+)?(?:async\s+)?(?:function|const|let|class|type|interface|enum)\s+([A-Za-z_][\w]*)/);
299
+ if (named?.[1])
300
+ return named[1];
301
+ const braced = content.match(/export\s*\{\s*([^}]+)\s*\}/);
302
+ if (braced?.[1]) {
303
+ const first = braced[1].split(",")[0]?.trim().split(/\s+as\s+/i)[0]?.trim();
304
+ if (first && /^[A-Za-z_][\w]*$/.test(first))
305
+ return first;
306
+ }
307
+ const def = content.match(/export\s+default\s+([A-Za-z_][\w]*)/);
308
+ if (def?.[1])
309
+ return def[1];
310
+ return undefined;
311
+ }
312
+ function detectMajorVersionBump(diff) {
313
+ for (const hunk of diff.hunks) {
314
+ if (!/(^|\/)package\.json$/i.test(hunk.path))
315
+ continue;
316
+ const removed = [];
317
+ const added = [];
318
+ for (const line of hunk.lines) {
319
+ const rem = line.match(/^-\s*"version"\s*:\s*"([^"]+)"/);
320
+ const add = line.match(/^\+\s*"version"\s*:\s*"([^"]+)"/);
321
+ if (rem?.[1])
322
+ removed.push(rem[1]);
323
+ if (add?.[1])
324
+ added.push(add[1]);
325
+ }
326
+ for (const from of removed) {
327
+ for (const to of added) {
328
+ if (isMajorBump(from, to))
329
+ return { from, to };
330
+ }
331
+ }
332
+ }
333
+ return undefined;
334
+ }
335
+ function isMajorBump(from, to) {
336
+ const a = parseSemverMajor(from);
337
+ const b = parseSemverMajor(to);
338
+ if (a === undefined || b === undefined)
339
+ return false;
340
+ return b > a;
341
+ }
342
+ function parseSemverMajor(version) {
343
+ const match = version.trim().match(/^v?(\d+)\./);
344
+ if (!match)
345
+ return undefined;
346
+ return Number(match[1]);
221
347
  }
222
348
  export function collectAddedIdentifiers(hunks) {
223
349
  const names = [];
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { proposeWithAi } from "./ai.ts";
2
2
  export { formatCommit, parseCommit, normalizeSubject } from "./format.ts";
3
3
  export { inspectStaged, commitWithMessage, GitError } from "./git.ts";
4
- export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.ts";
4
+ export { proposeHeuristic, inferType, inferScope, inferSubject, inferBreaking, ensureBreakingFooter, classifyPath, } from "./heuristic.ts";
5
5
  export { parseArgs, helpText, UsageError } from "./parse-args.ts";
6
6
  export { assembleDiff, parseUnifiedDiff } from "./parse-diff.ts";
7
7
  export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  export { proposeWithAi } from "./ai.js";
2
2
  export { formatCommit, parseCommit, normalizeSubject } from "./format.js";
3
3
  export { inspectStaged, commitWithMessage, GitError } from "./git.js";
4
- export { proposeHeuristic, inferType, inferScope, inferSubject, classifyPath, } from "./heuristic.js";
4
+ export { proposeHeuristic, inferType, inferScope, inferSubject, inferBreaking, ensureBreakingFooter, classifyPath, } from "./heuristic.js";
5
5
  export { parseArgs, helpText, UsageError } from "./parse-args.js";
6
6
  export { assembleDiff, parseUnifiedDiff } from "./parse-diff.js";
7
7
  export { installHook, uninstallHook, runPrepareCommitMsg, shouldSkipPrepareCommitMsg, hasNonCommentContent, HOOK_BEGIN, HOOK_END, } from "./hook.js";
package/dist/propose.js CHANGED
@@ -1,34 +1,28 @@
1
1
  import { proposeWithAi } from "./ai.js";
2
- import { proposeHeuristic } from "./heuristic.js";
2
+ import { breakingReason, ensureBreakingFooter, proposeHeuristic, } from "./heuristic.js";
3
3
  export async function proposeMessage(diff, options, env = process.env) {
4
4
  const heuristic = proposeHeuristic({
5
5
  diff,
6
6
  type: options.type,
7
7
  scope: options.scope,
8
8
  vibe: options.vibe,
9
+ breaking: options.breaking,
9
10
  });
10
- let message;
11
- if (options.preferAi === false) {
12
- message = heuristic;
13
- }
14
- else {
11
+ let message = heuristic;
12
+ if (options.preferAi !== false) {
15
13
  const hasKey = Boolean(env.OPENAI_API_KEY?.trim());
16
- if (!hasKey) {
17
- message = heuristic;
18
- }
19
- else {
14
+ if (hasKey) {
20
15
  const ai = await proposeWithAi({
21
16
  diff,
22
17
  vibe: options.vibe,
23
18
  type: options.type,
24
19
  scope: options.scope,
25
20
  }, env);
26
- message = ai ?? heuristic;
21
+ if (ai)
22
+ message = ai;
27
23
  }
28
24
  }
29
- if (options.breaking) {
30
- return { ...message, breaking: true };
31
- }
32
- return message;
25
+ const breaking = Boolean(options.breaking) || heuristic.breaking || message.breaking;
26
+ return ensureBreakingFooter({ ...message, breaking }, breakingReason(diff));
33
27
  }
34
28
  //# sourceMappingURL=propose.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webywz/vibe-commit",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "Stylish conventional commit messages with personality. Offline-first, AI-optional.",
5
5
  "keywords": [
6
6
  "git",