lintlane 2.5.0 → 3.0.1
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 +223 -97
- package/bin/ci.mjs +82 -76
- package/bin/cli.mjs +102 -180
- package/bin/pre-commit.mjs +55 -0
- package/bin/proc.mjs +113 -0
- package/bin/project.mjs +180 -0
- package/bin/staged.mjs +333 -0
- package/bin/ui-server.mjs +175 -89
- package/package.json +5 -3
- package/templates/pre-commit +5 -2
package/bin/proc.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Small process helpers built on node:child_process, so the CLI runs on plain
|
|
2
|
+
// Node.js (no Bun APIs). Nothing here goes through a shell on POSIX; arguments
|
|
3
|
+
// are always passed as an array.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { existsSync } from "node:fs";
|
|
6
|
+
import { createRequire } from "node:module";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const isWindows = process.platform === "win32";
|
|
10
|
+
|
|
11
|
+
/** Path of a binary in the project's node_modules/.bin (a .cmd shim on Windows). */
|
|
12
|
+
export function binPath(cwd, name) {
|
|
13
|
+
return join(cwd, "node_modules", ".bin", isWindows ? `${name}.cmd` : name);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The command (as an argv prefix) that runs this project's Biome, or null.
|
|
18
|
+
* Prefers the project's own node_modules/.bin/biome; falls back to the Biome
|
|
19
|
+
* that resolves from lintlane itself (its peer dependency).
|
|
20
|
+
*/
|
|
21
|
+
export function resolveBiome(cwd) {
|
|
22
|
+
const local = binPath(cwd, "biome");
|
|
23
|
+
if (existsSync(local)) return [local];
|
|
24
|
+
try {
|
|
25
|
+
const script = createRequire(import.meta.url).resolve("@biomejs/biome/bin/biome");
|
|
26
|
+
return [process.execPath, script];
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Windows can't spawn .cmd shims (npm, pnpm, yarn, node_modules/.bin/*) without
|
|
33
|
+
// a shell. git and node are real executables and are spawned directly.
|
|
34
|
+
function prepare(command, args) {
|
|
35
|
+
const direct = !isWindows || /(^|[\\/])(git|node)(\.exe)?$/i.test(command);
|
|
36
|
+
if (direct) return { command, args, options: {} };
|
|
37
|
+
const quote = (arg) => (/[\s"&|<>^()]/.test(arg) ? `"${arg.replace(/"/g, '\\"')}"` : arg);
|
|
38
|
+
return {
|
|
39
|
+
command: [command, ...args].map(quote).join(" "),
|
|
40
|
+
args: [],
|
|
41
|
+
options: { shell: true },
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Run a command and collect its output. Never rejects: a command that can't be
|
|
47
|
+
* started resolves with `error` set and code 127.
|
|
48
|
+
*/
|
|
49
|
+
export function runProcess(command, args = [], { cwd, input, env } = {}) {
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const prepared = prepare(command, args);
|
|
52
|
+
const stdout = [];
|
|
53
|
+
const stderr = [];
|
|
54
|
+
let settled = false;
|
|
55
|
+
const finish = (result) => {
|
|
56
|
+
if (settled) return;
|
|
57
|
+
settled = true;
|
|
58
|
+
resolve({ stdout: Buffer.concat(stdout), stderr: Buffer.concat(stderr), ...result });
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
let child;
|
|
62
|
+
try {
|
|
63
|
+
child = spawn(prepared.command, prepared.args, {
|
|
64
|
+
cwd,
|
|
65
|
+
env: env ?? process.env,
|
|
66
|
+
stdio: [input === undefined ? "ignore" : "pipe", "pipe", "pipe"],
|
|
67
|
+
...prepared.options,
|
|
68
|
+
});
|
|
69
|
+
} catch (error) {
|
|
70
|
+
finish({ code: 127, error });
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
74
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
75
|
+
child.on("error", (error) => finish({ code: 127, error }));
|
|
76
|
+
child.on("close", (code, signal) => finish({ code: code ?? (signal ? 128 : 1), signal }));
|
|
77
|
+
if (input !== undefined) {
|
|
78
|
+
child.stdin.on("error", () => {});
|
|
79
|
+
child.stdin.end(input);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Run a command with the terminal attached. Resolves with its exit code. */
|
|
85
|
+
export function runInherit(command, args = [], { cwd, env } = {}) {
|
|
86
|
+
return new Promise((resolve) => {
|
|
87
|
+
const prepared = prepare(command, args);
|
|
88
|
+
let child;
|
|
89
|
+
try {
|
|
90
|
+
child = spawn(prepared.command, prepared.args, {
|
|
91
|
+
cwd,
|
|
92
|
+
env: env ?? process.env,
|
|
93
|
+
stdio: "inherit",
|
|
94
|
+
...prepared.options,
|
|
95
|
+
});
|
|
96
|
+
} catch (error) {
|
|
97
|
+
process.stderr.write(`✘ ${error.message}\n`);
|
|
98
|
+
resolve(127);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
child.on("error", (error) => {
|
|
102
|
+
process.stderr.write(`✘ ${error.message}\n`);
|
|
103
|
+
resolve(127);
|
|
104
|
+
});
|
|
105
|
+
child.on("close", (code, signal) => resolve(code ?? (signal ? 128 : 1)));
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Spawn for streaming callers (the CI tab); same Windows handling as above. */
|
|
110
|
+
export function spawnPrepared(command, args, options) {
|
|
111
|
+
const prepared = prepare(command, args);
|
|
112
|
+
return spawn(prepared.command, prepared.args, { ...options, ...prepared.options });
|
|
113
|
+
}
|
package/bin/project.mjs
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Project detection and generated-file rendering used by `lintlane init`:
|
|
2
|
+
// package manager, framework preset, install commands, the CI workflow.
|
|
3
|
+
// Pure functions where possible, so they can be tested without a package manager.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export const PRESET_PATHS = {
|
|
8
|
+
base: "biome",
|
|
9
|
+
strict: "biome/strict",
|
|
10
|
+
next: "biome/next",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
// Node 20 left maintenance in April 2026; the generated workflows use 22.
|
|
14
|
+
const NODE_VERSION = 22;
|
|
15
|
+
|
|
16
|
+
export const PACKAGE_MANAGERS = {
|
|
17
|
+
bun: {
|
|
18
|
+
lockfiles: ["bun.lock", "bun.lockb"],
|
|
19
|
+
install: (pkgs) => ["bun", "add", "--dev", "--exact", ...pkgs],
|
|
20
|
+
runCheckCi: "bunx biome ci --reporter=github .",
|
|
21
|
+
setupSteps: [
|
|
22
|
+
" - uses: oven-sh/setup-bun@v2",
|
|
23
|
+
" - run: bun install --frozen-lockfile",
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
npm: {
|
|
27
|
+
lockfiles: ["package-lock.json", "npm-shrinkwrap.json"],
|
|
28
|
+
install: (pkgs) => ["npm", "install", "--save-dev", "--save-exact", ...pkgs],
|
|
29
|
+
runCheckCi: "npx biome ci --reporter=github .",
|
|
30
|
+
setupSteps: [
|
|
31
|
+
" - uses: actions/setup-node@v4",
|
|
32
|
+
" with:",
|
|
33
|
+
` node-version: ${NODE_VERSION}`,
|
|
34
|
+
" cache: npm",
|
|
35
|
+
" - run: npm ci",
|
|
36
|
+
],
|
|
37
|
+
},
|
|
38
|
+
pnpm: {
|
|
39
|
+
lockfiles: ["pnpm-lock.yaml"],
|
|
40
|
+
install: (pkgs) => ["pnpm", "add", "--save-dev", "--save-exact", ...pkgs],
|
|
41
|
+
runCheckCi: "pnpm exec biome ci --reporter=github .",
|
|
42
|
+
setupSteps: [
|
|
43
|
+
" - uses: pnpm/action-setup@v4",
|
|
44
|
+
" - uses: actions/setup-node@v4",
|
|
45
|
+
" with:",
|
|
46
|
+
` node-version: ${NODE_VERSION}`,
|
|
47
|
+
" cache: pnpm",
|
|
48
|
+
" - run: pnpm install --frozen-lockfile",
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
yarn: {
|
|
52
|
+
lockfiles: ["yarn.lock"],
|
|
53
|
+
install: (pkgs) => ["yarn", "add", "--dev", "--exact", ...pkgs],
|
|
54
|
+
runCheckCi: "yarn biome ci --reporter=github .",
|
|
55
|
+
setupSteps: [
|
|
56
|
+
" - uses: actions/setup-node@v4",
|
|
57
|
+
" with:",
|
|
58
|
+
` node-version: ${NODE_VERSION}`,
|
|
59
|
+
" cache: yarn",
|
|
60
|
+
" - run: yarn install --frozen-lockfile",
|
|
61
|
+
],
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// How each package manager's "run a package binary" command is spelled.
|
|
66
|
+
const RUNNERS = { npm: "npx", pnpm: "pnpm dlx", yarn: "yarn dlx", bun: "bunx" };
|
|
67
|
+
|
|
68
|
+
/** "npm/10.8.0 node/v22.1.0 darwin arm64 ..." -> "npm" (null if unknown). */
|
|
69
|
+
export function parseUserAgent(userAgent) {
|
|
70
|
+
const name = String(userAgent ?? "")
|
|
71
|
+
.split(" ")[0]
|
|
72
|
+
?.split("/")[0];
|
|
73
|
+
return name && name in PACKAGE_MANAGERS ? name : null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Which package manager should install things in this project?
|
|
78
|
+
* In order: the `packageManager` field, a lockfile, the tool that launched us
|
|
79
|
+
* (npm_config_user_agent, set by npx / pnpm dlx / yarn dlx / bunx), then npm,
|
|
80
|
+
* which ships with Node.js. Returns { name, source }.
|
|
81
|
+
*/
|
|
82
|
+
export function detectPackageManager(cwd, pkg, env = process.env) {
|
|
83
|
+
const declared = pkg?.packageManager?.split("@")[0];
|
|
84
|
+
if (declared && declared in PACKAGE_MANAGERS) {
|
|
85
|
+
return { name: declared, source: "the packageManager field in package.json" };
|
|
86
|
+
}
|
|
87
|
+
for (const [name, pm] of Object.entries(PACKAGE_MANAGERS)) {
|
|
88
|
+
const lockfile = pm.lockfiles.find((file) => existsSync(join(cwd, file)));
|
|
89
|
+
if (lockfile) return { name, source: lockfile };
|
|
90
|
+
}
|
|
91
|
+
const fromAgent = parseUserAgent(env.npm_config_user_agent);
|
|
92
|
+
if (fromAgent) return { name: fromAgent, source: "the command that launched lintlane" };
|
|
93
|
+
return { name: "npm", source: "default" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The runner prefix to show in messages: "npx", "pnpm dlx", "yarn dlx" or "bunx". */
|
|
97
|
+
export function runnerCommand(env = process.env) {
|
|
98
|
+
return RUNNERS[parseUserAgent(env.npm_config_user_agent) ?? "npm"];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The argv that installs `packages` as exact dev dependencies. */
|
|
102
|
+
export function installCommand(pmName, packages) {
|
|
103
|
+
const pm = PACKAGE_MANAGERS[pmName];
|
|
104
|
+
if (!pm) throw new Error(`Unknown package manager "${pmName}"`);
|
|
105
|
+
return pm.install(packages);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Checked in order; the first match wins. Only "next" has its own Biome
|
|
109
|
+
// lint domain — the others are still React under the hood, so they map to
|
|
110
|
+
// the "base" preset, but we name what we found instead of guessing silently.
|
|
111
|
+
const FRAMEWORK_DETECTORS = [
|
|
112
|
+
{ label: "Next.js", preset: "next", deps: ["next"] },
|
|
113
|
+
{ label: "TanStack Start", preset: "base", deps: ["@tanstack/react-start", "@tanstack/start"] },
|
|
114
|
+
{ label: "React Router (framework mode)", preset: "base", deps: ["@react-router/dev"] },
|
|
115
|
+
{ label: "Remix", preset: "base", deps: ["@remix-run/react", "@remix-run/node"] },
|
|
116
|
+
{ label: "React", preset: "base", deps: ["react"] },
|
|
117
|
+
];
|
|
118
|
+
|
|
119
|
+
export function detectPreset(pkg) {
|
|
120
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
121
|
+
for (const detector of FRAMEWORK_DETECTORS) {
|
|
122
|
+
if (detector.deps.some((dep) => dep in deps)) {
|
|
123
|
+
return { preset: detector.preset, label: detector.label };
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { preset: "base", label: null };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The GitHub Actions workflow `init` writes. */
|
|
130
|
+
export function renderWorkflow({ pmName, kitName, withCommitCheck }) {
|
|
131
|
+
const pm = PACKAGE_MANAGERS[pmName];
|
|
132
|
+
// Catches commits that skipped the local hooks (--no-verify, GUI clients, fresh clones).
|
|
133
|
+
const checkout = withCommitCheck ? "\n with:\n fetch-depth: 0" : "";
|
|
134
|
+
const commitStep = withCommitCheck
|
|
135
|
+
? [
|
|
136
|
+
"",
|
|
137
|
+
" - name: Check commit messages",
|
|
138
|
+
" if: github.event_name == 'pull_request'",
|
|
139
|
+
` run: node node_modules/${kitName}/bin/check-commit.mjs --rev-range origin/\${{ github.base_ref }}..HEAD`,
|
|
140
|
+
].join("\n")
|
|
141
|
+
: "";
|
|
142
|
+
return `name: Biome
|
|
143
|
+
|
|
144
|
+
on:
|
|
145
|
+
pull_request:
|
|
146
|
+
push:
|
|
147
|
+
branches:
|
|
148
|
+
- main
|
|
149
|
+
|
|
150
|
+
jobs:
|
|
151
|
+
biome:
|
|
152
|
+
runs-on: ubuntu-latest
|
|
153
|
+
steps:
|
|
154
|
+
- uses: actions/checkout@v4${checkout}
|
|
155
|
+
${pm.setupSteps.join("\n")}${commitStep}
|
|
156
|
+
- run: ${pm.runCheckCi}
|
|
157
|
+
`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The pre-commit hook `init` used to write (<= 2.5.1). It re-staged files with
|
|
162
|
+
* `git update-index --again`, which also stages unstaged hunks of partially
|
|
163
|
+
* staged files. `init` replaces an untouched copy of it.
|
|
164
|
+
*/
|
|
165
|
+
export function isLegacyPreCommit(content) {
|
|
166
|
+
const lines = content
|
|
167
|
+
.split("\n")
|
|
168
|
+
.map((line) => line.trim())
|
|
169
|
+
.filter(Boolean);
|
|
170
|
+
return (
|
|
171
|
+
lines.length === 2 &&
|
|
172
|
+
/^(\.\/)?node_modules\/\.bin\/biome check --write --staged\b/.test(lines[0]) &&
|
|
173
|
+
lines[1] === "git update-index --again"
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A pre-commit hook that still has the unsafe re-staging step, but was edited. */
|
|
178
|
+
export function hasUnsafeRestaging(content) {
|
|
179
|
+
return /git\s+update-index\s+--again/.test(content);
|
|
180
|
+
}
|
package/bin/staged.mjs
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
// Run Biome on the files staged for commit without ever touching anything the
|
|
2
|
+
// developer didn't stage.
|
|
3
|
+
//
|
|
4
|
+
// The old hook ran `biome check --write --staged` and then
|
|
5
|
+
// `git update-index --again`. Biome rewrites files in the working tree (unstaged
|
|
6
|
+
// hunks included) and `--again` re-stages whatever is in the working tree, so
|
|
7
|
+
// unstaged hunks of partially staged files silently joined the commit.
|
|
8
|
+
//
|
|
9
|
+
// This module instead:
|
|
10
|
+
// 1. reads the exact staged content (the index blob) of every staged file;
|
|
11
|
+
// 2. for files that also have unstaged changes, saves the developer's working
|
|
12
|
+
// copy to a backup and temporarily makes the working file equal the staged
|
|
13
|
+
// content, so Biome sees exactly what will be committed;
|
|
14
|
+
// 3. runs Biome on the real files (same ignore rules, config and exit codes
|
|
15
|
+
// as before);
|
|
16
|
+
// 4. on any failure, puts every file back byte for byte; the index is never
|
|
17
|
+
// touched;
|
|
18
|
+
// 5. on success, writes the fixed content into the index entry of each staged
|
|
19
|
+
// path (git hash-object + git update-index --index-info; never `git add`),
|
|
20
|
+
// and merges Biome's changes back into the developer's working copy with a
|
|
21
|
+
// three-way merge, so their unstaged hunks survive. If the merge would
|
|
22
|
+
// conflict, the working copy is left exactly as the developer wrote it.
|
|
23
|
+
//
|
|
24
|
+
// Plain Node (no Bun APIs, no dependencies): this runs on every teammate's machine.
|
|
25
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
26
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
27
|
+
import { tmpdir } from "node:os";
|
|
28
|
+
import { join, resolve } from "node:path";
|
|
29
|
+
import { runProcess } from "./proc.mjs";
|
|
30
|
+
|
|
31
|
+
const SKIPPED_MODES = new Set(["120000", "160000"]); // symlinks, submodules
|
|
32
|
+
const BIOME_CHUNK = 100;
|
|
33
|
+
const CONCURRENCY = 8;
|
|
34
|
+
|
|
35
|
+
export class StaleBackupError extends Error {
|
|
36
|
+
constructor(backupDir) {
|
|
37
|
+
super(`An earlier lintlane run was interrupted. Your files are saved in ${backupDir}`);
|
|
38
|
+
this.name = "StaleBackupError";
|
|
39
|
+
this.backupDir = backupDir;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function git(args, { cwd, input } = {}) {
|
|
44
|
+
const result = await runProcess("git", args, { cwd, input });
|
|
45
|
+
if (result.code !== 0) {
|
|
46
|
+
const detail =
|
|
47
|
+
result.stderr.toString().trim() || result.error?.message || `exit ${result.code}`;
|
|
48
|
+
throw new Error(`git ${args[0]} failed: ${detail}`);
|
|
49
|
+
}
|
|
50
|
+
return result.stdout;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function mapLimit(items, limit, fn) {
|
|
54
|
+
const results = new Array(items.length);
|
|
55
|
+
let next = 0;
|
|
56
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
|
|
57
|
+
for (let i = next++; i < items.length; i = next++) results[i] = await fn(items[i], i);
|
|
58
|
+
});
|
|
59
|
+
await Promise.all(workers);
|
|
60
|
+
return results;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Staged files that are added, copied or modified (not deleted, not symlinks or
|
|
65
|
+
* submodules). Renames are reported as a delete plus an add, so the new path is
|
|
66
|
+
* included and the old one is not.
|
|
67
|
+
*/
|
|
68
|
+
export async function listStagedFiles(cwd) {
|
|
69
|
+
const out = await git(
|
|
70
|
+
["diff", "--cached", "--raw", "-z", "--no-renames", "--no-abbrev", "--diff-filter=ACM"],
|
|
71
|
+
{ cwd },
|
|
72
|
+
);
|
|
73
|
+
const parts = out.toString("utf8").split("\0");
|
|
74
|
+
const files = [];
|
|
75
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
76
|
+
const [, mode, , sha] = parts[i].slice(1).split(" ");
|
|
77
|
+
const path = parts[i + 1];
|
|
78
|
+
if (!SKIPPED_MODES.has(mode)) files.push({ path, mode, sha });
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function backupDirFor(cwd) {
|
|
84
|
+
const rel = (await git(["rev-parse", "--git-path", "lintlane-backup"], { cwd }))
|
|
85
|
+
.toString()
|
|
86
|
+
.trim();
|
|
87
|
+
return resolve(cwd, rel);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Three-way merge of Biome's changes into the developer's working copy. */
|
|
91
|
+
async function mergeThreeWay({ base, ours, theirs }) {
|
|
92
|
+
const dir = await mkdtemp(join(tmpdir(), "lintlane-merge-"));
|
|
93
|
+
try {
|
|
94
|
+
await Promise.all([
|
|
95
|
+
writeFile(join(dir, "ours"), ours),
|
|
96
|
+
writeFile(join(dir, "base"), base),
|
|
97
|
+
writeFile(join(dir, "theirs"), theirs),
|
|
98
|
+
]);
|
|
99
|
+
const result = await runProcess(
|
|
100
|
+
"git",
|
|
101
|
+
["merge-file", "-p", "-L", "ours", "-L", "base", "-L", "theirs", "ours", "base", "theirs"],
|
|
102
|
+
{ cwd: dir },
|
|
103
|
+
);
|
|
104
|
+
// Exit code = number of conflicts (0 = clean); anything else is an error.
|
|
105
|
+
return result.code === 0
|
|
106
|
+
? { merged: result.stdout, conflict: false }
|
|
107
|
+
: { merged: null, conflict: true };
|
|
108
|
+
} finally {
|
|
109
|
+
await rm(dir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readIfExists(path) {
|
|
114
|
+
try {
|
|
115
|
+
return readFileSync(path);
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Put files back the way they were before the run. A file is only overwritten
|
|
123
|
+
* if it still holds what *we* left in it (the staged content or Biome's output);
|
|
124
|
+
* if the developer saved something else in the meantime, it is left alone and
|
|
125
|
+
* reported, and the backup is kept.
|
|
126
|
+
*/
|
|
127
|
+
function restoreOriginals(cwd, entries) {
|
|
128
|
+
const kept = [];
|
|
129
|
+
for (const entry of entries) {
|
|
130
|
+
const original = entry.kind === "partial" ? entry.working : entry.blob;
|
|
131
|
+
const abs = join(cwd, entry.path);
|
|
132
|
+
const current = readIfExists(abs);
|
|
133
|
+
if (current?.equals(original)) continue;
|
|
134
|
+
const ours = [entry.blob, entry.after].filter(Boolean);
|
|
135
|
+
if (current === null || ours.some((buffer) => buffer.equals(current))) {
|
|
136
|
+
writeFileSync(abs, original);
|
|
137
|
+
} else {
|
|
138
|
+
kept.push(entry.path);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return kept;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Check and fix the staged files. Resolves with { ok, changed, skipped, warnings }.
|
|
146
|
+
* `biome` is an argv prefix (see resolveBiome). `log` receives user-facing text.
|
|
147
|
+
*/
|
|
148
|
+
export async function runStagedChecks({ cwd, biome, log = {}, colors = "off" }) {
|
|
149
|
+
const say = log.out ?? (() => {});
|
|
150
|
+
const complain = log.err ?? (() => {});
|
|
151
|
+
const result = { ok: true, changed: [], skipped: [], warnings: [] };
|
|
152
|
+
|
|
153
|
+
const staged = await listStagedFiles(cwd);
|
|
154
|
+
if (staged.length === 0) return result;
|
|
155
|
+
|
|
156
|
+
const backupDir = await backupDirFor(cwd);
|
|
157
|
+
if (existsSync(backupDir) && readdirSync(backupDir).length > 0) {
|
|
158
|
+
throw new StaleBackupError(backupDir);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// 1. Snapshot the exact staged content and the working copy of every file.
|
|
162
|
+
const entries = [];
|
|
163
|
+
await mapLimit(staged, CONCURRENCY, async (file) => {
|
|
164
|
+
const blob = await git(["cat-file", "blob", file.sha], { cwd });
|
|
165
|
+
const working = readIfExists(join(cwd, file.path));
|
|
166
|
+
if (working === null) {
|
|
167
|
+
result.skipped.push(file.path); // deleted in the working tree: leave the index alone
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
const kind = working.equals(blob) ? "clean" : "partial";
|
|
171
|
+
entries.push({ ...file, blob, working, kind, after: null });
|
|
172
|
+
});
|
|
173
|
+
entries.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
174
|
+
if (entries.length === 0) return result;
|
|
175
|
+
|
|
176
|
+
// 2. Make the working copy of partially staged files equal the staged content,
|
|
177
|
+
// after saving the developer's version somewhere durable.
|
|
178
|
+
const partial = entries.filter((entry) => entry.kind === "partial");
|
|
179
|
+
if (partial.length > 0) {
|
|
180
|
+
mkdirSync(backupDir, { recursive: true });
|
|
181
|
+
writeFileSync(
|
|
182
|
+
join(backupDir, "manifest.json"),
|
|
183
|
+
JSON.stringify(
|
|
184
|
+
{
|
|
185
|
+
version: 1,
|
|
186
|
+
createdAt: new Date().toISOString(),
|
|
187
|
+
files: partial.map((entry, i) => ({ id: i, path: entry.path, blob: entry.sha })),
|
|
188
|
+
},
|
|
189
|
+
null,
|
|
190
|
+
2,
|
|
191
|
+
),
|
|
192
|
+
);
|
|
193
|
+
for (const [i, entry] of partial.entries()) {
|
|
194
|
+
writeFileSync(join(backupDir, String(i)), entry.working);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let finished = false;
|
|
199
|
+
const onSignal = (signal) => {
|
|
200
|
+
if (finished) return;
|
|
201
|
+
restoreOriginals(cwd, entries);
|
|
202
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
203
|
+
process.exit(signal === "SIGINT" ? 130 : 143);
|
|
204
|
+
};
|
|
205
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP"];
|
|
206
|
+
for (const signal of signals) process.on(signal, onSignal);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
for (const entry of partial) writeFileSync(join(cwd, entry.path), entry.blob);
|
|
210
|
+
|
|
211
|
+
// 3. Run Biome on the real files, exactly as the old hook did.
|
|
212
|
+
const paths = entries.map((entry) =>
|
|
213
|
+
entry.path.startsWith("-") ? `./${entry.path}` : entry.path,
|
|
214
|
+
);
|
|
215
|
+
let biomeOk = true;
|
|
216
|
+
for (let i = 0; i < paths.length; i += BIOME_CHUNK) {
|
|
217
|
+
const run = await runProcess(
|
|
218
|
+
biome[0],
|
|
219
|
+
[
|
|
220
|
+
...biome.slice(1),
|
|
221
|
+
"check",
|
|
222
|
+
"--write",
|
|
223
|
+
"--no-errors-on-unmatched",
|
|
224
|
+
"--files-ignore-unknown=true",
|
|
225
|
+
`--colors=${colors}`,
|
|
226
|
+
...paths.slice(i, i + BIOME_CHUNK),
|
|
227
|
+
],
|
|
228
|
+
{ cwd },
|
|
229
|
+
);
|
|
230
|
+
if (run.error) {
|
|
231
|
+
complain(`lintlane: couldn't run Biome: ${run.error.message}\n`);
|
|
232
|
+
biomeOk = false;
|
|
233
|
+
} else {
|
|
234
|
+
if (run.stdout.length) say(run.stdout.toString());
|
|
235
|
+
if (run.stderr.length) complain(run.stderr.toString());
|
|
236
|
+
if (run.code !== 0) biomeOk = false;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
for (const entry of entries) entry.after = readIfExists(join(cwd, entry.path));
|
|
240
|
+
|
|
241
|
+
// 4. Failure: put everything back. The index was never touched.
|
|
242
|
+
if (!biomeOk) {
|
|
243
|
+
const kept = restoreOriginals(cwd, entries);
|
|
244
|
+
result.ok = false;
|
|
245
|
+
result.warnings.push(
|
|
246
|
+
...kept.map((path) => `${path} changed while the hook ran; left as saved`),
|
|
247
|
+
);
|
|
248
|
+
if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// 5. Success: record Biome's changes in the index, entry by entry...
|
|
253
|
+
const changed = entries.filter((entry) => entry.after && !entry.after.equals(entry.blob));
|
|
254
|
+
if (changed.length > 0) {
|
|
255
|
+
const shas = await mapLimit(changed, CONCURRENCY, async (entry) =>
|
|
256
|
+
(await git(["hash-object", "-w", "--no-filters", "--stdin"], { cwd, input: entry.after }))
|
|
257
|
+
.toString()
|
|
258
|
+
.trim(),
|
|
259
|
+
);
|
|
260
|
+
const records = changed
|
|
261
|
+
.map((entry, i) => `${entry.mode} ${shas[i]} 0\t${entry.path}\0`)
|
|
262
|
+
.join("");
|
|
263
|
+
await git(["update-index", "-z", "--index-info"], { cwd, input: Buffer.from(records) });
|
|
264
|
+
result.changed = changed.map((entry) => entry.path);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// ...and bring the developer's working copy of partially staged files up to
|
|
268
|
+
// date without losing their unstaged edits.
|
|
269
|
+
const kept = [];
|
|
270
|
+
for (const entry of partial) {
|
|
271
|
+
const abs = join(cwd, entry.path);
|
|
272
|
+
const current = readIfExists(abs);
|
|
273
|
+
if (current === null || !(entry.after ?? entry.blob).equals(current)) {
|
|
274
|
+
kept.push(entry.path); // changed under us; never overwrite
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
277
|
+
let next = entry.working;
|
|
278
|
+
if (entry.after && !entry.after.equals(entry.blob)) {
|
|
279
|
+
const { merged, conflict } = await mergeThreeWay({
|
|
280
|
+
base: entry.blob,
|
|
281
|
+
ours: entry.working,
|
|
282
|
+
theirs: entry.after,
|
|
283
|
+
});
|
|
284
|
+
if (!conflict) next = merged;
|
|
285
|
+
else
|
|
286
|
+
result.warnings.push(
|
|
287
|
+
`${entry.path}: your unstaged edits overlap the formatting changes; left as you wrote it`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
writeFileSync(abs, next);
|
|
291
|
+
}
|
|
292
|
+
result.warnings.push(
|
|
293
|
+
...kept.map((path) => `${path} changed while the hook ran; left as saved`),
|
|
294
|
+
);
|
|
295
|
+
if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
|
|
296
|
+
return result;
|
|
297
|
+
} catch (error) {
|
|
298
|
+
restoreOriginals(cwd, entries);
|
|
299
|
+
rmSync(backupDir, { recursive: true, force: true });
|
|
300
|
+
throw error;
|
|
301
|
+
} finally {
|
|
302
|
+
finished = true;
|
|
303
|
+
for (const signal of signals) process.removeListener(signal, onSignal);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Recovery for a run that was killed before it could clean up (SIGKILL, power
|
|
309
|
+
* loss): put the saved working copies back, unless the file changed since.
|
|
310
|
+
*/
|
|
311
|
+
export async function restoreFromBackup(cwd) {
|
|
312
|
+
const backupDir = await backupDirFor(cwd);
|
|
313
|
+
const manifestPath = join(backupDir, "manifest.json");
|
|
314
|
+
if (!existsSync(manifestPath)) return { restored: [], kept: [], found: false };
|
|
315
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
|
|
316
|
+
const restored = [];
|
|
317
|
+
const kept = [];
|
|
318
|
+
for (const file of manifest.files) {
|
|
319
|
+
const abs = join(cwd, file.path);
|
|
320
|
+
const saved = readFileSync(join(backupDir, String(file.id)));
|
|
321
|
+
const blob = await git(["cat-file", "blob", file.blob], { cwd });
|
|
322
|
+
const current = readIfExists(abs);
|
|
323
|
+
// Still holding the staged content we swapped in (or already gone): safe to restore.
|
|
324
|
+
if (current === null || current.equals(blob) || current.equals(saved)) {
|
|
325
|
+
writeFileSync(abs, saved);
|
|
326
|
+
restored.push(file.path);
|
|
327
|
+
} else {
|
|
328
|
+
kept.push(file.path);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
if (kept.length === 0) rmSync(backupDir, { recursive: true, force: true });
|
|
332
|
+
return { restored, kept, found: true, backupDir };
|
|
333
|
+
}
|