@webywz/vibe-commit 0.1.2 → 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 +7 -0
- package/dist/cli.js +1 -0
- package/dist/format.js +9 -3
- package/dist/heuristic.d.ts +10 -0
- package/dist/heuristic.js +130 -4
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/parse-args.js +7 -0
- package/dist/propose.js +18 -13
- package/dist/types.d.ts +4 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -41,6 +41,7 @@ Requires **Node.js 18.18+** and `git`. Package: [`@webywz/vibe-commit`](https://
|
|
|
41
41
|
git add -p
|
|
42
42
|
vibe-commit # propose from staged diff
|
|
43
43
|
vibe-commit --vibe playful --commit # write the commit
|
|
44
|
+
vibe-commit --breaking --dry-run # force ! + BREAKING CHANGE footer
|
|
44
45
|
vibe-commit --dry-run # print only (default; wins over --commit)
|
|
45
46
|
git commit -m "$(vibe-commit --dry-run)"
|
|
46
47
|
vibe-commit install # optional prepare-commit-msg hook
|
|
@@ -54,6 +55,12 @@ vibe-commit install # optional prepare-commit-msg hook
|
|
|
54
55
|
| `--scope <scope>` | Override inferred scope · 覆盖推断出的 scope |
|
|
55
56
|
| `--vibe <style>` | `playful` \| `pro` \| `terse` (default: `pro`) |
|
|
56
57
|
| `--no-ai` | Skip the API even if `OPENAI_API_KEY` is set · 即使有 Key 也不走 AI |
|
|
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
|
+
|
|
57
64
|
| `--json` | Machine-readable proposal · 机器可读提案 |
|
|
58
65
|
| `install [--path]` | Install the prepare-commit-msg hook · 安装钩子 |
|
|
59
66
|
| `uninstall [--path]` | Remove the managed hook · 卸载钩子 |
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
46
46
|
scope: options.scope,
|
|
47
47
|
vibe: options.vibe,
|
|
48
48
|
preferAi: !options.noAi,
|
|
49
|
+
breaking: options.breaking,
|
|
49
50
|
});
|
|
50
51
|
const formatted = formatCommit(message);
|
|
51
52
|
const shouldCommit = options.commit && !options.dryRun;
|
package/dist/format.js
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import { COMMIT_TYPES, } from "./types.js";
|
|
2
2
|
const TYPE_PATTERN = COMMIT_TYPES.join("|");
|
|
3
3
|
const HEADER_RE = new RegExp(`^(${TYPE_PATTERN})(?:\\(([a-zA-Z0-9][\\w./-]{0,62})\\))?(!)?:\\s*(.+)$`);
|
|
4
|
+
const BREAKING_FOOTER_RE = /^BREAKING[ -]CHANGE:/im;
|
|
4
5
|
export function formatCommit(message) {
|
|
5
6
|
const bang = message.breaking ? "!" : "";
|
|
7
|
+
const subject = normalizeSubject(message.subject);
|
|
6
8
|
const head = message.scope
|
|
7
|
-
? `${message.type}(${message.scope})${bang}: ${
|
|
8
|
-
: `${message.type}${bang}: ${
|
|
9
|
-
|
|
9
|
+
? `${message.type}(${message.scope})${bang}: ${subject}`
|
|
10
|
+
: `${message.type}${bang}: ${subject}`;
|
|
11
|
+
let body = message.body?.trim() || "";
|
|
12
|
+
if (message.breaking && !BREAKING_FOOTER_RE.test(body)) {
|
|
13
|
+
const footer = `BREAKING CHANGE: ${subject}`;
|
|
14
|
+
body = body ? `${body}\n\n${footer}` : footer;
|
|
15
|
+
}
|
|
10
16
|
return body ? `${head}\n\n${body}` : head;
|
|
11
17
|
}
|
|
12
18
|
export function normalizeSubject(subject) {
|
package/dist/heuristic.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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/parse-args.js
CHANGED
|
@@ -17,6 +17,7 @@ Options
|
|
|
17
17
|
--scope <scope> Override inferred scope
|
|
18
18
|
--vibe <style> playful | pro | terse (default: pro)
|
|
19
19
|
--no-ai Skip AI even if OPENAI_API_KEY is set
|
|
20
|
+
--breaking Force breaking change (! + BREAKING CHANGE footer)
|
|
20
21
|
--json Print a machine-readable proposal
|
|
21
22
|
--path <repo> Repo root for install/uninstall (default: cwd)
|
|
22
23
|
-h, --help Show help
|
|
@@ -38,6 +39,7 @@ Examples
|
|
|
38
39
|
git add -p
|
|
39
40
|
vibe-commit
|
|
40
41
|
vibe-commit --vibe playful --commit
|
|
42
|
+
vibe-commit --breaking --dry-run
|
|
41
43
|
git commit -m "$(vibe-commit --dry-run)"
|
|
42
44
|
vibe-commit install
|
|
43
45
|
vibe-commit uninstall --path ~/projects/app
|
|
@@ -56,6 +58,7 @@ export function parseArgs(argv, env = process.env) {
|
|
|
56
58
|
json: false,
|
|
57
59
|
help: false,
|
|
58
60
|
version: false,
|
|
61
|
+
breaking: false,
|
|
59
62
|
};
|
|
60
63
|
let i = 0;
|
|
61
64
|
if (argv[0] && !argv[0].startsWith("-")) {
|
|
@@ -93,6 +96,10 @@ export function parseArgs(argv, env = process.env) {
|
|
|
93
96
|
options.json = true;
|
|
94
97
|
continue;
|
|
95
98
|
}
|
|
99
|
+
if (arg === "--breaking") {
|
|
100
|
+
options.breaking = true;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
96
103
|
if (arg === "--type" || arg.startsWith("--type=")) {
|
|
97
104
|
const value = arg.includes("=") ? arg.slice("--type=".length) : argv[++i];
|
|
98
105
|
options.type = parseType(value);
|
package/dist/propose.js
CHANGED
|
@@ -1,23 +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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
11
|
+
let message = heuristic;
|
|
12
|
+
if (options.preferAi !== false) {
|
|
13
|
+
const hasKey = Boolean(env.OPENAI_API_KEY?.trim());
|
|
14
|
+
if (hasKey) {
|
|
15
|
+
const ai = await proposeWithAi({
|
|
16
|
+
diff,
|
|
17
|
+
vibe: options.vibe,
|
|
18
|
+
type: options.type,
|
|
19
|
+
scope: options.scope,
|
|
20
|
+
}, env);
|
|
21
|
+
if (ai)
|
|
22
|
+
message = ai;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
const breaking = Boolean(options.breaking) || heuristic.breaking || message.breaking;
|
|
26
|
+
return ensureBreakingFooter({ ...message, breaking }, breakingReason(diff));
|
|
22
27
|
}
|
|
23
28
|
//# sourceMappingURL=propose.js.map
|
package/dist/types.d.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface ProposeOptions {
|
|
|
34
34
|
scope?: string;
|
|
35
35
|
vibe: Vibe;
|
|
36
36
|
preferAi?: boolean;
|
|
37
|
+
/** Force a breaking change (! + BREAKING CHANGE footer). */
|
|
38
|
+
breaking?: boolean;
|
|
37
39
|
}
|
|
38
40
|
export type CliCommand = "propose" | "install" | "uninstall" | "prepare-commit-msg";
|
|
39
41
|
export interface CliOptions {
|
|
@@ -47,6 +49,8 @@ export interface CliOptions {
|
|
|
47
49
|
json: boolean;
|
|
48
50
|
help: boolean;
|
|
49
51
|
version: boolean;
|
|
52
|
+
/** Force breaking change marker and footer. */
|
|
53
|
+
breaking: boolean;
|
|
50
54
|
/** Target repo for install/uninstall (default: cwd). */
|
|
51
55
|
path?: string;
|
|
52
56
|
/** Message file path for prepare-commit-msg. */
|