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.
@@ -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
  }
@@ -20,66 +20,135 @@ const FROZEN_INSTALL = {
20
20
  };
21
21
 
22
22
  const NPM_DEFAULT_TEST = /no test specified/i;
23
+ const CHECK_IDS = ["biome", "typecheck", "build", "test"];
23
24
 
24
25
  function firstScript(scripts, names) {
25
26
  return names.find((name) => typeof scripts[name] === "string");
26
27
  }
27
28
 
28
29
  /**
29
- * Work out which CI steps this project can run locally. Only commands the
30
- * project itself defines (package.json scripts, its own Biome) are offered,
31
- * and the browser only ever sends step ids back — never command lines.
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).
32
45
  */
33
- export function detectCiSteps({ cwd, biome, pmName, pkg }) {
34
- // `biome` is an argv prefix: [path] or [node, script] (see resolveBiome).
46
+ export function resolveChecks({ cwd, biome, pmName, pkg }) {
35
47
  const scripts = pkg?.scripts ?? {};
36
48
  const run = RUN_SCRIPT[pmName] ?? RUN_SCRIPT.npm;
37
- const steps = [];
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
+ }
38
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 = [];
39
76
  steps.push({
40
77
  id: "install",
41
78
  label: "Install (frozen lockfile)",
42
79
  cmd: FROZEN_INSTALL[pmName] ?? FROZEN_INSTALL.npm,
43
80
  defaultOn: false,
81
+ ci: false,
44
82
  note: "What CI does first. Reinstalls node_modules, so it's off by default.",
45
83
  });
46
84
 
47
- steps.push({
48
- id: "biome",
49
- label: "Biome CI (lint + format)",
50
- cmd: [...biome, "ci", "--colors=off", "."],
51
- display: "biome ci .",
52
- defaultOn: true,
53
- note: "The same check the GitHub workflow runs.",
54
- });
55
-
56
- const typecheck = firstScript(scripts, ["typecheck", "type-check", "check-types", "tsc"]);
57
- if (typecheck) {
85
+ if (enabled("biome")) {
58
86
  steps.push({
59
- id: "typecheck",
60
- label: "Typecheck",
61
- cmd: run(typecheck),
62
- defaultOn: true,
63
- });
64
- } else if (existsSync(`${cwd}/tsconfig.json`) && existsSync(binPath(cwd, "tsc"))) {
65
- steps.push({
66
- id: "typecheck",
67
- label: "Typecheck",
68
- cmd: [binPath(cwd, "tsc"), "--noEmit"],
69
- display: "tsc --noEmit",
87
+ id: "biome",
88
+ label: "Biome CI (lint + format)",
89
+ cmd: [...biome, "ci", "--colors=off", "."],
90
+ display: "biome ci .",
70
91
  defaultOn: true,
92
+ ci: true,
93
+ note: "The same check the GitHub workflow runs.",
71
94
  });
72
95
  }
73
96
 
74
- if (typeof scripts.build === "string") {
75
- steps.push({ id: "build", label: "Build", cmd: run("build"), defaultOn: true });
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
+ }
76
132
  }
77
133
 
78
- if (typeof scripts.test === "string" && !NPM_DEFAULT_TEST.test(scripts.test)) {
79
- steps.push({ id: "test", label: "Tests", cmd: run("test"), defaultOn: true });
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
+ }
80
146
  }
81
147
 
82
- return steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") }));
148
+ return {
149
+ steps: steps.map((step) => ({ ...step, display: step.display ?? step.cmd.join(" ") })),
150
+ problems,
151
+ };
83
152
  }
84
153
 
85
154
  // Kill the step and everything it started (a build tool's child processes too).
@@ -101,7 +170,7 @@ function killTree(child) {
101
170
  * Aborting `signal` (the browser's Stop button, or the page closing) kills the
102
171
  * running step. Resolves when the run is over; never rejects.
103
172
  */
104
- export async function runCiSteps({ cwd, steps, stopOnFail, send, signal }) {
173
+ export async function runCiSteps({ cwd, steps, stopOnFail, send, signal, extraEnv = {} }) {
105
174
  let child = null;
106
175
  const onAbort = () => killTree(child);
107
176
  signal?.addEventListener("abort", onAbort);
@@ -138,7 +207,7 @@ export async function runCiSteps({ cwd, steps, stopOnFail, send, signal }) {
138
207
  try {
139
208
  child = spawnPrepared(step.cmd[0], step.cmd.slice(1), {
140
209
  cwd,
141
- env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0" },
210
+ env: { ...process.env, CI: "true", NO_COLOR: "1", FORCE_COLOR: "0", ...extraEnv },
142
211
  stdio: ["ignore", "pipe", "pipe"],
143
212
  detached: process.platform !== "win32", // own process group, so Stop can kill the tree
144
213
  });