diff --git a/package.json b/package.json
index b5f16db..1435dcc 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,6 @@
}
},
"engines": {
- "node": ">=22.20 <23 || >=24.8"
+ "node": ">=22.20"
}
}
diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md
index cb1a836..463c940 100644
--- a/packages/uncheck/README.md
+++ b/packages/uncheck/README.md
@@ -10,7 +10,7 @@
-
+
@@ -21,52 +21,157 @@
-`uncheck` is a single command that lints, formats, type checks your project and keeps a monorepo consistent. It detects which tools the project already has — [`oxlint` and `oxfmt`](https://oxc.rs) for linting and formatting, `tsc` for types, [`sherif`](https://github.com/QuiiBz/sherif) for workspaces — and runs them together, so humans, coding agents and git hooks have one command to remember instead of four.
+`uncheck` lints, format checks and type checks your project with one command, and keeps a monorepo consistent. It runs the tools your project already has, so you, your git hooks and your coding agents all run the same check.
-## Usage
+```sh
+npm i -D uncheck oxlint oxfmt typescript # add sherif in a monorepo
+
+npx uncheck # check everything
+npx uncheck --fix # fix what can be fixed, report the rest
+npx uncheck prepare --pre-commit # check every commit
+npx uncheck hooks install claude # check every agent turn
+```
+
+uncheck needs Node 22.20 or later. Install only the tools you want: a check runs when its tool is installed and is skipped otherwise, except that a `tsconfig.json` without TypeScript installed fails. uncheck always uses the versions you installed.
+
+| Check | Checks | Runs when |
+| -------- | -------------------- | -------------------------------------------------------------------------------------------------- |
+| `sherif` | monorepo consistency | [sherif](https://github.com/QuiiBz/sherif) 1.10+ is installed, at the [workspace root](#monorepos) |
+| `oxlint` | lint rules | [oxlint](https://oxc.rs) 1.60+ is installed |
+| `oxfmt` | formatting | [oxfmt](https://oxc.rs) is installed |
+| `tsc` | types | the project has a `tsconfig.json` |
+
+## Check your project
+
+```text
+$ npx uncheck
+uncheck in /home/me/my-app
+○ sherif skipped, not installed
+▶ oxlint
+✔ oxlint passed 67ms
+▶ oxfmt --check
+Format issues found in above 2 files. Run without `--check` to fix.
+✘ oxfmt failed 65ms
+▶ tsc -p tsconfig.json --noEmit
+src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.
+✘ tsc failed 384ms
+
+✘ 2 of 3 checks failed: oxfmt, tsc
+ rerun with `--fix` to apply oxfmt fixes
+```
+
+Every check runs, so one run shows every problem. uncheck exits with 1 when a check fails, and also when no check could run, so a broken setup never passes quietly.
+
+```sh
+npx uncheck --only=oxlint --only=oxfmt # run only these checks
+npx uncheck --skip=tsc # skip a check
+npx uncheck --require=tsc # fail when tsc cannot run, instead of skipping it
+npx uncheck --cwd packages/app # run in another directory
+```
+
+`--only`, `--skip` and `--require` can be repeated and work on every command. Flags go after the command name (`npx uncheck staged --fix`), and `npx uncheck --help` lists them all.
+
+`--fix` applies oxlint's fixes, rewrites the formatting with oxfmt, and applies sherif's fixes, which runs your package manager's install afterwards. Type errors are yours to fix.
+
+## Check only some files
+
+```sh
+npx uncheck src/index.ts src/cli.ts # files
+npx uncheck src/app # a directory
+npx uncheck 'src/**/*.test.ts' # a glob, quoted so your shell leaves it alone
+npx uncheck src '!src/generated' # a directory, minus a part of it
+npx uncheck '!**/*.gen.ts' # everything except some files
+```
+
+uncheck turns your paths into one file list that every tool gets, so they never disagree about what a path means. Directories and globs match the files git knows about (tracked, or new and not ignored), dot files included. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`.
+
+tsc then checks only the projects that include one of the files, and sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among them.
+
+## Run it before every commit
+
+Add a `prepare` script, so every clone sets up the hook on install, and run it once now:
+
+```json
+{
+ "scripts": {
+ "prepare": "uncheck prepare --pre-commit"
+ }
+}
+```
+
+Every commit then runs `uncheck staged --fix`: it checks the staged files, fixes what oxlint and oxfmt can, and stages those fixes. A failing check blocks the commit, and `git commit --no-verify` skips the hook. No lint-staged or simple-git-hooks needed.
+
+| `prepare` flag | Effect |
+| ------------------------------- | ---------------------------------------------------------------------- |
+| `--no-fix` | The hook only checks and never changes your files |
+| `--allow-empty` | The hook lets a commit through when the fixes undo every staged change |
+| `--only`, `--skip`, `--require` | Written into the hook command |
+
+Run `prepare` again with other flags to change the hook. What it guarantees:
+
+- **You commit what was checked.** After `git add -p`, the unstaged part of a file is set aside while the checks run and put back afterwards, even after Ctrl-C.
+- **Nothing is lost.** If a fix clashes with your unstaged changes, every fix is undone and the commit stops. Stage the whole file, or stash the rest, and commit again.
+- **Only fixes to staged files are staged.** During a merge, only files that differ from the branch being merged in are checked.
+- **No empty commits.** If the fixes undo every staged change, the commit fails, unless you pass `--allow-empty`.
+
+Good to know:
+
+- tsc checks whole projects, so it can report errors in files you did not stage. `--only=oxlint --only=oxfmt` keeps the hook inside the commit.
+- sherif only reports in the hook, since its fixes reach beyond the commit. Run `npx uncheck --fix` for them.
+- A commit no selected check covers, such as a README change with `--only=tsc`, passes. Add `--require=tsc` to make it fail.
+- An existing hook is kept: uncheck adds one line after its setup (comments, `source`, `export`, variables) and before its commands. With husky 9 or Vite+, it writes the `pre-commit` file they run.
+- uncheck writes nothing, and says why, outside a git repository, when `core.hooksPath` comes from your global or system git config, or when the existing hook is not a shell script. Your install keeps working.
+- The hook runs uncheck through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`), so a missing install fails instead of downloading uncheck.
+- Yarn 2+ does not run `prepare`. Use `postinstall` instead, and in a package you publish, turn it off while packing, for example with `"prepack": "pinst --disable"` and `"postpack": "pinst --enable"`.
+
+## Run it after every agent turn
```sh
-npm i -D uncheck # Node 22.20+ or 24.8+, for stable path.matchesGlob
-
-npx uncheck # sherif, oxlint, oxfmt --check and tsc for everything under the current directory
-npx uncheck --fix # sherif --fix and oxlint --fix, then rewrite formatting with oxfmt
-npx uncheck src/app # check some files: paths, directories, globs and !exclusions
-npx uncheck --skip=tsc # skip a check that would otherwise run
-npx uncheck --only=oxlint --only=oxfmt # run only the named checks, here the fast ones
-npx uncheck --require=oxfmt # require a check: fail when it cannot run instead of skipping it
-npx uncheck --cwd packages/app # run in another directory, paths are relative to it
+npx uncheck hooks install claude codebuddy # name the agents
+npx uncheck hooks install # or pick them from a list
```
-Checks run in order and every check runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any check fails.
+| Agent | Name | Config file |
+| -------------- | ----------- | ---------------------------- |
+| Claude Code | `claude` | `.claude/settings.json` |
+| CodeBuddy | `codebuddy` | `.codebuddy/settings.json` |
+| Cursor | `cursor` | `.cursor/hooks.json` |
+| GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` |
+
+Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what it can, and when problems remain, sends the agent back to fix them. That happens at most once per turn, so an agent that cannot fix something is never stuck in a loop.
-| Check | Runs when | Command |
-| -------- | ----------------------------------------------------------- | -------------------------------------------------------------- |
-| `sherif` | `sherif` is installed and the directory is a workspace root | `sherif`, or `sherif --fix --select=highest` with `--fix` |
-| `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` |
-| `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` |
-| `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p` for the rest |
+- **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags.
+- **Your config is kept.** Other hooks and settings stay, and installing again only updates uncheck's entry. Comments in the file are lost when it is rewritten.
+- **Avoid double runs.** Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so add `cursor` or `copilot` next to `claude` only where they do not read that file.
+- **Copilot** reads `.github/hooks` only at the top of the repository, so install `copilot` from there.
-Paths given on the command line are resolved by `uncheck` itself into one list of files that every tool receives, so tools never disagree about what a directory or glob means: a file must exist, a directory expands to the project files below it (ignored files stay out, like `git ls-files`), globs use the usual `**`/`*` syntax and `!pattern` excludes. A path that matches nothing fails the run, unless `--no-error-on-unmatched-pattern` is passed.
+## Monorepos
-Tools are resolved from `node_modules` the way Node does, so the versions your project already depends on are used. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped.
+Run uncheck from the workspace root, the folder whose `package.json` has `workspaces` or that has a `pnpm-workspace.yaml`, to check the whole monorepo.
-### Monorepo consistency
+**sherif** checks the workspace as a whole, so it only runs at the root. Configure it in the `sherif` field of the root `package.json`, [as sherif documents](https://github.com/QuiiBz/sherif). With `--fix`, mismatched versions move to the highest one (unless you set `select`), and your install runs afterwards (unless you set `"noInstall": true`). When `CI` is set, sherif only reports.
-[`sherif`](https://github.com/QuiiBz/sherif) lints a monorepo as a whole: dependency versions that differ between packages, unordered dependencies, a missing `packageManager` field and so on. It runs when the directory is a workspace root (`workspaces` in `package.json` or a `pnpm-workspace.yaml`) and, when paths are given, only if a `package.json` or `pnpm-workspace.yaml` is among them, since nothing else changes its verdict. Its options are read from the `sherif` field of the root `package.json` as sherif documents, so rules and dependencies to ignore live there. With `--fix` sherif also runs your package manager's install afterwards, unless that field sets `noInstall`; aligning versions takes the highest one unless the field sets `select`, since choosing interactively needs a terminal. sherif refuses to fix anything in CI, so with `CI` set it only checks.
+**TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`:
-### Typecheck in monorepos
+- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations.
+- Every other project is checked with `tsc -p --noEmit`, a few at a time.
+- When only some files are checked, tsc runs just the projects that include them, and the projects that reference those. A changed tsconfig selects every project that extends it.
-Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively to build the project graph:
+**Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags:
-- Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package.
-- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p`.
-- Circular references are reported as an error.
+```sh
+#!/bin/sh
+# Written by `uncheck prepare`, run it again to change the command.
+pnpm exec uncheck staged --fix || exit 1
+(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1
+(cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1
+```
-When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Files `tsc` never checks, such as Markdown or CSS, select no project.
+An agent hook installed from a package folder checks only that package, wherever the agent moves to.
## Presets
-`uncheck/oxlint`, `uncheck/oxfmt` and `uncheck/tsconfig` export presets. `middleapi` is the one the [middleapi](https://github.com/middleapi) projects share:
+uncheck also ships the lint, format and TypeScript configs the [middleapi](https://github.com/middleapi) projects share. They are optional.
```ts
// oxlint.config.ts
@@ -85,7 +190,8 @@ export default defineConfig({ ...middleapi })
```
```jsonc
-// tsconfig.json, for Node.js code that is only type checked
+// tsconfig.json: `uncheck/tsconfig/middleapi` to only type check,
+// `uncheck/tsconfig/middleapi/lib` for a package that emits its declarations to dist
{
"extends": "uncheck/tsconfig/middleapi",
"compilerOptions": { "types": ["node"] },
@@ -93,68 +199,15 @@ export default defineConfig({ ...middleapi })
}
```
-```jsonc
-// packages/*/tsconfig.json, for a Node.js package that emits its declarations to dist
-{
- "extends": "uncheck/tsconfig/middleapi/lib",
- "compilerOptions": { "types": ["node"] },
- "include": ["src"],
-}
-```
-
-The tsconfig presets target ES2022 and load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers.
-
-## Agent hooks
-
-```sh
-npx uncheck hooks install # pick agents interactively
-npx uncheck hooks install claude cursor # or name them: claude, codebuddy, cursor, windsurf, copilot
-npx uncheck hooks install claude --only=oxlint --only=oxfmt # a fast hook: lint and format, no typecheck
-```
-
-This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json`, `.windsurf/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Whenever the agent finishes a turn, the hook runs:
-
-```sh
-uncheck hooks run --fix
-```
-
-through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). It runs every check on the files changed since the last commit (modified, staged and untracked, everything under the directory outside git), applies fixes, and prints the report on stderr. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop. Windsurf only shows the report.
-
-Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. When even that is too slow for a project, leave the typecheck to CI: `--only`, `--skip` and `--require` given to `install` are written into the hook command as they are, and reinstalling with other flags updates it, so `install claude --only=oxlint --only=oxfmt` gives a hook that only lints and formats.
-
-## Pre-commit hook
-
-```sh
-npx uncheck staged # check the staged files only, what a pre-commit hook should run
-npx uncheck staged --fix # also apply the fixes and stage them
-npx uncheck prepare --pre-commit # write .git/hooks/pre-commit so every commit runs `uncheck staged --fix`
-```
-
-`staged` runs the checks on the files staged for commit. The unstaged hunks of partially staged files (`git add -p`) are set aside while the checks run, so what `oxlint` and `oxfmt` see is what gets committed, then put back. The typecheck works differently by nature: `tsc` checks whole projects, so it also reports type errors in files you have not staged. Add `--only=oxlint --only=oxfmt` for a hook that never looks beyond the commit. With `--fix` the fixes are staged too. When a fix conflicts with an unstaged hunk, the fixes are undone and the commit fails, so nothing is ever lost: stage the whole file or stash its unstaged changes and commit again. A run killed before it puts them back leaves copies of the files in the git directory, and the next run stops and says where they are. When the fixes undo every staged change, the commit fails rather than recording an empty one, unless `--allow-empty` is passed.
-
-`prepare --pre-commit` replaces lint-staged and simple-git-hooks: it writes the git hook itself, running `uncheck staged --fix` through your package manager, and adds itself to an existing `pre-commit` hook rather than replacing it. With husky 9 or Vite+ (`vp config`) managing the hooks, it writes to `.husky/pre-commit` or `.vite-hooks/pre-commit`, the file their dispatcher runs, rather than to the generated shim in `_/`, which never reaches an added line and is rewritten on the next install. Running it again only ever rewrites the line it wrote itself, so lines you added by hand stay, a repeated copy of its own line is dropped, and in a monorepo each package that prepares gets its own line with its own flags. Without a flag `prepare` sets nothing up. Register it as the `prepare` script so every clone installs the hook:
-
-```json
-{
- "scripts": {
- "prepare": "uncheck prepare --pre-commit"
- }
-}
-```
+The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+. The tsconfig presets load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers.
-`--only`, `--skip` and `--require` given to `prepare` are written into the hook command as for `hooks install`, `--no-fix` gives a hook that only checks, and `--allow-empty` one that lets through a commit the fixes made empty. Outside a git repository `prepare` does nothing, so installs in CI and Docker builds keep working, and `git commit --no-verify` skips the hook.
+## Troubleshooting
-In a monorepo where the root and two packages prepare, the hook reads:
+**A path looks like a command, a flag or an exclusion.** Start it with `./`: `./staged`, `./-draft.ts`, `'./!notes.ts'`.
-```sh
-#!/bin/sh
-# Written by `uncheck prepare`, run it again to change the command.
-pnpm exec uncheck staged --fix || exit 1
-(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1
-(cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1
-```
+**`uncheck dist` says "No files match".** git ignores that folder, so it holds no project files. You can still name an ignored file directly.
-Every line ends in `|| exit 1`, so any failing check blocks the commit, and a package is entered in a subshell, so each line starts from the top of the working tree. A line you add by hand runs as you wrote it: give it `|| exit 1` too if its failure should block the commit.
+**A commit stops with "An earlier run left the unstaged versions of your files in …".** A pre-commit run was killed before it could put your unstaged changes back. Copy what your files are missing from the folder the message names, delete the folder, and commit again.
## Sponsors
diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json
index c2b46db..9977ac2 100644
--- a/packages/uncheck/package.json
+++ b/packages/uncheck/package.json
@@ -55,6 +55,7 @@
"@effect/platform-node": "^4.0.0-rc.116",
"effect": "^4.0.0-rc.116",
"jsonc-parser": "^3.3.1",
+ "minimatch": "^10.2.6",
"oxfmt": "^0.68.0",
"oxlint": "^1.83.0",
"sherif": "^1.13.0",
@@ -62,8 +63,8 @@
},
"peerDependencies": {
"oxfmt": "*",
- "oxlint": "*",
- "sherif": "*",
+ "oxlint": ">=1.60.0",
+ "sherif": ">=1.10.0",
"typescript": "*"
},
"peerDependenciesMeta": {
@@ -81,6 +82,6 @@
}
},
"engines": {
- "node": ">=22.20 <23 || >=24.8"
+ "node": ">=22.20"
}
}
diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts
index 85e66b4..c352cb5 100644
--- a/packages/uncheck/src/bin.ts
+++ b/packages/uncheck/src/bin.ts
@@ -1,37 +1,8 @@
#!/usr/bin/env node
-import process from 'node:process'
+import { enableCompileCache } from 'node:module'
-import * as NodeRuntime from '@effect/platform-node/NodeRuntime'
-import * as NodeServices from '@effect/platform-node/NodeServices'
-import { Effect } from 'effect'
-import { Command } from 'effect/unstable/cli'
+// The cache only covers modules compiled after it is on, hence the import that follows it.
+enableCompileCache()
-import pkg from '../package.json'
-import { hooks } from './commands/hooks'
-import { prepare } from './commands/prepare'
-import { staged } from './commands/staged'
-import { uncheck } from './commands/uncheck'
-
-// Any failed write (a reader gone after `| head`, a closed terminal, a full disk) would otherwise end
-// the run before `staged` puts unstaged changes back, so output errors are ignored.
-for (const stream of [process.stdout, process.stderr]) {
- stream.on('error', () => {})
-}
-
-Command.run(uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])), {
- version: pkg.version,
-}).pipe(
- Effect.catchTag('CheckFailed', () =>
- Effect.sync(() => {
- process.exitCode = 1
- }),
- ),
- Effect.catchTag('StopBlocked', () =>
- Effect.sync(() => {
- process.exitCode = 2
- }),
- ),
- Effect.provide(NodeServices.layer),
- NodeRuntime.runMain,
-)
+await import('./cli')
diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts
index be8f198..80336c9 100644
--- a/packages/uncheck/src/checks/oxfmt.ts
+++ b/packages/uncheck/src/checks/oxfmt.ts
@@ -6,7 +6,7 @@ import type { Check } from '../types'
export const oxfmt: Check = {
name: 'oxfmt',
- fixes: true,
+ fixes: 'files',
plan: Effect.fn(function* ({ cwd, fix, files }) {
const bin = yield* resolveBin('oxfmt', cwd)
@@ -20,7 +20,7 @@ export const oxfmt: Check = {
return [{ bin, args }]
}
- // Given files may include ones oxfmt does not handle (a Markdown file), which is not a failure.
+ // Given files may include ones oxfmt does not handle (a .txt file), which is not a failure.
return argvBatches(files).map((batch) => ({
bin,
args: [...args, '--no-error-on-unmatched-pattern'],
diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts
index c165182..0d4fcc3 100644
--- a/packages/uncheck/src/checks/oxlint.ts
+++ b/packages/uncheck/src/checks/oxlint.ts
@@ -6,7 +6,7 @@ import type { Check } from '../types'
export const oxlint: Check = {
name: 'oxlint',
- fixes: true,
+ fixes: 'files',
plan: Effect.fn(function* ({ cwd, fix, files }) {
const bin = yield* resolveBin('oxlint', cwd)
diff --git a/packages/uncheck/src/checks/sherif.ts b/packages/uncheck/src/checks/sherif.ts
index 142a7f4..a5f2a0e 100644
--- a/packages/uncheck/src/checks/sherif.ts
+++ b/packages/uncheck/src/checks/sherif.ts
@@ -11,7 +11,7 @@ const WORKSPACE_FILES = new Set(['package.json', 'pnpm-workspace.yaml'])
export const sherif: Check = {
name: 'sherif',
- fixes: true,
+ fixes: 'workspace',
plan: Effect.fn(function* ({ cwd, fix, files }) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts
index d434a3d..d5f552d 100644
--- a/packages/uncheck/src/checks/tsc.ts
+++ b/packages/uncheck/src/checks/tsc.ts
@@ -1,21 +1,23 @@
import { posix } from 'node:path'
import process from 'node:process'
-import { Data, Effect, FileSystem, Graph, Option, Path, Predicate } from 'effect'
+import { Effect, FileSystem, Option, Path, Predicate } from 'effect'
import { parse as parseJsonc } from 'jsonc-parser'
import { CannotCheck, NothingToCheck } from '../errors'
import { ancestors, readJson } from '../files'
import { resolveBin } from '../tool'
-import type { Check, CheckCommand } from '../types'
+import type { Check } from '../types'
/**
- * TypeScript replaces this token in `extends`, `references` and file specs with the folder of the
- * leaf config.
+ * TypeScript replaces this token at the start of `files`, `include`, `exclude` and path-valued
+ * compiler options with the folder of the leaf config, but never in `extends` or `references`.
*/
// oxlint-disable-next-line no-template-curly-in-string
const CONFIG_DIR = '${configDir}'
+const NOT_COVERED = 'no tsconfig.json covers the given files'
+
export const tsc: Check = {
name: 'tsc',
fixes: false,
@@ -25,12 +27,10 @@ export const tsc: Check = {
const targets = files
?.map((file) => path.resolve(cwd, file))
- .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file).toLowerCase()))
+ .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file)))
if (targets !== undefined && targets.length === 0) {
- return yield* Effect.fail(
- new NothingToCheck({ reason: 'no tsconfig.json covers the given files' }),
- )
+ return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED }))
}
const [typescript, tsconfigs] = yield* Effect.all(
@@ -54,12 +54,25 @@ export const tsc: Check = {
return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' }))
}
- const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(tsconfigs, targets)
+ const references = new Map>()
+ const queue = [...tsconfigs]
+
+ while (queue.length > 0) {
+ const configPath = queue.pop()!
+
+ if (!references.has(configPath) && (yield* fs.exists(configPath))) {
+ const referencedConfigs = yield* readReferences(configPath)
+
+ references.set(configPath, referencedConfigs)
+ queue.push(...referencedConfigs)
+ }
+ }
+
+ const selected =
+ targets === undefined ? tsconfigs : yield* selectTsconfigs([...references.keys()], targets)
if (selected.length === 0) {
- return yield* Effect.fail(
- new NothingToCheck({ reason: 'no tsconfig.json covers the given files' }),
- )
+ return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED }))
}
if (typescript === undefined) {
@@ -70,144 +83,121 @@ export const tsc: Check = {
)
}
- const bin = typescript
-
- const projects = new Map()
- const queue = [...selected]
-
- while (queue.length > 0) {
- const configPath = queue.pop()!
+ const shown = (configPath: string) => path.relative(cwd, configPath)
+ const referenced = new Set([...references.values()].flat())
+ const members = [...references]
+ .filter(
+ ([configPath, referencedConfigs]) =>
+ referencedConfigs.length > 0 || referenced.has(configPath),
+ )
+ .map(([configPath]) => configPath)
+ .sort()
- if (!projects.has(configPath)) {
- const references = yield* readReferences(configPath)
+ const cycle = findCycle(members, references)
- projects.set(configPath, { path: configPath, references })
- queue.push(...references)
- }
+ if (cycle !== undefined) {
+ return yield* Effect.fail(
+ new CannotCheck({
+ reason: `circular project references between ${cycle.map(shown).join(', ')}`,
+ }),
+ )
}
- const plan = yield* planTypecheck(selected, projects).pipe(
- Effect.catchTag('CircularProjectReferences', (error) => {
- const cycle = error.projects
- .map((configPath) => path.relative(cwd, configPath) || '.')
- .join(', ')
- return Effect.fail(
- new CannotCheck({ reason: `circular project references between ${cycle}` }),
- )
- }),
- )
+ const dependents = new Map()
- const commands: CheckCommand[] = []
+ for (const [dependent, referencedConfigs] of references) {
+ for (const configPath of referencedConfigs) {
+ const known = dependents.get(configPath) ?? []
- if (plan.build.length > 0) {
- commands.push({
- bin,
- args: ['-b', ...plan.build.map((configPath) => path.relative(cwd, configPath) || '.')],
- })
+ known.push(dependent)
+ dependents.set(configPath, known)
+ }
}
- for (const configPath of plan.check) {
- commands.push({ bin, args: ['-p', path.relative(cwd, configPath) || '.'] })
+ const affected = new Set(selected)
+
+ for (const configPath of affected) {
+ for (const dependent of dependents.get(configPath) ?? []) {
+ affected.add(dependent)
+ }
}
- return commands
+ const roots = members.filter((member) => !referenced.has(member) && affected.has(member))
+ const standalone = selected.filter((configPath) => !members.includes(configPath)).sort()
+
+ return [
+ ...(roots.length > 0 ? [{ bin: typescript, args: ['-b', ...roots.map(shown)] }] : []),
+ // `-p` would emit JavaScript next to sources that set no `noEmit`.
+ ...standalone.map((configPath) => ({
+ bin: typescript,
+ args: ['-p', shown(configPath), '--noEmit'],
+ parallel: true,
+ })),
+ ]
}),
}
-const CHECKABLE_EXTENSIONS = new Set([
- '.ts',
- '.tsx',
- '.mts',
- '.cts',
- '.js',
- '.jsx',
- '.mjs',
- '.cjs',
- '.json',
-])
-
-interface TsProject {
- readonly path: string
- readonly references: ReadonlyArray
-}
-
-interface TypecheckPlan {
- readonly build: ReadonlyArray
- readonly check: ReadonlyArray
-}
-
-class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{
- readonly projects: ReadonlyArray
-}> {}
-
-/**
- * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own.
- *
- * Every project that has references, or is referenced, belongs to the build graph.
- * Only the graph's roots are passed to `tsc -b` since it builds their references transitively.
- */
-function planTypecheck(
- entries: ReadonlyArray,
- projects: ReadonlyMap,
-): Effect.Effect {
- const members = new Set()
- const referenced = new Set()
-
- for (const project of projects.values()) {
- if (project.references.length === 0) {
- continue
- }
-
- members.add(project.path)
-
- for (const reference of project.references) {
- members.add(reference)
- referenced.add(reference)
+function findCycle(
+ nodes: ReadonlyArray,
+ references: ReadonlyMap>,
+ trail: ReadonlyArray = [],
+ done = new Set(),
+): ReadonlyArray | undefined {
+ for (const node of nodes) {
+ if (trail.includes(node)) {
+ return trail.slice(trail.indexOf(node))
}
- }
- const nodes = [...members].sort()
+ if (!done.has(node)) {
+ const cycle = findCycle(references.get(node) ?? [], references, [...trail, node], done)
- const graph = Graph.directed((mutable) => {
- const indexes = new Map(nodes.map((node) => [node, Graph.addNode(mutable, node)] as const))
+ done.add(node)
- for (const node of nodes) {
- for (const reference of projects.get(node)?.references ?? []) {
- Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null)
+ if (cycle !== undefined) {
+ return cycle
}
}
- })
-
- const cycle = Graph.findCycle(graph)
-
- if (Option.isSome(cycle)) {
- // The path closes on its first node, drop that repetition.
- const path = cycle.value.path
- .slice(0, -1)
- .map((index) => Option.getOrThrow(Graph.getNode(graph, index)))
-
- return Effect.fail(new CircularProjectReferences({ projects: path }))
}
- return Effect.succeed({
- build: nodes.filter((member) => !referenced.has(member)),
- check: entries.filter((entry) => !members.has(entry)).sort(),
- })
+ return undefined
}
-function selectTsconfigs(
- entries: ReadonlyArray,
+const selectTsconfigs = Effect.fn(function* (
+ candidates: ReadonlyArray,
files: ReadonlyArray,
-): Effect.Effect, never, FileSystem.FileSystem | Path.Path> {
- return Effect.filter(
- entries,
- (entry) =>
- Effect.map(loadTsconfigInputs(entry), (inputs) =>
- files.some((file) => includesFile(inputs, file)),
+) {
+ const jsonFiles = yield* Effect.forEach(
+ files.filter((file) => posix.extname(file) === '.json'),
+ realPath,
+ { concurrency: 'unbounded' },
+ )
+
+ return yield* Effect.filter(
+ candidates,
+ (candidate) =>
+ Effect.flatMap(loadTsconfigInputs(candidate), (inputs) =>
+ files.some((file) => includesFile(inputs, file))
+ ? Effect.succeed(true)
+ : jsonFiles.length === 0
+ ? Effect.succeed(false)
+ : Effect.map(
+ Effect.forEach(inputs.configs, realPath, { concurrency: 'unbounded' }),
+ (configs) => configs.some((config) => jsonFiles.includes(config)),
+ ),
),
{ concurrency: 'unbounded' },
- ).pipe(Effect.map((selected) => [...selected].sort()))
-}
+ )
+})
+
+/**
+ * A shared config is often extended through a workspace package linked into `node_modules`, while
+ * the given files name it by its real path.
+ */
+const realPath = Effect.fn(function* (file: string) {
+ const fs = yield* FileSystem.FileSystem
+
+ return yield* fs.realPath(file).pipe(Effect.orElseSucceed(() => file))
+})
interface RawTsconfig {
readonly extends?: unknown
@@ -242,7 +232,7 @@ const readReferences = Effect.fn(function* (configPath: string) {
return []
}
- const target = path.resolve(configDir, reference.path.replaceAll(CONFIG_DIR, configDir))
+ const target = path.resolve(configDir, reference.path.replaceAll('\\', '/'))
return [target.endsWith('.json') ? target : path.join(target, 'tsconfig.json')]
})
@@ -254,7 +244,7 @@ interface InputPattern {
}
interface TsconfigInputs {
- readonly dir: string
+ readonly configs: ReadonlyArray
readonly files: ReadonlyArray
readonly include: ReadonlyArray
readonly exclude: ReadonlyArray
@@ -263,7 +253,7 @@ interface TsconfigInputs {
const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts']
const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs']
-const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages']
+const CHECKABLE_EXTENSIONS = new Set([...TS_EXTENSIONS, ...JS_EXTENSIONS, '.json'])
interface Specs {
readonly dir: string
@@ -280,28 +270,31 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) {
const chain = yield* loadExtendsChain(configPath, new Set())
const leafDir = path.dirname(configPath)
- let files: Specs | undefined
- let include: Specs | undefined
- let exclude: Specs | undefined
- let outDir: Specs | undefined
+ const last: Partial<
+ Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir', Specs>
+ > = {}
let allowJs = false
for (const { dir, raw } of chain) {
- if (isStringArray(raw.files)) {
- files = { dir, specs: raw.files }
- }
+ for (const key of ['files', 'include', 'exclude'] as const) {
+ const specs = raw[key]
- if (isStringArray(raw.include)) {
- include = { dir, specs: raw.include }
- }
-
- if (isStringArray(raw.exclude)) {
- exclude = { dir, specs: raw.exclude }
+ if (isStringArray(specs)) {
+ last[key] = { dir, specs }
+ }
}
if (Predicate.isObject(raw.compilerOptions)) {
const { compilerOptions } = raw
+ for (const key of ['outDir', 'declarationDir'] as const) {
+ const spec = compilerOptions[key]
+
+ if (typeof spec === 'string') {
+ last[key] = { dir, specs: [spec] }
+ }
+ }
+
if (typeof compilerOptions.allowJs === 'boolean') {
allowJs = compilerOptions.allowJs
}
@@ -309,16 +302,16 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) {
if (compilerOptions.checkJs === true) {
allowJs = true
}
-
- if (typeof compilerOptions.outDir === 'string') {
- outDir = { dir, specs: [compilerOptions.outDir] }
- }
}
}
+ const { files, include, exclude, outDir, declarationDir } = last
+
const resolve = (specs: Specs | undefined): string[] =>
specs?.specs.map((spec) =>
- path.resolve(specs.dir, spec.replaceAll(CONFIG_DIR, leafDir)).replaceAll('\\', '/'),
+ path
+ .resolve(specs.dir, spec.replaceAll('\\', '/').replaceAll(CONFIG_DIR, leafDir))
+ .replaceAll('\\', '/'),
) ?? []
const includeSpecs =
@@ -327,15 +320,10 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) {
: resolve(include)
const excludeSpecs =
- exclude === undefined
- ? [
- ...DEFAULT_EXCLUDES.map((name) => `${leafDir.replaceAll('\\', '/')}/${name}`),
- ...resolve(outDir),
- ]
- : resolve(exclude)
+ exclude === undefined ? [...resolve(outDir), ...resolve(declarationDir)] : resolve(exclude)
return {
- dir: leafDir,
+ configs: chain.map(({ file }) => file),
files: resolve(files),
include: includeSpecs.flatMap((spec) => {
const regex = compileGlob(spec, 'files')
@@ -353,7 +341,7 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean {
return true
}
- const extension = posix.extname(target).toLowerCase()
+ const extension = posix.extname(target)
const json = extension === '.json'
if (!json && !inputs.extensions.has(extension)) {
@@ -371,20 +359,23 @@ function includesFile(inputs: TsconfigInputs, file: string): boolean {
}
interface ChainEntry {
+ readonly file: string
readonly dir: string
readonly raw: RawTsconfig
}
+/**
+ * Only a config that is still being resolved closes a cycle: like tsc, a base that two `extends`
+ * branches share applies in both.
+ */
const loadExtendsChain = Effect.fn(function* (
configPath: string,
- visited: Set,
+ resolving: ReadonlySet,
): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> {
- if (visited.has(configPath)) {
+ if (resolving.has(configPath)) {
return []
}
- visited.add(configPath)
-
const path = yield* Path.Path
const raw = yield* readTsconfig(configPath)
const dir = path.dirname(configPath)
@@ -392,17 +383,17 @@ const loadExtendsChain = Effect.fn(function* (
typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : []
const bases = yield* Effect.forEach(specs, (spec) =>
- resolveExtends(spec, dir).pipe(
+ resolveExtends(spec.replaceAll('\\', '/'), dir).pipe(
Effect.flatMap(
Option.match({
onNone: () => Effect.succeed>([]),
- onSome: (base) => loadExtendsChain(base, visited),
+ onSome: (base) => loadExtendsChain(base, new Set(resolving).add(configPath)),
}),
),
),
)
- return [...bases.flat(), { dir, raw }]
+ return [...bases.flat(), { file: configPath, dir, raw }]
})
const resolveExtends = Effect.fn(function* (spec: string, dir: string) {
@@ -467,6 +458,7 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) {
})
const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))'
+const FILES_ASTERISK = '(?:[^./]|(?:\\.(?!min\\.js$))?)*'
const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?`
const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?'
const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin'
@@ -475,7 +467,8 @@ const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'd
* Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it:
* `*` and `?` never cross a directory, a leading wildcard never matches a dot file, `**` skips
* `node_modules` and dot folders, and a pattern without extension or wildcard means the whole folder.
- * `exclude` patterns also match every path below them.
+ * In `include`, `*` never matches a name ending in `.min.js`; `exclude` patterns also match every
+ * path below them.
*/
function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined {
const components = pattern.replace(/\/+$/, '').split('/')
@@ -517,21 +510,21 @@ function filesComponent(component: string): string {
let rest = component
if (rest.startsWith('*')) {
- source += '(?:[^./][^/]*)?'
+ source += `(?:[^./]${FILES_ASTERISK})?`
rest = rest.slice(1)
} else if (rest.startsWith('?')) {
source += '[^./]'
rest = rest.slice(1)
}
- source += wildcards(rest)
+ source += wildcards(rest, FILES_ASTERISK)
return /[*?]/.test(component) ? IMPLICIT_EXCLUDE + source : source
}
-function wildcards(component: string): string {
+function wildcards(component: string, asterisk = '[^/]*'): string {
return component.replace(/[.*?+^${}()|[\]\\]/g, (char) =>
- char === '*' ? '[^/]*' : char === '?' ? '[^/]' : `\\${char}`,
+ char === '*' ? asterisk : char === '?' ? '[^/]' : `\\${char}`,
)
}
diff --git a/packages/uncheck/src/cli.ts b/packages/uncheck/src/cli.ts
new file mode 100644
index 0000000..8070344
--- /dev/null
+++ b/packages/uncheck/src/cli.ts
@@ -0,0 +1,52 @@
+import process from 'node:process'
+
+import * as NodeRuntime from '@effect/platform-node/NodeRuntime'
+import * as NodeServices from '@effect/platform-node/NodeServices'
+import { Console, Effect } from 'effect'
+import { CliConfig, CliError, CliOutput, Command, GlobalFlag } from 'effect/unstable/cli'
+
+import pkg from '../package.json'
+import { hooks } from './commands/hooks'
+import { prepare } from './commands/prepare'
+import { staged } from './commands/staged'
+import { uncheck } from './commands/uncheck'
+
+// Any failed write (a reader gone after `| head`, a closed terminal, a full disk) would otherwise end
+// the run before `staged` puts unstaged changes back, so output errors are ignored.
+for (const stream of [process.stdout, process.stderr]) {
+ stream.on('error', () => {})
+}
+
+// A closed terminal kills on SIGHUP before `staged` puts unstaged changes back, and sends it twice
+// (the shell forwards it, then the kernel), so `once` is not enough.
+process.on('SIGHUP', () => process.kill(process.pid, 'SIGTERM'))
+
+Command.run(uncheck.pipe(Command.withSubcommands([staged, prepare, hooks])), {
+ version: pkg.version,
+}).pipe(
+ Effect.catchTag('CheckFailed', () =>
+ Effect.sync(() => {
+ process.exitCode = 1
+ }),
+ ),
+ Effect.catchTag('StopBlocked', () =>
+ Effect.sync(() => {
+ process.exitCode = 2
+ }),
+ ),
+ Effect.catchTag('PlatformError', (error) =>
+ Effect.gen(function* () {
+ const formatter = yield* CliOutput.Formatter
+
+ yield* Console.error(
+ formatter.formatError(new CliError.UserError({ cause: error, userMessage: error.message })),
+ )
+ process.exitCode = 1
+ }),
+ ),
+ Effect.provide(
+ CliConfig.layer({ builtIns: [GlobalFlag.Help, GlobalFlag.Version, GlobalFlag.Completions] }),
+ ),
+ Effect.provide(NodeServices.layer),
+ NodeRuntime.runMain,
+)
diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts
index 3ec2aa8..9a007bf 100644
--- a/packages/uncheck/src/commands/hooks/install.ts
+++ b/packages/uncheck/src/commands/hooks/install.ts
@@ -1,58 +1,50 @@
-import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio } from 'effect'
+import { isDeepStrictEqual } from 'node:util'
+
+import { Console, Effect, FileSystem, Path, Predicate, Stdio } from 'effect'
import { Argument, Command, Prompt } from 'effect/unstable/cli'
-import { parse as parseJsonc } from 'jsonc-parser'
+import { type ParseError, parse as parseJsonc, printParseErrorCode } from 'jsonc-parser'
import { userError } from '../../errors'
-import { detectExec } from '../../pm'
+import { readJson, readTextIfExists } from '../../files'
+import { gitLocation } from '../../git'
+import { detectExec, invokes } from '../../pm'
import { bold, dim, green } from '../../style'
-import {
- cwdFlag,
- onlyFlag,
- requireFlag,
- selectionArgs,
- skipFlag,
- validateSelection,
-} from '../uncheck'
+import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from '../uncheck'
+
+// Copilot (30 s) and CodeBuddy (60 s) kill a typecheck at their default timeout and end the turn as
+// if no hook ran.
+const TIMEOUT_SECONDS = 600
+
+const CLAUDE_FORMAT = {
+ event: 'Stop',
+ timeout: 'timeout',
+ root: {},
+ entry: (command: string) => ({ type: 'command', command }),
+ group: (entry: object) => ({ hooks: [entry] }),
+}
const AGENTS = [
- {
- id: 'claude',
- name: 'Claude Code',
- path: '.claude/settings.json',
- content: (command: string) => ({
- hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] },
- }),
- },
- {
- id: 'codebuddy',
- name: 'CodeBuddy',
- path: '.codebuddy/settings.json',
- content: (command: string) => ({
- hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] },
- }),
- },
+ { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', ...CLAUDE_FORMAT },
+ { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', ...CLAUDE_FORMAT },
{
id: 'cursor',
name: 'Cursor',
path: '.cursor/hooks.json',
- content: (command: string) => ({ version: 1, hooks: { stop: [{ command }] } }),
- },
- {
- id: 'windsurf',
- name: 'Windsurf',
- path: '.windsurf/hooks.json',
- content: (command: string) => ({
- hooks: { post_cascade_response: [{ command, show_output: true }] },
- }),
+ event: 'stop',
+ timeout: 'timeout',
+ root: { version: 1 },
+ entry: (command: string) => ({ command }),
+ group: (entry: object) => entry,
},
{
id: 'copilot',
name: 'GitHub Copilot',
path: '.github/hooks/uncheck.json',
- content: (command: string) => ({
- version: 1,
- hooks: { agentStop: [{ type: 'command', bash: command, powershell: command }] },
- }),
+ event: 'agentStop',
+ timeout: 'timeoutSec',
+ root: { version: 1 },
+ entry: (command: string) => ({ type: 'command', bash: command, powershell: command }),
+ group: (entry: object) => entry,
},
] as const
@@ -62,9 +54,7 @@ export const install = Command.make(
'install',
{
cwd: cwdFlag,
- only: onlyFlag,
- required: requireFlag,
- skipped: skipFlag,
+ ...selectionFlags,
agents: Argument.Literals('agents', AGENT_IDS).pipe(
Argument.variadic(),
Argument.withDescription(
@@ -79,6 +69,27 @@ export const install = Command.make(
yield* validateSelection(selection)
+ // Agents run the hook wherever they last `cd`'d, so a project below the top of the repository
+ // is named relative to it.
+ const dir = yield* gitLocation(cwd).pipe(
+ Effect.map(({ prefix }) => prefix.replace(/\/$/, '')),
+ Effect.orElseSucceed(() => ''),
+ )
+ const manifest = yield* readJson(path.join(cwd, 'package.json'))
+ const declaresUncheck = [manifest?.dependencies, manifest?.devDependencies].some(
+ (dependencies) => Predicate.isObject(dependencies) && 'uncheck' in dependencies,
+ )
+ const exec = yield* detectExec(cwd, { fromAnyWorkspace: dir === '' || !declaresUncheck })
+ const flags = ['--fix', ...selectionArgs(selection), ...(dir === '' ? [] : [`--dir=${dir}`])]
+ const command = `${exec} ${HOOK_COMMAND} ${flags.join(' ')}`
+
+ // A reinstall that cannot recognise the command would add a second hook next to it.
+ if (!invokes(command, HOOK_COMMAND)) {
+ return yield* userError(
+ `The hook command cannot name ${dir}: install from the top of the repository or from a directory whose path has only letters, digits and _=./@+-`,
+ )
+ }
+
let selected: ReadonlyArray<(typeof AGENT_IDS)[number]> = agents
if (selected.length === 0) {
@@ -97,45 +108,76 @@ export const install = Command.make(
)
}
- const exec = yield* detectExec(cwd)
- const command = `${exec} ${HOOK_COMMAND} ${['--fix', ...selectionArgs(selection)].join(' ')}`
-
- for (const agent of AGENTS) {
- if (!selected.includes(agent.id)) {
- continue
- }
+ if (dir !== '' && selected.includes('copilot')) {
+ return yield* userError(
+ `Copilot reads .github/hooks only at the top of the repository, not in ${dir}: install copilot from there`,
+ )
+ }
- const file = path.join(cwd, agent.path)
- const existing = yield* fs.readFileString(file).pipe(Effect.option)
- let result: 'created' | 'updated' | 'unchanged'
+ const updates = yield* Effect.forEach(
+ AGENTS.filter((agent) => selected.includes(agent.id)),
+ (agent) =>
+ Effect.gen(function* () {
+ const file = path.join(cwd, agent.path)
+ const existing = yield* readTextIfExists(file)
+ const text = existing ?? ''
+ const errors: ParseError[] = []
+ const current: unknown = parseJsonc(text, errors, { allowTrailingComma: true })
+
+ if (text.trim() !== '' && (errors.length > 0 || !Predicate.isObject(current))) {
+ const [error] = errors
+ const problem =
+ error === undefined
+ ? 'is not a JSON object'
+ : `has ${printParseErrorCode(error.error)} on line ${text.slice(0, error.offset).split('\n').length}`
+
+ return yield* userError(`${agent.path} ${problem}, fix it and run again`)
+ }
- if (Option.isNone(existing)) {
- yield* fs.makeDirectory(path.dirname(file), { recursive: true })
- yield* fs.writeFileString(file, render(agent.content(command)))
- result = 'created'
- } else {
- const current: unknown = parseJsonc(existing.value, undefined, { allowTrailingComma: true })
- const base = Predicate.isObject(current) ? current : {}
- const installed: string[] = []
-
- const replaced = mapStrings(base, (text) => {
- if (!text.includes(HOOK_COMMAND)) {
- return text
+ const base = Predicate.isObject(current) ? current : {}
+ const hooks = Predicate.isObject(base.hooks) ? base.hooks : {}
+ const entry = agent.entry(command)
+ const timeout = { [agent.timeout]: TIMEOUT_SECONDS }
+ const entries = hooks[agent.event]
+ const found: object[] = []
+ // Copilot takes `timeout` as another name for `timeoutSec`, so either is one the user chose.
+ const replaced = mapOwnEntries(entries, (hook) => {
+ found.push(hook)
+ return {
+ ...hook,
+ ...entry,
+ ...('timeout' in hook || 'timeoutSec' in hook ? {} : timeout),
+ }
+ })
+ const next = {
+ ...base,
+ ...(found.length > 0 ? {} : agent.root),
+ hooks: {
+ ...hooks,
+ [agent.event]:
+ found.length > 0
+ ? replaced
+ : [
+ ...(Array.isArray(entries) ? entries : []),
+ agent.group({ ...entry, ...timeout }),
+ ],
+ },
}
+ const result =
+ existing === undefined
+ ? 'created'
+ : isDeepStrictEqual(next, base)
+ ? 'unchanged'
+ : 'updated'
+
+ return { agent, file, next, result }
+ }),
+ )
- installed.push(text)
- return command
- })
-
- if (installed.length === 0) {
- yield* fs.writeFileString(file, render(mergeJson(base, agent.content(command))))
- result = 'updated'
- } else if (installed.every((text) => text === command)) {
- result = 'unchanged'
- } else {
- yield* fs.writeFileString(file, render(replaced))
- result = 'updated'
- }
+ for (const { agent, file, next, result } of updates) {
+ if (result !== 'unchanged') {
+ yield* fs.makeDirectory(path.dirname(file), { recursive: true })
+ yield* fs.writeFileString(file, `${JSON.stringify(next, null, 2)}\n`)
}
yield* Console.log(`${green('✔')} ${bold(agent.name)} ${dim(`${agent.path} ${result}`)}`)
@@ -154,42 +196,22 @@ export const install = Command.make(
const HOOK_COMMAND = 'uncheck hooks run'
-function render(value: unknown): string {
- return `${JSON.stringify(value, null, 2)}\n`
-}
-
-function mapStrings(value: unknown, f: (text: string) => string): unknown {
- if (typeof value === 'string') {
- return f(value)
- }
-
+function mapOwnEntries(value: unknown, f: (hook: Record) => object): unknown {
if (Array.isArray(value)) {
- return value.map((item) => mapStrings(item, f))
+ return value.map((item) => mapOwnEntries(item, f))
}
- if (Predicate.isObject(value)) {
- return Object.fromEntries(
- Object.entries(value).map(([key, item]) => [key, mapStrings(item, f)]),
- )
- }
-
- return value
-}
-
-function mergeJson(base: unknown, addition: unknown): unknown {
- if (Array.isArray(base) && Array.isArray(addition)) {
- return [...base, ...addition]
+ if (!Predicate.isObject(value)) {
+ return value
}
- if (Predicate.isObject(base) && Predicate.isObject(addition)) {
- const merged: Record = { ...base }
-
- for (const [key, value] of Object.entries(addition)) {
- merged[key] = key in base ? mergeJson(base[key], value) : value
- }
-
- return merged
+ if (
+ Object.values(value).some((item) => typeof item === 'string' && invokes(item, HOOK_COMMAND))
+ ) {
+ return f(value)
}
- return addition
+ return Object.fromEntries(
+ Object.entries(value).map(([key, item]) => [key, mapOwnEntries(item, f)]),
+ )
}
diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts
index 13e24fd..fe72fb4 100644
--- a/packages/uncheck/src/commands/hooks/run.ts
+++ b/packages/uncheck/src/commands/hooks/run.ts
@@ -1,16 +1,36 @@
+import process from 'node:process'
import { stripVTControlCharacters } from 'node:util'
-import { Console, Effect, Predicate, Stdio, Stream } from 'effect'
-import { Command } from 'effect/unstable/cli'
+import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from 'effect'
+import { Command, Flag } from 'effect/unstable/cli'
import { StopBlocked, userError } from '../../errors'
import { listChangedFiles } from '../../files'
-import { cwdFlag, fixFlag, onlyFlag, requireFlag, runChecks, skipFlag } from '../uncheck'
+import { git } from '../../git'
+import { captureLines } from '../../tool'
+import { fixFlag, runChecks, selectionFlags } from '../uncheck'
export const run = Command.make(
'run',
- { cwd: cwdFlag, fix: fixFlag, only: onlyFlag, required: requireFlag, skipped: skipFlag },
- Effect.fn(function* ({ cwd, ...settings }) {
+ {
+ cwd: Flag.Directory('cwd', { mustExist: true }).pipe(
+ Flag.optional,
+ Flag.withDescription(
+ 'Directory to check. Defaults to the top of the git repository around the current directory, or the current directory outside git',
+ ),
+ ),
+ dir: Flag.String('dir').pipe(
+ Flag.optional,
+ Flag.withDescription(
+ 'Directory to check relative to the top of the git repository, whichever directory the agent moved to. `hooks install` writes it for a project below the top',
+ ),
+ ),
+ fix: fixFlag,
+ ...selectionFlags,
+ },
+ Effect.fn(function* ({ cwd: given, dir, ...settings }) {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
const stdio = yield* Stdio.Stdio
if (yield* stdio.stdinIsTerminal) {
@@ -23,44 +43,80 @@ export const run = Command.make(
Effect.orElseSucceed((): Record => ({})),
)
+ // Cursor also stops a turn the user interrupted, or one that failed; fixing it would edit half-done
+ // work and a follow-up would restart the agent.
+ if (payload.status === 'aborted' || payload.status === 'error') {
+ return
+ }
+
+ const start = Option.getOrElse(given, () => process.cwd())
+ const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe(
+ Effect.orElseSucceed(() => start),
+ )
+ const cwd = Option.isSome(dir) ? path.join(top, dir.value) : Option.getOrElse(given, () => top)
+
+ // Checking a folder that is gone would send the agent back to fix a configuration it cannot see.
+ if (Option.isSome(dir) && !(yield* fs.exists(cwd))) {
+ return yield* userError(
+ `--dir=${dir.value} names nothing in ${top}, run \`uncheck hooks install\` again from the project`,
+ )
+ }
+
const changed = yield* listChangedFiles(cwd)
if (changed?.length === 0) {
return
}
- const lines: string[] = []
-
- const capture: Console.Console = Object.assign(Object.create(globalThis.console), {
- log: (...parts: ReadonlyArray) => {
- lines.push(parts.join(' '))
- },
- })
-
- const failed = yield* runChecks(changed ?? [], { ...settings, cwd, allowUnmatched: true }).pipe(
+ const [failed, lines] = yield* runChecks(changed ?? [], {
+ ...settings,
+ cwd,
+ literal: true,
+ }).pipe(
Effect.map(() => false),
Effect.catchTag('CheckFailed', () => Effect.succeed(true)),
- Effect.provideService(Console.Console, capture),
+ captureLines,
)
const report = stripVTControlCharacters(lines.join('\n'))
yield* Console.error(report)
+ if (!failed) {
+ return
+ }
+
// Send the agent back at most once per turn, in the way its family understands. Claude Code and
- // CodeBuddy block on exit code 2 with stderr as the message and set `stop_hook_active` once they
- // are already continuing; Cursor continues on a follow-up message and counts them in `loop_count`;
- // Copilot continues on a block decision and also sets `stop_hook_active`. Windsurf only shows the report.
+ // CodeBuddy block on exit code 2 with stderr as the message, set `stop_hook_active` once they are
+ // already continuing, and show the user nothing but a `systemMessage` from a hook that exits 0;
+ // Cursor continues on a follow-up message and counts them in `loop_count`; Copilot continues on a
+ // block decision, also in the Claude format, where it takes exit code 2 for a mere warning.
const alreadyContinued =
payload.stop_hook_active === true ||
(typeof payload.loop_count === 'number' && payload.loop_count > 0)
- if (!failed || alreadyContinued) {
+ if (alreadyContinued) {
+ if (payload.hook_event_name === 'Stop') {
+ const summary =
+ report
+ .split('\n')
+ .filter((line) => line.startsWith('✘ '))
+ .at(-1) ?? ''
+
+ yield* Console.log(
+ JSON.stringify({ systemMessage: `uncheck still fails: ${summary.slice(2)}` }),
+ )
+ }
+
return
}
const reason = `uncheck found problems, fix them before finishing:\n\n${report}`
+ if (typeof (payload.stopReason ?? payload.stop_reason) === 'string') {
+ return yield* Console.log(JSON.stringify({ decision: 'block', reason }))
+ }
+
if (payload.hook_event_name === 'Stop') {
return yield* Effect.fail(new StopBlocked())
}
@@ -68,10 +124,6 @@ export const run = Command.make(
if (payload.hook_event_name === 'stop') {
return yield* Console.log(JSON.stringify({ followup_message: reason }))
}
-
- if (typeof payload.stopReason === 'string') {
- return yield* Console.log(JSON.stringify({ decision: 'block', reason }))
- }
}),
).pipe(
Command.withDescription(
diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts
index bb86968..7a7ef9d 100644
--- a/packages/uncheck/src/commands/prepare.ts
+++ b/packages/uncheck/src/commands/prepare.ts
@@ -1,20 +1,14 @@
import { randomBytes } from 'node:crypto'
-import { Console, Effect, FileSystem, Option, Path } from 'effect'
+import { Console, Effect, FileSystem, Option, Path, Result, Schedule } from 'effect'
import { Command, Flag } from 'effect/unstable/cli'
-import { userError } from '../errors'
-import { git } from '../git'
-import { detectExec, EXECS } from '../pm'
+import { platformMessage, userError } from '../errors'
+import { readTextIfExists } from '../files'
+import { git, gitLocation } from '../git'
+import { detectExec, invokes } from '../pm'
import { bold, dim, green, red } from '../style'
-import {
- cwdFlag,
- onlyFlag,
- requireFlag,
- selectionArgs,
- skipFlag,
- validateSelection,
-} from './uncheck'
+import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck'
const HOOK_COMMAND = 'uncheck staged'
const HEADER = '#!/bin/sh\n# Written by `uncheck prepare`, run it again to change the command.\n'
@@ -29,6 +23,26 @@ const EXIT = ' || exit 1'
const ENTERS = /^\(cd "([^"]*)" && (.*)\)$/
const OLD_ENTERS = /^cd "([^"]*)" && (.*)$/
+/** Git runs a hook through its shebang, where a line of `sh` would be a syntax error. */
+const OTHER_INTERPRETER = /^#!(?!.*\b(?:ba|da|k|z|a)?sh\b)/
+
+/** Invalid UTF-8 reads back as U+FFFD, so writing such a hook back would replace those bytes. */
+const NOT_TEXT = /[\0\uFFFD]/
+
+/** The comments and environment a hook sets up, such as its PATH, which the added line needs too. */
+const SETUP =
+ /^\s*(?:#|$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/
+
+/** A setup line that runs on into the next, where the added line would join it. */
+const CONTINUES =
+ /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$)/
+
+/**
+ * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends
+ * the hook line.
+ */
+const UNQUOTABLE = /["$`\\\n]/
+
function hookLine(inside: string, command: string): string {
return inside === '' ? `${command}${EXIT}` : `(cd "${inside}" && ${command})${EXIT}`
}
@@ -45,13 +59,7 @@ function ownLine(text: string): { readonly inside: string; readonly command: str
const enters = ENTERS.exec(body) ?? OLD_ENTERS.exec(body)
const command = enters?.[2] ?? body
- const runs = ['', ...EXECS.map((exec) => `${exec} `)].some((prefix) => {
- const rest = command.startsWith(prefix) ? command.slice(prefix.length) : undefined
-
- return rest === HOOK_COMMAND || rest?.startsWith(`${HOOK_COMMAND} `) === true
- })
-
- return runs ? { inside: enters?.[1] ?? '', command } : undefined
+ return invokes(command, HOOK_COMMAND) ? { inside: enters?.[1] ?? '', command } : undefined
}
// `sh` reads a script while running it, so a hook rewritten in place makes a commit already running
@@ -71,6 +79,26 @@ const replaceFile = Effect.fn(function* (file: string, content: string, mode: nu
)
})
+// A workspace install runs the `prepare` script of every package at once, all rewriting one hook.
+function locked(file: string, effect: Effect.Effect) {
+ return Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem
+ const lock = `${file}.lock`
+
+ return yield* Effect.acquireUseRelease(
+ fs.writeFileString(lock, '', { flag: 'wx' }).pipe(
+ Effect.retry({
+ while: (error) => error.reason._tag === 'AlreadyExists',
+ schedule: Schedule.spaced('20 millis'),
+ times: 500,
+ }),
+ ),
+ () => effect,
+ () => Effect.ignore(fs.remove(lock)),
+ )
+ })
+}
+
export const prepare = Command.make(
'prepare',
{
@@ -93,9 +121,7 @@ export const prepare = Command.make(
'Have the hook let a commit through when the fixes undo every staged change, which makes it empty',
),
),
- only: onlyFlag,
- required: requireFlag,
- skipped: skipFlag,
+ ...selectionFlags,
},
Effect.fn(function* ({ cwd: directory, preCommit, fix, allowEmpty, ...selection }) {
const fs = yield* FileSystem.FileSystem
@@ -111,17 +137,18 @@ export const prepare = Command.make(
const cwd = path.resolve(directory)
- const repository = yield* Effect.all([
- git(cwd, ['rev-parse', '--show-toplevel']),
- git(cwd, ['rev-parse', '--git-path', 'hooks']),
- ]).pipe(Effect.option)
+ const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.option)
// A `prepare` script runs on every install, including where there is no repository to hook.
if (Option.isNone(repository)) {
return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`)
}
- const [top, hooks] = repository.value.map((line) => line.trim())
+ const {
+ prefix,
+ paths: [hooks = ''],
+ } = repository.value
+ const inside = prefix.replace(/\/$/, '')
const exec = yield* detectExec(cwd)
const command = [
exec,
@@ -130,35 +157,48 @@ export const prepare = Command.make(
...(allowEmpty ? ['--allow-empty'] : []),
...selectionArgs(selection),
].join(' ')
-
- // Git runs hooks at the top of the working tree, so a project below it is entered first.
- const inside = path.relative(top!, yield* fs.realPath(cwd)).replaceAll('\\', '/')
const line = hookLine(inside, command)
- // husky 9 and Vite+ point core.hooksPath at generated shims that source the `h` dispatcher, which
- // exits before any line appended to a shim and runs the hook in the folder above instead.
- const configured = path.resolve(cwd, hooks!)
- const dispatched = yield* fs
- .exists(path.join(configured, 'h'))
- .pipe(Effect.orElseSucceed(() => false))
+ // husky 9 and Vite+ point core.hooksPath at a `_` folder of generated shims that source the `h`
+ // dispatcher, which exits before any line appended to a shim and runs the hook in the folder above.
+ const configured = path.resolve(cwd, hooks)
+ const dispatched =
+ path.basename(configured) === '_' &&
+ (yield* fs.exists(path.join(configured, 'h')).pipe(Effect.orElseSucceed(() => false)))
const file = path.join(dispatched ? path.dirname(configured) : configured, 'pre-commit')
const relative = path.relative(cwd, file)
const shown = relative.startsWith('..') ? file : relative
- const existing = yield* fs.readFileString(file).pipe(Effect.option)
- const next = Option.isNone(existing)
- ? `${HEADER}${line}\n`
- : rewrite(existing.value, line, inside)
- const result = Option.isNone(existing)
- ? 'created'
- : next === existing.value
- ? 'unchanged'
- : 'updated'
-
- const refused = yield* Effect.gen(function* () {
- // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would leave
- // every clone with a change to commit.
- if (result === 'unchanged') {
- return dispatched ? undefined : yield* fs.chmod(file, 0o755)
+ // A scope option turns includes off, and an included file can set a global core.hooksPath.
+ const hooksPath = (option: string) =>
+ git(cwd, ['config', option, '--includes', '--get', 'core.hooksPath'])
+ const shared = yield* hooksPath('--show-scope').pipe(
+ Effect.map((scoped) => /^(global|system)\t/.exec(scoped)?.[1]),
+ // Git before 2.26 has no --show-scope, which must not pass for an unset core.hooksPath.
+ Effect.catchIf(
+ (error) => error._tag === 'GitFailed' && error.exitCode === 129,
+ () =>
+ Effect.findFirst(['local', 'global', 'system'], (scope) =>
+ hooksPath(`--${scope}`).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ ),
+ ).pipe(Effect.map(Option.getOrUndefined)),
+ ),
+ Effect.map((scope) => (scope === 'local' ? undefined : scope)),
+ Effect.orElseSucceed(() => undefined),
+ )
+
+ const written = yield* Effect.gen(function* () {
+ if (shared !== undefined) {
+ return yield* Effect.fail(
+ `core.hooksPath is set in the ${shared} git config, so every repository runs it`,
+ )
+ }
+
+ if (UNQUOTABLE.test(inside)) {
+ return yield* Effect.fail(
+ `sh would misread the folder name ${JSON.stringify(inside)} between double quotes`,
+ )
}
// Renaming over a symlinked hook would replace the link, not the script it points to.
@@ -167,30 +207,60 @@ export const prepare = Command.make(
Effect.map((link) => path.resolve(path.dirname(file), link)),
Effect.orElseSucceed(() => file),
)
- const mode = dispatched
- ? yield* fs.stat(target).pipe(
- Effect.map((info) => info.mode & 0o7777),
- Effect.orElseSucceed(() => undefined),
- )
- : 0o755
yield* fs.makeDirectory(path.dirname(target), { recursive: true })
- yield* replaceFile(target, next, mode)
+
+ return yield* locked(
+ target,
+ Effect.gen(function* () {
+ const existing = yield* readTextIfExists(target)
+
+ if (
+ existing !== undefined &&
+ (OTHER_INTERPRETER.test(existing) || NOT_TEXT.test(existing))
+ ) {
+ return yield* Effect.fail(`it is not a shell script, have it run \`${line}\` yourself`)
+ }
+
+ const next = rewrite(existing ?? HEADER, line, inside)
+ const result =
+ existing === undefined ? 'created' : next === existing ? 'unchanged' : 'updated'
+
+ // The dispatcher runs the hook with `sh`, and flipping the mode of a committed hook would
+ // leave every clone with a change to commit.
+ if (result === 'unchanged') {
+ if (!dispatched) {
+ yield* fs.chmod(target, 0o755)
+ }
+
+ return result
+ }
+
+ const mode = dispatched
+ ? yield* fs.stat(target).pipe(
+ Effect.map((info) => info.mode & 0o7777),
+ Effect.orElseSucceed(() => undefined),
+ )
+ : 0o755
+
+ yield* replaceFile(target, next, mode)
+
+ return result
+ }),
+ )
}).pipe(
- Effect.as(undefined),
- Effect.catch((error) =>
- Effect.succeed(error.cause instanceof Error ? error.cause.message : error.message),
- ),
+ Effect.mapError((error) => (typeof error === 'string' ? error : platformMessage(error))),
+ Effect.result,
)
- // `prepare` runs on every install, so an unwritable hook says so rather than failing the install.
- if (refused !== undefined) {
+ // `prepare` runs on every install, so a hook it may not write says so rather than failing it.
+ if (Result.isFailure(written)) {
return yield* Console.log(
- `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${refused}`)}`,
+ `${red('✘')} ${bold('pre-commit')} ${dim(`${shown} not written, ${written.failure}`)}`,
)
}
- yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${result}`)}`)
+ yield* Console.log(`${green('✔')} ${bold('pre-commit')} ${dim(`${shown} ${written.success}`)}`)
yield* Console.log('')
yield* Console.log(
`${dim('The hook runs')} ${bold(command)} ${dim('before every commit, `git commit --no-verify` skips it.')}`,
@@ -198,14 +268,14 @@ export const prepare = Command.make(
}),
).pipe(
Command.withDescription(
- 'Set up git hooks, for the `prepare` script in package.json so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`',
+ 'Set up git hooks, for the `prepare` script in package.json (`postinstall` with Yarn 2+) so every clone gets them: --pre-commit writes the hook that runs `uncheck staged --fix`',
),
)
/**
- * Puts `line` into an existing hook, so running `prepare` again is idempotent: the line for this
- * directory is updated wherever it sits, duplicates of it are dropped, and everything else is kept,
- * including the commands of other packages in the same repository and whatever the user added.
+ * Puts `line` into a hook, so running `prepare` again is idempotent: the line for this directory is
+ * updated wherever it sits, duplicates of it are dropped, and everything else is kept, including the
+ * commands of other packages in the same repository and whatever the user added.
*/
function rewrite(hook: string, line: string, inside: string): string {
let placed = false
@@ -228,13 +298,28 @@ function rewrite(hook: string, line: string, inside: string): string {
return [line]
})
- const next = lines.join('\n')
if (placed) {
- return next
+ return lines.join('\n')
+ }
+
+ // Added after the commands of the hook, the line would set its exit status in their place, and
+ // would never run after an `exec` or `exit`. Before the hook's setup it would miss the PATH a GUI
+ // client lacks, and run twice with husky 8, whose sourced `_/husky.sh` runs the hook again.
+ const after = lines.reduce(
+ (last, text, index) => (ownLine(text) === undefined ? last : index + 1),
+ 0,
+ )
+ const at =
+ after > 0 ? after : lines.findIndex((text) => !SETUP.test(text) || CONTINUES.test(text))
+
+ if (at === -1) {
+ const kept = lines.join('\n')
+
+ return `${kept === '' || kept.endsWith('\n') ? kept : `${kept}\n`}${line}\n`
}
- const kept = next === '' || next.endsWith('\n') ? next : `${next}\n`
+ lines.splice(at, 0, line)
- return `${kept}${line}\n`
+ return lines.join('\n')
}
diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts
index c7421b4..7fc2c7b 100644
--- a/packages/uncheck/src/commands/staged.ts
+++ b/packages/uncheck/src/commands/staged.ts
@@ -4,36 +4,31 @@ import { Console, Effect, FileSystem, Path, Ref } from 'effect'
import { Command, Flag } from 'effect/unstable/cli'
import { userError } from '../errors'
-import { git, gitBytes, GitFailed, gitPaths } from '../git'
+import { existingFiles } from '../files'
+import { git, gitBytes, GitFailed, gitLocation, gitPaths } from '../git'
import { dim, green, listFiles, red } from '../style'
import { argvBatches } from '../tool'
-import {
- checkPaths,
- cwdFlag,
- fixFlag,
- onlyFlag,
- requireFlag,
- skipFlag,
- validateSelection,
-} from './uncheck'
+import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck'
/** What became of the unstaged hunks that were set aside while the checks ran. */
-type Unstaged = 'restored' | 'conflicted' | 'stranded'
+type Unstaged = 'restored' | 'stranded' | { readonly conflicted: ReadonlyArray }
export const staged = Command.make(
'staged',
{
cwd: cwdFlag,
- fix: fixFlag,
+ fix: fixFlag.pipe(
+ Flag.withDescription(
+ 'Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) in the staged files, then stage them. sherif only reports here: run `uncheck --fix` for its fixes',
+ ),
+ ),
allowEmpty: Flag.Boolean('allow-empty').pipe(
Flag.withDefault(false),
Flag.withDescription(
'Let the commit through when the fixes undo every staged change, which makes it empty',
),
),
- only: onlyFlag,
- required: requireFlag,
- skipped: skipFlag,
+ ...selectionFlags,
},
Effect.fn(
function* ({ cwd: directory, fix, allowEmpty, ...selection }) {
@@ -44,31 +39,42 @@ export const staged = Command.make(
const cwd = path.resolve(directory)
- // Staged deletions and submodules have nothing to check.
- const files = yield* gitPaths(cwd, [
- 'diff',
- '--cached',
- '--name-only',
- '--diff-filter=ACMR',
- '--ignore-submodules=all',
- '--relative',
- '-z',
- ]).pipe(
- Effect.catchTag('GitFailed', () => userError('`uncheck staged` needs a git repository')),
+ // Only regular files: tools follow a staged symlink to a file the commit does not hold.
+ const [listed, merging] = yield* Effect.all(
+ [
+ stagedFiles(cwd).pipe(
+ Effect.catchTag('GitFailed', () =>
+ userError('`uncheck staged` needs a git repository'),
+ ),
+ ),
+ mergeInProgress(cwd),
+ ],
+ { concurrency: 'unbounded' },
)
+ // Fixing what a merge takes from the other side would commit changes neither side made.
+ const notTheirs = merging ? new Set(yield* stagedFiles(cwd, 'MERGE_HEAD')) : undefined
+ const files = notTheirs === undefined ? listed : listed.filter((file) => notTheirs.has(file))
yield* Console.log(dim(`uncheck staged in ${cwd}`))
if (files.length === 0) {
- return yield* Console.log(`${dim('○')} nothing to check, no staged files`)
+ const reason =
+ listed.length > 0
+ ? 'every staged file comes from the branch being merged in'
+ : 'no staged files'
+
+ return yield* Console.log(`${dim('○')} nothing to check, ${reason}`)
}
- const [prefix = '', folder = ''] = (yield* git(cwd, [
- 'rev-parse',
- '--show-prefix',
- '--git-path',
- 'uncheck-unstaged',
- ])).split('\n')
+ const [
+ {
+ prefix,
+ paths: [folder = '', indexLock = ''],
+ },
+ unstaged,
+ ] = yield* Effect.all([gitLocation(cwd, ['uncheck-unstaged', 'index.lock']), rawDiff(cwd)], {
+ concurrency: 'unbounded',
+ })
const saved = path.resolve(cwd, folder)
const aside = { cwd, saved, prefix }
@@ -77,88 +83,79 @@ export const staged = Command.make(
return yield* leftover(saved)
}
- const bases = yield* partiallyStaged(cwd, files)
- const partial = [...bases.keys()]
- const before = yield* writeTree(cwd)
+ const partial = yield* partiallyStaged(unstaged, files)
const outcome = yield* Ref.make('restored')
const { failure, empty } = yield* Effect.scoped(
Effect.gen(function* () {
if (partial.length > 0) {
- yield* Effect.acquireRelease(setAside(aside, partial), () =>
- putBack(aside, files, bases, before).pipe(
+ const before = yield* writeTree(cwd)
+
+ yield* Effect.acquireRelease(setAside(aside, partial), (copies) =>
+ putBack(aside, files, copies, before).pipe(
Effect.flatMap((result) => Ref.set(outcome, result)),
),
)
}
- const failed = yield* checkPaths(files, {
+ const failure = yield* checkPaths(files, {
...selection,
cwd,
fix,
- allowUnmatched: true,
- }).pipe(
- Effect.map(() => undefined),
- Effect.catchTag('CheckFailed', (error) => Effect.succeed(error)),
- )
-
- if (fix) {
- yield* Effect.forEach(
- argvBatches(files),
- (batch) => git(cwd, ['add', '--', ...batch]),
- { discard: true },
- )
+ literal: true,
+ staged: true,
+ }).pipe(Effect.catchTag('CheckFailed', Effect.succeed))
- // `git commit ` runs the hook on a temporary index, and the index it leaves
- // behind (index.lock until then) needs the fixes too, or it would hold their revert.
- const active = process.env.GIT_INDEX_FILE
-
- if (active?.endsWith('.lock') === true) {
- const lock = path.resolve(
- cwd,
- (yield* git(cwd, ['rev-parse', '--git-path', 'index.lock'])).trim(),
- )
-
- if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) {
- yield* Effect.forEach(
- argvBatches(files),
- (batch) => git(cwd, ['add', '--', ...batch], { GIT_INDEX_FILE: lock }),
- { discard: true },
- )
- }
- }
+ if (!fix) {
+ return { failure, empty: false }
+ }
+
+ const changed = (yield* Effect.forEach(argvBatches(files), (batch) =>
+ gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', '--', ...batch]),
+ )).flat()
- const after = yield* writeTree(cwd)
+ if (changed.length === 0) {
+ return { failure, empty: false }
+ }
- if (after !== before) {
- const fixed = yield* gitPaths(cwd, [
- 'diff-tree',
- '-r',
- '--name-only',
- '--relative',
- '-z',
- before,
- after,
- ])
+ // `git add` refuses a path outside a sparse checkout even when it is on disk.
+ const stage = (env?: Readonly>) =>
+ gitEach(cwd, ['update-index'], changed, env)
- yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(fixed)}`)
- }
+ yield* stage()
- return {
- failure: failed,
- empty: after === (yield* headTree(cwd)) && !(yield* merging(cwd)),
+ // `git commit ` runs the hook on a temporary index, and the index it leaves
+ // behind (index.lock until then) needs the fixes too, or it would hold their revert.
+ const active = process.env.GIT_INDEX_FILE
+
+ if (active?.endsWith('.lock') === true) {
+ const lock = path.resolve(cwd, indexLock)
+
+ if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) {
+ yield* stage({ GIT_INDEX_FILE: lock })
}
}
- return { failure: failed, empty: false }
+ yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(changed)}`)
+
+ // git records a merge commit even when its tree is the one HEAD already has.
+ if (merging) {
+ return { failure, empty: false }
+ }
+
+ const [after, head] = yield* Effect.all([writeTree(cwd), headTree(cwd)], {
+ concurrency: 'unbounded',
+ })
+
+ return { failure, empty: after === head }
}),
)
const unstagedOutcome = yield* Ref.get(outcome)
- if (unstagedOutcome === 'conflicted') {
+ if (typeof unstagedOutcome === 'object') {
return yield* userError(
- `The fixes conflict with the unstaged changes of ${listFiles(partial)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`,
+ `The fixes conflict with the unstaged changes of ${listFiles(unstagedOutcome.conflicted)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`,
)
}
@@ -178,7 +175,7 @@ export const staged = Command.make(
return yield* Effect.fail(failure)
}
},
- Effect.catchTag('GitFailed', (error) => userError(`${error.command} failed: ${error.stderr}`)),
+ Effect.catchTag('GitFailed', (error) => userError(error.summary)),
),
).pipe(
Command.withDescription(
@@ -186,16 +183,14 @@ export const staged = Command.make(
),
)
-const writeTree = (cwd: string) => Effect.map(git(cwd, ['write-tree']), (sha) => sha.trim())
+const writeTree = (cwd: string) => git(cwd, ['write-tree'])
const headTree = (cwd: string) =>
git(cwd, ['rev-parse', '-q', '--verify', 'HEAD^{tree}']).pipe(
- Effect.map((sha) => sha.trim()),
Effect.catchTag('GitFailed', () => Effect.succeed(undefined)),
)
-// git records a merge commit even when its tree is the one HEAD already has.
-const merging = (cwd: string) =>
+const mergeInProgress = (cwd: string) =>
git(cwd, ['rev-parse', '-q', '--verify', 'MERGE_HEAD']).pipe(
Effect.as(true),
Effect.catchTag('GitFailed', () => Effect.succeed(false)),
@@ -208,26 +203,45 @@ const leftover = (saved: string) =>
const REGULAR_FILE_MODE = /^100(?:644|755)$/
-const partiallyStaged = Effect.fn(function* (cwd: string, files: ReadonlyArray) {
- const entries = (yield* git(cwd, [
- 'diff',
- '--raw',
- '--no-abbrev',
- '--no-renames',
- '--relative',
- '-z',
- ])).split('\0')
- const partial = new Map()
- const odd: string[] = []
+const rawDiff = (cwd: string, ...args: ReadonlyArray) =>
+ Effect.map(
+ git(cwd, [
+ 'diff',
+ '--raw',
+ '--no-renames',
+ '--ignore-submodules=all',
+ '--relative',
+ '-z',
+ ...args,
+ ]),
+ (output) => {
+ const fields = output.split('\0')
+
+ return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => {
+ const [fromMode = '', toMode = ''] = fields[index * 2]!.slice(1).split(' ')
+
+ return { file: fields[index * 2 + 1]!, fromMode, toMode }
+ })
+ },
+ )
- for (let index = 0; index + 1 < entries.length; index += 2) {
- const file = entries[index + 1]!
+const stagedFiles = (cwd: string, ...against: ReadonlyArray) =>
+ Effect.map(rawDiff(cwd, '--cached', '--diff-filter=ACMT', ...against), (entries) =>
+ entries.filter((entry) => REGULAR_FILE_MODE.test(entry.toMode)).map((entry) => entry.file),
+ )
- if (files.includes(file)) {
- const [indexMode = '', fileMode = '', indexBlob = ''] = entries[index]!.slice(1).split(' ')
+const partiallyStaged = Effect.fn(function* (
+ unstaged: Effect.Success>,
+ files: ReadonlyArray,
+) {
+ const staged = new Set(files)
+ const partial: string[] = []
+ const odd: string[] = []
- if (REGULAR_FILE_MODE.test(indexMode) && REGULAR_FILE_MODE.test(fileMode)) {
- partial.set(file, indexBlob)
+ for (const { file, fromMode, toMode } of unstaged) {
+ if (staged.has(file)) {
+ if (REGULAR_FILE_MODE.test(fromMode) && REGULAR_FILE_MODE.test(toMode)) {
+ partial.push(file)
} else {
odd.push(file)
}
@@ -243,23 +257,48 @@ const partiallyStaged = Effect.fn(function* (cwd: string, files: ReadonlyArray) {
- const path = yield* Path.Path
+interface SetAside {
+ readonly file: string
+ readonly target: string
+ readonly copy: string
+ readonly base: string
+}
- return files.map((file) => [path.join(cwd, file), path.join(saved, prefix, file)] as const)
-})
+/** Runs `git -- ` in batches; with no files, some commands would act on every path. */
+function gitEach(
+ cwd: string,
+ args: ReadonlyArray,
+ files: ReadonlyArray,
+ env?: Readonly>,
+) {
+ return Effect.forEach(
+ files.length === 0 ? [] : argvBatches(files),
+ (batch) => git(cwd, [...args, '--', ...batch], env),
+ { discard: true },
+ )
+}
-const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray) {
+const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
- const { cwd, saved } = aside
- const copies = yield* copiesOf(aside, files)
+ const copies = files.map((file) => ({
+ file,
+ target: path.join(cwd, file),
+ copy: path.join(saved, prefix, file),
+ }))
+ const restore = Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), {
+ discard: true,
+ })
// Creating the folder, not only finding it missing, is what claims it from a parallel run.
yield* fs.makeDirectory(saved).pipe(
@@ -270,62 +309,66 @@ const setAside = Effect.fn(function* (aside: Aside, files: ReadonlyArray
)
yield* Effect.forEach(
copies,
- ([file, copy]) =>
+ ({ target, copy }) =>
fs
.makeDirectory(path.dirname(copy), { recursive: true })
- .pipe(Effect.andThen(fs.copyFile(file, copy))),
+ .pipe(Effect.andThen(fs.copyFile(target, copy))),
{ discard: true },
).pipe(Effect.tapError(() => Effect.ignore(fs.remove(saved, { recursive: true }))))
- yield* Effect.forEach(argvBatches(files), (batch) => git(cwd, ['checkout', '--', ...batch]), {
- discard: true,
- }).pipe(
+ // Plumbing, since `git checkout` runs the post-checkout hook and fails when it does. The index
+ // blob is no merge base: git leaves a CRLF blob alone where hash-object normalizes it.
+ const ids = yield* Effect.forEach(argvBatches(files), (batch) =>
+ git(cwd, ['checkout-index', '-f', '--', ...batch]).pipe(
+ Effect.andThen(git(cwd, [...STORE, '--', ...batch])),
+ ),
+ ).pipe(
Effect.tapError(() =>
- Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true }).pipe(
- Effect.andThen(fs.remove(saved, { recursive: true })),
- Effect.ignore,
- ),
+ restore.pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore),
),
)
yield* Console.log(
dim(`○ unstaged changes of ${listFiles(files)} set aside until the checks finish`),
)
+
+ const bases = ids.flatMap((output) => output.split('\n'))
+
+ return copies.map((entry, index): SetAside => ({ ...entry, base: bases[index]! }))
})
/** Runs as a finalizer, so it must never fail: what it cannot do is reported and returned. */
const putBack = Effect.fn(function* (
aside: Aside,
files: ReadonlyArray,
- bases: ReadonlyMap,
+ copies: ReadonlyArray,
before: string,
) {
const fs = yield* FileSystem.FileSystem
const { cwd, saved } = aside
- const partial = [...bases.keys()]
+ const partial = copies.map(({ file }) => file)
const result = yield* Effect.gen(function* () {
- const copies = yield* copiesOf(aside, partial)
- const merged = yield* Effect.forEach(partial, (file, index) =>
- merge(cwd, file, copies[index]![1], bases.get(file)!),
- )
+ const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry, before), {
+ concurrency: 4,
+ })
+ const conflicted = partial.filter((_, index) => !merged[index])
- if (merged.every(Boolean)) {
+ if (conflicted.length === 0) {
yield* Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`))
return 'restored' as const
}
- yield* Effect.forEach(
- argvBatches(files),
- (batch) => git(cwd, ['checkout', before, '--', ...batch]),
- { discard: true },
- )
- yield* Effect.forEach(copies, ([file, copy]) => fs.copyFile(copy, file), { discard: true })
+ yield* gitEach(cwd, ['reset', '-q', before], files)
+ // checkout-index fails on a file a sparse checkout leaves out, which no check could change.
+ yield* gitEach(cwd, ['checkout-index', '-f'], yield* existingFiles(files, cwd))
+ yield* Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), {
+ discard: true,
+ })
- return 'conflicted' as const
+ return { conflicted }
}).pipe(
Effect.catch((error) => {
- const reason =
- error instanceof GitFailed ? `${error.command} failed, ${error.stderr}` : String(error)
+ const reason = error instanceof GitFailed ? error.summary : String(error)
return Console.log(
`${red('✘')} could not put back the unstaged changes of ${listFiles(partial)}: ${reason}\n their unstaged versions are in ${saved}, at their paths from the top of the repository: copy back what your files are missing and delete the folder`,
@@ -342,12 +385,14 @@ const putBack = Effect.fn(function* (
// Merges what git stores: a formatter rewriting line endings would conflict with every line of a
// raw merge. `apply --3way` would run the repository's merge drivers and rerere; `merge-file` does not.
-const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base: string) {
+const merge = Effect.fn(function* (
+ { cwd, prefix }: Aside,
+ { file, target, copy, base }: SetAside,
+ before: string,
+) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
- const target = path.join(cwd, file)
- const store = (from: string) =>
- Effect.map(git(cwd, ['hash-object', '-w', `--path=${file}`, '--', from]), (id) => id.trim())
+ const store = (from: string) => git(cwd, [...STORE, `--path=${file}`, '--', from])
const checked = yield* store(target)
if (checked === base) {
@@ -355,19 +400,36 @@ const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base
return true
}
+ // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it
+ // back through the filters would turn every line ending of the file to LF.
+ const [staged = '', stored = ''] = (yield* git(cwd, [
+ 'rev-parse',
+ `:0:${prefix}${file}`,
+ `${before}:${prefix}${file}`,
+ ])).split('\n')
+
+ if (stored !== base && staged !== checked) {
+ return false
+ }
+
const temp = yield* fs.makeTempDirectory()
const result = path.join(temp, 'result')
const original = path.join(temp, 'base')
const fixed = path.join(temp, 'fixed')
return yield* Effect.gen(function* () {
- for (const [to, id] of [
- [result, yield* store(copy)],
- [original, base],
- [fixed, checked],
- ] as const) {
- yield* fs.writeFile(to, yield* gitBytes(cwd, ['cat-file', 'blob', id]))
- }
+ const unstaged = yield* store(copy)
+
+ yield* Effect.forEach(
+ [
+ [result, unstaged],
+ [original, base],
+ [fixed, checked],
+ ] as const,
+ ([to, id]) =>
+ Effect.flatMap(gitBytes(cwd, ['cat-file', 'blob', id]), (bytes) => fs.writeFile(to, bytes)),
+ { concurrency: 'unbounded', discard: true },
+ )
const clean = yield* git(cwd, ['merge-file', '--quiet', result, original, fixed]).pipe(
Effect.as(true),
@@ -379,11 +441,12 @@ const merge = Effect.fn(function* (cwd: string, file: string, copy: string, base
)
if (clean) {
- const id = (yield* git(cwd, ['hash-object', '-w', '--no-filters', '--', result])).trim()
+ const id = yield* git(cwd, ['hash-object', '-w', '--no-filters', '--', result])
+ // Unlike hash-object, cat-file takes --path from the top of the repository.
yield* fs.writeFile(
target,
- yield* gitBytes(cwd, ['cat-file', '--filters', `--path=${file}`, id]),
+ yield* gitBytes(cwd, ['cat-file', '--filters', `--path=${prefix}${file}`, id]),
)
yield* fs.chmod(target, (yield* fs.stat(copy)).mode)
}
diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts
index b9382cb..6635846 100644
--- a/packages/uncheck/src/commands/uncheck.ts
+++ b/packages/uncheck/src/commands/uncheck.ts
@@ -1,7 +1,8 @@
+import { availableParallelism } from 'node:os'
import path from 'node:path'
import process from 'node:process'
-import { Console, Duration, Effect } from 'effect'
+import { Console, Duration, Effect, Fiber, Semaphore } from 'effect'
import type { CliError } from 'effect/unstable/cli'
import { Argument, Command, Flag } from 'effect/unstable/cli'
@@ -9,10 +10,10 @@ import { oxfmt } from '../checks/oxfmt'
import { oxlint } from '../checks/oxlint'
import { sherif } from '../checks/sherif'
import { tsc } from '../checks/tsc'
-import { CheckFailed, userError } from '../errors'
-import { listProjectFiles, resolvePaths } from '../files'
+import { CheckFailed, platformMessage, userError } from '../errors'
+import { existingFiles, listProjectFiles, resolvePaths } from '../files'
import { bold, dim, green, listFiles, red } from '../style'
-import { execute } from '../tool'
+import { captureLines, execute } from '../tool'
import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types'
const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc]
@@ -31,25 +32,27 @@ export const fixFlag = Flag.Boolean('fix').pipe(
const CHECK_NAMES = CHECKS.map((check) => check.name)
-export const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe(
+const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe(
Flag.atLeast(0),
Flag.withDescription(
'Require a check: fail when it cannot run instead of skipping it. Repeatable',
),
)
-export const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe(
+const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe(
Flag.atLeast(0),
Flag.withDescription('Skip a check even when it could run. Repeatable'),
)
-export const onlyFlag = Flag.Literals('only', CHECK_NAMES).pipe(
+const onlyFlag = Flag.Literals('only', CHECK_NAMES).pipe(
Flag.atLeast(0),
Flag.withDescription(
'Run only this check and skip the others, for example the fast ones in a hook. Repeatable',
),
)
+export const selectionFlags = { only: onlyFlag, required: requireFlag, skipped: skipFlag }
+
export interface CheckSelection {
readonly only: ReadonlyArray
readonly required: ReadonlyArray
@@ -59,7 +62,11 @@ export interface CheckSelection {
export interface RunSettings extends CheckSelection {
readonly cwd: string
readonly fix: boolean
- readonly allowUnmatched: boolean
+ readonly allowUnmatched?: boolean
+ /** The paths are file names from git: never patterns, and a run where none has anything to check passes. */
+ readonly literal?: boolean
+ /** Fixes are staged again, so only the ones that stay within the given files apply. */
+ readonly staged?: boolean
}
export function selectionArgs({ only, required, skipped }: CheckSelection): ReadonlyArray {
@@ -125,14 +132,18 @@ export const checkPaths = Effect.fn(function* (
paths: ReadonlyArray,
settings: RunSettings,
) {
- const { fix, only, required, skipped, allowUnmatched } = settings
- const cwd = path.resolve(settings.cwd)
+ const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings
+ const appliesFixes = (fixes: Check['fixes']) =>
+ settings.staged === true ? fixes === 'files' : fixes !== false
+ const { cwd } = settings
const projectFiles = yield* Effect.cached(listProjectFiles(cwd))
let files: ReadonlyArray | undefined
if (paths.length > 0) {
- const resolved = yield* resolvePaths(paths, cwd, projectFiles)
+ const resolved = literal
+ ? { files: yield* existingFiles(paths, cwd), unmatched: [] }
+ : yield* resolvePaths(paths, cwd, projectFiles)
if (resolved.unmatched.length > 0 && !allowUnmatched) {
return yield* userError(
@@ -149,7 +160,7 @@ export const checkPaths = Effect.fn(function* (
}
const plans = yield* Effect.all(
- CHECKS.map(({ name, plan }) => {
+ CHECKS.map(({ name, fixes, plan }) => {
const exclusion = skipped.includes(name)
? `disabled with --skip=${name}`
: only.length > 0 && !only.includes(name)
@@ -160,7 +171,7 @@ export const checkPaths = Effect.fn(function* (
return Effect.succeed({ name, status: 'skipped', reason: exclusion })
}
- return plan({ cwd, fix, files, projectFiles }).pipe(
+ return plan({ cwd, fix: fix && appliesFixes(fixes), files, projectFiles }).pipe(
Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })),
Effect.catchTag('NothingToCheck', ({ reason }) =>
Effect.succeed({
@@ -187,6 +198,10 @@ export const checkPaths = Effect.fn(function* (
if (ran.length === 0) {
const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ')
+ if (files !== undefined && (allowUnmatched || literal)) {
+ return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`)
+ }
+
yield* Console.log(`${red('✘')} nothing to check: ${reasons}`)
return yield* Effect.fail(new CheckFailed({ outcomes }))
}
@@ -200,14 +215,14 @@ export const checkPaths = Effect.fn(function* (
.filter(
(outcome) =>
outcome.reason === undefined &&
- CHECKS.find((check) => check.name === outcome.name)?.fixes,
+ appliesFixes(CHECKS.find((check) => check.name === outcome.name)!.fixes),
)
.map((outcome) => outcome.name)
.join(', ')
.replace(/, ([^,]+)$/, ' and $1')
if (!fix && fixable !== '') {
- yield* Console.log(dim(` run \`uncheck --fix\` to apply ${fixable} fixes`))
+ yield* Console.log(dim(` rerun with \`--fix\` to apply ${fixable} fixes`))
}
return yield* Effect.fail(new CheckFailed({ outcomes }))
@@ -228,16 +243,7 @@ const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) {
return plan
}
- const [duration, exitCodes] = yield* Effect.timed(
- Effect.forEach(plan.commands, (invocation) => {
- const { bin, args, files } = invocation
- const shown = files === undefined ? args : [...args, listFiles(files)]
-
- return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim(shown.join(' '))}`.trimEnd()).pipe(
- Effect.flatMap(() => execute(invocation, cwd)),
- )
- }),
- )
+ const [duration, exitCodes] = yield* Effect.timed(runCommands(plan.commands, cwd))
const failed = exitCodes.some((exitCode) => exitCode !== 0)
const ms = Duration.toMillis(duration)
@@ -254,6 +260,48 @@ const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) {
return outcome
})
+/** Each process of a type checker can take hundreds of megabytes, so only a few run at once. */
+const MAX_PARALLEL = Math.min(4, availableParallelism())
+
+const runCommands = Effect.fn(function* (commands: ReadonlyArray, cwd: string) {
+ const parallel = commands.filter((command) => command.parallel === true)
+
+ if (parallel.length < 2) {
+ return yield* Effect.forEach(commands, (command) => runCommand(command, cwd))
+ }
+
+ const exitCodes = yield* Effect.forEach(
+ commands.filter((command) => command.parallel !== true),
+ (command) => runCommand(command, cwd),
+ )
+ const semaphore = yield* Semaphore.make(MAX_PARALLEL)
+ const fibers = yield* Effect.forEach(parallel, (command) =>
+ Effect.forkChild(semaphore.withPermits(1)(captureLines(runCommand(command, cwd)))),
+ )
+
+ for (const fiber of fibers) {
+ const [exitCode, lines] = yield* Fiber.join(fiber)
+
+ yield* Effect.forEach(lines, (line) => Console.log(line), { discard: true })
+ exitCodes.push(exitCode)
+ }
+
+ return exitCodes
+})
+
+function runCommand(command: CheckCommand, cwd: string) {
+ const { bin, args, files } = command
+ const shown = files === undefined ? args : [...args, listFiles(files)]
+
+ return Console.log(`${dim('▶')} ${bold(bin.name)} ${dim(shown.join(' '))}`.trimEnd()).pipe(
+ Effect.andThen(execute(command, cwd)),
+ // A tool killed by a signal (say by the OOM killer) fails `exitCode` with a PlatformError, not a code.
+ Effect.catchTag('PlatformError', (error) =>
+ Console.log(red(platformMessage(error))).pipe(Effect.as(1)),
+ ),
+ )
+}
+
export const uncheck = Command.make(
'uncheck',
{
@@ -265,13 +313,11 @@ export const uncheck = Command.make(
'Run with whatever matched instead of failing when a given path or pattern matches no file',
),
),
- only: onlyFlag,
- required: requireFlag,
- skipped: skipFlag,
+ ...selectionFlags,
paths: Argument.String('paths').pipe(
Argument.variadic(),
Argument.withDescription(
- 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to everything under the current directory.',
+ 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to everything under the current directory, which exclusions on their own apply to.',
),
),
},
diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts
index b64dbad..7fd2a55 100644
--- a/packages/uncheck/src/errors.ts
+++ b/packages/uncheck/src/errors.ts
@@ -1,3 +1,4 @@
+import type { PlatformError } from 'effect'
import { Data, Effect } from 'effect'
import { CliError } from 'effect/unstable/cli'
@@ -20,3 +21,7 @@ export class StopBlocked extends Data.TaggedError('StopBlocked') {}
export function userError(userMessage: string): Effect.Effect {
return Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage }))
}
+
+export function platformMessage(error: PlatformError.PlatformError): string {
+ return error.cause instanceof Error ? error.cause.message : error.message
+}
diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts
index 59df2b1..2c84ce8 100644
--- a/packages/uncheck/src/files.ts
+++ b/packages/uncheck/src/files.ts
@@ -1,14 +1,14 @@
-import { posix } from 'node:path'
+import { existsSync } from 'node:fs'
-import type { PlatformError } from 'effect'
import { Effect, FileSystem, Path, Predicate } from 'effect'
import type { ChildProcessSpawner } from 'effect/unstable/process'
+import { Minimatch } from 'minimatch'
import { gitPaths } from './git'
export type ProjectFiles = Effect.Effect<
ReadonlyArray,
- PlatformError.PlatformError,
+ never,
FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner
>
@@ -20,7 +20,8 @@ export type ProjectFiles = Effect.Effect<
*/
export function listProjectFiles(cwd: string): ProjectFiles {
return gitPaths(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe(
- Effect.map((files) => files.filter((file) => !file.split('/').includes('node_modules'))),
+ // git lists a linked node_modules as one file, which a `node_modules/` ignore rule misses.
+ Effect.map((files) => files.filter((file) => !/(?:^|\/)node_modules(?:\/|$)/.test(file))),
Effect.catch(() => walk(cwd)),
Effect.map((files) => [...files].sort()),
)
@@ -38,20 +39,18 @@ export function listChangedFiles(
never,
ChildProcessSpawner.ChildProcessSpawner
> {
- return Effect.all([
- gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']),
- gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']),
- ]).pipe(
+ return Effect.all(
+ [
+ gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']),
+ gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']),
+ ],
+ { concurrency: 'unbounded' },
+ ).pipe(
Effect.map(([tracked, untracked]) => [...new Set([...tracked, ...untracked])].sort()),
Effect.orElseSucceed(() => undefined),
)
}
-export interface ResolvedPaths {
- readonly files: ReadonlyArray
- readonly unmatched: ReadonlyArray
-}
-
/** Turns the given paths into the project files they name, so every tool checks the same files. */
export const resolvePaths = Effect.fn(function* (
patterns: ReadonlyArray,
@@ -63,15 +62,12 @@ export const resolvePaths = Effect.fn(function* (
const relative = (pattern: string) =>
path.relative(cwd, path.resolve(cwd, pattern)).replaceAll('\\', '/')
+ const includes = patterns.filter((pattern) => !pattern.startsWith('!'))
const matched = new Set()
const unmatched: string[] = []
let universe: ReadonlyArray | undefined
- for (const pattern of patterns) {
- if (pattern.startsWith('!')) {
- continue
- }
-
+ for (const pattern of includes.length > 0 ? includes : ['.']) {
const target = relative(pattern)
// An existing path is taken as it is, so `app/[id].ts` names that file rather than a glob.
@@ -85,53 +81,46 @@ export const resolvePaths = Effect.fn(function* (
continue
}
- if (kind === 'Directory') {
- universe ??= yield* projectFiles
- const inside =
- target === '' ? universe : universe.filter((file) => file.startsWith(`${target}/`))
-
- for (const file of inside) {
- matched.add(file)
- }
-
- if (inside.length === 0) {
- unmatched.push(pattern)
- }
-
+ if (kind !== 'Directory' && !GLOB_CHARACTERS.test(target)) {
+ unmatched.push(pattern)
continue
}
- if (GLOB_CHARACTERS.test(target)) {
- universe ??= yield* projectFiles
- const hits = universe.filter((file) => posix.matchesGlob(file, target))
-
- for (const hit of hits) {
- matched.add(hit)
- }
-
- if (hits.length === 0) {
- unmatched.push(pattern)
- }
+ universe ??= yield* projectFiles
+ const hits = universe.filter(
+ kind === 'Directory'
+ ? (file) => target === '' || file.startsWith(`${target}/`)
+ : glob(target),
+ )
- continue
+ for (const hit of hits) {
+ matched.add(hit)
}
- unmatched.push(pattern)
+ if (hits.length === 0) {
+ unmatched.push(pattern)
+ }
}
const excludes = patterns
.filter((pattern) => pattern.startsWith('!'))
- .map((pattern) => relative(pattern.slice(1)))
- const excluded = (file: string) =>
- excludes.some(
- (exclude) =>
- file === exclude || file.startsWith(`${exclude}/`) || posix.matchesGlob(file, exclude),
- )
+ .map((pattern) => {
+ const target = relative(pattern.slice(1))
- const files = yield* Effect.filter(
- [...matched].filter((file) => !excluded(file)),
- (file) => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)),
- { concurrency: 'unbounded' },
+ if (target === '') {
+ return () => true
+ }
+
+ const matches = glob(target)
+
+ return (file: string) => file === target || file.startsWith(`${target}/`) || matches(file)
+ })
+
+ // One fiber per file costs far more than the check itself on a large project.
+ const files = yield* Effect.sync(() =>
+ [...matched].filter(
+ (file) => !excludes.some((excluded) => excluded(file)) && existsSync(path.resolve(cwd, file)),
+ ),
)
return { files: files.sort(), unmatched }
@@ -139,6 +128,38 @@ export const resolvePaths = Effect.fn(function* (
const GLOB_CHARACTERS = /[*?[\]{}()]/
+/** Dot files match too, as they do for oxfmt and for a directory given as it is. */
+function glob(pattern: string): (file: string) => boolean {
+ // Level 2 drops the `.` of `src/{.,deep}/*.ts` as path.matchesGlob does.
+ const matcher = new Minimatch(pattern, {
+ dot: true,
+ nonegate: true,
+ nocomment: true,
+ optimizationLevel: 2,
+ platform: 'linux',
+ })
+
+ return (file) => matcher.match(file)
+}
+
+export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) {
+ const fs = yield* FileSystem.FileSystem
+ const path = yield* Path.Path
+
+ // A concurrent `Effect.filter` keeps files in the order their checks finish, not the given one.
+ const isFile = yield* Effect.forEach(
+ files,
+ (file) =>
+ fs.stat(path.resolve(cwd, file)).pipe(
+ Effect.map((info) => info.type === 'File'),
+ Effect.orElseSucceed(() => false),
+ ),
+ { concurrency: 64 },
+ )
+
+ return files.filter((_, index) => isFile[index])
+})
+
export function ancestors(path: Path.Path, from: string): string[] {
const dirs = [path.resolve(from)]
@@ -173,10 +194,8 @@ const walk = Effect.fn(function* (cwd: string) {
const root = path.resolve(cwd)
const found: string[] = []
- const visit = Effect.fn(function* (
- dir: string,
- ): Effect.fn.Return {
- const names = yield* fs.readDirectory(dir)
+ const visit = Effect.fn(function* (dir: string): Effect.fn.Return {
+ const names = yield* fs.readDirectory(dir).pipe(Effect.orElseSucceed(() => []))
yield* Effect.forEach(names, (name) => visitEntry(dir, name), {
concurrency: 16,
@@ -184,10 +203,7 @@ const walk = Effect.fn(function* (cwd: string) {
})
})
- const visitEntry = Effect.fn(function* (
- dir: string,
- name: string,
- ): Effect.fn.Return {
+ const visitEntry = Effect.fn(function* (dir: string, name: string): Effect.fn.Return {
if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) {
return
}
@@ -196,7 +212,15 @@ const walk = Effect.fn(function* (cwd: string) {
const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined))
if (info?.type === 'Directory') {
- yield* visit(full)
+ // `stat` follows links, and a link back up the tree would be walked forever.
+ const linked = yield* fs.readLink(full).pipe(
+ Effect.as(true),
+ Effect.orElseSucceed(() => false),
+ )
+
+ if (!linked) {
+ yield* visit(full)
+ }
} else if (info?.type === 'File') {
found.push(path.relative(root, full).replaceAll('\\', '/'))
}
@@ -206,3 +230,11 @@ const walk = Effect.fn(function* (cwd: string) {
return found
})
+
+export const readTextIfExists = Effect.fn(function* (file: string) {
+ const fs = yield* FileSystem.FileSystem
+
+ return yield* fs
+ .readFileString(file)
+ .pipe(Effect.catchReason('PlatformError', 'NotFound', () => Effect.succeed(undefined)))
+})
diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts
index f6c628f..d36274a 100644
--- a/packages/uncheck/src/git.ts
+++ b/packages/uncheck/src/git.ts
@@ -18,6 +18,10 @@ export class GitFailed extends Data.TaggedError('GitFailed')<{
return `git ${shown.join(' ')}`
}
+
+ get summary(): string {
+ return `${this.command} failed: ${this.stderr}`
+ }
}
/**
@@ -68,10 +72,33 @@ export function git(
args: ReadonlyArray,
env?: Readonly>,
) {
- return Effect.map(gitBytes(cwd, args, env), (output) => output.toString())
+ return Effect.map(gitBytes(cwd, args, env), (output) => output.toString().replace(/\n$/, ''))
}
/** `git` for listings made with `-z`: the NUL-separated paths it printed. */
export function gitPaths(cwd: string, args: ReadonlyArray) {
return Effect.map(git(cwd, args), (output) => output.split('\0').filter((entry) => entry !== ''))
}
+
+/**
+ * The folder of `cwd` below the top of the working tree, `''` or ending in `/`, and where git keeps
+ * each of `names`. Fails outside a working tree.
+ */
+export function gitLocation(cwd: string, names: ReadonlyArray = []) {
+ const args = [
+ 'rev-parse',
+ '--is-inside-work-tree',
+ '--show-prefix',
+ ...names.flatMap((name) => ['--git-path', name]),
+ ]
+
+ return Effect.flatMap(git(cwd, args), (output) => {
+ // A newline in the prefix shifts the lines git prints, so they are counted from both ends.
+ const [inside, ...lines] = output.split('\n')
+ const paths = lines.splice(lines.length - names.length)
+
+ return inside === 'true'
+ ? Effect.succeed({ prefix: lines.join('\n'), paths })
+ : Effect.fail(new GitFailed({ args, exitCode: 128, stderr: 'not inside a work tree' }))
+ })
+}
diff --git a/packages/uncheck/src/pm.ts b/packages/uncheck/src/pm.ts
index faac987..4328f90 100644
--- a/packages/uncheck/src/pm.ts
+++ b/packages/uncheck/src/pm.ts
@@ -2,52 +2,83 @@ import { Effect, FileSystem, Option, Path } from 'effect'
import { ancestors, readJson } from './files'
-const EXEC_BY_PACKAGE_MANAGER: Readonly> = {
- pnpm: 'pnpm exec',
- yarn: 'yarn',
- bun: 'bunx',
- npm: 'npx',
-}
+// Hooks never get a terminal to ask in, so plain `npx` and `bunx` would download and run the latest
+// release whenever the project has none, and Yarn 1 wraps a run in lines of its own on stdout, where
+// agents expect nothing but their JSON.
+const EXEC_BY_PACKAGE_MANAGER: ReadonlyMap = new Map([
+ ['pnpm', 'pnpm exec'],
+ ['yarn', 'yarn run --silent'],
+ ['bun', 'bunx --no-install'],
+ ['npm', 'npx --no'],
+])
+
+const YARN_TOP_LEVEL = 'yarn run -T --silent'
-const EXEC_BY_LOCKFILE: ReadonlyArray = [
- ['pnpm-lock.yaml', 'pnpm exec'],
+const PACKAGE_MANAGER_BY_LOCKFILE = [
+ ['pnpm-lock.yaml', 'pnpm'],
['yarn.lock', 'yarn'],
- ['bun.lock', 'bunx'],
- ['bun.lockb', 'bunx'],
- ['package-lock.json', 'npx'],
-]
+ ['bun.lock', 'bun'],
+ ['bun.lockb', 'bun'],
+ ['package-lock.json', 'npm'],
+] as const
-/** Every prefix `detectExec` can return, so a generated command line can be recognised again. */
-export const EXECS: ReadonlyArray = [
- ...new Set([
- ...Object.values(EXEC_BY_PACKAGE_MANAGER),
- ...EXEC_BY_LOCKFILE.map(([, exec]) => exec),
- ]),
+/** Every prefix uncheck writes or once wrote, so a generated command line can be recognised again. */
+const EXECS: ReadonlyArray = [
+ ...EXEC_BY_PACKAGE_MANAGER.values(),
+ YARN_TOP_LEVEL,
+ 'yarn',
+ 'bunx',
+ 'npx',
]
+const FLAGS = /^(?: --[\w=./@+-]+)*$/
+
+/** Whether `text` runs `command`, directly or through a package manager, with nothing but flags after it. */
+export function invokes(text: string, command: string): boolean {
+ return ['', ...EXECS.map((exec) => `${exec} `)].some((prefix) => {
+ const rest = text.startsWith(prefix) ? text.slice(prefix.length) : ''
+
+ return (
+ (rest === command || rest.startsWith(`${command} `)) && FLAGS.test(rest.slice(command.length))
+ )
+ })
+}
+
/**
* The `npx`-like prefix that runs a project binary, from the package manager the nearest project
* declares in `packageManager` or, failing that, its lockfile.
*/
-export const detectExec = Effect.fn(function* (cwd: string) {
+export const detectExec = Effect.fn(function* (cwd: string, { fromAnyWorkspace = false } = {}) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
for (const dir of ancestors(path, cwd)) {
const manifest = yield* readJson(path.join(dir, 'package.json'))
+ const packageManager =
+ typeof manifest?.packageManager === 'string' ? manifest.packageManager : undefined
+
+ // Yarn 2+ runs only the binaries of the workspace it is started in, unless told to use the root's.
+ if (fromAnyWorkspace && packageManager !== undefined && /^yarn@(?!1\.)/.test(packageManager)) {
+ return YARN_TOP_LEVEL
+ }
+
const declared =
- typeof manifest?.packageManager === 'string'
- ? EXEC_BY_PACKAGE_MANAGER[manifest.packageManager.split('@')[0] ?? '']
- : undefined
+ packageManager === undefined
+ ? undefined
+ : EXEC_BY_PACKAGE_MANAGER.get(packageManager.split('@')[0]!)
+
+ if (declared !== undefined) {
+ return declared
+ }
- const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) =>
+ const lockfile = yield* Effect.findFirst(PACKAGE_MANAGER_BY_LOCKFILE, ([file]) =>
fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)),
)
- if (declared !== undefined || Option.isSome(lockfile)) {
- return declared ?? Option.getOrThrow(lockfile)[1]
+ if (Option.isSome(lockfile)) {
+ return EXEC_BY_PACKAGE_MANAGER.get(lockfile.value[1])!
}
}
- return 'npx'
+ return EXEC_BY_PACKAGE_MANAGER.get('npm')!
})
diff --git a/packages/uncheck/src/style.ts b/packages/uncheck/src/style.ts
index c53036b..d15907b 100644
--- a/packages/uncheck/src/style.ts
+++ b/packages/uncheck/src/style.ts
@@ -1,13 +1,8 @@
-import process from 'node:process'
import { styleText } from 'node:util'
-export const colors =
- !('NO_COLOR' in process.env) &&
- ((process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') ||
- process.stdout.isTTY === true)
+export const colors = styleText('bold', ' ') !== ' '
-const paint = (style: Parameters[0]) => (text: string) =>
- colors ? styleText(style, text, { validateStream: false }) : text
+const paint = (style: Parameters[0]) => (text: string) => styleText(style, text)
export const bold = paint('bold')
export const dim = paint('dim')
diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts
index 26906b1..b715a3e 100644
--- a/packages/uncheck/src/tool.ts
+++ b/packages/uncheck/src/tool.ts
@@ -1,3 +1,4 @@
+import { createRequire } from 'node:module'
import process from 'node:process'
import { Console, Effect, Path, Predicate, Stream } from 'effect'
@@ -14,15 +15,22 @@ export interface Bin {
/**
* Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest
- * `node_modules/`. Reading its manifest directly (instead of `require.resolve`) keeps this
- * independent from the package's `exports` map.
+ * `node_modules/`, whose manifest is read directly so its `exports` map does not matter, and,
+ * under Yarn PnP, first wherever its resolver finds `/package.json`.
*/
export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName: string = pkg) {
const path = yield* Path.Path
for (const dir of ancestors(path, cwd)) {
- const pkgDir = path.join(dir, 'node_modules', pkg)
- const manifest = yield* readJson(path.join(pkgDir, 'package.json'))
+ // Yarn PnP installs have no node_modules, only the resolver it loads into processes it starts.
+ const resolved =
+ process.versions.pnp === undefined
+ ? undefined
+ : yield* Effect.try(() =>
+ createRequire(path.join(dir, 'package.json')).resolve(`${pkg}/package.json`),
+ ).pipe(Effect.orElseSucceed(() => undefined))
+ const manifestPath = resolved ?? path.join(dir, 'node_modules', pkg, 'package.json')
+ const manifest = yield* readJson(manifestPath)
if (manifest === undefined) {
continue
@@ -35,38 +43,46 @@ export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName
? manifest.bin[binName]
: undefined
- return typeof bin === 'string' ? { name: binName, entry: path.resolve(pkgDir, bin) } : undefined
+ return typeof bin === 'string'
+ ? { name: binName, entry: path.resolve(path.dirname(manifestPath), bin) }
+ : undefined
}
return undefined
})
-/** Command lines stay well below every platform's argument limit. */
-const MAX_ARGV_LENGTH = 65_536
+/** Windows caps a whole command line, node and the tool path included, at 32,767 characters. */
+const MAX_ARGV_LENGTH = process.platform === 'win32' ? 30_000 : 65_536
export function argvBatches(args: ReadonlyArray): ReadonlyArray> {
const batches: string[][] = [[]]
let length = 0
for (const arg of args) {
- if (length + arg.length + 1 > MAX_ARGV_LENGTH) {
+ // Room for the separator and the quotes around an argument with a space.
+ if (length + arg.length + 3 > MAX_ARGV_LENGTH) {
batches.push([])
length = 0
}
batches[batches.length - 1]!.push(arg)
- length += arg.length + 1
+ length += arg.length + 3
}
return batches
}
-/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured in hook mode. */
+/** Tools read a leading `-` as a flag, and oxfmt reads a leading `!` as an exclusion even after `--`. */
+function asFileArgument(file: string): string {
+ return file.startsWith('-') || file.startsWith('!') ? `./${file}` : file
+}
+
+/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured. */
export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckCommand, cwd: string) {
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
const handle = yield* spawner.spawn(
- ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], {
+ ChildProcess.make(process.execPath, [bin.entry, ...args, ...files.map(asFileArgument)], {
cwd,
stdin: 'ignore',
// A piped tool cannot see the terminal, so tell it when colors are wanted.
@@ -81,3 +97,21 @@ export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckComm
return yield* handle.exitCode
}, Effect.scoped)
+
+export function captureLines(
+ effect: Effect.Effect,
+): Effect.Effect], E, R> {
+ return Effect.suspend(() => {
+ const lines: string[] = []
+ const capture: Console.Console = Object.assign(Object.create(globalThis.console), {
+ log: (...parts: ReadonlyArray) => {
+ lines.push(parts.join(' '))
+ },
+ })
+
+ return effect.pipe(
+ Effect.map((result) => [result, lines] as const),
+ Effect.provideService(Console.Console, capture),
+ )
+ })
+}
diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts
index c2158ef..7ab5771 100644
--- a/packages/uncheck/src/types.ts
+++ b/packages/uncheck/src/types.ts
@@ -17,6 +17,8 @@ export interface CheckCommand {
readonly bin: Bin
readonly args: ReadonlyArray
readonly files?: ReadonlyArray
+ /** Runs alongside the other `parallel` commands of its check, after the rest. */
+ readonly parallel?: boolean
}
export interface CheckInput {
@@ -29,7 +31,8 @@ export interface CheckInput {
export interface Check {
readonly name: CheckName
- readonly fixes: boolean
+ /** `workspace` fixes reach beyond the given files, so a commit, which stages only those, cannot take them. */
+ readonly fixes: false | 'files' | 'workspace'
readonly plan: (
input: CheckInput,
) => Effect.Effect<
diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts
index b968def..183b64f 100644
--- a/packages/uncheck/tests/command.test.ts
+++ b/packages/uncheck/tests/command.test.ts
@@ -1,3 +1,4 @@
+import { Buffer } from 'node:buffer'
import { execFileSync, spawn, spawnSync } from 'node:child_process'
import { once } from 'node:events'
import {
@@ -81,6 +82,10 @@ function text(lines: string[]): string {
return stripVTControlCharacters(lines.map((line) => `${line}\n`).join(''))
}
+const cliBin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url))
+
+const CLAUDE_STOP = JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false })
+
const oxlintrc = { rules: { 'no-var': 'error' } }
const standaloneTsconfig = {
@@ -132,7 +137,7 @@ describe('uncheck', { timeout: 120_000 }, () => {
])
expect(check.stdout).toContain('no-var')
expect(check.stdout).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt')
- expect(check.stdout).toContain('run `uncheck --fix` to apply oxlint and oxfmt fixes')
+ expect(check.stdout).toContain('rerun with `--fix` to apply oxlint and oxfmt fixes')
const fix = await run(dir, ['--fix'])
@@ -156,7 +161,7 @@ describe('uncheck', { timeout: 120_000 }, () => {
).toBe(true)
expect(clean.stdout).toContain('▶ oxlint\n')
expect(clean.stdout).toContain('▶ oxfmt --check\n')
- expect(clean.stdout).toContain('▶ tsc -p tsconfig.json\n')
+ expect(clean.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n')
expect(clean.stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)')
})
@@ -170,12 +175,17 @@ describe('uncheck', { timeout: 120_000 }, () => {
'packages/app/tsconfig.json': compositeTsconfig(['../lib']),
'packages/app/src/index.ts':
'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n',
+ 'tools/tsconfig.json': standaloneTsconfig,
+ 'tools/src/index.ts':
+ 'import { answer } from "../../packages/lib/dist/index";\n\nexport const tool: number = answer;\n',
})
const { result, stdout } = await run(dir)
expect(result).toBe('ok')
- expect(stdout).toContain('▶ tsc -b packages/app/tsconfig.json\n▶ tsc -p tsconfig.json\n')
+ 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')
@@ -193,6 +203,27 @@ describe('uncheck', { timeout: 120_000 }, () => {
expect(broken.stdout).toContain('TS2322')
})
+ it('checks standalone projects side by side but prints their output in plan order', async () => {
+ const dir = fixture(
+ {
+ 'packages/a/tsconfig.json': standaloneTsconfig,
+ 'packages/a/src/index.ts': 'export const a: number = 1;\n',
+ 'packages/b/tsconfig.json': standaloneTsconfig,
+ 'packages/b/src/index.ts': 'export const b: number = "2";\n',
+ 'packages/c/tsconfig.json': standaloneTsconfig,
+ 'packages/c/src/index.ts': 'export const c: number = 3;\n',
+ },
+ ['typescript'],
+ )
+
+ const { result, stdout } = await run(dir, ['--only=tsc'])
+
+ expect(result).toBeInstanceOf(CheckFailed)
+ expect(stdout).toMatch(
+ /▶ tsc -p packages\/a\/tsconfig\.json --noEmit\n▶ tsc -p packages\/b\/tsconfig\.json --noEmit\npackages\/b\/src\/index\.ts[^▶]*TS2322[^▶]*▶ tsc -p packages\/c\/tsconfig\.json --noEmit\n✘ tsc failed/,
+ )
+ })
+
it('forwards paths to oxlint and oxfmt and narrows tsc to the projects containing them', async () => {
const dir = fixture({
'.oxlintrc.json': oxlintrc,
@@ -228,7 +259,7 @@ describe('uncheck', { timeout: 120_000 }, () => {
'▶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n',
)
expect(single.stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n')
- expect(single.stdout).toContain('▶ tsc -p tsconfig.json\n')
+ expect(single.stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n')
expect(single.stdout).not.toContain('tsc -b')
const all = await run(dir, ['.'])
@@ -285,7 +316,7 @@ describe('uncheck', { timeout: 120_000 }, () => {
const walked = await run(plain)
expect(walked.result).toBeInstanceOf(CheckFailed)
- expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json\n')
+ expect(walked.stdout).toContain('▶ tsc -p ignored/tsconfig.json --noEmit\n')
const repo = fixture(files, ['typescript'])
execFileSync('git', ['init', '--quiet'], { cwd: repo })
@@ -350,6 +381,33 @@ describe('uncheck', { timeout: 120_000 }, () => {
'nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found',
)
})
+
+ it.skipIf(process.platform === 'win32')(
+ 'fails a check whose tool is killed and still runs the others',
+ async () => {
+ const dir = fixture(
+ {
+ '.oxlintrc.json': oxlintrc,
+ 'tsconfig.json': standaloneTsconfig,
+ 'src/index.ts': 'export const answer: number = 42;\n',
+ 'node_modules/oxlint/package.json': { name: 'oxlint', bin: { oxlint: 'bin.js' } },
+ 'node_modules/oxlint/bin.js': "process.kill(process.pid, 'SIGKILL')\n",
+ },
+ ['oxfmt', 'typescript'],
+ )
+
+ const { result, stdout } = await run(dir)
+
+ expect(result).toBeInstanceOf(CheckFailed)
+ expect(stdout).toContain(
+ "▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n",
+ )
+ expect(stdout).toContain('✘ oxlint failed')
+ expect(stdout).toContain('✔ tsc passed')
+ expect(stdout).toContain('✘ 1 of 3 checks failed: oxlint')
+ expect(stdout).not.toContain('PlatformError')
+ },
+ )
})
/** A workspace sherif has something to say about, with its install step off so the fix stays offline. */
@@ -403,7 +461,7 @@ describe('uncheck sherif', { timeout: 120_000 }, () => {
expect(check.stdout).toContain('unordered-dependencies')
expect(check.stdout).toContain('multiple-dependency-versions')
expect(check.stdout).toContain(
- '✘ 1 of 1 checks failed: sherif\n run `uncheck --fix` to apply sherif fixes',
+ '✘ 1 of 1 checks failed: sherif\n rerun with `--fix` to apply sherif fixes',
)
const fix = await run(dir, ['--fix'])
@@ -555,14 +613,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => {
it('writes stop hook configs for the named agents through the detected package manager', async () => {
const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, [])
- const { result, stdout } = await run(dir, [
- 'hooks',
- 'install',
- 'claude',
- 'cursor',
- 'windsurf',
- 'copilot',
- ])
+ const { result, stdout } = await run(dir, ['hooks', 'install', 'claude', 'cursor', 'copilot'])
expect(result).toBe('ok')
expect(stdout).toContain('✔ Claude Code .claude/settings.json created\n')
@@ -572,18 +623,17 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => {
const hook = 'pnpm exec uncheck hooks run --fix'
expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({
- hooks: { Stop: [{ hooks: [{ type: 'command', command: hook }] }] },
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: hook, timeout: 600 }] }] },
})
expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({
version: 1,
- hooks: { stop: [{ command: hook }] },
- })
- expect(JSON.parse(readFileSync(join(dir, '.windsurf/hooks.json'), 'utf8'))).toEqual({
- hooks: { post_cascade_response: [{ command: hook, show_output: true }] },
+ hooks: { stop: [{ command: hook, timeout: 600 }] },
})
expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({
version: 1,
- hooks: { agentStop: [{ type: 'command', bash: hook, powershell: hook }] },
+ hooks: {
+ agentStop: [{ type: 'command', bash: hook, powershell: hook, timeoutSec: 600 }],
+ },
})
const again = await run(dir, ['hooks', 'install', 'claude'])
@@ -599,25 +649,43 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => {
'.claude/settings.json': `{
// keep me
"permissions": { "allow": ["Bash(pnpm test)"] },
- "hooks": { "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }] },
+ "hooks": {
+ "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }],
+ "Stop": [{ "hooks": [{ "type": "command", "command": "notify-send done" }] }],
+ },
}
`,
},
[],
)
+ const hook = 'npx --no uncheck hooks run --fix --require=tsc --skip=sherif'
+
+ 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'])
+ const { result, stdout } = await run(dir, [
+ 'hooks',
+ 'install',
+ 'codebuddy',
+ 'claude',
+ '--require=tsc',
+ '--skip=sherif',
+ ])
expect(result).toBe('ok')
expect(stdout).toContain('✔ Claude Code .claude/settings.json updated\n')
expect(stdout).toContain('✔ CodeBuddy .codebuddy/settings.json created\n')
- expect(stdout).toContain('npx uncheck hooks run --fix')
+ expect(stdout).toContain(hook)
expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({
permissions: { allow: ['Bash(pnpm test)'] },
hooks: {
PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }],
- Stop: [{ hooks: [{ type: 'command', command: 'npx uncheck hooks run --fix' }] }],
+ Stop: [
+ { hooks: [{ type: 'command', command: 'notify-send done' }] },
+ { hooks: [{ type: 'command', command: hook, timeout: 600 }] },
+ ],
},
})
})
@@ -630,7 +698,7 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => {
it('writes the check flags into the hook command and updates an installed hook', async () => {
const dir = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, [])
- const fast = 'yarn uncheck hooks run --fix --only=oxlint --only=oxfmt'
+ const fast = 'yarn run --silent uncheck hooks run --fix --only=oxlint --only=oxfmt'
const { result, stdout } = await run(dir, [
'hooks',
@@ -644,27 +712,184 @@ describe('uncheck hooks install', { timeout: 120_000 }, () => {
expect(result).toBe('ok')
expect(stdout).toContain(fast)
expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({
- hooks: { Stop: [{ hooks: [{ type: 'command', command: fast }] }] },
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: fast, timeout: 600 }] }] },
})
- const all = 'yarn uncheck hooks run --fix'
+ const all = 'yarn run --silent uncheck hooks run --fix'
const again = await run(dir, ['hooks', 'install', 'claude', 'copilot'])
expect(again.result).toBe('ok')
expect(again.stdout).toContain('✔ Claude Code .claude/settings.json updated\n')
expect(again.stdout).toContain('✔ GitHub Copilot .github/hooks/uncheck.json updated\n')
expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({
- hooks: { Stop: [{ hooks: [{ type: 'command', command: all }] }] },
+ hooks: { Stop: [{ hooks: [{ type: 'command', command: all, timeout: 600 }] }] },
})
expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({
version: 1,
- hooks: { agentStop: [{ type: 'command', bash: all, powershell: all }] },
+ hooks: {
+ agentStop: [{ type: 'command', bash: all, powershell: all, timeoutSec: 600 }],
+ },
})
await expect(
run(dir, ['hooks', 'install', 'claude', '--only=oxlint', '--skip=oxlint']),
).rejects.toThrow(/--only=oxlint and --skip=oxlint/)
})
+
+ it('updates its own entry in place and leaves strings that only mention the hook alone', async () => {
+ const mentions = {
+ _comment: 'the Stop hook runs npx uncheck hooks run --fix',
+ permissions: { allow: ['Bash(npx uncheck hooks run:*)'] },
+ hooks: {
+ PostToolUse: [
+ {
+ hooks: [
+ {
+ type: 'command',
+ command: 'cd packages/web && npx uncheck hooks run --fix && notify',
+ },
+ { 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. */
@@ -690,6 +915,19 @@ function committed(files: Record) {
return dir
}
+/** Commits `files` on a new `side` branch and goes back to the branch before it. */
+function commitOnSide(dir: string, files: Record) {
+ gitIn(dir, 'checkout', '--quiet', '-b', 'side')
+
+ for (const [file, content] of Object.entries(files)) {
+ writeFileSync(join(dir, file), content)
+ }
+
+ gitIn(dir, 'add', '-A')
+ gitIn(dir, 'commit', '--quiet', '-m', 'side')
+ gitIn(dir, 'checkout', '--quiet', '-')
+}
+
const clean = {
'.oxlintrc.json': oxlintrc,
'tsconfig.json': standaloneTsconfig,
@@ -698,12 +936,10 @@ const clean = {
}
function installPreCommitHook(dir: string, args: string, folder = '.') {
- const bin = fileURLToPath(new URL('../dist/bin.mjs', import.meta.url))
-
mkdirSync(join(dir, '.git/hooks'), { recursive: true })
writeFileSync(
join(dir, '.git/hooks/pre-commit'),
- `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${bin}" ${args}) || exit 1\n`,
+ `#!/bin/sh\n(cd "${folder}" && "${process.execPath}" "${cliBin}" ${args}) || exit 1\n`,
{ mode: 0o755 },
)
}
@@ -714,18 +950,14 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => {
writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n')
writeFileSync(join(dir, 'src/fresh.ts'), 'export const fresh = 3;\n')
- const claude = await run(
- dir,
- ['hooks', 'run', '--fix'],
- JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }),
- )
+ const claude = await run(dir, ['hooks', 'run', '--fix'], CLAUDE_STOP)
expect(claude.result).toBe('blocked')
expect(claude.stdout).toBe('')
expect(claude.stderr).toContain(
'▶ oxlint --fix --no-error-on-unmatched-pattern src/fresh.ts src/index.ts\n',
)
- expect(claude.stderr).toContain('▶ tsc -p tsconfig.json\n')
+ expect(claude.stderr).toContain('▶ tsc -p tsconfig.json --noEmit\n')
expect(claude.stderr).toContain('TS2322')
expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc')
expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe(
@@ -739,7 +971,9 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => {
)
expect(continuing.result).toBe('ok')
- expect(continuing.stdout).toBe('')
+ expect(JSON.parse(continuing.stdout)).toEqual({
+ systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc',
+ })
expect(continuing.stderr).toContain('TS2322')
const cursor = await run(
@@ -762,15 +996,116 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => {
expect(copilot.result).toBe('ok')
expect(JSON.parse(copilot.stdout)).toMatchObject({ decision: 'block' })
- const windsurf = await run(
+ const copilotInClaudeFormat = await run(
dir,
['hooks', 'run', '--fix'],
- JSON.stringify({ agent_action_name: 'post_cascade_response' }),
+ JSON.stringify({ hook_event_name: 'Stop', stop_reason: 'end_turn', stop_hook_active: false }),
+ )
+
+ expect(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(windsurf.result).toBe('ok')
- expect(windsurf.stdout).toBe('')
- expect(windsurf.stderr).toContain('TS2322')
+ 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 () => {
@@ -804,7 +1139,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => {
const fast = await run(
dir,
['hooks', 'run', '--fix', '--only=oxlint', '--only=oxfmt'],
- JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }),
+ CLAUDE_STOP,
)
expect(fast.result).toBe('ok')
@@ -852,7 +1187,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
)
expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n')
expect(stdout).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n')
- expect(stdout).toContain('▶ tsc -p tsconfig.json\n')
+ expect(stdout).toContain('▶ tsc -p tsconfig.json --noEmit\n')
expect(stdout).toContain('✔ all checks passed (oxlint, oxfmt, tsc)\n')
expect(stdout).toContain(
'✔ staged the fixes to src/index.ts\n○ unstaged changes of src/index.ts restored\n',
@@ -872,7 +1207,8 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
})
it('undoes the fixes when they conflict with unstaged changes, so nothing is lost', async () => {
- const dir = committed(clean)
+ const far = Array.from({ length: 30 }, (_, index) => `export const f${index} = ${index};\n`)
+ const dir = committed({ ...clean, 'src/far.ts': far.join('') })
writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 42\n')
gitIn(dir, 'add', 'src/index.ts')
@@ -880,6 +1216,12 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 43\n')
writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n')
gitIn(dir, 'add', 'src/other.ts')
+ writeFileSync(join(dir, 'src/far.ts'), ['export const f0 = 0;\n', ...far.slice(1)].join(''))
+ gitIn(dir, 'add', 'src/far.ts')
+ writeFileSync(
+ join(dir, 'src/far.ts'),
+ ['export const f0 = 0;\n', ...far.slice(1, -1), 'export const f29 = 30;\n'].join(''),
+ )
await expect(run(dir, ['staged', '--fix'])).rejects.toThrow(
/fixes conflict with the unstaged changes of src\/index\.ts and were undone/,
@@ -930,21 +1272,22 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
expect(allowed.stdout).toContain('✔ staged the fixes to src/other.ts\n')
expect(gitIn(dir, 'diff', '--cached', '--name-only')).toBe('')
- const unborn = fixture(clean)
+ const unborn = fixture({ ...clean, 'src/index.ts': 'export const answer: number = 42\n' })
gitIn(unborn, 'init', '--quiet')
gitIn(unborn, 'add', '.')
- expect((await run(unborn, ['staged', '--fix'])).result).toBe('ok')
+ const first = await run(unborn, ['staged', '--fix'])
+
+ expect(first.result).toBe('ok')
+ expect(first.stdout).toContain('✔ staged the fixes to src/index.ts\n')
+ expect(gitIn(unborn, 'show', ':src/index.ts')).toBe('export const answer: number = 42;\n')
})
it('lets a merge through when the fixes turn its tree back into what HEAD has', async () => {
const dir = committed(clean)
- gitIn(dir, 'checkout', '--quiet', '-b', 'side')
- writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n')
- gitIn(dir, 'commit', '--quiet', '-am', 'side')
- gitIn(dir, 'checkout', '--quiet', '-')
+ commitOnSide(dir, { 'src/other.ts': 'export const other = 3;\n' })
gitIn(dir, 'merge', '--quiet', '--no-commit', '--no-ff', 'side')
writeFileSync(join(dir, 'src/other.ts'), 'export const other = 2\n')
gitIn(dir, 'add', 'src/other.ts')
@@ -1151,7 +1494,7 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
writeFileSync(join(dir, 'src/index.ts'), 'export const answer: number = 44;\n')
writeFileSync(join(dir, 'src/other.ts'), 'export const other = 4;\n')
- await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout \[2 paths\] failed/)
+ await expect(run(dir, ['staged'])).rejects.toThrow(/git checkout-index -f \[2 paths\] failed/)
expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe(
'export const answer: number = 44;\n',
@@ -1161,6 +1504,69 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
expect(existsSync(join(dir, '.git/uncheck-unstaged'))).toBe(false)
})
+ it('keeps the unstaged changes aside and stops the commit when they cannot be put back', async () => {
+ const rest = 'export const b = 1;\nexport const c = 1;\nexport const d = 1;\nexport const e ='
+ const dir = committed({ ...clean, 'src/index.ts': `export const a = 1;\n${rest} 1;\n` })
+
+ mkdirSync(join(dir, '.git/info'), { recursive: true })
+ writeFileSync(join(dir, '.git/info/attributes'), 'src/index.ts filter=once\n')
+ gitIn(dir, 'config', 'filter.once.clean', 'cat')
+ gitIn(
+ dir,
+ 'config',
+ 'filter.once.smudge',
+ 'if [ -e .git/smudged ]; then exit 1; else touch .git/smudged; cat; fi',
+ )
+ gitIn(dir, 'config', 'filter.once.required', 'true')
+ writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 1;\n`)
+ gitIn(dir, 'add', 'src/index.ts')
+ writeFileSync(join(dir, 'src/index.ts'), `export const a = 2\n${rest} 2;\n`)
+
+ await expect(run(dir, ['staged', '--fix', '--only=oxfmt'])).rejects.toThrow(
+ /The unstaged changes of src\/index\.ts could not be put back/,
+ )
+ expect(gitIn(dir, 'show', ':src/index.ts')).toBe(`export const a = 2;\n${rest} 1;\n`)
+ expect(readFileSync(join(dir, '.git/uncheck-unstaged/src/index.ts'), 'utf8')).toBe(
+ `export const a = 2\n${rest} 2;\n`,
+ )
+ })
+
+ it('keeps an edit saved to a partially staged file while the checks run', async () => {
+ const lines = Array.from({ length: 30 }, (_, index) => `export const v${index} = ${index};\n`)
+ const dir = fixture(
+ {
+ 'src/lines.ts': lines.join(''),
+ 'node_modules/oxlint/package.json': { name: 'oxlint', bin: 'lint.js' },
+ 'node_modules/oxlint/lint.js':
+ "const fs = require('node:fs')\nfs.writeFileSync('src/lines.ts', fs.readFileSync('src/lines.ts', 'utf8').replace('v15 = 15', 'v15 = 1500'))\n",
+ },
+ [],
+ )
+ const file = join(dir, 'src/lines.ts')
+ const staged = ['export const v0 = 100;\n', ...lines.slice(1)]
+
+ gitIn(dir, 'init', '--quiet')
+ gitIn(dir, 'add', '.')
+ gitIn(dir, 'commit', '--quiet', '-m', 'init')
+
+ writeFileSync(file, staged.join(''))
+ gitIn(dir, 'add', 'src/lines.ts')
+ writeFileSync(file, [...staged.slice(0, -1), 'export const v29 = 2900;\n'].join(''))
+
+ const { result } = await run(dir, ['staged', '--only=oxlint'])
+
+ expect(result).toBe('ok')
+ expect(readFileSync(file, 'utf8')).toBe(
+ [
+ ...staged.slice(0, 15),
+ 'export const v15 = 1500;\n',
+ ...staged.slice(16, -1),
+ 'export const v29 = 2900;\n',
+ ].join(''),
+ )
+ expect(gitIn(dir, 'show', ':src/lines.ts')).toBe(staged.join(''))
+ })
+
it('stages the fixes in the index `git commit ` leaves behind, not only in its own', () => {
const dir = committed(clean)
@@ -1211,49 +1617,373 @@ describe('uncheck staged', { timeout: 120_000 }, () => {
'export const answer: number = 44;\n',
)
})
-})
-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',
+ 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')
- 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')
+ expect((await run(dir, ['staged', '--fix', '--only=oxfmt'])).stdout).toContain(
+ '○ nothing to check, every staged file comes from the branch being merged in\n',
+ )
- const { result, stdout } = await run(join(dir, 'packages/app'), ['staged', '--fix'])
+ 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')
- // Staged files outside the package are another line's business, so they are neither checked nor fixed.
- expect(stdout).toContain('▶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n')
- expect(stdout).toContain('▶ tsc -p tsconfig.json\n')
- expect(stdout).toContain('✔ 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')
+ 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('reports what git refused to do instead of crashing', async () => {
- const dir = committed(clean)
+ 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')
- // A lock left behind by another git process makes every write to the index fail.
- writeFileSync(join(dir, '.git/index.lock'), '')
+ 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')
@@ -1344,6 +2074,18 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
const settled = await run(dir, ['prepare', '--pre-commit'])
expect(settled.stdout).toContain('✔ pre-commit .git/hooks/pre-commit unchanged\n')
+
+ const chained = 'npx uncheck staged --only=oxfmt && pnpm test'
+ const advisory = 'pnpm exec uncheck staged --fix || echo "not blocking"'
+
+ writeFileSync(hook, `#!/bin/sh\n${chained}\n${advisory}\n`)
+
+ const handWritten = await run(dir, ['prepare', '--pre-commit'])
+
+ expect(handWritten.stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n')
+ expect(readFileSync(hook, 'utf8')).toBe(
+ `#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\n${chained}\n${advisory}\n`,
+ )
})
it.skipIf(process.platform === 'win32')(
@@ -1400,6 +2142,44 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
},
)
+ it('keeps the line of every package when they all prepare at once, as a workspace install does', async () => {
+ const packages = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h']
+ const folders = ['.', ...packages.map((name) => `packages/${name}`)]
+ const dir = fixture(
+ {
+ 'pnpm-lock.yaml': '',
+ ...Object.fromEntries(folders.map((folder) => [`${folder}/package.json`, '{}\n'])),
+ },
+ [],
+ )
+ gitIn(dir, 'init', '--quiet')
+
+ const 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 () => {
@@ -1467,7 +2247,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(stdout).toContain('✔ pre-commit .git/hooks/pre-commit updated\n')
expect(lstatSync(hook).isSymbolicLink()).toBe(true)
expect(readFileSync(join(dir, 'scripts/pre-commit'), 'utf8')).toBe(
- '#!/bin/sh\npnpm test\npnpm exec uncheck staged --fix || exit 1\n',
+ '#!/bin/sh\npnpm exec uncheck staged --fix || exit 1\npnpm test\n',
)
expect(statSync(hook).mode & 0o777).toBe(0o755)
@@ -1527,6 +2307,30 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(again.stdout).toContain(`✔ pre-commit ${hook} unchanged\n`)
})
+ it('switches the lines older versions wrote to the current runner in place', async () => {
+ const npm = fixture({ 'package.json': '{}\n', 'package-lock.json': '{}\n' }, [])
+ gitIn(npm, 'init', '--quiet')
+ const npmHook = join(npm, '.git/hooks/pre-commit')
+ writeFileSync(npmHook, `${header}npx uncheck staged --fix || exit 1\npnpm test\n`)
+
+ await run(npm, ['prepare', '--pre-commit'])
+
+ expect(readFileSync(npmHook, 'utf8')).toBe(
+ `${header}npx --no uncheck staged --fix || exit 1\npnpm test\n`,
+ )
+
+ const yarn = fixture({ 'package.json': '{}\n', 'yarn.lock': '' }, [])
+ gitIn(yarn, 'init', '--quiet')
+ const yarnHook = join(yarn, '.git/hooks/pre-commit')
+ writeFileSync(yarnHook, '#!/bin/sh\nyarn uncheck staged --fix\npnpm test\n')
+
+ await run(yarn, ['prepare', '--pre-commit'])
+
+ expect(readFileSync(yarnHook, 'utf8')).toBe(
+ '#!/bin/sh\nyarn run --silent uncheck staged --fix || exit 1\npnpm test\n',
+ )
+ })
+
it('takes the runner from the packageManager field and reports an unwritable hook', async () => {
const dir = fixture({ 'package.json': { packageManager: 'bun@1.2.0' } }, [])
gitIn(dir, 'init', '--quiet')
@@ -1534,7 +2338,9 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
const { result, stdout } = await run(dir, ['prepare', '--pre-commit'])
expect(result).toBe('ok')
- expect(stdout).toContain('The hook runs bunx uncheck staged --fix before every commit')
+ expect(stdout).toContain(
+ 'The hook runs bunx --no-install uncheck staged --fix before every commit',
+ )
const blocked = fixture({ 'package.json': '{}\n' }, [])
gitIn(blocked, 'init', '--quiet')
@@ -1546,7 +2352,9 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(refused.result).toBe('ok')
expect(refused.stdout).toContain('✘ pre-commit .git/hooks/pre-commit not written,')
expect(
- readdirSync(join(blocked, '.git/hooks')).filter((name) => name.includes('uncheck')),
+ readdirSync(join(blocked, '.git/hooks')).filter(
+ (name) => name.includes('uncheck') || name.endsWith('.lock'),
+ ),
).toEqual([])
})
@@ -1564,7 +2372,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(result).toBe('ok')
expect(stdout).toContain(`✔ pre-commit ${hook} updated\n`)
expect(readFileSync(hook, 'utf8')).toBe(
- '#!/bin/sh\necho hi\n(cd "packages/app" && yarn uncheck staged --fix) || exit 1\n',
+ '#!/bin/sh\n(cd "packages/app" && yarn run --silent uncheck staged --fix) || exit 1\necho hi',
)
if (process.platform !== 'win32') {
@@ -1578,6 +2386,106 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(skipped.stdout).toBe('○ no git repository found, nothing to prepare\n')
})
+ it.skipIf(process.platform === 'win32')(
+ 'runs before the commands of an existing hook, so they still fail it and cannot skip it',
+ async () => {
+ const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, [])
+ gitIn(dir, 'init', '--quiet')
+ const hook = join(dir, '.git/hooks/pre-commit')
+ const bin = fixture({ pnpm: '#!/bin/sh\necho "$*" >> "$LOG"\ntest "$1" = exec\n' }, [])
+ const log = join(bin, 'log')
+ chmodSync(join(bin, 'pnpm'), 0o755)
+ writeFileSync(join(dir, '.git/hooks/env'), `PATH="${bin}:$PATH"\n`)
+ 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")"',
@@ -1618,7 +2526,7 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(nested.stdout).toContain(`✔ pre-commit ${hook} updated\n`)
expect(readFileSync(hook, 'utf8')).toBe(
- `pnpm test\npnpm exec uncheck staged --fix || exit 1\n${app}\n`,
+ `pnpm exec uncheck staged --fix || exit 1\n${app}\npnpm test\n`,
)
expect(readFileSync(join(dir, '.husky/_/pre-commit'), 'utf8')).toBe(shim)
@@ -1665,15 +2573,15 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(commit()).toEqual({
status: 0,
ran: [
- `${top} test`,
`${top} exec uncheck staged --fix`,
`${top}/packages/app exec uncheck staged --fix --only=oxlint`,
+ `${top} test`,
],
})
writeFileSync(join(dir, 'fail'), '')
- expect(commit()).toEqual({ status: 1, ran: [`${top} test`] })
+ expect(commit()).toEqual({ status: 1, ran: [`${top} exec uncheck staged --fix`] })
chmodSync(hook, 0o755)
@@ -1699,9 +2607,14 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
expect(existsSync(join(vite, '.vite-hooks/_/pre-commit'))).toBe(false)
})
- it('writes into a core.hooksPath set by hand as it is', async () => {
+ it('writes into a core.hooksPath set by hand as it is, even one holding an `h` script', async () => {
const dir = fixture(
- { 'package.json': '{}\n', 'pnpm-lock.yaml': '', 'packages/app/package.json': '{}\n' },
+ {
+ 'package.json': '{}\n',
+ 'pnpm-lock.yaml': '',
+ 'packages/app/package.json': '{}\n',
+ '.githooks/h': 'echo "help"\n',
+ },
[],
)
gitIn(dir, 'init', '--quiet')
@@ -1719,11 +2632,61 @@ describe('uncheck prepare', { timeout: 120_000 }, () => {
`${header}pnpm exec uncheck staged --fix || exit 1\n(cd "packages/app" && pnpm exec uncheck staged --fix) || exit 1\n`,
)
expect(existsSync(join(dir, '.git/hooks/pre-commit'))).toBe(false)
+ expect(existsSync(join(dir, 'pre-commit'))).toBe(false)
if (process.platform !== 'win32') {
expect(statSync(hook).mode & 0o111).toBe(0o111)
}
})
+
+ it('leaves the core.hooksPath of the global git config alone, since every repository runs it', async () => {
+ const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, [])
+ gitIn(dir, 'init', '--quiet')
+ const shared = fixture({ 'pre-commit': '#!/bin/sh\necho "scanning for secrets"\n' }, [])
+ gitIn(dir, 'config', '--file', globalConfig, 'core.hooksPath', shared)
+
+ const { result, stdout } = await run(dir, ['prepare', '--pre-commit'])
+
+ expect(result).toBe('ok')
+ expect(stdout).toBe(
+ `✘ pre-commit ${join(shared, 'pre-commit')} not written, core.hooksPath is set in the global git config, so every repository runs it\n`,
+ )
+ expect(readFileSync(join(shared, 'pre-commit'), 'utf8')).toBe(
+ '#!/bin/sh\necho "scanning for secrets"\n',
+ )
+
+ gitIn(dir, 'config', 'core.hooksPath', '.githooks')
+
+ const local = await run(dir, ['prepare', '--pre-commit'])
+
+ expect(local.stdout).toContain('✔ pre-commit .githooks/pre-commit created\n')
+ })
+
+ 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 }, () => {
@@ -1810,6 +2773,8 @@ describe('uncheck presets', { timeout: 120_000 }, () => {
'}',
'',
].join('\n'),
+ 'src/use.ts':
+ "import { first } from './index.ts'\n\nexport const name: string = first(['a'])\n",
},
['typescript'],
)
@@ -1818,9 +2783,35 @@ describe('uncheck presets', { timeout: 120_000 }, () => {
const check = await run(dir, ['--only=tsc', 'src/index.ts'])
expect(check.result).toBeInstanceOf(CheckFailed)
- expect(check.stdout).toContain('▶ tsc -p tsconfig.json')
+ expect(check.stdout).toContain('▶ tsc -p tsconfig.json --noEmit')
// `strict` and `noUncheckedIndexedAccess` come from the base preset, through the lib one
expect(check.stdout).toContain('error TS7006')
expect(check.stdout).toContain('error TS2322')
+ // 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
index c20898c..4154527 100644
--- a/packages/uncheck/tests/files.test.ts
+++ b/packages/uncheck/tests/files.test.ts
@@ -1,9 +1,12 @@
import { execFileSync } from 'node:child_process'
+import { chmodSync, mkdirSync, symlinkSync } from 'node:fs'
+import { join } from 'node:path'
+import process from 'node:process'
import { NodeServices } from '@effect/platform-node'
import { Effect } from 'effect'
-import { listProjectFiles, resolvePaths } from '../src/files'
+import { existingFiles, listProjectFiles, resolvePaths } from '../src/files'
import { fixture } from './fixture'
const project = {
@@ -86,4 +89,119 @@ describe('resolvePaths', () => {
unmatched: [],
})
})
+
+ it('walks past folders it cannot read and never into linked folders', async () => {
+ const dir = fixture(project, [])
+ symlinkSync('.', join(dir, 'loop'))
+ symlinkSync('..', join(dir, 'src/up'))
+ const locked = join(dir, 'locked')
+ mkdirSync(locked)
+
+ if (process.platform !== 'win32') {
+ chmodSync(locked, 0)
+ }
+
+ try {
+ expect(await resolve(dir, ['.'])).toEqual({
+ files: [
+ 'app/[id].ts',
+ 'dist/out.js',
+ 'docs/readme.md',
+ 'src/a.ts',
+ 'src/b.ts',
+ 'src/sub/c.ts',
+ ],
+ unmatched: [],
+ })
+ } finally {
+ chmodSync(locked, 0o755)
+ }
+ })
+
+ it('lets globs and exclusions match dot files the way directories include them', async () => {
+ const dir = fixture(
+ {
+ ...project,
+ '.github/workflows/ci.yml': '',
+ '.vscode/settings.json': '',
+ 'package.json': '{}',
+ 'src/.env.ts': '',
+ },
+ [],
+ )
+ execFileSync('git', ['init', '--quiet'], { cwd: dir })
+
+ expect(await resolve(dir, ['**/*.yml'])).toEqual({
+ files: ['.github/workflows/ci.yml'],
+ unmatched: [],
+ })
+ expect(await resolve(dir, ['src/**'])).toEqual(await resolve(dir, ['src']))
+ expect(await resolve(dir, ['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
index cf8ab92..cb12389 100644
--- a/packages/uncheck/tests/fixture.ts
+++ b/packages/uncheck/tests/fixture.ts
@@ -40,6 +40,11 @@ export function fixture(
}
for (const [relative, content] of Object.entries({ ...defaults, ...files })) {
+ // The tools are symlinks into the shared pnpm store, so writing below one corrupts every install.
+ if (tools.some((tool) => relative.startsWith(`node_modules/${tool}/`))) {
+ throw new Error(`${relative} would be written into the installed ${relative.split('/')[1]}`)
+ }
+
mkdirSync(dirname(join(dir, relative)), { recursive: true })
writeFileSync(
join(dir, relative),
diff --git a/packages/uncheck/tests/tool.test.ts b/packages/uncheck/tests/tool.test.ts
new file mode 100644
index 0000000..09f5797
--- /dev/null
+++ b/packages/uncheck/tests/tool.test.ts
@@ -0,0 +1,78 @@
+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
index cbd8ca2..f062178 100644
--- a/packages/uncheck/tests/tsc.test.ts
+++ b/packages/uncheck/tests/tsc.test.ts
@@ -1,3 +1,6 @@
+import { mkdirSync, symlinkSync } from 'node:fs'
+import { join } from 'node:path'
+
import { NodeServices } from '@effect/platform-node'
import { Effect } from 'effect'
@@ -20,12 +23,17 @@ function plan(dir: string, files?: string[]): Promise {
}
const NOT_COVERED = 'no tsconfig.json covers the given files'
+// oxlint-disable-next-line no-template-curly-in-string
+const CONFIG_DIR = '${configDir}'
describe('tsc project references', () => {
it('checks standalone projects with tsc -p', async () => {
const dir = fixture({ 'tsconfig.json': {}, 'packages/a/tsconfig.json': {} })
- expect(await plan(dir)).toEqual(['-p packages/a/tsconfig.json', '-p tsconfig.json'])
+ expect(await plan(dir)).toEqual([
+ '-p packages/a/tsconfig.json --noEmit',
+ '-p tsconfig.json --noEmit',
+ ])
})
it('builds only the roots of the reference graph and leaves the rest to tsc -b', async () => {
@@ -39,7 +47,7 @@ describe('tsc project references', () => {
'packages/nest/tsconfig.json': { references: [{ path: '../server/tsconfig.json' }] },
})
- expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json'])
+ expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json --noEmit'])
})
it('builds a solution-style root that references configs not named tsconfig.json', async () => {
@@ -83,9 +91,11 @@ describe('tsc project references', () => {
'c/tsconfig.json': {},
})
- expect(await plan(dir)).toBe(
- 'circular project references between a/tsconfig.json, b/tsconfig.json',
- )
+ const cycle = 'circular project references between a/tsconfig.json, b/tsconfig.json'
+
+ expect(await plan(dir)).toBe(cycle)
+ expect(await plan(dir, ['a/src/index.ts'])).toBe(cycle)
+ expect(await plan(dir, ['c/src/index.ts'])).toBe(cycle)
})
it('reports only the cyclic part when a root also exists', async () => {
@@ -99,6 +109,38 @@ describe('tsc project references', () => {
'circular project references between b/tsconfig.json, c/tsconfig.json',
)
})
+
+ it('follows references as written, leaving the configDir token alone like tsc', async () => {
+ const dir = fixture({
+ 'app/tsconfig.json': { references: [{ path: `${CONFIG_DIR}/../lib` }] },
+ 'app/lib/tsconfig.json': {},
+ 'lib/tsconfig.json': {},
+ })
+
+ expect(await plan(dir)).toEqual(['-b app/tsconfig.json', '-p lib/tsconfig.json --noEmit'])
+ })
+
+ it('reads backslashes in 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', () => {
@@ -157,8 +199,8 @@ describe('tsc project selection', () => {
},
'packages/b/tsconfig.json': { extends: '@shared/tsconfig' },
})
- const a = ['-p packages/a/tsconfig.json']
- const b = ['-p packages/b/tsconfig.json']
+ const a = ['-p packages/a/tsconfig.json --noEmit']
+ const b = ['-p packages/b/tsconfig.json --noEmit']
// `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it.
expect(await plan(dir, ['packages/a/entry.ts'])).toEqual(a)
@@ -196,10 +238,16 @@ describe('tsc project selection', () => {
})
// `allowJs` from the preset puts `.js` files in the project, so it was resolved.
- expect(await plan(dir, ['packages/a/src/index.js'])).toEqual(['-p packages/a/tsconfig.json'])
+ expect(await plan(dir, ['packages/a/src/index.js'])).toEqual([
+ '-p packages/a/tsconfig.json --noEmit',
+ ])
// `import` is not a condition tsc uses for `extends`, so `require` is picked instead.
- expect(await plan(dir, ['packages/b/src/index.js'])).toEqual(['-p packages/b/tsconfig.json'])
- expect(await plan(dir, ['packages/c/src/index.js'])).toEqual(['-p packages/c/tsconfig.json'])
+ expect(await plan(dir, ['packages/b/src/index.js'])).toEqual([
+ '-p packages/b/tsconfig.json --noEmit',
+ ])
+ expect(await plan(dir, ['packages/c/src/index.js'])).toEqual([
+ '-p packages/c/tsconfig.json --noEmit',
+ ])
// The file exists, but the package does not export it.
expect(await plan(dir, ['packages/d/src/index.js'])).toBe(NOT_COVERED)
})
@@ -213,15 +261,123 @@ describe('tsc project selection', () => {
'packages/b/tsconfig.json': lib,
})
- expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual(['-p packages/a/tsconfig.json'])
+ expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual([
+ '-p packages/a/tsconfig.json --noEmit',
+ ])
// Tests are excluded by the package and picked up by the root instead.
- expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json'])
- expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json'])
- expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json'])
+ expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json --noEmit'])
+ expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json --noEmit'])
+ expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json --noEmit'])
expect(await plan(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([
- '-p packages/a/tsconfig.json',
- '-p packages/b/tsconfig.json',
+ '-p packages/a/tsconfig.json --noEmit',
+ '-p packages/b/tsconfig.json --noEmit',
])
expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED)
})
+
+ 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/tsconfig/middleapi/base.json b/packages/uncheck/tsconfig/middleapi/base.json
index 8e9a79b..1ebadd8 100644
--- a/packages/uncheck/tsconfig/middleapi/base.json
+++ b/packages/uncheck/tsconfig/middleapi/base.json
@@ -7,6 +7,7 @@
"module": "ES2022",
"moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
"resolveJsonModule": true,
"types": [],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b686ac3..ea3c8d8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -220,6 +220,9 @@ importers:
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
+ minimatch:
+ specifier: ^10.2.6
+ version: 10.2.6
oxfmt:
specifier: ^0.68.0
version: 0.68.0
@@ -1313,6 +1316,10 @@ packages:
axios@1.20.0:
resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==}
+ balanced-match@4.0.4:
+ resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==}
+ engines: {node: 18 || 20 || >=22}
+
baseline-browser-mapping@2.11.25:
resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==}
engines: {node: '>=6.0.0'}
@@ -1325,6 +1332,10 @@ packages:
boolbase@1.0.0:
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
+ brace-expansion@5.0.12:
+ resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==}
+ engines: {node: 20 || >=22}
+
braces@3.0.3:
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
engines: {node: '>=8'}
@@ -1791,6 +1802,10 @@ packages:
resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
engines: {node: '>= 0.6'}
+ minimatch@10.2.6:
+ resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==}
+ engines: {node: 18 || 20 || >=22}
+
minipass@3.3.6:
resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==}
engines: {node: '>=8'}
@@ -3155,12 +3170,18 @@ snapshots:
- debug
- supports-color
+ balanced-match@4.0.4: {}
+
baseline-browser-mapping@2.11.25: {}
binary-extensions@2.3.0: {}
boolbase@1.0.0: {}
+ brace-expansion@5.0.12:
+ dependencies:
+ balanced-match: 4.0.4
+
braces@3.0.3:
dependencies:
fill-range: 7.1.1
@@ -3707,6 +3728,10 @@ snapshots:
dependencies:
mime-db: 1.52.0
+ minimatch@10.2.6:
+ dependencies:
+ brace-expansion: 5.0.12
+
minipass@3.3.6:
dependencies:
yallist: 4.0.0