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.
@@ -1,89 +1,37 @@
1
- // `lintlane check-commit` — validate commit messages (Commitizen's `cz check`).
2
- // Plain Node on purpose: it also runs in CI and in the pre-push hook.
1
+ // `lintlane check-commit` — validate commit messages with commitlint.
3
2
  //
4
3
  // --message "<msg>" check a string (or pipe it in on stdin)
5
- // --commit-msg-file <path> check a message file (what the commit-msg hook does)
6
- // --rev-range <range> check every commit in a git range, e.g. main..HEAD
4
+ // --commit-msg-file <path> check a message file
5
+ // --rev-range <a..b> check every commit in a git range
7
6
  // -d, --use-default-range same as --rev-range <default branch>..HEAD
8
- // --allow-abort accept an empty message (git aborts those itself)
9
- // --allowed-prefixes <p> extra message prefixes to skip (repeatable)
10
- // -l, --message-length-limit N max header length (0 = no limit)
11
- import { execFileSync } from "node:child_process";
12
7
  import { readFileSync } from "node:fs";
13
- import { pathToFileURL } from "node:url";
14
8
  import { parseArgs } from "node:util";
15
- import { loadCommitConfig } from "./commit-config.mjs";
16
- import { cleanMessage, explainFailure, validateMessage } from "./commit-lint.mjs";
9
+ import { resolveTool, runInherit, runProcess } from "./proc.mjs";
17
10
 
18
11
  const HELP = `Usage: lintlane check-commit [options]
19
12
 
20
- Checks that commit messages follow Conventional Commits. Give exactly one of:
13
+ Checks that commit messages follow Conventional Commits (commitlint). Give one of:
21
14
  --message <msg> a message (or pipe one in: echo "feat: x" | lintlane check-commit)
22
15
  --commit-msg-file <path> a file containing the message
23
- --rev-range <range> every commit in a range, e.g. origin/main..HEAD
16
+ --rev-range <a..b> every commit in a range, e.g. origin/main..HEAD
24
17
  -d, --use-default-range shorthand for <default branch>..HEAD
25
18
 
26
- Options:
27
- --allow-abort Accept an empty message
28
- --allowed-prefixes <prefix> Skip messages starting with this (repeatable)
29
- -l, --message-length-limit <n> Maximum header length (0 = no limit)
19
+ Rules come from commitlint.config.mjs (see \`lintlane init\`) and the optional
20
+ "lintlane.commit" block in package.json.
30
21
  `;
31
22
 
32
- const git = (args, cwd) =>
33
- execFileSync("git", args, {
23
+ const err = (text) => process.stderr.write(`${text}\n`);
24
+
25
+ async function defaultBranch(cwd) {
26
+ const head = await runProcess("git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], {
34
27
  cwd,
35
- encoding: "utf8",
36
- maxBuffer: 64 * 1024 * 1024,
37
- stdio: ["ignore", "pipe", "pipe"],
38
28
  });
39
-
40
- /** Commits reachable by the given `git log` arguments, oldest last. */
41
- export function listCommits(logArgs, cwd = process.cwd()) {
42
- const out = git(["log", "--format=%H%x1f%B%x1e", ...logArgs], cwd);
43
- return out
44
- .split("\x1e")
45
- .map((entry) => entry.replace(/^\n/, ""))
46
- .filter((entry) => entry.trim() !== "")
47
- .map((entry) => {
48
- const [hash, ...message] = entry.split("\x1f");
49
- return { hash: hash.trim(), message: message.join("\x1f") };
50
- });
51
- }
52
-
53
- /** Validate commits; returns only the failing ones, with their errors. */
54
- export function findInvalidCommits(commits, options) {
55
- const invalid = [];
56
- for (const commit of commits) {
57
- const result = validateMessage(commit.message, options);
58
- if (!result.valid) invalid.push({ ...commit, errors: result.errors });
59
- }
60
- return invalid;
61
- }
62
-
63
- export function describeInvalid(invalid) {
64
- return invalid
65
- .map((c) => {
66
- const subject = cleanMessage(c.message).split("\n")[0] || "(empty message)";
67
- return ` ${c.hash.slice(0, 8)} ${subject}\n${c.errors.map((e) => ` • ${e}`).join("\n")}`;
68
- })
69
- .join("\n");
70
- }
71
-
72
- function defaultBranchRange(cwd) {
73
- const candidates = [];
74
- try {
75
- candidates.push(git(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], cwd).trim());
76
- } catch {}
77
- candidates.push("origin/main", "origin/master", "main", "master");
78
- for (const ref of candidates) {
79
- try {
80
- git(["rev-parse", "--verify", "--quiet", ref], cwd);
81
- return `${ref}..HEAD`;
82
- } catch {}
29
+ if (head.code === 0) return head.stdout.toString().trim();
30
+ for (const name of ["origin/main", "origin/master", "main", "master"]) {
31
+ const probe = await runProcess("git", ["rev-parse", "--verify", "-q", name], { cwd });
32
+ if (probe.code === 0) return name;
83
33
  }
84
- throw new Error(
85
- "Couldn't find a default branch (tried origin/HEAD, main, master). Use --rev-range.",
86
- );
34
+ return null;
87
35
  }
88
36
 
89
37
  export async function main(argv, cwd = process.cwd()) {
@@ -96,14 +44,11 @@ export async function main(argv, cwd = process.cwd()) {
96
44
  "commit-msg-file": { type: "string" },
97
45
  "rev-range": { type: "string" },
98
46
  "use-default-range": { type: "boolean", short: "d" },
99
- "allow-abort": { type: "boolean" },
100
- "allowed-prefixes": { type: "string", multiple: true },
101
- "message-length-limit": { type: "string", short: "l" },
102
47
  help: { type: "boolean", short: "h" },
103
48
  },
104
49
  }));
105
50
  } catch (error) {
106
- process.stderr.write(`✘ ${error.message}\n\n${HELP}`);
51
+ err(`✘ ${error.message}\n\n${HELP}`);
107
52
  return 2;
108
53
  }
109
54
  if (values.help) {
@@ -111,73 +56,45 @@ export async function main(argv, cwd = process.cwd()) {
111
56
  return 0;
112
57
  }
113
58
 
114
- const config = loadCommitConfig(cwd);
115
- const options = {
116
- ...config,
117
- allowAbort: values["allow-abort"] ?? config.allowAbort,
118
- allowedPrefixes: [...(config.allowedPrefixes ?? []), ...(values["allowed-prefixes"] ?? [])],
119
- messageLengthLimit: values["message-length-limit"] ?? config.messageLengthLimit,
120
- };
121
-
122
- const modes = [
123
- values.message,
124
- values["commit-msg-file"],
125
- values["rev-range"],
126
- values["use-default-range"],
127
- ].filter((v) => v !== undefined && v !== false).length;
128
- if (modes > 1) {
129
- process.stderr.write(
130
- `✘ Choose only one of --message, --commit-msg-file, --rev-range, --use-default-range.\n`,
131
- );
132
- return 2;
59
+ const commitlint = resolveTool("@commitlint/cli", "commitlint");
60
+ if (!commitlint) {
61
+ err("✘ commitlint isn't installed. Reinstall lintlane with your package manager.");
62
+ return 1;
133
63
  }
64
+ const base = [...commitlint.slice(1), "--cwd", cwd];
65
+ const run = (args, input) =>
66
+ input === undefined
67
+ ? runInherit(commitlint[0], [...base, ...args], { cwd })
68
+ : runProcess(commitlint[0], [...base, ...args], { cwd, input }).then((result) => {
69
+ process.stdout.write(result.stdout);
70
+ process.stderr.write(result.stderr);
71
+ return result.code;
72
+ });
134
73
 
135
- try {
136
- let invalid;
137
- let checked;
138
- if (values["rev-range"] || values["use-default-range"]) {
139
- const range = values["rev-range"] ?? defaultBranchRange(cwd);
140
- const commits = listCommits([range], cwd);
141
- checked = commits.length;
142
- invalid = findInvalidCommits(commits, options);
143
- } else {
144
- let raw;
145
- if (values.message !== undefined) raw = values.message;
146
- else if (values["commit-msg-file"]) raw = readFileSync(values["commit-msg-file"], "utf8");
147
- else if (!process.stdin.isTTY) raw = readFileSync(0, "utf8");
148
- else {
149
- process.stderr.write(`✘ Nothing to check.\n\n${HELP}`);
150
- return 2;
151
- }
152
- checked = 1;
153
- const result = validateMessage(raw, options);
154
- invalid = result.valid ? [] : [{ hash: "", message: raw, errors: result.errors }];
155
- }
156
-
157
- if (invalid.length === 0) {
158
- process.stdout.write(`✔ Commit validation successful (${checked} checked).\n`);
159
- return 0;
74
+ let range = values["rev-range"];
75
+ if (values["use-default-range"]) {
76
+ const branch = await defaultBranch(cwd);
77
+ if (!branch) {
78
+ err("✘ Couldn't find the default branch. Use --rev-range <a..b>.");
79
+ return 1;
160
80
  }
161
- if (
162
- values.message !== undefined ||
163
- values["commit-msg-file"] ||
164
- (!values["rev-range"] && !values["use-default-range"])
165
- ) {
166
- process.stderr.write(`${explainFailure(invalid[0].errors, options)}\n`);
167
- } else {
168
- process.stderr.write(
169
- `\n✘ ${invalid.length} of ${checked} commit message(s) don't follow Conventional Commits:\n\n${describeInvalid(invalid)}\n\n` +
170
- " Fix them with `git rebase -i` (reword) or `git commit --amend` for the latest one.\n\n",
171
- );
81
+ range = `${branch}..HEAD`;
82
+ }
83
+ if (range) {
84
+ const [from, to = "HEAD"] = range.split("..");
85
+ if (!from) {
86
+ err('✘ --rev-range must look like "origin/main..HEAD".');
87
+ return 2;
172
88
  }
173
- return 1;
174
- } catch (error) {
175
- const detail = error.stderr?.toString().trim().split("\n")[0] || error.message;
176
- process.stderr.write(`✘ ${detail}\n`);
177
- return 2;
89
+ return run(["--from", from, "--to", to]);
178
90
  }
179
- }
180
-
181
- if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
182
- process.exit(await main(process.argv.slice(2)));
91
+ if (values["commit-msg-file"]) return run(["--edit", values["commit-msg-file"]]);
92
+ if (values.message !== undefined) return run([], values.message);
93
+ if (!process.stdin.isTTY) {
94
+ try {
95
+ return run([], readFileSync(0, "utf8"));
96
+ } catch {}
97
+ }
98
+ err(`✘ Nothing to check.\n\n${HELP}`);
99
+ return 2;
183
100
  }
package/bin/checks.mjs ADDED
@@ -0,0 +1,237 @@
1
+ import { existsSync } from "node:fs";
2
+ import { StringDecoder } from "node:string_decoder";
3
+ import { binPath, spawnPrepared } from "./proc.mjs";
4
+
5
+ const MAX_LOG_CHARS = 2_000_000;
6
+
7
+ const RUN_SCRIPT = {
8
+ bun: (name) => ["bun", "run", name],
9
+ npm: (name) => ["npm", "run", name],
10
+ pnpm: (name) => ["pnpm", "run", name],
11
+ yarn: (name) => ["yarn", "run", name],
12
+ };
13
+
14
+ // Same commands the generated GitHub workflow uses for its install step.
15
+ const FROZEN_INSTALL = {
16
+ bun: ["bun", "install", "--frozen-lockfile"],
17
+ npm: ["npm", "ci"],
18
+ pnpm: ["pnpm", "install", "--frozen-lockfile"],
19
+ yarn: ["yarn", "install", "--frozen-lockfile"],
20
+ };
21
+
22
+ const NPM_DEFAULT_TEST = /no test specified/i;
23
+ const CHECK_IDS = ["biome", "typecheck", "build", "test"];
24
+
25
+ function firstScript(scripts, names) {
26
+ return names.find((name) => typeof scripts[name] === "string");
27
+ }
28
+
29
+ /**
30
+ * The one list of checks. The dashboard, `lintlane run` and the generated CI
31
+ * workflow all read it, so they can't drift apart.
32
+ *
33
+ * Detected from package.json (scripts, tsconfig) and tuned in package.json:
34
+ *
35
+ * "lintlane": { "checks": { "build": false, "test": "test:unit", "typecheck": true } }
36
+ *
37
+ * false turns a check off everywhere; a string names the package.json script to
38
+ * run; true keeps the detected one. Unknown ids and bad values are reported in
39
+ * `problems` and otherwise ignored. Only script names ever come from config,
40
+ * never command lines, and the browser only ever sends step ids back.
41
+ *
42
+ * Each step: { id, label, cmd, display, defaultOn, note?, ci, script? }.
43
+ * `ci` says whether the generated workflow runs it; `script` is the
44
+ * package.json script behind it (null for Biome and the raw tsc fallback).
45
+ */
46
+ export function resolveChecks({ cwd, biome, pmName, pkg }) {
47
+ const scripts = pkg?.scripts ?? {};
48
+ const run = RUN_SCRIPT[pmName] ?? RUN_SCRIPT.npm;
49
+ const config = pkg?.lintlane?.checks;
50
+ const settings = config && typeof config === "object" && !Array.isArray(config) ? config : {};
51
+ const problems = [];
52
+
53
+ for (const [id, value] of Object.entries(settings)) {
54
+ if (!CHECK_IDS.includes(id)) {
55
+ problems.push(`lintlane.checks.${id}: unknown check (known: ${CHECK_IDS.join(", ")})`);
56
+ } else if (typeof value !== "boolean" && typeof value !== "string") {
57
+ problems.push(`lintlane.checks.${id}: expected true, false or a script name`);
58
+ } else if (typeof value === "string" && id !== "biome" && typeof scripts[value] !== "string") {
59
+ problems.push(`lintlane.checks.${id}: package.json has no script "${value}"`);
60
+ } else if (typeof value === "string" && id === "biome") {
61
+ problems.push("lintlane.checks.biome: can only be true or false");
62
+ }
63
+ }
64
+
65
+ // The script a check should run: the configured one if it exists, else detection.
66
+ const scriptFor = (id, candidates) => {
67
+ const chosen = settings[id];
68
+ if (typeof chosen === "string" && typeof scripts[chosen] === "string" && id !== "biome") {
69
+ return chosen;
70
+ }
71
+ return firstScript(scripts, candidates);
72
+ };
73
+ const enabled = (id) => settings[id] !== false;
74
+
75
+ const steps = [];
76
+ steps.push({
77
+ id: "install",
78
+ label: "Install (frozen lockfile)",
79
+ cmd: FROZEN_INSTALL[pmName] ?? FROZEN_INSTALL.npm,
80
+ defaultOn: false,
81
+ ci: false,
82
+ note: "What CI does first. Reinstalls node_modules, so it's off by default.",
83
+ });
84
+
85
+ if (enabled("biome")) {
86
+ steps.push({
87
+ id: "biome",
88
+ label: "Biome CI (lint + format)",
89
+ cmd: [...biome, "ci", "--colors=off", "."],
90
+ display: "biome ci .",
91
+ defaultOn: true,
92
+ ci: true,
93
+ note: "The same check the GitHub workflow runs.",
94
+ });
95
+ }
96
+
97
+ if (enabled("typecheck")) {
98
+ const script = scriptFor("typecheck", ["typecheck", "type-check", "check-types", "tsc"]);
99
+ if (script) {
100
+ steps.push({
101
+ id: "typecheck",
102
+ label: "Typecheck",
103
+ cmd: run(script),
104
+ script,
105
+ defaultOn: true,
106
+ ci: true,
107
+ });
108
+ } else if (existsSync(`${cwd}/tsconfig.json`) && existsSync(binPath(cwd, "tsc"))) {
109
+ steps.push({
110
+ id: "typecheck",
111
+ label: "Typecheck",
112
+ cmd: [binPath(cwd, "tsc"), "--noEmit"],
113
+ display: "tsc --noEmit",
114
+ defaultOn: true,
115
+ ci: false, // no script to name in a workflow
116
+ });
117
+ }
118
+ }
119
+
120
+ if (enabled("build")) {
121
+ const script = scriptFor("build", ["build"]);
122
+ if (script) {
123
+ steps.push({
124
+ id: "build",
125
+ label: "Build",
126
+ cmd: run(script),
127
+ script,
128
+ defaultOn: true,
129
+ ci: true,
130
+ });
131
+ }
132
+ }
133
+
134
+ if (enabled("test")) {
135
+ const script = scriptFor("test", ["test"]);
136
+ if (script && !NPM_DEFAULT_TEST.test(scripts[script])) {
137
+ steps.push({
138
+ id: "test",
139
+ label: "Tests",
140
+ cmd: run(script),
141
+ script,
142
+ defaultOn: true,
143
+ ci: true,
144
+ });
145
+ }
146
+ }
147
+
148
+ return {
149
+ steps: steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") })),
150
+ problems,
151
+ };
152
+ }
153
+
154
+ // Kill the step and everything it started (a build tool's child processes too).
155
+ function killTree(child) {
156
+ if (!child || child.exitCode !== null) return;
157
+ try {
158
+ if (process.platform === "win32") child.kill();
159
+ else process.kill(-child.pid, "SIGTERM");
160
+ } catch {
161
+ try {
162
+ child.kill();
163
+ } catch {}
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Run steps one after another, reporting events through `send`:
169
+ * start / log / end / skip / done
170
+ * Aborting `signal` (the browser's Stop button, or the page closing) kills the
171
+ * running step. Resolves when the run is over; never rejects.
172
+ */
173
+ export async function runCiSteps({ cwd, steps, stopOnFail, send, signal, extraEnv = {} }) {
174
+ let child = null;
175
+ const onAbort = () => killTree(child);
176
+ signal?.addEventListener("abort", onAbort);
177
+ let failed = false;
178
+
179
+ try {
180
+ for (const step of steps) {
181
+ if (signal?.aborted) return;
182
+ if (failed && stopOnFail) {
183
+ send({ type: "skip", id: step.id });
184
+ continue;
185
+ }
186
+
187
+ send({ type: "start", id: step.id });
188
+ const t0 = performance.now();
189
+ let sent = 0;
190
+ let truncated = false;
191
+
192
+ const pump = (stream) => {
193
+ const decoder = new StringDecoder("utf8");
194
+ stream.on("data", (chunk) => {
195
+ const text = decoder.write(chunk);
196
+ if (sent < MAX_LOG_CHARS) {
197
+ sent += text.length;
198
+ send({ type: "log", id: step.id, text });
199
+ } else if (!truncated) {
200
+ truncated = true;
201
+ send({ type: "log", id: step.id, text: "\n… output truncated …\n" });
202
+ }
203
+ });
204
+ };
205
+
206
+ const code = await new Promise((resolve) => {
207
+ try {
208
+ child = spawnPrepared(step.cmd[0], step.cmd.slice(1), {
209
+ cwd,
210
+ env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0", ...extraEnv },
211
+ stdio: ["ignore", "pipe", "pipe"],
212
+ detached: process.platform !== "win32", // own process group, so Stop can kill the tree
213
+ });
214
+ } catch (error) {
215
+ send({ type: "log", id: step.id, text: `\n${error.message}\n` });
216
+ resolve(127);
217
+ return;
218
+ }
219
+ pump(child.stdout);
220
+ pump(child.stderr);
221
+ child.on("error", (error) => {
222
+ send({ type: "log", id: step.id, text: `\n${error.message}\n` });
223
+ resolve(127);
224
+ });
225
+ child.on("close", (exitCode, killedBy) => resolve(exitCode ?? (killedBy ? 143 : 1)));
226
+ });
227
+ child = null;
228
+
229
+ if (signal?.aborted) return;
230
+ if (code !== 0) failed = true;
231
+ send({ type: "end", id: step.id, code, ms: Math.round(performance.now() - t0) });
232
+ }
233
+ send({ type: "done" });
234
+ } finally {
235
+ signal?.removeEventListener("abort", onAbort);
236
+ }
237
+ }