diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index d5f552d..e82ce7e 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -1,5 +1,4 @@ import { posix } from 'node:path' -import process from 'node:process' import { Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' @@ -240,7 +239,7 @@ const readReferences = Effect.fn(function* (configPath: string) { interface InputPattern { readonly spec: string - readonly regex: RegExp + readonly matches: (file: string) => boolean } interface TsconfigInputs { @@ -326,10 +325,10 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { configs: chain.map(({ file }) => file), files: resolve(files), include: includeSpecs.flatMap((spec) => { - const regex = compileGlob(spec, 'files') - return regex === undefined ? [] : [{ spec, regex }] + const matches = compileGlob(spec, 'files') + return matches === undefined ? [] : [{ spec, matches }] }), - exclude: excludeSpecs.map((spec) => ({ spec, regex: compileGlob(spec, 'exclude')! })), + exclude: excludeSpecs.map((spec) => ({ spec, matches: compileGlob(spec, 'exclude')! })), extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), } }) @@ -348,13 +347,13 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean { return false } - if (inputs.exclude.some((pattern) => pattern.regex.test(target))) { + if (inputs.exclude.some((pattern) => pattern.matches(target))) { return false } // JSON files only come in through an `include` that names the extension explicitly. return inputs.include.some( - (pattern) => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json')), + (pattern) => pattern.matches(target) && (!json || pattern.spec.endsWith('.json')), ) } @@ -461,7 +460,6 @@ const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/ const FILES_ASTERISK = '(?:[^./]|(?:\\.(?!min\\.js$))?)*' const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' -const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' /** * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: @@ -470,7 +468,10 @@ const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'd * In `include`, `*` never matches a name ending in `.min.js`; `exclude` patterns also match every * path below them. */ -function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { +function compileGlob( + pattern: string, + usage: 'files' | 'exclude', +): ((file: string) => boolean) | undefined { const components = pattern.replace(/\/+$/, '').split('/') const last = components[components.length - 1]! @@ -499,10 +500,15 @@ function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | unde written = true } - return new RegExp( - `^${source}${usage === 'exclude' ? '(?:$|/)' : '$'}`, - CASE_INSENSITIVE ? 'i' : '', - ) + // tsc ignores case on file systems that do, even in the lookaheads that keep node_modules and .min.js + // out, so `include` matches the way tsc does on either kind and `exclude` keeps case: on any file + // system that selects every project tsc would check, and at worst one more. + const regexes = + usage === 'files' + ? [new RegExp(`^${source}$`), new RegExp(`^${source}$`, 'i')] + : [new RegExp(`^${source}(?:$|/)`)] + + return (file) => regexes.some((regex) => regex.test(file)) } function filesComponent(component: string): string { @@ -546,9 +552,7 @@ const EXPORT_CONDITIONS = new Set(['node', 'require', 'types', 'default']) */ function resolveExports(exports: unknown, subpath: string): string | undefined { const map: Record = - Predicate.isObject(exports) && - !Array.isArray(exports) && - Object.keys(exports).some((key) => key.startsWith('.')) + Predicate.isObject(exports) && Object.keys(exports).some((key) => key.startsWith('.')) ? exports : { '.': exports } diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index fe72fb4..308ddb5 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -97,11 +97,10 @@ export const run = Command.make( if (alreadyContinued) { if (payload.hook_event_name === 'Stop') { - const summary = - report - .split('\n') - .filter((line) => line.startsWith('✘ ')) - .at(-1) ?? '' + const summary = report + .split('\n') + .filter((line) => line.startsWith('✘ ')) + .at(-1)! yield* Console.log( JSON.stringify({ systemMessage: `uncheck still fails: ${summary.slice(2)}` }), diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 7a7ef9d..66b8761 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -146,7 +146,7 @@ export const prepare = Command.make( const { prefix, - paths: [hooks = ''], + paths: [hooks], } = repository.value const inside = prefix.replace(/\/$/, '') const exec = yield* detectExec(cwd) @@ -161,7 +161,7 @@ export const prepare = Command.make( // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h` // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above. - const configured = path.resolve(cwd, hooks) + const configured = path.resolve(cwd, hooks!) const dispatched = path.basename(configured) === '_' && (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false))) diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index 7fc2c7b..dac2182 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -69,13 +69,13 @@ export const staged = Command.make( const [ { prefix, - paths: [folder = '', indexLock = ''], + paths: [folder, indexLock], }, unstaged, ] = yield* Effect.all([gitLocation(cwd, ['uncheck-unstaged', 'index.lock']), rawDiff(cwd)], { concurrency: 'unbounded', }) - const saved = path.resolve(cwd, folder) + const saved = path.resolve(cwd, folder!) const aside = { cwd, saved, prefix } // A run that was killed, or could not put them back, left the only copy of unstaged changes. @@ -129,7 +129,7 @@ export const staged = Command.make( const active = process.env.GIT_INDEX_FILE if (active?.endsWith('.lock') === true) { - const lock = path.resolve(cwd, indexLock) + const lock = path.resolve(cwd, indexLock!) if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { yield* stage({ GIT_INDEX_FILE: lock }) @@ -218,9 +218,9 @@ const rawDiff = (cwd: string, ...args: ReadonlyArray) => const fields = output.split('\0') return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { - const [fromMode = '', toMode = ''] = fields[index * 2]!.slice(1).split(' ') + const [fromMode, toMode] = fields[index * 2]!.slice(1).split(' ') - return { file: fields[index * 2 + 1]!, fromMode, toMode } + return { file: fields[index * 2 + 1]!, fromMode: fromMode!, toMode: toMode! } }) }, ) @@ -274,18 +274,16 @@ interface SetAside { readonly base: string } -/** Runs `git -- ` in batches; with no files, some commands would act on every path. */ +/** Runs `git -- ` in batches, and nothing without files. */ function gitEach( cwd: string, args: ReadonlyArray, files: ReadonlyArray, env?: Readonly>, ) { - return Effect.forEach( - files.length === 0 ? [] : argvBatches(files), - (batch) => git(cwd, [...args, '--', ...batch], env), - { discard: true }, - ) + return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch], env), { + discard: true, + }) } const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { @@ -402,7 +400,7 @@ const merge = Effect.fn(function* ( // 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, [ + const [staged, stored] = (yield* git(cwd, [ 'rev-parse', `:0:${prefix}${file}`, `${before}:${prefix}${file}`, diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index 7fd2a55..5315837 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -23,5 +23,5 @@ export function userError(userMessage: string): Effect.Effect): ReadonlyArray> { - const batches: string[][] = [[]] + const batches: string[][] = [] let length = 0 for (const arg of args) { // Room for the separator and the quotes around an argument with a space. - if (length + arg.length + 3 > MAX_ARGV_LENGTH) { + if (batches.length === 0 || length + arg.length + 3 > MAX_ARGV_LENGTH) { batches.push([]) length = 0 } diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts deleted file mode 100644 index 183b64f..0000000 --- a/packages/uncheck/tests/command.test.ts +++ /dev/null @@ -1,2817 +0,0 @@ -import { Buffer } from 'node:buffer' -import { execFileSync, spawn, spawnSync } from 'node:child_process' -import { once } from 'node:events' -import { - chmodSync, - existsSync, - lstatSync, - mkdirSync, - readdirSync, - readFileSync, - realpathSync, - rmSync, - statSync, - symlinkSync, - writeFileSync, -} from 'node:fs' -import { delimiter, join } from 'node:path' -import process from 'node:process' -import { fileURLToPath } from 'node:url' -import { stripVTControlCharacters } from 'node:util' - -import { NodeServices } from '@effect/platform-node' -import { Console, Effect, Stdio, Stream } from 'effect' -import { Command } from 'effect/unstable/cli' - -import { hooks } from '../src/commands/hooks' -import { prepare } from '../src/commands/prepare' -import { staged } from '../src/commands/staged' -import { uncheck } from '../src/commands/uncheck' -import { CheckFailed } from '../src/errors' -import { middleapi as oxfmtPreset } from '../src/presets/oxfmt' -import { middleapi as oxlintPreset } from '../src/presets/oxlint' -import { fixture } from './fixture' - -const cli = uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])) -const SUBCOMMANDS = new Set(['staged', 'prepare', 'hooks', 'install', 'run']) - -interface RunResult { - readonly result: 'ok' | 'blocked' | CheckFailed - /** Everything logged, one line each, without ANSI styling. */ - readonly stdout: string - readonly stderr: string -} - -/** Runs the CLI in `cwd`; the flag goes after the subcommand names so it lands on the command that runs. */ -async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): Promise { - let verbs = 0 - - while (SUBCOMMANDS.has(args[verbs] ?? '')) { - verbs++ - } - - const argv = [...args.slice(0, verbs), '--cwd', cwd, ...args.slice(verbs)] - const stdout: string[] = [] - const stderr: string[] = [] - - const capture: Console.Console = Object.assign(Object.create(globalThis.console), { - log: (...parts: ReadonlyArray) => { - stdout.push(parts.join(' ')) - }, - error: (...parts: ReadonlyArray) => { - stderr.push(parts.join(' ')) - }, - }) - - const result = await Effect.runPromise( - Command.runWith(cli, { version: '0.0.0' })(argv).pipe( - Effect.map(() => 'ok' as const), - Effect.catchTag('CheckFailed', (error) => Effect.succeed(error)), - Effect.catchTag('StopBlocked', () => Effect.succeed('blocked' as const)), - Effect.provideService(Console.Console, capture), - Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })), - Effect.provide(NodeServices.layer), - ), - ) - - return { result, stdout: text(stdout), stderr: text(stderr) } -} - -/** Joins captured lines the way a terminal would show them, without ANSI styling. */ -function text(lines: string[]): string { - return stripVTControlCharacters(lines.map((line) => `${line}\n`).join('')) -} - -const cliBin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url)) - -const CLAUDE_STOP = JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }) - -const oxlintrc = { rules: { 'no-var': 'error' } } - -const standaloneTsconfig = { - compilerOptions: { - noEmit: true, - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - }, - include: ['src', 'scripts'], -} - -function compositeTsconfig(references: string[] = []) { - return { - compilerOptions: { - composite: true, - declaration: true, - emitDeclarationOnly: true, - outDir: 'dist', - rootDir: 'src', - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - }, - references: references.map((path) => ({ path })), - include: ['src'], - } -} - -describe('uncheck', { timeout: 120_000 }, () => { - it('reports oxlint and oxfmt failures, fixes them with --fix, then passes', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/legacy.ts': 'var count = 1;\nexport { count };\n', - 'src/ugly.ts': 'export const ugly = {a:1,\n b:2}\n', - }) - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'failed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'passed' }, - ]) - expect(check.stdout).toContain('no-var') - expect(check.stdout).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') - expect(check.stdout).toContain('rerun with `--fix` to apply oxlint and oxfmt fixes') - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBe('ok') - expect(fix.stdout).toContain('▶ oxlint --fix\n') - expect(fix.stdout).toContain('▶ oxfmt\n') - expect(readFileSync(join(dir, 'src/legacy.ts'), 'utf8')).toBe( - 'const count = 1;\nexport { count };\n', - ) - expect(readFileSync(join(dir, 'src/ugly.ts'), 'utf8')).toBe( - 'export const ugly = { a: 1, b: 2 };\n', - ) - - const clean = await run(dir) - - expect(clean.result).toBe('ok') - expect( - clean.stdout.startsWith( - `uncheck in ${dir}\n○ sherif skipped, no package.json found\n▶ oxlint\n`, - ), - ).toBe(true) - expect(clean.stdout).toContain('▶ oxlint\n') - expect(clean.stdout).toContain('▶ oxfmt --check\n') - expect(clean.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(clean.stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - }) - - it('builds referenced projects with tsc -b before checking standalone ones with tsc -p', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'scripts/hello.ts': 'export const greeting = "hi";\n', - 'packages/lib/tsconfig.json': compositeTsconfig(), - 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', - 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), - 'packages/app/src/index.ts': - 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', - 'tools/tsconfig.json': standaloneTsconfig, - 'tools/src/index.ts': - 'import { answer } from "../../packages/lib/dist/index";\n\nexport const tool: number = answer;\n', - }) - - const { result, stdout } = await run(dir) - - expect(result).toBe('ok') - expect(stdout).toContain( - '▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tools/tsconfig.json --noEmit\n▶ tsc -p tsconfig.json --noEmit\n', - ) - expect(stdout).not.toContain('packages/lib/tsconfig.json') - - writeFileSync(join(dir, 'packages/lib/src/index.ts'), 'export const answer: number = "42";\n') - - const broken = await run(dir) - - expect(broken.result).toBeInstanceOf(CheckFailed) - expect((broken.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'passed' }, - { name: 'oxfmt', status: 'passed' }, - { name: 'tsc', status: 'failed' }, - ]) - expect(broken.stdout).toContain('packages/lib/src/index.ts') - expect(broken.stdout).toContain('TS2322') - }) - - it('checks standalone projects side by side but prints their output in plan order', async () => { - const dir = fixture( - { - 'packages/a/tsconfig.json': standaloneTsconfig, - 'packages/a/src/index.ts': 'export const a: number = 1;\n', - 'packages/b/tsconfig.json': standaloneTsconfig, - 'packages/b/src/index.ts': 'export const b: number = "2";\n', - 'packages/c/tsconfig.json': standaloneTsconfig, - 'packages/c/src/index.ts': 'export const c: number = 3;\n', - }, - ['typescript'], - ) - - const { result, stdout } = await run(dir, ['--only=tsc']) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toMatch( - /▶ tsc -p packages\/a\/tsconfig\.json --noEmit\n▶ tsc -p packages\/b\/tsconfig\.json --noEmit\npackages\/b\/src\/index\.ts[^▶]*TS2322[^▶]*▶ tsc -p packages\/c\/tsconfig\.json --noEmit\n✘ tsc failed/, - ) - }) - - it('forwards paths to oxlint and oxfmt and narrows tsc to the projects containing them', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'scripts/hello.ts': 'export const greeting = "hi";\n', - 'scripts/legacy.ts': 'var count = 1;\nexport { count };\n', - 'README.md': '# Hi\n', - 'packages/lib/tsconfig.json': compositeTsconfig(), - 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', - 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), - 'packages/app/src/index.ts': - 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', - }) - - const scoped = await run(dir, ['packages/app']) - - expect(scoped.result).toBe('ok') - expect(scoped.stdout).not.toContain('no-var') - expect(scoped.stdout).toContain( - '▶ oxlint --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', - ) - expect(scoped.stdout).toContain( - '▶ oxfmt --check --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', - ) - // The root project's inputs (src, scripts) do not reach into packages/app, so only app is built. - expect(scoped.stdout).toContain('▶ tsc -b packages/app/tsconfig.json\n✔ tsc') - expect(scoped.stdout).not.toContain('tsc -p tsconfig.json') - - const single = await run(dir, ['--fix', 'scripts/hello.ts']) - - expect(single.result).toBe('ok') - expect(single.stdout).toContain( - '▶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n', - ) - expect(single.stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') - expect(single.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(single.stdout).not.toContain('tsc -b') - - const all = await run(dir, ['.']) - - expect(all.stdout).toMatch(/▶ oxlint --no-error-on-unmatched-pattern \[\d+ files\]\n/) - expect(all.stdout).toMatch(/▶ oxfmt --check --no-error-on-unmatched-pattern \[\d+ files\]\n/) - - const docs = await run(dir, ['README.md']) - - expect(docs.result).toBe('ok') - expect(docs.stdout).toContain('▶ oxlint --no-error-on-unmatched-pattern README.md\n') - expect(docs.stdout).toContain('○ tsc skipped, no tsconfig.json covers the given files\n') - expect(docs.stdout).toContain('✔ all checks passed (oxlint, oxfmt)') - }) - - it('fails on paths that match nothing unless told otherwise', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - }) - - await expect(run(dir, ['src/index.ts', 'missing.ts', 'lib/**'])).rejects.toThrow( - /No files match missing.ts, lib\/\*\*/, - ) - - const lenient = await run(dir, [ - '--no-error-on-unmatched-pattern', - 'src/index.ts', - 'missing.ts', - ]) - - expect(lenient.result).toBe('ok') - expect(lenient.stdout).toContain('▶ oxlint --no-error-on-unmatched-pattern src/index.ts\n') - - const nothing = await run(dir, ['--no-error-on-unmatched-pattern', 'missing.ts']) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe( - `uncheck in ${dir}\n○ nothing to check, no files match missing.ts\n`, - ) - }) - - it('discovers tsconfig files through git so ignored folders are skipped', async () => { - const files = { - '.gitignore': 'node_modules\ndist\nignored\n', - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'ignored/tsconfig.json': standaloneTsconfig, - 'ignored/src/index.ts': 'export const answer: number = "42";\n', - } - - const plain = fixture(files, ['typescript']) - const walked = await run(plain) - - expect(walked.result).toBeInstanceOf(CheckFailed) - expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json --noEmit\n') - - const repo = fixture(files, ['typescript']) - execFileSync('git', ['init', '--quiet'], { cwd: repo }) - const tracked = await run(repo) - - expect(tracked.result).toBe('ok') - expect(tracked.stdout).not.toContain('ignored/tsconfig.json') - expect(tracked.stdout).toContain('○ oxlint skipped, not installed\n') - expect(tracked.stdout).toContain('○ oxfmt skipped, not installed\n') - }) - - it('fails on circular project references', async () => { - const dir = fixture( - { - 'packages/a/tsconfig.json': compositeTsconfig(['../b']), - 'packages/a/src/index.ts': 'export const a = 1;\n', - 'packages/b/tsconfig.json': compositeTsconfig(['../a']), - 'packages/b/src/index.ts': 'export const b = 2;\n', - }, - ['typescript'], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain( - '✘ tsc circular project references between packages/a/tsconfig.json, packages/b/tsconfig.json\n', - ) - }) - - it('fails when a tsconfig.json exists but typescript is not installed', async () => { - const dir = fixture( - { 'tsconfig.json': standaloneTsconfig, 'src/index.ts': 'export const answer = 42;\n' }, - [], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect((result as CheckFailed).outcomes.map((outcome) => outcome.status)).toEqual([ - 'skipped', - 'skipped', - 'skipped', - 'failed', - ]) - expect(stdout).toContain('found 1 tsconfig.json but typescript is not installed') - }) - - it('fails when there is nothing to check', async () => { - const dir = fixture({ 'package.json': '{}\n' }, []) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect((result as CheckFailed).outcomes.map((outcome) => outcome.status)).toEqual([ - 'skipped', - 'skipped', - 'skipped', - 'skipped', - ]) - expect(stdout).toContain( - 'nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', - ) - }) - - it.skipIf(process.platform === 'win32')( - 'fails a check whose tool is killed and still runs the others', - async () => { - const dir = fixture( - { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, - 'node_modules/oxlint/bin.js': "process.kill(process.pid, 'SIGKILL')\n", - }, - ['oxfmt', 'typescript'], - ) - - const { result, stdout } = await run(dir) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain( - "▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n", - ) - expect(stdout).toContain('✘ oxlint failed') - expect(stdout).toContain('✔ tsc passed') - expect(stdout).toContain('✘ 1 of 3 checks failed: oxlint') - expect(stdout).not.toContain('PlatformError') - }, - ) -}) - -/** A workspace sherif has something to say about, with its install step off so the fix stays offline. */ -function workspace( - extra: Record = {}, - config: Record = {}, -) { - return fixture( - { - 'package.json': { - name: 'root', - private: true, - packageManager: 'pnpm@10.0.0', - workspaces: ['packages/*'], - devDependencies: { zod: '^3.0.0', react: '^18.0.0' }, - sherif: { noInstall: true, ...config }, - }, - 'packages/a/package.json': { - name: 'a', - version: '1.0.0', - dependencies: { react: '^18.0.0' }, - }, - 'packages/b/package.json': { - name: 'b', - version: '1.0.0', - dependencies: { react: '^17.0.0' }, - }, - ...extra, - }, - ['sherif'], - ) -} - -describe('uncheck sherif', { timeout: 120_000 }, () => { - beforeEach(() => { - vi.stubEnv('CI', undefined) - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - it('checks a workspace root with sherif and fixes it, aligning versions on the highest', async () => { - const dir = workspace() - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes[0]).toEqual({ name: 'sherif', status: 'failed' }) - expect(check.stdout).toContain('▶ sherif\n') - expect(check.stdout).toContain('unordered-dependencies') - expect(check.stdout).toContain('multiple-dependency-versions') - expect(check.stdout).toContain( - '✘ 1 of 1 checks failed: sherif\n rerun with `--fix` to apply sherif fixes', - ) - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBe('ok') - expect(fix.stdout).toContain('▶ sherif --fix --select=highest\n') - expect( - Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).devDependencies), - ).toEqual(['react', 'zod']) - expect(JSON.parse(readFileSync(join(dir, 'packages/b/package.json'), 'utf8'))).toMatchObject({ - dependencies: { react: '^18.0.0' }, - }) - - const clean = await run(dir) - - expect(clean.result).toBe('ok') - expect(clean.stdout).toContain('✔ all checks passed (sherif)') - }) - - it('leaves the version choice to the sherif config when it makes one', async () => { - const dir = workspace({}, { select: 'lowest' }) - - const { result, stdout } = await run(dir, ['--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ sherif --fix\n') - expect(JSON.parse(readFileSync(join(dir, 'packages/a/package.json'), 'utf8'))).toMatchObject({ - dependencies: { react: '^17.0.0' }, - }) - }) - - it('only checks in CI, where sherif refuses to fix', async () => { - vi.stubEnv('CI', 'true') - - const dir = workspace() - - const { result, stdout } = await run(dir, ['--fix']) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain('▶ sherif\n') - expect(stdout).toContain('unordered-dependencies') - expect(stdout).not.toContain('Cannot fix issues inside a CI environment') - }) - - it('runs only for a workspace root and only when a package.json is among the given files', async () => { - const dir = workspace({ 'src/index.ts': 'export const answer = 42;\n' }) - - const code = await run(dir, ['src/index.ts']) - - expect(code.result).toBeInstanceOf(CheckFailed) - expect(code.stdout).toContain('○ sherif skipped, no package.json among the given files\n') - expect(code.stdout).toContain('nothing to check: sherif no package.json among the given files') - - const manifest = await run(dir, ['packages/a/package.json']) - - expect(manifest.result).toBeInstanceOf(CheckFailed) - expect(manifest.stdout).toContain('▶ sherif\n') - - const single = fixture({ 'package.json': { name: 'single', private: true } }, ['sherif']) - const alone = await run(single) - - expect(alone.result).toBeInstanceOf(CheckFailed) - expect(alone.stdout).toContain('○ sherif skipped, not a workspace root\n') - - const nested = fixture( - { - 'package.json': { name: 'nested', private: true }, - 'pnpm-workspace.yaml': 'packages:\n - packages/*\n', - }, - ['sherif'], - ) - const pnpm = await run(nested) - - expect(pnpm.stdout).toContain('▶ sherif\n') - }) -}) - -describe('uncheck check flags', { timeout: 120_000 }, () => { - it('skips a check with --skip and requires it with --require', async () => { - const dir = fixture( - { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: string = 1;\n', - }, - ['oxlint', 'typescript'], - ) - - const skipped = await run(dir, ['--skip=tsc']) - - expect(skipped.result).toBe('ok') - expect(skipped.stdout).toContain('○ oxfmt skipped, not installed\n') - expect(skipped.stdout).toContain('○ tsc skipped, disabled with --skip=tsc\n') - expect(skipped.stdout).toContain('✔ all checks passed (oxlint)') - - const required = await run(dir, ['--require=oxfmt', '--skip=tsc']) - - expect(required.result).toBeInstanceOf(CheckFailed) - expect(required.stdout).toContain('✘ oxfmt not installed\n') - expect(required.stdout).toContain('✘ 1 of 2 checks failed: oxfmt') - - await expect(run(dir, ['--require=tsc', '--skip=tsc'])).rejects.toThrow( - /--require=tsc and --skip=tsc/, - ) - - const none = await run(dir, ['--skip=oxlint', '--skip=oxfmt', '--skip=tsc']) - - expect(none.result).toBeInstanceOf(CheckFailed) - expect(none.stdout).toContain( - 'nothing to check: sherif not installed, oxlint disabled with --skip=oxlint, oxfmt disabled with --skip=oxfmt, tsc disabled with --skip=tsc', - ) - }) - - it('runs only the checks named with --only', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/ugly.ts': 'export const ugly = 1\n', - }) - - const lint = await run(dir, ['--only=oxlint']) - - expect(lint.result).toBe('ok') - expect(lint.stdout).toContain('▶ oxlint\n') - expect(lint.stdout).toContain('○ oxfmt skipped, not selected by --only\n') - expect(lint.stdout).toContain('○ tsc skipped, not selected by --only\n') - expect(lint.stdout).toContain('✔ all checks passed (oxlint)') - - const format = await run(dir, ['--only=oxfmt']) - - expect(format.result).toBeInstanceOf(CheckFailed) - expect((format.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'not selected by --only' }, - { name: 'oxlint', status: 'skipped', reason: 'not selected by --only' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'skipped', reason: 'not selected by --only' }, - ]) - - await expect(run(dir, ['--only=oxlint', '--skip=oxlint'])).rejects.toThrow( - /--only=oxlint and --skip=oxlint/, - ) - await expect(run(dir, ['--only=oxlint', '--only=oxfmt', '--require=tsc'])).rejects.toThrow( - /--require=tsc and --only=oxlint --only=oxfmt/, - ) - }) -}) - -describe('uncheck hooks install', { timeout: 120_000 }, () => { - it('writes stop hook configs for the named agents through the detected package manager', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - - const { result, stdout } = await run(dir, ['hooks', 'install', 'claude', 'cursor', 'copilot']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') - expect(stdout).toContain('✔ Cursor .cursor/hooks.json created\n') - expect(stdout).toContain('pnpm exec uncheck hooks run --fix') - - const hook = 'pnpm exec uncheck hooks run --fix' - - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: hook, timeout: 600 }] }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { stop: [{ command: hook, timeout: 600 }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { - agentStop: [{ type: 'command', bash: hook, powershell: hook, timeoutSec: 600 }], - }, - }) - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - }) - - it('merges into existing settings and keeps other hooks', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - '.claude/settings.json': `{ - // keep me - "permissions": { "allow": ["Bash(pnpm test)"] }, - "hooks": { - "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }], - "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }], - }, -} -`, - }, - [], - ) - const hook = 'npx --no uncheck hooks run --fix --require=tsc --skip=sherif' - - await expect(run(dir, ['hooks', 'install', '--require=tsc'])).rejects.toThrow( - 'Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot', - ) - - const { result, stdout } = await run(dir, [ - 'hooks', - 'install', - 'codebuddy', - 'claude', - '--require=tsc', - '--skip=sherif', - ]) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json created\n') - expect(stdout).toContain(hook) - - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - permissions: { allow: ['Bash(pnpm test)'] }, - hooks: { - PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], - Stop: [ - { hooks: [{ type: 'command', command: 'notify-send done' }] }, - { hooks: [{ type: 'command', command: hook, timeout: 600 }] }, - ], - }, - }) - }) - - it('rejects unknown agents', async () => { - const dir = fixture({ 'package.json': '{}\n' }, []) - - await expect(run(dir, ['hooks', 'install', 'emacs'])).rejects.toThrow() - }) - - it('writes the check flags into the hook command and updates an installed hook', async () => { - const dir = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) - const fast = 'yarn run --silent uncheck hooks run --fix --only=oxlint --only=oxfmt' - - const { result, stdout } = await run(dir, [ - 'hooks', - 'install', - 'claude', - 'copilot', - '--only=oxlint', - '--only=oxfmt', - ]) - - expect(result).toBe('ok') - expect(stdout).toContain(fast) - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: fast, timeout: 600 }] }] }, - }) - - const all = 'yarn run --silent uncheck hooks run --fix' - const again = await run(dir, ['hooks', 'install', 'claude', 'copilot']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(again.stdout).toContain('✔ GitHub Copilot .github/hooks/uncheck.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ type: 'command', command: all, timeout: 600 }] }] }, - }) - expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ - version: 1, - hooks: { - agentStop: [{ type: 'command', bash: all, powershell: all, timeoutSec: 600 }], - }, - }) - - await expect( - run(dir, ['hooks', 'install', 'claude', '--only=oxlint', '--skip=oxlint']), - ).rejects.toThrow(/--only=oxlint and --skip=oxlint/) - }) - - it('updates its own entry in place and leaves strings that only mention the hook alone', async () => { - const mentions = { - _comment: 'the Stop hook runs npx uncheck hooks run --fix', - permissions: { allow: ['Bash(npx uncheck hooks run:*)'] }, - hooks: { - PostToolUse: [ - { - hooks: [ - { - type: 'command', - command: 'cd packages/web && npx uncheck hooks run --fix && notify', - }, - { type: 'command', command: 'npx uncheck hooks run --fix --only=oxfmt' }, - ], - }, - ], - }, - } - const dir = fixture({ 'package.json': '{}\n', '.claude/settings.json': mentions }, []) - const hook = { type: 'command', command: 'npx --no uncheck hooks run --fix', timeout: 600 } - - const { result, stdout } = await run(dir, ['hooks', 'install', 'claude']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - ...mentions, - hooks: { ...mentions.hooks, Stop: [{ hooks: [hook] }] }, - }) - - const older = { - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: 'npx uncheck hooks run --fix --only=oxlint', - statusMessage: 'Checking', - }, - ], - }, - ], - }, - } - - writeFileSync(join(dir, '.claude/settings.json'), JSON.stringify(older)) - - const upgraded = await run(dir, ['hooks', 'install', 'claude']) - - expect(upgraded.stdout).toContain('✔ Claude Code .claude/settings.json updated\n') - expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { Stop: [{ hooks: [{ ...hook, statusMessage: 'Checking' }] }] }, - }) - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - - writeFileSync( - join(dir, '.claude/settings.json'), - JSON.stringify({ hooks: { Stop: [{ hooks: [{ ...hook, timeout: 1800 }] }] } }), - ) - mkdirSync(join(dir, '.github/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.github/hooks/uncheck.json'), - JSON.stringify({ hooks: { agentStop: [{ bash: hook.command, timeout: 1800 }] } }), - ) - - const raised = await run(dir, ['hooks', 'install', 'claude', 'copilot']) - - expect(raised.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - expect( - JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).hooks.agentStop, - ).toEqual([{ type: 'command', bash: hook.command, powershell: hook.command, timeout: 1800 }]) - }) - - it('runs the root binary of a Yarn 2+ workspace from wherever the agent moved to', async () => { - const dir = fixture({ 'package.json': { packageManager: 'yarn@4.18.0' }, 'yarn.lock': '' }, []) - - const { stdout } = await run(dir, ['hooks', 'install', 'claude']) - - expect(stdout).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix ') - - const again = await run(dir, ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - - const workspace = committed({ - 'package.json': { packageManager: 'yarn@4.18.0', devDependencies: { uncheck: '*' } }, - 'yarn.lock': '', - 'packages/app/package.json': { name: 'app' }, - 'packages/lib/package.json': { name: 'lib', devDependencies: { uncheck: '*' } }, - }) - - expect( - (await run(join(workspace, 'packages/app'), ['hooks', 'install', 'claude'])).stdout, - ).toContain('The hook runs yarn run -T --silent uncheck hooks run --fix --dir=packages/app ') - expect( - (await run(join(workspace, 'packages/lib'), ['hooks', 'install', 'claude'])).stdout, - ).toContain('The hook runs yarn run --silent uncheck hooks run --fix --dir=packages/lib ') - }) - - it('refuses a config that is not a JSON object and leaves every file as it is', async () => { - const broken = '{\n permissions: { "deny": ["Read(.env)"] },\n "model": "opus"\n}\n' - const dir = fixture( - { 'package.json': '{}\n', '.cursor/hooks.json': broken, '.github/hooks/uncheck.json': '[]' }, - [], - ) - - await expect(run(dir, ['hooks', 'install', 'claude', 'cursor'])).rejects.toThrow( - '.cursor/hooks.json has InvalidSymbol on line 2, fix it and run again', - ) - await expect(run(dir, ['hooks', 'install', 'copilot'])).rejects.toThrow( - '.github/hooks/uncheck.json is not a JSON object', - ) - expect(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8')).toBe(broken) - expect(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8')).toBe('[]') - expect(existsSync(join(dir, '.claude/settings.json'))).toBe(false) - - writeFileSync(join(dir, '.cursor/hooks.json'), '\n') - - const { result, stdout } = await run(dir, ['hooks', 'install', 'cursor']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Cursor .cursor/hooks.json updated\n') - }) - - it('names the directory below the top of the repository in the hook command', async () => { - const dir = committed({ 'package.json': '{}\n', 'packages/web/package.json': '{}\n' }) - mkdirSync(join(dir, 'packages/my web')) - - const { result, stdout } = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n') - expect( - JSON.parse(readFileSync(join(dir, 'packages/web/.claude/settings.json'), 'utf8')), - ).toMatchObject({ - hooks: { - Stop: [{ hooks: [{ command: 'npx --no uncheck hooks run --fix --dir=packages/web' }] }], - }, - }) - - const again = await run(join(dir, 'packages/web'), ['hooks', 'install', 'claude']) - - expect(again.stdout).toContain('✔ Claude Code .claude/settings.json unchanged\n') - await expect(run(join(dir, 'packages/my web'), ['hooks', 'install', 'claude'])).rejects.toThrow( - /cannot name packages\/my web/, - ) - await expect(run(join(dir, 'packages/web'), ['hooks', 'install', 'copilot'])).rejects.toThrow( - /Copilot reads \.github\/hooks only at the top of the repository/, - ) - }) -}) - -/** Runs git in `dir` with a throwaway identity and returns what it printed. */ -function gitIn(dir: string, ...args: string[]): string { - return execFileSync( - 'git', - ['-c', 'user.name=uncheck', '-c', 'user.email=uncheck@example.com', ...args], - { - cwd: dir, - encoding: 'utf8', - }, - ) -} - -/** A git repository with one clean commit, so later edits show up as working-tree changes. */ -function committed(files: Record) { - const dir = fixture(files) - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - - return dir -} - -/** Commits `files` on a new `side` branch and goes back to the branch before it. */ -function commitOnSide(dir: string, files: Record) { - gitIn(dir, 'checkout', '--quiet', '-b', 'side') - - for (const [file, content] of Object.entries(files)) { - writeFileSync(join(dir, file), content) - } - - gitIn(dir, 'add', '-A') - gitIn(dir, 'commit', '--quiet', '-m', 'side') - gitIn(dir, 'checkout', '--quiet', '-') -} - -const clean = { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - 'src/other.ts': 'export const other = 2;\n', -} - -function installPreCommitHook(dir: string, args: string, folder = '.') { - mkdirSync(join(dir, '.git/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.git/hooks/pre-commit'), - `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${cliBin}" ${args}) || exit 1\n`, - { mode: 0o755 }, - ) -} - -describe('uncheck hooks run', { timeout: 120_000 }, () => { - it('checks the files changed since the last commit and sends the agent back once', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - writeFileSync(join(dir, 'src/fresh.ts'), 'export const fresh = 3;\n') - - const claude = await run(dir, ['hooks', 'run', '--fix'], CLAUDE_STOP) - - expect(claude.result).toBe('blocked') - expect(claude.stdout).toBe('') - expect(claude.stderr).toContain( - '▶ oxlint --fix --no-error-on-unmatched-pattern src/fresh.ts src/index.ts\n', - ) - expect(claude.stderr).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(claude.stderr).toContain('TS2322') - expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1;\n', - ) - - const continuing = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: true }), - ) - - expect(continuing.result).toBe('ok') - expect(JSON.parse(continuing.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(continuing.stderr).toContain('TS2322') - - const cursor = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'stop', loop_count: 0 }), - ) - - expect(cursor.result).toBe('ok') - expect((JSON.parse(cursor.stdout) as { followup_message: string }).followup_message).toContain( - 'TS2322', - ) - - const copilot = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ stopReason: 'end_turn', stop_hook_active: false }), - ) - - expect(copilot.result).toBe('ok') - expect(JSON.parse(copilot.stdout)).toMatchObject({ decision: 'block' }) - - const copilotInClaudeFormat = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop', stop_reason: 'end_turn', stop_hook_active: false }), - ) - - expect(copilotInClaudeFormat.result).toBe('ok') - expect(JSON.parse(copilotInClaudeFormat.stdout)).toMatchObject({ decision: 'block' }) - }) - - it('passes a change no selected check covers, unless that check is required', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'README.md'), '# Project\n') - - const optional = await run(dir, ['hooks', 'run', '--fix', '--only=tsc'], CLAUDE_STOP) - - expect(optional.result).toBe('ok') - expect(optional.stderr).toContain('○ nothing to check') - - const required = await run( - dir, - ['hooks', 'run', '--fix', '--only=tsc', '--require=tsc'], - CLAUDE_STOP, - ) - - expect(required.result).toBe('blocked') - }) - - it('never takes a deleted file for a pattern that matches its neighbours', async () => { - const dir = committed({ - ...clean, - 'app/[id].ts': 'export const id = 1;\n', - 'app/i.ts': 'export const i = 1\n', - }) - rmSync(join(dir, 'app/[id].ts')) - - const { result, stderr } = await run( - dir, - ['hooks', 'run', '--fix', '--only=oxfmt'], - CLAUDE_STOP, - ) - - expect(result).toBe('ok') - expect(stderr).not.toContain('app/i.ts') - expect(readFileSync(join(dir, 'app/i.ts'), 'utf8')).toBe('export const i = 1\n') - }) - - it('checks the top of the repository wherever the agent moved to, or the directory in --dir', async () => { - const dir = committed({ - ...clean, - 'docs/guide.md': '# Guide\n', - 'packages/app/src/index.ts': 'export const app = 1;\n', - 'packages/web/src/index.ts': 'export const web = 1;\n', - }) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1;\n') - const moved = spawnSync(process.execPath, [cliBin, 'hooks', 'run', '--fix'], { - cwd: join(dir, 'docs'), - input: CLAUDE_STOP, - encoding: 'utf8', - }) - - expect(moved.status).toBe(2) - expect(moved.stderr).toContain('TS2322') - - writeFileSync(join(dir, 'packages/app/src/index.ts'), 'export const app = 2\n') - writeFileSync(join(dir, 'packages/web/src/index.ts'), 'export const web = 2\n') - - const { result, stderr } = await run( - join(dir, 'packages/web'), - ['hooks', 'run', '--fix', '--only=oxfmt', '--dir=packages/app'], - CLAUDE_STOP, - ) - - expect(result).toBe('ok') - expect(stderr).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(readFileSync(join(dir, 'packages/app/src/index.ts'), 'utf8')).toBe( - 'export const app = 2;\n', - ) - expect(readFileSync(join(dir, 'packages/web/src/index.ts'), 'utf8')).toBe( - 'export const web = 2\n', - ) - }) - - it('reports a --dir that names nothing instead of sending the agent back', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer = 1\n') - - await expect( - run(dir, ['hooks', 'run', '--fix', '--dir=packages/gone'], CLAUDE_STOP), - ).rejects.toThrow(/--dir=packages\/gone names nothing/) - }) - - it('leaves a Cursor turn the user stopped, or that failed, alone', async () => { - const dir = committed(clean) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - - for (const status of ['aborted', 'error']) { - const { result, stdout } = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'stop', status, loop_count: 0 }), - ) - - expect(result).toBe('ok') - expect(stdout).toBe('') - } - - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1\n', - ) - }) - - it('stays silent when nothing changed and passes quietly when the changes are clean', async () => { - const dir = committed(clean) - - const nothing = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop' }), - ) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe('') - expect(nothing.stderr).toBe('') - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - - const ok = await run( - dir, - ['hooks', 'run', '--fix'], - JSON.stringify({ hook_event_name: 'Stop' }), - ) - - expect(ok.result).toBe('ok') - expect(ok.stdout).toBe('') - expect(ok.stderr).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/other.ts\n') - expect(ok.stderr).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - - const fast = await run( - dir, - ['hooks', 'run', '--fix', '--only=oxlint', '--only=oxfmt'], - CLAUDE_STOP, - ) - - expect(fast.result).toBe('ok') - expect(fast.stdout).toBe('') - expect(fast.stderr).toContain('○ tsc skipped, not selected by --only\n') - expect(fast.stderr).toContain('✔ all checks passed (oxlint, oxfmt)') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: string = 1;\n', - ) - }) - - it('checks everything under the directory outside a git repository', async () => { - const dir = fixture(clean) - - const { result, stderr } = await run(dir, ['hooks', 'run'], '{}') - - expect(result).toBe('ok') - expect(stderr).toContain('▶ oxlint\n') - expect(stderr).toContain('✔ all checks passed (oxlint, oxfmt, tsc)') - }) -}) - -describe('uncheck staged', { timeout: 120_000 }, () => { - it('checks the staged files, stages the fixes and keeps unstaged changes out of the way', async () => { - const dir = committed(clean) - - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\n', - ) - gitIn(dir, 'add', 'src/index.ts') - // An unstaged hunk in the staged file, an unstaged file and an untracked one: none of them is checked. - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\nexport const three = 3;\n', - ) - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3\n') - writeFileSync(join(dir, 'src/fresh.ts'), 'var fresh = 4\n') - - const { result, stdout } = await run(dir, ['staged', '--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain( - `uncheck staged in ${dir}\n○ unstaged changes of src/index.ts set aside until the checks finish\n`, - ) - expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)\n') - expect(stdout).toContain( - '✔ staged the fixes to src/index.ts\n○ unstaged changes of src/index.ts restored\n', - ) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\n', - ) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\nexport const three = 3;\n', - ) - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 3\n') - expect(readFileSync(join(dir, 'src/fresh.ts'), 'utf8')).toBe('var fresh = 4\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe( - 'MM src/index.ts\n M src/other.ts\n?? src/fresh.ts\n', - ) - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('undoes the fixes when they conflict with unstaged changes, so nothing is lost', async () => { - const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) - const dir = committed({ ...clean, 'src/far.ts': far.join('') }) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - // The unstaged change touches the line the formatter rewrites. - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - writeFileSync(join(dir, 'src/far.ts'), ['export const f0 = 0;\n', ...far.slice(1)].join('')) - gitIn(dir, 'add', 'src/far.ts') - writeFileSync( - join(dir, 'src/far.ts'), - ['export const f0 = 0;\n', ...far.slice(1, -1), 'export const f29 = 30;\n'].join(''), - ) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 42\n') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 43\n', - ) - // The fixes to the other staged file are undone too, so a commit attempt never half applies. - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 2\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 2\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('fails when the fixes undo every staged change, unless empty commits are allowed', async () => { - const dir = committed({ - ...clean, - 'src/index.ts': 'export const answer: number = 42;\nexport const two = 2;\n', - }) - - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\n', - ) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - 'export const answer: number = 42\nexport const two = 2;\nexport const three = 3;\n', - ) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /fixes undid every staged change, so the commit would be empty/, - ) - - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 42;\nexport const two = 2;\nexport const three = 3;\n', - ) - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - - const allowed = await run(dir, ['staged', '--fix', '--allow-empty']) - - expect(allowed.result).toBe('ok') - expect(allowed.stdout).toContain('✔ staged the fixes to src/other.ts\n') - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - - const unborn = fixture({ ...clean, 'src/index.ts': 'export const answer: number = 42\n' }) - - gitIn(unborn, 'init', '--quiet') - gitIn(unborn, 'add', '.') - - const first = await run(unborn, ['staged', '--fix']) - - expect(first.result).toBe('ok') - expect(first.stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(unborn, 'show', ':src/index.ts')).toBe('export const answer: number = 42;\n') - }) - - it('lets a merge through when the fixes turn its tree back into what HEAD has', async () => { - const dir = committed(clean) - - commitOnSide(dir, { 'src/other.ts': 'export const other = 3;\n' }) - gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n') - gitIn(dir, 'add', 'src/other.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ staged the fixes to src/other.ts\n') - expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('') - }) - - it('only reports without --fix, stages the fixes even when a check fails, and needs staged files', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') - gitIn(dir, 'add', 'src/index.ts') - - const check = await run(dir, ['staged']) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json among the given files' }, - { name: 'oxlint', status: 'passed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'failed' }, - ]) - expect(check.stdout).toContain('▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts\n') - expect(check.stdout).not.toContain('staged the fixes') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: string = 1\n') - - const fix = await run(dir, ['staged', '--fix']) - - expect(fix.result).toBeInstanceOf(CheckFailed) - expect(fix.stdout).toContain('TS2322') - expect(fix.stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: string = 1;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M src/index.ts\n') - - gitIn(dir, 'commit', '--quiet', '-m', 'wip') - - const nothing = await run(dir, ['staged', '--fix']) - - expect(nothing.result).toBe('ok') - expect(nothing.stdout).toBe(`uncheck staged in ${dir}\n○ nothing to check, no staged files\n`) - - await expect(run(fixture(clean), ['staged'])).rejects.toThrow(/needs a git repository/) - }) - - it('puts unstaged lines back where they were after the fixes move or change lines around them', async () => { - const split = 'export const list = [\n 1,\n 2,\n];\n' - const joined = 'export const list = [1, 2];\n' - const staged = - "\nexport function f() {\n return 1;\n}\n\nexport function g() {\n return 'g'\n}\n" - const fixed = staged.replace("'g'", '"g";') - const edit = (text: string) => - `${text.replace(' return 1;', ' // in f\n return 1;')}\nexport const z = 1;\n` - const dir = committed({ 'packages/app/src/index.ts': joined }) - const file = join(dir, 'packages/app/src/index.ts') - - writeFileSync(file, split + staged) - gitIn(dir, 'add', '.') - writeFileSync(file, split + edit(staged)) - - const { result } = await run(join(dir, 'packages/app'), ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe(joined + fixed) - expect(readFileSync(file, 'utf8')).toBe(joined + edit(fixed)) - }) - - it('stages only the staged files, even when their names look like patterns', async () => { - const dir = committed({ - 'app/[id]/page.ts': 'export const id = 1;\n', - 'app/i/page.ts': 'export const i = 1;\n', - }) - - writeFileSync(join(dir, 'app/[id]/page.ts'), 'export const id = 2\n') - writeFileSync(join(dir, 'app/i/page.ts'), 'export const i = 2\n') - gitIn(dir, '--literal-pathspecs', 'add', 'app/[id]/page.ts') - vi.stubEnv('GIT_GLOB_PATHSPECS', '1') - vi.stubEnv('GIT_ICASE_PATHSPECS', '1') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']).finally(() => - vi.unstubAllEnvs(), - ) - - expect(result).toBe('ok') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M app/[id]/page.ts\n M app/i/page.ts\n') - }) - - it('never stages the commit a submodule has checked out but not staged', async () => { - const dir = committed(clean) - const sub = join(dir, 'sub') - - mkdirSync(sub) - gitIn(sub, 'init', '--quiet') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'one') - gitIn(dir, 'add', 'sub') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'two') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - gitIn(dir, 'add', 'sub', 'src/index.ts') - gitIn(sub, 'commit', '--quiet', '--allow-empty', '-m', 'three') - const staged = gitIn(dir, 'rev-parse', ':sub') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'rev-parse', ':sub')).toBe(staged) - }) - - it('merges what git stores, so line endings a formatter rewrites are no change', async () => { - const dir = committed(clean) - const crlf = (text: string) => text.replaceAll('\n', '\r\n') - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - - gitIn(dir, 'config', 'core.autocrlf', 'true') - writeFileSync(join(dir, 'src/index.ts'), crlf(`export const answer: number = 43\n${lines}`)) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - crlf(`export const answer: number = 43\n${lines}export const d = 4;\n`), - ) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const answer: number = 43;\n${lines}`) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - crlf(`export const answer: number = 43;\n${lines}export const d = 4;\n`), - ) - }) - - it('reads every unstaged change right, a rename among them', async () => { - const dir = committed({ ...clean, 'src/aaa.ts': 'export const aaa = 1;\n' }) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src/other.ts') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\nexport const more = 4;\n') - gitIn(dir, 'mv', 'src/aaa.ts', 'src/moved.ts') - gitIn(dir, 'reset', '--quiet', '--', 'src/aaa.ts', 'src/moved.ts') - gitIn(dir, 'add', '--intent-to-add', 'src/moved.ts') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 3;\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe( - 'export const other = 3;\nexport const more = 4;\n', - ) - }) - - it('merges by plain text whatever merge driver the repository sets', async () => { - const dir = committed(clean) - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), '*.ts merge=ours\n') - gitIn(dir, 'config', 'merge.ours.driver', 'true') - writeFileSync(join(dir, 'src/index.ts'), `export const answer: number = 42\n${lines}`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync( - join(dir, 'src/index.ts'), - `export const answer: number = 42\n${lines.replace('c = 3', 'c = 30')}`, - ) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - `export const answer: number = 42;\n${lines.replace('c = 3', 'c = 30')}`, - ) - }) - - it('refuses to set aside an unstaged change that is not an edit to a file', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src/other.ts') - rmSync(join(dir, 'src/other.ts')) - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /src\/other\.ts are not edits to a file/, - ) - expect(existsSync(join(dir, 'src/other.ts'))).toBe(false) - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 3;\n') - }) - - it('keeps the unstaged changes when setting them aside fails halfway', async () => { - const dir = committed(clean) - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), 'src/other.ts filter=flaky\n') - gitIn(dir, 'config', 'filter.flaky.clean', 'cat') - gitIn( - dir, - 'config', - 'filter.flaky.smudge', - 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi', - ) - gitIn(dir, 'config', 'filter.flaky.required', 'true') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43;\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') - gitIn(dir, 'add', 'src') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4;\n') - - await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout-index -f \[2 paths\] failed/) - - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 44;\n', - ) - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 4;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/index.ts\nMM src/other.ts\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }) - - it('keeps the unstaged changes aside and stops the commit when they cannot be put back', async () => { - const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' - const dir = committed({ ...clean, 'src/index.ts': `export const a = 1;\n${rest} 1;\n` }) - - mkdirSync(join(dir, '.git/info'), { recursive: true }) - writeFileSync(join(dir, '.git/info/attributes'), 'src/index.ts filter=once\n') - gitIn(dir, 'config', 'filter.once.clean', 'cat') - gitIn( - dir, - 'config', - 'filter.once.smudge', - 'if [ -e .git/smudged ]; then exit 1; else touch .git/smudged; cat; fi', - ) - gitIn(dir, 'config', 'filter.once.required', 'true') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /The unstaged changes of src\/index\.ts could not be put back/, - ) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) - expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe( - `export const a = 2\n${rest} 2;\n`, - ) - }) - - it('keeps an edit saved to a partially staged file while the checks run', async () => { - const lines = Array.from({ length: 30 }, (_, index) => `export const v${index} = ${index};\n`) - const dir = fixture( - { - 'src/lines.ts': lines.join(''), - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, - 'node_modules/oxlint/lint.js': - "const fs = require('node:fs')\nfs.writeFileSync('src/lines.ts', fs.readFileSync('src/lines.ts', 'utf8').replace('v15 = 15', 'v15 = 1500'))\n", - }, - [], - ) - const file = join(dir, 'src/lines.ts') - const staged = ['export const v0 = 100;\n', ...lines.slice(1)] - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - - writeFileSync(file, staged.join('')) - gitIn(dir, 'add', 'src/lines.ts') - writeFileSync(file, [...staged.slice(0, -1), 'export const v29 = 2900;\n'].join('')) - - const { result } = await run(dir, ['staged', '--only=oxlint']) - - expect(result).toBe('ok') - expect(readFileSync(file, 'utf8')).toBe( - [ - ...staged.slice(0, 15), - 'export const v15 = 1500;\n', - ...staged.slice(16, -1), - 'export const v29 = 2900;\n', - ].join(''), - ) - expect(gitIn(dir, 'show', ':src/lines.ts')).toBe(staged.join('')) - }) - - it('stages the fixes in the index `git commit ` leaves behind, not only in its own', () => { - const dir = committed(clean) - - installPreCommitHook(dir, 'staged --fix --only=oxfmt') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - gitIn(dir, 'commit', '--quiet', '-m', 'fix', 'src/index.ts') - - expect(gitIn(dir, 'show', 'HEAD:src/index.ts')).toBe('export const answer: number = 43;\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('') - }) - - it('runs from a package folder of a linked worktree, whose hook git hands GIT_DIR', () => { - const dir = committed({ - ...clean, - '.gitignore': 'node_modules\nworktrees\n', - 'packages/app/src/index.ts': 'export const app = 1;\n', - }) - const worktree = join(dir, 'worktrees/wt') - - installPreCommitHook(dir, 'staged --fix --only=oxfmt', 'packages/app') - gitIn(dir, 'worktree', 'add', '--quiet', worktree) - symlinkSync(join(dir, 'node_modules'), join(worktree, 'node_modules')) - writeFileSync(join(worktree, 'packages/app/src/index.ts'), 'export const app = 2\n') - gitIn(worktree, 'commit', '--quiet', '-am', 'fix') - - expect(gitIn(worktree, 'show', 'HEAD:packages/app/src/index.ts')).toBe( - 'export const app = 2;\n', - ) - expect(gitIn(worktree, 'status', '--porcelain')).toBe('') - }) - - it('stops instead of overwriting unstaged changes an earlier run left behind', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43;\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n') - mkdirSync(join(dir, '.git/uncheck-unstaged/src'), { recursive: true }) - writeFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'left behind') - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow( - /An earlier run left the unstaged versions of your files in .*uncheck-unstaged/, - ) - expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe( - 'left behind', - ) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 44;\n', - ) - }) - - it.skipIf(process.platform === 'win32')( - 'checks a symlink turned into a file, never what a staged symlink points to', - async () => { - const dir = committed(clean) - - symlinkSync('other.ts', join(dir, 'src/link.ts')) - gitIn(dir, 'add', 'src/link.ts') - gitIn(dir, 'commit', '--quiet', '-m', 'link') - rmSync(join(dir, 'src/link.ts')) - writeFileSync(join(dir, 'src/link.ts'), 'export const link = 1\n') - symlinkSync('other.ts', join(dir, 'src/alias.ts')) - gitIn(dir, 'add', 'src/link.ts', 'src/alias.ts') - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3\n') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts\n') - expect(stdout).toContain('✔ staged the fixes to src/link.ts\n') - expect(gitIn(dir, 'show', ':src/link.ts')).toBe('export const link = 1;\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 3\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe( - 'A src/alias.ts\nT src/link.ts\n M src/other.ts\n', - ) - }, - ) - - it('checks only the files of a merge that differ from the side merged in', async () => { - const dir = committed(clean) - - commitOnSide(dir, { - 'src/theirs.ts': 'export const theirs = 1\n', - 'src/other.ts': 'export const other = 3;\n', - }) - gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side') - - expect((await run(dir, ['staged', '--fix', '--only=oxfmt'])).stdout).toContain( - '○ nothing to check, every staged file comes from the branch being merged in\n', - ) - - writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4\n') - gitIn(dir, 'add', 'src/other.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts\n') - expect(gitIn(dir, 'show', ':src/other.ts')).toBe('export const other = 4;\n') - expect(gitIn(dir, 'show', ':src/theirs.ts')).toBe('export const theirs = 1\n') - }) - - it('undoes or stages only what the fixes changed, so a merge can bring in files outside a sparse checkout', async () => { - const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - - commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) - gitIn(dir, 'sparse-checkout', 'set', 'src') - gitIn(dir, 'merge', '--quiet', '--squash', 'side') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('M lib/lib.ts\nMM src/index.ts\n') - - gitIn(dir, 'add', 'src/index.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(existsSync(join(dir, 'lib'))).toBe(false) - expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 2;\n') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 43;\n') - }) - - it('stages fixes to a conflict a merge left outside a sparse checkout', async () => { - const dir = committed({ ...clean, 'lib/lib.ts': 'export const lib = 1;\n' }) - - commitOnSide(dir, { 'lib/lib.ts': 'export const lib = 2;\n' }) - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 3;\n') - gitIn(dir, 'commit', '--quiet', '-am', 'main') - gitIn(dir, 'sparse-checkout', 'set', 'src') - spawnSync('git', ['-c', 'user.name=u', '-c', 'user.email=u@e', 'merge', '--quiet', 'side'], { - cwd: dir, - }) - writeFileSync(join(dir, 'lib/lib.ts'), 'export const lib = 4\n') - gitIn(dir, 'add', '--sparse', 'lib/lib.ts') - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':lib/lib.ts')).toBe('export const lib = 4;\n') - }) - - it('undoes fixes to a file git keeps with CRLF line endings rather than turn them to LF', async () => { - const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' - const dir = committed({ ...clean, '.oxlintrc.json': oxlintrc, 'src/legacy.ts': lines }) - const file = join(dir, 'src/legacy.ts') - const unstaged = `var c = 3;\r\n${lines}export { c };\r\nexport const d = 4;\r\n` - - writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') - gitIn(dir, 'add', '.gitattributes') - gitIn(dir, 'commit', '--quiet', '-m', 'attributes') - writeFileSync(file, `var c = 3;\r\n${lines}export { c };\r\n`) - gitIn(dir, 'add', 'src/legacy.ts') - writeFileSync(file, unstaged) - - await expect(run(dir, ['staged', '--fix', '--only=oxlint'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/legacy\.ts and were undone/, - ) - expect(readFileSync(file, 'utf8')).toBe(unstaged) - }) - - it('sees no change in a file git keeps with CRLF line endings under text=auto', async () => { - const lines = 'export const a = 1;\r\nexport const b = 2;\r\n' - const dir = committed({ ...clean, 'src/legacy.ts': lines }) - const file = join(dir, 'src/legacy.ts') - - writeFileSync(join(dir, '.gitattributes'), '* text=auto\n') - gitIn(dir, 'add', '.gitattributes') - gitIn(dir, 'commit', '--quiet', '-m', 'attributes') - gitIn(dir, 'config', 'core.safecrlf', 'true') - writeFileSync(file, `${lines}export const c = 3;\r\n`) - gitIn(dir, 'add', 'src/legacy.ts') - writeFileSync(file, `${lines}export const c = 3;\r\nexport const d = 4;\r\n`) - - const { result } = await run(dir, ['staged', '--only=oxlint']) - - expect(result).toBe('ok') - expect(readFileSync(file, 'utf8')).toBe( - `${lines}export const c = 3;\r\nexport const d = 4;\r\n`, - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/legacy.ts\n') - }) - - it('sets unstaged changes aside and back without running the post-checkout hook', async () => { - const dir = committed(clean) - - mkdirSync(join(dir, '.git/hooks'), { recursive: true }) - writeFileSync( - join(dir, '.git/hooks/post-checkout'), - '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n', - { mode: 0o755 }, - ) - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n') - - await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow( - /fixes conflict with the unstaged changes of src\/index\.ts and were undone/, - ) - - expect(existsSync(join(dir, '.git/post-checkout-ran'))).toBe(false) - expect(gitIn(dir, 'show', ':src/index.ts')).toBe('export const answer: number = 42\n') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - 'export const answer: number = 43\n', - ) - }) - - it('only reports what sherif finds, since its fixes reach beyond the staged files', async () => { - const dir = workspace() - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync( - join(dir, 'packages/a/package.json'), - JSON.stringify({ name: 'a', version: '1.0.1', dependencies: { react: '^18.0.0' } }), - ) - gitIn(dir, 'add', 'packages/a/package.json') - vi.stubEnv('CI', undefined) - - const { result, stdout } = await run(dir, ['staged', '--fix']).finally(() => vi.unstubAllEnvs()) - - expect(result).toBeInstanceOf(CheckFailed) - expect(stdout).toContain('▶ sherif\n') - expect(gitIn(dir, 'status', '--porcelain')).toBe('M packages/a/package.json\n') - }) - - it('passes a commit no check has anything to do with, unless a check is required', async () => { - const dir = committed({ ...clean, 'README.md': '# readme\n' }) - - writeFileSync(join(dir, 'README.md'), '# readme\n\nmore\n') - gitIn(dir, 'add', 'README.md') - - const docs = await run(dir, ['staged', '--only=tsc']) - - expect(docs.result).toBe('ok') - expect(docs.stdout).toContain( - '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files\n', - ) - - const required = await run(dir, ['staged', '--only=tsc', '--require=tsc']) - - expect(required.result).toBeInstanceOf(CheckFailed) - expect(required.stdout).toContain('✘ tsc no tsconfig.json covers the given files\n') - }) - - it('checks and fixes staged files whose names start with ! or -', async () => { - const dir = committed({ ...clean, 'x.ts': 'export const x = 1;\n' }) - - writeFileSync(join(dir, '!x.ts'), 'export const bang = 1\n') - writeFileSync(join(dir, '-x.ts'), 'export const dash = 1\n') - writeFileSync(join(dir, 'x.ts'), 'export const x = 2\n') - gitIn(dir, 'add', '--', '!x.ts', '-x.ts', 'x.ts') - - const { result, stdout } = await run(dir, ['staged', '--fix', '--only=oxlint', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt)\n') - expect(stdout).toContain('✔ staged the fixes to !x.ts -x.ts x.ts\n') - expect(gitIn(dir, 'show', ':./!x.ts')).toBe('export const bang = 1;\n') - expect(gitIn(dir, 'show', ':./-x.ts')).toBe('export const dash = 1;\n') - expect(gitIn(dir, 'show', ':x.ts')).toBe('export const x = 2;\n') - }) - - it.skipIf(process.platform === 'win32')( - 'puts unstaged changes back when a closed terminal hangs up during a slow check', - async () => { - const dir = fixture( - { - ...clean, - 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' }, - 'node_modules/oxlint/lint.js': - "require('node:fs').writeFileSync('node_modules/started', '')\nsetTimeout(() => {}, 30_000)\n", - }, - [], - ) - const file = join(dir, 'src/index.ts') - - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync(file, 'export const answer: number = 43;\n') - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(file, 'export const answer: number = 43;\nexport const more = 1;\n') - const check = spawn(process.execPath, [cliBin, 'staged', '--only=oxlint'], { - cwd: dir, - stdio: 'ignore', - }) - const exited = once(check, 'exit') - onTestFinished(() => { - check.kill('SIGKILL') - }) - - await vi.waitFor(() => expect(existsSync(join(dir, 'node_modules/started'))).toBe(true), { - timeout: 10_000, - }) - check.kill('SIGHUP') - check.kill('SIGHUP') - - expect(await exited).toEqual([130, null]) - expect(readFileSync(file, 'utf8')).toBe( - 'export const answer: number = 43;\nexport const more = 1;\n', - ) - expect(gitIn(dir, 'status', '--porcelain')).toBe('MM src/index.ts\n') - expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false) - }, - ) -}) - -describe('uncheck staged in a package', { timeout: 120_000 }, () => { - it('checks and stages from inside a package, the way the hook of a monorepo does', async () => { - const dir = committed({ - '.oxlintrc.json': oxlintrc, - 'src/root.ts': 'export const root = 1;\n', - 'packages/app/tsconfig.json': standaloneTsconfig, - 'packages/app/src/index.ts': 'export const answer: number = 42;\n', - }) - - writeFileSync(join(dir, 'packages/app/src/index.ts'), 'export const answer: number = 42\n') - writeFileSync(join(dir, 'src/root.ts'), 'export const root = 11\n') - gitIn(dir, 'add', 'packages/app/src/index.ts', 'src/root.ts') - - const { result, stdout } = await run(join(dir, 'packages/app'), ['staged', '--fix']) - - expect(result).toBe('ok') - // Staged files outside the package are another line's business, so they are neither checked nor fixed. - expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n') - expect(stdout).toContain('✔ staged the fixes to src/index.ts\n') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( - 'export const answer: number = 42;\n', - ) - expect(gitIn(dir, 'show', ':src/root.ts')).toBe('export const root = 11\n') - }) - - it('puts unstaged changes back with the line endings the package sets', async () => { - const crlf = (text: string) => text.replaceAll('\n', '\r\n') - const lines = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' - const dir = committed({ - 'packages/app/.gitattributes': '*.ts text eol=crlf\n', - 'packages/app/src/index.ts': crlf(`export const answer: number = 42;\n${lines}`), - }) - const file = join(dir, 'packages/app/src/index.ts') - - writeFileSync(file, crlf(`export const answer: number = 43\n${lines}`)) - gitIn(dir, 'add', '.') - writeFileSync(file, crlf(`export const answer: number = 43\n${lines}export const d = 4;\n`)) - - const { result } = await run(join(dir, 'packages/app'), ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':packages/app/src/index.ts')).toBe( - `export const answer: number = 43;\n${lines}`, - ) - expect(readFileSync(file, 'utf8')).toBe( - crlf(`export const answer: number = 43;\n${lines}export const d = 4;\n`), - ) - }) - - it('reports what git refused to do instead of crashing', async () => { - const dir = committed(clean) - - writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n') - gitIn(dir, 'add', 'src/index.ts') - // A lock left behind by another git process makes every write to the index fail. - writeFileSync(join(dir, '.git/index.lock'), '') - - await expect(run(dir, ['staged', '--fix'])).rejects.toThrow(/git .* failed:/) - }) - - it.skipIf(process.platform === 'win32')( - 'puts unstaged changes back in a repository whose path has a newline', - async () => { - const dir = join(fixture({}), 'new\nline') - const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e =' - - mkdirSync(join(dir, 'src'), { recursive: true }) - writeFileSync(join(dir, 'src/index.ts'), `export const a = 1;\n${rest} 1;\n`) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'add', '.') - gitIn(dir, 'commit', '--quiet', '-m', 'init') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`) - gitIn(dir, 'add', 'src/index.ts') - writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`) - - const { result } = await run(dir, ['staged', '--fix', '--only=oxfmt']) - - expect(result).toBe('ok') - expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`) - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - `export const a = 2;\n${rest} 2;\n`, - ) - expect((await run(dir, ['hooks', 'install', 'claude'])).result).toBe('ok') - }, - ) -}) - -describe('uncheck prepare', { timeout: 120_000 }, () => { - const header = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' - - let globalConfig = '' - - beforeEach(() => { - globalConfig = join(fixture({ '.gitconfig': '' }, []), '.gitconfig') - vi.stubEnv('GIT_CONFIG_GLOBAL', globalConfig) - vi.stubEnv('GIT_CONFIG_NOSYSTEM', '1') - }) - - afterEach(() => { - vi.unstubAllEnvs() - }) - - it('writes the pre-commit hook through the detected package manager and updates it in place', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - await expect(run(dir, ['prepare'])).rejects.toThrow(/Pass --pre-commit/) - expect(existsSync(hook)).toBe(false) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit created\n') - expect(stdout).toContain('The hook runs pnpm exec uncheck staged --fix before every commit') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged --fix || exit 1\n`) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0o111) - } - - const again = await run(dir, ['prepare', '--pre-commit']) - - expect(again.result).toBe('ok') - expect(again.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n') - - const fast = await run(dir, ['prepare', '--pre-commit', '--only=oxlint', '--only=oxfmt']) - - expect(fast.result).toBe('ok') - expect(fast.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix --only=oxlint --only=oxfmt || exit 1\n`, - ) - - const allowEmpty = await run(dir, ['prepare', '--pre-commit', '--allow-empty']) - - expect(allowEmpty.result).toBe('ok') - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix --allow-empty || exit 1\n`, - ) - - const checkOnly = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(checkOnly.result).toBe('ok') - expect(checkOnly.stdout).toContain('The hook runs pnpm exec uncheck staged before every commit') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged || exit 1\n`) - - await expect( - run(dir, ['prepare', '--pre-commit', '--only=oxlint', '--skip=oxlint']), - ).rejects.toThrow(/--only=oxlint and --skip=oxlint/) - }) - - it('keeps one line per package, drops duplicates and leaves lines it did not write alone', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/app/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const app = '(cd "packages/app" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1' - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/app'), ['prepare', '--pre-commit', '--only=oxlint']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n${app}\n`, - ) - - // Preparing the root again leaves the line of the nested package alone. - const root = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(root.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe(`${header}pnpm exec uncheck staged || exit 1\n${app}\n`) - - const mine = 'echo "runs uncheck staged"' - - writeFileSync( - hook, - `${header}${mine}\npnpm exec uncheck staged --fix\npnpm test\nnpx uncheck staged --skip=tsc\n`, - ) - - const deduped = await run(dir, ['prepare', '--pre-commit']) - - expect(deduped.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - // The second copy goes, the line that only mentions the command and the unrelated one stay. - expect(readFileSync(hook, 'utf8')).toBe( - `${header}${mine}\npnpm exec uncheck staged --fix || exit 1\npnpm test\n`, - ) - - const settled = await run(dir, ['prepare', '--pre-commit']) - - expect(settled.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n') - - const chained = 'npx uncheck staged --only=oxfmt && pnpm test' - const advisory = 'pnpm exec uncheck staged --fix || echo "not blocking"' - - writeFileSync(hook, `#!/bin/sh\n${chained}\n${advisory}\n`) - - const handWritten = await run(dir, ['prepare', '--pre-commit']) - - expect(handWritten.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(readFileSync(hook, 'utf8')).toBe( - `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${chained}\n${advisory}\n`, - ) - }) - - it.skipIf(process.platform === 'win32')( - 'writes a hook that runs each line from the top of the working tree and fails when any line fails', - async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/a/package.json': '{}\n', - 'packages/b/package.json': '{}\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const top = realpathSync(dir) - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/a'), ['prepare', '--pre-commit', '--only=oxlint']) - await run(join(dir, 'packages/b'), ['prepare', '--pre-commit']) - - const bin = fixture( - { pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\ntest ! -e fail\n' }, - [], - ) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - - function commit() { - writeFileSync(log, '') - - const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log }, - }) - - return { status, ran: readFileSync(log, 'utf8').split('\n').filter(Boolean) } - } - - const root = `${top} exec uncheck staged --fix` - const a = `${top}/packages/a exec uncheck staged --fix --only=oxlint` - const b = `${top}/packages/b exec uncheck staged --fix` - - expect(commit()).toEqual({ status: 0, ran: [root, a, b] }) - - writeFileSync(join(dir, 'fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [root] }) - - rmSync(join(dir, 'fail')) - writeFileSync(join(dir, 'packages/a/fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [root, a] }) - }, - ) - - it('keeps the line of every package when they all prepare at once, as a workspace install does', async () => { - const packages = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] - const folders = ['.', ...packages.map((name) => `packages/${name}`)] - const dir = fixture( - { - 'pnpm-lock.yaml': '', - ...Object.fromEntries(folders.map((folder) => [`${folder}/package.json`, '{}\n'])), - }, - [], - ) - gitIn(dir, 'init', '--quiet') - - const exits = await Promise.all( - folders.map((folder) => { - const prepared = spawn(process.execPath, [cliBin, 'prepare', '--pre-commit'], { - cwd: join(dir, folder), - stdio: 'ignore', - }) - - return once(prepared, 'exit') - }), - ) - - expect(exits.map(([code]) => code)).toEqual(folders.map(() => 0)) - expect(readFileSync(join(dir, '.git/hooks/pre-commit'), 'utf8').split('\n').sort()).toEqual( - [ - ...header.split('\n'), - 'pnpm exec uncheck staged --fix || exit 1', - ...packages.map( - (name) => `(cd "packages/${name}" && pnpm exec uncheck staged --fix) || exit 1`, - ), - ].sort(), - ) - expect(readdirSync(join(dir, '.git/hooks')).filter((name) => name.endsWith('.lock'))).toEqual( - [], - ) - }) - - it.skipIf(process.platform === 'win32')( - 'swaps in the new hook whole, so a commit already running it finishes the old one', - async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/a/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const top = realpathSync(dir) - - await run(dir, ['prepare', '--pre-commit']) - await run(join(dir, 'packages/a'), ['prepare', '--pre-commit']) - - const bin = fixture( - { - pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\nwhile [ ! -e "$GO" ]; do sleep 0.05; done\n', - }, - [], - ) - const log = join(bin, 'log') - const go = join(bin, 'go') - chmodSync(join(bin, 'pnpm'), 0o755) - writeFileSync(log, '') - - const commit = spawn('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log, GO: go }, - stdio: 'ignore', - }) - const exited = once(commit, 'exit') - onTestFinished(() => writeFileSync(go, '')) - - await vi.waitFor(() => expect(readFileSync(log, 'utf8')).not.toBe(''), { timeout: 10_000 }) - - const { stdout } = await run(dir, ['prepare', '--pre-commit', '--only=oxlint']) - - writeFileSync(go, '') - - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect((await exited)[0]).toBe(0) - expect(readFileSync(log, 'utf8').split('\n').filter(Boolean)).toEqual([ - `${top} exec uncheck staged --fix`, - `${top}/packages/a exec uncheck staged --fix`, - ]) - }, - ) - - it.skipIf(process.platform === 'win32')( - 'writes the script a symlinked hook points to, even one not there yet, and keeps the link', - async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'scripts/pre-commit': '#!/bin/sh\npnpm test\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - symlinkSync('../../scripts/pre-commit', hook) - - const { stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n') - expect(lstatSync(hook).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dir, 'scripts/pre-commit'), 'utf8')).toBe( - '#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\npnpm test\n', - ) - expect(statSync(hook).mode & 0o777).toBe(0o755) - - rmSync(hook) - symlinkSync('../../scripts/later', hook) - - const dangling = await run(dir, ['prepare', '--pre-commit']) - - expect(dangling.stdout).toContain('✔ pre-commit .git/hooks/pre-commit created\n') - expect(lstatSync(hook).isSymbolicLink()).toBe(true) - expect(readFileSync(join(dir, 'scripts/later'), 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n`, - ) - expect(statSync(hook).mode & 0o777).toBe(0o755) - }, - ) - - it('updates the lines older versions wrote, whichever package prepares next', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/a/package.json': '{}\n', - 'packages/b/package.json': '{}\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - writeFileSync( - hook, - [ - `${header}pnpm exec uncheck staged --fix`, - 'pnpm test', - 'cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint', - 'cd "packages/b" && pnpm exec uncheck staged --fix', - '', - ].join('\n'), - ) - - const { stdout } = await run(join(dir, 'packages/b'), ['prepare', '--pre-commit', '--no-fix']) - - expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - [ - `${header}pnpm exec uncheck staged --fix || exit 1`, - 'pnpm test', - '(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', - '(cd "packages/b" && pnpm exec uncheck staged) || exit 1', - '', - ].join('\n'), - ) - - const again = await run(join(dir, 'packages/b'), ['prepare', '--pre-commit', '--no-fix']) - - expect(again.stdout).toContain(`✔ pre-commit ${hook} unchanged\n`) - }) - - it('switches the lines older versions wrote to the current runner in place', async () => { - const npm = fixture({ 'package.json': '{}\n', 'package-lock.json': '{}\n' }, []) - gitIn(npm, 'init', '--quiet') - const npmHook = join(npm, '.git/hooks/pre-commit') - writeFileSync(npmHook, `${header}npx uncheck staged --fix || exit 1\npnpm test\n`) - - await run(npm, ['prepare', '--pre-commit']) - - expect(readFileSync(npmHook, 'utf8')).toBe( - `${header}npx --no uncheck staged --fix || exit 1\npnpm test\n`, - ) - - const yarn = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, []) - gitIn(yarn, 'init', '--quiet') - const yarnHook = join(yarn, '.git/hooks/pre-commit') - writeFileSync(yarnHook, '#!/bin/sh\nyarn uncheck staged --fix\npnpm test\n') - - await run(yarn, ['prepare', '--pre-commit']) - - expect(readFileSync(yarnHook, 'utf8')).toBe( - '#!/bin/sh\nyarn run --silent uncheck staged --fix || exit 1\npnpm test\n', - ) - }) - - it('takes the runner from the packageManager field and reports an unwritable hook', async () => { - const dir = fixture({ 'package.json': { packageManager: 'bun@1.2.0' } }, []) - gitIn(dir, 'init', '--quiet') - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain( - 'The hook runs bunx --no-install uncheck staged --fix before every commit', - ) - - const blocked = fixture({ 'package.json': '{}\n' }, []) - gitIn(blocked, 'init', '--quiet') - mkdirSync(join(blocked, '.git/hooks/pre-commit'), { recursive: true }) - - // A `prepare` script that fails would fail the install, so it reports and carries on. - const refused = await run(blocked, ['prepare', '--pre-commit']) - - expect(refused.result).toBe('ok') - expect(refused.stdout).toContain('✘ pre-commit .git/hooks/pre-commit not written,') - expect( - readdirSync(join(blocked, '.git/hooks')).filter( - (name) => name.includes('uncheck') || name.endsWith('.lock'), - ), - ).toEqual([]) - }) - - it('adds itself to an existing hook, enters a nested project and does nothing outside git', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'packages/app/package.json': '{}\n', 'packages/app/yarn.lock': '' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - writeFileSync(hook, '#!/bin/sh\necho hi', { mode: 0o600 }) - - const { result, stdout } = await run(join(dir, 'packages/app'), ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - '#!/bin/sh\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\necho hi', - ) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o777).toBe(0o755) - } - - const plain = fixture({ 'package.json': '{}\n' }, []) - const skipped = await run(plain, ['prepare', '--pre-commit']) - - expect(skipped.result).toBe('ok') - expect(skipped.stdout).toBe('○ no git repository found, nothing to prepare\n') - }) - - it.skipIf(process.platform === 'win32')( - 'runs before the commands of an existing hook, so they still fail it and cannot skip it', - async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const bin = fixture({ pnpm: '#!/bin/sh\necho "$*" >> "$LOG"\ntest "$1" = exec\n' }, []) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`) - const setup = - '#!/bin/sh\n# lint\nexport HOOKS="$(dirname "$0")"\n[ -s "$HOOKS/env" ] && \\. "$HOOKS/env"\n' - writeFileSync(hook, `${setup}export CI=1 && exec pnpm lint-staged\n`) - - await run(dir, ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${setup}pnpm exec uncheck staged --fix || exit 1\nexport CI=1 && exec pnpm lint-staged\n`, - ) - - const { status } = spawnSync('sh', ['.git/hooks/pre-commit'], { - cwd: dir, - env: { ...process.env, LOG: log }, - }) - - expect(status).toBe(1) - expect(readFileSync(log, 'utf8')).toBe('exec uncheck staged --fix\nlint-staged\n') - }, - ) - - it('runs before a setup line that continues onto the next, not inside it', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - for (const setup of [ - 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n', - 'A="\n. b\n"\n', - "A='\n. b\n'\n", - ]) { - writeFileSync(hook, `#!/bin/sh\n${setup}npx lint-staged\n`) - - await run(dir, ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${setup}npx lint-staged\n`, - ) - } - }) - - it('leaves a hook in another language alone and says what it should run', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - const script = '#!/usr/bin/env node\nconsole.log("checked")\n' - writeFileSync(hook, script, { mode: 0o755 }) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - '✘ pre-commit .git/hooks/pre-commit not written, it is not a shell script, have it run `pnpm exec uncheck staged --fix || exit 1` yourself\n', - ) - expect(readFileSync(hook, 'utf8')).toBe(script) - - const binary = Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0, 0xff]) - writeFileSync(hook, binary) - - expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain('not a shell script') - expect(readFileSync(hook)).toEqual(binary) - - if (process.platform !== 'win32' && process.getuid?.() !== 0) { - writeFileSync(hook, script) - chmodSync(hook, 0) - - expect((await run(dir, ['prepare', '--pre-commit'])).stdout).toContain( - '✘ pre-commit .git/hooks/pre-commit not written, EACCES', - ) - chmodSync(hook, 0o755) - expect(readFileSync(hook, 'utf8')).toBe(script) - } - }) - - it('writes nothing for a package whose folder name sh would expand', async () => { - const dir = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/a$b/package.json': '{}\n' }, - [], - ) - gitIn(dir, 'init', '--quiet') - const hook = join(dir, '.git/hooks/pre-commit') - - const { result, stdout } = await run(join(dir, 'packages/a$b'), ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - `✘ pre-commit ${hook} not written, sh would misread the folder name "packages/a$b" between double quotes\n`, - ) - expect(existsSync(hook)).toBe(false) - }) - - it('writes the hook the husky 9 and Vite+ dispatcher runs, not their generated shim', async () => { - const dispatcher = [ - 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', - '[ ! -f "$s" ] && exit 0', - 'export PATH="node_modules/.bin:$PATH"', - 'sh -e "$s" "$@"', - 'exit $?', - '', - ].join('\n') - const shim = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/app/package.json': '{}\n', - '.husky/_/h': dispatcher, - '.husky/_/pre-commit': shim, - '.husky/pre-commit': 'pnpm test\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'config', 'core.hooksPath', '.husky/_') - chmodSync(join(dir, '.husky/_/pre-commit'), 0o755) - const hook = join(dir, '.husky/pre-commit') - const app = '(cd "packages/app" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1' - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .husky/pre-commit updated\n') - - const nested = await run(join(dir, 'packages/app'), [ - 'prepare', - '--pre-commit', - '--only=oxlint', - ]) - - expect(nested.stdout).toContain(`✔ pre-commit ${hook} updated\n`) - expect(readFileSync(hook, 'utf8')).toBe( - `pnpm exec uncheck staged --fix || exit 1\n${app}\npnpm test\n`, - ) - expect(readFileSync(join(dir, '.husky/_/pre-commit'), 'utf8')).toBe(shim) - - const again = await run(dir, ['prepare', '--pre-commit']) - - expect(again.stdout).toContain('✔ pre-commit .husky/pre-commit unchanged\n') - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0) - - const bin = fixture( - { pnpm: '#!/bin/sh\necho "$(pwd -P) $*" >> "$LOG"\ntest ! -e fail\n' }, - [], - ) - const log = join(bin, 'log') - chmodSync(join(bin, 'pnpm'), 0o755) - - function commit() { - writeFileSync(log, '') - - const { status } = spawnSync( - 'git', - [ - '-c', - 'user.name=uncheck', - '-c', - 'user.email=uncheck@example.com', - 'commit', - '--allow-empty', - '--quiet', - '--message=test', - ], - { - cwd: dir, - env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH}`, LOG: log }, - }, - ) - - return { status, ran: readFileSync(log, 'utf8').split('\n').filter(Boolean) } - } - - const top = realpathSync(dir) - - expect(commit()).toEqual({ - status: 0, - ran: [ - `${top} exec uncheck staged --fix`, - `${top}/packages/app exec uncheck staged --fix --only=oxlint`, - `${top} test`, - ], - }) - - writeFileSync(join(dir, 'fail'), '') - - expect(commit()).toEqual({ status: 1, ran: [`${top} exec uncheck staged --fix`] }) - - chmodSync(hook, 0o755) - - const committed = await run(dir, ['prepare', '--pre-commit', '--no-fix']) - - expect(committed.stdout).toContain('✔ pre-commit .husky/pre-commit updated\n') - expect(statSync(hook).mode & 0o777).toBe(0o755) - } - - const vite = fixture( - { 'package.json': '{}\n', 'pnpm-lock.yaml': '', '.vite-hooks/_/h': dispatcher }, - [], - ) - gitIn(vite, 'init', '--quiet') - gitIn(vite, 'config', 'core.hooksPath', '.vite-hooks/_') - - const created = await run(vite, ['prepare', '--pre-commit']) - - expect(created.stdout).toContain('✔ pre-commit .vite-hooks/pre-commit created\n') - expect(readFileSync(join(vite, '.vite-hooks/pre-commit'), 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n`, - ) - expect(existsSync(join(vite, '.vite-hooks/_/pre-commit'))).toBe(false) - }) - - it('writes into a core.hooksPath set by hand as it is, even one holding an `h` script', async () => { - const dir = fixture( - { - 'package.json': '{}\n', - 'pnpm-lock.yaml': '', - 'packages/app/package.json': '{}\n', - '.githooks/h': 'echo "help"\n', - }, - [], - ) - gitIn(dir, 'init', '--quiet') - gitIn(dir, 'config', 'core.hooksPath', '.githooks') - const hook = join(dir, '.githooks/pre-commit') - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') - - await run(join(dir, 'packages/app'), ['prepare', '--pre-commit']) - - expect(readFileSync(hook, 'utf8')).toBe( - `${header}pnpm exec uncheck staged --fix || exit 1\n(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1\n`, - ) - expect(existsSync(join(dir, '.git/hooks/pre-commit'))).toBe(false) - expect(existsSync(join(dir, 'pre-commit'))).toBe(false) - - if (process.platform !== 'win32') { - expect(statSync(hook).mode & 0o111).toBe(0o111) - } - }) - - it('leaves the core.hooksPath of the global git config alone, since every repository runs it', async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const shared = fixture({ 'pre-commit': '#!/bin/sh\necho "scanning for secrets"\n' }, []) - gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared) - - const { result, stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(result).toBe('ok') - expect(stdout).toBe( - `✘ pre-commit ${join(shared, 'pre-commit')} not written, core.hooksPath is set in the global git config, so every repository runs it\n`, - ) - expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe( - '#!/bin/sh\necho "scanning for secrets"\n', - ) - - gitIn(dir, 'config', 'core.hooksPath', '.githooks') - - const local = await run(dir, ['prepare', '--pre-commit']) - - expect(local.stdout).toContain('✔ pre-commit .githooks/pre-commit created\n') - }) - - it.skipIf(process.platform === 'win32')( - 'leaves a global core.hooksPath alone with a git too old to say where a setting comes from', - async () => { - const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - gitIn(dir, 'init', '--quiet') - const shared = fixture({ 'pre-commit': '#!/bin/sh\n' }, []) - const included = join(fixture({ 'work.gitconfig': '' }, []), 'work.gitconfig') - gitIn(dir, 'config', '--file', globalConfig, 'include.path', included) - gitIn(dir, 'config', '--file', included, 'core.hooksPath', shared) - const git = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() - const bin = fixture( - { - git: `#!/bin/sh\ncase "$*" in *--show-scope*) echo "error: unknown option" >&2; exit 129;; esac\nexec "${git}" "$@"\n`, - }, - [], - ) - chmodSync(join(bin, 'git'), 0o755) - vi.stubEnv('PATH', `${bin}${delimiter}${process.env.PATH}`) - - const { stdout } = await run(dir, ['prepare', '--pre-commit']) - - expect(stdout).toContain('not written, core.hooksPath is set in the global git config') - expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe('#!/bin/sh\n') - }, - ) -}) - -describe('uncheck presets', { timeout: 120_000 }, () => { - it('lints with the middleapi oxlint preset and formats with the middleapi oxfmt preset', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintPreset, - '.oxfmtrc.json': oxfmtPreset, - 'src/index.ts': - 'import { a } from "./lib";\nimport { b } from "./lib";\nconsole.log(a, b)\nexport const enum Level { Low }\n', - 'src/lib.ts': 'export const a = 1\nexport const b = 2\n', - 'src/warn.ts': - "import { a } from './lib'\nimport { b } from './lib'\n\nexport function pause() {\n debugger\n return a + b\n}\n", - 'src/bugs.ts': [ - 'export function parse() {', - ' try { JSON.parse("x") }', - ' catch (error) { throw new Error("parse failed") }', - '}', - 'export function assign(a: number, b: number) { a -= a - b; return a }', - 'export function collect(xs: number[]) { let out: number[] = []; for (const x of xs) { out = [...out, x] } return out }', - 'export function confuse(a: string | null, b: string) { return a! == b }', - 'export class Recurse { get v(): number { return this.v } }', - 'export function fill() { return new Array(3).fill([]) }', - 'export function negate(a: boolean, b: boolean) { return !a === b }', - 'export class Construct { constructor() { return { x: 1 } } }', - '', - ].join('\n'), - }) - - const check = await run(dir) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect((check.result as CheckFailed).outcomes).toEqual([ - { name: 'sherif', status: 'skipped', reason: 'no package.json found' }, - { name: 'oxlint', status: 'failed' }, - { name: 'oxfmt', status: 'failed' }, - { name: 'tsc', status: 'skipped', reason: 'no tsconfig.json found' }, - ]) - expect(check.stdout).toContain('eslint(no-console)') - expect(check.stdout).toContain('import(no-duplicates)') - expect(check.stdout).toContain('oxc(no-const-enum)') - - for (const rule of [ - 'eslint(preserve-caught-error)', - 'oxc(misrefactored-assign-op)', - 'oxc(no-accumulating-spread)', - 'typescript(no-confusing-non-null-assertion)', - 'unicorn(no-accessor-recursion)', - 'unicorn(no-array-fill-with-reference-type)', - 'unicorn(no-negation-in-equality-check)', - 'eslint(no-constructor-return)', - ]) { - expect(check.stdout).toContain(rule) - } - - // `warn` rules, a default one and `import/no-duplicates`, report without failing the run; the - // output format depends on the environment (a terminal, CI or an agent), so only names are matched - const warned = await run(dir, ['--only=oxlint', 'src/warn.ts']) - - expect(warned.result).toBe('ok') - expect(warned.stdout).toContain('eslint(no-debugger)') - expect(warned.stdout).toContain('import(no-duplicates)') - - const fix = await run(dir, ['--fix']) - - expect(fix.result).toBeInstanceOf(CheckFailed) - expect(fix.stdout).toContain('eslint(no-console)') - expect(fix.stdout).not.toContain('import(no-duplicates)') - expect(fix.stdout).not.toContain('oxc(no-const-enum)') - expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe( - "import { a, b } from './lib'\nconsole.log(a, b)\nexport enum Level {\n Low,\n}\n", - ) - }) - - it('type checks with the middleapi tsconfig presets', async () => { - const dir = fixture( - { - 'tsconfig.json': { extends: 'uncheck/tsconfig/middleapi/lib', include: ['src'] }, - 'src/index.ts': [ - 'export function identity(value) {', - ' return value', - '}', - 'export function first(items: string[]): string {', - ' return items[0]', - '}', - '', - ].join('\n'), - 'src/use.ts': - "import { first } from './index.ts'\n\nexport const name: string = first(['a'])\n", - }, - ['typescript'], - ) - symlinkSync(fileURLToPath(new URL('..', import.meta.url)), join(dir, 'node_modules', 'uncheck')) - - const check = await run(dir, ['--only=tsc', 'src/index.ts']) - - expect(check.result).toBeInstanceOf(CheckFailed) - expect(check.stdout).toContain('▶ tsc -p tsconfig.json --noEmit') - // `strict` and `noUncheckedIndexedAccess` come from the base preset, through the lib one - expect(check.stdout).toContain('error TS7006') - expect(check.stdout).toContain('error TS2322') - // Node runs TypeScript only through imports that name the .ts file. - expect(check.stdout).not.toContain('TS5097') - }) -}) - -describe('uncheck cli', { timeout: 120_000 }, () => { - it.skipIf(process.platform === 'win32')( - 'prints a file system failure as an error and has only its own global flags', - () => { - const dir = fixture({ '.claude': '' }, []) - const spawnCli = (...args: string[]) => - spawnSync(process.execPath, [cliBin, ...args], { cwd: dir, encoding: 'utf8' }) - - const install = spawnCli('hooks', 'install', 'claude') - - expect(install.status).toBe(1) - expect(install.stdout).toBe('') - expect(install.stderr).toContain(join(realpathSync(dir), '.claude', 'settings.json')) - - for (const flag of ['--log-level', '--wizard']) { - const refused = spawnCli(flag) - - expect(refused.status).toBe(1) - expect(stripVTControlCharacters(refused.stderr)).toContain(`Unrecognized flag: ${flag}`) - } - }, - ) -}) diff --git a/packages/uncheck/tests/files.test.ts b/packages/uncheck/tests/files.test.ts deleted file mode 100644 index 4154527..0000000 --- a/packages/uncheck/tests/files.test.ts +++ /dev/null @@ -1,207 +0,0 @@ -import { execFileSync } from 'node:child_process' -import { chmodSync, mkdirSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' -import process from 'node:process' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { existingFiles, listProjectFiles, resolvePaths } from '../src/files' -import { fixture } from './fixture' - -const project = { - '.gitignore': 'node_modules\ndist\n', - 'src/a.ts': '', - 'src/b.ts': '', - 'src/sub/c.ts': '', - 'docs/readme.md': '', - 'app/[id].ts': '', - 'dist/out.js': '', -} - -function resolve(dir: string, paths: string[]) { - return Effect.runPromise( - Effect.provide( - Effect.flatMap(Effect.cached(listProjectFiles(dir)), (projectFiles) => - resolvePaths(paths, dir, projectFiles), - ), - NodeServices.layer, - ), - ) -} - -describe('resolvePaths', () => { - it('turns files, directories, globs and negations into one file list', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['src/a.ts'])).toEqual({ files: ['src/a.ts'], unmatched: [] }) - expect(await resolve(dir, ['src'])).toEqual({ - files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['./src/', 'src/a.ts'])).toEqual({ - files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['src/**/*.ts', '!src/sub'])).toEqual({ - files: ['src/a.ts', 'src/b.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['**/*.md'])).toEqual({ files: ['docs/readme.md'], unmatched: [] }) - // An existing path is not read as a glob, so route files with brackets can be named. - expect(await resolve(dir, ['app/[id].ts'])).toEqual({ files: ['app/[id].ts'], unmatched: [] }) - expect(await resolve(dir, ['.'])).toEqual({ - files: [ - '.gitignore', - '.prettierignore', - 'app/[id].ts', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ], - unmatched: [], - }) - }) - - it('reports patterns that match nothing and keeps ignored files out unless named', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['nope.ts', 'src/**/*.tsx', 'dist'])).toEqual({ - files: [], - unmatched: ['nope.ts', 'src/**/*.tsx', 'dist'], - }) - expect(await resolve(dir, ['src/a.ts', 'missing/'])).toEqual({ - files: ['src/a.ts'], - unmatched: ['missing/'], - }) - // An explicitly named file counts even when git ignores it. - expect(await resolve(dir, ['dist/out.js'])).toEqual({ files: ['dist/out.js'], unmatched: [] }) - }) - - it('walks the tree outside a git repository', async () => { - const dir = fixture(project, []) - - expect(await resolve(dir, ['src', 'dist/**'])).toEqual({ - files: ['dist/out.js', 'src/a.ts', 'src/b.ts', 'src/sub/c.ts'], - unmatched: [], - }) - }) - - it('walks past folders it cannot read and never into linked folders', async () => { - const dir = fixture(project, []) - symlinkSync('.', join(dir, 'loop')) - symlinkSync('..', join(dir, 'src/up')) - const locked = join(dir, 'locked') - mkdirSync(locked) - - if (process.platform !== 'win32') { - chmodSync(locked, 0) - } - - try { - expect(await resolve(dir, ['.'])).toEqual({ - files: [ - 'app/[id].ts', - 'dist/out.js', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ], - unmatched: [], - }) - } finally { - chmodSync(locked, 0o755) - } - }) - - it('lets globs and exclusions match dot files the way directories include them', async () => { - const dir = fixture( - { - ...project, - '.github/workflows/ci.yml': '', - '.vscode/settings.json': '', - 'package.json': '{}', - 'src/.env.ts': '', - }, - [], - ) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['**/*.yml'])).toEqual({ - files: ['.github/workflows/ci.yml'], - unmatched: [], - }) - expect(await resolve(dir, ['src/**'])).toEqual(await resolve(dir, ['src'])) - expect(await resolve(dir, ['src/{.,sub}/*.ts'])).toEqual(await resolve(dir, ['src'])) - expect((await resolve(dir, ['.', '!**/*.json'])).files).not.toContain('.vscode/settings.json') - }) - - it('reads [!x] as any character but x and parentheses literally, as in route groups', async () => { - const dir = fixture( - { ...project, 'app/(marketing)/page.ts': '', 'app/marketing/page.ts': '' }, - [], - ) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['src/[!a]*.ts'])).toEqual({ files: ['src/b.ts'], unmatched: [] }) - expect(await resolve(dir, ['src', '!src/[!a]*.ts'])).toEqual({ - files: ['src/a.ts', 'src/sub/c.ts'], - unmatched: [], - }) - expect(await resolve(dir, ['app/(marketing)/**'])).toEqual({ - files: ['app/(marketing)/page.ts'], - unmatched: [], - }) - expect((await resolve(dir, ['app', '!app/(marketing)/**'])).files).toEqual([ - 'app/[id].ts', - 'app/marketing/page.ts', - ]) - }) - - it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { - const store = fixture({ 'dep/index.js': '' }, []) - const dir = fixture({ ...project, '.gitignore': 'node_modules/\ndist/\n' }, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - symlinkSync(store, join(dir, 'node_modules')) - symlinkSync(store, join(dir, 'src/node_modules')) - - expect((await resolve(dir, ['.', 'src'])).files).toEqual([ - '.gitignore', - '.prettierignore', - 'app/[id].ts', - 'docs/readme.md', - 'src/a.ts', - 'src/b.ts', - 'src/sub/c.ts', - ]) - }) - - it('excludes from everything when only exclusions are given', async () => { - const dir = fixture(project, []) - execFileSync('git', ['init', '--quiet'], { cwd: dir }) - - expect(await resolve(dir, ['!src/sub', '!.*'])).toEqual({ - files: ['app/[id].ts', 'docs/readme.md', 'src/a.ts', 'src/b.ts'], - unmatched: [], - }) - }) -}) - -describe('existingFiles', () => { - it('takes file names as they are and drops the ones that are gone', async () => { - const dir = fixture({ ...project, '!notes.ts': '', '-draft.ts': '' }, []) - - expect( - await Effect.runPromise( - Effect.provide( - existingFiles(['!notes.ts', '-draft.ts', 'app/[id].ts', 'app/i.ts', 'src'], dir), - NodeServices.layer, - ), - ), - ).toEqual(['!notes.ts', '-draft.ts', 'app/[id].ts']) - }) -}) diff --git a/packages/uncheck/tests/fixture.ts b/packages/uncheck/tests/fixture.ts deleted file mode 100644 index cb12389..0000000 --- a/packages/uncheck/tests/fixture.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' - -export const TOOLS = ['sherif', 'oxlint', 'oxfmt', 'typescript'] as const - -const packageNodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) -const fixtures: string[] = [] - -afterAll(() => { - for (const dir of fixtures) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -/** - * Creates a throwaway project in the OS temp dir. Objects are written as JSON, the requested tools - * are symlinked into `node_modules` so they resolve like a regular install, and config files are - * kept out of the format check so tests only see the formatting issues they put in. - */ -export function fixture( - files: Record, - tools: ReadonlyArray<(typeof TOOLS)[number]> = TOOLS, -): string { - const dir = mkdtempSync(join(tmpdir(), 'uncheck-')) - fixtures.push(dir) - - if (tools.length > 0) { - mkdirSync(join(dir, 'node_modules')) - - for (const tool of tools) { - symlinkSync(realpathSync(join(packageNodeModules, tool)), join(dir, 'node_modules', tool)) - } - } - - const defaults = { - '.gitignore': 'node_modules\ndist\n', - '.prettierignore': 'tsconfig.json\n.oxlintrc.json\n', - } - - for (const [relative, content] of Object.entries({ ...defaults, ...files })) { - // The tools are symlinks into the shared pnpm store, so writing below one corrupts every install. - if (tools.some((tool) => relative.startsWith(`node_modules/${tool}/`))) { - throw new Error(`${relative} would be written into the installed ${relative.split('/')[1]}`) - } - - mkdirSync(dirname(join(dir, relative)), { recursive: true }) - writeFileSync( - join(dir, relative), - typeof content === 'string' ? content : JSON.stringify(content), - ) - } - - return dir -} diff --git a/packages/uncheck/tests/hooks/install/agents.test.ts b/packages/uncheck/tests/hooks/install/agents.test.ts new file mode 100644 index 0000000..ec83836 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/agents.test.ts @@ -0,0 +1,153 @@ +import { LAYOUTS, cliError, singleRepo } from '../../utils/project' +import { + asWritten, + claudeSettings, + copilotHooks, + cursorHooks, + hookCommand, + installOutput, +} from './utils' + +describe.each(LAYOUTS)('hooks install writes the agent configs in a $name', ({ create, app }) => { + it('writes the stop hook of Claude Code, CodeBuddy and Cursor into the package', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'cursor', 'claude', 'codebuddy'], + { cwd: app }, + ) + + expect(exitCode).toBe(0) + expect(stderr).toBe('') + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json created', + 'CodeBuddy .codebuddy/settings.json created', + 'Cursor .cursor/hooks.json created', + ], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.codebuddy/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + expect(project.exists(`${app}.github`)).toBe(false) + }) + + it('leaves an installed hook as it is when run again', async () => { + const command = hookCommand(app) + const compact = JSON.stringify(cursorHooks(command)) + const project = create({ [`${app}.cursor/hooks.json`]: compact }) + + await project.uncheck(['hooks', 'install', 'claude'], { cwd: app }) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(compact) + }) + + it('writes the check selection into the hook and updates it in place when it changes', async () => { + const project = create() + const flags = ['--only=oxlint', '--only=oxfmt', '--require=oxfmt', '--skip=tsc'] + const fast = hookCommand(app, flags) + + const first = await project.uncheck(['hooks', 'install', 'claude', 'cursor', ...flags], { + cwd: app, + }) + + expect(first.exitCode).toBe(0) + expect(first.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], + fast, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(fast))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(fast))) + + const command = hookCommand(app) + const second = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(second.exitCode).toBe(0) + expect(second.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) +}) + +describe('hooks install for GitHub Copilot', () => { + it('writes the GitHub Copilot hook at the top of the repository', async () => { + const project = singleRepo() + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json created'], command), + ) + expect(project.read('.github/hooks/uncheck.json')).toBe(asWritten(copilotHooks(command))) + }) +}) + +describe('hooks install arguments', () => { + it.each([ + [['--require=tsc', '--skip=tsc'], '--require=tsc and --skip=tsc contradict each other.'], + [['--only=oxlint', '--skip=oxlint'], '--only=oxlint and --skip=oxlint contradict each other.'], + [ + ['--only=oxlint', '--only=oxfmt', '--require=tsc'], + '--require=tsc and --only=oxlint --only=oxfmt contradict each other.', + ], + ])('refuses the contradicting flags %j', async (flags, message) => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + ...flags, + ]) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe(cliError(message)) + expect(project.exists('.claude')).toBe(false) + }) + + it('refuses an agent it does not know, showing the usage', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + 'emacs', + ]) + + expect(exitCode).toBe(1) + expect(stdout).toContain('USAGE\n uncheck hooks install [flags] []\n') + expect(stderr).toBe( + cliError( + 'Invalid value for argument : "emacs". Expected: "claude" | "codebuddy" | "cursor" | "copilot"', + ), + ) + expect(project.exists('.claude')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/existing.test.ts b/packages/uncheck/tests/hooks/install/existing.test.ts new file mode 100644 index 0000000..7152635 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/existing.test.ts @@ -0,0 +1,370 @@ +import { LAYOUTS, cliError, singleRepo } from '../../utils/project' +import { + TIMEOUT, + asWritten, + claudeSettings, + cursorHooks, + hookCommand, + installOutput, +} from './utils' + +const MENTIONS = [ + 'cd web && npx uncheck hooks run --fix && notify', + 'npx uncheck hooks run --fix src', + 'npx uncheck hooks run-all --fix', + 'echo npx uncheck hooks run --fix', +] + +describe.each(LAYOUTS)( + 'hooks install merges into existing configs in a $name', + ({ create, app }) => { + it('adds its hook to a JSONC config, keeping every other setting and hook', async () => { + const project = create({ + [`${app}.claude/settings.json`]: `{ + // Shared with the team. + "permissions": { "allow": ["Bash(pnpm test)"], }, + "hooks": { + "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }], + "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }], + }, +} +`, + [`${app}.cursor/hooks.json`]: { hooks: { afterFileEdit: [{ command: 'echo edited' }] } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + permissions: { allow: ['Bash(pnpm test)'] }, + hooks: { + PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }, + ], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + hooks: { + afterFileEdit: [{ command: 'echo edited' }], + stop: [{ command, timeout: TIMEOUT }], + }, + version: 1, + }), + ) + }) + + it('replaces its own stop entry where it is, keeping what the user set on it', async () => { + const postToolUse = [ + { hooks: [{ type: 'command', command: 'npx uncheck hooks run --fix --only=oxfmt' }] }, + ] + const project = create({ + [`${app}.claude/settings.json`]: { + hooks: { + PostToolUse: postToolUse, + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { + matcher: '', + hooks: [ + { + type: 'command', + command: 'npx uncheck hooks run --fix --only=oxlint', + statusMessage: 'Checking', + timeout: 1800, + }, + ], + }, + { hooks: [{ type: 'command', command: 'echo last' }] }, + ], + }, + }, + [`${app}.cursor/hooks.json`]: { + version: 2, + hooks: { stop: [{ command: 'yarn uncheck hooks run' }, { command: 'echo last' }] }, + }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + hooks: { + PostToolUse: postToolUse, + Stop: [ + { hooks: [{ type: 'command', command: 'notify-send done' }] }, + { + matcher: '', + hooks: [{ type: 'command', command, statusMessage: 'Checking', timeout: 1800 }], + }, + { hooks: [{ type: 'command', command: 'echo last' }] }, + ], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + version: 2, + hooks: { stop: [{ command, timeout: TIMEOUT }, { command: 'echo last' }] }, + }), + ) + + const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + }) + + it('leaves stop hooks that only mention it alone, adding its own next to them', async () => { + const claudeStop = [ + { + hooks: [ + ...MENTIONS.map((mention) => ({ type: 'command', command: mention })), + { + type: 'command', + command: 'notify-send done', + statusMessage: 'Waiting for uncheck hooks run --fix', + }, + ], + }, + ] + const cursorStop = MENTIONS.map((mention) => ({ command: mention })) + const project = create({ + [`${app}.claude/settings.json`]: { hooks: { Stop: claudeStop } }, + [`${app}.cursor/hooks.json`]: { hooks: { stop: cursorStop } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ + hooks: { + Stop: [...claudeStop, { hooks: [{ type: 'command', command, timeout: TIMEOUT }] }], + }, + }), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe( + asWritten({ + hooks: { stop: [...cursorStop, { command, timeout: TIMEOUT }] }, + version: 1, + }), + ) + + const again = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { cwd: app }) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json unchanged', 'Cursor .cursor/hooks.json unchanged'], + command, + ), + ) + }) + + it('replaces a stop event that is not a list of hooks', async () => { + const project = create({ + [`${app}.claude/settings.json`]: { hooks: { Stop: 'notify-send done' } }, + [`${app}.cursor/hooks.json`]: { version: 1, hooks: { stop: { command: 'echo done' } } }, + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'cursor'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + ['Claude Code .claude/settings.json updated', 'Cursor .cursor/hooks.json updated'], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) + + it('writes a new config over an empty file or a hooks value that is not an object', async () => { + const project = create({ + [`${app}.claude/settings.json`]: { model: 'opus', hooks: ['notify-send done'] }, + [`${app}.codebuddy/settings.json`]: '', + [`${app}.cursor/hooks.json`]: ' \n\t\n', + }) + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheck( + ['hooks', 'install', 'claude', 'codebuddy', 'cursor'], + { cwd: app }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json updated', + 'CodeBuddy .codebuddy/settings.json updated', + 'Cursor .cursor/hooks.json updated', + ], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe( + asWritten({ model: 'opus', ...claudeSettings(command) }), + ) + expect(project.read(`${app}.codebuddy/settings.json`)).toBe( + asWritten(claudeSettings(command)), + ) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + }) + + it('refuses a config it cannot read as a JSON object and leaves every file as it is', async () => { + const broken = '{\n "version": 1,\n hooks: {}\n}\n' + const project = create({ + [`${app}.cursor/hooks.json`]: broken, + [`${app}.claude/settings.json`]: '["Stop"]\n', + [`${app}.codebuddy/settings.json`]: '"hooks"\n', + }) + + const cursor = await project.uncheck(['hooks', 'install', 'cursor'], { cwd: app }) + + expect(cursor.exitCode).toBe(1) + expect(cursor.stdout).toBe('') + expect(cursor.stderr).toBe( + cliError('.cursor/hooks.json has InvalidSymbol on line 3, fix it and run again'), + ) + + for (const [agent, file] of [ + ['claude', '.claude/settings.json'], + ['codebuddy', '.codebuddy/settings.json'], + ] as const) { + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', agent], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe(cliError(`${file} is not a JSON object, fix it and run again`)) + } + + expect(project.read(`${app}.cursor/hooks.json`)).toBe(broken) + expect(project.read(`${app}.claude/settings.json`)).toBe('["Stop"]\n') + expect(project.read(`${app}.codebuddy/settings.json`)).toBe('"hooks"\n') + }) + + it('writes no config when another one it was asked for is broken', async () => { + const project = create({ [`${app}.cursor/hooks.json`]: '{ "version": 1' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'claude', 'cursor'], + { cwd: app }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError('.cursor/hooks.json has CloseBraceExpected on line 1, fix it and run again'), + ) + expect(project.exists(`${app}.claude`)).toBe(false) + }) + }, +) + +describe('hooks install merges into an existing GitHub Copilot config', () => { + it('keeps a Copilot timeout under either of its names', async () => { + const project = singleRepo({ + '.github/hooks/uncheck.json': { + version: 1, + hooks: { + agentStop: [ + { type: 'command', bash: 'npx uncheck hooks run --fix', timeout: 1800 }, + { type: 'command', powershell: 'npx uncheck hooks run --fix', timeoutSec: 1200 }, + ], + }, + }, + }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), + ) + expect(project.read('.github/hooks/uncheck.json')).toBe( + asWritten({ + version: 1, + hooks: { + agentStop: [ + { type: 'command', bash: command, timeout: 1800, powershell: command }, + { type: 'command', powershell: command, timeoutSec: 1200, bash: command }, + ], + }, + }), + ) + }) + + it('leaves Copilot hooks that only mention it alone, adding its own next to them', async () => { + const agentStop = MENTIONS.map((mention) => ({ + type: 'command', + bash: mention, + powershell: mention, + })) + const project = singleRepo({ '.github/hooks/uncheck.json': { hooks: { agentStop } } }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'copilot']) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput(['GitHub Copilot .github/hooks/uncheck.json updated'], command), + ) + expect(project.read('.github/hooks/uncheck.json')).toBe( + asWritten({ + hooks: { + agentStop: [ + ...agentStop, + { type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }, + ], + }, + version: 1, + }), + ) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/location.test.ts b/packages/uncheck/tests/hooks/install/location.test.ts new file mode 100644 index 0000000..d5eb50b --- /dev/null +++ b/packages/uncheck/tests/hooks/install/location.test.ts @@ -0,0 +1,163 @@ +import { + CLI, + LAYOUTS, + PERMISSIONS_ENFORCED, + cliError, + monorepo, + run, + temporaryDirectory, +} from '../../utils/project' +import { asWritten, claudeSettings, copilotHooks, hookCommand, installOutput } from './utils' + +describe.each(LAYOUTS)('hooks install finds the package in a $name', ({ create, app }) => { + it('names a folder below the top of the repository in the hook command', async () => { + const folder = `${app}tools/@scope_v=1.2+x-y` + const project = create({ [`${folder}/index.ts`]: 'export const tool = 1;\n' }) + const command = hookCommand(`${folder}/`) + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + expect(project.read(`${folder}/.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + }) + + it.each(['my tools', "it's", 'café'])( + 'refuses a folder named %j that the hook command cannot name', + async (name) => { + const folder = `${app}${name}` + const project = create({ [`${folder}/index.ts`]: 'export const tool = 1;\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: folder, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `The hook command cannot name ${folder}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`, + ), + ) + expect(project.exists(`${folder}/.claude`)).toBe(false) + }, + ) + + it('writes a hook for the directory it runs in outside git', async () => { + const project = create({}, { git: 'none' }) + const command = hookCommand('') + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude', 'copilot'], { + cwd: app, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + installOutput( + [ + 'Claude Code .claude/settings.json created', + 'GitHub Copilot .github/hooks/uncheck.json created', + ], + command, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.github/hooks/uncheck.json`)).toBe(asWritten(copilotHooks(command))) + }) + + it('installs into the directory given with --cwd, wherever it runs', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout } = await run( + [...CLI, 'hooks', 'install', 'claude', `--cwd=${project.path(app)}`], + { cwd: temporaryDirectory() }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + }) + + it('reports a config it cannot read', async () => { + const project = create({ [`${app}.claude`]: 'not a folder\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install', 'claude'], { + cwd: app, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError(`BadResource: FileSystem.readFile (${project.path(`${app}.claude/settings.json`)})`), + ) + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'reports a config it cannot write, after writing the ones before it', + async () => { + const project = create({ [`${app}.cursor/rules.md`]: '# Rules\n' }).chmod( + `${app}.cursor`, + 0o555, + ) + const command = hookCommand(app) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'cursor', 'claude'], + { cwd: app }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('✔ Claude Code .claude/settings.json created\n') + expect(stderr).toBe( + cliError( + `PermissionDenied: FileSystem.writeFile (${project.path(`${app}.cursor/hooks.json`)})`, + ), + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.exists(`${app}.cursor/hooks.json`)).toBe(false) + }, + ) + + it.runIf(PERMISSIONS_ENFORCED)('reports a folder it cannot look into', async () => { + const project = create({ [`${app}locked/notes.md`]: '# Notes\n' }).chmod(`${app}locked`, 0o644) + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'hooks', + 'install', + 'claude', + `--cwd=${project.path(`${app}locked`)}`, + ]) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `PermissionDenied: FileSystem.readFile (${project.path(`${app}locked/.claude/settings.json`)})`, + ), + ) + }) +}) + +describe('hooks install in a monorepo package', () => { + it('refuses Copilot, which reads its hooks only at the top of the repository', async () => { + const project = monorepo() + + const { exitCode, stdout, stderr } = await project.uncheck( + ['hooks', 'install', 'claude', 'copilot'], + { cwd: 'packages/app' }, + ) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + 'Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there', + ), + ) + expect(project.exists('packages/app/.claude')).toBe(false) + expect(project.exists('packages/app/.github')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/package-manager.test.ts b/packages/uncheck/tests/hooks/install/package-manager.test.ts new file mode 100644 index 0000000..ecbcbc2 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/package-manager.test.ts @@ -0,0 +1,79 @@ +import { monorepo, singleRepo } from '../../utils/project' +import { installOutput } from './utils' + +function manifest(packageManager: string) { + return { 'package.json': { name: 'app', private: true, type: 'module', packageManager } } +} + +describe('hooks install runs uncheck through the package manager of the project', () => { + it.each([ + ['a Yarn lockfile', { 'pnpm-lock.yaml': null, 'yarn.lock': '' }, 'yarn run --silent'], + ['a Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lock': '{}\n' }, 'bunx --no-install'], + ['a binary Bun lockfile', { 'pnpm-lock.yaml': null, 'bun.lockb': '' }, 'bunx --no-install'], + ['an npm lockfile', { 'pnpm-lock.yaml': null, 'package-lock.json': {} }, 'npx --no'], + ['no lockfile', { 'pnpm-lock.yaml': null }, 'npx --no'], + ['npm declared over a pnpm lockfile', manifest('npm@10.9.0'), 'npx --no'], + [ + 'Yarn 1 declared', + { ...manifest('yarn@1.22.22'), 'pnpm-lock.yaml': null }, + 'yarn run --silent', + ], + [ + 'Yarn 4 declared, which runs the binaries of the root with -T', + { ...manifest('yarn@4.5.0'), 'pnpm-lock.yaml': null }, + 'yarn run -T --silent', + ], + [ + 'an unknown package manager declared over a pnpm lockfile', + manifest('deno@2.0.0'), + 'pnpm exec', + ], + ])('with %s', async (_, files, exec) => { + const project = singleRepo(files) + const command = `${exec} uncheck hooks run --fix` + + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude']) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + }) +}) + +describe('hooks install in a Yarn 4 monorepo', () => { + it("runs the root's binary unless the package declares uncheck itself", async () => { + const project = monorepo({ + 'package.json': { + name: 'monorepo', + private: true, + workspaces: ['packages/*'], + devDependencies: { uncheck: '^0.0.3' }, + packageManager: 'yarn@4.5.0', + }, + 'pnpm-workspace.yaml': null, + 'pnpm-lock.yaml': null, + 'yarn.lock': '', + 'packages/core/package.json': { + name: '@repo/core', + private: true, + devDependencies: { uncheck: '^0.0.3' }, + }, + 'packages/cli/package.json': { + name: '@repo/cli', + private: true, + dependencies: { uncheck: '^0.0.3' }, + }, + }) + + for (const [cwd, command] of [ + ['.', 'yarn run -T --silent uncheck hooks run --fix'], + ['packages/app', 'yarn run -T --silent uncheck hooks run --fix --dir=packages/app'], + ['packages/core', 'yarn run --silent uncheck hooks run --fix --dir=packages/core'], + ['packages/cli', 'yarn run --silent uncheck hooks run --fix --dir=packages/cli'], + ] as const) { + const { exitCode, stdout } = await project.uncheck(['hooks', 'install', 'claude'], { cwd }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(installOutput(['Claude Code .claude/settings.json created'], command)) + } + }) +}) diff --git a/packages/uncheck/tests/hooks/install/prompt.test.ts b/packages/uncheck/tests/hooks/install/prompt.test.ts new file mode 100644 index 0000000..dd3fb92 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/prompt.test.ts @@ -0,0 +1,73 @@ +import { LAYOUTS, cliError, monorepo } from '../../utils/project' +import { asWritten, claudeSettings, cursorHooks, hookCommand, installOutput } from './utils' + +const QUESTION = 'Which agents should run uncheck when they finish a turn?' +const CHOICES = `? ${QUESTION} › \n Select All\n Inverse Selection\n ☐ Claude Code \n ☐ CodeBuddy \n ☐ Cursor \n ☐ GitHub Copilot ` +const DOWN = '\u001B[B' +const SPACE = ' ' +const ENTER = '\r' + +function fromAnswer(output: string): string { + return output.slice(output.lastIndexOf(`✔ ${QUESTION}`)) +} + +describe.each(LAYOUTS)('hooks install without agents in a $name', ({ create, app }) => { + it('writes the agents chosen in a terminal', async () => { + const project = create() + const command = hookCommand(app) + + const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { + cwd: app, + waitFor: QUESTION, + keys: [DOWN, DOWN, SPACE, DOWN, DOWN, SPACE, ENTER], + }) + + expect(exitCode).toBe(0) + expect(stdout.slice(0, CHOICES.length)).toBe(CHOICES) + expect(fromAnswer(stdout)).toBe( + `✔ ${QUESTION} … Claude Code, Cursor\n${installOutput( + ['Claude Code .claude/settings.json created', 'Cursor .cursor/hooks.json created'], + command, + )}`, + ) + expect(project.read(`${app}.claude/settings.json`)).toBe(asWritten(claudeSettings(command))) + expect(project.read(`${app}.cursor/hooks.json`)).toBe(asWritten(cursorHooks(command))) + expect(project.exists(`${app}.codebuddy`)).toBe(false) + }) + + it('asks for the agents on the command line when stdin is not a terminal', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['hooks', 'install'], { cwd: app }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + 'Pass the agents to configure, for example: uncheck hooks install claude codebuddy cursor copilot', + ), + ) + expect(project.exists(`${app}.claude`)).toBe(false) + }) +}) + +describe('hooks install without agents in a monorepo package', () => { + it('refuses Copilot chosen in a terminal, writing none of the others', async () => { + const project = monorepo() + + const { exitCode, stdout } = await project.uncheckInTerminal(['hooks', 'install'], { + cwd: 'packages/app', + waitFor: QUESTION, + keys: [SPACE, ENTER], + }) + + expect(exitCode).toBe(1) + expect(fromAnswer(stdout)).toBe( + `✔ ${QUESTION} … Claude Code, CodeBuddy, Cursor, GitHub Copilot\n${cliError( + 'Copilot reads .github/hooks only at the top of the repository, not in packages/app: install copilot from there', + )}`, + ) + expect(project.exists('packages/app/.claude')).toBe(false) + expect(project.exists('packages/app/.github')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/hooks/install/utils.ts b/packages/uncheck/tests/hooks/install/utils.ts new file mode 100644 index 0000000..d192641 --- /dev/null +++ b/packages/uncheck/tests/hooks/install/utils.ts @@ -0,0 +1,32 @@ +export const TIMEOUT = 600 + +export function hookCommand(app: string, flags: ReadonlyArray = []): string { + const dir = app === '' ? [] : [`--dir=${app.slice(0, -1)}`] + + return ['pnpm exec uncheck hooks run --fix', ...flags, ...dir].join(' ') +} + +export function installOutput(results: ReadonlyArray, command: string): string { + return `${results.map((result) => `✔ ${result}\n`).join('')}\nThe hook runs ${command} whenever the agent finishes a turn.\n` +} + +export function claudeSettings(command: string) { + return { hooks: { Stop: [{ hooks: [{ type: 'command', command, timeout: TIMEOUT }] }] } } +} + +export function cursorHooks(command: string) { + return { version: 1, hooks: { stop: [{ command, timeout: TIMEOUT }] } } +} + +export function copilotHooks(command: string) { + return { + version: 1, + hooks: { + agentStop: [{ type: 'command', bash: command, powershell: command, timeoutSec: TIMEOUT }], + }, + } +} + +export function asWritten(config: object): string { + return `${JSON.stringify(config, null, 2)}\n` +} diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts new file mode 100644 index 0000000..14199b6 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -0,0 +1,139 @@ +import { stripVTControlCharacters } from 'node:util' + +import { LAYOUTS, report } from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook } from './utils' + +const UNFORMATTED = 'export const legacy = 1\n' + +describe.each(LAYOUTS)( + 'hooks run checks what changed since the last commit in a $name', + ({ create, app, tsc }) => { + it('stays silent when nothing changed', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stderr).toBe('') + expect(project.read(`${app}src/legacy.ts`)).toBe(UNFORMATTED) + }) + + it('fixes the changed and untracked files, leaving ignored and unchanged ones alone', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }) + const index = project.read(`${app}src/index.ts`) + + project.write({ + [`${app}src/index.ts`]: `${index}export var later = 2\n`, + [`${app}src/extra.ts`]: 'export const extra = 1\n', + [`${app}dist/bundle.js`]: 'export var bundle = 1\n', + }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + env: { FORCE_COLOR: '1' }, + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stripVTControlCharacters(stderr)).toBe(stderr) + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(project.read(`${app}src/index.ts`)).toBe(`${index}export const later = 2;\n`) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.read(`${app}src/legacy.ts`)).toBe(UNFORMATTED) + expect(project.read(`${app}dist/bundle.js`)).toBe('export var bundle = 1\n') + }) + + 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', + [`${app}src/i.ts`]: UNFORMATTED, + }).write({ [`${app}src/[id].ts`]: null }) + + const deleted = await stopHook(project, app, CLAUDE_CODE_STOP) + + expect(deleted.exitCode).toBe(0) + expect(deleted.stdout).toBe('') + expect(report(deleted.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ nothing to check, no files match src/[id].ts', + ]) + expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) + + project.write({ [`${app}src/new.ts`]: 'export const fresh = 1\n' }) + + const alongside = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxfmt'], + }) + + expect(alongside.exitCode).toBe(0) + expect(alongside.stdout).toBe('') + expect(report(alongside.stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/new.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/new.ts`)).toBe('export const fresh = 1;\n') + expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) + }) + + it('checks everything under the directory outside git', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'none' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: [], + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tsc, + '✔ tsc passed', + '✘ 1 of 3 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(stderr).toContain('src/legacy.ts') + }) + + it('checks everything before the first commit', async () => { + const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'init' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + }) + + 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 --check', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(stderr).toContain('src/legacy.ts') + }) + }, +) diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts new file mode 100644 index 0000000..2c6e5e6 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -0,0 +1,133 @@ +import type { Project, Run } from '../../utils/project' +import { LAYOUTS, report, run } from '../../utils/project' +import { + CLAUDE_CODE_STOP, + CLAUDE_CODE_STOP_AGAIN, + COPILOT_AGENT_STOP, + COPILOT_STOP_IN_CLAUDE_FORMAT, + CURSOR_STOP, + TYPE_ERROR, + stopHook, +} from './utils' + +interface ClaudeSettings { + readonly hooks: { + readonly Stop: ReadonlyArray<{ readonly hooks: [{ readonly command: string }] }> + } +} + +interface CursorHooks { + readonly hooks: { readonly stop: ReadonlyArray<{ readonly command: string }> } +} + +const DOCS = { 'docs/guide.md': '# Guide\n' } + +const DIAGNOSTIC = + "src/index.ts(1,14): error TS2322: Type 'number' is not assignable to type 'string'." + +async function installHook( + project: Project, + app: string, + agent: 'claude' | 'cursor', +): Promise<(payload: object) => Promise> { + const install = await project.uncheck(['hooks', 'install', agent], { cwd: app }) + + expect(install.exitCode).toBe(0) + + const command = + agent === 'claude' + ? (JSON.parse(project.read(`${app}.claude/settings.json`)) as ClaudeSettings).hooks.Stop[0]! + .hooks[0].command + : (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) }) +} + +function sendBackReason(stderr: string): string { + return `uncheck found problems, fix them before finishing:\n\n${stderr.replace(/\n$/, '')}` +} + +describe.each(LAYOUTS)( + 'hooks run sends each agent back its own way in a $name', + ({ create, app, tsc }) => { + const checks = (project: Project, ...outcome: ReadonlyArray) => [ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', + '✔ oxfmt passed', + tsc, + ...outcome, + ] + const failure = (project: Project) => + checks(project, '✘ tsc failed', '✘ 1 of 3 checks failed: tsc') + + 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') + + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const blocked = await stop(CLAUDE_CODE_STOP) + + expect(blocked.exitCode).toBe(2) + expect(blocked.stdout).toBe('') + expect(report(blocked.stderr)).toEqual(failure(project)) + 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)) + + project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) + + const passed = await stop(CLAUDE_CODE_STOP) + + expect(passed.exitCode).toBe(0) + expect(passed.stdout).toBe('') + expect(report(passed.stderr)).toEqual( + checks(project, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)'), + ) + expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') + }) + + 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') + + project.commit('hook').write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const followUp = await stop(CURSOR_STOP) + + expect(followUp.exitCode).toBe(0) + expect(report(followUp.stderr)).toEqual(failure(project)) + expect(JSON.parse(followUp.stdout)).toEqual({ + followup_message: sendBackReason(followUp.stderr), + }) + + const again = await stop({ ...CURSOR_STOP, loop_count: 1 }) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe('') + expect(report(again.stderr)).toEqual(failure(project)) + }) + + it('blocks Copilot with a decision, whichever payload format it sends', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + for (const payload of [COPILOT_AGENT_STOP, COPILOT_STOP_IN_CLAUDE_FORMAT]) { + const { exitCode, stdout, stderr } = await stopHook(project, app, payload) + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual(failure(project)) + expect(JSON.parse(stdout)).toEqual({ decision: 'block', reason: sendBackReason(stderr) }) + } + }) + }, +) diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts new file mode 100644 index 0000000..9c01548 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -0,0 +1,155 @@ +import { + CLI, + LAYOUTS, + cliError, + monorepo, + report, + run, + singleRepo, + temporaryDirectory, +} from '../../utils/project' +import { CLAUDE_CODE_STOP, stopHook } from './utils' + +const UNFORMATTED = 'export const extra = 1\n' +const FORMATTED = 'export const extra = 1;\n' + +const ONLY_OXFMT = [ + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', +] + +describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app }) => { + it('checks the directory given with --cwd, wherever it runs', async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await run( + [...CLI, 'hooks', 'run', '--fix', '--only=oxfmt', `--cwd=${project.path(app)}`], + { cwd: temporaryDirectory(), input: JSON.stringify(CLAUDE_CODE_STOP) }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) + }) + + it('reports a --dir that names nothing instead of sending the agent back', 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`], + cwd: `${app}src`, + }) + + expect(exitCode).toBe(1) + 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) + }) +}) + +describe('hooks run in a monorepo', () => { + it('checks only the package in --dir from wherever the agent moved to, and the whole repository without it', async () => { + const project = monorepo().write({ + 'packages/app/src/extra.ts': UNFORMATTED, + 'packages/core/src/extra.ts': UNFORMATTED, + }) + + const app = await stopHook(project, 'packages/app/', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + 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', + ]) + + const top = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxfmt'], + cwd: 'packages/core/src', + }) + + expect(top.exitCode).toBe(0) + expect(top.stdout).toBe('') + expect(report(top.stderr)).toEqual([ + `uncheck in ${project.dir}`, + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern packages/app/src/extra.ts packages/core/src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read('packages/app/src/extra.ts')).toBe(FORMATTED) + expect(project.read('packages/core/src/extra.ts')).toBe(FORMATTED) + }) + + it('takes --dir from the top of the repository around --cwd, not around where it runs', async () => { + const project = monorepo().write({ + 'packages/app/src/extra.ts': UNFORMATTED, + 'packages/core/src/extra.ts': UNFORMATTED, + }) + + const { exitCode, stdout, stderr } = await run( + [ + ...CLI, + 'hooks', + 'run', + '--fix', + '--only=oxfmt', + `--cwd=${project.path('packages/core/src')}`, + '--dir=packages/app', + ], + { cwd: temporaryDirectory(), input: JSON.stringify(CLAUDE_CODE_STOP) }, + ) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path('packages/app')}`, + ...ONLY_OXFMT, + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read('packages/app/src/extra.ts')).toBe(FORMATTED) + expect(project.read('packages/core/src/extra.ts')).toBe(UNFORMATTED) + }) +}) + +describe('hooks run arguments', () => { + it('refuses a --cwd that does not exist, checking nothing', async () => { + const project = singleRepo().write({ 'src/extra.ts': UNFORMATTED }) + const gone = project.path('gone') + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', `--cwd=${gone}`], + }) + + expect(exitCode).toBe(1) + expect(stdout).toContain('USAGE\n uncheck hooks run [flags]\n') + expect(stderr).toBe( + cliError(`Invalid value for flag --cwd: "${gone}". Expected: Path does not exist: ${gone}`), + ) + expect(project.read('src/extra.ts')).toBe(UNFORMATTED) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/payload.test.ts b/packages/uncheck/tests/hooks/run/payload.test.ts new file mode 100644 index 0000000..1981bc7 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/payload.test.ts @@ -0,0 +1,66 @@ +import { LAYOUTS, cliError, report } from '../../utils/project' +import { CURSOR_STOP, dirFlags, stopHook } from './utils' + +const UNFORMATTED = 'export const answer = 42\n' + +describe.each(LAYOUTS)('hooks run reads the payload in a $name', ({ create, app }) => { + it('refuses a terminal on stdin, where no agent sends a payload', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + const { exitCode, stdout } = await project.uncheckInTerminal([ + 'hooks', + 'run', + '--fix', + ...dirFlags(app), + ]) + + expect(exitCode).toBe(1) + expect(stdout).toBe( + cliError('`uncheck hooks run` expects the agent hook payload as JSON on stdin'), + ) + expect(project.read(`${app}src/index.ts`)).toBe(UNFORMATTED) + }) + + it('only reports through a payload from an agent it does not know, or one that is empty, not JSON or not an object', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + for (const payload of [ + { hook_event_name: 'SubagentStop' }, + '', + '{"hook_event_name": "Stop"', + 'null', + '["Stop"]', + ]) { + const { exitCode, stdout, stderr } = await stopHook(project, app, payload, { + 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/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + } + }) + + it('leaves a turn the user stopped, or that failed, as it is', async () => { + const project = create().write({ [`${app}src/index.ts`]: UNFORMATTED }) + + for (const status of ['aborted', 'error']) { + const { exitCode, stdout, stderr } = await stopHook(project, app, { ...CURSOR_STOP, status }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(stderr).toBe('') + } + + expect(project.read(`${app}src/index.ts`)).toBe(UNFORMATTED) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/selection.test.ts b/packages/uncheck/tests/hooks/run/selection.test.ts new file mode 100644 index 0000000..787b0f7 --- /dev/null +++ b/packages/uncheck/tests/hooks/run/selection.test.ts @@ -0,0 +1,86 @@ +import { LAYOUTS, cliError, report, singleRepo } from '../../utils/project' +import { CLAUDE_CODE_STOP, TYPE_ERROR, stopHook } from './utils' + +describe.each(LAYOUTS)('hooks run passes the check selection on in a $name', ({ create, app }) => { + it('skips a check with --skip', async () => { + const project = create().write({ + [`${app}src/index.ts`]: TYPE_ERROR, + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--skip=tsc', '--skip=oxlint'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, disabled with --skip=oxlint', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/index.ts', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + '✔ all checks passed (oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + }) + + it('passes a change no check selected with --only covers, unless it is required', async () => { + const project = create().write({ [`${app}README.md`]: '# App\n' }) + + const optional = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc'], + }) + + expect(optional.exitCode).toBe(0) + expect(optional.stdout).toBe('') + expect(report(optional.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 skipped, no tsconfig.json covers the given files', + '○ 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', + ]) + + const required = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc', '--require=tsc'], + }) + + expect(required.exitCode).toBe(2) + expect(required.stdout).toBe('') + expect(report(required.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 no tsconfig.json covers the given files', + '✘ 1 of 1 checks failed: tsc', + ]) + }) +}) + +describe('hooks run check selection', () => { + it('refuses contradicting flags once there is something to check', async () => { + const project = singleRepo() + + const unchanged = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--only=tsc', '--skip=tsc'], + }) + + expect(unchanged.exitCode).toBe(0) + expect(unchanged.stdout).toBe('') + expect(unchanged.stderr).toBe('') + + project.write({ 'src/extra.ts': 'export const extra = 1;\n' }) + + const changed = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--only=tsc', '--skip=tsc'], + }) + + expect(changed.exitCode).toBe(1) + expect(changed.stdout).toBe('') + expect(changed.stderr).toBe(cliError('--only=tsc and --skip=tsc contradict each other.')) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts new file mode 100644 index 0000000..e3c2dbf --- /dev/null +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -0,0 +1,44 @@ +import type { Env, Project, Run } from '../../utils/project' + +export const CLAUDE_CODE_STOP = { + session_id: 'session', + hook_event_name: 'Stop', + stop_hook_active: false, +} + +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 COPILOT_AGENT_STOP = { stopReason: 'end_turn' } + +export const COPILOT_STOP_IN_CLAUDE_FORMAT = { + hook_event_name: 'Stop', + stop_reason: 'end_turn', + stop_hook_active: false, +} + +export const TYPE_ERROR = 'export const answer: string = 1;\n' + +export function dirFlags(app: string): string[] { + return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] +} + +interface HookOptions { + readonly args?: ReadonlyArray + readonly cwd?: string + readonly env?: Env +} + +export function stopHook( + project: Project, + app: string, + payload: object | string, + { args = ['--fix'], cwd, env }: HookOptions = {}, +): Promise { + return project.uncheck(['hooks', 'run', ...dirFlags(app), ...args], { + cwd, + env, + input: typeof payload === 'string' ? payload : JSON.stringify(payload), + }) +} diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts new file mode 100644 index 0000000..357a49c --- /dev/null +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -0,0 +1,107 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, monorepo, report, run } from '../utils/project' +import { installHusky, prepare } from './utils' + +const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } } + +const UNFIXABLE = 'export function ignore({}: object): void {}\n' + +const TSC_WITHOUT_REFERENCES = '▶ tsc -p tsconfig.json --noEmit' + +function commit(project: Project, message: string) { + return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.dir }) +} + +function blockedByLint(dir: string, tsc: string): 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 failed', + '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✘ 1 of 3 checks failed: oxlint', + ] +} + +function fixedAndStaged(dir: string, tsc: string): string[] { + return [ + `uncheck staged in ${dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/spaced.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/spaced.ts', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + '✔ staged the fixes to src/spaced.ts', + ] +} + +describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create, app, tsc }) => { + it('blocks a commit with a lint error it cannot fix', async () => { + const project = create({ '.oxlintrc.json': LINT_CONFIG }) + 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(project.git('log', '--format=%s')).toBe('init\n') + expect(project.git('diff', '--cached', '--name-only')).toBe(`${app}src/ignore.ts\n`) + }) + + it('commits the fixes it applied', async () => { + const project = create() + await prepare(project, [], { cwd: app }) + project.stage({ [`${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(app, '.'), tsc)) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + expect(project.read(`${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + expect(project.git('status', '--porcelain')).toBe('') + }) + + 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 }) + project.commit('husky') + project.stage({ [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) + + const { exitCode, stderr } = await commit(project, 'spaced') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) + expect(stderr).toMatch(/\nhusky hook ran\n$/) + expect(project.git('show', `HEAD:${app}src/spaced.ts`)).toBe('export const spaced = 1;\n') + }) +}) + +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' }) + 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') + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual( + blockedByLint(project.path('packages/core'), TSC_WITHOUT_REFERENCES), + ) + expect(project.read('packages/app/src/spaced.ts')).toBe('export const spaced = 1\n') + 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 new file mode 100644 index 0000000..143e3cc --- /dev/null +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -0,0 +1,216 @@ +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' + +const HOOK = '.git/hooks/pre-commit' + +describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, app }) => { + const line = hookLine(app) + const folder = app.slice(0, -1) + + async function prepareHook(hook: string) { + const project = create().write({ [HOOK]: hook }) + + const run = await prepare(project, [], { cwd: app }) + + return { ...run, project, hook: project.read(HOOK) } + } + + it('rewrites the line an older version wrote in place', async () => { + const old = + app === '' ? 'npx uncheck staged --fix' : `cd "${folder}" && yarn uncheck staged --fix` + + const { stdout, hook, project } = await prepareHook(`${HEADER}${old}\npnpm test\n`) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${HEADER}${line}\npnpm test\n`) + }) + + it('drops the copies of its line and settles on one', async () => { + const copies = [ + line, + app === '' ? 'uncheck staged' : `(cd "${folder}" && npx --no uncheck staged)`, + app === '' ? 'bunx uncheck staged --only=oxfmt' : `cd "${folder}" && bunx uncheck staged`, + ] + + const { stdout, hook, project } = await prepareHook( + `${HEADER}${copies.join('\npnpm test\n')}\n`, + ) + + 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')) + }) + + it('leaves alone the lines that only mention the command or run it another way', async () => { + const others = [ + 'echo "runs uncheck staged"', + 'npx uncheck staged --only=oxfmt && pnpm test', + 'pnpm exec uncheck staged --fix || echo "not blocking"', + 'pnpm exec uncheck staged src/index.ts', + 'pnpm exec uncheck stagedly', + 'pnpm dlx uncheck staged', + ] + + const { hook } = await prepareHook(`#!/bin/sh\n${others.join('\n')}\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${others.join('\n')}\n`) + }) + + it('runs before the commands of the hook and after its setup', async () => { + const setup = [ + '#!/usr/bin/env bash', + '# lint before committing', + '', + 'set -e', + 'unset GIT_INDEX_FILE', + 'export HOOKS="$(dirname "$0")"', + 'source ~/.profile', + '. "$HOOKS/env"', + '\\. "$HOOKS/nvm.sh"', + '[ -f "$HOOKS/local" ] && . "$HOOKS/local"', + 'test -s "$HOOKS/path" && source "$HOOKS/path"', + 'NODE_OPTIONS=--max-old-space-size=4096', + ].join('\n') + const commands = 'export CI=1 && exec pnpm lint-staged\npnpm test\n' + + const { hook } = await prepareHook(`${setup}\n${commands}`) + + expect(hook).toBe(`${setup}\n${line}\n${commands}`) + }) + + it('runs after the setup of husky 8, whose script runs the hook again', async () => { + const setup = '#!/usr/bin/env sh\n. "$(dirname -- "$0")/_/husky.sh"\n\n' + + const { hook } = await prepareHook(`${setup}npx lint-staged\n`) + + expect(hook).toBe(`${setup}${line}\nnpx lint-staged\n`) + }) + + 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"], + ])('runs before a setup line continued by %s', async (_, setup) => { + const { hook } = await prepareHook(`#!/bin/sh\n${setup}npx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${setup}npx lint-staged\n`) + }) + + it.each([ + ['an odd single quote', "# don't commit secrets"], + ['an odd double quote', '# the "main branch'], + ['a trailing backslash', '# see C:\\'], + ])('runs after a setup comment with %s, which continues nothing', async (_, comment) => { + const { hook } = await prepareHook(`#!/bin/sh\n${comment}\nnpx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${comment}\n${line}\nnpx lint-staged\n`) + }) + + it('goes at the end of a hook that only sets up', async () => { + const { hook } = await prepareHook('#!/bin/sh\n# nothing yet\n') + + expect(hook).toBe(`#!/bin/sh\n# nothing yet\n${line}\n`) + }) + + it('ends a hook without a trailing newline before adding its line', async () => { + const { hook } = await prepareHook('#!/bin/sh\n# nothing yet') + + expect(hook).toBe(`#!/bin/sh\n# nothing yet\n${line}\n`) + }) + + it('keeps a hook without a trailing newline as it is around its line', async () => { + const { hook } = await prepareHook('#!/bin/sh\necho "checking"') + + expect(hook).toBe(`#!/bin/sh\n${line}\necho "checking"`) + }) + + it('fills an empty hook with its line alone', async () => { + const { stdout, hook, project } = await prepareHook('') + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${line}\n`) + expect(project.mode(HOOK)).toBe(0o755) + }) + + it('leaves a hook in another language alone and says what it should run', async () => { + const script = '#!/usr/bin/env python3\nprint("checked")\n' + + const { exitCode, stdout, hook, project } = await prepareHook(script) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `it is not a shell script, have it run \`${line}\` yourself`, + ), + ) + expect(hook).toBe(script) + }) + + it.each([ + ['invalid UTF-8', Buffer.from([0x7f, 0x45, 0x4c, 0x46, 0x02, 0xff, 0xfe])], + ['a NUL byte', Buffer.from('#!/bin/sh\necho "\0"\n')], + ])('leaves a hook with %s alone', async (_, bytes) => { + const project = create() + writeFileSync(project.path(HOOK), bytes) + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `it is not a shell script, have it run \`${line}\` yourself`, + ), + ) + expect(readFileSync(project.path(HOOK))).toEqual(bytes) + }) +}) + +describe('prepare with an existing hook in a monorepo', () => { + it('puts the line of a new package after the lines of the others', async () => { + const root = hookLine('') + const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) + const project = monorepo().write({ + [HOOK]: `#!/bin/sh\nexport CI=1\n${root}\n${core}\npnpm test\n`, + }) + + await prepare(project, [], { cwd: 'packages/app' }) + + expect(project.read(HOOK)).toBe( + `#!/bin/sh\nexport CI=1\n${root}\n${core}\n${hookLine('packages/app/')}\npnpm test\n`, + ) + }) + + it('rewrites the lines older versions wrote for the other packages', async () => { + const project = monorepo().write({ + [HOOK]: [ + '#!/bin/sh', + 'pnpm exec uncheck staged --fix', + 'pnpm test', + 'cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint', + 'cd "packages/app" && pnpm exec uncheck staged --fix', + '', + ].join('\n'), + }) + + const { stdout } = await prepare(project, ['--no-fix'], { cwd: 'packages/app' }) + + expect(stdout).toBe(written(project.path(HOOK), 'updated', 'pnpm exec uncheck staged')) + expect(project.read(HOOK)).toBe( + [ + '#!/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', + '', + ].join('\n'), + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts new file mode 100644 index 0000000..106e446 --- /dev/null +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -0,0 +1,172 @@ +import { readdirSync } from 'node:fs' + +import type { Project } from '../utils/project' +import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, singleRepo } from '../utils/project' +import { HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' + +const HOOK = '.git/hooks/pre-commit' + +function leftoverLocksAndCopies(project: Project, folder = '.git/hooks'): string[] { + return readdirSync(project.path(folder)).filter((name) => /\.(?:lock|uncheck-\w+)$/.test(name)) +} + +describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create, app }) => { + it.runIf(PERMISSIONS_ENFORCED)('reports a hooks folder it may not write to', async () => { + const project = create().chmod('.git/hooks', 0o555) + + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `EACCES: permission denied, open '${project.path(`${HOOK}.lock`)}'`, + ), + ) + expect(project.exists(HOOK)).toBe(false) + }) + + it.runIf(PERMISSIONS_ENFORCED)('reports a hook it may not read and leaves it alone', async () => { + const project = create() + .write({ [HOOK]: '#!/bin/sh\npnpm test\n' }) + .chmod(HOOK, 0o200) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app), + `EACCES: permission denied, open '${project.path(HOOK)}'`, + ), + ) + expect(project.chmod(HOOK, 0o644).read(HOOK)).toBe('#!/bin/sh\npnpm test\n') + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) + + it('reports a hooks folder it cannot create below a file', async () => { + const project = create() + project.git('config', 'core.hooksPath', 'package.json/hooks') + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + shownHook(project, app, 'package.json/hooks/pre-commit'), + `ENOTDIR: not a directory, mkdir '${project.path('package.json/hooks')}'`, + ), + ) + expect(project.git('status', '--porcelain')).toBe('') + }) + + it('reports a hook that is a folder and releases its lock', async () => { + const project = create().write({ [`${HOOK}/.keep`]: '' }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + // Node 26 adds the path to the message. + expect(stdout.replace(` '${project.path(HOOK)}'`, '')).toBe( + notWritten(shownHook(project, app), 'EISDIR: illegal operation on a directory, read'), + ) + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) + + it('reports a linked script whose name leaves no room for the copy it swaps in', async () => { + const name = 'x'.repeat(240) + const project = create({ [`scripts/${name}`]: '#!/bin/sh\npnpm test\n' }) + project.link(HOOK, `../../scripts/${name}`) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout.replace(/\.uncheck-[\da-f]{12}'/, ".uncheck-'")).toBe( + notWritten( + shownHook(project, app), + `ENAMETOOLONG: name too long, open '${project.path('scripts', name)}.uncheck-'`, + ), + ) + expect(project.read(`scripts/${name}`)).toBe('#!/bin/sh\npnpm test\n') + expect(leftoverLocksAndCopies(project, 'scripts')).toEqual([]) + }) +}) + +describe('prepare waiting for the lock of the hook', () => { + it('gives up on a lock held for too long and leaves it to its owner', async () => { + const project = singleRepo().write({ [`${HOOK}.lock`]: '' }) + + const { exitCode, stdout } = await prepare(project) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten(HOOK, `EEXIST: file already exists, open '${project.path(`${HOOK}.lock`)}'`), + ) + expect(project.exists(HOOK)).toBe(false) + expect(project.exists(`${HOOK}.lock`)).toBe(true) + }) +}) + +describe('prepare in every package of a monorepo at once', () => { + it('keeps the line of every package, as a workspace install running each prepare script does', async () => { + const packages = ['core', 'app', 'web', 'docs', 'cli'] + const project = monorepo( + Object.fromEntries( + packages.map((name) => [ + `packages/${name}/package.json`, + { name: `@repo/${name}`, version: '1.0.0', private: true }, + ]), + ), + ) + const folders = ['', ...packages.map((name) => `packages/${name}/`)] + + const runs = await Promise.all(folders.map((folder) => prepare(project, [], { cwd: folder }))) + + expect(runs.map(({ exitCode }) => exitCode)).toEqual(folders.map(() => 0)) + expect(runs.map(({ stdout }) => stdout.split('\n')[0])).toEqual( + folders.map((folder) => + expect.stringMatching( + new RegExp( + `^✔ pre-commit ${folder === '' ? '' : `${project.dir}/`}\\.git/hooks/pre-commit (?:created|updated)$`, + ), + ), + ), + ) + expect(project.read(HOOK).split('\n').sort()).toEqual( + [...HEADER.split('\n'), ...folders.map((folder) => hookLine(folder))].sort(), + ) + expect(leftoverLocksAndCopies(project)).toEqual([]) + }) +}) + +describe('prepare in a package whose folder name sh would misread', () => { + it.each(['a"b', 'a$b', 'a`b', 'a\\b', 'a\nb'])('writes nothing for %j', async (name) => { + const project = monorepo({ + [`packages/${name}/package.json`]: { name: 'odd', version: '1.0.0', private: true }, + }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: `packages/${name}` }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + project.path(HOOK), + `sh would misread the folder name ${JSON.stringify(`packages/${name}`)} between double quotes`, + ), + ) + expect(project.exists(HOOK)).toBe(false) + }) + + it('writes the line for a folder name with spaces and single quotes, which sh reads as it is', async () => { + const name = "it's my app" + const project = monorepo({ + [`packages/${name}/package.json`]: { name: 'spaced', version: '1.0.0', private: true }, + }) + + const { stdout } = await prepare(project, [], { cwd: `packages/${name}` }) + + expect(stdout).toBe(written(project.path(HOOK), 'created')) + expect(project.read(HOOK)).toBe(`${HEADER}${hookLine(`packages/${name}/`)}\n`) + }) +}) diff --git a/packages/uncheck/tests/prepare/hook.test.ts b/packages/uncheck/tests/prepare/hook.test.ts new file mode 100644 index 0000000..b9079b3 --- /dev/null +++ b/packages/uncheck/tests/prepare/hook.test.ts @@ -0,0 +1,206 @@ +import { cliError, LAYOUTS, monorepo, singleRepo } from '../utils/project' +import { COMMAND, HEADER, hookLine, prepare, shownHook, written } from './utils' + +describe('prepare flags', () => { + it('describes itself and the hook its flags write', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['prepare', '--help']) + + expect(stdout).toContain( + [ + 'DESCRIPTION', + ' Set up git hooks, for the `prepare` script in package.json (`postinstall` with Yarn 2+) so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`', + '', + 'USAGE', + ' uncheck prepare [flags]', + '', + ].join('\n'), + ) + expect(stdout).toContain( + [ + ' --pre-commit Write the git pre-commit hook, which runs `uncheck staged` on the files of every commit', + ' --fix Have the hook apply and stage fixes as well as report. On by default, --no-fix only checks', + ' --allow-empty Have the hook let a commit through when the fixes undo every staged change, which makes it empty', + ].join('\n'), + ) + + for (const flag of ['--cwd', '--only', '--require', '--skip']) { + expect(stdout).toMatch(new RegExp(`^ {2}${flag} `, 'm')) + } + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('refuses a directory that does not exist', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await prepare(project, ['--cwd=missing']) + + expect(stdout).toContain('USAGE\n uncheck prepare [flags]\n') + expect(stderr).toBe( + cliError( + `Invalid value for flag --cwd: "missing". Expected: Path does not exist: ${project.path('missing')}`, + ), + ) + expect(exitCode).toBe(1) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('asks for --pre-commit, the only thing it prepares', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck(['prepare']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + 'Nothing to prepare. Pass --pre-commit to write the git hook that runs `uncheck staged --fix` before every commit.', + ), + ) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('refuses a check selection that contradicts itself before writing anything', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await prepare(project, ['--only=oxlint', '--skip=oxlint']) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe(cliError('--only=oxlint and --skip=oxlint contradict each other.')) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) + +describe.each(LAYOUTS)('prepare outside a working tree in a $name', ({ create, app }) => { + it('has nothing to prepare without a git repository', async () => { + const project = create({}, { git: 'none' }) + + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + }) + + it('has nothing to prepare inside the .git folder', async () => { + const project = create() + + const { exitCode, stdout } = await prepare(project, ['--cwd=.git/hooks']) + + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('has nothing to prepare in a bare repository', async () => { + const project = create() + project.git('clone', '--bare', '--quiet', '.', 'mirror.git') + + const { exitCode, stdout } = await prepare(project, [], { cwd: 'mirror.git' }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('○ no git repository found, nothing to prepare\n') + expect(project.exists('mirror.git/hooks/pre-commit')).toBe(false) + }) +}) + +describe.each(LAYOUTS)('prepare in a $name', ({ create, app }) => { + it('creates an executable pre-commit hook that runs uncheck staged', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await prepare(project, [], { cwd: app }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) + expect(project.git('status', '--porcelain')).toBe('') + }) + + it('creates the hooks folder when git has none', async () => { + const project = create() + project.write({ '.git/hooks': null }) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it('leaves an up to date hook unchanged and makes it executable again', async () => { + const project = create() + await prepare(project, [], { cwd: app }) + project.chmod('.git/hooks/pre-commit', 0o644) + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'unchanged')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) + }) + + it.each([ + [['--no-fix'], 'pnpm exec uncheck staged'], + [['--allow-empty'], 'pnpm exec uncheck staged --fix --allow-empty'], + [ + ['--only=oxlint', '--only=oxfmt'], + 'pnpm exec uncheck staged --fix --only=oxlint --only=oxfmt', + ], + [ + ['--no-fix', '--require=tsc', '--skip=sherif'], + 'pnpm exec uncheck staged --require=tsc --skip=sherif', + ], + ])('updates the hook command for %j', async (flags, command) => { + const project = create() + await prepare(project, [], { cwd: app }) + + const { exitCode, stdout } = await prepare(project, flags, { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'updated', command)) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app, command)}\n`) + expect(project.mode('.git/hooks/pre-commit')).toBe(0o755) + }) + + it('takes the directory from --cwd', async () => { + const project = create() + + const { exitCode, stdout } = await prepare(project, [`--cwd=${project.path(app)}`], { + cwd: '.git', + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) +}) + +describe('prepare in several packages of a monorepo', () => { + it('keeps a line for each package in the order they were prepared, and updates one in place', async () => { + const root = hookLine('') + const core = hookLine('packages/core/', `${COMMAND} --only=oxlint`) + const app = hookLine('packages/app/') + const project = monorepo() + await prepare(project) + await prepare(project, ['--only=oxlint'], { cwd: 'packages/core' }) + + const added = await prepare(project, [], { cwd: 'packages/app' }) + + expect(added.stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'updated')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${root}\n${core}\n${app}\n`) + + const updated = await prepare(project, ['--no-fix']) + + expect(updated.stdout).toBe( + written('.git/hooks/pre-commit', 'updated', 'pnpm exec uncheck staged'), + ) + expect(project.read('.git/hooks/pre-commit')).toBe( + `${HEADER}${hookLine('', 'pnpm exec uncheck staged')}\n${core}\n${app}\n`, + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/linked-checkouts.test.ts b/packages/uncheck/tests/prepare/linked-checkouts.test.ts new file mode 100644 index 0000000..ebc2ebf --- /dev/null +++ b/packages/uncheck/tests/prepare/linked-checkouts.test.ts @@ -0,0 +1,65 @@ +import { basename } from 'node:path' + +import { LAYOUTS, linkedWorktree, monorepo, run, singleRepo } from '../utils/project' +import { HEADER, hookLine, prepare, written } from './utils' + +describe.each(LAYOUTS)('prepare in a linked worktree of a $name', ({ create, app }) => { + it('writes the hook the worktree shares with the main one, named by its full path', async () => { + const project = create() + const worktree = linkedWorktree(project, app) + + const { exitCode, stdout, stderr } = await prepare(worktree, [], { cwd: app }) + + expect(stderr).toBe('') + 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(app)}\n`) + expect(project.exists(`.git/worktrees/${basename(worktree.dir)}/hooks`)).toBe(false) + }) +}) + +describe('prepare run by a git hook in a linked worktree of a monorepo', () => { + it('enters the package folder although git exported the git folder of the worktree', async () => { + const project = monorepo() + const worktree = linkedWorktree(project, 'packages/app/') + project + .write({ + '.git/hooks/post-checkout': `#!/bin/sh\ncd packages/app && '${project.path('node_modules/.bin/uncheck')}' prepare --pre-commit\n`, + }) + .chmod('.git/hooks/post-checkout', 0o755) + + const { exitCode, stderr } = await run(['git', 'checkout', '--quiet', '-b', 'feature'], { + cwd: worktree.dir, + }) + + expect(exitCode).toBe(0) + expect(stderr).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine('packages/app/')}\n`) + }) +}) + +describe.each(LAYOUTS)('prepare in a submodule that is a $name', ({ create, app }) => { + it('writes the hook the superproject keeps for the submodule, named by its full path', async () => { + const superproject = singleRepo() + superproject.git( + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + create().dir, + 'vendor/lib', + ) + const hook = '.git/modules/vendor/lib/hooks/pre-commit' + + const { exitCode, stdout, stderr } = await prepare(superproject, [], { + cwd: `vendor/lib/${app}`, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe(written(superproject.path(hook), 'created')) + expect(superproject.read(hook)).toBe(`${HEADER}${hookLine(app)}\n`) + expect(superproject.exists('.git/hooks/pre-commit')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts new file mode 100644 index 0000000..f50023e --- /dev/null +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -0,0 +1,254 @@ +import { lstatSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import type { Env } from '../utils/project' +import { gitConfig, LAYOUTS, temporaryDirectory, wrappedGit } from '../utils/project' +import { + DISPATCHER, + HEADER, + hookLine, + HUSKY_SHIM, + installHusky, + notWritten, + prepare, + shownHook, + written, +} from './utils' + +const SHARED_HOOK = '#!/bin/sh\necho "scanning for secrets"\n' + +function withGitWithoutShowScope(env: Env = {}): Env { + return { + ...env, + ...wrappedGit( + `case " $* " in *" --show-scope "*) echo "error: unknown option 'show-scope'" >&2; exit 129;; esac`, + ), + } +} + +function sharedHooks(): string { + const dir = temporaryDirectory() + + writeFileSync(join(dir, 'pre-commit'), SHARED_HOOK) + + return dir +} + +describe.each(LAYOUTS)('prepare with core.hooksPath in a $name', ({ create, app }) => { + it('writes into a hooks folder set in the repository config', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + expect(project.read('.githooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('.githooks/pre-commit')).toBe(0o755) + expect(project.exists('.git/hooks/pre-commit')).toBe(false) + }) + + it('names a hooks folder outside the project by its full path', async () => { + const project = create() + const outside = temporaryDirectory() + project.git('config', 'core.hooksPath', outside) + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(join(outside, 'pre-commit'), 'created')) + expect(readFileSync(join(outside, 'pre-commit'), 'utf8')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it.each([ + ['global', (file: string) => ({ GIT_CONFIG_GLOBAL: file })], + ['system', (file: string) => ({ GIT_CONFIG_SYSTEM: file, GIT_CONFIG_NOSYSTEM: undefined })], + ])('leaves a hooks folder set in the %s config alone', async (scope, env) => { + const project = create() + const shared = sharedHooks() + + const { exitCode, stdout } = await prepare(project, [], { + cwd: app, + env: env(gitConfig(`[core]\n\thooksPath = ${shared}\n`)), + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + `core.hooksPath is set in the ${scope} git config, so every repository runs it`, + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) + + it('leaves a hooks folder set in a file the global config includes alone', async () => { + const project = create() + const shared = sharedHooks() + const included = gitConfig(`[core]\n\thooksPath = ${shared}\n`) + + const { stdout } = await prepare(project, [], { + cwd: app, + env: { GIT_CONFIG_GLOBAL: gitConfig(`[include]\n\tpath = ${included}\n`) }, + }) + + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + 'core.hooksPath is set in the global git config, so every repository runs it', + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) + + it('writes into a hooks folder the repository config sets over the global one', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { + cwd: app, + env: { GIT_CONFIG_GLOBAL: gitConfig(`[core]\n\thooksPath = ${sharedHooks()}\n`) }, + }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + }) +}) + +describe.each(LAYOUTS)('prepare with a git older than 2.26 in a $name', ({ create, app }) => { + it('writes the hook when core.hooksPath is not set', async () => { + const project = create() + + const { stdout } = await prepare(project, [], { cwd: app, env: withGitWithoutShowScope() }) + + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + }) + + it('writes into a hooks folder set in the repository config', async () => { + const project = create() + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { cwd: app, env: withGitWithoutShowScope() }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + }) + + it.each([ + ['global', (file: string) => ({ GIT_CONFIG_GLOBAL: file })], + ['system', (file: string) => ({ GIT_CONFIG_SYSTEM: file, GIT_CONFIG_NOSYSTEM: undefined })], + ])('leaves a hooks folder set in the %s config alone', async (scope, env) => { + const project = create() + const shared = sharedHooks() + + const { stdout } = await prepare(project, [], { + cwd: app, + env: withGitWithoutShowScope(env(gitConfig(`[core]\n\thooksPath = ${shared}\n`))), + }) + + expect(stdout).toBe( + notWritten( + join(shared, 'pre-commit'), + `core.hooksPath is set in the ${scope} git config, so every repository runs it`, + ), + ) + expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(SHARED_HOOK) + }) +}) + +describe.each(LAYOUTS)('prepare with a hook dispatcher in a $name', ({ create, app }) => { + function husky() { + return installHusky(create(), 'echo "husky hook ran"\n') + } + + it('writes the hook husky 9 runs, not its generated shim, and keeps its mode, also when unchanged', async () => { + const project = husky() + + const { exitCode, stdout } = await prepare(project, [], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'updated')) + expect(project.read('.husky/pre-commit')).toBe(`${hookLine(app)}\necho "husky hook ran"\n`) + expect(project.mode('.husky/pre-commit')).toBe(0o644) + expect(project.read('.husky/_/pre-commit')).toBe(HUSKY_SHIM) + + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app, '.husky/pre-commit'), 'unchanged')) + expect(project.mode('.husky/pre-commit')).toBe(0o644) + }) + + it('keeps the mode the user gave a husky hook when updating it', async () => { + const project = husky() + project.chmod('.husky/pre-commit', 0o700) + + const { exitCode, stdout } = await prepare(project, ['--no-fix'], { cwd: app }) + + expect(exitCode).toBe(0) + expect(stdout).toBe( + written(shownHook(project, app, '.husky/pre-commit'), 'updated', 'pnpm exec uncheck staged'), + ) + expect(project.read('.husky/pre-commit')).toBe( + `${hookLine(app, 'pnpm exec uncheck staged')}\necho "husky hook ran"\n`, + ) + expect(project.mode('.husky/pre-commit')).toBe(0o700) + }) + + it('creates the hook the Vite+ dispatcher runs, without making it executable', async () => { + const project = create({ '.vite-hooks/_/h': DISPATCHER }) + project.git('config', 'core.hooksPath', '.vite-hooks/_') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.vite-hooks/pre-commit'), 'created')) + expect(project.read('.vite-hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('.vite-hooks/pre-commit') & 0o111).toBe(0) + expect(project.exists('.vite-hooks/_/pre-commit')).toBe(false) + }) + + it('writes into a hooks folder with an h script as it is, when not named _', async () => { + const project = create({ '.githooks/h': 'echo "help"\n' }) + project.git('config', 'core.hooksPath', '.githooks') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.githooks/pre-commit'), 'created')) + expect(project.mode('.githooks/pre-commit')).toBe(0o755) + }) + + it('writes into a hooks folder named _ as it is, when its h cannot be read', async () => { + const project = create() + project.link('.hooks/_/h', 'h') + project.git('config', 'core.hooksPath', '.hooks/_') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app, '.hooks/_/pre-commit'), 'created')) + expect(project.read('.hooks/_/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('.hooks/_/pre-commit')).toBe(0o755) + }) +}) + +describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, app }) => { + it('updates the script the link points to and keeps the link', async () => { + const project = create({ 'scripts/pre-commit': '#!/bin/sh\npnpm test\n' }) + project.link('.git/hooks/pre-commit', '../../scripts/pre-commit') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) + expect(project.read('scripts/pre-commit')).toBe(`#!/bin/sh\n${hookLine(app)}\npnpm test\n`) + expect(project.mode('scripts/pre-commit')).toBe(0o755) + }) + + it('creates the script a dangling link points to', async () => { + const project = create() + project.link('.git/hooks/pre-commit', '../../hooks/pre-commit') + + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'created')) + expect(lstatSync(project.path('.git/hooks/pre-commit')).isSymbolicLink()).toBe(true) + expect(project.read('hooks/pre-commit')).toBe(`${HEADER}${hookLine(app)}\n`) + expect(project.mode('hooks/pre-commit')).toBe(0o755) + }) +}) diff --git a/packages/uncheck/tests/prepare/package-manager.test.ts b/packages/uncheck/tests/prepare/package-manager.test.ts new file mode 100644 index 0000000..7c6e42f --- /dev/null +++ b/packages/uncheck/tests/prepare/package-manager.test.ts @@ -0,0 +1,105 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, monorepo } from '../utils/project' +import { HEADER, hookLine, prepare, shownHook, written } from './utils' + +const YARN = 'yarn run --silent uncheck staged --fix' + +function declarePackageManager(project: Project, dir: string, packageManager: unknown): Project { + return project.update(`${dir}package.json`, (manifest) => ({ ...manifest, packageManager })) +} + +describe.each(LAYOUTS)('prepare picking the package manager in a $name', ({ create, app }) => { + async function expectHookCommand(project: Project, command: string): Promise { + const { stdout } = await prepare(project, [], { cwd: app }) + + expect(stdout).toBe(written(shownHook(project, app), 'created', command)) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine(app, command)}\n`) + } + + it.each([ + ['pnpm-lock.yaml', 'pnpm exec uncheck staged --fix'], + ['yarn.lock', YARN], + ['bun.lock', 'bunx --no-install uncheck staged --fix'], + ['bun.lockb', 'bunx --no-install uncheck staged --fix'], + ['package-lock.json', 'npx --no uncheck staged --fix'], + ])('runs uncheck through the package manager of %s', async (lockfile, command) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}${lockfile}`]: '' }) + + await expectHookCommand(project, command) + }) + + it.each([ + ['pnpm@10.0.0', 'pnpm exec uncheck staged --fix'], + ['yarn@1.22.22', YARN], + ['yarn@4.5.0', YARN], + ['bun@1.2.0', 'bunx --no-install uncheck staged --fix'], + ['npm@11.0.0', 'npx --no uncheck staged --fix'], + ])( + 'runs uncheck through the declared packageManager %s over the lockfile', + async (declared, command) => { + const project = declarePackageManager( + create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }), + app, + declared, + ) + + await expectHookCommand(project, command) + }, + ) + + it.each([ + ['an unknown packageManager', 'deno@2.0.0'], + ['a packageManager that is not a string', { name: 'bun' }], + ])('falls back to the lockfile with %s', async (_, declared) => { + const project = declarePackageManager( + create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }), + app, + declared, + ) + + await expectHookCommand(project, YARN) + }) + + it('skips a lockfile that cannot be resolved', async () => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + project.link(`${app}pnpm-lock.yaml`, 'pnpm-lock.yaml') + + await expectHookCommand(project, YARN) + }) + + it.each([ + ['not valid JSON', '{ "packageManager": "bun@1.2.0", }\n'], + ['not an object', '"bun@1.2.0"\n'], + ])('skips a package.json that is %s', async (_, manifest) => { + const project = create({ 'pnpm-lock.yaml': null, [`${app}yarn.lock`]: '' }) + project.write({ [`${app}package.json`]: manifest }) + + await expectHookCommand(project, YARN) + }) + + it('runs uncheck with npx --no when nothing names a package manager', async () => { + const project = declarePackageManager(create({ 'pnpm-lock.yaml': null }), '', undefined) + + await expectHookCommand(project, 'npx --no uncheck staged --fix') + }) +}) + +describe('prepare picking the package manager in the packages of a monorepo', () => { + it('takes the package manager of the nearest folder that names one', async () => { + const project = monorepo({ 'packages/app/bun.lockb': '' }) + const hook = project.path('.git/hooks/pre-commit') + + const app = await prepare(project, [], { cwd: 'packages/app' }) + const core = await prepare(project, [], { cwd: 'packages/core' }) + + expect(app.stdout).toBe(written(hook, 'created', 'bunx --no-install uncheck staged --fix')) + expect(core.stdout).toBe(written(hook, 'updated', 'pnpm exec uncheck staged --fix')) + 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', + ].join(''), + ) + }) +}) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts new file mode 100644 index 0000000..1e04eb4 --- /dev/null +++ b/packages/uncheck/tests/prepare/utils.ts @@ -0,0 +1,59 @@ +import type { Project, Run, RunOptions } from '../utils/project' + +export const HEADER = + '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n' + +export const COMMAND = 'pnpm exec uncheck staged --fix' + +export function prepare( + project: Project, + args: ReadonlyArray = [], + options?: RunOptions, +): Promise { + return project.uncheck(['prepare', '--pre-commit', ...args], options) +} + +export function hookLine(app: string, command = COMMAND): string { + return app === '' ? `${command} || exit 1` : `(cd "${app.slice(0, -1)}" && ${command}) || exit 1` +} + +export function shownHook(project: Project, app: string, hook = '.git/hooks/pre-commit'): string { + return app === '' ? hook : project.path(hook) +} + +export function written( + shown: string, + result: 'created' | 'updated' | 'unchanged', + command = COMMAND, +): string { + return `✔ pre-commit ${shown} ${result}\n\nThe hook runs ${command} before every commit, \`git commit --no-verify\` skips it.\n` +} + +export function notWritten(shown: string, reason: string): string { + return `✘ pre-commit ${shown} not written, ${reason}\n` +} + +export const DISPATCHER = [ + 's="$(dirname "$(dirname "$0")")/$(basename "$0")"', + '[ ! -f "$s" ] && exit 0', + 'export PATH="node_modules/.bin:$PATH"', + 'sh -e "$s" "$@"', + 'exit $?', + '', +].join('\n') + +export const HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' + +export function installHusky(project: Project, hook: string): Project { + project + .write({ + '.husky/_/h': DISPATCHER, + '.husky/_/pre-commit': HUSKY_SHIM, + '.husky/pre-commit': hook, + }) + .chmod('.husky/_/pre-commit', 0o755) + .chmod('.husky/pre-commit', 0o644) + .git('config', 'core.hooksPath', '.husky/_') + + return project +} diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts new file mode 100644 index 0000000..a7b15bc --- /dev/null +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -0,0 +1,264 @@ +import { cliError, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { folderOf, inIndex } from './utils' + +describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { + const folder = folderOf(app) + + it('needs a git repository', async () => { + const project = create({}, { git: 'none' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe(cliError('`uncheck staged` needs a git repository')) + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) + + it('has nothing to check when nothing is staged', async () => { + const project = create() + + project.write({ [`${app}src/index.ts`]: 'var unstaged = 1\n' }) + + 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, no staged files', + ]) + }) + + it('passes clean staged files and leaves unstaged and untracked ones out', async () => { + const project = create() + + project.stage({ [`${app}src/extra.ts`]: 'export const extra: number = 1;\n' }) + project.write({ + [`${app}src/index.ts`]: `${project.read(`${app}src/index.ts`)}var unstaged = 1\n`, + [`${app}src/untracked.ts`]: 'var untracked = 1\n', + }) + + 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)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(project.git('status', '--porcelain')).toBe( + `A ${app}src/extra.ts\n M ${app}src/index.ts\n?? ${app}src/untracked.ts\n`, + ) + }) + + it('fails on lint, format and type errors in the staged files without touching them', async () => { + const broken = 'var answer: number = "42"\nexport { answer }\n' + const project = create().stage({ [`${app}src/index.ts`]: broken }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern src/index.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', + '✘ oxfmt failed', + tsc, + '✘ tsc failed', + '✘ 3 of 3 checks failed: oxlint, oxfmt, tsc', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(stdout).toContain("error TS2322: Type 'string' is not assignable to type 'number'.") + expect(inIndex(project, `${app}src/index.ts`)).toBe(broken) + expect(project.read(`${app}src/index.ts`)).toBe(broken) + }) + + it('passes --skip, --only and --require through to the checks', async () => { + const project = create({ [`${app}README.md`]: '# app\n' }).stage({ + [`${app}README.md`]: '# app\n\nmore\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + const skipped = await project.uncheck(['staged', '--skip=oxlint', '--skip=tsc'], { + cwd: folder, + }) + + expect(skipped.exitCode).toBe(1) + expect(report(skipped.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, disabled with --skip=oxlint', + '▶ oxfmt --check --no-error-on-unmatched-pattern README.md src/extra.ts', + '✘ oxfmt failed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + + project.git('reset', '--quiet', '--', `${app}src/extra.ts`) + + const unneeded = await project.uncheck(['staged', '--only=tsc'], { cwd: folder }) + + expect(unneeded.exitCode).toBe(0) + expect(report(unneeded.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, no tsconfig.json covers the given files', + '○ 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', + ]) + + const required = await project.uncheck(['staged', '--only=tsc', '--require=tsc'], { + cwd: folder, + }) + + expect(required.exitCode).toBe(1) + expect(report(required.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '✘ tsc no tsconfig.json covers the given files', + '✘ 1 of 1 checks failed: tsc', + ]) + }) + + it('lists more than three files as a count', async () => { + const project = create().stage({ + [`${app}src/a.ts`]: 'export const a = 1\n', + [`${app}src/b.ts`]: 'export const b = 1\n', + [`${app}src/c.ts`]: 'export const c = 1\n', + [`${app}src/d.ts`]: 'export const d = 1\n', + }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + 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 [4 files]', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + '✔ staged the fixes to [4 files]', + ]) + expect(inIndex(project, `${app}src/d.ts`)).toBe('export const d = 1;\n') + }) +}) + +describe('uncheck staged flags', () => { + it('rejects a selection that contradicts itself before looking at git', async () => { + const project = singleRepo({}, { git: 'none' }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--skip=oxlint', + ]) + + expect(stderr).toBe(cliError('--only=oxlint and --skip=oxlint contradict each other.')) + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck staged in a package of a monorepo', () => { + const edits = { + 'packages/app/src/index.ts': 'export const app = 1\n', + 'packages/core/src/index.ts': 'export const core = 1\n', + } + + it('checks and fixes only the staged files of the package it runs in', async () => { + const project = monorepo().stage(edits) + + const inside = await project.uncheck(['staged', '--only=oxfmt'], { cwd: 'packages/app' }) + const fromTop = await project.uncheck(['staged', '--only=oxfmt', '--cwd', 'packages/app']) + + for (const { exitCode, stdout } of [inside, fromTop]) { + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) + } + + const fixed = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: 'packages/app', + }) + + expect(fixed.exitCode).toBe(0) + expect(report(fixed.stdout).at(-1)).toBe('✔ staged the fixes to src/index.ts') + 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']) + }) + + it('reports what sherif finds in the workspace but leaves its fixes to `uncheck --fix`', async () => { + const manifest = { + name: '@repo/app', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { '@repo/core': 'workspace:*' }, + devDependencies: { typescript: '^6.0.0' }, + } + const project = monorepo().stage({ + 'packages/app/package.json': manifest, + 'packages/app/src/index.ts': 'var app = 1\nexport { app }\n', + }) + + const checked = await project.uncheck(['staged', '--skip=tsc']) + + expect(checked.exitCode).toBe(1) + expect(report(checked.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✘ oxfmt failed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 3 of 3 checks failed: sherif, oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + + const fixed = await project.uncheck(['staged', '--fix', '--skip=tsc']) + + expect(fixed.exitCode).toBe(1) + expect(report(fixed.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', + '✔ oxlint passed', + '▶ 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', + ]) + 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 new file mode 100644 index 0000000..7f2625f --- /dev/null +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -0,0 +1,91 @@ +import { LAYOUTS, linkedWorktree, report, run } from '../utils/project' +import type { Project } from '../utils/project' +import { inIndex, stagePartially, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ create, app }) => { + const file = `${app}src/extra.ts` + const other = `${app}src/other.ts` + + function hooked(): Project { + return create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }) + .write({ + '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix) || exit 1\n`, + }) + .chmod('.git/hooks/pre-commit', 0o755) + } + + 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 }) + + const { exitCode, stderr } = await run( + ['git', 'commit', '--quiet', '--message=extra', '--', file], + { cwd: project.dir }, + ) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + 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`) + }) + + it('fixes the files of `git commit -a` in the commit it makes', async () => { + const project = hooked() + + 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, + }) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/other.ts') + 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('') + }) + + 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() + const worktree = stagePartially(linkedWorktree(project, app), file) + + const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--message=extra'], { + cwd: worktree.dir, + }) + + expect(exitCode).toBe(0) + expect(report(stderr).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(worktree.read(file)).toBe(VERSIONS.merged) + expect(worktree.git('status', '--porcelain')).toBe(` M ${file}\n`) + expect(project.exists('.git/worktrees/linked/uncheck-unstaged')).toBe(false) + }) + + it('fixes the files of `git commit ` in the index the worktree keeps', async () => { + const project = hooked() + const worktree = linkedWorktree(project, app).stage({ [other]: 'export const other = 2;\n' }) + + worktree.write({ [file]: VERSIONS.staged }) + + const { exitCode, stderr } = await run( + ['git', 'commit', '--quiet', '--message=extra', '--', file], + { cwd: worktree.dir }, + ) + + expect(exitCode).toBe(0) + expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + 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`) + expect(project.git('status', '--porcelain')).toBe('') + }) + }) +}) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts new file mode 100644 index 0000000..445fdef --- /dev/null +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -0,0 +1,248 @@ +import { mkdirSync } from 'node:fs' + +import { cliError, git, LAYOUTS, report } from '../utils/project' +import { EMPTY_COMMIT_ERROR, folderOf, inIndex } from './utils' + +describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) => { + const folder = folderOf(app) + + it('applies the lint and format fixes and stages them', async () => { + const project = create().stage({ + [`${app}src/index.ts`]: 'var answer = 42\nexport { answer }\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/index.ts', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + '✔ staged the fixes to src/index.ts', + ]) + 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') + expect(project.git('status', '--porcelain')).toBe(`M ${app}src/index.ts\n`) + }) + + it('stages the fixes even when a check still fails', async () => { + const project = create().stage({ + [`${app}src/index.ts`]: 'export const answer: number = "42"\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + 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', + ]) + expect(inIndex(project, `${app}src/index.ts`)).toBe('export const answer: number = "42";\n') + }) + + it('stages nothing when the fixes change nothing', async () => { + const project = create().stage({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(project.git('status', '--porcelain')).toBe(`A ${app}src/extra.ts\n`) + }) + + it('fails when the fixes undo every staged change, unless empty commits are allowed', async () => { + const project = create({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + const unformatted = { [`${app}src/extra.ts`]: 'export const extra = 1\n' } + + project.stage(unformatted) + + const empty = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + 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(project.git('status', '--porcelain')).toBe('') + + project.stage(unformatted) + + const allowed = await project.uncheck(['staged', '--fix', '--allow-empty', '--only=oxfmt'], { + cwd: folder, + }) + + expect(allowed.stderr).toBe('') + expect(allowed.exitCode).toBe(0) + expect(report(allowed.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(project.git('status', '--porcelain')).toBe('') + }) + + it('reports why git refused to stage the fixes', async () => { + const project = create().stage({ [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + + project.write({ '.git/index.lock': '' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + // Each git version words the advice that follows the first line of its message differently. + expect(project.normalize(stderr)).toContain( + cliError( + "git update-index [1 paths] failed: fatal: Unable to create '/.git/index.lock': File exists.", + ).trimEnd(), + ) + expect(exitCode).toBe(1) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1\n') + }) + + it('stages the fixes of the first commit, which has no HEAD to compare with', async () => { + const project = create( + { [`${app}src/extra.ts`]: 'export const extra = 1\n' }, + { git: 'init' }, + ) + + project.git('add', '--all') + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.git('diff', '--name-only')).toBe('') + }) + + it('keeps the executable mode of a staged file it fixes', async () => { + const project = create() + const file = `${app}src/run.ts` + + project.write({ [file]: 'export const run = 1\n' }).chmod(file, 0o755) + project.git('add', '--', file) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + 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(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) + expect(inIndex(project, file)).toBe('export const run = 1;\n') + expect(project.mode(file)).toBe(0o755) + expect(project.git('status', '--porcelain')).toBe(`A ${file}\n`) + }) + + it('checks regular files only, never a staged symlink or deletion', async () => { + const project = create({ [`${app}src/gone.ts`]: 'export const gone = 1;\n' }) + + project.link(`${app}src/link.ts`, 'index.ts').commit('link') + project.git('rm', '--quiet', '--', `${app}src/gone.ts`) + project.write({ [`${app}src/messy.ts`]: 'export const messy = 1\n' }) + project.link(`${app}src/alias.ts`, 'messy.ts') + project.git('add', '--', `${app}src/alias.ts`) + + const nothing = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(nothing.exitCode).toBe(0) + expect(report(nothing.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, no staged files', + ]) + + project.write({ [`${app}src/link.ts`]: null }) + project.stage({ [`${app}src/link.ts`]: 'export const link = 1\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + 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(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( + `A ${app}src/alias.ts\nD ${app}src/gone.ts\nT ${app}src/link.ts\n?? ${app}src/messy.ts\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' }) + + project.write({ [`${app}routes/i/page.ts`]: 'export const i = 2\n' }) + project.stage({ + [`${app}!x.ts`]: 'export const bang = 1\n', + [`${app}-x.ts`]: 'export const dash = 1\n', + [`${app}routes/[id]/page.ts`]: 'export const id = 1\n', + }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], + { cwd: folder, env: { GIT_GLOB_PATHSPECS: '1', GIT_ICASE_PATHSPECS: '1' } }, + ) + + 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 !x.ts -x.ts routes/[id]/page.ts', + '✔ oxlint passed', + '▶ 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', + ]) + expect(inIndex(project, `${app}!x.ts`)).toBe('export const bang = 1;\n') + expect(inIndex(project, `${app}-x.ts`)).toBe('export const dash = 1;\n') + expect(inIndex(project, `${app}routes/[id]/page.ts`)).toBe('export const id = 1;\n') + expect(project.git('status', '--porcelain')).toBe( + `A ${app}!x.ts\nA ${app}-x.ts\nA ${app}routes/[id]/page.ts\n M ${app}routes/i/page.ts\n`, + ) + }) + + it('never stages the commit a submodule has checked out but not staged', async () => { + const project = create() + const submodule = project.path(`${app}vendor`) + const commit = (message: string) => + git(submodule, ['commit', '--quiet', '--allow-empty', `--message=${message}`]) + + mkdirSync(submodule) + git(submodule, ['init', '--quiet']) + commit('one') + project.git('add', '--', `${app}vendor`) + project.commit('submodule') + commit('two') + project.git('add', '--', `${app}vendor`) + project.stage({ [`${app}src/extra.ts`]: 'export const extra = 1\n' }) + commit('three') + + const before = project.git('rev-parse', `:${app}vendor`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + 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 new file mode 100644 index 0000000..4276ec5 --- /dev/null +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -0,0 +1,59 @@ +import { writeFileSync } from 'node:fs' + +import { LAYOUTS, report } from '../utils/project' +import { folderOf, inIndex, stagePartially, startUncheck, VERSIONS } from './utils' + +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 () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n") + + const started = startUncheck(project, ['staged', '--only=oxlint'], { cwd: folder }) + + await started.printed('waiting') + started.child.kill('SIGHUP') + started.child.kill('SIGHUP') + + const { exitCode, stdout } = await started.exited + + 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 --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) + }) + + 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') + + project.fake( + 'oxlint', + `const { existsSync } = require('node:fs')\nconsole.log('waiting')\nconst wait = () => (existsSync(${JSON.stringify(go)}) ? console.log('done') : setTimeout(wait, 10))\nwait()\nsetTimeout(() => process.exit(1), 60_000).unref()\n`, + ) + + const started = startUncheck(project, ['staged', '--fix', '--only=oxlint'], { cwd: folder }) + + await started.printed('waiting') + started.child.stdout.destroy() + started.child.stderr.destroy() + writeFileSync(go, '') + + const { exitCode } = await started.exited + + expect(exitCode).toBe(0) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/staged/line-endings.test.ts b/packages/uncheck/tests/staged/line-endings.test.ts new file mode 100644 index 0000000..a7fcf70 --- /dev/null +++ b/packages/uncheck/tests/staged/line-endings.test.ts @@ -0,0 +1,94 @@ +import { cliError, LAYOUTS } from '../utils/project' +import { conflictError, folderOf, inIndex } from './utils' + +const crlf = (text: string) => text.replaceAll('\n', '\r\n') + +const LINES = 'export const a = 1;\nexport const b = 2;\nexport const c = 3;\n' + +describe.each(LAYOUTS)('uncheck staged with CRLF files in a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/crlf.ts` + + it('merges what git stores under core.autocrlf, so rewritten line endings are no change', async () => { + const project = create({ [file]: `export const answer: number = 42;\n${LINES}` }) + + project.git('config', 'core.autocrlf', 'true') + project.stage({ [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.write({ + [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), + }) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(`export const answer: number = 43;\n${LINES}`) + expect(project.read(file)).toBe( + crlf(`export const answer: number = 43;\n${LINES}export const d = 4;\n`), + ) + }) + + it('puts unstaged changes back with the line endings the package sets', async () => { + const project = create({ + [`${app}.gitattributes`]: '*.ts text eol=crlf\n', + [file]: crlf(`export const answer: number = 42;\n${LINES}`), + }) + + project.stage({ [file]: crlf(`export const answer: number = 43\n${LINES}`) }) + project.write({ + [file]: crlf(`export const answer: number = 43\n${LINES}export const d = 4;\n`), + }) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(`export const answer: number = 43;\n${LINES}`) + expect(project.read(file)).toBe( + crlf(`export const answer: number = 43;\n${LINES}export const d = 4;\n`), + ) + }) + + describe('that git keeps with CRLF under text=auto', () => { + const kept = 'export const a = 1;\r\nexport const b = 2;\r\n' + + function legacy() { + const project = create({ [file]: kept }) + + return project.write({ '.gitattributes': '* text=auto\n' }).commit('attributes') + } + + it('sees no change where nothing changed it', async () => { + const project = legacy() + const unstaged = `${kept}export const c = 3;\r\nexport const d = 4;\r\n` + + project.git('config', 'core.safecrlf', 'true') + project.stage({ [file]: `${kept}export const c = 3;\r\n` }) + project.write({ [file]: unstaged }) + + const { exitCode, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(project.read(file)).toBe(unstaged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) + }) + + it('undoes its fixes rather than turn its line endings to LF', async () => { + const project = legacy() + const unstaged = `var c = 3;\r\n${kept}export { c };\r\nexport const d = 4;\r\n` + + project.stage({ [file]: `var c = 3;\r\n${kept}export { c };\r\n` }) + project.write({ [file]: unstaged }) + + const { exitCode, stderr } = await project.uncheck(['staged', '--fix', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe(cliError(conflictError('src/crlf.ts'))) + expect(exitCode).toBe(1) + expect(project.read(file)).toBe(unstaged) + expect(inIndex(project, file)).toBe(`var c = 3;\r\n${kept}export { c };\r\n`) + }) + }) +}) diff --git a/packages/uncheck/tests/staged/many-files.test.ts b/packages/uncheck/tests/staged/many-files.test.ts new file mode 100644 index 0000000..23e7688 --- /dev/null +++ b/packages/uncheck/tests/staged/many-files.test.ts @@ -0,0 +1,60 @@ +import { LAYOUTS, report } from '../utils/project' +import { folderOf, inIndex, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, app }) => { + const folder = folderOf(app) + + it('splits git and tool calls whose paths pass 30,000 characters', async () => { + const named = (group: string) => + Array.from( + { length: 150 }, + (_, index) => `${app}src/${group}${String(index).padStart(3, '0')}${'x'.repeat(195)}.ts`, + ) + const fixed = named('f') + const partial = named('p') + const withVersion = (files: ReadonlyArray, version: string) => + Object.fromEntries(files.map((file) => [file, version])) + const project = create(withVersion([...fixed, ...partial], VERSIONS.committed)) + + project.stage({ + ...withVersion(fixed, VERSIONS.staged), + ...withVersion(partial, VERSIONS.fixed), + }) + project.write(withVersion(partial, VERSIONS.merged)) + + 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)}`, + '○ unstaged changes of [150 files] set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern [143 files]', + '▶ oxlint --fix --no-error-on-unmatched-pattern [143 files]', + '▶ oxlint --fix --no-error-on-unmatched-pattern [14 files]', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern [143 files]', + '▶ oxfmt --no-error-on-unmatched-pattern [143 files]', + '▶ 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', + ]) + + for (const file of [fixed[0]!, fixed.at(-1)!]) { + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + } + + for (const file of [partial[0]!, partial.at(-1)!]) { + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.merged) + } + }) +}) diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts new file mode 100644 index 0000000..123025e --- /dev/null +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -0,0 +1,94 @@ +import { cliError, LAYOUTS, report, run } from '../utils/project' +import { commitOnSide, folderOf, inIndex, stagePartially, VERSIONS } from './utils' + +describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { + const folder = folderOf(app) + const other = `${app}src/other.ts` + + it('checks only the staged files that differ from the branch being merged in', async () => { + const project = commitOnSide(create({ [other]: 'export const other = 2;\n' }), { + [`${app}src/theirs.ts`]: 'export const theirs = 1\n', + [other]: 'export const other = 3;\n', + }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + + const theirs = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(theirs.exitCode).toBe(0) + expect(report(theirs.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, every staged file comes from the branch being merged in', + ]) + + project.stage({ [other]: 'export const other = 4\n' }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + 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(inIndex(project, other)).toBe('export const other = 4;\n') + expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') + }) + + 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', + }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + project.stage({ [other]: 'export const other = 2\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expect(project.git('diff', '--cached', '--name-only')).toBe('') + + project.git('commit', '--quiet', '--no-edit', '--no-verify') + + expect(project.git('rev-parse', 'HEAD^2')).toBe(project.git('rev-parse', 'side')) + }) + + it('reports what git refuses while the merge still has conflicts', async () => { + const file = `${app}src/extra.ts` + const project = commitOnSide( + create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }), + { [other]: 'export const other = 2;\n' }, + ) + + project.write({ [other]: 'export const other = 3;\n' }).commit('main') + + const merge = await run(['git', 'merge', 'side'], { cwd: project.dir }) + + expect(merge.exitCode).toBe(1) + + stagePartially(project, file) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxfmt'], { + cwd: folder, + }) + + const unmerged = project + .git('ls-files', '--unmerged', '--', other) + .trimEnd() + .split('\n') + .map((entry) => `${other}: unmerged (${entry.split(' ')[1]})\n`) + .join('') + + expect(stderr).toBe( + cliError(`git write-tree failed: ${unmerged}fatal: git-write-tree: error building trees`), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) +}) diff --git a/packages/uncheck/tests/staged/precedence.test.ts b/packages/uncheck/tests/staged/precedence.test.ts new file mode 100644 index 0000000..8a10309 --- /dev/null +++ b/packages/uncheck/tests/staged/precedence.test.ts @@ -0,0 +1,103 @@ +import { cliError, LAYOUTS, report } from '../utils/project' +import { + conflictError, + EMPTY_COMMIT_ERROR, + folderOf, + inIndex, + stagePartially, + stranded, + STRANDED_HINT, + strandedError, + VERSIONS, +} from './utils' + +describe.each(LAYOUTS)( + 'uncheck staged with a failed check and more in a $name', + ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + + it('reports the fixes that conflict with unstaged changes over the failed check', async () => { + const project = create({ [file]: 'export const extra: number = 1;\n' }) + + project.stage({ [file]: 'export const extra: number = "42"\n' }) + project.write({ [file]: 'export const extra: number = "43"\n' }) + + 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(-3)).toEqual([ + '✘ tsc failed', + '✘ 1 of 2 checks failed: tsc', + '✔ staged the fixes to src/extra.ts', + ]) + expect(inIndex(project, file)).toBe('export const extra: number = "42"\n') + expect(project.read(file)).toBe('export const extra: number = "43"\n') + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) + }) + + it('reports the unstaged changes it could not put back over the failed check', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "const fs = require('node:fs')\nfs.rmSync('src/extra.ts')\nfs.mkdirSync('src/extra.ts')\nprocess.exitCode = 1\n", + ) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + const strandedLine = stranded( + 'src/extra.ts', + `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`, + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + 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 --no-error-on-unmatched-pattern src/extra.ts', + '✘ oxlint failed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 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) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.git('status', '--porcelain')).toBe(`MD ${file}\n`) + }) + + it('reports a commit the fixes leave empty over the failed check', async () => { + const project = create({ + [file]: 'export const extra = 1;\n', + [`${app}src/broken.ts`]: 'export const broken: number = "1";\n', + }) + + project.stage({ [file]: 'export const extra = 1\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) + 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', + ]) + 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 new file mode 100644 index 0000000..2ed6b46 --- /dev/null +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -0,0 +1,64 @@ +import { cliError, LAYOUTS, report, run } from '../utils/project' +import { commitOnSide, conflictError, folderOf, inIndex } from './utils' + +describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + const outside = `${app}lib/lib.ts` + + it('undoes or stages only what the fixes changed, leaving a staged file outside the cone alone', async () => { + const project = commitOnSide( + create({ [file]: 'export const extra = 1;\n', [outside]: 'export const lib = 1;\n' }), + { [outside]: 'export const lib = 2;\n' }, + ) + + project.git('sparse-checkout', 'set', `${app}src`) + project.git('merge', '--quiet', '--squash', 'side') + project.stage({ [file]: 'export const extra = 42\n' }) + project.write({ [file]: 'export const extra = 43\n' }) + + const conflicted = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(conflicted.stderr).toBe(cliError(conflictError('src/extra.ts'))) + expect(conflicted.exitCode).toBe(1) + expect(project.git('status', '--porcelain')).toBe(`M ${outside}\nMM ${file}\n`) + expect(project.read(file)).toBe('export const extra = 43\n') + + project.git('add', '--', file) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + 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(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') + }) + + it('stages the fixes to a conflict a merge left outside the cone', async () => { + const project = commitOnSide(create({ [outside]: 'export const lib = 1;\n' }), { + [outside]: 'export const lib = 2;\n', + }) + + project.write({ [outside]: 'export const lib = 3;\n' }).commit('main') + project.git('sparse-checkout', 'set', `${app}src`) + + const merge = await run(['git', 'merge', '--quiet', 'side'], { cwd: project.dir }) + + expect(merge.exitCode).toBe(1) + + project.write({ [outside]: 'export const lib = 4\n' }) + project.git('add', '--sparse', '--', outside) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('✔ staged the fixes to lib/lib.ts') + 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 new file mode 100644 index 0000000..688940d --- /dev/null +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -0,0 +1,475 @@ +import { lstatSync, readdirSync } from 'node:fs' + +import { cliError, LAYOUTS, PERMISSIONS_ENFORCED, report, run, wrappedGit } from '../utils/project' +import { + conflictError, + folderOf, + inIndex, + LEFTOVER_ERROR, + stagePartially, + stranded, + STRANDED_HINT, + strandedError, + VERSIONS, +} from './utils' + +describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + + it('checks the staged version of a partially staged file and puts the unstaged one back', async () => { + const project = create({ [file]: VERSIONS.committed }) + + project.stage({ [file]: VERSIONS.fixed }) + project.write({ [file]: `${VERSIONS.fixed}var unstaged = 1\n` }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--only=oxlint', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + 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 --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ 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', + ]) + expect(project.read(file)).toBe(`${VERSIONS.fixed}var unstaged = 1\n`) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('puts unstaged lines back where they were after the fixes move lines around them', async () => { + const split = 'export const list = [\n 1,\n 2,\n];\n' + const joined = 'export const list = [1, 2];\n' + const added = + "\nexport function f() {\n return 1;\n}\n\nexport function g() {\n return 'g'\n}\n" + const edit = (text: string) => + `${text.replace(' return 1;', ' // in f\n return 1;')}\nexport const z = 1;\n` + const fixed = added.replace("'g'", '"g";') + const project = create({ [file]: joined, [`${app}src/other.ts`]: 'export const other = 1;\n' }) + + project.stage({ [file]: split + added }) + project.write({ + [file]: split + edit(added), + [`${app}src/other.ts`]: 'export const other = 2\n', + [`${app}src/fresh.ts`]: 'export const fresh = 3\n', + }) + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).slice(-2)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + ]) + expect(inIndex(project, file)).toBe(joined + fixed) + expect(project.read(file)).toBe(joined + edit(fixed)) + expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.read(`${app}src/fresh.ts`)).toBe('export const fresh = 3\n') + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\n M ${app}src/other.ts\n?? ${app}src/fresh.ts\n`, + ) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('undoes every fix when one conflicts with unstaged changes, without running the post-checkout hook', async () => { + const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`) + const stagedFar = ['export const f0 = 0;\n', ...far.slice(1)].join('') + const unstagedFar = [ + 'export const f0 = 0;\n', + ...far.slice(1, -1), + 'export const f29 = 30;\n', + ].join('') + const project = create({ + [file]: 'export const extra = 1;\n', + [`${app}src/far.ts`]: far.join(''), + }) + .write({ '.git/hooks/post-checkout': '#!/bin/sh\ntouch .git/post-checkout-ran\nexit 1\n' }) + .chmod('.git/hooks/post-checkout', 0o755) + + project.stage({ + [file]: 'export const extra = 42\n', + [`${app}src/far.ts`]: stagedFar, + [`${app}src/other.ts`]: 'export const other = 2\n', + }) + project.write({ + [file]: 'export const extra = 43\n', + [`${app}src/far.ts`]: unstagedFar, + }) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + 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(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') + expect(project.read(file)).toBe('export const extra = 43\n') + expect(project.read(`${app}src/far.ts`)).toBe(unstagedFar) + expect(project.read(`${app}src/other.ts`)).toBe('export const other = 2\n') + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\nMM ${app}src/far.ts\nA ${app}src/other.ts\n`, + ) + expect(project.exists('.git/post-checkout-ran')).toBe(false) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('refuses unstaged changes that are not edits to a file', async () => { + const project = create({ + [`${app}src/a.ts`]: 'export const a = 1;\n', + [`${app}src/b.ts`]: 'export const b = 1;\n', + }) + + project.stage({ + [`${app}src/a.ts`]: 'export const a = 2;\n', + [`${app}src/b.ts`]: 'export const b = 2;\n', + }) + project.write({ [`${app}src/a.ts`]: null, [`${app}src/b.ts`]: null }) + project.link(`${app}src/b.ts`, 'index.ts') + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(stderr).toBe( + cliError( + 'The unstaged changes of src/a.ts src/b.ts are not edits to a file and cannot be set aside. Stage or stash them, then commit again.', + ), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.exists(`${app}src/a.ts`)).toBe(false) + expect(lstatSync(project.path(`${app}src/b.ts`)).isSymbolicLink()).toBe(true) + expect(project.git('status', '--porcelain')).toBe(`MD ${app}src/a.ts\nMT ${app}src/b.ts\n`) + }) + + it('stops rather than overwrite the unstaged changes an earlier run left behind', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.write({ [`.git/uncheck-unstaged/${file}`]: 'left behind' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe('left behind') + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('stops on the unstaged changes an earlier run left behind even with none to set aside now', async () => { + const project = create().stage({ [file]: 'export const extra = 1\n' }) + + project.write({ '.git/uncheck-unstaged/src/index.ts': 'left behind' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { cwd: folder }) + + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read('.git/uncheck-unstaged/src/index.ts')).toBe('left behind') + expect(inIndex(project, file)).toBe('export const extra = 1\n') + expect(project.git('status', '--porcelain')).toBe(`A ${file}\n`) + }) + + it('stops when a parallel run claims the folder for unstaged changes first', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--fix'], { + cwd: folder, + env: wrappedGit( + 'if [ "$1" = write-tree ]; then mkdir "$("$GIT" rev-parse --git-path uncheck-unstaged)"; fi', + ), + }) + + expect(project.normalize(stderr)).toBe(cliError(LEFTOVER_ERROR)) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(readdirSync(project.path('.git/uncheck-unstaged'))).toEqual([]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('keeps the unstaged mode of a file, whether or not the checks change it', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.chmod(file, 0o755) + + const checked = await project.uncheck(['staged', '--only=oxlint'], { cwd: folder }) + + expect(checked.exitCode).toBe(0) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.mode(file)).toBe(0o755) + + const fixed = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(fixed.exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100644 /) + expect(project.read(file)).toBe(VERSIONS.merged) + expect(project.mode(file)).toBe(0o755) + }) + + it('keeps an edit a tool saves to a partially staged file while the checks run', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "const fs = require('node:fs')\nfs.writeFileSync('src/extra.ts', fs.readFileSync('src/extra.ts', 'utf8').replace('c = 1', 'c = 3'))\n", + ) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(exitCode).toBe(0) + expect(report(stdout).at(-1)).toBe('○ unstaged changes of src/extra.ts restored') + expect(project.read(file)).toBe(VERSIONS.unstaged.replace('c = 1', 'c = 3')) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('merges as plain text whatever merge driver the repository sets', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.write({ '.git/info/attributes': '*.ts merge=ours\n' }) + project.git('config', 'merge.ours.driver', 'true') + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.merged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) + }) + + it('reads every unstaged change right, a rename among them', async () => { + const project = stagePartially( + create({ [file]: VERSIONS.committed, [`${app}src/aaa.ts`]: 'export const aaa = 1;\n' }), + file, + ) + + project.git('mv', '--', `${app}src/aaa.ts`, `${app}src/moved.ts`) + project.git('reset', '--quiet', '--', `${app}src/aaa.ts`, `${app}src/moved.ts`) + project.git('add', '--intent-to-add', '--', `${app}src/moved.ts`) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { cwd: folder }) + + expect(exitCode).toBe(0) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.merged) + expect(project.git('status', '--porcelain')).toBe( + `MM ${file}\n R ${app}src/aaa.ts -> ${app}src/moved.ts\n`, + ) + }) + + it('puts unstaged changes back from a folder whose name has a newline', async () => { + const nested = `${app}new\nline/extra.ts` + const project = stagePartially(create({ [nested]: VERSIONS.committed }), nested) + + const { exitCode } = await project.uncheck(['staged', '--fix', '--only=oxfmt'], { + cwd: `${app}new\nline`, + }) + + expect(exitCode).toBe(0) + expect(inIndex(project, nested)).toBe(VERSIONS.fixed) + expect(project.read(nested)).toBe(VERSIONS.merged) + }) +}) + +describe.each(LAYOUTS)( + 'uncheck staged failing to handle unstaged changes in a $name', + ({ create, app }) => { + const folder = folderOf(app) + const file = `${app}src/extra.ts` + const saved = `.git/uncheck-unstaged/${file}` + + it.runIf(PERMISSIONS_ENFORCED)( + 'keeps the unstaged changes aside and stops when a check leaves the file unwritable', + async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', "require('node:fs').chmodSync('src/extra.ts', 0o444)\n") + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + const output = project.normalize(stdout) + const strandedLine = stranded( + 'src/extra.ts', + `PlatformError: PermissionDenied: FileSystem.copyFile (/${saved})`, + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(output)).toEqual([ + `uncheck staged in ${project.normalize(project.path(folder))}`, + '○ 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)', + strandedLine, + ]) + expect(output).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + expect(project.read(file)).toBe(VERSIONS.staged) + expect(project.mode(file)).toBe(0o444) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }, + ) + + it('keeps the unstaged changes aside and stops when a check replaces the file with a folder', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake( + 'oxlint', + "const fs = require('node:fs')\nfs.rmSync('src/extra.ts')\nfs.mkdirSync('src/extra.ts')\n", + ) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=oxlint'], { + cwd: folder, + }) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(project.normalize(stdout)).toContain( + `${stranded('src/extra.ts', `git -c core.safecrlf=false hash-object -w --path=src/extra.ts [1 paths] failed: fatal: Unable to add ${file} to database`)}\n${STRANDED_HINT}`, + ) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + }) + + it('keeps the fixes staged and the unstaged changes aside when git cannot write the merge back', async () => { + const project = create({ [file]: VERSIONS.committed }) + + project.write({ '.git/info/attributes': `${file} filter=once\n` }) + project.git('config', 'filter.once.clean', 'cat') + project.git( + 'config', + 'filter.once.smudge', + 'if [ -e .git/smudged ]; then exit 1; fi; touch .git/smudged; cat', + ) + project.git('config', 'filter.once.required', 'true') + stagePartially(project, file) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder }, + ) + + const merged = await run(['git', 'hash-object', '--no-filters', '--stdin'], { + cwd: project.dir, + input: VERSIONS.merged, + }) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).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`, + ), + ]) + expect(inIndex(project, file)).toBe(VERSIONS.fixed) + expect(project.read(file)).toBe(VERSIONS.fixed) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + }) + + it('keeps the fixes staged and the unstaged changes aside when git merge-file breaks', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt'], + { + cwd: folder, + env: wrappedGit( + 'if [ "$1" = merge-file ]; then echo "merge-file broke" >&2; exit 129; fi', + ), + }, + ) + + 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( + /^✘ 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) + expect(project.read(file)).toBe(VERSIONS.fixed) + expect(project.read(saved)).toBe(VERSIONS.unstaged) + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'stops before touching a partially staged file it cannot read', + async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.chmod(file, 0o000) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(project.normalize(stderr)).toBe( + cliError(`PermissionDenied: FileSystem.copyFile (/${file})`), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + expect(project.mode(file)).toBe(0o000) + expect(project.chmod(file, 0o644).read(file)).toBe(VERSIONS.unstaged) + }, + ) + + it('puts back what it set aside when git fails to check out the staged versions', async () => { + const other = `${app}src/other.ts` + const project = create({ [file]: VERSIONS.committed, [other]: VERSIONS.committed }) + + project.write({ '.git/info/attributes': `${other} filter=flaky\n` }) + project.git('config', 'filter.flaky.clean', 'cat') + project.git( + 'config', + 'filter.flaky.smudge', + 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi', + ) + project.git('config', 'filter.flaky.required', 'true') + stagePartially(project, file) + stagePartially(project, other) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { + cwd: folder, + }) + + const filter = + "external filter 'if [ -e .git/failed ]; then cat; else touch .git/failed; exit 1; fi' failed" + + expect(stderr).toBe( + cliError( + `git checkout-index -f [2 paths] failed: error: ${filter} 1\nerror: ${filter}\nfatal: ${other}: smudge filter flaky failed`, + ), + ) + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck staged in ${project.path(folder)}`]) + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.read(other)).toBe(VERSIONS.unstaged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\nMM ${other}\n`) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + }, +) diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts new file mode 100644 index 0000000..a860251 --- /dev/null +++ b/packages/uncheck/tests/staged/utils.ts @@ -0,0 +1,118 @@ +import { spawn } from 'node:child_process' +import type { ChildProcessWithoutNullStreams } from 'node:child_process' + +import { CLI, environment } from '../utils/project' +import type { Env, Files, 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 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.' + +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.` +} + +export function strandedError(files: string): string { + return `The unstaged changes of ${files} could not be put back, see above.` +} + +/** The line explaining why the unstaged changes of `files` stayed in the saved folder. */ +export function stranded(files: string, reason: string): string { + return `✘ could not put back the unstaged changes of ${files}: ${reason}` +} + +export const STRANDED_HINT = + ' their unstaged versions are in /.git/uncheck-unstaged, at their paths from the top of the repository: copy back what your files are missing and delete the folder\n' + +const MIDDLE = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\n' + +/** + * The versions of a file whose staged change to its first line needs formatting, while an unstaged + * change edits its last line, far enough apart to merge. + */ +export const VERSIONS = { + committed: `export const a = 1;\n${MIDDLE}export const e = 1;\n`, + staged: `export const a = 2\n${MIDDLE}export const e = 1;\n`, + unstaged: `export const a = 2\n${MIDDLE}export const e = 2;\n`, + fixed: `export const a = 2;\n${MIDDLE}export const e = 1;\n`, + merged: `export const a = 2;\n${MIDDLE}export const e = 2;\n`, +} + +/** Stages the `staged` version of `file`, then leaves its `unstaged` version in the working tree. */ +export function stagePartially(project: Project, file: string): Project { + return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) +} + +/** 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 interface Started { + readonly child: ChildProcessWithoutNullStreams + /** Resolves once stdout 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 } = {}, +): Started { + const child = spawn(CLI[0], [...CLI.slice(1), ...args], { + cwd: project.path(cwd), + env: environment(env), + }) + const waiters: Array<{ readonly marker: string; readonly resolve: () => void }> = [] + let stdout = '' + let stderr = '' + + onTestFinished(() => { + child.kill('SIGKILL') + }) + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + + for (const waiter of waiters.filter(({ marker }) => stdout.includes(marker))) { + waiters.splice(waiters.indexOf(waiter), 1) + waiter.resolve() + } + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + + return { + child, + printed: (marker) => + new Promise((resolve) => { + if (stdout.includes(marker)) { + resolve() + } else { + waiters.push({ marker, resolve }) + } + }), + exited: new Promise((resolve, reject) => { + child.on('error', reject) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + }), + } +} diff --git a/packages/uncheck/tests/tool.test.ts b/packages/uncheck/tests/tool.test.ts deleted file mode 100644 index 09f5797..0000000 --- a/packages/uncheck/tests/tool.test.ts +++ /dev/null @@ -1,78 +0,0 @@ -import Module from 'node:module' -import { join } from 'node:path' -import process from 'node:process' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { argvBatches, resolveBin } from '../src/tool' -import { fixture } from './fixture' - -describe('argvBatches', () => { - it('splits long file lists into batches every platform can spawn, keeping their order', () => { - const files = Array.from({ length: 3000 }, (_, index) => `packages/app/src/feature ${index}.ts`) - const batches = argvBatches(files) - - expect(batches.length).toBeGreaterThan(1) - expect(batches.flat()).toEqual(files) - - for (const batch of batches) { - const length = batch.reduce((total, file) => total + file.length + 3, 0) - - expect(length).toBeLessThanOrEqual(process.platform === 'win32' ? 30_000 : 65_536) - } - }) - - it('keeps a short list in one batch', () => { - expect(argvBatches(['a.ts', 'b.ts'])).toEqual([['a.ts', 'b.ts']]) - expect(argvBatches([])).toEqual([[]]) - }) -}) - -describe('resolveBin', () => { - it('asks the Yarn PnP resolver of each folder up the tree, then its node_modules', async () => { - const dir = fixture( - { - 'package.json': '{}', - 'packages/app/package.json': '{}', - 'packages/app/node_modules/oxfmt/package.json': { bin: 'oxfmt.js' }, - '.yarn/oxlint/package.json': { bin: { oxlint: 'bin.js' } }, - }, - [], - ) - const loader = Module as unknown as { - _resolveFilename: ( - request: string, - parent: { filename: string }, - ...rest: unknown[] - ) => string - } - const resolveFilename = loader._resolveFilename - - loader._resolveFilename = (request, parent, ...rest) => - request === 'oxlint/package.json' && parent.filename === join(dir, 'package.json') - ? join(dir, '.yarn/oxlint/package.json') - : Reflect.apply(resolveFilename, Module, [request, parent, ...rest]) - process.versions.pnp = '3' - - const resolve = (pkg: string) => - Effect.runPromise( - resolveBin(pkg, join(dir, 'packages/app')).pipe(Effect.provide(NodeServices.layer)), - ) - - try { - expect(await resolve('oxlint')).toEqual({ - name: 'oxlint', - entry: join(dir, '.yarn/oxlint/bin.js'), - }) - expect(await resolve('oxfmt')).toEqual({ - name: 'oxfmt', - entry: join(dir, 'packages/app/node_modules/oxfmt/oxfmt.js'), - }) - expect(await resolve('sherif')).toBeUndefined() - } finally { - loader._resolveFilename = resolveFilename - delete process.versions.pnp - } - }) -}) diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts deleted file mode 100644 index f062178..0000000 --- a/packages/uncheck/tests/tsc.test.ts +++ /dev/null @@ -1,383 +0,0 @@ -import { mkdirSync, symlinkSync } from 'node:fs' -import { join } from 'node:path' - -import { NodeServices } from '@effect/platform-node' -import { Effect } from 'effect' - -import { tsc } from '../src/checks/tsc' -import { listProjectFiles } from '../src/files' -import { fixture } from './fixture' - -/** Plans the tsc check in `dir`: the tsc command lines it would run, or the reason it does not run. */ -function plan(dir: string, files?: string[]): Promise { - return Effect.runPromise( - Effect.provide( - tsc.plan({ cwd: dir, fix: false, files, projectFiles: listProjectFiles(dir) }).pipe( - Effect.map((commands) => commands.map((command) => command.args.join(' '))), - Effect.catchTag('NothingToCheck', (error) => Effect.succeed(error.reason)), - Effect.catchTag('CannotCheck', (error) => Effect.succeed(error.reason)), - ), - NodeServices.layer, - ), - ) -} - -const NOT_COVERED = 'no tsconfig.json covers the given files' -// oxlint-disable-next-line no-template-curly-in-string -const CONFIG_DIR = '${configDir}' - -describe('tsc project references', () => { - it('checks standalone projects with tsc -p', async () => { - const dir = fixture({ 'tsconfig.json': {}, 'packages/a/tsconfig.json': {} }) - - expect(await plan(dir)).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - '-p tsconfig.json --noEmit', - ]) - }) - - it('builds only the roots of the reference graph and leaves the rest to tsc -b', async () => { - const dir = fixture({ - 'tsconfig.json': {}, - 'packages/shared/tsconfig.json': {}, - 'packages/client/tsconfig.json': { references: [{ path: '../shared' }] }, - 'packages/server/tsconfig.json': { - references: [{ path: '../client' }, { path: '../shared' }], - }, - 'packages/nest/tsconfig.json': { references: [{ path: '../server/tsconfig.json' }] }, - }) - - expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json --noEmit']) - }) - - it('builds a solution-style root that references configs not named tsconfig.json', async () => { - const dir = fixture({ - 'tsconfig.json': { - references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], - }, - 'tsconfig.app.json': {}, - 'tsconfig.node.json': {}, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - }) - - it('keeps a root with references in build mode even when it is not composite', async () => { - const dir = fixture({ - 'tsconfig.json': { references: [{ path: './packages/a' }, { path: './packages/b' }] }, - 'packages/a/tsconfig.json': {}, - 'packages/b/tsconfig.json': { references: [{ path: '../a' }] }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - }) - - it('handles diamonds and multiple roots', async () => { - const dir = fixture({ - 'a/tsconfig.json': {}, - 'b/tsconfig.json': { references: [{ path: '../a' }] }, - 'c/tsconfig.json': { references: [{ path: '../a' }] }, - 'd/tsconfig.json': { references: [{ path: '../b' }, { path: '../c' }] }, - 'e/tsconfig.json': { references: [{ path: '../a' }] }, - }) - - expect(await plan(dir)).toEqual(['-b d/tsconfig.json e/tsconfig.json']) - }) - - it('fails on circular references instead of silently skipping them', async () => { - const dir = fixture({ - 'a/tsconfig.json': { references: [{ path: '../b' }] }, - 'b/tsconfig.json': { references: [{ path: '../a' }] }, - 'c/tsconfig.json': {}, - }) - - const cycle = 'circular project references between a/tsconfig.json, b/tsconfig.json' - - expect(await plan(dir)).toBe(cycle) - expect(await plan(dir, ['a/src/index.ts'])).toBe(cycle) - expect(await plan(dir, ['c/src/index.ts'])).toBe(cycle) - }) - - it('reports only the cyclic part when a root also exists', async () => { - const dir = fixture({ - 'a/tsconfig.json': { references: [{ path: '../b' }] }, - 'b/tsconfig.json': { references: [{ path: '../c' }] }, - 'c/tsconfig.json': { references: [{ path: '../b' }] }, - }) - - expect(await plan(dir)).toBe( - 'circular project references between b/tsconfig.json, c/tsconfig.json', - ) - }) - - it('follows references as written, leaving the configDir token alone like tsc', async () => { - const dir = fixture({ - 'app/tsconfig.json': { references: [{ path: `${CONFIG_DIR}/../lib` }] }, - 'app/lib/tsconfig.json': {}, - 'lib/tsconfig.json': {}, - }) - - expect(await plan(dir)).toEqual(['-b app/tsconfig.json', '-p lib/tsconfig.json --noEmit']) - }) - - it('reads backslashes in tsconfig paths as separators, like tsc', async () => { - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { allowJs: true }, include: [`${CONFIG_DIR}/src`] }, - 'tsconfig.json': { files: [], references: [{ path: '.\\packages\\b' }] }, - 'packages/a/tsconfig.json': { extends: '..\\..\\tsconfig.base.json' }, - 'packages/b/tsconfig.json': { - extends: '..\\..\\tsconfig.base.json', - references: [{ path: '..\\a' }], - }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/a/scripts/build.ts'])).toBe(NOT_COVERED) - - const project = fixture({ 'a/tsconfig.json': { include: ['.\\src', '..\\shared'] } }) - - expect(await plan(project, ['a/src/x.ts', 'shared/y.ts'])).toEqual([ - '-p a/tsconfig.json --noEmit', - ]) - }) -}) - -describe('tsc project selection', () => { - it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { - const dir = fixture({ - 'tsconfig.json': { - include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], - exclude: ['src/legacy'], - }, - }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('src/index.ts')).toBe(true) - expect(await covers('src/deep/nested/component.tsx')).toBe(true) - expect(await covers('src/types.d.ts')).toBe(true) - expect(await covers('src/index.js')).toBe(false) - expect(await covers('src/README.md')).toBe(false) - expect(await covers('src/.hidden/index.ts')).toBe(false) - expect(await covers('src/node_modules/dep/index.ts')).toBe(false) - expect(await covers('src/legacy/old.ts')).toBe(false) - expect(await covers('scripts/build.ts')).toBe(true) - expect(await covers('scripts/nested/deep/build.ts')).toBe(false) - expect(await covers('config/app.json')).toBe(true) - expect(await covers('src/data.json')).toBe(false) - expect(await covers('packages/app/build.config.ts')).toBe(true) - expect(await covers('packages/app/src/index.ts')).toBe(false) - expect(await covers('node_modules/dep/index.ts')).toBe(false) - }) - - it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { - const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('index.ts')).toBe(true) - expect(await covers('deep/index.ts')).toBe(true) - expect(await covers('dist/index.d.ts')).toBe(false) - expect(await covers('node_modules/dep/index.d.ts')).toBe(false) - expect(await covers('.git/hooks/pre-commit.ts')).toBe(false) - }) - - it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { - const dir = fixture({ - 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, - 'node_modules/@shared/tsconfig/base.json': { - compilerOptions: { allowJs: true }, - // oxlint-disable-next-line no-template-curly-in-string - include: ['${configDir}/lib'], - // oxlint-disable-next-line no-template-curly-in-string - exclude: ['${configDir}/lib/vendor'], - }, - 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, - 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, - 'packages/a/tsconfig.files.json': { - files: ['entry.ts'], - compilerOptions: { allowJs: false }, - }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, - }) - const a = ['-p packages/a/tsconfig.json --noEmit'] - const b = ['-p packages/b/tsconfig.json --noEmit'] - - // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. - expect(await plan(dir, ['packages/a/entry.ts'])).toEqual(a) - expect(await plan(dir, ['src/index.ts'])).toEqual(a) - expect(await plan(dir, ['packages/a/src/index.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['src/index.js'])).toBe(NOT_COVERED) - - // The base's `${configDir}` include and exclude point at the leaf config's folder. - expect(await plan(dir, ['packages/b/lib/util.ts'])).toEqual(b) - expect(await plan(dir, ['packages/b/lib/util.js'])).toEqual(b) - expect(await plan(dir, ['packages/b/lib/vendor/x.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['packages/b/src/index.ts'])).toBe(NOT_COVERED) - }) - - it('resolves extends through the exports of a package like tsc, and nothing they leave out', async () => { - const js = { compilerOptions: { allowJs: true } } - const dir = fixture({ - 'node_modules/@shared/tsconfig/package.json': { - exports: { - './base': './presets/base.json', - './lib': { import: './missing.json', require: './presets/lib.json' }, - './extra/*': './presets/extra/*.json', - }, - }, - 'node_modules/@shared/tsconfig/presets/base.json': js, - 'node_modules/@shared/tsconfig/presets/lib.json': js, - 'node_modules/@shared/tsconfig/presets/extra/web.json': js, - 'packages/a/tsconfig.json': { extends: '@shared/tsconfig/base', include: ['src'] }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig/lib', include: ['src'] }, - 'packages/c/tsconfig.json': { extends: '@shared/tsconfig/extra/web', include: ['src'] }, - 'packages/d/tsconfig.json': { - extends: '@shared/tsconfig/presets/base.json', - include: ['src'], - }, - }) - - // `allowJs` from the preset puts `.js` files in the project, so it was resolved. - expect(await plan(dir, ['packages/a/src/index.js'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - ]) - // `import` is not a condition tsc uses for `extends`, so `require` is picked instead. - expect(await plan(dir, ['packages/b/src/index.js'])).toEqual([ - '-p packages/b/tsconfig.json --noEmit', - ]) - expect(await plan(dir, ['packages/c/src/index.js'])).toEqual([ - '-p packages/c/tsconfig.json --noEmit', - ]) - // The file exists, but the package does not export it. - expect(await plan(dir, ['packages/d/src/index.js'])).toBe(NOT_COVERED) - }) - - it('selects only the projects whose inputs contain a given file', async () => { - const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': { include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'] }, - 'packages/a/tsconfig.json': lib, - 'packages/b/tsconfig.json': lib, - }) - - expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - ]) - // Tests are excluded by the package and picked up by the root instead. - expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ - '-p packages/a/tsconfig.json --noEmit', - '-p packages/b/tsconfig.json --noEmit', - ]) - expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED) - }) - - it('selects the configs a solution-style root references, whatever their name, through the root', async () => { - const dir = fixture({ - 'tsconfig.json': { - files: [], - references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], - }, - 'tsconfig.app.json': { include: ['src'] }, - 'tsconfig.node.json': { include: ['vite.config.ts'] }, - }) - - expect(await plan(dir, ['src/main.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['vite.config.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['tsconfig.app.json'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['scripts/release.ts'])).toBe(NOT_COVERED) - }) - - it('never selects a referenced config that does not exist, leaving the reference to tsc -b', async () => { - const dir = fixture({ - 'tsconfig.json': { include: ['src'], references: [{ path: './packages/gone' }] }, - }) - - expect(await plan(dir)).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['src/index.ts'])).toEqual(['-b tsconfig.json']) - expect(await plan(dir, ['packages/gone/index.ts'])).toBe(NOT_COVERED) - }) - - it('builds every root that depends on a selected project, and no other', async () => { - const dir = fixture({ - 'lib/tsconfig.json': {}, - 'app/tsconfig.json': { references: [{ path: '../lib' }] }, - 'web/tsconfig.json': { references: [{ path: '../lib' }] }, - 'core/tsconfig.json': {}, - 'cli/tsconfig.json': { references: [{ path: '../core' }] }, - 'solo/tsconfig.json': {}, - }) - - expect(await plan(dir, ['lib/src/index.ts'])).toEqual([ - '-b app/tsconfig.json web/tsconfig.json', - ]) - expect(await plan(dir, ['app/src/index.ts'])).toEqual(['-b app/tsconfig.json']) - expect(await plan(dir, ['core/src/index.ts', 'solo/src/index.ts'])).toEqual([ - '-b cli/tsconfig.json', - '-p solo/tsconfig.json --noEmit', - ]) - }) - - it('selects the projects a changed config applies to, also through a workspace package linked into node_modules', async () => { - const dir = fixture({ - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': { include: ['scripts'] }, - 'packages/config/package.json': { name: '@repo/config' }, - 'packages/config/strict.json': { compilerOptions: { noUncheckedIndexedAccess: true } }, - 'packages/a/tsconfig.json': { extends: '../../tsconfig.base.json', include: ['src'] }, - 'packages/b/tsconfig.json': { - extends: ['../../tsconfig.base.json', '@repo/config/strict.json'], - include: ['src'], - }, - }) - mkdirSync(join(dir, 'node_modules/@repo')) - symlinkSync(join(dir, 'packages/config'), join(dir, 'node_modules/@repo/config')) - const a = '-p packages/a/tsconfig.json --noEmit' - const b = '-p packages/b/tsconfig.json --noEmit' - - expect(await plan(dir, ['tsconfig.base.json'])).toEqual([a, b]) - expect(await plan(dir, ['packages/config/strict.json'])).toEqual([b]) - expect(await plan(dir, ['packages/a/tsconfig.json'])).toEqual([a]) - expect(await plan(dir, ['tsconfig.json'])).toEqual(['-p tsconfig.json --noEmit']) - expect(await plan(dir, ['packages/config/package.json'])).toBe(NOT_COVERED) - }) - - it('applies a base reached through two extends branches in both, like tsc, and stops at circular extends', async () => { - const dir = fixture({ - 'tsconfig.base.json': { include: [`${CONFIG_DIR}/lib`] }, - 'tsconfig.b.json': { extends: './tsconfig.base.json', include: [`${CONFIG_DIR}/src`] }, - 'tsconfig.c.json': { extends: './tsconfig.base.json' }, - 'pkg/tsconfig.json': { extends: ['../tsconfig.b.json', '../tsconfig.c.json'] }, - 'loop/tsconfig.json': { extends: './tsconfig.other.json' }, - 'loop/tsconfig.other.json': { extends: './tsconfig.json', include: ['src'] }, - }) - - expect(await plan(dir, ['pkg/lib/index.ts'])).toEqual(['-p pkg/tsconfig.json --noEmit']) - expect(await plan(dir, ['pkg/src/index.ts'])).toBe(NOT_COVERED) - expect(await plan(dir, ['loop/src/index.ts'])).toEqual(['-p loop/tsconfig.json --noEmit']) - }) - - it('takes the inputs tsc takes: no declarationDir, minified scripts or upper-case extensions, but an included node_modules folder', async () => { - const dir = fixture({ - 'tsconfig.json': { - compilerOptions: { allowJs: true, declarationDir: 'src/types' }, - include: ['src', 'vendor/*', 'plugins/jquery*', 'node_modules/x'], - }, - }) - const covers = (file: string) => plan(dir, [file]).then(Array.isArray) - - expect(await covers('src/index.ts')).toBe(true) - expect(await covers('src/types/index.d.ts')).toBe(false) - expect(await covers('vendor/jquery.js')).toBe(true) - expect(await covers('vendor/jquery.min.js')).toBe(false) - expect(await covers('vendor/jquery.min.jsx')).toBe(true) - expect(await covers('plugins/jquery.ui.js')).toBe(true) - expect(await covers('plugins/jquery.min.js')).toBe(false) - expect(await covers('src/UPPER.TS')).toBe(false) - expect(await covers('node_modules/x/index.ts')).toBe(true) - expect(await covers('node_modules/y/index.ts')).toBe(false) - }) -}) diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts new file mode 100644 index 0000000..0994105 --- /dev/null +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -0,0 +1,122 @@ +import { readFileSync } from 'node:fs' + +import { + CLI, + cliError, + LAYOUTS, + report, + run, + singleRepo, + temporaryDirectory, +} from '../utils/project' +import { CODE_WITH_VAR } from './utils' + +const { version } = JSON.parse( + readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), +) as { version: string } + +function onlyOxlint(dir: string): string[] { + return [ + `uncheck in ${dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ] +} + +describe.each(LAYOUTS)('uncheck --cwd in a $name', ({ create, app }) => { + const legacyOutsideSrc = { [`${app}legacy.ts`]: CODE_WITH_VAR } + + it('checks the directory given relative to the current one', async () => { + const project = create(legacyOutsideSrc) + + const { exitCode, stdout } = await project.uncheck(['--cwd', `${app}src`, '--only=oxlint']) + + expect(report(stdout)).toEqual(onlyOxlint(project.path(app, 'src'))) + expect(exitCode).toBe(0) + }) + + it('checks the directory given as an absolute path', async () => { + const project = create(legacyOutsideSrc) + + const { exitCode, stdout } = await run( + [...CLI, `--cwd=${project.path(app, 'src')}`, '--only=oxlint'], + { cwd: temporaryDirectory() }, + ) + + expect(report(stdout)).toEqual(onlyOxlint(project.path(app, 'src'))) + expect(exitCode).toBe(0) + }) + + it.each([ + { given: 'a directory that does not exist', cwd: 'missing', problem: 'Path does not exist' }, + { given: 'a file', cwd: 'src/index.ts', problem: 'Path is not a directory' }, + ])('refuses $given', async ({ cwd, problem }) => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['--cwd', `${app}${cwd}`]) + + expect(stdout).toContain('USAGE') + expect(stderr).toBe( + cliError( + `Invalid value for flag --cwd: "${app}${cwd}". Expected: ${problem}: ${project.path(app, cwd)}`, + ), + ) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck command line', () => { + it('prints its version', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--version']) + + expect(stdout).toBe(`uncheck v${version}\n`) + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('prints its usage', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--help']) + + expect(stdout).toContain('USAGE\n uncheck [flags] []\n') + + for (const flag of [ + '--cwd', + '--fix', + '--no-error-on-unmatched-pattern', + '--only', + '--require', + '--skip', + ]) { + expect(stdout).toMatch(new RegExp(`^ {2}${flag} `, 'm')) + } + + for (const subcommand of ['staged', 'prepare', 'hooks']) { + expect(stdout).toMatch(new RegExp(`^ {2}${subcommand} `, 'm')) + } + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it('prints a shell completion script', async () => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck(['--completions', 'bash']) + + expect(stdout).toMatch(/^###-begin-uncheck-completions-###\n/) + expect(stdout).toContain("compgen -W 'staged prepare hooks'") + expect(stdout).toMatch(/\ncomplete -F _uncheck uncheck\n###-end-uncheck-completions-###\n$/) + expect(stderr).toBe('') + expect(exitCode).toBe(0) + }) + + it.each(['--wizard', '--log-level'])('refuses the global flag %s it leaves out', async (flag) => { + const { exitCode, stdout, stderr } = await singleRepo().uncheck([flag]) + + expect(stdout).toContain('USAGE') + expect(stderr).toBe(cliError(`Unrecognized flag: ${flag} in command uncheck`)) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts new file mode 100644 index 0000000..7507ffa --- /dev/null +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -0,0 +1,214 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, report } from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CODE_WITH_VAR, + layoutChecks, + monorepoWithMismatchedVersions, + UNFORMATTED_CODE, +} from './utils' + +describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) + + it('applies lint fixes and rewrites formatting, then passes', async () => { + const project = create({ + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/ugly.ts`]: UNFORMATTED_CODE, + }) + + const { exitCode, stdout } = await project.uncheck(['--fix']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...layoutChecks(app, { fix: true }).sherif, + '▶ oxlint --fix', + '✔ oxlint passed', + '▶ oxfmt', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.join(', ')})`, + ]) + expect(exitCode).toBe(0) + expect(project.read(`${app}src/legacy.ts`)).toBe('const count = 1;\nexport { count };\n') + expect(project.read(`${app}src/ugly.ts`)).toBe('export const ugly = { a: 1, b: 2 };\n') + }) + + it('offers the fixes of every fixable check that failed', async () => { + const project = create({ + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/ugly.ts`]: UNFORMATTED_CODE, + }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=oxfmt']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('leaves type errors out of the fixes it offers', async () => { + const project = create({ + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + [`${app}src/broken.ts`]: CODE_WITH_TYPE_ERROR, + }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 2 of 2 checks failed: oxlint, tsc', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('leaves a required check that cannot run out of the fixes it offers', async () => { + const project = create( + { [`${app}src/legacy.ts`]: CODE_WITH_VAR }, + { tools: ['sherif', 'oxlint', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc', '--require=oxfmt']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '✘ oxfmt not installed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 2 of ${checks.length - 1} checks failed: oxlint, oxfmt`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('offers no fixes once --fix ran and a problem remains', async () => { + const project = create({ + '.oxlintrc.json': { rules: { 'no-console': 'error' } }, + [`${app}src/log.ts`]: 'console.log("hello");\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=oxlint']) + + expect(stdout).toContain('eslint(no-console)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix', + '✘ oxlint failed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxlint', + ]) + expect(exitCode).toBe(1) + expect(project.read(`${app}src/log.ts`)).toBe('console.log("hello");\n') + }) +}) + +function zodVersions(project: Project) { + return ['core', 'app'].map( + (name) => + ( + JSON.parse(project.read(`packages/${name}/package.json`)) as { + dependencies: Record + } + ).dependencies.zod, + ) +} + +describe('uncheck --fix in a monorepo', () => { + it('aligns mismatched versions on the highest one', async () => { + const project = monorepoWithMismatchedVersions() + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif --fix --select=highest', + '✔ sherif passed', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + expect(zodVersions(project)).toEqual(['^3.1.0', '^3.1.0']) + expect((await project.uncheck(['--only=sherif'])).exitCode).toBe(0) + }) + + it('leaves the version choice to a sherif config that makes one', async () => { + const project = monorepoWithMismatchedVersions({ select: 'lowest' }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif']) + + expect(report(stdout)).toContain('▶ sherif --fix') + expect(exitCode).toBe(0) + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.0.0']) + }) + + it('only reports what sherif finds in CI, where sherif refuses to fix', async () => { + const project = monorepoWithMismatchedVersions() + project.write({ 'packages/app/src/legacy.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--skip=tsc'], { + env: { CI: 'true' }, + }) + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix', + '✔ oxlint passed', + '▶ oxfmt', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + '✘ 1 of 3 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + expect(project.read('packages/app/src/legacy.ts')).toBe('const count = 1;\nexport { count };\n') + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.1.0']) + }) + + it.each(['0', ''])('takes CI=%j for CI too', async (ci) => { + const project = monorepoWithMismatchedVersions() + + const { exitCode, stdout } = await project.uncheck(['--fix', '--only=sherif'], { + env: { CI: ci }, + }) + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + expect(zodVersions(project)).toEqual(['^3.0.0', '^3.1.0']) + }) + + it('offers the fixes of three checks as a list', async () => { + const project = monorepoWithMismatchedVersions() + project.write({ + 'packages/app/src/legacy.ts': CODE_WITH_VAR, + 'packages/app/src/ugly.ts': UNFORMATTED_CODE, + }) + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) + + expect(report(stdout).slice(-2)).toEqual([ + '✘ 3 of 3 checks failed: sherif, oxlint, oxfmt', + ' rerun with `--fix` to apply sherif, oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts new file mode 100644 index 0000000..419f66b --- /dev/null +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -0,0 +1,207 @@ +import { availableParallelism } from 'node:os' +import { styleText } from 'node:util' + +import { 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 PRINT_FORCE_COLOR = "console.log('the tool sees FORCE_COLOR=' + process.env.FORCE_COLOR);\n" + +const STANDALONE_PROJECTS = [1, 2, 3, 4, 5].map((index) => `project-${index}`) + +function countConcurrentRuns(cap: number, total: number): string { + return `const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); + +const config = process.argv[process.argv.indexOf('-p') + 1]; +const marker = config.replaceAll('/', '_'); +const count = (dir) => readdirSync(dir).length; +const giveUp = Date.now() + 10_000; +let most = 0; + +console.log(\`\${config} started\`); +mkdirSync('running', { recursive: true }); +mkdirSync('finished', { recursive: true }); +writeFileSync(\`running/\${marker}\`, ''); + +(function waitForOthers() { + const running = count('running'); + most = Math.max(most, running); + + if (running === ${cap} || running + count('finished') === ${total}) { + console.log(\`\${config} saw \${most} running\`); + renameSync(\`running/\${marker}\`, \`finished/\${marker}\`); + } else if (Date.now() > giveUp) { + console.log(\`\${config} gave up with \${running} running\`); + } else { + setTimeout(waitForOthers, 10); + } +})(); +` +} + +function paint(style: Parameters[0], text: string): string { + return styleText(style, text, { validateStream: false }) +} + +describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) + + it('fails a check whose tool is killed by a signal and still runs the others', async () => { + const project = create().fake('oxlint', "process.kill(process.pid, 'SIGKILL');\n") + + const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) + + expect(stdout).toMatch( + /^▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, + ) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 1 of ${checks.length - 1} checks failed: oxlint`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('prints the output of standalone projects one project after the other', async () => { + const project = create({ + [`${app}scripts/tsconfig.json`]: STANDALONE_TSCONFIG, + [`${app}scripts/check.ts`]: 'export const scripts: number = "1";\n', + [`${app}tools/tsconfig.json`]: STANDALONE_TSCONFIG, + [`${app}tools/check.ts`]: 'export const tools: number = "2";\n', + }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + const lines = stdout + .split('\n') + .filter((line) => line.startsWith('▶ ') || line.includes('error TS')) + .map((line) => line.replace(/\(\d+,\d+\): error (TS\d+).*$/, ' $1')) + + expect(lines).toEqual([ + ...(app === '' ? [] : [tsc]), + `▶ tsc -p ${app}scripts/tsconfig.json --noEmit`, + `${app}scripts/check.ts TS2322`, + `▶ tsc -p ${app}tools/tsconfig.json --noEmit`, + `${app}tools/check.ts TS2322`, + ...(app === '' ? [tsc] : []), + ]) + expect(report(stdout).at(-1)).toBe('✘ 1 of 1 checks failed: tsc') + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck output', () => { + it('runs up to four type checkers side by side and prints the output of each in one piece, in order', async () => { + const cap = Math.min(4, availableParallelism()) + const project = singleRepo( + Object.fromEntries( + STANDALONE_PROJECTS.flatMap((name) => [ + [`${name}/tsconfig.json`, STANDALONE_TSCONFIG], + [`${name}/check.ts`, 'export const value = 1;\n'], + ]), + ), + ).fake('typescript', countConcurrentRuns(cap, STANDALONE_PROJECTS.length + 1)) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + const runs = [ + ...stdout.matchAll(/^▶ tsc -p (\S+) --noEmit\n\1 started\n\1 saw (\d+) running$/gm), + ] + + expect(runs.map(([, config]) => config)).toEqual([ + ...STANDALONE_PROJECTS.map((name) => `${name}/tsconfig.json`), + 'tsconfig.json', + ]) + expect(Math.max(...runs.map(([, , most]) => Number(most)))).toBe(cap) + expect(report(stdout).at(-1)).toBe('✔ all checks passed (tsc)') + expect(exitCode).toBe(0) + }) + + it('shows how long a slow check took in seconds', async () => { + const project = singleRepo().fake('oxlint', 'setTimeout(() => {}, 1100);\n') + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) + + expect(stdout).toMatch(/^✔ oxlint passed \d+\.\ds$/m) + expect(exitCode).toBe(0) + }) + + it('styles its lines and asks tools for colors when FORCE_COLOR is set', async () => { + const project = singleRepo().fake('oxfmt', PRINT_FORCE_COLOR) + + const { exitCode, stdout } = await project.uncheck(['--only=oxfmt'], { + env: { FORCE_COLOR: '1' }, + }) + + expect(stdout).toContain( + `${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`, + ) + expect(stdout).toContain( + `${paint('green', '✔')} ${paint('bold', 'oxfmt')} ${paint('green', 'passed')} `, + ) + expect(stdout).toContain(`${paint('green', '✔')} all checks passed (oxfmt)\n`) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('styles failures in red', async () => { + const project = singleRepo({ 'src/legacy.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--require=oxlint'], { + env: { FORCE_COLOR: '1' }, + }) + + expect(stdout).toContain( + `${paint('red', '✘')} ${paint('bold', 'oxlint')} ${paint('red', 'failed')} `, + ) + expect(stdout).toContain(`${paint('red', '✘')} 1 of 1 checks failed: oxlint\n`) + expect(stdout).toContain(paint('dim', ' rerun with `--fix` to apply oxlint fixes')) + expect(exitCode).toBe(1) + }) + + it('asks tools for colors when it prints to a terminal', async () => { + const project = singleRepo().fake('oxlint', PRINT_FORCE_COLOR) + + const { exitCode, stdout } = await project.uncheckInTerminal(['--only=oxlint']) + + expect(stdout).toContain('\nthe tool sees FORCE_COLOR=1\n') + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxlint)') + expect(exitCode).toBe(0) + }) + + it('leaves tools without colors when it prints to a pipe', async () => { + const project = singleRepo().fake('oxlint', PRINT_FORCE_COLOR) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) + + expect(stdout).toContain('▶ oxlint\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 new file mode 100644 index 0000000..4d0a301 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts @@ -0,0 +1,133 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, report } from '../utils/project' +import { checkedFiles, listingProject } from './utils' + +const ROUTES = [ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + 'nested/deep.ts', + 'nested/more/deeper.ts', + 'settings.json', +] + +describe.each(LAYOUTS)('uncheck with exclusions in a $name', ({ create, app }) => { + const cwd = `${app}src/routes` + + function routesProject(): Project { + return listingProject(create, Object.fromEntries(ROUTES.map((file) => [`${cwd}/${file}`, '']))) + } + + async function check(project: Project, ...patterns: ReadonlyArray): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + const without = (...excluded: ReadonlyArray) => + ROUTES.filter((file) => !excluded.includes(file)) + + it('leaves out a file', async () => { + const project = routesProject() + + expect(await check(project, '.', '!home.ts', '!./about.ts')).toEqual( + without('home.ts', 'about.ts'), + ) + }) + + it('leaves out a file whose name looks like a glob along with every file that glob matches', async () => { + const project = routesProject() + + expect(await check(project, '.', '![id].ts')).toEqual(without('[id].ts', 'd.ts', 'i.ts')) + }) + + it('leaves out everything below a directory', async () => { + const project = routesProject() + + expect(await check(project, '.', '!nested/more')).toEqual(without('nested/more/deeper.ts')) + expect(await check(project, '.', '!nested/')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + }) + + it('leaves out the files a glob matches, dot files included', async () => { + const project = routesProject() + + expect(await check(project, '.', '!**/*.json', '!.*')).toEqual( + without('settings.json', '.env.ts'), + ) + expect(await check(project, '.', '!nested/**/deep*.ts')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + }) + + it('reads (...) literally and [!...] as any other character in an exclusion', async () => { + const project = routesProject() + + expect(await check(project, '.', '!(group)/**')).toEqual(without('(group)/page.ts')) + expect(await check(project, '.', '![!ah]*.ts')).toEqual( + without('#draft.ts', '.env.ts', '[id].ts', 'd.ts', 'i.ts'), + ) + }) + + it('reads an exclusion starting with "#" as a glob rather than a comment', async () => { + const project = routesProject() + + expect(await check(project, '.', '!#*.ts')).toEqual(without('#draft.ts')) + }) + + it('reads "!!name" as leaving out a file whose name starts with "!"', async () => { + const project = routesProject().write({ [`${cwd}/!draft.ts`]: '' }) + + expect(await check(project, '.')).toEqual(['./!draft.ts', ...ROUTES]) + expect(await check(project, '.', '!!draft.ts')).toEqual(ROUTES) + }) + + it('applies exclusions on their own to everything', async () => { + const project = routesProject() + + expect(await check(project, '!nested', '!*.json')).toEqual([ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + ]) + }) + + it('applies exclusions wherever they stand and ignores ones that match nothing', async () => { + const project = routesProject() + + expect(await check(project, '!home.ts', 'home.ts', 'about.ts', '!missing')).toEqual([ + 'about.ts', + ]) + }) + + it('leaves nothing to check after "!."', async () => { + const project = routesProject() + + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', '.', '!.'], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path(cwd)}`, + '○ nothing to check, no files match . !.', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts new file mode 100644 index 0000000..f8628a8 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -0,0 +1,135 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { cliError, LAYOUTS, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './utils' + +describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ create, app }) => { + const routes = `${app}src/routes` + + it('leaves files git ignores out of a directory or glob but checks one named', async () => { + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/dist/bundle.ts`]: CODE_WITH_VAR, + }) + + const directory = await project.uncheck(['--only=oxlint', routes]) + const glob = await project.uncheck(['--only=oxlint', `${routes}/**/*.ts`]) + const ignoredDirectory = await project.uncheck(['--only=oxlint', `${routes}/dist`]) + const named = await project.uncheck(['--only=oxlint', `${routes}/dist/bundle.ts`]) + + for (const { exitCode, stdout } of [directory, glob]) { + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + } + expect(ignoredDirectory.exitCode).toBe(1) + expect(ignoredDirectory.stderr).toBe( + cliError( + `No files match ${routes}/dist. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), + ) + expect(named.exitCode).toBe(1) + expect(selectedReport(named.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/dist/bundle.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks untracked files', async () => { + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }) + project.write({ [`${routes}/fresh.ts`]: CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(1) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/fresh.ts ${routes}/home.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks the files of a repository without commits', async () => { + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }, { git: 'init' }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('drops a tracked file deleted from the working tree before it is staged', async () => { + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/gone.ts`]: CLEAN_CODE, + }) + project.write({ [`${routes}/gone.ts`]: null }) + + const directory = await project.uncheck(['--only=oxlint', routes]) + const glob = await project.uncheck(['--only=oxlint', `${routes}/*.ts`]) + + for (const { exitCode, stdout } of [directory, glob]) { + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + } + }) + + it('hands the tools a linked folder as the file git lists, so oxlint checks its files twice', async () => { + const project = create({ [`${routes}/shared/util.ts`]: CODE_WITH_VAR }) + .link(`${routes}/linked`, 'shared') + .commit() + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(1) + expect(stdout).toContain(`${routes}/linked/util.ts:1:1`) + expect(stdout).toContain(`${routes}/shared/util.ts:1:1`) + expect(stdout).toContain('eslint(no-var)') + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/linked ${routes}/shared/util.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'index.ts'), CODE_WITH_VAR) + const project = create({ + '.gitignore': 'node_modules/\ndist/\n', + [`${routes}/home.ts`]: CLEAN_CODE, + }).link(`${routes}/node_modules`, store) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', routes]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-globs.test.ts b/packages/uncheck/tests/uncheck/paths-globs.test.ts new file mode 100644 index 0000000..bc8af90 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-globs.test.ts @@ -0,0 +1,130 @@ +import type { Project } from '../utils/project' +import { LAYOUTS } from '../utils/project' +import { checkedFiles, listingProject } from './utils' + +const ROUTES = [ + '#draft.ts', + '(group)/page.ts', + '.env.ts', + '[id].ts', + '[slug]/page.ts', + 'about.ts', + 'd.ts', + 'group/page.ts', + 'home.ts', + 'i.ts', + 'nested/.draft.ts', + 'nested/deep.ts', + 'notes.md', +] + +describe.each(LAYOUTS)('uncheck with globs in a $name', ({ create, app }) => { + const routes = (...files: ReadonlyArray) => + files.map((file) => `${app}src/routes/${file}`) + + function routesProject() { + return listingProject(create, { + ...Object.fromEntries(routes(...ROUTES).map((file) => [file, ''])), + '.github/workflows/ci.yml': 'on: push\n', + }) + } + + async function check( + project: Project, + patterns: ReadonlyArray, + cwd?: string, + ): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns], { + cwd, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + it('matches * within one folder, dot files included', async () => { + const project = routesProject() + + expect(await check(project, routes('*.ts'))).toEqual( + routes('#draft.ts', '.env.ts', '[id].ts', 'about.ts', 'd.ts', 'home.ts', 'i.ts'), + ) + }) + + it('matches ** across folders, dot files and dot folders included', async () => { + const project = routesProject() + + expect(await check(project, routes('**/*.ts'))).toEqual( + routes(...ROUTES.filter((file) => file.endsWith('.ts'))), + ) + expect(await check(project, ['**/*.yml'])).toEqual(['.github/workflows/ci.yml']) + }) + + it('matches ? as one character', async () => { + const project = routesProject() + + expect(await check(project, routes('?.ts'))).toEqual(routes('d.ts', 'i.ts')) + }) + + it('matches [...] as one of the characters and [!...] as any other', async () => { + const project = routesProject() + + expect(await check(project, routes('[hd]*.ts'))).toEqual(routes('d.ts', 'home.ts')) + expect(await check(project, routes('[!ah]*.ts'))).toEqual( + routes('#draft.ts', '.env.ts', '[id].ts', 'd.ts', 'i.ts'), + ) + }) + + it('matches {a,b} as either, and a "." among them as the folder itself', async () => { + const project = routesProject() + + expect(await check(project, routes('{home,about}.ts'))).toEqual(routes('about.ts', 'home.ts')) + expect(await check(project, routes('{.,nested}/*.ts'))).toEqual( + routes( + '#draft.ts', + '.env.ts', + '[id].ts', + 'about.ts', + 'd.ts', + 'home.ts', + 'i.ts', + 'nested/.draft.ts', + 'nested/deep.ts', + ), + ) + }) + + it('reads (...) literally, as in route groups, and @(a|b) as either', async () => { + const project = routesProject() + + expect(await check(project, routes('(group)/**'))).toEqual(routes('(group)/page.ts')) + expect(await check(project, routes('@(home|about).ts'))).toEqual(routes('about.ts', 'home.ts')) + }) + + it('reads a glob starting with "#" as a glob rather than a comment', async () => { + const project = routesProject() + + expect(await check(project, ['#*.ts'], `${app}src/routes`)).toEqual(['#draft.ts']) + }) + + it('takes an existing file whose name looks like a glob as that file', async () => { + const project = routesProject() + + expect(await check(project, routes('[id].ts', '(group)/page.ts'))).toEqual( + routes('(group)/page.ts', '[id].ts'), + ) + + project.write({ [routes('[id].ts')[0]!]: null }) + + expect(await check(project, routes('[id].ts'))).toEqual(routes('d.ts', 'i.ts')) + }) + + it('takes an existing folder whose name looks like a glob as that folder', async () => { + const project = routesProject() + + expect(await check(project, routes('(group)', '[slug]'))).toEqual( + routes('(group)/page.ts', '[slug]/page.ts'), + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-tools.test.ts b/packages/uncheck/tests/uncheck/paths-tools.test.ts new file mode 100644 index 0000000..87f9d6b --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-tools.test.ts @@ -0,0 +1,232 @@ +import { writeFileSync } from 'node:fs' +import { join, relative } from 'node:path' + +import { LAYOUTS, report, temporaryDirectory } from '../utils/project' +import { CLEAN_CODE, CODE_WITH_VAR, NOT_COVERED, selectedReport } from './utils' + +const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] + +describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ create, app, tsc }) => { + it('hands oxlint and oxfmt the given files alone and runs tsc on the config covering them', async () => { + const project = create({ + [`${app}src/extra.ts`]: CLEAN_CODE, + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + `${app}src/index.ts`, + `${app}src/extra.ts`, + ]) + + expect(stderr).toBe('') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/extra.ts ${app}src/index.ts`, + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + }) + + it('lists every file for "." but leaves the choice to each tool without paths', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/legacy.ts`]: CODE_WITH_VAR, + }) + + const dot = await project.uncheck([...ONLY_FILE_CHECKS, '.'], { cwd: `${app}src/routes` }) + const everything = await project.uncheck(ONLY_FILE_CHECKS, { cwd: `${app}src/routes` }) + + expect(dot.exitCode).toBe(1) + expect(selectedReport(dot.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint --no-error-on-unmatched-pattern home.ts legacy.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern home.ts legacy.ts', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(everything.exitCode).toBe(1) + expect(selectedReport(everything.stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + 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 }) + + const { exitCode, stdout } = await project.uncheck(['--fix', `${app}src/given.ts`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --fix --no-error-on-unmatched-pattern ${app}src/given.ts`, + '✔ oxlint passed', + `▶ oxfmt --no-error-on-unmatched-pattern ${app}src/given.ts`, + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + expect(project.read(`${app}src/given.ts`)).toBe( + 'const point = { x: 1, y: 2 };\nexport { point };\n', + ) + expect(project.read(`${app}src/other.ts`)).toBe(broken) + }) + + it('passes files that no tool handles to the linter and formatter without failing', async () => { + const project = create({ + [`${app}notes.txt`]: 'var draft\n', + [`${app}README.md`]: '# App\n', + }) + + const { exitCode, stdout } = await project.uncheck([`${app}notes.txt`, `${app}README.md`]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + `▶ oxlint --no-error-on-unmatched-pattern ${app}README.md ${app}notes.txt`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}README.md ${app}notes.txt`, + '✔ oxfmt passed', + NOT_COVERED, + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('lists up to three files on the command line and counts more', async () => { + const project = create({ + [`${app}lib/a.ts`]: 'export const a = 1;\n', + [`${app}lib/b.ts`]: 'export const b = 2;\n', + [`${app}lib/c.ts`]: 'export const c = 3;\n', + [`${app}lib/d.ts`]: 'export const d = 4;\n', + }) + + const three = await project.uncheck([ + '--only=oxlint', + `${app}lib/c.ts`, + `${app}lib/a.ts`, + `${app}lib/b.ts`, + ]) + + expect(selectedReport(three.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}lib/a.ts ${app}lib/b.ts ${app}lib/c.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(three.exitCode).toBe(0) + + const four = await project.uncheck(['--only=oxlint', `${app}lib`]) + + expect(selectedReport(four.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ oxlint --no-error-on-unmatched-pattern [4 files]', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(four.exitCode).toBe(0) + }) + + it('splits a file list too long for one command line into several runs', async () => { + const name = (index: number) => + `${app}generated/${'x'.repeat(80 - app.length)}${String(index).padStart(4, '0')}.ts` + const files = Object.fromEntries( + Array.from({ length: 301 }, (_, index) => [name(index), 'export const value = 1;\n']), + ) + const project = create({ ...files, [name(300)]: CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck([...ONLY_FILE_CHECKS, `${app}generated`]) + + expect(stdout).toContain('eslint(no-var)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern [300 files]', + `▶ oxlint --no-error-on-unmatched-pattern ${name(300)}`, + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern [300 files]', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${name(300)}`, + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 2 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('hands tools file names starting with - or ! as files', async () => { + const project = create({ + [`${app}-draft.ts`]: CODE_WITH_VAR, + [`${app}!notes.ts`]: 'export const notes = 1\n', + }) + + const check = await project.uncheck([...ONLY_FILE_CHECKS, '*.ts'], { cwd: app }) + + expect(check.stdout).toContain('-draft.ts') + expect(check.stdout).toContain('eslint(no-var)') + expect(check.stdout).toContain('!notes.ts') + expect(report(check.stdout)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern !notes.ts -draft.ts', + '✘ oxlint failed', + '▶ oxfmt --check --no-error-on-unmatched-pattern !notes.ts -draft.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(check.exitCode).toBe(1) + + const fix = await project.uncheck(['--fix', ...ONLY_FILE_CHECKS, '*.ts'], { cwd: app }) + + expect(fix.exitCode).toBe(0) + expect(project.read(`${app}-draft.ts`)).toBe('const count = 1;\nexport { count };\n') + expect(project.read(`${app}!notes.ts`)).toBe('export const notes = 1;\n') + }) + + it('hands oxlint and oxfmt a file above the directory it runs in as a "../" path they reject', async () => { + const project = create() + const outside = temporaryDirectory() + writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) + const handed = relative(project.dir, join(outside, 'shared.ts')) + + const { exitCode, stdout, stderr } = await project.uncheck([ + ...ONLY_FILE_CHECKS, + join(outside, 'shared.ts'), + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${handed}`, + '✘ oxlint failed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${handed}`, + '✘ oxfmt failed', + '✘ 2 of 2 checks failed: oxlint, oxfmt', + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect( + stdout + .split('\n') + .filter((line) => line === `Error: \`${handed}\`: PATH must not contain ".."`), + ).toHaveLength(2) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts new file mode 100644 index 0000000..c66eb52 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts @@ -0,0 +1,69 @@ +import { mkdirSync } from 'node:fs' + +import { cliError, LAYOUTS, report } from '../utils/project' + +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 () => { + const project = create() + mkdirSync(project.path(app, 'src/empty')) + + const { exitCode, stdout, stderr } = await project.uncheck([ + `${app}src/index.ts`, + `${app}missing.ts`, + `${app}src/**/*.tsx`, + `${app}lib/`, + `${app}src/empty`, + ]) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) + expect(stderr).toBe( + cliError( + `No files match ${app}missing.ts, ${app}src/**/*.tsx, ${app}lib/, ${app}src/empty. 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() + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + '--only=oxlint', + '--only=oxfmt', + `${app}missing.ts`, + `${app}src/index.ts`, + `${app}src/**/*.tsx`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxlint passed', + `▶ oxfmt --check --no-error-on-unmatched-pattern ${app}src/index.ts`, + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + }) + + it('passes with nothing to check when no path matched with --no-error-on-unmatched-pattern', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + `${app}missing.ts`, + `${app}src/**/*.tsx`, + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `○ nothing to check, no files match ${app}missing.ts ${app}src/**/*.tsx`, + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths-walk.test.ts b/packages/uncheck/tests/uncheck/paths-walk.test.ts new file mode 100644 index 0000000..f3951e9 --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths-walk.test.ts @@ -0,0 +1,82 @@ +import type { Project } from '../utils/project' +import { LAYOUTS, PERMISSIONS_ENFORCED } from '../utils/project' +import { checkedFiles, listingProject } from './utils' + +describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, app }) => { + const routes = `${app}src/routes` + + function walkedProject(...files: ReadonlyArray): Project { + return listingProject( + create, + Object.fromEntries(files.map((file) => [`${routes}/${file}`, ''])), + { git: 'none' }, + ) + } + + async function check(project: Project, ...patterns: ReadonlyArray): Promise { + const { exitCode, stdout, stderr } = await project.uncheck(['--only=oxlint', ...patterns]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + + return checkedFiles(stdout) + } + + it('lists the files below a directory, sorted, without dot files or installed packages', async () => { + const project = walkedProject( + 'home.ts', + 'Zone.ts', + 'nested/deep.ts', + '.env.ts', + '.cache/entry.ts', + 'node_modules/dep/index.ts', + 'bower_components/dep/index.ts', + 'jspm_packages/dep/index.ts', + ) + const listed = [`${routes}/Zone.ts`, `${routes}/home.ts`, `${routes}/nested/deep.ts`] + + expect(await check(project, routes)).toEqual(listed) + expect(await check(project, `${routes}/**/*.ts`)).toEqual(listed) + }) + + it('lists the files .gitignore would leave out', async () => { + const project = walkedProject('home.ts', 'dist/bundle.ts').write({ + '.gitignore': 'dist/\n', + [`${app}dist/out.js`]: '', + }) + + expect(await check(project, routes, `${app}dist/**`)).toEqual([ + `${app}dist/out.js`, + `${routes}/dist/bundle.ts`, + `${routes}/home.ts`, + ]) + }) + + 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`] + + expect(await check(project, ...named)).toEqual(named) + }) + + it('lists a linked file but never enters a linked folder or follows a broken link', async () => { + const project = walkedProject('home.ts', 'shared/util.ts') + .link(`${routes}/alias.ts`, 'home.ts') + .link(`${routes}/broken.ts`, 'missing.ts') + .link(`${routes}/self.ts`, 'self.ts') + .link(`${routes}/up`, '..') + .link(`${routes}/linked`, 'shared') + + expect(await check(project, routes)).toEqual([ + `${routes}/alias.ts`, + `${routes}/home.ts`, + `${routes}/shared/util.ts`, + ]) + }) + + it.runIf(PERMISSIONS_ENFORCED)('walks past a folder it cannot read', async () => { + const project = walkedProject('home.ts', 'locked/secret.ts').chmod(`${routes}/locked`, 0) + + expect(await check(project, routes)).toEqual([`${routes}/home.ts`]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts new file mode 100644 index 0000000..2d4c1da --- /dev/null +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -0,0 +1,152 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { cliError, LAYOUTS, monorepo, 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 }) => { + it('checks every file below a directory it is given', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/nested/legacy.ts`]: CODE_WITH_VAR, + [`${app}scripts/legacy.ts`]: CODE_WITH_VAR, + }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', `${app}src/routes/`]) + + expect(exitCode).toBe(1) + expect(stdout).toContain(`${app}src/routes/nested/legacy.ts:1:1`) + expect(stdout).toContain('eslint(no-var)') + expect(stdout).not.toContain('scripts/legacy.ts') + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/routes/home.ts ${app}src/routes/nested/legacy.ts`, + '✘ oxlint failed', + '✘ 1 of 1 checks failed: oxlint', + ' rerun with `--fix` to apply oxlint fixes', + ]) + }) + + it('checks a file named by several paths once', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/routes/nested/about.ts`]: CLEAN_CODE, + }) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `${app}src/routes/nested`, + `${app}src/routes/*.ts`, + `${app}src/routes/home.ts`, + `./${app}src/routes/nested/../home.ts`, + ]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/routes/home.ts ${app}src/routes/nested/about.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('resolves paths against --cwd rather than the directory it starts in', async () => { + const project = create({ 'index.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `--cwd=${app}src`, + 'index.ts', + ]) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '▶ oxlint --no-error-on-unmatched-pattern index.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('resolves paths against the subdirectory it starts in', async () => { + const project = create({ 'index.ts': CODE_WITH_VAR }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', 'index.ts'], { + cwd: `${app}src`, + }) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '▶ oxlint --no-error-on-unmatched-pattern index.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('follows "../" back into the directory it runs in', async () => { + const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE }) + + const { exitCode, stdout } = await project.uncheck( + ['--only=oxlint', '../routes/home.ts', '../routes'], + { cwd: `${app}src/routes` }, + ) + + expect(exitCode).toBe(0) + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src/routes')}`, + '▶ oxlint --no-error-on-unmatched-pattern home.ts', + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }) + + it('matches nothing with a directory or glob above the directory it runs in', async () => { + const project = create({ + [`${app}src/routes/home.ts`]: CLEAN_CODE, + [`${app}src/legacy.ts`]: CODE_WITH_VAR, + }) + const outside = temporaryDirectory() + writeFileSync(join(outside, 'shared.ts'), CLEAN_CODE) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['--only=oxlint', '..', '../*.ts', outside], + { cwd: `${app}src/routes` }, + ) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.path(app, 'src/routes')}`]) + expect(stderr).toBe( + cliError( + `No files match .., ../*.ts, ${outside}. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), + ) + }) +}) + +describe('uncheck selecting files by path in a monorepo package', () => { + it('resolves paths from the package directory and checks that package alone', async () => { + const project = monorepo({ + 'packages/core/src/legacy.ts': CODE_WITH_VAR, + 'packages/app/src/extra.ts': CLEAN_CODE, + }) + + const { exitCode, stdout, stderr } = await project.uncheck(['src', 'package.json'], { + cwd: 'packages/app', + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path('packages/app')}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint --no-error-on-unmatched-pattern package.json src/extra.ts src/index.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern package.json src/extra.ts src/index.ts', + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts new file mode 100644 index 0000000..734b43d --- /dev/null +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -0,0 +1,179 @@ +import { LAYOUTS, report } from '../utils/project' +import { layoutChecks } from './utils' + +const PRESET_CONFIGS = { + '.oxlintrc.json': null, + 'oxlint.config.ts': `import { defineConfig } from 'oxlint' +import { middleapi } from 'uncheck/oxlint' + +export default defineConfig({ + extends: [middleapi], +}) +`, + 'oxfmt.config.ts': `import { defineConfig } from 'oxfmt' +import { middleapi } from 'uncheck/oxfmt' + +export default defineConfig({ + ...middleapi, +}) +`, +} + +const BUG_RULES = [ + 'eslint(preserve-caught-error)', + 'oxc(misrefactored-assign-op)', + 'oxc(no-accumulating-spread)', + 'typescript(no-confusing-non-null-assertion)', + 'unicorn(no-accessor-recursion)', + 'unicorn(no-array-fill-with-reference-type)', + 'unicorn(no-negation-in-equality-check)', + 'eslint(no-constructor-return)', +] + +const USE_BEFORE_FIX = `import { c } from "./other.ts"; +import { a } from "./lib.ts"; +import { b } from "./lib.ts"; +console.log(a, b, c) +export const enum Level { Low } +` + +function presetProject(create: (typeof LAYOUTS)[number]['create'], app: string) { + return create({ + ...PRESET_CONFIGS, + [`${app}tsconfig.json`]: + app === '' + ? { extends: 'uncheck/tsconfig/middleapi', include: ['src'] } + : { + extends: 'uncheck/tsconfig/middleapi/lib', + references: [{ path: '../core' }], + include: ['src'], + }, + [`${app}src/legacy.ts`]: 'var count = 1\nif (count == 1) {\n count = 2\n}\nexport { count }\n', + [`${app}src/strict.ts`]: `export function first(items: string[]): string { + return items[0] +} + +export function identity(value) { + return value +} +`, + [`${app}src/bugs.ts`]: `export function parse() { + try { JSON.parse("x") } + catch (error) { throw new Error("parse failed") } +} +export function assign(a: number, b: number) { a -= a - b; return a } +export function collect(xs: number[]) { let out: number[] = []; for (const x of xs) { out = [...out, x] } return out } +export function confuse(a: string | null, b: string) { return a! == b } +export class Recurse { get v(): number { return this.v } } +export function fill() { return new Array(3).fill([]) } +export function negate(a: boolean, b: boolean) { return !a === b } +export class Construct { constructor() { return { x: 1 } } } +`, + [`${app}src/lib.ts`]: 'export const a = 1\nexport const b = 2\n', + [`${app}src/other.ts`]: 'export const c = 3\n', + [`${app}src/use.ts`]: USE_BEFORE_FIX, + [`${app}src/warn.ts`]: `import { a } from './lib.ts' +import { b } from './lib.ts' + +export function pause() { + debugger + return a + b +} +`, + [`${app}src/log.ts`]: "console.warn('careful')\nconsole.error('failed')\n", + }) +} + +describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) + + it('reports the problems the presets catch', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck() + + for (const rule of [ + 'eslint(no-var)', + 'eslint(eqeqeq)', + 'eslint(no-console)', + 'oxc(no-const-enum)', + 'import(no-duplicates)', + ...BUG_RULES, + ]) { + expect(stdout).toContain(rule) + } + + expect(stdout).toContain(`${app}src/index.ts`) + expect(stdout).toContain('error TS2322') + expect(stdout).toContain('error TS7006') + expect(stdout).not.toContain('TS5097') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tsc, + '✘ tsc failed', + `✘ 3 of ${checks.length} checks failed: oxlint, oxfmt, tsc`, + ' rerun with `--fix` to apply oxlint and oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('reports warnings without failing the run', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + `${app}src/warn.ts`, + `${app}src/log.ts`, + ]) + + expect(stdout).toContain('eslint(no-debugger)') + expect(stdout).toContain('import(no-duplicates)') + expect(stdout).not.toContain('no-console') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + `▶ oxlint --no-error-on-unmatched-pattern ${app}src/log.ts ${app}src/warn.ts`, + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(exitCode).toBe(0) + }) + + it('fixes what the presets can fix', async () => { + const project = presetProject(create, app) + + const { exitCode, stdout } = await project.uncheck(['--fix', '--skip=tsc']) + + expect(stdout).toContain('eslint(no-console)') + expect(stdout).toContain('eslint(eqeqeq)') + expect(stdout).not.toContain('import(no-duplicates)') + expect(stdout).not.toContain('oxc(no-const-enum)') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...layoutChecks(app, { fix: true }).sherif, + '▶ oxlint --fix', + '✘ oxlint failed', + '▶ oxfmt', + '✔ oxfmt passed', + '○ tsc skipped, disabled with --skip=tsc', + `✘ 1 of ${checks.length - 1} checks failed: oxlint`, + ]) + expect(exitCode).toBe(1) + expect(project.read(`${app}src/use.ts`)).toBe( + "import { a, b } from './lib.ts'\nimport { c } from './other.ts'\nconsole.log(a, b, c)\nexport enum Level {\n Low,\n}\n", + ) + expect(project.read(`${app}src/index.ts`)).toMatch( + /^import \{ double \} from '[^']+'\n\nexport const answer: number = double\(21\)\n$/, + ) + expect(project.read(`${app}src/legacy.ts`)).toBe( + 'let count = 1\nif (count == 1) {\n count = 2\n}\nexport { count }\n', + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts new file mode 100644 index 0000000..c6873e4 --- /dev/null +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -0,0 +1,141 @@ +import { LAYOUTS, report } from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CODE_WITH_VAR, + layoutChecks, + monorepoWithMismatchedVersions, + UNFORMATTED_CODE, +} from './utils' + +describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) + + it('passes a clean project', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck() + + expect(stderr).toBe('') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.join(', ')})`, + ]) + expect(exitCode).toBe(0) + }) + + it('fails on a lint error and still runs the other checks', async () => { + const project = create({ [`${app}src/legacy.ts`]: CODE_WITH_VAR }) + + const { exitCode, stdout, stderr } = await project.uncheck() + + expect(stderr).toBe('') + expect(stdout).toContain('eslint(no-var)') + expect(stdout).toContain(`${app}src/legacy.ts`) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✘ oxlint failed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + `✘ 1 of ${checks.length} checks failed: oxlint`, + ' rerun with `--fix` to apply oxlint fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('fails on a formatting issue', async () => { + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain(`${app}src/ugly.ts`) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✘ oxfmt failed', + tsc, + '✔ tsc passed', + `✘ 1 of ${checks.length} checks failed: oxfmt`, + ' rerun with `--fix` to apply oxfmt fixes', + ]) + expect(exitCode).toBe(1) + }) + + it('fails on a type error without offering fixes', async () => { + const project = create({ [`${app}src/broken.ts`]: CODE_WITH_TYPE_ERROR }) + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain( + `${app}src/broken.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`, + ) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✘ tsc failed', + `✘ 1 of ${checks.length} checks failed: tsc`, + ]) + expect(exitCode).toBe(1) + }) + + it('fails when a tsconfig.json exists but typescript is not installed', async () => { + const project = create({}, { tools: ['sherif', 'oxlint', 'oxfmt'] }) + const tsconfigs = app === '' ? 1 : 3 + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + `✘ tsc found ${tsconfigs} tsconfig.json but typescript is not installed`, + `✘ 1 of ${checks.length} checks failed: tsc`, + ]) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck in a monorepo', () => { + it('fails on a workspace issue sherif finds', async () => { + const project = monorepoWithMismatchedVersions() + + const { exitCode, stdout } = await project.uncheck() + + expect(stdout).toContain('multiple-dependency-versions') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✔ tsc passed', + '✘ 1 of 4 checks failed: sherif', + ' rerun with `--fix` to apply sherif fixes', + ]) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts new file mode 100644 index 0000000..f07f190 --- /dev/null +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -0,0 +1,204 @@ +import { cliError, LAYOUTS, project as bareProject, report, singleRepo } 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 }) => { + const { sherif, checks } = layoutChecks(app) + + it('runs only the checks named with --only', async () => { + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) + + const { exitCode, stdout } = await project.uncheck(['--only=oxlint', '--only=tsc']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, tsc)', + ]) + expect(exitCode).toBe(0) + }) + + it('skips the checks named with --skip', async () => { + const project = create({ [`${app}src/ugly.ts`]: UNFORMATTED_CODE }) + + const { exitCode, stdout } = await project.uncheck(['--skip=oxfmt', '--skip=tsc']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, disabled with --skip=oxfmt', + '○ tsc skipped, disabled with --skip=tsc', + `✔ all checks passed (${checks.filter((name) => name !== 'oxfmt' && name !== 'tsc').join(', ')})`, + ]) + expect(exitCode).toBe(0) + }) + + it('skips a check whose tool is not installed, unless it is required', async () => { + const project = create({}, { tools: ['sherif', 'oxlint', 'typescript'] }) + + const skipped = await project.uncheck(['--only=oxlint', '--only=oxfmt']) + + expect(report(skipped.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(skipped.exitCode).toBe(0) + + const required = await project.uncheck(['--only=oxlint', '--only=oxfmt', '--require=oxfmt']) + + expect(report(required.stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '✘ oxfmt not installed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 2 checks failed: oxfmt', + ]) + expect(required.exitCode).toBe(1) + }) + + it('runs a required check that can run as usual', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--only=oxlint', + '--only=oxfmt', + '--require=oxlint', + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('fails when every check is skipped', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck([ + '--skip=sherif', + '--skip=oxlint', + '--skip=oxfmt', + '--skip=tsc', + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, disabled with --skip=sherif', + '○ oxlint skipped, disabled with --skip=oxlint', + '○ oxfmt skipped, disabled with --skip=oxfmt', + '○ tsc skipped, disabled with --skip=tsc', + '✘ nothing to check: sherif disabled with --skip=sherif, oxlint disabled with --skip=oxlint, oxfmt disabled with --skip=oxfmt, tsc disabled with --skip=tsc', + ]) + expect(exitCode).toBe(1) + }) + + it('fails when no check has anything to do with the given files', async () => { + const project = create({ [`${app}README.md`]: '# App\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', `${app}README.md`]) + + expect(report(stdout)).toEqual([ + `uncheck 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', + ]) + expect(exitCode).toBe(1) + }) + + it('passes when no check has anything to do with the given files and unmatched patterns are allowed', async () => { + const project = create({ [`${app}README.md`]: '# App\n' }) + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + '--only=tsc', + `${app}README.md`, + ]) + + expect(report(stdout)).toEqual([ + `uncheck 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', + ]) + expect(exitCode).toBe(0) + }) +}) + +describe('uncheck check selection', () => { + it('fails in a project that uses none of the tools', async () => { + const project = bareProject({ 'package.json': { name: 'bare', private: true } }, { tools: [] }) + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck 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', + ]) + expect(exitCode).toBe(1) + }) + + it.each([ + { + contradiction: 'a required check it skips', + flags: ['--require=tsc', '--skip=tsc'], + message: '--require=tsc and --skip=tsc contradict each other.', + }, + { + contradiction: 'a selected check it skips', + flags: ['--only=oxlint', '--skip=oxlint'], + message: '--only=oxlint and --skip=oxlint contradict each other.', + }, + { + contradiction: 'a required check it does not select', + flags: ['--only=oxlint', '--only=oxfmt', '--require=tsc'], + message: '--require=tsc and --only=oxlint --only=oxfmt contradict each other.', + }, + ])('refuses $contradiction', async ({ flags, message }) => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck(flags) + + expect(stdout).toBe('') + expect(stderr).toBe(cliError(message)) + expect(exitCode).toBe(1) + }) + + it('refuses a check name it does not know', async () => { + const project = singleRepo() + + const { exitCode, stdout, stderr } = await project.uncheck(['--only=eslint']) + + expect(stdout).toContain('USAGE') + expect(stderr).toBe( + cliError( + 'Invalid value for flag --only: "eslint". Expected: "sherif" | "oxlint" | "oxfmt" | "tsc"', + ), + ) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts new file mode 100644 index 0000000..af4fb87 --- /dev/null +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -0,0 +1,151 @@ +import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' + +const OTHERS_NOT_SELECTED = [ + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', +] + +function sherifSkipped(dir: string, reason: string): string[] { + return [ + `uncheck in ${dir}`, + `○ sherif skipped, ${reason}`, + ...OTHERS_NOT_SELECTED, + `✘ nothing to check: sherif ${reason}, oxlint not selected by --only, oxfmt not selected by --only, tsc not selected by --only`, + ] +} + +describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { + it('skips sherif when it is not installed', async () => { + const project = create({}, { tools: ['oxlint', 'oxfmt', 'typescript'] }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', '--only=oxlint']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '▶ oxlint', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint)', + ]) + expect(exitCode).toBe(0) + }) + + it('skips sherif in a folder without a package.json', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif'], { cwd: `${app}src` }) + + expect(report(stdout)).toEqual(sherifSkipped(project.path(app, 'src'), 'no package.json found')) + expect(exitCode).toBe(1) + }) + + it.each([ + ['is not JSON', '{ "name": \n'], + ['is not an object', 'null\n'], + ])('skips sherif when the package.json %s', async (_, manifest) => { + const project = create({ 'package.json': manifest }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'no package.json found')) + expect(exitCode).toBe(1) + }) + + it('skips sherif in a package that is not a workspace root', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif'], { cwd: app }) + + expect(report(stdout)).toEqual(sherifSkipped(project.path(app, '.'), 'not a workspace root')) + expect(exitCode).toBe(1) + }) + + it('skips sherif when no package.json is among the given files', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', `${app}src/index.ts`]) + + expect(report(stdout)).toEqual( + sherifSkipped(project.dir, 'no package.json among the given files'), + ) + 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', + 'pnpm-workspace.yaml', + ) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'not a workspace root')) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck sherif in a single repo', () => { + it('fails when sherif is required outside a workspace', async () => { + const project = singleRepo() + + const { exitCode, stdout } = await project.uncheck(['--require=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '✘ sherif not a workspace root', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✘ 1 of 4 checks failed: sherif', + ]) + expect(exitCode).toBe(1) + }) +}) + +describe('uncheck sherif in a monorepo', () => { + it.each(['packages/core/package.json', 'pnpm-workspace.yaml'])( + 'checks the whole workspace when %s is among the given files', + async (file) => { + const project = monorepo() + + const { exitCode, stdout } = await project.uncheck(['--only=sherif', file]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✔ sherif passed', + ...OTHERS_NOT_SELECTED, + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + }, + ) + + 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/*'] })) + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { 'zod': '^3.0.0', '@repo/core': 'workspace:*' }, + })) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(stdout).toContain('unordered-dependencies') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + ...OTHERS_NOT_SELECTED, + '✘ 1 of 1 checks failed: sherif', + ' rerun with `--fix` to apply sherif fixes', + ]) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tools.test.ts b/packages/uncheck/tests/uncheck/tools.test.ts new file mode 100644 index 0000000..b81c8fa --- /dev/null +++ b/packages/uncheck/tests/uncheck/tools.test.ts @@ -0,0 +1,158 @@ +import { LAYOUTS, monorepo, report } from '../utils/project' +import { layoutChecks } from './utils' + +const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] + +const YARN_PNP_RESOLVER = `const Module = require('node:module'); +const path = require('node:path'); + +process.versions.pnp = '3'; + +const resolveFilename = Module._resolveFilename; + +Module._resolveFilename = function (request, ...rest) { + return request === 'oxlint/package.json' + ? path.join(__dirname, '.yarn/unplugged/oxlint/package.json') + : resolveFilename.call(this, request, ...rest); +}; +` + +describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc }) => { + const { sherif, checks } = layoutChecks(app) + + it('skips oxlint and oxfmt when they are not installed', async () => { + const project = create({}, { tools: ['sherif', 'typescript'] }) + + const { exitCode, stdout } = await project.uncheck() + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...sherif, + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + tsc, + '✔ tsc passed', + `✔ all checks passed (${checks.filter((name) => name === 'sherif' || name === 'tsc').join(', ')})`, + ]) + expect(exitCode).toBe(0) + }) + + it('runs a tool whose manifest names its only bin with a string', async () => { + const project = create( + { + 'node_modules/oxfmt/package.json': { name: 'oxfmt', bin: 'cli.js' }, + 'node_modules/oxfmt/cli.js': + "console.log('oxfmt ran with ' + process.argv.slice(2).join(' '));\n", + }, + { tools: ['sherif', 'oxlint', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(['--only=oxfmt']) + + expect(stdout).toContain('▶ oxfmt --check\noxfmt ran with --check\n') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it.each([ + ['declares a bin for another command', { name: 'oxlint', bin: { oxc: 'bin.js' } }], + ['declares no bin', { name: 'oxlint' }], + ['is not JSON', '{ "name": "oxlint",\n'], + ['is not an object', '"oxlint"\n'], + ])('takes a tool whose manifest %s for not installed', async (_, manifest) => { + const project = create( + { 'node_modules/oxlint/package.json': manifest, 'node_modules/oxfmt/package.json': manifest }, + { tools: ['sherif', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, not selected by --only', + '✘ nothing to check: sherif not selected by --only, oxlint not installed, oxfmt not installed, tsc not selected by --only', + ]) + expect(exitCode).toBe(1) + }) + + it('finds the tools installed above the folder it checks', async () => { + const project = create() + + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS, { cwd: `${app}src` }) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.path(app, 'src')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + }) + + it('finds a tool through the resolver of Yarn PnP', async () => { + const project = create( + { + '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n.yarn\n.pnp.cjs\n', + '.pnp.cjs': YARN_PNP_RESOLVER, + '.yarn/unplugged/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } }, + '.yarn/unplugged/oxlint/bin.js': "console.log('oxlint from the Yarn cache');\n", + }, + { tools: ['oxfmt', 'typescript'] }, + ) + + const { exitCode, stdout } = await project.uncheck([], { + env: { NODE_OPTIONS: `--require ${project.path('.pnp.cjs')}` }, + }) + + expect(stdout).toContain('▶ oxlint\noxlint from the Yarn cache\n') + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '▶ oxlint', + '✔ oxlint passed', + '▶ oxfmt --check', + '✔ oxfmt passed', + tsc, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(exitCode).toBe(0) + }) +}) + +describe('uncheck finding tools in a monorepo', () => { + it('runs the tool installed nearest to the folder it checks', async () => { + const project = monorepo({ + 'packages/app/node_modules/oxlint/package.json': { + name: 'oxlint', + bin: { oxlint: 'bin.js' }, + }, + 'packages/app/node_modules/oxlint/bin.js': "console.log('oxlint of packages/app');\n", + }) + + const inPackage = await project.uncheck(['--only=oxlint'], { cwd: 'packages/app' }) + + expect(inPackage.stdout).toContain('▶ oxlint\noxlint of packages/app\n') + expect(inPackage.exitCode).toBe(0) + + const atRoot = await project.uncheck(['--only=oxlint']) + + expect(atRoot.stdout).not.toContain('oxlint of packages/app') + expect(atRoot.exitCode).toBe(0) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts new file mode 100644 index 0000000..d9d8781 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -0,0 +1,250 @@ +import { LAYOUTS, monorepo } from '../utils/project' +import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './utils' + +const NO_JS = { compilerOptions: { allowJs: false } } + +describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { + it('resolves a relative or absolute extends, with or without .json', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: ALLOW_JS, + [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { extends: '../tsconfig.base', include: ['src'] }, + [`${app}docs/tsconfig.json`]: { extends: '..\\tsconfig.base.json', include: ['src'] }, + [`${app}cli/tsconfig.json`]: { extends: './tsconfig.shared.json', include: ['src'] }, + [`${app}cli/tsconfig.shared.json`]: ALLOW_JS, + [`${app}legacy/tsconfig.json`]: { extends: '../tsconfig.legacy', include: ['src'] }, + }) + + project.write({ + [`${app}bin/tsconfig.json`]: { + extends: project.path(app, 'tsconfig.base.json'), + include: ['src'], + }, + }) + + const scripts = ['bin', 'cli', 'docs', 'legacy', 'server', 'web'].map( + (dir) => `${dir}/src/a.js`, + ) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p bin/tsconfig.json --noEmit', + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p docs/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends to its tsconfig.json, its tsconfig field or a file in it', async () => { + const project = withFakeTsc(create, { + 'node_modules/@tsconfig/node22/package.json': { name: '@tsconfig/node22', version: '1.0.0' }, + 'node_modules/@tsconfig/node22/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-preset/package.json': { + name: 'tsconfig-preset', + version: '1.0.0', + tsconfig: './preset.json', + }, + 'node_modules/tsconfig-preset/preset.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/package.json': { name: '@acme/tsconfig', version: '1.0.0' }, + 'node_modules/@acme/tsconfig/node.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/web/tsconfig.json': ALLOW_JS, + [`${app}node/tsconfig.json`]: { extends: '@tsconfig/node22', include: ['src'] }, + [`${app}preset/tsconfig.json`]: { extends: 'tsconfig-preset', include: ['src'] }, + [`${app}file/tsconfig.json`]: { extends: '@acme/tsconfig/node.json', include: ['src'] }, + [`${app}name/tsconfig.json`]: { extends: '@acme/tsconfig/node', include: ['src'] }, + [`${app}folder/tsconfig.json`]: { extends: '@acme/tsconfig/web', include: ['src'] }, + [`${app}missing/tsconfig.json`]: { extends: '@acme/missing', include: ['src'] }, + }) + + const scripts = ['file', 'folder', 'missing', 'name', 'node', 'preset'].map( + (dir) => `${dir}/src/a.js`, + ) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p file/tsconfig.json --noEmit', + '▶ tsc -p folder/tsconfig.json --noEmit', + '▶ tsc -p name/tsconfig.json --noEmit', + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p preset/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through the subpaths and conditions of its exports only', async () => { + const project = withFakeTsc(create, { + 'node_modules/@acme/tsconfig/package.json': { + name: '@acme/tsconfig', + version: '1.0.0', + exports: { + '.': './tsconfig.json', + './lib': { import: './esm.json', require: './lib.json' }, + './node': { node: { types: './node.json' } }, + './web': [{ import: './esm.json' }, './web.json'], + './legacy': null, + }, + }, + 'node_modules/@acme/tsconfig/tsconfig.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/esm.json': NO_JS, + 'node_modules/@acme/tsconfig/lib.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/node.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/web.json': ALLOW_JS, + 'node_modules/@acme/tsconfig/legacy.json': ALLOW_JS, + [`${app}main/tsconfig.json`]: { extends: '@acme/tsconfig', include: ['src'] }, + [`${app}lib/tsconfig.json`]: { extends: '@acme/tsconfig/lib', include: ['src'] }, + [`${app}node/tsconfig.json`]: { extends: '@acme/tsconfig/node', include: ['src'] }, + [`${app}web/tsconfig.json`]: { extends: '@acme/tsconfig/web', include: ['src'] }, + [`${app}legacy/tsconfig.json`]: { extends: '@acme/tsconfig/legacy', include: ['src'] }, + [`${app}file/tsconfig.json`]: { extends: '@acme/tsconfig/lib.json', include: ['src'] }, + }) + + const scripts = ['file', 'legacy', 'lib', 'main', 'node', 'web'].map((dir) => `${dir}/src/a.js`) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p lib/tsconfig.json --noEmit', + '▶ tsc -p main/tsconfig.json --noEmit', + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through the * pattern of its exports with the longest prefix', async () => { + const project = withFakeTsc(create, { + 'node_modules/@acme/presets/package.json': { + name: '@acme/presets', + version: '1.0.0', + exports: { + './*': './loose/*.json', + './strict/*': './strict/*.json', + './s*': './loose/s*.json', + }, + }, + 'node_modules/@acme/presets/loose/web.json': ALLOW_JS, + 'node_modules/@acme/presets/loose/strict/node.json': NO_JS, + 'node_modules/@acme/presets/strict/node.json': ALLOW_JS, + [`${app}web/tsconfig.json`]: { extends: '@acme/presets/web', include: ['src'] }, + [`${app}node/tsconfig.json`]: { extends: '@acme/presets/strict/node', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['node/src/a.js', 'web/src/a.js'])).toEqual([ + '▶ tsc -p node/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('never matches a * pattern of its exports longer than the subpath', async () => { + const project = withFakeTsc(create, { + 'node_modules/tsconfig-pattern/package.json': { + name: 'tsconfig-pattern', + version: '1.0.0', + exports: { './a*a': './tsconfig.json' }, + }, + 'node_modules/tsconfig-pattern/tsconfig.json': ALLOW_JS, + [`${app}short/tsconfig.json`]: { extends: 'tsconfig-pattern/a', include: ['src'] }, + [`${app}long/tsconfig.json`]: { extends: 'tsconfig-pattern/aba', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['long/src/a.js', 'short/src/a.js'])).toEqual([ + '▶ tsc -p long/tsconfig.json --noEmit', + ]) + }) + + it('resolves a package extends through exports given as a path, an array or conditions', async () => { + const project = withFakeTsc(create, { + 'node_modules/tsconfig-path/package.json': { + name: 'tsconfig-path', + version: '1.0.0', + exports: './tsconfig.json', + }, + 'node_modules/tsconfig-path/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-array/package.json': { + name: 'tsconfig-array', + version: '1.0.0', + exports: ['./tsconfig.json'], + }, + 'node_modules/tsconfig-array/tsconfig.json': ALLOW_JS, + 'node_modules/tsconfig-conditions/package.json': { + name: 'tsconfig-conditions', + version: '1.0.0', + exports: { import: './esm.json', default: './tsconfig.json' }, + }, + 'node_modules/tsconfig-conditions/esm.json': NO_JS, + 'node_modules/tsconfig-conditions/tsconfig.json': ALLOW_JS, + [`${app}path/tsconfig.json`]: { extends: 'tsconfig-path', include: ['src'] }, + [`${app}array/tsconfig.json`]: { extends: 'tsconfig-array', include: ['src'] }, + [`${app}conditions/tsconfig.json`]: { extends: 'tsconfig-conditions', include: ['src'] }, + }) + + const scripts = ['array', 'conditions', 'path'].map((dir) => `${dir}/src/a.js`) + + expect(await tscPlan(project, app, scripts)).toEqual([ + '▶ tsc -p array/tsconfig.json --noEmit', + '▶ tsc -p conditions/tsconfig.json --noEmit', + '▶ tsc -p path/tsconfig.json --noEmit', + ]) + }) + + it('applies the configs of an extends array in order and ignores an invalid extends', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.js.json`]: ALLOW_JS, + [`${app}tsconfig.ts.json`]: NO_JS, + [`${app}web/tsconfig.json`]: { extends: ['../tsconfig.ts.json', '../tsconfig.js.json'] }, + [`${app}server/tsconfig.json`]: { extends: ['../tsconfig.js.json', '../tsconfig.ts.json'] }, + [`${app}cli/tsconfig.json`]: { extends: ['../tsconfig.js.json', 42] }, + }) + + expect(await tscPlan(project, app, ['cli/a.js', 'server/a.js', 'web/a.js'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('stops at circular extends', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { extends: './tsconfig.src.json' }, + [`${app}web/tsconfig.src.json`]: { extends: './tsconfig.json', include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + }) + + it('applies a base that two extends branches share in both, like tsc', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: { include: [`${CONFIG_DIR}/lib`] }, + [`${app}tsconfig.src.json`]: { + extends: './tsconfig.base.json', + include: [`${CONFIG_DIR}/src`], + }, + [`${app}tsconfig.strict.json`]: { + extends: './tsconfig.base.json', + compilerOptions: { strict: true }, + }, + [`${app}web/tsconfig.json`]: { extends: ['../tsconfig.src.json', '../tsconfig.strict.json'] }, + }) + + expect(await tscPlan(project, app, ['web/lib/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) + }) +}) + +describe('tsc extends across the packages of a monorepo', () => { + it('resolves a package from the node_modules closest to each config', async () => { + const project = withFakeTsc(monorepo, { + 'node_modules/@acme/tsconfig/package.json': { name: '@acme/tsconfig', version: '1.0.0' }, + 'node_modules/@acme/tsconfig/tsconfig.json': ALLOW_JS, + 'packages/app/node_modules/@acme/tsconfig/package.json': { + name: '@acme/tsconfig', + 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'] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/a.js'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/app/src/a.js'])).toEqual([NOT_COVERED]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts new file mode 100644 index 0000000..5bc1974 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -0,0 +1,215 @@ +import { LAYOUTS, monorepo, report, wrappedGit } from '../utils/project' +import { + ALLOW_JS, + CONFIG_DIR, + NOT_COVERED, + SKIPPED_BESIDE_TSC, + tscPlan, + withFakeTsc, +} from './utils' + +describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { + it('takes files, include and exclude from the last config setting them, relative to it', async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.base.json`]: { include: ['../lib'], exclude: ['../lib/vendor'] }, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + [`${app}server/tsconfig.json`]: { + extends: '../config/tsconfig.base.json', + include: ['src'], + }, + [`${app}cli/tsconfig.json`]: { + extends: ['../config/tsconfig.base.json', './tsconfig.entry.json'], + }, + [`${app}cli/tsconfig.entry.json`]: { files: ['main.ts'] }, + [`${app}docs/tsconfig.json`]: { files: ['index.ts'] }, + }) + + expect(await tscPlan(project, app, ['lib/index.ts'])).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['cli/main.ts', 'docs/index.ts', 'server/src/index.ts']), + ).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p docs/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['docs/guide.ts', 'lib/vendor/index.ts', 'web/lib/index.ts']), + ).toEqual([NOT_COVERED]) + }) + + it(`reads ${CONFIG_DIR} as the folder of the config that extends the shared one`, async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.base.json`]: { + files: [`${CONFIG_DIR}/env.d.ts`], + include: [`${CONFIG_DIR}/src`], + exclude: [`${CONFIG_DIR}/src/generated`], + }, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + [`${app}server/tsconfig.json`]: { extends: '../config/tsconfig.base.json' }, + }) + + expect(await tscPlan(project, app, ['server/env.d.ts', 'web/src/index.ts'])).toEqual([ + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['config/src/index.ts', 'web/src/generated/api.ts']), + ).toEqual([NOT_COVERED]) + }) + + it('leaves out outDir and declarationDir unless exclude is set', async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.build.json`]: { + compilerOptions: { outDir: `${CONFIG_DIR}/build` }, + }, + [`${app}web/tsconfig.json`]: { compilerOptions: { outDir: 'dist', declarationDir: 'types' } }, + [`${app}server/tsconfig.json`]: { + compilerOptions: { outDir: 'dist' }, + exclude: ['**/*.test.ts'], + }, + [`${app}cli/tsconfig.json`]: { extends: '../config/tsconfig.build.json' }, + }) + + expect( + await tscPlan(project, app, ['cli/src/index.ts', 'server/dist/index.ts', 'web/src/index.ts']), + ).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'cli/build/index.d.ts', + 'server/src/index.test.ts', + 'web/dist/index.d.ts', + 'web/types/index.d.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('selects JavaScript files only when allowJs or checkJs is on', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.js.json`]: ALLOW_JS, + [`${app}js/tsconfig.json`]: { extends: '../tsconfig.js.json' }, + [`${app}ts/tsconfig.json`]: { + extends: '../tsconfig.js.json', + compilerOptions: { allowJs: false }, + }, + [`${app}check/tsconfig.json`]: { compilerOptions: { checkJs: true } }, + [`${app}both/tsconfig.json`]: { compilerOptions: { allowJs: false, checkJs: true } }, + [`${app}plain/tsconfig.json`]: { compilerOptions: { strict: true } }, + }) + + expect( + await tscPlan(project, app, [ + 'both/index.js', + 'check/index.mjs', + 'js/index.js', + 'plain/index.cjs', + 'ts/index.jsx', + ]), + ).toEqual([ + '▶ tsc -p both/tsconfig.json --noEmit', + '▶ tsc -p check/tsconfig.json --noEmit', + '▶ tsc -p js/tsconfig.json --noEmit', + ]) + }) + + it('selects a JavaScript file named in the files list even without allowJs', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { files: ['legacy.js'], include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['web/legacy.js'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/legacy.js'])).toEqual([NOT_COVERED]) + }) + + it('selects a JSON file only through the files list or an include ending in .json', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src', 'config/*.json'] }, + [`${app}server/tsconfig.json`]: { files: ['data.json'], include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['server/data.json', 'web/config/app.json'])).toEqual([ + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['server/src/data.json', 'web/src/data.json'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('selects a changed config and every config that extends it', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.base.json`]: { compilerOptions: { strict: true } }, + [`${app}web/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}cli/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app, ['cli/tsconfig.json', 'tsconfig.base.json'])).toEqual([ + '▶ tsc -p cli/tsconfig.json --noEmit', + '▶ tsc -p server/tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['package.json'])).toEqual([NOT_COVERED]) + }) + + it('reads a tsconfig.json with comments and trailing commas', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: '{\n // Only the library.\n "include": ["lib",],\n}\n', + }) + + expect(await tscPlan(project, app, ['web/lib/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) + }) + + it('still plans the other files when a given JSON file is deleted while uncheck runs', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}web/src/index.ts`]: '', + [`${app}web/data.json`]: '{}\n', + }) + + const { exitCode, stdout } = await project.uncheck( + ['--only=tsc', 'web/src/index.ts', 'web/data.json'], + { cwd: app, env: wrappedGit('[ "$1" = ls-files ] && rm -f web/data.json') }, + ) + + 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)', + ]) + }) +}) + +describe('tsc inputs across the packages of a monorepo', () => { + it('selects the configs that extend a changed workspace config package by its real path', async () => { + const project = withFakeTsc(monorepo, { + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + }, + 'packages/tsconfig/base.json': { compilerOptions: { strict: true } }, + 'packages/app/tsconfig.json': { extends: '@repo/tsconfig/base.json', include: ['src'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + expect(await tscPlan(project, '', ['packages/tsconfig/base.json'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/tsconfig/package.json'])).toEqual([NOT_COVERED]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-patterns.test.ts b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts new file mode 100644 index 0000000..2ad2c86 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-patterns.test.ts @@ -0,0 +1,169 @@ +import { LAYOUTS } from '../utils/project' +import { ALLOW_JS, NOT_COVERED, tscPlan, withFakeTsc } from './utils' + +describe.each(LAYOUTS)('tsc include and exclude patterns in a $name', ({ create, app }) => { + it('reads a pattern without extension or wildcard as a folder', async () => { + const project = withFakeTsc(create, { + [`${app}folder/tsconfig.json`]: { include: ['lib'] }, + [`${app}slash/tsconfig.json`]: { include: ['.\\lib/'] }, + [`${app}dotted/tsconfig.json`]: { include: ['lib/v1.0'] }, + }) + + expect(await tscPlan(project, app, ['folder/lib/deep/index.ts', 'slash/lib/index.ts'])).toEqual( + ['▶ tsc -p folder/tsconfig.json --noEmit', '▶ tsc -p slash/tsconfig.json --noEmit'], + ) + expect(await tscPlan(project, app, ['dotted/lib/v1.0/index.ts', 'folder/lib.ts'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('ignores an include that ends in **', async () => { + const project = withFakeTsc(create, { + [`${app}bare/tsconfig.json`]: { include: ['src/**'] }, + [`${app}files/tsconfig.json`]: { include: ['src/**/*.ts'] }, + }) + + expect(await tscPlan(project, app, ['bare/src/index.ts'])).toEqual([NOT_COVERED]) + expect(await tscPlan(project, app, ['files/src/deep/index.ts'])).toEqual([ + '▶ tsc -p files/tsconfig.json --noEmit', + ]) + }) + + it('never lets * or ? cross a folder', async () => { + const project = withFakeTsc(create, { + [`${app}star/tsconfig.json`]: { include: ['scripts/*.ts'] }, + [`${app}question/tsconfig.json`]: { include: ['v?/*.ts'] }, + }) + + expect(await tscPlan(project, app, ['question/v1/index.ts', 'star/scripts/build.ts'])).toEqual([ + '▶ tsc -p question/tsconfig.json --noEmit', + '▶ tsc -p star/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, ['question/v10/index.ts', 'star/scripts/deep/build.ts']), + ).toEqual([NOT_COVERED]) + }) + + it('never matches a dot file with a leading wildcard', async () => { + const project = withFakeTsc(create, { + [`${app}star/tsconfig.json`]: { include: ['*.ts'] }, + [`${app}question/tsconfig.json`]: { include: ['?rc.ts'] }, + [`${app}named/tsconfig.json`]: { include: ['.config/*.ts'] }, + }) + + expect( + await tscPlan(project, app, ['named/.config/index.ts', 'question/arc.ts', 'star/index.ts']), + ).toEqual([ + '▶ tsc -p named/tsconfig.json --noEmit', + '▶ tsc -p question/tsconfig.json --noEmit', + '▶ tsc -p star/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['question/.rc.ts', 'star/.eslintrc.ts'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('skips node_modules and dot folders unless the pattern names them', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}vendor/tsconfig.json`]: { include: ['node_modules/lib'] }, + [`${app}generated/tsconfig.json`]: { include: ['.generated/**/*'] }, + }) + + expect( + await tscPlan(project, app, [ + 'generated/.generated/index.ts', + 'vendor/node_modules/lib/index.ts', + ]), + ).toEqual([ + '▶ tsc -p generated/tsconfig.json --noEmit', + '▶ tsc -p vendor/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'web/src/.cache/index.ts', + 'web/src/bower_components/index.ts', + 'web/src/node_modules/lib/index.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('never matches a name ending in .min.js with *', async () => { + const project = withFakeTsc(create, { + [`${app}vendor/tsconfig.json`]: { ...ALLOW_JS, include: ['*'] }, + [`${app}plugins/tsconfig.json`]: { ...ALLOW_JS, include: ['jquery*'] }, + }) + + expect(await tscPlan(project, app, ['plugins/jquery.ui.js', 'vendor/jquery.min.jsx'])).toEqual([ + '▶ tsc -p plugins/tsconfig.json --noEmit', + '▶ tsc -p vendor/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['plugins/jquery.min.js', 'vendor/jquery.min.js'])).toEqual([ + NOT_COVERED, + ]) + }) + + it('keeps out only node_modules and .min.js spelled in their own case, as tsc does where case matters', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { include: ['src'], ...ALLOW_JS }, + }) + + expect( + await tscPlan(project, app, ['web/src/Node_Modules/index.ts', 'web/src/vendor.Min.js']), + ).toEqual(['▶ tsc -p web/tsconfig.json --noEmit']) + expect( + await tscPlan(project, app, ['web/src/node_modules/index.ts', 'web/src/vendor.min.js']), + ).toEqual([NOT_COVERED]) + }) + + it('ignores case in include but not in exclude', async () => { + const project = withFakeTsc(create, { + [`${app}include/tsconfig.json`]: { include: ['SRC'] }, + [`${app}exclude/tsconfig.json`]: { include: ['src'], exclude: ['src/Legacy'] }, + }) + + expect( + await tscPlan(project, app, ['exclude/src/legacy/index.ts', 'include/src/index.ts']), + ).toEqual([ + '▶ tsc -p exclude/tsconfig.json --noEmit', + '▶ tsc -p include/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['exclude/src/Legacy/index.ts'])).toEqual([NOT_COVERED]) + }) + + it('excludes everything below an excluded folder or pattern', async () => { + const project = withFakeTsc(create, { + [`${app}web/tsconfig.json`]: { + include: ['src'], + exclude: ['src/legacy', 'src/**/*.test.ts', '**/__mocks__/**', 'src/v?'], + }, + }) + + expect(await tscPlan(project, app, ['web/src/deep/index.ts'])).toEqual([ + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect( + await tscPlan(project, app, [ + 'web/src/__mocks__/api/index.ts', + 'web/src/deep/index.test.ts', + 'web/src/index.test.ts', + 'web/src/legacy/deep/index.ts', + 'web/src/v1/index.ts', + ]), + ).toEqual([NOT_COVERED]) + }) + + it('matches the other characters of a pattern as they are', async () => { + const project = withFakeTsc(create, { + [`${app}group/tsconfig.json`]: { include: ['app/(marketing)'] }, + [`${app}route/tsconfig.json`]: { include: ['app/[id]+.ts'] }, + }) + + expect( + await tscPlan(project, app, ['group/app/(marketing)/page.tsx', 'route/app/[id]+.ts']), + ).toEqual(['▶ tsc -p group/tsconfig.json --noEmit', '▶ tsc -p route/tsconfig.json --noEmit']) + expect(await tscPlan(project, app, ['group/app/marketing/page.tsx', 'route/app/i.ts'])).toEqual( + [NOT_COVERED], + ) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts new file mode 100644 index 0000000..1c83fba --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -0,0 +1,198 @@ +import { LAYOUTS, monorepo, report } from '../utils/project' +import { CONFIG_DIR, NOT_COVERED, 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 () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], + }, + [`${app}tsconfig.app.json`]: { include: ['src'] }, + [`${app}tsconfig.node.json`]: { include: ['vite.config.ts'] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['vite.config.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([NOT_COVERED]) + }) + + it('follows references to a folder, to a .json file and with backslashes', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: 'web' }, { path: '.\\server\\tsconfig.build.json' }], + }, + [`${app}web/tsconfig.json`]: { + include: ['src'], + references: [{ path: '../ui/tsconfig.json' }], + }, + [`${app}ui/tsconfig.json`]: { include: ['src'] }, + [`${app}server/tsconfig.build.json`]: { + include: ['src'], + references: [{ path: '..\\shared' }], + }, + [`${app}shared/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['server/src/main.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + expect( + await tscPlan(project, app, ['shared/src/index.ts', 'ui/src/index.ts', 'web/src/index.ts']), + ).toEqual(['▶ tsc -b tsconfig.json']) + }) + + 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}lib/tsconfig.json`]: { include: ['src'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b web/tsconfig.json', + '▶ tsc -p lib/tsconfig.json --noEmit', + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('ignores references that are not objects with a string path', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: ['./scripts', { path: 42 }, null, { prepend: true }, { path: './lib' }], + }, + [`${app}lib/tsconfig.json`]: { include: ['src'], references: './scripts' }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p scripts/tsconfig.json --noEmit', + ]) + }) + + 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}e2e/tsconfig.json`]: { + include: ['src'], + references: [{ path: '../web' }, { path: '../server' }], + }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b cli/tsconfig.json e2e/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ + '▶ tsc -b cli/tsconfig.json e2e/tsconfig.json', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([ + '▶ tsc -b e2e/tsconfig.json', + ]) + expect(await tscPlan(project, app, ['cli/src/index.ts', 'scripts/release.ts'])).toEqual([ + '▶ tsc -b cli/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('checks each standalone tsconfig.json with -p --noEmit', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'] }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + [`${app}test/tsconfig.json`]: { include: ['.'] }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -p scripts/tsconfig.json --noEmit', + '▶ tsc -p test/tsconfig.json --noEmit', + '▶ tsc -p tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['test/index.test.ts', 'scripts/release.ts'])).toEqual([ + '▶ tsc -p scripts/tsconfig.json --noEmit', + '▶ tsc -p test/tsconfig.json --noEmit', + ]) + }) + + it('fails on circular references, naming only the configs on the cycle', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['scripts'] }, + [`${app}api/tsconfig.json`]: { references: [{ path: '../lib' }] }, + [`${app}lib/tsconfig.json`]: { references: [{ path: '../utils' }] }, + [`${app}utils/tsconfig.json`]: { references: [{ path: '../lib' }] }, + }) + const cycle = '✘ tsc circular project references between lib/tsconfig.json, utils/tsconfig.json' + + expect(await tscPlan(project, app)).toEqual([cycle]) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([cycle]) + }) + + it('leaves a reference to a missing config to tsc -b', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './legacy' }] }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, app, ['legacy/index.ts'])).toEqual([NOT_COVERED]) + }) + + 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}lib.json/index.ts`]: '', + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + }) +}) + +describe('tsc project references across the packages of a monorepo', () => { + it('builds every package that references a changed package, and no other', async () => { + 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/cli/package.json': { name: '@repo/cli', version: '1.0.0', private: true }, + 'packages/cli/tsconfig.json': { include: ['src'] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -b packages/app/tsconfig.json packages/web/tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/app/src/index.ts'])).toEqual([ + '▶ tsc -b packages/app/tsconfig.json', + ]) + expect(await tscPlan(project, '', ['packages/cli/src/index.ts', 'scripts/release.ts'])).toEqual( + ['▶ tsc -p packages/cli/tsconfig.json --noEmit', '▶ tsc -p tsconfig.json --noEmit'], + ) + }) + + it('builds a package from its folder for a change in a package it references', async () => { + const project = withFakeTsc(monorepo) + + expect(await tscPlan(project, 'packages/app', ['../core/src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + }) + + it('checks a package on its own from its folder', async () => { + const project = withFakeTsc(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)', + ]) + }) +}) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts new file mode 100644 index 0000000..9679ca3 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -0,0 +1,170 @@ +import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, 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 }) => { + it('skips the check when the project has no tsconfig.json', async () => { + const project = create({ [`${app}tsconfig.json`]: null }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(1) + 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', + ]) + }) + + it('skips a tracked tsconfig.json deleted from the working tree', async () => { + 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']) + }) + + it('skips the check when no tsconfig.json covers the given files', async () => { + const project = withFakeTsc(create, { [`${app}README.md`]: '# App\n' }) + + expect(await tscPlan(project, app, ['README.md', 'src/LEGACY.TS'])).toEqual([NOT_COVERED]) + expect(await tscPlan(project, app, ['README.md', 'scripts/release.ts'])).toEqual([NOT_COVERED]) + }) + + it('fails when typescript is not installed, counting the configs it would check', async () => { + const project = create({ [`${app}scripts/tsconfig.json`]: { include: ['.'] } }, { tools: [] }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + ...SKIPPED_BESIDE_TSC, + '✘ tsc found 2 tsconfig.json but typescript is not installed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([ + '✘ tsc found 1 tsconfig.json but typescript is not installed', + ]) + }) + + it('hands a malformed tsconfig.json to tsc, which reports it', async () => { + const project = create({ [`${app}tsconfig.json`]: 'oops\n', [`${app}scripts/release.ts`]: '' }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'scripts/release.ts'], { + cwd: app, + }) + + 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(stdout).toContain("tsconfig.json(1,1): error TS1005: '{' expected.") + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'hands an unreadable tsconfig.json to tsc, which reports it', + async () => { + const project = create().chmod(`${app}tsconfig.json`, 0) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', 'src/index.ts'], { + cwd: app, + }) + + 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(stdout).toContain( + `error TS5083: Cannot read file '${project.path(app, 'tsconfig.json')}'.`, + ) + }, + ) + + it('finds the tsconfig.json files outside git by walking the folders, never into node_modules', async () => { + const project = withFakeTsc( + create, + { + [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }] }, + [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}scripts/tsconfig.json`]: { include: ['.'] }, + [`${app}node_modules/@tsconfig/node22/tsconfig.json`]: ALLOW_JS, + [`${app}web/node_modules/lib/tsconfig.json`]: {}, + }, + { git: 'none' }, + ) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p scripts/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual(['▶ tsc -b tsconfig.json']) + }) +}) + +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: [] }, + include: ['src'], + }, + }) + + 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(project.exists('src/index.js')).toBe(false) + }) +}) + +describe('tsc with the real compiler in a monorepo', () => { + it('builds the solution tsconfig.json with -b and reports errors in the package having them', async () => { + const project = 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(project.exists('packages/core/dist/index.d.ts')).toBe(true) + + project.write({ + 'packages/core/src/index.ts': + 'export function double(value: number): number {\n return String(value);\n}\n', + }) + + 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(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 new file mode 100644 index 0000000..5580887 --- /dev/null +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path' + +import type { Files, Layout, Project, ProjectOptions } from '../utils/project' +import { monorepo, report } from '../utils/project' + +export const CLEAN_CODE = 'export const home = "home";\n' + +/** Fails the `no-var` rule the projects enable in oxlint. */ +export const CODE_WITH_VAR = 'var count = 1;\nexport { count };\n' + +export const UNFORMATTED_CODE = 'export const ugly = {a:1,\n b:2}\n' + +export const CODE_WITH_TYPE_ERROR = 'export const broken: number = "42";\n' + +export interface LayoutChecks { + readonly sherif: ReadonlyArray + readonly checks: ReadonlyArray +} + +export function layoutChecks(app: string, { fix = false } = {}): LayoutChecks { + return app === '' + ? { sherif: ['○ sherif skipped, not a workspace root'], checks: ['oxlint', 'oxfmt', 'tsc'] } + : { + sherif: [fix ? '▶ sherif --fix --select=highest' : '▶ sherif', '✔ sherif passed'], + checks: ['sherif', 'oxlint', 'oxfmt', 'tsc'], + } +} + +export function monorepoWithMismatchedVersions(sherif: object = {}): Project { + // Without noInstall, sherif --fix runs the package manager to install the versions it aligned. + return monorepo() + .update('package.json', (manifest) => ({ ...manifest, sherif: { noInstall: true, ...sherif } })) + .update('packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) +} + +const LISTING_OXLINT = [ + 'for (const arg of process.argv.slice(2)) {', + " if (!arg.startsWith('--')) console.log('checked ' + arg)", + '}', + '', +].join('\n') + +/** A project whose oxlint passes and prints each file it gets, to see every file of a long list. */ +export function listingProject( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, +): Project { + return create(files, { ...options, tools: [] }).fake('oxlint', LISTING_OXLINT) +} + +/** The files the oxlint of `listingProject` printed, in the order it got them. */ +export function checkedFiles(stdout: string): string[] { + return stdout + .split('\n') + .filter((line) => line.startsWith('checked ')) + .map((line) => line.slice('checked '.length)) +} + +/** The report without the checks that `--only` leaves out. */ +export function selectedReport(stdout: string): string[] { + return report(stdout).filter((line) => !line.endsWith(' skipped, not selected by --only')) +} + +export const NOT_COVERED = '○ tsc skipped, no tsconfig.json covers the given files' + +export const SKIPPED_BESIDE_TSC = [ + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', +] + +// oxlint-disable-next-line no-template-curly-in-string +export const CONFIG_DIR = '${configDir}' + +export const ALLOW_JS = { compilerOptions: { allowJs: true } } + +export function withFakeTsc( + create: Layout['create'], + files?: Files, + options?: ProjectOptions, +): Project { + return create(files, { ...options, tools: [] }).fake('typescript', '') +} + +/** The tsc lines of a `--only=tsc` run on `paths`, which it first creates when missing. */ +export async function tscPlan( + project: Project, + cwd: string, + paths: ReadonlyArray = [], +): Promise { + const missing = paths.map((file) => join(cwd, file)).filter((file) => !project.exists(file)) + + project.write(Object.fromEntries(missing.map((file) => [file, '']))) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc', ...paths], { cwd }) + const plan = report(stdout).filter((line) => /^[▶○✘] tsc /.test(line)) + + expect(exitCode).toBe(plan.length > 0 && plan.every((line) => line.startsWith('▶')) ? 0 : 1) + + return plan +} diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts new file mode 100644 index 0000000..28441b3 --- /dev/null +++ b/packages/uncheck/tests/utils/project.ts @@ -0,0 +1,570 @@ +import { execFileSync, spawn } from 'node:child_process' +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, delimiter, dirname, join, sep } from 'node:path' +import process from 'node:process' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { stripVTControlCharacters } from 'node:util' + +export const TOOLS = ['sherif', 'oxlint', 'oxfmt', 'typescript'] as const + +export type Tool = (typeof TOOLS)[number] + +export type Files = Readonly> + +export type Env = Readonly> + +export interface ProjectOptions { + readonly tools?: ReadonlyArray + readonly git?: 'none' | 'init' | 'commit' +} + +export interface RunOptions { + /** Relative to the project. */ + readonly cwd?: string + readonly input?: string + readonly env?: Env +} + +export interface TerminalOptions { + readonly cwd?: string + readonly env?: Env + /** Typed one at a time once the output shows `waitFor`. */ + readonly keys?: ReadonlyArray + readonly waitFor?: string +} + +export interface Run { + readonly exitCode: number | null + readonly signal: NodeJS.Signals | null + readonly stdout: string + readonly stderr: string +} + +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. */ +const PROJECT_MARKERS = [ + 'package.json', + 'node_modules', + 'tsconfig.json', + '.pnp.cjs', + 'pnpm-lock.yaml', + 'yarn.lock', + 'bun.lock', + 'bun.lockb', + 'package-lock.json', +] + +for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { + const marker = PROJECT_MARKERS.find((name) => existsSync(join(dir, name))) + + if (marker !== undefined) { + throw new Error( + `${join(dir, marker)} would leak into every test project, point TMPDIR elsewhere`, + ) + } + + if (dir === dirname(dir)) { + break + } +} + +const ROOT = realpathSync(mkdtempSync(join(tmpdir(), 'uncheck-e2e-'))) +const GIT_CONFIG = join(ROOT, 'gitconfig') +const SHIMS = join(ROOT, 'bin') +const REAL_GIT = execFileSync('sh', ['-c', 'command -v git'], { encoding: 'utf8' }).trim() + +writeFileSync(GIT_CONFIG, '') +mkdirSync(SHIMS) +writeFileSync( + join(SHIMS, 'pnpm'), + [ + '#!/bin/sh', + '[ "$1" = exec ] && shift', + 'dir=$PWD', + 'until [ -x "$dir/node_modules/.bin/$1" ] || [ "$dir" = / ]; do dir=$(dirname "$dir"); done', + 'bin=$1', + 'shift', + 'exec "$dir/node_modules/.bin/$bin" "$@"', + '', + ].join('\n'), + { mode: 0o755 }, +) + +// A folder a test left unreadable or read-only would stop the removal of the temporary files. +function restorePermissions(dir: string): void { + chmodSync(dir, 0o755) + + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.isDirectory()) { + restorePermissions(join(dir, entry.name)) + } + } +} + +afterAll(() => { + restorePermissions(ROOT) + rmSync(ROOT, { recursive: true, force: true }) +}) + +// Root reads and writes files whatever their mode, so chmod cannot make anything fail for it. +export const PERMISSIONS_ENFORCED = process.getuid?.() !== 0 + +// A stray path would run git or the CLI in the repository these tests live in. +function inside(path: string): string { + if (!path.startsWith(`${ROOT}${sep}`)) { + throw new Error(`${path} is outside ${ROOT}`) + } + + return path +} + +export function temporaryDirectory(): string { + return mkdtempSync(join(ROOT, 'tmp-')) +} + +// Tools change their output on CI, under GitHub Actions and in the AI agents they detect, so nothing +// else of the machine's environment reaches the processes the tests start. +const INHERITED = new Set(['HOME', 'TMPDIR', 'NODE_V8_COVERAGE']) + +/** The environment of every process the tests start, the same on every machine. */ +export function environment(overrides: Env = {}): NodeJS.ProcessEnv { + return { + ...Object.fromEntries(Object.entries(process.env).filter(([key]) => INHERITED.has(key))), + PATH: `${SHIMS}${delimiter}${process.env.PATH}`, + // git translates the messages tests assert to the language of the machine. + LC_ALL: 'C', + // Node paints nothing in a terminal of unknown type. + TERM: 'xterm-256color', + GIT_CEILING_DIRECTORIES: ROOT, + GIT_CONFIG_GLOBAL: GIT_CONFIG, + GIT_CONFIG_NOSYSTEM: '1', + GIT_AUTHOR_NAME: 'uncheck', + GIT_AUTHOR_EMAIL: 'uncheck@example.com', + GIT_COMMITTER_NAME: 'uncheck', + GIT_COMMITTER_EMAIL: 'uncheck@example.com', + // Functions deserialized from the compile cache report imprecise coverage. + ...(process.env.NODE_V8_COVERAGE === undefined ? {} : { NODE_DISABLE_COMPILE_CACHE: '1' }), + ...overrides, + } +} + +/** An environment whose `git` first runs the shell `script`, which finds the real git in `$GIT`. */ +export function wrappedGit(script: string): Env { + const shims = temporaryDirectory() + + writeFileSync(join(shims, 'git'), `#!/bin/sh\nGIT='${REAL_GIT}'\n${script}\nexec "$GIT" "$@"\n`, { + mode: 0o755, + }) + + return { PATH: `${shims}${delimiter}${environment().PATH}` } +} + +/** A git config file holding `content`, for GIT_CONFIG_GLOBAL or GIT_CONFIG_SYSTEM. */ +export function gitConfig(content: string): string { + const file = join(temporaryDirectory(), 'gitconfig') + + writeFileSync(file, content) + + return file +} + +export function git(cwd: string, args: ReadonlyArray, env?: Env): string { + return execFileSync('git', args, { + cwd: inside(cwd), + env: environment(env), + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +export function run( + command: ReadonlyArray, + { cwd, input = '', env }: Omit & { readonly cwd: string }, +): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command[0]!, command.slice(1), { + cwd: inside(cwd), + env: environment(env), + }) + let stdout = '' + let stderr = '' + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + }) + child.stderr.setEncoding('utf8').on('data', (chunk: string) => { + stderr += chunk + }) + child.on('error', reject) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + child.stdin.end(input) + }) +} + +function shellQuote(arg: string): string { + return `'${arg.replaceAll("'", `'\\''`)}'` +} + +/** Runs `command` in a pseudo-terminal, which merges stdout and stderr into `stdout`. */ +export function runInTerminal( + command: ReadonlyArray, + { cwd, env, keys = [], waitFor }: Omit & { readonly cwd: string }, +): Promise { + const args = + process.platform === 'darwin' + ? ['-q', '/dev/null', ...command] + : ['-qfec', command.map(shellQuote).join(' '), '/dev/null'] + + return new Promise((resolve, reject) => { + const child = spawn('script', args, { cwd: inside(cwd), env: environment(env) }) + let stdout = '' + let typing = false + + child.stdout.setEncoding('utf8').on('data', (chunk: string) => { + stdout += chunk + + if (!typing && waitFor !== undefined && stripVTControlCharacters(stdout).includes(waitFor)) { + typing = true + keys.forEach((key, index) => setTimeout(() => child.stdin.write(key), 100 * (index + 1))) + } + }) + child.on('error', reject) + child.on('close', (exitCode, signal) => + resolve({ + exitCode, + signal, + stdout: stripVTControlCharacters(stdout).replaceAll('\r\n', '\n'), + stderr: '', + }), + ) + }) +} + +/** The lines uncheck itself prints, without colors and the timings that differ between runs. */ +export function report(output: string): string[] { + return stripVTControlCharacters(output) + .split('\n') + .filter((line) => /^(?:uncheck (?:staged )?in |[▶✔✘○] | {2}rerun )/.test(line)) + .map((line) => line.replace(/ (?:\d+ms|\d+\.\ds)$/, '')) +} + +/** How the CLI prints the error that stops a run. */ +export function cliError(message: string): string { + return `\nERROR\n ${message}\n` +} + +/** JSON the way oxfmt writes it, so the files a test adds pass the format check. */ +function json(value: object, file: string): string { + const text = JSON.stringify(value, null, 2) + + if (basename(file) === 'package.json') { + return `${text}\n` + } + + const primitive = String.raw`(?:"(?:[^"\\\n]|\\.)*"|-?[\d.e+-]+|true|false|null)` + const list = new RegExp(String.raw`\[\n\s*(${primitive}(?:,\n\s*${primitive})*)\n\s*\]`, 'g') + + return `${text.replace(list, (_, items: string) => `[${items.split(/,\n\s*/).join(', ')}]`)}\n` +} + +export class Project { + constructor(readonly dir: string) {} + + path(...segments: ReadonlyArray): string { + return join(this.dir, ...segments) + } + + write(files: Files): this { + for (const [file, content] of Object.entries(files)) { + const target = this.path(file) + + if (content === null) { + rmSync(target, { recursive: true, force: true }) + continue + } + + 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)) + } + + return this + } + + /** Rewrites the JSON `file` with what `change` makes of it. */ + update(file: string, change: (value: Record) => object): this { + return this.write({ [file]: change(JSON.parse(this.read(file)) as Record) }) + } + + read(file: string): string { + return readFileSync(this.path(file), 'utf8') + } + + mode(file: string): number { + return statSync(this.path(file)).mode & 0o7777 + } + + chmod(file: string, mode: number): this { + chmodSync(this.path(file), mode) + + return this + } + + /** `text` with the folder of the project, which differs between runs, as ``. */ + normalize(text: string): string { + return text.replaceAll(this.dir, '') + } + + exists(file: string): boolean { + return existsSync(this.path(file)) + } + + link(file: string, target: string): this { + mkdirSync(dirname(this.path(file)), { recursive: true }) + symlinkSync(target, this.path(file)) + + return this + } + + /** Installs a stand-in for `tool` that runs `script` with node, in place of the real one. */ + fake(tool: Tool, script: string): this { + const bin = tool === 'typescript' ? 'tsc' : tool + + return this.write({ + [`node_modules/${tool}`]: null, + [`node_modules/${tool}/package.json`]: { name: tool, bin: { [bin]: 'bin.js' } }, + [`node_modules/${tool}/bin.js`]: script, + }) + } + + git(...args: ReadonlyArray): string { + return git(this.dir, args) + } + + /** Writes `files` and stages them as they are, even names that look like globs. */ + stage(files: Files): this { + this.write(files) + this.git('--literal-pathspecs', 'add', '--all', '--', ...Object.keys(files)) + + return this + } + + commit(message = 'change'): this { + this.git('add', '--all') + this.git('commit', '--quiet', '--no-verify', `--message=${message}`) + + return this + } + + uncheck(args: ReadonlyArray = [], options: RunOptions = {}): Promise { + return run([...CLI, ...args], { ...options, cwd: this.path(options.cwd ?? '.') }) + } + + uncheckInTerminal(args: ReadonlyArray, options: TerminalOptions = {}): Promise { + return runInTerminal([...CLI, ...args], { ...options, cwd: this.path(options.cwd ?? '.') }) + } +} + +function install(project: Project, tools: ReadonlyArray): void { + for (const tool of tools) { + project.link(`node_modules/${tool}`, realpathSync(join(PACKAGE, 'node_modules', tool))) + } + + const manifest = JSON.parse(readFileSync(join(PACKAGE, 'package.json'), 'utf8')) as { + exports: Record + } + + for (const target of Object.values(manifest.exports)) { + if (typeof target === 'object') { + const source = join( + PACKAGE, + target.default.replace('./dist/', 'src/').replace(/\.mjs$/, '.ts'), + ) + + project.write({ + [`node_modules/uncheck/${target.default}`]: `export * from '${pathToFileURL(source)}'\n`, + }) + } + } + + project + .write({ + 'node_modules/uncheck/package.json': manifest, + 'node_modules/.bin/uncheck': `#!/bin/sh\nexec ${CLI.map(shellQuote).join(' ')} "$@"\n`, + }) + .link('node_modules/uncheck/tsconfig', join(PACKAGE, 'tsconfig')) + chmodSync(project.path('node_modules/.bin/uncheck'), 0o755) +} + +/** A project in a fresh directory, with the tools linked in as a package manager installs them. */ +export function project(files: Files = {}, { tools = TOOLS, git = 'commit' }: ProjectOptions = {}) { + const created = new Project(temporaryDirectory()) + + install(created, tools) + created.write({ '.gitignore': 'node_modules\ndist\n*.tsbuildinfo\n', ...files }) + + if (git !== 'none') { + created.git('init', '--quiet', '--initial-branch=main') + } + + if (git === 'commit') { + created.commit('init') + } + + return created +} + +const OXLINT_CONFIG = { rules: { 'no-var': 'error' } } + +function compilerOptions(extra: object = {}) { + return { + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + ...extra, + } +} + +const TOOL_VERSIONS = { + oxfmt: '^0.68.0', + oxlint: '^1.83.0', + sherif: '^1.13.0', + typescript: '^7.0.2', + uncheck: '^0.0.3', +} + +/** One package at the top of the repository. */ +export function singleRepo(files: Files = {}, options?: ProjectOptions): Project { + return project( + { + 'package.json': { + name: 'app', + version: '1.0.0', + private: true, + type: 'module', + devDependencies: TOOL_VERSIONS, + }, + 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', + '.oxlintrc.json': OXLINT_CONFIG, + 'tsconfig.json': { compilerOptions: compilerOptions({ noEmit: true }), include: ['src'] }, + 'src/index.ts': + 'import { double } from "./utils";\n\nexport const answer: number = double(21);\n', + 'src/utils.ts': 'export function double(value: number): number {\n return value * 2;\n}\n', + ...files, + }, + options, + ) +} + +/** 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({ + composite: true, + emitDeclarationOnly: true, + outDir: 'dist', + rootDir: 'src', + }) + + const created = project( + { + 'package.json': { + name: 'monorepo', + private: true, + type: 'module', + devDependencies: TOOL_VERSIONS, + packageManager: 'pnpm@10.0.0', + }, + 'pnpm-workspace.yaml': 'packages:\n - packages/*\n', + 'pnpm-lock.yaml': 'lockfileVersion: "9.0"\n', + '.oxlintrc.json': OXLINT_CONFIG, + 'tsconfig.json': { + files: [], + references: [{ path: 'packages/core' }, { path: 'packages/app' }], + }, + 'packages/core/package.json': { + name: '@repo/core', + version: '1.0.0', + private: true, + type: 'module', + exports: { '.': { types: './dist/index.d.ts', default: './src/index.ts' } }, + }, + 'packages/core/tsconfig.json': { compilerOptions: composite, include: ['src'] }, + 'packages/core/src/index.ts': + 'export function double(value: number): number {\n return value * 2;\n}\n', + 'packages/app/package.json': { + name: '@repo/app', + version: '1.0.0', + private: true, + type: 'module', + dependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/app/tsconfig.json': { + compilerOptions: composite, + references: [{ path: '../core' }], + include: ['src'], + }, + 'packages/app/src/index.ts': + 'import { double } from "@repo/core";\n\nexport const answer: number = double(21);\n', + ...files, + }, + { ...options, git: 'none' }, + ) + + created.link('packages/app/node_modules/@repo/core', '../../../core') + + if (options?.git !== 'none') { + created.git('init', '--quiet', '--initial-branch=main') + } + + if ((options?.git ?? 'commit') === 'commit') { + created.commit('init') + } + + return created +} + +/** A checkout of `project` made with `git worktree add`, with the links an install would make. */ +export function linkedWorktree(project: Project, app: string): Project { + const worktree = new Project(join(temporaryDirectory(), 'linked')) + + project.git('worktree', 'add', '--quiet', worktree.dir) + worktree.link('node_modules', project.path('node_modules')) + + if (app !== '') { + worktree.link(`${app}node_modules/@repo/core`, '../../../core') + } + + return worktree +} + +export interface Layout { + readonly name: string + readonly create: (files?: Files, options?: ProjectOptions) => Project + /** Where the code of the package the tests work on lives: `''` or a folder ending in `/`. */ + readonly app: string + /** The line of the tsc run that checks the whole project. */ + readonly tsc: string +} + +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' }, +] diff --git a/packages/uncheck/tests/utils/register.ts b/packages/uncheck/tests/utils/register.ts new file mode 100644 index 0000000..e670354 --- /dev/null +++ b/packages/uncheck/tests/utils/register.ts @@ -0,0 +1,30 @@ +import { readFileSync } from 'node:fs' +import { registerHooks } from 'node:module' +import { fileURLToPath } from 'node:url' + +// The sources import without extensions and read package.json without attributes, which only the +// bundler understands. +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('.') && !/\.(?:[cm]?[jt]s|json)$/.test(specifier)) { + for (const candidate of [`${specifier}.ts`, `${specifier}/index.ts`]) { + try { + return nextResolve(candidate, context) + } catch {} + } + } + + return nextResolve(specifier, context) + }, + load(url, context, nextLoad) { + if (url.endsWith('.json') && context.importAttributes.type === undefined) { + return { + format: 'module', + shortCircuit: true, + source: `export default ${readFileSync(fileURLToPath(url), 'utf8')}`, + } + } + + return nextLoad(url, context) + }, +}) diff --git a/vitest.config.ts b/vitest.config.ts index 0eb877c..6ace99b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,9 +7,12 @@ export default defineConfig(() => ({ globals: true, include: ['**/*.test.ts'], exclude: [...defaultExclude, '**/.claude/**'], + testTimeout: 120_000, coverage: { include: ['packages/*/src/**'], exclude: ['**.test-d.*', '**.test.*', '**/*.bench.ts'], + autoAttachSubprocess: true, + thresholds: { 100: true }, }, benchmark: { include: ['**/*.bench.ts'],