fix(hooks): support Windows linter paths and ESLint 9 (#3076)

pre-bash-commit-quality spawned Windows .cmd/.bat linters unquoted, so a spaced path failed, and passed --format compact, which ESLint 9 removed (#3075). Batch executables now run through cmd.exe with each argument carried in an env token and quoted, with quote, NUL, CR and LF rejected before spawn; non-batch Windows and POSIX paths keep direct argv spawn with shell false. ESLint uses its bundled default formatter, present on 8, 9 and 10. Regression tests cover the batch, non-batch and POSIX branches and the formatter change. Independent exact-head review passed with no P0/P1; CI 44/44 at the head.
This commit is contained in:
Dante
2026-09-12 01:46:38 +01:00
committed by GitHub
parent 3033436dcc
commit 2083c9839a
2 changed files with 216 additions and 11 deletions
+82 -10
View File
@@ -259,20 +259,83 @@ function resolveCommand(command) {
return null;
}
const LINTER_TIMEOUT_MS = 30000;
const UNSAFE_CMD_TOKEN = /["\0\r\n]/;
const CMD_TOKEN_ENV_PREFIX = 'ECC_LINTER_TOKEN_';
function validateCmdToken(value) {
const token = String(value);
if (UNSAFE_CMD_TOKEN.test(token)) {
throw new Error(`Unsafe character in Windows linter argument: ${JSON.stringify(token)}`);
}
return token;
}
function getLinterInvocation(command, args, platform = process.platform) {
const useCmd = platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
if (useCmd) {
const environment = { ...process.env };
for (const name of Object.keys(environment)) {
if (name.toUpperCase().startsWith(CMD_TOKEN_ENV_PREFIX)) {
delete environment[name];
}
}
// Keep untrusted values out of cmd.exe source. Percent expansion is
// non-recursive, so percent signs introduced by these environment values
// stay literal. Disabling delayed expansion likewise preserves exclamation
// marks. Quotes and line controls remain invalid because they could escape
// the quoted token boundary or create another command line.
const tokenReferences = [command, ...args].map((value, index) => {
const name = `${CMD_TOKEN_ENV_PREFIX}${index}`;
environment[name] = validateCmdToken(value);
return `"%${name}%"`;
});
const commandLine = tokenReferences.join(' ');
return {
command: process.env.ComSpec || process.env.COMSPEC || 'cmd.exe',
args: ['/d', '/v:off', '/s', '/c', `"${commandLine}"`],
options: {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: LINTER_TIMEOUT_MS,
shell: false,
windowsVerbatimArguments: true,
env: environment
}
};
}
return {
command,
args,
options: {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: LINTER_TIMEOUT_MS,
shell: false
}
};
}
function runLinterCommand(command, args) {
const useShell = process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command);
return spawnSync(command, args, {
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: 30000,
shell: useShell
});
try {
const invocation = getLinterInvocation(command, args);
return spawnSync(invocation.command, invocation.args, invocation.options);
} catch (error) {
return { status: null, stdout: '', stderr: '', error };
}
}
function commandOutput(result) {
return result.stdout || result.stderr || result.error?.message || '';
}
function golintSucceeded(result) {
return result.status === 0 && !result.error && (!result.stdout || result.stdout.trim() === '');
}
/**
* Run linter on staged files
* @param {string[]} files
@@ -294,7 +357,7 @@ function runLinter(files) {
const eslintBin = process.platform === 'win32' ? 'eslint.cmd' : 'eslint';
const eslintPath = path.join(process.cwd(), 'node_modules', '.bin', eslintBin);
if (fs.existsSync(eslintPath)) {
const result = runLinterCommand(eslintPath, ['--format', 'compact', ...jsFiles]);
const result = runLinterCommand(eslintPath, jsFiles);
results.eslint = {
success: result.status === 0,
output: commandOutput(result)
@@ -329,7 +392,7 @@ function runLinter(files) {
} else {
const result = runLinterCommand(golintPath, goFiles);
results.golint = {
success: !result.stdout || result.stdout.trim() === '',
success: golintSucceeded(result),
output: commandOutput(result)
};
}
@@ -481,4 +544,13 @@ if (require.main === module) {
});
}
module.exports = { run, evaluate, validateCommitMessage, findFileIssues, isPlaceholderSecret };
module.exports = {
run,
evaluate,
validateCommitMessage,
findFileIssues,
isPlaceholderSecret,
getLinterInvocation,
golintSucceeded,
runLinter
};