From 0db3e65e2da04e9ddf57e08fe8bb6bb93efdca24 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 13:54:11 +0700 Subject: [PATCH 01/25] test(uncheck): harden the test harness against machine leaks and false greens --- .../uncheck/tests/staged/interrupt.test.ts | 39 ++++++++++++++++--- packages/uncheck/tests/uncheck/output.test.ts | 11 +++++- packages/uncheck/tests/utils/project.test.ts | 29 ++++++++++++++ packages/uncheck/tests/utils/project.ts | 34 +++++++++++++--- 4 files changed, 101 insertions(+), 12 deletions(-) create mode 100644 packages/uncheck/tests/utils/project.test.ts diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index 4276ec5..595533f 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -1,25 +1,35 @@ -import { writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' -import { LAYOUTS, report } from '../utils/project' +import { LAYOUTS, report, wrappedGit } from '../utils/project' import { folderOf, inIndex, stagePartially, startUncheck, VERSIONS } from './utils' +const SLOW_OXLINT = "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n" + describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app }) => { const folder = folderOf(app) const file = `${app}src/extra.ts` - it('puts the unstaged changes back when a closed terminal hangs up during a slow check', async () => { + it('puts the unstaged changes back when a closed terminal hangs up twice during a slow check', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + const hungUp = project.path('.git/hung-up') - project.fake('oxlint', "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n") + project.fake('oxlint', SLOW_OXLINT) - const started = startUncheck(project, ['staged', '--only=oxlint'], { cwd: folder }) + // Two hang-ups sent back to back arrive as one, so the second comes from inside the merge that + // puts the changes back, the only hash-object call with --path. + const started = startUncheck(project, ['staged', '--only=oxlint'], { + cwd: folder, + env: wrappedGit( + `case " $* " in *" hash-object "*" --path="*) echo $PPID >'${hungUp}'; kill -HUP $PPID; sleep 1;; esac`, + ), + }) await started.printed('waiting') started.child.kill('SIGHUP') - started.child.kill('SIGHUP') const { exitCode, stdout } = await started.exited + expect(readFileSync(hungUp, 'utf8')).toBe(`${started.child.pid}\n`) expect(exitCode).toBe(130) expect(report(stdout)).toEqual([ `uncheck staged in ${project.path(folder)}`, @@ -33,6 +43,23 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) + it('puts the unstaged changes back after Ctrl-C during a slow check', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', SLOW_OXLINT) + + const { exitCode } = await project.uncheckInTerminal(['staged', '--only=oxlint'], { + cwd: folder, + waitFor: 'waiting', + keys: ['\u0003'], + }) + + expect(exitCode).toBe(130) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + it('puts the unstaged changes back when nothing reads its output any more', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) const go = project.path('.git/go') diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts index 419f66b..a8634e6 100644 --- a/packages/uncheck/tests/uncheck/output.test.ts +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -18,6 +18,8 @@ const PRINT_FORCE_COLOR = "console.log('the tool sees FORCE_COLOR=' + process.en const STANDALONE_PROJECTS = [1, 2, 3, 4, 5].map((index) => `project-${index}`) +const HOLD_AT_CAP_MS = 300 + function countConcurrentRuns(cap: number, total: number): string { return `const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); @@ -26,6 +28,7 @@ const marker = config.replaceAll('/', '_'); const count = (dir) => readdirSync(dir).length; const giveUp = Date.now() + 10_000; let most = 0; +let reachedCap; console.log(\`\${config} started\`); mkdirSync('running', { recursive: true }); @@ -36,7 +39,13 @@ writeFileSync(\`running/\${marker}\`, ''); const running = count('running'); most = Math.max(most, running); - if (running === ${cap} || running + count('finished') === ${total}) { + if (running >= ${cap}) { + reachedCap ??= Date.now(); + } + + const heldAtCap = reachedCap !== undefined && Date.now() - reachedCap >= ${HOLD_AT_CAP_MS}; + + if (heldAtCap || running + count('finished') === ${total}) { console.log(\`\${config} saw \${most} running\`); renameSync(\`running/\${marker}\`, \`finished/\${marker}\`); } else if (Date.now() > giveUp) { diff --git a/packages/uncheck/tests/utils/project.test.ts b/packages/uncheck/tests/utils/project.test.ts new file mode 100644 index 0000000..8da4eca --- /dev/null +++ b/packages/uncheck/tests/utils/project.test.ts @@ -0,0 +1,29 @@ +import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { Project, temporaryDirectory } from './project' + +describe('test projects', () => { + it('change nothing through a link to a folder outside their temporary folder', () => { + const outside = mkdtempSync(join(tmpdir(), 'uncheck-outside-')) + const manifest = join(outside, 'package.json') + + onTestFinished(() => rmSync(outside, { recursive: true, force: true })) + writeFileSync(manifest, '{}\n') + chmodSync(manifest, 0o644) + + const project = new Project(temporaryDirectory()).link('linked', outside) + + expect(() => project.write({ 'linked/dist/cli.js': '' })).toThrow(' is outside ') + expect(() => project.write({ 'linked/package.json': null })).toThrow(' is outside ') + expect(() => project.chmod('linked/package.json', 0o600)).toThrow(' is outside ') + expect(() => project.link('linked/bin/cli', 'cli.js')).toThrow(' is outside ') + + project.write({ linked: null }) + + expect(project.exists('linked')).toBe(false) + expect(readdirSync(outside)).toEqual(['package.json']) + expect(statSync(manifest).mode & 0o7777).toBe(0o644) + }) +}) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 28441b3..f758f80 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -57,7 +57,7 @@ const PACKAGE = fileURLToPath(new URL('../..', import.meta.url)) const REGISTER = fileURLToPath(new URL('register.ts', import.meta.url)) export const CLI = [process.execPath, '--import', REGISTER, join(PACKAGE, 'src/bin.ts')] as const -/** What the CLI looks for in every folder above a project, so one above the tests leaks into them all. */ +/** What the CLI and the tools it runs read above a project, so one above the tests leaks into them all. */ const PROJECT_MARKERS = [ 'package.json', 'node_modules', @@ -68,6 +68,15 @@ const PROJECT_MARKERS = [ 'bun.lock', 'bun.lockb', 'package-lock.json', + '.editorconfig', + '.oxfmtrc.json', + '.oxfmtrc.jsonc', + 'oxfmt.config.ts', + 'oxfmt.config.mts', + '.oxlintrc.json', + '.oxlintrc.jsonc', + 'oxlint.config.ts', + 'oxlint.config.mts', ] for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { @@ -134,6 +143,19 @@ function inside(path: string): string { return path } +// Tools are linked from the shared pnpm store, which a change made through the link would corrupt. +function landsInside(path: string): string { + let existing = path + + while (!existsSync(existing)) { + existing = dirname(existing) + } + + inside(realpathSync(existing)) + + return path +} + export function temporaryDirectory(): string { return mkdtempSync(join(ROOT, 'tmp-')) } @@ -153,6 +175,8 @@ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { TERM: 'xterm-256color', GIT_CEILING_DIRECTORIES: ROOT, GIT_CONFIG_GLOBAL: GIT_CONFIG, + // git still reads ~/.config/git/ignore and attributes when GIT_CONFIG_GLOBAL points elsewhere. + XDG_CONFIG_HOME: join(ROOT, 'config'), GIT_CONFIG_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'uncheck', GIT_AUTHOR_EMAIL: 'uncheck@example.com', @@ -295,13 +319,13 @@ export class Project { const target = this.path(file) if (content === null) { + landsInside(dirname(target)) rmSync(target, { recursive: true, force: true }) continue } + landsInside(target) mkdirSync(dirname(target), { recursive: true }) - // Tools are linked from the shared pnpm store, which a write through the link would corrupt. - inside(realpathSync(existsSync(target) ? target : dirname(target))) writeFileSync(target, typeof content === 'string' ? content : json(content, file)) } @@ -322,7 +346,7 @@ export class Project { } chmod(file: string, mode: number): this { - chmodSync(this.path(file), mode) + chmodSync(landsInside(this.path(file)), mode) return this } @@ -337,7 +361,7 @@ export class Project { } link(file: string, target: string): this { - mkdirSync(dirname(this.path(file)), { recursive: true }) + mkdirSync(landsInside(dirname(this.path(file))), { recursive: true }) symlinkSync(target, this.path(file)) return this From c4e04125cb1fc2fed4f3a87c57b117e9e3255a07 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:04:09 +0700 Subject: [PATCH 02/25] fix(uncheck): keep node_modules and links leaving the project out of file lists --- packages/uncheck/README.md | 6 +- packages/uncheck/src/checks/oxfmt.ts | 10 +- packages/uncheck/src/checks/oxlint.ts | 16 +-- packages/uncheck/src/commands/staged.ts | 6 +- packages/uncheck/src/commands/uncheck.ts | 4 +- packages/uncheck/src/files.ts | 110 +++++++++++++++--- .../uncheck/tests/hooks/run/changes.test.ts | 42 ++++++- packages/uncheck/tests/staged/fix.test.ts | 31 +++++ packages/uncheck/tests/uncheck/cli.test.ts | 2 +- packages/uncheck/tests/uncheck/fix.test.ts | 34 +++++- packages/uncheck/tests/uncheck/output.test.ts | 14 ++- .../tests/uncheck/paths-exclusions.test.ts | 9 ++ .../uncheck/tests/uncheck/paths-git.test.ts | 7 +- .../uncheck/tests/uncheck/paths-tools.test.ts | 27 ++++- .../tests/uncheck/paths-unmatched.test.ts | 25 ++++ .../uncheck/tests/uncheck/paths-walk.test.ts | 25 +++- packages/uncheck/tests/uncheck/paths.test.ts | 53 ++++++++- .../uncheck/tests/uncheck/presets.test.ts | 8 +- .../uncheck/tests/uncheck/project.test.ts | 24 ++-- .../uncheck/tests/uncheck/selection.test.ts | 12 +- packages/uncheck/tests/uncheck/sherif.test.ts | 6 +- packages/uncheck/tests/uncheck/tools.test.ts | 22 ++-- packages/uncheck/tests/uncheck/tsc.test.ts | 25 +++- 23 files changed, 418 insertions(+), 100 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f733fd1..659f604 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -47,9 +47,9 @@ uncheck needs Node 22.20 or later. Install only the tools you want: a check runs $ npx uncheck uncheck in /home/me/my-app ○ sherif skipped, not installed -▶ oxlint +▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern ✔ oxlint passed 67ms -▶ oxfmt --check +▶ oxfmt --check --no-error-on-unmatched-pattern Format issues found in above 2 files. Run without `--check` to fix. ✘ oxfmt failed 65ms ▶ tsc -p tsconfig.json --noEmit @@ -83,7 +83,7 @@ npx uncheck src '!src/generated' # a directory, minus a part of it npx uncheck '!**/*.gen.ts' # everything except some files ``` -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`. +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, but never links that point outside the directory uncheck runs in. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. 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. diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts index 80336c9..be733cf 100644 --- a/packages/uncheck/src/checks/oxfmt.ts +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -14,17 +14,13 @@ export const oxfmt: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - const args = fix ? [] : ['--check'] + // A folder with nothing oxfmt handles, or a given file it does not (a .txt file), is no failure. + const args = [...(fix ? [] : ['--check']), '--no-error-on-unmatched-pattern'] 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, - })) + return argvBatches(files).map((batch) => ({ bin, args, files: batch })) }), } diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts index 0d4fcc3..6b0fffa 100644 --- a/packages/uncheck/src/checks/oxlint.ts +++ b/packages/uncheck/src/checks/oxlint.ts @@ -14,17 +14,19 @@ export const oxlint: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - const args = fix ? ['--fix'] : [] + const args = [ + ...(fix ? ['--fix'] : []), + // oxlint walks into node_modules unless an ignore file says not to, and a fix there rewrites + // installed packages, through pnpm's hard links even those of its shared store. + ...(files === undefined ? ['--ignore-pattern=node_modules'] : []), + // A folder with nothing oxlint handles, or a given file it does not (a .md file), is no failure. + '--no-error-on-unmatched-pattern', + ] 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, - })) + return argvBatches(files).map((batch) => ({ bin, args, files: batch })) }), } diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index dac2182..b9f212e 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -4,7 +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 { existingFiles, inNodeModules } from '../files' import { git, gitBytes, GitFailed, gitLocation, gitPaths } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' @@ -227,7 +227,9 @@ const rawDiff = (cwd: string, ...args: ReadonlyArray) => 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), + entries + .filter((entry) => REGULAR_FILE_MODE.test(entry.toMode) && !inNodeModules(entry.file)) + .map((entry) => entry.file), ) const partiallyStaged = Effect.fn(function* ( diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 6635846..7cc410c 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -11,7 +11,7 @@ import { oxlint } from '../checks/oxlint' import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' import { CheckFailed, platformMessage, userError } from '../errors' -import { existingFiles, listProjectFiles, resolvePaths } from '../files' +import { checkableFiles, listProjectFiles, resolvePaths } from '../files' import { bold, dim, green, listFiles, red } from '../style' import { captureLines, execute } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' @@ -142,7 +142,7 @@ export const checkPaths = Effect.fn(function* ( if (paths.length > 0) { const resolved = literal - ? { files: yield* existingFiles(paths, cwd), unmatched: [] } + ? { files: yield* checkableFiles(paths, cwd), unmatched: [] } : yield* resolvePaths(paths, cwd, projectFiles) if (resolved.unmatched.length > 0 && !allowUnmatched) { diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 16a9c8a..422d056 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -1,4 +1,4 @@ -import { existsSync, statSync } from 'node:fs' +import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs' import { Effect, FileSystem, Path, Predicate } from 'effect' import type { ChildProcessSpawner } from 'effect/unstable/process' @@ -21,13 +21,19 @@ export type ProjectFiles = Effect.Effect< */ export function listProjectFiles(cwd: string): ProjectFiles { return gitPaths(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe( - // 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.map((files) => files.filter((file) => !inNodeModules(file))), Effect.catch(() => walk(cwd)), - Effect.map((files) => [...files].sort()), + // git lists a file with merge conflicts once per side. + Effect.map((files) => [...new Set(files)].sort()), ) } +// Ignore rules can miss it (a linked one, a nested one under `/node_modules`, or no rule at all), and a +// fix there rewrites installed packages, through pnpm's hard links even those of its shared store. +export function inNodeModules(file: string): boolean { + return /(?:^|\/)node_modules(?:\/|$)/.test(file) +} + /** * The files changed since the last commit, relative to `cwd`: modified or staged tracked files plus * untracked ones, ignored files excluded. `undefined` outside a git repository or before the first @@ -47,7 +53,9 @@ export function listChangedFiles( ], { concurrency: 'unbounded' }, ).pipe( - Effect.map(([tracked, untracked]) => [...new Set([...tracked, ...untracked])].sort()), + Effect.map(([tracked, untracked]) => + [...new Set([...tracked, ...untracked])].filter((file) => !inNodeModules(file)).sort(), + ), Effect.orElseSucceed(() => undefined), ) } @@ -60,12 +68,26 @@ export const resolvePaths = Effect.fn(function* ( ) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - // A backslash is a glob escape on POSIX, never a separator. - const relative = (pattern: string) => - path.relative(cwd, path.resolve(cwd, pattern)).split(path.sep).join('/') + const realCwd = realpathSync(cwd) + const isCheckable = checkableFile(path, cwd, realCwd) + + const relative = (pattern: string) => { + const resolved = path.resolve(cwd, pattern) + const lexical = slashedRelative(path, cwd, resolved) + + if (!isOutside(path, lexical)) { + return lexical + } + + // The current directory is a real path, while an absolute path may run through a linked folder. + const real = slashedRelative(path, realCwd, resolveFolders(path, resolved)) + + return isOutside(path, real) ? lexical : real + } const includes = patterns.filter((pattern) => !pattern.startsWith('!')) const matched = new Set() + const named = new Set() const unmatched: string[] = [] let universe: ReadonlyArray | undefined @@ -73,7 +95,7 @@ export const resolvePaths = Effect.fn(function* ( const target = relative(pattern) // oxlint and oxfmt reject a path containing "..". - if (target === '..' || target.startsWith('../') || path.isAbsolute(target)) { + if (isOutside(path, target)) { return yield* userError( `${pattern} is outside ${cwd}, run from a folder that contains it or pass one with --cwd`, ) @@ -87,6 +109,7 @@ export const resolvePaths = Effect.fn(function* ( if (kind === 'File') { matched.add(target) + named.add(target) continue } @@ -106,7 +129,7 @@ export const resolvePaths = Effect.fn(function* ( matched.add(hit) } - if (hits.length === 0) { + if (!hits.some((hit) => isCheckable(hit))) { unmatched.push(pattern) } } @@ -125,28 +148,79 @@ export const resolvePaths = Effect.fn(function* ( return (file: string) => file === target || file.startsWith(`${target}/`) } - return glob(target) + const matches = glob(target) + const matchesInside = glob(`${target}/**`) + + return (file: string) => matches(file) || matchesInside(file) }) // 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)) && isFile(path.resolve(cwd, file)), + (file) => + !excludes.some((excluded) => excluded(file)) && (named.has(file) || isCheckable(file)), ), ) return { files: files.sort(), unmatched } }) +/** The given files the tools may get, the way a directory or glob keeps them. */ +export const checkableFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { + const path = yield* Path.Path + const isCheckable = checkableFile(path, cwd, realpathSync(cwd)) + + return files.filter((file) => isCheckable(file)) +}) + const GLOB_CHARACTERS = /[*?[\]{}()]/ -// git lists a linked folder as one file, which the tools would check through the link. -function isFile(file: string): boolean { - try { - return statSync(file).isFile() - } catch { - return false +// git lists a linked folder as one file, and a fix through a link rewrites its target, so of the links +// only those to a file inside `cwd` are kept. +function checkableFile(path: Path.Path, cwd: string, realCwd: string): (file: string) => boolean { + return (file) => { + const absolute = path.resolve(cwd, file) + + try { + const info = lstatSync(absolute) + + if (!info.isSymbolicLink()) { + return info.isFile() + } + + const target = realpathSync(absolute) + + return statSync(target).isFile() && !isOutside(path, slashedRelative(path, realCwd, target)) + } catch { + return false + } + } +} + +function isOutside(path: Path.Path, relative: string): boolean { + return relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) +} + +function slashedRelative(path: Path.Path, from: string, to: string): string { + // A backslash is a glob escape on POSIX, never a separator. + return path.relative(from, to).split(path.sep).join('/') +} + +/** `file` with every linked folder on its way resolved. A linked file keeps its own name. */ +function resolveFolders(path: Path.Path, file: string): string { + const rest: string[] = [] + let folder = file + + while (!isDirectory(folder)) { + rest.unshift(path.basename(folder)) + folder = path.dirname(folder) } + + return path.join(realpathSync(folder), ...rest) +} + +function isDirectory(file: string): boolean { + return statSync(file, { throwIfNoEntry: false })?.isDirectory() === true } /** Dot files match too, as they do for oxfmt and for a directory given as it is. */ diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index 14199b6..d42a6db 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -1,6 +1,8 @@ +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS, report } from '../../utils/project' +import { LAYOUTS, report, temporaryDirectory } from '../../utils/project' import { CLAUDE_CODE_STOP, stopHook } from './utils' const UNFORMATTED = 'export const legacy = 1\n' @@ -53,6 +55,38 @@ describe.each(LAYOUTS)( expect(project.read(`${app}dist/bundle.js`)).toBe('export var bundle = 1\n') }) + it('leaves installed packages that no ignore rule covers and links leaving the project alone', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'shared.ts'), UNFORMATTED) + const dependency = `${app}lib/node_modules/dep/index.js` + const project = create({ '.gitignore': '/node_modules\ndist\n*.tsbuildinfo\n' }) + .write({ + [dependency]: 'export var dep = 1\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + .link(`${app}src/shared.ts`, join(store, 'shared.ts')) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxlint', '--only=oxfmt'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.read(dependency)).toBe('export var dep = 1\n') + expect(readFileSync(join(store, 'shared.ts'), 'utf8')).toBe(UNFORMATTED) + }) + it('leaves a file deleted since the last commit out, even one whose name reads as a pattern', async () => { const project = create({ [`${app}src/[id].ts`]: 'export const id = 1;\n', @@ -102,9 +136,9 @@ describe.each(LAYOUTS)( expect(report(stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not a workspace root', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -127,7 +161,7 @@ describe.each(LAYOUTS)( `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✘ oxfmt failed', '○ tsc skipped, not selected by --only', '✘ 1 of 1 checks failed: oxfmt', diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 445fdef..150264a 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -183,6 +183,37 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) ) }) + it('leaves staged files under node_modules alone', async () => { + const dependency = `${app}vendor/node_modules/dep/index.js` + const project = create().write({ + [dependency]: 'export var dep = 1\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + project.git('add', '--force', '--', dependency, `${app}src/extra.ts`) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + '✔ staged the fixes to src/extra.ts', + ]) + expect(inIndex(project, dependency)).toBe('export var dep = 1\n') + expect(project.read(dependency)).toBe('export var dep = 1\n') + }) + it('fixes and stages exactly the staged files, whatever their names look like', async () => { const project = create({ [`${app}routes/i/page.ts`]: 'export const i = 1;\n' }) diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts index 0994105..361bebd 100644 --- a/packages/uncheck/tests/uncheck/cli.test.ts +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -19,7 +19,7 @@ function onlyOxlint(dir: string): string[] { return [ `uncheck in ${dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts index 7507ffa..be722b7 100644 --- a/packages/uncheck/tests/uncheck/fix.test.ts +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -5,6 +5,7 @@ import { CODE_WITH_VAR, layoutChecks, monorepoWithMismatchedVersions, + selectedReport, UNFORMATTED_CODE, } from './utils' @@ -22,9 +23,9 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix', + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt', + '▶ oxfmt --no-error-on-unmatched-pattern', '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -76,7 +77,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', '✘ oxfmt not installed', '○ tsc skipped, disabled with --skip=tsc', @@ -98,7 +99,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --fix', + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -107,6 +108,27 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(exitCode).toBe(1) expect(project.read(`${app}src/log.ts`)).toBe('console.log("hello");\n') }) + + it('never lints or fixes installed packages that no ignore rule covers', async () => { + const dependency = 'packages/a/node_modules/dep/index.js' + const project = create({ '.gitignore': '/node_modules\n' }).write({ + [dependency]: CODE_WITH_VAR, + }) + + const check = await project.uncheck(['--only=oxlint']) + const fix = await project.uncheck(['--fix', '--only=oxlint']) + + expect(check.stdout).not.toContain(dependency) + expect(selectedReport(check.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(check.exitCode).toBe(0) + expect(fix.exitCode).toBe(0) + expect(project.read(dependency)).toBe(CODE_WITH_VAR) + }) }) function zodVersions(project: Project) { @@ -163,9 +185,9 @@ describe('uncheck --fix in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint --fix', + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt', + '▶ oxfmt --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', '✘ 1 of 3 checks failed: sherif', diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts index 419f66b..5fcc6a1 100644 --- a/packages/uncheck/tests/uncheck/output.test.ts +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -61,14 +61,14 @@ describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app, tsc }) => { const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) expect(stdout).toMatch( - /^▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, + /^▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, @@ -152,7 +152,7 @@ describe('uncheck output', () => { `${paint('dim', '○')} ${paint('bold', 'sherif')} ${paint('dim', 'skipped, not selected by --only')}\n`, ) expect(stdout).toContain( - `${paint('dim', '▶')} ${paint('bold', 'oxfmt')} ${paint('dim', '--check')}\nthe tool sees FORCE_COLOR=1\n`, + `${paint('dim', '▶')} ${paint('bold', 'oxfmt')} ${paint('dim', '--check --no-error-on-unmatched-pattern')}\nthe tool sees FORCE_COLOR=1\n`, ) expect(stdout).toContain( `${paint('green', '✔')} ${paint('bold', 'oxfmt')} ${paint('green', 'passed')} `, @@ -162,7 +162,7 @@ describe('uncheck output', () => { `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', @@ -200,7 +200,9 @@ describe('uncheck output', () => { const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) - expect(stdout).toContain('▶ oxlint\nthe tool sees FORCE_COLOR=undefined\n') + expect(stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\nthe tool sees FORCE_COLOR=undefined\n', + ) expect(stdout).not.toContain('\u001B[') expect(exitCode).toBe(0) }) diff --git a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts index d8fa3e1..9dd0f32 100644 --- a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts +++ b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts @@ -71,6 +71,15 @@ describe.each(LAYOUTS)('uncheck with exclusions in a $name', ({ create, app }) = ) }) + it('leaves out everything below the folders a glob matches', async () => { + const project = routesProject() + + expect(await check(project, '.', '!**/nested')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + expect(await check(project, '.', '!*/more')).toEqual(without('nested/more/deeper.ts')) + }) + it('leaves out the files a glob matches, dot files included', async () => { const project = routesProject() diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index b648a4c..4e5ab14 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -94,7 +94,7 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ } }) - it('checks a linked file but never a linked folder or a broken link, as the walk outside git', async () => { + it('checks a linked file inside the project but never a linked folder or a broken link, as the walk outside git', async () => { const store = temporaryDirectory() writeFileSync(join(store, 'vendor.ts'), CODE_WITH_VAR) const project = create({ @@ -105,6 +105,7 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ .link(`${routes}/broken.ts`, 'missing.ts') .link(`${routes}/linked`, 'shared') .link(`${routes}/vendor`, store) + .link(`${routes}/outside.ts`, join(store, 'vendor.ts')) .commit() const check = await project.uncheck(['--only=oxlint', routes]) @@ -113,6 +114,7 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ expect(check.stdout).toContain(`${routes}/shared/util.ts:1:1`) expect(check.stdout).not.toContain(`${routes}/linked/`) expect(check.stdout).not.toContain(`${routes}/vendor/`) + expect(check.stdout).not.toContain(`${routes}/outside.ts`) expect(selectedReport(check.stdout)).toEqual([ `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`, @@ -124,6 +126,9 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ const fix = await project.uncheck(['--only=oxlint', '--fix', routes]) expect(fix.exitCode).toBe(0) + expect(selectedReport(fix.stdout)).toContain( + `▶ oxlint --fix --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`, + ) expect(project.read(`${routes}/shared/util.ts`)).toBe('const count = 1;\nexport { count };\n') expect(readFileSync(join(store, 'vendor.ts'), 'utf8')).toBe(CODE_WITH_VAR) }) diff --git a/packages/uncheck/tests/uncheck/paths-tools.test.ts b/packages/uncheck/tests/uncheck/paths-tools.test.ts index 4ea4638..286a32f 100644 --- a/packages/uncheck/tests/uncheck/paths-tools.test.ts +++ b/packages/uncheck/tests/uncheck/paths-tools.test.ts @@ -52,15 +52,38 @@ describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ creat expect(everything.exitCode).toBe(1) expect(selectedReport(everything.stdout)).toEqual([ `uncheck in ${project.path(app, 'src/routes')}`, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '✘ 1 of 2 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', ]) }) + it('passes oxlint and oxfmt without paths in a folder holding nothing they handle', async () => { + const project = create({ + [`${app}config/base.json`]: { compilerOptions: { strict: true } }, + [`${app}notes/todo.txt`]: 'var draft\n', + }) + + for (const folder of ['config', 'notes']) { + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS, { + cwd: `${app}${folder}`, + }) + + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, folder)}`, + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern', + '✔ oxfmt passed', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + } + }) + it('fixes only the given files', async () => { const broken = 'var point = {x:1,\n y:2}\nexport { point }\n' const project = create({ [`${app}src/given.ts`]: broken, [`${app}src/other.ts`]: broken }) diff --git a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts index c66eb52..58ce873 100644 --- a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts +++ b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts @@ -1,6 +1,7 @@ import { mkdirSync } from 'node:fs' import { cliError, LAYOUTS, report } from '../utils/project' +import { CLEAN_CODE } from './utils' describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app }) => { it('fails before checking anything and names every path that matched no file', async () => { @@ -24,6 +25,30 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app ) }) + it('fails a glob or directory whose only git entries are no files', async () => { + const project = create({ + [`${app}legacy/old.ts`]: CLEAN_CODE, + [`${app}shared/util.ts`]: CLEAN_CODE, + }) + .link(`${app}links/shared`, '../shared') + .commit() + .write({ [`${app}legacy/old.ts`]: null }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + `${app}legacy/*.ts`, + `${app}links`, + ]) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) + expect(stderr).toBe( + cliError( + `No files match ${app}legacy/*.ts, ${app}links. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), + ) + }) + it('checks whatever matched with --no-error-on-unmatched-pattern', async () => { const project = create() diff --git a/packages/uncheck/tests/uncheck/paths-walk.test.ts b/packages/uncheck/tests/uncheck/paths-walk.test.ts index f3951e9..f9a4901 100644 --- a/packages/uncheck/tests/uncheck/paths-walk.test.ts +++ b/packages/uncheck/tests/uncheck/paths-walk.test.ts @@ -1,5 +1,8 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + import type { Project } from '../utils/project' -import { LAYOUTS, PERMISSIONS_ENFORCED } from '../utils/project' +import { LAYOUTS, PERMISSIONS_ENFORCED, temporaryDirectory } from '../utils/project' import { checkedFiles, listingProject } from './utils' describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, app }) => { @@ -52,16 +55,28 @@ describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, a ]) }) - it('checks a file the walk skips when it is named', async () => { - const project = walkedProject('.env.ts', 'node_modules/dep/index.ts') - const named = [`${routes}/.env.ts`, `${routes}/node_modules/dep/index.ts`] + it('checks a file the walk skips or a link leaving the project when it is named', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'vendor.ts'), '') + const project = walkedProject('.env.ts', 'node_modules/dep/index.ts').link( + `${routes}/vendor.ts`, + join(store, 'vendor.ts'), + ) + const named = [ + `${routes}/.env.ts`, + `${routes}/node_modules/dep/index.ts`, + `${routes}/vendor.ts`, + ] expect(await check(project, ...named)).toEqual(named) }) - it('lists a linked file but never enters a linked folder or follows a broken link', async () => { + it('lists a linked file inside the project but never enters a linked folder or follows a broken link', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'vendor.ts'), '') const project = walkedProject('home.ts', 'shared/util.ts') .link(`${routes}/alias.ts`, 'home.ts') + .link(`${routes}/vendor.ts`, join(store, 'vendor.ts')) .link(`${routes}/broken.ts`, 'missing.ts') .link(`${routes}/self.ts`, 'self.ts') .link(`${routes}/up`, '..') diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index c4807c2..9be50d5 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -1,4 +1,4 @@ -import { writeFileSync } from 'node:fs' +import { symlinkSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { cliError, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' @@ -101,6 +101,57 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, ]) }) + it('takes an absolute path through a linked folder to the directory it runs in', async () => { + const routes = `${app}src/routes` + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/legacy.ts`]: CODE_WITH_VAR, + }) + const links = temporaryDirectory() + const linked = join(links, 'linked') + const elsewhere = temporaryDirectory() + writeFileSync(join(elsewhere, 'shared.ts'), CLEAN_CODE) + symlinkSync(project.dir, linked) + symlinkSync(elsewhere, join(links, 'elsewhere')) + + const through = await project.uncheck([ + '--only=oxlint', + join(linked, routes, 'home.ts'), + join(linked, routes, '*.ts'), + `!${join(linked, routes, 'legacy.ts')}`, + ]) + const fromLink = await project.uncheck([ + '--only=oxlint', + `--cwd=${linked}`, + project.path(routes, 'home.ts'), + ]) + const outside = join(links, 'elsewhere/shared.ts') + const rejected = await project.uncheck(['--only=oxlint', outside]) + + expect(through.stderr).toBe('') + expect(through.exitCode).toBe(0) + expect(selectedReport(through.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(fromLink.stderr).toBe('') + expect(fromLink.exitCode).toBe(0) + expect(selectedReport(fromLink.stdout)).toEqual([ + `uncheck in ${linked}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(rejected.exitCode).toBe(1) + expect(rejected.stderr).toBe( + cliError( + `${outside} is outside ${project.dir}, run from a folder that contains it or pass one with --cwd`, + ), + ) + }) + it('fails naming a file, directory or glob above the directory it runs in', async () => { const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE, diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts index 734b43d..f67f8df 100644 --- a/packages/uncheck/tests/uncheck/presets.test.ts +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -110,9 +110,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✘ oxfmt failed', tsc, '✘ tsc failed', @@ -158,9 +158,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix', + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', - '▶ oxfmt', + '▶ oxfmt --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts index c6873e4..0909739 100644 --- a/packages/uncheck/tests/uncheck/project.test.ts +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -19,9 +19,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -41,9 +41,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✘ oxlint failed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -62,9 +62,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -85,9 +85,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', tsc, '✘ tsc failed', @@ -105,9 +105,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', `✘ tsc found ${tsconfigs} tsconfig.json but typescript is not installed`, `✘ 1 of ${checks.length} checks failed: tsc`, @@ -127,9 +127,9 @@ describe('uncheck in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '▶ tsc -b tsconfig.json', '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index f07f190..6326219 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -12,7 +12,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', tsc, @@ -30,7 +30,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '○ oxfmt skipped, disabled with --skip=oxfmt', '○ tsc skipped, disabled with --skip=tsc', @@ -47,7 +47,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(skipped.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '○ oxfmt skipped, not installed', '○ tsc skipped, not selected by --only', @@ -60,7 +60,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(required.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '✘ oxfmt not installed', '○ tsc skipped, not selected by --only', @@ -81,9 +81,9 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts index af4fb87..c7f168c 100644 --- a/packages/uncheck/tests/uncheck/sherif.test.ts +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -24,7 +24,7 @@ describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -96,9 +96,9 @@ describe('uncheck sherif in a single repo', () => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '✘ sherif not a workspace root', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '▶ tsc -p tsconfig.json --noEmit', '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/tools.test.ts b/packages/uncheck/tests/uncheck/tools.test.ts index b81c8fa..a0e2e13 100644 --- a/packages/uncheck/tests/uncheck/tools.test.ts +++ b/packages/uncheck/tests/uncheck/tools.test.ts @@ -49,12 +49,14 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } const { exitCode, stdout } = await project.uncheck(['--only=oxfmt']) - expect(stdout).toContain('▶ oxfmt --check\noxfmt ran with --check\n') + expect(stdout).toContain( + '▶ oxfmt --check --no-error-on-unmatched-pattern\noxfmt ran with --check --no-error-on-unmatched-pattern\n', + ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', @@ -94,9 +96,9 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } expect(report(stdout)).toEqual([ `uncheck in ${project.path(app, 'src')}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', @@ -119,13 +121,15 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } env: { NODE_OPTIONS: `--require ${project.path('.pnp.cjs')}` }, }) - expect(stdout).toContain('▶ oxlint\noxlint from the Yarn cache\n') + expect(stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\noxlint from the Yarn cache\n', + ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', '✔ oxlint passed', - '▶ oxfmt --check', + '▶ oxfmt --check --no-error-on-unmatched-pattern', '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -147,7 +151,9 @@ describe('uncheck finding tools in a monorepo', () => { const inPackage = await project.uncheck(['--only=oxlint'], { cwd: 'packages/app' }) - expect(inPackage.stdout).toContain('▶ oxlint\noxlint of packages/app\n') + expect(inPackage.stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\noxlint of packages/app\n', + ) expect(inPackage.exitCode).toBe(0) const atRoot = await project.uncheck(['--only=oxlint']) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 9679ca3..931d80b 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,7 +1,7 @@ -import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, singleRepo } from '../utils/project' +import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, run, singleRepo } from '../utils/project' import { ALLOW_JS, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' -describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { +describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { it('skips the check when the project has no tsconfig.json', async () => { const project = create({ [`${app}tsconfig.json`]: null }) @@ -23,6 +23,27 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(await tscPlan(project, app)).toEqual(['○ tsc skipped, no tsconfig.json found']) }) + it('checks a tsconfig.json with merge conflicts once', async () => { + const project = withFakeTsc(create) + const config = `${app}tsconfig.json` + const include = (folder: string) => (value: Record) => ({ + ...value, + include: ['src', folder], + }) + + project.git('checkout', '--quiet', '-b', 'side') + project.update(config, include('side')).commit('side') + project.git('checkout', '--quiet', 'main') + project.update(config, include('main')).commit('main') + + expect((await run(['git', 'merge', 'side'], { cwd: project.dir })).exitCode).toBe(1) + + project.git('checkout', '--theirs', '--', config) + + expect(project.git('ls-files', '--', config)).toBe(`${config}\n`.repeat(3)) + expect(await tscPlan(project, app)).toEqual([tsc]) + }) + it('skips the check when no tsconfig.json covers the given files', async () => { const project = withFakeTsc(create, { [`${app}README.md`]: '# App\n' }) From 22d3294cb8536b7d693655f91257e4336d490312 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:05:24 +0700 Subject: [PATCH 03/25] fix(uncheck): skip pre-commit package lines with nothing staged or no folder, place hook lines around husky 4 and quoted setup --- packages/uncheck/README.md | 6 +- packages/uncheck/src/commands/prepare.ts | 67 +++++++-- packages/uncheck/tests/prepare/commit.test.ts | 137 +++++++++++++++++- .../tests/prepare/existing-hook.test.ts | 124 +++++++++++++++- .../tests/prepare/package-manager.test.ts | 4 +- packages/uncheck/tests/prepare/utils.ts | 19 ++- 6 files changed, 334 insertions(+), 23 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f733fd1..de2a0ea 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -157,14 +157,14 @@ Run uncheck from the workspace root, the folder whose `package.json` has `worksp - 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: +**Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags. A package's line runs only when the commit changes files in that package: ```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 +git --literal-pathspecs diff --cached --quiet -- "packages/a" || [ ! -d "packages/a" ] || (cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1 +git --literal-pathspecs diff --cached --quiet -- "packages/b" || [ ! -d "packages/b" ] || (cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1 ``` An agent hook installed from a package folder checks only that package, wherever the agent moves to. diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 66b8761..29d4f2a 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -20,9 +20,13 @@ const EXIT = ' || exit 1' * A line that enters a directory first, since git runs hooks at the top of the working tree, in a * subshell so the `cd` does not carry over to the next line. */ +const ENTERS_WHEN_STAGED = + /^git --literal-pathspecs diff --cached --quiet -- "([^"]*)" \|\| \[ ! -d "\1" \] \|\| \(cd "\1" && (.*)\)$/ const ENTERS = /^\(cd "([^"]*)" && (.*)\)$/ const OLD_ENTERS = /^cd "([^"]*)" && (.*)$/ +const CWD_FLAG = / --cwd=/ + /** 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)/ @@ -33,9 +37,17 @@ const NOT_TEXT = /[\0\uFFFD]/ const SETUP = /^\s*(?:#|$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ +/** husky 4 sources this runner, which always exits, so nothing after it runs. */ +const HUSKY_4_RUNNER = /^\s*\.\s+"\$\(dirname "\$0"\)\/husky\.sh"\s*$/ + +const INNERMOST_SUBSTITUTION = /\$\([^()]*\)/g + +// A `\"` outside quotes opens nothing, and a `"` between single quotes opens nothing either. +const QUOTED = /\\.|"(?:[^"\\]|\\.)*"|'[^']*'/g + /** A setup line that runs on into the next, where the added line would join it. */ const CONTINUES = - /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$)/ + /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<)/ /** * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends @@ -43,23 +55,53 @@ const CONTINUES = */ const UNQUOTABLE = /["$`\\\n]/ +// Without --literal-pathspecs, git reads a folder whose name starts with `:` as pathspec magic. function hookLine(inside: string, command: string): string { - return inside === '' ? `${command}${EXIT}` : `(cd "${inside}" && ${command})${EXIT}` + return inside === '' + ? `${command}${EXIT}` + : `git --literal-pathspecs diff --cached --quiet -- "${inside}" || [ ! -d "${inside}" ] || (cd "${inside}" && ${command})${EXIT}` } /** * The directory and command of a hook line that runs `uncheck staged`, or `undefined` when the line * is not one `prepare` writes. A line only counts as ours when it runs the command directly or - * through a package manager, so a line that merely mentions it, or runs it some other way, is left - * alone. + * through a package manager, so a line that merely mentions it, or runs it some other way, such as + * in another directory with `--cwd`, is left alone. */ function ownLine(text: string): { readonly inside: string; readonly command: string } | undefined { const line = text.trim() const body = line.endsWith(EXIT) ? line.slice(0, -EXIT.length) : line - const enters = ENTERS.exec(body) ?? OLD_ENTERS.exec(body) + const enters = ENTERS_WHEN_STAGED.exec(body) ?? ENTERS.exec(body) ?? OLD_ENTERS.exec(body) const command = enters?.[2] ?? body - return invokes(command, HOOK_COMMAND) ? { inside: enters?.[1] ?? '', command } : undefined + return invokes(command, HOOK_COMMAND) && !CWD_FLAG.test(command) + ? { inside: enters?.[1] ?? '', command } + : undefined +} + +/** A `;`, `&` or `|` between quotes or in a command substitution does not end the command. */ +function opaque(text: string): string { + const substituted = text.replace(INNERMOST_SUBSTITUTION, '_') + + return substituted === text + ? text.replace(QUOTED, (token) => (token.startsWith('\\') ? token : '""')) + : opaque(substituted) +} + +function beforeHusky4Runner(texts: ReadonlyArray): ReadonlyArray { + const runner = texts.findIndex((text) => HUSKY_4_RUNNER.test(text)) + + if (runner === -1) { + return texts + } + + const rest = texts.slice(runner) + + return [ + ...texts.slice(0, runner), + ...rest.filter((text) => ownLine(text) !== undefined), + ...rest.filter((text) => ownLine(text) === undefined), + ] } // `sh` reads a script while running it, so a hook rewritten in place makes a commit already running @@ -274,12 +316,13 @@ export const prepare = Command.make( /** * 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. + * updated where it sits, or moved up from after husky 4's runner, 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 - const lines = hook.split('\n').flatMap((text) => { + const lines = beforeHusky4Runner(hook.split('\n')).flatMap((text) => { const own = ownLine(text) if (own === undefined) { @@ -311,7 +354,11 @@ function rewrite(hook: string, line: string, inside: string): string { 0, ) const at = - after > 0 ? after : lines.findIndex((text) => !SETUP.test(text) || CONTINUES.test(text)) + after > 0 + ? after + : lines.findIndex( + (text) => !SETUP.test(opaque(text)) || HUSKY_4_RUNNER.test(text) || CONTINUES.test(text), + ) if (at === -1) { const kept = lines.join('\n') diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index 357a49c..ff9b7d7 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -1,6 +1,6 @@ import type { Project } from '../utils/project' import { LAYOUTS, monorepo, report, run } from '../utils/project' -import { installHusky, prepare } from './utils' +import { HUSKY_4_BANNER, HUSKY_4_RUNNER, installHusky, prepare } from './utils' const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } } @@ -8,17 +8,19 @@ const UNFIXABLE = 'export function ignore({}: object): void {}\n' const TSC_WITHOUT_REFERENCES = '▶ tsc -p tsconfig.json --noEmit' +const TSC_WITH_REFERENCES = '▶ tsc -b tsconfig.json' + function commit(project: Project, message: string) { return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.dir }) } -function blockedByLint(dir: string, tsc: string): string[] { +function blockedByLint(dir: string, tsc: string, file = 'src/ignore.ts'): string[] { return [ `uncheck staged in ${dir}`, '○ sherif skipped, no package.json among the given files', - '▶ oxlint --fix --no-error-on-unmatched-pattern src/ignore.ts', + `▶ oxlint --fix --no-error-on-unmatched-pattern ${file}`, '✘ oxlint failed', - '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + `▶ oxfmt --no-error-on-unmatched-pattern ${file}`, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -70,6 +72,23 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create expect(project.git('status', '--porcelain')).toBe('') }) + it('blocks a commit before the runner of husky 4, which always exits', async () => { + const project = create({ '.oxlintrc.json': LINT_CONFIG }) + project.write({ + '.git/hooks/pre-commit': `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n`, + '.git/hooks/husky.sh': 'echo "husky 4 ran" >&2\nexit 0\n', + }) + await prepare(project, [], { cwd: app }) + project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) + + const { exitCode, stderr } = await commit(project, 'ignore') + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) + expect(stderr).not.toContain('husky 4 ran') + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + it('runs through the husky 9 dispatcher before the commands of the hook', async () => { const project = installHusky(create(), 'echo "husky hook ran"\n') await prepare(project, [], { cwd: app }) @@ -104,4 +123,114 @@ describe('committing with the prepared hook of several packages in a monorepo', expect(project.read('packages/app/src/spaced.ts')).toBe('export const spaced = 1\n') expect(project.git('log', '--format=%s')).toBe('init\n') }) + + it('skips the line of a package the commit leaves alone', async () => { + const project = monorepo() + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + project.stage({ 'packages/app/src/spaced.ts': 'export var spaced = 1\n' }) + + const { exitCode, stderr } = await commit(project, 'spaced') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual( + fixedAndStaged(project.path('packages/app'), TSC_WITH_REFERENCES), + ) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + }) + + it('checks the files of `git commit ` in a package', async () => { + const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + project.write({ 'packages/app/src/index.ts': UNFIXABLE }) + + const { exitCode, stderr } = await run( + ['git', 'commit', '--quiet', '--message=ignore', '--', 'packages/app/src/index.ts'], + { cwd: project.dir }, + ) + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual( + blockedByLint(project.path('packages/app'), TSC_WITH_REFERENCES, 'src/index.ts'), + ) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + + it('runs the line of a package the commit only deletes from', async () => { + const project = monorepo() + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + project.git('rm', '--quiet', 'packages/app/src/index.ts') + + const { exitCode, stderr } = await commit(project, 'remove') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + '○ nothing to check, no staged files', + ]) + expect(project.git('log', '--format=%s')).toBe('remove\ninit\n') + }) + + it('skips the line of a package that was moved away', async () => { + const project = monorepo() + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + project.git('mv', 'packages/app', 'packages/web') + await prepare(project, [], { cwd: 'packages/web' }) + + const { exitCode, stderr } = await commit(project, 'rename') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path('packages/web')}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint --fix --no-error-on-unmatched-pattern package.json src/index.ts tsconfig.json', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern package.json src/index.ts tsconfig.json', + '✔ oxfmt passed', + TSC_WITH_REFERENCES, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(project.git('log', '--format=%s')).toBe('rename\ninit\n') + }) + + it('skips the line of a package a sparse checkout leaves out', async () => { + const project = monorepo() + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + project.git('sparse-checkout', 'set', 'packages/core') + project.stage({ 'packages/core/src/spaced.ts': 'export var spaced = 1\n' }) + + const { exitCode, stderr } = await commit(project, 'spaced') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual( + fixedAndStaged(project.path('packages/core'), TSC_WITHOUT_REFERENCES), + ) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + }) + + it('checks a package whose folder name git could read as pathspec magic', async () => { + const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) + project.stage({ ':docs/src/ignore.ts': UNFIXABLE }) + await prepare(project, [], { cwd: ':docs' }) + + const { exitCode, stderr } = await commit(project, 'ignore') + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path(':docs')}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/ignore.ts', + '✘ oxlint failed', + '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + '✔ oxfmt passed', + '○ tsc skipped, no tsconfig.json found', + '✘ 1 of 2 checks failed: oxlint', + ]) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) }) diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 143e3cc..7e288ae 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -2,7 +2,17 @@ import { Buffer } from 'node:buffer' import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, monorepo } from '../utils/project' -import { COMMAND, HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' +import { + COMMAND, + HEADER, + hookLine, + HUSKY_4_BANNER, + HUSKY_4_RUNNER, + notWritten, + prepare, + shownHook, + written, +} from './utils' const HOOK = '.git/hooks/pre-commit' @@ -92,10 +102,59 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(hook).toBe(`${setup}${line}\nnpx lint-staged\n`) }) + it.each([ + ['escaped double quotes', 'export A="\\"" && exec lint B="\\""'], + ['double quotes between single quotes', "export A='\"' && exec lint B='\"'"], + ])('runs before an export that goes on to run a command, with %s', async (_, command) => { + const { hook } = await prepareHook(`#!/bin/sh\n${command}\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${command}\n`) + }) + + it('runs after the lines of nvm that set up its PATH, whose quotes hold commands', async () => { + const setup = [ + '#!/bin/sh', + // oxlint-disable-next-line no-template-curly-in-string + 'export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"', + '[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"', + ].join('\n') + + const { hook } = await prepareHook(`${setup}\nnpx lint-staged\n`) + + expect(hook).toBe(`${setup}\n${line}\nnpx lint-staged\n`) + }) + + it('runs before the runner of husky 4, which always exits', async () => { + const { hook } = await prepareHook(`${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n`) + + expect(hook).toBe(`${HUSKY_4_BANNER}${line}\n${HUSKY_4_RUNNER}\n`) + }) + + it('moves the line an older version wrote after the runner of husky 4 before it', async () => { + const old = app === '' ? line : `(cd "${folder}" && ${COMMAND}) || exit 1` + + const { stdout, hook, project } = await prepareHook( + `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${old}\n`, + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${HUSKY_4_BANNER}${line}\n${HUSKY_4_RUNNER}\n`) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }) + it.each([ ['a trailing backslash', 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n'], ['an open double quote', 'MESSAGE="checks\n. before committing"\n'], ['an open single quote', "MESSAGE='checks\n. before committing'\n"], + ['an open command substitution', 'FILES=$(\n git diff --cached --name-only\n)\n'], + [ + 'a here-document in a command substitution', + 'export MESSAGE=$(cat < { const { hook } = await prepareHook(`#!/bin/sh\n${setup}npx lint-staged\n`) @@ -187,6 +246,65 @@ describe('prepare with an existing hook in a monorepo', () => { ) }) + it('rewrites the lines the previous version wrote in place, then leaves them unchanged', async () => { + const project = monorepo().write({ + [HOOK]: [ + '#!/bin/sh', + '(cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', + '(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1', + 'pnpm test', + '', + ].join('\n'), + }) + + const { stdout } = await prepare(project, [], { cwd: 'packages/app' }) + + expect(stdout).toBe(written(project.path(HOOK), 'updated')) + expect(project.read(HOOK)).toBe( + [ + '#!/bin/sh', + hookLine('packages/core/', `${COMMAND} --only=oxlint`), + hookLine('packages/app/'), + 'pnpm test', + '', + ].join('\n'), + ) + + const again = await prepare(project, [], { cwd: 'packages/app' }) + + expect(again.stdout).toBe(written(project.path(HOOK), 'unchanged')) + }) + + it('moves the lines of the other packages after the runner of husky 4 before it', async () => { + const core = hookLine('packages/core/') + const project = monorepo().write({ + [HOOK]: `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${core}\n`, + }) + + await prepare(project) + + expect(project.read(HOOK)).toBe( + `${HUSKY_4_BANNER}${core}\n${hookLine('')}\n${HUSKY_4_RUNNER}\n`, + ) + }) + + it('leaves alone the lines that enter a package with --cwd and adds its own', async () => { + const lines = [ + `${COMMAND} --cwd=packages/core || exit 1`, + `${COMMAND} --only=oxlint --cwd=packages/app || exit 1`, + ] + const project = monorepo().write({ [HOOK]: `#!/bin/sh\n${lines.join('\n')}\n` }) + + const { stdout } = await prepare(project) + + expect(stdout).toBe(written(HOOK, 'updated')) + expect(project.read(HOOK)).toBe(`#!/bin/sh\n${hookLine('')}\n${lines.join('\n')}\n`) + + const again = await prepare(project) + + expect(again.stdout).toBe(written(HOOK, 'unchanged')) + }) + it('rewrites the lines older versions wrote for the other packages', async () => { const project = monorepo().write({ [HOOK]: [ @@ -207,8 +325,8 @@ describe('prepare with an existing hook in a monorepo', () => { '#!/bin/sh', 'pnpm exec uncheck staged --fix || exit 1', 'pnpm test', - '(cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', - '(cd "packages/app" && pnpm exec uncheck staged) || exit 1', + 'git --literal-pathspecs diff --cached --quiet -- "packages/core" || [ ! -d "packages/core" ] || (cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', + 'git --literal-pathspecs diff --cached --quiet -- "packages/app" || [ ! -d "packages/app" ] || (cd "packages/app" && pnpm exec uncheck staged) || exit 1', '', ].join('\n'), ) diff --git a/packages/uncheck/tests/prepare/package-manager.test.ts b/packages/uncheck/tests/prepare/package-manager.test.ts index 7c6e42f..89eec0c 100644 --- a/packages/uncheck/tests/prepare/package-manager.test.ts +++ b/packages/uncheck/tests/prepare/package-manager.test.ts @@ -97,8 +97,8 @@ describe('prepare picking the package manager in the packages of a monorepo', () expect(project.read('.git/hooks/pre-commit')).toBe( [ HEADER, - '(cd "packages/app" && bunx --no-install uncheck staged --fix) || exit 1\n', - '(cd "packages/core" && pnpm exec uncheck staged --fix) || exit 1\n', + 'git --literal-pathspecs diff --cached --quiet -- "packages/app" || [ ! -d "packages/app" ] || (cd "packages/app" && bunx --no-install uncheck staged --fix) || exit 1\n', + 'git --literal-pathspecs diff --cached --quiet -- "packages/core" || [ ! -d "packages/core" ] || (cd "packages/core" && pnpm exec uncheck staged --fix) || exit 1\n', ].join(''), ) }) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts index 1e04eb4..d5a3ace 100644 --- a/packages/uncheck/tests/prepare/utils.ts +++ b/packages/uncheck/tests/prepare/utils.ts @@ -14,7 +14,11 @@ export function prepare( } export function hookLine(app: string, command = COMMAND): string { - return app === '' ? `${command} || exit 1` : `(cd "${app.slice(0, -1)}" && ${command}) || exit 1` + const folder = app.slice(0, -1) + + return app === '' + ? `${command} || exit 1` + : `git --literal-pathspecs diff --cached --quiet -- "${folder}" || [ ! -d "${folder}" ] || (cd "${folder}" && ${command}) || exit 1` } export function shownHook(project: Project, app: string, hook = '.git/hooks/pre-commit'): string { @@ -42,6 +46,19 @@ export const DISPATCHER = [ '', ].join('\n') +export const HUSKY_4_RUNNER = '. "$(dirname "$0")/husky.sh"' + +export const HUSKY_4_BANNER = [ + '#!/bin/sh', + '# husky', + '', + '# Created by Husky v4.3.8 (https://github.com/typicode/husky#readme)', + '# At: 1/20/2021, 3:58:58 PM', + '# From: /home/me/my-app (https://github.com/me/my-app#readme)', + '', + '', +].join('\n') + export const HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' export function installHusky(project: Project, hook: string): Project { From 0a5ecfb443186630c08cad5dd1636f448ad9e813 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:19:01 +0700 Subject: [PATCH 04/25] fix(uncheck): address review of C1 --- packages/uncheck/tests/utils/project.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index f758f80..c07514f 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -250,10 +250,11 @@ export function runInTerminal( command: ReadonlyArray, { cwd, env, keys = [], waitFor }: Omit & { readonly cwd: string }, ): Promise { + // Without exec, the shell that script starts also gets Ctrl-C and exits 130 whatever the command does. const args = process.platform === 'darwin' ? ['-q', '/dev/null', ...command] - : ['-qfec', command.map(shellQuote).join(' '), '/dev/null'] + : ['-qfec', `exec ${command.map(shellQuote).join(' ')}`, '/dev/null'] return new Promise((resolve, reject) => { const child = spawn('script', args, { cwd: inside(cwd), env: environment(env) }) From d8d3830b59bd35bd733aef7f5bd679bd7e18fe20 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:22:27 +0700 Subject: [PATCH 05/25] fix(uncheck): address review of B1 --- packages/uncheck/src/commands/prepare.ts | 2 +- packages/uncheck/tests/prepare/existing-hook.test.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 29d4f2a..886ebe9 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -38,7 +38,7 @@ const SETUP = /^\s*(?:#|$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ /** husky 4 sources this runner, which always exits, so nothing after it runs. */ -const HUSKY_4_RUNNER = /^\s*\.\s+"\$\(dirname "\$0"\)\/husky\.sh"\s*$/ +const HUSKY_4_RUNNER = /^\.\s+"\$\(dirname "\$0"\)\/husky\.sh"\s*$/ const INNERMOST_SUBSTITUTION = /\$\([^()]*\)/g diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 7e288ae..93683a5 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -145,6 +145,15 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) }) + it('keeps its line after a block that only runs the husky 4 runner when it exists', async () => { + const existing = `#!/bin/sh\nif [ -f "$(dirname "$0")/husky.sh" ]; then\n ${HUSKY_4_RUNNER}\nfi\n${line}\n` + + const { stdout, hook, project } = await prepareHook(existing) + + expect(stdout).toBe(written(shownHook(project, app), 'unchanged')) + expect(hook).toBe(existing) + }) + it.each([ ['a trailing backslash', 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n'], ['an open double quote', 'MESSAGE="checks\n. before committing"\n'], From b3682480684d064bb1ecf692849075b3b135da20 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:26:25 +0700 Subject: [PATCH 06/25] fix(uncheck): address review of A1 --- packages/uncheck/README.md | 2 +- packages/uncheck/src/files.ts | 9 +++- packages/uncheck/tests/uncheck/paths.test.ts | 53 +++++++++++++++++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 659f604..b9b1d52 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -83,7 +83,7 @@ npx uncheck src '!src/generated' # a directory, minus a part of it npx uncheck '!**/*.gen.ts' # everything except some files ``` -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, but never links that point outside the directory uncheck runs in. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. +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, but never links that point outside the directory uncheck runs in. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. 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. diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 422d056..764c6b2 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -80,9 +80,14 @@ export const resolvePaths = Effect.fn(function* ( } // The current directory is a real path, while an absolute path may run through a linked folder. - const real = slashedRelative(path, realCwd, resolveFolders(path, resolved)) + // `throwIfNoEntry` spares only missing paths: a locked folder or a looping link still throws. + try { + const real = slashedRelative(path, realCwd, resolveFolders(path, resolved)) - return isOutside(path, real) ? lexical : real + return isOutside(path, real) ? lexical : real + } catch { + return lexical + } } const includes = patterns.filter((pattern) => !pattern.startsWith('!')) diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index 9be50d5..7f38c4b 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -1,7 +1,14 @@ -import { symlinkSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { cliError, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { + cliError, + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + report, + temporaryDirectory, +} from '../utils/project' import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './utils' describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, app }) => { @@ -157,11 +164,14 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, [`${app}src/routes/home.ts`]: CLEAN_CODE, [`${app}src/legacy.ts`]: CODE_WITH_VAR, }) - const outside = join(temporaryDirectory(), 'shared.ts') + const elsewhere = temporaryDirectory() + const outside = join(elsewhere, 'shared.ts') + const looping = join(elsewhere, 'loop') writeFileSync(outside, CLEAN_CODE) + symlinkSync(looping, looping) const routes = `${app}src/routes` - for (const pattern of ['../legacy.ts', '..', '../*.ts', outside]) { + for (const pattern of ['../legacy.ts', '..', '../*.ts', outside, join(looping, 'shared.ts')]) { const fromInside = await project.uncheck(['--only=oxlint', 'home.ts', pattern], { cwd: routes, }) @@ -183,6 +193,37 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, } } }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'fails naming, and ignores leaving out, a file in a folder outside it cannot look into', + async () => { + const routes = `${app}src/routes` + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }) + const locked = temporaryDirectory() + const hidden = join(locked, 'inner/shared.ts') + mkdirSync(dirname(hidden)) + writeFileSync(hidden, CLEAN_CODE) + chmodSync(locked, 0) + + const named = await project.uncheck(['--only=oxlint', hidden]) + const excluded = await project.uncheck(['--only=oxlint', routes, `!${hidden}`]) + + expect(named.exitCode).toBe(1) + expect(named.stderr).toBe( + cliError( + `${hidden} is outside ${project.dir}, run from a folder that contains it or pass one with --cwd`, + ), + ) + expect(excluded.stderr).toBe('') + expect(excluded.exitCode).toBe(0) + expect(selectedReport(excluded.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }, + ) }) describe('uncheck selecting files by path in a monorepo package', () => { From 7869740ed7debe4ebccc10b955f30c29a7ef1460 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:36:33 +0700 Subject: [PATCH 07/25] fix(uncheck): report repositories git refuses in prepare, resolve a symlinked --cwd, test the hook lock deterministically --- packages/uncheck/README.md | 20 ++++--- packages/uncheck/src/commands/prepare.ts | 19 ++++-- packages/uncheck/src/commands/uncheck.ts | 6 +- .../uncheck/tests/prepare/failures.test.ts | 51 +++++++++++++++- .../uncheck/tests/prepare/location.test.ts | 59 ++++++++++++++++++- 5 files changed, 137 insertions(+), 18 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index de2a0ea..9bb115c 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -101,28 +101,32 @@ Add a `prepare` script, so every clone sets up the hook on install, and run it o 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. -| `prepare` flag | Effect | +To change the hook, put flags in the `prepare` script and run it again, such as `"prepare": "uncheck prepare --pre-commit --only=oxlint --only=oxfmt"`. Every install runs the script, so a flag passed only by hand is undone by the next install. + +| Flag in the `prepare` script | 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 | +| `--only`, `--skip`, `--require` | Pick the checks the hook runs | -Run `prepare` again with other flags to change the hook. What it guarantees: +What the hook guarantees: -- **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. +- **Each staged file is checked as you staged it.** 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. +- **Only fixes to staged files are staged.** An edit you save while the checks run stays unstaged. 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`. Good to know: -- 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. +- tsc checks whole projects as they are on disk, so it can report errors in files you did not stage, and pass thanks to a file you forgot to `git add`. `--only=oxlint --only=oxfmt` keeps the hook inside the commit. +- A hook line checks the staged files in its folder and below, so a line at the root already covers every package. A package's own line adds its checks, with its flags, on top. - 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. +- uncheck writes nothing, and says why, outside a git repository, when git refuses the 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"`. +- Installs that leave out devDependencies (`npm ci --omit=dev`, `NODE_ENV=production`, `bun install --production`, `yarn workspaces focus --production`) still run `prepare` or `postinstall`, but without uncheck. Append `|| exit 0` so they pass: `"prepare": "uncheck prepare --pre-commit || exit 0"`. ## Run it after every agent turn @@ -207,6 +211,8 @@ The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+. The tsconfig pre **`uncheck dist` says "No files match".** git ignores that folder, so it holds no project files. You can still name an ignored file directly. +**`uncheck prepare` says "git refuses the repository".** git does not trust a repository another user owns, such as a checkout mounted into a container. Run the `safe.directory` command `git status` prints there, then `prepare` again. + **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 diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 886ebe9..4ee241e 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -179,17 +179,28 @@ export const prepare = Command.make( const cwd = path.resolve(directory) - const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.option) + const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.result) - // A `prepare` script runs on every install, including where there is no repository to hook. - if (Option.isNone(repository)) { + if (Result.isFailure(repository)) { + const { failure } = repository + + // git translates its messages, but never the name of a setting. + if (failure._tag === 'GitFailed' && failure.stderr.includes('safe.directory')) { + const reason = failure.stderr.split('\n')[0]!.replace(/^fatal: /, '') + + return yield* Console.log( + `${red('✘')} ${bold('pre-commit')} ${dim(`not written, git refuses the repository: ${reason}`)}`, + ) + } + + // A `prepare` script runs on every install, including where there is no repository to hook. return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) } const { prefix, paths: [hooks], - } = repository.value + } = repository.success const inside = prefix.replace(/\/$/, '') const exec = yield* detectExec(cwd) const command = [ diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 6635846..300f6a4 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -2,7 +2,7 @@ import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' -import { Console, Duration, Effect, Fiber, Semaphore } from 'effect' +import { Console, Duration, Effect, Fiber, FileSystem, Semaphore } from 'effect' import type { CliError } from 'effect/unstable/cli' import { Argument, Command, Flag } from 'effect/unstable/cli' @@ -19,6 +19,10 @@ import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc] export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( + // git runs in the folder a symlink points to and prints its paths relative to that folder. + Flag.mapEffect((directory) => + FileSystem.FileSystem.use((fs) => fs.realPath(directory)).pipe(Effect.orDie), + ), Flag.withDefault(Effect.sync(() => process.cwd())), Flag.withDescription('Directory to run in. Defaults to the current one'), ) diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index 106e446..362ddc5 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -1,7 +1,15 @@ -import { readdirSync } from 'node:fs' +import { readdirSync, rmSync } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' import type { Project } from '../utils/project' -import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, singleRepo } from '../utils/project' +import { + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + singleRepo, + temporaryDirectory, + wrappedGit, +} from '../utils/project' import { HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' const HOOK = '.git/hooks/pre-commit' @@ -11,6 +19,22 @@ function leftoverLocksAndCopies(project: Project, folder = '.git/hooks'): string } describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create, app }) => { + it('reports a repository git refuses to work in', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await prepare(project, [], { + cwd: app, + env: { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe( + `✘ pre-commit not written, git refuses the repository: detected dubious ownership in repository at '${project.dir}'\n`, + ) + expect(project.exists(HOOK)).toBe(false) + }) + it.runIf(PERMISSIONS_ENFORCED)('reports a hooks folder it may not write to', async () => { const project = create().chmod('.git/hooks', 0o555) @@ -120,8 +144,28 @@ describe('prepare in every package of a monorepo at once', () => { ), ) const folders = ['', ...packages.map((name) => `packages/${name}/`)] + const atLock = temporaryDirectory() + // Relies on `git config --show-scope` being the last git call before the lock. + const env = wrappedGit( + `case " $* " in *" --show-scope "*) "$GIT" "$@"; status=$?; touch '${atLock}'/$$; exit $status;; esac`, + ) + project.write({ [`${HOOK}.lock`]: '' }) + + const running = folders.map((folder) => prepare(project, [], { cwd: folder, env })) + const endedEarly = Promise.race(running).then(({ stdout }) => { + throw new Error(`A run ended while the lock was held:\n${stdout}`) + }) - const runs = await Promise.all(folders.map((folder) => prepare(project, [], { cwd: folder }))) + await Promise.race([ + endedEarly, + vi.waitFor(() => expect(readdirSync(atLock)).toHaveLength(folders.length), { + timeout: 30_000, + interval: 10, + }), + ]) + await sleep(200) + rmSync(project.path(`${HOOK}.lock`)) + const runs = await Promise.all(running) expect(runs.map(({ exitCode }) => exitCode)).toEqual(folders.map(() => 0)) expect(runs.map(({ stdout }) => stdout.split('\n')[0])).toEqual( @@ -133,6 +177,7 @@ describe('prepare in every package of a monorepo at once', () => { ), ), ) + expect(runs.filter(({ stdout }) => stdout.split('\n')[0]!.endsWith(' created'))).toHaveLength(1) expect(project.read(HOOK).split('\n').sort()).toEqual( [...HEADER.split('\n'), ...folders.map((folder) => hookLine(folder))].sort(), ) diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts index f50023e..1e89915 100644 --- a/packages/uncheck/tests/prepare/location.test.ts +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -1,8 +1,15 @@ -import { lstatSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' import type { Env } from '../utils/project' -import { gitConfig, LAYOUTS, temporaryDirectory, wrappedGit } from '../utils/project' +import { + gitConfig, + LAYOUTS, + monorepo, + report, + temporaryDirectory, + wrappedGit, +} from '../utils/project' import { DISPATCHER, HEADER, @@ -252,3 +259,49 @@ describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, ap expect(project.mode('hooks/pre-commit')).toBe(0o755) }) }) + +describe('prepare with --cwd naming a symlink to a package', () => { + function linkedApp() { + return monorepo().link('applink', 'packages/app') + } + + it('writes the hook of the repository the package is in, and nothing above it', async () => { + const project = linkedApp() + + const { exitCode, stdout } = await prepare(project, ['--cwd=applink']) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine('packages/app/')}\n`) + expect(existsSync(join(dirname(project.dir), '.git'))).toBe(false) + }) + + it('lets the hook command set aside unstaged changes in the package', async () => { + const file = 'packages/app/src/extra.ts' + const project = linkedApp() + .stage({ [file]: 'export const extra = 1;\n' }) + .write({ [file]: 'export const extra = 1;\nvar unstaged = 1\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--cwd=applink', + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(project.read(file)).toBe('export const extra = 1;\nvar unstaged = 1\n') + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) +}) From e7cb8c2738c9b27ea55bfb5dbf11b5815c78cb57 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 14:51:02 +0700 Subject: [PATCH 08/25] fix(uncheck): typecheck deletions, never pass a broken setup, keep report-only sherif read-only --- packages/uncheck/README.md | 4 +- packages/uncheck/src/checks/oxfmt.ts | 7 ++ packages/uncheck/src/checks/oxlint.ts | 7 ++ packages/uncheck/src/checks/sherif.ts | 42 ++++++--- packages/uncheck/src/checks/tsc.ts | 24 +++-- packages/uncheck/src/commands/hooks/run.ts | 5 +- packages/uncheck/src/commands/staged.ts | 86 ++++++++---------- packages/uncheck/src/commands/uncheck.ts | 27 ++++-- packages/uncheck/src/errors.ts | 2 + packages/uncheck/src/files.ts | 35 +++++--- packages/uncheck/src/git.ts | 28 ++++++ packages/uncheck/src/types.ts | 3 + .../uncheck/tests/hooks/run/changes.test.ts | 88 ++++++++++++++++-- packages/uncheck/tests/hooks/run/utils.ts | 3 + packages/uncheck/tests/staged/basics.test.ts | 47 +++++++++- .../uncheck/tests/staged/deletions.test.ts | 90 +++++++++++++++++++ packages/uncheck/tests/staged/fix.test.ts | 6 +- packages/uncheck/tests/staged/merge.test.ts | 21 +++++ packages/uncheck/tests/staged/utils.ts | 3 + .../uncheck/tests/uncheck/selection.test.ts | 29 ++++++ packages/uncheck/tests/uncheck/sherif.test.ts | 65 ++++++++++++++ 21 files changed, 525 insertions(+), 97 deletions(-) create mode 100644 packages/uncheck/tests/staged/deletions.test.ts diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index b9b1d52..09784a3 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -147,9 +147,9 @@ Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It ## 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. +Run uncheck from the workspace root, the folder whose `package.json` has `workspaces` or whose `pnpm-workspace.yaml` lists `packages`, 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. +**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. Leave out `"fix": true`, since uncheck decides when sherif fixes: a run that only reports fails while it is set. **TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts index be733cf..d10cf6c 100644 --- a/packages/uncheck/src/checks/oxfmt.ts +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -14,6 +14,13 @@ export const oxfmt: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } + // An empty list makes no batch, so the check would pass without running. + if (files?.length === 0) { + return yield* Effect.fail( + new NothingToCheck({ reason: 'only deleted files', unrelated: true }), + ) + } + // A folder with nothing oxfmt handles, or a given file it does not (a .txt file), is no failure. const args = [...(fix ? [] : ['--check']), '--no-error-on-unmatched-pattern'] diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts index 6b0fffa..798f3ca 100644 --- a/packages/uncheck/src/checks/oxlint.ts +++ b/packages/uncheck/src/checks/oxlint.ts @@ -14,6 +14,13 @@ export const oxlint: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } + // An empty list makes no batch, so the check would pass without running. + if (files?.length === 0) { + return yield* Effect.fail( + new NothingToCheck({ reason: 'only deleted files', unrelated: true }), + ) + } + const args = [ ...(fix ? ['--fix'] : []), // oxlint walks into node_modules unless an ignore file says not to, and a fix there rewrites diff --git a/packages/uncheck/src/checks/sherif.ts b/packages/uncheck/src/checks/sherif.ts index a5f2a0e..87e712b 100644 --- a/packages/uncheck/src/checks/sherif.ts +++ b/packages/uncheck/src/checks/sherif.ts @@ -2,39 +2,49 @@ import process from 'node:process' import { Effect, FileSystem, Path, Predicate } from 'effect' -import { NothingToCheck } from '../errors' +import { CannotCheck, NothingToCheck } from '../errors' import { readJson } from '../files' import { resolveBin } from '../tool' import type { Check } from '../types' const WORKSPACE_FILES = new Set(['package.json', 'pnpm-workspace.yaml']) +// pnpm also keeps its settings in pnpm-workspace.yaml, and sherif fails on one without packages. +const DECLARES_PACKAGES = /^["']?packages["']?\s*:/m + export const sherif: Check = { name: 'sherif', fixes: 'workspace', - plan: Effect.fn(function* ({ cwd, fix, files }) { + plan: Effect.fn(function* ({ cwd, fix, files, deleted }) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - if (files?.some((file) => WORKSPACE_FILES.has(path.basename(file))) === false) { + const bin = yield* resolveBin('sherif', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + if ( + files !== undefined && + ![...files, ...deleted].some((file) => WORKSPACE_FILES.has(path.basename(file))) + ) { return yield* Effect.fail( - new NothingToCheck({ reason: 'no package.json among the given files' }), + new NothingToCheck({ reason: 'no package.json among the given files', unrelated: true }), ) } - const [bin, manifest, pnpmWorkspace] = yield* Effect.all( + const [manifest, pnpmWorkspace] = yield* Effect.all( [ - resolveBin('sherif', cwd), readJson(path.join(cwd, 'package.json')), - fs.exists(path.join(cwd, 'pnpm-workspace.yaml')).pipe(Effect.orElseSucceed(() => false)), + fs.readFileString(path.join(cwd, 'pnpm-workspace.yaml')).pipe( + Effect.map((text) => DECLARES_PACKAGES.test(text)), + Effect.orElseSucceed(() => false), + ), ], { concurrency: 'unbounded' }, ) - if (bin === undefined) { - return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) - } - if (manifest === undefined) { return yield* Effect.fail(new NothingToCheck({ reason: 'no package.json found' })) } @@ -44,6 +54,16 @@ export const sherif: Check = { } if (!fix || process.env.CI !== undefined) { + // sherif reads this setting on every run, and no flag turns it off. + if (Predicate.hasProperty(manifest.sherif, 'fix') && manifest.sherif.fix === true) { + return yield* Effect.fail( + new CannotCheck({ + reason: + 'would fix files while uncheck only reports, remove "fix": true from the sherif field of package.json', + }), + ) + } + return [{ bin, args: [] }] } diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index e82ce7e..0da7492 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -5,6 +5,7 @@ import { parse as parseJsonc } from 'jsonc-parser' import { CannotCheck, NothingToCheck } from '../errors' import { ancestors, readJson } from '../files' +import type { Bin } from '../tool' import { resolveBin } from '../tool' import type { Check } from '../types' @@ -15,21 +16,30 @@ import type { Check } from '../types' // oxlint-disable-next-line no-template-curly-in-string const CONFIG_DIR = '${configDir}' -const NOT_COVERED = 'no tsconfig.json covers the given files' +function notCovered(typescript: Bin | undefined): NothingToCheck { + return new NothingToCheck({ + reason: 'no tsconfig.json covers the given files', + unrelated: typescript !== undefined, + }) +} export const tsc: Check = { name: 'tsc', fixes: false, - plan: Effect.fn(function* ({ cwd, files, projectFiles }) { + plan: Effect.fn(function* ({ cwd, files, deleted, projectFiles }) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const targets = files - ?.map((file) => path.resolve(cwd, file)) - .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) + // Deleted files are among the targets, so selecting a project must never need one on disk. + const targets = + files === undefined + ? undefined + : [...files, ...deleted] + .map((file) => path.resolve(cwd, file)) + .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) if (targets !== undefined && targets.length === 0) { - return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) + return yield* Effect.fail(notCovered(yield* resolveBin('typescript', cwd, 'tsc'))) } const [typescript, tsconfigs] = yield* Effect.all( @@ -71,7 +81,7 @@ export const tsc: Check = { targets === undefined ? tsconfigs : yield* selectTsconfigs([...references.keys()], targets) if (selected.length === 0) { - return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) + return yield* Effect.fail(notCovered(typescript)) } if (typescript === undefined) { diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 308ddb5..4441f1f 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -64,14 +64,15 @@ export const run = Command.make( const changed = yield* listChangedFiles(cwd) - if (changed?.length === 0) { + if (changed !== undefined && changed.files.length === 0 && changed.deleted.length === 0) { return } - const [failed, lines] = yield* runChecks(changed ?? [], { + const [failed, lines] = yield* runChecks(changed?.files ?? [], { ...settings, cwd, literal: true, + deleted: changed?.deleted, }).pipe( Effect.map(() => false), Effect.catchTag('CheckFailed', () => Effect.succeed(true)), diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index b9f212e..dbfb968 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, inNodeModules } from '../files' -import { git, gitBytes, GitFailed, gitLocation, gitPaths } from '../git' +import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' @@ -39,42 +39,42 @@ 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, merging] = yield* Effect.all( - [ - stagedFiles(cwd).pipe( - Effect.catchTag('GitFailed', () => - userError('`uncheck staged` needs a git repository'), - ), + const { + prefix, + paths: [folder, indexLock], + } = yield* gitLocation(cwd, ['uncheck-unstaged', 'index.lock']).pipe( + Effect.catchTag('GitFailed', ({ stderr }) => + // The setting git names is never translated, unlike the rest of its message. + userError( + stderr.includes('safe.directory') ? stderr : '`uncheck staged` needs a git repository', ), - mergeInProgress(cwd), - ], + ), + ) + + // Only regular files: tools follow a staged symlink to a file the commit does not hold. + const [listed, merging, unstaged] = yield* Effect.all( + [stagedFiles(cwd), mergeInProgress(cwd), rawDiff(cwd)], { concurrency: 'unbounded' }, ) // 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)) + const theirs = merging ? yield* stagedFiles(cwd, 'MERGE_HEAD', '--') : undefined + const notTheirs = theirs && new Set([...theirs.files, ...theirs.deleted]) + const ours = (paths: ReadonlyArray) => + notTheirs === undefined ? paths : paths.filter((file) => notTheirs.has(file)) + const files = ours(listed.files) + const deleted = ours(listed.deleted) yield* Console.log(dim(`uncheck staged in ${cwd}`)) - if (files.length === 0) { + if (files.length === 0 && deleted.length === 0) { const reason = - listed.length > 0 + listed.files.length > 0 || listed.deleted.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, - 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 } @@ -104,6 +104,7 @@ export const staged = Command.make( fix, literal: true, staged: true, + deleted, }).pipe(Effect.catchTag('CheckFailed', Effect.succeed)) if (!fix) { @@ -203,34 +204,19 @@ 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: fromMode!, toMode: 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) && !inNodeModules(entry.file)) - .map((entry) => entry.file), - ) + Effect.map(rawDiff(cwd, '--cached', '--diff-filter=ACMTD', ...against), (entries) => { + const kept = entries.filter((entry) => !inNodeModules(entry.file)) + + return { + files: kept + .filter((entry) => entry.status !== 'D' && REGULAR_FILE_MODE.test(entry.toMode)) + .map((entry) => entry.file), + deleted: kept + .filter((entry) => entry.status === 'D' && REGULAR_FILE_MODE.test(entry.fromMode)) + .map((entry) => entry.file), + } + }) const partiallyStaged = Effect.fn(function* ( unstaged: Effect.Success>, diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 7cc410c..8489a8c 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -67,6 +67,8 @@ export interface RunSettings extends CheckSelection { readonly literal?: boolean /** Fixes are staged again, so only the ones that stay within the given files apply. */ readonly staged?: boolean + /** Paths the change deletes. Only for `literal` runs: without paths, any other run checks everything. */ + readonly deleted?: ReadonlyArray } export function selectionArgs({ only, required, skipped }: CheckSelection): ReadonlyArray { @@ -132,7 +134,15 @@ export const checkPaths = Effect.fn(function* ( paths: ReadonlyArray, settings: RunSettings, ) { - const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings + const { + fix, + only, + required, + skipped, + allowUnmatched = false, + literal = false, + deleted = [], + } = settings const appliesFixes = (fixes: Check['fixes']) => settings.staged === true ? fixes === 'files' : fixes !== false const { cwd } = settings @@ -140,7 +150,7 @@ export const checkPaths = Effect.fn(function* ( let files: ReadonlyArray | undefined - if (paths.length > 0) { + if (paths.length > 0 || deleted.length > 0) { const resolved = literal ? { files: yield* checkableFiles(paths, cwd), unmatched: [] } : yield* resolvePaths(paths, cwd, projectFiles) @@ -151,7 +161,7 @@ export const checkPaths = Effect.fn(function* ( ) } - if (resolved.files.length === 0) { + if (resolved.files.length === 0 && deleted.length === 0) { yield* Console.log(`${dim('○')} nothing to check, no files match ${paths.join(' ')}`) return } @@ -171,13 +181,14 @@ export const checkPaths = Effect.fn(function* ( return Effect.succeed({ name, status: 'skipped', reason: exclusion }) } - return plan({ cwd, fix: fix && appliesFixes(fixes), files, projectFiles }).pipe( + return plan({ cwd, fix: fix && appliesFixes(fixes), files, deleted, projectFiles }).pipe( Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), - Effect.catchTag('NothingToCheck', ({ reason }) => + Effect.catchTag('NothingToCheck', ({ reason, unrelated }) => Effect.succeed({ name, status: required.includes(name) ? 'failed' : 'skipped', reason, + unrelated, }), ), Effect.catchTag('CannotCheck', ({ reason }) => @@ -198,7 +209,11 @@ export const checkPaths = Effect.fn(function* ( if (ran.length === 0) { const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ') - if (files !== undefined && (allowUnmatched || literal)) { + if ( + files !== undefined && + (allowUnmatched || literal) && + outcomes.some((outcome) => outcome.unrelated === true) + ) { return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`) } diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index 5315837..fad8957 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -6,6 +6,8 @@ import type { CheckOutcome } from './types' export class NothingToCheck extends Data.TaggedError('NothingToCheck')<{ readonly reason: string + /** Lets a run on given files pass, so never for a check that is not set up. */ + readonly unrelated?: boolean }> {} export class CannotCheck extends Data.TaggedError('CannotCheck')<{ diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 764c6b2..b2d3a55 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -5,7 +5,7 @@ import type { ChildProcessSpawner } from 'effect/unstable/process' import { Minimatch } from 'minimatch' import { userError } from './errors' -import { gitPaths } from './git' +import { gitPaths, rawDiff } from './git' export type ProjectFiles = Effect.Effect< ReadonlyArray, @@ -34,28 +34,39 @@ export function inNodeModules(file: string): boolean { return /(?:^|\/)node_modules(?:\/|$)/.test(file) } +export interface ChangedFiles { + readonly files: ReadonlyArray + readonly deleted: ReadonlyArray +} + /** * The files changed since the last commit, relative to `cwd`: modified or staged tracked files plus - * untracked ones, ignored files excluded. `undefined` outside a git repository or before the first - * commit, which callers treat as "everything under `cwd`". + * untracked ones, ignored files excluded, and apart from them the deleted ones, the old path of a + * rename included. `undefined` outside a git repository or before the first commit, which callers + * treat as "everything under `cwd`". */ export function listChangedFiles( cwd: string, -): Effect.Effect< - ReadonlyArray | undefined, - never, - ChildProcessSpawner.ChildProcessSpawner -> { +): Effect.Effect { return Effect.all( [ - gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']), + // Without `--`, a file named HEAD makes the revision ambiguous and git fails. + rawDiff(cwd, 'HEAD', '--'), gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), ], { concurrency: 'unbounded' }, ).pipe( - Effect.map(([tracked, untracked]) => - [...new Set([...tracked, ...untracked])].filter((file) => !inNodeModules(file)).sort(), - ), + Effect.map(([tracked, untracked]) => { + const changed = tracked.filter((entry) => entry.status !== 'D').map((entry) => entry.file) + const deleted = tracked.filter((entry) => entry.status === 'D').map((entry) => entry.file) + + return { + files: [...new Set([...changed, ...untracked])] + .filter((file) => !inNodeModules(file)) + .sort(), + deleted: deleted.filter((file) => !inNodeModules(file)), + } + }), Effect.orElseSucceed(() => undefined), ) } diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index d36274a..cdd3ce8 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -80,6 +80,34 @@ export function gitPaths(cwd: string, args: ReadonlyArray) { return Effect.map(git(cwd, args), (output) => output.split('\0').filter((entry) => entry !== '')) } +export function rawDiff(cwd: string, ...args: ReadonlyArray) { + return 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, , , status] = fields[index * 2]!.slice(1).split(' ') + + return { + file: fields[index * 2 + 1]!, + status: status!, + fromMode: fromMode!, + toMode: toMode!, + } + }) + }, + ) +} + /** * 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. diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts index 7ab5771..b2a5ea1 100644 --- a/packages/uncheck/src/types.ts +++ b/packages/uncheck/src/types.ts @@ -11,6 +11,7 @@ export interface CheckOutcome { readonly name: CheckName readonly status: 'passed' | 'failed' | 'skipped' readonly reason?: string + readonly unrelated?: boolean } export interface CheckCommand { @@ -26,6 +27,8 @@ export interface CheckInput { readonly fix: boolean /** Files to check, relative to `cwd`, or `undefined` for everything under it. */ readonly files: ReadonlyArray | undefined + /** Files the change deletes, relative to `cwd`. Never handed to a tool, which would read `[id].ts` as a glob. */ + readonly deleted: ReadonlyArray readonly projectFiles: ProjectFiles } diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index d42a6db..c3b6a93 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -1,9 +1,9 @@ -import { readFileSync, writeFileSync } from 'node:fs' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS, report, temporaryDirectory } from '../../utils/project' -import { CLAUDE_CODE_STOP, stopHook } from './utils' +import { LAYOUTS, report, singleRepo, temporaryDirectory } from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook, UTILS_NOT_FOUND } from './utils' const UNFORMATTED = 'export const legacy = 1\n' @@ -87,7 +87,7 @@ describe.each(LAYOUTS)( expect(readFileSync(join(store, 'shared.ts'), 'utf8')).toBe(UNFORMATTED) }) - it('leaves a file deleted since the last commit out, even one whose name reads as a pattern', async () => { + it('never hands oxlint or oxfmt a deleted file, even one whose name reads as a pattern', async () => { const project = create({ [`${app}src/[id].ts`]: 'export const id = 1;\n', [`${app}src/i.ts`]: UNFORMATTED, @@ -99,7 +99,12 @@ describe.each(LAYOUTS)( expect(deleted.stdout).toBe('') expect(report(deleted.stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, - '○ nothing to check, no files match src/[id].ts', + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + tsc, + '✔ tsc passed', + '✔ all checks passed (tsc)', ]) expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) @@ -124,6 +129,29 @@ describe.each(LAYOUTS)( expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) }) + it('checks only the changed files beside a file named HEAD', async () => { + const project = create({ + [`${app}HEAD`]: 'not a revision\n', + [`${app}src/legacy.ts`]: UNFORMATTED, + }).write({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + }) + it('checks everything under the directory outside git', async () => { const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'none' }) @@ -171,3 +199,53 @@ describe.each(LAYOUTS)( }) }, ) + +describe('hooks run with deleted files in a single repo', () => { + it.each([[[]], [['--require=tsc']]])( + 'sends the agent back when a deleted file is still imported, with %j', + async (flags) => { + const project = singleRepo().write({ 'src/utils.ts': null }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', ...flags], + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stderr).toContain(UTILS_NOT_FOUND) + }, + ) + + it('sends the agent back when a moved file is still imported from where it was', async () => { + const project = singleRepo() + + mkdirSync(project.path('scripts')) + project.git('mv', 'src/utils.ts', 'scripts/utils.ts') + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stderr).toContain(UTILS_NOT_FOUND) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index e3c2dbf..2b68caf 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -20,6 +20,9 @@ export const COPILOT_STOP_IN_CLAUDE_FORMAT = { export const TYPE_ERROR = 'export const answer: string = 1;\n' +export const UTILS_NOT_FOUND = + "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." + export function dirFlags(app: string): string[] { return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] } diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts index a7b15bc..1b241b9 100644 --- a/packages/uncheck/tests/staged/basics.test.ts +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -1,4 +1,11 @@ -import { cliError, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { + cliError, + LAYOUTS, + monorepo, + project as bareProject, + report, + singleRepo, +} from '../utils/project' import { folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { @@ -14,6 +21,22 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { expect(exitCode).toBe(1) }) + it('shows why git refuses the repository', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { + cwd: folder, + env: { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }, + }) + + expect(project.normalize(stderr)).toContain( + cliError("fatal: detected dubious ownership in repository at ''").trimEnd(), + ) + expect(stderr).toContain('safe.directory') + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) + it('has nothing to check when nothing is staged', async () => { const project = create() @@ -161,6 +184,28 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { }) }) +describe('uncheck staged without tools', () => { + it('fails when no check can run on the staged files', async () => { + const project = bareProject( + { 'package.json': { name: 'bare', private: true } }, + { tools: [] }, + ).stage({ 'src/index.ts': 'export const answer = 42;\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged']) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, not installed', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', + ]) + }) +}) + describe('uncheck staged flags', () => { it('rejects a selection that contradicts itself before looking at git', async () => { const project = singleRepo({}, { git: 'none' }) diff --git a/packages/uncheck/tests/staged/deletions.test.ts b/packages/uncheck/tests/staged/deletions.test.ts new file mode 100644 index 0000000..ea9fa27 --- /dev/null +++ b/packages/uncheck/tests/staged/deletions.test.ts @@ -0,0 +1,90 @@ +import { mkdirSync } from 'node:fs' + +import { report, singleRepo } from '../utils/project' +import { UTILS_NOT_FOUND } from './utils' + +const SKIPPED_FOR_DELETIONS = [ + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', +] + +describe('uncheck staged with deleted files in a single repo', () => { + it('typechecks the project a staged deletion breaks', async () => { + const project = singleRepo() + + project.git('rm', '--quiet', '--', 'src/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) + + it('typechecks the project a file moves out of', async () => { + const project = singleRepo() + + mkdirSync(project.path('scripts')) + project.git('mv', 'src/utils.ts', 'scripts/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) + + it('passes a staged deletion nothing imports', async () => { + const project = singleRepo({ 'src/unused.ts': 'export const unused = 1;\n' }) + + project.git('rm', '--quiet', '--', 'src/unused.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(exitCode).toBe(0) + }) + + it('never fixes or stages a file only removed from the index', async () => { + const legacy = 'export const legacy = 1\n' + const project = singleRepo({ 'src/legacy.ts': legacy }) + + project.git('rm', '--quiet', '--cached', '--', 'src/legacy.ts') + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(exitCode).toBe(0) + expect(project.read('src/legacy.ts')).toBe(legacy) + expect(project.git('status', '--porcelain')).toBe('D src/legacy.ts\n?? src/legacy.ts\n') + }) +}) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 150264a..9d813f0 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -163,7 +163,11 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(nothing.exitCode).toBe(0) expect(report(nothing.stdout)).toEqual([ `uncheck staged in ${project.path(folder)}`, - '○ nothing to check, no staged files', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, only deleted files', + '○ tsc skipped, not selected by --only', + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt only deleted files, tsc not selected by --only', ]) project.write({ [`${app}src/link.ts`]: null }) diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 123025e..55fd3c9 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -34,6 +34,27 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') }) + it('leaves out what the branch being merged in deletes, even beside a file named MERGE_HEAD', async () => { + const project = commitOnSide( + create({ + [`${app}MERGE_HEAD`]: 'not a revision\n', + [`${app}src/gone.ts`]: 'export const gone = 1;\n', + }), + { [`${app}src/gone.ts`]: null }, + ) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, every staged file comes from the branch being merged in', + ]) + }) + it('never takes a merge whose fixes bring back the tree of HEAD for an empty commit', async () => { const project = commitOnSide(create({ [other]: 'export const other = 2;\n' }), { [other]: 'export const other = 3;\n', diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index a860251..7e28336 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -9,6 +9,9 @@ export function folderOf(app: string): string { return app.replace(/\/$/, '') || '.' } +export const UTILS_NOT_FOUND = + "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." + export const LEFTOVER_ERROR = 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index 6326219..3e66f0c 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -162,6 +162,35 @@ describe('uncheck check selection', () => { expect(exitCode).toBe(1) }) + it.each([ + ['src/index.ts', 'no tsconfig.json found'], + ['README.md', 'no tsconfig.json covers the given files'], + ])( + 'fails on %s in a project that uses none of the tools, even when unmatched patterns are allowed', + async (file, tscReason) => { + const project = bareProject( + { + 'package.json': { name: 'bare', private: true }, + 'README.md': '# Bare\n', + 'src/index.ts': 'export const answer = 42;\n', + }, + { tools: [] }, + ) + + const { exitCode, stdout } = await project.uncheck(['--no-error-on-unmatched-pattern', file]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + `○ tsc skipped, ${tscReason}`, + `✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc ${tscReason}`, + ]) + expect(exitCode).toBe(1) + }, + ) + it.each([ { contradiction: 'a required check it skips', diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts index c7f168c..1915b1a 100644 --- a/packages/uncheck/tests/uncheck/sherif.test.ts +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -1,4 +1,5 @@ import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { monorepoWithMismatchedVersions } from './utils' const OTHERS_NOT_SELECTED = [ '○ oxlint skipped, not selected by --only', @@ -74,6 +75,18 @@ describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { expect(exitCode).toBe(1) }) + it.each([ + ['only holds settings', 'onlyBuiltDependencies:\n - esbuild\n'], + ['is empty', ''], + ])('skips sherif where the pnpm-workspace.yaml %s', async (_, workspace) => { + const project = create({ 'pnpm-workspace.yaml': workspace }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'not a workspace root')) + expect(exitCode).toBe(1) + }) + it('takes a pnpm-workspace.yaml link that loops for no workspace', async () => { const project = create({ 'pnpm-workspace.yaml': null }).link( 'pnpm-workspace.yaml', @@ -127,6 +140,58 @@ describe('uncheck sherif in a monorepo', () => { }, ) + it('finds the packages of a pnpm-workspace.yaml with a byte order mark and a quoted key', async () => { + const project = monorepo({ 'pnpm-workspace.yaml': '\uFEFF"packages":\n - packages/*\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✔ sherif passed', + ...OTHERS_NOT_SELECTED, + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + }) + + it('refuses to only report when the sherif field sets fix, which makes sherif fix files', async () => { + const project = monorepoWithMismatchedVersions({ fix: true, select: 'highest' }) + + project.git('add', '--', 'packages/app/package.json') + + const manifests = ['package.json', 'packages/app/package.json', 'packages/core/package.json'] + const before = manifests.map((file) => project.read(file)) + const status = project.git('status', '--porcelain') + const refused = + '✘ sherif would fix files while uncheck only reports, remove "fix": true from the sherif field of package.json' + + const runs = [ + { header: `uncheck in ${project.dir}`, run: await project.uncheck(['--only=sherif']) }, + { + header: `uncheck in ${project.dir}`, + run: await project.uncheck(['--fix', '--only=sherif'], { env: { CI: '1' } }), + }, + { + header: `uncheck staged in ${project.dir}`, + run: await project.uncheck(['staged', '--only=sherif']), + }, + ] + + for (const { header, run } of runs) { + expect(report(run.stdout)).toEqual([ + header, + refused, + ...OTHERS_NOT_SELECTED, + '✘ 1 of 1 checks failed: sherif', + ]) + expect(run.exitCode).toBe(1) + } + + expect(manifests.map((file) => project.read(file))).toEqual(before) + expect(project.git('status', '--porcelain')).toBe(status) + }) + it('finds a workspace declared in the workspaces field of package.json', async () => { const project = monorepo({ 'pnpm-workspace.yaml': null }) .update('package.json', (manifest) => ({ ...manifest, workspaces: ['packages/*'] })) From b90d8be4aff2e5789b812b57eb984f595a34158f Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 15:18:32 +0700 Subject: [PATCH 09/25] fix(uncheck): address review of A2 --- packages/uncheck/src/checks/oxfmt.ts | 5 +- packages/uncheck/src/checks/oxlint.ts | 5 +- packages/uncheck/src/checks/tsc.ts | 30 ++++---- packages/uncheck/src/commands/uncheck.ts | 4 +- packages/uncheck/src/errors.ts | 1 + .../uncheck/tests/staged/deletions.test.ts | 71 ++++++++++++++++--- packages/uncheck/tests/staged/merge.test.ts | 27 ++++++- .../uncheck/tests/uncheck/selection.test.ts | 42 ++++++++++- 8 files changed, 151 insertions(+), 34 deletions(-) diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts index d10cf6c..7c952fc 100644 --- a/packages/uncheck/src/checks/oxfmt.ts +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -14,10 +14,11 @@ export const oxfmt: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - // An empty list makes no batch, so the check would pass without running. + // An empty list makes no batch, so the check would pass without running. Kept skipped when + // required, or `--require=oxfmt` would block every commit that only deletes files. if (files?.length === 0) { return yield* Effect.fail( - new NothingToCheck({ reason: 'only deleted files', unrelated: true }), + new NothingToCheck({ reason: 'only deleted files', unrelated: true, evenIfRequired: true }), ) } diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts index 798f3ca..7305dfc 100644 --- a/packages/uncheck/src/checks/oxlint.ts +++ b/packages/uncheck/src/checks/oxlint.ts @@ -14,10 +14,11 @@ export const oxlint: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - // An empty list makes no batch, so the check would pass without running. + // An empty list makes no batch, so the check would pass without running. Kept skipped when + // required, or `--require=oxlint` would block every commit that only deletes files. if (files?.length === 0) { return yield* Effect.fail( - new NothingToCheck({ reason: 'only deleted files', unrelated: true }), + new NothingToCheck({ reason: 'only deleted files', unrelated: true, evenIfRequired: true }), ) } diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 0da7492..3b3cfa0 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -77,8 +77,7 @@ export const tsc: Check = { } } - const selected = - targets === undefined ? tsconfigs : yield* selectTsconfigs([...references.keys()], targets) + const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(references, targets) if (selected.length === 0) { return yield* Effect.fail(notCovered(typescript)) @@ -172,7 +171,7 @@ function findCycle( } const selectTsconfigs = Effect.fn(function* ( - candidates: ReadonlyArray, + references: ReadonlyMap>, files: ReadonlyArray, ) { const jsonFiles = yield* Effect.forEach( @@ -182,18 +181,21 @@ const selectTsconfigs = Effect.fn(function* ( ) return yield* Effect.filter( - candidates, + [...references.keys()], (candidate) => - 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)), - ), - ), + // A deleted config is no candidate, so only the configs that reference it lead to its dependents. + references.get(candidate)!.some((config) => files.includes(config)) + ? Effect.succeed(true) + : 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' }, ) }) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 8489a8c..27e8b2a 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -183,10 +183,10 @@ export const checkPaths = Effect.fn(function* ( return plan({ cwd, fix: fix && appliesFixes(fixes), files, deleted, projectFiles }).pipe( Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), - Effect.catchTag('NothingToCheck', ({ reason, unrelated }) => + Effect.catchTag('NothingToCheck', ({ reason, unrelated, evenIfRequired }) => Effect.succeed({ name, - status: required.includes(name) ? 'failed' : 'skipped', + status: required.includes(name) && evenIfRequired !== true ? 'failed' : 'skipped', reason, unrelated, }), diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index fad8957..f97f6eb 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -8,6 +8,7 @@ export class NothingToCheck extends Data.TaggedError('NothingToCheck')<{ readonly reason: string /** Lets a run on given files pass, so never for a check that is not set up. */ readonly unrelated?: boolean + readonly evenIfRequired?: boolean }> {} export class CannotCheck extends Data.TaggedError('CannotCheck')<{ diff --git a/packages/uncheck/tests/staged/deletions.test.ts b/packages/uncheck/tests/staged/deletions.test.ts index ea9fa27..043225c 100644 --- a/packages/uncheck/tests/staged/deletions.test.ts +++ b/packages/uncheck/tests/staged/deletions.test.ts @@ -1,6 +1,6 @@ import { mkdirSync } from 'node:fs' -import { report, singleRepo } from '../utils/project' +import { monorepo, report, singleRepo } from '../utils/project' import { UTILS_NOT_FOUND } from './utils' const SKIPPED_FOR_DELETIONS = [ @@ -51,19 +51,46 @@ describe('uncheck staged with deleted files in a single repo', () => { expect(exitCode).toBe(1) }) - it('passes a staged deletion nothing imports', async () => { - const project = singleRepo({ 'src/unused.ts': 'export const unused = 1;\n' }) - - project.git('rm', '--quiet', '--', 'src/unused.ts') - - const { exitCode, stdout } = await project.uncheck(['staged']) + it.each([[[]], [['--require=oxlint', '--require=oxfmt']]])( + 'passes a staged deletion nothing imports, with %j', + async (flags) => { + const project = singleRepo({ 'src/unused.ts': 'export const unused = 1;\n' }) + + project.git('rm', '--quiet', '--', 'src/unused.ts') + + const { exitCode, stdout } = await project.uncheck(['staged', ...flags]) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(exitCode).toBe(0) + }, + ) + + it('passes a staged deletion no check covers with oxlint and oxfmt required', async () => { + const project = singleRepo({ 'NOTES.md': '# Notes\n' }) + + project.git('rm', '--quiet', '--', 'NOTES.md') + + const { exitCode, stdout } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--only=oxfmt', + '--require=oxlint', + '--require=oxfmt', + ]) expect(report(stdout)).toEqual([ `uncheck staged in ${project.dir}`, - ...SKIPPED_FOR_DELETIONS, - '▶ tsc -p tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + '○ tsc skipped, not selected by --only', + '○ nothing to check: sherif not selected by --only, oxlint only deleted files, oxfmt only deleted files, tsc not selected by --only', ]) expect(exitCode).toBe(0) }) @@ -88,3 +115,25 @@ describe('uncheck staged with deleted files in a single repo', () => { expect(project.git('status', '--porcelain')).toBe('D src/legacy.ts\n?? src/legacy.ts\n') }) }) + +describe('uncheck staged with deleted files in a monorepo', () => { + it('builds the projects that reference a deleted tsconfig.json', async () => { + const project = monorepo() + + project.git('rm', '--quiet', '--', 'packages/core/tsconfig.json') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(project.normalize(stdout)).toContain( + "error TS6053: File '/packages/core/tsconfig.json' not found.", + ) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 55fd3c9..65750a5 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -1,5 +1,5 @@ -import { cliError, LAYOUTS, report, run } from '../utils/project' -import { commitOnSide, folderOf, inIndex, stagePartially, VERSIONS } from './utils' +import { cliError, LAYOUTS, report, run, singleRepo } from '../utils/project' +import { commitOnSide, folderOf, inIndex, stagePartially, UTILS_NOT_FOUND, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -113,3 +113,26 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(inIndex(project, file)).toBe(VERSIONS.staged) }) }) + +describe('uncheck staged during a merge in a single repo', () => { + it('typechecks a deletion of its own that the branch being merged in keeps', async () => { + const project = commitOnSide(singleRepo(), { 'src/theirs.ts': 'export const theirs = 1;\n' }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + project.git('rm', '--quiet', '--', 'src/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index 3e66f0c..a6892ba 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -1,4 +1,11 @@ -import { cliError, LAYOUTS, project as bareProject, report, singleRepo } from '../utils/project' +import { + cliError, + LAYOUTS, + project as bareProject, + report, + singleRepo, + TOOLS, +} from '../utils/project' import { layoutChecks, NOT_COVERED, SKIPPED_BESIDE_TSC, UNFORMATTED_CODE } from './utils' describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc }) => { @@ -143,6 +150,39 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc ]) expect(exitCode).toBe(0) }) + + it.each([ + { outcome: 'passes', typescript: 'installed', tools: TOOLS, mark: '○', expectedExitCode: 0 }, + { + outcome: 'fails', + typescript: 'not installed', + tools: TOOLS.filter((tool) => tool !== 'typescript'), + mark: '✘', + expectedExitCode: 1, + }, + ])( + '$outcome on a source file no tsconfig.json covers when typescript is $typescript and unmatched patterns are allowed', + async ({ tools, mark, expectedExitCode }) => { + const project = create( + { [`${app}scripts/release.ts`]: 'export const release = 1;\n' }, + { tools }, + ) + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + '--only=tsc', + `${app}scripts/release.ts`, + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + NOT_COVERED, + `${mark} 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`, + ]) + expect(exitCode).toBe(expectedExitCode) + }, + ) }) describe('uncheck check selection', () => { From 62eb4493ff499f117d6530412a4e485f1f87e8f2 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 16:24:15 +0700 Subject: [PATCH 10/25] fix(uncheck): select the projects tsc itself would check - use the nearest tsconfig.json above a folder that has none, scoped to the folder's files - add the projects of workspace packages that depend on a selected one - check reference graphs whose projects would emit next to their sources with tsc -p --noEmit - pass config paths starting with - or @ to tsc with ./ - leave unreachable tsconfig paths to tsc instead of aborting the run - skip shared bases named tsconfig.json, select the extenders of a deleted base --- packages/uncheck/README.md | 9 +- packages/uncheck/src/checks/tsc.ts | 311 +++++++++++++++--- .../uncheck/tests/uncheck/tsc-extends.test.ts | 37 ++- .../uncheck/tests/uncheck/tsc-inputs.test.ts | 36 +- .../tests/uncheck/tsc-references.test.ts | 260 ++++++++++++++- .../tests/uncheck/tsc-workspace.test.ts | 91 +++++ packages/uncheck/tests/uncheck/tsc.test.ts | 96 +++++- packages/uncheck/tests/uncheck/utils.ts | 5 + 8 files changed, 768 insertions(+), 77 deletions(-) create mode 100644 packages/uncheck/tests/uncheck/tsc-workspace.test.ts diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 09784a3..b160f84 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -85,7 +85,7 @@ npx uncheck '!**/*.gen.ts' # everything except some files 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, but never links that point outside the directory uncheck runs in. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. -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. +tsc then checks only the projects that include one of the files, or included one that was deleted or moved, and the projects that depend on those. sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among the files. ## Run it before every commit @@ -153,9 +153,10 @@ Run uncheck from the workspace root, the folder whose `package.json` has `worksp **TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: -- 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. +- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. If one of them sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile`, `tsc -b` would write JavaScript next to its sources, so each project of that graph is checked with `tsc -p --noEmit` instead. +- Every other project is checked with `tsc -p --noEmit`, a few at a time. A `tsconfig.json` that other configs extend and that includes no files is a shared base, not a project. +- When only some files are checked, tsc runs just the projects that include them, the projects that reference those, and the projects of the packages that depend on theirs. A changed tsconfig selects every project that extends it, and a deleted or moved file the projects that included it. +- In a folder with no `tsconfig.json`, such as a package that shares the root one, uncheck uses the nearest one above it, as tsc does, when it includes files of that folder. **Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags: diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 3b3cfa0..e9f8e6b 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -16,13 +16,22 @@ import type { Check } from '../types' // oxlint-disable-next-line no-template-curly-in-string const CONFIG_DIR = '${configDir}' -function notCovered(typescript: Bin | undefined): NothingToCheck { +function notCovered( + typescript: Bin | undefined, + files: ReadonlyArray | undefined, +): NothingToCheck { return new NothingToCheck({ - reason: 'no tsconfig.json covers the given files', + reason: `no tsconfig.json covers ${files === undefined ? 'this folder' : 'the given files'}`, unrelated: typescript !== undefined, }) } +/** + * Composite projects still write their `.tsbuildinfo` under `--noEmit`, and without `composite`, + * `emitDeclarationOnly` fails unless `declaration` is set. + */ +const WITHOUT_BUILD_INFO = ['--composite', 'false', '--declaration'] + export const tsc: Check = { name: 'tsc', fixes: false, @@ -30,28 +39,29 @@ export const tsc: Check = { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path + const checkable = (relativeFiles: ReadonlyArray) => + relativeFiles + .map((file) => path.resolve(cwd, file)) + .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) + // Deleted files are among the targets, so selecting a project must never need one on disk. - const targets = - files === undefined - ? undefined - : [...files, ...deleted] - .map((file) => path.resolve(cwd, file)) - .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) + const given = files === undefined ? undefined : checkable([...files, ...deleted]) - if (targets !== undefined && targets.length === 0) { - return yield* Effect.fail(notCovered(yield* resolveBin('typescript', cwd, 'tsc'))) + if (given !== undefined && given.length === 0) { + return yield* Effect.fail(notCovered(yield* resolveBin('typescript', cwd, 'tsc'), files)) } - const [typescript, tsconfigs] = yield* Effect.all( + const [typescript, tracked] = yield* Effect.all( [ resolveBin('typescript', cwd, 'tsc'), Effect.flatMap(projectFiles, (all) => - // Tracked files can be deleted from the working tree without being staged yet. + // Tracked files can be deleted from the working tree without being staged yet, while a + // path `exists` cannot reach is left for tsc to report. Effect.filter( all .filter((file) => path.basename(file) === 'tsconfig.json') .map((file) => path.resolve(cwd, file)), - (candidate) => fs.exists(candidate), + (candidate) => fs.exists(candidate).pipe(Effect.orElseSucceed(() => true)), { concurrency: 'unbounded' }, ), ), @@ -59,17 +69,29 @@ export const tsc: Check = { { concurrency: 'unbounded' }, ) - if (tsconfigs.length === 0) { + const inherited = + tracked.length > 0 + ? undefined + : Option.getOrUndefined( + yield* firstFile( + ancestors(path, path.dirname(cwd)).map((dir) => path.join(dir, 'tsconfig.json')), + ), + ) + + if (inherited === undefined && tracked.length === 0) { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) } + // A config above the folder may cover far more than it, so even a full run selects by its files. + const targets = given ?? (inherited === undefined ? undefined : checkable(yield* projectFiles)) + const references = new Map>() - const queue = [...tsconfigs] + const queue = inherited === undefined ? [...tracked] : [inherited] while (queue.length > 0) { const configPath = queue.pop()! - if (!references.has(configPath) && (yield* fs.exists(configPath))) { + if (!references.has(configPath) && (yield* fileKind(configPath)) === 'File') { const referencedConfigs = yield* readReferences(configPath) references.set(configPath, referencedConfigs) @@ -77,21 +99,50 @@ export const tsc: Check = { } } - const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(references, targets) + const selected = + targets === undefined + ? tracked + : yield* selectTsconfigs( + references, + targets, + deleted.map((file) => path.resolve(cwd, file)), + ) if (selected.length === 0) { - return yield* Effect.fail(notCovered(typescript)) + return yield* Effect.fail(notCovered(typescript, files)) } + const projects = + targets === undefined + ? selected + : [ + ...new Set([ + ...selected, + ...(yield* workspaceDependents( + [...references.keys()], + selected, + (yield* projectFiles) + .filter((file) => path.basename(file) === 'package.json') + .map((file) => path.resolve(cwd, file)), + )), + ]), + ] + if (typescript === undefined) { return yield* Effect.fail( new CannotCheck({ - reason: `found ${selected.length} tsconfig.json but typescript is not installed`, + reason: `found ${projects.length} tsconfig.json but typescript is not installed`, }), ) } - const shown = (configPath: string) => path.relative(cwd, configPath) + const shown = (configPath: string) => { + const relative = path.relative(cwd, configPath) + + // tsc reads an argument starting with `-` as an option and one starting with `@` as a file of + // arguments, even after `-b`. + return /^[-@]/.test(relative) ? `./${relative}` : relative + } const referenced = new Set([...references.values()].flat()) const members = [...references] .filter( @@ -122,7 +173,7 @@ export const tsc: Check = { } } - const affected = new Set(selected) + const affected = new Set(targets === undefined ? references.keys() : projects) for (const configPath of affected) { for (const dependent of dependents.get(configPath) ?? []) { @@ -131,20 +182,166 @@ export const tsc: Check = { } const roots = members.filter((member) => !referenced.has(member) && affected.has(member)) - const standalone = selected.filter((configPath) => !members.includes(configPath)).sort() + const extended = yield* extendedConfigs([...references.keys()]) + const allFiles = yield* projectFiles + const standalone = yield* Effect.filter( + projects.filter((configPath) => !members.includes(configPath)), + (configPath) => + Effect.map(isSharedBase(configPath, extended, cwd, allFiles), (shared) => !shared), + { concurrency: 'unbounded' }, + ) + const built: string[] = [] + const checkedAlone = new Map>( + standalone.map((configPath) => [configPath, []]), + ) + + for (const root of roots) { + const graph = new Set([root]) + + for (const configPath of graph) { + for (const referencedConfig of references.get(configPath)!) { + if (references.has(referencedConfig)) { + graph.add(referencedConfig) + } + } + } + + const graphInputs = yield* Effect.forEach( + graph, + (configPath) => + Effect.map(loadTsconfigInputs(configPath), (inputs) => ({ configPath, inputs })), + { concurrency: 'unbounded' }, + ) + + // `tsc -b` would write JavaScript next to the sources of such a project, and Vite, for one, + // then loads a stale vite.config.js instead of vite.config.ts. + if (graphInputs.some(({ inputs }) => hasInputs(inputs) && inputs.emitsBesideSources)) { + for (const { configPath, inputs } of graphInputs) { + if (affected.has(configPath) && hasInputs(inputs)) { + checkedAlone.set(configPath, inputs.composite ? WITHOUT_BUILD_INFO : []) + } + } + } else { + built.push(root) + } + } + + if (built.length === 0 && checkedAlone.size === 0) { + return yield* Effect.fail(notCovered(typescript, files)) + } return [ - ...(roots.length > 0 ? [{ bin: typescript, args: ['-b', ...roots.map(shown)] }] : []), + ...(built.length > 0 ? [{ bin: typescript, args: ['-b', ...built.map(shown)] }] : []), // `-p` would emit JavaScript next to sources that set no `noEmit`. - ...standalone.map((configPath) => ({ + ...[...checkedAlone.keys()].sort().map((configPath) => ({ bin: typescript, - args: ['-p', shown(configPath), '--noEmit'], + args: ['-p', shown(configPath), '--noEmit', ...checkedAlone.get(configPath)!], parallel: true, })), ] }), } +/** The configs other configs extend, by their real path, as an extends through a workspace link names the link. */ +const extendedConfigs = Effect.fn(function* (configPaths: ReadonlyArray) { + const chains = yield* Effect.forEach( + configPaths, + (configPath) => loadExtendsChain(configPath, new Set()), + { concurrency: 'unbounded' }, + ) + + return new Set( + yield* Effect.forEach( + chains.flatMap((chain) => chain.slice(0, -1)), + ({ file }) => realPath(file), + { concurrency: 'unbounded' }, + ), + ) +}) + +/** + * A shared base kept as a tsconfig.json, the way `@tsconfig/bases` packages ship theirs, has no + * sources of its own: tsc fails it with TS18003, and the configs extending it are checked anyway. + */ +const isSharedBase = Effect.fn(function* ( + configPath: string, + extended: ReadonlySet, + cwd: string, + files: ReadonlyArray, +) { + const path = yield* Path.Path + + if (!extended.has(yield* realPath(configPath))) { + return false + } + + const inputs = yield* loadTsconfigInputs(configPath) + + return !files.some((file) => includesFile(inputs, path.resolve(cwd, file))) +}) + +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const + +/** + * The configs of the workspace packages that depend, directly or through others, on a package that + * owns one of `selected`: a package imported through its workspace link needs no project reference. + */ +const workspaceDependents = Effect.fn(function* ( + candidates: ReadonlyArray, + selected: ReadonlyArray, + manifests: ReadonlyArray, +) { + const path = yield* Path.Path + const packages = yield* Effect.forEach( + manifests, + (manifestPath) => + Effect.map(readJson(manifestPath), (manifest) => ({ + dir: path.dirname(manifestPath), + name: manifest?.name, + dependencies: DEPENDENCY_FIELDS.flatMap((field) => { + const versions = manifest?.[field] + return Predicate.isObject(versions) ? Object.keys(versions) : [] + }), + })), + // A manifest that fails to open for want of file descriptors would silently drop its package. + { concurrency: 64 }, + ) + const packageDirs = new Set(packages.map(({ dir }) => dir)) + const ownerOf = (configPath: string) => + ancestors(path, path.dirname(configPath)).find((dir) => packageDirs.has(dir)) + + const owners = new Set(selected.map(ownerOf)) + const reachedNames = new Set( + packages.filter(({ dir }) => owners.has(dir)).map(({ name }) => name), + ) + const dependentDirs = new Set() + + for (let grown = true; grown;) { + grown = false + + for (const { dir, name, dependencies } of packages) { + if ( + !dependentDirs.has(dir) && + dependencies.some((dependency) => reachedNames.has(dependency)) + ) { + dependentDirs.add(dir) + reachedNames.add(name) + grown = true + } + } + } + + return candidates.filter((configPath) => { + const owner = ownerOf(configPath) + return owner !== undefined && dependentDirs.has(owner) + }) +}) + function findCycle( nodes: ReadonlyArray, references: ReadonlyMap>, @@ -173,6 +370,7 @@ function findCycle( const selectTsconfigs = Effect.fn(function* ( references: ReadonlyMap>, files: ReadonlyArray, + deleted: ReadonlyArray, ) { const jsonFiles = yield* Effect.forEach( files.filter((file) => posix.extname(file) === '.json'), @@ -184,7 +382,7 @@ const selectTsconfigs = Effect.fn(function* ( [...references.keys()], (candidate) => // A deleted config is no candidate, so only the configs that reference it lead to its dependents. - references.get(candidate)!.some((config) => files.includes(config)) + references.get(candidate)!.some((config) => deleted.includes(config)) ? Effect.succeed(true) : Effect.flatMap(loadTsconfigInputs(candidate), (inputs) => files.some((file) => includesFile(inputs, file)) @@ -260,6 +458,13 @@ interface TsconfigInputs { readonly include: ReadonlyArray readonly exclude: ReadonlyArray readonly extensions: ReadonlySet + /** Neither `noEmit`, `emitDeclarationOnly`, `outDir` nor `outFile` keeps the output away from the sources. */ + readonly emitsBesideSources: boolean + readonly composite: boolean +} + +function hasInputs(inputs: TsconfigInputs): boolean { + return inputs.files.length > 0 || inputs.include.length > 0 } const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] @@ -282,9 +487,9 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { const leafDir = path.dirname(configPath) const last: Partial< - Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir', Specs> + Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir' | 'outFile', Specs> > = {} - let allowJs = false + const flags = { allowJs: false, noEmit: false, emitDeclarationOnly: false, composite: false } for (const { dir, raw } of chain) { for (const key of ['files', 'include', 'exclude'] as const) { @@ -298,7 +503,7 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { if (Predicate.isObject(raw.compilerOptions)) { const { compilerOptions } = raw - for (const key of ['outDir', 'declarationDir'] as const) { + for (const key of ['outDir', 'declarationDir', 'outFile'] as const) { const spec = compilerOptions[key] if (typeof spec === 'string') { @@ -306,17 +511,22 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { } } - if (typeof compilerOptions.allowJs === 'boolean') { - allowJs = compilerOptions.allowJs + for (const key of ['allowJs', 'noEmit', 'emitDeclarationOnly', 'composite'] as const) { + const value = compilerOptions[key] + + if (typeof value === 'boolean') { + flags[key] = value + } } if (compilerOptions.checkJs === true) { - allowJs = true + flags.allowJs = true } } } - const { files, include, exclude, outDir, declarationDir } = last + const { files, include, exclude, outDir, declarationDir, outFile } = last + const { allowJs, noEmit, emitDeclarationOnly, composite } = flags const resolve = (specs: Specs | undefined): string[] => specs?.specs.map((spec) => @@ -342,6 +552,9 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { }), exclude: excludeSpecs.map((spec) => ({ spec, matches: compileGlob(spec, 'exclude')! })), extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), + emitsBesideSources: + !noEmit && !emitDeclarationOnly && outDir === undefined && outFile === undefined, + composite, } }) @@ -407,24 +620,30 @@ const loadExtendsChain = Effect.fn(function* ( return [...bases.flat(), { file: configPath, dir, raw }] }) -const resolveExtends = Effect.fn(function* (spec: string, dir: string) { +const fileKind = Effect.fn(function* (target: string) { const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const kind = (target: string) => - fs.stat(target).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => undefined), - ) + return yield* fs.stat(target).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => undefined), + ) +}) - const firstFile = (candidates: ReadonlyArray) => - Effect.findFirst(candidates, (candidate) => - Effect.map(kind(candidate), (type) => type === 'File'), - ) +const firstFile = (candidates: ReadonlyArray) => + Effect.findFirst(candidates, (candidate) => + Effect.map(fileKind(candidate), (type) => type === 'File'), + ) + +const resolveExtends = Effect.fn(function* (spec: string, dir: string) { + const path = yield* Path.Path if (spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec)) { const target = path.resolve(dir, spec) - return yield* firstFile([target, `${target}.json`]) + + // A deleted base must still select the configs that extend it. + return Option.orElseSome(yield* firstFile([target, `${target}.json`]), () => + target.endsWith('.json') ? target : `${target}.json`, + ) } const [name, subpath] = splitPackageSpec(spec) @@ -440,7 +659,7 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { } const base = path.join(current, 'node_modules', spec) - const baseKind = yield* kind(base) + const baseKind = yield* fileKind(base) if (baseKind === 'File') { return Option.some(base) diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts index d9d8781..48c8afc 100644 --- a/packages/uncheck/tests/uncheck/tsc-extends.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -1,5 +1,13 @@ -import { LAYOUTS, monorepo } from '../utils/project' -import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './utils' +import { LAYOUTS, monorepo, report } from '../utils/project' +import { + ALLOW_JS, + CONFIG_DIR, + NOT_COVERED, + OUT_DIR, + SKIPPED_BESIDE_TSC, + tscPlan, + withFakeTsc, +} from './utils' const NO_JS = { compilerOptions: { allowJs: false } } @@ -226,6 +234,27 @@ describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { ]) expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) }) + + it('typechecks the projects extending a deleted base', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: NO_JS, + [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { include: ['src'] }, + }) + + project.git('rm', '--quiet', '--', `${app}tsconfig.base.json`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p web/tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + }) }) describe('tsc extends across the packages of a monorepo', () => { @@ -238,8 +267,8 @@ describe('tsc extends across the packages of a monorepo', () => { version: '2.0.0', }, 'packages/app/node_modules/@acme/tsconfig/tsconfig.json': NO_JS, - 'packages/app/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, - 'packages/core/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, + 'packages/app/tsconfig.json': { extends: '@acme/tsconfig', ...OUT_DIR, include: ['src'] }, + 'packages/core/tsconfig.json': { extends: '@acme/tsconfig', ...OUT_DIR, include: ['src'] }, }) expect(await tscPlan(project, '', ['packages/core/src/a.js'])).toEqual([ diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts index 5bc1974..a0f289c 100644 --- a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -203,7 +203,9 @@ describe('tsc inputs across the packages of a monorepo', () => { version: '1.0.0', private: true, }, - 'packages/tsconfig/base.json': { compilerOptions: { strict: true } }, + 'packages/tsconfig/base.json': { + compilerOptions: { strict: true, outDir: `${CONFIG_DIR}/dist` }, + }, 'packages/app/tsconfig.json': { extends: '@repo/tsconfig/base.json', include: ['src'] }, }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') @@ -212,4 +214,36 @@ describe('tsc inputs across the packages of a monorepo', () => { ]) expect(await tscPlan(project, '', ['packages/tsconfig/package.json'])).toEqual([NOT_COVERED]) }) + + it('checks a shared base kept as a tsconfig.json with no sources only through the configs extending it', async () => { + const project = withFakeTsc(monorepo, { + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + }, + 'packages/tsconfig/tsconfig.json': { + compilerOptions: { strict: true, outDir: `${CONFIG_DIR}/dist` }, + }, + 'packages/app/tsconfig.json': { + extends: '@repo/tsconfig', + references: [{ path: '../core' }], + include: ['src'], + }, + 'packages/web/tsconfig.json': { include: ['src'] }, + 'packages/web/src/index.ts': '', + 'packages/web/test/tsconfig.json': { extends: '../tsconfig.json', include: ['.'] }, + 'packages/docs/tsconfig.json': { include: ['scr'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + expect(await tscPlan(project, '')).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p packages/docs/tsconfig.json --noEmit', + '▶ tsc -p packages/web/test/tsconfig.json --noEmit', + '▶ tsc -p packages/web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/tsconfig/tsconfig.json'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + }) }) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index eb71e0b..40b894d 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -1,5 +1,14 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' -import { CONFIG_DIR, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' +import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CONFIG_DIR, + NO_EMIT, + NOT_COVERED, + OUT_DIR, + SKIPPED_BESIDE_TSC, + tscPlan, + withFakeTsc, +} from './utils' describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => { it('builds a solution-style tsconfig.json whose references have other names', async () => { @@ -8,8 +17,8 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], }, - [`${app}tsconfig.app.json`]: { include: ['src'] }, - [`${app}tsconfig.node.json`]: { include: ['vite.config.ts'] }, + [`${app}tsconfig.app.json`]: { ...NO_EMIT, include: ['src'] }, + [`${app}tsconfig.node.json`]: { ...NO_EMIT, include: ['vite.config.ts'] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -17,6 +26,74 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([NOT_COVERED]) }) + it('builds a references graph whose projects keep their output away from their sources, also through extends', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: ['no-emit', 'declarations', 'out-dir', 'out-file'].map((dir) => ({ + path: `./${dir}`, + })), + }, + [`${app}tsconfig.base.json`]: NO_EMIT, + [`${app}tsconfig.dist.json`]: { compilerOptions: { outDir: `${CONFIG_DIR}/dist` } }, + [`${app}no-emit/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}declarations/tsconfig.json`]: { + extends: '../tsconfig.base.json', + compilerOptions: { noEmit: false, emitDeclarationOnly: true }, + include: ['src'], + }, + [`${app}out-dir/tsconfig.json`]: { extends: '../tsconfig.dist.json', include: ['src'] }, + [`${app}out-file/tsconfig.json`]: { + compilerOptions: { outFile: 'index.js' }, + include: ['src'], + }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + }) + + it('checks the projects of a references graph with -p when tsc -b would write JavaScript next to their sources', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }, { path: './node' }] }, + [`${app}tsconfig.base.json`]: NO_EMIT, + [`${app}web/tsconfig.json`]: { + ...NO_EMIT, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}node/tsconfig.json`]: { + extends: '../tsconfig.base.json', + compilerOptions: { composite: true, noEmit: false }, + include: ['vite.config.ts'], + }, + [`${app}shared/tsconfig.json`]: { + compilerOptions: { composite: true, outDir: 'dist' }, + include: ['src'], + }, + [`${app}lib/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b lib/tsconfig.json', + '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p shared/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['node/vite.config.ts'])).toEqual([ + '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + ]) + expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ + '▶ tsc -b lib/tsconfig.json', + '▶ tsc -p shared/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['tsconfig.json'])).toEqual([NOT_COVERED]) + }) + it('follows references to a folder, to a .json file and with backslashes', async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { @@ -24,15 +101,17 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => references: [{ path: 'web' }, { path: '.\\server\\tsconfig.build.json' }], }, [`${app}web/tsconfig.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '../ui/tsconfig.json' }], }, - [`${app}ui/tsconfig.json`]: { include: ['src'] }, + [`${app}ui/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}server/tsconfig.build.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '..\\shared' }], }, - [`${app}shared/tsconfig.json`]: { include: ['src'] }, + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -45,8 +124,8 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it(`follows a reference path with ${CONFIG_DIR} as written, like tsc`, async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { include: ['src'] }, - [`${app}web/tsconfig.json`]: { references: [{ path: `${CONFIG_DIR}/../lib` }] }, - [`${app}web/lib/tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { ...OUT_DIR, references: [{ path: `${CONFIG_DIR}/../lib` }] }, + [`${app}web/lib/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}lib/tsconfig.json`]: { include: ['src'] }, }) @@ -63,7 +142,7 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => files: [], references: ['./scripts', { path: 42 }, null, { prepend: true }, { path: './lib' }], }, - [`${app}lib/tsconfig.json`]: { include: ['src'], references: './scripts' }, + [`${app}lib/tsconfig.json`]: { ...OUT_DIR, include: ['src'], references: './scripts' }, [`${app}scripts/tsconfig.json`]: { include: ['.'] }, }) @@ -76,11 +155,24 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('builds the roots of the reference graph that depend on the given files', async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { include: ['scripts'] }, - [`${app}shared/tsconfig.json`]: { include: ['src'] }, - [`${app}web/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, - [`${app}server/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, - [`${app}cli/tsconfig.json`]: { include: ['src'], references: [{ path: '../server' }] }, + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, + [`${app}web/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}server/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}cli/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../server' }], + }, [`${app}e2e/tsconfig.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '../web' }, { path: '../server' }], }, @@ -135,7 +227,7 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('leaves a reference to a missing config to tsc -b', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './legacy' }] }, + [`${app}tsconfig.json`]: { ...NO_EMIT, include: ['src'], references: [{ path: './legacy' }] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -144,7 +236,11 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('leaves a reference to a folder named like a config file to tsc -b', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './lib.json' }] }, + [`${app}tsconfig.json`]: { + ...NO_EMIT, + include: ['src'], + references: [{ path: './lib.json' }], + }, [`${app}lib.json/index.ts`]: '', }) @@ -157,7 +253,11 @@ describe('tsc project references across the packages of a monorepo', () => { const project = withFakeTsc(monorepo, { 'tsconfig.json': { include: ['scripts'] }, 'packages/web/package.json': { name: '@repo/web', version: '1.0.0', private: true }, - 'packages/web/tsconfig.json': { include: ['src'], references: [{ path: '../core' }] }, + 'packages/web/tsconfig.json': { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../core' }], + }, 'packages/cli/package.json': { name: '@repo/cli', version: '1.0.0', private: true }, 'packages/cli/tsconfig.json': { include: ['src'] }, }) @@ -188,3 +288,131 @@ describe('tsc project references across the packages of a monorepo', () => { ]) }) }) + +describe('tsc project references with the real compiler in a single repo', () => { + const COMPOSITE = { + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + composite: true, + emitDeclarationOnly: true, + outDir: 'dist', + rootDir: 'src', + } + + it('checks each project of a references graph holding a Vite 4 config with -p, writing no file', async () => { + const project = singleRepo({ + 'tsconfig.node.json': { + compilerOptions: { + composite: true, + skipLibCheck: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + }, + include: ['vite.config.ts'], + }, + 'vite.config.ts': 'export default { base: "/" };\n', + 'lib/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, + 'lib/src/index.ts': 'export const one = 1;\n', + }) + .update('tsconfig.json', (config) => ({ + ...config, + references: [{ path: './tsconfig.node.json' }, { path: './lib' }], + })) + .commit() + const plan = [ + '▶ tsc -p lib/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', + ] + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + ...plan, + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(project.git('status', '--porcelain', '--ignored')).toBe('!! node_modules/\n') + + project.write({ 'vite.config.ts': CODE_WITH_TYPE_ERROR }) + + const failed = await project.uncheck(['--only=tsc']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + ...plan, + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(failed.stdout).toContain( + "vite.config.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) + + it('builds roots in folders starting with - or @, which tsc would read as an option or a file of arguments', async () => { + const project = singleRepo({ + 'core/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, + 'core/src/index.ts': 'export const one = 1;\n', + ...Object.fromEntries( + ['-pkg', '@app'].flatMap((folder) => [ + [ + `${folder}/tsconfig.json`, + { compilerOptions: COMPOSITE, include: ['src'], references: [{ path: '../core' }] }, + ], + [`${folder}/src/index.ts`, CODE_WITH_TYPE_ERROR], + ]), + ), + }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -b ./-pkg/tsconfig.json ./@app/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + + for (const folder of ['-pkg', '@app']) { + expect(stdout).toContain( + `\n${folder}/src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`, + ) + } + }) + + it('runs the other checks when a reference names a file, and tsc reports it', async () => { + const project = singleRepo({ 'README.md': '# App\n' }).update('tsconfig.json', (config) => ({ + ...config, + references: [{ path: './README.md' }], + })) + + const { exitCode, stdout } = await project.uncheck() + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern', + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stdout).toContain( + `error TS6053: File '${project.path('README.md/tsconfig.json')}' not found.`, + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts new file mode 100644 index 0000000..92b473f --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts @@ -0,0 +1,91 @@ +import type { Files, ProjectOptions } from '../utils/project' +import { monorepo, report } from '../utils/project' +import { SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' + +const PACKAGE_CONFIG = { + compilerOptions: { + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + noEmit: true, + }, + include: ['src'], +} + +/** A workspace whose `app` imports the sources of `core` through its link alone, without project references. */ +function linkedMonorepo(files: Files = {}, options?: ProjectOptions) { + return monorepo( + { + 'tsconfig.json': null, + 'packages/core/package.json': { + name: '@repo/core', + version: '1.0.0', + private: true, + type: 'module', + exports: './src/index.ts', + }, + 'packages/core/tsconfig.json': PACKAGE_CONFIG, + 'packages/app/tsconfig.json': PACKAGE_CONFIG, + ...files, + }, + options, + ) +} + +describe('tsc across the packages of a workspace', () => { + it('checks the packages that depend on a changed one, directly or through others', async () => { + const project = withFakeTsc(linkedMonorepo, { + 'packages/e2e/package.json': { + name: '@repo/e2e', + version: '1.0.0', + private: true, + devDependencies: { '@repo/app': 'workspace:*' }, + }, + 'packages/e2e/tsconfig.json': PACKAGE_CONFIG, + 'packages/web/package.json': { + name: '@repo/web', + version: '1.0.0', + private: true, + peerDependencies: { react: '*' }, + }, + 'packages/web/tsconfig.json': PACKAGE_CONFIG, + 'packages/broken/package.json': 'oops\n', + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + '▶ tsc -p packages/e2e/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/app/src/index.ts'])).toEqual([ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/e2e/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/web/src/index.ts'])).toEqual([ + '▶ tsc -p packages/web/tsconfig.json --noEmit', + ]) + }) + + it('fails a change that breaks a package importing it through its workspace link', async () => { + const project = linkedMonorepo().write({ + 'packages/core/src/index.ts': + 'export function double(value: number): string {\n return String(value);\n}\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'packages/core/src/index.ts']) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain( + "packages/app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 931d80b..6de85bc 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,8 +1,20 @@ import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, run, singleRepo } from '../utils/project' -import { ALLOW_JS, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' +import { + ALLOW_JS, + CLEAN_CODE, + CODE_WITH_TYPE_ERROR, + NOT_COVERED, + OUT_DIR, + SKIPPED_BESIDE_TSC, + tscPlan, + withFakeTsc, +} from './utils' describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { - it('skips the check when the project has no tsconfig.json', async () => { + const uncoveredFolder = + app === '' ? 'no tsconfig.json found' : 'no tsconfig.json covers this folder' + + it('skips the check when no tsconfig.json covers the folder', async () => { const project = create({ [`${app}tsconfig.json`]: null }) const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) @@ -11,8 +23,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.path(app, '.')}`, ...SKIPPED_BESIDE_TSC, - '○ tsc skipped, no tsconfig.json found', - '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json found', + `○ tsc skipped, ${uncoveredFolder}`, + `✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc ${uncoveredFolder}`, ]) }) @@ -20,7 +32,19 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { const project = withFakeTsc(create).write({ [`${app}tsconfig.json`]: null }) expect(project.git('ls-files', `${app}tsconfig.json`)).toBe(`${app}tsconfig.json\n`) - expect(await tscPlan(project, app)).toEqual(['○ tsc skipped, no tsconfig.json found']) + expect(await tscPlan(project, app)).toEqual([`○ tsc skipped, ${uncoveredFolder}`]) + }) + + it('checks a folder below its tsconfig.json with that config, if it includes files of the folder', async () => { + const project = withFakeTsc(create, { [`${app}scripts/release.ts`]: '' }) + const inherited = tsc.replace('tsconfig.json', '../tsconfig.json') + + expect(await tscPlan(project, `${app}src`)).toEqual([inherited]) + expect(await tscPlan(project, `${app}src`, ['index.ts'])).toEqual([inherited]) + expect(await tscPlan(project, `${app}scripts`)).toEqual([ + '○ tsc skipped, no tsconfig.json covers this folder', + ]) + expect(await tscPlan(project, `${app}scripts`, ['release.ts'])).toEqual([NOT_COVERED]) }) it('checks a tsconfig.json with merge conflicts once', async () => { @@ -114,7 +138,7 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { create, { [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }] }, - [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}scripts/tsconfig.json`]: { include: ['.'] }, [`${app}node_modules/@tsconfig/node22/tsconfig.json`]: ALLOW_JS, [`${app}web/node_modules/lib/tsconfig.json`]: {}, @@ -131,6 +155,66 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { }) describe('tsc with the real compiler in a single repo', () => { + it('typechecks a folder below the tsconfig.json with it, as tsc does', async () => { + const project = singleRepo({ + 'src/index.ts': CODE_WITH_TYPE_ERROR, + 'scripts/release.ts': CLEAN_CODE, + }) + const failedIn = (folder: string) => [ + `uncheck in ${project.path(folder)}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p ../tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ] + const diagnostic = + "index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." + + const inside = await project.uncheck(['--only=tsc'], { cwd: 'src' }) + + expect(inside.exitCode).toBe(1) + expect(report(inside.stdout)).toEqual(failedIn('src')) + expect(inside.stdout).toContain(`\n${diagnostic}`) + + const given = await project.uncheck(['--only=tsc', '--cwd', 'src', 'index.ts']) + + expect(given.exitCode).toBe(1) + expect(report(given.stdout)).toEqual(failedIn('src')) + expect(given.stdout).toContain(`\n${diagnostic}`) + + const outside = await project.uncheck(['--only=tsc'], { cwd: 'scripts' }) + + expect(outside.exitCode).toBe(1) + expect(report(outside.stdout)).toEqual([ + `uncheck in ${project.path('scripts')}`, + ...SKIPPED_BESIDE_TSC, + '○ tsc skipped, no tsconfig.json covers this folder', + '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers this folder', + ]) + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'hands a tsconfig.json in a folder it cannot open to tsc, which reports it', + async () => { + const project = singleRepo({ 'locked/tsconfig.json': { include: ['.'] } }).chmod('locked', 0) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p locked/tsconfig.json --noEmit', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain( + `error TS5058: The specified path does not exist: '${project.path('locked/tsconfig.json')}'.`, + ) + }, + ) + it('typechecks a tsconfig.json with -p --noEmit, emitting nothing', async () => { const project = singleRepo({ 'tsconfig.json': { diff --git a/packages/uncheck/tests/uncheck/utils.ts b/packages/uncheck/tests/uncheck/utils.ts index 5580887..c020a47 100644 --- a/packages/uncheck/tests/uncheck/utils.ts +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -82,6 +82,11 @@ export const CONFIG_DIR = '${configDir}' export const ALLOW_JS = { compilerOptions: { allowJs: true } } +/** Without it, the members of a references graph are checked with `-p` instead of built with `-b`. */ +export const OUT_DIR = { compilerOptions: { outDir: 'dist' } } + +export const NO_EMIT = { compilerOptions: { noEmit: true } } + export function withFakeTsc( create: Layout['create'], files?: Files, From 5e6f40a26ebcb770c351893ab0448d85ceb9ee4d Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 19:56:09 +0700 Subject: [PATCH 11/25] docs(uncheck): the presets need oxfmt 0.43+, the first to take a boolean sortImports --- 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 9bb115c..b795572 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -203,7 +203,7 @@ export default defineConfig({ ...middleapi }) } ``` -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. +The presets need oxlint 1.70+, oxfmt 0.43+ 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. ## Troubleshooting From a962b11bfed5e9c054b84d20e0b374472b4a4359 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 20:08:02 +0700 Subject: [PATCH 12/25] fix(uncheck): address review of A3 - build the clean projects that -p checked ones reference first, so -p reads fresh declarations - look for a tsconfig.json above the folder only inside its git repository - test workspace dependents reached through references and peer or optional dependencies --- packages/uncheck/README.md | 4 +- packages/uncheck/src/checks/tsc.ts | 98 ++++++++++++------- .../tests/uncheck/tsc-references.test.ts | 42 +++++--- .../tests/uncheck/tsc-workspace.test.ts | 27 +++++ packages/uncheck/tests/uncheck/tsc.test.ts | 28 +++++- 5 files changed, 149 insertions(+), 50 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index b160f84..945b84c 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -153,10 +153,10 @@ Run uncheck from the workspace root, the folder whose `package.json` has `worksp **TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: -- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. If one of them sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile`, `tsc -b` would write JavaScript next to its sources, so each project of that graph is checked with `tsc -p --noEmit` instead. +- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. A project that sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile` would get JavaScript next to its sources, so it and the projects that reference it are checked with `tsc -p --noEmit` instead, after `tsc -b` builds the projects they reference. These cannot import each other: tsc reports TS6305 until you build them. - Every other project is checked with `tsc -p --noEmit`, a few at a time. A `tsconfig.json` that other configs extend and that includes no files is a shared base, not a project. - When only some files are checked, tsc runs just the projects that include them, the projects that reference those, and the projects of the packages that depend on theirs. A changed tsconfig selects every project that extends it, and a deleted or moved file the projects that included it. -- In a folder with no `tsconfig.json`, such as a package that shares the root one, uncheck uses the nearest one above it, as tsc does, when it includes files of that folder. +- In a folder with no `tsconfig.json`, such as a package that shares the root one, uncheck uses the nearest one above it in the same git repository, when it includes files of that folder. **Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags: diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index e9f8e6b..9c39075 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -70,13 +70,7 @@ export const tsc: Check = { ) const inherited = - tracked.length > 0 - ? undefined - : Option.getOrUndefined( - yield* firstFile( - ancestors(path, path.dirname(cwd)).map((dir) => path.join(dir, 'tsconfig.json')), - ), - ) + tracked.length > 0 ? undefined : Option.getOrUndefined(yield* tsconfigAboveInRepository(cwd)) if (inherited === undefined && tracked.length === 0) { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) @@ -181,7 +175,29 @@ export const tsc: Check = { } } - const roots = members.filter((member) => !referenced.has(member) && affected.has(member)) + const memberInputs = new Map( + yield* Effect.forEach( + members, + (configPath) => + Effect.map(loadTsconfigInputs(configPath), (inputs) => [configPath, inputs] as const), + { concurrency: 'unbounded' }, + ), + ) + // `tsc -b` on these would write JavaScript next to the sources of a project, and Vite, for one, + // then loads a stale vite.config.js instead of vite.config.ts. + const buildsBesideSources = new Set( + members.filter((member) => { + const inputs = memberInputs.get(member)! + return hasInputs(inputs) && inputs.emitsBesideSources + }), + ) + + for (const configPath of buildsBesideSources) { + for (const dependent of dependents.get(configPath) ?? []) { + buildsBesideSources.add(dependent) + } + } + const extended = yield* extendedConfigs([...references.keys()]) const allFiles = yield* projectFiles const standalone = yield* Effect.filter( @@ -190,48 +206,47 @@ export const tsc: Check = { Effect.map(isSharedBase(configPath, extended, cwd, allFiles), (shared) => !shared), { concurrency: 'unbounded' }, ) - const built: string[] = [] + const built = new Set( + members.filter( + (member) => + !referenced.has(member) && affected.has(member) && !buildsBesideSources.has(member), + ), + ) const checkedAlone = new Map>( standalone.map((configPath) => [configPath, []]), ) - for (const root of roots) { - const graph = new Set([root]) + for (const member of members) { + if (affected.has(member) && buildsBesideSources.has(member)) { + const inputs = memberInputs.get(member)! + const checked = hasInputs(inputs) - for (const configPath of graph) { - for (const referencedConfig of references.get(configPath)!) { - if (references.has(referencedConfig)) { - graph.add(referencedConfig) - } + if (checked) { + checkedAlone.set(member, inputs.composite ? WITHOUT_BUILD_INFO : []) } - } - - const graphInputs = yield* Effect.forEach( - graph, - (configPath) => - Effect.map(loadTsconfigInputs(configPath), (inputs) => ({ configPath, inputs })), - { concurrency: 'unbounded' }, - ) - // `tsc -b` would write JavaScript next to the sources of such a project, and Vite, for one, - // then loads a stale vite.config.js instead of vite.config.ts. - if (graphInputs.some(({ inputs }) => hasInputs(inputs) && inputs.emitsBesideSources)) { - for (const { configPath, inputs } of graphInputs) { - if (affected.has(configPath) && hasInputs(inputs)) { - checkedAlone.set(configPath, inputs.composite ? WITHOUT_BUILD_INFO : []) + // `-p` reads a referenced project through the declarations on disk, which a fresh checkout + // lacks and an earlier build left stale. + for (const referencedConfig of references.get(member)!) { + if ( + memberInputs.has(referencedConfig) && + !buildsBesideSources.has(referencedConfig) && + (checked || affected.has(referencedConfig)) + ) { + built.add(referencedConfig) } } - } else { - built.push(root) } } - if (built.length === 0 && checkedAlone.size === 0) { + if (built.size === 0 && checkedAlone.size === 0) { return yield* Effect.fail(notCovered(typescript, files)) } return [ - ...(built.length > 0 ? [{ bin: typescript, args: ['-b', ...built.map(shown)] }] : []), + ...(built.size > 0 + ? [{ bin: typescript, args: ['-b', ...[...built].sort().map(shown)] }] + : []), // `-p` would emit JavaScript next to sources that set no `noEmit`. ...[...checkedAlone.keys()].sort().map((configPath) => ({ bin: typescript, @@ -242,6 +257,21 @@ export const tsc: Check = { }), } +const tsconfigAboveInRepository = Effect.fn(function* (cwd: string) { + const path = yield* Path.Path + const dirs = ancestors(path, cwd) + // A tsconfig.json above the repository, say in the home folder, belongs to another project. + const top = yield* Effect.findFirst(dirs, (dir) => + Effect.map(fileKind(path.join(dir, '.git')), (kind) => kind !== undefined), + ) + const searched = Option.match(top, { + onNone: () => [], + onSome: (dir) => dirs.slice(1, dirs.indexOf(dir) + 1), + }) + + return yield* firstFile(searched.map((dir) => path.join(dir, 'tsconfig.json'))) +}) + /** The configs other configs extend, by their real path, as an extends through a workspace link names the link. */ const extendedConfigs = Effect.fn(function* (configPaths: ReadonlyArray) { const chains = yield* Effect.forEach( diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index 40b894d..703df93 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -52,25 +52,30 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) }) - it('checks the projects of a references graph with -p when tsc -b would write JavaScript next to their sources', async () => { + it('checks with -p the projects whose tsc -b would write JavaScript next to sources, after building the projects they reference', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }, { path: './node' }] }, + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: './web' }, { path: './node' }, { path: './shared' }], + }, [`${app}tsconfig.base.json`]: NO_EMIT, [`${app}web/tsconfig.json`]: { ...NO_EMIT, include: ['src'], - references: [{ path: '../shared' }], + references: [{ path: '../node' }, { path: '../ui' }, { path: '../legacy' }], }, [`${app}node/tsconfig.json`]: { extends: '../tsconfig.base.json', compilerOptions: { composite: true, noEmit: false }, include: ['vite.config.ts'], }, - [`${app}shared/tsconfig.json`]: { + [`${app}ui/tsconfig.json`]: { compilerOptions: { composite: true, outDir: 'dist' }, include: ['src'], + references: [{ path: '../shared' }], }, - [`${app}lib/tsconfig.json`]: { + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, + [`${app}docs/tsconfig.json`]: { ...OUT_DIR, include: ['src'], references: [{ path: '../shared' }], @@ -78,17 +83,17 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => }) expect(await tscPlan(project, app)).toEqual([ - '▶ tsc -b lib/tsconfig.json', + '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', - '▶ tsc -p shared/tsconfig.json --noEmit --composite false --declaration', '▶ tsc -p web/tsconfig.json --noEmit', ]) expect(await tscPlan(project, app, ['node/vite.config.ts'])).toEqual([ + '▶ tsc -b ui/tsconfig.json', '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p web/tsconfig.json --noEmit', ]) expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ - '▶ tsc -b lib/tsconfig.json', - '▶ tsc -p shared/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', '▶ tsc -p web/tsconfig.json --noEmit', ]) expect(await tscPlan(project, app, ['tsconfig.json'])).toEqual([NOT_COVERED]) @@ -301,7 +306,7 @@ describe('tsc project references with the real compiler in a single repo', () => rootDir: 'src', } - it('checks each project of a references graph holding a Vite 4 config with -p, writing no file', async () => { + it('builds the library a Vite 4 app imports before checking the app with -p, writing no JavaScript', async () => { const project = singleRepo({ 'tsconfig.node.json': { compilerOptions: { @@ -316,6 +321,8 @@ describe('tsc project references with the real compiler in a single repo', () => 'vite.config.ts': 'export default { base: "/" };\n', 'lib/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, 'lib/src/index.ts': 'export const one = 1;\n', + 'src/index.ts': + 'import { one } from "../lib/src/index";\n\nexport const two: number = one + 1;\n', }) .update('tsconfig.json', (config) => ({ ...config, @@ -323,7 +330,7 @@ describe('tsc project references with the real compiler in a single repo', () => })) .commit() const plan = [ - '▶ tsc -p lib/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -b lib/tsconfig.json', '▶ tsc -p tsconfig.json --noEmit', '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', ] @@ -338,9 +345,14 @@ describe('tsc project references with the real compiler in a single repo', () => '✔ tsc passed', '✔ all checks passed (tsc)', ]) - expect(project.git('status', '--porcelain', '--ignored')).toBe('!! node_modules/\n') + expect(project.git('status', '--porcelain', '--ignored')).toBe( + '!! lib/dist/\n!! lib/tsconfig.tsbuildinfo\n!! node_modules/\n', + ) - project.write({ 'vite.config.ts': CODE_WITH_TYPE_ERROR }) + project.write({ + 'lib/src/index.ts': 'export const one = "1";\n', + 'vite.config.ts': CODE_WITH_TYPE_ERROR, + }) const failed = await project.uncheck(['--only=tsc']) @@ -352,9 +364,13 @@ describe('tsc project references with the real compiler in a single repo', () => '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', ]) + expect(failed.stdout).toContain( + "src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) expect(failed.stdout).toContain( "vite.config.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.", ) + expect(project.exists('vite.config.js')).toBe(false) }) it('builds roots in folders starting with - or @, which tsc would read as an option or a file of arguments', async () => { diff --git a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts index 92b473f..26db8ca 100644 --- a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts @@ -50,13 +50,29 @@ describe('tsc across the packages of a workspace', () => { peerDependencies: { react: '*' }, }, 'packages/web/tsconfig.json': PACKAGE_CONFIG, + 'packages/ui/package.json': { + name: '@repo/ui', + version: '1.0.0', + private: true, + peerDependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/ui/tsconfig.json': PACKAGE_CONFIG, + 'packages/cli/package.json': { + name: '@repo/cli', + version: '1.0.0', + private: true, + optionalDependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/cli/tsconfig.json': PACKAGE_CONFIG, 'packages/broken/package.json': 'oops\n', }) expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/cli/tsconfig.json --noEmit', '▶ tsc -p packages/core/tsconfig.json --noEmit', '▶ tsc -p packages/e2e/tsconfig.json --noEmit', + '▶ tsc -p packages/ui/tsconfig.json --noEmit', ]) expect(await tscPlan(project, '', ['packages/app/src/index.ts'])).toEqual([ '▶ tsc -p packages/app/tsconfig.json --noEmit', @@ -67,6 +83,17 @@ describe('tsc across the packages of a workspace', () => { ]) }) + it('builds the configs that reference the config of a package depending on a changed one', async () => { + const project = withFakeTsc(linkedMonorepo, { + 'tsconfig.json': { files: [], references: [{ path: 'packages/app' }] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + ]) + }) + it('fails a change that breaks a package importing it through its workspace link', async () => { const project = linkedMonorepo().write({ 'packages/core/src/index.ts': diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 6de85bc..097fcfd 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,4 +1,12 @@ -import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, run, singleRepo } from '../utils/project' +import { + git, + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + report, + run, + singleRepo, +} from '../utils/project' import { ALLOW_JS, CLEAN_CODE, @@ -154,6 +162,24 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { }) }) +describe('tsc in a folder below a tsconfig.json of another project', () => { + it('never uses a tsconfig.json above the git repository, or above a folder outside one', async () => { + const project = withFakeTsc( + singleRepo, + { 'tsconfig.json': {}, 'repo/src/index.ts': '' }, + { git: 'none' }, + ) + const notFound = ['○ tsc skipped, no tsconfig.json found'] + + expect(await tscPlan(project, 'repo/src')).toEqual(notFound) + + git(project.path('repo'), ['init', '--quiet']) + + expect(await tscPlan(project, 'repo')).toEqual(notFound) + expect(await tscPlan(project, 'repo/src', ['index.ts'])).toEqual(notFound) + }) +}) + describe('tsc with the real compiler in a single repo', () => { it('typechecks a folder below the tsconfig.json with it, as tsc does', async () => { const project = singleRepo({ From 0ccb3948a64fc38b55e3002c60cd76a52ece0890 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 20:23:39 +0700 Subject: [PATCH 13/25] fix(uncheck): agent hook keeps sherif report-only, remembers its own block and checks the agent's project --- packages/uncheck/README.md | 5 +- packages/uncheck/src/commands/hooks/run.ts | 156 +++++++++++++++--- packages/uncheck/src/commands/staged.ts | 2 +- packages/uncheck/src/commands/uncheck.ts | 6 +- packages/uncheck/src/types.ts | 2 +- .../uncheck/tests/hooks/run/changes.test.ts | 37 ++++- .../uncheck/tests/hooks/run/families.test.ts | 58 ++++++- .../uncheck/tests/hooks/run/location.test.ts | 146 ++++++++++++++-- packages/uncheck/tests/hooks/run/utils.ts | 17 +- 9 files changed, 382 insertions(+), 47 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 945b84c..794633b 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -138,9 +138,12 @@ npx uncheck hooks install # or pick them from a list | Cursor | `cursor` | `.cursor/hooks.json` | | GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` | -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. +Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back at most once per turn, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. +- **sherif only reports** here, since its fixes reach beyond the agent's change: they move versions in other packages and run your install. Run `npx uncheck --fix` for them. - **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags. +- **Outside git** and before the first commit, the hook checks the whole folder, and oxlint also fixes the files that links in it point to. +- **Installed from a package?** The hook finds uncheck only where your package manager can run it, so add uncheck to the workspace root too when the agent may work outside that package. - **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. diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 4441f1f..dff72cc 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' import process from 'node:process' import { stripVTControlCharacters } from 'node:util' @@ -16,7 +18,7 @@ export const run = Command.make( 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', + "Directory to check. Defaults to the top of the git repository around the current directory, or around the agent's project (CLAUDE_PROJECT_DIR or CODEBUDDY_PROJECT_DIR) when the current directory is outside git or in a repository nested in the project's, and to the project itself outside git", ), ), dir: Flag.String('dir').pipe( @@ -25,7 +27,11 @@ export const run = Command.make( '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, + fix: fixFlag.pipe( + Flag.withDescription( + 'Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) in the changed files. sherif only reports here: run `uncheck --fix` for its fixes', + ), + ), ...selectionFlags, }, Effect.fn(function* ({ cwd: given, dir, ...settings }) { @@ -50,16 +56,44 @@ export const run = Command.make( } const start = Option.getOrElse(given, () => process.cwd()) - const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe( - Effect.orElseSucceed(() => start), - ) + const projectDir = Option.isSome(given) + ? undefined + : (process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEBUDDY_PROJECT_DIR) + const top = yield* topToCheck(start, projectDir) 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}, run \`uncheck hooks install\` again from the project`, + if (Option.isSome(dir)) { + const isFolder = yield* fs.stat(cwd).pipe( + Effect.map((info) => info.type === 'Directory'), + Effect.orElseSucceed(() => false), ) + + if (!isFolder) { + return yield* userError( + `--dir=${dir.value} names no folder in ${top}, run \`uncheck hooks install\` again from the project`, + ) + } + } + + // Agents also set these when another Stop hook (Claude Code's /goal is one) continued the turn, so + // only the marker uncheck leaves when it blocks tells that it already sent the agent back. + const continuing = + payload.stop_hook_active === true || + (typeof payload.loop_count === 'number' && payload.loop_count > 0) + const sessionId = payload.session_id ?? payload.conversation_id + const marker = + typeof sessionId === 'string' + ? path.join( + tmpdir(), + `uncheck-stop-${createHash('sha256') + .update(JSON.stringify([sessionId, path.resolve(cwd)])) + .digest('hex')}`, + ) + : undefined + + if (marker !== undefined && !continuing) { + yield* Effect.ignore(fs.remove(marker, { force: true })) } const changed = yield* listChangedFiles(cwd) @@ -72,6 +106,7 @@ export const run = Command.make( ...settings, cwd, literal: true, + fixesWithinFiles: true, deleted: changed?.deleted, }).pipe( Effect.map(() => false), @@ -92,11 +127,30 @@ export const run = Command.make( // 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) + const reason = `uncheck found problems, fix them before finishing:\n\n${report}` + const sendBack = + typeof (payload.stopReason ?? payload.stop_reason) === 'string' + ? Console.log(JSON.stringify({ decision: 'block', reason })) + : payload.hook_event_name === 'Stop' + ? Effect.fail(new StopBlocked()) + : payload.hook_event_name === 'stop' + ? Console.log(JSON.stringify({ followup_message: reason })) + : undefined + + if (sendBack === undefined) { + return + } - if (alreadyContinued) { + // A marker that cannot be written counts as one that exists, so a broken temporary folder never + // sends the agent back on every stop. + const firstBlock = + marker !== undefined && + (yield* fs.writeFileString(marker, '', { flag: 'wx' }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + )) + + if (continuing && !firstBlock) { if (payload.hook_event_name === 'Stop') { const summary = report .split('\n') @@ -111,22 +165,74 @@ export const run = Command.make( 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()) - } - - if (payload.hook_event_name === 'stop') { - return yield* Console.log(JSON.stringify({ followup_message: reason })) - } + return yield* sendBack }), ).pipe( Command.withDescription( 'Run as an agent stop hook: check the files changed since the last commit, report on stderr and send the agent back to fix what remains', ), ) + +interface Repository { + readonly top: string + readonly commonDir: string +} + +const repositoryAround = Effect.fn( + function* (folder: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const [top, commonDir] = yield* Effect.all( + [ + git(folder, ['rev-parse', '--show-toplevel']), + git(folder, ['rev-parse', '--git-common-dir']), + ], + { concurrency: 'unbounded' }, + ) + + // git prints the common dir relative to `folder`, which may run through a link. + const repository: Repository = { + top, + commonDir: yield* fs.realPath(path.resolve(folder, commonDir)), + } + + return repository + }, + Effect.orElseSucceed(() => undefined), +) + +const topToCheck = Effect.fn(function* (start: string, projectDir: string | undefined) { + const path = yield* Path.Path + + const [here, project] = yield* Effect.all( + [ + repositoryAround(start), + projectDir === undefined ? Effect.succeed(undefined) : repositoryAround(projectDir), + ], + { concurrency: 'unbounded' }, + ) + + if (project === undefined) { + return here?.top ?? projectDir ?? start + } + + if (here === undefined) { + return project.top + } + + // Claude Code keeps the project folder on the main checkout while the agent works in a linked + // worktree, which shares its common dir: only a submodule or a nested clone, with a common dir of + // its own, gives way to the project's repository above it. + let outer: Repository | undefined = here + + while ( + outer !== undefined && + outer.commonDir !== project.commonDir && + outer.top !== path.dirname(outer.top) + ) { + outer = yield* repositoryAround(path.dirname(outer.top)) + } + + return outer?.commonDir === project.commonDir ? outer.top : here.top +}) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index dbfb968..61b34af 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -103,7 +103,7 @@ export const staged = Command.make( cwd, fix, literal: true, - staged: true, + fixesWithinFiles: true, deleted, }).pipe(Effect.catchTag('CheckFailed', Effect.succeed)) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 27e8b2a..1ae5fc7 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -65,8 +65,8 @@ export interface RunSettings extends CheckSelection { 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 + /** Only the fixes that stay within the given files apply. */ + readonly fixesWithinFiles?: boolean /** Paths the change deletes. Only for `literal` runs: without paths, any other run checks everything. */ readonly deleted?: ReadonlyArray } @@ -144,7 +144,7 @@ export const checkPaths = Effect.fn(function* ( deleted = [], } = settings const appliesFixes = (fixes: Check['fixes']) => - settings.staged === true ? fixes === 'files' : fixes !== false + settings.fixesWithinFiles === true ? fixes === 'files' : fixes !== false const { cwd } = settings const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts index b2a5ea1..f8cd9f1 100644 --- a/packages/uncheck/src/types.ts +++ b/packages/uncheck/src/types.ts @@ -34,7 +34,7 @@ export interface CheckInput { export interface Check { readonly name: CheckName - /** `workspace` fixes reach beyond the given files, so a commit, which stages only those, cannot take them. */ + /** `workspace` fixes reach beyond the given files, so the hooks, which fix only a commit or an agent's change, leave them out. */ readonly fixes: false | 'files' | 'workspace' readonly plan: ( input: CheckInput, diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index c3b6a93..f5b2565 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -2,7 +2,7 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS, report, singleRepo, temporaryDirectory } from '../../utils/project' +import { LAYOUTS, monorepo, report, singleRepo, temporaryDirectory } from '../../utils/project' import { CLAUDE_CODE_STOP, stopHook, UTILS_NOT_FOUND } from './utils' const UNFORMATTED = 'export const legacy = 1\n' @@ -249,3 +249,38 @@ describe('hooks run with deleted files in a single repo', () => { expect(stderr).toContain(UTILS_NOT_FOUND) }) }) + +describe('hooks run with a changed package.json in a monorepo', () => { + it('only reports what sherif finds, since its fixes reach beyond the change', async () => { + const project = monorepo() + .update('packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + .commit('zod') + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) + const manifests = ['package.json', 'packages/core/package.json', 'packages/app/package.json'] + const before = manifests.map((file) => project.read(file)) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix --no-error-on-unmatched-pattern packages/app/package.json', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern packages/app/package.json', + '✔ oxfmt passed', + '○ tsc skipped, no tsconfig.json covers the given files', + '✘ 1 of 3 checks failed: sherif', + ]) + expect(stderr).toContain('multiple-dependency-versions') + expect(manifests.map((file) => project.read(file))).toEqual(before) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index 2c6e5e6..a426b7c 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -7,6 +7,7 @@ import { COPILOT_STOP_IN_CLAUDE_FORMAT, CURSOR_STOP, TYPE_ERROR, + hookEnv, stopHook, } from './utils' @@ -41,7 +42,11 @@ async function installHook( : (JSON.parse(project.read(`${app}.cursor/hooks.json`)) as CursorHooks).hooks.stop[0]!.command return (payload) => - run(['sh', '-c', command], { cwd: project.path('docs'), input: JSON.stringify(payload) }) + run(['sh', '-c', command], { + cwd: project.path('docs'), + env: hookEnv(), + input: JSON.stringify(payload), + }) } function sendBackReason(stderr: string): string { @@ -97,6 +102,45 @@ describe.each(LAYOUTS)( expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') }) + it('blocks Claude Code once per turn, even after another hook continued it', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const continued = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(continued.exitCode).toBe(2) + expect(continued.stdout).toBe('') + expect(report(continued.stderr)).toEqual(failure(project)) + + const again = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(again.exitCode).toBe(0) + expect(JSON.parse(again.stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(report(again.stderr)).toEqual(failure(project)) + + const nextTurn = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(nextTurn.exitCode).toBe(2) + expect(nextTurn.stdout).toBe('') + expect(report(nextTurn.stderr)).toEqual(failure(project)) + }) + + it('takes a continued turn without a session id as one uncheck already blocked', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, { + hook_event_name: 'Stop', + stop_hook_active: true, + }) + + expect(exitCode).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(report(stderr)).toEqual(failure(project)) + }) + it('sends Cursor back once with a follow-up message, through its hook run from outside the change', async () => { const project = create(DOCS) const stop = await installHook(project, app, 'cursor') @@ -116,6 +160,18 @@ describe.each(LAYOUTS)( expect(again.exitCode).toBe(0) expect(again.stdout).toBe('') expect(report(again.stderr)).toEqual(failure(project)) + + const anotherConversation = await stop({ + ...CURSOR_STOP, + conversation_id: 'other', + loop_count: 1, + }) + + expect(anotherConversation.exitCode).toBe(0) + expect(report(anotherConversation.stderr)).toEqual(failure(project)) + expect(JSON.parse(anotherConversation.stdout)).toEqual({ + followup_message: sendBackReason(anotherConversation.stderr), + }) }) it('blocks Copilot with a decision, whichever payload format it sends', async () => { diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index 9c01548..553369d 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -2,13 +2,15 @@ import { CLI, LAYOUTS, cliError, + git, + linkedWorktree, monorepo, report, run, singleRepo, temporaryDirectory, } from '../../utils/project' -import { CLAUDE_CODE_STOP, stopHook } from './utils' +import { CLAUDE_CODE_STOP, TYPE_ERROR, dirFlags, hookEnv, stopHook } from './utils' const UNFORMATTED = 'export const extra = 1\n' const FORMATTED = 'export const extra = 1;\n' @@ -18,7 +20,19 @@ const ONLY_OXFMT = [ '○ oxlint skipped, not selected by --only', ] -describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app }) => { +function oxfmtFailed(dir: string): string[] { + return [ + `uncheck in ${dir}`, + ...ONLY_OXFMT, + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ] +} + +describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, tsc }) => { it('checks the directory given with --cwd, wherever it runs', async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) @@ -40,22 +54,130 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) }) - it('reports a --dir that names nothing instead of sending the agent back', async () => { + it.each(['gone', 'package.json'])( + 'reports a --dir naming %s, which is no folder, instead of sending the agent back', + async (name) => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', `--dir=${app}${name}`], + cwd: `${app}src`, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `--dir=${app}${name} names no folder in ${project.dir}, run \`uncheck hooks install\` again from the project`, + ), + ) + expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) + }, + ) + + it.each(['CLAUDE_PROJECT_DIR', 'CODEBUDDY_PROJECT_DIR'])( + 'checks the project in %s outside git, from the folder the agent moved to', + async (variable) => { + const project = create({ [`${app}src/index.ts`]: TYPE_ERROR }, { git: 'none' }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc'], + cwd: `${app}src`, + env: { [variable]: project.path(app) }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + tsc, + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + }, + ) + + it("checks the agent's project from a folder outside git", async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { - args: ['--fix', `--dir=${app}gone`], + const { exitCode, stdout, stderr } = await run( + [...CLI, 'hooks', 'run', '--only=oxfmt', ...dirFlags(app)], + { + cwd: temporaryDirectory(), + env: hookEnv({ CLAUDE_PROJECT_DIR: project.path(app) }), + input: JSON.stringify(CLAUDE_CODE_STOP), + }, + ) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) + }) + + it("checks the agent's project from a submodule the agent moved into", async () => { + const library = temporaryDirectory() + + git(library, ['init', '--quiet']) + git(library, ['commit', '--quiet', '--allow-empty', '--message=library']) + + const project = create() + + project.git( + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + library, + 'vendor/lib', + ) + project.commit('submodule').write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'vendor/lib', + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) + }) + + it('checks the linked worktree the agent works in, although its project folder is the main checkout', async () => { + const project = create() + const worktree = linkedWorktree(project, app).write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(worktree, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], cwd: `${app}src`, + env: { CLAUDE_PROJECT_DIR: project.path(app) }, }) - expect(exitCode).toBe(1) + expect(exitCode).toBe(2) expect(stdout).toBe('') - expect(stderr).toBe( - cliError( - `--dir=${app}gone names nothing in ${project.dir}, run \`uncheck hooks install\` again from the project`, - ), - ) - expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) + expect(report(stderr)).toEqual(oxfmtFailed(worktree.path(app, '.'))) + }) + + it('checks the repository the agent moved to when it is not part of the project', async () => { + const elsewhere = temporaryDirectory() + + git(elsewhere, ['init', '--quiet']) + + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: `${app}src`, + env: { CLAUDE_PROJECT_DIR: elsewhere }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) }) }) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index 2b68caf..b9f7395 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -1,4 +1,5 @@ import type { Env, Project, Run } from '../../utils/project' +import { temporaryDirectory } from '../../utils/project' export const CLAUDE_CODE_STOP = { session_id: 'session', @@ -8,7 +9,12 @@ export const CLAUDE_CODE_STOP = { export const CLAUDE_CODE_STOP_AGAIN = { ...CLAUDE_CODE_STOP, stop_hook_active: true } -export const CURSOR_STOP = { hook_event_name: 'stop', status: 'completed', loop_count: 0 } +export const CURSOR_STOP = { + conversation_id: 'conversation', + hook_event_name: 'stop', + status: 'completed', + loop_count: 0, +} export const COPILOT_AGENT_STOP = { stopReason: 'end_turn' } @@ -23,6 +29,13 @@ export const TYPE_ERROR = 'export const answer: string = 1;\n' export const UTILS_NOT_FOUND = "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." +// uncheck remembers the turns it blocked in the temporary folder, which would keep a file of every test run. +const MARKERS = temporaryDirectory() + +export function hookEnv(env?: Env): Env { + return { TMPDIR: MARKERS, ...env } +} + export function dirFlags(app: string): string[] { return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] } @@ -41,7 +54,7 @@ export function stopHook( ): Promise { return project.uncheck(['hooks', 'run', ...dirFlags(app), ...args], { cwd, - env, + env: hookEnv(env), input: typeof payload === 'string' ? payload : JSON.stringify(payload), }) } From cc9abae6a677054ea176b671f901e872737e3a64 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 20:49:10 +0700 Subject: [PATCH 14/25] fix(uncheck): address review of A4 --- packages/uncheck/src/commands/hooks/run.ts | 8 ++++-- .../uncheck/tests/hooks/run/families.test.ts | 18 +++++++++++++ .../uncheck/tests/hooks/run/location.test.ts | 27 +++++++++++++++++++ 3 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index dff72cc..7aa2bc6 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -224,14 +224,18 @@ const topToCheck = Effect.fn(function* (start: string, projectDir: string | unde // Claude Code keeps the project folder on the main checkout while the agent works in a linked // worktree, which shares its common dir: only a submodule or a nested clone, with a common dir of // its own, gives way to the project's repository above it. + let folder = here.top let outer: Repository | undefined = here while ( outer !== undefined && outer.commonDir !== project.commonDir && - outer.top !== path.dirname(outer.top) + folder !== path.dirname(folder) ) { - outer = yield* repositoryAround(path.dirname(outer.top)) + // A repository whose core.worktree is a subfolder names that subfolder as its top from above it + // too, so climbing from its top alone would ask about the same folder forever. + folder = path.dirname(path.relative(outer.top, folder).startsWith('..') ? folder : outer.top) + outer = yield* repositoryAround(folder) } return outer?.commonDir === project.commonDir ? outer.top : here.top diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index a426b7c..b90958d 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -124,6 +124,24 @@ describe.each(LAYOUTS)( expect(nextTurn.exitCode).toBe(2) expect(nextTurn.stdout).toBe('') expect(report(nextTurn.stderr)).toEqual(failure(project)) + + project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42";\n' }) + + const passingTurn = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(passingTurn.exitCode).toBe(0) + expect(passingTurn.stdout).toBe('') + expect(report(passingTurn.stderr)).toEqual( + checks(project, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)'), + ) + + project.write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const continuedAfterPassing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(continuedAfterPassing.exitCode).toBe(2) + expect(continuedAfterPassing.stdout).toBe('') + expect(report(continuedAfterPassing.stderr)).toEqual(failure(project)) }) it('takes a continued turn without a session id as one uncheck already blocked', async () => { diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index 553369d..5fa0a22 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -1,6 +1,9 @@ +import { join } from 'node:path' + import { CLI, LAYOUTS, + Project, cliError, git, linkedWorktree, @@ -258,6 +261,30 @@ describe('hooks run in a monorepo', () => { }) }) +describe('hooks run in a repository whose work tree lies below its .git folder', () => { + it("checks that repository when the agent's project is another one", async () => { + const project = singleRepo() + const holder = temporaryDirectory() + const checkout = new Project(join(holder, 'checkout')) + .write({ '.gitignore': 'node_modules\n' }) + .link('node_modules', project.path('node_modules')) + + git(holder, ['init', '--quiet']) + git(holder, ['config', 'core.worktree', checkout.dir]) + checkout.commit('init').write({ 'src/extra.ts': UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(checkout, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'src', + env: { CLAUDE_PROJECT_DIR: project.dir }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(checkout.dir)) + }) +}) + describe('hooks run arguments', () => { it('refuses a --cwd that does not exist, checking nothing', async () => { const project = singleRepo().write({ 'src/extra.ts': UNFORMATTED }) From 80ee21496e5c605a7cd2615e34470fe8b54ae45c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Thu, 1 Oct 2026 21:18:01 +0700 Subject: [PATCH 15/25] fix(uncheck): staged stages what the fixers produced, keeps later edits and ends with the verdict --- packages/uncheck/src/commands/staged.ts | 129 +++++++++++++----- packages/uncheck/src/commands/uncheck.ts | 53 +++++-- packages/uncheck/src/files.ts | 18 --- packages/uncheck/tests/prepare/commit.test.ts | 2 +- packages/uncheck/tests/staged/basics.test.ts | 9 +- packages/uncheck/tests/staged/commit.test.ts | 115 ++++++++++++++-- packages/uncheck/tests/staged/fix.test.ts | 46 +++++-- .../uncheck/tests/staged/many-files.test.ts | 2 +- packages/uncheck/tests/staged/merge.test.ts | 10 +- .../uncheck/tests/staged/precedence.test.ts | 6 +- .../tests/staged/sparse-checkout.test.ts | 10 +- .../uncheck/tests/staged/unstaged.test.ts | 59 ++++++-- packages/uncheck/tests/staged/utils.ts | 15 +- 13 files changed, 361 insertions(+), 113 deletions(-) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 61b34af..cd93151 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -4,7 +4,7 @@ import { Console, Effect, FileSystem, Path, Ref } from 'effect' import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' -import { existingFiles, inNodeModules } from '../files' +import { inNodeModules } from '../files' import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' @@ -85,6 +85,27 @@ export const staged = Command.make( const partial = yield* partiallyStaged(unstaged, files) const outcome = yield* Ref.make('restored') + const fixed = yield* Ref.make>([]) + const summary = yield* Ref.make>([]) + + // `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 + const lock = path.resolve(cwd, indexLock!) + const lockToo = active?.endsWith('.lock') === true && path.resolve(cwd, active) !== lock + + // Staging after every check would also stage what was saved to a file while tsc ran. + const stageFixes = Effect.gen(function* () { + const changed = yield* differFromIndex(cwd, files) + + // `git add` refuses a path outside a sparse checkout even when it is on disk. + yield* gitEach(cwd, ['update-index'], changed) + yield* Ref.set(fixed, changed) + + if (lockToo && (yield* fs.exists(lock))) { + yield* stageEntries(cwd, yield* indexEntries(cwd, changed), { GIT_INDEX_FILE: lock }) + } + }) const { failure, empty } = yield* Effect.scoped( Effect.gen(function* () { @@ -92,7 +113,7 @@ export const staged = Command.make( const before = yield* writeTree(cwd) yield* Effect.acquireRelease(setAside(aside, partial), (copies) => - putBack(aside, files, copies, before).pipe( + putBack(aside, files, copies, before, fixed).pipe( Effect.flatMap((result) => Ref.set(outcome, result)), ), ) @@ -105,38 +126,16 @@ export const staged = Command.make( literal: true, fixesWithinFiles: true, deleted, + afterFixes: fix ? stageFixes : undefined, + holdSummary: (lines) => Ref.set(summary, lines), }).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() + const changed = yield* Ref.get(fixed) if (changed.length === 0) { return { failure, empty: false } } - // `git add` refuses a path outside a sparse checkout even when it is on disk. - const stage = (env?: Readonly>) => - gitEach(cwd, ['update-index'], changed, env) - - yield* stage() - - // `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, indexLock!) - - if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { - yield* stage({ GIT_INDEX_FILE: lock }) - } - } - 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. @@ -150,6 +149,12 @@ export const staged = Command.make( return { failure, empty: after === head } }), + ).pipe( + Effect.ensuring( + Effect.flatMap(Ref.get(summary), (lines) => + Effect.forEach(lines, (line) => Console.log(line), { discard: true }), + ), + ), ) const unstagedOutcome = yield* Ref.get(outcome) @@ -168,7 +173,7 @@ export const staged = Command.make( if (empty && !allowEmpty) { return yield* userError( - 'The fixes undid every staged change, so the commit would be empty. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.', + 'The fixes undid every staged change and are staged now, so nothing new is left to commit. Commit again: git amends only the message, or refuses an empty commit. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.', ) } @@ -262,14 +267,56 @@ interface SetAside { readonly base: string } -/** Runs `git -- ` in batches, and nothing without files. */ -function gitEach( +/** Of `files`, those whose working tree differs from the index. */ +function differFromIndex(cwd: string, files: ReadonlyArray) { + return Effect.map( + Effect.forEach(argvBatches(files), (batch) => + gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', '--', ...batch]), + ), + (batches) => batches.flat(), + ) +} + +/** `,,` of each of `files` in the index, as `update-index --cacheinfo` takes it. */ +function indexEntries(cwd: string, files: ReadonlyArray) { + return Effect.map( + Effect.forEach(argvBatches(files), (batch) => + // `--cacheinfo` reads paths from the top of the repository, wherever git runs. + gitPaths(cwd, ['ls-files', '--stage', '--full-name', '-z', '--', ...batch]), + ), + (batches) => + batches.flat().map((entry) => { + const [mode, id] = entry.split(' ') + + return `${mode},${id},${entry.slice(entry.indexOf('\t') + 1)}` + }), + ) +} + +const CACHEINFO = '--cacheinfo' + +/** Stages the given index entries as they are, whatever the working tree holds now. */ +function stageEntries( cwd: string, - args: ReadonlyArray, - files: ReadonlyArray, - env?: Readonly>, + entries: ReadonlyArray, + env: Readonly>, ) { - return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch], env), { + // Each entry needs a flag of its own, which the batches have to count. + return Effect.forEach( + argvBatches(entries.map((entry) => `${CACHEINFO} ${entry}`)), + (batch) => + git( + cwd, + ['update-index', ...batch.flatMap((arg) => [CACHEINFO, arg.slice(CACHEINFO.length + 1)])], + env, + ), + { discard: true }, + ) +} + +/** Runs `git -- ` in batches, and nothing without files. */ +function gitEach(cwd: string, args: ReadonlyArray, files: ReadonlyArray) { + return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch]), { discard: true, }) } @@ -328,6 +375,7 @@ const putBack = Effect.fn(function* ( files: ReadonlyArray, copies: ReadonlyArray, before: string, + fixed: Ref.Ref>, ) { const fs = yield* FileSystem.FileSystem const { cwd, saved } = aside @@ -344,9 +392,18 @@ const putBack = Effect.fn(function* ( return 'restored' as const } + const isPartial = new Set(partial) + const fixedInFull = (yield* Ref.get(fixed)).filter((file) => !isPartial.has(file)) + // A file saved since its fixes were staged holds an edit made during the run, which a + // checkout would throw away. + const editedSince = new Set(yield* differFromIndex(cwd, fixedInFull)) + yield* gitEach(cwd, ['reset', '-q', before], files) - // checkout-index fails on a file a sparse checkout leaves out, which no check could change. - yield* gitEach(cwd, ['checkout-index', '-f'], yield* existingFiles(files, cwd)) + yield* gitEach( + cwd, + ['checkout-index', '-f'], + fixedInFull.filter((file) => !editedSince.has(file)), + ) yield* Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), { discard: true, }) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 1ae5fc7..ff4208f 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -2,9 +2,11 @@ import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' +import type { PlatformError } 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 type { ChildProcessSpawner } from 'effect/unstable/process' import { oxfmt } from '../checks/oxfmt' import { oxlint } from '../checks/oxlint' @@ -12,12 +14,18 @@ import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' import { CheckFailed, platformMessage, userError } from '../errors' import { checkableFiles, listProjectFiles, resolvePaths } from '../files' +import type { GitFailed } from '../git' import { bold, dim, green, listFiles, red } from '../style' import { captureLines, execute } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc] +const FIXES_DONE_AFTER = CHECKS.reduce( + (count, check, index) => (check.fixes === false ? count : index + 1), + 0, +) + export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( Flag.withDefault(Effect.sync(() => process.cwd())), Flag.withDescription('Directory to run in. Defaults to the current one'), @@ -69,6 +77,14 @@ export interface RunSettings extends CheckSelection { readonly fixesWithinFiles?: boolean /** Paths the change deletes. Only for `literal` runs: without paths, any other run checks everything. */ readonly deleted?: ReadonlyArray + /** Runs once the checks that can fix files are done, before the others start. */ + readonly afterFixes?: Effect.Effect< + void, + GitFailed | PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner + > + /** Takes the closing summary instead of printing it, for a caller that prints it last. */ + readonly holdSummary?: (lines: ReadonlyArray) => Effect.Effect } export function selectionArgs({ only, required, skipped }: CheckSelection): ReadonlyArray { @@ -146,6 +162,9 @@ export const checkPaths = Effect.fn(function* ( const appliesFixes = (fixes: Check['fixes']) => settings.fixesWithinFiles === true ? fixes === 'files' : fixes !== false const { cwd } = settings + const summarize = (lines: ReadonlyArray) => + settings.holdSummary?.(lines) ?? + Effect.forEach(lines, (line) => Console.log(line), { discard: true }) const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) let files: ReadonlyArray | undefined @@ -162,7 +181,7 @@ export const checkPaths = Effect.fn(function* ( } if (resolved.files.length === 0 && deleted.length === 0) { - yield* Console.log(`${dim('○')} nothing to check, no files match ${paths.join(' ')}`) + yield* summarize([`${dim('○')} nothing to check, no files match ${paths.join(' ')}`]) return } @@ -199,13 +218,17 @@ export const checkPaths = Effect.fn(function* ( { concurrency: 'unbounded' }, ) - const outcomes = yield* Effect.forEach(plans, (plan) => runCheck(plan, cwd)) + const runAll = (planned: ReadonlyArray) => + Effect.forEach(planned, (plan) => runCheck(plan, cwd)) + const fixing = yield* runAll(plans.slice(0, FIXES_DONE_AFTER)) + + yield* settings.afterFixes ?? Effect.void + + const outcomes = [...fixing, ...(yield* runAll(plans.slice(FIXES_DONE_AFTER)))] const ran = outcomes.filter((outcome) => outcome.status !== 'skipped') const failed = outcomes.filter((outcome) => outcome.status === 'failed') - yield* Console.log('') - if (ran.length === 0) { const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ') @@ -214,18 +237,15 @@ export const checkPaths = Effect.fn(function* ( (allowUnmatched || literal) && outcomes.some((outcome) => outcome.unrelated === true) ) { - return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`) + yield* summarize(['', `${dim('○')} nothing to check: ${reasons}`]) + return } - yield* Console.log(`${red('✘')} nothing to check: ${reasons}`) + yield* summarize(['', `${red('✘')} nothing to check: ${reasons}`]) return yield* Effect.fail(new CheckFailed({ outcomes })) } if (failed.length > 0) { - yield* Console.log( - `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map((outcome) => outcome.name).join(', ')}`, - ) - const fixable = failed .filter( (outcome) => @@ -236,16 +256,19 @@ export const checkPaths = Effect.fn(function* ( .join(', ') .replace(/, ([^,]+)$/, ' and $1') - if (!fix && fixable !== '') { - yield* Console.log(dim(` rerun with \`--fix\` to apply ${fixable} fixes`)) - } + yield* summarize([ + '', + `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map((outcome) => outcome.name).join(', ')}`, + ...(!fix && fixable !== '' ? [dim(` rerun with \`--fix\` to apply ${fixable} fixes`)] : []), + ]) return yield* Effect.fail(new CheckFailed({ outcomes })) } - yield* Console.log( + yield* summarize([ + '', `${green('✔')} all checks passed (${ran.map((outcome) => outcome.name).join(', ')})`, - ) + ]) }) const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index b2d3a55..dcc0e87 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -253,24 +253,6 @@ function glob(pattern: string): (file: string) => boolean { return (file) => matcher.match(file) } -export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - // 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( - Effect.map((info) => info.type === 'File'), - Effect.orElseSucceed(() => false), - ), - { concurrency: 64 }, - ) - - return files.filter((_, index) => isFile[index]) -}) - export function ancestors(path: Path.Path, from: string): string[] { const dirs = [path.resolve(from)] diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index 357a49c..6be4d28 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -36,8 +36,8 @@ function fixedAndStaged(dir: string, tsc: string): string[] { '✔ oxfmt passed', tsc, '✔ tsc passed', - '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/spaced.ts', + '✔ all checks passed (oxlint, oxfmt, tsc)', ] } diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts index 1b241b9..8f51d93 100644 --- a/packages/uncheck/tests/staged/basics.test.ts +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -177,8 +177,8 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { '▶ oxfmt --no-error-on-unmatched-pattern [4 files]', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxfmt)', '✔ staged the fixes to [4 files]', + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, `${app}src/d.ts`)).toBe('export const d = 1;\n') }) @@ -253,7 +253,10 @@ describe('uncheck staged in a package of a monorepo', () => { }) expect(fixed.exitCode).toBe(0) - expect(report(fixed.stdout).at(-1)).toBe('✔ staged the fixes to src/index.ts') + expect(report(fixed.stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/index.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, 'packages/app/src/index.ts')).toBe('export const app = 1;\n') expect(inIndex(project, 'packages/core/src/index.ts')).toBe(edits['packages/core/src/index.ts']) }) @@ -300,8 +303,8 @@ describe('uncheck staged in a package of a monorepo', () => { '▶ oxfmt --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', - '✘ 1 of 3 checks failed: sherif', '✔ staged the fixes to packages/app/src/index.ts', + '✘ 1 of 3 checks failed: sherif', ]) expect(JSON.parse(inIndex(project, 'packages/app/package.json'))).toEqual(manifest) expect(JSON.parse(project.read('packages/app/package.json'))).toEqual(manifest) diff --git a/packages/uncheck/tests/staged/commit.test.ts b/packages/uncheck/tests/staged/commit.test.ts index 7f2625f..864d5d6 100644 --- a/packages/uncheck/tests/staged/commit.test.ts +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -1,6 +1,15 @@ -import { LAYOUTS, linkedWorktree, report, run } from '../utils/project' +import { cliError, LAYOUTS, linkedWorktree, report, run } from '../utils/project' import type { Project } from '../utils/project' -import { inIndex, stagePartially, VERSIONS } from './utils' +import { + EMPTY_COMMIT_ERROR, + inIndex, + SAVED_LINE, + saveWhileTscRuns, + stagePartially, + VERSIONS, +} from './utils' + +const ALL_PASSED = '✔ all checks passed (oxlint, oxfmt, tsc)' describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ create, app }) => { const file = `${app}src/extra.ts` @@ -14,23 +23,25 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr .chmod('.git/hooks/pre-commit', 0o755) } + function commit(project: Project, ...args: ReadonlyArray) { + return run(['git', 'commit', '--quiet', ...args], { cwd: project.dir }) + } + it('fixes the files of `git commit ` in the commit and in the index it leaves', async () => { const project = hooked().stage({ [other]: 'export const other = 2;\n' }) project.write({ [file]: VERSIONS.staged }) + saveWhileTscRuns(project, [file]) - const { exitCode, stderr } = await run( - ['git', 'commit', '--quiet', '--message=extra', '--', file], - { cwd: project.dir }, - ) + const { exitCode, stderr } = await commit(project, '--message=extra', '--', file) expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stderr).slice(-2)).toEqual(['✔ staged the fixes to src/extra.ts', ALL_PASSED]) expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 1;\n') expect(inIndex(project, file)).toBe(VERSIONS.fixed) - expect(project.read(file)).toBe(VERSIONS.fixed) - expect(project.git('status', '--porcelain')).toBe(`M ${other}\n`) + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.fixed) + expect(project.git('status', '--porcelain')).toBe(` M ${file}\nM ${other}\n`) }) it('fixes the files of `git commit -a` in the commit it makes', async () => { @@ -43,12 +54,91 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr }) expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/other.ts') + expect(report(stderr).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts src/other.ts', + ALL_PASSED, + ]) expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') expect(project.git('status', '--porcelain')).toBe('') }) + it('commits the fixes as the fixers left them, without what is saved while tsc runs', async () => { + const clean = `${app}src/clean.ts` + const project = stagePartially( + hooked().stage({ + [other]: 'export const other = 2\n', + [clean]: 'export const clean = 1;\n', + }), + file, + ) + + saveWhileTscRuns(project, [file, other, clean]) + + const { exitCode, stderr } = await commit(project, '--message=saved') + + expect(exitCode).toBe(0) + expect(report(stderr).slice(-3)).toEqual([ + '✔ staged the fixes to src/extra.ts src/other.ts', + '○ unstaged changes of src/extra.ts restored', + ALL_PASSED, + ]) + expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') + expect(project.git('show', `HEAD:${clean}`)).toBe('export const clean = 1;\n') + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.merged) + expect(project.read(other)).toBe(`${SAVED_LINE}export const other = 2;\n`) + expect(project.read(clean)).toBe(`${SAVED_LINE}export const clean = 1;\n`) + expect(project.git('status', '--porcelain')).toBe(` M ${clean}\n M ${file}\n M ${other}\n`) + }) + + it('ends a blocked commit with the summary of the checks', async () => { + const project = stagePartially( + hooked().stage({ [`${app}src/broken.ts`]: 'export const broken: number = "1";\n' }), + file, + ) + + const { exitCode, stderr } = await commit(project, '--message=broken') + + expect(exitCode).toBe(1) + expect(report(stderr).slice(-3)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stderr).toMatch(/\n✘ 1 of 3 checks failed: tsc\n$/) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + + it('blocks a commit the fixes empty, which git amends or refuses when run again', async () => { + const project = hooked() + const unformatted = { [other]: 'export const other = 1\n' } + + project.stage(unformatted) + + const amend = await commit(project, '--amend', '--message=reworded') + + expect(amend.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) + expect(amend.exitCode).toBe(1) + expect(project.git('log', '--format=%s')).toBe('init\n') + expect((await commit(project, '--amend', '--message=reworded')).exitCode).toBe(0) + expect(project.git('log', '--format=%s')).toBe('reworded\n') + + project.stage(unformatted) + + const plain = await commit(project, '--message=empty') + + expect(plain.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) + expect(plain.exitCode).toBe(1) + + const again = await commit(project, '--message=empty') + + expect(again.stdout).toContain('nothing to commit') + expect(again.exitCode).toBe(1) + expect(project.git('log', '--format=%s')).toBe('reworded\n') + expect(project.git('status', '--porcelain')).toBe('') + }) + describe('in a linked worktree, whose hook git hands GIT_DIR', () => { it('sets the unstaged changes aside and puts them back', async () => { const project = hooked() @@ -59,9 +149,10 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr }) expect(exitCode).toBe(0) - expect(report(stderr).slice(-2)).toEqual([ + expect(report(stderr).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', '○ unstaged changes of src/extra.ts restored', + ALL_PASSED, ]) expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(worktree.read(file)).toBe(VERSIONS.merged) @@ -81,7 +172,7 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr ) expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stderr).slice(-2)).toEqual(['✔ staged the fixes to src/extra.ts', ALL_PASSED]) expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(inIndex(worktree, file)).toBe(VERSIONS.fixed) expect(worktree.git('status', '--porcelain')).toBe(`M ${other}\n`) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 9d813f0..47001fd 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -24,8 +24,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) '✔ oxfmt passed', tsc, '✔ tsc passed', - '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/index.ts', + '✔ all checks passed (oxlint, oxfmt, tsc)', ]) expect(inIndex(project, `${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') expect(project.read(`${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') @@ -45,8 +45,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(stderr).toBe('') expect(exitCode).toBe(1) expect(report(stdout).slice(-2)).toEqual([ - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/index.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(inIndex(project, `${app}src/index.ts`)).toBe('export const answer: number = "42";\n') }) @@ -73,7 +73,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(empty.stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) expect(empty.exitCode).toBe(1) - expect(report(empty.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(empty.stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.git('status', '--porcelain')).toBe('') project.stage(unformatted) @@ -84,7 +87,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(allowed.stderr).toBe('') expect(allowed.exitCode).toBe(0) - expect(report(allowed.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(allowed.stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.git('status', '--porcelain')).toBe('') }) @@ -105,7 +111,13 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) ).trimEnd(), ) expect(exitCode).toBe(1) - expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + ]) expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1\n') }) @@ -124,7 +136,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1;\n') expect(project.git('diff', '--name-only')).toBe('') }) @@ -142,7 +157,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/run.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/run.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/run.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) expect(inIndex(project, file)).toBe('export const run = 1;\n') expect(project.mode(file)).toBe(0o755) @@ -179,7 +197,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/link.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/link.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, `${app}src/link.ts`)).toBe('export const link = 1;\n') expect(project.read(`${app}src/messy.ts`)).toBe('export const messy = 1\n') expect(project.git('status', '--porcelain')).toBe( @@ -211,8 +232,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxlint, oxfmt)', ]) expect(inIndex(project, dependency)).toBe('export var dep = 1\n') expect(project.read(dependency)).toBe('export var dep = 1\n') @@ -243,8 +264,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) '▶ oxfmt --no-error-on-unmatched-pattern !x.ts -x.ts routes/[id]/page.ts', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '✔ staged the fixes to !x.ts -x.ts routes/[id]/page.ts', + '✔ all checks passed (oxlint, oxfmt)', ]) expect(inIndex(project, `${app}!x.ts`)).toBe('export const bang = 1;\n') expect(inIndex(project, `${app}-x.ts`)).toBe('export const dash = 1;\n') @@ -277,7 +298,10 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.git('rev-parse', `:${app}vendor`)).toBe(before) }) }) diff --git a/packages/uncheck/tests/staged/many-files.test.ts b/packages/uncheck/tests/staged/many-files.test.ts index 23e7688..ac1f07c 100644 --- a/packages/uncheck/tests/staged/many-files.test.ts +++ b/packages/uncheck/tests/staged/many-files.test.ts @@ -42,9 +42,9 @@ describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, a '▶ oxfmt --no-error-on-unmatched-pattern [14 files]', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '✔ staged the fixes to [150 files]', '○ unstaged changes of [150 files] restored', + '✔ all checks passed (oxlint, oxfmt)', ]) for (const file of [fixed[0]!, fixed.at(-1)!]) { diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 65750a5..84f368e 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -29,7 +29,10 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/other.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, other)).toBe('export const other = 4;\n') expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') }) @@ -70,7 +73,10 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/other.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.git('diff', '--cached', '--name-only')).toBe('') project.git('commit', '--quiet', '--no-edit', '--no-verify') diff --git a/packages/uncheck/tests/staged/precedence.test.ts b/packages/uncheck/tests/staged/precedence.test.ts index 8a10309..6679a46 100644 --- a/packages/uncheck/tests/staged/precedence.test.ts +++ b/packages/uncheck/tests/staged/precedence.test.ts @@ -32,8 +32,8 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/extra.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(inIndex(project, file)).toBe('export const extra: number = "42"\n') expect(project.read(file)).toBe('export const extra: number = "43"\n') @@ -67,9 +67,9 @@ describe.each(LAYOUTS)( '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', + strandedLine, '✘ 1 of 1 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', - strandedLine, ]) expect(project.normalize(stdout)).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) @@ -94,8 +94,8 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/extra.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(project.git('status', '--porcelain')).toBe('') }) diff --git a/packages/uncheck/tests/staged/sparse-checkout.test.ts b/packages/uncheck/tests/staged/sparse-checkout.test.ts index 2ed6b46..3d6fa84 100644 --- a/packages/uncheck/tests/staged/sparse-checkout.test.ts +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -32,7 +32,10 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxfmt)', + ]) expect(project.exists(`${app}lib`)).toBe(false) expect(inIndex(project, outside)).toBe('export const lib = 2;\n') expect(inIndex(project, file)).toBe('export const extra = 43;\n') @@ -58,7 +61,10 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to lib/lib.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to lib/lib.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, outside)).toBe('export const lib = 4;\n') }) }) diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts index 688940d..58f5aa1 100644 --- a/packages/uncheck/tests/staged/unstaged.test.ts +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -6,6 +6,8 @@ import { folderOf, inIndex, LEFTOVER_ERROR, + SAVED_LINE, + saveWhileTscRuns, stagePartially, stranded, STRANDED_HINT, @@ -39,8 +41,8 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint, oxfmt)', ]) expect(project.read(file)).toBe(`${VERSIONS.fixed}var unstaged = 1\n`) expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) @@ -69,9 +71,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre }) expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ + expect(report(stdout).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, file)).toBe(joined + fixed) expect(project.read(file)).toBe(joined + edit(fixed)) @@ -115,7 +118,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/far.ts src/other.ts') + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts src/far.ts src/other.ts', + '✔ all checks passed (oxfmt)', + ]) expect(inIndex(project, file)).toBe('export const extra = 42\n') expect(inIndex(project, `${app}src/far.ts`)).toBe(stagedFar) expect(inIndex(project, `${app}src/other.ts`)).toBe('export const other = 2\n') @@ -129,6 +135,38 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) + it('undoes the fixes but keeps what is saved to a fully staged file while tsc runs', async () => { + const other = `${app}src/other.ts` + const plain = `${app}src/plain.ts` + const project = create({ [file]: 'export const extra = 1;\n' }) + + project.stage({ + [file]: 'export const extra = 42\n', + [other]: 'export const other = 2\n', + [plain]: 'export const plain = 3\n', + }) + project.write({ [file]: 'export const extra = 43\n' }) + saveWhileTscRuns(project, [other]) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts src/other.ts src/plain.ts', + '✔ all checks passed (oxfmt, tsc)', + ]) + expect(inIndex(project, other)).toBe('export const other = 2\n') + expect(project.read(other)).toBe(`${SAVED_LINE}export const other = 2;\n`) + expect(inIndex(project, plain)).toBe('export const plain = 3\n') + expect(project.read(plain)).toBe('export const plain = 3\n') + expect(project.read(file)).toBe('export const extra = 43\n') + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\nAM ${other}\nA ${plain}\n`) + }) + it('refuses unstaged changes that are not edits to a file', async () => { const project = create({ [`${app}src/a.ts`]: 'export const a = 1;\n', @@ -237,7 +275,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('○ unstaged changes of src/extra.ts restored') + expect(report(stdout).slice(-2)).toEqual([ + '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint)', + ]) expect(project.read(file)).toBe(VERSIONS.unstaged.replace('c = 1', 'c = 3')) expect(inIndex(project, file)).toBe(VERSIONS.staged) }) @@ -324,8 +365,8 @@ describe.each(LAYOUTS)( '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint)', strandedLine, + '✔ all checks passed (oxlint)', ]) expect(output).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) expect(project.read(saved)).toBe(VERSIONS.unstaged) @@ -381,12 +422,13 @@ describe.each(LAYOUTS)( expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).slice(-2)).toEqual([ + expect(report(stdout).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', stranded( 'src/extra.ts', `git cat-file --filters --path=${file} ${merged.stdout.trim()} failed: error: external filter 'if [ -e .git/smudged ]; then exit 1; fi; touch .git/smudged; cat' failed 1`, ), + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.read(file)).toBe(VERSIONS.fixed) @@ -408,8 +450,9 @@ describe.each(LAYOUTS)( expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).at(-2)).toBe('✔ staged the fixes to src/extra.ts') - expect(report(stdout).at(-1)).toMatch( + expect(report(stdout).at(-3)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(report(stdout).at(-2)).toMatch( /^✘ could not put back the unstaged changes of src\/extra\.ts: git merge-file --quiet \S+\/result \S+\/base \S+\/fixed failed: merge-file broke$/, ) expect(inIndex(project, file)).toBe(VERSIONS.fixed) diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index 7e28336..1b41bb0 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -16,7 +16,7 @@ export const LEFTOVER_ERROR = 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' export const EMPTY_COMMIT_ERROR = - 'The fixes undid every staged change, so the commit would be empty. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.' + 'The fixes undid every staged change and are staged now, so nothing new is left to commit. Commit again: git amends only the message, or refuses an empty commit. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.' export function conflictError(files: string): string { return `The fixes conflict with the unstaged changes of ${files} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.` @@ -53,6 +53,19 @@ export function stagePartially(project: Project, file: string): Project { return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) } +/** What `saveWhileTscRuns` adds to the top of a file. */ +export const SAVED_LINE = '// saved while tsc ran\n' + +/** Fakes a tsc that adds `SAVED_LINE` to `files` as it runs, as an editor saving them would. */ +export function saveWhileTscRuns(project: Project, files: ReadonlyArray): Project { + const paths = JSON.stringify(files.map((file) => project.path(file))) + + return project.fake( + 'typescript', + `const fs = require('node:fs')\nfor (const file of ${paths}) fs.writeFileSync(file, ${JSON.stringify(SAVED_LINE)} + fs.readFileSync(file, 'utf8'))\n`, + ) +} + /** Commits `files` on a new `side` branch, then goes back to `main`. */ export function commitOnSide(project: Project, files: Files): Project { project.git('checkout', '--quiet', '-b', 'side') From 4ac7cfb5a2d61fb992e6b6493389f6394a5c5547 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 07:03:13 +0700 Subject: [PATCH 16/25] test(uncheck): align merged expectations: deletions run tsc, physical --cwd, verdict last --- packages/uncheck/tests/prepare/commit.test.ts | 9 +++++++-- packages/uncheck/tests/prepare/location.test.ts | 2 +- packages/uncheck/tests/uncheck/paths.test.ts | 2 +- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index 5f42ee1..fabe184 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -168,7 +168,12 @@ describe('committing with the prepared hook of several packages in a monorepo', expect(exitCode).toBe(0) expect(report(stderr)).toEqual([ `uncheck staged in ${project.path('packages/app')}`, - '○ nothing to check, no staged files', + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + TSC_WITH_REFERENCES, + '✔ tsc passed', + '✔ all checks passed (tsc)', ]) expect(project.git('log', '--format=%s')).toBe('remove\ninit\n') }) @@ -228,7 +233,7 @@ describe('committing with the prepared hook of several packages in a monorepo', '✘ oxlint failed', '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', '✔ oxfmt passed', - '○ tsc skipped, no tsconfig.json found', + '○ tsc skipped, no tsconfig.json covers the given files', '✘ 1 of 2 checks failed: oxlint', ]) expect(project.git('log', '--format=%s')).toBe('init\n') diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts index 1e89915..5a91a5f 100644 --- a/packages/uncheck/tests/prepare/location.test.ts +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -298,8 +298,8 @@ describe('prepare with --cwd naming a symlink to a package', () => { '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint)', '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint)', ]) expect(project.read(file)).toBe('export const extra = 1;\nvar unstaged = 1\n') expect(project.exists('.git/uncheck-unstaged')).toBe(false) diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index 7f38c4b..bd7e52c 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -146,7 +146,7 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, expect(fromLink.stderr).toBe('') expect(fromLink.exitCode).toBe(0) expect(selectedReport(fromLink.stdout)).toEqual([ - `uncheck in ${linked}`, + `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, '✔ oxlint passed', '✔ all checks passed (oxlint)', From 5889b0118749473fbd89444cce0604f667910a7f Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:18:37 +0700 Subject: [PATCH 17/25] fix(uncheck): staged keeps edits saved during a conflicted run, survives a dying commit index and names what it skipped --- packages/uncheck/README.md | 2 +- packages/uncheck/src/commands/staged.ts | 118 ++++++++++++------ packages/uncheck/tests/staged/fix.test.ts | 15 ++- .../uncheck/tests/staged/interrupt.test.ts | 50 +++++++- .../uncheck/tests/staged/unstaged.test.ts | 59 +++++++++ packages/uncheck/tests/staged/utils.ts | 95 ++++++++++---- 6 files changed, 268 insertions(+), 71 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 5883d92..4f8a94b 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -113,7 +113,7 @@ What the hook guarantees: - **Each staged file is checked as you staged it.** 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.** An edit you save while the checks run stays unstaged. During a merge, only files that differ from the branch being merged in are checked. +- **Only fixes to staged files are staged.** The fixes are staged once oxlint and oxfmt finish, so an edit you save while tsc runs stays unstaged. 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`. Good to know: diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index cd93151..9e4a790 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -11,7 +11,12 @@ 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' | 'stranded' | { readonly conflicted: ReadonlyArray } +interface Unstaged { + readonly conflicted: ReadonlyArray + readonly stranded: ReadonlyArray +} + +const RESTORED: Unstaged = { conflicted: [], stranded: [] } export const staged = Command.make( 'staged', @@ -70,7 +75,9 @@ export const staged = Command.make( const reason = listed.files.length > 0 || listed.deleted.length > 0 ? 'every staged file comes from the branch being merged in' - : 'no staged files' + : listed.anyStaged + ? 'only links and node_modules files are staged' + : 'no staged files' return yield* Console.log(`${dim('○')} nothing to check, ${reason}`) } @@ -84,7 +91,7 @@ export const staged = Command.make( } const partial = yield* partiallyStaged(unstaged, files) - const outcome = yield* Ref.make('restored') + const outcome = yield* Ref.make(RESTORED) const fixed = yield* Ref.make>([]) const summary = yield* Ref.make>([]) @@ -113,7 +120,7 @@ export const staged = Command.make( const before = yield* writeTree(cwd) yield* Effect.acquireRelease(setAside(aside, partial), (copies) => - putBack(aside, files, copies, before, fixed).pipe( + putBack(aside, files, copies, before, fixed, active).pipe( Effect.flatMap((result) => Ref.set(outcome, result)), ), ) @@ -157,17 +164,18 @@ export const staged = Command.make( ), ) - const unstagedOutcome = yield* Ref.get(outcome) + const { conflicted, stranded } = yield* Ref.get(outcome) - if (typeof unstagedOutcome === 'object') { + if (stranded.length > 0 || conflicted.length > 0) { return yield* userError( - `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.`, - ) - } - - if (unstagedOutcome === 'stranded') { - return yield* userError( - `The unstaged changes of ${listFiles(partial)} could not be put back, see above.`, + [ + stranded.length > 0 && + `The unstaged changes of ${listFiles(stranded)} could not be put back, see above.`, + conflicted.length > 0 && + `The fixes conflict with the unstaged changes of ${listFiles(conflicted)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`, + ] + .filter(Boolean) + .join(' '), ) } @@ -214,6 +222,7 @@ const stagedFiles = (cwd: string, ...against: ReadonlyArray) => const kept = entries.filter((entry) => !inNodeModules(entry.file)) return { + anyStaged: entries.length > 0, files: kept .filter((entry) => entry.status !== 'D' && REGULAR_FILE_MODE.test(entry.toMode)) .map((entry) => entry.file), @@ -376,26 +385,44 @@ const putBack = Effect.fn(function* ( copies: ReadonlyArray, before: string, fixed: Ref.Ref>, + index: string | undefined, ) { const fs = yield* FileSystem.FileSystem const { cwd, saved } = aside const partial = copies.map(({ file }) => file) + const copyBack = (entries: ReadonlyArray) => + Effect.forEach(entries, ({ target, copy }) => fs.copyFile(copy, target), { discard: true }) + const restored = Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)).pipe( + Effect.as(RESTORED), + ) + const strand = (stranded: ReadonlyArray, reason: string) => + Console.log( + `${red('✘')} could not put back the unstaged changes of ${listFiles(stranded)}: ${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`, + ) const result = yield* Effect.gen(function* () { - const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry, before), { - concurrency: 4, - }) - const conflicted = partial.filter((_, index) => !merged[index]) + // Killed by Ctrl-C, git deletes the lock index it ran the hook on, as with `git commit -i`: no staged + // version is left to merge with, and writing that index would leave a stale lock behind. + if (index?.endsWith('.lock') === true && !(yield* fs.exists(index))) { + yield* copyBack(copies) + return yield* restored + } + + const merged = yield* Effect.forEach( + copies, + (entry) => Effect.map(merge(aside, entry, before), (outcome) => ({ ...entry, ...outcome })), + { concurrency: 4 }, + ) + const conflicted = merged.filter(({ clean }) => !clean) if (conflicted.length === 0) { - yield* Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)) - return 'restored' as const + return yield* restored } const isPartial = new Set(partial) const fixedInFull = (yield* Ref.get(fixed)).filter((file) => !isPartial.has(file)) // A file saved since its fixes were staged holds an edit made during the run, which a - // checkout would throw away. + // checkout or its unstaged copy would throw away. const editedSince = new Set(yield* differFromIndex(cwd, fixedInFull)) yield* gitEach(cwd, ['reset', '-q', before], files) @@ -404,22 +431,36 @@ const putBack = Effect.fn(function* ( ['checkout-index', '-f'], fixedInFull.filter((file) => !editedSince.has(file)), ) - yield* Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), { - discard: true, - }) + yield* copyBack(merged.filter(({ edited }) => !edited)) - return { conflicted } - }).pipe( - Effect.catch((error) => { - const reason = error instanceof GitFailed ? error.summary : String(error) + const stranded = conflicted.filter(({ edited }) => edited) - 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`, - ).pipe(Effect.as('stranded' as const)) - }), + if (stranded.length > 0) { + // A copy left beside the stranded ones would invite copying it over a newer file. + yield* Effect.forEach( + merged.filter((entry) => !stranded.includes(entry)), + ({ copy }) => fs.remove(copy), + { discard: true }, + ) + yield* strand( + stranded.map(({ file }) => file), + 'they conflict with edits saved while the checks ran', + ) + } + + return { + conflicted: conflicted.filter(({ edited }) => !edited).map(({ file }) => file), + stranded: stranded.map(({ file }) => file), + } + }).pipe( + Effect.catch((error) => + strand(partial, error instanceof GitFailed ? error.summary : String(error)).pipe( + Effect.as({ conflicted: [], stranded: partial }), + ), + ), ) - if (result !== 'stranded') { + if (result.stranded.length === 0) { yield* Effect.ignore(fs.remove(saved, { recursive: true })) } @@ -440,19 +481,22 @@ const merge = Effect.fn(function* ( if (checked === base) { yield* fs.copyFile(copy, target) - return true + return { clean: true, edited: false } } - // 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. const [staged, stored] = (yield* git(cwd, [ 'rev-parse', `:0:${prefix}${file}`, `${before}:${prefix}${file}`, ])).split('\n') + // hash-object turns the CRLF of a blob git keeps that way to LF, so only git can tell whether the + // file still holds what is staged. + const edited = staged !== checked && (yield* differFromIndex(cwd, [file])).length > 0 + // 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 (stored !== base && staged !== checked) { - return false + return { clean: false, edited } } const temp = yield* fs.makeTempDirectory() @@ -494,6 +538,6 @@ const merge = Effect.fn(function* ( yield* fs.chmod(target, (yield* fs.stat(copy)).mode) } - return clean + return { clean, edited } }).pipe(Effect.ensuring(Effect.ignore(fs.remove(temp, { recursive: true })))) }) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 47001fd..658894d 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -215,7 +215,20 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) [`${app}src/extra.ts`]: 'export const extra = 1\n', }) - project.git('add', '--force', '--', dependency, `${app}src/extra.ts`) + project.git('add', '--force', '--', dependency) + + const alone = await project.uncheck(['staged', '--fix', '--only=oxlint', '--only=oxfmt'], { + cwd: folder, + }) + + expect(alone.stderr).toBe('') + expect(alone.exitCode).toBe(0) + expect(report(alone.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, only links and node_modules files are staged', + ]) + + project.git('add', '--', `${app}src/extra.ts`) const { exitCode, stdout, stderr } = await project.uncheck( ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index 595533f..b196e8a 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -1,11 +1,11 @@ import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, report, wrappedGit } from '../utils/project' -import { folderOf, inIndex, stagePartially, startUncheck, VERSIONS } from './utils' +import { folderOf, inIndex, stagePartially, startGit, startUncheck, VERSIONS } from './utils' -const SLOW_OXLINT = "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n" +const SLOW_CHECK = "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n" -describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app }) => { +describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) const file = `${app}src/extra.ts` @@ -13,7 +13,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } const project = stagePartially(create({ [file]: VERSIONS.committed }), file) const hungUp = project.path('.git/hung-up') - project.fake('oxlint', SLOW_OXLINT) + project.fake('oxlint', SLOW_CHECK) // Two hang-ups sent back to back arrive as one, so the second comes from inside the merge that // puts the changes back, the only hash-object call with --path. @@ -46,7 +46,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } it('puts the unstaged changes back after Ctrl-C during a slow check', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) - project.fake('oxlint', SLOW_OXLINT) + project.fake('oxlint', SLOW_CHECK) const { exitCode } = await project.uncheckInTerminal(['staged', '--only=oxlint'], { cwd: folder, @@ -60,6 +60,46 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) + it('puts the unstaged changes back as they were after Ctrl-C kills `git commit --include` and its index', async () => { + const other = `${app}src/other.ts` + const project = stagePartially( + create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }), + file, + ) + .write({ + [other]: 'export const other = 2;\n', + '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix --only=oxfmt --only=tsc) || exit 1\n`, + }) + .chmod('.git/hooks/pre-commit', 0o755) + .fake('typescript', SLOW_CHECK) + + const commit = startGit(project, ['commit', '--include', '--message=include', '--', other]) + + await commit.printed('waiting') + commit.interrupt() + + const { signal, stderr } = await commit.exited + + expect(signal).toBe('SIGINT') + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/other.ts', + '✔ oxfmt passed', + tsc, + '○ unstaged changes of src/extra.ts restored', + ]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.read(other)).toBe('export const other = 2;\n') + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n M ${other}\n`) + expect(project.git('log', '--format=%s')).toBe('init\n') + expect(project.exists('.git/index.lock')).toBe(false) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + it('puts the unstaged changes back when nothing reads its output any more', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) const go = project.path('.git/go') diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts index 58f5aa1..316f797 100644 --- a/packages/uncheck/tests/staged/unstaged.test.ts +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -167,6 +167,65 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(project.git('status', '--porcelain')).toBe(`MM ${file}\nAM ${other}\nA ${plain}\n`) }) + it('undoes the fixes but keeps the merge of what is saved to a partially staged file while tsc runs', async () => { + const conflicting = `${app}src/conflicting.ts` + const project = stagePartially( + create({ [file]: VERSIONS.committed, [conflicting]: 'export const c = 1;\n' }), + file, + ) + + project.stage({ [conflicting]: 'export const c = 42\n' }) + project.write({ [conflicting]: 'export const c = 43\n' }) + saveWhileTscRuns(project, [file]) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(cliError(conflictError('src/conflicting.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/conflicting.ts src/extra.ts', + '✔ all checks passed (oxfmt, tsc)', + ]) + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.merged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.read(conflicting)).toBe('export const c = 43\n') + expect(inIndex(project, conflicting)).toBe('export const c = 42\n') + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('keeps an edit saved next to the unstaged changes while tsc runs, and their copy aside', async () => { + const other = `${app}src/other.ts` + const project = stagePartially( + stagePartially(create({ [file]: VERSIONS.committed, [other]: VERSIONS.committed }), file), + other, + ) + + saveWhileTscRuns(project, [file], { atEnd: true }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=tsc'], { + cwd: folder, + }) + + const strandedLine = stranded( + 'src/extra.ts', + 'they conflict with edits saved while the checks ran', + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([strandedLine, '✔ all checks passed (tsc)']) + expect(project.normalize(stdout)).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) + expect(project.read(file)).toBe(VERSIONS.staged + SAVED_LINE) + expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.read(other)).toBe(VERSIONS.unstaged) + expect(project.exists(`.git/uncheck-unstaged/${other}`)).toBe(false) + expect(inIndex(project, other)).toBe(VERSIONS.staged) + }) + it('refuses unstaged changes that are not edits to a file', async () => { const project = create({ [`${app}src/a.ts`]: 'export const a = 1;\n', diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index 1b41bb0..45bfd44 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -1,5 +1,6 @@ import { spawn } from 'node:child_process' import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import process from 'node:process' import { CLI, environment } from '../utils/project' import type { Env, Files, Project, Run } from '../utils/project' @@ -53,16 +54,22 @@ export function stagePartially(project: Project, file: string): Project { return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) } -/** What `saveWhileTscRuns` adds to the top of a file. */ +/** What `saveWhileTscRuns` adds to a file. */ export const SAVED_LINE = '// saved while tsc ran\n' -/** Fakes a tsc that adds `SAVED_LINE` to `files` as it runs, as an editor saving them would. */ -export function saveWhileTscRuns(project: Project, files: ReadonlyArray): Project { +/** Fakes a tsc that adds `SAVED_LINE` to the top of `files`, or to their end, as an editor would. */ +export function saveWhileTscRuns( + project: Project, + files: ReadonlyArray, + { atEnd = false }: { readonly atEnd?: boolean } = {}, +): Project { const paths = JSON.stringify(files.map((file) => project.path(file))) + const line = JSON.stringify(SAVED_LINE) + const content = "fs.readFileSync(file, 'utf8')" return project.fake( 'typescript', - `const fs = require('node:fs')\nfor (const file of ${paths}) fs.writeFileSync(file, ${JSON.stringify(SAVED_LINE)} + fs.readFileSync(file, 'utf8'))\n`, + `const fs = require('node:fs')\nfor (const file of ${paths}) fs.writeFileSync(file, ${atEnd ? `${content} + ${line}` : `${line} + ${content}`})\n`, ) } @@ -81,46 +88,58 @@ export function inIndex(project: Project, file: string): string { export interface Started { readonly child: ChildProcessWithoutNullStreams - /** Resolves once stdout has shown `marker`. */ + /** Resolves once stdout or stderr has shown `marker`. */ readonly printed: (marker: string) => Promise readonly exited: Promise } -/** Starts the CLI without waiting for it, for tests that act on the process while it runs. */ -export function startUncheck( - project: Project, - args: ReadonlyArray, - { cwd = '.', env }: { readonly cwd?: string; readonly env?: Env } = {}, +interface StartOptions { + readonly cwd: string + readonly env?: Env + readonly ownProcessGroup?: boolean +} + +function start( + command: ReadonlyArray, + { cwd, env, ownProcessGroup = false }: StartOptions, ): Started { - const child = spawn(CLI[0], [...CLI.slice(1), ...args], { - cwd: project.path(cwd), + const child = spawn(command[0]!, command.slice(1), { + cwd, env: environment(env), + detached: ownProcessGroup, }) const waiters: Array<{ readonly marker: string; readonly resolve: () => void }> = [] - let stdout = '' - let stderr = '' + const output = { stdout: '', stderr: '' } + const shown = (marker: string) => output.stdout.includes(marker) || output.stderr.includes(marker) + const collect = (stream: keyof typeof output) => (chunk: string) => { + output[stream] += chunk - onTestFinished(() => { - child.kill('SIGKILL') - }) - - child.stdout.setEncoding('utf8').on('data', (chunk: string) => { - stdout += chunk - - for (const waiter of waiters.filter(({ marker }) => stdout.includes(marker))) { + for (const waiter of waiters.filter(({ marker }) => shown(marker))) { waiters.splice(waiters.indexOf(waiter), 1) waiter.resolve() } + } + + onTestFinished(() => { + if (!ownProcessGroup) { + child.kill('SIGKILL') + return + } + + // Throws once every process of the group has exited. + try { + process.kill(-child.pid!, 'SIGKILL') + } catch {} }) - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderr += chunk - }) + + child.stdout.setEncoding('utf8').on('data', collect('stdout')) + child.stderr.setEncoding('utf8').on('data', collect('stderr')) return { child, printed: (marker) => new Promise((resolve) => { - if (stdout.includes(marker)) { + if (shown(marker)) { resolve() } else { waiters.push({ marker, resolve }) @@ -128,7 +147,29 @@ export function startUncheck( }), exited: new Promise((resolve, reject) => { child.on('error', reject) - child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, ...output })) }), } } + +/** Starts the CLI without waiting for it, for tests that act on the process while it runs. */ +export function startUncheck( + project: Project, + args: ReadonlyArray, + { cwd = '.', env }: { readonly cwd?: string; readonly env?: Env } = {}, +): Started { + return start([...CLI, ...args], { cwd: project.path(cwd), env }) +} + +/** + * Starts `git ` in a process group of its own, which `interrupt` sends Ctrl-C to as a terminal + * would. `exited` waits for the hooks git started too, since they hold its output. + */ +export function startGit( + project: Project, + args: ReadonlyArray, +): Started & { readonly interrupt: () => void } { + const started = start(['git', ...args], { cwd: project.dir, ownProcessGroup: true }) + + return { ...started, interrupt: () => process.kill(-started.child.pid!, 'SIGINT') } +} From 22fca7e96c35f5ac5fc6096bd41260390a80b604 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:21:29 +0700 Subject: [PATCH 18/25] fix(uncheck): prepare lifts the lines v0.0.3 appended after an exec, reads setup without comments, keeps lines in blocks --- packages/uncheck/src/commands/prepare.ts | 123 +++++++++++------ .../tests/prepare/existing-hook.test.ts | 129 +++++++++++++++++- 2 files changed, 206 insertions(+), 46 deletions(-) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 4ee241e..1bb16a4 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -35,7 +35,7 @@ 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|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ + /^\s*(?:$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ /** husky 4 sources this runner, which always exits, so nothing after it runs. */ const HUSKY_4_RUNNER = /^\.\s+"\$\(dirname "\$0"\)\/husky\.sh"\s*$/ @@ -45,9 +45,13 @@ const INNERMOST_SUBSTITUTION = /\$\([^()]*\)/g // A `\"` outside quotes opens nothing, and a `"` between single quotes opens nothing either. const QUOTED = /\\.|"(?:[^"\\]|\\.)*"|'[^']*'/g +const COMMENT = /(?:^|\s)#.*$/ + /** A setup line that runs on into the next, where the added line would join it. */ const CONTINUES = - /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<)/ + /^(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<)/ + +const INDENT = /^\s*/ /** * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends @@ -79,29 +83,71 @@ function ownLine(text: string): { readonly inside: string; readonly command: str : undefined } -/** A `;`, `&` or `|` between quotes or in a command substitution does not end the command. */ -function opaque(text: string): string { +/** + * A `;`, `&` or `|` between quotes or in a command substitution does not end the command, and a + * quote in a comment opens nothing. + */ +function codeOf(text: string): string { const substituted = text.replace(INNERMOST_SUBSTITUTION, '_') return substituted === text - ? text.replace(QUOTED, (token) => (token.startsWith('\\') ? token : '""')) - : opaque(substituted) + ? text.replace(QUOTED, (token) => (token.startsWith('\\') ? token : '""')).replace(COMMENT, '') + : codeOf(substituted) } -function beforeHusky4Runner(texts: ReadonlyArray): ReadonlyArray { - const runner = texts.findIndex((text) => HUSKY_4_RUNNER.test(text)) +function indentOf(text: string): string { + return INDENT.exec(text)![0] +} - if (runner === -1) { - return texts - } +function writtenByV003(text: string): boolean { + const own = ownLine(text) - const rest = texts.slice(runner) + return ( + own !== undefined && + text === (own.inside === '' ? own.command : `cd "${own.inside}" && ${own.command}`) + ) +} + +// Added after the commands of the hook, a line would set its exit status in their place, and 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. +function beforeCommands( + texts: ReadonlyArray, + added: ReadonlyArray, +): ReadonlyArray { + const at = texts.findIndex((text) => { + const code = codeOf(text) + + return !SETUP.test(code) || HUSKY_4_RUNNER.test(text) || CONTINUES.test(code) + }) - return [ - ...texts.slice(0, runner), - ...rest.filter((text) => ownLine(text) !== undefined), - ...rest.filter((text) => ownLine(text) === undefined), - ] + return at === -1 + ? [...(texts.at(-1) === '' ? texts.slice(0, -1) : texts), ...added, ''] + : [...texts.slice(0, at), ...added, ...texts.slice(at)] +} + +/** + * Older versions appended their lines to the hook, where an `exec` or `exit` before them, or the + * runner of husky 4, skips them. Only the end of the hook is theirs: v0.0.3 also rewrote the user's + * lines in place, unindented, in blocks that would be left empty without them. + */ +function liftAppended(texts: ReadonlyArray): ReadonlyArray { + const runner = texts.findIndex((text) => HUSKY_4_RUNNER.test(text)) + const tail = texts.reduce( + (start, text, index) => (text.trim() === '' || writtenByV003(text) ? start : index + 1), + 0, + ) + const from = runner === -1 ? tail : Math.min(tail, runner + 1) + const appended = texts.map( + (text, index) => index >= from && indentOf(text) === '' && ownLine(text) !== undefined, + ) + + return appended.includes(true) + ? beforeCommands( + texts.filter((_, index) => !appended[index]), + texts.filter((_, index) => appended[index]), + ) + : texts } // `sh` reads a script while running it, so a hook rewritten in place makes a commit already running @@ -326,58 +372,49 @@ export const prepare = Command.make( ) /** - * Puts `line` into a hook, so running `prepare` again is idempotent: the line for this directory is - * updated where it sits, or moved up from after husky 4's runner, duplicates of it are dropped, and - * everything else is kept, including the commands of other packages in the same repository and + * Puts `line` into a hook, so running `prepare` again is idempotent: the lines older versions + * appended are moved up first, the line for this directory is updated where it sits, keeping its + * indentation, its unindented copies are dropped, as an indented one is in a block of the user's, + * 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 - const lines = beforeHusky4Runner(hook.split('\n')).flatMap((text) => { + const lines = liftAppended(hook.split('\n')).flatMap((text) => { const own = ownLine(text) if (own === undefined) { return [text] } + const indent = indentOf(text) + if (own.inside !== inside) { - return [hookLine(own.inside, own.command)] + return [`${indent}${hookLine(own.inside, own.command)}`] } - if (placed) { + if (placed && indent === '') { return [] } placed = true - return [line] + return [`${indent}${line}`] }) if (placed) { 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 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), + (last, text, index) => + indentOf(text) === '' && ownLine(text) !== undefined ? index + 1 : last, 0, ) - const at = - after > 0 - ? after - : lines.findIndex( - (text) => !SETUP.test(opaque(text)) || HUSKY_4_RUNNER.test(text) || CONTINUES.test(text), - ) - if (at === -1) { - const kept = lines.join('\n') - - return `${kept === '' || kept.endsWith('\n') ? kept : `${kept}\n`}${line}\n` - } - - lines.splice(at, 0, line) - - return lines.join('\n') + return ( + after > 0 + ? [...lines.slice(0, after), line, ...lines.slice(after)] + : beforeCommands(lines, [line]) + ).join('\n') } diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 93683a5..56e5b4e 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -1,4 +1,5 @@ import { Buffer } from 'node:buffer' +import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, monorepo } from '../utils/project' @@ -16,9 +17,61 @@ import { const HOOK = '.git/hooks/pre-commit' +const EXITING_HOOKS = [ + { + name: "git's pre-commit.sample", + setup: '#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n\n', + commands: [ + 'if git rev-parse --verify HEAD >/dev/null 2>&1', + 'then', + '\tagainst=HEAD', + 'else', + '\tagainst=$(git hash-object -t tree /dev/null)', + 'fi', + '', + 'exec 1>&2', + '', + 'exec git diff-index --check --cached $against --', + '', + ].join('\n'), + }, + { + name: 'the hook of the pre-commit framework', + setup: [ + '#!/usr/bin/env bash', + '# File generated by pre-commit: https://pre-commit.com', + '# ID: 138fd403232d2ddd5efb44317e38bf03', + '', + '# start templated', + 'INSTALL_PYTHON=/usr/bin/python3', + '', + ].join('\n'), + commands: [ + 'ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)', + '# end templated', + '', + 'HERE="$(cd "$(dirname "$0")" && pwd)"', + 'ARGS+=(--hook-dir "$HERE" -- "$@")', + '', + 'if [ -x "$INSTALL_PYTHON" ]; then', + // oxlint-disable-next-line no-template-curly-in-string + ' exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"', + 'elif command -v pre-commit > /dev/null; then', + // oxlint-disable-next-line no-template-curly-in-string + ' exec pre-commit "${ARGS[@]}"', + 'else', + " echo '`pre-commit` not found. Did you forget to activate your virtualenv?' 1>&2", + ' exit 1', + 'fi', + '', + ].join('\n'), + }, +] + describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, app }) => { const line = hookLine(app) const folder = app.slice(0, -1) + const lineOfV003 = app === '' ? COMMAND : `cd "${folder}" && ${COMMAND}` async function prepareHook(hook: string) { const project = create().write({ [HOOK]: hook }) @@ -38,6 +91,49 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(hook).toBe(`${HEADER}${line}\npnpm test\n`) }) + it.each(EXITING_HOOKS)( + 'moves the line v0.0.3 appended after the exec of $name before it', + async ({ setup, commands }) => { + const { stdout, hook, project } = await prepareHook(`${setup}${commands}${lineOfV003}\n`) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${setup}${line}\n${commands}`) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }, + ) + + it('keeps in its block a line v0.0.3 rewrote in place', async () => { + const block = (text: string) => `#!/bin/sh\nif [ -z "$SKIP_CHECKS" ]; then\n${text}\nfi\n` + + const { hook } = await prepareHook(block(lineOfV003)) + + expect(hook).toBe(block(line)) + }) + + it('keeps its line in each branch of an if, indented, so the hook still parses', async () => { + const runs = (command: string) => (app === '' ? command : `(cd "${folder}" && ${command})`) + const branches = (first: string, second: string) => + `#!/bin/sh\nif [ -n "$CI" ]; then\n ${first}\nelse\n ${second}\nfi\n` + + const { stdout, hook, project } = await prepareHook( + branches(runs('pnpm exec uncheck staged --only=oxlint'), runs('pnpm exec uncheck staged')), + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(branches(line, line)) + expect(spawnSync('sh', ['-n', project.path(HOOK)], { encoding: 'utf8' })).toMatchObject({ + status: 0, + stderr: '', + }) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }) + it('drops the copies of its line and settles on one', async () => { const copies = [ line, @@ -170,6 +266,20 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(hook).toBe(`#!/bin/sh\n${line}\n${setup}npx lint-staged\n`) }) + it.each([ + [ + 'an apostrophe in a trailing comment', + 'export PATH="/opt/bin:$PATH" # GUI clients don\'t load ~/.zshrc', + ], + ['an apostrophe between double quotes', 'export MESSAGE="don\'t commit secrets"'], + ['a trailing comment after a variable', 'PATH="/opt/homebrew/bin:$PATH" # for GUI clients'], + ['a semicolon in a trailing comment', 'export PATH="/opt/bin:$PATH" # GUI clients; see docs'], + ])('runs after a setup line with %s', async (_, setup) => { + const { hook } = await prepareHook(`#!/bin/sh\n${setup}\nnpx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${setup}\n${line}\nnpx lint-staged\n`) + }) + it.each([ ['an odd single quote', "# don't commit secrets"], ['an odd double quote', '# the "main branch'], @@ -314,7 +424,16 @@ describe('prepare with an existing hook in a monorepo', () => { expect(again.stdout).toBe(written(HOOK, 'unchanged')) }) - it('rewrites the lines older versions wrote for the other packages', async () => { + it('puts the line of a new package before a block that runs the line of another', async () => { + const block = `if [ -z "$QUICK" ]; then\n ${hookLine('packages/core/')}\nfi\n` + const project = monorepo().write({ [HOOK]: `#!/bin/sh\n${block}` }) + + await prepare(project) + + expect(project.read(HOOK)).toBe(`#!/bin/sh\n${hookLine('')}\n${block}`) + }) + + it('moves up the lines v0.0.3 appended for the other packages, in order, and rewrites them', async () => { const project = monorepo().write({ [HOOK]: [ '#!/bin/sh', @@ -332,12 +451,16 @@ describe('prepare with an existing hook in a monorepo', () => { expect(project.read(HOOK)).toBe( [ '#!/bin/sh', - 'pnpm exec uncheck staged --fix || exit 1', - 'pnpm test', 'git --literal-pathspecs diff --cached --quiet -- "packages/core" || [ ! -d "packages/core" ] || (cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', 'git --literal-pathspecs diff --cached --quiet -- "packages/app" || [ ! -d "packages/app" ] || (cd "packages/app" && pnpm exec uncheck staged) || exit 1', + 'pnpm exec uncheck staged --fix || exit 1', + 'pnpm test', '', ].join('\n'), ) + + const again = await prepare(project, ['--no-fix'], { cwd: 'packages/app' }) + + expect(again.stdout).toBe(written(project.path(HOOK), 'unchanged', 'pnpm exec uncheck staged')) }) }) From 33383283f69079f3972724db870ed38b59a7a333 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:25:23 +0700 Subject: [PATCH 19/25] fix(uncheck): tsc builds git-ignored in-place projects with -b, checks solutions in the -p fallback, uses the config above a folder without its own, and names a missing typescript - R5: a references member that emits beside its sources stays in tsc -b when git ignores its would-be JavaScript - R8: an affected solution without sources in a -p graph is checked with -p, so a missing reference fails with TS6053 - R6: the config above cwd is used whenever cwd has no tsconfig.json of its own, next to the nested ones - R7: workspace dependents also come from the packages owning the given files, before the not-covered exit - R9: a deleted base extended by package name selects its extenders - R11/R17: no tsconfig.json fails even for unrelated files; a missing typescript reads 'not installed' - R19: README scopes deleted or moved files to the hooks --- packages/uncheck/README.md | 8 +- packages/uncheck/src/checks/tsc.ts | 183 ++++++++++++------ .../uncheck/tests/uncheck/selection.test.ts | 31 +-- .../uncheck/tests/uncheck/tsc-extends.test.ts | 8 +- .../uncheck/tests/uncheck/tsc-inputs.test.ts | 53 +++++ .../tests/uncheck/tsc-references.test.ts | 124 +++++++++++- .../tests/uncheck/tsc-workspace.test.ts | 57 ++++-- packages/uncheck/tests/uncheck/tsc.test.ts | 34 ++++ 8 files changed, 398 insertions(+), 100 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 5883d92..334887c 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -85,7 +85,7 @@ npx uncheck '!**/*.gen.ts' # everything except some files 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, but never links that point outside the directory uncheck runs in. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. -tsc then checks only the projects that include one of the files, or included one that was deleted or moved, and the projects that depend on those. sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among the files. +tsc then checks only the projects that include one of the files, and the projects that depend on those. sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among the files. ## Run it before every commit @@ -160,10 +160,10 @@ Run uncheck from the workspace root, the folder whose `package.json` has `worksp **TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: -- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. A project that sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile` would get JavaScript next to its sources, so it and the projects that reference it are checked with `tsc -p --noEmit` instead, after `tsc -b` builds the projects they reference. These cannot import each other: tsc reports TS6305 until you build them. +- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. A project that sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile` gets JavaScript next to its sources. When git ignores that JavaScript, the project builds in place on purpose and stays in `tsc -b`. Otherwise, as with Vite's `tsconfig.node.json`, it and the projects that reference it are checked with `tsc -p --noEmit` instead, after `tsc -b` builds the projects they reference. These cannot import each other: tsc reports TS6305. - Every other project is checked with `tsc -p --noEmit`, a few at a time. A `tsconfig.json` that other configs extend and that includes no files is a shared base, not a project. -- When only some files are checked, tsc runs just the projects that include them, the projects that reference those, and the projects of the packages that depend on theirs. A changed tsconfig selects every project that extends it, and a deleted or moved file the projects that included it. -- In a folder with no `tsconfig.json`, such as a package that shares the root one, uncheck uses the nearest one above it in the same git repository, when it includes files of that folder. +- When only some files are checked, tsc runs just the projects that include them, the projects that reference those, and the projects of the packages that depend on the files' packages. A changed tsconfig selects every project that extends it. In the hooks, a deleted or moved file also selects the projects that included or extended it. +- In a folder without its own `tsconfig.json`, such as a package that shares the root one, uncheck also uses the nearest one above it in the same git repository, when it includes files of that folder. **Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags. A package's line runs only when the commit changes files in that package: diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index 9c39075..fddd5fd 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -4,7 +4,8 @@ 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 { ancestors, listProjectFiles, readJson } from '../files' +import { git } from '../git' import type { Bin } from '../tool' import { resolveBin } from '../tool' import type { Check } from '../types' @@ -20,10 +21,12 @@ function notCovered( typescript: Bin | undefined, files: ReadonlyArray | undefined, ): NothingToCheck { - return new NothingToCheck({ - reason: `no tsconfig.json covers ${files === undefined ? 'this folder' : 'the given files'}`, - unrelated: typescript !== undefined, - }) + return typescript === undefined + ? new NothingToCheck({ reason: 'not installed' }) + : new NothingToCheck({ + reason: `no tsconfig.json covers ${files === undefined ? 'this folder' : 'the given files'}`, + unrelated: true, + }) } /** @@ -47,10 +50,6 @@ export const tsc: Check = { // Deleted files are among the targets, so selecting a project must never need one on disk. const given = files === undefined ? undefined : checkable([...files, ...deleted]) - if (given !== undefined && given.length === 0) { - return yield* Effect.fail(notCovered(yield* resolveBin('typescript', cwd, 'tsc'), files)) - } - const [typescript, tracked] = yield* Effect.all( [ resolveBin('typescript', cwd, 'tsc'), @@ -69,29 +68,23 @@ export const tsc: Check = { { concurrency: 'unbounded' }, ) - const inherited = - tracked.length > 0 ? undefined : Option.getOrUndefined(yield* tsconfigAboveInRepository(cwd)) + const inherited = tracked.includes(path.resolve(cwd, 'tsconfig.json')) + ? undefined + : Option.getOrUndefined(yield* tsconfigAboveInRepository(cwd)) if (inherited === undefined && tracked.length === 0) { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) } + if (given !== undefined && given.length === 0) { + return yield* Effect.fail(notCovered(typescript, files)) + } + // A config above the folder may cover far more than it, so even a full run selects by its files. const targets = given ?? (inherited === undefined ? undefined : checkable(yield* projectFiles)) - - const references = new Map>() - const queue = inherited === undefined ? [...tracked] : [inherited] - - while (queue.length > 0) { - const configPath = queue.pop()! - - if (!references.has(configPath) && (yield* fileKind(configPath)) === 'File') { - const referencedConfigs = yield* readReferences(configPath) - - references.set(configPath, referencedConfigs) - queue.push(...referencedConfigs) - } - } + const references = yield* followReferences( + inherited === undefined ? tracked : [...tracked, inherited], + ) const selected = targets === undefined @@ -102,10 +95,7 @@ export const tsc: Check = { deleted.map((file) => path.resolve(cwd, file)), ) - if (selected.length === 0) { - return yield* Effect.fail(notCovered(typescript, files)) - } - + const projectPaths = (yield* projectFiles).map((file) => path.resolve(cwd, file)) const projects = targets === undefined ? selected @@ -114,14 +104,16 @@ export const tsc: Check = { ...selected, ...(yield* workspaceDependents( [...references.keys()], - selected, - (yield* projectFiles) - .filter((file) => path.basename(file) === 'package.json') - .map((file) => path.resolve(cwd, file)), + [...selected, ...(given ?? []).filter((file) => posix.extname(file) !== '.json')], + projectPaths.filter((file) => path.basename(file) === 'package.json'), )), ]), ] + if (projects.length === 0) { + return yield* Effect.fail(notCovered(typescript, files)) + } + if (typescript === undefined) { return yield* Effect.fail( new CannotCheck({ @@ -184,12 +176,20 @@ export const tsc: Check = { ), ) // `tsc -b` on these would write JavaScript next to the sources of a project, and Vite, for one, - // then loads a stale vite.config.js instead of vite.config.ts. + // then loads a stale vite.config.js instead of vite.config.ts. Where git ignores that JavaScript, + // the project builds in place on purpose, and `-p` would read the declarations of its last build. const buildsBesideSources = new Set( - members.filter((member) => { - const inputs = memberInputs.get(member)! - return hasInputs(inputs) && inputs.emitsBesideSources - }), + yield* Effect.filter( + members, + (member) => { + const inputs = memberInputs.get(member)! + + return hasInputs(inputs) && inputs.emitsBesideSources + ? Effect.map(gitIgnoresOutput(cwd, member, inputs, projectPaths), (ignored) => !ignored) + : Effect.succeed(false) + }, + { concurrency: 'unbounded' }, + ), ) for (const configPath of buildsBesideSources) { @@ -199,11 +199,10 @@ export const tsc: Check = { } const extended = yield* extendedConfigs([...references.keys()]) - const allFiles = yield* projectFiles const standalone = yield* Effect.filter( projects.filter((configPath) => !members.includes(configPath)), (configPath) => - Effect.map(isSharedBase(configPath, extended, cwd, allFiles), (shared) => !shared), + Effect.map(isSharedBase(configPath, extended, projectPaths), (shared) => !shared), { concurrency: 'unbounded' }, ) const built = new Set( @@ -219,11 +218,9 @@ export const tsc: Check = { for (const member of members) { if (affected.has(member) && buildsBesideSources.has(member)) { const inputs = memberInputs.get(member)! - const checked = hasInputs(inputs) - if (checked) { - checkedAlone.set(member, inputs.composite ? WITHOUT_BUILD_INFO : []) - } + // A solution without sources needs `-p` too, or a reference to a missing config passes. + checkedAlone.set(member, inputs.composite ? WITHOUT_BUILD_INFO : []) // `-p` reads a referenced project through the declarations on disk, which a fresh checkout // lacks and an earlier build left stale. @@ -231,7 +228,7 @@ export const tsc: Check = { if ( memberInputs.has(referencedConfig) && !buildsBesideSources.has(referencedConfig) && - (checked || affected.has(referencedConfig)) + (hasInputs(inputs) || affected.has(referencedConfig)) ) { built.add(referencedConfig) } @@ -257,6 +254,68 @@ export const tsc: Check = { }), } +const followReferences = Effect.fn(function* (configPaths: ReadonlyArray) { + const references = new Map>() + const queue = [...configPaths] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (!references.has(configPath) && (yield* fileKind(configPath)) === 'File') { + const referencedConfigs = yield* readReferences(configPath) + + references.set(configPath, referencedConfigs) + queue.push(...referencedConfigs) + } + } + + return references +}) + +const OUTPUT_EXTENSIONS = new Map([ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.mts', '.mjs'], + ['.cts', '.cjs'], +]) + +function outputBeside(source: string): string | undefined { + const extension = posix.extname(source) + const output = OUTPUT_EXTENSIONS.get(extension) + + return output === undefined || /\.d\.[cm]?ts$/.test(source) + ? undefined + : `${source.slice(0, -extension.length)}${output}` +} + +const gitIgnoresOutput = Effect.fn(function* ( + cwd: string, + configPath: string, + inputs: TsconfigInputs, + projectPaths: ReadonlyArray, +) { + const path = yield* Path.Path + const configDir = path.dirname(configPath) + const files = ancestors(path, configDir).includes(cwd) + ? projectPaths + : (yield* listProjectFiles(configDir)).map((file) => path.resolve(configDir, file)) + + for (const file of files) { + const output = outputBeside(file) + + if (output !== undefined && includesFile(inputs, file)) { + // check-ignore refuses the literal pathspecs `git` turns on. + return yield* Effect.isSuccess( + git(cwd, ['check-ignore', '--quiet', '--no-index', '--', output], { + GIT_LITERAL_PATHSPECS: '0', + }), + ) + } + } + + return false +}) + const tsconfigAboveInRepository = Effect.fn(function* (cwd: string) { const path = yield* Path.Path const dirs = ancestors(path, cwd) @@ -296,18 +355,15 @@ const extendedConfigs = Effect.fn(function* (configPaths: ReadonlyArray) const isSharedBase = Effect.fn(function* ( configPath: string, extended: ReadonlySet, - cwd: string, files: ReadonlyArray, ) { - const path = yield* Path.Path - if (!extended.has(yield* realPath(configPath))) { return false } const inputs = yield* loadTsconfigInputs(configPath) - return !files.some((file) => includesFile(inputs, path.resolve(cwd, file))) + return !files.some((file) => includesFile(inputs, file)) }) const DEPENDENCY_FIELDS = [ @@ -319,11 +375,11 @@ const DEPENDENCY_FIELDS = [ /** * The configs of the workspace packages that depend, directly or through others, on a package that - * owns one of `selected`: a package imported through its workspace link needs no project reference. + * owns one of `changed`: a package imported through its workspace link needs no project reference. */ const workspaceDependents = Effect.fn(function* ( candidates: ReadonlyArray, - selected: ReadonlyArray, + changed: ReadonlyArray, manifests: ReadonlyArray, ) { const path = yield* Path.Path @@ -342,10 +398,10 @@ const workspaceDependents = Effect.fn(function* ( { concurrency: 64 }, ) const packageDirs = new Set(packages.map(({ dir }) => dir)) - const ownerOf = (configPath: string) => - ancestors(path, path.dirname(configPath)).find((dir) => packageDirs.has(dir)) + const ownerOf = (file: string) => + ancestors(path, path.dirname(file)).find((dir) => packageDirs.has(dir)) - const owners = new Set(selected.map(ownerOf)) + const owners = new Set(changed.map(ownerOf)) const reachedNames = new Set( packages.filter(({ dir }) => owners.has(dir)).map(({ name }) => name), ) @@ -434,8 +490,15 @@ const selectTsconfigs = Effect.fn(function* ( */ const realPath = Effect.fn(function* (file: string) { const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path - return yield* fs.realPath(file).pipe(Effect.orElseSucceed(() => file)) + // A deleted file has no real path, while the folder it was in can still be reached through a link. + return yield* fs.realPath(file).pipe( + Effect.catch(() => + Effect.map(fs.realPath(path.dirname(file)), (dir) => path.join(dir, path.basename(file))), + ), + Effect.orElseSucceed(() => file), + ) }) interface RawTsconfig { @@ -677,6 +740,7 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { } const [name, subpath] = splitPackageSpec(spec) + let deletedBase: string | undefined for (const current of ancestors(path, dir)) { const pkg = path.join(current, 'node_modules', name) @@ -685,7 +749,7 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { // Like tsc, a package that declares `exports` is reachable only through them. if (pkgManifest?.exports !== undefined) { const target = resolveExports(pkgManifest.exports, subpath) - return target === undefined ? Option.none() : yield* firstFile([path.resolve(pkg, target)]) + return target === undefined ? Option.none() : Option.some(path.resolve(pkg, target)) } const base = path.join(current, 'node_modules', spec) @@ -712,9 +776,14 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { if (Option.isSome(found)) { return found } + + if (pkgManifest !== undefined) { + deletedBase ??= + baseKind === 'Directory' ? candidates[1] : base.endsWith('.json') ? base : candidates[0] + } } - return Option.none() + return Option.fromUndefinedOr(deletedBase) }) const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index a6892ba..cd36625 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -152,17 +152,25 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc }) it.each([ - { outcome: 'passes', typescript: 'installed', tools: TOOLS, mark: '○', expectedExitCode: 0 }, + { + outcome: 'passes', + typescript: 'installed', + tools: TOOLS, + tscReason: 'no tsconfig.json covers the given files', + mark: '○', + expectedExitCode: 0, + }, { outcome: 'fails', typescript: 'not installed', tools: TOOLS.filter((tool) => tool !== 'typescript'), + tscReason: 'not installed', mark: '✘', expectedExitCode: 1, }, ])( '$outcome on a source file no tsconfig.json covers when typescript is $typescript and unmatched patterns are allowed', - async ({ tools, mark, expectedExitCode }) => { + async ({ tools, tscReason, mark, expectedExitCode }) => { const project = create( { [`${app}scripts/release.ts`]: 'export const release = 1;\n' }, { tools }, @@ -177,8 +185,8 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...SKIPPED_BESIDE_TSC, - NOT_COVERED, - `${mark} 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`, + `○ tsc skipped, ${tscReason}`, + `${mark} nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc ${tscReason}`, ]) expect(exitCode).toBe(expectedExitCode) }, @@ -203,18 +211,19 @@ describe('uncheck check selection', () => { }) it.each([ - ['src/index.ts', 'no tsconfig.json found'], - ['README.md', 'no tsconfig.json covers the given files'], + { file: 'src/index.ts', typescript: 'not installed', tools: [] }, + { file: 'README.md', typescript: 'not installed', tools: [] }, + { file: 'README.md', typescript: 'installed', tools: ['typescript'] as const }, ])( - 'fails on %s in a project that uses none of the tools, even when unmatched patterns are allowed', - async (file, tscReason) => { + 'fails on $file in a project that uses none of the tools with typescript $typescript, even when unmatched patterns are allowed', + async ({ file, tools }) => { const project = bareProject( { 'package.json': { name: 'bare', private: true }, 'README.md': '# Bare\n', 'src/index.ts': 'export const answer = 42;\n', }, - { tools: [] }, + { tools }, ) const { exitCode, stdout } = await project.uncheck(['--no-error-on-unmatched-pattern', file]) @@ -224,8 +233,8 @@ describe('uncheck check selection', () => { '○ sherif skipped, not installed', '○ oxlint skipped, not installed', '○ oxfmt skipped, not installed', - `○ tsc skipped, ${tscReason}`, - `✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc ${tscReason}`, + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', ]) expect(exitCode).toBe(1) }, diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts index 48c8afc..d0d108b 100644 --- a/packages/uncheck/tests/uncheck/tsc-extends.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -235,14 +235,14 @@ describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) }) - it('typechecks the projects extending a deleted base', async () => { + it('typechecks the projects extending a base deleted with its folder', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.base.json`]: NO_JS, - [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}config/tsconfig.base.json`]: NO_JS, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json', include: ['src'] }, [`${app}server/tsconfig.json`]: { include: ['src'] }, }) - project.git('rm', '--quiet', '--', `${app}tsconfig.base.json`) + project.git('rm', '--quiet', '-r', '--', `${app}config`) const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc'], { cwd: app }) diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts index a0f289c..268bc7e 100644 --- a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -215,6 +215,46 @@ describe('tsc inputs across the packages of a monorepo', () => { expect(await tscPlan(project, '', ['packages/tsconfig/package.json'])).toEqual([NOT_COVERED]) }) + it.each([ + { spelling: 'a file of it', spec: '@repo/tsconfig/base.json', base: 'base.json' }, + { spelling: 'a file of it without .json', spec: '@repo/tsconfig/base', base: 'base.json' }, + { spelling: 'its tsconfig.json', spec: '@repo/tsconfig', base: 'tsconfig.json' }, + { + spelling: 'an export of it', + spec: '@repo/tsconfig/strict', + base: 'strict.json', + exports: { './strict': './strict.json' }, + }, + ])( + 'typechecks the projects extending a deleted base they name by its package and $spelling', + async ({ spec, base, exports }) => { + const project = withFakeTsc(monorepo, { + 'tsconfig.json': null, + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + exports, + }, + [`packages/tsconfig/${base}`]: { compilerOptions: { strict: true } }, + 'packages/app/tsconfig.json': { extends: spec, include: ['src'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + project.git('rm', '--quiet', '--', `packages/tsconfig/${base}`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc']) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + }, + ) + it('checks a shared base kept as a tsconfig.json with no sources only through the configs extending it', async () => { const project = withFakeTsc(monorepo, { 'packages/tsconfig/package.json': { @@ -245,5 +285,18 @@ describe('tsc inputs across the packages of a monorepo', () => { expect(await tscPlan(project, '', ['packages/tsconfig/tsconfig.json'])).toEqual([ '▶ tsc -b tsconfig.json', ]) + + project.write({ 'packages/tsconfig/legacy.ts': '' }).commit() + project.git('rm', '--quiet', '--', 'packages/tsconfig/legacy.ts') + + const deleted = await project.uncheck(['staged', '--only=tsc']) + + expect(deleted.exitCode).toBe(0) + expect(report(deleted.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + NOT_COVERED, + '○ 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', + ]) }) }) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index 703df93..a6e26e1 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -85,18 +85,45 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => expect(await tscPlan(project, app)).toEqual([ '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p tsconfig.json --noEmit', '▶ tsc -p web/tsconfig.json --noEmit', ]) expect(await tscPlan(project, app, ['node/vite.config.ts'])).toEqual([ '▶ tsc -b ui/tsconfig.json', '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p tsconfig.json --noEmit', '▶ tsc -p web/tsconfig.json --noEmit', ]) expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', '▶ tsc -p web/tsconfig.json --noEmit', ]) - expect(await tscPlan(project, app, ['tsconfig.json'])).toEqual([NOT_COVERED]) + expect(await tscPlan(project, app, ['tsconfig.json'])).toEqual([ + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('builds with tsc -b the projects whose JavaScript next to their sources git ignores, also from the folder of one', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { files: [], references: [{ path: './lib' }, { path: './web' }] }, + [`${app}lib/.gitignore`]: '*.js\n', + [`${app}lib/tsconfig.json`]: { compilerOptions: { composite: true }, include: ['src'] }, + [`${app}lib/src/index.ts`]: '', + [`${app}web/.gitignore`]: '*.js\n', + [`${app}web/tsconfig.json`]: { + compilerOptions: { composite: true }, + include: ['src'], + references: [{ path: '../lib' }], + }, + [`${app}web/src/index.ts`]: '', + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, `${app}web`)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, `${app}web`, ['src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) }) it('follows references to a folder, to a .json file and with backslashes', async () => { @@ -295,16 +322,14 @@ describe('tsc project references across the packages of a monorepo', () => { }) describe('tsc project references with the real compiler in a single repo', () => { - const COMPOSITE = { + const IN_PLACE = { strict: true, module: 'esnext', moduleResolution: 'bundler', types: [], composite: true, - emitDeclarationOnly: true, - outDir: 'dist', - rootDir: 'src', } + const COMPOSITE = { ...IN_PLACE, emitDeclarationOnly: true, outDir: 'dist', rootDir: 'src' } it('builds the library a Vite 4 app imports before checking the app with -p, writing no JavaScript', async () => { const project = singleRepo({ @@ -373,6 +398,95 @@ describe('tsc project references with the real compiler in a single repo', () => expect(project.exists('vite.config.js')).toBe(false) }) + it('builds with tsc -b the projects whose JavaScript next to their sources git ignores, so a break across them fails after a build', async () => { + const project = singleRepo({ + '.gitignore': 'node_modules\n*.js\n*.d.ts\n*.tsbuildinfo\n', + 'tsconfig.json': { files: [], references: [{ path: './lib' }, { path: './app' }] }, + 'lib/tsconfig.json': { compilerOptions: IN_PLACE, include: ['src'] }, + 'lib/src/index.ts': 'export const one = 1;\n', + 'app/tsconfig.json': { + compilerOptions: IN_PLACE, + include: ['src'], + references: [{ path: '../lib' }], + }, + 'app/src/index.ts': + 'import { one } from "../../lib/src/index";\n\nexport const two: number = one + 1;\n', + }) + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(project.exists('lib/src/index.d.ts')).toBe(true) + expect(project.git('status', '--porcelain')).toBe('') + + project.write({ 'lib/src/index.ts': 'export const one = "1";\n' }) + + const failed = await project.uncheck(['--only=tsc', 'lib/src/index.ts']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(failed.stdout).toContain( + "app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) + + it('checks with -p a solution that references a project writing JavaScript next to its sources, so a reference to a deleted config fails', async () => { + const project = singleRepo({ + 'tsconfig.json': { + files: [], + references: [{ path: './tsconfig.node.json' }, { path: './tsconfig.app.json' }], + }, + 'tsconfig.node.json': { compilerOptions: IN_PLACE, include: ['vite.config.ts'] }, + 'tsconfig.app.json': { + compilerOptions: { ...IN_PLACE, noEmit: true }, + include: ['src'], + }, + 'vite.config.ts': 'export default { base: "/" };\n', + }) + + project.git('rm', '--quiet', '--', 'tsconfig.app.json') + + const missing = `error TS6053: File '${project.path('tsconfig.app.json')}' not found.` + const full = await project.uncheck(['--only=tsc']) + + expect(full.exitCode).toBe(1) + expect(report(full.stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(full.stdout).toContain(missing) + + const staged = await project.uncheck(['staged', '--only=tsc']) + + expect(staged.exitCode).toBe(1) + expect(report(staged.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(staged.stdout).toContain(missing) + expect(project.exists('vite.config.js')).toBe(false) + }) + it('builds roots in folders starting with - or @, which tsc would read as an option or a file of arguments', async () => { const project = singleRepo({ 'core/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, diff --git a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts index 26db8ca..77848d1 100644 --- a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts @@ -94,25 +94,44 @@ describe('tsc across the packages of a workspace', () => { ]) }) - it('fails a change that breaks a package importing it through its workspace link', async () => { - const project = linkedMonorepo().write({ - 'packages/core/src/index.ts': - 'export function double(value: number): string {\n return String(value);\n}\n', - }) + it.each([ + { + core: 'its own tsconfig.json', + coreConfig: PACKAGE_CONFIG, + plan: [ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + ], + }, + { + core: 'no tsconfig.json', + coreConfig: null, + plan: ['▶ tsc -p packages/app/tsconfig.json --noEmit'], + }, + ])( + 'fails a change that breaks a package importing it through its workspace link, with $core', + async ({ coreConfig, plan }) => { + const project = linkedMonorepo({ 'packages/core/tsconfig.json': coreConfig }).write({ + 'packages/core/src/index.ts': + 'export function double(value: number): string {\n return String(value);\n}\n', + }) - const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'packages/core/src/index.ts']) + const { exitCode, stdout } = await project.uncheck([ + '--only=tsc', + 'packages/core/src/index.ts', + ]) - expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p packages/app/tsconfig.json --noEmit', - '▶ tsc -p packages/core/tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) - expect(stdout).toContain( - "packages/app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", - ) - }) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + ...plan, + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain( + "packages/app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }, + ) }) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 097fcfd..bf0761e 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -219,6 +219,40 @@ describe('tsc with the real compiler in a single repo', () => { ]) }) + it('typechecks a folder without its own tsconfig.json with the one above it, next to the ones inside it', async () => { + const project = singleRepo({ + 'web/src/index.ts': CODE_WITH_TYPE_ERROR, + 'web/cypress/tsconfig.json': { + compilerOptions: { strict: true, noEmit: true, types: [] }, + include: ['**/*.ts'], + }, + 'web/cypress/e2e.ts': CLEAN_CODE, + }).update('tsconfig.json', (config) => ({ ...config, include: ['web/src'] })) + const failed = (plan: ReadonlyArray) => [ + `uncheck in ${project.path('web')}`, + ...SKIPPED_BESIDE_TSC, + ...plan, + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ] + const diagnostic = + "src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." + + const full = await project.uncheck(['--only=tsc'], { cwd: 'web' }) + + expect(full.exitCode).toBe(1) + expect(report(full.stdout)).toEqual( + failed(['▶ tsc -p ../tsconfig.json --noEmit', '▶ tsc -p cypress/tsconfig.json --noEmit']), + ) + expect(full.stdout).toContain(`\n${diagnostic}`) + + const given = await project.uncheck(['--only=tsc', 'src/index.ts'], { cwd: 'web' }) + + expect(given.exitCode).toBe(1) + expect(report(given.stdout)).toEqual(failed(['▶ tsc -p ../tsconfig.json --noEmit'])) + expect(given.stdout).toContain(`\n${diagnostic}`) + }) + it.runIf(PERMISSIONS_ENFORCED)( 'hands a tsconfig.json in a folder it cannot open to tsc, which reports it', async () => { From 66f1fdd4c82ff57266580f8ce9284f6310d0b7fa Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:41:24 +0700 Subject: [PATCH 20/25] fix(uncheck): address review of S --- packages/uncheck/src/commands/staged.ts | 20 +++++++++----- .../uncheck/tests/staged/interrupt.test.ts | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 9e4a790..564d531 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -92,7 +92,7 @@ export const staged = Command.make( const partial = yield* partiallyStaged(unstaged, files) const outcome = yield* Ref.make(RESTORED) - const fixed = yield* Ref.make>([]) + const fixed = yield* Ref.make | undefined>(fix ? undefined : []) const summary = yield* Ref.make>([]) // `git commit ` runs the hook on a temporary index, and the index it leaves @@ -139,7 +139,7 @@ export const staged = Command.make( const changed = yield* Ref.get(fixed) - if (changed.length === 0) { + if (changed === undefined || changed.length === 0) { return { failure, empty: false } } @@ -384,7 +384,7 @@ const putBack = Effect.fn(function* ( files: ReadonlyArray, copies: ReadonlyArray, before: string, - fixed: Ref.Ref>, + fixed: Ref.Ref | undefined>, index: string | undefined, ) { const fs = yield* FileSystem.FileSystem @@ -408,9 +408,14 @@ const putBack = Effect.fn(function* ( return yield* restored } + const stagedFixes = yield* Ref.get(fixed) const merged = yield* Effect.forEach( copies, - (entry) => Effect.map(merge(aside, entry, before), (outcome) => ({ ...entry, ...outcome })), + (entry) => + Effect.map(merge(aside, entry, before, stagedFixes !== undefined), (outcome) => ({ + ...entry, + ...outcome, + })), { concurrency: 4 }, ) const conflicted = merged.filter(({ clean }) => !clean) @@ -420,7 +425,7 @@ const putBack = Effect.fn(function* ( } const isPartial = new Set(partial) - const fixedInFull = (yield* Ref.get(fixed)).filter((file) => !isPartial.has(file)) + const fixedInFull = (stagedFixes ?? []).filter((file) => !isPartial.has(file)) // A file saved since its fixes were staged holds an edit made during the run, which a // checkout or its unstaged copy would throw away. const editedSince = new Set(yield* differFromIndex(cwd, fixedInFull)) @@ -473,6 +478,7 @@ const merge = Effect.fn(function* ( { cwd, prefix }: Aside, { file, target, copy, base }: SetAside, before: string, + fixesStaged: boolean, ) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -489,9 +495,11 @@ const merge = Effect.fn(function* ( `:0:${prefix}${file}`, `${before}:${prefix}${file}`, ])).split('\n') + // Until the fixes are staged, a fixed file differs from the index just as an edited one does. // hash-object turns the CRLF of a blob git keeps that way to LF, so only git can tell whether the // file still holds what is staged. - const edited = staged !== checked && (yield* differFromIndex(cwd, [file])).length > 0 + const edited = + fixesStaged && staged !== checked && (yield* differFromIndex(cwd, [file])).length > 0 // 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. diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index b196e8a..f3afbd8 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -60,6 +60,33 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) + it('puts the unstaged changes back over a clashing fix after Ctrl-C before the fixes are staged', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + const clashing = VERSIONS.fixed.replace('const e = 1', 'const e = 3') + + project.fake( + 'oxfmt', + `require('node:fs').writeFileSync(${JSON.stringify(project.path(file))}, ${JSON.stringify(clashing)})\n${SLOW_CHECK}`, + ) + + const { exitCode, stdout } = await project.uncheckInTerminal( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder, waitFor: 'waiting', keys: ['\u0003'] }, + ) + + expect(exitCode).toBe(130) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + ]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + it('puts the unstaged changes back as they were after Ctrl-C kills `git commit --include` and its index', async () => { const other = `${app}src/other.ts` const project = stagePartially( From 6c52ce6f05308b757a50cce9c13453712346d56a Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:46:10 +0700 Subject: [PATCH 21/25] fix(uncheck): address review of P --- packages/uncheck/src/commands/prepare.ts | 38 ++++++++++--- .../tests/prepare/existing-hook.test.ts | 57 +++++++++++++++++-- packages/uncheck/tests/prepare/utils.ts | 8 +++ 3 files changed, 89 insertions(+), 14 deletions(-) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 1bb16a4..2e0118c 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -47,9 +47,12 @@ const QUOTED = /\\.|"(?:[^"\\]|\\.)*"|'[^']*'/g const COMMENT = /(?:^|\s)#.*$/ -/** A setup line that runs on into the next, where the added line would join it. */ +/** + * A line that runs on into the next, so a line added after it would join its command or block, and + * one removed after it would leave them incomplete. + */ const CONTINUES = - /^(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<)/ + /^(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<|.*(?:&&|\||(?:^|[\s;])(?:if|then|elif|else|while|until|do))\s*$)/ const INDENT = /^\s*/ @@ -99,6 +102,19 @@ function indentOf(text: string): string { return INDENT.exec(text)![0] } +// prepare never indents its lines nor continues another line into them, so such a line is the +// user's, and dropping or moving it would break the block or command it is in. +function standalone(texts: ReadonlyArray, index: number): boolean { + const previous = texts + .slice(0, index) + .filter((text) => codeOf(text).trim() !== '') + .at(-1) + + return ( + indentOf(texts[index]!) === '' && (previous === undefined || !CONTINUES.test(codeOf(previous))) + ) +} + function writtenByV003(text: string): boolean { const own = ownLine(text) @@ -139,7 +155,7 @@ function liftAppended(texts: ReadonlyArray): ReadonlyArray { ) const from = runner === -1 ? tail : Math.min(tail, runner + 1) const appended = texts.map( - (text, index) => index >= from && indentOf(text) === '' && ownLine(text) !== undefined, + (text, index) => index >= from && ownLine(text) !== undefined && standalone(texts, index), ) return appended.includes(true) @@ -373,14 +389,15 @@ export const prepare = Command.make( /** * Puts `line` into a hook, so running `prepare` again is idempotent: the lines older versions - * appended are moved up first, the line for this directory is updated where it sits, keeping its - * indentation, its unindented copies are dropped, as an indented one is in a block of the user's, - * and everything else is kept, including the commands of other packages in the same repository and + * appended are moved up first, every line for this directory is updated where it sits, keeping its + * indentation, except the standalone copies after the first standalone one, which 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 - const lines = liftAppended(hook.split('\n')).flatMap((text) => { + let placedStandalone = false + const lines = liftAppended(hook.split('\n')).flatMap((text, index, texts) => { const own = ownLine(text) if (own === undefined) { @@ -393,11 +410,14 @@ function rewrite(hook: string, line: string, inside: string): string { return [`${indent}${hookLine(own.inside, own.command)}`] } - if (placed && indent === '') { + const isStandalone = standalone(texts, index) + + if (isStandalone && placedStandalone) { return [] } placed = true + placedStandalone ||= isStandalone return [`${indent}${line}`] }) @@ -408,7 +428,7 @@ function rewrite(hook: string, line: string, inside: string): string { const after = lines.reduce( (last, text, index) => - indentOf(text) === '' && ownLine(text) !== undefined ? index + 1 : last, + ownLine(text) !== undefined && standalone(lines, index) ? index + 1 : last, 0, ) diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 56e5b4e..25aa8e9 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -1,5 +1,4 @@ import { Buffer } from 'node:buffer' -import { spawnSync } from 'node:child_process' import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, monorepo } from '../utils/project' @@ -12,6 +11,7 @@ import { notWritten, prepare, shownHook, + syntaxCheck, written, } from './utils' @@ -124,10 +124,57 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(branches(line, line)) - expect(spawnSync('sh', ['-n', project.path(HOOK)], { encoding: 'utf8' })).toMatchObject({ - status: 0, - stderr: '', - }) + expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }) + + it.each([ + ['&&', 'pnpm test &&'], + ['||', '[ -n "$SKIP_CHECKS" ] ||'], + ['a backslash', 'git diff --cached --quiet || \\'], + ])( + 'keeps in place the line of v0.0.3 after a command that runs on into it with %s', + async (_, command) => { + const existing = (text: string) => `#!/bin/sh\n${command}\n${text}\n` + + const { stdout, hook, project } = await prepareHook(existing(lineOfV003)) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(existing(line)) + expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + }, + ) + + it.each([ + ['after a command that runs on into it', 'pnpm test &&\n\n', ''], + ['that is all of an unindented block', 'if [ "$FULL" = 1 ]; then\n', 'fi\n'], + ])('keeps in place a copy of its line %s', async (_, before, after) => { + const existing = (text: string) => `${HEADER}${line}\n${before}${text}\n${after}` + + const { hook, project } = await prepareHook(existing(lineOfV003)) + + expect(hook).toBe(existing(line)) + expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + }) + + it('keeps its line after a block that runs it too', async () => { + const runs = (command: string) => (app === '' ? command : `(cd "${folder}" && ${command})`) + const existing = (inBlock: string, after: string) => + `#!/bin/sh\nif [ -n "$CI" ]; then\n ${inBlock}\nfi\n${after}\n` + + const { stdout, hook, project } = await prepareHook( + existing(runs('pnpm exec uncheck staged --only=oxlint'), `${runs(COMMAND)} || exit 1`), + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(existing(line, line)) const again = await prepare(project, [], { cwd: app }) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts index d5a3ace..0b8b165 100644 --- a/packages/uncheck/tests/prepare/utils.ts +++ b/packages/uncheck/tests/prepare/utils.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' + import type { Project, Run, RunOptions } from '../utils/project' export const HEADER = @@ -33,6 +35,12 @@ export function written( return `✔ pre-commit ${shown} ${result}\n\nThe hook runs ${command} before every commit, \`git commit --no-verify\` skips it.\n` } +export function syntaxCheck(file: string): { status: number | null; stderr: string } { + const { status, stderr } = spawnSync('sh', ['-n', file], { encoding: 'utf8' }) + + return { status, stderr } +} + export function notWritten(shown: string, reason: string): string { return `✘ pre-commit ${shown} not written, ${reason}\n` } From 7e37e71a8992fcceb1029ebdff10b1e8fad5a86c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:52:10 +0700 Subject: [PATCH 22/25] fix(uncheck): leave out links into node_modules, isolate tests from /etc/gitattributes and ancestor ignore files, wait for parallel prepares --- packages/uncheck/src/files.ts | 9 +++++++-- packages/uncheck/tests/hooks/run/changes.test.ts | 3 ++- packages/uncheck/tests/prepare/failures.test.ts | 3 +++ packages/uncheck/tests/uncheck/paths-git.test.ts | 7 ++++++- packages/uncheck/tests/utils/project.ts | 6 +++++- 5 files changed, 23 insertions(+), 5 deletions(-) diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index dcc0e87..3d8cf50 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -192,7 +192,7 @@ export const checkableFiles = Effect.fn(function* (files: ReadonlyArray, const GLOB_CHARACTERS = /[*?[\]{}()]/ // git lists a linked folder as one file, and a fix through a link rewrites its target, so of the links -// only those to a file inside `cwd` are kept. +// only those to a file inside `cwd` and outside node_modules are kept. function checkableFile(path: Path.Path, cwd: string, realCwd: string): (file: string) => boolean { return (file) => { const absolute = path.resolve(cwd, file) @@ -205,8 +205,13 @@ function checkableFile(path: Path.Path, cwd: string, realCwd: string): (file: st } const target = realpathSync(absolute) + const relativeTarget = slashedRelative(path, realCwd, target) - return statSync(target).isFile() && !isOutside(path, slashedRelative(path, realCwd, target)) + return ( + statSync(target).isFile() && + !isOutside(path, relativeTarget) && + !inNodeModules(relativeTarget) + ) } catch { return false } diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index f5b2565..976e4f7 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -55,7 +55,7 @@ describe.each(LAYOUTS)( expect(project.read(`${app}dist/bundle.js`)).toBe('export var bundle = 1\n') }) - it('leaves installed packages that no ignore rule covers and links leaving the project alone', async () => { + it('leaves alone installed packages that no ignore rule covers, links into them and links leaving the project', async () => { const store = temporaryDirectory() writeFileSync(join(store, 'shared.ts'), UNFORMATTED) const dependency = `${app}lib/node_modules/dep/index.js` @@ -65,6 +65,7 @@ describe.each(LAYOUTS)( [`${app}src/extra.ts`]: 'export const extra = 1\n', }) .link(`${app}src/shared.ts`, join(store, 'shared.ts')) + .link(`${app}src/vendor.js`, '../lib/node_modules/dep/index.js') const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { args: ['--fix', '--only=oxlint', '--only=oxfmt'], diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index 362ddc5..0b0e352 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -152,6 +152,9 @@ describe('prepare in every package of a monorepo at once', () => { project.write({ [`${HOOK}.lock`]: '' }) const running = folders.map((folder) => prepare(project, [], { cwd: folder, env })) + onTestFinished(async () => { + await Promise.allSettled(running) + }) const endedEarly = Promise.race(running).then(({ stdout }) => { throw new Error(`A run ended while the lock was held:\n${stdout}`) }) diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index 4e5ab14..23a19ec 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -94,18 +94,21 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ } }) - it('checks a linked file inside the project but never a linked folder or a broken link, as the walk outside git', async () => { + it('checks a linked file inside the project but never a linked folder, a broken link or a link into node_modules, as the walk outside git', async () => { const store = temporaryDirectory() writeFileSync(join(store, 'vendor.ts'), CODE_WITH_VAR) const project = create({ [`${routes}/home.ts`]: CLEAN_CODE, [`${routes}/shared/util.ts`]: CODE_WITH_VAR, + 'node_modules/dep/index.ts': CODE_WITH_VAR, }) + project .link(`${routes}/alias.ts`, 'home.ts') .link(`${routes}/broken.ts`, 'missing.ts') .link(`${routes}/linked`, 'shared') .link(`${routes}/vendor`, store) .link(`${routes}/outside.ts`, join(store, 'vendor.ts')) + .link(`${routes}/installed.ts`, project.path('node_modules/dep/index.ts')) .commit() const check = await project.uncheck(['--only=oxlint', routes]) @@ -115,6 +118,7 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ expect(check.stdout).not.toContain(`${routes}/linked/`) expect(check.stdout).not.toContain(`${routes}/vendor/`) expect(check.stdout).not.toContain(`${routes}/outside.ts`) + expect(check.stdout).not.toContain(`${routes}/installed.ts`) expect(selectedReport(check.stdout)).toEqual([ `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`, @@ -131,6 +135,7 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ ) expect(project.read(`${routes}/shared/util.ts`)).toBe('const count = 1;\nexport { count };\n') expect(readFileSync(join(store, 'vendor.ts'), 'utf8')).toBe(CODE_WITH_VAR) + expect(project.read('node_modules/dep/index.ts')).toBe(CODE_WITH_VAR) }) it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index c07514f..04308a4 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -77,6 +77,8 @@ const PROJECT_MARKERS = [ '.oxlintrc.jsonc', 'oxlint.config.ts', 'oxlint.config.mts', + '.eslintignore', + '.gitignore', ] for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { @@ -175,9 +177,11 @@ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { TERM: 'xterm-256color', GIT_CEILING_DIRECTORIES: ROOT, GIT_CONFIG_GLOBAL: GIT_CONFIG, - // git still reads ~/.config/git/ignore and attributes when GIT_CONFIG_GLOBAL points elsewhere. + // git still reads ~/.config/git/ignore and attributes when GIT_CONFIG_GLOBAL points elsewhere, and + // /etc/gitattributes under GIT_CONFIG_NOSYSTEM. XDG_CONFIG_HOME: join(ROOT, 'config'), GIT_CONFIG_NOSYSTEM: '1', + GIT_ATTR_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'uncheck', GIT_AUTHOR_EMAIL: 'uncheck@example.com', GIT_COMMITTER_NAME: 'uncheck', From 97b3d0baffd7ae553ab91120f07e71d57722315d Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 08:53:28 +0700 Subject: [PATCH 23/25] fix(uncheck): agent hook re-arms its block once the checks pass, follows links, reads Copilot's sessionId and reports a repository git refuses --- packages/uncheck/README.md | 4 +- packages/uncheck/src/commands/hooks/run.ts | 84 +++++++++------- packages/uncheck/src/commands/prepare.ts | 5 +- packages/uncheck/src/commands/staged.ts | 7 +- packages/uncheck/src/commands/uncheck.ts | 10 +- packages/uncheck/src/git.ts | 5 + .../uncheck/tests/hooks/run/families.test.ts | 71 +++++++++++-- .../uncheck/tests/hooks/run/location.test.ts | 99 +++++++++++++++++++ packages/uncheck/tests/hooks/run/utils.ts | 6 +- 9 files changed, 233 insertions(+), 58 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 4f8a94b..21bc2d9 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -142,11 +142,11 @@ npx uncheck hooks install # or pick them from a list | Cursor | `cursor` | `.cursor/hooks.json` | | GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` | -Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back at most once per turn, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. +Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back again only once the checks have passed in between, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. - **sherif only reports** here, since its fixes reach beyond the agent's change: they move versions in other packages and run your install. Run `npx uncheck --fix` for them. - **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags. -- **Outside git** and before the first commit, the hook checks the whole folder, and oxlint also fixes the files that links in it point to. +- **Outside git** and before the first commit, the hook checks the whole folder, and oxlint also fixes the files that links in it point to. In a repository git refuses, such as one another user owns, the hook checks nothing and shows git's message. - **Installed from a package?** The hook finds uncheck only where your package manager can run it, so add uncheck to the workspace root too when the agent may work outside that package. - **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. diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 7aa2bc6..765cf3e 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -8,14 +8,15 @@ import { Command, Flag } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' import { listChangedFiles } from '../../files' -import { git } from '../../git' +import { git, refusesRepository } from '../../git' import { captureLines } from '../../tool' -import { fixFlag, runChecks, selectionFlags } from '../uncheck' +import { fixFlag, followLinks, runChecks, selectionFlags } from '../uncheck' export const run = Command.make( 'run', { cwd: Flag.Directory('cwd', { mustExist: true }).pipe( + followLinks, Flag.optional, Flag.withDescription( "Directory to check. Defaults to the top of the git repository around the current directory, or around the agent's project (CLAUDE_PROJECT_DIR or CODEBUDDY_PROJECT_DIR) when the current directory is outside git or in a repository nested in the project's, and to the project itself outside git", @@ -81,7 +82,7 @@ export const run = Command.make( const continuing = payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) - const sessionId = payload.session_id ?? payload.conversation_id + const sessionId = payload.session_id ?? payload.sessionId ?? payload.conversation_id const marker = typeof sessionId === 'string' ? path.join( @@ -92,14 +93,17 @@ export const run = Command.make( ) : undefined - if (marker !== undefined && !continuing) { - yield* Effect.ignore(fs.remove(marker, { force: true })) + const forgetBlock = + marker === undefined ? Effect.void : Effect.ignore(fs.remove(marker, { force: true })) + + if (!continuing) { + yield* forgetBlock } const changed = yield* listChangedFiles(cwd) if (changed !== undefined && changed.files.length === 0 && changed.deleted.length === 0) { - return + return yield* forgetBlock } const [failed, lines] = yield* runChecks(changed?.files ?? [], { @@ -119,14 +123,15 @@ export const run = Command.make( yield* Console.error(report) if (!failed) { - return + return yield* forgetBlock } - // 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, 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. + // Send the agent back once until the checks pass again, in the way its family understands. Claude + // Code and 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 reason = `uncheck found problems, fix them before finishing:\n\n${report}` const sendBack = typeof (payload.stopReason ?? payload.stop_reason) === 'string' @@ -178,37 +183,46 @@ interface Repository { readonly commonDir: string } -const repositoryAround = Effect.fn( - function* (folder: string) { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path +const repositoryAround = Effect.fn(function* (folder: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path - const [top, commonDir] = yield* Effect.all( - [ - git(folder, ['rev-parse', '--show-toplevel']), - git(folder, ['rev-parse', '--git-common-dir']), - ], - { concurrency: 'unbounded' }, - ) + const [physicalFolder, top, commonDir] = yield* Effect.all( + [ + fs.realPath(folder), + git(folder, ['rev-parse', '--show-toplevel']), + git(folder, ['rev-parse', '--git-common-dir']), + ], + { concurrency: 'unbounded' }, + ) - // git prints the common dir relative to `folder`, which may run through a link. - const repository: Repository = { - top, - commonDir: yield* fs.realPath(path.resolve(folder, commonDir)), - } + // git prints the common dir relative to the real path of `folder`, and a linked worktree may name + // it through a link. + const repository: Repository = { + top, + commonDir: yield* fs.realPath(path.resolve(physicalFolder, commonDir)), + } - return repository - }, - Effect.orElseSucceed(() => undefined), -) + return repository +}) + +// Checking a repository git refuses as if it were outside git would fix every file in the folder. +const repositoryUnlessRefused = (folder: string) => + repositoryAround(folder).pipe( + Effect.catch((error) => + error._tag === 'GitFailed' && refusesRepository(error) + ? userError(error.stderr) + : Effect.succeed(undefined), + ), + ) const topToCheck = Effect.fn(function* (start: string, projectDir: string | undefined) { const path = yield* Path.Path const [here, project] = yield* Effect.all( [ - repositoryAround(start), - projectDir === undefined ? Effect.succeed(undefined) : repositoryAround(projectDir), + repositoryUnlessRefused(start), + projectDir === undefined ? Effect.succeed(undefined) : repositoryUnlessRefused(projectDir), ], { concurrency: 'unbounded' }, ) @@ -235,7 +249,7 @@ const topToCheck = Effect.fn(function* (start: string, projectDir: string | unde // A repository whose core.worktree is a subfolder names that subfolder as its top from above it // too, so climbing from its top alone would ask about the same folder forever. folder = path.dirname(path.relative(outer.top, folder).startsWith('..') ? folder : outer.top) - outer = yield* repositoryAround(folder) + outer = yield* repositoryAround(folder).pipe(Effect.orElseSucceed(() => undefined)) } return outer?.commonDir === project.commonDir ? outer.top : here.top diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 4ee241e..9c94233 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -5,7 +5,7 @@ import { Command, Flag } from 'effect/unstable/cli' import { platformMessage, userError } from '../errors' import { readTextIfExists } from '../files' -import { git, gitLocation } from '../git' +import { git, gitLocation, refusesRepository } from '../git' import { detectExec, invokes } from '../pm' import { bold, dim, green, red } from '../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck' @@ -184,8 +184,7 @@ export const prepare = Command.make( if (Result.isFailure(repository)) { const { failure } = repository - // git translates its messages, but never the name of a setting. - if (failure._tag === 'GitFailed' && failure.stderr.includes('safe.directory')) { + if (failure._tag === 'GitFailed' && refusesRepository(failure)) { const reason = failure.stderr.split('\n')[0]!.replace(/^fatal: /, '') return yield* Console.log( diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 564d531..359874e 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 { inNodeModules } from '../files' -import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff } from '../git' +import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff, refusesRepository } from '../git' import { dim, green, listFiles, red } from '../style' import { argvBatches } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' @@ -48,10 +48,9 @@ export const staged = Command.make( prefix, paths: [folder, indexLock], } = yield* gitLocation(cwd, ['uncheck-unstaged', 'index.lock']).pipe( - Effect.catchTag('GitFailed', ({ stderr }) => - // The setting git names is never translated, unlike the rest of its message. + Effect.catchTag('GitFailed', (error) => userError( - stderr.includes('safe.directory') ? stderr : '`uncheck staged` needs a git repository', + refusesRepository(error) ? error.stderr : '`uncheck staged` needs a git repository', ), ), ) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index fc3b10b..24f60b5 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -26,11 +26,13 @@ const FIXES_DONE_AFTER = CHECKS.reduce( 0, ) +// git runs in the folder a symlink points to and prints its paths relative to that folder. +export const followLinks = Flag.mapEffect((directory: string) => + FileSystem.FileSystem.use((fs) => fs.realPath(directory)).pipe(Effect.orDie), +) + export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( - // git runs in the folder a symlink points to and prints its paths relative to that folder. - Flag.mapEffect((directory) => - FileSystem.FileSystem.use((fs) => fs.realPath(directory)).pipe(Effect.orDie), - ), + followLinks, Flag.withDefault(Effect.sync(() => process.cwd())), Flag.withDescription('Directory to run in. Defaults to the current one'), ) diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index cdd3ce8..977f76d 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -24,6 +24,11 @@ export class GitFailed extends Data.TaggedError('GitFailed')<{ } } +export function refusesRepository(error: GitFailed): boolean { + // git translates its messages, but never the name of a setting. + return error.stderr.includes('safe.directory') +} + /** * Runs git in `cwd` and returns the bytes it printed. A non-zero exit fails with `GitFailed` * carrying stderr, where git explains itself; running outside a repository is one such failure. diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index b90958d..615a87e 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -102,7 +102,7 @@ describe.each(LAYOUTS)( expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') }) - it('blocks Claude Code once per turn, even after another hook continued it', async () => { + it('blocks Claude Code again only after its checks passed, even when another hook continued the turn', async () => { const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) const continued = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) @@ -127,21 +127,45 @@ describe.each(LAYOUTS)( project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42";\n' }) - const passingTurn = await stopHook(project, app, CLAUDE_CODE_STOP) + const passing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(passingTurn.exitCode).toBe(0) - expect(passingTurn.stdout).toBe('') - expect(report(passingTurn.stderr)).toEqual( + expect(passing.exitCode).toBe(0) + expect(passing.stdout).toBe('') + expect(report(passing.stderr)).toEqual( checks(project, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)'), ) project.write({ [`${app}src/index.ts`]: TYPE_ERROR }) - const continuedAfterPassing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + const failingAfterPassing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(continuedAfterPassing.exitCode).toBe(2) - expect(continuedAfterPassing.stdout).toBe('') - expect(report(continuedAfterPassing.stderr)).toEqual(failure(project)) + expect(failingAfterPassing.exitCode).toBe(2) + expect(failingAfterPassing.stdout).toBe('') + expect(report(failingAfterPassing.stderr)).toEqual(failure(project)) + + const stillFailing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(stillFailing.exitCode).toBe(0) + expect(JSON.parse(stillFailing.stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(report(stillFailing.stderr)).toEqual(failure(project)) + + project.git('checkout', '--', '.') + + const reverted = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(reverted.exitCode).toBe(0) + expect(reverted.stdout).toBe('') + expect(reverted.stderr).toBe('') + + project.write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const failingAfterReverting = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(failingAfterReverting.exitCode).toBe(2) + expect(failingAfterReverting.stdout).toBe('') + expect(report(failingAfterReverting.stderr)).toEqual(failure(project)) }) it('takes a continued turn without a session id as one uncheck already blocked', async () => { @@ -192,6 +216,35 @@ describe.each(LAYOUTS)( }) }) + it('blocks Copilot once per turn, even after another hook continued it', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + const continuedStop = { ...COPILOT_AGENT_STOP, stop_hook_active: true } + + const continued = await stopHook(project, app, continuedStop) + + expect(continued.exitCode).toBe(0) + expect(report(continued.stderr)).toEqual(failure(project)) + expect(JSON.parse(continued.stdout)).toEqual({ + decision: 'block', + reason: sendBackReason(continued.stderr), + }) + + const again = await stopHook(project, app, continuedStop) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe('') + expect(report(again.stderr)).toEqual(failure(project)) + + const nextTurn = await stopHook(project, app, COPILOT_AGENT_STOP) + + expect(nextTurn.exitCode).toBe(0) + expect(report(nextTurn.stderr)).toEqual(failure(project)) + expect(JSON.parse(nextTurn.stdout)).toEqual({ + decision: 'block', + reason: sendBackReason(nextTurn.stderr), + }) + }) + it('blocks Copilot with a decision, whichever payload format it sends', async () => { const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index 5fa0a22..a74ec03 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -6,6 +6,7 @@ import { Project, cliError, git, + gitConfig, linkedWorktree, monorepo, report, @@ -78,6 +79,33 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, }, ) + it('reports a repository git refuses instead of checking the whole folder', async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + const refused = { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' } + + const fromInside = await stopHook(project, app, CLAUDE_CODE_STOP, { + cwd: `${app}src`, + env: refused, + }) + const fromOutside = await run([...CLI, 'hooks', 'run', '--fix', ...dirFlags(app)], { + cwd: temporaryDirectory(), + env: hookEnv({ ...refused, CLAUDE_PROJECT_DIR: project.path(app) }), + input: JSON.stringify(CLAUDE_CODE_STOP), + }) + + for (const { exitCode, stdout, stderr } of [fromInside, fromOutside]) { + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(project.normalize(stderr)).toContain( + cliError("fatal: detected dubious ownership in repository at ''").trimEnd(), + ) + expect(stderr).toContain('safe.directory') + expect(report(stderr)).toEqual([]) + } + + expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) + }) + it.each(['CLAUDE_PROJECT_DIR', 'CODEBUDDY_PROJECT_DIR'])( 'checks the project in %s outside git, from the folder the agent moved to', async (variable) => { @@ -261,6 +289,77 @@ describe('hooks run in a monorepo', () => { }) }) +describe('hooks run through links into a package', () => { + it('checks the package a --cwd link points to, with and without --dir', async () => { + const project = monorepo().write({ 'packages/app/src/extra.ts': UNFORMATTED }) + const outside = new Project(temporaryDirectory()).link('app', project.path('packages/app')) + + for (const dir of [['--dir=packages/app'], []]) { + const { exitCode, stdout, stderr } = await run( + [...CLI, 'hooks', 'run', '--only=oxfmt', `--cwd=${outside.path('app')}`, ...dir], + { cwd: outside.dir, env: hookEnv(), input: JSON.stringify(CLAUDE_CODE_STOP) }, + ) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(project.path('packages/app'))) + } + }) + + it("checks the agent's project named through a link from a repository nested in it", async () => { + const project = monorepo().write({ 'vendor/nested/README.md': '# Nested\n' }) + const outside = new Project(temporaryDirectory()).link('app', project.path('packages/app')) + + git(project.path('vendor/nested'), ['init', '--quiet']) + project.write({ 'packages/app/src/extra.ts': UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook( + project, + 'packages/app/', + CLAUDE_CODE_STOP, + { + args: ['--only=oxfmt'], + cwd: 'vendor/nested', + env: { CLAUDE_PROJECT_DIR: outside.path('app') }, + }, + ) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(project.path('packages/app'))) + }) +}) + +describe('hooks run in a repository nested in one git refuses', () => { + it('checks the repository the agent moved to when the project is another one', async () => { + const project = singleRepo() + const outer = temporaryDirectory() + const nested = new Project(join(outer, 'nested')) + .write({ '.gitignore': 'node_modules\n' }) + .link('node_modules', project.path('node_modules')) + + git(outer, ['init', '--quiet']) + git(nested.dir, ['init', '--quiet']) + nested.commit('init').write({ 'src/extra.ts': UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(nested, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'src', + env: { + CLAUDE_PROJECT_DIR: project.dir, + GIT_TEST_ASSUME_DIFFERENT_OWNER: '1', + GIT_CONFIG_GLOBAL: gitConfig( + `[safe]\n\tdirectory = ${nested.dir}\n\tdirectory = ${project.dir}\n`, + ), + }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(oxfmtFailed(nested.dir)) + }) +}) + describe('hooks run in a repository whose work tree lies below its .git folder', () => { it("checks that repository when the agent's project is another one", async () => { const project = singleRepo() diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index b9f7395..f28b56f 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -16,7 +16,11 @@ export const CURSOR_STOP = { loop_count: 0, } -export const COPILOT_AGENT_STOP = { stopReason: 'end_turn' } +export const COPILOT_AGENT_STOP = { + sessionId: 'session', + stopReason: 'end_turn', + stop_hook_active: false, +} export const COPILOT_STOP_IN_CLAUDE_FORMAT = { hook_event_name: 'Stop', From fc1e0a2ca37bc4c492aca4a4efbdedfce892d4bc Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 09:07:46 +0700 Subject: [PATCH 24/25] docs(uncheck): links into node_modules, a repeated send-back, refused repositories in every command --- packages/uncheck/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f325382..48e4985 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -83,7 +83,7 @@ npx uncheck src '!src/generated' # a directory, minus a part of it npx uncheck '!**/*.gen.ts' # everything except some files ``` -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, but never links that point outside the directory uncheck runs in. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. +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, but never links that point outside the directory uncheck runs in or into `node_modules`. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. tsc then checks only the projects that include one of the files, and the projects that depend on those. sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among the files. @@ -142,7 +142,7 @@ npx uncheck hooks install # or pick them from a list | Cursor | `cursor` | `.cursor/hooks.json` | | GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` | -Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back again only once the checks have passed in between, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. +Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back again only after the checks have passed in between, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. - **sherif only reports** here, since its fixes reach beyond the agent's change: they move versions in other packages and run your install. Run `npx uncheck --fix` for them. - **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags. @@ -215,7 +215,7 @@ The presets need oxlint 1.70+, oxfmt 0.43+ and TypeScript 5.6+. The tsconfig pre **`uncheck dist` says "No files match".** git ignores that folder, so it holds no project files. You can still name an ignored file directly. -**`uncheck prepare` says "git refuses the repository".** git does not trust a repository another user owns, such as a checkout mounted into a container. Run the `safe.directory` command `git status` prints there, then `prepare` again. +**git refuses the repository ("detected dubious ownership").** git does not trust a repository another user owns, such as a checkout mounted into a container, so `prepare` writes no hook and `uncheck staged` and the agent hook check nothing. Run the `safe.directory` command `git status` prints there, then try again. **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. From 12831b916d3d8fcbc902d0335d5be0538609d0f7 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Fri, 2 Oct 2026 10:44:36 +0700 Subject: [PATCH 25/25] refactor(uncheck): simplify the review changes and plan tsc faster - the runner skips a check that plans no commands, replacing the guards in oxlint and oxfmt and the evenIfRequired flag; literal runs imply the fixes stay within the given files - shared helpers replace copies (fileKind, resolveFolders, isOutside, logLines, a batched git listing, one copyBack); rawDiff always ends with -- - the agent hook finds its repository with one git call and looks it up once when the agent works in its project folder - tsc reads each config and its extends chain once per run and asks git once which projects build in place, so planning a 300-package monorepo drops from about 2-5 s to under 0.2 s with the same plans - tests share their repeated constants and assertion helpers --- packages/uncheck/src/checks/oxfmt.ts | 8 - packages/uncheck/src/checks/oxlint.ts | 8 - packages/uncheck/src/checks/tsc.ts | 481 +++++++++++------- packages/uncheck/src/commands/hooks/run.ts | 97 ++-- packages/uncheck/src/commands/staged.ts | 48 +- packages/uncheck/src/commands/uncheck.ts | 34 +- packages/uncheck/src/errors.ts | 1 - packages/uncheck/src/files.ts | 30 +- packages/uncheck/src/git.ts | 2 + packages/uncheck/src/tool.ts | 4 + .../uncheck/tests/hooks/run/changes.test.ts | 28 +- .../uncheck/tests/hooks/run/families.test.ts | 60 +-- .../uncheck/tests/hooks/run/location.test.ts | 121 ++--- packages/uncheck/tests/hooks/run/utils.ts | 25 +- packages/uncheck/tests/prepare/commit.test.ts | 68 ++- .../tests/prepare/existing-hook.test.ts | 60 +-- .../uncheck/tests/prepare/failures.test.ts | 3 +- packages/uncheck/tests/prepare/utils.ts | 8 + packages/uncheck/tests/staged/basics.test.ts | 10 +- packages/uncheck/tests/staged/commit.test.ts | 43 +- .../uncheck/tests/staged/deletions.test.ts | 15 +- packages/uncheck/tests/staged/fix.test.ts | 32 +- .../uncheck/tests/staged/interrupt.test.ts | 22 +- packages/uncheck/tests/staged/merge.test.ts | 27 +- .../tests/staged/sparse-checkout.test.ts | 14 +- .../uncheck/tests/staged/unstaged.test.ts | 16 +- packages/uncheck/tests/staged/utils.ts | 31 +- packages/uncheck/tests/uncheck/cli.test.ts | 3 +- packages/uncheck/tests/uncheck/fix.test.ts | 16 +- packages/uncheck/tests/uncheck/output.test.ts | 25 +- .../uncheck/tests/uncheck/paths-tools.test.ts | 10 +- .../uncheck/tests/uncheck/presets.test.ts | 17 +- .../uncheck/tests/uncheck/project.test.ts | 26 +- .../uncheck/tests/uncheck/selection.test.ts | 14 +- packages/uncheck/tests/uncheck/sherif.test.ts | 8 +- packages/uncheck/tests/uncheck/tools.test.ts | 12 +- .../uncheck/tests/uncheck/tsc-extends.test.ts | 16 +- .../uncheck/tests/uncheck/tsc-inputs.test.ts | 29 +- .../tests/uncheck/tsc-references.test.ts | 125 ++--- .../tests/uncheck/tsc-workspace.test.ts | 24 +- packages/uncheck/tests/uncheck/tsc.test.ts | 113 ++-- packages/uncheck/tests/uncheck/utils.ts | 15 + packages/uncheck/tests/utils/project.ts | 34 +- 43 files changed, 906 insertions(+), 877 deletions(-) diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts index 7c952fc..be733cf 100644 --- a/packages/uncheck/src/checks/oxfmt.ts +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -14,14 +14,6 @@ export const oxfmt: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - // An empty list makes no batch, so the check would pass without running. Kept skipped when - // required, or `--require=oxfmt` would block every commit that only deletes files. - if (files?.length === 0) { - return yield* Effect.fail( - new NothingToCheck({ reason: 'only deleted files', unrelated: true, evenIfRequired: true }), - ) - } - // A folder with nothing oxfmt handles, or a given file it does not (a .txt file), is no failure. const args = [...(fix ? [] : ['--check']), '--no-error-on-unmatched-pattern'] diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts index 7305dfc..6b0fffa 100644 --- a/packages/uncheck/src/checks/oxlint.ts +++ b/packages/uncheck/src/checks/oxlint.ts @@ -14,14 +14,6 @@ export const oxlint: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - // An empty list makes no batch, so the check would pass without running. Kept skipped when - // required, or `--require=oxlint` would block every commit that only deletes files. - if (files?.length === 0) { - return yield* Effect.fail( - new NothingToCheck({ reason: 'only deleted files', unrelated: true, evenIfRequired: true }), - ) - } - const args = [ ...(fix ? ['--fix'] : []), // oxlint walks into node_modules unless an ignore file says not to, and a fix there rewrites diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index fddd5fd..441751a 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -1,13 +1,20 @@ import { posix } from 'node:path' -import { Effect, FileSystem, Option, Path, Predicate } from 'effect' +import { Cache, Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' import { CannotCheck, NothingToCheck } from '../errors' -import { ancestors, listProjectFiles, readJson } from '../files' +import { + ancestors, + fileKind, + isOutside, + listProjectFiles, + readJson, + resolveFolders, +} from '../files' import { git } from '../git' import type { Bin } from '../tool' -import { resolveBin } from '../tool' +import { argvBatches, resolveBin } from '../tool' import type { Check } from '../types' /** @@ -76,42 +83,55 @@ export const tsc: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) } + const uncovered = notCovered(typescript, files) + if (given !== undefined && given.length === 0) { - return yield* Effect.fail(notCovered(typescript, files)) + return yield* Effect.fail(uncovered) } // A config above the folder may cover far more than it, so even a full run selects by its files. const targets = given ?? (inherited === undefined ? undefined : checkable(yield* projectFiles)) + const tsconfigs = yield* cachedTsconfigs const references = yield* followReferences( inherited === undefined ? tracked : [...tracked, inherited], + tsconfigs, + ) + const inputs = new Map( + yield* Effect.forEach( + new Set([...references.keys(), ...tracked]), + (configPath) => + Effect.map( + loadTsconfigInputs(configPath, tsconfigs), + (loaded) => [configPath, loaded] as const, + ), + { concurrency: 'unbounded' }, + ), ) - const selected = - targets === undefined - ? tracked - : yield* selectTsconfigs( - references, - targets, - deleted.map((file) => path.resolve(cwd, file)), - ) - - const projectPaths = (yield* projectFiles).map((file) => path.resolve(cwd, file)) - const projects = - targets === undefined - ? selected - : [ - ...new Set([ - ...selected, - ...(yield* workspaceDependents( - [...references.keys()], - [...selected, ...(given ?? []).filter((file) => posix.extname(file) !== '.json')], - projectPaths.filter((file) => path.basename(file) === 'package.json'), - )), - ]), - ] + let projects: ReadonlyArray = tracked + + if (targets !== undefined) { + const selected = yield* selectTsconfigs( + references, + inputs, + targets, + deleted.map((file) => path.resolve(cwd, file)), + tsconfigs.realPath, + ) + const manifests = (yield* projectFiles) + .filter((file) => file === 'package.json' || file.endsWith('/package.json')) + .map((file) => path.resolve(cwd, file)) + const dependentPackages = yield* workspaceDependents( + [...references.keys()], + [...selected, ...(given ?? []).filter((file) => posix.extname(file) !== '.json')], + manifests, + ) + + projects = [...new Set([...selected, ...dependentPackages])] + } if (projects.length === 0) { - return yield* Effect.fail(notCovered(typescript, files)) + return yield* Effect.fail(uncovered) } if (typescript === undefined) { @@ -148,62 +168,19 @@ 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(targets === undefined ? references.keys() : projects) - - for (const configPath of affected) { - for (const dependent of dependents.get(configPath) ?? []) { - affected.add(dependent) - } - } - - const memberInputs = new Map( - yield* Effect.forEach( - members, - (configPath) => - Effect.map(loadTsconfigInputs(configPath), (inputs) => [configPath, inputs] as const), - { concurrency: 'unbounded' }, - ), + const dependents = reverseEdges(references.keys(), (configPath) => references.get(configPath)!) + const dependentsOf = (configPath: string) => dependents.get(configPath) ?? [] + const affected = reachable(targets === undefined ? references.keys() : projects, dependentsOf) + const buildsBesideSources = reachable( + yield* writingBesideSources(cwd, yield* projectFiles, members, inputs), + dependentsOf, ) - // `tsc -b` on these would write JavaScript next to the sources of a project, and Vite, for one, - // then loads a stale vite.config.js instead of vite.config.ts. Where git ignores that JavaScript, - // the project builds in place on purpose, and `-p` would read the declarations of its last build. - const buildsBesideSources = new Set( - yield* Effect.filter( - members, - (member) => { - const inputs = memberInputs.get(member)! - - return hasInputs(inputs) && inputs.emitsBesideSources - ? Effect.map(gitIgnoresOutput(cwd, member, inputs, projectPaths), (ignored) => !ignored) - : Effect.succeed(false) - }, - { concurrency: 'unbounded' }, - ), - ) - - for (const configPath of buildsBesideSources) { - for (const dependent of dependents.get(configPath) ?? []) { - buildsBesideSources.add(dependent) - } - } - - const extended = yield* extendedConfigs([...references.keys()]) - const standalone = yield* Effect.filter( + const standalone = yield* withoutSharedBases( + cwd, + yield* projectFiles, projects.filter((configPath) => !members.includes(configPath)), - (configPath) => - Effect.map(isSharedBase(configPath, extended, projectPaths), (shared) => !shared), - { concurrency: 'unbounded' }, + inputs, + tsconfigs.realPath, ) const built = new Set( members.filter( @@ -217,18 +194,18 @@ export const tsc: Check = { for (const member of members) { if (affected.has(member) && buildsBesideSources.has(member)) { - const inputs = memberInputs.get(member)! + const memberInputs = inputs.get(member)! // A solution without sources needs `-p` too, or a reference to a missing config passes. - checkedAlone.set(member, inputs.composite ? WITHOUT_BUILD_INFO : []) + checkedAlone.set(member, memberInputs.composite ? WITHOUT_BUILD_INFO : []) // `-p` reads a referenced project through the declarations on disk, which a fresh checkout // lacks and an earlier build left stale. for (const referencedConfig of references.get(member)!) { if ( - memberInputs.has(referencedConfig) && + references.has(referencedConfig) && !buildsBesideSources.has(referencedConfig) && - (hasInputs(inputs) || affected.has(referencedConfig)) + (hasInputs(memberInputs) || affected.has(referencedConfig)) ) { built.add(referencedConfig) } @@ -237,7 +214,7 @@ export const tsc: Check = { } if (built.size === 0 && checkedAlone.size === 0) { - return yield* Effect.fail(notCovered(typescript, files)) + return yield* Effect.fail(uncovered) } return [ @@ -254,7 +231,10 @@ export const tsc: Check = { }), } -const followReferences = Effect.fn(function* (configPaths: ReadonlyArray) { +const followReferences = Effect.fn(function* ( + configPaths: ReadonlyArray, + tsconfigs: Tsconfigs, +) { const references = new Map>() const queue = [...configPaths] @@ -262,7 +242,7 @@ const followReferences = Effect.fn(function* (configPaths: ReadonlyArray const configPath = queue.pop()! if (!references.has(configPath) && (yield* fileKind(configPath)) === 'File') { - const referencedConfigs = yield* readReferences(configPath) + const referencedConfigs = yield* readReferences(configPath, tsconfigs) references.set(configPath, referencedConfigs) queue.push(...referencedConfigs) @@ -288,32 +268,110 @@ function outputBeside(source: string): string | undefined { : `${source.slice(0, -extension.length)}${output}` } -const gitIgnoresOutput = Effect.fn(function* ( +// `tsc -b` on these would write JavaScript next to the sources of a project, and Vite, for one, +// then loads a stale vite.config.js instead of vite.config.ts. Where git ignores that JavaScript, +// the project builds in place on purpose, and `-p` would read the declarations of its last build. +const writingBesideSources = Effect.fn(function* ( cwd: string, + projectFiles: ReadonlyArray, + members: ReadonlyArray, + inputs: ReadonlyMap, +) { + const path = yield* Path.Path + const outputs = yield* Effect.forEach( + members.filter((member) => { + const memberInputs = inputs.get(member)! + + return hasInputs(memberInputs) && memberInputs.emitsBesideSources + }), + (member) => + Effect.map( + outputBesideAnInput(cwd, projectFiles, member, inputs.get(member)!), + (output) => [member, output] as const, + ), + { concurrency: 'unbounded' }, + ) + const written = outputs.map(([, output]) => output).filter(Predicate.isNotUndefined) + const ignored = yield* ignoredByGit(cwd, [ + ...argvBatches(written.filter((output) => !isOutside(path, output))), + // git refuses every path given along with one outside its repository or behind a link. + ...written.filter((output) => isOutside(path, output)).map((output) => [output]), + ]) + + return outputs + .filter(([, output]) => output === undefined || !ignored.has(output)) + .map(([member]) => member) +}) + +const outputBesideAnInput = Effect.fn(function* ( + cwd: string, + projectFiles: ReadonlyArray, configPath: string, inputs: TsconfigInputs, - projectPaths: ReadonlyArray, ) { const path = yield* Path.Path const configDir = path.dirname(configPath) const files = ancestors(path, configDir).includes(cwd) - ? projectPaths + ? projectFiles : (yield* listProjectFiles(configDir)).map((file) => path.resolve(configDir, file)) + const isInput = (file: string) => + outputBeside(file) !== undefined && includesFile(inputs, path.resolve(cwd, file)) + const relativeConfig = path.relative(cwd, configPath) + const input = + findInFolder(files, relativeConfig.slice(0, relativeConfig.lastIndexOf('/') + 1), isInput) ?? + files.find(isInput) + + return input === undefined ? undefined : outputBeside(input) +}) + +/** Needs `files` sorted, as listProjectFiles leaves them. */ +function findInFolder( + files: ReadonlyArray, + folder: string, + predicate: (file: string) => boolean, +): string | undefined { + let low = 0 + let high = files.length - for (const file of files) { - const output = outputBeside(file) + while (low < high) { + const middle = Math.floor((low + high) / 2) - if (output !== undefined && includesFile(inputs, file)) { - // check-ignore refuses the literal pathspecs `git` turns on. - return yield* Effect.isSuccess( - git(cwd, ['check-ignore', '--quiet', '--no-index', '--', output], { - GIT_LITERAL_PATHSPECS: '0', - }), - ) + if (files[middle]! < folder) { + low = middle + 1 + } else { + high = middle } } - return false + for (let index = low; index < files.length && files[index]!.startsWith(folder); index += 1) { + if (predicate(files[index]!)) { + return files[index] + } + } + + return undefined +} + +const ignoredByGit = Effect.fn(function* ( + cwd: string, + batches: ReadonlyArray>, +) { + const printed = yield* Effect.forEach( + batches, + (batch) => + // check-ignore refuses the literal pathspecs `git` turns on and exits with 1 when it ignores + // none. It takes `-z` only with `--stdin`, so it quotes a path holding a control character, + // `"` or `\`, which then matches no output. + git(cwd, ['-c', 'core.quotePath=false', 'check-ignore', '--no-index', '--', ...batch], { + GIT_LITERAL_PATHSPECS: '0', + }).pipe( + Effect.map((output) => output.split('\n')), + Effect.orElseSucceed(() => []), + ), + { concurrency: 'unbounded' }, + ) + + return new Set(printed.flat()) }) const tsconfigAboveInRepository = Effect.fn(function* (cwd: string) { @@ -331,39 +389,38 @@ const tsconfigAboveInRepository = Effect.fn(function* (cwd: string) { return yield* firstFile(searched.map((dir) => path.join(dir, 'tsconfig.json'))) }) -/** The configs other configs extend, by their real path, as an extends through a workspace link names the link. */ -const extendedConfigs = Effect.fn(function* (configPaths: ReadonlyArray) { - const chains = yield* Effect.forEach( - configPaths, - (configPath) => loadExtendsChain(configPath, new Set()), - { concurrency: 'unbounded' }, - ) - - return new Set( - yield* Effect.forEach( - chains.flatMap((chain) => chain.slice(0, -1)), - ({ file }) => realPath(file), - { concurrency: 'unbounded' }, - ), - ) -}) - /** * A shared base kept as a tsconfig.json, the way `@tsconfig/bases` packages ship theirs, has no * sources of its own: tsc fails it with TS18003, and the configs extending it are checked anyway. */ -const isSharedBase = Effect.fn(function* ( - configPath: string, - extended: ReadonlySet, - files: ReadonlyArray, +const withoutSharedBases = Effect.fn(function* ( + cwd: string, + projectFiles: ReadonlyArray, + configPaths: ReadonlyArray, + inputs: ReadonlyMap, + realPath: (file: string) => Effect.Effect, ) { - if (!extended.has(yield* realPath(configPath))) { - return false + if (configPaths.length === 0) { + return configPaths } - const inputs = yield* loadTsconfigInputs(configPath) + const path = yield* Path.Path + const bases = new Set([...inputs.values()].flatMap(({ configs }) => configs.slice(0, -1))) + const extended = new Set(yield* Effect.forEach(bases, realPath, { concurrency: 'unbounded' })) - return !files.some((file) => includesFile(inputs, file)) + return yield* Effect.filter( + configPaths, + (configPath) => + Effect.map( + realPath(configPath), + (real) => + !extended.has(real) || + projectFiles.some((file) => + includesFile(inputs.get(configPath)!, path.resolve(cwd, file)), + ), + ), + { concurrency: 'unbounded' }, + ) }) const DEPENDENCY_FIELDS = [ @@ -373,10 +430,13 @@ const DEPENDENCY_FIELDS = [ 'optionalDependencies', ] as const -/** - * The configs of the workspace packages that depend, directly or through others, on a package that - * owns one of `changed`: a package imported through its workspace link needs no project reference. - */ +interface WorkspacePackage { + readonly dir: string + readonly name: unknown + readonly dependencies: ReadonlyArray +} + +/** A package imported through its workspace link needs no project reference. */ const workspaceDependents = Effect.fn(function* ( candidates: ReadonlyArray, changed: ReadonlyArray, @@ -386,7 +446,7 @@ const workspaceDependents = Effect.fn(function* ( const packages = yield* Effect.forEach( manifests, (manifestPath) => - Effect.map(readJson(manifestPath), (manifest) => ({ + Effect.map(readJson(manifestPath), (manifest): WorkspacePackage => ({ dir: path.dirname(manifestPath), name: manifest?.name, dependencies: DEPENDENCY_FIELDS.flatMap((field) => { @@ -402,25 +462,16 @@ const workspaceDependents = Effect.fn(function* ( ancestors(path, path.dirname(file)).find((dir) => packageDirs.has(dir)) const owners = new Set(changed.map(ownerOf)) - const reachedNames = new Set( - packages.filter(({ dir }) => owners.has(dir)).map(({ name }) => name), + const dependents = reverseEdges( + packages, + ({ dependencies }) => dependencies, ) - const dependentDirs = new Set() - - for (let grown = true; grown;) { - grown = false - - for (const { dir, name, dependencies } of packages) { - if ( - !dependentDirs.has(dir) && - dependencies.some((dependency) => reachedNames.has(dependency)) - ) { - dependentDirs.add(dir) - reachedNames.add(name) - grown = true - } - } - } + const dependentsOf = ({ name }: WorkspacePackage) => dependents.get(name) ?? [] + const reached = reachable( + packages.filter(({ dir }) => owners.has(dir)).flatMap(dependentsOf), + dependentsOf, + ) + const dependentDirs = new Set([...reached].map(({ dir }) => dir)) return candidates.filter((configPath) => { const owner = ownerOf(configPath) @@ -428,6 +479,33 @@ const workspaceDependents = Effect.fn(function* ( }) }) +function reverseEdges(nodes: Iterable, edges: (node: T) => Iterable): Map { + const reversed = new Map() + + for (const node of nodes) { + for (const target of edges(node)) { + const sources = reversed.get(target) ?? [] + + sources.push(node) + reversed.set(target, sources) + } + } + + return reversed +} + +function reachable(seeds: Iterable, next: (node: T) => Iterable): Set { + const reached = new Set(seeds) + + for (const node of reached) { + for (const neighbour of next(node)) { + reached.add(neighbour) + } + } + + return reached +} + function findCycle( nodes: ReadonlyArray, references: ReadonlyMap>, @@ -455,8 +533,10 @@ function findCycle( const selectTsconfigs = Effect.fn(function* ( references: ReadonlyMap>, + inputs: ReadonlyMap, files: ReadonlyArray, deleted: ReadonlyArray, + realPath: (file: string) => Effect.Effect, ) { const jsonFiles = yield* Effect.forEach( files.filter((file) => posix.extname(file) === '.json'), @@ -466,20 +546,20 @@ const selectTsconfigs = Effect.fn(function* ( return yield* Effect.filter( [...references.keys()], - (candidate) => + (candidate) => { + const candidateInputs = inputs.get(candidate)! + // A deleted config is no candidate, so only the configs that reference it lead to its dependents. - references.get(candidate)!.some((config) => deleted.includes(config)) + return references.get(candidate)!.some((config) => deleted.includes(config)) || + files.some((file) => includesFile(candidateInputs, file)) ? Effect.succeed(true) - : 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)), - ), - ), + : jsonFiles.length === 0 + ? Effect.succeed(false) + : Effect.map( + Effect.forEach(candidateInputs.configs, realPath, { concurrency: 'unbounded' }), + (configs) => configs.some((config) => jsonFiles.includes(config)), + ) + }, { concurrency: 'unbounded' }, ) }) @@ -494,9 +574,7 @@ const realPath = Effect.fn(function* (file: string) { // A deleted file has no real path, while the folder it was in can still be reached through a link. return yield* fs.realPath(file).pipe( - Effect.catch(() => - Effect.map(fs.realPath(path.dirname(file)), (dir) => path.join(dir, path.basename(file))), - ), + Effect.catch(() => Effect.try(() => resolveFolders(path, file))), Effect.orElseSucceed(() => file), ) }) @@ -523,11 +601,44 @@ const readTsconfig = Effect.fn( Effect.orElseSucceed((): RawTsconfig => ({})), ) +const resolveBases = Effect.fn(function* (configPath: string, raw: RawTsconfig) { + const path = yield* Path.Path + const specs = + typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : [] + const bases = yield* Effect.forEach(specs, (spec) => + resolveExtends(spec.replaceAll('\\', '/'), path.dirname(configPath)), + ) + + return bases.flatMap(Option.toArray) +}) + +interface Tsconfigs { + readonly read: (configPath: string) => Effect.Effect + readonly bases: (configPath: string) => Effect.Effect> + readonly realPath: (file: string) => Effect.Effect +} + +function memoized(lookup: (key: string) => Effect.Effect) { + return Effect.map( + Cache.make({ capacity: Number.POSITIVE_INFINITY, lookup }), + (cache) => (key: string) => Cache.get(cache, key), + ) +} + +const cachedTsconfigs = Effect.gen(function* () { + const read = yield* memoized(readTsconfig) + const bases = yield* memoized((configPath: string) => + Effect.flatMap(read(configPath), (raw) => resolveBases(configPath, raw)), + ) + + return { read, bases, realPath: yield* memoized(realPath) } +}) + /** `references` are never inherited through `extends`, so only the file itself is read. */ -const readReferences = Effect.fn(function* (configPath: string) { +const readReferences = Effect.fn(function* (configPath: string, tsconfigs: Tsconfigs) { const path = yield* Path.Path const configDir = path.dirname(configPath) - const { references } = yield* readTsconfig(configPath) + const { references } = yield* tsconfigs.read(configPath) return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { if (!Predicate.isObject(reference) || typeof reference.path !== 'string') { @@ -551,7 +662,6 @@ interface TsconfigInputs { readonly include: ReadonlyArray readonly exclude: ReadonlyArray readonly extensions: ReadonlySet - /** Neither `noEmit`, `emitDeclarationOnly`, `outDir` nor `outFile` keeps the output away from the sources. */ readonly emitsBesideSources: boolean readonly composite: boolean } @@ -574,9 +684,9 @@ interface Specs { * `tsc` does: each field comes from the last config in the `extends` chain that sets it, relative * paths resolve against the config that set them, and `${configDir}` means the leaf config's folder. */ -const loadTsconfigInputs = Effect.fn(function* (configPath: string) { +const loadTsconfigInputs = Effect.fn(function* (configPath: string, tsconfigs: Tsconfigs) { const path = yield* Path.Path - const chain = yield* loadExtendsChain(configPath, new Set()) + const chain = yield* loadExtendsChain(configPath, new Set(), tsconfigs) const leafDir = path.dirname(configPath) const last: Partial< @@ -688,38 +798,19 @@ interface ChainEntry { const loadExtendsChain = Effect.fn(function* ( configPath: string, resolving: ReadonlySet, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + tsconfigs: Tsconfigs, +): Effect.fn.Return, never, Path.Path> { if (resolving.has(configPath)) { return [] } const path = yield* Path.Path - const raw = yield* readTsconfig(configPath) - const dir = path.dirname(configPath) - const specs = - typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : [] - - const bases = yield* Effect.forEach(specs, (spec) => - resolveExtends(spec.replaceAll('\\', '/'), dir).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed>([]), - onSome: (base) => loadExtendsChain(base, new Set(resolving).add(configPath)), - }), - ), - ), + const raw = yield* tsconfigs.read(configPath) + const chains = yield* Effect.forEach(yield* tsconfigs.bases(configPath), (base) => + loadExtendsChain(base, new Set(resolving).add(configPath), tsconfigs), ) - return [...bases.flat(), { file: configPath, dir, raw }] -}) - -const fileKind = Effect.fn(function* (target: string) { - const fs = yield* FileSystem.FileSystem - - return yield* fs.stat(target).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => undefined), - ) + return [...chains.flat(), { file: configPath, dir: path.dirname(configPath), raw }] }) const firstFile = (candidates: ReadonlyArray) => diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 765cf3e..ee629f2 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -7,7 +7,7 @@ import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } f import { Command, Flag } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' -import { listChangedFiles } from '../../files' +import { fileKind, isOutside, listChangedFiles, slashedRelative } from '../../files' import { git, refusesRepository } from '../../git' import { captureLines } from '../../tool' import { fixFlag, followLinks, runChecks, selectionFlags } from '../uncheck' @@ -64,17 +64,10 @@ export const run = Command.make( 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)) { - const isFolder = yield* fs.stat(cwd).pipe( - Effect.map((info) => info.type === 'Directory'), - Effect.orElseSucceed(() => false), + if (Option.isSome(dir) && (yield* fileKind(cwd)) !== 'Directory') { + return yield* userError( + `--dir=${dir.value} names no folder in ${top}, run \`uncheck hooks install\` again from the project`, ) - - if (!isFolder) { - return yield* userError( - `--dir=${dir.value} names no folder in ${top}, run \`uncheck hooks install\` again from the project`, - ) - } } // Agents also set these when another Stop hook (Claude Code's /goal is one) continued the turn, so @@ -82,19 +75,12 @@ export const run = Command.make( const continuing = payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) - const sessionId = payload.session_id ?? payload.sessionId ?? payload.conversation_id - const marker = - typeof sessionId === 'string' - ? path.join( - tmpdir(), - `uncheck-stop-${createHash('sha256') - .update(JSON.stringify([sessionId, path.resolve(cwd)])) - .digest('hex')}`, - ) - : undefined - - const forgetBlock = - marker === undefined ? Effect.void : Effect.ignore(fs.remove(marker, { force: true })) + const { forgetBlock, claimBlock } = stopMarker( + fs, + path, + payload.session_id ?? payload.sessionId ?? payload.conversation_id, + cwd, + ) if (!continuing) { yield* forgetBlock @@ -110,7 +96,6 @@ export const run = Command.make( ...settings, cwd, literal: true, - fixesWithinFiles: true, deleted: changed?.deleted, }).pipe( Effect.map(() => false), @@ -146,14 +131,7 @@ export const run = Command.make( return } - // A marker that cannot be written counts as one that exists, so a broken temporary folder never - // sends the agent back on every stop. - const firstBlock = - marker !== undefined && - (yield* fs.writeFileString(marker, '', { flag: 'wx' }).pipe( - Effect.as(true), - Effect.orElseSucceed(() => false), - )) + const firstBlock = yield* claimBlock if (continuing && !firstBlock) { if (payload.hook_event_name === 'Stop') { @@ -178,6 +156,34 @@ export const run = Command.make( ), ) +function stopMarker( + fs: FileSystem.FileSystem, + path: Path.Path, + sessionId: unknown, + cwd: string, +): { readonly forgetBlock: Effect.Effect; readonly claimBlock: Effect.Effect } { + if (typeof sessionId !== 'string') { + return { forgetBlock: Effect.void, claimBlock: Effect.succeed(false) } + } + + const marker = path.join( + tmpdir(), + `uncheck-stop-${createHash('sha256') + .update(JSON.stringify([sessionId, path.resolve(cwd)])) + .digest('hex')}`, + ) + + return { + forgetBlock: Effect.ignore(fs.remove(marker, { force: true })), + // A marker that cannot be written counts as one that exists, so a broken temporary folder never + // sends the agent back on every stop. + claimBlock: fs.writeFileString(marker, '', { flag: 'wx' }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + } +} + interface Repository { readonly top: string readonly commonDir: string @@ -187,14 +193,14 @@ const repositoryAround = Effect.fn(function* (folder: string) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const [physicalFolder, top, commonDir] = yield* Effect.all( - [ - fs.realPath(folder), - git(folder, ['rev-parse', '--show-toplevel']), - git(folder, ['rev-parse', '--git-common-dir']), - ], + const [physicalFolder, output] = yield* Effect.all( + [fs.realPath(folder), git(folder, ['rev-parse', '--show-toplevel', '--git-common-dir'])], { concurrency: 'unbounded' }, ) + // A newline in the top shifts the lines git prints, so the common dir is counted from the end. + const lines = output.split('\n') + const commonDir = lines.pop()! + const top = lines.join('\n') // git prints the common dir relative to the real path of `folder`, and a linked worktree may name // it through a link. @@ -219,13 +225,18 @@ const repositoryUnlessRefused = (folder: string) => const topToCheck = Effect.fn(function* (start: string, projectDir: string | undefined) { const path = yield* Path.Path - const [here, project] = yield* Effect.all( + const startsInProject = + projectDir !== undefined && path.resolve(projectDir) === path.resolve(start) + const [here, elsewhere] = yield* Effect.all( [ repositoryUnlessRefused(start), - projectDir === undefined ? Effect.succeed(undefined) : repositoryUnlessRefused(projectDir), + projectDir === undefined || startsInProject + ? Effect.succeed(undefined) + : repositoryUnlessRefused(projectDir), ], { concurrency: 'unbounded' }, ) + const project = startsInProject ? here : elsewhere if (project === undefined) { return here?.top ?? projectDir ?? start @@ -248,7 +259,9 @@ const topToCheck = Effect.fn(function* (start: string, projectDir: string | unde ) { // A repository whose core.worktree is a subfolder names that subfolder as its top from above it // too, so climbing from its top alone would ask about the same folder forever. - folder = path.dirname(path.relative(outer.top, folder).startsWith('..') ? folder : outer.top) + folder = path.dirname( + isOutside(path, slashedRelative(path, outer.top, folder)) ? folder : outer.top, + ) outer = yield* repositoryAround(folder).pipe(Effect.orElseSucceed(() => undefined)) } diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 359874e..f89365e 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -7,7 +7,7 @@ import { userError } from '../errors' import { inNodeModules } from '../files' import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff, refusesRepository } from '../git' import { dim, green, listFiles, red } from '../style' -import { argvBatches } from '../tool' +import { argvBatches, logLines } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' /** What became of the unstaged hunks that were set aside while the checks ran. */ @@ -61,7 +61,7 @@ export const staged = Command.make( { concurrency: 'unbounded' }, ) // Fixing what a merge takes from the other side would commit changes neither side made. - const theirs = merging ? yield* stagedFiles(cwd, 'MERGE_HEAD', '--') : undefined + const theirs = merging ? yield* stagedFiles(cwd, 'MERGE_HEAD') : undefined const notTheirs = theirs && new Set([...theirs.files, ...theirs.deleted]) const ours = (paths: ReadonlyArray) => notTheirs === undefined ? paths : paths.filter((file) => notTheirs.has(file)) @@ -130,7 +130,6 @@ export const staged = Command.make( cwd, fix, literal: true, - fixesWithinFiles: true, deleted, afterFixes: fix ? stageFixes : undefined, holdSummary: (lines) => Ref.set(summary, lines), @@ -155,13 +154,7 @@ export const staged = Command.make( return { failure, empty: after === head } }), - ).pipe( - Effect.ensuring( - Effect.flatMap(Ref.get(summary), (lines) => - Effect.forEach(lines, (line) => Console.log(line), { discard: true }), - ), - ), - ) + ).pipe(Effect.ensuring(Effect.flatMap(Ref.get(summary), logLines))) const { conflicted, stranded } = yield* Ref.get(outcome) @@ -275,25 +268,24 @@ interface SetAside { readonly base: string } -/** Of `files`, those whose working tree differs from the index. */ -function differFromIndex(cwd: string, files: ReadonlyArray) { +function gitPathsEach(cwd: string, args: ReadonlyArray, files: ReadonlyArray) { return Effect.map( - Effect.forEach(argvBatches(files), (batch) => - gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', '--', ...batch]), - ), + Effect.forEach(argvBatches(files), (batch) => gitPaths(cwd, [...args, '--', ...batch])), (batches) => batches.flat(), ) } +function differFromIndex(cwd: string, files: ReadonlyArray) { + return gitPathsEach(cwd, ['diff', '--name-only', '--relative', '-z'], files) +} + /** `,,` of each of `files` in the index, as `update-index --cacheinfo` takes it. */ function indexEntries(cwd: string, files: ReadonlyArray) { + // `--cacheinfo` reads paths from the top of the repository, wherever git runs. return Effect.map( - Effect.forEach(argvBatches(files), (batch) => - // `--cacheinfo` reads paths from the top of the repository, wherever git runs. - gitPaths(cwd, ['ls-files', '--stage', '--full-name', '-z', '--', ...batch]), - ), - (batches) => - batches.flat().map((entry) => { + gitPathsEach(cwd, ['ls-files', '--stage', '--full-name', '-z'], files), + (entries) => + entries.map((entry) => { const [mode, id] = entry.split(' ') return `${mode},${id},${entry.slice(entry.indexOf('\t') + 1)}` @@ -303,7 +295,6 @@ function indexEntries(cwd: string, files: ReadonlyArray) { const CACHEINFO = '--cacheinfo' -/** Stages the given index entries as they are, whatever the working tree holds now. */ function stageEntries( cwd: string, entries: ReadonlyArray, @@ -329,6 +320,12 @@ function gitEach(cwd: string, args: ReadonlyArray, files: ReadonlyArray< }) } +function copyBack(entries: ReadonlyArray<{ readonly target: string; readonly copy: string }>) { + return FileSystem.FileSystem.use((fs) => + Effect.forEach(entries, ({ target, copy }) => fs.copyFile(copy, target), { discard: true }), + ) +} + const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -337,9 +334,6 @@ const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: Read 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( @@ -365,7 +359,7 @@ const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: Read ), ).pipe( Effect.tapError(() => - restore.pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore), + copyBack(copies).pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore), ), ) yield* Console.log( @@ -389,8 +383,6 @@ const putBack = Effect.fn(function* ( const fs = yield* FileSystem.FileSystem const { cwd, saved } = aside const partial = copies.map(({ file }) => file) - const copyBack = (entries: ReadonlyArray) => - Effect.forEach(entries, ({ target, copy }) => fs.copyFile(copy, target), { discard: true }) const restored = Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)).pipe( Effect.as(RESTORED), ) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 24f60b5..1d09a59 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -16,7 +16,7 @@ import { CheckFailed, platformMessage, userError } from '../errors' import { checkableFiles, listProjectFiles, resolvePaths } from '../files' import type { GitFailed } from '../git' import { bold, dim, green, listFiles, red } from '../style' -import { captureLines, execute } from '../tool' +import { captureLines, execute, logLines } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc] @@ -77,19 +77,18 @@ export interface RunSettings extends CheckSelection { readonly cwd: string readonly fix: boolean readonly allowUnmatched?: boolean - /** The paths are file names from git: never patterns, and a run where none has anything to check passes. */ + /** + * The paths are file names from git, a change the hooks fix: never patterns, a run where none has + * anything to check passes, and only the fixes that stay within them apply. + */ readonly literal?: boolean - /** Only the fixes that stay within the given files apply. */ - readonly fixesWithinFiles?: boolean - /** Paths the change deletes. Only for `literal` runs: without paths, any other run checks everything. */ + /** Only for `literal` runs: without paths, any other run checks everything. */ readonly deleted?: ReadonlyArray - /** Runs once the checks that can fix files are done, before the others start. */ readonly afterFixes?: Effect.Effect< void, GitFailed | PlatformError.PlatformError, ChildProcessSpawner.ChildProcessSpawner > - /** Takes the closing summary instead of printing it, for a caller that prints it last. */ readonly holdSummary?: (lines: ReadonlyArray) => Effect.Effect } @@ -165,12 +164,10 @@ export const checkPaths = Effect.fn(function* ( literal = false, deleted = [], } = settings - const appliesFixes = (fixes: Check['fixes']) => - settings.fixesWithinFiles === true ? fixes === 'files' : fixes !== false + const appliesFixes = (fixes: Check['fixes']) => (literal ? fixes === 'files' : fixes !== false) const { cwd } = settings const summarize = (lines: ReadonlyArray) => - settings.holdSummary?.(lines) ?? - Effect.forEach(lines, (line) => Console.log(line), { discard: true }) + settings.holdSummary?.(lines) ?? logLines(lines) const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) let files: ReadonlyArray | undefined @@ -207,11 +204,18 @@ export const checkPaths = Effect.fn(function* ( } return plan({ cwd, fix: fix && appliesFixes(fixes), files, deleted, projectFiles }).pipe( - Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), - Effect.catchTag('NothingToCheck', ({ reason, unrelated, evenIfRequired }) => + // A plan without commands, as oxlint and oxfmt make for a change that only deletes files, + // would pass without running anything. It stays skipped when required, or + // `--require=oxlint` would block every such commit. + Effect.map((commands): CheckPlan => + commands.length === 0 + ? { name, status: 'skipped', reason: 'only deleted files', unrelated: true } + : { name, status: 'run', commands }, + ), + Effect.catchTag('NothingToCheck', ({ reason, unrelated }) => Effect.succeed({ name, - status: required.includes(name) && evenIfRequired !== true ? 'failed' : 'skipped', + status: required.includes(name) ? 'failed' : 'skipped', reason, unrelated, }), @@ -326,7 +330,7 @@ const runCommands = Effect.fn(function* (commands: ReadonlyArray, for (const fiber of fibers) { const [exitCode, lines] = yield* Fiber.join(fiber) - yield* Effect.forEach(lines, (line) => Console.log(line), { discard: true }) + yield* logLines(lines) exitCodes.push(exitCode) } diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index f97f6eb..fad8957 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -8,7 +8,6 @@ export class NothingToCheck extends Data.TaggedError('NothingToCheck')<{ readonly reason: string /** Lets a run on given files pass, so never for a check that is not set up. */ readonly unrelated?: boolean - readonly evenIfRequired?: boolean }> {} export class CannotCheck extends Data.TaggedError('CannotCheck')<{ diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 3d8cf50..a86170d 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -34,7 +34,7 @@ export function inNodeModules(file: string): boolean { return /(?:^|\/)node_modules(?:\/|$)/.test(file) } -export interface ChangedFiles { +interface ChangedFiles { readonly files: ReadonlyArray readonly deleted: ReadonlyArray } @@ -49,11 +49,7 @@ export function listChangedFiles( cwd: string, ): Effect.Effect { return Effect.all( - [ - // Without `--`, a file named HEAD makes the revision ambiguous and git fails. - rawDiff(cwd, 'HEAD', '--'), - gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), - ], + [rawDiff(cwd, 'HEAD'), gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])], { concurrency: 'unbounded' }, ).pipe( Effect.map(([tracked, untracked]) => { @@ -77,7 +73,6 @@ export const resolvePaths = Effect.fn(function* ( cwd: string, projectFiles: ProjectFiles, ) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const realCwd = realpathSync(cwd) const isCheckable = checkableFile(path, cwd, realCwd) @@ -118,10 +113,7 @@ export const resolvePaths = Effect.fn(function* ( } // An existing path is taken as it is, so `app/[id].ts` names that file rather than a glob. - const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => undefined), - ) + const kind = yield* fileKind(path.resolve(cwd, pattern)) if (kind === 'File') { matched.add(target) @@ -181,7 +173,6 @@ export const resolvePaths = Effect.fn(function* ( return { files: files.sort(), unmatched } }) -/** The given files the tools may get, the way a directory or glob keeps them. */ export const checkableFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { const path = yield* Path.Path const isCheckable = checkableFile(path, cwd, realpathSync(cwd)) @@ -218,17 +209,17 @@ function checkableFile(path: Path.Path, cwd: string, realCwd: string): (file: st } } -function isOutside(path: Path.Path, relative: string): boolean { +export function isOutside(path: Path.Path, relative: string): boolean { return relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) } -function slashedRelative(path: Path.Path, from: string, to: string): string { +export function slashedRelative(path: Path.Path, from: string, to: string): string { // A backslash is a glob escape on POSIX, never a separator. return path.relative(from, to).split(path.sep).join('/') } /** `file` with every linked folder on its way resolved. A linked file keeps its own name. */ -function resolveFolders(path: Path.Path, file: string): string { +export function resolveFolders(path: Path.Path, file: string): string { const rest: string[] = [] let folder = file @@ -272,6 +263,15 @@ export function ancestors(path: Path.Path, from: string): string[] { return dirs } +export const fileKind = Effect.fn(function* (target: string) { + const fs = yield* FileSystem.FileSystem + + return yield* fs.stat(target).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => undefined), + ) +}) + export const readJson = Effect.fn( function* (file: string) { const fs = yield* FileSystem.FileSystem diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index 977f76d..0e3ce6d 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -95,6 +95,8 @@ export function rawDiff(cwd: string, ...args: ReadonlyArray) { '--relative', '-z', ...args, + // Without it, a file named like a revision, such as HEAD, makes git fail as ambiguous. + '--', ]), (output) => { const fields = output.split('\0') diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index d035af0..d66bea1 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -99,6 +99,10 @@ export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckComm return yield* handle.exitCode }, Effect.scoped) +export function logLines(lines: ReadonlyArray): Effect.Effect { + return Effect.forEach(lines, (line) => Console.log(line), { discard: true }) +} + export function captureLines( effect: Effect.Effect, ): Effect.Effect], E, R> { diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index 976e4f7..6cc47c8 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -2,8 +2,18 @@ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS, monorepo, report, singleRepo, temporaryDirectory } from '../../utils/project' -import { CLAUDE_CODE_STOP, stopHook, UTILS_NOT_FOUND } from './utils' +import { + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + monorepo, + report, + singleRepo, + SKIPPED_FOR_DELETIONS, + temporaryDirectory, + UTILS_NOT_FOUND, +} from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook } from './utils' const UNFORMATTED = 'export const legacy = 1\n' @@ -100,9 +110,7 @@ describe.each(LAYOUTS)( expect(deleted.stdout).toBe('') expect(report(deleted.stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, - '○ sherif skipped, no package.json among the given files', - '○ oxlint skipped, only deleted files', - '○ oxfmt skipped, only deleted files', + ...SKIPPED_FOR_DELETIONS, tsc, '✔ tsc passed', '✔ all checks passed (tsc)', @@ -165,9 +173,9 @@ describe.each(LAYOUTS)( expect(report(stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not a workspace root', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -190,7 +198,7 @@ describe.each(LAYOUTS)( `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✘ oxfmt failed', '○ tsc skipped, not selected by --only', '✘ 1 of 1 checks failed: oxfmt', @@ -215,9 +223,7 @@ describe('hooks run with deleted files in a single repo', () => { expect(stdout).toBe('') expect(report(stderr)).toEqual([ `uncheck in ${project.dir}`, - '○ sherif skipped, no package.json among the given files', - '○ oxlint skipped, only deleted files', - '○ oxfmt skipped, only deleted files', + ...SKIPPED_FOR_DELETIONS, '▶ tsc -p tsconfig.json --noEmit', '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index 615a87e..65c04e6 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -69,6 +69,20 @@ describe.each(LAYOUTS)( const failure = (project: Project) => checks(project, '✘ tsc failed', '✘ 1 of 3 checks failed: tsc') + function expectBlocked(project: Project, { exitCode, stdout, stderr }: Run): void { + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(failure(project)) + } + + function expectStillFails(project: Project, { exitCode, stdout, stderr }: Run): void { + expect(exitCode).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(report(stderr)).toEqual(failure(project)) + } + it('blocks Claude Code with exit code 2 until its change passes, through its hook run from outside the change', async () => { const project = create(DOCS) const stop = await installHook(project, app, 'claude') @@ -77,18 +91,12 @@ describe.each(LAYOUTS)( const blocked = await stop(CLAUDE_CODE_STOP) - expect(blocked.exitCode).toBe(2) - expect(blocked.stdout).toBe('') - expect(report(blocked.stderr)).toEqual(failure(project)) + expectBlocked(project, blocked) expect(blocked.stderr).toContain(`\n${DIAGNOSTIC}\n`) const again = await stop(CLAUDE_CODE_STOP_AGAIN) - expect(again.exitCode).toBe(0) - expect(JSON.parse(again.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(report(again.stderr)).toEqual(failure(project)) + expectStillFails(project, again) project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) @@ -107,23 +115,15 @@ describe.each(LAYOUTS)( const continued = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(continued.exitCode).toBe(2) - expect(continued.stdout).toBe('') - expect(report(continued.stderr)).toEqual(failure(project)) + expectBlocked(project, continued) const again = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(again.exitCode).toBe(0) - expect(JSON.parse(again.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(report(again.stderr)).toEqual(failure(project)) + expectStillFails(project, again) const nextTurn = await stopHook(project, app, CLAUDE_CODE_STOP) - expect(nextTurn.exitCode).toBe(2) - expect(nextTurn.stdout).toBe('') - expect(report(nextTurn.stderr)).toEqual(failure(project)) + expectBlocked(project, nextTurn) project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42";\n' }) @@ -139,17 +139,11 @@ describe.each(LAYOUTS)( const failingAfterPassing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(failingAfterPassing.exitCode).toBe(2) - expect(failingAfterPassing.stdout).toBe('') - expect(report(failingAfterPassing.stderr)).toEqual(failure(project)) + expectBlocked(project, failingAfterPassing) const stillFailing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(stillFailing.exitCode).toBe(0) - expect(JSON.parse(stillFailing.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(report(stillFailing.stderr)).toEqual(failure(project)) + expectStillFails(project, stillFailing) project.git('checkout', '--', '.') @@ -163,24 +157,18 @@ describe.each(LAYOUTS)( const failingAfterReverting = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) - expect(failingAfterReverting.exitCode).toBe(2) - expect(failingAfterReverting.stdout).toBe('') - expect(report(failingAfterReverting.stderr)).toEqual(failure(project)) + expectBlocked(project, failingAfterReverting) }) it('takes a continued turn without a session id as one uncheck already blocked', async () => { const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) - const { exitCode, stdout, stderr } = await stopHook(project, app, { + const continued = await stopHook(project, app, { hook_event_name: 'Stop', stop_hook_active: true, }) - expect(exitCode).toBe(0) - expect(JSON.parse(stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(report(stderr)).toEqual(failure(project)) + expectStillFails(project, continued) }) it('sends Cursor back once with a follow-up message, through its hook run from outside the change', async () => { diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index a74ec03..0250906 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -1,7 +1,9 @@ import { join } from 'node:path' +import type { Run } from '../../utils/project' import { CLI, + DUBIOUS_OWNERSHIP, LAYOUTS, Project, cliError, @@ -14,7 +16,7 @@ import { singleRepo, temporaryDirectory, } from '../../utils/project' -import { CLAUDE_CODE_STOP, TYPE_ERROR, dirFlags, hookEnv, stopHook } from './utils' +import { CLAUDE_CODE_STOP, TYPE_ERROR, stopHook, stopHookIn } from './utils' const UNFORMATTED = 'export const extra = 1\n' const FORMATTED = 'export const extra = 1;\n' @@ -24,8 +26,10 @@ const ONLY_OXFMT = [ '○ oxlint skipped, not selected by --only', ] -function oxfmtFailed(dir: string): string[] { - return [ +function expectSentBackByOxfmt({ exitCode, stdout, stderr }: Run, dir: string): void { + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ `uncheck in ${dir}`, ...ONLY_OXFMT, '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', @@ -33,7 +37,7 @@ function oxfmtFailed(dir: string): string[] { '○ tsc skipped, not selected by --only', '✘ 1 of 1 checks failed: oxfmt', ' rerun with `--fix` to apply oxfmt fixes', - ] + ]) } describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, tsc }) => { @@ -81,16 +85,13 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, it('reports a repository git refuses instead of checking the whole folder', async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const refused = { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' } const fromInside = await stopHook(project, app, CLAUDE_CODE_STOP, { cwd: `${app}src`, - env: refused, + env: DUBIOUS_OWNERSHIP, }) - const fromOutside = await run([...CLI, 'hooks', 'run', '--fix', ...dirFlags(app)], { - cwd: temporaryDirectory(), - env: hookEnv({ ...refused, CLAUDE_PROJECT_DIR: project.path(app) }), - input: JSON.stringify(CLAUDE_CODE_STOP), + const fromOutside = await stopHookIn(temporaryDirectory(), app, CLAUDE_CODE_STOP, { + env: { ...DUBIOUS_OWNERSHIP, CLAUDE_PROJECT_DIR: project.path(app) }, }) for (const { exitCode, stdout, stderr } of [fromInside, fromOutside]) { @@ -131,21 +132,27 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, }, ) + it("checks the agent's project from its own folder", async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: app, + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expectSentBackByOxfmt(sentBack, project.path(app, '.')) + }) + it("checks the agent's project from a folder outside git", async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await run( - [...CLI, 'hooks', 'run', '--only=oxfmt', ...dirFlags(app)], - { - cwd: temporaryDirectory(), - env: hookEnv({ CLAUDE_PROJECT_DIR: project.path(app) }), - input: JSON.stringify(CLAUDE_CODE_STOP), - }, - ) + const sentBack = await stopHookIn(temporaryDirectory(), app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) + expectSentBackByOxfmt(sentBack, project.path(app, '.')) }) it("checks the agent's project from a submodule the agent moved into", async () => { @@ -167,30 +174,26 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, ) project.commit('submodule').write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { args: ['--only=oxfmt'], cwd: 'vendor/lib', env: { CLAUDE_PROJECT_DIR: project.path(app) }, }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) + expectSentBackByOxfmt(sentBack, project.path(app, '.')) }) it('checks the linked worktree the agent works in, although its project folder is the main checkout', async () => { const project = create() const worktree = linkedWorktree(project, app).write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(worktree, app, CLAUDE_CODE_STOP, { + const sentBack = await stopHook(worktree, app, CLAUDE_CODE_STOP, { args: ['--only=oxfmt'], cwd: `${app}src`, env: { CLAUDE_PROJECT_DIR: project.path(app) }, }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(worktree.path(app, '.'))) + expectSentBackByOxfmt(sentBack, worktree.path(app, '.')) }) it('checks the repository the agent moved to when it is not part of the project', async () => { @@ -200,15 +203,13 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { args: ['--only=oxfmt'], cwd: `${app}src`, env: { CLAUDE_PROJECT_DIR: elsewhere }, }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(project.path(app, '.'))) + expectSentBackByOxfmt(sentBack, project.path(app, '.')) }) }) @@ -224,17 +225,7 @@ describe('hooks run in a monorepo', () => { cwd: 'packages/core/src', }) - expect(app.exitCode).toBe(2) - expect(app.stdout).toBe('') - expect(report(app.stderr)).toEqual([ - `uncheck in ${project.path('packages/app')}`, - ...ONLY_OXFMT, - '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', - '✘ oxfmt failed', - '○ tsc skipped, not selected by --only', - '✘ 1 of 1 checks failed: oxfmt', - ' rerun with `--fix` to apply oxfmt fixes', - ]) + expectSentBackByOxfmt(app, project.path('packages/app')) const top = await stopHook(project, '', CLAUDE_CODE_STOP, { args: ['--fix', '--only=oxfmt'], @@ -295,14 +286,11 @@ describe('hooks run through links into a package', () => { const outside = new Project(temporaryDirectory()).link('app', project.path('packages/app')) for (const dir of [['--dir=packages/app'], []]) { - const { exitCode, stdout, stderr } = await run( - [...CLI, 'hooks', 'run', '--only=oxfmt', `--cwd=${outside.path('app')}`, ...dir], - { cwd: outside.dir, env: hookEnv(), input: JSON.stringify(CLAUDE_CODE_STOP) }, - ) + const sentBack = await stopHook(outside, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt', `--cwd=${outside.path('app')}`, ...dir], + }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(project.path('packages/app'))) + expectSentBackByOxfmt(sentBack, project.path('packages/app')) } }) @@ -313,20 +301,13 @@ describe('hooks run through links into a package', () => { git(project.path('vendor/nested'), ['init', '--quiet']) project.write({ 'packages/app/src/extra.ts': UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook( - project, - 'packages/app/', - CLAUDE_CODE_STOP, - { - args: ['--only=oxfmt'], - cwd: 'vendor/nested', - env: { CLAUDE_PROJECT_DIR: outside.path('app') }, - }, - ) + const sentBack = await stopHook(project, 'packages/app/', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'vendor/nested', + env: { CLAUDE_PROJECT_DIR: outside.path('app') }, + }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(project.path('packages/app'))) + expectSentBackByOxfmt(sentBack, project.path('packages/app')) }) }) @@ -342,7 +323,7 @@ describe('hooks run in a repository nested in one git refuses', () => { git(nested.dir, ['init', '--quiet']) nested.commit('init').write({ 'src/extra.ts': UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(nested, '', CLAUDE_CODE_STOP, { + const sentBack = await stopHook(nested, '', CLAUDE_CODE_STOP, { args: ['--only=oxfmt'], cwd: 'src', env: { @@ -354,9 +335,7 @@ describe('hooks run in a repository nested in one git refuses', () => { }, }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(nested.dir)) + expectSentBackByOxfmt(sentBack, nested.dir) }) }) @@ -372,15 +351,13 @@ describe('hooks run in a repository whose work tree lies below its .git folder', git(holder, ['config', 'core.worktree', checkout.dir]) checkout.commit('init').write({ 'src/extra.ts': UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(checkout, '', CLAUDE_CODE_STOP, { + const sentBack = await stopHook(checkout, '', CLAUDE_CODE_STOP, { args: ['--only=oxfmt'], cwd: 'src', env: { CLAUDE_PROJECT_DIR: project.dir }, }) - expect(exitCode).toBe(2) - expect(stdout).toBe('') - expect(report(stderr)).toEqual(oxfmtFailed(checkout.dir)) + expectSentBackByOxfmt(sentBack, checkout.dir) }) }) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index f28b56f..a7091ba 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -1,5 +1,5 @@ import type { Env, Project, Run } from '../../utils/project' -import { temporaryDirectory } from '../../utils/project' +import { CLI, run, temporaryDirectory } from '../../utils/project' export const CLAUDE_CODE_STOP = { session_id: 'session', @@ -30,9 +30,6 @@ export const COPILOT_STOP_IN_CLAUDE_FORMAT = { export const TYPE_ERROR = 'export const answer: string = 1;\n' -export const UTILS_NOT_FOUND = - "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." - // uncheck remembers the turns it blocked in the temporary folder, which would keep a file of every test run. const MARKERS = temporaryDirectory() @@ -46,19 +43,27 @@ export function dirFlags(app: string): string[] { interface HookOptions { readonly args?: ReadonlyArray - readonly cwd?: string readonly env?: Env } -export function stopHook( - project: Project, +export function stopHookIn( + dir: string, app: string, payload: object | string, - { args = ['--fix'], cwd, env }: HookOptions = {}, + { args = ['--fix'], env }: HookOptions = {}, ): Promise { - return project.uncheck(['hooks', 'run', ...dirFlags(app), ...args], { - cwd, + return run([...CLI, 'hooks', 'run', ...dirFlags(app), ...args], { + cwd: dir, env: hookEnv(env), input: typeof payload === 'string' ? payload : JSON.stringify(payload), }) } + +export function stopHook( + project: Project, + app: string, + payload: object | string, + { cwd = '.', ...options }: HookOptions & { readonly cwd?: string } = {}, +): Promise { + return stopHookIn(project.path(cwd), app, payload, options) +} diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index fabe184..7ebb275 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -1,5 +1,5 @@ -import type { Project } from '../utils/project' -import { LAYOUTS, monorepo, report, run } from '../utils/project' +import type { Files, Project } from '../utils/project' +import { commitWithHooks, LAYOUTS, monorepo, report, SKIPPED_FOR_DELETIONS } from '../utils/project' import { HUSKY_4_BANNER, HUSKY_4_RUNNER, installHusky, prepare } from './utils' const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } } @@ -10,8 +10,12 @@ const TSC_WITHOUT_REFERENCES = '▶ tsc -p tsconfig.json --noEmit' const TSC_WITH_REFERENCES = '▶ tsc -b tsconfig.json' -function commit(project: Project, message: string) { - return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.dir }) +async function preparedMonorepo(files?: Files): Promise { + const project = monorepo(files) + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + + return project } function blockedByLint(dir: string, tsc: string, file = 'src/ignore.ts'): string[] { @@ -49,7 +53,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create await prepare(project, [], { cwd: app }) project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) - const { exitCode, stderr } = await commit(project, 'ignore') + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') expect(exitCode).toBe(1) expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) @@ -62,7 +66,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create await prepare(project, [], { cwd: app }) project.stage({ [`${app}src/spaced.ts`]: 'export var spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) @@ -81,7 +85,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create await prepare(project, [], { cwd: app }) project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) - const { exitCode, stderr } = await commit(project, 'ignore') + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') expect(exitCode).toBe(1) expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) @@ -95,7 +99,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create project.commit('husky') project.stage({ [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) @@ -106,15 +110,13 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create describe('committing with the prepared hook of several packages in a monorepo', () => { it('stops at the first package whose line fails', async () => { - const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo({ '.oxlintrc.json': LINT_CONFIG }) project.stage({ 'packages/core/src/ignore.ts': UNFIXABLE, 'packages/app/src/spaced.ts': 'export const spaced = 1\n', }) - const { exitCode, stderr } = await commit(project, 'both') + const { exitCode, stderr } = await commitWithHooks(project, '--message=both') expect(exitCode).toBe(1) expect(report(stderr)).toEqual( @@ -125,12 +127,10 @@ describe('committing with the prepared hook of several packages in a monorepo', }) it('skips the line of a package the commit leaves alone', async () => { - const project = monorepo() - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo() project.stage({ 'packages/app/src/spaced.ts': 'export var spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual( @@ -140,14 +140,14 @@ describe('committing with the prepared hook of several packages in a monorepo', }) it('checks the files of `git commit ` in a package', async () => { - const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo({ '.oxlintrc.json': LINT_CONFIG }) project.write({ 'packages/app/src/index.ts': UNFIXABLE }) - const { exitCode, stderr } = await run( - ['git', 'commit', '--quiet', '--message=ignore', '--', 'packages/app/src/index.ts'], - { cwd: project.dir }, + const { exitCode, stderr } = await commitWithHooks( + project, + '--message=ignore', + '--', + 'packages/app/src/index.ts', ) expect(exitCode).toBe(1) @@ -158,19 +158,15 @@ describe('committing with the prepared hook of several packages in a monorepo', }) it('runs the line of a package the commit only deletes from', async () => { - const project = monorepo() - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo() project.git('rm', '--quiet', 'packages/app/src/index.ts') - const { exitCode, stderr } = await commit(project, 'remove') + const { exitCode, stderr } = await commitWithHooks(project, '--message=remove') expect(exitCode).toBe(0) expect(report(stderr)).toEqual([ `uncheck staged in ${project.path('packages/app')}`, - '○ sherif skipped, no package.json among the given files', - '○ oxlint skipped, only deleted files', - '○ oxfmt skipped, only deleted files', + ...SKIPPED_FOR_DELETIONS, TSC_WITH_REFERENCES, '✔ tsc passed', '✔ all checks passed (tsc)', @@ -179,13 +175,11 @@ describe('committing with the prepared hook of several packages in a monorepo', }) it('skips the line of a package that was moved away', async () => { - const project = monorepo() - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo() project.git('mv', 'packages/app', 'packages/web') await prepare(project, [], { cwd: 'packages/web' }) - const { exitCode, stderr } = await commit(project, 'rename') + const { exitCode, stderr } = await commitWithHooks(project, '--message=rename') expect(exitCode).toBe(0) expect(report(stderr)).toEqual([ @@ -203,13 +197,11 @@ describe('committing with the prepared hook of several packages in a monorepo', }) it('skips the line of a package a sparse checkout leaves out', async () => { - const project = monorepo() - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo() project.git('sparse-checkout', 'set', 'packages/core') project.stage({ 'packages/core/src/spaced.ts': 'export var spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual( @@ -223,7 +215,7 @@ describe('committing with the prepared hook of several packages in a monorepo', project.stage({ ':docs/src/ignore.ts': UNFIXABLE }) await prepare(project, [], { cwd: ':docs' }) - const { exitCode, stderr } = await commit(project, 'ignore') + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') expect(exitCode).toBe(1) expect(report(stderr)).toEqual([ diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 25aa8e9..3825eb3 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -1,6 +1,7 @@ import { Buffer } from 'node:buffer' import { readFileSync, writeFileSync } from 'node:fs' +import type { Project } from '../utils/project' import { LAYOUTS, monorepo } from '../utils/project' import { COMMAND, @@ -10,6 +11,8 @@ import { HUSKY_4_RUNNER, notWritten, prepare, + previousHookLine, + runsIn, shownHook, syntaxCheck, written, @@ -17,6 +20,10 @@ import { const HOOK = '.git/hooks/pre-commit' +function expectHookParses(project: Project): void { + expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) +} + const EXITING_HOOKS = [ { name: "git's pre-commit.sample", @@ -81,6 +88,12 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap return { ...run, project, hook: project.read(HOOK) } } + async function expectUnchangedWhenPreparedAgain(project: Project): Promise { + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + } + it('rewrites the line an older version wrote in place', async () => { const old = app === '' ? 'npx uncheck staged --fix' : `cd "${folder}" && yarn uncheck staged --fix` @@ -99,9 +112,7 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(`${setup}${line}\n${commands}`) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }, ) @@ -114,21 +125,21 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap }) it('keeps its line in each branch of an if, indented, so the hook still parses', async () => { - const runs = (command: string) => (app === '' ? command : `(cd "${folder}" && ${command})`) const branches = (first: string, second: string) => `#!/bin/sh\nif [ -n "$CI" ]; then\n ${first}\nelse\n ${second}\nfi\n` const { stdout, hook, project } = await prepareHook( - branches(runs('pnpm exec uncheck staged --only=oxlint'), runs('pnpm exec uncheck staged')), + branches( + runsIn(app, 'pnpm exec uncheck staged --only=oxlint'), + runsIn(app, 'pnpm exec uncheck staged'), + ), ) expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(branches(line, line)) - expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + expectHookParses(project) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }) it.each([ @@ -144,11 +155,9 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(existing(line)) - expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + expectHookParses(project) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }, ) @@ -161,24 +170,21 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap const { hook, project } = await prepareHook(existing(lineOfV003)) expect(hook).toBe(existing(line)) - expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) + expectHookParses(project) }) it('keeps its line after a block that runs it too', async () => { - const runs = (command: string) => (app === '' ? command : `(cd "${folder}" && ${command})`) const existing = (inBlock: string, after: string) => `#!/bin/sh\nif [ -n "$CI" ]; then\n ${inBlock}\nfi\n${after}\n` const { stdout, hook, project } = await prepareHook( - existing(runs('pnpm exec uncheck staged --only=oxlint'), `${runs(COMMAND)} || exit 1`), + existing(runsIn(app, 'pnpm exec uncheck staged --only=oxlint'), previousHookLine(app)), ) expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(existing(line, line)) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }) it('drops the copies of its line and settles on one', async () => { @@ -195,9 +201,7 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(`${HEADER}${line}\npnpm test\npnpm test\n`) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }) it('leaves alone the lines that only mention the command or run it another way', async () => { @@ -274,18 +278,14 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap }) it('moves the line an older version wrote after the runner of husky 4 before it', async () => { - const old = app === '' ? line : `(cd "${folder}" && ${COMMAND}) || exit 1` - const { stdout, hook, project } = await prepareHook( - `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${old}\n`, + `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${previousHookLine(app)}\n`, ) expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(`${HUSKY_4_BANNER}${line}\n${HUSKY_4_RUNNER}\n`) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }) it('keeps its line after a block that only runs the husky 4 runner when it exists', async () => { @@ -416,8 +416,8 @@ describe('prepare with an existing hook in a monorepo', () => { const project = monorepo().write({ [HOOK]: [ '#!/bin/sh', - '(cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', - '(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1', + previousHookLine('packages/core/', `${COMMAND} --only=oxlint`), + previousHookLine('packages/app/'), 'pnpm test', '', ].join('\n'), diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index 0b0e352..a844caa 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -3,6 +3,7 @@ import { setTimeout as sleep } from 'node:timers/promises' import type { Project } from '../utils/project' import { + DUBIOUS_OWNERSHIP, LAYOUTS, monorepo, PERMISSIONS_ENFORCED, @@ -24,7 +25,7 @@ describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app, - env: { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }, + env: DUBIOUS_OWNERSHIP, }) expect(stderr).toBe('') diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts index 0b8b165..0337a28 100644 --- a/packages/uncheck/tests/prepare/utils.ts +++ b/packages/uncheck/tests/prepare/utils.ts @@ -15,6 +15,14 @@ export function prepare( return project.uncheck(['prepare', '--pre-commit', ...args], options) } +export function runsIn(app: string, command: string): string { + return app === '' ? command : `(cd "${app.slice(0, -1)}" && ${command})` +} + +export function previousHookLine(app: string, command = COMMAND): string { + return `${runsIn(app, command)} || exit 1` +} + export function hookLine(app: string, command = COMMAND): string { const folder = app.slice(0, -1) diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts index 8f51d93..ed50bcd 100644 --- a/packages/uncheck/tests/staged/basics.test.ts +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -1,12 +1,13 @@ import { cliError, + DUBIOUS_OWNERSHIP, LAYOUTS, monorepo, project as bareProject, report, singleRepo, } from '../utils/project' -import { folderOf, inIndex } from './utils' +import { expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) @@ -26,7 +27,7 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder, - env: { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' }, + env: DUBIOUS_OWNERSHIP, }) expect(project.normalize(stderr)).toContain( @@ -253,10 +254,7 @@ describe('uncheck staged in a package of a monorepo', () => { }) expect(fixed.exitCode).toBe(0) - expect(report(fixed.stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/index.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(fixed.stdout, 'src/index.ts', 'oxfmt') expect(inIndex(project, 'packages/app/src/index.ts')).toBe('export const app = 1;\n') expect(inIndex(project, 'packages/core/src/index.ts')).toBe(edits['packages/core/src/index.ts']) }) diff --git a/packages/uncheck/tests/staged/commit.test.ts b/packages/uncheck/tests/staged/commit.test.ts index 864d5d6..fe174df 100644 --- a/packages/uncheck/tests/staged/commit.test.ts +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -1,7 +1,8 @@ -import { cliError, LAYOUTS, linkedWorktree, report, run } from '../utils/project' +import { cliError, commitWithHooks, LAYOUTS, linkedWorktree, report } from '../utils/project' import type { Project } from '../utils/project' import { EMPTY_COMMIT_ERROR, + expectFixesStaged, inIndex, SAVED_LINE, saveWhileTscRuns, @@ -23,20 +24,16 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr .chmod('.git/hooks/pre-commit', 0o755) } - function commit(project: Project, ...args: ReadonlyArray) { - return run(['git', 'commit', '--quiet', ...args], { cwd: project.dir }) - } - it('fixes the files of `git commit ` in the commit and in the index it leaves', async () => { const project = hooked().stage({ [other]: 'export const other = 2;\n' }) project.write({ [file]: VERSIONS.staged }) saveWhileTscRuns(project, [file]) - const { exitCode, stderr } = await commit(project, '--message=extra', '--', file) + const { exitCode, stderr } = await commitWithHooks(project, '--message=extra', '--', file) expect(exitCode).toBe(0) - expect(report(stderr).slice(-2)).toEqual(['✔ staged the fixes to src/extra.ts', ALL_PASSED]) + expectFixesStaged(stderr, 'src/extra.ts', 'oxlint, oxfmt, tsc') expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 1;\n') expect(inIndex(project, file)).toBe(VERSIONS.fixed) @@ -49,15 +46,10 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr project.write({ [file]: VERSIONS.staged, [other]: 'export const other = 2\n' }) - const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--all', '--message=all'], { - cwd: project.dir, - }) + const { exitCode, stderr } = await commitWithHooks(project, '--all', '--message=all') expect(exitCode).toBe(0) - expect(report(stderr).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts src/other.ts', - ALL_PASSED, - ]) + expectFixesStaged(stderr, 'src/extra.ts src/other.ts', 'oxlint, oxfmt, tsc') expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') expect(project.git('status', '--porcelain')).toBe('') @@ -75,7 +67,7 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr saveWhileTscRuns(project, [file, other, clean]) - const { exitCode, stderr } = await commit(project, '--message=saved') + const { exitCode, stderr } = await commitWithHooks(project, '--message=saved') expect(exitCode).toBe(0) expect(report(stderr).slice(-3)).toEqual([ @@ -98,7 +90,7 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr file, ) - const { exitCode, stderr } = await commit(project, '--message=broken') + const { exitCode, stderr } = await commitWithHooks(project, '--message=broken') expect(exitCode).toBe(1) expect(report(stderr).slice(-3)).toEqual([ @@ -116,22 +108,22 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr project.stage(unformatted) - const amend = await commit(project, '--amend', '--message=reworded') + const amend = await commitWithHooks(project, '--amend', '--message=reworded') expect(amend.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) expect(amend.exitCode).toBe(1) expect(project.git('log', '--format=%s')).toBe('init\n') - expect((await commit(project, '--amend', '--message=reworded')).exitCode).toBe(0) + expect((await commitWithHooks(project, '--amend', '--message=reworded')).exitCode).toBe(0) expect(project.git('log', '--format=%s')).toBe('reworded\n') project.stage(unformatted) - const plain = await commit(project, '--message=empty') + const plain = await commitWithHooks(project, '--message=empty') expect(plain.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) expect(plain.exitCode).toBe(1) - const again = await commit(project, '--message=empty') + const again = await commitWithHooks(project, '--message=empty') expect(again.stdout).toContain('nothing to commit') expect(again.exitCode).toBe(1) @@ -144,9 +136,7 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr const project = hooked() const worktree = stagePartially(linkedWorktree(project, app), file) - const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--message=extra'], { - cwd: worktree.dir, - }) + const { exitCode, stderr } = await commitWithHooks(worktree, '--message=extra') expect(exitCode).toBe(0) expect(report(stderr).slice(-3)).toEqual([ @@ -166,13 +156,10 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr worktree.write({ [file]: VERSIONS.staged }) - const { exitCode, stderr } = await run( - ['git', 'commit', '--quiet', '--message=extra', '--', file], - { cwd: worktree.dir }, - ) + const { exitCode, stderr } = await commitWithHooks(worktree, '--message=extra', '--', file) expect(exitCode).toBe(0) - expect(report(stderr).slice(-2)).toEqual(['✔ staged the fixes to src/extra.ts', ALL_PASSED]) + expectFixesStaged(stderr, 'src/extra.ts', 'oxlint, oxfmt, tsc') expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(inIndex(worktree, file)).toBe(VERSIONS.fixed) expect(worktree.git('status', '--porcelain')).toBe(`M ${other}\n`) diff --git a/packages/uncheck/tests/staged/deletions.test.ts b/packages/uncheck/tests/staged/deletions.test.ts index 043225c..88555f5 100644 --- a/packages/uncheck/tests/staged/deletions.test.ts +++ b/packages/uncheck/tests/staged/deletions.test.ts @@ -1,13 +1,12 @@ import { mkdirSync } from 'node:fs' -import { monorepo, report, singleRepo } from '../utils/project' -import { UTILS_NOT_FOUND } from './utils' - -const SKIPPED_FOR_DELETIONS = [ - '○ sherif skipped, no package.json among the given files', - '○ oxlint skipped, only deleted files', - '○ oxfmt skipped, only deleted files', -] +import { + monorepo, + report, + singleRepo, + SKIPPED_FOR_DELETIONS, + UTILS_NOT_FOUND, +} from '../utils/project' describe('uncheck staged with deleted files in a single repo', () => { it('typechecks the project a staged deletion breaks', async () => { diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 658894d..6632592 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -1,7 +1,7 @@ import { mkdirSync } from 'node:fs' import { cliError, git, LAYOUTS, report } from '../utils/project' -import { EMPTY_COMMIT_ERROR, folderOf, inIndex } from './utils' +import { EMPTY_COMMIT_ERROR, expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) @@ -73,10 +73,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(empty.stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) expect(empty.exitCode).toBe(1) - expect(report(empty.stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(empty.stdout, 'src/extra.ts', 'oxfmt') expect(project.git('status', '--porcelain')).toBe('') project.stage(unformatted) @@ -87,10 +84,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(allowed.stderr).toBe('') expect(allowed.exitCode).toBe(0) - expect(report(allowed.stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(allowed.stdout, 'src/extra.ts', 'oxfmt') expect(project.git('status', '--porcelain')).toBe('') }) @@ -136,10 +130,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1;\n') expect(project.git('diff', '--name-only')).toBe('') }) @@ -157,10 +148,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/run.ts') - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/run.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/run.ts', 'oxfmt') expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) expect(inIndex(project, file)).toBe('export const run = 1;\n') expect(project.mode(file)).toBe(0o755) @@ -197,10 +185,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts') - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/link.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/link.ts', 'oxfmt') expect(inIndex(project, `${app}src/link.ts`)).toBe('export const link = 1;\n') expect(project.read(`${app}src/messy.ts`)).toBe('export const messy = 1\n') expect(project.git('status', '--porcelain')).toBe( @@ -311,10 +296,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) }) expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(project.git('rev-parse', `:${app}vendor`)).toBe(before) }) }) diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index f3afbd8..e3cd514 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -1,7 +1,15 @@ import { readFileSync, writeFileSync } from 'node:fs' import { LAYOUTS, report, wrappedGit } from '../utils/project' -import { folderOf, inIndex, stagePartially, startGit, startUncheck, VERSIONS } from './utils' +import { + expectRestored, + folderOf, + inIndex, + stagePartially, + startGit, + startUncheck, + VERSIONS, +} from './utils' const SLOW_CHECK = "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n" @@ -38,9 +46,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', '○ unstaged changes of src/extra.ts restored', ]) - expect(project.read(file)).toBe(VERSIONS.unstaged) - expect(inIndex(project, file)).toBe(VERSIONS.staged) - expect(project.exists('.git/uncheck-unstaged')).toBe(false) + expectRestored(project, file) }) it('puts the unstaged changes back after Ctrl-C during a slow check', async () => { @@ -55,9 +61,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, }) expect(exitCode).toBe(130) - expect(project.read(file)).toBe(VERSIONS.unstaged) - expect(inIndex(project, file)).toBe(VERSIONS.staged) - expect(project.exists('.git/uncheck-unstaged')).toBe(false) + expectRestored(project, file) }) it('puts the unstaged changes back over a clashing fix after Ctrl-C before the fixes are staged', async () => { @@ -82,9 +86,7 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, '○ oxlint skipped, not selected by --only', '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', ]) - expect(project.read(file)).toBe(VERSIONS.unstaged) - expect(inIndex(project, file)).toBe(VERSIONS.staged) - expect(project.exists('.git/uncheck-unstaged')).toBe(false) + expectRestored(project, file) }) it('puts the unstaged changes back as they were after Ctrl-C kills `git commit --include` and its index', async () => { diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 84f368e..b56474c 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -1,5 +1,14 @@ -import { cliError, LAYOUTS, report, run, singleRepo } from '../utils/project' -import { commitOnSide, folderOf, inIndex, stagePartially, UTILS_NOT_FOUND, VERSIONS } from './utils' +import { + cliError, + commitOnSide, + LAYOUTS, + report, + run, + singleRepo, + SKIPPED_FOR_DELETIONS, + UTILS_NOT_FOUND, +} from '../utils/project' +import { expectFixesStaged, folderOf, inIndex, stagePartially, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -29,10 +38,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts') - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/other.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/other.ts', 'oxfmt') expect(inIndex(project, other)).toBe('export const other = 4;\n') expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') }) @@ -73,10 +79,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/other.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/other.ts', 'oxfmt') expect(project.git('diff', '--cached', '--name-only')).toBe('') project.git('commit', '--quiet', '--no-edit', '--no-verify') @@ -131,9 +134,7 @@ describe('uncheck staged during a merge in a single repo', () => { expect(report(stdout)).toEqual([ `uncheck staged in ${project.dir}`, - '○ sherif skipped, no package.json among the given files', - '○ oxlint skipped, only deleted files', - '○ oxfmt skipped, only deleted files', + ...SKIPPED_FOR_DELETIONS, '▶ tsc -p tsconfig.json --noEmit', '✘ tsc failed', '✘ 1 of 1 checks failed: tsc', diff --git a/packages/uncheck/tests/staged/sparse-checkout.test.ts b/packages/uncheck/tests/staged/sparse-checkout.test.ts index 3d6fa84..02f77d0 100644 --- a/packages/uncheck/tests/staged/sparse-checkout.test.ts +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -1,5 +1,5 @@ -import { cliError, LAYOUTS, report, run } from '../utils/project' -import { commitOnSide, conflictError, folderOf, inIndex } from './utils' +import { cliError, commitOnSide, LAYOUTS, report, run } from '../utils/project' +import { conflictError, expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ create, app }) => { const folder = folderOf(app) @@ -32,10 +32,7 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts') - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(project.exists(`${app}lib`)).toBe(false) expect(inIndex(project, outside)).toBe('export const lib = 2;\n') expect(inIndex(project, file)).toBe('export const extra = 43;\n') @@ -61,10 +58,7 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea }) expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to lib/lib.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'lib/lib.ts', 'oxfmt') expect(inIndex(project, outside)).toBe('export const lib = 4;\n') }) }) diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts index 316f797..0a54d72 100644 --- a/packages/uncheck/tests/staged/unstaged.test.ts +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -3,6 +3,7 @@ import { lstatSync, readdirSync } from 'node:fs' import { cliError, LAYOUTS, PERMISSIONS_ENFORCED, report, run, wrappedGit } from '../utils/project' import { conflictError, + expectFixesStaged, folderOf, inIndex, LEFTOVER_ERROR, @@ -118,10 +119,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts src/far.ts src/other.ts', - '✔ all checks passed (oxfmt)', - ]) + expectFixesStaged(stdout, 'src/extra.ts src/far.ts src/other.ts', 'oxfmt') expect(inIndex(project, file)).toBe('export const extra = 42\n') expect(inIndex(project, `${app}src/far.ts`)).toBe(stagedFar) expect(inIndex(project, `${app}src/other.ts`)).toBe('export const other = 2\n') @@ -155,10 +153,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/extra.ts src/other.ts src/plain.ts', - '✔ all checks passed (oxfmt, tsc)', - ]) + expectFixesStaged(stdout, 'src/extra.ts src/other.ts src/plain.ts', 'oxfmt, tsc') expect(inIndex(project, other)).toBe('export const other = 2\n') expect(project.read(other)).toBe(`${SAVED_LINE}export const other = 2;\n`) expect(inIndex(project, plain)).toBe('export const plain = 3\n') @@ -185,10 +180,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(stderr).toBe(cliError(conflictError('src/conflicting.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).slice(-2)).toEqual([ - '✔ staged the fixes to src/conflicting.ts src/extra.ts', - '✔ all checks passed (oxfmt, tsc)', - ]) + expectFixesStaged(stdout, 'src/conflicting.ts src/extra.ts', 'oxfmt, tsc') expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.merged) expect(inIndex(project, file)).toBe(VERSIONS.staged) expect(project.read(conflicting)).toBe('export const c = 43\n') diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index 45bfd44..43d9edc 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -2,17 +2,14 @@ import { spawn } from 'node:child_process' import type { ChildProcessWithoutNullStreams } from 'node:child_process' import process from 'node:process' -import { CLI, environment } from '../utils/project' -import type { Env, Files, Project, Run } from '../utils/project' +import { CLI, environment, report } from '../utils/project' +import type { Env, Project, Run } from '../utils/project' /** The folder of a layout's package, relative to the top of the repository. */ export function folderOf(app: string): string { return app.replace(/\/$/, '') || '.' } -export const UTILS_NOT_FOUND = - "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." - export const LEFTOVER_ERROR = 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' @@ -54,10 +51,14 @@ export function stagePartially(project: Project, file: string): Project { return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) } -/** What `saveWhileTscRuns` adds to a file. */ +export function expectRestored(project: Project, file: string): void { + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) +} + export const SAVED_LINE = '// saved while tsc ran\n' -/** Fakes a tsc that adds `SAVED_LINE` to the top of `files`, or to their end, as an editor would. */ export function saveWhileTscRuns( project: Project, files: ReadonlyArray, @@ -73,19 +74,17 @@ export function saveWhileTscRuns( ) } -/** Commits `files` on a new `side` branch, then goes back to `main`. */ -export function commitOnSide(project: Project, files: Files): Project { - project.git('checkout', '--quiet', '-b', 'side') - project.write(files).commit('side') - project.git('checkout', '--quiet', 'main') - - return project -} - export function inIndex(project: Project, file: string): string { return project.git('show', `:${file}`) } +export function expectFixesStaged(output: string, files: string, checks: string): void { + expect(report(output).slice(-2)).toEqual([ + `✔ staged the fixes to ${files}`, + `✔ all checks passed (${checks})`, + ]) +} + export interface Started { readonly child: ChildProcessWithoutNullStreams /** Resolves once stdout or stderr has shown `marker`. */ diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts index 361bebd..54cd8b2 100644 --- a/packages/uncheck/tests/uncheck/cli.test.ts +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { CLI, cliError, + FULL_OXLINT, LAYOUTS, report, run, @@ -19,7 +20,7 @@ function onlyOxlint(dir: string): string[] { return [ `uncheck in ${dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts index be722b7..597ec54 100644 --- a/packages/uncheck/tests/uncheck/fix.test.ts +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -1,5 +1,5 @@ import type { Project } from '../utils/project' -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT_FIX, FULL_OXLINT, FULL_OXLINT_FIX, LAYOUTS, report } from '../utils/project' import { CODE_WITH_TYPE_ERROR, CODE_WITH_VAR, @@ -23,9 +23,9 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT_FIX, '✔ oxlint passed', - '▶ oxfmt --no-error-on-unmatched-pattern', + FULL_OXFMT_FIX, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -77,7 +77,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✘ oxlint failed', '✘ oxfmt not installed', '○ tsc skipped, disabled with --skip=tsc', @@ -99,7 +99,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT_FIX, '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -121,7 +121,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(check.stdout).not.toContain(dependency) expect(selectedReport(check.stdout)).toEqual([ `uncheck in ${project.dir}`, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '✔ all checks passed (oxlint)', ]) @@ -185,9 +185,9 @@ describe('uncheck --fix in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT_FIX, '✔ oxlint passed', - '▶ oxfmt --no-error-on-unmatched-pattern', + FULL_OXFMT_FIX, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', '✘ 1 of 3 checks failed: sherif', diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts index 3685bee..bdc8e35 100644 --- a/packages/uncheck/tests/uncheck/output.test.ts +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -1,18 +1,17 @@ import { availableParallelism } from 'node:os' import { styleText } from 'node:util' -import { LAYOUTS, report, singleRepo } from '../utils/project' +import { + compilerOptions, + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + report, + singleRepo, +} from '../utils/project' import { CODE_WITH_VAR, layoutChecks } from './utils' -const STANDALONE_TSCONFIG = { - compilerOptions: { - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - noEmit: true, - }, -} +const STANDALONE_TSCONFIG = { compilerOptions: compilerOptions({ noEmit: true }) } const PRINT_FORCE_COLOR = "console.log('the tool sees FORCE_COLOR=' + process.env.FORCE_COLOR);\n" @@ -75,9 +74,9 @@ describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, @@ -171,7 +170,7 @@ describe('uncheck output', () => { `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', diff --git a/packages/uncheck/tests/uncheck/paths-tools.test.ts b/packages/uncheck/tests/uncheck/paths-tools.test.ts index 286a32f..efa3f5e 100644 --- a/packages/uncheck/tests/uncheck/paths-tools.test.ts +++ b/packages/uncheck/tests/uncheck/paths-tools.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, report } from '../utils/project' import { CLEAN_CODE, CODE_WITH_VAR, NOT_COVERED, selectedReport } from './utils' const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] @@ -52,9 +52,9 @@ describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ creat expect(everything.exitCode).toBe(1) expect(selectedReport(everything.stdout)).toEqual([ `uncheck in ${project.path(app, 'src/routes')}`, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '✘ 1 of 2 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', @@ -74,9 +74,9 @@ describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ creat expect(selectedReport(stdout)).toEqual([ `uncheck in ${project.path(app, folder)}`, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '✔ all checks passed (oxlint, oxfmt)', ]) diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts index f67f8df..3afd259 100644 --- a/packages/uncheck/tests/uncheck/presets.test.ts +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -1,4 +1,11 @@ -import { LAYOUTS, report } from '../utils/project' +import { + FULL_OXFMT, + FULL_OXFMT_FIX, + FULL_OXLINT, + FULL_OXLINT_FIX, + LAYOUTS, + report, +} from '../utils/project' import { layoutChecks } from './utils' const PRESET_CONFIGS = { @@ -110,9 +117,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✘ tsc failed', @@ -158,9 +165,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT_FIX, '✘ oxlint failed', - '▶ oxfmt --no-error-on-unmatched-pattern', + FULL_OXFMT_FIX, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts index 0909739..6357d34 100644 --- a/packages/uncheck/tests/uncheck/project.test.ts +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, report } from '../utils/project' import { CODE_WITH_TYPE_ERROR, CODE_WITH_VAR, @@ -19,9 +19,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -41,9 +41,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -62,9 +62,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -85,9 +85,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✘ tsc failed', @@ -105,9 +105,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', `✘ tsc found ${tsconfigs} tsconfig.json but typescript is not installed`, `✘ 1 of ${checks.length} checks failed: tsc`, @@ -127,9 +127,9 @@ describe('uncheck in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '▶ tsc -b tsconfig.json', '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index cd36625..6770e9f 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -1,5 +1,7 @@ import { cliError, + FULL_OXFMT, + FULL_OXLINT, LAYOUTS, project as bareProject, report, @@ -19,7 +21,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', tsc, @@ -37,7 +39,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, disabled with --skip=oxfmt', '○ tsc skipped, disabled with --skip=tsc', @@ -54,7 +56,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(skipped.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not installed', '○ tsc skipped, not selected by --only', @@ -67,7 +69,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(required.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '✘ oxfmt not installed', '○ tsc skipped, not selected by --only', @@ -88,9 +90,9 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts index 1915b1a..a32d9cb 100644 --- a/packages/uncheck/tests/uncheck/sherif.test.ts +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' import { monorepoWithMismatchedVersions } from './utils' const OTHERS_NOT_SELECTED = [ @@ -25,7 +25,7 @@ describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -109,9 +109,9 @@ describe('uncheck sherif in a single repo', () => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '✘ sherif not a workspace root', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '▶ tsc -p tsconfig.json --noEmit', '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/tools.test.ts b/packages/uncheck/tests/uncheck/tools.test.ts index a0e2e13..93c3ab2 100644 --- a/packages/uncheck/tests/uncheck/tools.test.ts +++ b/packages/uncheck/tests/uncheck/tools.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, monorepo, report } from '../utils/project' import { layoutChecks } from './utils' const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] @@ -56,7 +56,7 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', @@ -96,9 +96,9 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } expect(report(stdout)).toEqual([ `uncheck in ${project.path(app, 'src')}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', @@ -127,9 +127,9 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts index d0d108b..2ff7a4c 100644 --- a/packages/uncheck/tests/uncheck/tsc-extends.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -4,7 +4,7 @@ import { CONFIG_DIR, NOT_COVERED, OUT_DIR, - SKIPPED_BESIDE_TSC, + tscOnlyReport, tscPlan, withFakeTsc, } from './utils' @@ -247,13 +247,13 @@ describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc'], { cwd: app }) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck staged in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p web/tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.path(app, '.')}`, + ['▶ tsc -p web/tsconfig.json --noEmit'], + 'passed', + ), + ) }) }) diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts index 268bc7e..a699675 100644 --- a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -4,6 +4,7 @@ import { CONFIG_DIR, NOT_COVERED, SKIPPED_BESIDE_TSC, + tscOnlyReport, tscPlan, withFakeTsc, } from './utils' @@ -185,13 +186,13 @@ describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { expect(project.exists(`${app}web/data.json`)).toBe(false) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p web/tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p web/tsconfig.json --noEmit'], + 'passed', + ), + ) }) }) @@ -245,13 +246,13 @@ describe('tsc inputs across the packages of a monorepo', () => { const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc']) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck staged in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p packages/app/tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.dir}`, + ['▶ tsc -p packages/app/tsconfig.json --noEmit'], + 'passed', + ), + ) }, ) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index a6e26e1..f4412b7 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -1,11 +1,19 @@ -import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { + compilerOptions, + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + monorepo, + report, + singleRepo, +} from '../utils/project' import { CODE_WITH_TYPE_ERROR, CONFIG_DIR, NO_EMIT, NOT_COVERED, OUT_DIR, - SKIPPED_BESIDE_TSC, + tscOnlyReport, tscPlan, withFakeTsc, } from './utils' @@ -311,24 +319,18 @@ describe('tsc project references across the packages of a monorepo', () => { const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: 'packages/core' }) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path('packages/core')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path('packages/core')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'passed', + ), + ) }) }) describe('tsc project references with the real compiler in a single repo', () => { - const IN_PLACE = { - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - composite: true, - } + const IN_PLACE = compilerOptions({ composite: true }) const COMPOSITE = { ...IN_PLACE, emitDeclarationOnly: true, outDir: 'dist', rootDir: 'src' } it('builds the library a Vite 4 app imports before checking the app with -p, writing no JavaScript', async () => { @@ -363,13 +365,9 @@ describe('tsc project references with the real compiler in a single repo', () => const passed = await project.uncheck(['--only=tsc']) expect(passed.exitCode).toBe(0) - expect(report(passed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - ...plan, - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, plan, 'passed'), + ) expect(project.git('status', '--porcelain', '--ignored')).toBe( '!! lib/dist/\n!! lib/tsconfig.tsbuildinfo\n!! node_modules/\n', ) @@ -382,13 +380,9 @@ describe('tsc project references with the real compiler in a single repo', () => const failed = await project.uncheck(['--only=tsc']) expect(failed.exitCode).toBe(1) - expect(report(failed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - ...plan, - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, plan, 'failed'), + ) expect(failed.stdout).toContain( "src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", ) @@ -416,13 +410,9 @@ describe('tsc project references with the real compiler in a single repo', () => const passed = await project.uncheck(['--only=tsc']) expect(passed.exitCode).toBe(0) - expect(report(passed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'passed'), + ) expect(project.exists('lib/src/index.d.ts')).toBe(true) expect(project.git('status', '--porcelain')).toBe('') @@ -431,13 +421,9 @@ describe('tsc project references with the real compiler in a single repo', () => const failed = await project.uncheck(['--only=tsc', 'lib/src/index.ts']) expect(failed.exitCode).toBe(1) - expect(report(failed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'failed'), + ) expect(failed.stdout).toContain( "app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", ) @@ -463,26 +449,28 @@ describe('tsc project references with the real compiler in a single repo', () => const full = await project.uncheck(['--only=tsc']) expect(full.exitCode).toBe(1) - expect(report(full.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(full.stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + [ + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', + ], + 'failed', + ), + ) expect(full.stdout).toContain(missing) const staged = await project.uncheck(['staged', '--only=tsc']) expect(staged.exitCode).toBe(1) - expect(report(staged.stdout)).toEqual([ - `uncheck staged in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(staged.stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.dir}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(staged.stdout).toContain(missing) expect(project.exists('vite.config.js')).toBe(false) }) @@ -505,14 +493,13 @@ describe('tsc project references with the real compiler in a single repo', () => const { exitCode, stdout } = await project.uncheck(['--only=tsc']) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b ./-pkg/tsconfig.json ./@app/tsconfig.json', - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + ['▶ tsc -b ./-pkg/tsconfig.json ./@app/tsconfig.json', '▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) for (const folder of ['-pkg', '@app']) { expect(stdout).toContain( @@ -533,9 +520,9 @@ describe('tsc project references with the real compiler in a single repo', () => expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not a workspace root', - '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check --no-error-on-unmatched-pattern', + FULL_OXFMT, '✔ oxfmt passed', '▶ tsc -b tsconfig.json', '✘ tsc failed', diff --git a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts index 77848d1..e951227 100644 --- a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts @@ -1,19 +1,9 @@ import type { Files, ProjectOptions } from '../utils/project' -import { monorepo, report } from '../utils/project' -import { SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' +import { compilerOptions, monorepo, report } from '../utils/project' +import { tscOnlyReport, tscPlan, withFakeTsc } from './utils' -const PACKAGE_CONFIG = { - compilerOptions: { - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - noEmit: true, - }, - include: ['src'], -} +const PACKAGE_CONFIG = { compilerOptions: compilerOptions({ noEmit: true }), include: ['src'] } -/** A workspace whose `app` imports the sources of `core` through its link alone, without project references. */ function linkedMonorepo(files: Files = {}, options?: ProjectOptions) { return monorepo( { @@ -122,13 +112,7 @@ describe('tsc across the packages of a workspace', () => { ]) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - ...plan, - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual(tscOnlyReport(`uncheck in ${project.dir}`, plan, 'failed')) expect(stdout).toContain( "packages/app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", ) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index bf0761e..ea1f5c4 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,4 +1,6 @@ import { + commitOnSide, + compilerOptions, git, LAYOUTS, monorepo, @@ -14,6 +16,7 @@ import { NOT_COVERED, OUT_DIR, SKIPPED_BESIDE_TSC, + tscOnlyReport, tscPlan, withFakeTsc, } from './utils' @@ -58,15 +61,13 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { it('checks a tsconfig.json with merge conflicts once', async () => { const project = withFakeTsc(create) const config = `${app}tsconfig.json` - const include = (folder: string) => (value: Record) => ({ - ...value, + const including = (folder: string) => ({ + ...(JSON.parse(project.read(config)) as object), include: ['src', folder], }) - project.git('checkout', '--quiet', '-b', 'side') - project.update(config, include('side')).commit('side') - project.git('checkout', '--quiet', 'main') - project.update(config, include('main')).commit('main') + commitOnSide(project, { [config]: including('side') }) + project.write({ [config]: including('main') }).commit('main') expect((await run(['git', 'merge', 'side'], { cwd: project.dir })).exitCode).toBe(1) @@ -108,13 +109,13 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { }) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(stdout).toContain("tsconfig.json(1,1): error TS1005: '{' expected.") }) @@ -128,13 +129,13 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { }) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(stdout).toContain( `error TS5083: Cannot read file '${project.path(app, 'tsconfig.json')}'.`, ) @@ -186,26 +187,24 @@ describe('tsc with the real compiler in a single repo', () => { 'src/index.ts': CODE_WITH_TYPE_ERROR, 'scripts/release.ts': CLEAN_CODE, }) - const failedIn = (folder: string) => [ - `uncheck in ${project.path(folder)}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p ../tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ] + const failedInSrc = tscOnlyReport( + `uncheck in ${project.path('src')}`, + ['▶ tsc -p ../tsconfig.json --noEmit'], + 'failed', + ) const diagnostic = "index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." const inside = await project.uncheck(['--only=tsc'], { cwd: 'src' }) expect(inside.exitCode).toBe(1) - expect(report(inside.stdout)).toEqual(failedIn('src')) + expect(report(inside.stdout)).toEqual(failedInSrc) expect(inside.stdout).toContain(`\n${diagnostic}`) const given = await project.uncheck(['--only=tsc', '--cwd', 'src', 'index.ts']) expect(given.exitCode).toBe(1) - expect(report(given.stdout)).toEqual(failedIn('src')) + expect(report(given.stdout)).toEqual(failedInSrc) expect(given.stdout).toContain(`\n${diagnostic}`) const outside = await project.uncheck(['--only=tsc'], { cwd: 'scripts' }) @@ -228,13 +227,8 @@ describe('tsc with the real compiler in a single repo', () => { }, 'web/cypress/e2e.ts': CLEAN_CODE, }).update('tsconfig.json', (config) => ({ ...config, include: ['web/src'] })) - const failed = (plan: ReadonlyArray) => [ - `uncheck in ${project.path('web')}`, - ...SKIPPED_BESIDE_TSC, - ...plan, - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ] + const failed = (plan: ReadonlyArray) => + tscOnlyReport(`uncheck in ${project.path('web')}`, plan, 'failed') const diagnostic = "src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." @@ -261,14 +255,13 @@ describe('tsc with the real compiler in a single repo', () => { const { exitCode, stdout } = await project.uncheck(['--only=tsc']) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p locked/tsconfig.json --noEmit', - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + ['▶ tsc -p locked/tsconfig.json --noEmit', '▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(stdout).toContain( `error TS5058: The specified path does not exist: '${project.path('locked/tsconfig.json')}'.`, ) @@ -278,7 +271,7 @@ describe('tsc with the real compiler in a single repo', () => { it('typechecks a tsconfig.json with -p --noEmit, emitting nothing', async () => { const project = singleRepo({ 'tsconfig.json': { - compilerOptions: { strict: true, module: 'esnext', moduleResolution: 'bundler', types: [] }, + compilerOptions: compilerOptions(), include: ['src'], }, }) @@ -286,13 +279,9 @@ describe('tsc with the real compiler in a single repo', () => { const { exitCode, stdout } = await project.uncheck(['--only=tsc']) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -p tsconfig.json --noEmit'], 'passed'), + ) expect(project.exists('src/index.js')).toBe(false) }) }) @@ -304,13 +293,9 @@ describe('tsc with the real compiler in a monorepo', () => { const passed = await project.uncheck(['--only=tsc']) expect(passed.exitCode).toBe(0) - expect(report(passed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'passed'), + ) expect(project.exists('packages/core/dist/index.d.ts')).toBe(true) project.write({ @@ -321,13 +306,9 @@ describe('tsc with the real compiler in a monorepo', () => { const failed = await project.uncheck(['--only=tsc', 'packages/core/src/index.ts']) expect(failed.exitCode).toBe(1) - expect(report(failed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'failed'), + ) expect(failed.stdout).toContain( "packages/core/src/index.ts(2,3): error TS2322: Type 'string' is not assignable to type 'number'.", ) diff --git a/packages/uncheck/tests/uncheck/utils.ts b/packages/uncheck/tests/uncheck/utils.ts index c020a47..0075010 100644 --- a/packages/uncheck/tests/uncheck/utils.ts +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -77,6 +77,21 @@ export const SKIPPED_BESIDE_TSC = [ '○ oxfmt skipped, not selected by --only', ] +export function tscOnlyReport( + heading: string, + plan: ReadonlyArray, + outcome: 'passed' | 'failed', +): string[] { + return [ + heading, + ...SKIPPED_BESIDE_TSC, + ...plan, + ...(outcome === 'passed' + ? ['✔ tsc passed', '✔ all checks passed (tsc)'] + : ['✘ tsc failed', '✘ 1 of 1 checks failed: tsc']), + ] +} + // oxlint-disable-next-line no-template-curly-in-string export const CONFIG_DIR = '${configDir}' diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 04308a4..8d87c2b 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -221,6 +221,8 @@ export function git(cwd: string, args: ReadonlyArray, env?: Env): string }) } +export const DUBIOUS_OWNERSHIP: Env = { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' } + export function run( command: ReadonlyArray, { cwd, input = '', env }: Omit & { readonly cwd: string }, @@ -411,6 +413,18 @@ export class Project { } } +export function commitWithHooks(project: Project, ...args: ReadonlyArray): Promise { + return run(['git', 'commit', '--quiet', ...args], { cwd: project.dir }) +} + +export function commitOnSide(project: Project, files: Files): Project { + project.git('checkout', '--quiet', '-b', 'side') + project.write(files).commit('side') + project.git('checkout', '--quiet', 'main') + + return project +} + function install(project: Project, tools: ReadonlyArray): void { for (const tool of tools) { project.link(`node_modules/${tool}`, realpathSync(join(PACKAGE, 'node_modules', tool))) @@ -462,7 +476,7 @@ export function project(files: Files = {}, { tools = TOOLS, git = 'commit' }: Pr const OXLINT_CONFIG = { rules: { 'no-var': 'error' } } -function compilerOptions(extra: object = {}) { +export function compilerOptions(extra: object = {}) { return { strict: true, module: 'esnext', @@ -503,6 +517,9 @@ export function singleRepo(files: Files = {}, options?: ProjectOptions): Project ) } +export const UTILS_NOT_FOUND = + "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." + /** A pnpm workspace whose `app` package builds on its `core` package through project references. */ export function monorepo(files: Files = {}, options?: ProjectOptions): Project { const composite = compilerOptions({ @@ -597,3 +614,18 @@ export const LAYOUTS: ReadonlyArray = [ { name: 'single repo', create: singleRepo, app: '', tsc: '▶ tsc -p tsconfig.json --noEmit' }, { name: 'monorepo', create: monorepo, app: 'packages/app/', tsc: '▶ tsc -b tsconfig.json' }, ] + +export const FULL_OXLINT = '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern' + +export const FULL_OXLINT_FIX = + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern' + +export const FULL_OXFMT = '▶ oxfmt --check --no-error-on-unmatched-pattern' + +export const FULL_OXFMT_FIX = '▶ oxfmt --no-error-on-unmatched-pattern' + +export const SKIPPED_FOR_DELETIONS = [ + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', +]