mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
Merge pull request #3736 from haelyra/integration/l4xb-four-hooks-20261025
fix(hooks): integrate four focused L4XB fixes
This commit is contained in:
12 files changed
+324
-25
No files matched your search
@@ -14,7 +14,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const { isGitRepo, getGitModifiedFiles, readFile, log } = require('../lib/utils');
|
const path = require('path');
|
||||||
|
const { isGitRepo, getGitModifiedFiles, readFile, log, runCommand } = require('../lib/utils');
|
||||||
|
|
||||||
// Files where console.log is expected and should not trigger warnings
|
// Files where console.log is expected and should not trigger warnings
|
||||||
const EXCLUDED_PATTERNS = [
|
const EXCLUDED_PATTERNS = [
|
||||||
@@ -27,6 +28,18 @@ const EXCLUDED_PATTERNS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
|
const MAX_DIRECT_STDIN_BYTES = 16 * 1024 * 1024;
|
||||||
|
|
||||||
|
// This Stop hook inspects the session's working directory. Inherited GIT_*
|
||||||
|
// variables from another Git process can silently redirect discovery and diff
|
||||||
|
// commands to a different repository, so give every Git call the same clean env.
|
||||||
|
function sessionGitOptions() {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const name of Object.keys(env)) {
|
||||||
|
if (name.toUpperCase().startsWith('GIT_')) delete env[name];
|
||||||
|
}
|
||||||
|
return { env };
|
||||||
|
}
|
||||||
|
|
||||||
let data = '';
|
let data = '';
|
||||||
let stdinBytes = 0;
|
let stdinBytes = 0;
|
||||||
let oversized = false;
|
let oversized = false;
|
||||||
@@ -61,19 +74,23 @@ function passThroughAndExit() {
|
|||||||
|
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
try {
|
try {
|
||||||
if (!isGitRepo()) {
|
const gitOptions = sessionGitOptions();
|
||||||
|
if (!isGitRepo(gitOptions)) {
|
||||||
passThroughAndExit();
|
passThroughAndExit();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'])
|
// git reports paths from the repository root, and the session can run in a
|
||||||
.filter(f => fs.existsSync(f))
|
// subdirectory of it, so read each file from the root.
|
||||||
|
const root = runCommand('git rev-parse --show-toplevel', gitOptions).output;
|
||||||
|
const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$'], gitOptions)
|
||||||
|
.filter(f => fs.existsSync(path.join(root, f)))
|
||||||
.filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f)));
|
.filter(f => !EXCLUDED_PATTERNS.some(pattern => pattern.test(f)));
|
||||||
|
|
||||||
let hasConsole = false;
|
let hasConsole = false;
|
||||||
|
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const content = readFile(file);
|
const content = readFile(path.join(root, file));
|
||||||
if (content && content.includes('console.log')) {
|
if (content && content.includes('console.log')) {
|
||||||
log(`[Hook] WARNING: console.log found in ${file}`);
|
log(`[Hook] WARNING: console.log found in ${file}`);
|
||||||
hasConsole = true;
|
hasConsole = true;
|
||||||
|
|||||||
@@ -117,8 +117,9 @@ const PROTECTED_FILES = new Set([
|
|||||||
const PROTECTED_PATTERNS = [
|
const PROTECTED_PATTERNS = [
|
||||||
// eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ...
|
// eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ...
|
||||||
/^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i,
|
/^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i,
|
||||||
// .eslintrc.base.json, .prettierrc.shared.yml ...
|
// .eslintrc.base.json, .prettierrc.shared.yml ... Prettier also reads
|
||||||
/^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i,
|
// `.prettierrc.ts`, `.mts`, `.cts` and `.json5`.
|
||||||
|
/^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|ts|mts|cts|json|jsonc|json5|yml|yaml|toml)$/i,
|
||||||
];
|
];
|
||||||
|
|
||||||
function isProtectedName(basename) {
|
function isProtectedName(basename) {
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ function readToken(input, startIndex) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ch === '\\' && quote === '"' && index + 1 < input.length) {
|
// Inside double quotes, bash drops a backslash only before $ ` " \ or a
|
||||||
|
// newline, so `"echo ready\;"` keeps its `\;` for tmux.
|
||||||
|
if (ch === '\\' && quote === '"' && index + 1 < input.length && '$`"\\\n'.includes(input[index + 1])) {
|
||||||
token += input[index + 1];
|
token += input[index + 1];
|
||||||
index += 2;
|
index += 2;
|
||||||
continue;
|
continue;
|
||||||
@@ -104,7 +106,7 @@ function normalizeCommandWord(token) {
|
|||||||
return base.replace(/\.(cmd|exe|bat)$/i, '');
|
return base.replace(/\.(cmd|exe|bat)$/i, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLeadingCommandWord(segment) {
|
function getLeadingCommand(segment) {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let activeWrapper = null;
|
let activeWrapper = null;
|
||||||
let skipNextValue = false;
|
let skipNextValue = false;
|
||||||
@@ -145,7 +147,7 @@ function getLeadingCommandWord(segment) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
return normalizedToken;
|
return { word: normalizedToken, end: index };
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
@@ -160,7 +162,19 @@ process.stdin.on('data', chunk => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const TMUX_LAUNCHER = /^\s*tmux\s+(new|new-session|new-window|split-window)\b/;
|
// tmux commands, and their aliases, that run their shell command in a tmux pane,
|
||||||
|
// where its output stays readable. run-shell and `tmux -c` run it without one.
|
||||||
|
const TMUX_PANE_COMMANDS = new Set([
|
||||||
|
'new-session', 'new',
|
||||||
|
'new-window', 'neww',
|
||||||
|
'split-window', 'splitw',
|
||||||
|
'respawn-pane', 'respawnp',
|
||||||
|
'respawn-window', 'respawnw',
|
||||||
|
'send-keys', 'send'
|
||||||
|
]);
|
||||||
|
// tmux's own options that take a value (`-L work`, `-Lwork`, or the last of a
|
||||||
|
// cluster such as `-vL work`).
|
||||||
|
const TMUX_OPTION_VALUE_LETTERS = new Set(['f', 'L', 'S', 'T']);
|
||||||
// Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so
|
// Trailing (?![\w-]) rather than \b: \b treats a hyphen as a word boundary, so
|
||||||
// `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` /
|
// `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` /
|
||||||
// `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches
|
// `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches
|
||||||
@@ -199,10 +213,51 @@ function collectCheckSegments(cmd) {
|
|||||||
return segments;
|
return segments;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return true when the tmux invocation that starts at `index` runs the dev
|
||||||
|
* server in a pane. The tmux commands follow tmux's own options (`-L work`,
|
||||||
|
* `-f conf`), and the executable may come through a path or an env prefix, so
|
||||||
|
* this reads the parsed tokens instead of the start of the segment. A sequence
|
||||||
|
* (`new-session -d \; run-shell ...`) is exempt only when every command in it
|
||||||
|
* that runs the dev server is a pane command.
|
||||||
|
*/
|
||||||
|
function isTmuxPaneLaunch(segment, index) {
|
||||||
|
const tokens = [];
|
||||||
|
for (let parsed = readToken(segment, index); parsed; parsed = readToken(segment, parsed.end)) {
|
||||||
|
if (parsed.token) tokens.push(parsed.token);
|
||||||
|
}
|
||||||
|
|
||||||
|
let next = 0;
|
||||||
|
for (; next < tokens.length && isOptionToken(tokens[next]); next++) {
|
||||||
|
const letters = tokens[next].slice(1);
|
||||||
|
for (let i = 0; i < letters.length; i++) {
|
||||||
|
// -c runs its shell command without a pane.
|
||||||
|
if (letters[i] === 'c') return false;
|
||||||
|
if (TMUX_OPTION_VALUE_LETTERS.has(letters[i])) {
|
||||||
|
if (i === letters.length - 1) next++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmux ends a command at a `;` argument and at a `;` that ends an argument
|
||||||
|
// (`'npm run dev;'`); an escaped `\;` at the end stays literal.
|
||||||
|
const commands = [[]];
|
||||||
|
for (const token of tokens.slice(next)) {
|
||||||
|
const ends = token.endsWith(';') && !token.endsWith('\\;');
|
||||||
|
const value = ends ? token.slice(0, -1) : token;
|
||||||
|
if (value) commands[commands.length - 1].push(value);
|
||||||
|
if (ends) commands.push([]);
|
||||||
|
}
|
||||||
|
const devCommands = commands.filter(command => DEV_PATTERN.test(command.join(' ')));
|
||||||
|
return devCommands.length > 0 && devCommands.every(command => TMUX_PANE_COMMANDS.has(command[0]));
|
||||||
|
}
|
||||||
|
|
||||||
function isBlockedDevSegment(segment) {
|
function isBlockedDevSegment(segment) {
|
||||||
const commandWord = getLeadingCommandWord(segment);
|
const command = getLeadingCommand(segment);
|
||||||
if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false;
|
if (!command || !DEV_COMMAND_WORDS.has(command.word)) return false;
|
||||||
return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment);
|
if (!DEV_PATTERN.test(segment)) return false;
|
||||||
|
return !(command.word === 'tmux' && isTmuxPaneLaunch(segment, command.end));
|
||||||
}
|
}
|
||||||
|
|
||||||
process.stdin.on('end', () => {
|
process.stdin.on('end', () => {
|
||||||
|
|||||||
@@ -128,10 +128,16 @@ function findTsConfigDir(filePath) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// tsc --pretty false starts each diagnostic with the file it is about:
|
||||||
|
// `src/a.ts(1,14): error TS2322: ...`. Compare that path instead of looking for
|
||||||
|
// the edited path anywhere in the line, which let a root `index.ts` claim the
|
||||||
|
// errors of `src/index.ts`, and `src/utils.ts` those of `web/src/utils.ts`.
|
||||||
function diagnosticMatchesFile(line, paths) {
|
function diagnosticMatchesFile(line, paths) {
|
||||||
const normalizedLine = line.replace(/\\/g, '/');
|
const location = /^(.+?)\(\d+,\d+\): /.exec(line);
|
||||||
|
if (!location) return false;
|
||||||
|
const file = location[1].replace(/\\/g, '/');
|
||||||
for (const candidate of paths) {
|
for (const candidate of paths) {
|
||||||
if (normalizedLine.includes(candidate.replace(/\\/g, '/'))) return true;
|
if (file === candidate.replace(/\\/g, '/')) return true;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,16 @@ const PRETTIER_CONFIGS = [
|
|||||||
'.prettierrc.toml',
|
'.prettierrc.toml',
|
||||||
'prettier.config.js',
|
'prettier.config.js',
|
||||||
'prettier.config.cjs',
|
'prettier.config.cjs',
|
||||||
'prettier.config.mjs'
|
'prettier.config.mjs',
|
||||||
|
// The rest of the names Prettier's config searcher looks for
|
||||||
|
// (src/config/prettier-config/config-searcher.js).
|
||||||
|
'.prettierrc.json5',
|
||||||
|
'.prettierrc.ts',
|
||||||
|
'.prettierrc.mts',
|
||||||
|
'.prettierrc.cts',
|
||||||
|
'prettier.config.ts',
|
||||||
|
'prettier.config.mts',
|
||||||
|
'prettier.config.cts'
|
||||||
];
|
];
|
||||||
|
|
||||||
const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS];
|
const PROJECT_ROOT_MARKERS = ['package.json', ...BIOME_CONFIGS, ...PRETTIER_CONFIGS];
|
||||||
|
|||||||
Vendored
+2
-2
@@ -193,10 +193,10 @@ export interface CommandResult {
|
|||||||
export function runCommand(cmd: string, options?: ExecSyncOptions): CommandResult;
|
export function runCommand(cmd: string, options?: ExecSyncOptions): CommandResult;
|
||||||
|
|
||||||
/** Check if the current directory is inside a git repository */
|
/** Check if the current directory is inside a git repository */
|
||||||
export function isGitRepo(): boolean;
|
export function isGitRepo(options?: ExecSyncOptions): boolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get git modified files (staged + unstaged), optionally filtered by regex patterns.
|
* Get git modified files (staged + unstaged), optionally filtered by regex patterns.
|
||||||
* Invalid regex patterns are silently skipped.
|
* Invalid regex patterns are silently skipped.
|
||||||
*/
|
*/
|
||||||
export function getGitModifiedFiles(patterns?: string[]): string[];
|
export function getGitModifiedFiles(patterns?: string[], options?: ExecSyncOptions): string[];
|
||||||
@@ -537,22 +537,25 @@ function runCommand(cmd, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if current directory is a git repository
|
* Check if current directory is a git repository.
|
||||||
|
* @param {object} [options] - Options forwarded to runCommand, including env.
|
||||||
*/
|
*/
|
||||||
function isGitRepo() {
|
function isGitRepo(options = {}) {
|
||||||
return runCommand('git rev-parse --git-dir').success;
|
return runCommand('git rev-parse --git-dir', options).success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get git modified files, optionally filtered by regex patterns
|
* Get git modified files, optionally filtered by regex patterns
|
||||||
* @param {string[]} patterns - Array of regex pattern strings to filter files.
|
* @param {string[]} patterns - Array of regex pattern strings to filter files.
|
||||||
* Invalid patterns are silently skipped.
|
* Invalid patterns are silently skipped.
|
||||||
|
* @param {object} [options] - Options forwarded to every Git subprocess.
|
||||||
* @returns {string[]} Array of modified file paths
|
* @returns {string[]} Array of modified file paths
|
||||||
*/
|
*/
|
||||||
function getGitModifiedFiles(patterns = []) {
|
function getGitModifiedFiles(patterns = [], options = {}) {
|
||||||
if (!isGitRepo()) return [];
|
if (!isGitRepo(options)) return [];
|
||||||
|
|
||||||
const result = runCommand('git diff --name-only HEAD');
|
// Paths from the repository root even when diff.relative is set.
|
||||||
|
const result = runCommand('git -c diff.relative=false diff --name-only HEAD', options);
|
||||||
if (!result.success) return [];
|
if (!result.success) return [];
|
||||||
|
|
||||||
let files = result.output.split('\n').filter(Boolean);
|
let files = result.output.split('\n').filter(Boolean);
|
||||||
|
|||||||
@@ -447,6 +447,25 @@ function runTests() {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
results.push(
|
||||||
|
test("blocks Prettier's TypeScript and JSON5 rc files", () => {
|
||||||
|
return withOwnedDirectory(fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-config-protect-prettier-')), tmpDir => {
|
||||||
|
for (const name of ['.prettierrc.ts', '.prettierrc.mts', '.prettierrc.cts', '.prettierrc.json5', '.prettierrc.shared.ts']) {
|
||||||
|
const absPath = path.join(tmpDir, name);
|
||||||
|
fs.writeFileSync(absPath, 'export default {};\n');
|
||||||
|
|
||||||
|
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: absPath } });
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 2, 'Expected ' + name + ' to be blocked');
|
||||||
|
assert.ok(
|
||||||
|
result.stderr.includes('BLOCKED: Modifying ' + name + ' is not allowed.'),
|
||||||
|
'Expected block message for ' + name + ', got: ' + result.stderr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
results.push(
|
results.push(
|
||||||
test('does not block build or test tooling configs', () => {
|
test('does not block build or test tooling configs', () => {
|
||||||
// Pins the boundary: this hook guards LINTER configs. A future widening
|
// Pins the boundary: this hook guards LINTER configs. A future widening
|
||||||
|
|||||||
@@ -233,6 +233,14 @@ function createTestDir() {
|
|||||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'));
|
return fs.mkdtempSync(path.join(os.tmpdir(), 'hooks-test-'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function fixtureGitOptions(cwd) {
|
||||||
|
const env = { ...process.env };
|
||||||
|
for (const name of Object.keys(env)) {
|
||||||
|
if (name.toUpperCase().startsWith('GIT_')) delete env[name];
|
||||||
|
}
|
||||||
|
return { cwd, env };
|
||||||
|
}
|
||||||
|
|
||||||
// Clean up test directory
|
// Clean up test directory
|
||||||
function cleanupTestDir(testDir) {
|
function cleanupTestDir(testDir) {
|
||||||
const retryableCodes = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']);
|
const retryableCodes = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']);
|
||||||
@@ -1201,6 +1209,74 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('finds console.log in a modified file when the session runs in a subdirectory', async () => {
|
||||||
|
// git reports paths from the repository root, not from the session's directory.
|
||||||
|
const repo = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-console-log-'));
|
||||||
|
try {
|
||||||
|
const appDir = path.join(repo, 'packages', 'app');
|
||||||
|
fs.mkdirSync(path.join(appDir, 'src'), { recursive: true });
|
||||||
|
execFileSync('git', ['init', '-q'], fixtureGitOptions(repo));
|
||||||
|
fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\n');
|
||||||
|
execFileSync('git', ['add', '-A'], fixtureGitOptions(repo));
|
||||||
|
execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], fixtureGitOptions(repo));
|
||||||
|
fs.writeFileSync(path.join(appDir, 'src', 'a.ts'), 'export const a = 1;\nconsole.log(a);\n');
|
||||||
|
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir);
|
||||||
|
assert.strictEqual(result.code, 0);
|
||||||
|
assert.ok(result.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn about the file, got: ${result.stderr}`);
|
||||||
|
|
||||||
|
// diff.relative would make git report paths from the session's directory.
|
||||||
|
execFileSync('git', ['config', 'diff.relative', 'true'], fixtureGitOptions(repo));
|
||||||
|
const relative = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', {}, appDir);
|
||||||
|
assert.ok(relative.stderr.includes('console.log found in packages/app/src/a.ts'), `Should warn with diff.relative, got: ${relative.stderr}`);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(repo, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('uses the session repository despite inherited Git environment overrides', async () => {
|
||||||
|
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-console-git-env-'));
|
||||||
|
try {
|
||||||
|
const makeRepo = (name, file) => {
|
||||||
|
const repo = path.join(root, name);
|
||||||
|
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
||||||
|
execFileSync('git', ['init', '-q'], fixtureGitOptions(repo));
|
||||||
|
fs.writeFileSync(path.join(repo, 'src', file), 'export const value = 1;\n');
|
||||||
|
execFileSync('git', ['add', '-A'], fixtureGitOptions(repo));
|
||||||
|
execFileSync('git', ['-c', 'user.email=t@t.local', '-c', 'user.name=t', '-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'seed'], fixtureGitOptions(repo));
|
||||||
|
return repo;
|
||||||
|
};
|
||||||
|
const sessionRepo = makeRepo('session', 'local.ts');
|
||||||
|
const foreignRepo = makeRepo('foreign', 'foreign.ts');
|
||||||
|
fs.writeFileSync(path.join(foreignRepo, 'src', 'foreign.ts'), 'console.log("foreign");\n');
|
||||||
|
const env = {
|
||||||
|
GIT_DIR: path.join(foreignRepo, '.git'),
|
||||||
|
GIT_WORK_TREE: foreignRepo,
|
||||||
|
GIT_INDEX_FILE: path.join(foreignRepo, '.git', 'index')
|
||||||
|
};
|
||||||
|
|
||||||
|
const clean = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', env, sessionRepo);
|
||||||
|
assert.strictEqual(clean.code, 0);
|
||||||
|
assert.ok(!clean.stderr.includes('console.log found'), `foreign repository leaked into hook: ${clean.stderr}`);
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(sessionRepo, 'src', 'local.ts'), 'console.log("local");\n');
|
||||||
|
const local = await runScript(path.join(scriptsDir, 'check-console-log.js'), '{}', env, sessionRepo);
|
||||||
|
assert.strictEqual(local.code, 0);
|
||||||
|
assert.ok(local.stderr.includes('console.log found in src/local.ts'), local.stderr);
|
||||||
|
assert.ok(!local.stderr.includes('foreign.ts'), local.stderr);
|
||||||
|
} finally {
|
||||||
|
fs.rmSync(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
// session-end.js tests
|
// session-end.js tests
|
||||||
console.log('\nsession-end.js:');
|
console.log('\nsession-end.js:');
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,25 @@ function runTests() {
|
|||||||
const result = runScript('npm run dev:ssr');
|
const result = runScript('npm run dev:ssr');
|
||||||
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`);
|
||||||
}) ? passed++ : failed++);
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('still blocks tmux run-shell and tmux -c — no pane keeps the log (exit 2)', () => {
|
||||||
|
for (const command of [
|
||||||
|
'tmux run-shell "npm run dev"',
|
||||||
|
'tmux -L work run-shell "npm run dev"',
|
||||||
|
'tmux -c "npm run dev"',
|
||||||
|
"tmux -c'npm run dev'",
|
||||||
|
// In -vL the L takes `new-session` as its socket name, so run-shell is the command.
|
||||||
|
'tmux -vL new-session run-shell "npm run dev"',
|
||||||
|
// A pane command earlier in a sequence does not cover a later run-shell.
|
||||||
|
'tmux new-session -d \\; run-shell "npm run dev"',
|
||||||
|
'tmux -L work new-session -d \\; run-shell "npm run dev"',
|
||||||
|
// A `;` that ends an argument separates tmux commands too.
|
||||||
|
"tmux -L work new-session -d 'npm run dev;' run-shell 'npm run dev'"
|
||||||
|
]) {
|
||||||
|
const result = runScript(command);
|
||||||
|
assert.strictEqual(result.code, 2, `Expected exit code 2 for ${command}, got ${result.code}`);
|
||||||
|
}
|
||||||
|
}) ? passed++ : failed++);
|
||||||
} else {
|
} else {
|
||||||
console.log(' (skipping blocking tests on Windows)\n');
|
console.log(' (skipping blocking tests on Windows)\n');
|
||||||
}
|
}
|
||||||
@@ -79,6 +98,34 @@ function runTests() {
|
|||||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
}) ? passed++ : failed++);
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows a tmux launch after tmux options, a path, an env prefix or a command alias (exit code 0)', () => {
|
||||||
|
for (const command of [
|
||||||
|
'tmux -L work new-session -d -s dev "npm run dev"',
|
||||||
|
'tmux -f ~/.tmux.dev.conf new-session -d "npm run dev"',
|
||||||
|
'/opt/homebrew/bin/tmux new-session -d -s dev "npm run dev"',
|
||||||
|
'env TERM=xterm-256color tmux new-session -d "npm run dev"',
|
||||||
|
'tmux neww "npm run dev"',
|
||||||
|
'tmux splitw -h "pnpm dev"'
|
||||||
|
]) {
|
||||||
|
const result = runScript(command);
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`);
|
||||||
|
}
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
|
(test('allows npm run dev sent to or respawned in a tmux pane (exit code 0)', () => {
|
||||||
|
for (const command of [
|
||||||
|
'tmux send-keys -t dev "npm run dev" Enter',
|
||||||
|
'tmux respawn-pane -k -t dev "npm run dev"',
|
||||||
|
'tmux new-session -d -s dev \\; send-keys -t dev "npm run dev" Enter',
|
||||||
|
"tmux new-session -d 'echo start;' send-keys -t dev 'npm run dev' Enter",
|
||||||
|
// In double quotes bash keeps the backslash before ;, so tmux reads a literal ;
|
||||||
|
'tmux send-keys -t dev "echo ready\\;" "npm run dev" Enter'
|
||||||
|
]) {
|
||||||
|
const result = runScript(command);
|
||||||
|
assert.strictEqual(result.code, 0, `Expected exit code 0 for ${command}, got ${result.code}`);
|
||||||
|
}
|
||||||
|
}) ? passed++ : failed++);
|
||||||
|
|
||||||
(test('allows npm install (exit code 0)', () => {
|
(test('allows npm install (exit code 0)', () => {
|
||||||
const result = runScript('npm install');
|
const result = runScript('npm install');
|
||||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||||
|
|||||||
@@ -246,6 +246,57 @@ if (test('matches forward-slash TypeScript paths against Windows edited paths',
|
|||||||
|
|
||||||
})) passed++; else failed++;
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('matches a diagnostic by the file it names, not by a substring of the line', () => {
|
||||||
|
const rootIndex = new Set(['/project/index.ts', 'index.ts']);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile('index.ts(1,14): error TS2322: Type', rootIndex), true);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile('src/index.ts(1,14): error TS2322: Type', rootIndex), false);
|
||||||
|
const utils = new Set(['/project/src/utils.ts', 'src/utils.ts']);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile('web/src/utils.ts(2,1): error TS2304: Cannot find name', utils), false);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile("app.ts(3,1): error TS2307: Cannot find module './src/utils.ts'", utils), false);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile('src/(group)/page.tsx(3,5): error TS2322', new Set(['src/(group)/page.tsx'])), true);
|
||||||
|
assert.strictEqual(diagnosticMatchesFile(" Type 'string' is not assignable to type 'number'.", utils), false);
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
|
if (test('stop hook reports a tsc diagnostic only under the file it names', () => {
|
||||||
|
const { spawnSync } = require('child_process');
|
||||||
|
const projectDir = fs.mkdtempSync(path.join(accumulatorTempRoot, 'ts-project-'));
|
||||||
|
const binDir = path.join(projectDir, 'bin');
|
||||||
|
fs.mkdirSync(path.join(projectDir, 'src'));
|
||||||
|
fs.mkdirSync(binDir);
|
||||||
|
fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), '{}');
|
||||||
|
fs.writeFileSync(path.join(projectDir, 'index.ts'), 'export const ok = 1;\n');
|
||||||
|
fs.writeFileSync(path.join(projectDir, 'src', 'index.ts'), 'export const bad: number = "x";\n');
|
||||||
|
const diagnostic = "src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.";
|
||||||
|
const fakeTsc = path.join(binDir, 'fake-tsc.js');
|
||||||
|
fs.writeFileSync(fakeTsc, `process.stdout.write(${JSON.stringify(`${diagnostic}\n`)});\nprocess.exitCode = 2;\n`);
|
||||||
|
if (process.platform === 'win32') {
|
||||||
|
fs.writeFileSync(path.join(binDir, 'npx.cmd'), `@echo off\r\nnode "${fakeTsc}" %*\r\n`);
|
||||||
|
} else {
|
||||||
|
fs.writeFileSync(path.join(binDir, 'npx'), `#!/usr/bin/env node\nrequire(${JSON.stringify(fakeTsc)});\n`);
|
||||||
|
fs.chmodSync(path.join(binDir, 'npx'), 0o755);
|
||||||
|
}
|
||||||
|
const pathKey = Object.keys(process.env).find(key => key.toLowerCase() === 'path') || 'PATH';
|
||||||
|
const stopScript = path.resolve(__dirname, '../../scripts/hooks/stop-format-typecheck.js');
|
||||||
|
const stopStderr = editedFile => {
|
||||||
|
fs.writeFileSync(getAccumFile(), `${editedFile}\n`);
|
||||||
|
return spawnSync('node', [stopScript], {
|
||||||
|
input: '{}',
|
||||||
|
env: { ...process.env, CLAUDE_SESSION_ID: TEST_SESSION_ID, [pathKey]: `${binDir}${path.delimiter}${process.env[pathKey]}` },
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 10000
|
||||||
|
}).stderr;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const rootStderr = stopStderr(path.join(projectDir, 'index.ts'));
|
||||||
|
assert.ok(!rootStderr.includes('TypeScript errors'), `root index.ts must not get src/index.ts errors:\n${rootStderr}`);
|
||||||
|
const nestedStderr = stopStderr(path.join(projectDir, 'src', 'index.ts'));
|
||||||
|
assert.ok(nestedStderr.includes(`TypeScript errors in index.ts:\n${diagnostic}`), nestedStderr);
|
||||||
|
} finally {
|
||||||
|
cleanAccumFile();
|
||||||
|
fs.rmSync(projectDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
})) passed++; else failed++;
|
||||||
|
|
||||||
if (test('stop hook clears accumulator after processing duplicates', () => {
|
if (test('stop hook clears accumulator after processing duplicates', () => {
|
||||||
cleanAccumFile();
|
cleanAccumFile();
|
||||||
fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8');
|
fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8');
|
||||||
|
|||||||
@@ -155,6 +155,21 @@ function runTests() {
|
|||||||
assert.strictEqual(detectFormatter(root), 'prettier');
|
assert.strictEqual(detectFormatter(root), 'prettier');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
run("detectFormatter: detects Prettier's TypeScript and JSON5 config files", () => {
|
||||||
|
const names = ['.prettierrc.json5', '.prettierrc.ts', '.prettierrc.mts', '.prettierrc.cts',
|
||||||
|
'prettier.config.ts', 'prettier.config.mts', 'prettier.config.cts'];
|
||||||
|
for (const name of names) {
|
||||||
|
const root = makeTmpDir();
|
||||||
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test' }));
|
||||||
|
fs.writeFileSync(path.join(root, name), 'export default {};\n');
|
||||||
|
assert.strictEqual(detectFormatter(root), 'prettier', name);
|
||||||
|
const nested = path.join(root, 'src');
|
||||||
|
fs.mkdirSync(nested);
|
||||||
|
fs.rmSync(path.join(root, 'package.json'));
|
||||||
|
assert.strictEqual(findProjectRoot(nested), root, `${name} marks the project root`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
run('detectFormatter: detects prettier key in package.json', () => {
|
run('detectFormatter: detects prettier key in package.json', () => {
|
||||||
const root = makeTmpDir();
|
const root = makeTmpDir();
|
||||||
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test', prettier: { singleQuote: true } }));
|
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test', prettier: { singleQuote: true } }));
|
||||||
|
|||||||
Reference in new issue
Block a user