lintlane 2.5.0 → 4.0.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/LICENSE +21 -0
- package/README.md +210 -512
- package/bin/check-commit.mjs +54 -137
- package/bin/checks.mjs +237 -0
- package/bin/cli.mjs +299 -220
- package/bin/commit-cli.mjs +36 -122
- package/bin/doctor.mjs +328 -0
- package/bin/hook.mjs +156 -0
- package/bin/proc.mjs +132 -0
- package/bin/project.mjs +276 -0
- package/bin/ui-server.mjs +244 -91
- package/package.json +22 -7
- package/presets/commitizen.cjs +4 -0
- package/presets/commitlint.mjs +47 -0
- package/templates/commit-msg +2 -2
- package/templates/pre-commit +2 -2
- package/templates/pre-push +2 -2
- package/templates/prepare-commit-msg +2 -2
- package/ui/index.html +210 -44
- package/bin/ci.mjs +0 -162
- package/bin/commit-config.mjs +0 -24
- package/bin/commit-lint.mjs +0 -143
- package/bin/commit-msg.mjs +0 -18
- package/bin/commit-prompt.mjs +0 -110
- package/bin/pre-push-check.mjs +0 -48
- package/bin/prepare-commit-msg.mjs +0 -46
package/bin/commit-lint.mjs
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
// Conventional Commits rules. Plain Node (no Bun APIs, no dependencies):
|
|
2
|
-
// git hooks run this on every teammate's machine, whatever package manager
|
|
3
|
-
// or runtime they use.
|
|
4
|
-
|
|
5
|
-
export const TYPES = [
|
|
6
|
-
["feat", "A new feature"],
|
|
7
|
-
["fix", "A bug fix"],
|
|
8
|
-
["docs", "Documentation only"],
|
|
9
|
-
["style", "Formatting only, no code change"],
|
|
10
|
-
["refactor", "Code change that neither fixes a bug nor adds a feature"],
|
|
11
|
-
["perf", "A performance improvement"],
|
|
12
|
-
["test", "Adding or fixing tests"],
|
|
13
|
-
["build", "Build system or dependencies"],
|
|
14
|
-
["ci", "CI configuration"],
|
|
15
|
-
["chore", "Other changes that don't touch src or tests"],
|
|
16
|
-
["revert", "Reverts a previous commit"],
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
// Same defaults as Commitizen's `cz check --allowed-prefixes`: messages git or
|
|
20
|
-
// GitHub generate themselves.
|
|
21
|
-
export const DEFAULT_ALLOWED_PREFIXES = [
|
|
22
|
-
"Merge",
|
|
23
|
-
"Revert",
|
|
24
|
-
"Pull request",
|
|
25
|
-
"fixup!",
|
|
26
|
-
"squash!",
|
|
27
|
-
"amend!",
|
|
28
|
-
];
|
|
29
|
-
|
|
30
|
-
export const DEFAULT_MAX_HEADER = 100;
|
|
31
|
-
export const EXAMPLE = "feat(auth): add login form";
|
|
32
|
-
|
|
33
|
-
const HEADER = /^([a-z]+)(?:\(([^()]+)\))?(!)?: (.*)$/;
|
|
34
|
-
const LOOSE_HEADER = /^([A-Za-z]+)(?:\(([^()]*)\))?(!)?:/;
|
|
35
|
-
|
|
36
|
-
/**
|
|
37
|
-
* Merge user options (from package.json "lintlane".commit or CLI flags) over
|
|
38
|
-
* the defaults. `types` may be a list of names, [name, description] pairs, or
|
|
39
|
-
* an object of { name: description }. A max header of 0 turns the limit off.
|
|
40
|
-
*/
|
|
41
|
-
export function resolveOptions(options = {}) {
|
|
42
|
-
let types = TYPES;
|
|
43
|
-
if (Array.isArray(options.types) && options.types.length > 0) {
|
|
44
|
-
types = options.types.map((t) => (Array.isArray(t) ? [t[0], t[1] ?? ""] : [String(t), ""]));
|
|
45
|
-
} else if (options.types && typeof options.types === "object" && !Array.isArray(options.types)) {
|
|
46
|
-
types = Object.entries(options.types).map(([name, description]) => [name, String(description)]);
|
|
47
|
-
}
|
|
48
|
-
const limit = Number(options.messageLengthLimit ?? options.maxHeader);
|
|
49
|
-
return {
|
|
50
|
-
types,
|
|
51
|
-
maxHeader: Number.isFinite(limit) && limit >= 0 ? limit : DEFAULT_MAX_HEADER,
|
|
52
|
-
allowedPrefixes: [
|
|
53
|
-
...DEFAULT_ALLOWED_PREFIXES,
|
|
54
|
-
...(Array.isArray(options.allowedPrefixes) ? options.allowedPrefixes : []),
|
|
55
|
-
],
|
|
56
|
-
allowAbort: Boolean(options.allowAbort),
|
|
57
|
-
};
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/** Drop git's "# ..." comment lines and surrounding blank space. */
|
|
61
|
-
export function cleanMessage(raw) {
|
|
62
|
-
return raw
|
|
63
|
-
.split("\n")
|
|
64
|
-
.filter((line) => !line.startsWith("#"))
|
|
65
|
-
.join("\n")
|
|
66
|
-
.trim();
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function validateMessage(raw, options) {
|
|
70
|
-
const opts = resolveOptions(options);
|
|
71
|
-
const message = cleanMessage(raw);
|
|
72
|
-
if (!message) {
|
|
73
|
-
return opts.allowAbort
|
|
74
|
-
? { valid: true, errors: [], skipped: true }
|
|
75
|
-
: { valid: false, errors: ["The commit message is empty."] };
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
const [header, ...rest] = message.split("\n");
|
|
79
|
-
if (opts.allowedPrefixes.some((prefix) => header.startsWith(prefix))) {
|
|
80
|
-
return { valid: true, errors: [], skipped: true };
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
const errors = [];
|
|
84
|
-
const typeNames = opts.types.map(([type]) => type);
|
|
85
|
-
const match = HEADER.exec(header);
|
|
86
|
-
|
|
87
|
-
if (!match) {
|
|
88
|
-
const loose = LOOSE_HEADER.exec(header);
|
|
89
|
-
if (loose && loose[1] !== loose[1].toLowerCase()) {
|
|
90
|
-
errors.push(`The type "${loose[1]}" must be lowercase ("${loose[1].toLowerCase()}").`);
|
|
91
|
-
} else if (loose && header.slice(loose[0].length, loose[0].length + 1) !== " ") {
|
|
92
|
-
errors.push('Put a space after the colon: "type: subject".');
|
|
93
|
-
} else {
|
|
94
|
-
errors.push(`The header must look like "type(scope): subject", e.g. "${EXAMPLE}".`);
|
|
95
|
-
}
|
|
96
|
-
} else {
|
|
97
|
-
const [, type, , , subject] = match;
|
|
98
|
-
if (!typeNames.includes(type)) {
|
|
99
|
-
errors.push(`Unknown type "${type}". Use one of: ${typeNames.join(", ")}.`);
|
|
100
|
-
}
|
|
101
|
-
if (!subject.trim()) errors.push("The subject after the colon can't be empty.");
|
|
102
|
-
if (subject.trim().endsWith(".")) errors.push("The subject shouldn't end with a period.");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
if (opts.maxHeader > 0 && header.length > opts.maxHeader) {
|
|
106
|
-
errors.push(`The header is ${header.length} characters; keep it under ${opts.maxHeader}.`);
|
|
107
|
-
}
|
|
108
|
-
if (rest.length > 0 && rest[0].trim() !== "") {
|
|
109
|
-
errors.push("Leave a blank line between the header and the body.");
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
return { valid: errors.length === 0, errors };
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
/** Build the final message from the prompt's answers. */
|
|
116
|
-
export function buildMessage({ type, scope, subject, body, breaking, footer }) {
|
|
117
|
-
const header = `${type}${scope ? `(${scope})` : ""}${breaking ? "!" : ""}: ${subject}`;
|
|
118
|
-
const parts = [header];
|
|
119
|
-
if (body) parts.push(body);
|
|
120
|
-
const footers = [];
|
|
121
|
-
if (breaking) footers.push(`BREAKING CHANGE: ${breaking}`);
|
|
122
|
-
if (footer) footers.push(footer);
|
|
123
|
-
if (footers.length > 0) parts.push(footers.join("\n"));
|
|
124
|
-
return parts.join("\n\n");
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
export function explainFailure(errors, options) {
|
|
128
|
-
const { types } = resolveOptions(options);
|
|
129
|
-
return [
|
|
130
|
-
"",
|
|
131
|
-
"✘ Commit message rejected",
|
|
132
|
-
"",
|
|
133
|
-
...errors.map((error) => ` • ${error}`),
|
|
134
|
-
"",
|
|
135
|
-
" Format: type(scope): subject",
|
|
136
|
-
` Example: ${EXAMPLE}`,
|
|
137
|
-
` Types: ${types.map(([type]) => type).join(", ")}`,
|
|
138
|
-
"",
|
|
139
|
-
" Not sure? Run the guided prompt: npx lintlane commit",
|
|
140
|
-
" (or just `git commit` with no -m, and you'll be asked step by step)",
|
|
141
|
-
"",
|
|
142
|
-
].join("\n");
|
|
143
|
-
}
|
package/bin/commit-msg.mjs
DELETED
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
// git `commit-msg` hook entry: rejects messages that aren't Conventional Commits.
|
|
2
|
-
// Plain Node on purpose — see commit-lint.mjs.
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import { loadCommitConfig } from "./commit-config.mjs";
|
|
5
|
-
import { explainFailure, validateMessage } from "./commit-lint.mjs";
|
|
6
|
-
|
|
7
|
-
const file = process.argv[2];
|
|
8
|
-
if (!file) {
|
|
9
|
-
process.stderr.write("commit-msg: expected the path to the commit message file.\n");
|
|
10
|
-
process.exit(2);
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const options = loadCommitConfig();
|
|
14
|
-
const result = validateMessage(readFileSync(file, "utf8"), options);
|
|
15
|
-
if (!result.valid) {
|
|
16
|
-
process.stderr.write(`${explainFailure(result.errors, options)}\n`);
|
|
17
|
-
process.exit(1);
|
|
18
|
-
}
|
package/bin/commit-prompt.mjs
DELETED
|
@@ -1,110 +0,0 @@
|
|
|
1
|
-
// Interactive "commit with steps" prompt (the Commitizen-style flow).
|
|
2
|
-
// Plain Node, readline only — no dependencies.
|
|
3
|
-
import { createInterface } from "node:readline/promises";
|
|
4
|
-
import { buildMessage, resolveOptions } from "./commit-lint.mjs";
|
|
5
|
-
|
|
6
|
-
const supportsColor = (stream) => stream.isTTY && !process.env.NO_COLOR;
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Ask the questions and return the finished message, or null if the user
|
|
10
|
-
* chose not to commit. Throws if the input ends (Ctrl+C / Ctrl+D).
|
|
11
|
-
*/
|
|
12
|
-
export async function promptCommit({
|
|
13
|
-
input = process.stdin,
|
|
14
|
-
output = process.stderr,
|
|
15
|
-
options,
|
|
16
|
-
} = {}) {
|
|
17
|
-
const { types: TYPES, maxHeader: MAX_HEADER } = resolveOptions(options);
|
|
18
|
-
const typeWidth = Math.max(...TYPES.map(([type]) => type.length));
|
|
19
|
-
const rl = createInterface({ input, output });
|
|
20
|
-
const c = supportsColor(output)
|
|
21
|
-
? {
|
|
22
|
-
b: (s) => `\x1b[1m${s}\x1b[0m`,
|
|
23
|
-
d: (s) => `\x1b[2m${s}\x1b[0m`,
|
|
24
|
-
g: (s) => `\x1b[32m${s}\x1b[0m`,
|
|
25
|
-
r: (s) => `\x1b[31m${s}\x1b[0m`,
|
|
26
|
-
}
|
|
27
|
-
: { b: (s) => s, d: (s) => s, g: (s) => s, r: (s) => s };
|
|
28
|
-
const say = (text = "") => output.write(`${text}\n`);
|
|
29
|
-
|
|
30
|
-
// Queue incoming lines so input that arrives early (pasted or piped) isn't
|
|
31
|
-
// lost between questions, and so a closed input ends the prompt instead of
|
|
32
|
-
// leaving it waiting forever.
|
|
33
|
-
const lines = rl[Symbol.asyncIterator]();
|
|
34
|
-
const ask = async (question) => {
|
|
35
|
-
rl.setPrompt(question);
|
|
36
|
-
rl.prompt();
|
|
37
|
-
const { value, done } = await lines.next();
|
|
38
|
-
if (done) throw new Error("input closed");
|
|
39
|
-
return value.trim();
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
try {
|
|
43
|
-
say(c.b("\nCommit — step 1/7: type of change"));
|
|
44
|
-
TYPES.forEach(([type, description], i) => {
|
|
45
|
-
say(` ${String(i + 1).padStart(2)}. ${c.g(type.padEnd(typeWidth))} ${c.d(description)}`);
|
|
46
|
-
});
|
|
47
|
-
let type;
|
|
48
|
-
while (!type) {
|
|
49
|
-
const answer = (await ask("Choose a type (number or name): ")).toLowerCase();
|
|
50
|
-
type = TYPES.find(([name], i) => name === answer || String(i + 1) === answer)?.[0];
|
|
51
|
-
if (!type) say(c.r(" Pick one of the listed types."));
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
say(c.b("\nStep 2/7: scope") + c.d(" (optional — the area you changed, e.g. auth, api, ui)"));
|
|
55
|
-
let scope = "";
|
|
56
|
-
for (;;) {
|
|
57
|
-
scope = await ask("Scope (Enter to skip): ");
|
|
58
|
-
if (!/[()]/.test(scope)) break;
|
|
59
|
-
say(c.r(" Scope can't contain parentheses."));
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
say(c.b("\nStep 3/7: subject") + c.d(" (short, imperative: “add login form”, no period)"));
|
|
63
|
-
let subject = "";
|
|
64
|
-
for (;;) {
|
|
65
|
-
subject = await ask("Subject: ");
|
|
66
|
-
const headerLength = `${type}${scope ? `(${scope})` : ""}: ${subject}`.length;
|
|
67
|
-
if (!subject) say(c.r(" The subject is required."));
|
|
68
|
-
else if (subject.endsWith(".")) say(c.r(" Remove the trailing period."));
|
|
69
|
-
else if (MAX_HEADER > 0 && headerLength > MAX_HEADER)
|
|
70
|
-
say(c.r(` Too long (${headerLength}/${MAX_HEADER}). Shorten it.`));
|
|
71
|
-
else break;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
say(c.b("\nStep 4/7: body") + c.d(" (optional — why, not what. Empty line to finish)"));
|
|
75
|
-
const bodyLines = [];
|
|
76
|
-
for (;;) {
|
|
77
|
-
const line = await ask(bodyLines.length === 0 ? "Body (Enter to skip): " : " …: ");
|
|
78
|
-
if (line === "") break;
|
|
79
|
-
bodyLines.push(line);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
say(c.b("\nStep 5/7: breaking change?"));
|
|
83
|
-
let breaking = "";
|
|
84
|
-
if ((await ask("Does this break existing behaviour? (y/N): ")).toLowerCase().startsWith("y")) {
|
|
85
|
-
while (!breaking) {
|
|
86
|
-
breaking = await ask("Describe what breaks and how to migrate: ");
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
say(c.b("\nStep 6/7: footer") + c.d(" (optional — issues this closes, e.g. “Closes #123”)"));
|
|
91
|
-
const footer = await ask("Footer (Enter to skip): ");
|
|
92
|
-
|
|
93
|
-
const message = buildMessage({
|
|
94
|
-
type,
|
|
95
|
-
scope,
|
|
96
|
-
subject,
|
|
97
|
-
body: bodyLines.join("\n"),
|
|
98
|
-
breaking,
|
|
99
|
-
footer,
|
|
100
|
-
});
|
|
101
|
-
say(c.b("\nStep 7/7: confirm"));
|
|
102
|
-
say(c.d("─".repeat(40)));
|
|
103
|
-
say(message);
|
|
104
|
-
say(c.d("─".repeat(40)));
|
|
105
|
-
const confirm = (await ask("Use this message? (Y/n): ")).toLowerCase();
|
|
106
|
-
return confirm === "" || confirm.startsWith("y") ? message : null;
|
|
107
|
-
} finally {
|
|
108
|
-
rl.close();
|
|
109
|
-
}
|
|
110
|
-
}
|
package/bin/pre-push-check.mjs
DELETED
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
// git `pre-push` hook entry: checks every commit that's about to be pushed.
|
|
2
|
-
// This is the safety net for commits that skipped the commit-msg hook
|
|
3
|
-
// (`git commit --no-verify`, a GUI client, a clone where hooks weren't installed).
|
|
4
|
-
// Plain Node on purpose — see commit-lint.mjs.
|
|
5
|
-
import { readFileSync } from "node:fs";
|
|
6
|
-
import { describeInvalid, findInvalidCommits, listCommits } from "./check-commit.mjs";
|
|
7
|
-
import { loadCommitConfig } from "./commit-config.mjs";
|
|
8
|
-
|
|
9
|
-
const [remote = "origin"] = process.argv.slice(2);
|
|
10
|
-
const ZERO = /^0+$/;
|
|
11
|
-
|
|
12
|
-
let input = "";
|
|
13
|
-
try {
|
|
14
|
-
input = readFileSync(0, "utf8");
|
|
15
|
-
} catch {}
|
|
16
|
-
|
|
17
|
-
const options = loadCommitConfig();
|
|
18
|
-
const invalid = [];
|
|
19
|
-
const seen = new Set();
|
|
20
|
-
|
|
21
|
-
for (const line of input.split("\n")) {
|
|
22
|
-
const [localRef, localSha, , remoteSha] = line.trim().split(/\s+/);
|
|
23
|
-
if (!localRef || !localSha || ZERO.test(localSha)) continue; // deleting a branch
|
|
24
|
-
if (localRef.startsWith("refs/tags/")) continue;
|
|
25
|
-
|
|
26
|
-
// New branch: everything not already on a remote. Existing: just the new commits.
|
|
27
|
-
const logArgs = ZERO.test(remoteSha ?? "0")
|
|
28
|
-
? [localSha, "--not", `--remotes=${remote}`]
|
|
29
|
-
: [`${remoteSha}..${localSha}`];
|
|
30
|
-
try {
|
|
31
|
-
for (const bad of findInvalidCommits(listCommits(logArgs), options)) {
|
|
32
|
-
if (!seen.has(bad.hash)) {
|
|
33
|
-
seen.add(bad.hash);
|
|
34
|
-
invalid.push(bad);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
} catch {
|
|
38
|
-
// Can't inspect the range (e.g. the remote's commit isn't local). Don't block the push.
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
if (invalid.length > 0) {
|
|
43
|
-
process.stderr.write(
|
|
44
|
-
`\n✘ Push blocked: ${invalid.length} commit message(s) don't follow Conventional Commits:\n\n${describeInvalid(invalid)}\n\n` +
|
|
45
|
-
" Reword them with `git rebase -i` (or `git commit --amend` for the latest), then push again.\n\n",
|
|
46
|
-
);
|
|
47
|
-
process.exit(1);
|
|
48
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
// git `prepare-commit-msg` hook entry: when you run a plain `git commit` (no -m,
|
|
2
|
-
// no template, not a merge), walk through the guided prompt and pre-fill the
|
|
3
|
-
// message — the same flow as `lintlane commit`.
|
|
4
|
-
// Plain Node on purpose — see commit-lint.mjs.
|
|
5
|
-
import { closeSync, openSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
|
-
import { ReadStream } from "node:tty";
|
|
7
|
-
import { loadCommitConfig } from "./commit-config.mjs";
|
|
8
|
-
import { promptCommit } from "./commit-prompt.mjs";
|
|
9
|
-
|
|
10
|
-
const [file, source] = process.argv.slice(2);
|
|
11
|
-
|
|
12
|
-
// source is set when the message came from -m/-F, a template, a merge, a
|
|
13
|
-
// squash, or an amend. Only a plain `git commit` leaves it empty.
|
|
14
|
-
if (!file || source || process.env.CI || process.env.LINTLANE_NO_PROMPT) process.exit(0);
|
|
15
|
-
|
|
16
|
-
// Git runs hooks with stdin detached; talk to the terminal directly. If there
|
|
17
|
-
// isn't one (a GUI client, CI, a script), stay out of the way.
|
|
18
|
-
let fd;
|
|
19
|
-
try {
|
|
20
|
-
fd = openSync("/dev/tty", "r");
|
|
21
|
-
} catch {
|
|
22
|
-
process.exit(0);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
try {
|
|
26
|
-
const message = await promptCommit({
|
|
27
|
-
input: new ReadStream(fd),
|
|
28
|
-
output: process.stderr,
|
|
29
|
-
options: loadCommitConfig(),
|
|
30
|
-
});
|
|
31
|
-
if (message === null) {
|
|
32
|
-
process.stderr.write("\nCommit cancelled.\n");
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
const existing = readFileSync(file, "utf8");
|
|
36
|
-
writeFileSync(file, `${message}\n${existing}`);
|
|
37
|
-
// The open terminal handle would otherwise keep this process (and git) waiting.
|
|
38
|
-
process.exit(0);
|
|
39
|
-
} catch {
|
|
40
|
-
process.stderr.write("\nCommit cancelled.\n");
|
|
41
|
-
process.exit(1);
|
|
42
|
-
} finally {
|
|
43
|
-
try {
|
|
44
|
-
closeSync(fd);
|
|
45
|
-
} catch {}
|
|
46
|
-
}
|