diff --git a/scripts/hooks/check-console-log.js b/scripts/hooks/check-console-log.js index 28f3ac6db..021d617ef 100755 --- a/scripts/hooks/check-console-log.js +++ b/scripts/hooks/check-console-log.js @@ -14,7 +14,8 @@ */ 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 const EXCLUDED_PATTERNS = [ @@ -27,6 +28,18 @@ const EXCLUDED_PATTERNS = [ ]; 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 stdinBytes = 0; let oversized = false; @@ -61,19 +74,23 @@ function passThroughAndExit() { process.stdin.on('end', () => { try { - if (!isGitRepo()) { + const gitOptions = sessionGitOptions(); + if (!isGitRepo(gitOptions)) { passThroughAndExit(); return; } - const files = getGitModifiedFiles(['\\.tsx?$', '\\.jsx?$']) - .filter(f => fs.existsSync(f)) + // git reports paths from the repository root, and the session can run in a + // 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))); let hasConsole = false; for (const file of files) { - const content = readFile(file); + const content = readFile(path.join(root, file)); if (content && content.includes('console.log')) { log(`[Hook] WARNING: console.log found in ${file}`); hasConsole = true; diff --git a/scripts/hooks/config-protection.js b/scripts/hooks/config-protection.js index 75a7a0781..8dce8b2ea 100644 --- a/scripts/hooks/config-protection.js +++ b/scripts/hooks/config-protection.js @@ -117,8 +117,9 @@ const PROTECTED_FILES = new Set([ const PROTECTED_PATTERNS = [ // 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, - // .eslintrc.base.json, .prettierrc.shared.yml ... - /^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i, + // .eslintrc.base.json, .prettierrc.shared.yml ... Prettier also reads + // `.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) { diff --git a/scripts/hooks/pre-bash-dev-server-block.js b/scripts/hooks/pre-bash-dev-server-block.js index 06eb64020..c5c336e68 100755 --- a/scripts/hooks/pre-bash-dev-server-block.js +++ b/scripts/hooks/pre-bash-dev-server-block.js @@ -56,7 +56,9 @@ function readToken(input, startIndex) { 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]; index += 2; continue; @@ -104,7 +106,7 @@ function normalizeCommandWord(token) { return base.replace(/\.(cmd|exe|bat)$/i, ''); } -function getLeadingCommandWord(segment) { +function getLeadingCommand(segment) { let index = 0; let activeWrapper = null; let skipNextValue = false; @@ -145,7 +147,7 @@ function getLeadingCommandWord(segment) { continue; } - return normalizedToken; + return { word: normalizedToken, end: index }; } 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 // `dev\b` matches the `dev` prefix of distinct scripts like `dev-setup` / // `dev-docs` / `dev-build` and wrongly blocks them. The lookahead still matches @@ -199,10 +213,51 @@ function collectCheckSegments(cmd) { 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) { - const commandWord = getLeadingCommandWord(segment); - if (!commandWord || !DEV_COMMAND_WORDS.has(commandWord)) return false; - return DEV_PATTERN.test(segment) && !TMUX_LAUNCHER.test(segment); + const command = getLeadingCommand(segment); + if (!command || !DEV_COMMAND_WORDS.has(command.word)) return false; + if (!DEV_PATTERN.test(segment)) return false; + return !(command.word === 'tmux' && isTmuxPaneLaunch(segment, command.end)); } process.stdin.on('end', () => { diff --git a/scripts/hooks/stop-format-typecheck.js b/scripts/hooks/stop-format-typecheck.js index 25341ed44..27adec981 100644 --- a/scripts/hooks/stop-format-typecheck.js +++ b/scripts/hooks/stop-format-typecheck.js @@ -128,10 +128,16 @@ function findTsConfigDir(filePath) { 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) { - 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) { - if (normalizedLine.includes(candidate.replace(/\\/g, '/'))) return true; + if (file === candidate.replace(/\\/g, '/')) return true; } return false; } diff --git a/scripts/lib/resolve-formatter.js b/scripts/lib/resolve-formatter.js index 3fec591c9..251264e78 100644 --- a/scripts/lib/resolve-formatter.js +++ b/scripts/lib/resolve-formatter.js @@ -32,7 +32,16 @@ const PRETTIER_CONFIGS = [ '.prettierrc.toml', 'prettier.config.js', '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]; diff --git a/scripts/lib/utils.d.ts b/scripts/lib/utils.d.ts index 9e980c699..df9bb8aad 100644 --- a/scripts/lib/utils.d.ts +++ b/scripts/lib/utils.d.ts @@ -193,10 +193,10 @@ export interface CommandResult { export function runCommand(cmd: string, options?: ExecSyncOptions): CommandResult; /** 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. * Invalid regex patterns are silently skipped. */ -export function getGitModifiedFiles(patterns?: string[]): string[]; +export function getGitModifiedFiles(patterns?: string[], options?: ExecSyncOptions): string[]; diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 2e8550e5e..416c1c21f 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -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() { - return runCommand('git rev-parse --git-dir').success; +function isGitRepo(options = {}) { + return runCommand('git rev-parse --git-dir', options).success; } /** * Get git modified files, optionally filtered by regex patterns * @param {string[]} patterns - Array of regex pattern strings to filter files. * Invalid patterns are silently skipped. + * @param {object} [options] - Options forwarded to every Git subprocess. * @returns {string[]} Array of modified file paths */ -function getGitModifiedFiles(patterns = []) { - if (!isGitRepo()) return []; +function getGitModifiedFiles(patterns = [], options = {}) { + 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 []; let files = result.output.split('\n').filter(Boolean); diff --git a/tests/hooks/config-protection.test.js b/tests/hooks/config-protection.test.js index b0b88ae54..9ab7d844f 100644 --- a/tests/hooks/config-protection.test.js +++ b/tests/hooks/config-protection.test.js @@ -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( test('does not block build or test tooling configs', () => { // Pins the boundary: this hook guards LINTER configs. A future widening diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 899b05a69..e31cdf449 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -233,6 +233,14 @@ function createTestDir() { 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 function cleanupTestDir(testDir) { const retryableCodes = new Set(['EPERM', 'EBUSY', 'ENOTEMPTY']); @@ -1201,6 +1209,74 @@ async function runTests() { passed++; 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 console.log('\nsession-end.js:'); diff --git a/tests/hooks/pre-bash-dev-server-block.test.js b/tests/hooks/pre-bash-dev-server-block.test.js index 89524b1e0..a5d47c8e2 100644 --- a/tests/hooks/pre-bash-dev-server-block.test.js +++ b/tests/hooks/pre-bash-dev-server-block.test.js @@ -68,6 +68,25 @@ function runTests() { const result = runScript('npm run dev:ssr'); assert.strictEqual(result.code, 2, `Expected exit code 2, got ${result.code}`); }) ? 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 { 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}`); }) ? 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)', () => { const result = runScript('npm install'); assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`); diff --git a/tests/hooks/stop-format-typecheck.test.js b/tests/hooks/stop-format-typecheck.test.js index 944c546a1..1e440254d 100644 --- a/tests/hooks/stop-format-typecheck.test.js +++ b/tests/hooks/stop-format-typecheck.test.js @@ -246,6 +246,57 @@ if (test('matches forward-slash TypeScript paths against Windows edited paths', })) 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', () => { cleanAccumFile(); fs.writeFileSync(getAccumFile(), '/nonexistent/x.ts\n/nonexistent/x.ts\n/nonexistent/y.ts\n', 'utf8'); diff --git a/tests/lib/resolve-formatter.test.js b/tests/lib/resolve-formatter.test.js index dc478fc84..42185725f 100644 --- a/tests/lib/resolve-formatter.test.js +++ b/tests/lib/resolve-formatter.test.js @@ -155,6 +155,21 @@ function runTests() { 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', () => { const root = makeTmpDir(); fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'test', prettier: { singleQuote: true } }));