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-cli.mjs
CHANGED
|
@@ -1,148 +1,62 @@
|
|
|
1
|
-
// `lintlane commit` — guided Conventional Commit
|
|
1
|
+
// `lintlane commit` — guided Conventional Commit, run by Commitizen.
|
|
2
2
|
//
|
|
3
|
-
// lintlane commit [options] [-- <git commit args>]
|
|
3
|
+
// lintlane commit [commitizen options] [-- <git commit args>]
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
// (e.g. `-- -a -S` to stage all and sign, `-- -n` to skip hooks)
|
|
11
|
-
import { spawnSync } from "node:child_process";
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
-
import { dirname, resolve } from "node:path";
|
|
14
|
-
import { parseArgs } from "node:util";
|
|
15
|
-
import { loadCommitConfig } from "./commit-config.mjs";
|
|
16
|
-
import { promptCommit } from "./commit-prompt.mjs";
|
|
5
|
+
// Commitizen's own flags work as-is: --dry-run, --retry, -a, -S, ... and
|
|
6
|
+
// everything after -- goes to `git commit` (e.g. `-- -n` to skip hooks).
|
|
7
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { resolveTool, runInherit } from "./proc.mjs";
|
|
17
10
|
|
|
18
11
|
export const COMMIT_HELP = `Usage: lintlane commit [options] [-- <git commit args>]
|
|
19
12
|
|
|
20
|
-
Asks a few questions and creates a Conventional Commit. Alias: lintlane c
|
|
13
|
+
Asks a few questions and creates a Conventional Commit (Commitizen). Alias: lintlane c
|
|
21
14
|
|
|
22
|
-
Options:
|
|
23
|
-
--dry-run
|
|
24
|
-
--
|
|
25
|
-
--
|
|
26
|
-
|
|
27
|
-
-l, --message-length-limit <n> Maximum header length (0 = no limit)
|
|
15
|
+
Options (passed to Commitizen):
|
|
16
|
+
--dry-run Show what would happen without committing
|
|
17
|
+
--retry Reuse the last commit message after a failed commit
|
|
18
|
+
-a, --all Stage all tracked changes first
|
|
19
|
+
-S, --signoff Sign off the commit
|
|
28
20
|
|
|
29
21
|
Anything after -- is passed to git commit:
|
|
30
|
-
lintlane commit -- -
|
|
31
|
-
lintlane commit -- -n
|
|
32
|
-
|
|
22
|
+
lintlane commit -- -S sign the commit
|
|
23
|
+
lintlane commit -- -n skip git hooks
|
|
24
|
+
|
|
25
|
+
Needs "config.commitizen.path" in package.json; \`lintlane init\` adds it.
|
|
33
26
|
`;
|
|
34
27
|
|
|
35
28
|
const err = (text) => process.stderr.write(`${text}\n`);
|
|
36
29
|
|
|
37
|
-
function
|
|
38
|
-
|
|
30
|
+
function hasAdapter(cwd) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
33
|
+
return Boolean(pkg?.config?.commitizen?.path) || existsSync(join(cwd, ".czrc"));
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
39
37
|
}
|
|
40
38
|
|
|
41
39
|
export async function runCommit({ argv, gitArgs = [], cwd = process.cwd() }) {
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
({ values } = parseArgs({
|
|
45
|
-
args: argv,
|
|
46
|
-
options: {
|
|
47
|
-
"dry-run": { type: "boolean" },
|
|
48
|
-
"write-message-to-file": { type: "string" },
|
|
49
|
-
retry: { type: "boolean" },
|
|
50
|
-
"no-retry": { type: "boolean" },
|
|
51
|
-
"message-length-limit": { type: "string", short: "l" },
|
|
52
|
-
help: { type: "boolean", short: "h" },
|
|
53
|
-
},
|
|
54
|
-
}));
|
|
55
|
-
} catch (error) {
|
|
56
|
-
err(`✘ ${error.message}\n\n${COMMIT_HELP}`);
|
|
57
|
-
return 2;
|
|
58
|
-
}
|
|
59
|
-
if (values.help) {
|
|
40
|
+
if (argv.includes("-h") || argv.includes("--help")) {
|
|
60
41
|
process.stdout.write(COMMIT_HELP);
|
|
61
42
|
return 0;
|
|
62
43
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
const inRepo = git(["rev-parse", "--git-dir"], cwd);
|
|
69
|
-
if (inRepo.status !== 0) {
|
|
70
|
-
err("✘ Not inside a git repository.");
|
|
44
|
+
const cz = resolveTool("commitizen", "git-cz");
|
|
45
|
+
if (!cz) {
|
|
46
|
+
err("✘ Commitizen isn't installed. Reinstall lintlane with your package manager.");
|
|
71
47
|
return 1;
|
|
72
48
|
}
|
|
73
|
-
|
|
74
|
-
const config = loadCommitConfig(cwd);
|
|
75
|
-
const options = {
|
|
76
|
-
...config,
|
|
77
|
-
messageLengthLimit: values["message-length-limit"] ?? config.messageLengthLimit,
|
|
78
|
-
};
|
|
79
|
-
const dryRun = Boolean(values["dry-run"]);
|
|
80
|
-
|
|
81
|
-
// `-- -a` makes git stage tracked changes itself, so "nothing staged" is fine then.
|
|
82
|
-
const stagesItself = gitArgs.some((a) => a === "-a" || a === "--all");
|
|
83
|
-
if (!dryRun && !stagesItself && git(["diff", "--cached", "--quiet"], cwd).status === 0) {
|
|
49
|
+
if (!hasAdapter(cwd)) {
|
|
84
50
|
err(
|
|
85
|
-
|
|
51
|
+
'✘ Commitizen has no adapter configured. Run `lintlane init`, or add to package.json:\n "config": { "commitizen": { "path": "lintlane/presets/commitizen.cjs" } }',
|
|
86
52
|
);
|
|
87
53
|
return 1;
|
|
88
54
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
let message;
|
|
97
|
-
const autoRetry = Boolean(config.retryAfterFailure) && !values["no-retry"] && saved !== null;
|
|
98
|
-
if (values.retry || autoRetry) {
|
|
99
|
-
if (saved === null) {
|
|
100
|
-
err("✘ There's no previous commit message to retry.");
|
|
101
|
-
return 1;
|
|
102
|
-
}
|
|
103
|
-
message = saved;
|
|
104
|
-
err("↻ Reusing your last commit message:\n");
|
|
105
|
-
err(message.replace(/^/gm, " "));
|
|
106
|
-
err("");
|
|
107
|
-
} else {
|
|
108
|
-
try {
|
|
109
|
-
message = await promptCommit({ options });
|
|
110
|
-
} catch {
|
|
111
|
-
err("\n✘ Commit cancelled.");
|
|
112
|
-
return 1;
|
|
113
|
-
}
|
|
114
|
-
if (message === null) {
|
|
115
|
-
err("\n✘ Commit cancelled.");
|
|
116
|
-
return 1;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (values["write-message-to-file"]) {
|
|
121
|
-
const target = resolve(cwd, values["write-message-to-file"]);
|
|
122
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
123
|
-
writeFileSync(target, `${message}\n`);
|
|
124
|
-
}
|
|
125
|
-
if (dryRun) {
|
|
126
|
-
process.stdout.write(`${message}\n`);
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// Keep the message until the commit actually succeeds, so a failing
|
|
131
|
-
// pre-commit hook doesn't throw away your answers.
|
|
132
|
-
mkdirSync(dirname(savedPath), { recursive: true });
|
|
133
|
-
writeFileSync(savedPath, message);
|
|
134
|
-
|
|
135
|
-
const result = spawnSync("git", ["commit", "-F", "-", ...gitArgs], {
|
|
136
|
-
cwd,
|
|
137
|
-
input: message,
|
|
138
|
-
stdio: ["pipe", "inherit", "inherit"],
|
|
139
|
-
});
|
|
140
|
-
if (result.status === 0) {
|
|
141
|
-
rmSync(savedPath, { force: true });
|
|
142
|
-
return 0;
|
|
143
|
-
}
|
|
144
|
-
err(
|
|
145
|
-
"\n✘ git commit failed. Your message was saved — fix the problem, then run: lintlane commit --retry",
|
|
55
|
+
return runInherit(
|
|
56
|
+
cz[0],
|
|
57
|
+
[...cz.slice(1), ...argv, ...(gitArgs.length ? ["--", ...gitArgs] : [])],
|
|
58
|
+
{
|
|
59
|
+
cwd,
|
|
60
|
+
},
|
|
146
61
|
);
|
|
147
|
-
return result.status ?? 1;
|
|
148
62
|
}
|
package/bin/doctor.mjs
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
// `lintlane doctor` — diagnose the setup. Read-only: it never changes anything.
|
|
2
|
+
//
|
|
3
|
+
// Each finding is { id, status: "pass" | "warn" | "fail", message, fix? }.
|
|
4
|
+
// "fail" means something is broken; "warn" means something is missing or out of
|
|
5
|
+
// date but nothing is wrong yet. The exit code is 1 only when there is a fail.
|
|
6
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { resolveChecks } from "./checks.mjs";
|
|
9
|
+
import { binPath, resolveBiome, resolveTool, runProcess } from "./proc.mjs";
|
|
10
|
+
import {
|
|
11
|
+
ciCommand,
|
|
12
|
+
classifyHook,
|
|
13
|
+
detectPackageManager,
|
|
14
|
+
hasCommitlintConfig,
|
|
15
|
+
hasLintStagedConfig,
|
|
16
|
+
installCommand,
|
|
17
|
+
} from "./project.mjs";
|
|
18
|
+
|
|
19
|
+
const HOOK_NAMES = ["pre-commit", "commit-msg", "prepare-commit-msg", "pre-push"];
|
|
20
|
+
|
|
21
|
+
function major(version) {
|
|
22
|
+
return Number(String(version).replace(/^v/, "").split(".")[0]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function runDoctor({ cwd, kitDir }) {
|
|
26
|
+
const findings = [];
|
|
27
|
+
const add = (id, status, message, fix) =>
|
|
28
|
+
findings.push({ id, status, message, ...(fix ? { fix } : {}) });
|
|
29
|
+
|
|
30
|
+
const kitPkg = JSON.parse(readFileSync(join(kitDir, "package.json"), "utf8"));
|
|
31
|
+
const minNode = major(String(kitPkg.engines?.node ?? ">=22").replace(/[^\d.]/g, ""));
|
|
32
|
+
if (major(process.version) >= minNode) add("node", "pass", `Node.js ${process.version}`);
|
|
33
|
+
else {
|
|
34
|
+
add(
|
|
35
|
+
"node",
|
|
36
|
+
"fail",
|
|
37
|
+
`Node.js ${process.version}; lintlane needs ${kitPkg.engines.node}`,
|
|
38
|
+
"Upgrade Node.js.",
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const pkgPath = join(cwd, "package.json");
|
|
43
|
+
let pkg = null;
|
|
44
|
+
if (!existsSync(pkgPath)) {
|
|
45
|
+
add(
|
|
46
|
+
"package-json",
|
|
47
|
+
"fail",
|
|
48
|
+
"No package.json in this directory",
|
|
49
|
+
"Run this from your project root.",
|
|
50
|
+
);
|
|
51
|
+
} else {
|
|
52
|
+
try {
|
|
53
|
+
pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
54
|
+
add("package-json", "pass", "package.json is valid");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
add("package-json", "fail", `package.json is not valid JSON: ${error.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (!pkg) return finish(findings, kitPkg);
|
|
60
|
+
|
|
61
|
+
const pm = detectPackageManager(cwd, pkg);
|
|
62
|
+
const install = (name) => installCommand(pm.name, [name]).join(" ");
|
|
63
|
+
add("package-manager", "pass", `${pm.name} (from ${pm.source})`);
|
|
64
|
+
|
|
65
|
+
const git = await runProcess("git", ["rev-parse", "--show-toplevel"], { cwd });
|
|
66
|
+
const inRepo = git.code === 0;
|
|
67
|
+
if (inRepo) add("git", "pass", "Inside a git repository");
|
|
68
|
+
else add("git", "warn", "Not a git repository, so hooks can't be installed", "Run `git init`.");
|
|
69
|
+
|
|
70
|
+
// Biome
|
|
71
|
+
const biome = resolveBiome(cwd);
|
|
72
|
+
if (!biome) {
|
|
73
|
+
add("biome", "fail", "Biome isn't installed", `Install it: ${install("@biomejs/biome")}`);
|
|
74
|
+
} else {
|
|
75
|
+
const version = await runProcess(biome[0], [...biome.slice(1), "--version"], { cwd });
|
|
76
|
+
const local = existsSync(binPath(cwd, "biome"));
|
|
77
|
+
if (version.code !== 0)
|
|
78
|
+
add("biome", "fail", "Biome is installed but doesn't run", "Reinstall dependencies.");
|
|
79
|
+
else if (!local) {
|
|
80
|
+
add(
|
|
81
|
+
"biome",
|
|
82
|
+
"warn",
|
|
83
|
+
"Biome isn't installed in this project; using the copy next to lintlane, which lint-staged can't find",
|
|
84
|
+
`Install it: ${install("@biomejs/biome")}`,
|
|
85
|
+
);
|
|
86
|
+
} else
|
|
87
|
+
add(
|
|
88
|
+
"biome",
|
|
89
|
+
"pass",
|
|
90
|
+
`Biome ${version.stdout
|
|
91
|
+
.toString()
|
|
92
|
+
.trim()
|
|
93
|
+
.replace(/^Version:\s*/, "")}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (existsSync(join(cwd, "biome.json")) || existsSync(join(cwd, "biome.jsonc"))) {
|
|
97
|
+
add("biome-config", "pass", "Biome config found");
|
|
98
|
+
} else {
|
|
99
|
+
add("biome-config", "warn", "No biome.json", "Run `lintlane init`.");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// lintlane itself
|
|
103
|
+
const localKit = join(cwd, "node_modules", "lintlane", "bin", "cli.mjs");
|
|
104
|
+
const isKitRepo = pkg.name === "lintlane";
|
|
105
|
+
if (existsSync(localKit) || isKitRepo)
|
|
106
|
+
add("lintlane-installed", "pass", "lintlane is installed in this project");
|
|
107
|
+
else
|
|
108
|
+
add(
|
|
109
|
+
"lintlane-installed",
|
|
110
|
+
"fail",
|
|
111
|
+
"lintlane isn't installed in this project",
|
|
112
|
+
`Install it: ${install("lintlane")}`,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
// Bundled tools
|
|
116
|
+
for (const [pkgName, bin] of [
|
|
117
|
+
["lint-staged"],
|
|
118
|
+
["@commitlint/cli", "commitlint"],
|
|
119
|
+
["commitizen", "git-cz"],
|
|
120
|
+
]) {
|
|
121
|
+
add(
|
|
122
|
+
`tool:${pkgName}`,
|
|
123
|
+
resolveTool(pkgName, bin) ? "pass" : "fail",
|
|
124
|
+
resolveTool(pkgName, bin) ? `${pkgName} is available` : `${pkgName} can't be found`,
|
|
125
|
+
resolveTool(pkgName, bin) ? undefined : "Reinstall lintlane.",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Git hooks
|
|
130
|
+
if (inRepo) {
|
|
131
|
+
const hooksPath = (
|
|
132
|
+
await runProcess("git", ["config", "--get", "core.hooksPath"], { cwd })
|
|
133
|
+
).stdout
|
|
134
|
+
.toString()
|
|
135
|
+
.trim();
|
|
136
|
+
const huskyDir = join(cwd, ".husky");
|
|
137
|
+
const hasHooks = HOOK_NAMES.some((name) => existsSync(join(huskyDir, name)));
|
|
138
|
+
if (!hasHooks) {
|
|
139
|
+
add(
|
|
140
|
+
"hooks",
|
|
141
|
+
"warn",
|
|
142
|
+
"No .husky hooks found",
|
|
143
|
+
"Run `lintlane init` (or ignore if you opted out with --no-hooks).",
|
|
144
|
+
);
|
|
145
|
+
} else {
|
|
146
|
+
const huskyDep = pkg.devDependencies?.husky ?? pkg.dependencies?.husky;
|
|
147
|
+
if (!huskyDep)
|
|
148
|
+
add(
|
|
149
|
+
"husky",
|
|
150
|
+
"fail",
|
|
151
|
+
"Hooks exist but husky isn't a dependency",
|
|
152
|
+
`Install it: ${install("husky")}`,
|
|
153
|
+
);
|
|
154
|
+
else add("husky", "pass", `husky ${huskyDep}`);
|
|
155
|
+
|
|
156
|
+
if (/husky/.test(pkg.scripts?.prepare ?? ""))
|
|
157
|
+
add("prepare-script", "pass", '"prepare" script runs husky');
|
|
158
|
+
else
|
|
159
|
+
add(
|
|
160
|
+
"prepare-script",
|
|
161
|
+
"warn",
|
|
162
|
+
'No "prepare" script running husky, so teammates won\'t get hooks on install',
|
|
163
|
+
'Add "prepare": "husky".',
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (/(^|\/)\.husky(\/_)?$/.test(hooksPath))
|
|
167
|
+
add("hooks-path", "pass", `core.hooksPath = ${hooksPath}`);
|
|
168
|
+
else {
|
|
169
|
+
add(
|
|
170
|
+
"hooks-path",
|
|
171
|
+
"fail",
|
|
172
|
+
`git isn't using .husky (core.hooksPath = ${hooksPath || "unset"})`,
|
|
173
|
+
`Run: ${pm.name === "npm" ? "npx" : pm.name === "bun" ? "bunx" : `${pm.name} exec`} husky`,
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const name of HOOK_NAMES) {
|
|
178
|
+
const hookPath = join(huskyDir, name);
|
|
179
|
+
const templatePath = join(kitDir, "templates", name);
|
|
180
|
+
if (!existsSync(hookPath)) {
|
|
181
|
+
if (name === "pre-commit")
|
|
182
|
+
add(`hook:${name}`, "fail", ".husky/pre-commit is missing", "Run `lintlane init`.");
|
|
183
|
+
else
|
|
184
|
+
add(
|
|
185
|
+
`hook:${name}`,
|
|
186
|
+
"warn",
|
|
187
|
+
`.husky/${name} is missing`,
|
|
188
|
+
"Run `lintlane init` (or ignore if you opted out with --no-commit-lint).",
|
|
189
|
+
);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const state = classifyHook(
|
|
193
|
+
name,
|
|
194
|
+
readFileSync(hookPath, "utf8"),
|
|
195
|
+
readFileSync(templatePath, "utf8"),
|
|
196
|
+
);
|
|
197
|
+
const executable = process.platform === "win32" || (statSync(hookPath).mode & 0o111) !== 0;
|
|
198
|
+
if (state === "legacy") {
|
|
199
|
+
add(
|
|
200
|
+
`hook:${name}`,
|
|
201
|
+
"fail",
|
|
202
|
+
`.husky/${name} is from an older lintlane and no longer does anything`,
|
|
203
|
+
"Run `lintlane init` to upgrade it.",
|
|
204
|
+
);
|
|
205
|
+
} else if (state === "stale" || state === "unsafe") {
|
|
206
|
+
add(
|
|
207
|
+
`hook:${name}`,
|
|
208
|
+
"fail",
|
|
209
|
+
`.husky/${name} calls files lintlane no longer ships`,
|
|
210
|
+
`Replace its contents with node_modules/lintlane/templates/${name}.`,
|
|
211
|
+
);
|
|
212
|
+
} else if (!executable) {
|
|
213
|
+
add(`hook:${name}`, "fail", `.husky/${name} isn't executable`, `chmod +x .husky/${name}`);
|
|
214
|
+
} else {
|
|
215
|
+
add(
|
|
216
|
+
`hook:${name}`,
|
|
217
|
+
"pass",
|
|
218
|
+
state === "current" ? `.husky/${name} is up to date` : `.husky/${name} is customized`,
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (existsSync(join(git.stdout.toString().trim(), ".git", "lintlane-backup"))) {
|
|
224
|
+
add(
|
|
225
|
+
"old-backup",
|
|
226
|
+
"warn",
|
|
227
|
+
"Leftover .git/lintlane-backup from lintlane < 4 (an interrupted old hook run)",
|
|
228
|
+
"Check its files for unsaved work, then delete the folder.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// Staged files + commits
|
|
234
|
+
if (hasLintStagedConfig(cwd, pkg)) add("lint-staged-config", "pass", "lint-staged is configured");
|
|
235
|
+
else
|
|
236
|
+
add(
|
|
237
|
+
"lint-staged-config",
|
|
238
|
+
"warn",
|
|
239
|
+
"No lint-staged configuration; the pre-commit hook has nothing to run",
|
|
240
|
+
"Run `lintlane init`.",
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
if (hasCommitlintConfig(cwd, pkg)) add("commitlint-config", "pass", "commitlint is configured");
|
|
244
|
+
else
|
|
245
|
+
add(
|
|
246
|
+
"commitlint-config",
|
|
247
|
+
"warn",
|
|
248
|
+
"No commitlint configuration; commit messages aren't validated",
|
|
249
|
+
"Run `lintlane init`.",
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
const czPath = pkg.config?.commitizen?.path;
|
|
253
|
+
if (czPath) add("commitizen-config", "pass", `Commitizen adapter: ${czPath}`);
|
|
254
|
+
else
|
|
255
|
+
add(
|
|
256
|
+
"commitizen-config",
|
|
257
|
+
"warn",
|
|
258
|
+
"No Commitizen adapter; `lintlane commit` can't prompt",
|
|
259
|
+
"Run `lintlane init`.",
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Checks + CI
|
|
263
|
+
if (biome) {
|
|
264
|
+
const { steps, problems } = resolveChecks({ cwd, biome, pmName: pm.name, pkg });
|
|
265
|
+
for (const problem of problems) add("checks-config", "warn", problem);
|
|
266
|
+
if (problems.length === 0) {
|
|
267
|
+
add(
|
|
268
|
+
"checks",
|
|
269
|
+
"pass",
|
|
270
|
+
`Checks: ${steps
|
|
271
|
+
.filter((s) => s.defaultOn)
|
|
272
|
+
.map((s) => s.id)
|
|
273
|
+
.join(", ")}`,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const ciPath = join(cwd, ".github", "workflows", "biome.yml");
|
|
277
|
+
if (existsSync(ciPath)) {
|
|
278
|
+
const workflow = readFileSync(ciPath, "utf8");
|
|
279
|
+
const missing = steps
|
|
280
|
+
.filter((step) => step.ci)
|
|
281
|
+
.map((step) => ciCommand(pm.name, step))
|
|
282
|
+
.filter((command) => command && !workflow.includes(command));
|
|
283
|
+
if (missing.length === 0)
|
|
284
|
+
add("ci", "pass", ".github/workflows/biome.yml matches your checks");
|
|
285
|
+
else
|
|
286
|
+
add(
|
|
287
|
+
"ci",
|
|
288
|
+
"warn",
|
|
289
|
+
`CI workflow doesn't run: ${missing.join(", ")}`,
|
|
290
|
+
"Add those steps, or turn the check off in package.json under lintlane.checks.",
|
|
291
|
+
);
|
|
292
|
+
} else {
|
|
293
|
+
add(
|
|
294
|
+
"ci",
|
|
295
|
+
"warn",
|
|
296
|
+
"No .github/workflows/biome.yml",
|
|
297
|
+
"Run `lintlane init` (or ignore if you opted out with --no-ci).",
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return finish(findings, kitPkg);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function finish(findings, kitPkg) {
|
|
306
|
+
return {
|
|
307
|
+
ok: !findings.some((finding) => finding.status === "fail"),
|
|
308
|
+
version: kitPkg.version,
|
|
309
|
+
findings,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const ICONS = { pass: "✔", warn: "!", fail: "✘" };
|
|
314
|
+
|
|
315
|
+
export function formatReport(report) {
|
|
316
|
+
const lines = [`lintlane doctor (v${report.version})`, ""];
|
|
317
|
+
for (const f of report.findings) {
|
|
318
|
+
lines.push(`${ICONS[f.status]} ${f.message}`);
|
|
319
|
+
if (f.fix && f.status !== "pass") lines.push(` → ${f.fix}`);
|
|
320
|
+
}
|
|
321
|
+
const fails = report.findings.filter((f) => f.status === "fail").length;
|
|
322
|
+
const warns = report.findings.filter((f) => f.status === "warn").length;
|
|
323
|
+
lines.push(
|
|
324
|
+
"",
|
|
325
|
+
fails || warns ? `${fails} problem(s), ${warns} warning(s).` : "Everything looks good.",
|
|
326
|
+
);
|
|
327
|
+
return lines.join("\n");
|
|
328
|
+
}
|
package/bin/hook.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// Git hook entry points, run as `lintlane hook <name> [git args]` from the
|
|
2
|
+
// .husky/ scripts. Each one hands the real work to a proven tool:
|
|
3
|
+
//
|
|
4
|
+
// pre-commit lint-staged (safe with partially staged files; runs Biome)
|
|
5
|
+
// commit-msg commitlint (validates the message)
|
|
6
|
+
// prepare-commit-msg commitizen (guided prompt on a plain `git commit`)
|
|
7
|
+
// pre-push commitlint (checks every commit about to be pushed)
|
|
8
|
+
import { spawnSync } from "node:child_process";
|
|
9
|
+
import { closeSync, openSync, readFileSync } from "node:fs";
|
|
10
|
+
import { resolveTool, runInherit, runProcess } from "./proc.mjs";
|
|
11
|
+
|
|
12
|
+
export const HOOKS = ["pre-commit", "commit-msg", "prepare-commit-msg", "pre-push"];
|
|
13
|
+
|
|
14
|
+
const ZERO = /^0+$/;
|
|
15
|
+
const err = (text) => process.stderr.write(`${text}\n`);
|
|
16
|
+
|
|
17
|
+
function tool(pkg, bin) {
|
|
18
|
+
const argv = resolveTool(pkg, bin);
|
|
19
|
+
if (!argv) {
|
|
20
|
+
err(`lintlane: couldn't find ${pkg}. Reinstall lintlane with your package manager.`);
|
|
21
|
+
}
|
|
22
|
+
return argv;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function gitText(args, cwd) {
|
|
26
|
+
const result = await runProcess("git", args, { cwd });
|
|
27
|
+
return result.code === 0 ? result.stdout.toString().trim() : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function preCommit(cwd) {
|
|
31
|
+
const argv = tool("lint-staged");
|
|
32
|
+
if (!argv) return 1;
|
|
33
|
+
return runInherit(argv[0], argv.slice(1), { cwd });
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function commitMsg(cwd, [file]) {
|
|
37
|
+
if (!file) {
|
|
38
|
+
err("lintlane: commit-msg expects the path to the commit message file.");
|
|
39
|
+
return 2;
|
|
40
|
+
}
|
|
41
|
+
const argv = tool("@commitlint/cli", "commitlint");
|
|
42
|
+
if (!argv) return 1;
|
|
43
|
+
return runInherit(argv[0], [...argv.slice(1), "--edit", file, "--cwd", cwd], { cwd });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A plain `git commit` (no -m, no template, not a merge or amend) gets the guided
|
|
47
|
+
// prompt. Anything else, or no terminal to ask on, is left alone.
|
|
48
|
+
function prepareCommitMsg(cwd, [file, source]) {
|
|
49
|
+
if (!file || source || process.env.CI || process.env.LINTLANE_NO_PROMPT) return 0;
|
|
50
|
+
if (process.platform === "win32") return 0;
|
|
51
|
+
const argv = tool("commitizen", "git-cz");
|
|
52
|
+
if (!argv) return 0;
|
|
53
|
+
let tty;
|
|
54
|
+
try {
|
|
55
|
+
tty = openSync("/dev/tty", "r"); // git runs hooks with stdin detached
|
|
56
|
+
} catch {
|
|
57
|
+
return 0; // GUI client, script: stay out of the way
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
const result = spawnSync(argv[0], [...argv.slice(1), "--hook"], {
|
|
61
|
+
cwd,
|
|
62
|
+
stdio: [tty, "inherit", "inherit"],
|
|
63
|
+
});
|
|
64
|
+
return result.status ?? 1;
|
|
65
|
+
} finally {
|
|
66
|
+
closeSync(tty);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function lintRange(argv, cwd, from, to) {
|
|
71
|
+
const result = await runProcess(
|
|
72
|
+
argv[0],
|
|
73
|
+
[...argv.slice(1), "--from", from, "--to", to, "--cwd", cwd],
|
|
74
|
+
{
|
|
75
|
+
cwd,
|
|
76
|
+
},
|
|
77
|
+
);
|
|
78
|
+
if (result.code !== 0) {
|
|
79
|
+
process.stderr.write(result.stderr);
|
|
80
|
+
process.stdout.write(result.stdout);
|
|
81
|
+
}
|
|
82
|
+
return result.code === 0;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function lintMessage(argv, cwd, message) {
|
|
86
|
+
const result = await runProcess(argv[0], [...argv.slice(1), "--cwd", cwd], {
|
|
87
|
+
cwd,
|
|
88
|
+
input: message,
|
|
89
|
+
});
|
|
90
|
+
if (result.code !== 0) {
|
|
91
|
+
process.stderr.write(result.stderr);
|
|
92
|
+
process.stdout.write(result.stdout);
|
|
93
|
+
}
|
|
94
|
+
return result.code === 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Safety net for commits that skipped commit-msg (--no-verify, a GUI client, a
|
|
98
|
+
// clone without hooks). Never blocks when a range can't be inspected.
|
|
99
|
+
async function prePush(cwd, [remote = "origin"], stdin) {
|
|
100
|
+
const argv = tool("@commitlint/cli", "commitlint");
|
|
101
|
+
if (!argv) return 1;
|
|
102
|
+
let bad = 0;
|
|
103
|
+
for (const line of stdin.split("\n")) {
|
|
104
|
+
const [localRef, localSha, , remoteSha] = line.trim().split(/\s+/);
|
|
105
|
+
if (!localRef || !localSha || ZERO.test(localSha) || localRef.startsWith("refs/tags/"))
|
|
106
|
+
continue;
|
|
107
|
+
if (!ZERO.test(remoteSha ?? "0")) {
|
|
108
|
+
if (!(await lintRange(argv, cwd, remoteSha, localSha))) bad++;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
// New branch: every commit not already on a remote.
|
|
112
|
+
const listed = await gitText(
|
|
113
|
+
["rev-list", "--reverse", localSha, "--not", `--remotes=${remote}`],
|
|
114
|
+
cwd,
|
|
115
|
+
);
|
|
116
|
+
const commits = listed ? listed.split("\n") : [];
|
|
117
|
+
if (commits.length === 0) continue;
|
|
118
|
+
if ((await gitText(["rev-parse", "--verify", "-q", `${commits[0]}^`], cwd)) !== null) {
|
|
119
|
+
if (!(await lintRange(argv, cwd, `${commits[0]}^`, localSha))) bad++;
|
|
120
|
+
} else {
|
|
121
|
+
for (const sha of commits) {
|
|
122
|
+
const message = await gitText(["log", "-1", "--format=%B", sha], cwd);
|
|
123
|
+
if (message !== null && !(await lintMessage(argv, cwd, message))) bad++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (bad > 0) {
|
|
128
|
+
err(
|
|
129
|
+
"\n✘ Push blocked: some commit messages don't follow Conventional Commits.\n" +
|
|
130
|
+
" Reword them with `git rebase -i` (or `git commit --amend` for the latest), then push again.\n",
|
|
131
|
+
);
|
|
132
|
+
return 1;
|
|
133
|
+
}
|
|
134
|
+
return 0;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function runHook(name, args, cwd = process.cwd()) {
|
|
138
|
+
switch (name) {
|
|
139
|
+
case "pre-commit":
|
|
140
|
+
return preCommit(cwd);
|
|
141
|
+
case "commit-msg":
|
|
142
|
+
return commitMsg(cwd, args);
|
|
143
|
+
case "prepare-commit-msg":
|
|
144
|
+
return prepareCommitMsg(cwd, args);
|
|
145
|
+
case "pre-push": {
|
|
146
|
+
let stdin = "";
|
|
147
|
+
try {
|
|
148
|
+
stdin = readFileSync(0, "utf8");
|
|
149
|
+
} catch {}
|
|
150
|
+
return prePush(cwd, args, stdin);
|
|
151
|
+
}
|
|
152
|
+
default:
|
|
153
|
+
err(`lintlane: unknown hook "${name}". Known hooks: ${HOOKS.join(", ")}.`);
|
|
154
|
+
return 2;
|
|
155
|
+
}
|
|
156
|
+
}
|