From 17dad810fbd2c40a01fa62644ea46b58466c08b9 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:39:39 +0700 Subject: [PATCH 01/19] fix(uncheck): harden the shared runner, file resolution and package-manager commands - globs match dot files (picomatch) and exclusions on their own apply to everything - git file names are taken literally in staged and hooks run; names starting with ! or - reach tools intact - a run given files that no check handles passes in staged and hooks run - tools killed or failing to spawn fail their check instead of crashing the run - argv batches fit Windows' command line; Yarn PnP installs resolve tools - hooks never download: npx --no, bunx --no-install; yarn run --silent keeps agent JSON clean - sherif only reports in staged, since its fixes reach beyond the commit - colours follow Node's rules; SIGHUP puts unstaged changes back; platform errors print cleanly - minified bundle with a compile-cached entry; dead --wizard and --log-level flags hidden --- packages/uncheck/build.config.ts | 1 + packages/uncheck/package.json | 6 +- packages/uncheck/src/bin.ts | 37 +----- packages/uncheck/src/checks/oxc.ts | 35 +++++ packages/uncheck/src/checks/oxfmt.ts | 30 ----- packages/uncheck/src/checks/oxlint.ts | 30 ----- packages/uncheck/src/checks/sherif.ts | 2 +- packages/uncheck/src/cli.ts | 52 ++++++++ .../uncheck/src/commands/hooks/install.ts | 13 +- packages/uncheck/src/commands/hooks/run.ts | 21 ++- packages/uncheck/src/commands/prepare.ts | 29 +---- packages/uncheck/src/commands/staged.ts | 27 ++-- packages/uncheck/src/commands/uncheck.ts | 119 ++++++++++++----- packages/uncheck/src/files.ts | 123 ++++++++++-------- packages/uncheck/src/git.ts | 2 +- packages/uncheck/src/pm.ts | 47 +++++-- packages/uncheck/src/style.ts | 9 +- packages/uncheck/src/tool.ts | 53 ++++++-- packages/uncheck/src/types.ts | 5 +- packages/uncheck/tests/command.test.ts | 18 +-- pnpm-lock.yaml | 11 ++ 21 files changed, 384 insertions(+), 286 deletions(-) create mode 100644 packages/uncheck/src/checks/oxc.ts delete mode 100644 packages/uncheck/src/checks/oxfmt.ts delete mode 100644 packages/uncheck/src/checks/oxlint.ts create mode 100644 packages/uncheck/src/cli.ts diff --git a/packages/uncheck/build.config.ts b/packages/uncheck/build.config.ts index 6143714..c1a84b8 100644 --- a/packages/uncheck/build.config.ts +++ b/packages/uncheck/build.config.ts @@ -3,5 +3,6 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ rollup: { inlineDependencies: true, + esbuild: { minify: true }, }, }) diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index c2b46db..337f53f 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -53,17 +53,19 @@ }, "devDependencies": { "@effect/platform-node": "^4.0.0-rc.116", + "@types/picomatch": "^4.0.3", "effect": "^4.0.0-rc.116", "jsonc-parser": "^3.3.1", "oxfmt": "^0.68.0", "oxlint": "^1.83.0", + "picomatch": "^4.0.7", "sherif": "^1.13.0", "typescript": "^7.0.2" }, "peerDependencies": { "oxfmt": "*", - "oxlint": "*", - "sherif": "*", + "oxlint": ">=1.60.0", + "sherif": ">=1.10.0", "typescript": "*" }, "peerDependenciesMeta": { diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts index 85e66b4..c352cb5 100644 --- a/packages/uncheck/src/bin.ts +++ b/packages/uncheck/src/bin.ts @@ -1,37 +1,8 @@ #!/usr/bin/env node -import process from 'node:process' +import { enableCompileCache } from 'node:module' -import * as NodeRuntime from '@effect/platform-node/NodeRuntime' -import * as NodeServices from '@effect/platform-node/NodeServices' -import { Effect } from 'effect' -import { Command } from 'effect/unstable/cli' +// The cache only covers modules compiled after it is on, hence the import that follows it. +enableCompileCache() -import pkg from '../package.json' -import { hooks } from './commands/hooks' -import { prepare } from './commands/prepare' -import { staged } from './commands/staged' -import { uncheck } from './commands/uncheck' - -// Any failed write (a reader gone after `| head`, a closed terminal, a full disk) would otherwise end -// the run before `staged` puts unstaged changes back, so output errors are ignored. -for (const stream of [process.stdout, process.stderr]) { - stream.on('error', () => {}) -} - -Command.run(uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])), { - version: pkg.version, -}).pipe( - Effect.catchTag('CheckFailed', () => - Effect.sync(() => { - process.exitCode = 1 - }), - ), - Effect.catchTag('StopBlocked', () => - Effect.sync(() => { - process.exitCode = 2 - }), - ), - Effect.provide(NodeServices.layer), - NodeRuntime.runMain, -) +await import('./cli') diff --git a/packages/uncheck/src/checks/oxc.ts b/packages/uncheck/src/checks/oxc.ts new file mode 100644 index 0000000..45ecd90 --- /dev/null +++ b/packages/uncheck/src/checks/oxc.ts @@ -0,0 +1,35 @@ +import { Effect } from 'effect' + +import { NothingToCheck } from '../errors' +import { argvBatches, resolveBin } from '../tool' +import type { Check, CheckName } from '../types' + +function oxc(name: CheckName, fixArgs: (fix: boolean) => ReadonlyArray): Check { + return { + name, + fixes: 'files', + plan: Effect.fn(function* ({ cwd, fix, files }) { + const bin = yield* resolveBin(name, cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + if (files === undefined) { + return [{ bin, args: fixArgs(fix) }] + } + + // Given files may include ones the tool does not handle (Markdown for oxlint, a .txt file for + // oxfmt), which is not a failure. + return argvBatches(files).map((batch) => ({ + bin, + args: [...fixArgs(fix), '--no-error-on-unmatched-pattern'], + files: batch, + })) + }), + } +} + +export const oxlint = oxc('oxlint', (fix) => (fix ? ['--fix'] : [])) + +export const oxfmt = oxc('oxfmt', (fix) => (fix ? [] : ['--check'])) diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts deleted file mode 100644 index be8f198..0000000 --- a/packages/uncheck/src/checks/oxfmt.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Effect } from 'effect' - -import { NothingToCheck } from '../errors' -import { argvBatches, resolveBin } from '../tool' -import type { Check } from '../types' - -export const oxfmt: Check = { - name: 'oxfmt', - fixes: true, - plan: Effect.fn(function* ({ cwd, fix, files }) { - const bin = yield* resolveBin('oxfmt', cwd) - - if (bin === undefined) { - return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) - } - - const args = fix ? [] : ['--check'] - - if (files === undefined) { - return [{ bin, args }] - } - - // Given files may include ones oxfmt does not handle (a Markdown file), which is not a failure. - return argvBatches(files).map((batch) => ({ - bin, - args: [...args, '--no-error-on-unmatched-pattern'], - files: batch, - })) - }), -} diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts deleted file mode 100644 index c165182..0000000 --- a/packages/uncheck/src/checks/oxlint.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { Effect } from 'effect' - -import { NothingToCheck } from '../errors' -import { argvBatches, resolveBin } from '../tool' -import type { Check } from '../types' - -export const oxlint: Check = { - name: 'oxlint', - fixes: true, - plan: Effect.fn(function* ({ cwd, fix, files }) { - const bin = yield* resolveBin('oxlint', cwd) - - if (bin === undefined) { - return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) - } - - const args = fix ? ['--fix'] : [] - - if (files === undefined) { - return [{ bin, args }] - } - - // Given files may include ones oxlint does not handle (a Markdown file), which is not a failure. - return argvBatches(files).map((batch) => ({ - bin, - args: [...args, '--no-error-on-unmatched-pattern'], - files: batch, - })) - }), -} diff --git a/packages/uncheck/src/checks/sherif.ts b/packages/uncheck/src/checks/sherif.ts index 142a7f4..a5f2a0e 100644 --- a/packages/uncheck/src/checks/sherif.ts +++ b/packages/uncheck/src/checks/sherif.ts @@ -11,7 +11,7 @@ const WORKSPACE_FILES = new Set(['package.json', 'pnpm-workspace.yaml']) export const sherif: Check = { name: 'sherif', - fixes: true, + fixes: 'workspace', plan: Effect.fn(function* ({ cwd, fix, files }) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path diff --git a/packages/uncheck/src/cli.ts b/packages/uncheck/src/cli.ts new file mode 100644 index 0000000..8070344 --- /dev/null +++ b/packages/uncheck/src/cli.ts @@ -0,0 +1,52 @@ +import process from 'node:process' + +import * as NodeRuntime from '@effect/platform-node/NodeRuntime' +import * as NodeServices from '@effect/platform-node/NodeServices' +import { Console, Effect } from 'effect' +import { CliConfig, CliError, CliOutput, Command, GlobalFlag } from 'effect/unstable/cli' + +import pkg from '../package.json' +import { hooks } from './commands/hooks' +import { prepare } from './commands/prepare' +import { staged } from './commands/staged' +import { uncheck } from './commands/uncheck' + +// Any failed write (a reader gone after `| head`, a closed terminal, a full disk) would otherwise end +// the run before `staged` puts unstaged changes back, so output errors are ignored. +for (const stream of [process.stdout, process.stderr]) { + stream.on('error', () => {}) +} + +// A closed terminal kills on SIGHUP before `staged` puts unstaged changes back, and sends it twice +// (the shell forwards it, then the kernel), so `once` is not enough. +process.on('SIGHUP', () => process.kill(process.pid, 'SIGTERM')) + +Command.run(uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])), { + version: pkg.version, +}).pipe( + Effect.catchTag('CheckFailed', () => + Effect.sync(() => { + process.exitCode = 1 + }), + ), + Effect.catchTag('StopBlocked', () => + Effect.sync(() => { + process.exitCode = 2 + }), + ), + Effect.catchTag('PlatformError', (error) => + Effect.gen(function* () { + const formatter = yield* CliOutput.Formatter + + yield* Console.error( + formatter.formatError(new CliError.UserError({ cause: error, userMessage: error.message })), + ) + process.exitCode = 1 + }), + ), + Effect.provide( + CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version, GlobalFlag.Completions] }), + ), + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, +) diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 3ec2aa8..368325e 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -5,14 +5,7 @@ import { parse as parseJsonc } from 'jsonc-parser' import { userError } from '../../errors' import { detectExec } from '../../pm' import { bold, dim, green } from '../../style' -import { - cwdFlag, - onlyFlag, - requireFlag, - selectionArgs, - skipFlag, - validateSelection, -} from '../uncheck' +import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck' const AGENTS = [ { @@ -62,9 +55,7 @@ export const install = Command.make( 'install', { cwd: cwdFlag, - only: onlyFlag, - required: requireFlag, - skipped: skipFlag, + ...selectionFlags, agents: Argument.Literals('agents', AGENT_IDS).pipe( Argument.variadic(), Argument.withDescription( diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 13e24fd..f02a61a 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -5,11 +5,12 @@ import { Command } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' import { listChangedFiles } from '../../files' -import { cwdFlag, fixFlag, onlyFlag, requireFlag, runChecks, skipFlag } from '../uncheck' +import { captureLines } from '../../tool' +import { cwdFlag, fixFlag, runChecks, selectionFlags } from '../uncheck' export const run = Command.make( 'run', - { cwd: cwdFlag, fix: fixFlag, only: onlyFlag, required: requireFlag, skipped: skipFlag }, + { cwd: cwdFlag, fix: fixFlag, ...selectionFlags }, Effect.fn(function* ({ cwd, ...settings }) { const stdio = yield* Stdio.Stdio @@ -29,18 +30,14 @@ export const run = Command.make( return } - const lines: string[] = [] - - const capture: Console.Console = Object.assign(Object.create(globalThis.console), { - log: (...parts: ReadonlyArray) => { - lines.push(parts.join(' ')) - }, - }) - - const failed = yield* runChecks(changed ?? [], { ...settings, cwd, allowUnmatched: true }).pipe( + const [failed, lines] = yield* runChecks(changed ?? [], { + ...settings, + cwd, + literal: true, + }).pipe( Effect.map(() => false), Effect.catchTag('CheckFailed', () => Effect.succeed(true)), - Effect.provideService(Console.Console, capture), + captureLines, ) const report = stripVTControlCharacters(lines.join('\n')) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index bb86968..fcdc7cd 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -5,16 +5,9 @@ import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' import { git } from '../git' -import { detectExec, EXECS } from '../pm' +import { detectExec, invokes } from '../pm' import { bold, dim, green, red } from '../style' -import { - cwdFlag, - onlyFlag, - requireFlag, - selectionArgs, - skipFlag, - validateSelection, -} from './uncheck' +import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck' const HOOK_COMMAND = 'uncheck staged' const HEADER = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' @@ -45,13 +38,7 @@ function ownLine(text: string): { readonly inside: string; readonly command: str const enters = ENTERS.exec(body) ?? OLD_ENTERS.exec(body) const command = enters?.[2] ?? body - const runs = ['', ...EXECS.map((exec) => `${exec} `)].some((prefix) => { - const rest = command.startsWith(prefix) ? command.slice(prefix.length) : undefined - - return rest === HOOK_COMMAND || rest?.startsWith(`${HOOK_COMMAND} `) === true - }) - - return runs ? { inside: enters?.[1] ?? '', command } : undefined + return invokes(command, HOOK_COMMAND) ? { inside: enters?.[1] ?? '', command } : undefined } // `sh` reads a script while running it, so a hook rewritten in place makes a commit already running @@ -93,9 +80,7 @@ export const prepare = Command.make( 'Have the hook let a commit through when the fixes undo every staged change, which makes it empty', ), ), - only: onlyFlag, - required: requireFlag, - skipped: skipFlag, + ...selectionFlags, }, Effect.fn(function* ({ cwd: directory, preCommit, fix, allowEmpty, ...selection }) { const fs = yield* FileSystem.FileSystem @@ -121,7 +106,7 @@ export const prepare = Command.make( return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) } - const [top, hooks] = repository.value.map((line) => line.trim()) + const [top, hooks] = repository.value const exec = yield* detectExec(cwd) const command = [ exec, @@ -132,12 +117,12 @@ export const prepare = Command.make( ].join(' ') // Git runs hooks at the top of the working tree, so a project below it is entered first. - const inside = path.relative(top!, yield* fs.realPath(cwd)).replaceAll('\\', '/') + const inside = path.relative(top, yield* fs.realPath(cwd)).replaceAll('\\', '/') const line = hookLine(inside, command) // husky 9 and Vite+ point core.hooksPath at generated shims that source the `h` dispatcher, which // exits before any line appended to a shim and runs the hook in the folder above instead. - const configured = path.resolve(cwd, hooks!) + const configured = path.resolve(cwd, hooks) const dispatched = yield* fs .exists(path.join(configured, 'h')) .pipe(Effect.orElseSucceed(() => false)) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index c7421b4..22935f0 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -7,15 +7,7 @@ import { userError } from '../errors' import { git, gitBytes, GitFailed, gitPaths } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' -import { - checkPaths, - cwdFlag, - fixFlag, - onlyFlag, - requireFlag, - skipFlag, - validateSelection, -} from './uncheck' +import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' /** What became of the unstaged hunks that were set aside while the checks ran. */ type Unstaged = 'restored' | 'conflicted' | 'stranded' @@ -31,9 +23,7 @@ export const staged = Command.make( 'Let the commit through when the fixes undo every staged change, which makes it empty', ), ), - only: onlyFlag, - required: requireFlag, - skipped: skipFlag, + ...selectionFlags, }, Effect.fn( function* ({ cwd: directory, fix, allowEmpty, ...selection }) { @@ -96,7 +86,8 @@ export const staged = Command.make( ...selection, cwd, fix, - allowUnmatched: true, + literal: true, + staged: true, }).pipe( Effect.map(() => undefined), Effect.catchTag('CheckFailed', (error) => Effect.succeed(error)), @@ -116,7 +107,7 @@ export const staged = Command.make( if (active?.endsWith('.lock') === true) { const lock = path.resolve( cwd, - (yield* git(cwd, ['rev-parse', '--git-path', 'index.lock'])).trim(), + yield* git(cwd, ['rev-parse', '--git-path', 'index.lock']), ) if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { @@ -186,11 +177,10 @@ export const staged = Command.make( ), ) -const writeTree = (cwd: string) => Effect.map(git(cwd, ['write-tree']), (sha) => sha.trim()) +const writeTree = (cwd: string) => git(cwd, ['write-tree']) const headTree = (cwd: string) => git(cwd, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']).pipe( - Effect.map((sha) => sha.trim()), Effect.catchTag('GitFailed', () => Effect.succeed(undefined)), ) @@ -346,8 +336,7 @@ const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const target = path.join(cwd, file) - const store = (from: string) => - Effect.map(git(cwd, ['hash-object', '-w', `--path=${file}`, '--', from]), (id) => id.trim()) + const store = (from: string) => git(cwd, ['hash-object', '-w', `--path=${file}`, '--', from]) const checked = yield* store(target) if (checked === base) { @@ -379,7 +368,7 @@ const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base ) if (clean) { - const id = (yield* git(cwd, ['hash-object', '-w', '--no-filters', '--', result])).trim() + const id = yield* git(cwd, ['hash-object', '-w', '--no-filters', '--', result]) yield* fs.writeFile( target, diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index b9382cb..c85bf81 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -1,18 +1,18 @@ +import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' -import { Console, Duration, Effect } from 'effect' +import { Console, Duration, Effect, Fiber, Semaphore } from 'effect' import type { CliError } from 'effect/unstable/cli' import { Argument, Command, Flag } from 'effect/unstable/cli' -import { oxfmt } from '../checks/oxfmt' -import { oxlint } from '../checks/oxlint' +import { oxfmt, oxlint } from '../checks/oxc' import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' import { CheckFailed, userError } from '../errors' -import { listProjectFiles, resolvePaths } from '../files' +import { existingFiles, listProjectFiles, resolvePaths } from '../files' import { bold, dim, green, listFiles, red } from '../style' -import { execute } from '../tool' +import { captureLines, execute } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc] @@ -31,25 +31,27 @@ export const fixFlag = Flag.Boolean('fix').pipe( const CHECK_NAMES = CHECKS.map((check) => check.name) -export const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe( +const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe( Flag.atLeast(0), Flag.withDescription( 'Require a check: fail when it cannot run instead of skipping it. Repeatable', ), ) -export const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe( +const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe( Flag.atLeast(0), Flag.withDescription('Skip a check even when it could run. Repeatable'), ) -export const onlyFlag = Flag.Literals('only', CHECK_NAMES).pipe( +const onlyFlag = Flag.Literals('only', CHECK_NAMES).pipe( Flag.atLeast(0), Flag.withDescription( 'Run only this check and skip the others, for example the fast ones in a hook. Repeatable', ), ) +export const selectionFlags = { only: onlyFlag, required: requireFlag, skipped: skipFlag } + export interface CheckSelection { readonly only: ReadonlyArray readonly required: ReadonlyArray @@ -59,7 +61,11 @@ export interface CheckSelection { export interface RunSettings extends CheckSelection { readonly cwd: string readonly fix: boolean - readonly allowUnmatched: boolean + readonly allowUnmatched?: boolean + /** The paths are file names from git: never patterns, and a run where none has anything to check passes. */ + readonly literal?: boolean + /** Fixes are staged again, so only the ones that stay within the given files apply. */ + readonly staged?: boolean } export function selectionArgs({ only, required, skipped }: CheckSelection): ReadonlyArray { @@ -125,14 +131,17 @@ export const checkPaths = Effect.fn(function* ( paths: ReadonlyArray, settings: RunSettings, ) { - const { fix, only, required, skipped, allowUnmatched } = settings + const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings + const staged = settings.staged ?? false const cwd = path.resolve(settings.cwd) const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) let files: ReadonlyArray | undefined if (paths.length > 0) { - const resolved = yield* resolvePaths(paths, cwd, projectFiles) + const resolved = literal + ? { files: yield* existingFiles(paths, cwd), unmatched: [] } + : yield* resolvePaths(paths, cwd, projectFiles) if (resolved.unmatched.length > 0 && !allowUnmatched) { return yield* userError( @@ -149,7 +158,7 @@ export const checkPaths = Effect.fn(function* ( } const plans = yield* Effect.all( - CHECKS.map(({ name, plan }) => { + CHECKS.map(({ name, fixes, plan }) => { const exclusion = skipped.includes(name) ? `disabled with --skip=${name}` : only.length > 0 && !only.includes(name) @@ -160,7 +169,12 @@ export const checkPaths = Effect.fn(function* ( return Effect.succeed({ name, status: 'skipped', reason: exclusion }) } - return plan({ cwd, fix, files, projectFiles }).pipe( + return plan({ + cwd, + fix: fix && !(staged && fixes === 'workspace'), + files, + projectFiles, + }).pipe( Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), Effect.catchTag('NothingToCheck', ({ reason }) => Effect.succeed({ @@ -187,6 +201,11 @@ export const checkPaths = Effect.fn(function* ( if (ran.length === 0) { const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ') + // Files a run was given, rather than asked for, may simply be ones no check handles. + if (files !== undefined && (allowUnmatched || literal)) { + return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`) + } + yield* Console.log(`${red('✘')} nothing to check: ${reasons}`) return yield* Effect.fail(new CheckFailed({ outcomes })) } @@ -197,17 +216,17 @@ export const checkPaths = Effect.fn(function* ( ) const fixable = failed - .filter( - (outcome) => - outcome.reason === undefined && - CHECKS.find((check) => check.name === outcome.name)?.fixes, - ) + .filter((outcome) => { + const fixes = CHECKS.find((check) => check.name === outcome.name)?.fixes + + return outcome.reason === undefined && (staged ? fixes === 'files' : fixes !== false) + }) .map((outcome) => outcome.name) .join(', ') .replace(/, ([^,]+)$/, ' and $1') if (!fix && fixable !== '') { - yield* Console.log(dim(` run \`uncheck --fix\` to apply ${fixable} fixes`)) + yield* Console.log(dim(` rerun with \`--fix\` to apply ${fixable} fixes`)) } return yield* Effect.fail(new CheckFailed({ outcomes })) @@ -228,16 +247,7 @@ const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { return plan } - const [duration, exitCodes] = yield* Effect.timed( - Effect.forEach(plan.commands, (invocation) => { - const { bin, args, files } = invocation - const shown = files === undefined ? args : [...args, listFiles(files)] - - return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim(shown.join(' '))}`.trimEnd()).pipe( - Effect.flatMap(() => execute(invocation, cwd)), - ) - }), - ) + const [duration, exitCodes] = yield* Effect.timed(runCommands(plan.commands, cwd)) const failed = exitCodes.some((exitCode) => exitCode !== 0) const ms = Duration.toMillis(duration) @@ -254,6 +264,51 @@ const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { return outcome }) +/** Each process of a type checker can take hundreds of megabytes, so only a few run at once. */ +const MAX_PARALLEL = Math.min(4, availableParallelism()) + +/** `parallel` commands start once the others are done, and their output is held back to keep its order. */ +const runCommands = Effect.fn(function* (commands: ReadonlyArray, cwd: string) { + const parallel = commands.filter((command) => command.parallel === true) + + if (parallel.length < 2) { + return yield* Effect.forEach(commands, (command) => runCommand(command, cwd)) + } + + const exitCodes = yield* Effect.forEach( + commands.filter((command) => command.parallel !== true), + (command) => runCommand(command, cwd), + ) + const semaphore = yield* Semaphore.make(MAX_PARALLEL) + const fibers = yield* Effect.forEach(parallel, (command) => + Effect.forkChild(semaphore.withPermits(1)(captureLines(runCommand(command, cwd)))), + ) + + for (const fiber of fibers) { + const [exitCode, lines] = yield* Fiber.join(fiber) + + yield* Effect.forEach(lines, (line) => Console.log(line), { discard: true }) + exitCodes.push(exitCode) + } + + return exitCodes +}) + +function runCommand(command: CheckCommand, cwd: string) { + const { bin, args, files } = command + const shown = files === undefined ? args : [...args, listFiles(files)] + + return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim(shown.join(' '))}`.trimEnd()).pipe( + Effect.andThen(execute(command, cwd)), + // A tool that cannot start, or is killed (say by the OOM killer), fails its check, not the run. + Effect.catchTag('PlatformError', (error) => + Console.log(red(error.cause instanceof Error ? error.cause.message : error.message)).pipe( + Effect.as(1), + ), + ), + ) +} + export const uncheck = Command.make( 'uncheck', { @@ -265,13 +320,11 @@ export const uncheck = Command.make( 'Run with whatever matched instead of failing when a given path or pattern matches no file', ), ), - only: onlyFlag, - required: requireFlag, - skipped: skipFlag, + ...selectionFlags, paths: Argument.String('paths').pipe( Argument.variadic(), Argument.withDescription( - 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to everything under the current directory.', + 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to everything under the current directory, which exclusions on their own apply to.', ), ), }, diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 59df2b1..46bf0c3 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -1,14 +1,12 @@ -import { posix } from 'node:path' - -import type { PlatformError } from 'effect' import { Effect, FileSystem, Path, Predicate } from 'effect' import type { ChildProcessSpawner } from 'effect/unstable/process' +import picomatch from 'picomatch/posix' import { gitPaths } from './git' export type ProjectFiles = Effect.Effect< ReadonlyArray, - PlatformError.PlatformError, + never, FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > @@ -47,11 +45,6 @@ export function listChangedFiles( ) } -export interface ResolvedPaths { - readonly files: ReadonlyArray - readonly unmatched: ReadonlyArray -} - /** Turns the given paths into the project files they name, so every tool checks the same files. */ export const resolvePaths = Effect.fn(function* ( patterns: ReadonlyArray, @@ -63,15 +56,13 @@ export const resolvePaths = Effect.fn(function* ( const relative = (pattern: string) => path.relative(cwd, path.resolve(cwd, pattern)).replaceAll('\\', '/') + const includes = patterns.filter((pattern) => !pattern.startsWith('!')) const matched = new Set() const unmatched: string[] = [] let universe: ReadonlyArray | undefined - for (const pattern of patterns) { - if (pattern.startsWith('!')) { - continue - } - + // Exclusions on their own exclude from everything, like a run without paths. + for (const pattern of includes.length > 0 ? includes : ['.']) { const target = relative(pattern) // An existing path is taken as it is, so `app/[id].ts` names that file rather than a glob. @@ -85,51 +76,43 @@ export const resolvePaths = Effect.fn(function* ( continue } - if (kind === 'Directory') { - universe ??= yield* projectFiles - const inside = - target === '' ? universe : universe.filter((file) => file.startsWith(`${target}/`)) - - for (const file of inside) { - matched.add(file) - } - - if (inside.length === 0) { - unmatched.push(pattern) - } - + if (kind !== 'Directory' && !GLOB_CHARACTERS.test(target)) { + unmatched.push(pattern) continue } - if (GLOB_CHARACTERS.test(target)) { - universe ??= yield* projectFiles - const hits = universe.filter((file) => posix.matchesGlob(file, target)) - - for (const hit of hits) { - matched.add(hit) - } - - if (hits.length === 0) { - unmatched.push(pattern) - } + universe ??= yield* projectFiles + const hits = universe.filter( + kind === 'Directory' + ? (file) => target === '' || file.startsWith(`${target}/`) + : glob(target), + ) - continue + for (const hit of hits) { + matched.add(hit) } - unmatched.push(pattern) + if (hits.length === 0) { + unmatched.push(pattern) + } } const excludes = patterns .filter((pattern) => pattern.startsWith('!')) - .map((pattern) => relative(pattern.slice(1))) - const excluded = (file: string) => - excludes.some( - (exclude) => - file === exclude || file.startsWith(`${exclude}/`) || posix.matchesGlob(file, exclude), - ) + .map((pattern) => { + const target = relative(pattern.slice(1)) + + if (target === '') { + return () => true + } + + const matches = glob(target) + + return (file: string) => file === target || file.startsWith(`${target}/`) || matches(file) + }) const files = yield* Effect.filter( - [...matched].filter((file) => !excluded(file)), + [...matched].filter((file) => !excludes.some((excluded) => excluded(file))), (file) => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)), { concurrency: 'unbounded' }, ) @@ -139,6 +122,30 @@ export const resolvePaths = Effect.fn(function* ( const GLOB_CHARACTERS = /[*?[\]{}()]/ +/** Dot files match too, as they do for oxfmt and for a directory given as it is. */ +function glob(pattern: string): (file: string) => boolean { + const matches = picomatch(pattern, { dot: true }) + + // The matcher's second parameter asks for a result object, and `filter` passes an index there. + return (file) => matches(file) +} + +/** The given files that exist, taken literally: file names from git are never patterns. */ +export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + return yield* Effect.filter( + files, + (file) => + fs.stat(path.resolve(cwd, file)).pipe( + Effect.map((info) => info.type === 'File'), + Effect.orElseSucceed(() => false), + ), + { concurrency: 64 }, + ) +}) + export function ancestors(path: Path.Path, from: string): string[] { const dirs = [path.resolve(from)] @@ -173,10 +180,9 @@ const walk = Effect.fn(function* (cwd: string) { const root = path.resolve(cwd) const found: string[] = [] - const visit = Effect.fn(function* ( - dir: string, - ): Effect.fn.Return { - const names = yield* fs.readDirectory(dir) + const visit = Effect.fn(function* (dir: string): Effect.fn.Return { + // Like git, a folder that cannot be read is left out rather than ending the run. + const names = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])) yield* Effect.forEach(names, (name) => visitEntry(dir, name), { concurrency: 16, @@ -184,10 +190,7 @@ const walk = Effect.fn(function* (cwd: string) { }) }) - const visitEntry = Effect.fn(function* ( - dir: string, - name: string, - ): Effect.fn.Return { + const visitEntry = Effect.fn(function* (dir: string, name: string): Effect.fn.Return { if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { return } @@ -196,7 +199,15 @@ const walk = Effect.fn(function* (cwd: string) { const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) if (info?.type === 'Directory') { - yield* visit(full) + // `stat` follows links, and a link back up the tree would be walked forever. + const linked = yield* fs.readLink(full).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ) + + if (!linked) { + yield* visit(full) + } } else if (info?.type === 'File') { found.push(path.relative(root, full).replaceAll('\\', '/')) } diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index f6c628f..3bb58f7 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -68,7 +68,7 @@ export function git( args: ReadonlyArray, env?: Readonly>, ) { - return Effect.map(gitBytes(cwd, args, env), (output) => output.toString()) + return Effect.map(gitBytes(cwd, args, env), (output) => output.toString().replace(/\n$/, '')) } /** `git` for listings made with `-z`: the NUL-separated paths it printed. */ diff --git a/packages/uncheck/src/pm.ts b/packages/uncheck/src/pm.ts index faac987..5a9ce10 100644 --- a/packages/uncheck/src/pm.ts +++ b/packages/uncheck/src/pm.ts @@ -2,29 +2,48 @@ import { Effect, FileSystem, Option, Path } from 'effect' import { ancestors, readJson } from './files' +// Hooks never get a terminal to ask in, so plain `npx` and `bunx` would download and run the latest +// release whenever the project has none, and Yarn 1 wraps a run in lines of its own on stdout, where +// agents expect nothing but their JSON. const EXEC_BY_PACKAGE_MANAGER: Readonly> = { pnpm: 'pnpm exec', - yarn: 'yarn', - bun: 'bunx', - npm: 'npx', + yarn: 'yarn run --silent', + bun: 'bunx --no-install', + npm: 'npx --no', } const EXEC_BY_LOCKFILE: ReadonlyArray = [ ['pnpm-lock.yaml', 'pnpm exec'], - ['yarn.lock', 'yarn'], - ['bun.lock', 'bunx'], - ['bun.lockb', 'bunx'], - ['package-lock.json', 'npx'], + ['yarn.lock', 'yarn run --silent'], + ['bun.lock', 'bunx --no-install'], + ['bun.lockb', 'bunx --no-install'], + ['package-lock.json', 'npx --no'], ] -/** Every prefix `detectExec` can return, so a generated command line can be recognised again. */ -export const EXECS: ReadonlyArray = [ +/** Every prefix `detectExec` returns or once returned, so a generated command line can be recognised again. */ +const EXECS: ReadonlyArray = [ ...new Set([ ...Object.values(EXEC_BY_PACKAGE_MANAGER), ...EXEC_BY_LOCKFILE.map(([, exec]) => exec), + 'yarn', + 'bunx', + 'npx', ]), ] +const FLAGS = /^(?: --[\w=./@+-]+)*$/ + +/** Whether `text` runs `command`, directly or through a package manager, with nothing but flags after it. */ +export function invokes(text: string, command: string): boolean { + return ['', ...EXECS.map((exec) => `${exec} `)].some((prefix) => { + const rest = text.startsWith(prefix) ? text.slice(prefix.length) : '' + + return ( + (rest === command || rest.startsWith(`${command} `)) && FLAGS.test(rest.slice(command.length)) + ) + }) +} + /** * The `npx`-like prefix that runs a project binary, from the package manager the nearest project * declares in `packageManager` or, failing that, its lockfile. @@ -40,14 +59,18 @@ export const detectExec = Effect.fn(function* (cwd: string) { ? EXEC_BY_PACKAGE_MANAGER[manifest.packageManager.split('@')[0] ?? ''] : undefined + if (declared !== undefined) { + return declared + } + const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) => fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), ) - if (declared !== undefined || Option.isSome(lockfile)) { - return declared ?? Option.getOrThrow(lockfile)[1] + if (Option.isSome(lockfile)) { + return lockfile.value[1] } } - return 'npx' + return 'npx --no' }) diff --git a/packages/uncheck/src/style.ts b/packages/uncheck/src/style.ts index c53036b..d15907b 100644 --- a/packages/uncheck/src/style.ts +++ b/packages/uncheck/src/style.ts @@ -1,13 +1,8 @@ -import process from 'node:process' import { styleText } from 'node:util' -export const colors = - !('NO_COLOR' in process.env) && - ((process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') || - process.stdout.isTTY === true) +export const colors = styleText('bold', ' ') !== ' ' -const paint = (style: Parameters[0]) => (text: string) => - colors ? styleText(style, text, { validateStream: false }) : text +const paint = (style: Parameters[0]) => (text: string) => styleText(style, text) export const bold = paint('bold') export const dim = paint('dim') diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index 26906b1..bda0ab6 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -1,3 +1,4 @@ +import { createRequire } from 'node:module' import process from 'node:process' import { Console, Effect, Path, Predicate, Stream } from 'effect' @@ -20,8 +21,20 @@ export interface Bin { export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName: string = pkg) { const path = yield* Path.Path - for (const dir of ancestors(path, cwd)) { - const pkgDir = path.join(dir, 'node_modules', pkg) + // Yarn PnP installs have no node_modules, only the resolver it loads into processes it starts. + const resolved = + process.versions.pnp === undefined + ? undefined + : yield* Effect.try(() => + createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`), + ).pipe(Effect.orElseSucceed(() => undefined)) + + const pkgDirs = + resolved === undefined + ? ancestors(path, cwd).map((dir) => path.join(dir, 'node_modules', pkg)) + : [path.dirname(resolved)] + + for (const pkgDir of pkgDirs) { const manifest = yield* readJson(path.join(pkgDir, 'package.json')) if (manifest === undefined) { @@ -41,32 +54,38 @@ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName return undefined }) -/** Command lines stay well below every platform's argument limit. */ -const MAX_ARGV_LENGTH = 65_536 +/** Windows caps a whole command line, node and the tool path included, at 32,767 characters. */ +const MAX_ARGV_LENGTH = process.platform === 'win32' ? 30_000 : 65_536 export function argvBatches(args: ReadonlyArray): ReadonlyArray> { const batches: string[][] = [[]] let length = 0 for (const arg of args) { - if (length + arg.length + 1 > MAX_ARGV_LENGTH) { + // Room for the separator and the quotes around an argument with a space. + if (length + arg.length + 3 > MAX_ARGV_LENGTH) { batches.push([]) length = 0 } batches[batches.length - 1]!.push(arg) - length += arg.length + 1 + length += arg.length + 3 } return batches } -/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured in hook mode. */ +/** Tools read a leading `-` as a flag, and oxfmt reads a leading `!` as an exclusion even after `--`. */ +function asFileArgument(file: string): string { + return file.startsWith('-') || file.startsWith('!') ? `./${file}` : file +} + +/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured. */ export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckCommand, cwd: string) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const handle = yield* spawner.spawn( - ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], { + ChildProcess.make(process.execPath, [bin.entry, ...args, ...files.map(asFileArgument)], { cwd, stdin: 'ignore', // A piped tool cannot see the terminal, so tell it when colors are wanted. @@ -81,3 +100,21 @@ export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckComm return yield* handle.exitCode }, Effect.scoped) + +export function captureLines( + effect: Effect.Effect, +): Effect.Effect], E, R> { + return Effect.suspend(() => { + const lines: string[] = [] + const capture: Console.Console = Object.assign(Object.create(globalThis.console), { + log: (...parts: ReadonlyArray) => { + lines.push(parts.join(' ')) + }, + }) + + return effect.pipe( + Effect.map((result) => [result, lines] as const), + Effect.provideService(Console.Console, capture), + ) + }) +} diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts index c2158ef..7ab5771 100644 --- a/packages/uncheck/src/types.ts +++ b/packages/uncheck/src/types.ts @@ -17,6 +17,8 @@ export interface CheckCommand { readonly bin: Bin readonly args: ReadonlyArray readonly files?: ReadonlyArray + /** Runs alongside the other `parallel` commands of its check, after the rest. */ + readonly parallel?: boolean } export interface CheckInput { @@ -29,7 +31,8 @@ export interface CheckInput { export interface Check { readonly name: CheckName - readonly fixes: boolean + /** `workspace` fixes reach beyond the given files, so a commit, which stages only those, cannot take them. */ + readonly fixes: false | 'files' | 'workspace' readonly plan: ( input: CheckInput, ) => Effect.Effect< diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index b968def..f099654 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -132,7 +132,7 @@ describe('uncheck', { timeout: 120_000 }, () => { ]) expect(check.stdout).toContain('no-var') expect(check.stdout).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') - expect(check.stdout).toContain('run `uncheck --fix` to apply oxlint and oxfmt fixes') + expect(check.stdout).toContain('rerun with `--fix` to apply oxlint and oxfmt fixes') const fix = await run(dir, ['--fix']) @@ -403,7 +403,7 @@ describe('uncheck sherif', { timeout: 120_000 }, () => { expect(check.stdout).toContain('unordered-dependencies') expect(check.stdout).toContain('multiple-dependency-versions') expect(check.stdout).toContain( - '✘ 1 of 1 checks failed: sherif\n run `uncheck --fix` to apply sherif fixes', + '✘ 1 of 1 checks failed: sherif\n rerun with `--fix` to apply sherif fixes', ) const fix = await run(dir, ['--fix']) @@ -611,13 +611,13 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { expect(result).toBe('ok') expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json created\n') - expect(stdout).toContain('npx uncheck hooks run --fix') + expect(stdout).toContain('npx --no uncheck hooks run --fix') expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ permissions: { allow: ['Bash(pnpm test)'] }, hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], - Stop: [{ hooks: [{ type: 'command', command: 'npx uncheck hooks run --fix' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'npx --no uncheck hooks run --fix' }] }], }, }) }) @@ -630,7 +630,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { it('writes the check flags into the hook command and updates an installed hook', async () => { const dir = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) - const fast = 'yarn uncheck hooks run --fix --only=oxlint --only=oxfmt' + const fast = 'yarn run --silent uncheck hooks run --fix --only=oxlint --only=oxfmt' const { result, stdout } = await run(dir, [ 'hooks', @@ -647,7 +647,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { hooks: { Stop: [{ hooks: [{ type: 'command', command: fast }] }] }, }) - const all = 'yarn uncheck hooks run --fix' + const all = 'yarn run --silent uncheck hooks run --fix' const again = await run(dir, ['hooks', 'install', 'claude', 'copilot']) expect(again.result).toBe('ok') @@ -1534,7 +1534,9 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) expect(result).toBe('ok') - expect(stdout).toContain('The hook runs bunx uncheck staged --fix before every commit') + expect(stdout).toContain( + 'The hook runs bunx --no-install uncheck staged --fix before every commit', + ) const blocked = fixture({ 'package.json': '{}\n' }, []) gitIn(blocked, 'init', '--quiet') @@ -1564,7 +1566,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(result).toBe('ok') expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) expect(readFileSync(hook, 'utf8')).toBe( - '#!/bin/sh\necho hi\n(cd "packages/app" && yarn uncheck staged --fix) || exit 1\n', + '#!/bin/sh\necho hi\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\n', ) if (process.platform !== 'win32') { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b686ac3..4752829 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,6 +214,9 @@ importers: '@effect/platform-node': specifier: ^4.0.0-rc.116 version: 4.0.0-rc.116(effect@4.0.0-rc.116)(redis@6.2.1) + '@types/picomatch': + specifier: ^4.0.3 + version: 4.0.3 effect: specifier: ^4.0.0-rc.116 version: 4.0.0-rc.116 @@ -226,6 +229,9 @@ importers: oxlint: specifier: ^1.83.0 version: 1.83.0 + picomatch: + specifier: ^4.0.7 + version: 4.0.7 sherif: specifier: ^1.13.0 version: 1.13.0 @@ -1112,6 +1118,9 @@ packages: '@types/node@26.6.2': resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@types/picomatch@4.0.3': + resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} + '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -3010,6 +3019,8 @@ snapshots: dependencies: undici-types: 8.9.0 + '@types/picomatch@4.0.3': {} + '@types/resolve@1.20.2': {} '@types/ws@8.18.1': From 2da462bce07792a79ac3134f35aa8095e49557c9 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:40:42 +0700 Subject: [PATCH 02/19] fix(uncheck): typecheck standalone projects with --noEmit, several at once tsc -p no longer writes JavaScript or declarations during a check, and the standalone projects of a monorepo are checked up to four at a time with their output kept in plan order. --- packages/uncheck/src/checks/tsc.ts | 7 ++++- packages/uncheck/tests/command.test.ts | 18 +++++++------ packages/uncheck/tests/tsc.test.ts | 37 +++++++++++++++++--------- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index d434a3d..f6ba192 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -107,7 +107,12 @@ export const tsc: Check = { } for (const configPath of plan.check) { - commands.push({ bin, args: ['-p', path.relative(cwd, configPath) || '.'] }) + // A check writes nothing: `-p` would emit JavaScript next to sources that set no `noEmit`. + commands.push({ + bin, + args: ['-p', path.relative(cwd, configPath) || '.', '--noEmit'], + parallel: true, + }) } return commands diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index f099654..8ab4c3b 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -156,7 +156,7 @@ describe('uncheck', { timeout: 120_000 }, () => { ).toBe(true) expect(clean.stdout).toContain('▶ oxlint\n') expect(clean.stdout).toContain('▶ oxfmt --check\n') - expect(clean.stdout).toContain('▶ tsc -p tsconfig.json\n') + expect(clean.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') expect(clean.stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') }) @@ -175,7 +175,9 @@ describe('uncheck', { timeout: 120_000 }, () => { const { result, stdout } = await run(dir) expect(result).toBe('ok') - expect(stdout).toContain('▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tsconfig.json\n') + expect(stdout).toContain( + '▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tsconfig.json --noEmit\n', + ) expect(stdout).not.toContain('packages/lib/tsconfig.json') writeFileSync(join(dir, 'packages/lib/src/index.ts'), 'export const answer: number = "42";\n') @@ -228,7 +230,7 @@ describe('uncheck', { timeout: 120_000 }, () => { '▶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n', ) expect(single.stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') - expect(single.stdout).toContain('▶ tsc -p tsconfig.json\n') + expect(single.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') expect(single.stdout).not.toContain('tsc -b') const all = await run(dir, ['.']) @@ -285,7 +287,7 @@ describe('uncheck', { timeout: 120_000 }, () => { const walked = await run(plain) expect(walked.result).toBeInstanceOf(CheckFailed) - expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json\n') + expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json --noEmit\n') const repo = fixture(files, ['typescript']) execFileSync('git', ['init', '--quiet'], { cwd: repo }) @@ -725,7 +727,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { expect(claude.stderr).toContain( '▶ oxlint --fix --no-error-on-unmatched-pattern src/fresh.ts src/index.ts\n', ) - expect(claude.stderr).toContain('▶ tsc -p tsconfig.json\n') + expect(claude.stderr).toContain('▶ tsc -p tsconfig.json --noEmit\n') expect(claude.stderr).toContain('TS2322') expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc') expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( @@ -852,7 +854,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { ) expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json\n') + expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)\n') expect(stdout).toContain( '✔ staged the fixes to src/index.ts\n○ unstaged changes of src/index.ts restored\n', @@ -1231,7 +1233,7 @@ describe('uncheck staged in a package', { timeout: 120_000 }, () => { expect(result).toBe('ok') // Staged files outside the package are another line's business, so they are neither checked nor fixed. expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json\n') + expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( 'export const answer: number = 42;\n', @@ -1820,7 +1822,7 @@ describe('uncheck presets', { timeout: 120_000 }, () => { const check = await run(dir, ['--only=tsc', 'src/index.ts']) expect(check.result).toBeInstanceOf(CheckFailed) - expect(check.stdout).toContain('▶ tsc -p tsconfig.json') + expect(check.stdout).toContain('▶ tsc -p tsconfig.json --noEmit') // `strict` and `noUncheckedIndexedAccess` come from the base preset, through the lib one expect(check.stdout).toContain('error TS7006') expect(check.stdout).toContain('error TS2322') diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts index cbd8ca2..f445fde 100644 --- a/packages/uncheck/tests/tsc.test.ts +++ b/packages/uncheck/tests/tsc.test.ts @@ -25,7 +25,10 @@ describe('tsc project references', () => { it('checks standalone projects with tsc -p', async () => { const dir = fixture({ 'tsconfig.json': {}, 'packages/a/tsconfig.json': {} }) - expect(await plan(dir)).toEqual(['-p packages/a/tsconfig.json', '-p tsconfig.json']) + expect(await plan(dir)).toEqual([ + '-p packages/a/tsconfig.json --noEmit', + '-p tsconfig.json --noEmit', + ]) }) it('builds only the roots of the reference graph and leaves the rest to tsc -b', async () => { @@ -39,7 +42,7 @@ describe('tsc project references', () => { 'packages/nest/tsconfig.json': { references: [{ path: '../server/tsconfig.json' }] }, }) - expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json']) + expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json --noEmit']) }) it('builds a solution-style root that references configs not named tsconfig.json', async () => { @@ -157,8 +160,8 @@ describe('tsc project selection', () => { }, 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, }) - const a = ['-p packages/a/tsconfig.json'] - const b = ['-p packages/b/tsconfig.json'] + const a = ['-p packages/a/tsconfig.json --noEmit'] + const b = ['-p packages/b/tsconfig.json --noEmit'] // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. expect(await plan(dir, ['packages/a/entry.ts'])).toEqual(a) @@ -196,10 +199,16 @@ describe('tsc project selection', () => { }) // `allowJs` from the preset puts `.js` files in the project, so it was resolved. - expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-p packages/a/tsconfig.json']) + expect(await plan(dir, ['packages/a/src/index.js'])).toEqual([ + '-p packages/a/tsconfig.json --noEmit', + ]) // `import` is not a condition tsc uses for `extends`, so `require` is picked instead. - expect(await plan(dir, ['packages/b/src/index.js'])).toEqual(['-p packages/b/tsconfig.json']) - expect(await plan(dir, ['packages/c/src/index.js'])).toEqual(['-p packages/c/tsconfig.json']) + expect(await plan(dir, ['packages/b/src/index.js'])).toEqual([ + '-p packages/b/tsconfig.json --noEmit', + ]) + expect(await plan(dir, ['packages/c/src/index.js'])).toEqual([ + '-p packages/c/tsconfig.json --noEmit', + ]) // The file exists, but the package does not export it. expect(await plan(dir, ['packages/d/src/index.js'])).toBe(NOT_COVERED) }) @@ -213,14 +222,16 @@ describe('tsc project selection', () => { 'packages/b/tsconfig.json': lib, }) - expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual(['-p packages/a/tsconfig.json']) + expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual([ + '-p packages/a/tsconfig.json --noEmit', + ]) // Tests are excluded by the package and picked up by the root instead. - expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json']) - expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json']) - expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json']) + expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json --noEmit']) + expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json --noEmit']) + expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json --noEmit']) expect(await plan(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ - '-p packages/a/tsconfig.json', - '-p packages/b/tsconfig.json', + '-p packages/a/tsconfig.json --noEmit', + '-p packages/b/tsconfig.json --noEmit', ]) expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED) }) From 43876ee6fffcd1a790468ae7e4838a1e3b31d17a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:44:30 +0700 Subject: [PATCH 03/19] test(uncheck): cover dot-file globs, exclusion-only paths, literal git paths, argv batches and killed tools Also lets the middleapi tsconfig preset accept the .ts imports Node's type stripping needs. --- packages/uncheck/tests/command.test.ts | 31 ++++++++ packages/uncheck/tests/files.test.ts | 79 ++++++++++++++++++- packages/uncheck/tests/tool.test.ts | 24 ++++++ packages/uncheck/tsconfig/middleapi/base.json | 1 + 4 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 packages/uncheck/tests/tool.test.ts diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 8ab4c3b..3267f46 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -352,6 +352,33 @@ describe('uncheck', { timeout: 120_000 }, () => { 'nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', ) }) + + it.skipIf(process.platform === 'win32')( + 'fails a check whose tool is killed and still runs the others', + async () => { + const dir = fixture( + { + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + 'node_modules/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, + 'node_modules/oxlint/bin.js': "process.kill(process.pid, 'SIGKILL')\n", + }, + ['oxfmt', 'typescript'], + ) + + const { result, stdout } = await run(dir) + + expect(result).toBeInstanceOf(CheckFailed) + expect(stdout).toContain( + "▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n", + ) + expect(stdout).toContain('✘ oxlint failed') + expect(stdout).toContain('✔ tsc passed') + expect(stdout).toContain('✘ 1 of 3 checks failed: oxlint') + expect(stdout).not.toContain('PlatformError') + }, + ) }) /** A workspace sherif has something to say about, with its install step off so the fix stays offline. */ @@ -1814,6 +1841,8 @@ describe('uncheck presets', { timeout: 120_000 }, () => { '}', '', ].join('\n'), + 'src/use.ts': + "import { first } from './index.ts'\n\nexport const name: string = first(['a'])\n", }, ['typescript'], ) @@ -1826,5 +1855,7 @@ describe('uncheck presets', { timeout: 120_000 }, () => { // `strict` and `noUncheckedIndexedAccess` come from the base preset, through the lib one expect(check.stdout).toContain('error TS7006') expect(check.stdout).toContain('error TS2322') + // Node runs TypeScript only through imports that name the .ts file. + expect(check.stdout).not.toContain('TS5097') }) }) diff --git a/packages/uncheck/tests/files.test.ts b/packages/uncheck/tests/files.test.ts index c20898c..838d36c 100644 --- a/packages/uncheck/tests/files.test.ts +++ b/packages/uncheck/tests/files.test.ts @@ -1,9 +1,12 @@ import { execFileSync } from 'node:child_process' +import { chmodSync, mkdirSync, symlinkSync } from 'node:fs' +import { join } from 'node:path' +import process from 'node:process' import { NodeServices } from '@effect/platform-node' import { Effect } from 'effect' -import { listProjectFiles, resolvePaths } from '../src/files' +import { existingFiles, listProjectFiles, resolvePaths } from '../src/files' import { fixture } from './fixture' const project = { @@ -86,4 +89,78 @@ describe('resolvePaths', () => { unmatched: [], }) }) + + it('walks past folders it cannot read and never into linked folders', async () => { + const dir = fixture(project, []) + symlinkSync('.', join(dir, 'loop')) + symlinkSync('..', join(dir, 'src/up')) + const locked = join(dir, 'locked') + mkdirSync(locked) + + if (process.platform !== 'win32') { + chmodSync(locked, 0) + } + + try { + expect(await resolve(dir, ['.'])).toEqual({ + files: [ + 'app/[id].ts', + 'dist/out.js', + 'docs/readme.md', + 'src/a.ts', + 'src/b.ts', + 'src/sub/c.ts', + ], + unmatched: [], + }) + } finally { + chmodSync(locked, 0o755) + } + }) + + it('lets globs and exclusions match dot files the way directories include them', async () => { + const dir = fixture( + { + ...project, + '.github/workflows/ci.yml': '', + '.vscode/settings.json': '', + 'package.json': '{}', + 'src/.env.ts': '', + }, + [], + ) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + + expect(await resolve(dir, ['**/*.yml'])).toEqual({ + files: ['.github/workflows/ci.yml'], + unmatched: [], + }) + expect(await resolve(dir, ['src/**'])).toEqual(await resolve(dir, ['src'])) + expect((await resolve(dir, ['.', '!**/*.json'])).files).not.toContain('.vscode/settings.json') + }) + + it('excludes from everything when only exclusions are given', async () => { + const dir = fixture(project, []) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + + expect(await resolve(dir, ['!src/sub', '!.*'])).toEqual({ + files: ['app/[id].ts', 'docs/readme.md', 'src/a.ts', 'src/b.ts'], + unmatched: [], + }) + }) +}) + +describe('existingFiles', () => { + it('takes file names as they are and drops the ones that are gone', async () => { + const dir = fixture({ ...project, '!notes.ts': '', '-draft.ts': '' }, []) + + expect( + await Effect.runPromise( + Effect.provide( + existingFiles(['!notes.ts', '-draft.ts', 'app/[id].ts', 'app/i.ts', 'src'], dir), + NodeServices.layer, + ), + ), + ).toEqual(['!notes.ts', '-draft.ts', 'app/[id].ts']) + }) }) diff --git a/packages/uncheck/tests/tool.test.ts b/packages/uncheck/tests/tool.test.ts new file mode 100644 index 0000000..765778b --- /dev/null +++ b/packages/uncheck/tests/tool.test.ts @@ -0,0 +1,24 @@ +import process from 'node:process' + +import { argvBatches } from '../src/tool' + +describe('argvBatches', () => { + it('splits long file lists into batches every platform can spawn, keeping their order', () => { + const files = Array.from({ length: 3000 }, (_, index) => `packages/app/src/feature ${index}.ts`) + const batches = argvBatches(files) + + expect(batches.length).toBeGreaterThan(1) + expect(batches.flat()).toEqual(files) + + for (const batch of batches) { + const length = batch.reduce((total, file) => total + file.length + 3, 0) + + expect(length).toBeLessThanOrEqual(process.platform === 'win32' ? 30_000 : 65_536) + } + }) + + it('keeps a short list in one batch', () => { + expect(argvBatches(['a.ts', 'b.ts'])).toEqual([['a.ts', 'b.ts']]) + expect(argvBatches([])).toEqual([[]]) + }) +}) diff --git a/packages/uncheck/tsconfig/middleapi/base.json b/packages/uncheck/tsconfig/middleapi/base.json index 8e9a79b..1ebadd8 100644 --- a/packages/uncheck/tsconfig/middleapi/base.json +++ b/packages/uncheck/tsconfig/middleapi/base.json @@ -7,6 +7,7 @@ "module": "ES2022", "moduleResolution": "bundler", + "allowImportingTsExtensions": true, "resolveJsonModule": true, "types": [], From ec6dafbea719879e2b743187b918e157b2a5ee87 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:45:16 +0700 Subject: [PATCH 04/19] docs(uncheck): describe dot-file globs, PnP, --noEmit, sherif in staged and preset versions --- packages/uncheck/README.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index cb1a836..1596f85 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -26,7 +26,7 @@ ## Usage ```sh -npm i -D uncheck # Node 22.20+ or 24.8+, for stable path.matchesGlob +npm i -D uncheck # Node 22.20+ or 24.8+ npx uncheck # sherif, oxlint, oxfmt --check and tsc for everything under the current directory npx uncheck --fix # sherif --fix and oxlint --fix, then rewrite formatting with oxfmt @@ -39,27 +39,27 @@ npx uncheck --cwd packages/app # run in another directory, paths are relative Checks run in order and every check runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any check fails. -| Check | Runs when | Command | -| -------- | ----------------------------------------------------------- | -------------------------------------------------------------- | -| `sherif` | `sherif` is installed and the directory is a workspace root | `sherif`, or `sherif --fix --select=highest` with `--fix` | -| `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` | -| `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` | -| `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p` for the rest | +| Check | Runs when | Command | +| -------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | +| `sherif` | `sherif` is installed and the directory is a workspace root | `sherif`, or `sherif --fix --select=highest` with `--fix` | +| `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` | +| `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` | +| `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p --noEmit` for the rest | -Paths given on the command line are resolved by `uncheck` itself into one list of files that every tool receives, so tools never disagree about what a directory or glob means: a file must exist, a directory expands to the project files below it (ignored files stay out, like `git ls-files`), globs use the usual `**`/`*` syntax and `!pattern` excludes. A path that matches nothing fails the run, unless `--no-error-on-unmatched-pattern` is passed. +Paths given on the command line are resolved by `uncheck` itself into one list of files that every tool receives, so tools never disagree about what a directory or glob means: a file must exist, a directory expands to the project files below it (ignored files stay out, like `git ls-files`), globs use the usual `**`/`*` syntax and match dot files too, and `!pattern` excludes, from everything when no other path is given. A path that matches nothing fails the run, unless `--no-error-on-unmatched-pattern` is passed. -Tools are resolved from `node_modules` the way Node does, so the versions your project already depends on are used. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped. +Tools are resolved from `node_modules` the way Node does, or through Yarn's PnP resolver when run through `yarn`, so the versions your project already depends on are used. Checking a list of files needs oxlint 1.60 or later. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped, and a tool that crashes or is killed fails its check while the others still run. ### Monorepo consistency -[`sherif`](https://github.com/QuiiBz/sherif) lints a monorepo as a whole: dependency versions that differ between packages, unordered dependencies, a missing `packageManager` field and so on. It runs when the directory is a workspace root (`workspaces` in `package.json` or a `pnpm-workspace.yaml`) and, when paths are given, only if a `package.json` or `pnpm-workspace.yaml` is among them, since nothing else changes its verdict. Its options are read from the `sherif` field of the root `package.json` as sherif documents, so rules and dependencies to ignore live there. With `--fix` sherif also runs your package manager's install afterwards, unless that field sets `noInstall`; aligning versions takes the highest one unless the field sets `select`, since choosing interactively needs a terminal. sherif refuses to fix anything in CI, so with `CI` set it only checks. +[`sherif`](https://github.com/QuiiBz/sherif) lints a monorepo as a whole: dependency versions that differ between packages, unordered dependencies, a missing `packageManager` field and so on. It runs when the directory is a workspace root (`workspaces` in `package.json` or a `pnpm-workspace.yaml`) and, when paths are given, only if a `package.json` or `pnpm-workspace.yaml` is among them, since nothing else changes its verdict. Its options are read from the `sherif` field of the root `package.json` as sherif documents, so rules and dependencies to ignore live there. With `--fix` sherif also runs your package manager's install afterwards, unless that field sets `noInstall`; aligning versions takes the highest one unless the field sets `select`, since choosing interactively needs a terminal. sherif refuses to fix anything in CI, so with `CI` set it only checks, and so does `uncheck staged`: its fixes reach manifests outside the commit and need an install and a lockfile update, so run `uncheck --fix` and stage the result. ### Typecheck in monorepos Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively to build the project graph: - Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package. -- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p`. +- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p --noEmit`, up to four at a time, so they never write build output. - Circular references are reported as an error. When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Files `tsc` never checks, such as Markdown or CSS, select no project. @@ -102,7 +102,7 @@ export default defineConfig({ ...middleapi }) } ``` -The tsconfig presets target ES2022 and load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. +The tsconfig presets target ES2022 and load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. They accept imports that name the `.ts` file, as Node's type stripping requires. The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+: an older oxfmt ignores `oxfmt.config.ts` and formats with its defaults. ## Agent hooks From bfd42b304e5cff67a21970755ba7a42220089110 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:57:01 +0700 Subject: [PATCH 05/19] fix(uncheck): check only a commit's own files in `staged` and put unstaged changes back intact - A staged symlink is no longer checked or fixed through to the file it points to (outside the repository, untracked or unstaged); a symlink turned into a regular file is now checked. - During a merge, only files that differ from the side merged in are checked and fixed, so merge commits no longer carry fixes to the other side's work. - With --fix only the files the fixes changed are staged, so a merge or squash bringing in files outside a sparse checkout can be committed, and a conflicting fix is still undone there. - A partially staged file stored with CRLF under text=auto or core.autocrlf is no longer reported as a conflicting fix. - Run from a package folder, the restored file keeps the line endings and encoding its .gitattributes set. - Setting unstaged changes aside and undoing fixes no longer runs, or fails with, the post-checkout hook. --- packages/uncheck/src/commands/staged.ts | 186 ++++++++++-------- packages/uncheck/tests/command.test.ts | 251 +++++++++++++++++++++++- 2 files changed, 352 insertions(+), 85 deletions(-) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 22935f0..e1b3076 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -4,6 +4,7 @@ import { Console, Effect, FileSystem, Path, Ref } from 'effect' import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' +import { existingFiles } from '../files' import { git, gitBytes, GitFailed, gitPaths } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' @@ -34,18 +35,14 @@ export const staged = Command.make( const cwd = path.resolve(directory) - // Staged deletions and submodules have nothing to check. - const files = yield* gitPaths(cwd, [ - 'diff', - '--cached', - '--name-only', - '--diff-filter=ACMR', - '--ignore-submodules=all', - '--relative', - '-z', - ]).pipe( + // Only regular files: tools follow a staged symlink to a file the commit does not hold. + const listed = yield* stagedFiles(cwd).pipe( Effect.catchTag('GitFailed', () => userError('`uncheck staged` needs a git repository')), ) + const merging = yield* mergeInProgress(cwd) + // Fixing what a merge takes from the other side would commit changes neither side made. + const notTheirs = merging ? new Set(yield* stagedFiles(cwd, 'MERGE_HEAD')) : undefined + const files = notTheirs === undefined ? listed : listed.filter((file) => notTheirs.has(file)) yield* Console.log(dim(`uncheck staged in ${cwd}`)) @@ -67,81 +64,68 @@ export const staged = Command.make( return yield* leftover(saved) } - const bases = yield* partiallyStaged(cwd, files) - const partial = [...bases.keys()] + const partial = yield* partiallyStaged(cwd, files) const before = yield* writeTree(cwd) const outcome = yield* Ref.make('restored') const { failure, empty } = yield* Effect.scoped( Effect.gen(function* () { if (partial.length > 0) { - yield* Effect.acquireRelease(setAside(aside, partial), () => + yield* Effect.acquireRelease(setAside(aside, partial), (bases) => putBack(aside, files, bases, before).pipe( Effect.flatMap((result) => Ref.set(outcome, result)), ), ) } - const failed = yield* checkPaths(files, { + const failure = yield* checkPaths(files, { ...selection, cwd, fix, literal: true, staged: true, - }).pipe( - Effect.map(() => undefined), - Effect.catchTag('CheckFailed', (error) => Effect.succeed(error)), - ) - - if (fix) { - yield* Effect.forEach( - argvBatches(files), - (batch) => git(cwd, ['add', '--', ...batch]), + }).pipe(Effect.catchTag('CheckFailed', Effect.succeed)) + + if (!fix) { + return { failure, empty: false } + } + + const changed = (yield* Effect.forEach(argvBatches(files), (batch) => + gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', '--', ...batch]), + )).flat() + + if (changed.length === 0) { + return { failure, empty: false } + } + + const stage = (env?: Readonly>) => + Effect.forEach( + argvBatches(changed), + (batch) => git(cwd, ['add', '--', ...batch], env), { discard: true }, ) - // `git commit ` runs the hook on a temporary index, and the index it leaves - // behind (index.lock until then) needs the fixes too, or it would hold their revert. - const active = process.env.GIT_INDEX_FILE - - if (active?.endsWith('.lock') === true) { - const lock = path.resolve( - cwd, - yield* git(cwd, ['rev-parse', '--git-path', 'index.lock']), - ) - - if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { - yield* Effect.forEach( - argvBatches(files), - (batch) => git(cwd, ['add', '--', ...batch], { GIT_INDEX_FILE: lock }), - { discard: true }, - ) - } - } + yield* stage() - const after = yield* writeTree(cwd) + // `git commit ` runs the hook on a temporary index, and the index it leaves + // behind (index.lock until then) needs the fixes too, or it would hold their revert. + const active = process.env.GIT_INDEX_FILE - if (after !== before) { - const fixed = yield* gitPaths(cwd, [ - 'diff-tree', - '-r', - '--name-only', - '--relative', - '-z', - before, - after, - ]) - - yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(fixed)}`) - } + if (active?.endsWith('.lock') === true) { + const lock = path.resolve( + cwd, + yield* git(cwd, ['rev-parse', '--git-path', 'index.lock']), + ) - return { - failure: failed, - empty: after === (yield* headTree(cwd)) && !(yield* merging(cwd)), + if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { + yield* stage({ GIT_INDEX_FILE: lock }) } } - return { failure: failed, empty: false } + yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(changed)}`) + + // git records a merge commit even when its tree is the one HEAD already has. + return { failure, empty: !merging && (yield* writeTree(cwd)) === (yield* headTree(cwd)) } }), ) @@ -184,8 +168,7 @@ const headTree = (cwd: string) => Effect.catchTag('GitFailed', () => Effect.succeed(undefined)), ) -// git records a merge commit even when its tree is the one HEAD already has. -const merging = (cwd: string) => +const mergeInProgress = (cwd: string) => git(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).pipe( Effect.as(true), Effect.catchTag('GitFailed', () => Effect.succeed(false)), @@ -198,26 +181,41 @@ const leftover = (saved: string) => const REGULAR_FILE_MODE = /^100(?:644|755)$/ +const rawDiff = (cwd: string, ...args: ReadonlyArray) => + Effect.map( + git(cwd, [ + 'diff', + '--raw', + '--no-renames', + '--ignore-submodules=all', + '--relative', + '-z', + ...args, + ]), + (output) => { + const fields = output.split('\0') + + return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { + const [fromMode = '', toMode = ''] = fields[index * 2]!.slice(1).split(' ') + + return { file: fields[index * 2 + 1]!, fromMode, toMode } + }) + }, + ) + +const stagedFiles = (cwd: string, ...against: ReadonlyArray) => + Effect.map(rawDiff(cwd, '--cached', '--diff-filter=ACMT', ...against), (entries) => + entries.filter((entry) => REGULAR_FILE_MODE.test(entry.toMode)).map((entry) => entry.file), + ) + const partiallyStaged = Effect.fn(function* (cwd: string, files: ReadonlyArray) { - const entries = (yield* git(cwd, [ - 'diff', - '--raw', - '--no-abbrev', - '--no-renames', - '--relative', - '-z', - ])).split('\0') - const partial = new Map() + const partial: string[] = [] const odd: string[] = [] - for (let index = 0; index + 1 < entries.length; index += 2) { - const file = entries[index + 1]! - + for (const { file, fromMode, toMode } of yield* rawDiff(cwd)) { if (files.includes(file)) { - const [indexMode = '', fileMode = '', indexBlob = ''] = entries[index]!.slice(1).split(' ') - - if (REGULAR_FILE_MODE.test(indexMode) && REGULAR_FILE_MODE.test(fileMode)) { - partial.set(file, indexBlob) + if (REGULAR_FILE_MODE.test(fromMode) && REGULAR_FILE_MODE.test(toMode)) { + partial.push(file) } else { odd.push(file) } @@ -267,9 +265,13 @@ const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray { discard: true }, ).pipe(Effect.tapError(() => Effect.ignore(fs.remove(saved, { recursive: true })))) - yield* Effect.forEach(argvBatches(files), (batch) => git(cwd, ['checkout', '--', ...batch]), { - discard: true, - }).pipe( + // Plumbing, since `git checkout` runs the post-checkout hook and fails when it does. The index + // blob is no merge base: git leaves a CRLF blob alone where hash-object normalizes it. + const ids = yield* Effect.forEach(argvBatches(files), (batch) => + git(cwd, ['checkout-index', '-f', '--', ...batch]).pipe( + Effect.andThen(git(cwd, ['hash-object', '-w', '--', ...batch])), + ), + ).pipe( Effect.tapError(() => Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }).pipe( Effect.andThen(fs.remove(saved, { recursive: true })), @@ -280,6 +282,10 @@ const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray yield* Console.log( dim(`○ unstaged changes of ${listFiles(files)} set aside until the checks finish`), ) + + return new Map( + ids.flatMap((output) => output.split('\n')).map((id, index) => [files[index]!, id]), + ) }) /** Runs as a finalizer, so it must never fail: what it cannot do is reported and returned. */ @@ -296,7 +302,7 @@ const putBack = Effect.fn(function* ( const result = yield* Effect.gen(function* () { const copies = yield* copiesOf(aside, partial) const merged = yield* Effect.forEach(partial, (file, index) => - merge(cwd, file, copies[index]![1], bases.get(file)!), + merge(aside, file, copies[index]![1], bases.get(file)!), ) if (merged.every(Boolean)) { @@ -306,7 +312,13 @@ const putBack = Effect.fn(function* ( yield* Effect.forEach( argvBatches(files), - (batch) => git(cwd, ['checkout', before, '--', ...batch]), + (batch) => git(cwd, ['reset', '-q', before, '--', ...batch]), + { discard: true }, + ) + // checkout-index fails on a file a sparse checkout leaves out, which no check could change. + yield* Effect.forEach( + argvBatches(yield* existingFiles(files, cwd)), + (batch) => git(cwd, ['checkout-index', '-f', '--', ...batch]), { discard: true }, ) yield* Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }) @@ -332,7 +344,12 @@ const putBack = Effect.fn(function* ( // Merges what git stores: a formatter rewriting line endings would conflict with every line of a // raw merge. `apply --3way` would run the repository's merge drivers and rerere; `merge-file` does not. -const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base: string) { +const merge = Effect.fn(function* ( + { cwd, prefix }: Aside, + file: string, + copy: string, + base: string, +) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const target = path.join(cwd, file) @@ -370,9 +387,10 @@ const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base if (clean) { const id = yield* git(cwd, ['hash-object', '-w', '--no-filters', '--', result]) + // Unlike hash-object, cat-file takes --path from the top of the repository. yield* fs.writeFile( target, - yield* gitBytes(cwd, ['cat-file', '--filters', `--path=${file}`, id]), + yield* gitBytes(cwd, ['cat-file', '--filters', `--path=${prefix}${file}`, id]), ) yield* fs.chmod(target, (yield* fs.stat(copy)).mode) } diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 3267f46..62fc716 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -1180,7 +1180,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n') writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4;\n') - await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout \[2 paths\] failed/) + await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout-index -f \[2 paths\] failed/) expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( 'export const answer: number = 44;\n', @@ -1240,6 +1240,231 @@ describe('uncheck staged', { timeout: 120_000 }, () => { 'export const answer: number = 44;\n', ) }) + + it.skipIf(process.platform === 'win32')( + 'checks a symlink turned into a file, never what a staged symlink points to', + async () => { + const dir = committed(clean) + + symlinkSync('other.ts', join(dir, 'src/link.ts')) + gitIn(dir, 'add', 'src/link.ts') + gitIn(dir, 'commit', '--quiet', '-m', 'link') + rmSync(join(dir, 'src/link.ts')) + writeFileSync(join(dir, 'src/link.ts'), 'export const link = 1\n') + symlinkSync('other.ts', join(dir, 'src/alias.ts')) + gitIn(dir, 'add', 'src/link.ts', 'src/alias.ts') + writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3\n') + + const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts\n') + expect(stdout).toContain('✔ staged the fixes to src/link.ts\n') + expect(gitIn(dir, 'show', ':src/link.ts')).toBe('export const link = 1;\n') + expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 3\n') + expect(gitIn(dir, 'status', '--porcelain')).toBe( + 'A src/alias.ts\nT src/link.ts\n M src/other.ts\n', + ) + }, + ) + + it('checks only the files of a merge that differ from the side merged in', async () => { + const dir = committed(clean) + + gitIn(dir, 'checkout', '--quiet', '-b', 'side') + writeFileSync(join(dir, 'src/theirs.ts'), 'export const theirs = 1\n') + writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') + gitIn(dir, 'add', 'src') + gitIn(dir, 'commit', '--quiet', '-m', 'side') + gitIn(dir, 'checkout', '--quiet', '-') + gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') + writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4\n') + gitIn(dir, 'add', 'src/other.ts') + + const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts\n') + expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 4;\n') + expect(gitIn(dir, 'show', ':src/theirs.ts')).toBe('export const theirs = 1\n') + }) + + it('undoes or stages only what the fixes changed, so a merge can bring in files outside a sparse checkout', async () => { + const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) + + gitIn(dir, 'checkout', '--quiet', '-b', 'side') + writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 2;\n') + gitIn(dir, 'commit', '--quiet', '-am', 'side') + gitIn(dir, 'checkout', '--quiet', '-') + gitIn(dir, 'sparse-checkout', 'set', 'src') + gitIn(dir, 'merge', '--quiet', '--squash', 'side') + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') + gitIn(dir, 'add', 'src/index.ts') + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') + + await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( + /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, + ) + expect(gitIn(dir, 'status', '--porcelain')).toBe('M lib/lib.ts\nMM src/index.ts\n') + + gitIn(dir, 'add', 'src/index.ts') + + const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') + expect(existsSync(join(dir, 'lib'))).toBe(false) + expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 2;\n') + expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 43;\n') + }) + + it('sees no change in a file git keeps with CRLF line endings under text=auto', async () => { + const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' + const dir = committed({ ...clean, 'src/legacy.ts': lines }) + const file = join(dir, 'src/legacy.ts') + + writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') + gitIn(dir, 'add', '.gitattributes') + gitIn(dir, 'commit', '--quiet', '-m', 'attributes') + writeFileSync(file, `${lines}export const c = 3;\r\n`) + gitIn(dir, 'add', 'src/legacy.ts') + writeFileSync(file, `${lines}export const c = 3;\r\nexport const d = 4;\r\n`) + + const { result } = await run(dir, ['staged', '--only=oxlint']) + + expect(result).toBe('ok') + expect(readFileSync(file, 'utf8')).toBe( + `${lines}export const c = 3;\r\nexport const d = 4;\r\n`, + ) + expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/legacy.ts\n') + }) + + it('sets unstaged changes aside and back without running the post-checkout hook', async () => { + const dir = committed(clean) + + mkdirSync(join(dir, '.git/hooks'), { recursive: true }) + writeFileSync( + join(dir, '.git/hooks/post-checkout'), + '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n', + { mode: 0o755 }, + ) + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') + gitIn(dir, 'add', 'src/index.ts') + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') + + await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( + /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, + ) + + expect(existsSync(join(dir, '.git/post-checkout-ran'))).toBe(false) + expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 42\n') + expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( + 'export const answer: number = 43\n', + ) + }) + + it('only reports what sherif finds, since its fixes reach beyond the staged files', async () => { + const dir = workspace() + + gitIn(dir, 'init', '--quiet') + gitIn(dir, 'add', '.') + gitIn(dir, 'commit', '--quiet', '-m', 'init') + writeFileSync( + join(dir, 'packages/a/package.json'), + JSON.stringify({ name: 'a', version: '1.0.1', dependencies: { react: '^18.0.0' } }), + ) + gitIn(dir, 'add', 'packages/a/package.json') + vi.stubEnv('CI', undefined) + + const { result, stdout } = await run(dir, ['staged', '--fix']).finally(() => vi.unstubAllEnvs()) + + expect(result).toBeInstanceOf(CheckFailed) + expect(stdout).toContain('▶ sherif\n') + expect(gitIn(dir, 'status', '--porcelain')).toBe('M packages/a/package.json\n') + }) + + it('passes a commit no check has anything to do with, unless a check is required', async () => { + const dir = committed({ ...clean, 'README.md': '# readme\n' }) + + writeFileSync(join(dir, 'README.md'), '# readme\n\nmore\n') + gitIn(dir, 'add', 'README.md') + + const docs = await run(dir, ['staged', '--only=tsc']) + + expect(docs.result).toBe('ok') + expect(docs.stdout).toContain( + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files\n', + ) + + const required = await run(dir, ['staged', '--only=tsc', '--require=tsc']) + + expect(required.result).toBeInstanceOf(CheckFailed) + expect(required.stdout).toContain('✘ tsc no tsconfig.json covers the given files\n') + }) + + it('checks and fixes staged files whose names start with ! or -', async () => { + const dir = committed({ ...clean, 'x.ts': 'export const x = 1;\n' }) + + writeFileSync(join(dir, '!x.ts'), 'export const bang = 1\n') + writeFileSync(join(dir, '-x.ts'), 'export const dash = 1\n') + writeFileSync(join(dir, 'x.ts'), 'export const x = 2\n') + gitIn(dir, 'add', '--', '!x.ts', '-x.ts', 'x.ts') + + const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxlint', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt)\n') + expect(stdout).toContain('✔ staged the fixes to !x.ts -x.ts x.ts\n') + expect(gitIn(dir, 'show', ':./!x.ts')).toBe('export const bang = 1;\n') + expect(gitIn(dir, 'show', ':./-x.ts')).toBe('export const dash = 1;\n') + expect(gitIn(dir, 'show', ':x.ts')).toBe('export const x = 2;\n') + }) + + it.skipIf(process.platform === 'win32')( + 'puts unstaged changes back when a closed terminal hangs up during a slow check', + async () => { + const dir = fixture( + { + ...clean, + 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, + 'node_modules/oxlint/lint.js': + "require('node:fs').writeFileSync('node_modules/started', '')\nsetTimeout(() => {}, 30_000)\n", + }, + [], + ) + const file = join(dir, 'src/index.ts') + + gitIn(dir, 'init', '--quiet') + gitIn(dir, 'add', '.') + gitIn(dir, 'commit', '--quiet', '-m', 'init') + writeFileSync(file, 'export const answer: number = 43;\n') + gitIn(dir, 'add', 'src/index.ts') + writeFileSync(file, 'export const answer: number = 43;\nexport const more = 1;\n') + + const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) + const check = spawn(process.execPath, [bin, 'staged', '--only=oxlint'], { + cwd: dir, + stdio: 'ignore', + }) + const exited = once(check, 'exit') + onTestFinished(() => { + check.kill('SIGKILL') + }) + + await vi.waitFor(() => expect(existsSync(join(dir, 'node_modules/started'))).toBe(true), { + timeout: 10_000, + }) + check.kill('SIGHUP') + check.kill('SIGHUP') + + expect(await exited).toEqual([130, null]) + expect(readFileSync(file, 'utf8')).toBe( + 'export const answer: number = 43;\nexport const more = 1;\n', + ) + expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/index.ts\n') + expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) + }, + ) }) describe('uncheck staged in a package', { timeout: 120_000 }, () => { @@ -1268,6 +1493,30 @@ describe('uncheck staged in a package', { timeout: 120_000 }, () => { expect(gitIn(dir, 'show', ':src/root.ts')).toBe('export const root = 11\n') }) + it('puts unstaged changes back with the line endings the package sets', async () => { + const crlf = (text: string) => text.replaceAll('\n', '\r\n') + const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' + const dir = committed({ + 'packages/app/.gitattributes': '*.ts text eol=crlf\n', + 'packages/app/src/index.ts': crlf(`export const answer: number = 42;\n${lines}`), + }) + const file = join(dir, 'packages/app/src/index.ts') + + writeFileSync(file, crlf(`export const answer: number = 43\n${lines}`)) + gitIn(dir, 'add', '.') + writeFileSync(file, crlf(`export const answer: number = 43\n${lines}export const d = 4;\n`)) + + const { result } = await run(join(dir, 'packages/app'), ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( + `export const answer: number = 43;\n${lines}`, + ) + expect(readFileSync(file, 'utf8')).toBe( + crlf(`export const answer: number = 43;\n${lines}export const d = 4;\n`), + ) + }) + it('reports what git refused to do instead of crashing', async () => { const dir = committed(clean) From 6747c8996090ac800107d049eab3b0c888c0b8a0 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:55:27 +0700 Subject: [PATCH 06/19] fix(uncheck): keep the pre-commit hook prepare writes safe for every repository and every package - A core.hooksPath from the global or system git config is left alone and reported, so one install no longer adds this repository's line to the hook every repository runs - Packages of a workspace that prepare at the same time all keep their line - In a hook that already has commands, the line runs before them, so their failure still blocks the commit and an `exec` or `exit` no longer skips it - A hook in another language (Node, Python, Ruby) is left alone and prepare says which line it should run, instead of breaking every commit - A package folder whose name sh would expand inside double quotes gets no line rather than a broken or ever-growing one - A hooks folder only counts as the husky 9 or Vite+ dispatcher when it is their `_` folder - The folder a line enters comes from git itself, so it no longer depends on how the path to the package was spelled - Lines older versions wrote with plain npx or yarn are upgraded in place, and hand-written lines that chain or soften the command stay as they are --- packages/uncheck/src/commands/prepare.ts | 182 +++++++++++++++------ packages/uncheck/tests/command.test.ts | 195 ++++++++++++++++++++++- 2 files changed, 319 insertions(+), 58 deletions(-) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index fcdc7cd..ab6b00b 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'node:crypto' -import { Console, Effect, FileSystem, Option, Path } from 'effect' +import { Console, Effect, FileSystem, Option, Path, Result, Schedule } from 'effect' import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' @@ -22,6 +22,15 @@ const EXIT = ' || exit 1' const ENTERS = /^\(cd "([^"]*)" && (.*)\)$/ const OLD_ENTERS = /^cd "([^"]*)" && (.*)$/ +/** Git runs a hook through its shebang, where a line of `sh` would be a syntax error. */ +const OTHER_INTERPRETER = /^#!(?!.*\b(?:ba|da|k|z|a)?sh\b)/ + +/** + * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends + * the hook line. + */ +const UNQUOTABLE = /["$`\\\n]/ + function hookLine(inside: string, command: string): string { return inside === '' ? `${command}${EXIT}` : `(cd "${inside}" && ${command})${EXIT}` } @@ -58,6 +67,26 @@ const replaceFile = Effect.fn(function* (file: string, content: string, mode: nu ) }) +// A workspace install runs the `prepare` script of every package at once, all rewriting one hook. +function locked(file: string, effect: Effect.Effect) { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const lock = `${file}.lock` + + return yield* Effect.acquireUseRelease( + fs.writeFileString(lock, '', { flag: 'wx' }).pipe( + Effect.retry({ + while: (error) => error.reason._tag === 'AlreadyExists', + schedule: Schedule.spaced('20 millis'), + times: 500, + }), + ), + () => effect, + () => Effect.ignore(fs.remove(lock)), + ) + }) +} + export const prepare = Command.make( 'prepare', { @@ -96,9 +125,12 @@ export const prepare = Command.make( const cwd = path.resolve(directory) - const repository = yield* Effect.all([ - git(cwd, ['rev-parse', '--show-toplevel']), - git(cwd, ['rev-parse', '--git-path', 'hooks']), + const repository = yield* git(cwd, [ + 'rev-parse', + '--show-toplevel', + '--show-prefix', + '--git-path', + 'hooks', ]).pipe(Effect.option) // A `prepare` script runs on every install, including where there is no repository to hook. @@ -106,7 +138,10 @@ export const prepare = Command.make( return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) } - const [top, hooks] = repository.value + // A newline in a path shifts the lines git prints, so they are split to land it in `inside`. + const [, ...printed] = repository.value.split('\n') + const hooks = printed.pop() ?? '' + const inside = printed.join('\n').replace(/\/$/, '') const exec = yield* detectExec(cwd) const command = [ exec, @@ -115,35 +150,33 @@ export const prepare = Command.make( ...(allowEmpty ? ['--allow-empty'] : []), ...selectionArgs(selection), ].join(' ') - - // Git runs hooks at the top of the working tree, so a project below it is entered first. - const inside = path.relative(top, yield* fs.realPath(cwd)).replaceAll('\\', '/') const line = hookLine(inside, command) - // husky 9 and Vite+ point core.hooksPath at generated shims that source the `h` dispatcher, which - // exits before any line appended to a shim and runs the hook in the folder above instead. + // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h` + // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above. const configured = path.resolve(cwd, hooks) - const dispatched = yield* fs - .exists(path.join(configured, 'h')) - .pipe(Effect.orElseSucceed(() => false)) + const dispatched = + path.basename(configured) === '_' && + (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false))) const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit') const relative = path.relative(cwd, file) const shown = relative.startsWith('..') ? file : relative - const existing = yield* fs.readFileString(file).pipe(Effect.option) - const next = Option.isNone(existing) - ? `${HEADER}${line}\n` - : rewrite(existing.value, line, inside) - const result = Option.isNone(existing) - ? 'created' - : next === existing.value - ? 'unchanged' - : 'updated' - - const refused = yield* Effect.gen(function* () { - // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would leave - // every clone with a change to commit. - if (result === 'unchanged') { - return dispatched ? undefined : yield* fs.chmod(file, 0o755) + const shared = yield* git(cwd, ['config', '--show-scope', '--get', 'core.hooksPath']).pipe( + Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]), + Effect.orElseSucceed(() => undefined), + ) + + const written = yield* Effect.gen(function* () { + if (shared !== undefined) { + return yield* Effect.fail( + `core.hooksPath is set in the ${shared} git config, so every repository runs it`, + ) + } + + if (UNQUOTABLE.test(inside)) { + return yield* Effect.fail( + `sh would misread the folder name ${JSON.stringify(inside)} between double quotes`, + ) } // Renaming over a symlinked hook would replace the link, not the script it points to. @@ -152,30 +185,65 @@ export const prepare = Command.make( Effect.map((link) => path.resolve(path.dirname(file), link)), Effect.orElseSucceed(() => file), ) - const mode = dispatched - ? yield* fs.stat(target).pipe( - Effect.map((info) => info.mode & 0o7777), - Effect.orElseSucceed(() => undefined), - ) - : 0o755 yield* fs.makeDirectory(path.dirname(target), { recursive: true }) - yield* replaceFile(target, next, mode) + + return yield* locked( + target, + Effect.gen(function* () { + const existing = yield* fs + .readFileString(target) + .pipe(Effect.orElseSucceed(() => undefined)) + + if (existing !== undefined && OTHER_INTERPRETER.test(existing)) { + return yield* Effect.fail(`it is not a shell script, have it run \`${line}\` yourself`) + } + + const next = rewrite(existing ?? HEADER, line, inside) + const result = + existing === undefined ? 'created' : next === existing ? 'unchanged' : 'updated' + + // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would + // leave every clone with a change to commit. + if (result === 'unchanged') { + if (!dispatched) { + yield* fs.chmod(target, 0o755) + } + + return result + } + + const mode = dispatched + ? yield* fs.stat(target).pipe( + Effect.map((info) => info.mode & 0o7777), + Effect.orElseSucceed(() => undefined), + ) + : 0o755 + + yield* replaceFile(target, next, mode) + + return result + }), + ) }).pipe( - Effect.as(undefined), - Effect.catch((error) => - Effect.succeed(error.cause instanceof Error ? error.cause.message : error.message), + Effect.mapError((error) => + typeof error === 'string' + ? error + : error.cause instanceof Error + ? error.cause.message + : error.message, ), + Effect.result, ) - // `prepare` runs on every install, so an unwritable hook says so rather than failing the install. - if (refused !== undefined) { + // `prepare` runs on every install, so a hook it may not write says so rather than failing it. + if (Result.isFailure(written)) { return yield* Console.log( - `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${refused}`)}`, + `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${written.failure}`)}`, ) } - yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${result}`)}`) + yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${written.success}`)}`) yield* Console.log('') yield* Console.log( `${dim('The hook runs')} ${bold(command)} ${dim('before every commit, `git commit --no-verify` skips it.')}`, @@ -183,14 +251,14 @@ export const prepare = Command.make( }), ).pipe( Command.withDescription( - 'Set up git hooks, for the `prepare` script in package.json so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`', + 'Set up git hooks, for the `prepare` script in package.json (`postinstall` with Yarn 2+) so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`', ), ) /** - * Puts `line` into an existing hook, so running `prepare` again is idempotent: the line for this - * directory is updated wherever it sits, duplicates of it are dropped, and everything else is kept, - * including the commands of other packages in the same repository and whatever the user added. + * Puts `line` into a hook, so running `prepare` again is idempotent: the line for this directory is + * updated wherever it sits, duplicates of it are dropped, and everything else is kept, including the + * commands of other packages in the same repository and whatever the user added. */ function rewrite(hook: string, line: string, inside: string): string { let placed = false @@ -213,13 +281,27 @@ function rewrite(hook: string, line: string, inside: string): string { return [line] }) - const next = lines.join('\n') if (placed) { - return next + return lines.join('\n') + } + + // Added after the commands of the hook, the line would set its exit status in their place, and + // would never run after an `exec` or `exit`. Before a sourced file it would run twice with husky 8, + // whose `_/husky.sh` runs the hook again. + const after = lines.reduce( + (last, text, index) => (ownLine(text) === undefined ? last : index + 1), + 0, + ) + const at = after > 0 ? after : lines.findIndex((text) => !/^\s*(?:#|\.\s|$)/.test(text)) + + if (at === -1) { + const kept = lines.join('\n') + + return `${kept === '' || kept.endsWith('\n') ? kept : `${kept}\n`}${line}\n` } - const kept = next === '' || next.endsWith('\n') ? next : `${next}\n` + lines.splice(at, 0, line) - return `${kept}${line}\n` + return lines.join('\n') } diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 62fc716..4d24bc0 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -1532,6 +1532,18 @@ describe('uncheck staged in a package', { timeout: 120_000 }, () => { describe('uncheck prepare', { timeout: 120_000 }, () => { const header = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' + let globalConfig = '' + + beforeEach(() => { + globalConfig = join(fixture({ '.gitconfig': '' }, []), '.gitconfig') + vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) + vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') + }) + + afterEach(() => { + vi.unstubAllEnvs() + }) + it('writes the pre-commit hook through the detected package manager and updates it in place', async () => { const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) gitIn(dir, 'init', '--quiet') @@ -1622,6 +1634,18 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { const settled = await run(dir, ['prepare', '--pre-commit']) expect(settled.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n') + + const chained = 'npx uncheck staged --only=oxfmt && pnpm test' + const advisory = 'pnpm exec uncheck staged --fix || echo "not blocking"' + + writeFileSync(hook, `#!/bin/sh\n${chained}\n${advisory}\n`) + + const handWritten = await run(dir, ['prepare', '--pre-commit']) + + expect(handWritten.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') + expect(readFileSync(hook, 'utf8')).toBe( + `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${chained}\n${advisory}\n`, + ) }) it.skipIf(process.platform === 'win32')( @@ -1678,6 +1702,45 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { }, ) + it('keeps the line of every package when they all prepare at once, as a workspace install does', async () => { + const packages = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + const folders = ['.', ...packages.map((name) => `packages/${name}`)] + const dir = fixture( + { + 'pnpm-lock.yaml': '', + ...Object.fromEntries(folders.map((folder) => [`${folder}/package.json`, '{}\n'])), + }, + [], + ) + gitIn(dir, 'init', '--quiet') + const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) + + const exits = await Promise.all( + folders.map((folder) => { + const prepared = spawn(process.execPath, [bin, 'prepare', '--pre-commit'], { + cwd: join(dir, folder), + stdio: 'ignore', + }) + + return once(prepared, 'exit') + }), + ) + + expect(exits.map(([code]) => code)).toEqual(folders.map(() => 0)) + expect(readFileSync(join(dir, '.git/hooks/pre-commit'), 'utf8').split('\n').sort()).toEqual( + [ + ...header.split('\n'), + 'pnpm exec uncheck staged --fix || exit 1', + ...packages.map( + (name) => `(cd "packages/${name}" && pnpm exec uncheck staged --fix) || exit 1`, + ), + ].sort(), + ) + expect(readdirSync(join(dir, '.git/hooks')).filter((name) => name.endsWith('.lock'))).toEqual( + [], + ) + }) + it.skipIf(process.platform === 'win32')( 'swaps in the new hook whole, so a commit already running it finishes the old one', async () => { @@ -1745,7 +1808,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') expect(lstatSync(hook).isSymbolicLink()).toBe(true) expect(readFileSync(join(dir, 'scripts/pre-commit'), 'utf8')).toBe( - '#!/bin/sh\npnpm test\npnpm exec uncheck staged --fix || exit 1\n', + '#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\npnpm test\n', ) expect(statSync(hook).mode & 0o777).toBe(0o755) @@ -1805,6 +1868,30 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(again.stdout).toContain(`✔ pre-commit ${hook} unchanged\n`) }) + it('switches the lines older versions wrote to the current runner in place', async () => { + const npm = fixture({ 'package.json': '{}\n', 'package-lock.json': '{}\n' }, []) + gitIn(npm, 'init', '--quiet') + const npmHook = join(npm, '.git/hooks/pre-commit') + writeFileSync(npmHook, `${header}npx uncheck staged --fix || exit 1\npnpm test\n`) + + await run(npm, ['prepare', '--pre-commit']) + + expect(readFileSync(npmHook, 'utf8')).toBe( + `${header}npx --no uncheck staged --fix || exit 1\npnpm test\n`, + ) + + const yarn = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) + gitIn(yarn, 'init', '--quiet') + const yarnHook = join(yarn, '.git/hooks/pre-commit') + writeFileSync(yarnHook, '#!/bin/sh\nyarn uncheck staged --fix\npnpm test\n') + + await run(yarn, ['prepare', '--pre-commit']) + + expect(readFileSync(yarnHook, 'utf8')).toBe( + '#!/bin/sh\nyarn run --silent uncheck staged --fix || exit 1\npnpm test\n', + ) + }) + it('takes the runner from the packageManager field and reports an unwritable hook', async () => { const dir = fixture({ 'package.json': { packageManager: 'bun@1.2.0' } }, []) gitIn(dir, 'init', '--quiet') @@ -1826,7 +1913,9 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(refused.result).toBe('ok') expect(refused.stdout).toContain('✘ pre-commit .git/hooks/pre-commit not written,') expect( - readdirSync(join(blocked, '.git/hooks')).filter((name) => name.includes('uncheck')), + readdirSync(join(blocked, '.git/hooks')).filter( + (name) => name.includes('uncheck') || name.endsWith('.lock'), + ), ).toEqual([]) }) @@ -1844,7 +1933,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(result).toBe('ok') expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) expect(readFileSync(hook, 'utf8')).toBe( - '#!/bin/sh\necho hi\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\n', + '#!/bin/sh\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\necho hi', ) if (process.platform !== 'win32') { @@ -1858,6 +1947,67 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(skipped.stdout).toBe('○ no git repository found, nothing to prepare\n') }) + it.skipIf(process.platform === 'win32')( + 'runs before the commands of an existing hook, so they still fail it and cannot skip it', + async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + gitIn(dir, 'init', '--quiet') + const hook = join(dir, '.git/hooks/pre-commit') + const bin = fixture({ pnpm: '#!/bin/sh\necho "$*" >> "$LOG"\ntest "$1" = exec\n' }, []) + const log = join(bin, 'log') + chmodSync(join(bin, 'pnpm'), 0o755) + writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`) + writeFileSync(hook, '#!/bin/sh\n# lint\n. "$(dirname "$0")/env"\nexec pnpm lint-staged\n') + + await run(dir, ['prepare', '--pre-commit']) + + expect(readFileSync(hook, 'utf8')).toBe( + '#!/bin/sh\n# lint\n. "$(dirname "$0")/env"\npnpm exec uncheck staged --fix || exit 1\nexec pnpm lint-staged\n', + ) + + const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { + cwd: dir, + env: { ...process.env, LOG: log }, + }) + + expect(status).toBe(1) + expect(readFileSync(log, 'utf8')).toBe('exec uncheck staged --fix\nlint-staged\n') + }, + ) + + it('leaves a hook in another language alone and says what it should run', async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + gitIn(dir, 'init', '--quiet') + const hook = join(dir, '.git/hooks/pre-commit') + const script = '#!/usr/bin/env node\nconsole.log("checked")\n' + writeFileSync(hook, script, { mode: 0o755 }) + + const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) + + expect(result).toBe('ok') + expect(stdout).toBe( + '✘ pre-commit .git/hooks/pre-commit not written, it is not a shell script, have it run `pnpm exec uncheck staged --fix || exit 1` yourself\n', + ) + expect(readFileSync(hook, 'utf8')).toBe(script) + }) + + it('writes nothing for a package whose folder name sh would expand', async () => { + const dir = fixture( + { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/a$b/package.json': '{}\n' }, + [], + ) + gitIn(dir, 'init', '--quiet') + const hook = join(dir, '.git/hooks/pre-commit') + + const { result, stdout } = await run(join(dir, 'packages/a$b'), ['prepare', '--pre-commit']) + + expect(result).toBe('ok') + expect(stdout).toBe( + `✘ pre-commit ${hook} not written, sh would misread the folder name "packages/a$b" between double quotes\n`, + ) + expect(existsSync(hook)).toBe(false) + }) + it('writes the hook the husky 9 and Vite+ dispatcher runs, not their generated shim', async () => { const dispatcher = [ 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', @@ -1898,7 +2048,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(nested.stdout).toContain(`✔ pre-commit ${hook} updated\n`) expect(readFileSync(hook, 'utf8')).toBe( - `pnpm test\npnpm exec uncheck staged --fix || exit 1\n${app}\n`, + `pnpm exec uncheck staged --fix || exit 1\n${app}\npnpm test\n`, ) expect(readFileSync(join(dir, '.husky/_/pre-commit'), 'utf8')).toBe(shim) @@ -1945,15 +2095,15 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(commit()).toEqual({ status: 0, ran: [ - `${top} test`, `${top} exec uncheck staged --fix`, `${top}/packages/app exec uncheck staged --fix --only=oxlint`, + `${top} test`, ], }) writeFileSync(join(dir, 'fail'), '') - expect(commit()).toEqual({ status: 1, ran: [`${top} test`] }) + expect(commit()).toEqual({ status: 1, ran: [`${top} exec uncheck staged --fix`] }) chmodSync(hook, 0o755) @@ -1979,9 +2129,14 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(existsSync(join(vite, '.vite-hooks/_/pre-commit'))).toBe(false) }) - it('writes into a core.hooksPath set by hand as it is', async () => { + it('writes into a core.hooksPath set by hand as it is, even one holding an `h` script', async () => { const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/app/package.json': '{}\n' }, + { + 'package.json': '{}\n', + 'pnpm-lock.yaml': '', + 'packages/app/package.json': '{}\n', + '.githooks/h': 'echo "help"\n', + }, [], ) gitIn(dir, 'init', '--quiet') @@ -1999,11 +2154,35 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { `${header}pnpm exec uncheck staged --fix || exit 1\n(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1\n`, ) expect(existsSync(join(dir, '.git/hooks/pre-commit'))).toBe(false) + expect(existsSync(join(dir, 'pre-commit'))).toBe(false) if (process.platform !== 'win32') { expect(statSync(hook).mode & 0o111).toBe(0o111) } }) + + it('leaves the core.hooksPath of the global git config alone, since every repository runs it', async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + gitIn(dir, 'init', '--quiet') + const shared = fixture({ 'pre-commit': '#!/bin/sh\necho "scanning for secrets"\n' }, []) + gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared) + + const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) + + expect(result).toBe('ok') + expect(stdout).toBe( + `✘ pre-commit ${join(shared, 'pre-commit')} not written, core.hooksPath is set in the global git config, so every repository runs it\n`, + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe( + '#!/bin/sh\necho "scanning for secrets"\n', + ) + + gitIn(dir, 'config', 'core.hooksPath', '.githooks') + + const local = await run(dir, ['prepare', '--pre-commit']) + + expect(local.stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') + }) }) describe('uncheck presets', { timeout: 120_000 }, () => { From a445de39e7641c1034ef7f9c33a72b5d55141d19 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:51:07 +0700 Subject: [PATCH 07/19] fix(uncheck): agent hooks keep your settings, check the right directory and reach every agent - `hooks install` refuses a config that is not valid JSON(C) or not an object, naming the file and leaving every file as it was, instead of rewriting it with settings lost - Reinstalling updates only uncheck's own hook entry, keeping keys added to it; permission rules, notes and chained commands that merely mention `uncheck hooks run` stay as they are, and a Stop hook is still added next to them - Hook entries carry a 600 s timeout, so Copilot, VS Code and CodeBuddy no longer kill a typecheck at their 30 s or 60 s default; existing installs are upgraded on the next install - The hook checks the top of the repository whichever directory the agent `cd`'d into; installed below the top it carries `--dir=` and checks that directory - Copilot is sent back through its Claude-format entry too, not only warned - When checks still fail after the one retry, Claude Code and CodeBuddy show the user a warning instead of ending the turn silently - A change no selected check covers passes the hook, and a deleted file whose name looks like a pattern no longer checks its neighbours - Windsurf is no longer offered: its hook event can neither show the report nor send Cascade back --- .../uncheck/src/commands/hooks/install.ts | 164 ++++++------ packages/uncheck/src/commands/hooks/run.ts | 69 +++++- packages/uncheck/tests/command.test.ts | 234 ++++++++++++++++-- 3 files changed, 360 insertions(+), 107 deletions(-) diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 368325e..af5aebf 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -1,56 +1,60 @@ -import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio } from 'effect' +import { isDeepStrictEqual } from 'node:util' + +import { Console, Effect, FileSystem, Path, Predicate, Stdio } from 'effect' import { Argument, Command, Prompt } from 'effect/unstable/cli' -import { parse as parseJsonc } from 'jsonc-parser' +import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser' import { userError } from '../../errors' -import { detectExec } from '../../pm' +import { git } from '../../git' +import { detectExec, invokes } from '../../pm' import { bold, dim, green } from '../../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck' +// Copilot (30 s) and CodeBuddy (60 s) kill a typecheck at their default timeout and end the turn as +// if no hook ran. +const TIMEOUT_SECONDS = 600 + const AGENTS = [ { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', - content: (command: string) => ({ - hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] }, - }), + entry: (command: string) => ({ type: 'command', command, timeout: TIMEOUT_SECONDS }), + content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), }, { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', - content: (command: string) => ({ - hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] }, - }), + entry: (command: string) => ({ type: 'command', command, timeout: TIMEOUT_SECONDS }), + content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), }, { id: 'cursor', name: 'Cursor', path: '.cursor/hooks.json', - content: (command: string) => ({ version: 1, hooks: { stop: [{ command }] } }), - }, - { - id: 'windsurf', - name: 'Windsurf', - path: '.windsurf/hooks.json', - content: (command: string) => ({ - hooks: { post_cascade_response: [{ command, show_output: true }] }, - }), + entry: (command: string) => ({ command, timeout: TIMEOUT_SECONDS }), + content: (entry: object) => ({ version: 1, hooks: { stop: [entry] } }), }, { id: 'copilot', name: 'GitHub Copilot', path: '.github/hooks/uncheck.json', - content: (command: string) => ({ - version: 1, - hooks: { agentStop: [{ type: 'command', bash: command, powershell: command }] }, + entry: (command: string) => ({ + type: 'command', + bash: command, + powershell: command, + timeoutSec: TIMEOUT_SECONDS, }), + content: (entry: object) => ({ version: 1, hooks: { agentStop: [entry] } }), }, ] as const const AGENT_IDS = AGENTS.map((agent) => agent.id) +// Must stay within what `invokes` accepts in a flag, or a reinstall adds a second hook. +const FLAG_VALUE = /^[\w./@+-]*$/ + export const install = Command.make( 'install', { @@ -70,6 +74,18 @@ export const install = Command.make( yield* validateSelection(selection) + // Agents run the hook wherever they last `cd`'d, so a project below the top of the repository + // is named relative to it. + const dir = (yield* git(cwd, ['rev-parse', '--show-prefix']).pipe( + Effect.orElseSucceed(() => ''), + )).replace(/\/$/, '') + + if (!FLAG_VALUE.test(dir)) { + return yield* userError( + `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _./@+-`, + ) + } + let selected: ReadonlyArray<(typeof AGENT_IDS)[number]> = agents if (selected.length === 0) { @@ -89,44 +105,54 @@ export const install = Command.make( } const exec = yield* detectExec(cwd) - const command = `${exec} ${HOOK_COMMAND} ${['--fix', ...selectionArgs(selection)].join(' ')}` - - for (const agent of AGENTS) { - if (!selected.includes(agent.id)) { - continue - } + const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] + const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` + + const updates = yield* Effect.forEach( + AGENTS.filter((agent) => selected.includes(agent.id)), + (agent) => + Effect.gen(function* () { + const file = path.join(cwd, agent.path) + const existing = yield* fs + .readFileString(file) + .pipe(Effect.orElseSucceed(() => undefined)) + const text = existing ?? '' + const errors: ParseError[] = [] + const current: unknown = parseJsonc(text, errors, { allowTrailingComma: true }) + + if (text.trim() !== '' && (errors.length > 0 || !Predicate.isObject(current))) { + const [error] = errors + const problem = + error === undefined + ? 'is not a JSON object' + : `has ${printParseErrorCode(error.error)} on line ${text.slice(0, error.offset).split('\n').length}` + + return yield* userError(`${agent.path} ${problem}, fix it and run again`) + } - const file = path.join(cwd, agent.path) - const existing = yield* fs.readFileString(file).pipe(Effect.option) - let result: 'created' | 'updated' | 'unchanged' + const base = Predicate.isObject(current) ? current : {} + const entry = agent.entry(command) + const found: object[] = [] + const replaced = mapOwnEntries(base, (hook) => { + found.push(hook) + return { ...hook, ...entry } + }) + const next = found.length > 0 ? replaced : mergeJson(base, agent.content(entry)) + const result = + existing === undefined + ? 'created' + : isDeepStrictEqual(next, base) + ? 'unchanged' + : 'updated' + + return { agent, file, next, result } + }), + ) - if (Option.isNone(existing)) { + for (const { agent, file, next, result } of updates) { + if (result !== 'unchanged') { yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString(file, render(agent.content(command))) - result = 'created' - } else { - const current: unknown = parseJsonc(existing.value, undefined, { allowTrailingComma: true }) - const base = Predicate.isObject(current) ? current : {} - const installed: string[] = [] - - const replaced = mapStrings(base, (text) => { - if (!text.includes(HOOK_COMMAND)) { - return text - } - - installed.push(text) - return command - }) - - if (installed.length === 0) { - yield* fs.writeFileString(file, render(mergeJson(base, agent.content(command)))) - result = 'updated' - } else if (installed.every((text) => text === command)) { - result = 'unchanged' - } else { - yield* fs.writeFileString(file, render(replaced)) - result = 'updated' - } + yield* fs.writeFileString(file, `${JSON.stringify(next, null, 2)}\n`) } yield* Console.log(`${green('✔')} ${bold(agent.name)} ${dim(`${agent.path} ${result}`)}`) @@ -145,26 +171,24 @@ export const install = Command.make( const HOOK_COMMAND = 'uncheck hooks run' -function render(value: unknown): string { - return `${JSON.stringify(value, null, 2)}\n` -} - -function mapStrings(value: unknown, f: (text: string) => string): unknown { - if (typeof value === 'string') { - return f(value) +function mapOwnEntries(value: unknown, f: (hook: Record) => object): unknown { + if (Array.isArray(value)) { + return value.map((item) => mapOwnEntries(item, f)) } - if (Array.isArray(value)) { - return value.map((item) => mapStrings(item, f)) + if (!Predicate.isObject(value)) { + return value } - if (Predicate.isObject(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, item]) => [key, mapStrings(item, f)]), - ) + if ( + Object.values(value).some((item) => typeof item === 'string' && invokes(item, HOOK_COMMAND)) + ) { + return f(value) } - return value + return Object.fromEntries( + Object.entries(value).map(([key, item]) => [key, mapOwnEntries(item, f)]), + ) } function mergeJson(base: unknown, addition: unknown): unknown { diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index f02a61a..a93e961 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -1,17 +1,35 @@ +import process from 'node:process' import { stripVTControlCharacters } from 'node:util' -import { Console, Effect, Predicate, Stdio, Stream } from 'effect' -import { Command } from 'effect/unstable/cli' +import { Console, Effect, Option, Path, Predicate, Stdio, Stream } from 'effect' +import { Command, Flag } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' import { listChangedFiles } from '../../files' +import { git } from '../../git' import { captureLines } from '../../tool' -import { cwdFlag, fixFlag, runChecks, selectionFlags } from '../uncheck' +import { fixFlag, runChecks, selectionFlags } from '../uncheck' export const run = Command.make( 'run', - { cwd: cwdFlag, fix: fixFlag, ...selectionFlags }, - Effect.fn(function* ({ cwd, ...settings }) { + { + cwd: Flag.Directory('cwd', { mustExist: true }).pipe( + Flag.optional, + Flag.withDescription( + 'Directory to check. Defaults to the top of the git repository around the current directory, or the current directory outside git', + ), + ), + dir: Flag.String('dir').pipe( + Flag.optional, + Flag.withDescription( + 'Directory to check relative to the top of the git repository, whichever directory the agent moved to. `hooks install` writes it for a project below the top', + ), + ), + fix: fixFlag, + ...selectionFlags, + }, + Effect.fn(function* ({ cwd: given, dir, ...settings }) { + const path = yield* Path.Path const stdio = yield* Stdio.Stdio if (yield* stdio.stdinIsTerminal) { @@ -24,6 +42,14 @@ export const run = Command.make( Effect.orElseSucceed((): Record => ({})), ) + const start = Option.getOrElse(given, () => process.cwd()) + const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe( + Effect.orElseSucceed(() => undefined), + ) + const cwd = Option.isSome(dir) + ? path.join(top ?? start, dir.value) + : Option.getOrElse(given, () => top ?? start) + const changed = yield* listChangedFiles(cwd) if (changed?.length === 0) { @@ -44,20 +70,41 @@ export const run = Command.make( yield* Console.error(report) + if (!failed) { + return + } + // Send the agent back at most once per turn, in the way its family understands. Claude Code and - // CodeBuddy block on exit code 2 with stderr as the message and set `stop_hook_active` once they - // are already continuing; Cursor continues on a follow-up message and counts them in `loop_count`; - // Copilot continues on a block decision and also sets `stop_hook_active`. Windsurf only shows the report. + // CodeBuddy block on exit code 2 with stderr as the message, set `stop_hook_active` once they are + // already continuing, and show the user nothing but a `systemMessage` from a hook that exits 0; + // Cursor continues on a follow-up message and counts them in `loop_count`; Copilot continues on a + // block decision, also in the Claude format, where it takes exit code 2 for a mere warning. const alreadyContinued = payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) - if (!failed || alreadyContinued) { + if (alreadyContinued) { + if (payload.hook_event_name === 'Stop') { + const summary = + report + .split('\n') + .filter((line) => line.startsWith('✘ ')) + .at(-1) ?? '' + + yield* Console.log( + JSON.stringify({ systemMessage: `uncheck still fails: ${summary.slice(2)}` }), + ) + } + return } const reason = `uncheck found problems, fix them before finishing:\n\n${report}` + if (typeof (payload.stopReason ?? payload.stop_reason) === 'string') { + return yield* Console.log(JSON.stringify({ decision: 'block', reason })) + } + if (payload.hook_event_name === 'Stop') { return yield* Effect.fail(new StopBlocked()) } @@ -65,10 +112,6 @@ export const run = Command.make( if (payload.hook_event_name === 'stop') { return yield* Console.log(JSON.stringify({ followup_message: reason })) } - - if (typeof payload.stopReason === 'string') { - return yield* Console.log(JSON.stringify({ decision: 'block', reason })) - } }), ).pipe( Command.withDescription( diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 4d24bc0..96324f2 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -584,14 +584,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { it('writes stop hook configs for the named agents through the detected package manager', async () => { const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - const { result, stdout } = await run(dir, [ - 'hooks', - 'install', - 'claude', - 'cursor', - 'windsurf', - 'copilot', - ]) + const { result, stdout } = await run(dir, ['hooks', 'install', 'claude', 'cursor', 'copilot']) expect(result).toBe('ok') expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') @@ -601,18 +594,17 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { const hook = 'pnpm exec uncheck hooks run --fix' expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: hook }] }] }, + hooks: { Stop: [{ hooks: [{ type: 'command', command: hook, timeout: 600 }] }] }, }) expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({ version: 1, - hooks: { stop: [{ command: hook }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.windsurf/hooks.json'), 'utf8'))).toEqual({ - hooks: { post_cascade_response: [{ command: hook, show_output: true }] }, + hooks: { stop: [{ command: hook, timeout: 600 }] }, }) expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ version: 1, - hooks: { agentStop: [{ type: 'command', bash: hook, powershell: hook }] }, + hooks: { + agentStop: [{ type: 'command', bash: hook, powershell: hook, timeoutSec: 600 }], + }, }) const again = await run(dir, ['hooks', 'install', 'claude']) @@ -646,7 +638,11 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { permissions: { allow: ['Bash(pnpm test)'] }, hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], - Stop: [{ hooks: [{ type: 'command', command: 'npx --no uncheck hooks run --fix' }] }], + Stop: [ + { + hooks: [{ type: 'command', command: 'npx --no uncheck hooks run --fix', timeout: 600 }], + }, + ], }, }) }) @@ -673,7 +669,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { expect(result).toBe('ok') expect(stdout).toContain(fast) expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: fast }] }] }, + hooks: { Stop: [{ hooks: [{ type: 'command', command: fast, timeout: 600 }] }] }, }) const all = 'yarn run --silent uncheck hooks run --fix' @@ -683,17 +679,127 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { expect(again.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') expect(again.stdout).toContain('✔ GitHub Copilot .github/hooks/uncheck.json updated\n') expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: all }] }] }, + hooks: { Stop: [{ hooks: [{ type: 'command', command: all, timeout: 600 }] }] }, }) expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ version: 1, - hooks: { agentStop: [{ type: 'command', bash: all, powershell: all }] }, + hooks: { + agentStop: [{ type: 'command', bash: all, powershell: all, timeoutSec: 600 }], + }, }) await expect( run(dir, ['hooks', 'install', 'claude', '--only=oxlint', '--skip=oxlint']), ).rejects.toThrow(/--only=oxlint and --skip=oxlint/) }) + + it('updates its own entry in place and leaves strings that only mention the hook alone', async () => { + const mentions = { + _comment: 'the Stop hook runs npx uncheck hooks run --fix', + permissions: { allow: ['Bash(npx uncheck hooks run:*)'] }, + hooks: { + PostToolUse: [ + { + hooks: [ + { + type: 'command', + command: 'cd packages/web && npx uncheck hooks run --fix && notify', + }, + ], + }, + ], + }, + } + const dir = fixture({ 'package.json': '{}\n', '.claude/settings.json': mentions }, []) + const hook = { type: 'command', command: 'npx --no uncheck hooks run --fix', timeout: 600 } + + const { result, stdout } = await run(dir, ['hooks', 'install', 'claude']) + + expect(result).toBe('ok') + expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') + expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ + ...mentions, + hooks: { ...mentions.hooks, Stop: [{ hooks: [hook] }] }, + }) + + const older = { + hooks: { + Stop: [ + { + hooks: [ + { + type: 'command', + command: 'npx uncheck hooks run --fix --only=oxlint', + statusMessage: 'Checking', + }, + ], + }, + ], + }, + } + + writeFileSync(join(dir, '.claude/settings.json'), JSON.stringify(older)) + + const upgraded = await run(dir, ['hooks', 'install', 'claude']) + + expect(upgraded.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') + expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ + hooks: { Stop: [{ hooks: [{ ...hook, statusMessage: 'Checking' }] }] }, + }) + + const again = await run(dir, ['hooks', 'install', 'claude']) + + expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') + }) + + it('refuses a config that is not a JSON object and leaves every file as it is', async () => { + const broken = '{\n permissions: { "deny": ["Read(.env)"] },\n "model": "opus"\n}\n' + const dir = fixture( + { 'package.json': '{}\n', '.cursor/hooks.json': broken, '.github/hooks/uncheck.json': '[]' }, + [], + ) + + await expect(run(dir, ['hooks', 'install', 'claude', 'cursor'])).rejects.toThrow( + '.cursor/hooks.json has InvalidSymbol on line 2, fix it and run again', + ) + await expect(run(dir, ['hooks', 'install', 'copilot'])).rejects.toThrow( + '.github/hooks/uncheck.json is not a JSON object', + ) + expect(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8')).toBe(broken) + expect(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).toBe('[]') + expect(existsSync(join(dir, '.claude/settings.json'))).toBe(false) + + writeFileSync(join(dir, '.cursor/hooks.json'), '\n') + + const { result, stdout } = await run(dir, ['hooks', 'install', 'cursor']) + + expect(result).toBe('ok') + expect(stdout).toContain('✔ Cursor .cursor/hooks.json updated\n') + }) + + it('names the directory below the top of the repository in the hook command', async () => { + const dir = committed({ 'package.json': '{}\n', 'packages/web/package.json': '{}\n' }) + mkdirSync(join(dir, 'packages/my web')) + + const { result, stdout } = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) + + expect(result).toBe('ok') + expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') + expect( + JSON.parse(readFileSync(join(dir, 'packages/web/.claude/settings.json'), 'utf8')), + ).toMatchObject({ + hooks: { + Stop: [{ hooks: [{ command: 'npx --no uncheck hooks run --fix --dir=packages/web' }] }], + }, + }) + + const again = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) + + expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') + await expect(run(join(dir, 'packages/my web'), ['hooks', 'install', 'claude'])).rejects.toThrow( + /cannot name packages\/my web/, + ) + }) }) /** Runs git in `dir` with a throwaway identity and returns what it printed. */ @@ -768,7 +874,9 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { ) expect(continuing.result).toBe('ok') - expect(continuing.stdout).toBe('') + expect(JSON.parse(continuing.stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) expect(continuing.stderr).toContain('TS2322') const cursor = await run( @@ -791,15 +899,93 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { expect(copilot.result).toBe('ok') expect(JSON.parse(copilot.stdout)).toMatchObject({ decision: 'block' }) - const windsurf = await run( + const copilotInClaudeFormat = await run( dir, ['hooks', 'run', '--fix'], - JSON.stringify({ agent_action_name: 'post_cascade_response' }), + JSON.stringify({ hook_event_name: 'Stop', stop_reason: 'end_turn', stop_hook_active: false }), ) - expect(windsurf.result).toBe('ok') - expect(windsurf.stdout).toBe('') - expect(windsurf.stderr).toContain('TS2322') + expect(copilotInClaudeFormat.result).toBe('ok') + expect(JSON.parse(copilotInClaudeFormat.stdout)).toMatchObject({ decision: 'block' }) + }) + + it('passes a change no selected check covers, unless that check is required', async () => { + const dir = committed(clean) + writeFileSync(join(dir, 'README.md'), '# Project\n') + + const optional = await run( + dir, + ['hooks', 'run', '--fix', '--only=tsc'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + ) + + expect(optional.result).toBe('ok') + expect(optional.stderr).toContain('○ nothing to check') + + const required = await run( + dir, + ['hooks', 'run', '--fix', '--only=tsc', '--require=tsc'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + ) + + expect(required.result).toBe('blocked') + }) + + it('never takes a deleted file for a pattern that matches its neighbours', async () => { + const dir = committed({ + ...clean, + 'app/[id].ts': 'export const id = 1;\n', + 'app/i.ts': 'export const i = 1\n', + }) + rmSync(join(dir, 'app/[id].ts')) + + const { result, stderr } = await run( + dir, + ['hooks', 'run', '--fix', '--only=oxfmt'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + ) + + expect(result).toBe('ok') + expect(stderr).not.toContain('app/i.ts') + expect(readFileSync(join(dir, 'app/i.ts'), 'utf8')).toBe('export const i = 1\n') + }) + + it('checks the top of the repository wherever the agent moved to, or the directory in --dir', async () => { + const dir = committed({ + ...clean, + 'docs/guide.md': '# Guide\n', + 'packages/app/src/index.ts': 'export const app = 1;\n', + 'packages/web/src/index.ts': 'export const web = 1;\n', + }) + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1;\n') + + const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) + const moved = spawnSync(process.execPath, [bin, 'hooks', 'run', '--fix'], { + cwd: join(dir, 'docs'), + input: JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + encoding: 'utf8', + }) + + expect(moved.status).toBe(2) + expect(moved.stderr).toContain('TS2322') + + writeFileSync(join(dir, 'packages/app/src/index.ts'), 'export const app = 2\n') + writeFileSync(join(dir, 'packages/web/src/index.ts'), 'export const web = 2\n') + + const { result, stderr } = await run( + join(dir, 'packages/web'), + ['hooks', 'run', '--fix', '--only=oxfmt', '--dir=packages/app'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + ) + + expect(result).toBe('ok') + expect(stderr).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') + expect(readFileSync(join(dir, 'packages/app/src/index.ts'), 'utf8')).toBe( + 'export const app = 2;\n', + ) + expect(readFileSync(join(dir, 'packages/web/src/index.ts'), 'utf8')).toBe( + 'export const web = 2\n', + ) }) it('stays silent when nothing changed and passes quietly when the changes are clean', async () => { From 46bd3e81825e008a64951d338b97db188b12a3e6 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:55:59 +0700 Subject: [PATCH 08/19] fix(uncheck): typecheck every project a change reaches, the way tsc sees it - staged and given files select referenced configs of any name, so a Vite, Nx or Angular solution layout (tsconfig.app.json) is no longer skipped - a changed tsconfig, or a base it extends, selects the projects it applies to, also when the base is a workspace package linked into node_modules - a selected project that others reference builds the roots depending on it with tsc -b, so a library change that breaks its dependents fails the commit; unrelated roots stay out - project inputs match tsc: a base shared by two extends branches applies in both, declarationDir is excluded, `*` skips .min.js, extensions are case sensitive and an explicit node_modules include counts - backslashes in extends and references read as separators, and references keep ${configDir} as written, like tsc - a reference cycle anywhere fails runs on given files too --- packages/uncheck/src/checks/tsc.ts | 291 ++++++++++++------------- packages/uncheck/tests/command.test.ts | 21 ++ packages/uncheck/tests/tsc.test.ts | 137 ++++++++++++ 3 files changed, 301 insertions(+), 148 deletions(-) diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index f6ba192..dff194e 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -1,21 +1,23 @@ import { posix } from 'node:path' import process from 'node:process' -import { Data, Effect, FileSystem, Graph, Option, Path, Predicate } from 'effect' +import { Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' import { CannotCheck, NothingToCheck } from '../errors' import { ancestors, readJson } from '../files' import { resolveBin } from '../tool' -import type { Check, CheckCommand } from '../types' +import type { Check } from '../types' /** - * TypeScript replaces this token in `extends`, `references` and file specs with the folder of the - * leaf config. + * TypeScript replaces this token at the start of `files`, `include`, `exclude` and path-valued + * compiler options with the folder of the leaf config, but never in `extends` or `references`. */ // oxlint-disable-next-line no-template-curly-in-string const CONFIG_DIR = '${configDir}' +const NOT_COVERED = 'no tsconfig.json covers the given files' + export const tsc: Check = { name: 'tsc', fixes: false, @@ -25,12 +27,10 @@ export const tsc: Check = { const targets = files ?.map((file) => path.resolve(cwd, file)) - .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file).toLowerCase())) + .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) if (targets !== undefined && targets.length === 0) { - return yield* Effect.fail( - new NothingToCheck({ reason: 'no tsconfig.json covers the given files' }), - ) + return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) } const [typescript, tsconfigs] = yield* Effect.all( @@ -54,12 +54,25 @@ export const tsc: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) } - const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(tsconfigs, targets) + const references = new Map>() + const queue = [...tsconfigs] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (!references.has(configPath) && (yield* fs.exists(configPath))) { + const referencedConfigs = yield* readReferences(configPath) + + references.set(configPath, referencedConfigs) + queue.push(...referencedConfigs) + } + } + + const selected = + targets === undefined ? tsconfigs : yield* selectTsconfigs([...references.keys()], targets) if (selected.length === 0) { - return yield* Effect.fail( - new NothingToCheck({ reason: 'no tsconfig.json covers the given files' }), - ) + return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) } if (typescript === undefined) { @@ -70,149 +83,124 @@ export const tsc: Check = { ) } - const bin = typescript + const shown = (configPath: string) => path.relative(cwd, configPath) + const referenced = new Set([...references.values()].flat()) + const members = [...references] + .filter( + ([configPath, referencedConfigs]) => + referencedConfigs.length > 0 || referenced.has(configPath), + ) + .map(([configPath]) => configPath) + .sort() - const projects = new Map() - const queue = [...selected] + const cycle = findCycle(members, (configPath) => references.get(configPath) ?? []) - while (queue.length > 0) { - const configPath = queue.pop()! + if (cycle !== undefined) { + return yield* Effect.fail( + new CannotCheck({ + reason: `circular project references between ${cycle.map(shown).join(', ')}`, + }), + ) + } - if (!projects.has(configPath)) { - const references = yield* readReferences(configPath) + const affected = new Set(selected) - projects.set(configPath, { path: configPath, references }) - queue.push(...references) + for (const configPath of affected) { + for (const [dependent, referencedConfigs] of references) { + if (referencedConfigs.includes(configPath)) { + affected.add(dependent) + } } } - const plan = yield* planTypecheck(selected, projects).pipe( - Effect.catchTag('CircularProjectReferences', (error) => { - const cycle = error.projects - .map((configPath) => path.relative(cwd, configPath) || '.') - .join(', ') - return Effect.fail( - new CannotCheck({ reason: `circular project references between ${cycle}` }), - ) - }), - ) - - const commands: CheckCommand[] = [] + const roots = members.filter((member) => !referenced.has(member) && affected.has(member)) + const standalone = selected.filter((configPath) => !members.includes(configPath)).sort() - if (plan.build.length > 0) { - commands.push({ - bin, - args: ['-b', ...plan.build.map((configPath) => path.relative(cwd, configPath) || '.')], - }) - } - - for (const configPath of plan.check) { + return [ + ...(roots.length > 0 ? [{ bin: typescript, args: ['-b', ...roots.map(shown)] }] : []), // A check writes nothing: `-p` would emit JavaScript next to sources that set no `noEmit`. - commands.push({ - bin, - args: ['-p', path.relative(cwd, configPath) || '.', '--noEmit'], + ...standalone.map((configPath) => ({ + bin: typescript, + args: ['-p', shown(configPath), '--noEmit'], parallel: true, - }) - } - - return commands + })), + ] }), } -const CHECKABLE_EXTENSIONS = new Set([ - '.ts', - '.tsx', - '.mts', - '.cts', - '.js', - '.jsx', - '.mjs', - '.cjs', - '.json', -]) - -interface TsProject { - readonly path: string - readonly references: ReadonlyArray -} +function findCycle( + nodes: ReadonlyArray, + edges: (node: string) => ReadonlyArray, +): ReadonlyArray | undefined { + const done = new Set() + const trail: string[] = [] -interface TypecheckPlan { - readonly build: ReadonlyArray - readonly check: ReadonlyArray -} - -class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ - readonly projects: ReadonlyArray -}> {} + function visitAll(starts: ReadonlyArray): ReadonlyArray | undefined { + for (const start of starts) { + const cycle = visit(start) -/** - * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. - * - * Every project that has references, or is referenced, belongs to the build graph. - * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. - */ -function planTypecheck( - entries: ReadonlyArray, - projects: ReadonlyMap, -): Effect.Effect { - const members = new Set() - const referenced = new Set() - - for (const project of projects.values()) { - if (project.references.length === 0) { - continue + if (cycle !== undefined) { + return cycle + } } - members.add(project.path) - - for (const reference of project.references) { - members.add(reference) - referenced.add(reference) - } + return undefined } - const nodes = [...members].sort() - - const graph = Graph.directed((mutable) => { - const indexes = new Map(nodes.map((node) => [node, Graph.addNode(mutable, node)] as const)) - - for (const node of nodes) { - for (const reference of projects.get(node)?.references ?? []) { - Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) - } + function visit(node: string): ReadonlyArray | undefined { + if (trail.includes(node)) { + return trail.slice(trail.indexOf(node)) } - }) - const cycle = Graph.findCycle(graph) + if (done.has(node)) { + return undefined + } - if (Option.isSome(cycle)) { - // The path closes on its first node, drop that repetition. - const path = cycle.value.path - .slice(0, -1) - .map((index) => Option.getOrThrow(Graph.getNode(graph, index))) + trail.push(node) + const cycle = visitAll(edges(node)) + trail.pop() + done.add(node) - return Effect.fail(new CircularProjectReferences({ projects: path })) + return cycle } - return Effect.succeed({ - build: nodes.filter((member) => !referenced.has(member)), - check: entries.filter((entry) => !members.has(entry)).sort(), - }) + return visitAll(nodes) } -function selectTsconfigs( - entries: ReadonlyArray, +const selectTsconfigs = Effect.fn(function* ( + candidates: ReadonlyArray, files: ReadonlyArray, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.filter( - entries, - (entry) => - Effect.map(loadTsconfigInputs(entry), (inputs) => - files.some((file) => includesFile(inputs, file)), +) { + const jsonFiles = yield* Effect.forEach( + files.filter((file) => posix.extname(file) === '.json'), + realPath, + { concurrency: 'unbounded' }, + ) + + const selected = yield* Effect.filter( + candidates, + (candidate) => + Effect.map( + loadTsconfigInputs(candidate), + (inputs) => + inputs.configRealPaths.some((config) => jsonFiles.includes(config)) || + files.some((file) => includesFile(inputs, file)), ), { concurrency: 'unbounded' }, - ).pipe(Effect.map((selected) => [...selected].sort())) -} + ) + + return [...selected].sort() +}) + +/** + * A shared config is often extended through a workspace package linked into `node_modules`, while + * the given files name it by its real path. + */ +const realPath = Effect.fn(function* (file: string) { + const fs = yield* FileSystem.FileSystem + + return yield* fs.realPath(file).pipe(Effect.orElseSucceed(() => file)) +}) interface RawTsconfig { readonly extends?: unknown @@ -247,7 +235,7 @@ const readReferences = Effect.fn(function* (configPath: string) { return [] } - const target = path.resolve(configDir, reference.path.replaceAll(CONFIG_DIR, configDir)) + const target = path.resolve(configDir, reference.path.replaceAll('\\', '/')) return [target.endsWith('.json') ? target : path.join(target, 'tsconfig.json')] }) @@ -259,7 +247,7 @@ interface InputPattern { } interface TsconfigInputs { - readonly dir: string + readonly configRealPaths: ReadonlyArray readonly files: ReadonlyArray readonly include: ReadonlyArray readonly exclude: ReadonlyArray @@ -268,7 +256,7 @@ interface TsconfigInputs { const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] -const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages'] +const CHECKABLE_EXTENSIONS = new Set([...TS_EXTENSIONS, ...JS_EXTENSIONS, '.json']) interface Specs { readonly dir: string @@ -289,6 +277,7 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { let include: Specs | undefined let exclude: Specs | undefined let outDir: Specs | undefined + let declarationDir: Specs | undefined let allowJs = false for (const { dir, raw } of chain) { @@ -318,6 +307,10 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { if (typeof compilerOptions.outDir === 'string') { outDir = { dir, specs: [compilerOptions.outDir] } } + + if (typeof compilerOptions.declarationDir === 'string') { + declarationDir = { dir, specs: [compilerOptions.declarationDir] } + } } } @@ -332,15 +325,12 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { : resolve(include) const excludeSpecs = - exclude === undefined - ? [ - ...DEFAULT_EXCLUDES.map((name) => `${leafDir.replaceAll('\\', '/')}/${name}`), - ...resolve(outDir), - ] - : resolve(exclude) + exclude === undefined ? [...resolve(outDir), ...resolve(declarationDir)] : resolve(exclude) return { - dir: leafDir, + configRealPaths: yield* Effect.forEach(chain, ({ file }) => realPath(file), { + concurrency: 'unbounded', + }), files: resolve(files), include: includeSpecs.flatMap((spec) => { const regex = compileGlob(spec, 'files') @@ -358,7 +348,7 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean { return true } - const extension = posix.extname(target).toLowerCase() + const extension = posix.extname(target) const json = extension === '.json' if (!json && !inputs.extensions.has(extension)) { @@ -376,20 +366,23 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean { } interface ChainEntry { + readonly file: string readonly dir: string readonly raw: RawTsconfig } +/** + * Only a config that is still being resolved closes a cycle: like tsc, a base that two `extends` + * branches share applies in both. + */ const loadExtendsChain = Effect.fn(function* ( configPath: string, - visited: Set, + resolving: ReadonlySet, ): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { - if (visited.has(configPath)) { + if (resolving.has(configPath)) { return [] } - visited.add(configPath) - const path = yield* Path.Path const raw = yield* readTsconfig(configPath) const dir = path.dirname(configPath) @@ -397,17 +390,17 @@ const loadExtendsChain = Effect.fn(function* ( typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : [] const bases = yield* Effect.forEach(specs, (spec) => - resolveExtends(spec, dir).pipe( + resolveExtends(spec.replaceAll('\\', '/'), dir).pipe( Effect.flatMap( Option.match({ onNone: () => Effect.succeed>([]), - onSome: (base) => loadExtendsChain(base, visited), + onSome: (base) => loadExtendsChain(base, new Set(resolving).add(configPath)), }), ), ), ) - return [...bases.flat(), { dir, raw }] + return [...bases.flat(), { file: configPath, dir, raw }] }) const resolveExtends = Effect.fn(function* (spec: string, dir: string) { @@ -472,6 +465,7 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { }) const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' +const FILES_ASTERISK = '(?:[^./]|(?:\\.(?!min\\.js$))?)*' const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' @@ -480,7 +474,8 @@ const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'd * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: * `*` and `?` never cross a directory, a leading wildcard never matches a dot file, `**` skips * `node_modules` and dot folders, and a pattern without extension or wildcard means the whole folder. - * `exclude` patterns also match every path below them. + * In `include`, `*` never matches a name ending in `.min.js`; `exclude` patterns also match every + * path below them. */ function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { const components = pattern.replace(/\/+$/, '').split('/') @@ -522,21 +517,21 @@ function filesComponent(component: string): string { let rest = component if (rest.startsWith('*')) { - source += '(?:[^./][^/]*)?' + source += `(?:[^./]${FILES_ASTERISK})?` rest = rest.slice(1) } else if (rest.startsWith('?')) { source += '[^./]' rest = rest.slice(1) } - source += wildcards(rest) + source += wildcards(rest, FILES_ASTERISK) return /[*?]/.test(component) ? IMPLICIT_EXCLUDE + source : source } -function wildcards(component: string): string { +function wildcards(component: string, asterisk = '[^/]*'): string { return component.replace(/[.*?+^${}()|[\]\\]/g, (char) => - char === '*' ? '[^/]*' : char === '?' ? '[^/]' : `\\${char}`, + char === '*' ? asterisk : char === '?' ? '[^/]' : `\\${char}`, ) } diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 96324f2..eb77c1c 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -195,6 +195,27 @@ describe('uncheck', { timeout: 120_000 }, () => { expect(broken.stdout).toContain('TS2322') }) + it('checks standalone projects side by side but prints their output in plan order', async () => { + const dir = fixture( + { + 'packages/a/tsconfig.json': standaloneTsconfig, + 'packages/a/src/index.ts': 'export const a: number = 1;\n', + 'packages/b/tsconfig.json': standaloneTsconfig, + 'packages/b/src/index.ts': 'export const b: number = "2";\n', + 'packages/c/tsconfig.json': standaloneTsconfig, + 'packages/c/src/index.ts': 'export const c: number = 3;\n', + }, + ['typescript'], + ) + + const { result, stdout } = await run(dir, ['--only=tsc']) + + expect(result).toBeInstanceOf(CheckFailed) + expect(stdout).toMatch( + /▶ tsc -p packages\/a\/tsconfig\.json --noEmit\n▶ tsc -p packages\/b\/tsconfig\.json --noEmit\npackages\/b\/src\/index\.ts[^▶]*TS2322[^▶]*▶ tsc -p packages\/c\/tsconfig\.json --noEmit\n✘ tsc failed/, + ) + }) + it('forwards paths to oxlint and oxfmt and narrows tsc to the projects containing them', async () => { const dir = fixture({ '.oxlintrc.json': oxlintrc, diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts index f445fde..bf797b1 100644 --- a/packages/uncheck/tests/tsc.test.ts +++ b/packages/uncheck/tests/tsc.test.ts @@ -1,3 +1,6 @@ +import { mkdirSync, symlinkSync } from 'node:fs' +import { join } from 'node:path' + import { NodeServices } from '@effect/platform-node' import { Effect } from 'effect' @@ -20,6 +23,8 @@ function plan(dir: string, files?: string[]): Promise { } const NOT_COVERED = 'no tsconfig.json covers the given files' +// oxlint-disable-next-line no-template-curly-in-string +const CONFIG_DIR = '${configDir}' describe('tsc project references', () => { it('checks standalone projects with tsc -p', async () => { @@ -102,6 +107,32 @@ describe('tsc project references', () => { 'circular project references between b/tsconfig.json, c/tsconfig.json', ) }) + + it('follows references as written, leaving the configDir token alone like tsc', async () => { + const dir = fixture({ + 'app/tsconfig.json': { references: [{ path: `${CONFIG_DIR}/../lib` }] }, + 'app/lib/tsconfig.json': {}, + 'lib/tsconfig.json': {}, + }) + + expect(await plan(dir)).toEqual(['-b app/tsconfig.json', '-p lib/tsconfig.json --noEmit']) + }) + + it('reads backslashes in extends and references as separators, like tsc', async () => { + const dir = fixture({ + 'tsconfig.base.json': { compilerOptions: { allowJs: true }, include: [`${CONFIG_DIR}/src`] }, + 'tsconfig.json': { files: [], references: [{ path: '.\\packages\\b' }] }, + 'packages/a/tsconfig.json': { extends: '..\\..\\tsconfig.base.json' }, + 'packages/b/tsconfig.json': { + extends: '..\\..\\tsconfig.base.json', + references: [{ path: '..\\a' }], + }, + }) + + expect(await plan(dir)).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['packages/a/scripts/build.ts'])).toBe(NOT_COVERED) + }) }) describe('tsc project selection', () => { @@ -235,4 +266,110 @@ describe('tsc project selection', () => { ]) expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED) }) + + it('selects the configs a solution-style root references, whatever their name, through the root', async () => { + const dir = fixture({ + 'tsconfig.json': { + files: [], + references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], + }, + 'tsconfig.app.json': { include: ['src'] }, + 'tsconfig.node.json': { include: ['vite.config.ts'] }, + }) + + expect(await plan(dir, ['src/main.ts'])).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['vite.config.ts'])).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['tsconfig.app.json'])).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['scripts/release.ts'])).toBe(NOT_COVERED) + }) + + it('never selects a referenced config that does not exist, leaving the reference to tsc -b', async () => { + const dir = fixture({ + 'tsconfig.json': { include: ['src'], references: [{ path: './packages/gone' }] }, + }) + + expect(await plan(dir)).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['src/index.ts'])).toEqual(['-b tsconfig.json']) + expect(await plan(dir, ['packages/gone/index.ts'])).toBe(NOT_COVERED) + }) + + it('builds every root that depends on a selected project, and no other', async () => { + const dir = fixture({ + 'lib/tsconfig.json': {}, + 'app/tsconfig.json': { references: [{ path: '../lib' }] }, + 'web/tsconfig.json': { references: [{ path: '../lib' }] }, + 'core/tsconfig.json': {}, + 'cli/tsconfig.json': { references: [{ path: '../core' }] }, + 'solo/tsconfig.json': {}, + }) + + expect(await plan(dir, ['lib/src/index.ts'])).toEqual([ + '-b app/tsconfig.json web/tsconfig.json', + ]) + expect(await plan(dir, ['app/src/index.ts'])).toEqual(['-b app/tsconfig.json']) + expect(await plan(dir, ['core/src/index.ts', 'solo/src/index.ts'])).toEqual([ + '-b cli/tsconfig.json', + '-p solo/tsconfig.json --noEmit', + ]) + }) + + it('selects the projects a changed config applies to, also through a workspace package linked into node_modules', async () => { + const dir = fixture({ + 'tsconfig.base.json': { compilerOptions: { strict: true } }, + 'tsconfig.json': { include: ['scripts'] }, + 'packages/config/package.json': { name: '@repo/config' }, + 'packages/config/strict.json': { compilerOptions: { noUncheckedIndexedAccess: true } }, + 'packages/a/tsconfig.json': { extends: '../../tsconfig.base.json', include: ['src'] }, + 'packages/b/tsconfig.json': { + extends: ['../../tsconfig.base.json', '@repo/config/strict.json'], + include: ['src'], + }, + }) + mkdirSync(join(dir, 'node_modules/@repo')) + symlinkSync(join(dir, 'packages/config'), join(dir, 'node_modules/@repo/config')) + const a = '-p packages/a/tsconfig.json --noEmit' + const b = '-p packages/b/tsconfig.json --noEmit' + + expect(await plan(dir, ['tsconfig.base.json'])).toEqual([a, b]) + expect(await plan(dir, ['packages/config/strict.json'])).toEqual([b]) + expect(await plan(dir, ['packages/a/tsconfig.json'])).toEqual([a]) + expect(await plan(dir, ['tsconfig.json'])).toEqual(['-p tsconfig.json --noEmit']) + expect(await plan(dir, ['packages/config/package.json'])).toBe(NOT_COVERED) + }) + + it('applies a base reached through two extends branches in both, like tsc, and stops at circular extends', async () => { + const dir = fixture({ + 'tsconfig.base.json': { include: [`${CONFIG_DIR}/lib`] }, + 'tsconfig.b.json': { extends: './tsconfig.base.json', include: [`${CONFIG_DIR}/src`] }, + 'tsconfig.c.json': { extends: './tsconfig.base.json' }, + 'pkg/tsconfig.json': { extends: ['../tsconfig.b.json', '../tsconfig.c.json'] }, + 'loop/tsconfig.json': { extends: './tsconfig.other.json' }, + 'loop/tsconfig.other.json': { extends: './tsconfig.json', include: ['src'] }, + }) + + expect(await plan(dir, ['pkg/lib/index.ts'])).toEqual(['-p pkg/tsconfig.json --noEmit']) + expect(await plan(dir, ['pkg/src/index.ts'])).toBe(NOT_COVERED) + expect(await plan(dir, ['loop/src/index.ts'])).toEqual(['-p loop/tsconfig.json --noEmit']) + }) + + it('takes the inputs tsc takes: no declarationDir, minified scripts or upper-case extensions, but an included node_modules folder', async () => { + const dir = fixture({ + 'tsconfig.json': { + compilerOptions: { allowJs: true, declarationDir: 'src/types' }, + include: ['src', 'vendor/*', 'plugins/jquery*', 'node_modules/x'], + }, + }) + const covers = (file: string) => plan(dir, [file]).then(Array.isArray) + + expect(await covers('src/index.ts')).toBe(true) + expect(await covers('src/types/index.d.ts')).toBe(false) + expect(await covers('vendor/jquery.js')).toBe(true) + expect(await covers('vendor/jquery.min.js')).toBe(false) + expect(await covers('vendor/jquery.min.jsx')).toBe(true) + expect(await covers('plugins/jquery.ui.js')).toBe(true) + expect(await covers('plugins/jquery.min.js')).toBe(false) + expect(await covers('src/UPPER.TS')).toBe(false) + expect(await covers('node_modules/x/index.ts')).toBe(true) + expect(await covers('node_modules/y/index.ts')).toBe(false) + }) }) From 9a0b1331f84df38f86ff8905a4fa29d0beced322 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 11:59:55 +0700 Subject: [PATCH 09/19] fix(uncheck): keep literal file lists in their given order; document the staged, prepare, hooks and tsc changes --- packages/uncheck/README.md | 20 +++++++++++--------- packages/uncheck/src/files.ts | 5 ++++- 2 files changed, 15 insertions(+), 10 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 1596f85..f8ede91 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -56,13 +56,13 @@ Tools are resolved from `node_modules` the way Node does, or through Yarn's PnP ### Typecheck in monorepos -Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively to build the project graph: +Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively, whatever the referenced configs are named, to build the project graph: - Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package. - Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p --noEmit`, up to four at a time, so they never write build output. -- Circular references are reported as an error. +- Circular references anywhere in the graph are reported as an error. -When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Files `tsc` never checks, such as Markdown or CSS, select no project. +When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Configs that are only referenced count too, such as `tsconfig.app.json` in a Vite, Nx or Angular solution layout, and a changed tsconfig selects every project it applies to through `extends`, also from a config package linked into `node_modules`. A selected project in the reference graph is built with `tsc -b` through the roots that depend on it, so the projects using a changed library are checked again; packages that import each other from source without `references` are not followed. Files `tsc` never checks, such as Markdown or CSS, select no project. ## Presets @@ -108,17 +108,17 @@ The tsconfig presets target ES2022 and load no runtime types, so name yours: `"t ```sh npx uncheck hooks install # pick agents interactively -npx uncheck hooks install claude cursor # or name them: claude, codebuddy, cursor, windsurf, copilot +npx uncheck hooks install claude codebuddy # or name them: claude, codebuddy, cursor, copilot npx uncheck hooks install claude --only=oxlint --only=oxfmt # a fast hook: lint and format, no typecheck ``` -This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json`, `.windsurf/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Whenever the agent finishes a turn, the hook runs: +This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Running it again updates only uncheck's own entry, keeping keys you added to it, while permission rules and other commands that merely mention `uncheck hooks run` stay as they are; a file that is not valid JSON is reported and left alone. Each entry gives the hook 600 seconds, since the 30 and 60 second defaults of Copilot and CodeBuddy would cut a typecheck short. Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so install `cursor` or `copilot` next to `claude` only where they do not read it, or uncheck runs twice per turn. Whenever the agent finishes a turn, the hook runs: ```sh uncheck hooks run --fix ``` -through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). It runs every check on the files changed since the last commit (modified, staged and untracked, everything under the directory outside git), applies fixes, and prints the report on stderr. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop. Windsurf only shows the report. +through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`, detected from the lockfile, so a missing install fails instead of downloading uncheck). Installed below the top of the repository, the command also carries `--dir=`, so the hook checks the same place whichever directory the agent last `cd`'d into. It runs every check on the files changed since the last commit (modified, staged and untracked; everything under the current directory outside git), applies fixes, and prints the report on stderr. A change no selected check covers, such as a README edit with `--only=tsc`, passes. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop; when problems remain after it, Claude Code and CodeBuddy show you that uncheck still fails. Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. When even that is too slow for a project, leave the typecheck to CI: `--only`, `--skip` and `--require` given to `install` are written into the hook command as they are, and reinstalling with other flags updates it, so `install claude --only=oxlint --only=oxfmt` gives a hook that only lints and formats. @@ -130,9 +130,9 @@ npx uncheck staged --fix # also apply the fixes and stage them npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit runs `uncheck staged --fix` ``` -`staged` runs the checks on the files staged for commit. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. A run killed before it puts them back leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed. +`staged` runs the checks on the files staged for commit, regular files only: a staged symlink is skipped, since the tools would follow it to a file the commit does not hold. During a merge only the staged files that differ from the branch being merged in are checked, so the merge commit never carries fixes to the other side's work. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too, and only the files they changed. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. Ctrl-C or a closed terminal still puts them back; a run killed outright before it does leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed. A commit no check has anything to do with, docs only for example, passes; add `--require=` to make it fail instead. -`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook: +`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it, before the hook's own commands (after its shebang, comments and sourced files), so their failure still blocks the commit and an `exec` or `exit` cannot skip it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags, also when a workspace install runs them all at once. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook: ```json { @@ -142,7 +142,9 @@ npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit } ``` -`--only`, `--skip` and `--require` given to `prepare` are written into the hook command as for `hooks install`, `--no-fix` gives a hook that only checks, and `--allow-empty` one that lets through a commit the fixes made empty. Outside a git repository `prepare` does nothing, so installs in CI and Docker builds keep working, and `git commit --no-verify` skips the hook. +Yarn 2+ does not run `prepare` on install, so register the command as `postinstall` there, in a package that is not published: `postinstall` also runs when others install a published package, and fails without uncheck. For a published package, turn it off while packing, for example with `"prepack": "pinst --disable"` and `"postpack": "pinst --enable"`. + +`--only`, `--skip` and `--require` given to `prepare` are written into the hook command as for `hooks install`, `--no-fix` gives a hook that only checks, and `--allow-empty` one that lets through a commit the fixes made empty. Outside a git repository `prepare` does nothing, so installs in CI and Docker builds keep working, and `git commit --no-verify` skips the hook. It also writes nothing, says why and lets the install succeed when `core.hooksPath` comes from the global or system git config, which every repository on the machine runs, when the hook is written for another interpreter such as `#!/usr/bin/env node`, and for a package folder whose name holds `"`, `$`, a backtick, `\` or a newline. In a monorepo where the root and two packages prepare, the hook reads: diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 46bf0c3..3ea8c2f 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -135,7 +135,8 @@ export const existingFiles = Effect.fn(function* (files: ReadonlyArray, const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - return yield* Effect.filter( + // A concurrent `Effect.filter` keeps files in the order their checks finish, not the given one. + const isFile = yield* Effect.forEach( files, (file) => fs.stat(path.resolve(cwd, file)).pipe( @@ -144,6 +145,8 @@ export const existingFiles = Effect.fn(function* (files: ReadonlyArray, ), { concurrency: 64 }, ) + + return files.filter((_, index) => isFile[index]) }) export function ancestors(path: Path.Path, from: string): string[] { From 5e876d077806862332c29ef6d24362afcdc09d62 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 12:47:28 +0700 Subject: [PATCH 10/19] fix(uncheck): address the final review of the branch - globs read [!x] as POSIX negation and parentheses literally, as before picomatch - staged: a CRLF file under text=auto is undone rather than turned to LF, fixes outside a sparse checkout are staged, core.safecrlf no longer blocks, messages name only what applies - prepare: the line goes after the hook's PATH and environment setup, git before 2.26 still refuses a global core.hooksPath, an unreadable or binary hook is never overwritten - hooks: only the stop event's entry is uncheck's, a timeout the user set is kept, a stale --dir is reported, Yarn 2+ root installs run the root binary, Copilot is refused below the top, Cursor turns the user stopped are left alone - tsc: dependents found without a quadratic walk, backslashes in include/exclude, smaller cycle search - Yarn PnP resolves tools from every ancestor workspace --- packages/uncheck/src/checks/tsc.ts | 67 +++++++-------- .../uncheck/src/commands/hooks/install.ts | 83 +++++++++++-------- packages/uncheck/src/commands/hooks/run.ts | 16 +++- packages/uncheck/src/commands/prepare.ts | 34 ++++++-- packages/uncheck/src/commands/staged.ts | 41 ++++++--- packages/uncheck/src/commands/uncheck.ts | 24 ++---- packages/uncheck/src/files.ts | 11 ++- packages/uncheck/src/pm.ts | 51 ++++++------ packages/uncheck/src/tool.ts | 33 ++++---- 9 files changed, 211 insertions(+), 149 deletions(-) diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index dff194e..dfe1c10 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -93,7 +93,7 @@ export const tsc: Check = { .map(([configPath]) => configPath) .sort() - const cycle = findCycle(members, (configPath) => references.get(configPath) ?? []) + const cycle = findCycle(members, references) if (cycle !== undefined) { return yield* Effect.fail( @@ -103,13 +103,22 @@ export const tsc: Check = { ) } + const dependents = new Map() + + for (const [dependent, referencedConfigs] of references) { + for (const configPath of referencedConfigs) { + const known = dependents.get(configPath) ?? [] + + known.push(dependent) + dependents.set(configPath, known) + } + } + const affected = new Set(selected) for (const configPath of affected) { - for (const [dependent, referencedConfigs] of references) { - if (referencedConfigs.includes(configPath)) { - affected.add(dependent) - } + for (const dependent of dependents.get(configPath) ?? []) { + affected.add(dependent) } } @@ -118,7 +127,7 @@ export const tsc: Check = { return [ ...(roots.length > 0 ? [{ bin: typescript, args: ['-b', ...roots.map(shown)] }] : []), - // A check writes nothing: `-p` would emit JavaScript next to sources that set no `noEmit`. + // `-p` would emit JavaScript next to sources that set no `noEmit`. ...standalone.map((configPath) => ({ bin: typescript, args: ['-p', shown(configPath), '--noEmit'], @@ -130,41 +139,27 @@ export const tsc: Check = { function findCycle( nodes: ReadonlyArray, - edges: (node: string) => ReadonlyArray, + references: ReadonlyMap>, + trail: ReadonlyArray = [], + done = new Set(), ): ReadonlyArray | undefined { - const done = new Set() - const trail: string[] = [] - - function visitAll(starts: ReadonlyArray): ReadonlyArray | undefined { - for (const start of starts) { - const cycle = visit(start) - - if (cycle !== undefined) { - return cycle - } - } - - return undefined - } - - function visit(node: string): ReadonlyArray | undefined { + for (const node of nodes) { if (trail.includes(node)) { return trail.slice(trail.indexOf(node)) } - if (done.has(node)) { - return undefined - } + if (!done.has(node)) { + const cycle = findCycle(references.get(node) ?? [], references, [...trail, node], done) - trail.push(node) - const cycle = visitAll(edges(node)) - trail.pop() - done.add(node) + done.add(node) - return cycle + if (cycle !== undefined) { + return cycle + } + } } - return visitAll(nodes) + return undefined } const selectTsconfigs = Effect.fn(function* ( @@ -177,7 +172,7 @@ const selectTsconfigs = Effect.fn(function* ( { concurrency: 'unbounded' }, ) - const selected = yield* Effect.filter( + return yield* Effect.filter( candidates, (candidate) => Effect.map( @@ -188,8 +183,6 @@ const selectTsconfigs = Effect.fn(function* ( ), { concurrency: 'unbounded' }, ) - - return [...selected].sort() }) /** @@ -316,7 +309,9 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { const resolve = (specs: Specs | undefined): string[] => specs?.specs.map((spec) => - path.resolve(specs.dir, spec.replaceAll(CONFIG_DIR, leafDir)).replaceAll('\\', '/'), + path + .resolve(specs.dir, spec.replaceAll('\\', '/').replaceAll(CONFIG_DIR, leafDir)) + .replaceAll('\\', '/'), ) ?? [] const includeSpecs = diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index af5aebf..6adc291 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -5,8 +5,9 @@ import { Argument, Command, Prompt } from 'effect/unstable/cli' import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser' import { userError } from '../../errors' +import { readJson } from '../../files' import { git } from '../../git' -import { detectExec, invokes } from '../../pm' +import { detectExec, invokes, YARN_TOP_LEVEL } from '../../pm' import { bold, dim, green } from '../../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck' @@ -14,47 +15,38 @@ import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../un // if no hook ran. const TIMEOUT_SECONDS = 600 +const CLAUDE_FORMAT = { + event: 'Stop', + timeout: 'timeout', + entry: (command: string) => ({ type: 'command', command }), + content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), +} + const AGENTS = [ - { - id: 'claude', - name: 'Claude Code', - path: '.claude/settings.json', - entry: (command: string) => ({ type: 'command', command, timeout: TIMEOUT_SECONDS }), - content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), - }, - { - id: 'codebuddy', - name: 'CodeBuddy', - path: '.codebuddy/settings.json', - entry: (command: string) => ({ type: 'command', command, timeout: TIMEOUT_SECONDS }), - content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), - }, + { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', ...CLAUDE_FORMAT }, + { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', ...CLAUDE_FORMAT }, { id: 'cursor', name: 'Cursor', path: '.cursor/hooks.json', - entry: (command: string) => ({ command, timeout: TIMEOUT_SECONDS }), + event: 'stop', + timeout: 'timeout', + entry: (command: string) => ({ command }), content: (entry: object) => ({ version: 1, hooks: { stop: [entry] } }), }, { id: 'copilot', name: 'GitHub Copilot', path: '.github/hooks/uncheck.json', - entry: (command: string) => ({ - type: 'command', - bash: command, - powershell: command, - timeoutSec: TIMEOUT_SECONDS, - }), + event: 'agentStop', + timeout: 'timeoutSec', + entry: (command: string) => ({ type: 'command', bash: command, powershell: command }), content: (entry: object) => ({ version: 1, hooks: { agentStop: [entry] } }), }, ] as const const AGENT_IDS = AGENTS.map((agent) => agent.id) -// Must stay within what `invokes` accepts in a flag, or a reinstall adds a second hook. -const FLAG_VALUE = /^[\w./@+-]*$/ - export const install = Command.make( 'install', { @@ -79,10 +71,21 @@ export const install = Command.make( const dir = (yield* git(cwd, ['rev-parse', '--show-prefix']).pipe( Effect.orElseSucceed(() => ''), )).replace(/\/$/, '') + const exec = yield* detectExec(cwd) + const manifest = yield* readJson(path.join(cwd, 'package.json')) + const launcher = + dir === '' && + exec === 'yarn run --silent' && + /^yarn@(?!1\.)/.test(String(manifest?.packageManager)) + ? YARN_TOP_LEVEL + : exec + const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] + const command = `${launcher} ${HOOK_COMMAND} ${flags.join(' ')}` - if (!FLAG_VALUE.test(dir)) { + // A reinstall that cannot recognise the command would add a second hook next to it. + if (!invokes(command, HOOK_COMMAND)) { return yield* userError( - `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _./@+-`, + `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`, ) } @@ -104,9 +107,11 @@ export const install = Command.make( ) } - const exec = yield* detectExec(cwd) - const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] - const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` + if (dir !== '' && selected.includes('copilot')) { + return yield* userError( + `Copilot reads .github/hooks only at the top of the repository, not in ${dir}: install copilot from there`, + ) + } const updates = yield* Effect.forEach( AGENTS.filter((agent) => selected.includes(agent.id)), @@ -115,7 +120,7 @@ export const install = Command.make( const file = path.join(cwd, agent.path) const existing = yield* fs .readFileString(file) - .pipe(Effect.orElseSucceed(() => undefined)) + .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined))) const text = existing ?? '' const errors: ParseError[] = [] const current: unknown = parseJsonc(text, errors, { allowTrailingComma: true }) @@ -131,13 +136,23 @@ export const install = Command.make( } const base = Predicate.isObject(current) ? current : {} + const hooks = Predicate.isObject(base.hooks) ? base.hooks : {} const entry = agent.entry(command) + const timeout = { [agent.timeout]: TIMEOUT_SECONDS } const found: object[] = [] - const replaced = mapOwnEntries(base, (hook) => { + // Copilot takes `timeout` as another name for `timeoutSec`, so either is one the user chose. + const replaced = mapOwnEntries(hooks[agent.event], (hook) => { found.push(hook) - return { ...hook, ...entry } + return { + ...hook, + ...entry, + ...('timeout' in hook || 'timeoutSec' in hook ? {} : timeout), + } }) - const next = found.length > 0 ? replaced : mergeJson(base, agent.content(entry)) + const next = + found.length > 0 + ? { ...base, hooks: { ...hooks, [agent.event]: replaced } } + : mergeJson(base, agent.content({ ...entry, ...timeout })) const result = existing === undefined ? 'created' diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index a93e961..9717dde 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -1,7 +1,7 @@ import process from 'node:process' import { stripVTControlCharacters } from 'node:util' -import { Console, Effect, Option, Path, Predicate, Stdio, Stream } from 'effect' +import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from 'effect' import { Command, Flag } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' @@ -29,6 +29,7 @@ export const run = Command.make( ...selectionFlags, }, Effect.fn(function* ({ cwd: given, dir, ...settings }) { + const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const stdio = yield* Stdio.Stdio @@ -42,6 +43,12 @@ export const run = Command.make( Effect.orElseSucceed((): Record => ({})), ) + // Cursor also stops a turn the user interrupted, or one that failed; fixing it would edit half-done + // work and a follow-up would restart the agent. + if (payload.status === 'aborted' || payload.status === 'error') { + return + } + const start = Option.getOrElse(given, () => process.cwd()) const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe( Effect.orElseSucceed(() => undefined), @@ -50,6 +57,13 @@ export const run = Command.make( ? path.join(top ?? start, dir.value) : Option.getOrElse(given, () => top ?? start) + // Checking a folder that is gone would send the agent back to fix a configuration it cannot see. + if (Option.isSome(dir) && !(yield* fs.exists(cwd))) { + return yield* userError( + `--dir=${dir.value} names nothing in ${top ?? start}, run \`uncheck hooks install\` again from the project`, + ) + } + const changed = yield* listChangedFiles(cwd) if (changed?.length === 0) { diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index ab6b00b..3fc7bf8 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -25,6 +25,13 @@ const OLD_ENTERS = /^cd "([^"]*)" && (.*)$/ /** Git runs a hook through its shebang, where a line of `sh` would be a syntax error. */ const OTHER_INTERPRETER = /^#!(?!.*\b(?:ba|da|k|z|a)?sh\b)/ +/** Invalid UTF-8 reads back as U+FFFD, so writing such a hook back would replace those bytes. */ +const NOT_TEXT = /[\0\uFFFD]/ + +/** The comments and environment a hook sets up, such as its PATH, which the added line needs too. */ +const SETUP = + /^\s*(?:#|$|\\?\.\s|(?:source|export|set|unset)\s|[A-Za-z_]\w*=\S*\s*$|.*(?:&&|;)\s*(?:\\?\.|source)\s)/ + /** * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends * the hook line. @@ -161,8 +168,20 @@ export const prepare = Command.make( const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit') const relative = path.relative(cwd, file) const shown = relative.startsWith('..') ? file : relative - const shared = yield* git(cwd, ['config', '--show-scope', '--get', 'core.hooksPath']).pipe( + const hooksPath = (option: string) => git(cwd, ['config', option, '--get', 'core.hooksPath']) + const shared = yield* hooksPath('--show-scope').pipe( Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]), + // Git before 2.26 has no --show-scope, which must not pass for an unset core.hooksPath. + Effect.catchIf( + (error) => error._tag === 'GitFailed' && error.exitCode === 129, + () => + Effect.findFirst(['global', 'system'], (scope) => + hooksPath(`--${scope}`).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + ).pipe(Effect.map(Option.getOrUndefined)), + ), Effect.orElseSucceed(() => undefined), ) @@ -193,9 +212,12 @@ export const prepare = Command.make( Effect.gen(function* () { const existing = yield* fs .readFileString(target) - .pipe(Effect.orElseSucceed(() => undefined)) + .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined))) - if (existing !== undefined && OTHER_INTERPRETER.test(existing)) { + if ( + existing !== undefined && + (OTHER_INTERPRETER.test(existing) || NOT_TEXT.test(existing)) + ) { return yield* Effect.fail(`it is not a shell script, have it run \`${line}\` yourself`) } @@ -287,13 +309,13 @@ function rewrite(hook: string, line: string, inside: string): string { } // Added after the commands of the hook, the line would set its exit status in their place, and - // would never run after an `exec` or `exit`. Before a sourced file it would run twice with husky 8, - // whose `_/husky.sh` runs the hook again. + // would never run after an `exec` or `exit`. Before the hook's setup it would miss the PATH a GUI + // client lacks, and run twice with husky 8, whose sourced `_/husky.sh` runs the hook again. const after = lines.reduce( (last, text, index) => (ownLine(text) === undefined ? last : index + 1), 0, ) - const at = after > 0 ? after : lines.findIndex((text) => !/^\s*(?:#|\.\s|$)/.test(text)) + const at = after > 0 ? after : lines.findIndex((text) => !SETUP.test(text)) if (at === -1) { const kept = lines.join('\n') diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index e1b3076..d1d35fd 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -11,13 +11,17 @@ import { argvBatches } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' /** What became of the unstaged hunks that were set aside while the checks ran. */ -type Unstaged = 'restored' | 'conflicted' | 'stranded' +type Unstaged = 'restored' | 'stranded' | { readonly conflicted: ReadonlyArray } export const staged = Command.make( 'staged', { cwd: cwdFlag, - fix: fixFlag, + fix: fixFlag.pipe( + Flag.withDescription( + 'Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) in the staged files, then stage them. sherif only reports here: run `uncheck --fix` for its fixes', + ), + ), allowEmpty: Flag.Boolean('allow-empty').pipe( Flag.withDefault(false), Flag.withDescription( @@ -47,7 +51,12 @@ export const staged = Command.make( yield* Console.log(dim(`uncheck staged in ${cwd}`)) if (files.length === 0) { - return yield* Console.log(`${dim('○')} nothing to check, no staged files`) + const reason = + listed.length > 0 + ? 'every staged file comes from the branch being merged in' + : 'no staged files' + + return yield* Console.log(`${dim('○')} nothing to check, ${reason}`) } const [prefix = '', folder = ''] = (yield* git(cwd, [ @@ -101,7 +110,8 @@ export const staged = Command.make( const stage = (env?: Readonly>) => Effect.forEach( argvBatches(changed), - (batch) => git(cwd, ['add', '--', ...batch], env), + // `git add` refuses a path outside a sparse checkout even when it is on disk. + (batch) => git(cwd, ['update-index', '--', ...batch], env), { discard: true }, ) @@ -131,9 +141,9 @@ export const staged = Command.make( const unstagedOutcome = yield* Ref.get(outcome) - if (unstagedOutcome === 'conflicted') { + if (typeof unstagedOutcome === 'object') { return yield* userError( - `The fixes conflict with the unstaged changes of ${listFiles(partial)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`, + `The fixes conflict with the unstaged changes of ${listFiles(unstagedOutcome.conflicted)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`, ) } @@ -231,6 +241,10 @@ const partiallyStaged = Effect.fn(function* (cwd: string, files: ReadonlyArray // blob is no merge base: git leaves a CRLF blob alone where hash-object normalizes it. const ids = yield* Effect.forEach(argvBatches(files), (batch) => git(cwd, ['checkout-index', '-f', '--', ...batch]).pipe( - Effect.andThen(git(cwd, ['hash-object', '-w', '--', ...batch])), + Effect.andThen(git(cwd, [...STORE, '--', ...batch])), ), ).pipe( Effect.tapError(() => @@ -304,8 +318,9 @@ const putBack = Effect.fn(function* ( const merged = yield* Effect.forEach(partial, (file, index) => merge(aside, file, copies[index]![1], bases.get(file)!), ) + const conflicted = partial.filter((_, index) => !merged[index]) - if (merged.every(Boolean)) { + if (conflicted.length === 0) { yield* Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)) return 'restored' as const } @@ -323,7 +338,7 @@ const putBack = Effect.fn(function* ( ) yield* Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }) - return 'conflicted' as const + return { conflicted } }).pipe( Effect.catch((error) => { const reason = @@ -353,7 +368,7 @@ const merge = Effect.fn(function* ( const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const target = path.join(cwd, file) - const store = (from: string) => git(cwd, ['hash-object', '-w', `--path=${file}`, '--', from]) + const store = (from: string) => git(cwd, [...STORE, `--path=${file}`, '--', from]) const checked = yield* store(target) if (checked === base) { @@ -361,6 +376,12 @@ const merge = Effect.fn(function* ( return true } + // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it + // back through the filters would turn every line ending of the file to LF. + if ((yield* git(cwd, ['rev-parse', `:0:${prefix}${file}`])) !== checked) { + return false + } + const temp = yield* fs.makeTempDirectory() const result = path.join(temp, 'result') const original = path.join(temp, 'base') diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index c85bf81..f042d0c 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -132,7 +132,8 @@ export const checkPaths = Effect.fn(function* ( settings: RunSettings, ) { const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings - const staged = settings.staged ?? false + const appliesFixes = (fixes: Check['fixes']) => + settings.staged === true ? fixes === 'files' : fixes !== false const cwd = path.resolve(settings.cwd) const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) @@ -169,12 +170,7 @@ export const checkPaths = Effect.fn(function* ( return Effect.succeed({ name, status: 'skipped', reason: exclusion }) } - return plan({ - cwd, - fix: fix && !(staged && fixes === 'workspace'), - files, - projectFiles, - }).pipe( + return plan({ cwd, fix: fix && appliesFixes(fixes), files, projectFiles }).pipe( Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), Effect.catchTag('NothingToCheck', ({ reason }) => Effect.succeed({ @@ -201,7 +197,6 @@ export const checkPaths = Effect.fn(function* ( if (ran.length === 0) { const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ') - // Files a run was given, rather than asked for, may simply be ones no check handles. if (files !== undefined && (allowUnmatched || literal)) { return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`) } @@ -216,11 +211,11 @@ export const checkPaths = Effect.fn(function* ( ) const fixable = failed - .filter((outcome) => { - const fixes = CHECKS.find((check) => check.name === outcome.name)?.fixes - - return outcome.reason === undefined && (staged ? fixes === 'files' : fixes !== false) - }) + .filter( + (outcome) => + outcome.reason === undefined && + appliesFixes(CHECKS.find((check) => check.name === outcome.name)!.fixes), + ) .map((outcome) => outcome.name) .join(', ') .replace(/, ([^,]+)$/, ' and $1') @@ -267,7 +262,6 @@ const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { /** Each process of a type checker can take hundreds of megabytes, so only a few run at once. */ const MAX_PARALLEL = Math.min(4, availableParallelism()) -/** `parallel` commands start once the others are done, and their output is held back to keep its order. */ const runCommands = Effect.fn(function* (commands: ReadonlyArray, cwd: string) { const parallel = commands.filter((command) => command.parallel === true) @@ -300,7 +294,7 @@ function runCommand(command: CheckCommand, cwd: string) { return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim(shown.join(' '))}`.trimEnd()).pipe( Effect.andThen(execute(command, cwd)), - // A tool that cannot start, or is killed (say by the OOM killer), fails its check, not the run. + // A tool killed by a signal (say by the OOM killer) fails `exitCode` with a PlatformError, not a code. Effect.catchTag('PlatformError', (error) => Console.log(red(error.cause instanceof Error ? error.cause.message : error.message)).pipe( Effect.as(1), diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 3ea8c2f..f9a23d7 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -61,7 +61,6 @@ export const resolvePaths = Effect.fn(function* ( const unmatched: string[] = [] let universe: ReadonlyArray | undefined - // Exclusions on their own exclude from everything, like a run without paths. for (const pattern of includes.length > 0 ? includes : ['.']) { const target = relative(pattern) @@ -124,13 +123,18 @@ const GLOB_CHARACTERS = /[*?[\]{}()]/ /** Dot files match too, as they do for oxfmt and for a directory given as it is. */ function glob(pattern: string): (file: string) => boolean { - const matches = picomatch(pattern, { dot: true }) + // Without `posix`, picomatch reads `[!a]` as "! or a", and a bare `(…)` is a regex group, so + // `app/(marketing)/**` would match `app/marketing`. + const matches = picomatch(pattern.replace(/(? matches(file) } -/** The given files that exist, taken literally: file names from git are never patterns. */ export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -184,7 +188,6 @@ const walk = Effect.fn(function* (cwd: string) { const found: string[] = [] const visit = Effect.fn(function* (dir: string): Effect.fn.Return { - // Like git, a folder that cannot be read is left out rather than ending the run. const names = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => [])) yield* Effect.forEach(names, (name) => visitEntry(dir, name), { diff --git a/packages/uncheck/src/pm.ts b/packages/uncheck/src/pm.ts index 5a9ce10..63d85ce 100644 --- a/packages/uncheck/src/pm.ts +++ b/packages/uncheck/src/pm.ts @@ -5,30 +5,31 @@ import { ancestors, readJson } from './files' // Hooks never get a terminal to ask in, so plain `npx` and `bunx` would download and run the latest // release whenever the project has none, and Yarn 1 wraps a run in lines of its own on stdout, where // agents expect nothing but their JSON. -const EXEC_BY_PACKAGE_MANAGER: Readonly> = { - pnpm: 'pnpm exec', - yarn: 'yarn run --silent', - bun: 'bunx --no-install', - npm: 'npx --no', -} +const EXEC_BY_PACKAGE_MANAGER: ReadonlyMap = new Map([ + ['pnpm', 'pnpm exec'], + ['yarn', 'yarn run --silent'], + ['bun', 'bunx --no-install'], + ['npm', 'npx --no'], +]) -const EXEC_BY_LOCKFILE: ReadonlyArray = [ - ['pnpm-lock.yaml', 'pnpm exec'], - ['yarn.lock', 'yarn run --silent'], - ['bun.lock', 'bunx --no-install'], - ['bun.lockb', 'bunx --no-install'], - ['package-lock.json', 'npx --no'], -] +/** Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's. */ +export const YARN_TOP_LEVEL = 'yarn run -T --silent' + +const PACKAGE_MANAGER_BY_LOCKFILE = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['package-lock.json', 'npm'], +] as const -/** Every prefix `detectExec` returns or once returned, so a generated command line can be recognised again. */ +/** Every prefix uncheck writes or once wrote, so a generated command line can be recognised again. */ const EXECS: ReadonlyArray = [ - ...new Set([ - ...Object.values(EXEC_BY_PACKAGE_MANAGER), - ...EXEC_BY_LOCKFILE.map(([, exec]) => exec), - 'yarn', - 'bunx', - 'npx', - ]), + ...EXEC_BY_PACKAGE_MANAGER.values(), + YARN_TOP_LEVEL, + 'yarn', + 'bunx', + 'npx', ] const FLAGS = /^(?: --[\w=./@+-]+)*$/ @@ -56,21 +57,21 @@ export const detectExec = Effect.fn(function* (cwd: string) { const manifest = yield* readJson(path.join(dir, 'package.json')) const declared = typeof manifest?.packageManager === 'string' - ? EXEC_BY_PACKAGE_MANAGER[manifest.packageManager.split('@')[0] ?? ''] + ? EXEC_BY_PACKAGE_MANAGER.get(manifest.packageManager.split('@')[0]!) : undefined if (declared !== undefined) { return declared } - const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) => + const lockfile = yield* Effect.findFirst(PACKAGE_MANAGER_BY_LOCKFILE, ([file]) => fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), ) if (Option.isSome(lockfile)) { - return lockfile.value[1] + return EXEC_BY_PACKAGE_MANAGER.get(lockfile.value[1])! } } - return 'npx --no' + return EXEC_BY_PACKAGE_MANAGER.get('npm')! }) diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index bda0ab6..173320e 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -15,29 +15,24 @@ export interface Bin { /** * Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest - * `node_modules/`. Reading its manifest directly (instead of `require.resolve`) keeps this - * independent from the package's `exports` map. + * `node_modules/`, whose manifest is read directly so its `exports` map does not matter, or, + * under Yarn PnP, wherever its resolver finds `/package.json`. */ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName: string = pkg) { const path = yield* Path.Path - // Yarn PnP installs have no node_modules, only the resolver it loads into processes it starts. - const resolved = - process.versions.pnp === undefined - ? undefined - : yield* Effect.try(() => - createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`), - ).pipe(Effect.orElseSucceed(() => undefined)) + for (const dir of ancestors(path, cwd)) { + // Yarn PnP installs have no node_modules, only the resolver it loads into processes it starts. + const manifestPath = + process.versions.pnp === undefined + ? path.join(dir, 'node_modules', pkg, 'package.json') + : yield* Effect.try(() => + createRequire(path.join(dir, 'package.json')).resolve(`${pkg}/package.json`), + ).pipe(Effect.orElseSucceed(() => undefined)) - const pkgDirs = - resolved === undefined - ? ancestors(path, cwd).map((dir) => path.join(dir, 'node_modules', pkg)) - : [path.dirname(resolved)] + const manifest = manifestPath === undefined ? undefined : yield* readJson(manifestPath) - for (const pkgDir of pkgDirs) { - const manifest = yield* readJson(path.join(pkgDir, 'package.json')) - - if (manifest === undefined) { + if (manifestPath === undefined || manifest === undefined) { continue } @@ -48,7 +43,9 @@ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName ? manifest.bin[binName] : undefined - return typeof bin === 'string' ? { name: binName, entry: path.resolve(pkgDir, bin) } : undefined + return typeof bin === 'string' + ? { name: binName, entry: path.resolve(path.dirname(manifestPath), bin) } + : undefined } return undefined From 0965dc10da56de47a1d7bb518ac9033257809789 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 12:52:24 +0700 Subject: [PATCH 11/19] test(uncheck): cover the final review fixes and correct the docs they touch --- packages/uncheck/README.md | 10 +- packages/uncheck/tests/command.test.ts | 170 ++++++++++++++++++++++++- packages/uncheck/tests/files.test.ts | 22 ++++ packages/uncheck/tests/tsc.test.ts | 16 ++- 4 files changed, 206 insertions(+), 12 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f8ede91..018c282 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -59,7 +59,7 @@ Tools are resolved from `node_modules` the way Node does, or through Yarn's PnP Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively, whatever the referenced configs are named, to build the project graph: - Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package. -- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p --noEmit`, up to four at a time, so they never write build output. +- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p --noEmit`, up to four at a time, so they emit no JavaScript or declarations (an `incremental` or `composite` project still writes its `.tsbuildinfo`). - Circular references anywhere in the graph are reported as an error. When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Configs that are only referenced count too, such as `tsconfig.app.json` in a Vite, Nx or Angular solution layout, and a changed tsconfig selects every project it applies to through `extends`, also from a config package linked into `node_modules`. A selected project in the reference graph is built with `tsc -b` through the roots that depend on it, so the projects using a changed library are checked again; packages that import each other from source without `references` are not followed. Files `tsc` never checks, such as Markdown or CSS, select no project. @@ -112,13 +112,13 @@ npx uncheck hooks install claude codebuddy # or name them: claude, codebuddy, c npx uncheck hooks install claude --only=oxlint --only=oxfmt # a fast hook: lint and format, no typecheck ``` -This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Running it again updates only uncheck's own entry, keeping keys you added to it, while permission rules and other commands that merely mention `uncheck hooks run` stay as they are; a file that is not valid JSON is reported and left alone. Each entry gives the hook 600 seconds, since the 30 and 60 second defaults of Copilot and CodeBuddy would cut a typecheck short. Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so install `cursor` or `copilot` next to `claude` only where they do not read it, or uncheck runs twice per turn. Whenever the agent finishes a turn, the hook runs: +This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Running it again updates only uncheck's own entry, keeping keys you added to it, while permission rules and other commands that merely mention `uncheck hooks run` stay as they are; a file that is not valid JSON is reported and left alone. Each new entry gives the hook 600 seconds, since the 30 and 60 second defaults of Copilot and CodeBuddy would cut a typecheck short; a timeout you set is kept. Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so install `cursor` or `copilot` next to `claude` only where they do not read it, or uncheck runs twice per turn. Whenever the agent finishes a turn, the hook runs: ```sh uncheck hooks run --fix ``` -through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`, detected from the lockfile, so a missing install fails instead of downloading uncheck). Installed below the top of the repository, the command also carries `--dir=`, so the hook checks the same place whichever directory the agent last `cd`'d into. It runs every check on the files changed since the last commit (modified, staged and untracked; everything under the current directory outside git), applies fixes, and prints the report on stderr. A change no selected check covers, such as a README edit with `--only=tsc`, passes. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop; when problems remain after it, Claude Code and CodeBuddy show you that uncheck still fails. +through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`, detected from the lockfile, so a missing install fails instead of downloading uncheck). Installed below the top of the repository, the command also carries `--dir=`, so the hook checks the same place whichever directory the agent last `cd`'d into; Copilot reads `.github/hooks` only at the top, so `copilot` is installed from there. In a Yarn 2+ workspace a root install runs the root's uncheck with `yarn run -T`. It runs every check on the files changed since the last commit (modified, staged and untracked; everything under the current directory outside git), applies fixes, and prints the report on stderr. A change no selected check covers, such as a README edit with `--only=tsc`, passes. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop; when problems remain after it, Claude Code and CodeBuddy show you that uncheck still fails. A Cursor turn you stopped, or one that ended in an error, is left alone. Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. When even that is too slow for a project, leave the typecheck to CI: `--only`, `--skip` and `--require` given to `install` are written into the hook command as they are, and reinstalling with other flags updates it, so `install claude --only=oxlint --only=oxfmt` gives a hook that only lints and formats. @@ -130,9 +130,9 @@ npx uncheck staged --fix # also apply the fixes and stage them npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit runs `uncheck staged --fix` ``` -`staged` runs the checks on the files staged for commit, regular files only: a staged symlink is skipped, since the tools would follow it to a file the commit does not hold. During a merge only the staged files that differ from the branch being merged in are checked, so the merge commit never carries fixes to the other side's work. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too, and only the files they changed. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. Ctrl-C or a closed terminal still puts them back; a run killed outright before it does leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed. A commit no check has anything to do with, docs only for example, passes; add `--require=` to make it fail instead. +`staged` runs the checks on the files staged for commit, regular files only: a staged symlink is skipped, since the tools would follow it to a file the commit does not hold. During a merge only the staged files that differ from the branch being merged in are checked, so the merge commit never carries fixes to the other side's work. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too, and only the files they changed. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. Ctrl-C or a closed terminal still puts them back; a run killed outright before it does leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed. A commit no selected check covers, such as a README edit with `--only=tsc`, passes; add `--require=tsc` to make it fail instead. -`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it, before the hook's own commands (after its shebang, comments and sourced files), so their failure still blocks the commit and an `exec` or `exit` cannot skip it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags, also when a workspace install runs them all at once. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook: +`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it, before the hook's own commands (after its shebang, comments and the lines that set up its environment: sourced files, `export`, `set` and variable assignments), so their failure still blocks the commit and an `exec` or `exit` cannot skip it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags, also when a workspace install runs them all at once. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook: ```json { diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index eb77c1c..9b42732 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -1,3 +1,4 @@ +import { Buffer } from 'node:buffer' import { execFileSync, spawn, spawnSync } from 'node:child_process' import { once } from 'node:events' import { @@ -726,6 +727,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { type: 'command', command: 'cd packages/web && npx uncheck hooks run --fix && notify', }, + { type: 'command', command: 'npx uncheck hooks run --fix --only=oxfmt' }, ], }, ], @@ -771,6 +773,35 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { const again = await run(dir, ['hooks', 'install', 'claude']) expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') + + writeFileSync( + join(dir, '.claude/settings.json'), + JSON.stringify({ hooks: { Stop: [{ hooks: [{ ...hook, timeout: 1800 }] }] } }), + ) + mkdirSync(join(dir, '.github/hooks'), { recursive: true }) + writeFileSync( + join(dir, '.github/hooks/uncheck.json'), + JSON.stringify({ hooks: { agentStop: [{ bash: hook.command, timeout: 1800 }] } }), + ) + + const raised = await run(dir, ['hooks', 'install', 'claude', 'copilot']) + + expect(raised.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') + expect( + JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).hooks.agentStop, + ).toEqual([{ type: 'command', bash: hook.command, powershell: hook.command, timeout: 1800 }]) + }) + + it('runs the root binary of a Yarn 2+ workspace from wherever the agent moved to', async () => { + const dir = fixture({ 'package.json': { packageManager: 'yarn@4.18.0' }, 'yarn.lock': '' }, []) + + const { stdout } = await run(dir, ['hooks', 'install', 'claude']) + + expect(stdout).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix ') + + const again = await run(dir, ['hooks', 'install', 'claude']) + + expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') }) it('refuses a config that is not a JSON object and leaves every file as it is', async () => { @@ -820,6 +851,9 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { await expect(run(join(dir, 'packages/my web'), ['hooks', 'install', 'claude'])).rejects.toThrow( /cannot name packages\/my web/, ) + await expect(run(join(dir, 'packages/web'), ['hooks', 'install', 'copilot'])).rejects.toThrow( + /Copilot reads \.github\/hooks only at the top of the repository/, + ) }) }) @@ -1009,6 +1043,39 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { ) }) + it('reports a --dir that names nothing instead of sending the agent back', async () => { + const dir = committed(clean) + writeFileSync(join(dir, 'src/index.ts'), 'export const answer = 1\n') + + await expect( + run( + dir, + ['hooks', 'run', '--fix', '--dir=packages/gone'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + ), + ).rejects.toThrow(/--dir=packages\/gone names nothing/) + }) + + it('leaves a Cursor turn the user stopped, or that failed, alone', async () => { + const dir = committed(clean) + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') + + for (const status of ['aborted', 'error']) { + const { result, stdout } = await run( + dir, + ['hooks', 'run', '--fix'], + JSON.stringify({ hook_event_name: 'stop', status, loop_count: 0 }), + ) + + expect(result).toBe('ok') + expect(stdout).toBe('') + } + + expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( + 'export const answer: string = 1\n', + ) + }) + it('stays silent when nothing changed and passes quietly when the changes are clean', async () => { const dir = committed(clean) @@ -1108,7 +1175,8 @@ describe('uncheck staged', { timeout: 120_000 }, () => { }) it('undoes the fixes when they conflict with unstaged changes, so nothing is lost', async () => { - const dir = committed(clean) + const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) + const dir = committed({ ...clean, 'src/far.ts': far.join('') }) writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') gitIn(dir, 'add', 'src/index.ts') @@ -1116,6 +1184,12 @@ describe('uncheck staged', { timeout: 120_000 }, () => { writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') gitIn(dir, 'add', 'src/other.ts') + writeFileSync(join(dir, 'src/far.ts'), ['export const f0 = 0;\n', ...far.slice(1)].join('')) + gitIn(dir, 'add', 'src/far.ts') + writeFileSync( + join(dir, 'src/far.ts'), + ['export const f0 = 0;\n', ...far.slice(1, -1), 'export const f29 = 30;\n'].join(''), + ) await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, @@ -1485,6 +1559,11 @@ describe('uncheck staged', { timeout: 120_000 }, () => { gitIn(dir, 'commit', '--quiet', '-m', 'side') gitIn(dir, 'checkout', '--quiet', '-') gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') + + expect((await run(dir, ['staged', '--fix', '--only=oxfmt'])).stdout).toContain( + '○ nothing to check, every staged file comes from the branch being merged in\n', + ) + writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4\n') gitIn(dir, 'add', 'src/other.ts') @@ -1525,6 +1604,47 @@ describe('uncheck staged', { timeout: 120_000 }, () => { expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 43;\n') }) + it('stages fixes to a conflict a merge left outside a sparse checkout', async () => { + const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) + + gitIn(dir, 'checkout', '--quiet', '-b', 'side') + writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 2;\n') + gitIn(dir, 'commit', '--quiet', '-am', 'side') + gitIn(dir, 'checkout', '--quiet', '-') + writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 3;\n') + gitIn(dir, 'commit', '--quiet', '-am', 'main') + gitIn(dir, 'sparse-checkout', 'set', 'src') + spawnSync('git', ['-c', 'user.name=u', '-c', 'user.email=u@e', 'merge', '--quiet', 'side'], { + cwd: dir, + }) + writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 4\n') + gitIn(dir, 'add', '--sparse', 'lib/lib.ts') + + const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 4;\n') + }) + + it('undoes fixes to a file git keeps with CRLF line endings rather than turn them to LF', async () => { + const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' + const dir = committed({ ...clean, '.oxlintrc.json': oxlintrc, 'src/legacy.ts': lines }) + const file = join(dir, 'src/legacy.ts') + const unstaged = `var c = 3;\r\n${lines}export { c };\r\nexport const d = 4;\r\n` + + writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') + gitIn(dir, 'add', '.gitattributes') + gitIn(dir, 'commit', '--quiet', '-m', 'attributes') + writeFileSync(file, `var c = 3;\r\n${lines}export { c };\r\n`) + gitIn(dir, 'add', 'src/legacy.ts') + writeFileSync(file, unstaged) + + await expect(run(dir, ['staged', '--fix', '--only=oxlint'])).rejects.toThrow( + /fixes conflict with the unstaged changes of src\/legacy\.ts and were undone/, + ) + expect(readFileSync(file, 'utf8')).toBe(unstaged) + }) + it('sees no change in a file git keeps with CRLF line endings under text=auto', async () => { const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' const dir = committed({ ...clean, 'src/legacy.ts': lines }) @@ -1533,6 +1653,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') gitIn(dir, 'add', '.gitattributes') gitIn(dir, 'commit', '--quiet', '-m', 'attributes') + gitIn(dir, 'config', 'core.safecrlf', 'true') writeFileSync(file, `${lines}export const c = 3;\r\n`) gitIn(dir, 'add', 'src/legacy.ts') writeFileSync(file, `${lines}export const c = 3;\r\nexport const d = 4;\r\n`) @@ -2164,12 +2285,14 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { const log = join(bin, 'log') chmodSync(join(bin, 'pnpm'), 0o755) writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`) - writeFileSync(hook, '#!/bin/sh\n# lint\n. "$(dirname "$0")/env"\nexec pnpm lint-staged\n') + const setup = + '#!/bin/sh\n# lint\nexport HOOKS="$(dirname "$0")"\n[ -s "$HOOKS/env" ] && \\. "$HOOKS/env"\n' + writeFileSync(hook, `${setup}exec pnpm lint-staged\n`) await run(dir, ['prepare', '--pre-commit']) expect(readFileSync(hook, 'utf8')).toBe( - '#!/bin/sh\n# lint\n. "$(dirname "$0")/env"\npnpm exec uncheck staged --fix || exit 1\nexec pnpm lint-staged\n', + `${setup}pnpm exec uncheck staged --fix || exit 1\nexec pnpm lint-staged\n`, ) const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { @@ -2196,6 +2319,23 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { '✘ pre-commit .git/hooks/pre-commit not written, it is not a shell script, have it run `pnpm exec uncheck staged --fix || exit 1` yourself\n', ) expect(readFileSync(hook, 'utf8')).toBe(script) + + const binary = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 0xff]) + writeFileSync(hook, binary) + + expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain('not a shell script') + expect(readFileSync(hook)).toEqual(binary) + + if (process.platform !== 'win32' && process.getuid?.() !== 0) { + writeFileSync(hook, script) + chmodSync(hook, 0) + + expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain( + '✘ pre-commit .git/hooks/pre-commit not written, EACCES', + ) + chmodSync(hook, 0o755) + expect(readFileSync(hook, 'utf8')).toBe(script) + } }) it('writes nothing for a package whose folder name sh would expand', async () => { @@ -2390,6 +2530,30 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { expect(local.stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') }) + + it.skipIf(process.platform === 'win32')( + 'leaves a global core.hooksPath alone with a git too old to say where a setting comes from', + async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + gitIn(dir, 'init', '--quiet') + const shared = fixture({ 'pre-commit': '#!/bin/sh\n' }, []) + gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared) + const git = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() + const bin = fixture( + { + git: `#!/bin/sh\ncase "$*" in *--show-scope*) echo "error: unknown option" >&2; exit 129;; esac\nexec "${git}" "$@"\n`, + }, + [], + ) + chmodSync(join(bin, 'git'), 0o755) + vi.stubEnv('PATH', `${bin}${delimiter}${process.env.PATH}`) + + const { stdout } = await run(dir, ['prepare', '--pre-commit']) + + expect(stdout).toContain('not written, core.hooksPath is set in the global git config') + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe('#!/bin/sh\n') + }, + ) }) describe('uncheck presets', { timeout: 120_000 }, () => { diff --git a/packages/uncheck/tests/files.test.ts b/packages/uncheck/tests/files.test.ts index 838d36c..96e01bc 100644 --- a/packages/uncheck/tests/files.test.ts +++ b/packages/uncheck/tests/files.test.ts @@ -139,6 +139,28 @@ describe('resolvePaths', () => { expect((await resolve(dir, ['.', '!**/*.json'])).files).not.toContain('.vscode/settings.json') }) + it('reads [!x] as any character but x and parentheses literally, as in route groups', async () => { + const dir = fixture( + { ...project, 'app/(marketing)/page.ts': '', 'app/marketing/page.ts': '' }, + [], + ) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + + expect(await resolve(dir, ['src/[!a]*.ts'])).toEqual({ files: ['src/b.ts'], unmatched: [] }) + expect(await resolve(dir, ['src', '!src/[!a]*.ts'])).toEqual({ + files: ['src/a.ts', 'src/sub/c.ts'], + unmatched: [], + }) + expect(await resolve(dir, ['app/(marketing)/**'])).toEqual({ + files: ['app/(marketing)/page.ts'], + unmatched: [], + }) + expect((await resolve(dir, ['app', '!app/(marketing)/**'])).files).toEqual([ + 'app/[id].ts', + 'app/marketing/page.ts', + ]) + }) + it('excludes from everything when only exclusions are given', async () => { const dir = fixture(project, []) execFileSync('git', ['init', '--quiet'], { cwd: dir }) diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts index bf797b1..f062178 100644 --- a/packages/uncheck/tests/tsc.test.ts +++ b/packages/uncheck/tests/tsc.test.ts @@ -91,9 +91,11 @@ describe('tsc project references', () => { 'c/tsconfig.json': {}, }) - expect(await plan(dir)).toBe( - 'circular project references between a/tsconfig.json, b/tsconfig.json', - ) + const cycle = 'circular project references between a/tsconfig.json, b/tsconfig.json' + + expect(await plan(dir)).toBe(cycle) + expect(await plan(dir, ['a/src/index.ts'])).toBe(cycle) + expect(await plan(dir, ['c/src/index.ts'])).toBe(cycle) }) it('reports only the cyclic part when a root also exists', async () => { @@ -118,7 +120,7 @@ describe('tsc project references', () => { expect(await plan(dir)).toEqual(['-b app/tsconfig.json', '-p lib/tsconfig.json --noEmit']) }) - it('reads backslashes in extends and references as separators, like tsc', async () => { + it('reads backslashes in tsconfig paths as separators, like tsc', async () => { const dir = fixture({ 'tsconfig.base.json': { compilerOptions: { allowJs: true }, include: [`${CONFIG_DIR}/src`] }, 'tsconfig.json': { files: [], references: [{ path: '.\\packages\\b' }] }, @@ -132,6 +134,12 @@ describe('tsc project references', () => { expect(await plan(dir)).toEqual(['-b tsconfig.json']) expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-b tsconfig.json']) expect(await plan(dir, ['packages/a/scripts/build.ts'])).toBe(NOT_COVERED) + + const project = fixture({ 'a/tsconfig.json': { include: ['.\\src', '..\\shared'] } }) + + expect(await plan(project, ['a/src/x.ts', 'shared/y.ts'])).toEqual([ + '-p a/tsconfig.json --noEmit', + ]) }) }) From 98582cfb0818bb977ffdd0e49dc59fcc9e8b0016 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 13:28:05 +0700 Subject: [PATCH 12/19] refactor(uncheck): simplify and speed up the branch's code - globs use minimatch with dot matching, the engine path.matchesGlob already used, instead of patching picomatch back to its semantics - shared helpers for the rev-parse location, reading a file that may not exist, platform error messages and batched git calls; the Yarn 2+ -T rule lives in detectExec - staged runs its independent git calls together, writes a tree only when it sets changes aside, and merges files concurrently; hooks run lists changed files concurrently - existence and node_modules filters no longer cost a fiber or an array per file - tsc resolves real paths only when a config is among the given files - tests share the Stop payload, the CLI path and the side-branch setup --- packages/uncheck/package.json | 3 +- packages/uncheck/src/checks/tsc.ts | 60 +++--- .../uncheck/src/commands/hooks/install.ts | 74 +++---- packages/uncheck/src/commands/hooks/run.ts | 8 +- packages/uncheck/src/commands/prepare.ts | 37 ++-- packages/uncheck/src/commands/staged.ts | 181 ++++++++++-------- packages/uncheck/src/commands/uncheck.ts | 8 +- packages/uncheck/src/errors.ts | 5 + packages/uncheck/src/files.ts | 46 +++-- packages/uncheck/src/git.ts | 23 +++ packages/uncheck/src/pm.ts | 19 +- packages/uncheck/tests/command.test.ts | 85 ++++---- pnpm-lock.yaml | 36 ++-- 13 files changed, 315 insertions(+), 270 deletions(-) diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index 337f53f..4614051 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -53,12 +53,11 @@ }, "devDependencies": { "@effect/platform-node": "^4.0.0-rc.116", - "@types/picomatch": "^4.0.3", "effect": "^4.0.0-rc.116", "jsonc-parser": "^3.3.1", + "minimatch": "^10.2.6", "oxfmt": "^0.68.0", "oxlint": "^1.83.0", - "picomatch": "^4.0.7", "sherif": "^1.13.0", "typescript": "^7.0.2" }, diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index dfe1c10..d5f552d 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -175,11 +175,15 @@ const selectTsconfigs = Effect.fn(function* ( return yield* Effect.filter( candidates, (candidate) => - Effect.map( - loadTsconfigInputs(candidate), - (inputs) => - inputs.configRealPaths.some((config) => jsonFiles.includes(config)) || - files.some((file) => includesFile(inputs, file)), + Effect.flatMap(loadTsconfigInputs(candidate), (inputs) => + files.some((file) => includesFile(inputs, file)) + ? Effect.succeed(true) + : jsonFiles.length === 0 + ? Effect.succeed(false) + : Effect.map( + Effect.forEach(inputs.configs, realPath, { concurrency: 'unbounded' }), + (configs) => configs.some((config) => jsonFiles.includes(config)), + ), ), { concurrency: 'unbounded' }, ) @@ -240,7 +244,7 @@ interface InputPattern { } interface TsconfigInputs { - readonly configRealPaths: ReadonlyArray + readonly configs: ReadonlyArray readonly files: ReadonlyArray readonly include: ReadonlyArray readonly exclude: ReadonlyArray @@ -266,29 +270,31 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { const chain = yield* loadExtendsChain(configPath, new Set()) const leafDir = path.dirname(configPath) - let files: Specs | undefined - let include: Specs | undefined - let exclude: Specs | undefined - let outDir: Specs | undefined - let declarationDir: Specs | undefined + const last: Partial< + Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir', Specs> + > = {} let allowJs = false for (const { dir, raw } of chain) { - if (isStringArray(raw.files)) { - files = { dir, specs: raw.files } - } - - if (isStringArray(raw.include)) { - include = { dir, specs: raw.include } - } + for (const key of ['files', 'include', 'exclude'] as const) { + const specs = raw[key] - if (isStringArray(raw.exclude)) { - exclude = { dir, specs: raw.exclude } + if (isStringArray(specs)) { + last[key] = { dir, specs } + } } if (Predicate.isObject(raw.compilerOptions)) { const { compilerOptions } = raw + for (const key of ['outDir', 'declarationDir'] as const) { + const spec = compilerOptions[key] + + if (typeof spec === 'string') { + last[key] = { dir, specs: [spec] } + } + } + if (typeof compilerOptions.allowJs === 'boolean') { allowJs = compilerOptions.allowJs } @@ -296,17 +302,11 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { if (compilerOptions.checkJs === true) { allowJs = true } - - if (typeof compilerOptions.outDir === 'string') { - outDir = { dir, specs: [compilerOptions.outDir] } - } - - if (typeof compilerOptions.declarationDir === 'string') { - declarationDir = { dir, specs: [compilerOptions.declarationDir] } - } } } + const { files, include, exclude, outDir, declarationDir } = last + const resolve = (specs: Specs | undefined): string[] => specs?.specs.map((spec) => path @@ -323,9 +323,7 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { exclude === undefined ? [...resolve(outDir), ...resolve(declarationDir)] : resolve(exclude) return { - configRealPaths: yield* Effect.forEach(chain, ({ file }) => realPath(file), { - concurrency: 'unbounded', - }), + configs: chain.map(({ file }) => file), files: resolve(files), include: includeSpecs.flatMap((spec) => { const regex = compileGlob(spec, 'files') diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 6adc291..53a7c2a 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -5,9 +5,9 @@ import { Argument, Command, Prompt } from 'effect/unstable/cli' import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser' import { userError } from '../../errors' -import { readJson } from '../../files' -import { git } from '../../git' -import { detectExec, invokes, YARN_TOP_LEVEL } from '../../pm' +import { readTextIfExists } from '../../files' +import { gitLocation } from '../../git' +import { detectExec, invokes } from '../../pm' import { bold, dim, green } from '../../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck' @@ -18,8 +18,9 @@ const TIMEOUT_SECONDS = 600 const CLAUDE_FORMAT = { event: 'Stop', timeout: 'timeout', + root: {}, entry: (command: string) => ({ type: 'command', command }), - content: (entry: object) => ({ hooks: { Stop: [{ hooks: [entry] }] } }), + group: (entry: object) => ({ hooks: [entry] }), } const AGENTS = [ @@ -31,8 +32,9 @@ const AGENTS = [ path: '.cursor/hooks.json', event: 'stop', timeout: 'timeout', + root: { version: 1 }, entry: (command: string) => ({ command }), - content: (entry: object) => ({ version: 1, hooks: { stop: [entry] } }), + group: (entry: object) => entry, }, { id: 'copilot', @@ -40,8 +42,9 @@ const AGENTS = [ path: '.github/hooks/uncheck.json', event: 'agentStop', timeout: 'timeoutSec', + root: { version: 1 }, entry: (command: string) => ({ type: 'command', bash: command, powershell: command }), - content: (entry: object) => ({ version: 1, hooks: { agentStop: [entry] } }), + group: (entry: object) => entry, }, ] as const @@ -68,19 +71,13 @@ export const install = Command.make( // Agents run the hook wherever they last `cd`'d, so a project below the top of the repository // is named relative to it. - const dir = (yield* git(cwd, ['rev-parse', '--show-prefix']).pipe( + const dir = yield* gitLocation(cwd).pipe( + Effect.map(({ prefix }) => prefix.replace(/\/$/, '')), Effect.orElseSucceed(() => ''), - )).replace(/\/$/, '') - const exec = yield* detectExec(cwd) - const manifest = yield* readJson(path.join(cwd, 'package.json')) - const launcher = - dir === '' && - exec === 'yarn run --silent' && - /^yarn@(?!1\.)/.test(String(manifest?.packageManager)) - ? YARN_TOP_LEVEL - : exec + ) + const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' }) const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] - const command = `${launcher} ${HOOK_COMMAND} ${flags.join(' ')}` + const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` // A reinstall that cannot recognise the command would add a second hook next to it. if (!invokes(command, HOOK_COMMAND)) { @@ -118,9 +115,7 @@ export const install = Command.make( (agent) => Effect.gen(function* () { const file = path.join(cwd, agent.path) - const existing = yield* fs - .readFileString(file) - .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined))) + const existing = yield* readTextIfExists(file) const text = existing ?? '' const errors: ParseError[] = [] const current: unknown = parseJsonc(text, errors, { allowTrailingComma: true }) @@ -139,9 +134,10 @@ export const install = Command.make( const hooks = Predicate.isObject(base.hooks) ? base.hooks : {} const entry = agent.entry(command) const timeout = { [agent.timeout]: TIMEOUT_SECONDS } + const entries = hooks[agent.event] const found: object[] = [] // Copilot takes `timeout` as another name for `timeoutSec`, so either is one the user chose. - const replaced = mapOwnEntries(hooks[agent.event], (hook) => { + const replaced = mapOwnEntries(entries, (hook) => { found.push(hook) return { ...hook, @@ -149,10 +145,20 @@ export const install = Command.make( ...('timeout' in hook || 'timeoutSec' in hook ? {} : timeout), } }) - const next = - found.length > 0 - ? { ...base, hooks: { ...hooks, [agent.event]: replaced } } - : mergeJson(base, agent.content({ ...entry, ...timeout })) + const next = { + ...base, + ...(found.length > 0 ? {} : agent.root), + hooks: { + ...hooks, + [agent.event]: + found.length > 0 + ? replaced + : [ + ...(Array.isArray(entries) ? entries : []), + agent.group({ ...entry, ...timeout }), + ], + }, + } const result = existing === undefined ? 'created' @@ -205,21 +211,3 @@ function mapOwnEntries(value: unknown, f: (hook: Record) => obj Object.entries(value).map(([key, item]) => [key, mapOwnEntries(item, f)]), ) } - -function mergeJson(base: unknown, addition: unknown): unknown { - if (Array.isArray(base) && Array.isArray(addition)) { - return [...base, ...addition] - } - - if (Predicate.isObject(base) && Predicate.isObject(addition)) { - const merged: Record = { ...base } - - for (const [key, value] of Object.entries(addition)) { - merged[key] = key in base ? mergeJson(base[key], value) : value - } - - return merged - } - - return addition -} diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 9717dde..fe72fb4 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -51,16 +51,14 @@ export const run = Command.make( const start = Option.getOrElse(given, () => process.cwd()) const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe( - Effect.orElseSucceed(() => undefined), + Effect.orElseSucceed(() => start), ) - const cwd = Option.isSome(dir) - ? path.join(top ?? start, dir.value) - : Option.getOrElse(given, () => top ?? start) + const cwd = Option.isSome(dir) ? path.join(top, dir.value) : Option.getOrElse(given, () => top) // Checking a folder that is gone would send the agent back to fix a configuration it cannot see. if (Option.isSome(dir) && !(yield* fs.exists(cwd))) { return yield* userError( - `--dir=${dir.value} names nothing in ${top ?? start}, run \`uncheck hooks install\` again from the project`, + `--dir=${dir.value} names nothing in ${top}, run \`uncheck hooks install\` again from the project`, ) } diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 3fc7bf8..13abdbf 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -3,8 +3,9 @@ import { randomBytes } from 'node:crypto' import { Console, Effect, FileSystem, Option, Path, Result, Schedule } from 'effect' import { Command, Flag } from 'effect/unstable/cli' -import { userError } from '../errors' -import { git } from '../git' +import { platformMessage, userError } from '../errors' +import { readTextIfExists } from '../files' +import { git, gitLocation } from '../git' import { detectExec, invokes } from '../pm' import { bold, dim, green, red } from '../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck' @@ -132,23 +133,18 @@ export const prepare = Command.make( const cwd = path.resolve(directory) - const repository = yield* git(cwd, [ - 'rev-parse', - '--show-toplevel', - '--show-prefix', - '--git-path', - 'hooks', - ]).pipe(Effect.option) + const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.option) // A `prepare` script runs on every install, including where there is no repository to hook. if (Option.isNone(repository)) { return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) } - // A newline in a path shifts the lines git prints, so they are split to land it in `inside`. - const [, ...printed] = repository.value.split('\n') - const hooks = printed.pop() ?? '' - const inside = printed.join('\n').replace(/\/$/, '') + const { + prefix, + paths: [hooks = ''], + } = repository.value + const inside = prefix.replace(/\/$/, '') const exec = yield* detectExec(cwd) const command = [ exec, @@ -175,13 +171,14 @@ export const prepare = Command.make( Effect.catchIf( (error) => error._tag === 'GitFailed' && error.exitCode === 129, () => - Effect.findFirst(['global', 'system'], (scope) => + Effect.findFirst(['local', 'global', 'system'], (scope) => hooksPath(`--${scope}`).pipe( Effect.as(true), Effect.orElseSucceed(() => false), ), ).pipe(Effect.map(Option.getOrUndefined)), ), + Effect.map((scope) => (scope === 'local' ? undefined : scope)), Effect.orElseSucceed(() => undefined), ) @@ -210,9 +207,7 @@ export const prepare = Command.make( return yield* locked( target, Effect.gen(function* () { - const existing = yield* fs - .readFileString(target) - .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined))) + const existing = yield* readTextIfExists(target) if ( existing !== undefined && @@ -248,13 +243,7 @@ export const prepare = Command.make( }), ) }).pipe( - Effect.mapError((error) => - typeof error === 'string' - ? error - : error.cause instanceof Error - ? error.cause.message - : error.message, - ), + Effect.mapError((error) => (typeof error === 'string' ? error : platformMessage(error))), Effect.result, ) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index d1d35fd..371e9cc 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -5,7 +5,7 @@ import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' import { existingFiles } from '../files' -import { git, gitBytes, GitFailed, gitPaths } from '../git' +import { git, gitBytes, GitFailed, gitLocation, gitPaths } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' @@ -40,10 +40,17 @@ export const staged = Command.make( const cwd = path.resolve(directory) // Only regular files: tools follow a staged symlink to a file the commit does not hold. - const listed = yield* stagedFiles(cwd).pipe( - Effect.catchTag('GitFailed', () => userError('`uncheck staged` needs a git repository')), + const [listed, merging] = yield* Effect.all( + [ + stagedFiles(cwd).pipe( + Effect.catchTag('GitFailed', () => + userError('`uncheck staged` needs a git repository'), + ), + ), + mergeInProgress(cwd), + ], + { concurrency: 'unbounded' }, ) - const merging = yield* mergeInProgress(cwd) // Fixing what a merge takes from the other side would commit changes neither side made. const notTheirs = merging ? new Set(yield* stagedFiles(cwd, 'MERGE_HEAD')) : undefined const files = notTheirs === undefined ? listed : listed.filter((file) => notTheirs.has(file)) @@ -59,12 +66,15 @@ export const staged = Command.make( return yield* Console.log(`${dim('○')} nothing to check, ${reason}`) } - const [prefix = '', folder = ''] = (yield* git(cwd, [ - 'rev-parse', - '--show-prefix', - '--git-path', - 'uncheck-unstaged', - ])).split('\n') + const [ + { + prefix, + paths: [folder = '', indexLock = ''], + }, + unstaged, + ] = yield* Effect.all([gitLocation(cwd, ['uncheck-unstaged', 'index.lock']), rawDiff(cwd)], { + concurrency: 'unbounded', + }) const saved = path.resolve(cwd, folder) const aside = { cwd, saved, prefix } @@ -73,15 +83,16 @@ export const staged = Command.make( return yield* leftover(saved) } - const partial = yield* partiallyStaged(cwd, files) - const before = yield* writeTree(cwd) + const partial = yield* partiallyStaged(unstaged, files) const outcome = yield* Ref.make('restored') const { failure, empty } = yield* Effect.scoped( Effect.gen(function* () { if (partial.length > 0) { - yield* Effect.acquireRelease(setAside(aside, partial), (bases) => - putBack(aside, files, bases, before).pipe( + const before = yield* writeTree(cwd) + + yield* Effect.acquireRelease(setAside(aside, partial), (copies) => + putBack(aside, files, copies, before).pipe( Effect.flatMap((result) => Ref.set(outcome, result)), ), ) @@ -107,13 +118,9 @@ export const staged = Command.make( return { failure, empty: false } } + // `git add` refuses a path outside a sparse checkout even when it is on disk. const stage = (env?: Readonly>) => - Effect.forEach( - argvBatches(changed), - // `git add` refuses a path outside a sparse checkout even when it is on disk. - (batch) => git(cwd, ['update-index', '--', ...batch], env), - { discard: true }, - ) + gitEach(cwd, ['update-index'], changed, env) yield* stage() @@ -122,10 +129,7 @@ export const staged = Command.make( const active = process.env.GIT_INDEX_FILE if (active?.endsWith('.lock') === true) { - const lock = path.resolve( - cwd, - yield* git(cwd, ['rev-parse', '--git-path', 'index.lock']), - ) + const lock = path.resolve(cwd, indexLock) if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { yield* stage({ GIT_INDEX_FILE: lock }) @@ -135,7 +139,15 @@ export const staged = Command.make( yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(changed)}`) // git records a merge commit even when its tree is the one HEAD already has. - return { failure, empty: !merging && (yield* writeTree(cwd)) === (yield* headTree(cwd)) } + if (merging) { + return { failure, empty: false } + } + + const [after, head] = yield* Effect.all([writeTree(cwd), headTree(cwd)], { + concurrency: 'unbounded', + }) + + return { failure, empty: after === head } }), ) @@ -163,7 +175,7 @@ export const staged = Command.make( return yield* Effect.fail(failure) } }, - Effect.catchTag('GitFailed', (error) => userError(`${error.command} failed: ${error.stderr}`)), + Effect.catchTag('GitFailed', (error) => userError(error.summary)), ), ).pipe( Command.withDescription( @@ -218,12 +230,16 @@ const stagedFiles = (cwd: string, ...against: ReadonlyArray) => entries.filter((entry) => REGULAR_FILE_MODE.test(entry.toMode)).map((entry) => entry.file), ) -const partiallyStaged = Effect.fn(function* (cwd: string, files: ReadonlyArray) { +const partiallyStaged = Effect.fn(function* ( + unstaged: Effect.Success>, + files: ReadonlyArray, +) { + const staged = new Set(files) const partial: string[] = [] const odd: string[] = [] - for (const { file, fromMode, toMode } of yield* rawDiff(cwd)) { - if (files.includes(file)) { + for (const { file, fromMode, toMode } of unstaged) { + if (staged.has(file)) { if (REGULAR_FILE_MODE.test(fromMode) && REGULAR_FILE_MODE.test(toMode)) { partial.push(file) } else { @@ -251,17 +267,38 @@ interface Aside { readonly prefix: string } -const copiesOf = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { - const path = yield* Path.Path +interface SetAside { + readonly file: string + readonly target: string + readonly copy: string + readonly base: string +} - return files.map((file) => [path.join(cwd, file), path.join(saved, prefix, file)] as const) -}) +/** Runs `git -- ` in batches; with no files, some commands would act on every path. */ +function gitEach( + cwd: string, + args: ReadonlyArray, + files: ReadonlyArray, + env?: Readonly>, +) { + return Effect.forEach( + files.length === 0 ? [] : argvBatches(files), + (batch) => git(cwd, [...args, '--', ...batch], env), + { discard: true }, + ) +} -const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray) { +const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const { cwd, saved } = aside - const copies = yield* copiesOf(aside, files) + const copies = files.map((file) => ({ + file, + target: path.join(cwd, file), + copy: path.join(saved, prefix, file), + })) + const restore = Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), { + discard: true, + }) // Creating the folder, not only finding it missing, is what claims it from a parallel run. yield* fs.makeDirectory(saved).pipe( @@ -272,10 +309,10 @@ const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray ) yield* Effect.forEach( copies, - ([file, copy]) => + ({ target, copy }) => fs .makeDirectory(path.dirname(copy), { recursive: true }) - .pipe(Effect.andThen(fs.copyFile(file, copy))), + .pipe(Effect.andThen(fs.copyFile(target, copy))), { discard: true }, ).pipe(Effect.tapError(() => Effect.ignore(fs.remove(saved, { recursive: true })))) @@ -287,37 +324,33 @@ const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray ), ).pipe( Effect.tapError(() => - Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }).pipe( - Effect.andThen(fs.remove(saved, { recursive: true })), - Effect.ignore, - ), + restore.pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore), ), ) yield* Console.log( dim(`○ unstaged changes of ${listFiles(files)} set aside until the checks finish`), ) - return new Map( - ids.flatMap((output) => output.split('\n')).map((id, index) => [files[index]!, id]), - ) + const bases = ids.flatMap((output) => output.split('\n')) + + return copies.map((entry, index): SetAside => ({ ...entry, base: bases[index]! })) }) /** Runs as a finalizer, so it must never fail: what it cannot do is reported and returned. */ const putBack = Effect.fn(function* ( aside: Aside, files: ReadonlyArray, - bases: ReadonlyMap, + copies: ReadonlyArray, before: string, ) { const fs = yield* FileSystem.FileSystem const { cwd, saved } = aside - const partial = [...bases.keys()] + const partial = copies.map(({ file }) => file) const result = yield* Effect.gen(function* () { - const copies = yield* copiesOf(aside, partial) - const merged = yield* Effect.forEach(partial, (file, index) => - merge(aside, file, copies[index]![1], bases.get(file)!), - ) + const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry), { + concurrency: 4, + }) const conflicted = partial.filter((_, index) => !merged[index]) if (conflicted.length === 0) { @@ -325,24 +358,17 @@ const putBack = Effect.fn(function* ( return 'restored' as const } - yield* Effect.forEach( - argvBatches(files), - (batch) => git(cwd, ['reset', '-q', before, '--', ...batch]), - { discard: true }, - ) + yield* gitEach(cwd, ['reset', '-q', before], files) // checkout-index fails on a file a sparse checkout leaves out, which no check could change. - yield* Effect.forEach( - argvBatches(yield* existingFiles(files, cwd)), - (batch) => git(cwd, ['checkout-index', '-f', '--', ...batch]), - { discard: true }, - ) - yield* Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }) + yield* gitEach(cwd, ['checkout-index', '-f'], yield* existingFiles(files, cwd)) + yield* Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), { + discard: true, + }) return { conflicted } }).pipe( Effect.catch((error) => { - const reason = - error instanceof GitFailed ? `${error.command} failed, ${error.stderr}` : String(error) + const reason = error instanceof GitFailed ? error.summary : String(error) return Console.log( `${red('✘')} could not put back the unstaged changes of ${listFiles(partial)}: ${reason}\n their unstaged versions are in ${saved}, at their paths from the top of the repository: copy back what your files are missing and delete the folder`, @@ -359,15 +385,9 @@ const putBack = Effect.fn(function* ( // Merges what git stores: a formatter rewriting line endings would conflict with every line of a // raw merge. `apply --3way` would run the repository's merge drivers and rerere; `merge-file` does not. -const merge = Effect.fn(function* ( - { cwd, prefix }: Aside, - file: string, - copy: string, - base: string, -) { +const merge = Effect.fn(function* ({ cwd, prefix }: Aside, { file, target, copy, base }: SetAside) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const target = path.join(cwd, file) const store = (from: string) => git(cwd, [...STORE, `--path=${file}`, '--', from]) const checked = yield* store(target) @@ -388,13 +408,18 @@ const merge = Effect.fn(function* ( const fixed = path.join(temp, 'fixed') return yield* Effect.gen(function* () { - for (const [to, id] of [ - [result, yield* store(copy)], - [original, base], - [fixed, checked], - ] as const) { - yield* fs.writeFile(to, yield* gitBytes(cwd, ['cat-file', 'blob', id])) - } + const unstaged = yield* store(copy) + + yield* Effect.forEach( + [ + [result, unstaged], + [original, base], + [fixed, checked], + ] as const, + ([to, id]) => + Effect.flatMap(gitBytes(cwd, ['cat-file', 'blob', id]), (bytes) => fs.writeFile(to, bytes)), + { concurrency: 'unbounded', discard: true }, + ) const clean = yield* git(cwd, ['merge-file', '--quiet', result, original, fixed]).pipe( Effect.as(true), diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index f042d0c..ef3defc 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -9,7 +9,7 @@ import { Argument, Command, Flag } from 'effect/unstable/cli' import { oxfmt, oxlint } from '../checks/oxc' import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' -import { CheckFailed, userError } from '../errors' +import { CheckFailed, platformMessage, userError } from '../errors' import { existingFiles, listProjectFiles, resolvePaths } from '../files' import { bold, dim, green, listFiles, red } from '../style' import { captureLines, execute } from '../tool' @@ -134,7 +134,7 @@ export const checkPaths = Effect.fn(function* ( const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings const appliesFixes = (fixes: Check['fixes']) => settings.staged === true ? fixes === 'files' : fixes !== false - const cwd = path.resolve(settings.cwd) + const { cwd } = settings const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) let files: ReadonlyArray | undefined @@ -296,9 +296,7 @@ function runCommand(command: CheckCommand, cwd: string) { Effect.andThen(execute(command, cwd)), // A tool killed by a signal (say by the OOM killer) fails `exitCode` with a PlatformError, not a code. Effect.catchTag('PlatformError', (error) => - Console.log(red(error.cause instanceof Error ? error.cause.message : error.message)).pipe( - Effect.as(1), - ), + Console.log(red(platformMessage(error))).pipe(Effect.as(1)), ), ) } diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index b64dbad..7fd2a55 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -1,3 +1,4 @@ +import type { PlatformError } from 'effect' import { Data, Effect } from 'effect' import { CliError } from 'effect/unstable/cli' @@ -20,3 +21,7 @@ export class StopBlocked extends Data.TaggedError('StopBlocked') {} export function userError(userMessage: string): Effect.Effect { return Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) } + +export function platformMessage(error: PlatformError.PlatformError): string { + return error.cause instanceof Error ? error.cause.message : error.message +} diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index f9a23d7..43062b0 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -1,6 +1,8 @@ +import { existsSync } from 'node:fs' + import { Effect, FileSystem, Path, Predicate } from 'effect' import type { ChildProcessSpawner } from 'effect/unstable/process' -import picomatch from 'picomatch/posix' +import { Minimatch } from 'minimatch' import { gitPaths } from './git' @@ -18,7 +20,9 @@ export type ProjectFiles = Effect.Effect< */ export function listProjectFiles(cwd: string): ProjectFiles { return gitPaths(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe( - Effect.map((files) => files.filter((file) => !file.split('/').includes('node_modules'))), + Effect.map((files) => + files.filter((file) => !file.startsWith('node_modules/') && !file.includes('/node_modules/')), + ), Effect.catch(() => walk(cwd)), Effect.map((files) => [...files].sort()), ) @@ -36,10 +40,13 @@ export function listChangedFiles( never, ChildProcessSpawner.ChildProcessSpawner > { - return Effect.all([ - gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']), - gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), - ]).pipe( + return Effect.all( + [ + gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']), + gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), + ], + { concurrency: 'unbounded' }, + ).pipe( Effect.map(([tracked, untracked]) => [...new Set([...tracked, ...untracked])].sort()), Effect.orElseSucceed(() => undefined), ) @@ -110,10 +117,11 @@ export const resolvePaths = Effect.fn(function* ( return (file: string) => file === target || file.startsWith(`${target}/`) || matches(file) }) - const files = yield* Effect.filter( - [...matched].filter((file) => !excludes.some((excluded) => excluded(file))), - (file) => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)), - { concurrency: 'unbounded' }, + // One fiber per file costs far more than the check itself on a large project. + const files = yield* Effect.sync(() => + [...matched].filter( + (file) => !excludes.some((excluded) => excluded(file)) && existsSync(path.resolve(cwd, file)), + ), ) return { files: files.sort(), unmatched } @@ -123,16 +131,14 @@ const GLOB_CHARACTERS = /[*?[\]{}()]/ /** Dot files match too, as they do for oxfmt and for a directory given as it is. */ function glob(pattern: string): (file: string) => boolean { - // Without `posix`, picomatch reads `[!a]` as "! or a", and a bare `(…)` is a regex group, so - // `app/(marketing)/**` would match `app/marketing`. - const matches = picomatch(pattern.replace(/(? matches(file) + return (file) => matcher.match(file) } export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { @@ -223,3 +229,11 @@ const walk = Effect.fn(function* (cwd: string) { return found }) + +export const readTextIfExists = Effect.fn(function* (file: string) { + const fs = yield* FileSystem.FileSystem + + return yield* fs + .readFileString(file) + .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined))) +}) diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index 3bb58f7..c6c0816 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -18,6 +18,10 @@ export class GitFailed extends Data.TaggedError('GitFailed')<{ return `git ${shown.join(' ')}` } + + get summary(): string { + return `${this.command} failed: ${this.stderr}` + } } /** @@ -75,3 +79,22 @@ export function git( export function gitPaths(cwd: string, args: ReadonlyArray) { return Effect.map(git(cwd, args), (output) => output.split('\0').filter((entry) => entry !== '')) } + +/** + * The folder of `cwd` below the top of the working tree, `''` or ending in `/`, and where git keeps + * each of `names`. Fails outside a working tree. + */ +export function gitLocation(cwd: string, names: ReadonlyArray = []) { + const gitPathArgs = names.flatMap((name) => ['--git-path', name]) + + return Effect.map( + git(cwd, ['rev-parse', '--show-toplevel', '--show-prefix', ...gitPathArgs]), + (output) => { + // A newline in a path shifts the lines git prints, so they are counted from both ends. + const [, ...lines] = output.split('\n') + const paths = lines.splice(lines.length - names.length) + + return { prefix: lines.join('\n'), paths } + }, + ) +} diff --git a/packages/uncheck/src/pm.ts b/packages/uncheck/src/pm.ts index 63d85ce..4328f90 100644 --- a/packages/uncheck/src/pm.ts +++ b/packages/uncheck/src/pm.ts @@ -12,8 +12,7 @@ const EXEC_BY_PACKAGE_MANAGER: ReadonlyMap = new Map([ ['npm', 'npx --no'], ]) -/** Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's. */ -export const YARN_TOP_LEVEL = 'yarn run -T --silent' +const YARN_TOP_LEVEL = 'yarn run -T --silent' const PACKAGE_MANAGER_BY_LOCKFILE = [ ['pnpm-lock.yaml', 'pnpm'], @@ -49,16 +48,24 @@ export function invokes(text: string, command: string): boolean { * The `npx`-like prefix that runs a project binary, from the package manager the nearest project * declares in `packageManager` or, failing that, its lockfile. */ -export const detectExec = Effect.fn(function* (cwd: string) { +export const detectExec = Effect.fn(function* (cwd: string, { fromAnyWorkspace = false } = {}) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path for (const dir of ancestors(path, cwd)) { const manifest = yield* readJson(path.join(dir, 'package.json')) + const packageManager = + typeof manifest?.packageManager === 'string' ? manifest.packageManager : undefined + + // Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's. + if (fromAnyWorkspace && packageManager !== undefined && /^yarn@(?!1\.)/.test(packageManager)) { + return YARN_TOP_LEVEL + } + const declared = - typeof manifest?.packageManager === 'string' - ? EXEC_BY_PACKAGE_MANAGER.get(manifest.packageManager.split('@')[0]!) - : undefined + packageManager === undefined + ? undefined + : EXEC_BY_PACKAGE_MANAGER.get(packageManager.split('@')[0]!) if (declared !== undefined) { return declared diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 9b42732..08a5ba2 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -82,6 +82,10 @@ function text(lines: string[]): string { return stripVTControlCharacters(lines.map((line) => `${line}\n`).join('')) } +const cliBin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) + +const CLAUDE_STOP = JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }) + const oxlintrc = { rules: { 'no-var': 'error' } } const standaloneTsconfig = { @@ -880,6 +884,19 @@ function committed(files: Record) { return dir } +/** Commits `files` on a new `side` branch and goes back to the branch before it. */ +function commitOnSide(dir: string, files: Record) { + gitIn(dir, 'checkout', '--quiet', '-b', 'side') + + for (const [file, content] of Object.entries(files)) { + writeFileSync(join(dir, file), content) + } + + gitIn(dir, 'add', '-A') + gitIn(dir, 'commit', '--quiet', '-m', 'side') + gitIn(dir, 'checkout', '--quiet', '-') +} + const clean = { '.oxlintrc.json': oxlintrc, 'tsconfig.json': standaloneTsconfig, @@ -888,12 +905,10 @@ const clean = { } function installPreCommitHook(dir: string, args: string, folder = '.') { - const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) - mkdirSync(join(dir, '.git/hooks'), { recursive: true }) writeFileSync( join(dir, '.git/hooks/pre-commit'), - `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${bin}" ${args}) || exit 1\n`, + `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${cliBin}" ${args}) || exit 1\n`, { mode: 0o755 }, ) } @@ -904,11 +919,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') writeFileSync(join(dir, 'src/fresh.ts'), 'export const fresh = 3;\n') - const claude = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), - ) + const claude = await run(dir, ['hooks', 'run', '--fix'], CLAUDE_STOP) expect(claude.result).toBe('blocked') expect(claude.stdout).toBe('') @@ -968,11 +979,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { const dir = committed(clean) writeFileSync(join(dir, 'README.md'), '# Project\n') - const optional = await run( - dir, - ['hooks', 'run', '--fix', '--only=tsc'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), - ) + const optional = await run(dir, ['hooks', 'run', '--fix', '--only=tsc'], CLAUDE_STOP) expect(optional.result).toBe('ok') expect(optional.stderr).toContain('○ nothing to check') @@ -980,7 +987,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { const required = await run( dir, ['hooks', 'run', '--fix', '--only=tsc', '--require=tsc'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + CLAUDE_STOP, ) expect(required.result).toBe('blocked') @@ -997,7 +1004,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { const { result, stderr } = await run( dir, ['hooks', 'run', '--fix', '--only=oxfmt'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + CLAUDE_STOP, ) expect(result).toBe('ok') @@ -1013,11 +1020,9 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { 'packages/web/src/index.ts': 'export const web = 1;\n', }) writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1;\n') - - const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) - const moved = spawnSync(process.execPath, [bin, 'hooks', 'run', '--fix'], { + const moved = spawnSync(process.execPath, [cliBin, 'hooks', 'run', '--fix'], { cwd: join(dir, 'docs'), - input: JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + input: CLAUDE_STOP, encoding: 'utf8', }) @@ -1030,7 +1035,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { const { result, stderr } = await run( join(dir, 'packages/web'), ['hooks', 'run', '--fix', '--only=oxfmt', '--dir=packages/app'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + CLAUDE_STOP, ) expect(result).toBe('ok') @@ -1048,11 +1053,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { writeFileSync(join(dir, 'src/index.ts'), 'export const answer = 1\n') await expect( - run( - dir, - ['hooks', 'run', '--fix', '--dir=packages/gone'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), - ), + run(dir, ['hooks', 'run', '--fix', '--dir=packages/gone'], CLAUDE_STOP), ).rejects.toThrow(/--dir=packages\/gone names nothing/) }) @@ -1107,7 +1108,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { const fast = await run( dir, ['hooks', 'run', '--fix', '--only=oxlint', '--only=oxfmt'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), + CLAUDE_STOP, ) expect(fast.result).toBe('ok') @@ -1251,10 +1252,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { it('lets a merge through when the fixes turn its tree back into what HEAD has', async () => { const dir = committed(clean) - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'commit', '--quiet', '-am', 'side') - gitIn(dir, 'checkout', '--quiet', '-') + commitOnSide(dir, { 'src/other.ts': 'export const other = 3;\n' }) gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') gitIn(dir, 'add', 'src/other.ts') @@ -1552,12 +1550,10 @@ describe('uncheck staged', { timeout: 120_000 }, () => { it('checks only the files of a merge that differ from the side merged in', async () => { const dir = committed(clean) - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - writeFileSync(join(dir, 'src/theirs.ts'), 'export const theirs = 1\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src') - gitIn(dir, 'commit', '--quiet', '-m', 'side') - gitIn(dir, 'checkout', '--quiet', '-') + commitOnSide(dir, { + 'src/theirs.ts': 'export const theirs = 1\n', + 'src/other.ts': 'export const other = 3;\n', + }) gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') expect((await run(dir, ['staged', '--fix', '--only=oxfmt'])).stdout).toContain( @@ -1578,10 +1574,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { it('undoes or stages only what the fixes changed, so a merge can bring in files outside a sparse checkout', async () => { const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 2;\n') - gitIn(dir, 'commit', '--quiet', '-am', 'side') - gitIn(dir, 'checkout', '--quiet', '-') + commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) gitIn(dir, 'sparse-checkout', 'set', 'src') gitIn(dir, 'merge', '--quiet', '--squash', 'side') writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') @@ -1607,10 +1600,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { it('stages fixes to a conflict a merge left outside a sparse checkout', async () => { const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 2;\n') - gitIn(dir, 'commit', '--quiet', '-am', 'side') - gitIn(dir, 'checkout', '--quiet', '-') + commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 3;\n') gitIn(dir, 'commit', '--quiet', '-am', 'main') gitIn(dir, 'sparse-checkout', 'set', 'src') @@ -1768,9 +1758,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => { writeFileSync(file, 'export const answer: number = 43;\n') gitIn(dir, 'add', 'src/index.ts') writeFileSync(file, 'export const answer: number = 43;\nexport const more = 1;\n') - - const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) - const check = spawn(process.execPath, [bin, 'staged', '--only=oxlint'], { + const check = spawn(process.execPath, [cliBin, 'staged', '--only=oxlint'], { cwd: dir, stdio: 'ignore', }) @@ -2041,11 +2029,10 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { [], ) gitIn(dir, 'init', '--quiet') - const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) const exits = await Promise.all( folders.map((folder) => { - const prepared = spawn(process.execPath, [bin, 'prepare', '--pre-commit'], { + const prepared = spawn(process.execPath, [cliBin, 'prepare', '--pre-commit'], { cwd: join(dir, folder), stdio: 'ignore', }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4752829..ea3c8d8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -214,24 +214,21 @@ importers: '@effect/platform-node': specifier: ^4.0.0-rc.116 version: 4.0.0-rc.116(effect@4.0.0-rc.116)(redis@6.2.1) - '@types/picomatch': - specifier: ^4.0.3 - version: 4.0.3 effect: specifier: ^4.0.0-rc.116 version: 4.0.0-rc.116 jsonc-parser: specifier: ^3.3.1 version: 3.3.1 + minimatch: + specifier: ^10.2.6 + version: 10.2.6 oxfmt: specifier: ^0.68.0 version: 0.68.0 oxlint: specifier: ^1.83.0 version: 1.83.0 - picomatch: - specifier: ^4.0.7 - version: 4.0.7 sherif: specifier: ^1.13.0 version: 1.13.0 @@ -1118,9 +1115,6 @@ packages: '@types/node@26.6.2': resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} - '@types/picomatch@4.0.3': - resolution: {integrity: sha512-iG0T6+nYJ9FAPmx9SsUlnwcq1ZVRuCXcVEvWnntoPlrOpwtSTKNDC9uVAxTsC3PUvJ+99n4RpAcNgBbHX3JSnQ==} - '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} @@ -1322,6 +1316,10 @@ packages: axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + baseline-browser-mapping@2.11.25: resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} @@ -1334,6 +1332,10 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + brace-expansion@5.0.12: + resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} + engines: {node: 20 || >=22} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1800,6 +1802,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} + engines: {node: 18 || 20 || >=22} + minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -3019,8 +3025,6 @@ snapshots: dependencies: undici-types: 8.9.0 - '@types/picomatch@4.0.3': {} - '@types/resolve@1.20.2': {} '@types/ws@8.18.1': @@ -3166,12 +3170,18 @@ snapshots: - debug - supports-color + balanced-match@4.0.4: {} + baseline-browser-mapping@2.11.25: {} binary-extensions@2.3.0: {} boolbase@1.0.0: {} + brace-expansion@5.0.12: + dependencies: + balanced-match: 4.0.4 + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -3718,6 +3728,10 @@ snapshots: dependencies: mime-db: 1.52.0 + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.12 + minipass@3.3.6: dependencies: yallist: 4.0.0 From 0ffa8c72996111f18ecd5d0690b8a48633038f22 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 14:19:38 +0700 Subject: [PATCH 13/19] fix(uncheck): address the review of the latest commits - staged: an edit saved to a partially staged file while the checks run is merged back again; only a CRLF blob git keeps as it is refuses the merge - a newline in the repository's path no longer shifts the prefix staged, prepare and install use - prepare puts its line before a setup line that also runs a command or continues onto the next, and the old-git scope check reads included config files - hooks install in a Yarn 2+ package without its own uncheck runs the root's - a linked node_modules stays out of the project files; globs drop `.` inside braces again - Yarn PnP falls back to node_modules for folders its resolver does not cover --- .../uncheck/src/commands/hooks/install.ts | 8 +++++-- packages/uncheck/src/commands/prepare.ts | 13 +++++++--- packages/uncheck/src/commands/staged.ts | 16 ++++++++++--- packages/uncheck/src/files.ts | 7 +++--- packages/uncheck/src/git.ts | 24 +++++++++++-------- packages/uncheck/src/tool.ts | 14 +++++------ 6 files changed, 54 insertions(+), 28 deletions(-) diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 53a7c2a..9a007bf 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -5,7 +5,7 @@ import { Argument, Command, Prompt } from 'effect/unstable/cli' import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser' import { userError } from '../../errors' -import { readTextIfExists } from '../../files' +import { readJson, readTextIfExists } from '../../files' import { gitLocation } from '../../git' import { detectExec, invokes } from '../../pm' import { bold, dim, green } from '../../style' @@ -75,7 +75,11 @@ export const install = Command.make( Effect.map(({ prefix }) => prefix.replace(/\/$/, '')), Effect.orElseSucceed(() => ''), ) - const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' }) + const manifest = yield* readJson(path.join(cwd, 'package.json')) + const declaresUncheck = [manifest?.dependencies, manifest?.devDependencies].some( + (dependencies) => Predicate.isObject(dependencies) && 'uncheck' in dependencies, + ) + const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' || !declaresUncheck }) const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])] const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}` diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 13abdbf..7a7ef9d 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -31,7 +31,11 @@ const NOT_TEXT = /[\0\uFFFD]/ /** The comments and environment a hook sets up, such as its PATH, which the added line needs too. */ const SETUP = - /^\s*(?:#|$|\\?\.\s|(?:source|export|set|unset)\s|[A-Za-z_]\w*=\S*\s*$|.*(?:&&|;)\s*(?:\\?\.|source)\s)/ + /^\s*(?:#|$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ + +/** A setup line that runs on into the next, where the added line would join it. */ +const CONTINUES = + /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$)/ /** * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends @@ -164,7 +168,9 @@ export const prepare = Command.make( const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit') const relative = path.relative(cwd, file) const shown = relative.startsWith('..') ? file : relative - const hooksPath = (option: string) => git(cwd, ['config', option, '--get', 'core.hooksPath']) + // A scope option turns includes off, and an included file can set a global core.hooksPath. + const hooksPath = (option: string) => + git(cwd, ['config', option, '--includes', '--get', 'core.hooksPath']) const shared = yield* hooksPath('--show-scope').pipe( Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]), // Git before 2.26 has no --show-scope, which must not pass for an unset core.hooksPath. @@ -304,7 +310,8 @@ function rewrite(hook: string, line: string, inside: string): string { (last, text, index) => (ownLine(text) === undefined ? last : index + 1), 0, ) - const at = after > 0 ? after : lines.findIndex((text) => !SETUP.test(text)) + const at = + after > 0 ? after : lines.findIndex((text) => !SETUP.test(text) || CONTINUES.test(text)) if (at === -1) { const kept = lines.join('\n') diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 371e9cc..7fc2c7b 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -348,7 +348,7 @@ const putBack = Effect.fn(function* ( const partial = copies.map(({ file }) => file) const result = yield* Effect.gen(function* () { - const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry), { + const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry, before), { concurrency: 4, }) const conflicted = partial.filter((_, index) => !merged[index]) @@ -385,7 +385,11 @@ const putBack = Effect.fn(function* ( // Merges what git stores: a formatter rewriting line endings would conflict with every line of a // raw merge. `apply --3way` would run the repository's merge drivers and rerere; `merge-file` does not. -const merge = Effect.fn(function* ({ cwd, prefix }: Aside, { file, target, copy, base }: SetAside) { +const merge = Effect.fn(function* ( + { cwd, prefix }: Aside, + { file, target, copy, base }: SetAside, + before: string, +) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const store = (from: string) => git(cwd, [...STORE, `--path=${file}`, '--', from]) @@ -398,7 +402,13 @@ const merge = Effect.fn(function* ({ cwd, prefix }: Aside, { file, target, copy, // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it // back through the filters would turn every line ending of the file to LF. - if ((yield* git(cwd, ['rev-parse', `:0:${prefix}${file}`])) !== checked) { + const [staged = '', stored = ''] = (yield* git(cwd, [ + 'rev-parse', + `:0:${prefix}${file}`, + `${before}:${prefix}${file}`, + ])).split('\n') + + if (stored !== base && staged !== checked) { return false } diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 43062b0..2c84ce8 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -20,9 +20,8 @@ export type ProjectFiles = Effect.Effect< */ export function listProjectFiles(cwd: string): ProjectFiles { return gitPaths(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe( - Effect.map((files) => - files.filter((file) => !file.startsWith('node_modules/') && !file.includes('/node_modules/')), - ), + // git lists a linked node_modules as one file, which a `node_modules/` ignore rule misses. + Effect.map((files) => files.filter((file) => !/(?:^|\/)node_modules(?:\/|$)/.test(file))), Effect.catch(() => walk(cwd)), Effect.map((files) => [...files].sort()), ) @@ -131,10 +130,12 @@ const GLOB_CHARACTERS = /[*?[\]{}()]/ /** Dot files match too, as they do for oxfmt and for a directory given as it is. */ function glob(pattern: string): (file: string) => boolean { + // Level 2 drops the `.` of `src/{.,deep}/*.ts` as path.matchesGlob does. const matcher = new Minimatch(pattern, { dot: true, nonegate: true, nocomment: true, + optimizationLevel: 2, platform: 'linux', }) diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index c6c0816..d36274a 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -85,16 +85,20 @@ export function gitPaths(cwd: string, args: ReadonlyArray) { * each of `names`. Fails outside a working tree. */ export function gitLocation(cwd: string, names: ReadonlyArray = []) { - const gitPathArgs = names.flatMap((name) => ['--git-path', name]) + const args = [ + 'rev-parse', + '--is-inside-work-tree', + '--show-prefix', + ...names.flatMap((name) => ['--git-path', name]), + ] - return Effect.map( - git(cwd, ['rev-parse', '--show-toplevel', '--show-prefix', ...gitPathArgs]), - (output) => { - // A newline in a path shifts the lines git prints, so they are counted from both ends. - const [, ...lines] = output.split('\n') - const paths = lines.splice(lines.length - names.length) + return Effect.flatMap(git(cwd, args), (output) => { + // A newline in the prefix shifts the lines git prints, so they are counted from both ends. + const [inside, ...lines] = output.split('\n') + const paths = lines.splice(lines.length - names.length) - return { prefix: lines.join('\n'), paths } - }, - ) + return inside === 'true' + ? Effect.succeed({ prefix: lines.join('\n'), paths }) + : Effect.fail(new GitFailed({ args, exitCode: 128, stderr: 'not inside a work tree' })) + }) } diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index 173320e..b715a3e 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -15,24 +15,24 @@ export interface Bin { /** * Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest - * `node_modules/`, whose manifest is read directly so its `exports` map does not matter, or, - * under Yarn PnP, wherever its resolver finds `/package.json`. + * `node_modules/`, whose manifest is read directly so its `exports` map does not matter, and, + * under Yarn PnP, first wherever its resolver finds `/package.json`. */ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName: string = pkg) { const path = yield* Path.Path for (const dir of ancestors(path, cwd)) { // Yarn PnP installs have no node_modules, only the resolver it loads into processes it starts. - const manifestPath = + const resolved = process.versions.pnp === undefined - ? path.join(dir, 'node_modules', pkg, 'package.json') + ? undefined : yield* Effect.try(() => createRequire(path.join(dir, 'package.json')).resolve(`${pkg}/package.json`), ).pipe(Effect.orElseSucceed(() => undefined)) + const manifestPath = resolved ?? path.join(dir, 'node_modules', pkg, 'package.json') + const manifest = yield* readJson(manifestPath) - const manifest = manifestPath === undefined ? undefined : yield* readJson(manifestPath) - - if (manifestPath === undefined || manifest === undefined) { + if (manifest === undefined) { continue } From 9ed527295aa8310d05371f282e5ee6cb949122d1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 14:28:58 +0700 Subject: [PATCH 14/19] test(uncheck): cover the latest review fixes and the gaps it found Adds tests for an edit saved during the checks, a put-back that fails, a first commit with fixes, a repository path with a newline, other tools' Stop hooks and --require/--skip in hook commands, Yarn 2+ package installs, setup lines that run a command or continue, included git config on old git, tsc -b before parallel tsc -p, Yarn PnP resolution, linked node_modules, braces with `.`, and the CLI's error output and global flags. The fixture now refuses to write into a symlinked tool, which would change the shared pnpm store. --- packages/uncheck/tests/command.test.ts | 194 +++++++++++++++++++++++-- packages/uncheck/tests/files.test.ts | 19 +++ packages/uncheck/tests/fixture.ts | 5 + packages/uncheck/tests/tool.test.ts | 56 ++++++- 4 files changed, 261 insertions(+), 13 deletions(-) diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 08a5ba2..183b64f 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -175,13 +175,16 @@ describe('uncheck', { timeout: 120_000 }, () => { 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), 'packages/app/src/index.ts': 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', + 'tools/tsconfig.json': standaloneTsconfig, + 'tools/src/index.ts': + 'import { answer } from "../../packages/lib/dist/index";\n\nexport const tool: number = answer;\n', }) const { result, stdout } = await run(dir) expect(result).toBe('ok') expect(stdout).toContain( - '▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tsconfig.json --noEmit\n', + '▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tools/tsconfig.json --noEmit\n▶ tsc -p tsconfig.json --noEmit\n', ) expect(stdout).not.toContain('packages/lib/tsconfig.json') @@ -646,28 +649,42 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { '.claude/settings.json': `{ // keep me "permissions": { "allow": ["Bash(pnpm test)"] }, - "hooks": { "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }] }, + "hooks": { + "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }], + }, } `, }, [], ) + const hook = 'npx --no uncheck hooks run --fix --require=tsc --skip=sherif' - const { result, stdout } = await run(dir, ['hooks', 'install', 'codebuddy', 'claude']) + await expect(run(dir, ['hooks', 'install', '--require=tsc'])).rejects.toThrow( + 'Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot', + ) + + const { result, stdout } = await run(dir, [ + 'hooks', + 'install', + 'codebuddy', + 'claude', + '--require=tsc', + '--skip=sherif', + ]) expect(result).toBe('ok') expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json created\n') - expect(stdout).toContain('npx --no uncheck hooks run --fix') + expect(stdout).toContain(hook) expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ permissions: { allow: ['Bash(pnpm test)'] }, hooks: { PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], Stop: [ - { - hooks: [{ type: 'command', command: 'npx --no uncheck hooks run --fix', timeout: 600 }], - }, + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { hooks: [{ type: 'command', command: hook, timeout: 600 }] }, ], }, }) @@ -806,6 +823,20 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => { const again = await run(dir, ['hooks', 'install', 'claude']) expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') + + const workspace = committed({ + 'package.json': { packageManager: 'yarn@4.18.0', devDependencies: { uncheck: '*' } }, + 'yarn.lock': '', + 'packages/app/package.json': { name: 'app' }, + 'packages/lib/package.json': { name: 'lib', devDependencies: { uncheck: '*' } }, + }) + + expect( + (await run(join(workspace, 'packages/app'), ['hooks', 'install', 'claude'])).stdout, + ).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix --dir=packages/app ') + expect( + (await run(join(workspace, 'packages/lib'), ['hooks', 'install', 'claude'])).stdout, + ).toContain('The hook runs yarn run --silent uncheck hooks run --fix --dir=packages/lib ') }) it('refuses a config that is not a JSON object and leaves every file as it is', async () => { @@ -1241,12 +1272,16 @@ describe('uncheck staged', { timeout: 120_000 }, () => { expect(allowed.stdout).toContain('✔ staged the fixes to src/other.ts\n') expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - const unborn = fixture(clean) + const unborn = fixture({ ...clean, 'src/index.ts': 'export const answer: number = 42\n' }) gitIn(unborn, 'init', '--quiet') gitIn(unborn, 'add', '.') - expect((await run(unborn, ['staged', '--fix'])).result).toBe('ok') + const first = await run(unborn, ['staged', '--fix']) + + expect(first.result).toBe('ok') + expect(first.stdout).toContain('✔ staged the fixes to src/index.ts\n') + expect(gitIn(unborn, 'show', ':src/index.ts')).toBe('export const answer: number = 42;\n') }) it('lets a merge through when the fixes turn its tree back into what HEAD has', async () => { @@ -1469,6 +1504,69 @@ describe('uncheck staged', { timeout: 120_000 }, () => { expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) }) + it('keeps the unstaged changes aside and stops the commit when they cannot be put back', async () => { + const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' + const dir = committed({ ...clean, 'src/index.ts': `export const a = 1;\n${rest} 1;\n` }) + + mkdirSync(join(dir, '.git/info'), { recursive: true }) + writeFileSync(join(dir, '.git/info/attributes'), 'src/index.ts filter=once\n') + gitIn(dir, 'config', 'filter.once.clean', 'cat') + gitIn( + dir, + 'config', + 'filter.once.smudge', + 'if [ -e .git/smudged ]; then exit 1; else touch .git/smudged; cat; fi', + ) + gitIn(dir, 'config', 'filter.once.required', 'true') + writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) + gitIn(dir, 'add', 'src/index.ts') + writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) + + await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( + /The unstaged changes of src\/index\.ts could not be put back/, + ) + expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) + expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe( + `export const a = 2\n${rest} 2;\n`, + ) + }) + + it('keeps an edit saved to a partially staged file while the checks run', async () => { + const lines = Array.from({ length: 30 }, (_, index) => `export const v${index} = ${index};\n`) + const dir = fixture( + { + 'src/lines.ts': lines.join(''), + 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, + 'node_modules/oxlint/lint.js': + "const fs = require('node:fs')\nfs.writeFileSync('src/lines.ts', fs.readFileSync('src/lines.ts', 'utf8').replace('v15 = 15', 'v15 = 1500'))\n", + }, + [], + ) + const file = join(dir, 'src/lines.ts') + const staged = ['export const v0 = 100;\n', ...lines.slice(1)] + + gitIn(dir, 'init', '--quiet') + gitIn(dir, 'add', '.') + gitIn(dir, 'commit', '--quiet', '-m', 'init') + + writeFileSync(file, staged.join('')) + gitIn(dir, 'add', 'src/lines.ts') + writeFileSync(file, [...staged.slice(0, -1), 'export const v29 = 2900;\n'].join('')) + + const { result } = await run(dir, ['staged', '--only=oxlint']) + + expect(result).toBe('ok') + expect(readFileSync(file, 'utf8')).toBe( + [ + ...staged.slice(0, 15), + 'export const v15 = 1500;\n', + ...staged.slice(16, -1), + 'export const v29 = 2900;\n', + ].join(''), + ) + expect(gitIn(dir, 'show', ':src/lines.ts')).toBe(staged.join('')) + }) + it('stages the fixes in the index `git commit ` leaves behind, not only in its own', () => { const dir = committed(clean) @@ -1843,6 +1941,32 @@ describe('uncheck staged in a package', { timeout: 120_000 }, () => { await expect(run(dir, ['staged', '--fix'])).rejects.toThrow(/git .* failed:/) }) + + it.skipIf(process.platform === 'win32')( + 'puts unstaged changes back in a repository whose path has a newline', + async () => { + const dir = join(fixture({}), 'new\nline') + const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' + + mkdirSync(join(dir, 'src'), { recursive: true }) + writeFileSync(join(dir, 'src/index.ts'), `export const a = 1;\n${rest} 1;\n`) + gitIn(dir, 'init', '--quiet') + gitIn(dir, 'add', '.') + gitIn(dir, 'commit', '--quiet', '-m', 'init') + writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) + gitIn(dir, 'add', 'src/index.ts') + writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) + + const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) + + expect(result).toBe('ok') + expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) + expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( + `export const a = 2;\n${rest} 2;\n`, + ) + expect((await run(dir, ['hooks', 'install', 'claude'])).result).toBe('ok') + }, + ) }) describe('uncheck prepare', { timeout: 120_000 }, () => { @@ -2274,12 +2398,12 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`) const setup = '#!/bin/sh\n# lint\nexport HOOKS="$(dirname "$0")"\n[ -s "$HOOKS/env" ] && \\. "$HOOKS/env"\n' - writeFileSync(hook, `${setup}exec pnpm lint-staged\n`) + writeFileSync(hook, `${setup}export CI=1 && exec pnpm lint-staged\n`) await run(dir, ['prepare', '--pre-commit']) expect(readFileSync(hook, 'utf8')).toBe( - `${setup}pnpm exec uncheck staged --fix || exit 1\nexec pnpm lint-staged\n`, + `${setup}pnpm exec uncheck staged --fix || exit 1\nexport CI=1 && exec pnpm lint-staged\n`, ) const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { @@ -2292,6 +2416,26 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { }, ) + it('runs before a setup line that continues onto the next, not inside it', async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + gitIn(dir, 'init', '--quiet') + const hook = join(dir, '.git/hooks/pre-commit') + + for (const setup of [ + 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n', + 'A="\n. b\n"\n', + "A='\n. b\n'\n", + ]) { + writeFileSync(hook, `#!/bin/sh\n${setup}npx lint-staged\n`) + + await run(dir, ['prepare', '--pre-commit']) + + expect(readFileSync(hook, 'utf8')).toBe( + `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${setup}npx lint-staged\n`, + ) + } + }) + it('leaves a hook in another language alone and says what it should run', async () => { const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) gitIn(dir, 'init', '--quiet') @@ -2524,7 +2668,9 @@ describe('uncheck prepare', { timeout: 120_000 }, () => { const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) gitIn(dir, 'init', '--quiet') const shared = fixture({ 'pre-commit': '#!/bin/sh\n' }, []) - gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared) + const included = join(fixture({ 'work.gitconfig': '' }, []), 'work.gitconfig') + gitIn(dir, 'config', '--file', globalConfig, 'include.path', included) + gitIn(dir, 'config', '--file', included, 'core.hooksPath', shared) const git = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() const bin = fixture( { @@ -2645,3 +2791,27 @@ describe('uncheck presets', { timeout: 120_000 }, () => { expect(check.stdout).not.toContain('TS5097') }) }) + +describe('uncheck cli', { timeout: 120_000 }, () => { + it.skipIf(process.platform === 'win32')( + 'prints a file system failure as an error and has only its own global flags', + () => { + const dir = fixture({ '.claude': '' }, []) + const spawnCli = (...args: string[]) => + spawnSync(process.execPath, [cliBin, ...args], { cwd: dir, encoding: 'utf8' }) + + const install = spawnCli('hooks', 'install', 'claude') + + expect(install.status).toBe(1) + expect(install.stdout).toBe('') + expect(install.stderr).toContain(join(realpathSync(dir), '.claude', 'settings.json')) + + for (const flag of ['--log-level', '--wizard']) { + const refused = spawnCli(flag) + + expect(refused.status).toBe(1) + expect(stripVTControlCharacters(refused.stderr)).toContain(`Unrecognized flag: ${flag}`) + } + }, + ) +}) diff --git a/packages/uncheck/tests/files.test.ts b/packages/uncheck/tests/files.test.ts index 96e01bc..4154527 100644 --- a/packages/uncheck/tests/files.test.ts +++ b/packages/uncheck/tests/files.test.ts @@ -136,6 +136,7 @@ describe('resolvePaths', () => { unmatched: [], }) expect(await resolve(dir, ['src/**'])).toEqual(await resolve(dir, ['src'])) + expect(await resolve(dir, ['src/{.,sub}/*.ts'])).toEqual(await resolve(dir, ['src'])) expect((await resolve(dir, ['.', '!**/*.json'])).files).not.toContain('.vscode/settings.json') }) @@ -161,6 +162,24 @@ describe('resolvePaths', () => { ]) }) + it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { + const store = fixture({ 'dep/index.js': '' }, []) + const dir = fixture({ ...project, '.gitignore': 'node_modules/\ndist/\n' }, []) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + symlinkSync(store, join(dir, 'node_modules')) + symlinkSync(store, join(dir, 'src/node_modules')) + + expect((await resolve(dir, ['.', 'src'])).files).toEqual([ + '.gitignore', + '.prettierignore', + 'app/[id].ts', + 'docs/readme.md', + 'src/a.ts', + 'src/b.ts', + 'src/sub/c.ts', + ]) + }) + it('excludes from everything when only exclusions are given', async () => { const dir = fixture(project, []) execFileSync('git', ['init', '--quiet'], { cwd: dir }) diff --git a/packages/uncheck/tests/fixture.ts b/packages/uncheck/tests/fixture.ts index cf8ab92..cb12389 100644 --- a/packages/uncheck/tests/fixture.ts +++ b/packages/uncheck/tests/fixture.ts @@ -40,6 +40,11 @@ export function fixture( } for (const [relative, content] of Object.entries({ ...defaults, ...files })) { + // The tools are symlinks into the shared pnpm store, so writing below one corrupts every install. + if (tools.some((tool) => relative.startsWith(`node_modules/${tool}/`))) { + throw new Error(`${relative} would be written into the installed ${relative.split('/')[1]}`) + } + mkdirSync(dirname(join(dir, relative)), { recursive: true }) writeFileSync( join(dir, relative), diff --git a/packages/uncheck/tests/tool.test.ts b/packages/uncheck/tests/tool.test.ts index 765778b..09f5797 100644 --- a/packages/uncheck/tests/tool.test.ts +++ b/packages/uncheck/tests/tool.test.ts @@ -1,6 +1,12 @@ +import Module from 'node:module' +import { join } from 'node:path' import process from 'node:process' -import { argvBatches } from '../src/tool' +import { NodeServices } from '@effect/platform-node' +import { Effect } from 'effect' + +import { argvBatches, resolveBin } from '../src/tool' +import { fixture } from './fixture' describe('argvBatches', () => { it('splits long file lists into batches every platform can spawn, keeping their order', () => { @@ -22,3 +28,51 @@ describe('argvBatches', () => { expect(argvBatches([])).toEqual([[]]) }) }) + +describe('resolveBin', () => { + it('asks the Yarn PnP resolver of each folder up the tree, then its node_modules', async () => { + const dir = fixture( + { + 'package.json': '{}', + 'packages/app/package.json': '{}', + 'packages/app/node_modules/oxfmt/package.json': { bin: 'oxfmt.js' }, + '.yarn/oxlint/package.json': { bin: { oxlint: 'bin.js' } }, + }, + [], + ) + const loader = Module as unknown as { + _resolveFilename: ( + request: string, + parent: { filename: string }, + ...rest: unknown[] + ) => string + } + const resolveFilename = loader._resolveFilename + + loader._resolveFilename = (request, parent, ...rest) => + request === 'oxlint/package.json' && parent.filename === join(dir, 'package.json') + ? join(dir, '.yarn/oxlint/package.json') + : Reflect.apply(resolveFilename, Module, [request, parent, ...rest]) + process.versions.pnp = '3' + + const resolve = (pkg: string) => + Effect.runPromise( + resolveBin(pkg, join(dir, 'packages/app')).pipe(Effect.provide(NodeServices.layer)), + ) + + try { + expect(await resolve('oxlint')).toEqual({ + name: 'oxlint', + entry: join(dir, '.yarn/oxlint/bin.js'), + }) + expect(await resolve('oxfmt')).toEqual({ + name: 'oxfmt', + entry: join(dir, 'packages/app/node_modules/oxfmt/oxfmt.js'), + }) + expect(await resolve('sherif')).toBeUndefined() + } finally { + loader._resolveFilename = resolveFilename + delete process.versions.pnp + } + }) +}) From e945ed02827901c104232a30f03d0512894f2014 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 15:16:22 +0700 Subject: [PATCH 15/19] refactor(uncheck): keep the oxlint and oxfmt checks separate They are different tools and will grow apart, so each keeps its own file as on main. --- packages/uncheck/src/checks/oxc.ts | 35 ------------------------ packages/uncheck/src/checks/oxfmt.ts | 30 ++++++++++++++++++++ packages/uncheck/src/checks/oxlint.ts | 30 ++++++++++++++++++++ packages/uncheck/src/commands/uncheck.ts | 3 +- 4 files changed, 62 insertions(+), 36 deletions(-) delete mode 100644 packages/uncheck/src/checks/oxc.ts create mode 100644 packages/uncheck/src/checks/oxfmt.ts create mode 100644 packages/uncheck/src/checks/oxlint.ts diff --git a/packages/uncheck/src/checks/oxc.ts b/packages/uncheck/src/checks/oxc.ts deleted file mode 100644 index 45ecd90..0000000 --- a/packages/uncheck/src/checks/oxc.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Effect } from 'effect' - -import { NothingToCheck } from '../errors' -import { argvBatches, resolveBin } from '../tool' -import type { Check, CheckName } from '../types' - -function oxc(name: CheckName, fixArgs: (fix: boolean) => ReadonlyArray): Check { - return { - name, - fixes: 'files', - plan: Effect.fn(function* ({ cwd, fix, files }) { - const bin = yield* resolveBin(name, cwd) - - if (bin === undefined) { - return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) - } - - if (files === undefined) { - return [{ bin, args: fixArgs(fix) }] - } - - // Given files may include ones the tool does not handle (Markdown for oxlint, a .txt file for - // oxfmt), which is not a failure. - return argvBatches(files).map((batch) => ({ - bin, - args: [...fixArgs(fix), '--no-error-on-unmatched-pattern'], - files: batch, - })) - }), - } -} - -export const oxlint = oxc('oxlint', (fix) => (fix ? ['--fix'] : [])) - -export const oxfmt = oxc('oxfmt', (fix) => (fix ? [] : ['--check'])) diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts new file mode 100644 index 0000000..80336c9 --- /dev/null +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -0,0 +1,30 @@ +import { Effect } from 'effect' + +import { NothingToCheck } from '../errors' +import { argvBatches, resolveBin } from '../tool' +import type { Check } from '../types' + +export const oxfmt: Check = { + name: 'oxfmt', + fixes: 'files', + plan: Effect.fn(function* ({ cwd, fix, files }) { + const bin = yield* resolveBin('oxfmt', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + const args = fix ? [] : ['--check'] + + if (files === undefined) { + return [{ bin, args }] + } + + // Given files may include ones oxfmt does not handle (a .txt file), which is not a failure. + return argvBatches(files).map((batch) => ({ + bin, + args: [...args, '--no-error-on-unmatched-pattern'], + files: batch, + })) + }), +} diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts new file mode 100644 index 0000000..0d4fcc3 --- /dev/null +++ b/packages/uncheck/src/checks/oxlint.ts @@ -0,0 +1,30 @@ +import { Effect } from 'effect' + +import { NothingToCheck } from '../errors' +import { argvBatches, resolveBin } from '../tool' +import type { Check } from '../types' + +export const oxlint: Check = { + name: 'oxlint', + fixes: 'files', + plan: Effect.fn(function* ({ cwd, fix, files }) { + const bin = yield* resolveBin('oxlint', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + const args = fix ? ['--fix'] : [] + + if (files === undefined) { + return [{ bin, args }] + } + + // Given files may include ones oxlint does not handle (a Markdown file), which is not a failure. + return argvBatches(files).map((batch) => ({ + bin, + args: [...args, '--no-error-on-unmatched-pattern'], + files: batch, + })) + }), +} diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index ef3defc..6635846 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -6,7 +6,8 @@ import { Console, Duration, Effect, Fiber, Semaphore } from 'effect' import type { CliError } from 'effect/unstable/cli' import { Argument, Command, Flag } from 'effect/unstable/cli' -import { oxfmt, oxlint } from '../checks/oxc' +import { oxfmt } from '../checks/oxfmt' +import { oxlint } from '../checks/oxlint' import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' import { CheckFailed, platformMessage, userError } from '../errors' From ec14133fb2a36b23e0d99a94e2d2a2d3616b2a71 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 15:17:20 +0700 Subject: [PATCH 16/19] build(uncheck): ship the bundle unminified so crash traces stay readable A stack trace with real names is what users can report back. --- packages/uncheck/build.config.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/uncheck/build.config.ts b/packages/uncheck/build.config.ts index c1a84b8..6143714 100644 --- a/packages/uncheck/build.config.ts +++ b/packages/uncheck/build.config.ts @@ -3,6 +3,5 @@ import { defineBuildConfig } from 'unbuild' export default defineBuildConfig({ rollup: { inlineDependencies: true, - esbuild: { minify: true }, }, }) From 2491be91eaf998a2077980297602474a0f5a6e4d Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 15:18:39 +0700 Subject: [PATCH 17/19] chore(uncheck): require Node 22.20 or later, nothing more path.matchesGlob, the reason for skipping 23.x and early 24.x, is no longer used. --- package.json | 2 +- packages/uncheck/README.md | 2 +- packages/uncheck/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index b5f16db..1435dcc 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,6 @@ } }, "engines": { - "node": ">=22.20 <23 || >=24.8" + "node": ">=22.20" } } diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 018c282..f0381fc 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -26,7 +26,7 @@ ## Usage ```sh -npm i -D uncheck # Node 22.20+ or 24.8+ +npm i -D uncheck # Node 22.20+ npx uncheck # sherif, oxlint, oxfmt --check and tsc for everything under the current directory npx uncheck --fix # sherif --fix and oxlint --fix, then rewrite formatting with oxfmt diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index 4614051..9977ac2 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -82,6 +82,6 @@ } }, "engines": { - "node": ">=22.20 <23 || >=24.8" + "node": ">=22.20" } } From 40c86a951ad08a5e3e619f3d0e5b9b9be70f09f9 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 16:09:10 +0700 Subject: [PATCH 18/19] docs(uncheck): rewrite the README to be clear, concise and task-first Organised by what a reader wants to do: check a project, check some files, run it before every commit, run it after every agent turn, then monorepos, presets and troubleshooting. Every example was run against the CLI. Also fixes the licence badge, which linked to a missing file. --- packages/uncheck/README.md | 233 ++++++++++++++++++++++--------------- 1 file changed, 142 insertions(+), 91 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f0381fc..94485a9 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -10,7 +10,7 @@ CodSpeed - + MIT License @@ -21,142 +21,193 @@ -`uncheck` is a single command that lints, formats, type checks your project and keeps a monorepo consistent. It detects which tools the project already has — [`oxlint` and `oxfmt`](https://oxc.rs) for linting and formatting, `tsc` for types, [`sherif`](https://github.com/QuiiBz/sherif) for workspaces — and runs them together, so humans, coding agents and git hooks have one command to remember instead of four. +`uncheck` lints, format checks and type checks your project with one command, and keeps a monorepo consistent. It runs the tools your project already has, so you, your git hooks and your coding agents all run the same check. -## Usage +```sh +npm i -D uncheck oxlint oxfmt typescript # add sherif in a monorepo + +npx uncheck # check everything +npx uncheck --fix # fix what can be fixed, report the rest +npx uncheck prepare --pre-commit # check every commit +npx uncheck hooks install claude # check every agent turn +``` + +uncheck needs Node 22.20 or later. Install only the tools you want: a check runs when its tool is installed and is skipped otherwise. uncheck always uses the versions you installed. + +| Check | Checks | Runs when | +| -------- | -------------------- | -------------------------------------------------------------------------------------------------- | +| `sherif` | monorepo consistency | [sherif](https://github.com/QuiiBz/sherif) 1.10+ is installed, at the [workspace root](#monorepos) | +| `oxlint` | lint rules | [oxlint](https://oxc.rs) 1.60+ is installed | +| `oxfmt` | formatting | [oxfmt](https://oxc.rs) is installed | +| `tsc` | types | the project has a `tsconfig.json` | + +## Check your project + +```text +$ npx uncheck +uncheck in /home/me/my-app +○ sherif skipped, not installed +▶ oxlint +✔ oxlint passed 67ms +▶ oxfmt --check +Format issues found in above 2 files. Run without `--check` to fix. +✘ oxfmt failed 65ms +▶ tsc -p tsconfig.json --noEmit +src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'. +✘ tsc failed 384ms + +✘ 2 of 3 checks failed: oxfmt, tsc + rerun with `--fix` to apply oxfmt fixes +``` + +Every check runs, so one run shows every problem. uncheck exits with 1 when a check fails, and also when no check could run, so a broken setup never passes quietly. ```sh -npm i -D uncheck # Node 22.20+ - -npx uncheck # sherif, oxlint, oxfmt --check and tsc for everything under the current directory -npx uncheck --fix # sherif --fix and oxlint --fix, then rewrite formatting with oxfmt -npx uncheck src/app # check some files: paths, directories, globs and !exclusions -npx uncheck --skip=tsc # skip a check that would otherwise run -npx uncheck --only=oxlint --only=oxfmt # run only the named checks, here the fast ones -npx uncheck --require=oxfmt # require a check: fail when it cannot run instead of skipping it -npx uncheck --cwd packages/app # run in another directory, paths are relative to it +npx uncheck --only=oxlint --only=oxfmt # run only these checks +npx uncheck --skip=tsc # skip a check +npx uncheck --require=tsc # fail when tsc cannot run, instead of skipping it +npx uncheck --cwd packages/app # run in another directory ``` -Checks run in order and every check runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any check fails. +`--only`, `--skip` and `--require` can be repeated and work on every command. Flags go after the command name (`npx uncheck staged --fix`), and `npx uncheck --help` lists them all. -| Check | Runs when | Command | -| -------- | ----------------------------------------------------------- | ---------------------------------------------------------------------- | -| `sherif` | `sherif` is installed and the directory is a workspace root | `sherif`, or `sherif --fix --select=highest` with `--fix` | -| `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` | -| `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` | -| `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p --noEmit` for the rest | +`--fix` applies oxlint's fixes, rewrites the formatting with oxfmt, and applies sherif's fixes, which runs your package manager's install afterwards. Type errors are yours to fix. -Paths given on the command line are resolved by `uncheck` itself into one list of files that every tool receives, so tools never disagree about what a directory or glob means: a file must exist, a directory expands to the project files below it (ignored files stay out, like `git ls-files`), globs use the usual `**`/`*` syntax and match dot files too, and `!pattern` excludes, from everything when no other path is given. A path that matches nothing fails the run, unless `--no-error-on-unmatched-pattern` is passed. +## Check only some files -Tools are resolved from `node_modules` the way Node does, or through Yarn's PnP resolver when run through `yarn`, so the versions your project already depends on are used. Checking a list of files needs oxlint 1.60 or later. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped, and a tool that crashes or is killed fails its check while the others still run. +```sh +npx uncheck src/index.ts src/cli.ts # files +npx uncheck src/app # a directory +npx uncheck 'src/**/*.test.ts' # a glob, quoted so your shell leaves it alone +npx uncheck src '!src/generated' # a directory, minus a part of it +npx uncheck '!**/*.gen.ts' # everything except some files +``` -### Monorepo consistency +uncheck turns your paths into one file list that every tool gets, so they never disagree about what a path means. Directories and globs match the files git knows about (tracked, or new and not ignored), dot files included. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. -[`sherif`](https://github.com/QuiiBz/sherif) lints a monorepo as a whole: dependency versions that differ between packages, unordered dependencies, a missing `packageManager` field and so on. It runs when the directory is a workspace root (`workspaces` in `package.json` or a `pnpm-workspace.yaml`) and, when paths are given, only if a `package.json` or `pnpm-workspace.yaml` is among them, since nothing else changes its verdict. Its options are read from the `sherif` field of the root `package.json` as sherif documents, so rules and dependencies to ignore live there. With `--fix` sherif also runs your package manager's install afterwards, unless that field sets `noInstall`; aligning versions takes the highest one unless the field sets `select`, since choosing interactively needs a terminal. sherif refuses to fix anything in CI, so with `CI` set it only checks, and so does `uncheck staged`: its fixes reach manifests outside the commit and need an install and a lockfile update, so run `uncheck --fix` and stage the result. +tsc then checks only the projects that include one of the files, and sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among them. -### Typecheck in monorepos +## Run it before every commit -Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively, whatever the referenced configs are named, to build the project graph: +Add a `prepare` script, so every clone sets up the hook on install, and run it once now: -- Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package. -- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p --noEmit`, up to four at a time, so they emit no JavaScript or declarations (an `incremental` or `composite` project still writes its `.tsbuildinfo`). -- Circular references anywhere in the graph are reported as an error. +```json +{ + "scripts": { + "prepare": "uncheck prepare --pre-commit" + } +} +``` -When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Configs that are only referenced count too, such as `tsconfig.app.json` in a Vite, Nx or Angular solution layout, and a changed tsconfig selects every project it applies to through `extends`, also from a config package linked into `node_modules`. A selected project in the reference graph is built with `tsc -b` through the roots that depend on it, so the projects using a changed library are checked again; packages that import each other from source without `references` are not followed. Files `tsc` never checks, such as Markdown or CSS, select no project. +Every commit then runs `uncheck staged --fix`: it checks the staged files, fixes what oxlint and oxfmt can, and stages those fixes. A failing check blocks the commit, and `git commit --no-verify` skips the hook. No lint-staged or simple-git-hooks needed. -## Presets +| `prepare` flag | Effect | +| ------------------------------- | ---------------------------------------------------------------------- | +| `--no-fix` | The hook only checks and never changes your files | +| `--allow-empty` | The hook lets a commit through when the fixes undo every staged change | +| `--only`, `--skip`, `--require` | Written into the hook command | -`uncheck/oxlint`, `uncheck/oxfmt` and `uncheck/tsconfig` export presets. `middleapi` is the one the [middleapi](https://github.com/middleapi) projects share: +Run `prepare` again with other flags to change the hook. What it guarantees: -```ts -// oxlint.config.ts -import { defineConfig } from 'oxlint' -import { middleapi } from 'uncheck/oxlint' +- **You commit what was checked.** After `git add -p`, the unstaged part of a file is set aside while the checks run and put back afterwards, even after Ctrl-C. +- **Nothing is lost.** If a fix clashes with your unstaged changes, every fix is undone and the commit stops. Stage the whole file, or stash the rest, and commit again. +- **Only fixes to staged files are staged.** During a merge, only files that differ from the branch being merged in are checked. +- **No empty commits.** If the fixes undo every staged change, the commit fails, unless you pass `--allow-empty`. -export default defineConfig({ extends: [middleapi] }) -``` +Good to know: -```ts -// oxfmt.config.ts -import { defineConfig } from 'oxfmt' -import { middleapi } from 'uncheck/oxfmt' +- tsc checks whole projects, so it can report errors in files you did not stage. `--only=oxlint --only=oxfmt` keeps the hook inside the commit. +- sherif only reports in the hook, since its fixes reach beyond the commit. Run `npx uncheck --fix` for them. +- A commit no selected check covers, such as a README change with `--only=tsc`, passes. Add `--require=tsc` to make it fail. +- An existing hook is kept: uncheck adds one line after its setup (comments, `source`, `export`, variables) and before its commands. With husky 9 or Vite+, it writes the `pre-commit` file they run. +- uncheck writes nothing, and says why, outside a git repository, when `core.hooksPath` comes from your global or system git config, or when the existing hook is not a shell script. Your install keeps working. +- The hook runs uncheck through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`), so a missing install fails instead of downloading uncheck. +- Yarn 2+ does not run `prepare`. Use `postinstall` instead, and in a package you publish, turn it off while packing, for example with `"prepack": "pinst --disable"` and `"postpack": "pinst --enable"`. -export default defineConfig({ ...middleapi }) -``` +## Run it after every agent turn -```jsonc -// tsconfig.json, for Node.js code that is only type checked -{ - "extends": "uncheck/tsconfig/middleapi", - "compilerOptions": { "types": ["node"] }, - "include": ["src"], -} +```sh +npx uncheck hooks install claude codebuddy # name the agents +npx uncheck hooks install # or pick them from a list ``` -```jsonc -// packages/*/tsconfig.json, for a Node.js package that emits its declarations to dist -{ - "extends": "uncheck/tsconfig/middleapi/lib", - "compilerOptions": { "types": ["node"] }, - "include": ["src"], -} -``` +| Agent | Name | Config file | +| -------------- | ----------- | ---------------------------- | +| Claude Code | `claude` | `.claude/settings.json` | +| CodeBuddy | `codebuddy` | `.codebuddy/settings.json` | +| Cursor | `cursor` | `.cursor/hooks.json` | +| GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` | -The tsconfig presets target ES2022 and load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. They accept imports that name the `.ts` file, as Node's type stripping requires. The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+: an older oxfmt ignores `oxfmt.config.ts` and formats with its defaults. +Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what it can, and when problems remain, sends the agent back to fix them. That happens at most once per turn, so an agent that cannot fix something is never stuck in a loop. -## Agent hooks +- **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags. +- **Your config is kept.** Other hooks and settings stay, and installing again only updates uncheck's entry. Comments in the file are lost when it is rewritten. +- **Avoid double runs.** Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so add `cursor` or `copilot` next to `claude` only where they do not read that file. +- **Copilot** reads `.github/hooks` only at the top of the repository, so install `copilot` from there. -```sh -npx uncheck hooks install # pick agents interactively -npx uncheck hooks install claude codebuddy # or name them: claude, codebuddy, cursor, copilot -npx uncheck hooks install claude --only=oxlint --only=oxfmt # a fast hook: lint and format, no typecheck -``` +## Monorepos + +Run uncheck from the workspace root, the folder whose `package.json` has `workspaces` or that has a `pnpm-workspace.yaml`, to check the whole monorepo. + +**sherif** checks the workspace as a whole, so it only runs at the root. Configure it in the `sherif` field of the root `package.json`, [as sherif documents](https://github.com/QuiiBz/sherif). With `--fix`, mismatched versions move to the highest one (unless you set `select`), and your install runs afterwards (unless you set `"noInstall": true`). When `CI` is set, sherif only reports. + +**TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: -This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Running it again updates only uncheck's own entry, keeping keys you added to it, while permission rules and other commands that merely mention `uncheck hooks run` stay as they are; a file that is not valid JSON is reported and left alone. Each new entry gives the hook 600 seconds, since the 30 and 60 second defaults of Copilot and CodeBuddy would cut a typecheck short; a timeout you set is kept. Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so install `cursor` or `copilot` next to `claude` only where they do not read it, or uncheck runs twice per turn. Whenever the agent finishes a turn, the hook runs: +- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. +- Every other project is checked with `tsc -p --noEmit`, a few at a time. +- When only some files are checked, tsc runs just the projects that include them, and the projects that reference those. A changed tsconfig selects every project that extends it. + +**Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags: ```sh -uncheck hooks run --fix +#!/bin/sh +# Written by `uncheck prepare`, run it again to change the command. +pnpm exec uncheck staged --fix || exit 1 +(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1 +(cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1 ``` -through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`, detected from the lockfile, so a missing install fails instead of downloading uncheck). Installed below the top of the repository, the command also carries `--dir=`, so the hook checks the same place whichever directory the agent last `cd`'d into; Copilot reads `.github/hooks` only at the top, so `copilot` is installed from there. In a Yarn 2+ workspace a root install runs the root's uncheck with `yarn run -T`. It runs every check on the files changed since the last commit (modified, staged and untracked; everything under the current directory outside git), applies fixes, and prints the report on stderr. A change no selected check covers, such as a README edit with `--only=tsc`, passes. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop; when problems remain after it, Claude Code and CodeBuddy show you that uncheck still fails. A Cursor turn you stopped, or one that ended in an error, is left alone. +An agent hook installed from a package folder checks only that package, wherever the agent moves to. -Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. When even that is too slow for a project, leave the typecheck to CI: `--only`, `--skip` and `--require` given to `install` are written into the hook command as they are, and reinstalling with other flags updates it, so `install claude --only=oxlint --only=oxfmt` gives a hook that only lints and formats. +## Presets -## Pre-commit hook +uncheck also ships the lint, format and TypeScript configs the [middleapi](https://github.com/middleapi) projects share. They are optional. -```sh -npx uncheck staged # check the staged files only, what a pre-commit hook should run -npx uncheck staged --fix # also apply the fixes and stage them -npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit runs `uncheck staged --fix` +```ts +// oxlint.config.ts +import { defineConfig } from 'oxlint' +import { middleapi } from 'uncheck/oxlint' + +export default defineConfig({ extends: [middleapi] }) ``` -`staged` runs the checks on the files staged for commit, regular files only: a staged symlink is skipped, since the tools would follow it to a file the commit does not hold. During a merge only the staged files that differ from the branch being merged in are checked, so the merge commit never carries fixes to the other side's work. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too, and only the files they changed. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. Ctrl-C or a closed terminal still puts them back; a run killed outright before it does leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed. A commit no selected check covers, such as a README edit with `--only=tsc`, passes; add `--require=tsc` to make it fail instead. +```ts +// oxfmt.config.ts +import { defineConfig } from 'oxfmt' +import { middleapi } from 'uncheck/oxfmt' -`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it, before the hook's own commands (after its shebang, comments and the lines that set up its environment: sourced files, `export`, `set` and variable assignments), so their failure still blocks the commit and an `exec` or `exit` cannot skip it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags, also when a workspace install runs them all at once. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook: +export default defineConfig({ ...middleapi }) +``` -```json +```jsonc +// tsconfig.json: `uncheck/tsconfig/middleapi` to only type check, +// `uncheck/tsconfig/middleapi/lib` for a package that emits its declarations to dist { - "scripts": { - "prepare": "uncheck prepare --pre-commit" - } + "extends": "uncheck/tsconfig/middleapi", + "compilerOptions": { "types": ["node"] }, + "include": ["src"], } ``` -Yarn 2+ does not run `prepare` on install, so register the command as `postinstall` there, in a package that is not published: `postinstall` also runs when others install a published package, and fails without uncheck. For a published package, turn it off while packing, for example with `"prepack": "pinst --disable"` and `"postpack": "pinst --enable"`. +The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+. The tsconfig presets load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. -`--only`, `--skip` and `--require` given to `prepare` are written into the hook command as for `hooks install`, `--no-fix` gives a hook that only checks, and `--allow-empty` one that lets through a commit the fixes made empty. Outside a git repository `prepare` does nothing, so installs in CI and Docker builds keep working, and `git commit --no-verify` skips the hook. It also writes nothing, says why and lets the install succeed when `core.hooksPath` comes from the global or system git config, which every repository on the machine runs, when the hook is written for another interpreter such as `#!/usr/bin/env node`, and for a package folder whose name holds `"`, `$`, a backtick, `\` or a newline. +## Troubleshooting -In a monorepo where the root and two packages prepare, the hook reads: +**A path looks like a command, a flag or an exclusion.** Start it with `./`: `./staged`, `./-draft.ts`, `'./!notes.ts'`. -```sh -#!/bin/sh -# Written by `uncheck prepare`, run it again to change the command. -pnpm exec uncheck staged --fix || exit 1 -(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1 -(cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1 -``` +**`uncheck dist` says "No files match".** git ignores that folder, so it holds no project files. You can still name an ignored file directly. -Every line ends in `|| exit 1`, so any failing check blocks the commit, and a package is entered in a subshell, so each line starts from the top of the working tree. A line you add by hand runs as you wrote it: give it `|| exit 1` too if its failure should block the commit. +**A commit stops with "An earlier run left the unstaged versions of your files in …".** A pre-commit run was killed before it could put your unstaged changes back. Copy what your files are missing from the folder the message names, delete the folder, and commit again. ## Sponsors From 3f7831ce40c0d6dfe72ec5fd41d2a165b0a154b7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 24 Sep 2026 16:16:39 +0700 Subject: [PATCH 19/19] docs(uncheck): say that a tsconfig.json without TypeScript fails rather than being skipped --- packages/uncheck/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 94485a9..463c940 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -32,7 +32,7 @@ npx uncheck prepare --pre-commit # check every commit npx uncheck hooks install claude # check every agent turn ``` -uncheck needs Node 22.20 or later. Install only the tools you want: a check runs when its tool is installed and is skipped otherwise. uncheck always uses the versions you installed. +uncheck needs Node 22.20 or later. Install only the tools you want: a check runs when its tool is installed and is skipped otherwise, except that a `tsconfig.json` without TypeScript installed fails. uncheck always uses the versions you installed. | Check | Checks | Runs when | | -------- | -------------------- | -------------------------------------------------------------------------------------------------- |