lintlane 3.0.1 → 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/README.md +204 -632
- package/bin/check-commit.mjs +54 -137
- package/bin/{ci.mjs → checks.mjs} +104 -35
- package/bin/cli.mjs +226 -69
- package/bin/commit-cli.mjs +36 -122
- package/bin/doctor.mjs +328 -0
- package/bin/hook.mjs +156 -0
- package/bin/proc.mjs +20 -1
- package/bin/project.mjs +100 -4
- package/bin/ui-server.mjs +77 -10
- package/package.json +18 -5
- package/presets/commitizen.cjs +4 -0
- package/presets/commitlint.mjs +47 -0
- package/templates/commit-msg +2 -2
- package/templates/pre-commit +2 -5
- package/templates/pre-push +2 -2
- package/templates/prepare-commit-msg +2 -2
- package/ui/index.html +210 -44
- 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-commit.mjs +0 -55
- package/bin/pre-push-check.mjs +0 -48
- package/bin/prepare-commit-msg.mjs +0 -46
- package/bin/staged.mjs +0 -333
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
|
+
}
|
package/bin/proc.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { existsSync } from "node:fs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
|
-
import { join } from "node:path";
|
|
7
|
+
import { dirname, join } from "node:path";
|
|
8
8
|
|
|
9
9
|
const isWindows = process.platform === "win32";
|
|
10
10
|
|
|
@@ -29,6 +29,25 @@ export function resolveBiome(cwd) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The argv that runs a binary shipped by one of lintlane's own dependencies
|
|
34
|
+
* (lint-staged, commitlint, commitizen). Resolved from lintlane's location, so it
|
|
35
|
+
* works with every package manager, including pnpm's strict node_modules layout.
|
|
36
|
+
* Returns null if the package isn't installed.
|
|
37
|
+
*/
|
|
38
|
+
export function resolveTool(pkgName, binName = pkgName) {
|
|
39
|
+
try {
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const manifestPath = require.resolve(`${pkgName}/package.json`);
|
|
42
|
+
const manifest = require(manifestPath);
|
|
43
|
+
const rel = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.[binName];
|
|
44
|
+
if (!rel) return null;
|
|
45
|
+
return [process.execPath, join(dirname(manifestPath), rel)];
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
32
51
|
// Windows can't spawn .cmd shims (npm, pnpm, yarn, node_modules/.bin/*) without
|
|
33
52
|
// a shell. git and node are real executables and are spawned directly.
|
|
34
53
|
function prepare(command, args) {
|
package/bin/project.mjs
CHANGED
|
@@ -126,8 +126,21 @@ export function detectPreset(pkg) {
|
|
|
126
126
|
return { preset: "base", label: null };
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
const RUN_SCRIPT_PREFIX = { npm: "npm run", pnpm: "pnpm run", yarn: "yarn run", bun: "bun run" };
|
|
130
|
+
|
|
131
|
+
/** The `run:` command a check has in the generated workflow, or null if it has none. */
|
|
132
|
+
export function ciCommand(pmName, step) {
|
|
133
|
+
if (step.id === "biome") return PACKAGE_MANAGERS[pmName].runCheckCi;
|
|
134
|
+
if (step.ci && step.script) return `${RUN_SCRIPT_PREFIX[pmName]} ${step.script}`;
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The GitHub Actions workflow `init` writes. `checks` is the resolved list from
|
|
140
|
+
* checks.mjs, so the workflow runs exactly what the CLI and dashboard run.
|
|
141
|
+
* Without it, only Biome.
|
|
142
|
+
*/
|
|
143
|
+
export function renderWorkflow({ pmName, kitName, withCommitCheck, checks }) {
|
|
131
144
|
const pm = PACKAGE_MANAGERS[pmName];
|
|
132
145
|
// Catches commits that skipped the local hooks (--no-verify, GUI clients, fresh clones).
|
|
133
146
|
const checkout = withCommitCheck ? "\n with:\n fetch-depth: 0" : "";
|
|
@@ -136,9 +149,13 @@ export function renderWorkflow({ pmName, kitName, withCommitCheck }) {
|
|
|
136
149
|
"",
|
|
137
150
|
" - name: Check commit messages",
|
|
138
151
|
" if: github.event_name == 'pull_request'",
|
|
139
|
-
` run: node node_modules/${kitName}/bin/check-commit
|
|
152
|
+
` run: node node_modules/${kitName}/bin/cli.mjs check-commit --rev-range origin/\${{ github.base_ref }}..HEAD`,
|
|
140
153
|
].join("\n")
|
|
141
154
|
: "";
|
|
155
|
+
const commands = (checks ?? [{ id: "biome", ci: true }])
|
|
156
|
+
.map((step) => ciCommand(pmName, step))
|
|
157
|
+
.filter(Boolean);
|
|
158
|
+
const runSteps = commands.map((command) => ` - run: ${command}`).join("\n");
|
|
142
159
|
return `name: Biome
|
|
143
160
|
|
|
144
161
|
on:
|
|
@@ -153,7 +170,7 @@ jobs:
|
|
|
153
170
|
steps:
|
|
154
171
|
- uses: actions/checkout@v4${checkout}
|
|
155
172
|
${pm.setupSteps.join("\n")}${commitStep}
|
|
156
|
-
|
|
173
|
+
${runSteps}
|
|
157
174
|
`;
|
|
158
175
|
}
|
|
159
176
|
|
|
@@ -178,3 +195,82 @@ export function isLegacyPreCommit(content) {
|
|
|
178
195
|
export function hasUnsafeRestaging(content) {
|
|
179
196
|
return /git\s+update-index\s+--again/.test(content);
|
|
180
197
|
}
|
|
198
|
+
|
|
199
|
+
// Hooks written by earlier versions of lintlane that did their own work in
|
|
200
|
+
// bin/*.mjs files which no longer exist. An untouched copy is safe to replace.
|
|
201
|
+
const LEGACY_HOOKS = {
|
|
202
|
+
"pre-commit": [
|
|
203
|
+
'if [ -f node_modules/lintlane/bin/pre-commit.mjs ]; then\n node node_modules/lintlane/bin/pre-commit.mjs\nelse\n echo "lintlane: node_modules/lintlane not found; skipping staged-file checks. Run your package manager\'s install." >&2\nfi',
|
|
204
|
+
],
|
|
205
|
+
"commit-msg": [
|
|
206
|
+
'[ -f node_modules/lintlane/bin/commit-msg.mjs ] || exit 0\nnode node_modules/lintlane/bin/commit-msg.mjs "$1"',
|
|
207
|
+
],
|
|
208
|
+
"prepare-commit-msg": [
|
|
209
|
+
'[ -f node_modules/lintlane/bin/prepare-commit-msg.mjs ] || exit 0\nnode node_modules/lintlane/bin/prepare-commit-msg.mjs "$1" "$2"',
|
|
210
|
+
],
|
|
211
|
+
"pre-push": [
|
|
212
|
+
'[ -f node_modules/lintlane/bin/pre-push-check.mjs ] || exit 0\nnode node_modules/lintlane/bin/pre-push-check.mjs "$1" "$2"',
|
|
213
|
+
],
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const REMOVED_ENTRYPOINTS =
|
|
217
|
+
/bin\/(pre-commit|commit-msg|prepare-commit-msg|pre-push-check|staged|commit-lint)\.mjs/;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* What a hook file is, relative to this version of lintlane:
|
|
221
|
+
* "current" the shipped template
|
|
222
|
+
* "legacy" an untouched hook from an older lintlane (safe to replace)
|
|
223
|
+
* "stale" edited, but still calls files lintlane no longer ships (needs a human)
|
|
224
|
+
* "unsafe" edited, and re-stages with `git update-index --again`
|
|
225
|
+
* "custom" the project's own hook
|
|
226
|
+
*/
|
|
227
|
+
export function classifyHook(name, content, template) {
|
|
228
|
+
const normalized = content.trim();
|
|
229
|
+
if (normalized === template.trim()) return "current";
|
|
230
|
+
if (name === "pre-commit" && isLegacyPreCommit(content)) return "legacy";
|
|
231
|
+
if ((LEGACY_HOOKS[name] ?? []).some((legacy) => legacy === normalized)) return "legacy";
|
|
232
|
+
if (REMOVED_ENTRYPOINTS.test(content)) return "stale";
|
|
233
|
+
if (name === "pre-commit" && hasUnsafeRestaging(content)) return "unsafe";
|
|
234
|
+
return "custom";
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Does the project already configure lint-staged? (package.json key or a config file) */
|
|
238
|
+
export function hasLintStagedConfig(cwd, pkg) {
|
|
239
|
+
if (pkg?.["lint-staged"]) return true;
|
|
240
|
+
return [
|
|
241
|
+
".lintstagedrc",
|
|
242
|
+
".lintstagedrc.json",
|
|
243
|
+
".lintstagedrc.yaml",
|
|
244
|
+
".lintstagedrc.yml",
|
|
245
|
+
".lintstagedrc.js",
|
|
246
|
+
".lintstagedrc.cjs",
|
|
247
|
+
".lintstagedrc.mjs",
|
|
248
|
+
"lint-staged.config.js",
|
|
249
|
+
"lint-staged.config.cjs",
|
|
250
|
+
"lint-staged.config.mjs",
|
|
251
|
+
].some((file) => existsSync(join(cwd, file)));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Does the project already configure commitlint? */
|
|
255
|
+
export function hasCommitlintConfig(cwd, pkg) {
|
|
256
|
+
if (pkg?.commitlint) return true;
|
|
257
|
+
return [
|
|
258
|
+
".commitlintrc",
|
|
259
|
+
".commitlintrc.json",
|
|
260
|
+
".commitlintrc.yaml",
|
|
261
|
+
".commitlintrc.yml",
|
|
262
|
+
".commitlintrc.js",
|
|
263
|
+
".commitlintrc.cjs",
|
|
264
|
+
".commitlintrc.mjs",
|
|
265
|
+
".commitlintrc.ts",
|
|
266
|
+
"commitlint.config.js",
|
|
267
|
+
"commitlint.config.cjs",
|
|
268
|
+
"commitlint.config.mjs",
|
|
269
|
+
"commitlint.config.ts",
|
|
270
|
+
].some((file) => existsSync(join(cwd, file)));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const LINT_STAGED_CONFIG = {
|
|
274
|
+
"*": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true",
|
|
275
|
+
};
|
|
276
|
+
export const COMMITIZEN_PATH = "lintlane/presets/commitizen.cjs";
|