diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index f733fd1..48e4985 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -47,9 +47,9 @@ uncheck needs Node 22.20 or later. Install only the tools you want: a check runs $ npx uncheck uncheck in /home/me/my-app ○ sherif skipped, not installed -▶ oxlint +▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern ✔ oxlint passed 67ms -▶ oxfmt --check +▶ oxfmt --check --no-error-on-unmatched-pattern Format issues found in above 2 files. Run without `--check` to fix. ✘ oxfmt failed 65ms ▶ tsc -p tsconfig.json --noEmit @@ -83,9 +83,9 @@ npx uncheck src '!src/generated' # a directory, minus a part of it npx uncheck '!**/*.gen.ts' # everything except some files ``` -uncheck turns your paths into one file list that every tool gets, so they never disagree about what a path means. Directories and globs match the files git knows about (tracked, or new and not ignored), dot files included. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. +uncheck turns your paths into one file list that every tool gets, so they never disagree about what a path means. Directories and globs match the files git knows about (tracked, or new and not ignored), dot files included, but never links that point outside the directory uncheck runs in or into `node_modules`. Without paths, each tool finds the files itself, and oxlint checks and fixes those links too: pass `.` to leave them out. A path that exists is never read as a glob, so `'app/[id]/page.tsx'` and `'app/(marketing)/**'` just work. An exclusion glob also leaves out the folders it matches, as in `.gitignore`: `'!**/generated'`. A path that matches nothing fails the run, unless you pass `--no-error-on-unmatched-pattern`. -tsc then checks only the projects that include one of the files, and sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among them. +tsc then checks only the projects that include one of the files, and the projects that depend on those. sherif runs only when a `package.json` or `pnpm-workspace.yaml` is among the files. ## Run it before every commit @@ -101,28 +101,32 @@ Add a `prepare` script, so every clone sets up the hook on install, and run it o Every commit then runs `uncheck staged --fix`: it checks the staged files, fixes what oxlint and oxfmt can, and stages those fixes. A failing check blocks the commit, and `git commit --no-verify` skips the hook. No lint-staged or simple-git-hooks needed. -| `prepare` flag | Effect | +To change the hook, put flags in the `prepare` script and run it again, such as `"prepare": "uncheck prepare --pre-commit --only=oxlint --only=oxfmt"`. Every install runs the script, so a flag passed only by hand is undone by the next install. + +| Flag in the `prepare` script | Effect | | ------------------------------- | ---------------------------------------------------------------------- | | `--no-fix` | The hook only checks and never changes your files | | `--allow-empty` | The hook lets a commit through when the fixes undo every staged change | -| `--only`, `--skip`, `--require` | Written into the hook command | +| `--only`, `--skip`, `--require` | Pick the checks the hook runs | -Run `prepare` again with other flags to change the hook. What it guarantees: +What the hook guarantees: -- **You commit what was checked.** After `git add -p`, the unstaged part of a file is set aside while the checks run and put back afterwards, even after Ctrl-C. +- **Each staged file is checked as you staged it.** After `git add -p`, the unstaged part of a file is set aside while the checks run and put back afterwards, even after Ctrl-C. - **Nothing is lost.** If a fix clashes with your unstaged changes, every fix is undone and the commit stops. Stage the whole file, or stash the rest, and commit again. -- **Only fixes to staged files are staged.** During a merge, only files that differ from the branch being merged in are checked. +- **Only fixes to staged files are staged.** The fixes are staged once oxlint and oxfmt finish, so an edit you save while tsc runs stays unstaged. During a merge, only files that differ from the branch being merged in are checked. - **No empty commits.** If the fixes undo every staged change, the commit fails, unless you pass `--allow-empty`. Good to know: -- tsc checks whole projects, so it can report errors in files you did not stage. `--only=oxlint --only=oxfmt` keeps the hook inside the commit. +- tsc checks whole projects as they are on disk, so it can report errors in files you did not stage, and pass thanks to a file you forgot to `git add`. `--only=oxlint --only=oxfmt` keeps the hook inside the commit. +- A hook line checks the staged files in its folder and below, so a line at the root already covers every package. A package's own line adds its checks, with its flags, on top. - sherif only reports in the hook, since its fixes reach beyond the commit. Run `npx uncheck --fix` for them. - A commit no selected check covers, such as a README change with `--only=tsc`, passes. Add `--require=tsc` to make it fail. - An existing hook is kept: uncheck adds one line after its setup (comments, `source`, `export`, variables) and before its commands. With husky 9 or Vite+, it writes the `pre-commit` file they run. -- uncheck writes nothing, and says why, outside a git repository, when `core.hooksPath` comes from your global or system git config, or when the existing hook is not a shell script. Your install keeps working. +- uncheck writes nothing, and says why, outside a git repository, when git refuses the repository, when `core.hooksPath` comes from your global or system git config, or when the existing hook is not a shell script. Your install keeps working. - The hook runs uncheck through your package manager (`pnpm exec`, `yarn run --silent`, `bunx --no-install` or `npx --no`), so a missing install fails instead of downloading uncheck. - Yarn 2+ does not run `prepare`. Use `postinstall` instead, and in a package you publish, turn it off while packing, for example with `"prepack": "pinst --disable"` and `"postpack": "pinst --enable"`. +- Installs that leave out devDependencies (`npm ci --omit=dev`, `NODE_ENV=production`, `bun install --production`, `yarn workspaces focus --production`) still run `prepare` or `postinstall`, but without uncheck. Append `|| exit 0` so they pass: `"prepare": "uncheck prepare --pre-commit || exit 0"`. ## Run it after every agent turn @@ -138,33 +142,37 @@ npx uncheck hooks install # or pick them from a list | Cursor | `cursor` | `.cursor/hooks.json` | | GitHub Copilot | `copilot` | `.github/hooks/uncheck.json` | -Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what it can, and when problems remain, sends the agent back to fix them. That happens at most once per turn, so an agent that cannot fix something is never stuck in a loop. +Whenever the agent finishes a turn, the hook runs `uncheck hooks run --fix`. It checks the files changed since the last commit, fixes what oxlint and oxfmt can, and when problems remain, sends the agent back to fix them. It sends the agent back again only after the checks have passed in between, whatever other hooks do, so an agent that cannot fix something is never stuck in a loop. +- **sherif only reports** here, since its fixes reach beyond the agent's change: they move versions in other packages and run your install. Run `npx uncheck --fix` for them. - **Too slow?** Leave the typecheck to CI: `npx uncheck hooks install claude --only=oxlint --only=oxfmt`. Install again to change the flags. +- **Outside git** and before the first commit, the hook checks the whole folder, and oxlint also fixes the files that links in it point to. In a repository git refuses, such as one another user owns, the hook checks nothing and shows git's message. +- **Installed from a package?** The hook finds uncheck only where your package manager can run it, so add uncheck to the workspace root too when the agent may work outside that package. - **Your config is kept.** Other hooks and settings stay, and installing again only updates uncheck's entry. Comments in the file are lost when it is rewritten. - **Avoid double runs.** Cursor and Copilot CLI also run the hooks in `.claude/settings.json`, so add `cursor` or `copilot` next to `claude` only where they do not read that file. - **Copilot** reads `.github/hooks` only at the top of the repository, so install `copilot` from there. ## Monorepos -Run uncheck from the workspace root, the folder whose `package.json` has `workspaces` or that has a `pnpm-workspace.yaml`, to check the whole monorepo. +Run uncheck from the workspace root, the folder whose `package.json` has `workspaces` or whose `pnpm-workspace.yaml` lists `packages`, to check the whole monorepo. -**sherif** checks the workspace as a whole, so it only runs at the root. Configure it in the `sherif` field of the root `package.json`, [as sherif documents](https://github.com/QuiiBz/sherif). With `--fix`, mismatched versions move to the highest one (unless you set `select`), and your install runs afterwards (unless you set `"noInstall": true`). When `CI` is set, sherif only reports. +**sherif** checks the workspace as a whole, so it only runs at the root. Configure it in the `sherif` field of the root `package.json`, [as sherif documents](https://github.com/QuiiBz/sherif). With `--fix`, mismatched versions move to the highest one (unless you set `select`), and your install runs afterwards (unless you set `"noInstall": true`). When `CI` is set, sherif only reports. Leave out `"fix": true`, since uncheck decides when sherif fixes: a run that only reports fails while it is set. **TypeScript.** uncheck finds every `tsconfig.json` and follows their `references`: -- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. -- Every other project is checked with `tsc -p --noEmit`, a few at a time. -- When only some files are checked, tsc runs just the projects that include them, and the projects that reference those. A changed tsconfig selects every project that extends it. +- Projects linked by `references` are built with one `tsc -b`, which writes what your configs ask for, such as declarations. A project that sets none of `noEmit`, `emitDeclarationOnly`, `outDir` and `outFile` gets JavaScript next to its sources. When git ignores that JavaScript, the project builds in place on purpose and stays in `tsc -b`. Otherwise, as with Vite's `tsconfig.node.json`, it and the projects that reference it are checked with `tsc -p --noEmit` instead, after `tsc -b` builds the projects they reference. These cannot import each other: tsc reports TS6305. +- Every other project is checked with `tsc -p --noEmit`, a few at a time. A `tsconfig.json` that other configs extend and that includes no files is a shared base, not a project. +- When only some files are checked, tsc runs just the projects that include them, the projects that reference those, and the projects of the packages that depend on the files' packages. A changed tsconfig selects every project that extends it. In the hooks, a deleted or moved file also selects the projects that included or extended it. +- In a folder without its own `tsconfig.json`, such as a package that shares the root one, uncheck also uses the nearest one above it in the same git repository, when it includes files of that folder. -**Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags: +**Hooks.** Each package that runs `uncheck prepare --pre-commit` gets its own line in the one pre-commit hook, with its own flags. A package's line runs only when the commit changes files in that package: ```sh #!/bin/sh # Written by `uncheck prepare`, run it again to change the command. pnpm exec uncheck staged --fix || exit 1 -(cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1 -(cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1 +git --literal-pathspecs diff --cached --quiet -- "packages/a" || [ ! -d "packages/a" ] || (cd "packages/a" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1 +git --literal-pathspecs diff --cached --quiet -- "packages/b" || [ ! -d "packages/b" ] || (cd "packages/b" && pnpm exec uncheck staged --fix) || exit 1 ``` An agent hook installed from a package folder checks only that package, wherever the agent moves to. @@ -199,7 +207,7 @@ export default defineConfig({ ...middleapi }) } ``` -The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+. The tsconfig presets load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. +The presets need oxlint 1.70+, oxfmt 0.43+ and TypeScript 5.6+. The tsconfig presets load no runtime types, so name yours: `"types": ["node"]` for Node.js, or `"lib": ["ES2022", "DOM", "DOM.Iterable"]` for browsers. ## Troubleshooting @@ -207,6 +215,8 @@ The presets need oxlint 1.70+, oxfmt 0.41+ and TypeScript 5.6+. The tsconfig pre **`uncheck dist` says "No files match".** git ignores that folder, so it holds no project files. You can still name an ignored file directly. +**git refuses the repository ("detected dubious ownership").** git does not trust a repository another user owns, such as a checkout mounted into a container, so `prepare` writes no hook and `uncheck staged` and the agent hook check nothing. Run the `safe.directory` command `git status` prints there, then try again. + **A commit stops with "An earlier run left the unstaged versions of your files in …".** A pre-commit run was killed before it could put your unstaged changes back. Copy what your files are missing from the folder the message names, delete the folder, and commit again. ## Sponsors diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts index 80336c9..be733cf 100644 --- a/packages/uncheck/src/checks/oxfmt.ts +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -14,17 +14,13 @@ export const oxfmt: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - const args = fix ? [] : ['--check'] + // A folder with nothing oxfmt handles, or a given file it does not (a .txt file), is no failure. + const args = [...(fix ? [] : ['--check']), '--no-error-on-unmatched-pattern'] if (files === undefined) { return [{ bin, args }] } - // Given files may include ones oxfmt does not handle (a .txt file), which is not a failure. - return argvBatches(files).map((batch) => ({ - bin, - args: [...args, '--no-error-on-unmatched-pattern'], - files: batch, - })) + return argvBatches(files).map((batch) => ({ bin, args, files: batch })) }), } diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts index 0d4fcc3..6b0fffa 100644 --- a/packages/uncheck/src/checks/oxlint.ts +++ b/packages/uncheck/src/checks/oxlint.ts @@ -14,17 +14,19 @@ export const oxlint: Check = { return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) } - const args = fix ? ['--fix'] : [] + const args = [ + ...(fix ? ['--fix'] : []), + // oxlint walks into node_modules unless an ignore file says not to, and a fix there rewrites + // installed packages, through pnpm's hard links even those of its shared store. + ...(files === undefined ? ['--ignore-pattern=node_modules'] : []), + // A folder with nothing oxlint handles, or a given file it does not (a .md file), is no failure. + '--no-error-on-unmatched-pattern', + ] if (files === undefined) { return [{ bin, args }] } - // Given files may include ones oxlint does not handle (a Markdown file), which is not a failure. - return argvBatches(files).map((batch) => ({ - bin, - args: [...args, '--no-error-on-unmatched-pattern'], - files: batch, - })) + return argvBatches(files).map((batch) => ({ bin, args, files: batch })) }), } diff --git a/packages/uncheck/src/checks/sherif.ts b/packages/uncheck/src/checks/sherif.ts index a5f2a0e..87e712b 100644 --- a/packages/uncheck/src/checks/sherif.ts +++ b/packages/uncheck/src/checks/sherif.ts @@ -2,39 +2,49 @@ import process from 'node:process' import { Effect, FileSystem, Path, Predicate } from 'effect' -import { NothingToCheck } from '../errors' +import { CannotCheck, NothingToCheck } from '../errors' import { readJson } from '../files' import { resolveBin } from '../tool' import type { Check } from '../types' const WORKSPACE_FILES = new Set(['package.json', 'pnpm-workspace.yaml']) +// pnpm also keeps its settings in pnpm-workspace.yaml, and sherif fails on one without packages. +const DECLARES_PACKAGES = /^["']?packages["']?\s*:/m + export const sherif: Check = { name: 'sherif', fixes: 'workspace', - plan: Effect.fn(function* ({ cwd, fix, files }) { + plan: Effect.fn(function* ({ cwd, fix, files, deleted }) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - if (files?.some((file) => WORKSPACE_FILES.has(path.basename(file))) === false) { + const bin = yield* resolveBin('sherif', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + if ( + files !== undefined && + ![...files, ...deleted].some((file) => WORKSPACE_FILES.has(path.basename(file))) + ) { return yield* Effect.fail( - new NothingToCheck({ reason: 'no package.json among the given files' }), + new NothingToCheck({ reason: 'no package.json among the given files', unrelated: true }), ) } - const [bin, manifest, pnpmWorkspace] = yield* Effect.all( + const [manifest, pnpmWorkspace] = yield* Effect.all( [ - resolveBin('sherif', cwd), readJson(path.join(cwd, 'package.json')), - fs.exists(path.join(cwd, 'pnpm-workspace.yaml')).pipe(Effect.orElseSucceed(() => false)), + fs.readFileString(path.join(cwd, 'pnpm-workspace.yaml')).pipe( + Effect.map((text) => DECLARES_PACKAGES.test(text)), + Effect.orElseSucceed(() => false), + ), ], { concurrency: 'unbounded' }, ) - if (bin === undefined) { - return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) - } - if (manifest === undefined) { return yield* Effect.fail(new NothingToCheck({ reason: 'no package.json found' })) } @@ -44,6 +54,16 @@ export const sherif: Check = { } if (!fix || process.env.CI !== undefined) { + // sherif reads this setting on every run, and no flag turns it off. + if (Predicate.hasProperty(manifest.sherif, 'fix') && manifest.sherif.fix === true) { + return yield* Effect.fail( + new CannotCheck({ + reason: + 'would fix files while uncheck only reports, remove "fix": true from the sherif field of package.json', + }), + ) + } + return [{ bin, args: [] }] } diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts index e82ce7e..441751a 100644 --- a/packages/uncheck/src/checks/tsc.ts +++ b/packages/uncheck/src/checks/tsc.ts @@ -1,11 +1,20 @@ import { posix } from 'node:path' -import { Effect, FileSystem, Option, Path, Predicate } from 'effect' +import { Cache, Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' import { CannotCheck, NothingToCheck } from '../errors' -import { ancestors, readJson } from '../files' -import { resolveBin } from '../tool' +import { + ancestors, + fileKind, + isOutside, + listProjectFiles, + readJson, + resolveFolders, +} from '../files' +import { git } from '../git' +import type { Bin } from '../tool' +import { argvBatches, resolveBin } from '../tool' import type { Check } from '../types' /** @@ -15,33 +24,50 @@ import type { Check } from '../types' // oxlint-disable-next-line no-template-curly-in-string const CONFIG_DIR = '${configDir}' -const NOT_COVERED = 'no tsconfig.json covers the given files' +function notCovered( + typescript: Bin | undefined, + files: ReadonlyArray | undefined, +): NothingToCheck { + return typescript === undefined + ? new NothingToCheck({ reason: 'not installed' }) + : new NothingToCheck({ + reason: `no tsconfig.json covers ${files === undefined ? 'this folder' : 'the given files'}`, + unrelated: true, + }) +} + +/** + * Composite projects still write their `.tsbuildinfo` under `--noEmit`, and without `composite`, + * `emitDeclarationOnly` fails unless `declaration` is set. + */ +const WITHOUT_BUILD_INFO = ['--composite', 'false', '--declaration'] export const tsc: Check = { name: 'tsc', fixes: false, - plan: Effect.fn(function* ({ cwd, files, projectFiles }) { + plan: Effect.fn(function* ({ cwd, files, deleted, projectFiles }) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const targets = files - ?.map((file) => path.resolve(cwd, file)) - .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) + const checkable = (relativeFiles: ReadonlyArray) => + relativeFiles + .map((file) => path.resolve(cwd, file)) + .filter((file) => CHECKABLE_EXTENSIONS.has(posix.extname(file))) - if (targets !== undefined && targets.length === 0) { - return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) - } + // Deleted files are among the targets, so selecting a project must never need one on disk. + const given = files === undefined ? undefined : checkable([...files, ...deleted]) - const [typescript, tsconfigs] = yield* Effect.all( + const [typescript, tracked] = yield* Effect.all( [ resolveBin('typescript', cwd, 'tsc'), Effect.flatMap(projectFiles, (all) => - // Tracked files can be deleted from the working tree without being staged yet. + // Tracked files can be deleted from the working tree without being staged yet, while a + // path `exists` cannot reach is left for tsc to report. Effect.filter( all .filter((file) => path.basename(file) === 'tsconfig.json') .map((file) => path.resolve(cwd, file)), - (candidate) => fs.exists(candidate), + (candidate) => fs.exists(candidate).pipe(Effect.orElseSucceed(() => true)), { concurrency: 'unbounded' }, ), ), @@ -49,40 +75,80 @@ export const tsc: Check = { { concurrency: 'unbounded' }, ) - if (tsconfigs.length === 0) { + const inherited = tracked.includes(path.resolve(cwd, 'tsconfig.json')) + ? undefined + : Option.getOrUndefined(yield* tsconfigAboveInRepository(cwd)) + + if (inherited === undefined && tracked.length === 0) { return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) } - const references = new Map>() - const queue = [...tsconfigs] + const uncovered = notCovered(typescript, files) + + if (given !== undefined && given.length === 0) { + return yield* Effect.fail(uncovered) + } + + // A config above the folder may cover far more than it, so even a full run selects by its files. + const targets = given ?? (inherited === undefined ? undefined : checkable(yield* projectFiles)) + const tsconfigs = yield* cachedTsconfigs + const references = yield* followReferences( + inherited === undefined ? tracked : [...tracked, inherited], + tsconfigs, + ) + const inputs = new Map( + yield* Effect.forEach( + new Set([...references.keys(), ...tracked]), + (configPath) => + Effect.map( + loadTsconfigInputs(configPath, tsconfigs), + (loaded) => [configPath, loaded] as const, + ), + { concurrency: 'unbounded' }, + ), + ) - while (queue.length > 0) { - const configPath = queue.pop()! + let projects: ReadonlyArray = tracked - if (!references.has(configPath) && (yield* fs.exists(configPath))) { - const referencedConfigs = yield* readReferences(configPath) + if (targets !== undefined) { + const selected = yield* selectTsconfigs( + references, + inputs, + targets, + deleted.map((file) => path.resolve(cwd, file)), + tsconfigs.realPath, + ) + const manifests = (yield* projectFiles) + .filter((file) => file === 'package.json' || file.endsWith('/package.json')) + .map((file) => path.resolve(cwd, file)) + const dependentPackages = yield* workspaceDependents( + [...references.keys()], + [...selected, ...(given ?? []).filter((file) => posix.extname(file) !== '.json')], + manifests, + ) - references.set(configPath, referencedConfigs) - queue.push(...referencedConfigs) - } + projects = [...new Set([...selected, ...dependentPackages])] } - const selected = - targets === undefined ? tsconfigs : yield* selectTsconfigs([...references.keys()], targets) - - if (selected.length === 0) { - return yield* Effect.fail(new NothingToCheck({ reason: NOT_COVERED })) + if (projects.length === 0) { + return yield* Effect.fail(uncovered) } if (typescript === undefined) { return yield* Effect.fail( new CannotCheck({ - reason: `found ${selected.length} tsconfig.json but typescript is not installed`, + reason: `found ${projects.length} tsconfig.json but typescript is not installed`, }), ) } - const shown = (configPath: string) => path.relative(cwd, configPath) + const shown = (configPath: string) => { + const relative = path.relative(cwd, configPath) + + // tsc reads an argument starting with `-` as an option and one starting with `@` as a file of + // arguments, even after `-b`. + return /^[-@]/.test(relative) ? `./${relative}` : relative + } const referenced = new Set([...references.values()].flat()) const members = [...references] .filter( @@ -102,40 +168,344 @@ export const tsc: Check = { ) } - const dependents = new Map() - - for (const [dependent, referencedConfigs] of references) { - for (const configPath of referencedConfigs) { - const known = dependents.get(configPath) ?? [] + const dependents = reverseEdges(references.keys(), (configPath) => references.get(configPath)!) + const dependentsOf = (configPath: string) => dependents.get(configPath) ?? [] + const affected = reachable(targets === undefined ? references.keys() : projects, dependentsOf) + const buildsBesideSources = reachable( + yield* writingBesideSources(cwd, yield* projectFiles, members, inputs), + dependentsOf, + ) + const standalone = yield* withoutSharedBases( + cwd, + yield* projectFiles, + projects.filter((configPath) => !members.includes(configPath)), + inputs, + tsconfigs.realPath, + ) + const built = new Set( + members.filter( + (member) => + !referenced.has(member) && affected.has(member) && !buildsBesideSources.has(member), + ), + ) + const checkedAlone = new Map>( + standalone.map((configPath) => [configPath, []]), + ) - known.push(dependent) - dependents.set(configPath, known) + for (const member of members) { + if (affected.has(member) && buildsBesideSources.has(member)) { + const memberInputs = inputs.get(member)! + + // A solution without sources needs `-p` too, or a reference to a missing config passes. + checkedAlone.set(member, memberInputs.composite ? WITHOUT_BUILD_INFO : []) + + // `-p` reads a referenced project through the declarations on disk, which a fresh checkout + // lacks and an earlier build left stale. + for (const referencedConfig of references.get(member)!) { + if ( + references.has(referencedConfig) && + !buildsBesideSources.has(referencedConfig) && + (hasInputs(memberInputs) || affected.has(referencedConfig)) + ) { + built.add(referencedConfig) + } + } } } - const affected = new Set(selected) - - for (const configPath of affected) { - for (const dependent of dependents.get(configPath) ?? []) { - affected.add(dependent) - } + if (built.size === 0 && checkedAlone.size === 0) { + return yield* Effect.fail(uncovered) } - 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)] }] : []), + ...(built.size > 0 + ? [{ bin: typescript, args: ['-b', ...[...built].sort().map(shown)] }] + : []), // `-p` would emit JavaScript next to sources that set no `noEmit`. - ...standalone.map((configPath) => ({ + ...[...checkedAlone.keys()].sort().map((configPath) => ({ bin: typescript, - args: ['-p', shown(configPath), '--noEmit'], + args: ['-p', shown(configPath), '--noEmit', ...checkedAlone.get(configPath)!], parallel: true, })), ] }), } +const followReferences = Effect.fn(function* ( + configPaths: ReadonlyArray, + tsconfigs: Tsconfigs, +) { + const references = new Map>() + const queue = [...configPaths] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (!references.has(configPath) && (yield* fileKind(configPath)) === 'File') { + const referencedConfigs = yield* readReferences(configPath, tsconfigs) + + references.set(configPath, referencedConfigs) + queue.push(...referencedConfigs) + } + } + + return references +}) + +const OUTPUT_EXTENSIONS = new Map([ + ['.ts', '.js'], + ['.tsx', '.js'], + ['.mts', '.mjs'], + ['.cts', '.cjs'], +]) + +function outputBeside(source: string): string | undefined { + const extension = posix.extname(source) + const output = OUTPUT_EXTENSIONS.get(extension) + + return output === undefined || /\.d\.[cm]?ts$/.test(source) + ? undefined + : `${source.slice(0, -extension.length)}${output}` +} + +// `tsc -b` on these would write JavaScript next to the sources of a project, and Vite, for one, +// then loads a stale vite.config.js instead of vite.config.ts. Where git ignores that JavaScript, +// the project builds in place on purpose, and `-p` would read the declarations of its last build. +const writingBesideSources = Effect.fn(function* ( + cwd: string, + projectFiles: ReadonlyArray, + members: ReadonlyArray, + inputs: ReadonlyMap, +) { + const path = yield* Path.Path + const outputs = yield* Effect.forEach( + members.filter((member) => { + const memberInputs = inputs.get(member)! + + return hasInputs(memberInputs) && memberInputs.emitsBesideSources + }), + (member) => + Effect.map( + outputBesideAnInput(cwd, projectFiles, member, inputs.get(member)!), + (output) => [member, output] as const, + ), + { concurrency: 'unbounded' }, + ) + const written = outputs.map(([, output]) => output).filter(Predicate.isNotUndefined) + const ignored = yield* ignoredByGit(cwd, [ + ...argvBatches(written.filter((output) => !isOutside(path, output))), + // git refuses every path given along with one outside its repository or behind a link. + ...written.filter((output) => isOutside(path, output)).map((output) => [output]), + ]) + + return outputs + .filter(([, output]) => output === undefined || !ignored.has(output)) + .map(([member]) => member) +}) + +const outputBesideAnInput = Effect.fn(function* ( + cwd: string, + projectFiles: ReadonlyArray, + configPath: string, + inputs: TsconfigInputs, +) { + const path = yield* Path.Path + const configDir = path.dirname(configPath) + const files = ancestors(path, configDir).includes(cwd) + ? projectFiles + : (yield* listProjectFiles(configDir)).map((file) => path.resolve(configDir, file)) + const isInput = (file: string) => + outputBeside(file) !== undefined && includesFile(inputs, path.resolve(cwd, file)) + const relativeConfig = path.relative(cwd, configPath) + const input = + findInFolder(files, relativeConfig.slice(0, relativeConfig.lastIndexOf('/') + 1), isInput) ?? + files.find(isInput) + + return input === undefined ? undefined : outputBeside(input) +}) + +/** Needs `files` sorted, as listProjectFiles leaves them. */ +function findInFolder( + files: ReadonlyArray, + folder: string, + predicate: (file: string) => boolean, +): string | undefined { + let low = 0 + let high = files.length + + while (low < high) { + const middle = Math.floor((low + high) / 2) + + if (files[middle]! < folder) { + low = middle + 1 + } else { + high = middle + } + } + + for (let index = low; index < files.length && files[index]!.startsWith(folder); index += 1) { + if (predicate(files[index]!)) { + return files[index] + } + } + + return undefined +} + +const ignoredByGit = Effect.fn(function* ( + cwd: string, + batches: ReadonlyArray>, +) { + const printed = yield* Effect.forEach( + batches, + (batch) => + // check-ignore refuses the literal pathspecs `git` turns on and exits with 1 when it ignores + // none. It takes `-z` only with `--stdin`, so it quotes a path holding a control character, + // `"` or `\`, which then matches no output. + git(cwd, ['-c', 'core.quotePath=false', 'check-ignore', '--no-index', '--', ...batch], { + GIT_LITERAL_PATHSPECS: '0', + }).pipe( + Effect.map((output) => output.split('\n')), + Effect.orElseSucceed(() => []), + ), + { concurrency: 'unbounded' }, + ) + + return new Set(printed.flat()) +}) + +const tsconfigAboveInRepository = Effect.fn(function* (cwd: string) { + const path = yield* Path.Path + const dirs = ancestors(path, cwd) + // A tsconfig.json above the repository, say in the home folder, belongs to another project. + const top = yield* Effect.findFirst(dirs, (dir) => + Effect.map(fileKind(path.join(dir, '.git')), (kind) => kind !== undefined), + ) + const searched = Option.match(top, { + onNone: () => [], + onSome: (dir) => dirs.slice(1, dirs.indexOf(dir) + 1), + }) + + return yield* firstFile(searched.map((dir) => path.join(dir, 'tsconfig.json'))) +}) + +/** + * A shared base kept as a tsconfig.json, the way `@tsconfig/bases` packages ship theirs, has no + * sources of its own: tsc fails it with TS18003, and the configs extending it are checked anyway. + */ +const withoutSharedBases = Effect.fn(function* ( + cwd: string, + projectFiles: ReadonlyArray, + configPaths: ReadonlyArray, + inputs: ReadonlyMap, + realPath: (file: string) => Effect.Effect, +) { + if (configPaths.length === 0) { + return configPaths + } + + const path = yield* Path.Path + const bases = new Set([...inputs.values()].flatMap(({ configs }) => configs.slice(0, -1))) + const extended = new Set(yield* Effect.forEach(bases, realPath, { concurrency: 'unbounded' })) + + return yield* Effect.filter( + configPaths, + (configPath) => + Effect.map( + realPath(configPath), + (real) => + !extended.has(real) || + projectFiles.some((file) => + includesFile(inputs.get(configPath)!, path.resolve(cwd, file)), + ), + ), + { concurrency: 'unbounded' }, + ) +}) + +const DEPENDENCY_FIELDS = [ + 'dependencies', + 'devDependencies', + 'peerDependencies', + 'optionalDependencies', +] as const + +interface WorkspacePackage { + readonly dir: string + readonly name: unknown + readonly dependencies: ReadonlyArray +} + +/** A package imported through its workspace link needs no project reference. */ +const workspaceDependents = Effect.fn(function* ( + candidates: ReadonlyArray, + changed: ReadonlyArray, + manifests: ReadonlyArray, +) { + const path = yield* Path.Path + const packages = yield* Effect.forEach( + manifests, + (manifestPath) => + Effect.map(readJson(manifestPath), (manifest): WorkspacePackage => ({ + dir: path.dirname(manifestPath), + name: manifest?.name, + dependencies: DEPENDENCY_FIELDS.flatMap((field) => { + const versions = manifest?.[field] + return Predicate.isObject(versions) ? Object.keys(versions) : [] + }), + })), + // A manifest that fails to open for want of file descriptors would silently drop its package. + { concurrency: 64 }, + ) + const packageDirs = new Set(packages.map(({ dir }) => dir)) + const ownerOf = (file: string) => + ancestors(path, path.dirname(file)).find((dir) => packageDirs.has(dir)) + + const owners = new Set(changed.map(ownerOf)) + const dependents = reverseEdges( + packages, + ({ dependencies }) => dependencies, + ) + const dependentsOf = ({ name }: WorkspacePackage) => dependents.get(name) ?? [] + const reached = reachable( + packages.filter(({ dir }) => owners.has(dir)).flatMap(dependentsOf), + dependentsOf, + ) + const dependentDirs = new Set([...reached].map(({ dir }) => dir)) + + return candidates.filter((configPath) => { + const owner = ownerOf(configPath) + return owner !== undefined && dependentDirs.has(owner) + }) +}) + +function reverseEdges(nodes: Iterable, edges: (node: T) => Iterable): Map { + const reversed = new Map() + + for (const node of nodes) { + for (const target of edges(node)) { + const sources = reversed.get(target) ?? [] + + sources.push(node) + reversed.set(target, sources) + } + } + + return reversed +} + +function reachable(seeds: Iterable, next: (node: T) => Iterable): Set { + const reached = new Set(seeds) + + for (const node of reached) { + for (const neighbour of next(node)) { + reached.add(neighbour) + } + } + + return reached +} + function findCycle( nodes: ReadonlyArray, references: ReadonlyMap>, @@ -162,8 +532,11 @@ function findCycle( } const selectTsconfigs = Effect.fn(function* ( - candidates: ReadonlyArray, + references: ReadonlyMap>, + inputs: ReadonlyMap, files: ReadonlyArray, + deleted: ReadonlyArray, + realPath: (file: string) => Effect.Effect, ) { const jsonFiles = yield* Effect.forEach( files.filter((file) => posix.extname(file) === '.json'), @@ -172,18 +545,21 @@ const selectTsconfigs = Effect.fn(function* ( ) 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)), - ), - ), + [...references.keys()], + (candidate) => { + const candidateInputs = inputs.get(candidate)! + + // A deleted config is no candidate, so only the configs that reference it lead to its dependents. + return references.get(candidate)!.some((config) => deleted.includes(config)) || + files.some((file) => includesFile(candidateInputs, file)) + ? Effect.succeed(true) + : jsonFiles.length === 0 + ? Effect.succeed(false) + : Effect.map( + Effect.forEach(candidateInputs.configs, realPath, { concurrency: 'unbounded' }), + (configs) => configs.some((config) => jsonFiles.includes(config)), + ) + }, { concurrency: 'unbounded' }, ) }) @@ -194,8 +570,13 @@ const selectTsconfigs = Effect.fn(function* ( */ const realPath = Effect.fn(function* (file: string) { const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path - return yield* fs.realPath(file).pipe(Effect.orElseSucceed(() => file)) + // A deleted file has no real path, while the folder it was in can still be reached through a link. + return yield* fs.realPath(file).pipe( + Effect.catch(() => Effect.try(() => resolveFolders(path, file))), + Effect.orElseSucceed(() => file), + ) }) interface RawTsconfig { @@ -220,11 +601,44 @@ const readTsconfig = Effect.fn( Effect.orElseSucceed((): RawTsconfig => ({})), ) +const resolveBases = Effect.fn(function* (configPath: string, raw: RawTsconfig) { + const path = yield* Path.Path + const specs = + typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : [] + const bases = yield* Effect.forEach(specs, (spec) => + resolveExtends(spec.replaceAll('\\', '/'), path.dirname(configPath)), + ) + + return bases.flatMap(Option.toArray) +}) + +interface Tsconfigs { + readonly read: (configPath: string) => Effect.Effect + readonly bases: (configPath: string) => Effect.Effect> + readonly realPath: (file: string) => Effect.Effect +} + +function memoized(lookup: (key: string) => Effect.Effect) { + return Effect.map( + Cache.make({ capacity: Number.POSITIVE_INFINITY, lookup }), + (cache) => (key: string) => Cache.get(cache, key), + ) +} + +const cachedTsconfigs = Effect.gen(function* () { + const read = yield* memoized(readTsconfig) + const bases = yield* memoized((configPath: string) => + Effect.flatMap(read(configPath), (raw) => resolveBases(configPath, raw)), + ) + + return { read, bases, realPath: yield* memoized(realPath) } +}) + /** `references` are never inherited through `extends`, so only the file itself is read. */ -const readReferences = Effect.fn(function* (configPath: string) { +const readReferences = Effect.fn(function* (configPath: string, tsconfigs: Tsconfigs) { const path = yield* Path.Path const configDir = path.dirname(configPath) - const { references } = yield* readTsconfig(configPath) + const { references } = yield* tsconfigs.read(configPath) return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { if (!Predicate.isObject(reference) || typeof reference.path !== 'string') { @@ -248,6 +662,12 @@ interface TsconfigInputs { readonly include: ReadonlyArray readonly exclude: ReadonlyArray readonly extensions: ReadonlySet + readonly emitsBesideSources: boolean + readonly composite: boolean +} + +function hasInputs(inputs: TsconfigInputs): boolean { + return inputs.files.length > 0 || inputs.include.length > 0 } const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] @@ -264,15 +684,15 @@ interface Specs { * `tsc` does: each field comes from the last config in the `extends` chain that sets it, relative * paths resolve against the config that set them, and `${configDir}` means the leaf config's folder. */ -const loadTsconfigInputs = Effect.fn(function* (configPath: string) { +const loadTsconfigInputs = Effect.fn(function* (configPath: string, tsconfigs: Tsconfigs) { const path = yield* Path.Path - const chain = yield* loadExtendsChain(configPath, new Set()) + const chain = yield* loadExtendsChain(configPath, new Set(), tsconfigs) const leafDir = path.dirname(configPath) const last: Partial< - Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir', Specs> + Record<'files' | 'include' | 'exclude' | 'outDir' | 'declarationDir' | 'outFile', Specs> > = {} - let allowJs = false + const flags = { allowJs: false, noEmit: false, emitDeclarationOnly: false, composite: false } for (const { dir, raw } of chain) { for (const key of ['files', 'include', 'exclude'] as const) { @@ -286,7 +706,7 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { if (Predicate.isObject(raw.compilerOptions)) { const { compilerOptions } = raw - for (const key of ['outDir', 'declarationDir'] as const) { + for (const key of ['outDir', 'declarationDir', 'outFile'] as const) { const spec = compilerOptions[key] if (typeof spec === 'string') { @@ -294,17 +714,22 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { } } - if (typeof compilerOptions.allowJs === 'boolean') { - allowJs = compilerOptions.allowJs + for (const key of ['allowJs', 'noEmit', 'emitDeclarationOnly', 'composite'] as const) { + const value = compilerOptions[key] + + if (typeof value === 'boolean') { + flags[key] = value + } } if (compilerOptions.checkJs === true) { - allowJs = true + flags.allowJs = true } } } - const { files, include, exclude, outDir, declarationDir } = last + const { files, include, exclude, outDir, declarationDir, outFile } = last + const { allowJs, noEmit, emitDeclarationOnly, composite } = flags const resolve = (specs: Specs | undefined): string[] => specs?.specs.map((spec) => @@ -330,6 +755,9 @@ const loadTsconfigInputs = Effect.fn(function* (configPath: string) { }), exclude: excludeSpecs.map((spec) => ({ spec, matches: compileGlob(spec, 'exclude')! })), extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), + emitsBesideSources: + !noEmit && !emitDeclarationOnly && outDir === undefined && outFile === undefined, + composite, } }) @@ -370,52 +798,40 @@ interface ChainEntry { const loadExtendsChain = Effect.fn(function* ( configPath: string, resolving: ReadonlySet, -): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + tsconfigs: Tsconfigs, +): Effect.fn.Return, never, Path.Path> { if (resolving.has(configPath)) { return [] } const path = yield* Path.Path - const raw = yield* readTsconfig(configPath) - const dir = path.dirname(configPath) - const specs = - typeof raw.extends === 'string' ? [raw.extends] : isStringArray(raw.extends) ? raw.extends : [] - - const bases = yield* Effect.forEach(specs, (spec) => - resolveExtends(spec.replaceAll('\\', '/'), dir).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed>([]), - onSome: (base) => loadExtendsChain(base, new Set(resolving).add(configPath)), - }), - ), - ), + const raw = yield* tsconfigs.read(configPath) + const chains = yield* Effect.forEach(yield* tsconfigs.bases(configPath), (base) => + loadExtendsChain(base, new Set(resolving).add(configPath), tsconfigs), ) - return [...bases.flat(), { file: configPath, dir, raw }] + return [...chains.flat(), { file: configPath, dir: path.dirname(configPath), raw }] }) +const firstFile = (candidates: ReadonlyArray) => + Effect.findFirst(candidates, (candidate) => + Effect.map(fileKind(candidate), (type) => type === 'File'), + ) + const resolveExtends = Effect.fn(function* (spec: string, dir: string) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - const kind = (target: string) => - fs.stat(target).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => undefined), - ) - - const firstFile = (candidates: ReadonlyArray) => - Effect.findFirst(candidates, (candidate) => - Effect.map(kind(candidate), (type) => type === 'File'), - ) - if (spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec)) { const target = path.resolve(dir, spec) - return yield* firstFile([target, `${target}.json`]) + + // A deleted base must still select the configs that extend it. + return Option.orElseSome(yield* firstFile([target, `${target}.json`]), () => + target.endsWith('.json') ? target : `${target}.json`, + ) } const [name, subpath] = splitPackageSpec(spec) + let deletedBase: string | undefined for (const current of ancestors(path, dir)) { const pkg = path.join(current, 'node_modules', name) @@ -424,11 +840,11 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { // Like tsc, a package that declares `exports` is reachable only through them. if (pkgManifest?.exports !== undefined) { const target = resolveExports(pkgManifest.exports, subpath) - return target === undefined ? Option.none() : yield* firstFile([path.resolve(pkg, target)]) + return target === undefined ? Option.none() : Option.some(path.resolve(pkg, target)) } const base = path.join(current, 'node_modules', spec) - const baseKind = yield* kind(base) + const baseKind = yield* fileKind(base) if (baseKind === 'File') { return Option.some(base) @@ -451,9 +867,14 @@ const resolveExtends = Effect.fn(function* (spec: string, dir: string) { if (Option.isSome(found)) { return found } + + if (pkgManifest !== undefined) { + deletedBase ??= + baseKind === 'Directory' ? candidates[1] : base.endsWith('.json') ? base : candidates[0] + } } - return Option.none() + return Option.fromUndefinedOr(deletedBase) }) const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index 308ddb5..ee629f2 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -1,3 +1,5 @@ +import { createHash } from 'node:crypto' +import { tmpdir } from 'node:os' import process from 'node:process' import { stripVTControlCharacters } from 'node:util' @@ -5,18 +7,19 @@ import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } f import { Command, Flag } from 'effect/unstable/cli' import { StopBlocked, userError } from '../../errors' -import { listChangedFiles } from '../../files' -import { git } from '../../git' +import { fileKind, isOutside, listChangedFiles, slashedRelative } from '../../files' +import { git, refusesRepository } from '../../git' import { captureLines } from '../../tool' -import { fixFlag, runChecks, selectionFlags } from '../uncheck' +import { fixFlag, followLinks, runChecks, selectionFlags } from '../uncheck' export const run = Command.make( 'run', { cwd: Flag.Directory('cwd', { mustExist: true }).pipe( + followLinks, Flag.optional, Flag.withDescription( - 'Directory to check. Defaults to the top of the git repository around the current directory, or the current directory outside git', + "Directory to check. Defaults to the top of the git repository around the current directory, or around the agent's project (CLAUDE_PROJECT_DIR or CODEBUDDY_PROJECT_DIR) when the current directory is outside git or in a repository nested in the project's, and to the project itself outside git", ), ), dir: Flag.String('dir').pipe( @@ -25,7 +28,11 @@ export const run = Command.make( 'Directory to check relative to the top of the git repository, whichever directory the agent moved to. `hooks install` writes it for a project below the top', ), ), - fix: fixFlag, + fix: fixFlag.pipe( + Flag.withDescription( + 'Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) in the changed files. sherif only reports here: run `uncheck --fix` for its fixes', + ), + ), ...selectionFlags, }, Effect.fn(function* ({ cwd: given, dir, ...settings }) { @@ -50,28 +57,46 @@ export const run = Command.make( } const start = Option.getOrElse(given, () => process.cwd()) - const top = yield* git(start, ['rev-parse', '--show-toplevel']).pipe( - Effect.orElseSucceed(() => start), - ) + const projectDir = Option.isSome(given) + ? undefined + : (process.env.CLAUDE_PROJECT_DIR ?? process.env.CODEBUDDY_PROJECT_DIR) + const top = yield* topToCheck(start, projectDir) const cwd = Option.isSome(dir) ? path.join(top, dir.value) : Option.getOrElse(given, () => top) // Checking a folder that is gone would send the agent back to fix a configuration it cannot see. - if (Option.isSome(dir) && !(yield* fs.exists(cwd))) { + if (Option.isSome(dir) && (yield* fileKind(cwd)) !== 'Directory') { return yield* userError( - `--dir=${dir.value} names nothing in ${top}, run \`uncheck hooks install\` again from the project`, + `--dir=${dir.value} names no folder in ${top}, run \`uncheck hooks install\` again from the project`, ) } + // Agents also set these when another Stop hook (Claude Code's /goal is one) continued the turn, so + // only the marker uncheck leaves when it blocks tells that it already sent the agent back. + const continuing = + payload.stop_hook_active === true || + (typeof payload.loop_count === 'number' && payload.loop_count > 0) + const { forgetBlock, claimBlock } = stopMarker( + fs, + path, + payload.session_id ?? payload.sessionId ?? payload.conversation_id, + cwd, + ) + + if (!continuing) { + yield* forgetBlock + } + const changed = yield* listChangedFiles(cwd) - if (changed?.length === 0) { - return + if (changed !== undefined && changed.files.length === 0 && changed.deleted.length === 0) { + return yield* forgetBlock } - const [failed, lines] = yield* runChecks(changed ?? [], { + const [failed, lines] = yield* runChecks(changed?.files ?? [], { ...settings, cwd, literal: true, + deleted: changed?.deleted, }).pipe( Effect.map(() => false), Effect.catchTag('CheckFailed', () => Effect.succeed(true)), @@ -83,19 +108,32 @@ export const run = Command.make( yield* Console.error(report) if (!failed) { + return yield* forgetBlock + } + + // Send the agent back once until the checks pass again, in the way its family understands. Claude + // Code and CodeBuddy block on exit code 2 with stderr as the message, set `stop_hook_active` once + // they are already continuing, and show the user nothing but a `systemMessage` from a hook that + // exits 0; Cursor continues on a follow-up message and counts them in `loop_count`; Copilot + // continues on a block decision, also in the Claude format, where it takes exit code 2 for a mere + // warning. + const reason = `uncheck found problems, fix them before finishing:\n\n${report}` + const sendBack = + typeof (payload.stopReason ?? payload.stop_reason) === 'string' + ? Console.log(JSON.stringify({ decision: 'block', reason })) + : payload.hook_event_name === 'Stop' + ? Effect.fail(new StopBlocked()) + : payload.hook_event_name === 'stop' + ? Console.log(JSON.stringify({ followup_message: reason })) + : undefined + + if (sendBack === undefined) { return } - // Send the agent back at most once per turn, in the way its family understands. Claude Code and - // CodeBuddy block on exit code 2 with stderr as the message, set `stop_hook_active` once they are - // already continuing, and show the user nothing but a `systemMessage` from a hook that exits 0; - // Cursor continues on a follow-up message and counts them in `loop_count`; Copilot continues on a - // block decision, also in the Claude format, where it takes exit code 2 for a mere warning. - const alreadyContinued = - payload.stop_hook_active === true || - (typeof payload.loop_count === 'number' && payload.loop_count > 0) + const firstBlock = yield* claimBlock - if (alreadyContinued) { + if (continuing && !firstBlock) { if (payload.hook_event_name === 'Stop') { const summary = report .split('\n') @@ -110,22 +148,122 @@ export const run = Command.make( return } - const reason = `uncheck found problems, fix them before finishing:\n\n${report}` - - if (typeof (payload.stopReason ?? payload.stop_reason) === 'string') { - return yield* Console.log(JSON.stringify({ decision: 'block', reason })) - } - - if (payload.hook_event_name === 'Stop') { - return yield* Effect.fail(new StopBlocked()) - } - - if (payload.hook_event_name === 'stop') { - return yield* Console.log(JSON.stringify({ followup_message: reason })) - } + return yield* sendBack }), ).pipe( Command.withDescription( 'Run as an agent stop hook: check the files changed since the last commit, report on stderr and send the agent back to fix what remains', ), ) + +function stopMarker( + fs: FileSystem.FileSystem, + path: Path.Path, + sessionId: unknown, + cwd: string, +): { readonly forgetBlock: Effect.Effect; readonly claimBlock: Effect.Effect } { + if (typeof sessionId !== 'string') { + return { forgetBlock: Effect.void, claimBlock: Effect.succeed(false) } + } + + const marker = path.join( + tmpdir(), + `uncheck-stop-${createHash('sha256') + .update(JSON.stringify([sessionId, path.resolve(cwd)])) + .digest('hex')}`, + ) + + return { + forgetBlock: Effect.ignore(fs.remove(marker, { force: true })), + // A marker that cannot be written counts as one that exists, so a broken temporary folder never + // sends the agent back on every stop. + claimBlock: fs.writeFileString(marker, '', { flag: 'wx' }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ), + } +} + +interface Repository { + readonly top: string + readonly commonDir: string +} + +const repositoryAround = Effect.fn(function* (folder: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + const [physicalFolder, output] = yield* Effect.all( + [fs.realPath(folder), git(folder, ['rev-parse', '--show-toplevel', '--git-common-dir'])], + { concurrency: 'unbounded' }, + ) + // A newline in the top shifts the lines git prints, so the common dir is counted from the end. + const lines = output.split('\n') + const commonDir = lines.pop()! + const top = lines.join('\n') + + // git prints the common dir relative to the real path of `folder`, and a linked worktree may name + // it through a link. + const repository: Repository = { + top, + commonDir: yield* fs.realPath(path.resolve(physicalFolder, commonDir)), + } + + return repository +}) + +// Checking a repository git refuses as if it were outside git would fix every file in the folder. +const repositoryUnlessRefused = (folder: string) => + repositoryAround(folder).pipe( + Effect.catch((error) => + error._tag === 'GitFailed' && refusesRepository(error) + ? userError(error.stderr) + : Effect.succeed(undefined), + ), + ) + +const topToCheck = Effect.fn(function* (start: string, projectDir: string | undefined) { + const path = yield* Path.Path + + const startsInProject = + projectDir !== undefined && path.resolve(projectDir) === path.resolve(start) + const [here, elsewhere] = yield* Effect.all( + [ + repositoryUnlessRefused(start), + projectDir === undefined || startsInProject + ? Effect.succeed(undefined) + : repositoryUnlessRefused(projectDir), + ], + { concurrency: 'unbounded' }, + ) + const project = startsInProject ? here : elsewhere + + if (project === undefined) { + return here?.top ?? projectDir ?? start + } + + if (here === undefined) { + return project.top + } + + // Claude Code keeps the project folder on the main checkout while the agent works in a linked + // worktree, which shares its common dir: only a submodule or a nested clone, with a common dir of + // its own, gives way to the project's repository above it. + let folder = here.top + let outer: Repository | undefined = here + + while ( + outer !== undefined && + outer.commonDir !== project.commonDir && + folder !== path.dirname(folder) + ) { + // A repository whose core.worktree is a subfolder names that subfolder as its top from above it + // too, so climbing from its top alone would ask about the same folder forever. + folder = path.dirname( + isOutside(path, slashedRelative(path, outer.top, folder)) ? folder : outer.top, + ) + outer = yield* repositoryAround(folder).pipe(Effect.orElseSucceed(() => undefined)) + } + + return outer?.commonDir === project.commonDir ? outer.top : here.top +}) diff --git a/packages/uncheck/src/commands/prepare.ts b/packages/uncheck/src/commands/prepare.ts index 66b8761..78fb7e2 100644 --- a/packages/uncheck/src/commands/prepare.ts +++ b/packages/uncheck/src/commands/prepare.ts @@ -5,7 +5,7 @@ import { Command, Flag } from 'effect/unstable/cli' import { platformMessage, userError } from '../errors' import { readTextIfExists } from '../files' -import { git, gitLocation } from '../git' +import { git, gitLocation, refusesRepository } from '../git' import { detectExec, invokes } from '../pm' import { bold, dim, green, red } from '../style' import { cwdFlag, selectionArgs, selectionFlags, validateSelection } from './uncheck' @@ -20,9 +20,13 @@ const EXIT = ' || exit 1' * A line that enters a directory first, since git runs hooks at the top of the working tree, in a * subshell so the `cd` does not carry over to the next line. */ +const ENTERS_WHEN_STAGED = + /^git --literal-pathspecs diff --cached --quiet -- "([^"]*)" \|\| \[ ! -d "\1" \] \|\| \(cd "\1" && (.*)\)$/ const ENTERS = /^\(cd "([^"]*)" && (.*)\)$/ const OLD_ENTERS = /^cd "([^"]*)" && (.*)$/ +const CWD_FLAG = / --cwd=/ + /** Git runs a hook through its shebang, where a line of `sh` would be a syntax error. */ const OTHER_INTERPRETER = /^#!(?!.*\b(?:ba|da|k|z|a)?sh\b)/ @@ -31,11 +35,26 @@ const NOT_TEXT = /[\0\uFFFD]/ /** The comments and environment a hook sets up, such as its PATH, which the added line needs too. */ const SETUP = - /^\s*(?:#|$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ + /^\s*(?:$|\\?\.\s|(?:(?:source|export|set|unset)\s|(?:\[|test)\s[^;&|]*&&\s*(?:\\?\.|source)\s)[^;&|]*$|[A-Za-z_]\w*=\S*\s*$)/ + +/** husky 4 sources this runner, which always exits, so nothing after it runs. */ +const HUSKY_4_RUNNER = /^\.\s+"\$\(dirname "\$0"\)\/husky\.sh"\s*$/ + +const INNERMOST_SUBSTITUTION = /\$\([^()]*\)/g -/** A setup line that runs on into the next, where the added line would join it. */ +// A `\"` outside quotes opens nothing, and a `"` between single quotes opens nothing either. +const QUOTED = /\\.|"(?:[^"\\]|\\.)*"|'[^']*'/g + +const COMMENT = /(?:^|\s)#.*$/ + +/** + * A line that runs on into the next, so a line added after it would join its command or block, and + * one removed after it would leave them incomplete. + */ const CONTINUES = - /^(?!\s*#)(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$)/ + /^(?:.*\\\s*$|(?:[^"]*"[^"]*")*[^"]*"[^"]*$|(?:[^']*'[^']*')*[^']*'[^']*$|(?:[^`]*`[^`]*`)*[^`]*`[^`]*$|.*[({]\s*$|.*<<|.*(?:&&|\||(?:^|[\s;])(?:if|then|elif|else|while|until|do))\s*$)/ + +const INDENT = /^\s*/ /** * `sh` still expands `$`, backticks and `\` between double quotes, `"` ends them, and a newline ends @@ -43,23 +62,108 @@ const CONTINUES = */ const UNQUOTABLE = /["$`\\\n]/ +// Without --literal-pathspecs, git reads a folder whose name starts with `:` as pathspec magic. function hookLine(inside: string, command: string): string { - return inside === '' ? `${command}${EXIT}` : `(cd "${inside}" && ${command})${EXIT}` + return inside === '' + ? `${command}${EXIT}` + : `git --literal-pathspecs diff --cached --quiet -- "${inside}" || [ ! -d "${inside}" ] || (cd "${inside}" && ${command})${EXIT}` } /** * The directory and command of a hook line that runs `uncheck staged`, or `undefined` when the line * is not one `prepare` writes. A line only counts as ours when it runs the command directly or - * through a package manager, so a line that merely mentions it, or runs it some other way, is left - * alone. + * through a package manager, so a line that merely mentions it, or runs it some other way, such as + * in another directory with `--cwd`, is left alone. */ function ownLine(text: string): { readonly inside: string; readonly command: string } | undefined { const line = text.trim() const body = line.endsWith(EXIT) ? line.slice(0, -EXIT.length) : line - const enters = ENTERS.exec(body) ?? OLD_ENTERS.exec(body) + const enters = ENTERS_WHEN_STAGED.exec(body) ?? ENTERS.exec(body) ?? OLD_ENTERS.exec(body) const command = enters?.[2] ?? body - return invokes(command, HOOK_COMMAND) ? { inside: enters?.[1] ?? '', command } : undefined + return invokes(command, HOOK_COMMAND) && !CWD_FLAG.test(command) + ? { inside: enters?.[1] ?? '', command } + : undefined +} + +/** + * A `;`, `&` or `|` between quotes or in a command substitution does not end the command, and a + * quote in a comment opens nothing. + */ +function codeOf(text: string): string { + const substituted = text.replace(INNERMOST_SUBSTITUTION, '_') + + return substituted === text + ? text.replace(QUOTED, (token) => (token.startsWith('\\') ? token : '""')).replace(COMMENT, '') + : codeOf(substituted) +} + +function indentOf(text: string): string { + return INDENT.exec(text)![0] +} + +// prepare never indents its lines nor continues another line into them, so such a line is the +// user's, and dropping or moving it would break the block or command it is in. +function standalone(texts: ReadonlyArray, index: number): boolean { + const previous = texts + .slice(0, index) + .filter((text) => codeOf(text).trim() !== '') + .at(-1) + + return ( + indentOf(texts[index]!) === '' && (previous === undefined || !CONTINUES.test(codeOf(previous))) + ) +} + +function writtenByV003(text: string): boolean { + const own = ownLine(text) + + return ( + own !== undefined && + text === (own.inside === '' ? own.command : `cd "${own.inside}" && ${own.command}`) + ) +} + +// Added after the commands of the hook, a line would set its exit status in their place, and would +// never run after an `exec` or `exit`. Before the hook's setup it would miss the PATH a GUI client +// lacks, and run twice with husky 8, whose sourced `_/husky.sh` runs the hook again. +function beforeCommands( + texts: ReadonlyArray, + added: ReadonlyArray, +): ReadonlyArray { + const at = texts.findIndex((text) => { + const code = codeOf(text) + + return !SETUP.test(code) || HUSKY_4_RUNNER.test(text) || CONTINUES.test(code) + }) + + return at === -1 + ? [...(texts.at(-1) === '' ? texts.slice(0, -1) : texts), ...added, ''] + : [...texts.slice(0, at), ...added, ...texts.slice(at)] +} + +/** + * Older versions appended their lines to the hook, where an `exec` or `exit` before them, or the + * runner of husky 4, skips them. Only the end of the hook is theirs: v0.0.3 also rewrote the user's + * lines in place, unindented, in blocks that would be left empty without them. + */ +function liftAppended(texts: ReadonlyArray): ReadonlyArray { + const runner = texts.findIndex((text) => HUSKY_4_RUNNER.test(text)) + const tail = texts.reduce( + (start, text, index) => (text.trim() === '' || writtenByV003(text) ? start : index + 1), + 0, + ) + const from = runner === -1 ? tail : Math.min(tail, runner + 1) + const appended = texts.map( + (text, index) => index >= from && ownLine(text) !== undefined && standalone(texts, index), + ) + + return appended.includes(true) + ? beforeCommands( + texts.filter((_, index) => !appended[index]), + texts.filter((_, index) => appended[index]), + ) + : texts } // `sh` reads a script while running it, so a hook rewritten in place makes a commit already running @@ -137,17 +241,27 @@ export const prepare = Command.make( const cwd = path.resolve(directory) - const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.option) + const repository = yield* gitLocation(cwd, ['hooks']).pipe(Effect.result) - // A `prepare` script runs on every install, including where there is no repository to hook. - if (Option.isNone(repository)) { + if (Result.isFailure(repository)) { + const { failure } = repository + + if (failure._tag === 'GitFailed' && refusesRepository(failure)) { + const reason = failure.stderr.split('\n')[0]!.replace(/^fatal: /, '') + + return yield* Console.log( + `${red('✘')} ${bold('pre-commit')} ${dim(`not written, git refuses the repository: ${reason}`)}`, + ) + } + + // A `prepare` script runs on every install, including where there is no repository to hook. return yield* Console.log(`${dim('○')} no git repository found, nothing to prepare`) } const { prefix, paths: [hooks], - } = repository.value + } = repository.success const inside = prefix.replace(/\/$/, '') const exec = yield* detectExec(cwd) const command = [ @@ -273,53 +387,53 @@ export const prepare = Command.make( ) /** - * Puts `line` into a hook, so running `prepare` again is idempotent: the line for this directory is - * updated wherever it sits, duplicates of it are dropped, and everything else is kept, including the - * commands of other packages in the same repository and whatever the user added. + * Puts `line` into a hook, so running `prepare` again is idempotent: the lines older versions + * appended are moved up first, every line for this directory is updated where it sits, keeping its + * indentation, except the standalone copies after the first standalone one, which are dropped, and + * everything else is kept, including the commands of other packages in the same repository and + * whatever the user added. */ function rewrite(hook: string, line: string, inside: string): string { let placed = false - const lines = hook.split('\n').flatMap((text) => { + let placedStandalone = false + const lines = liftAppended(hook.split('\n')).flatMap((text, index, texts) => { const own = ownLine(text) if (own === undefined) { return [text] } + const indent = indentOf(text) + if (own.inside !== inside) { - return [hookLine(own.inside, own.command)] + return [`${indent}${hookLine(own.inside, own.command)}`] } - if (placed) { + const isStandalone = standalone(texts, index) + + if (isStandalone && placedStandalone) { return [] } placed = true + placedStandalone ||= isStandalone - return [line] + return [`${indent}${line}`] }) if (placed) { return lines.join('\n') } - // Added after the commands of the hook, the line would set its exit status in their place, and - // would never run after an `exec` or `exit`. Before the hook's setup it would miss the PATH a GUI - // client lacks, and run twice with husky 8, whose sourced `_/husky.sh` runs the hook again. const after = lines.reduce( - (last, text, index) => (ownLine(text) === undefined ? last : index + 1), + (last, text, index) => + ownLine(text) !== undefined && standalone(lines, index) ? index + 1 : last, 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` - } - - lines.splice(at, 0, line) - return lines.join('\n') + return ( + after > 0 + ? [...lines.slice(0, after), line, ...lines.slice(after)] + : beforeCommands(lines, [line]) + ).join('\n') } diff --git a/packages/uncheck/src/commands/staged.ts b/packages/uncheck/src/commands/staged.ts index dac2182..f89365e 100644 --- a/packages/uncheck/src/commands/staged.ts +++ b/packages/uncheck/src/commands/staged.ts @@ -4,14 +4,19 @@ import { Console, Effect, FileSystem, Path, Ref } from 'effect' import { Command, Flag } from 'effect/unstable/cli' import { userError } from '../errors' -import { existingFiles } from '../files' -import { git, gitBytes, GitFailed, gitLocation, gitPaths } from '../git' +import { inNodeModules } from '../files' +import { git, gitBytes, GitFailed, gitLocation, gitPaths, rawDiff, refusesRepository } from '../git' import { dim, green, listFiles, red } from '../style' -import { argvBatches } from '../tool' +import { argvBatches, logLines } from '../tool' import { checkPaths, cwdFlag, fixFlag, selectionFlags, validateSelection } from './uncheck' /** What became of the unstaged hunks that were set aside while the checks ran. */ -type Unstaged = 'restored' | 'stranded' | { readonly conflicted: ReadonlyArray } +interface Unstaged { + readonly conflicted: ReadonlyArray + readonly stranded: ReadonlyArray +} + +const RESTORED: Unstaged = { conflicted: [], stranded: [] } export const staged = Command.make( 'staged', @@ -39,42 +44,43 @@ export const staged = Command.make( const cwd = path.resolve(directory) - // Only regular files: tools follow a staged symlink to a file the commit does not hold. - const [listed, merging] = yield* Effect.all( - [ - stagedFiles(cwd).pipe( - Effect.catchTag('GitFailed', () => - userError('`uncheck staged` needs a git repository'), - ), + const { + prefix, + paths: [folder, indexLock], + } = yield* gitLocation(cwd, ['uncheck-unstaged', 'index.lock']).pipe( + Effect.catchTag('GitFailed', (error) => + userError( + refusesRepository(error) ? error.stderr : '`uncheck staged` needs a git repository', ), - mergeInProgress(cwd), - ], + ), + ) + + // Only regular files: tools follow a staged symlink to a file the commit does not hold. + const [listed, merging, unstaged] = yield* Effect.all( + [stagedFiles(cwd), mergeInProgress(cwd), rawDiff(cwd)], { concurrency: 'unbounded' }, ) // Fixing what a merge takes from the other side would commit changes neither side made. - const notTheirs = merging ? new Set(yield* stagedFiles(cwd, 'MERGE_HEAD')) : undefined - const files = notTheirs === undefined ? listed : listed.filter((file) => notTheirs.has(file)) + const theirs = merging ? yield* stagedFiles(cwd, 'MERGE_HEAD') : undefined + const notTheirs = theirs && new Set([...theirs.files, ...theirs.deleted]) + const ours = (paths: ReadonlyArray) => + notTheirs === undefined ? paths : paths.filter((file) => notTheirs.has(file)) + const files = ours(listed.files) + const deleted = ours(listed.deleted) yield* Console.log(dim(`uncheck staged in ${cwd}`)) - if (files.length === 0) { + if (files.length === 0 && deleted.length === 0) { const reason = - listed.length > 0 + listed.files.length > 0 || listed.deleted.length > 0 ? 'every staged file comes from the branch being merged in' - : 'no staged files' + : listed.anyStaged + ? 'only links and node_modules files are staged' + : 'no staged files' return yield* Console.log(`${dim('○')} nothing to check, ${reason}`) } - const [ - { - prefix, - paths: [folder, indexLock], - }, - unstaged, - ] = yield* Effect.all([gitLocation(cwd, ['uncheck-unstaged', 'index.lock']), rawDiff(cwd)], { - concurrency: 'unbounded', - }) const saved = path.resolve(cwd, folder!) const aside = { cwd, saved, prefix } @@ -84,7 +90,28 @@ export const staged = Command.make( } const partial = yield* partiallyStaged(unstaged, files) - const outcome = yield* Ref.make('restored') + const outcome = yield* Ref.make(RESTORED) + const fixed = yield* Ref.make | undefined>(fix ? undefined : []) + const summary = yield* Ref.make>([]) + + // `git commit ` runs the hook on a temporary index, and the index it leaves + // behind (index.lock until then) needs the fixes too, or it would hold their revert. + const active = process.env.GIT_INDEX_FILE + const lock = path.resolve(cwd, indexLock!) + const lockToo = active?.endsWith('.lock') === true && path.resolve(cwd, active) !== lock + + // Staging after every check would also stage what was saved to a file while tsc ran. + const stageFixes = Effect.gen(function* () { + const changed = yield* differFromIndex(cwd, files) + + // `git add` refuses a path outside a sparse checkout even when it is on disk. + yield* gitEach(cwd, ['update-index'], changed) + yield* Ref.set(fixed, changed) + + if (lockToo && (yield* fs.exists(lock))) { + yield* stageEntries(cwd, yield* indexEntries(cwd, changed), { GIT_INDEX_FILE: lock }) + } + }) const { failure, empty } = yield* Effect.scoped( Effect.gen(function* () { @@ -92,7 +119,7 @@ export const staged = Command.make( const before = yield* writeTree(cwd) yield* Effect.acquireRelease(setAside(aside, partial), (copies) => - putBack(aside, files, copies, before).pipe( + putBack(aside, files, copies, before, fixed, active).pipe( Effect.flatMap((result) => Ref.set(outcome, result)), ), ) @@ -103,39 +130,17 @@ export const staged = Command.make( cwd, fix, literal: true, - staged: true, + deleted, + afterFixes: fix ? stageFixes : undefined, + holdSummary: (lines) => Ref.set(summary, lines), }).pipe(Effect.catchTag('CheckFailed', Effect.succeed)) - if (!fix) { - return { failure, empty: false } - } - - const changed = (yield* Effect.forEach(argvBatches(files), (batch) => - gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', '--', ...batch]), - )).flat() + const changed = yield* Ref.get(fixed) - if (changed.length === 0) { + if (changed === undefined || changed.length === 0) { return { failure, empty: false } } - // `git add` refuses a path outside a sparse checkout even when it is on disk. - const stage = (env?: Readonly>) => - gitEach(cwd, ['update-index'], changed, env) - - yield* stage() - - // `git commit ` runs the hook on a temporary index, and the index it leaves - // behind (index.lock until then) needs the fixes too, or it would hold their revert. - const active = process.env.GIT_INDEX_FILE - - if (active?.endsWith('.lock') === true) { - const lock = path.resolve(cwd, indexLock!) - - if (path.resolve(cwd, active) !== lock && (yield* fs.exists(lock))) { - yield* stage({ GIT_INDEX_FILE: lock }) - } - } - yield* Console.log(`${green('✔')} staged the fixes to ${listFiles(changed)}`) // git records a merge commit even when its tree is the one HEAD already has. @@ -149,25 +154,26 @@ export const staged = Command.make( return { failure, empty: after === head } }), - ) + ).pipe(Effect.ensuring(Effect.flatMap(Ref.get(summary), logLines))) - const unstagedOutcome = yield* Ref.get(outcome) + const { conflicted, stranded } = yield* Ref.get(outcome) - if (typeof unstagedOutcome === 'object') { + if (stranded.length > 0 || conflicted.length > 0) { return yield* userError( - `The fixes conflict with the unstaged changes of ${listFiles(unstagedOutcome.conflicted)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`, - ) - } - - if (unstagedOutcome === 'stranded') { - return yield* userError( - `The unstaged changes of ${listFiles(partial)} could not be put back, see above.`, + [ + stranded.length > 0 && + `The unstaged changes of ${listFiles(stranded)} could not be put back, see above.`, + conflicted.length > 0 && + `The fixes conflict with the unstaged changes of ${listFiles(conflicted)} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.`, + ] + .filter(Boolean) + .join(' '), ) } if (empty && !allowEmpty) { return yield* userError( - 'The fixes undid every staged change, so the commit would be empty. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.', + 'The fixes undid every staged change and are staged now, so nothing new is left to commit. Commit again: git amends only the message, or refuses an empty commit. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.', ) } @@ -203,32 +209,20 @@ const leftover = (saved: string) => const REGULAR_FILE_MODE = /^100(?:644|755)$/ -const rawDiff = (cwd: string, ...args: ReadonlyArray) => - Effect.map( - git(cwd, [ - 'diff', - '--raw', - '--no-renames', - '--ignore-submodules=all', - '--relative', - '-z', - ...args, - ]), - (output) => { - const fields = output.split('\0') - - return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { - const [fromMode, toMode] = fields[index * 2]!.slice(1).split(' ') - - return { file: fields[index * 2 + 1]!, fromMode: fromMode!, toMode: toMode! } - }) - }, - ) - const stagedFiles = (cwd: string, ...against: ReadonlyArray) => - Effect.map(rawDiff(cwd, '--cached', '--diff-filter=ACMT', ...against), (entries) => - entries.filter((entry) => REGULAR_FILE_MODE.test(entry.toMode)).map((entry) => entry.file), - ) + Effect.map(rawDiff(cwd, '--cached', '--diff-filter=ACMTD', ...against), (entries) => { + const kept = entries.filter((entry) => !inNodeModules(entry.file)) + + return { + anyStaged: entries.length > 0, + files: kept + .filter((entry) => entry.status !== 'D' && REGULAR_FILE_MODE.test(entry.toMode)) + .map((entry) => entry.file), + deleted: kept + .filter((entry) => entry.status === 'D' && REGULAR_FILE_MODE.test(entry.fromMode)) + .map((entry) => entry.file), + } + }) const partiallyStaged = Effect.fn(function* ( unstaged: Effect.Success>, @@ -274,18 +268,64 @@ interface SetAside { readonly base: string } -/** Runs `git -- ` in batches, and nothing without files. */ -function gitEach( +function gitPathsEach(cwd: string, args: ReadonlyArray, files: ReadonlyArray) { + return Effect.map( + Effect.forEach(argvBatches(files), (batch) => gitPaths(cwd, [...args, '--', ...batch])), + (batches) => batches.flat(), + ) +} + +function differFromIndex(cwd: string, files: ReadonlyArray) { + return gitPathsEach(cwd, ['diff', '--name-only', '--relative', '-z'], files) +} + +/** `,,` of each of `files` in the index, as `update-index --cacheinfo` takes it. */ +function indexEntries(cwd: string, files: ReadonlyArray) { + // `--cacheinfo` reads paths from the top of the repository, wherever git runs. + return Effect.map( + gitPathsEach(cwd, ['ls-files', '--stage', '--full-name', '-z'], files), + (entries) => + entries.map((entry) => { + const [mode, id] = entry.split(' ') + + return `${mode},${id},${entry.slice(entry.indexOf('\t') + 1)}` + }), + ) +} + +const CACHEINFO = '--cacheinfo' + +function stageEntries( cwd: string, - args: ReadonlyArray, - files: ReadonlyArray, - env?: Readonly>, + entries: ReadonlyArray, + env: Readonly>, ) { - return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch], env), { + // Each entry needs a flag of its own, which the batches have to count. + return Effect.forEach( + argvBatches(entries.map((entry) => `${CACHEINFO} ${entry}`)), + (batch) => + git( + cwd, + ['update-index', ...batch.flatMap((arg) => [CACHEINFO, arg.slice(CACHEINFO.length + 1)])], + env, + ), + { discard: true }, + ) +} + +/** Runs `git -- ` in batches, and nothing without files. */ +function gitEach(cwd: string, args: ReadonlyArray, files: ReadonlyArray) { + return Effect.forEach(argvBatches(files), (batch) => git(cwd, [...args, '--', ...batch]), { discard: true, }) } +function copyBack(entries: ReadonlyArray<{ readonly target: string; readonly copy: string }>) { + return FileSystem.FileSystem.use((fs) => + Effect.forEach(entries, ({ target, copy }) => fs.copyFile(copy, target), { discard: true }), + ) +} + const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: ReadonlyArray) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -294,9 +334,6 @@ const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: Read target: path.join(cwd, file), copy: path.join(saved, prefix, file), })) - const restore = Effect.forEach(copies, ({ target, copy }) => fs.copyFile(copy, target), { - discard: true, - }) // Creating the folder, not only finding it missing, is what claims it from a parallel run. yield* fs.makeDirectory(saved).pipe( @@ -322,7 +359,7 @@ const setAside = Effect.fn(function* ({ cwd, saved, prefix }: Aside, files: Read ), ).pipe( Effect.tapError(() => - restore.pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore), + copyBack(copies).pipe(Effect.andThen(fs.remove(saved, { recursive: true })), Effect.ignore), ), ) yield* Console.log( @@ -340,41 +377,86 @@ const putBack = Effect.fn(function* ( files: ReadonlyArray, copies: ReadonlyArray, before: string, + fixed: Ref.Ref | undefined>, + index: string | undefined, ) { const fs = yield* FileSystem.FileSystem const { cwd, saved } = aside const partial = copies.map(({ file }) => file) + const restored = Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)).pipe( + Effect.as(RESTORED), + ) + const strand = (stranded: ReadonlyArray, reason: string) => + Console.log( + `${red('✘')} could not put back the unstaged changes of ${listFiles(stranded)}: ${reason}\n their unstaged versions are in ${saved}, at their paths from the top of the repository: copy back what your files are missing and delete the folder`, + ) const result = yield* Effect.gen(function* () { - const merged = yield* Effect.forEach(copies, (entry) => merge(aside, entry, before), { - concurrency: 4, - }) - const conflicted = partial.filter((_, index) => !merged[index]) + // Killed by Ctrl-C, git deletes the lock index it ran the hook on, as with `git commit -i`: no staged + // version is left to merge with, and writing that index would leave a stale lock behind. + if (index?.endsWith('.lock') === true && !(yield* fs.exists(index))) { + yield* copyBack(copies) + return yield* restored + } + + const stagedFixes = yield* Ref.get(fixed) + const merged = yield* Effect.forEach( + copies, + (entry) => + Effect.map(merge(aside, entry, before, stagedFixes !== undefined), (outcome) => ({ + ...entry, + ...outcome, + })), + { concurrency: 4 }, + ) + const conflicted = merged.filter(({ clean }) => !clean) if (conflicted.length === 0) { - yield* Console.log(dim(`○ unstaged changes of ${listFiles(partial)} restored`)) - return 'restored' as const + return yield* restored } + const isPartial = new Set(partial) + const fixedInFull = (stagedFixes ?? []).filter((file) => !isPartial.has(file)) + // A file saved since its fixes were staged holds an edit made during the run, which a + // checkout or its unstaged copy would throw away. + const editedSince = new Set(yield* differFromIndex(cwd, fixedInFull)) + 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, - }) + yield* gitEach( + cwd, + ['checkout-index', '-f'], + fixedInFull.filter((file) => !editedSince.has(file)), + ) + yield* copyBack(merged.filter(({ edited }) => !edited)) - return { conflicted } - }).pipe( - Effect.catch((error) => { - const reason = error instanceof GitFailed ? error.summary : String(error) + const stranded = conflicted.filter(({ edited }) => edited) + + if (stranded.length > 0) { + // A copy left beside the stranded ones would invite copying it over a newer file. + yield* Effect.forEach( + merged.filter((entry) => !stranded.includes(entry)), + ({ copy }) => fs.remove(copy), + { discard: true }, + ) + yield* strand( + stranded.map(({ file }) => file), + 'they conflict with edits saved while the checks ran', + ) + } - return Console.log( - `${red('✘')} could not put back the unstaged changes of ${listFiles(partial)}: ${reason}\n their unstaged versions are in ${saved}, at their paths from the top of the repository: copy back what your files are missing and delete the folder`, - ).pipe(Effect.as('stranded' as const)) - }), + return { + conflicted: conflicted.filter(({ edited }) => !edited).map(({ file }) => file), + stranded: stranded.map(({ file }) => file), + } + }).pipe( + Effect.catch((error) => + strand(partial, error instanceof GitFailed ? error.summary : String(error)).pipe( + Effect.as({ conflicted: [], stranded: partial }), + ), + ), ) - if (result !== 'stranded') { + if (result.stranded.length === 0) { yield* Effect.ignore(fs.remove(saved, { recursive: true })) } @@ -387,6 +469,7 @@ const merge = Effect.fn(function* ( { cwd, prefix }: Aside, { file, target, copy, base }: SetAside, before: string, + fixesStaged: boolean, ) { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -395,19 +478,24 @@ const merge = Effect.fn(function* ( if (checked === base) { yield* fs.copyFile(copy, target) - return true + return { clean: true, edited: false } } - // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it - // back through the filters would turn every line ending of the file to LF. const [staged, stored] = (yield* git(cwd, [ 'rev-parse', `:0:${prefix}${file}`, `${before}:${prefix}${file}`, ])).split('\n') + // Until the fixes are staged, a fixed file differs from the index just as an edited one does. + // hash-object turns the CRLF of a blob git keeps that way to LF, so only git can tell whether the + // file still holds what is staged. + const edited = + fixesStaged && staged !== checked && (yield* differFromIndex(cwd, [file])).length > 0 + // git keeps a CRLF blob as it is under text=auto, so merging what hash-object stores and writing it + // back through the filters would turn every line ending of the file to LF. if (stored !== base && staged !== checked) { - return false + return { clean: false, edited } } const temp = yield* fs.makeTempDirectory() @@ -449,6 +537,6 @@ const merge = Effect.fn(function* ( yield* fs.chmod(target, (yield* fs.stat(copy)).mode) } - return clean + return { clean, edited } }).pipe(Effect.ensuring(Effect.ignore(fs.remove(temp, { recursive: true })))) }) diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index 6635846..1d09a59 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -2,23 +2,37 @@ import { availableParallelism } from 'node:os' import path from 'node:path' import process from 'node:process' -import { Console, Duration, Effect, Fiber, Semaphore } from 'effect' +import type { PlatformError } from 'effect' +import { Console, Duration, Effect, Fiber, FileSystem, Semaphore } from 'effect' import type { CliError } from 'effect/unstable/cli' import { Argument, Command, Flag } from 'effect/unstable/cli' +import type { ChildProcessSpawner } from 'effect/unstable/process' import { oxfmt } from '../checks/oxfmt' import { oxlint } from '../checks/oxlint' import { sherif } from '../checks/sherif' import { tsc } from '../checks/tsc' import { CheckFailed, platformMessage, userError } from '../errors' -import { existingFiles, listProjectFiles, resolvePaths } from '../files' +import { checkableFiles, listProjectFiles, resolvePaths } from '../files' +import type { GitFailed } from '../git' import { bold, dim, green, listFiles, red } from '../style' -import { captureLines, execute } from '../tool' +import { captureLines, execute, logLines } from '../tool' import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' const CHECKS: ReadonlyArray = [sherif, oxlint, oxfmt, tsc] +const FIXES_DONE_AFTER = CHECKS.reduce( + (count, check, index) => (check.fixes === false ? count : index + 1), + 0, +) + +// git runs in the folder a symlink points to and prints its paths relative to that folder. +export const followLinks = Flag.mapEffect((directory: string) => + FileSystem.FileSystem.use((fs) => fs.realPath(directory)).pipe(Effect.orDie), +) + export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( + followLinks, Flag.withDefault(Effect.sync(() => process.cwd())), Flag.withDescription('Directory to run in. Defaults to the current one'), ) @@ -63,10 +77,19 @@ export interface RunSettings extends CheckSelection { readonly cwd: string readonly fix: boolean readonly allowUnmatched?: boolean - /** The paths are file names from git: never patterns, and a run where none has anything to check passes. */ + /** + * The paths are file names from git, a change the hooks fix: never patterns, a run where none has + * anything to check passes, and only the fixes that stay within them apply. + */ readonly literal?: boolean - /** Fixes are staged again, so only the ones that stay within the given files apply. */ - readonly staged?: boolean + /** Only for `literal` runs: without paths, any other run checks everything. */ + readonly deleted?: ReadonlyArray + readonly afterFixes?: Effect.Effect< + void, + GitFailed | PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner + > + readonly holdSummary?: (lines: ReadonlyArray) => Effect.Effect } export function selectionArgs({ only, required, skipped }: CheckSelection): ReadonlyArray { @@ -132,17 +155,26 @@ export const checkPaths = Effect.fn(function* ( paths: ReadonlyArray, settings: RunSettings, ) { - const { fix, only, required, skipped, allowUnmatched = false, literal = false } = settings - const appliesFixes = (fixes: Check['fixes']) => - settings.staged === true ? fixes === 'files' : fixes !== false + const { + fix, + only, + required, + skipped, + allowUnmatched = false, + literal = false, + deleted = [], + } = settings + const appliesFixes = (fixes: Check['fixes']) => (literal ? fixes === 'files' : fixes !== false) const { cwd } = settings + const summarize = (lines: ReadonlyArray) => + settings.holdSummary?.(lines) ?? logLines(lines) const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) let files: ReadonlyArray | undefined - if (paths.length > 0) { + if (paths.length > 0 || deleted.length > 0) { const resolved = literal - ? { files: yield* existingFiles(paths, cwd), unmatched: [] } + ? { files: yield* checkableFiles(paths, cwd), unmatched: [] } : yield* resolvePaths(paths, cwd, projectFiles) if (resolved.unmatched.length > 0 && !allowUnmatched) { @@ -151,8 +183,8 @@ export const checkPaths = Effect.fn(function* ( ) } - if (resolved.files.length === 0) { - yield* Console.log(`${dim('○')} nothing to check, no files match ${paths.join(' ')}`) + if (resolved.files.length === 0 && deleted.length === 0) { + yield* summarize([`${dim('○')} nothing to check, no files match ${paths.join(' ')}`]) return } @@ -171,13 +203,21 @@ export const checkPaths = Effect.fn(function* ( return Effect.succeed({ name, status: 'skipped', reason: exclusion }) } - return plan({ cwd, fix: fix && appliesFixes(fixes), files, projectFiles }).pipe( - Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), - Effect.catchTag('NothingToCheck', ({ reason }) => + return plan({ cwd, fix: fix && appliesFixes(fixes), files, deleted, projectFiles }).pipe( + // A plan without commands, as oxlint and oxfmt make for a change that only deletes files, + // would pass without running anything. It stays skipped when required, or + // `--require=oxlint` would block every such commit. + Effect.map((commands): CheckPlan => + commands.length === 0 + ? { name, status: 'skipped', reason: 'only deleted files', unrelated: true } + : { name, status: 'run', commands }, + ), + Effect.catchTag('NothingToCheck', ({ reason, unrelated }) => Effect.succeed({ name, status: required.includes(name) ? 'failed' : 'skipped', reason, + unrelated, }), ), Effect.catchTag('CannotCheck', ({ reason }) => @@ -188,29 +228,34 @@ export const checkPaths = Effect.fn(function* ( { concurrency: 'unbounded' }, ) - const outcomes = yield* Effect.forEach(plans, (plan) => runCheck(plan, cwd)) + const runAll = (planned: ReadonlyArray) => + Effect.forEach(planned, (plan) => runCheck(plan, cwd)) + const fixing = yield* runAll(plans.slice(0, FIXES_DONE_AFTER)) + + yield* settings.afterFixes ?? Effect.void + + const outcomes = [...fixing, ...(yield* runAll(plans.slice(FIXES_DONE_AFTER)))] const ran = outcomes.filter((outcome) => outcome.status !== 'skipped') const failed = outcomes.filter((outcome) => outcome.status === 'failed') - yield* Console.log('') - if (ran.length === 0) { const reasons = outcomes.map((outcome) => `${outcome.name} ${outcome.reason}`).join(', ') - if (files !== undefined && (allowUnmatched || literal)) { - return yield* Console.log(`${dim('○')} nothing to check: ${reasons}`) + if ( + files !== undefined && + (allowUnmatched || literal) && + outcomes.some((outcome) => outcome.unrelated === true) + ) { + yield* summarize(['', `${dim('○')} nothing to check: ${reasons}`]) + return } - yield* Console.log(`${red('✘')} nothing to check: ${reasons}`) + yield* summarize(['', `${red('✘')} nothing to check: ${reasons}`]) return yield* Effect.fail(new CheckFailed({ outcomes })) } if (failed.length > 0) { - yield* Console.log( - `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map((outcome) => outcome.name).join(', ')}`, - ) - const fixable = failed .filter( (outcome) => @@ -221,16 +266,19 @@ export const checkPaths = Effect.fn(function* ( .join(', ') .replace(/, ([^,]+)$/, ' and $1') - if (!fix && fixable !== '') { - yield* Console.log(dim(` rerun with \`--fix\` to apply ${fixable} fixes`)) - } + yield* summarize([ + '', + `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map((outcome) => outcome.name).join(', ')}`, + ...(!fix && fixable !== '' ? [dim(` rerun with \`--fix\` to apply ${fixable} fixes`)] : []), + ]) return yield* Effect.fail(new CheckFailed({ outcomes })) } - yield* Console.log( + yield* summarize([ + '', `${green('✔')} all checks passed (${ran.map((outcome) => outcome.name).join(', ')})`, - ) + ]) }) const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { @@ -282,7 +330,7 @@ const runCommands = Effect.fn(function* (commands: ReadonlyArray, for (const fiber of fibers) { const [exitCode, lines] = yield* Fiber.join(fiber) - yield* Effect.forEach(lines, (line) => Console.log(line), { discard: true }) + yield* logLines(lines) exitCodes.push(exitCode) } diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts index 5315837..fad8957 100644 --- a/packages/uncheck/src/errors.ts +++ b/packages/uncheck/src/errors.ts @@ -6,6 +6,8 @@ import type { CheckOutcome } from './types' export class NothingToCheck extends Data.TaggedError('NothingToCheck')<{ readonly reason: string + /** Lets a run on given files pass, so never for a check that is not set up. */ + readonly unrelated?: boolean }> {} export class CannotCheck extends Data.TaggedError('CannotCheck')<{ diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index 16a9c8a..a86170d 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -1,11 +1,11 @@ -import { existsSync, statSync } from 'node:fs' +import { existsSync, lstatSync, realpathSync, statSync } from 'node:fs' import { Effect, FileSystem, Path, Predicate } from 'effect' import type { ChildProcessSpawner } from 'effect/unstable/process' import { Minimatch } from 'minimatch' import { userError } from './errors' -import { gitPaths } from './git' +import { gitPaths, rawDiff } from './git' export type ProjectFiles = Effect.Effect< ReadonlyArray, @@ -21,33 +21,48 @@ export type ProjectFiles = Effect.Effect< */ export function listProjectFiles(cwd: string): ProjectFiles { return gitPaths(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe( - // git lists a linked node_modules as one file, which a `node_modules/` ignore rule misses. - Effect.map((files) => files.filter((file) => !/(?:^|\/)node_modules(?:\/|$)/.test(file))), + Effect.map((files) => files.filter((file) => !inNodeModules(file))), Effect.catch(() => walk(cwd)), - Effect.map((files) => [...files].sort()), + // git lists a file with merge conflicts once per side. + Effect.map((files) => [...new Set(files)].sort()), ) } +// Ignore rules can miss it (a linked one, a nested one under `/node_modules`, or no rule at all), and a +// fix there rewrites installed packages, through pnpm's hard links even those of its shared store. +export function inNodeModules(file: string): boolean { + return /(?:^|\/)node_modules(?:\/|$)/.test(file) +} + +interface ChangedFiles { + readonly files: ReadonlyArray + readonly deleted: ReadonlyArray +} + /** * The files changed since the last commit, relative to `cwd`: modified or staged tracked files plus - * untracked ones, ignored files excluded. `undefined` outside a git repository or before the first - * commit, which callers treat as "everything under `cwd`". + * untracked ones, ignored files excluded, and apart from them the deleted ones, the old path of a + * rename included. `undefined` outside a git repository or before the first commit, which callers + * treat as "everything under `cwd`". */ export function listChangedFiles( cwd: string, -): Effect.Effect< - ReadonlyArray | undefined, - never, - ChildProcessSpawner.ChildProcessSpawner -> { +): Effect.Effect { return Effect.all( - [ - gitPaths(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']), - gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), - ], + [rawDiff(cwd, 'HEAD'), gitPaths(cwd, ['ls-files', '--others', '--exclude-standard', '-z'])], { concurrency: 'unbounded' }, ).pipe( - Effect.map(([tracked, untracked]) => [...new Set([...tracked, ...untracked])].sort()), + Effect.map(([tracked, untracked]) => { + const changed = tracked.filter((entry) => entry.status !== 'D').map((entry) => entry.file) + const deleted = tracked.filter((entry) => entry.status === 'D').map((entry) => entry.file) + + return { + files: [...new Set([...changed, ...untracked])] + .filter((file) => !inNodeModules(file)) + .sort(), + deleted: deleted.filter((file) => !inNodeModules(file)), + } + }), Effect.orElseSucceed(() => undefined), ) } @@ -58,14 +73,32 @@ export const resolvePaths = Effect.fn(function* ( cwd: string, projectFiles: ProjectFiles, ) { - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - // A backslash is a glob escape on POSIX, never a separator. - const relative = (pattern: string) => - path.relative(cwd, path.resolve(cwd, pattern)).split(path.sep).join('/') + const realCwd = realpathSync(cwd) + const isCheckable = checkableFile(path, cwd, realCwd) + + const relative = (pattern: string) => { + const resolved = path.resolve(cwd, pattern) + const lexical = slashedRelative(path, cwd, resolved) + + if (!isOutside(path, lexical)) { + return lexical + } + + // The current directory is a real path, while an absolute path may run through a linked folder. + // `throwIfNoEntry` spares only missing paths: a locked folder or a looping link still throws. + try { + const real = slashedRelative(path, realCwd, resolveFolders(path, resolved)) + + return isOutside(path, real) ? lexical : real + } catch { + return lexical + } + } const includes = patterns.filter((pattern) => !pattern.startsWith('!')) const matched = new Set() + const named = new Set() const unmatched: string[] = [] let universe: ReadonlyArray | undefined @@ -73,20 +106,18 @@ export const resolvePaths = Effect.fn(function* ( const target = relative(pattern) // oxlint and oxfmt reject a path containing "..". - if (target === '..' || target.startsWith('../') || path.isAbsolute(target)) { + if (isOutside(path, target)) { return yield* userError( `${pattern} is outside ${cwd}, run from a folder that contains it or pass one with --cwd`, ) } // An existing path is taken as it is, so `app/[id].ts` names that file rather than a glob. - const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe( - Effect.map((info) => info.type), - Effect.orElseSucceed(() => undefined), - ) + const kind = yield* fileKind(path.resolve(cwd, pattern)) if (kind === 'File') { matched.add(target) + named.add(target) continue } @@ -106,7 +137,7 @@ export const resolvePaths = Effect.fn(function* ( matched.add(hit) } - if (hits.length === 0) { + if (!hits.some((hit) => isCheckable(hit))) { unmatched.push(pattern) } } @@ -125,28 +156,83 @@ export const resolvePaths = Effect.fn(function* ( return (file: string) => file === target || file.startsWith(`${target}/`) } - return glob(target) + const matches = glob(target) + const matchesInside = glob(`${target}/**`) + + return (file: string) => matches(file) || matchesInside(file) }) // One fiber per file costs far more than the check itself on a large project. const files = yield* Effect.sync(() => [...matched].filter( - (file) => !excludes.some((excluded) => excluded(file)) && isFile(path.resolve(cwd, file)), + (file) => + !excludes.some((excluded) => excluded(file)) && (named.has(file) || isCheckable(file)), ), ) return { files: files.sort(), unmatched } }) +export const checkableFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { + const path = yield* Path.Path + const isCheckable = checkableFile(path, cwd, realpathSync(cwd)) + + return files.filter((file) => isCheckable(file)) +}) + const GLOB_CHARACTERS = /[*?[\]{}()]/ -// git lists a linked folder as one file, which the tools would check through the link. -function isFile(file: string): boolean { - try { - return statSync(file).isFile() - } catch { - return false +// git lists a linked folder as one file, and a fix through a link rewrites its target, so of the links +// only those to a file inside `cwd` and outside node_modules are kept. +function checkableFile(path: Path.Path, cwd: string, realCwd: string): (file: string) => boolean { + return (file) => { + const absolute = path.resolve(cwd, file) + + try { + const info = lstatSync(absolute) + + if (!info.isSymbolicLink()) { + return info.isFile() + } + + const target = realpathSync(absolute) + const relativeTarget = slashedRelative(path, realCwd, target) + + return ( + statSync(target).isFile() && + !isOutside(path, relativeTarget) && + !inNodeModules(relativeTarget) + ) + } catch { + return false + } + } +} + +export function isOutside(path: Path.Path, relative: string): boolean { + return relative === '..' || relative.startsWith('../') || path.isAbsolute(relative) +} + +export function slashedRelative(path: Path.Path, from: string, to: string): string { + // A backslash is a glob escape on POSIX, never a separator. + return path.relative(from, to).split(path.sep).join('/') +} + +/** `file` with every linked folder on its way resolved. A linked file keeps its own name. */ +export function resolveFolders(path: Path.Path, file: string): string { + const rest: string[] = [] + let folder = file + + while (!isDirectory(folder)) { + rest.unshift(path.basename(folder)) + folder = path.dirname(folder) } + + return path.join(realpathSync(folder), ...rest) +} + +function isDirectory(file: string): boolean { + return statSync(file, { throwIfNoEntry: false })?.isDirectory() === true } /** Dot files match too, as they do for oxfmt and for a directory given as it is. */ @@ -163,24 +249,6 @@ function glob(pattern: string): (file: string) => boolean { return (file) => matcher.match(file) } -export const existingFiles = Effect.fn(function* (files: ReadonlyArray, cwd: string) { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - // A concurrent `Effect.filter` keeps files in the order their checks finish, not the given one. - const isFile = yield* Effect.forEach( - files, - (file) => - fs.stat(path.resolve(cwd, file)).pipe( - Effect.map((info) => info.type === 'File'), - Effect.orElseSucceed(() => false), - ), - { concurrency: 64 }, - ) - - return files.filter((_, index) => isFile[index]) -}) - export function ancestors(path: Path.Path, from: string): string[] { const dirs = [path.resolve(from)] @@ -195,6 +263,15 @@ export function ancestors(path: Path.Path, from: string): string[] { return dirs } +export const fileKind = Effect.fn(function* (target: string) { + const fs = yield* FileSystem.FileSystem + + return yield* fs.stat(target).pipe( + Effect.map((info) => info.type), + Effect.orElseSucceed(() => undefined), + ) +}) + export const readJson = Effect.fn( function* (file: string) { const fs = yield* FileSystem.FileSystem diff --git a/packages/uncheck/src/git.ts b/packages/uncheck/src/git.ts index d36274a..0e3ce6d 100644 --- a/packages/uncheck/src/git.ts +++ b/packages/uncheck/src/git.ts @@ -24,6 +24,11 @@ export class GitFailed extends Data.TaggedError('GitFailed')<{ } } +export function refusesRepository(error: GitFailed): boolean { + // git translates its messages, but never the name of a setting. + return error.stderr.includes('safe.directory') +} + /** * Runs git in `cwd` and returns the bytes it printed. A non-zero exit fails with `GitFailed` * carrying stderr, where git explains itself; running outside a repository is one such failure. @@ -80,6 +85,36 @@ export function gitPaths(cwd: string, args: ReadonlyArray) { return Effect.map(git(cwd, args), (output) => output.split('\0').filter((entry) => entry !== '')) } +export function rawDiff(cwd: string, ...args: ReadonlyArray) { + return Effect.map( + git(cwd, [ + 'diff', + '--raw', + '--no-renames', + '--ignore-submodules=all', + '--relative', + '-z', + ...args, + // Without it, a file named like a revision, such as HEAD, makes git fail as ambiguous. + '--', + ]), + (output) => { + const fields = output.split('\0') + + return Array.from({ length: Math.floor(fields.length / 2) }, (_, index) => { + const [fromMode, toMode, , , status] = fields[index * 2]!.slice(1).split(' ') + + return { + file: fields[index * 2 + 1]!, + status: status!, + fromMode: fromMode!, + toMode: toMode!, + } + }) + }, + ) +} + /** * The folder of `cwd` below the top of the working tree, `''` or ending in `/`, and where git keeps * each of `names`. Fails outside a working tree. diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index d035af0..d66bea1 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -99,6 +99,10 @@ export const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckComm return yield* handle.exitCode }, Effect.scoped) +export function logLines(lines: ReadonlyArray): Effect.Effect { + return Effect.forEach(lines, (line) => Console.log(line), { discard: true }) +} + export function captureLines( effect: Effect.Effect, ): Effect.Effect], E, R> { diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts index 7ab5771..f8cd9f1 100644 --- a/packages/uncheck/src/types.ts +++ b/packages/uncheck/src/types.ts @@ -11,6 +11,7 @@ export interface CheckOutcome { readonly name: CheckName readonly status: 'passed' | 'failed' | 'skipped' readonly reason?: string + readonly unrelated?: boolean } export interface CheckCommand { @@ -26,12 +27,14 @@ export interface CheckInput { readonly fix: boolean /** Files to check, relative to `cwd`, or `undefined` for everything under it. */ readonly files: ReadonlyArray | undefined + /** Files the change deletes, relative to `cwd`. Never handed to a tool, which would read `[id].ts` as a glob. */ + readonly deleted: ReadonlyArray readonly projectFiles: ProjectFiles } export interface Check { readonly name: CheckName - /** `workspace` fixes reach beyond the given files, so a commit, which stages only those, cannot take them. */ + /** `workspace` fixes reach beyond the given files, so the hooks, which fix only a commit or an agent's change, leave them out. */ readonly fixes: false | 'files' | 'workspace' readonly plan: ( input: CheckInput, diff --git a/packages/uncheck/tests/hooks/run/changes.test.ts b/packages/uncheck/tests/hooks/run/changes.test.ts index 14199b6..6cc47c8 100644 --- a/packages/uncheck/tests/hooks/run/changes.test.ts +++ b/packages/uncheck/tests/hooks/run/changes.test.ts @@ -1,6 +1,18 @@ +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' -import { LAYOUTS, report } from '../../utils/project' +import { + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + monorepo, + report, + singleRepo, + SKIPPED_FOR_DELETIONS, + temporaryDirectory, + UTILS_NOT_FOUND, +} from '../../utils/project' import { CLAUDE_CODE_STOP, stopHook } from './utils' const UNFORMATTED = 'export const legacy = 1\n' @@ -53,7 +65,40 @@ describe.each(LAYOUTS)( expect(project.read(`${app}dist/bundle.js`)).toBe('export var bundle = 1\n') }) - it('leaves a file deleted since the last commit out, even one whose name reads as a pattern', async () => { + it('leaves alone installed packages that no ignore rule covers, links into them and links leaving the project', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'shared.ts'), UNFORMATTED) + const dependency = `${app}lib/node_modules/dep/index.js` + const project = create({ '.gitignore': '/node_modules\ndist\n*.tsbuildinfo\n' }) + .write({ + [dependency]: 'export var dep = 1\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + .link(`${app}src/shared.ts`, join(store, 'shared.ts')) + .link(`${app}src/vendor.js`, '../lib/node_modules/dep/index.js') + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--fix', '--only=oxlint', '--only=oxfmt'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(project.read(`${app}src/extra.ts`)).toBe('export const extra = 1;\n') + expect(project.read(dependency)).toBe('export var dep = 1\n') + expect(readFileSync(join(store, 'shared.ts'), 'utf8')).toBe(UNFORMATTED) + }) + + it('never hands oxlint or oxfmt a deleted file, even one whose name reads as a pattern', async () => { const project = create({ [`${app}src/[id].ts`]: 'export const id = 1;\n', [`${app}src/i.ts`]: UNFORMATTED, @@ -65,7 +110,10 @@ describe.each(LAYOUTS)( expect(deleted.stdout).toBe('') expect(report(deleted.stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, - '○ nothing to check, no files match src/[id].ts', + ...SKIPPED_FOR_DELETIONS, + tsc, + '✔ tsc passed', + '✔ all checks passed (tsc)', ]) expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) @@ -90,6 +138,29 @@ describe.each(LAYOUTS)( expect(project.read(`${app}src/i.ts`)).toBe(UNFORMATTED) }) + it('checks only the changed files beside a file named HEAD', async () => { + const project = create({ + [`${app}HEAD`]: 'not a revision\n', + [`${app}src/legacy.ts`]: UNFORMATTED, + }).write({ [`${app}src/extra.ts`]: 'export const extra = 1;\n' }) + + const { exitCode, stdout, stderr } = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + }) + + expect(exitCode).toBe(0) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ all checks passed (oxfmt)', + ]) + }) + it('checks everything under the directory outside git', async () => { const project = create({ [`${app}src/legacy.ts`]: UNFORMATTED }, { git: 'none' }) @@ -102,9 +173,9 @@ describe.each(LAYOUTS)( expect(report(stderr)).toEqual([ `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not a workspace root', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -127,7 +198,7 @@ describe.each(LAYOUTS)( `uncheck in ${project.path(app, '.')}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + FULL_OXFMT, '✘ oxfmt failed', '○ tsc skipped, not selected by --only', '✘ 1 of 1 checks failed: oxfmt', @@ -137,3 +208,86 @@ describe.each(LAYOUTS)( }) }, ) + +describe('hooks run with deleted files in a single repo', () => { + it.each([[[]], [['--require=tsc']]])( + 'sends the agent back when a deleted file is still imported, with %j', + async (flags) => { + const project = singleRepo().write({ 'src/utils.ts': null }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', ...flags], + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stderr).toContain(UTILS_NOT_FOUND) + }, + ) + + it('sends the agent back when a moved file is still imported from where it was', async () => { + const project = singleRepo() + + mkdirSync(project.path('scripts')) + project.git('mv', 'src/utils.ts', 'scripts/utils.ts') + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stderr).toContain(UTILS_NOT_FOUND) + }) +}) + +describe('hooks run with a changed package.json in a monorepo', () => { + it('only reports what sherif finds, since its fixes reach beyond the change', async () => { + const project = monorepo() + .update('packages/core/package.json', (manifest) => ({ + ...manifest, + dependencies: { zod: '^3.0.0' }, + })) + .commit('zod') + .update('packages/app/package.json', (manifest) => ({ + ...manifest, + dependencies: { '@repo/core': 'workspace:*', 'zod': '^3.1.0' }, + })) + const manifests = ['package.json', 'packages/core/package.json', 'packages/app/package.json'] + const before = manifests.map((file) => project.read(file)) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✘ sherif failed', + '▶ oxlint --fix --no-error-on-unmatched-pattern packages/app/package.json', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern packages/app/package.json', + '✔ oxfmt passed', + '○ tsc skipped, no tsconfig.json covers the given files', + '✘ 1 of 3 checks failed: sherif', + ]) + expect(stderr).toContain('multiple-dependency-versions') + expect(manifests.map((file) => project.read(file))).toEqual(before) + }) +}) diff --git a/packages/uncheck/tests/hooks/run/families.test.ts b/packages/uncheck/tests/hooks/run/families.test.ts index 2c6e5e6..65c04e6 100644 --- a/packages/uncheck/tests/hooks/run/families.test.ts +++ b/packages/uncheck/tests/hooks/run/families.test.ts @@ -7,6 +7,7 @@ import { COPILOT_STOP_IN_CLAUDE_FORMAT, CURSOR_STOP, TYPE_ERROR, + hookEnv, stopHook, } from './utils' @@ -41,7 +42,11 @@ async function installHook( : (JSON.parse(project.read(`${app}.cursor/hooks.json`)) as CursorHooks).hooks.stop[0]!.command return (payload) => - run(['sh', '-c', command], { cwd: project.path('docs'), input: JSON.stringify(payload) }) + run(['sh', '-c', command], { + cwd: project.path('docs'), + env: hookEnv(), + input: JSON.stringify(payload), + }) } function sendBackReason(stderr: string): string { @@ -64,6 +69,20 @@ describe.each(LAYOUTS)( const failure = (project: Project) => checks(project, '✘ tsc failed', '✘ 1 of 3 checks failed: tsc') + function expectBlocked(project: Project, { exitCode, stdout, stderr }: Run): void { + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual(failure(project)) + } + + function expectStillFails(project: Project, { exitCode, stdout, stderr }: Run): void { + expect(exitCode).toBe(0) + expect(JSON.parse(stdout)).toEqual({ + systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', + }) + expect(report(stderr)).toEqual(failure(project)) + } + it('blocks Claude Code with exit code 2 until its change passes, through its hook run from outside the change', async () => { const project = create(DOCS) const stop = await installHook(project, app, 'claude') @@ -72,18 +91,12 @@ describe.each(LAYOUTS)( const blocked = await stop(CLAUDE_CODE_STOP) - expect(blocked.exitCode).toBe(2) - expect(blocked.stdout).toBe('') - expect(report(blocked.stderr)).toEqual(failure(project)) + expectBlocked(project, blocked) expect(blocked.stderr).toContain(`\n${DIAGNOSTIC}\n`) const again = await stop(CLAUDE_CODE_STOP_AGAIN) - expect(again.exitCode).toBe(0) - expect(JSON.parse(again.stdout)).toEqual({ - systemMessage: 'uncheck still fails: 1 of 3 checks failed: tsc', - }) - expect(report(again.stderr)).toEqual(failure(project)) + expectStillFails(project, again) project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42"\n' }) @@ -97,6 +110,67 @@ describe.each(LAYOUTS)( expect(project.read(`${app}src/index.ts`)).toBe('export const answer: string = "42";\n') }) + it('blocks Claude Code again only after its checks passed, even when another hook continued the turn', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const continued = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expectBlocked(project, continued) + + const again = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expectStillFails(project, again) + + const nextTurn = await stopHook(project, app, CLAUDE_CODE_STOP) + + expectBlocked(project, nextTurn) + + project.write({ [`${app}src/index.ts`]: 'export const answer: string = "42";\n' }) + + const passing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(passing.exitCode).toBe(0) + expect(passing.stdout).toBe('') + expect(report(passing.stderr)).toEqual( + checks(project, '✔ tsc passed', '✔ all checks passed (oxlint, oxfmt, tsc)'), + ) + + project.write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const failingAfterPassing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expectBlocked(project, failingAfterPassing) + + const stillFailing = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expectStillFails(project, stillFailing) + + project.git('checkout', '--', '.') + + const reverted = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expect(reverted.exitCode).toBe(0) + expect(reverted.stdout).toBe('') + expect(reverted.stderr).toBe('') + + project.write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const failingAfterReverting = await stopHook(project, app, CLAUDE_CODE_STOP_AGAIN) + + expectBlocked(project, failingAfterReverting) + }) + + it('takes a continued turn without a session id as one uncheck already blocked', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + + const continued = await stopHook(project, app, { + hook_event_name: 'Stop', + stop_hook_active: true, + }) + + expectStillFails(project, continued) + }) + it('sends Cursor back once with a follow-up message, through its hook run from outside the change', async () => { const project = create(DOCS) const stop = await installHook(project, app, 'cursor') @@ -116,6 +190,47 @@ describe.each(LAYOUTS)( expect(again.exitCode).toBe(0) expect(again.stdout).toBe('') expect(report(again.stderr)).toEqual(failure(project)) + + const anotherConversation = await stop({ + ...CURSOR_STOP, + conversation_id: 'other', + loop_count: 1, + }) + + expect(anotherConversation.exitCode).toBe(0) + expect(report(anotherConversation.stderr)).toEqual(failure(project)) + expect(JSON.parse(anotherConversation.stdout)).toEqual({ + followup_message: sendBackReason(anotherConversation.stderr), + }) + }) + + it('blocks Copilot once per turn, even after another hook continued it', async () => { + const project = create().write({ [`${app}src/index.ts`]: TYPE_ERROR }) + const continuedStop = { ...COPILOT_AGENT_STOP, stop_hook_active: true } + + const continued = await stopHook(project, app, continuedStop) + + expect(continued.exitCode).toBe(0) + expect(report(continued.stderr)).toEqual(failure(project)) + expect(JSON.parse(continued.stdout)).toEqual({ + decision: 'block', + reason: sendBackReason(continued.stderr), + }) + + const again = await stopHook(project, app, continuedStop) + + expect(again.exitCode).toBe(0) + expect(again.stdout).toBe('') + expect(report(again.stderr)).toEqual(failure(project)) + + const nextTurn = await stopHook(project, app, COPILOT_AGENT_STOP) + + expect(nextTurn.exitCode).toBe(0) + expect(report(nextTurn.stderr)).toEqual(failure(project)) + expect(JSON.parse(nextTurn.stdout)).toEqual({ + decision: 'block', + reason: sendBackReason(nextTurn.stderr), + }) }) it('blocks Copilot with a decision, whichever payload format it sends', async () => { diff --git a/packages/uncheck/tests/hooks/run/location.test.ts b/packages/uncheck/tests/hooks/run/location.test.ts index 9c01548..0250906 100644 --- a/packages/uncheck/tests/hooks/run/location.test.ts +++ b/packages/uncheck/tests/hooks/run/location.test.ts @@ -1,14 +1,22 @@ +import { join } from 'node:path' + +import type { Run } from '../../utils/project' import { CLI, + DUBIOUS_OWNERSHIP, LAYOUTS, + Project, cliError, + git, + gitConfig, + linkedWorktree, monorepo, report, run, singleRepo, temporaryDirectory, } from '../../utils/project' -import { CLAUDE_CODE_STOP, stopHook } from './utils' +import { CLAUDE_CODE_STOP, TYPE_ERROR, stopHook, stopHookIn } from './utils' const UNFORMATTED = 'export const extra = 1\n' const FORMATTED = 'export const extra = 1;\n' @@ -18,7 +26,21 @@ const ONLY_OXFMT = [ '○ oxlint skipped, not selected by --only', ] -describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app }) => { +function expectSentBackByOxfmt({ exitCode, stdout, stderr }: Run, dir: string): void { + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${dir}`, + ...ONLY_OXFMT, + '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', + '✘ oxfmt failed', + '○ tsc skipped, not selected by --only', + '✘ 1 of 1 checks failed: oxfmt', + ' rerun with `--fix` to apply oxfmt fixes', + ]) +} + +describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app, tsc }) => { it('checks the directory given with --cwd, wherever it runs', async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) @@ -40,23 +62,155 @@ describe.each(LAYOUTS)('hooks run finds the project in a $name', ({ create, app expect(project.read(`${app}src/extra.ts`)).toBe(FORMATTED) }) - it('reports a --dir that names nothing instead of sending the agent back', async () => { + it.each(['gone', 'package.json'])( + 'reports a --dir naming %s, which is no folder, instead of sending the agent back', + async (name) => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', `--dir=${app}${name}`], + cwd: `${app}src`, + }) + + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(stderr).toBe( + cliError( + `--dir=${app}${name} names no folder in ${project.dir}, run \`uncheck hooks install\` again from the project`, + ), + ) + expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) + }, + ) + + it('reports a repository git refuses instead of checking the whole folder', async () => { const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) - const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { - args: ['--fix', `--dir=${app}gone`], + const fromInside = await stopHook(project, app, CLAUDE_CODE_STOP, { cwd: `${app}src`, + env: DUBIOUS_OWNERSHIP, + }) + const fromOutside = await stopHookIn(temporaryDirectory(), app, CLAUDE_CODE_STOP, { + env: { ...DUBIOUS_OWNERSHIP, CLAUDE_PROJECT_DIR: project.path(app) }, }) - expect(exitCode).toBe(1) - expect(stdout).toBe('') - expect(stderr).toBe( - cliError( - `--dir=${app}gone names nothing in ${project.dir}, run \`uncheck hooks install\` again from the project`, - ), - ) + for (const { exitCode, stdout, stderr } of [fromInside, fromOutside]) { + expect(exitCode).toBe(1) + expect(stdout).toBe('') + expect(project.normalize(stderr)).toContain( + cliError("fatal: detected dubious ownership in repository at ''").trimEnd(), + ) + expect(stderr).toContain('safe.directory') + expect(report(stderr)).toEqual([]) + } + expect(project.read(`${app}src/extra.ts`)).toBe(UNFORMATTED) }) + + it.each(['CLAUDE_PROJECT_DIR', 'CODEBUDDY_PROJECT_DIR'])( + 'checks the project in %s outside git, from the folder the agent moved to', + async (variable) => { + const project = create({ [`${app}src/index.ts`]: TYPE_ERROR }, { git: 'none' }) + + const { exitCode, stdout, stderr } = await stopHook(project, '', CLAUDE_CODE_STOP, { + args: ['--fix', '--only=tsc'], + cwd: `${app}src`, + env: { [variable]: project.path(app) }, + }) + + expect(exitCode).toBe(2) + expect(stdout).toBe('') + expect(report(stderr)).toEqual([ + `uncheck in ${project.path(app, '.')}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, not selected by --only', + tsc, + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + }, + ) + + it("checks the agent's project from its own folder", async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: app, + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expectSentBackByOxfmt(sentBack, project.path(app, '.')) + }) + + it("checks the agent's project from a folder outside git", async () => { + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHookIn(temporaryDirectory(), app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expectSentBackByOxfmt(sentBack, project.path(app, '.')) + }) + + it("checks the agent's project from a submodule the agent moved into", async () => { + const library = temporaryDirectory() + + git(library, ['init', '--quiet']) + git(library, ['commit', '--quiet', '--allow-empty', '--message=library']) + + const project = create() + + project.git( + '-c', + 'protocol.file.allow=always', + 'submodule', + 'add', + '--quiet', + library, + 'vendor/lib', + ) + project.commit('submodule').write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'vendor/lib', + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expectSentBackByOxfmt(sentBack, project.path(app, '.')) + }) + + it('checks the linked worktree the agent works in, although its project folder is the main checkout', async () => { + const project = create() + const worktree = linkedWorktree(project, app).write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHook(worktree, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: `${app}src`, + env: { CLAUDE_PROJECT_DIR: project.path(app) }, + }) + + expectSentBackByOxfmt(sentBack, worktree.path(app, '.')) + }) + + it('checks the repository the agent moved to when it is not part of the project', async () => { + const elsewhere = temporaryDirectory() + + git(elsewhere, ['init', '--quiet']) + + const project = create().write({ [`${app}src/extra.ts`]: UNFORMATTED }) + + const sentBack = await stopHook(project, app, CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: `${app}src`, + env: { CLAUDE_PROJECT_DIR: elsewhere }, + }) + + expectSentBackByOxfmt(sentBack, project.path(app, '.')) + }) }) describe('hooks run in a monorepo', () => { @@ -71,17 +225,7 @@ describe('hooks run in a monorepo', () => { cwd: 'packages/core/src', }) - expect(app.exitCode).toBe(2) - expect(app.stdout).toBe('') - expect(report(app.stderr)).toEqual([ - `uncheck in ${project.path('packages/app')}`, - ...ONLY_OXFMT, - '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', - '✘ oxfmt failed', - '○ tsc skipped, not selected by --only', - '✘ 1 of 1 checks failed: oxfmt', - ' rerun with `--fix` to apply oxfmt fixes', - ]) + expectSentBackByOxfmt(app, project.path('packages/app')) const top = await stopHook(project, '', CLAUDE_CODE_STOP, { args: ['--fix', '--only=oxfmt'], @@ -136,6 +280,87 @@ describe('hooks run in a monorepo', () => { }) }) +describe('hooks run through links into a package', () => { + it('checks the package a --cwd link points to, with and without --dir', async () => { + const project = monorepo().write({ 'packages/app/src/extra.ts': UNFORMATTED }) + const outside = new Project(temporaryDirectory()).link('app', project.path('packages/app')) + + for (const dir of [['--dir=packages/app'], []]) { + const sentBack = await stopHook(outside, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt', `--cwd=${outside.path('app')}`, ...dir], + }) + + expectSentBackByOxfmt(sentBack, project.path('packages/app')) + } + }) + + it("checks the agent's project named through a link from a repository nested in it", async () => { + const project = monorepo().write({ 'vendor/nested/README.md': '# Nested\n' }) + const outside = new Project(temporaryDirectory()).link('app', project.path('packages/app')) + + git(project.path('vendor/nested'), ['init', '--quiet']) + project.write({ 'packages/app/src/extra.ts': UNFORMATTED }) + + const sentBack = await stopHook(project, 'packages/app/', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'vendor/nested', + env: { CLAUDE_PROJECT_DIR: outside.path('app') }, + }) + + expectSentBackByOxfmt(sentBack, project.path('packages/app')) + }) +}) + +describe('hooks run in a repository nested in one git refuses', () => { + it('checks the repository the agent moved to when the project is another one', async () => { + const project = singleRepo() + const outer = temporaryDirectory() + const nested = new Project(join(outer, 'nested')) + .write({ '.gitignore': 'node_modules\n' }) + .link('node_modules', project.path('node_modules')) + + git(outer, ['init', '--quiet']) + git(nested.dir, ['init', '--quiet']) + nested.commit('init').write({ 'src/extra.ts': UNFORMATTED }) + + const sentBack = await stopHook(nested, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'src', + env: { + CLAUDE_PROJECT_DIR: project.dir, + GIT_TEST_ASSUME_DIFFERENT_OWNER: '1', + GIT_CONFIG_GLOBAL: gitConfig( + `[safe]\n\tdirectory = ${nested.dir}\n\tdirectory = ${project.dir}\n`, + ), + }, + }) + + expectSentBackByOxfmt(sentBack, nested.dir) + }) +}) + +describe('hooks run in a repository whose work tree lies below its .git folder', () => { + it("checks that repository when the agent's project is another one", async () => { + const project = singleRepo() + const holder = temporaryDirectory() + const checkout = new Project(join(holder, 'checkout')) + .write({ '.gitignore': 'node_modules\n' }) + .link('node_modules', project.path('node_modules')) + + git(holder, ['init', '--quiet']) + git(holder, ['config', 'core.worktree', checkout.dir]) + checkout.commit('init').write({ 'src/extra.ts': UNFORMATTED }) + + const sentBack = await stopHook(checkout, '', CLAUDE_CODE_STOP, { + args: ['--only=oxfmt'], + cwd: 'src', + env: { CLAUDE_PROJECT_DIR: project.dir }, + }) + + expectSentBackByOxfmt(sentBack, checkout.dir) + }) +}) + describe('hooks run arguments', () => { it('refuses a --cwd that does not exist, checking nothing', async () => { const project = singleRepo().write({ 'src/extra.ts': UNFORMATTED }) diff --git a/packages/uncheck/tests/hooks/run/utils.ts b/packages/uncheck/tests/hooks/run/utils.ts index e3c2dbf..a7091ba 100644 --- a/packages/uncheck/tests/hooks/run/utils.ts +++ b/packages/uncheck/tests/hooks/run/utils.ts @@ -1,4 +1,5 @@ import type { Env, Project, Run } from '../../utils/project' +import { CLI, run, temporaryDirectory } from '../../utils/project' export const CLAUDE_CODE_STOP = { session_id: 'session', @@ -8,9 +9,18 @@ export const CLAUDE_CODE_STOP = { export const CLAUDE_CODE_STOP_AGAIN = { ...CLAUDE_CODE_STOP, stop_hook_active: true } -export const CURSOR_STOP = { hook_event_name: 'stop', status: 'completed', loop_count: 0 } +export const CURSOR_STOP = { + conversation_id: 'conversation', + hook_event_name: 'stop', + status: 'completed', + loop_count: 0, +} -export const COPILOT_AGENT_STOP = { stopReason: 'end_turn' } +export const COPILOT_AGENT_STOP = { + sessionId: 'session', + stopReason: 'end_turn', + stop_hook_active: false, +} export const COPILOT_STOP_IN_CLAUDE_FORMAT = { hook_event_name: 'Stop', @@ -20,25 +30,40 @@ export const COPILOT_STOP_IN_CLAUDE_FORMAT = { export const TYPE_ERROR = 'export const answer: string = 1;\n' +// uncheck remembers the turns it blocked in the temporary folder, which would keep a file of every test run. +const MARKERS = temporaryDirectory() + +export function hookEnv(env?: Env): Env { + return { TMPDIR: MARKERS, ...env } +} + export function dirFlags(app: string): string[] { return app === '' ? [] : [`--dir=${app.slice(0, -1)}`] } interface HookOptions { readonly args?: ReadonlyArray - readonly cwd?: string readonly env?: Env } -export function stopHook( - project: Project, +export function stopHookIn( + dir: string, app: string, payload: object | string, - { args = ['--fix'], cwd, env }: HookOptions = {}, + { args = ['--fix'], env }: HookOptions = {}, ): Promise { - return project.uncheck(['hooks', 'run', ...dirFlags(app), ...args], { - cwd, - env, + return run([...CLI, 'hooks', 'run', ...dirFlags(app), ...args], { + cwd: dir, + env: hookEnv(env), input: typeof payload === 'string' ? payload : JSON.stringify(payload), }) } + +export function stopHook( + project: Project, + app: string, + payload: object | string, + { cwd = '.', ...options }: HookOptions & { readonly cwd?: string } = {}, +): Promise { + return stopHookIn(project.path(cwd), app, payload, options) +} diff --git a/packages/uncheck/tests/prepare/commit.test.ts b/packages/uncheck/tests/prepare/commit.test.ts index 357a49c..7ebb275 100644 --- a/packages/uncheck/tests/prepare/commit.test.ts +++ b/packages/uncheck/tests/prepare/commit.test.ts @@ -1,6 +1,6 @@ -import type { Project } from '../utils/project' -import { LAYOUTS, monorepo, report, run } from '../utils/project' -import { installHusky, prepare } from './utils' +import type { Files, Project } from '../utils/project' +import { commitWithHooks, LAYOUTS, monorepo, report, SKIPPED_FOR_DELETIONS } from '../utils/project' +import { HUSKY_4_BANNER, HUSKY_4_RUNNER, installHusky, prepare } from './utils' const LINT_CONFIG = { rules: { 'no-var': 'error', 'no-empty-pattern': 'error' } } @@ -8,17 +8,23 @@ const UNFIXABLE = 'export function ignore({}: object): void {}\n' const TSC_WITHOUT_REFERENCES = '▶ tsc -p tsconfig.json --noEmit' -function commit(project: Project, message: string) { - return run(['git', 'commit', '--quiet', `--message=${message}`], { cwd: project.dir }) +const TSC_WITH_REFERENCES = '▶ tsc -b tsconfig.json' + +async function preparedMonorepo(files?: Files): Promise { + const project = monorepo(files) + await prepare(project, [], { cwd: 'packages/core' }) + await prepare(project, [], { cwd: 'packages/app' }) + + return project } -function blockedByLint(dir: string, tsc: string): string[] { +function blockedByLint(dir: string, tsc: string, file = 'src/ignore.ts'): string[] { return [ `uncheck staged in ${dir}`, '○ sherif skipped, no package.json among the given files', - '▶ oxlint --fix --no-error-on-unmatched-pattern src/ignore.ts', + `▶ oxlint --fix --no-error-on-unmatched-pattern ${file}`, '✘ oxlint failed', - '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + `▶ oxfmt --no-error-on-unmatched-pattern ${file}`, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -36,8 +42,8 @@ function fixedAndStaged(dir: string, tsc: string): string[] { '✔ oxfmt passed', tsc, '✔ tsc passed', - '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/spaced.ts', + '✔ all checks passed (oxlint, oxfmt, tsc)', ] } @@ -47,7 +53,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create await prepare(project, [], { cwd: app }) project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) - const { exitCode, stderr } = await commit(project, 'ignore') + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') expect(exitCode).toBe(1) expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) @@ -60,7 +66,7 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create await prepare(project, [], { cwd: app }) project.stage({ [`${app}src/spaced.ts`]: 'export var spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) @@ -70,13 +76,30 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create expect(project.git('status', '--porcelain')).toBe('') }) + it('blocks a commit before the runner of husky 4, which always exits', async () => { + const project = create({ '.oxlintrc.json': LINT_CONFIG }) + project.write({ + '.git/hooks/pre-commit': `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n`, + '.git/hooks/husky.sh': 'echo "husky 4 ran" >&2\nexit 0\n', + }) + await prepare(project, [], { cwd: app }) + project.stage({ [`${app}src/ignore.ts`]: UNFIXABLE }) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual(blockedByLint(project.path(app, '.'), tsc)) + expect(stderr).not.toContain('husky 4 ran') + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + it('runs through the husky 9 dispatcher before the commands of the hook', async () => { const project = installHusky(create(), 'echo "husky hook ran"\n') await prepare(project, [], { cwd: app }) project.commit('husky') project.stage({ [`${app}src/spaced.ts`]: 'export const spaced = 1\n' }) - const { exitCode, stderr } = await commit(project, 'spaced') + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') expect(exitCode).toBe(0) expect(report(stderr)).toEqual(fixedAndStaged(project.path(app, '.'), tsc)) @@ -87,15 +110,13 @@ describe.each(LAYOUTS)('committing with the prepared hook in a $name', ({ create describe('committing with the prepared hook of several packages in a monorepo', () => { it('stops at the first package whose line fails', async () => { - const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) - await prepare(project, [], { cwd: 'packages/core' }) - await prepare(project, [], { cwd: 'packages/app' }) + const project = await preparedMonorepo({ '.oxlintrc.json': LINT_CONFIG }) project.stage({ 'packages/core/src/ignore.ts': UNFIXABLE, 'packages/app/src/spaced.ts': 'export const spaced = 1\n', }) - const { exitCode, stderr } = await commit(project, 'both') + const { exitCode, stderr } = await commitWithHooks(project, '--message=both') expect(exitCode).toBe(1) expect(report(stderr)).toEqual( @@ -104,4 +125,109 @@ describe('committing with the prepared hook of several packages in a monorepo', expect(project.read('packages/app/src/spaced.ts')).toBe('export const spaced = 1\n') expect(project.git('log', '--format=%s')).toBe('init\n') }) + + it('skips the line of a package the commit leaves alone', async () => { + const project = await preparedMonorepo() + project.stage({ 'packages/app/src/spaced.ts': 'export var spaced = 1\n' }) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual( + fixedAndStaged(project.path('packages/app'), TSC_WITH_REFERENCES), + ) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + }) + + it('checks the files of `git commit ` in a package', async () => { + const project = await preparedMonorepo({ '.oxlintrc.json': LINT_CONFIG }) + project.write({ 'packages/app/src/index.ts': UNFIXABLE }) + + const { exitCode, stderr } = await commitWithHooks( + project, + '--message=ignore', + '--', + 'packages/app/src/index.ts', + ) + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual( + blockedByLint(project.path('packages/app'), TSC_WITH_REFERENCES, 'src/index.ts'), + ) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + + it('runs the line of a package the commit only deletes from', async () => { + const project = await preparedMonorepo() + project.git('rm', '--quiet', 'packages/app/src/index.ts') + + const { exitCode, stderr } = await commitWithHooks(project, '--message=remove') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + ...SKIPPED_FOR_DELETIONS, + TSC_WITH_REFERENCES, + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(project.git('log', '--format=%s')).toBe('remove\ninit\n') + }) + + it('skips the line of a package that was moved away', async () => { + const project = await preparedMonorepo() + project.git('mv', 'packages/app', 'packages/web') + await prepare(project, [], { cwd: 'packages/web' }) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=rename') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path('packages/web')}`, + '○ sherif skipped, not a workspace root', + '▶ oxlint --fix --no-error-on-unmatched-pattern package.json src/index.ts tsconfig.json', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern package.json src/index.ts tsconfig.json', + '✔ oxfmt passed', + TSC_WITH_REFERENCES, + '✔ tsc passed', + '✔ all checks passed (oxlint, oxfmt, tsc)', + ]) + expect(project.git('log', '--format=%s')).toBe('rename\ninit\n') + }) + + it('skips the line of a package a sparse checkout leaves out', async () => { + const project = await preparedMonorepo() + project.git('sparse-checkout', 'set', 'packages/core') + project.stage({ 'packages/core/src/spaced.ts': 'export var spaced = 1\n' }) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=spaced') + + expect(exitCode).toBe(0) + expect(report(stderr)).toEqual( + fixedAndStaged(project.path('packages/core'), TSC_WITHOUT_REFERENCES), + ) + expect(project.git('log', '--format=%s')).toBe('spaced\ninit\n') + }) + + it('checks a package whose folder name git could read as pathspec magic', async () => { + const project = monorepo({ '.oxlintrc.json': LINT_CONFIG }) + project.stage({ ':docs/src/ignore.ts': UNFIXABLE }) + await prepare(project, [], { cwd: ':docs' }) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=ignore') + + expect(exitCode).toBe(1) + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path(':docs')}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/ignore.ts', + '✘ oxlint failed', + '▶ oxfmt --no-error-on-unmatched-pattern src/ignore.ts', + '✔ oxfmt passed', + '○ tsc skipped, no tsconfig.json covers the given files', + '✘ 1 of 2 checks failed: oxlint', + ]) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) }) diff --git a/packages/uncheck/tests/prepare/existing-hook.test.ts b/packages/uncheck/tests/prepare/existing-hook.test.ts index 143e3cc..3825eb3 100644 --- a/packages/uncheck/tests/prepare/existing-hook.test.ts +++ b/packages/uncheck/tests/prepare/existing-hook.test.ts @@ -1,14 +1,84 @@ import { Buffer } from 'node:buffer' import { readFileSync, writeFileSync } from 'node:fs' +import type { Project } from '../utils/project' import { LAYOUTS, monorepo } from '../utils/project' -import { COMMAND, HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' +import { + COMMAND, + HEADER, + hookLine, + HUSKY_4_BANNER, + HUSKY_4_RUNNER, + notWritten, + prepare, + previousHookLine, + runsIn, + shownHook, + syntaxCheck, + written, +} from './utils' const HOOK = '.git/hooks/pre-commit' +function expectHookParses(project: Project): void { + expect(syntaxCheck(project.path(HOOK))).toEqual({ status: 0, stderr: '' }) +} + +const EXITING_HOOKS = [ + { + name: "git's pre-commit.sample", + setup: '#!/bin/sh\n#\n# An example hook script to verify what is about to be committed.\n\n', + commands: [ + 'if git rev-parse --verify HEAD >/dev/null 2>&1', + 'then', + '\tagainst=HEAD', + 'else', + '\tagainst=$(git hash-object -t tree /dev/null)', + 'fi', + '', + 'exec 1>&2', + '', + 'exec git diff-index --check --cached $against --', + '', + ].join('\n'), + }, + { + name: 'the hook of the pre-commit framework', + setup: [ + '#!/usr/bin/env bash', + '# File generated by pre-commit: https://pre-commit.com', + '# ID: 138fd403232d2ddd5efb44317e38bf03', + '', + '# start templated', + 'INSTALL_PYTHON=/usr/bin/python3', + '', + ].join('\n'), + commands: [ + 'ARGS=(hook-impl --config=.pre-commit-config.yaml --hook-type=pre-commit)', + '# end templated', + '', + 'HERE="$(cd "$(dirname "$0")" && pwd)"', + 'ARGS+=(--hook-dir "$HERE" -- "$@")', + '', + 'if [ -x "$INSTALL_PYTHON" ]; then', + // oxlint-disable-next-line no-template-curly-in-string + ' exec "$INSTALL_PYTHON" -mpre_commit "${ARGS[@]}"', + 'elif command -v pre-commit > /dev/null; then', + // oxlint-disable-next-line no-template-curly-in-string + ' exec pre-commit "${ARGS[@]}"', + 'else', + " echo '`pre-commit` not found. Did you forget to activate your virtualenv?' 1>&2", + ' exit 1', + 'fi', + '', + ].join('\n'), + }, +] + describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, app }) => { const line = hookLine(app) const folder = app.slice(0, -1) + const lineOfV003 = app === '' ? COMMAND : `cd "${folder}" && ${COMMAND}` async function prepareHook(hook: string) { const project = create().write({ [HOOK]: hook }) @@ -18,6 +88,12 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap return { ...run, project, hook: project.read(HOOK) } } + async function expectUnchangedWhenPreparedAgain(project: Project): Promise { + const again = await prepare(project, [], { cwd: app }) + + expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + } + it('rewrites the line an older version wrote in place', async () => { const old = app === '' ? 'npx uncheck staged --fix' : `cd "${folder}" && yarn uncheck staged --fix` @@ -28,6 +104,89 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(hook).toBe(`${HEADER}${line}\npnpm test\n`) }) + it.each(EXITING_HOOKS)( + 'moves the line v0.0.3 appended after the exec of $name before it', + async ({ setup, commands }) => { + const { stdout, hook, project } = await prepareHook(`${setup}${commands}${lineOfV003}\n`) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${setup}${line}\n${commands}`) + + await expectUnchangedWhenPreparedAgain(project) + }, + ) + + it('keeps in its block a line v0.0.3 rewrote in place', async () => { + const block = (text: string) => `#!/bin/sh\nif [ -z "$SKIP_CHECKS" ]; then\n${text}\nfi\n` + + const { hook } = await prepareHook(block(lineOfV003)) + + expect(hook).toBe(block(line)) + }) + + it('keeps its line in each branch of an if, indented, so the hook still parses', async () => { + const branches = (first: string, second: string) => + `#!/bin/sh\nif [ -n "$CI" ]; then\n ${first}\nelse\n ${second}\nfi\n` + + const { stdout, hook, project } = await prepareHook( + branches( + runsIn(app, 'pnpm exec uncheck staged --only=oxlint'), + runsIn(app, 'pnpm exec uncheck staged'), + ), + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(branches(line, line)) + expectHookParses(project) + + await expectUnchangedWhenPreparedAgain(project) + }) + + it.each([ + ['&&', 'pnpm test &&'], + ['||', '[ -n "$SKIP_CHECKS" ] ||'], + ['a backslash', 'git diff --cached --quiet || \\'], + ])( + 'keeps in place the line of v0.0.3 after a command that runs on into it with %s', + async (_, command) => { + const existing = (text: string) => `#!/bin/sh\n${command}\n${text}\n` + + const { stdout, hook, project } = await prepareHook(existing(lineOfV003)) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(existing(line)) + expectHookParses(project) + + await expectUnchangedWhenPreparedAgain(project) + }, + ) + + it.each([ + ['after a command that runs on into it', 'pnpm test &&\n\n', ''], + ['that is all of an unindented block', 'if [ "$FULL" = 1 ]; then\n', 'fi\n'], + ])('keeps in place a copy of its line %s', async (_, before, after) => { + const existing = (text: string) => `${HEADER}${line}\n${before}${text}\n${after}` + + const { hook, project } = await prepareHook(existing(lineOfV003)) + + expect(hook).toBe(existing(line)) + expectHookParses(project) + }) + + it('keeps its line after a block that runs it too', async () => { + const existing = (inBlock: string, after: string) => + `#!/bin/sh\nif [ -n "$CI" ]; then\n ${inBlock}\nfi\n${after}\n` + + const { stdout, hook, project } = await prepareHook( + existing(runsIn(app, 'pnpm exec uncheck staged --only=oxlint'), previousHookLine(app)), + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(existing(line, line)) + + await expectUnchangedWhenPreparedAgain(project) + }) + it('drops the copies of its line and settles on one', async () => { const copies = [ line, @@ -42,9 +201,7 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(stdout).toBe(written(shownHook(project, app), 'updated')) expect(hook).toBe(`${HEADER}${line}\npnpm test\npnpm test\n`) - const again = await prepare(project, [], { cwd: app }) - - expect(again.stdout).toBe(written(shownHook(project, app), 'unchanged')) + await expectUnchangedWhenPreparedAgain(project) }) it('leaves alone the lines that only mention the command or run it another way', async () => { @@ -92,16 +249,84 @@ describe.each(LAYOUTS)('prepare with an existing hook in a $name', ({ create, ap expect(hook).toBe(`${setup}${line}\nnpx lint-staged\n`) }) + it.each([ + ['escaped double quotes', 'export A="\\"" && exec lint B="\\""'], + ['double quotes between single quotes', "export A='\"' && exec lint B='\"'"], + ])('runs before an export that goes on to run a command, with %s', async (_, command) => { + const { hook } = await prepareHook(`#!/bin/sh\n${command}\n`) + + expect(hook).toBe(`#!/bin/sh\n${line}\n${command}\n`) + }) + + it('runs after the lines of nvm that set up its PATH, whose quotes hold commands', async () => { + const setup = [ + '#!/bin/sh', + // oxlint-disable-next-line no-template-curly-in-string + 'export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"', + '[ -s "$NVM_DIR/nvm.sh" ] && \\. "$NVM_DIR/nvm.sh"', + ].join('\n') + + const { hook } = await prepareHook(`${setup}\nnpx lint-staged\n`) + + expect(hook).toBe(`${setup}\n${line}\nnpx lint-staged\n`) + }) + + it('runs before the runner of husky 4, which always exits', async () => { + const { hook } = await prepareHook(`${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n`) + + expect(hook).toBe(`${HUSKY_4_BANNER}${line}\n${HUSKY_4_RUNNER}\n`) + }) + + it('moves the line an older version wrote after the runner of husky 4 before it', async () => { + const { stdout, hook, project } = await prepareHook( + `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${previousHookLine(app)}\n`, + ) + + expect(stdout).toBe(written(shownHook(project, app), 'updated')) + expect(hook).toBe(`${HUSKY_4_BANNER}${line}\n${HUSKY_4_RUNNER}\n`) + + await expectUnchangedWhenPreparedAgain(project) + }) + + it('keeps its line after a block that only runs the husky 4 runner when it exists', async () => { + const existing = `#!/bin/sh\nif [ -f "$(dirname "$0")/husky.sh" ]; then\n ${HUSKY_4_RUNNER}\nfi\n${line}\n` + + const { stdout, hook, project } = await prepareHook(existing) + + expect(stdout).toBe(written(shownHook(project, app), 'unchanged')) + expect(hook).toBe(existing) + }) + it.each([ ['a trailing backslash', 'export PATH=/opt/bin:\\\n/usr/bin:$PATH\n'], ['an open double quote', 'MESSAGE="checks\n. before committing"\n'], ['an open single quote', "MESSAGE='checks\n. before committing'\n"], + ['an open command substitution', 'FILES=$(\n git diff --cached --name-only\n)\n'], + [ + 'a here-document in a command substitution', + 'export MESSAGE=$(cat < { const { hook } = await prepareHook(`#!/bin/sh\n${setup}npx lint-staged\n`) expect(hook).toBe(`#!/bin/sh\n${line}\n${setup}npx lint-staged\n`) }) + it.each([ + [ + 'an apostrophe in a trailing comment', + 'export PATH="/opt/bin:$PATH" # GUI clients don\'t load ~/.zshrc', + ], + ['an apostrophe between double quotes', 'export MESSAGE="don\'t commit secrets"'], + ['a trailing comment after a variable', 'PATH="/opt/homebrew/bin:$PATH" # for GUI clients'], + ['a semicolon in a trailing comment', 'export PATH="/opt/bin:$PATH" # GUI clients; see docs'], + ])('runs after a setup line with %s', async (_, setup) => { + const { hook } = await prepareHook(`#!/bin/sh\n${setup}\nnpx lint-staged\n`) + + expect(hook).toBe(`#!/bin/sh\n${setup}\n${line}\nnpx lint-staged\n`) + }) + it.each([ ['an odd single quote', "# don't commit secrets"], ['an odd double quote', '# the "main branch'], @@ -187,7 +412,75 @@ describe('prepare with an existing hook in a monorepo', () => { ) }) - it('rewrites the lines older versions wrote for the other packages', async () => { + it('rewrites the lines the previous version wrote in place, then leaves them unchanged', async () => { + const project = monorepo().write({ + [HOOK]: [ + '#!/bin/sh', + previousHookLine('packages/core/', `${COMMAND} --only=oxlint`), + previousHookLine('packages/app/'), + 'pnpm test', + '', + ].join('\n'), + }) + + const { stdout } = await prepare(project, [], { cwd: 'packages/app' }) + + expect(stdout).toBe(written(project.path(HOOK), 'updated')) + expect(project.read(HOOK)).toBe( + [ + '#!/bin/sh', + hookLine('packages/core/', `${COMMAND} --only=oxlint`), + hookLine('packages/app/'), + 'pnpm test', + '', + ].join('\n'), + ) + + const again = await prepare(project, [], { cwd: 'packages/app' }) + + expect(again.stdout).toBe(written(project.path(HOOK), 'unchanged')) + }) + + it('moves the lines of the other packages after the runner of husky 4 before it', async () => { + const core = hookLine('packages/core/') + const project = monorepo().write({ + [HOOK]: `${HUSKY_4_BANNER}${HUSKY_4_RUNNER}\n${core}\n`, + }) + + await prepare(project) + + expect(project.read(HOOK)).toBe( + `${HUSKY_4_BANNER}${core}\n${hookLine('')}\n${HUSKY_4_RUNNER}\n`, + ) + }) + + it('leaves alone the lines that enter a package with --cwd and adds its own', async () => { + const lines = [ + `${COMMAND} --cwd=packages/core || exit 1`, + `${COMMAND} --only=oxlint --cwd=packages/app || exit 1`, + ] + const project = monorepo().write({ [HOOK]: `#!/bin/sh\n${lines.join('\n')}\n` }) + + const { stdout } = await prepare(project) + + expect(stdout).toBe(written(HOOK, 'updated')) + expect(project.read(HOOK)).toBe(`#!/bin/sh\n${hookLine('')}\n${lines.join('\n')}\n`) + + const again = await prepare(project) + + expect(again.stdout).toBe(written(HOOK, 'unchanged')) + }) + + it('puts the line of a new package before a block that runs the line of another', async () => { + const block = `if [ -z "$QUICK" ]; then\n ${hookLine('packages/core/')}\nfi\n` + const project = monorepo().write({ [HOOK]: `#!/bin/sh\n${block}` }) + + await prepare(project) + + expect(project.read(HOOK)).toBe(`#!/bin/sh\n${hookLine('')}\n${block}`) + }) + + it('moves up the lines v0.0.3 appended for the other packages, in order, and rewrites them', async () => { const project = monorepo().write({ [HOOK]: [ '#!/bin/sh', @@ -205,12 +498,16 @@ describe('prepare with an existing hook in a monorepo', () => { expect(project.read(HOOK)).toBe( [ '#!/bin/sh', + 'git --literal-pathspecs diff --cached --quiet -- "packages/core" || [ ! -d "packages/core" ] || (cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', + 'git --literal-pathspecs diff --cached --quiet -- "packages/app" || [ ! -d "packages/app" ] || (cd "packages/app" && pnpm exec uncheck staged) || exit 1', 'pnpm exec uncheck staged --fix || exit 1', 'pnpm test', - '(cd "packages/core" && pnpm exec uncheck staged --fix --only=oxlint) || exit 1', - '(cd "packages/app" && pnpm exec uncheck staged) || exit 1', '', ].join('\n'), ) + + const again = await prepare(project, ['--no-fix'], { cwd: 'packages/app' }) + + expect(again.stdout).toBe(written(project.path(HOOK), 'unchanged', 'pnpm exec uncheck staged')) }) }) diff --git a/packages/uncheck/tests/prepare/failures.test.ts b/packages/uncheck/tests/prepare/failures.test.ts index 106e446..a844caa 100644 --- a/packages/uncheck/tests/prepare/failures.test.ts +++ b/packages/uncheck/tests/prepare/failures.test.ts @@ -1,7 +1,16 @@ -import { readdirSync } from 'node:fs' +import { readdirSync, rmSync } from 'node:fs' +import { setTimeout as sleep } from 'node:timers/promises' import type { Project } from '../utils/project' -import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, singleRepo } from '../utils/project' +import { + DUBIOUS_OWNERSHIP, + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + singleRepo, + temporaryDirectory, + wrappedGit, +} from '../utils/project' import { HEADER, hookLine, notWritten, prepare, shownHook, written } from './utils' const HOOK = '.git/hooks/pre-commit' @@ -11,6 +20,22 @@ function leftoverLocksAndCopies(project: Project, folder = '.git/hooks'): string } describe.each(LAYOUTS)('prepare failing to write the hook in a $name', ({ create, app }) => { + it('reports a repository git refuses to work in', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await prepare(project, [], { + cwd: app, + env: DUBIOUS_OWNERSHIP, + }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(stdout).toBe( + `✘ pre-commit not written, git refuses the repository: detected dubious ownership in repository at '${project.dir}'\n`, + ) + expect(project.exists(HOOK)).toBe(false) + }) + it.runIf(PERMISSIONS_ENFORCED)('reports a hooks folder it may not write to', async () => { const project = create().chmod('.git/hooks', 0o555) @@ -120,8 +145,31 @@ describe('prepare in every package of a monorepo at once', () => { ), ) const folders = ['', ...packages.map((name) => `packages/${name}/`)] + const atLock = temporaryDirectory() + // Relies on `git config --show-scope` being the last git call before the lock. + const env = wrappedGit( + `case " $* " in *" --show-scope "*) "$GIT" "$@"; status=$?; touch '${atLock}'/$$; exit $status;; esac`, + ) + project.write({ [`${HOOK}.lock`]: '' }) + + const running = folders.map((folder) => prepare(project, [], { cwd: folder, env })) + onTestFinished(async () => { + await Promise.allSettled(running) + }) + const endedEarly = Promise.race(running).then(({ stdout }) => { + throw new Error(`A run ended while the lock was held:\n${stdout}`) + }) - const runs = await Promise.all(folders.map((folder) => prepare(project, [], { cwd: folder }))) + await Promise.race([ + endedEarly, + vi.waitFor(() => expect(readdirSync(atLock)).toHaveLength(folders.length), { + timeout: 30_000, + interval: 10, + }), + ]) + await sleep(200) + rmSync(project.path(`${HOOK}.lock`)) + const runs = await Promise.all(running) expect(runs.map(({ exitCode }) => exitCode)).toEqual(folders.map(() => 0)) expect(runs.map(({ stdout }) => stdout.split('\n')[0])).toEqual( @@ -133,6 +181,7 @@ describe('prepare in every package of a monorepo at once', () => { ), ), ) + expect(runs.filter(({ stdout }) => stdout.split('\n')[0]!.endsWith(' created'))).toHaveLength(1) expect(project.read(HOOK).split('\n').sort()).toEqual( [...HEADER.split('\n'), ...folders.map((folder) => hookLine(folder))].sort(), ) diff --git a/packages/uncheck/tests/prepare/location.test.ts b/packages/uncheck/tests/prepare/location.test.ts index f50023e..5a91a5f 100644 --- a/packages/uncheck/tests/prepare/location.test.ts +++ b/packages/uncheck/tests/prepare/location.test.ts @@ -1,8 +1,15 @@ -import { lstatSync, readFileSync, writeFileSync } from 'node:fs' -import { join } from 'node:path' +import { existsSync, lstatSync, readFileSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' import type { Env } from '../utils/project' -import { gitConfig, LAYOUTS, temporaryDirectory, wrappedGit } from '../utils/project' +import { + gitConfig, + LAYOUTS, + monorepo, + report, + temporaryDirectory, + wrappedGit, +} from '../utils/project' import { DISPATCHER, HEADER, @@ -252,3 +259,49 @@ describe.each(LAYOUTS)('prepare with a symlinked hook in a $name', ({ create, ap expect(project.mode('hooks/pre-commit')).toBe(0o755) }) }) + +describe('prepare with --cwd naming a symlink to a package', () => { + function linkedApp() { + return monorepo().link('applink', 'packages/app') + } + + it('writes the hook of the repository the package is in, and nothing above it', async () => { + const project = linkedApp() + + const { exitCode, stdout } = await prepare(project, ['--cwd=applink']) + + expect(exitCode).toBe(0) + expect(stdout).toBe(written(project.path('.git/hooks/pre-commit'), 'created')) + expect(project.read('.git/hooks/pre-commit')).toBe(`${HEADER}${hookLine('packages/app/')}\n`) + expect(existsSync(join(dirname(project.dir), '.git'))).toBe(false) + }) + + it('lets the hook command set aside unstaged changes in the package', async () => { + const file = 'packages/app/src/extra.ts' + const project = linkedApp() + .stage({ [file]: 'export const extra = 1;\n' }) + .write({ [file]: 'export const extra = 1;\nvar unstaged = 1\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--cwd=applink', + ]) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path('packages/app')}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '○ oxfmt skipped, not selected by --only', + '○ tsc skipped, not selected by --only', + '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint)', + ]) + expect(project.read(file)).toBe('export const extra = 1;\nvar unstaged = 1\n') + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) +}) diff --git a/packages/uncheck/tests/prepare/package-manager.test.ts b/packages/uncheck/tests/prepare/package-manager.test.ts index 7c6e42f..89eec0c 100644 --- a/packages/uncheck/tests/prepare/package-manager.test.ts +++ b/packages/uncheck/tests/prepare/package-manager.test.ts @@ -97,8 +97,8 @@ describe('prepare picking the package manager in the packages of a monorepo', () expect(project.read('.git/hooks/pre-commit')).toBe( [ HEADER, - '(cd "packages/app" && bunx --no-install uncheck staged --fix) || exit 1\n', - '(cd "packages/core" && pnpm exec uncheck staged --fix) || exit 1\n', + 'git --literal-pathspecs diff --cached --quiet -- "packages/app" || [ ! -d "packages/app" ] || (cd "packages/app" && bunx --no-install uncheck staged --fix) || exit 1\n', + 'git --literal-pathspecs diff --cached --quiet -- "packages/core" || [ ! -d "packages/core" ] || (cd "packages/core" && pnpm exec uncheck staged --fix) || exit 1\n', ].join(''), ) }) diff --git a/packages/uncheck/tests/prepare/utils.ts b/packages/uncheck/tests/prepare/utils.ts index 1e04eb4..0337a28 100644 --- a/packages/uncheck/tests/prepare/utils.ts +++ b/packages/uncheck/tests/prepare/utils.ts @@ -1,3 +1,5 @@ +import { spawnSync } from 'node:child_process' + import type { Project, Run, RunOptions } from '../utils/project' export const HEADER = @@ -13,8 +15,20 @@ export function prepare( return project.uncheck(['prepare', '--pre-commit', ...args], options) } +export function runsIn(app: string, command: string): string { + return app === '' ? command : `(cd "${app.slice(0, -1)}" && ${command})` +} + +export function previousHookLine(app: string, command = COMMAND): string { + return `${runsIn(app, command)} || exit 1` +} + export function hookLine(app: string, command = COMMAND): string { - return app === '' ? `${command} || exit 1` : `(cd "${app.slice(0, -1)}" && ${command}) || exit 1` + const folder = app.slice(0, -1) + + return app === '' + ? `${command} || exit 1` + : `git --literal-pathspecs diff --cached --quiet -- "${folder}" || [ ! -d "${folder}" ] || (cd "${folder}" && ${command}) || exit 1` } export function shownHook(project: Project, app: string, hook = '.git/hooks/pre-commit'): string { @@ -29,6 +43,12 @@ export function written( return `✔ pre-commit ${shown} ${result}\n\nThe hook runs ${command} before every commit, \`git commit --no-verify\` skips it.\n` } +export function syntaxCheck(file: string): { status: number | null; stderr: string } { + const { status, stderr } = spawnSync('sh', ['-n', file], { encoding: 'utf8' }) + + return { status, stderr } +} + export function notWritten(shown: string, reason: string): string { return `✘ pre-commit ${shown} not written, ${reason}\n` } @@ -42,6 +62,19 @@ export const DISPATCHER = [ '', ].join('\n') +export const HUSKY_4_RUNNER = '. "$(dirname "$0")/husky.sh"' + +export const HUSKY_4_BANNER = [ + '#!/bin/sh', + '# husky', + '', + '# Created by Husky v4.3.8 (https://github.com/typicode/husky#readme)', + '# At: 1/20/2021, 3:58:58 PM', + '# From: /home/me/my-app (https://github.com/me/my-app#readme)', + '', + '', +].join('\n') + export const HUSKY_SHIM = '#!/usr/bin/env sh\n. "$(dirname "$0")/h"\n' export function installHusky(project: Project, hook: string): Project { diff --git a/packages/uncheck/tests/staged/basics.test.ts b/packages/uncheck/tests/staged/basics.test.ts index a7b15bc..ed50bcd 100644 --- a/packages/uncheck/tests/staged/basics.test.ts +++ b/packages/uncheck/tests/staged/basics.test.ts @@ -1,5 +1,13 @@ -import { cliError, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' -import { folderOf, inIndex } from './utils' +import { + cliError, + DUBIOUS_OWNERSHIP, + LAYOUTS, + monorepo, + project as bareProject, + report, + singleRepo, +} from '../utils/project' +import { expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) @@ -14,6 +22,22 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { expect(exitCode).toBe(1) }) + it('shows why git refuses the repository', async () => { + const project = create() + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { + cwd: folder, + env: DUBIOUS_OWNERSHIP, + }) + + expect(project.normalize(stderr)).toContain( + cliError("fatal: detected dubious ownership in repository at ''").trimEnd(), + ) + expect(stderr).toContain('safe.directory') + expect(stdout).toBe('') + expect(exitCode).toBe(1) + }) + it('has nothing to check when nothing is staged', async () => { const project = create() @@ -154,13 +178,35 @@ describe.each(LAYOUTS)('uncheck staged in a $name', ({ create, app, tsc }) => { '▶ oxfmt --no-error-on-unmatched-pattern [4 files]', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxfmt)', '✔ staged the fixes to [4 files]', + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, `${app}src/d.ts`)).toBe('export const d = 1;\n') }) }) +describe('uncheck staged without tools', () => { + it('fails when no check can run on the staged files', async () => { + const project = bareProject( + { 'package.json': { name: 'bare', private: true } }, + { tools: [] }, + ).stage({ 'src/index.ts': 'export const answer = 42;\n' }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged']) + + expect(stderr).toBe('') + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, not installed', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', + ]) + }) +}) + describe('uncheck staged flags', () => { it('rejects a selection that contradicts itself before looking at git', async () => { const project = singleRepo({}, { git: 'none' }) @@ -208,7 +254,7 @@ describe('uncheck staged in a package of a monorepo', () => { }) expect(fixed.exitCode).toBe(0) - expect(report(fixed.stdout).at(-1)).toBe('✔ staged the fixes to src/index.ts') + expectFixesStaged(fixed.stdout, 'src/index.ts', 'oxfmt') expect(inIndex(project, 'packages/app/src/index.ts')).toBe('export const app = 1;\n') expect(inIndex(project, 'packages/core/src/index.ts')).toBe(edits['packages/core/src/index.ts']) }) @@ -255,8 +301,8 @@ describe('uncheck staged in a package of a monorepo', () => { '▶ oxfmt --no-error-on-unmatched-pattern packages/app/package.json packages/app/src/index.ts', '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', - '✘ 1 of 3 checks failed: sherif', '✔ staged the fixes to packages/app/src/index.ts', + '✘ 1 of 3 checks failed: sherif', ]) expect(JSON.parse(inIndex(project, 'packages/app/package.json'))).toEqual(manifest) expect(JSON.parse(project.read('packages/app/package.json'))).toEqual(manifest) diff --git a/packages/uncheck/tests/staged/commit.test.ts b/packages/uncheck/tests/staged/commit.test.ts index 7f2625f..fe174df 100644 --- a/packages/uncheck/tests/staged/commit.test.ts +++ b/packages/uncheck/tests/staged/commit.test.ts @@ -1,6 +1,16 @@ -import { LAYOUTS, linkedWorktree, report, run } from '../utils/project' +import { cliError, commitWithHooks, LAYOUTS, linkedWorktree, report } from '../utils/project' import type { Project } from '../utils/project' -import { inIndex, stagePartially, VERSIONS } from './utils' +import { + EMPTY_COMMIT_ERROR, + expectFixesStaged, + inIndex, + SAVED_LINE, + saveWhileTscRuns, + stagePartially, + VERSIONS, +} from './utils' + +const ALL_PASSED = '✔ all checks passed (oxlint, oxfmt, tsc)' describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ create, app }) => { const file = `${app}src/extra.ts` @@ -18,19 +28,17 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr const project = hooked().stage({ [other]: 'export const other = 2;\n' }) project.write({ [file]: VERSIONS.staged }) + saveWhileTscRuns(project, [file]) - const { exitCode, stderr } = await run( - ['git', 'commit', '--quiet', '--message=extra', '--', file], - { cwd: project.dir }, - ) + const { exitCode, stderr } = await commitWithHooks(project, '--message=extra', '--', file) expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(stderr, 'src/extra.ts', 'oxlint, oxfmt, tsc') expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 1;\n') expect(inIndex(project, file)).toBe(VERSIONS.fixed) - expect(project.read(file)).toBe(VERSIONS.fixed) - expect(project.git('status', '--porcelain')).toBe(`M ${other}\n`) + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.fixed) + expect(project.git('status', '--porcelain')).toBe(` M ${file}\nM ${other}\n`) }) it('fixes the files of `git commit -a` in the commit it makes', async () => { @@ -38,30 +46,103 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr project.write({ [file]: VERSIONS.staged, [other]: 'export const other = 2\n' }) - const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--all', '--message=all'], { - cwd: project.dir, - }) + const { exitCode, stderr } = await commitWithHooks(project, '--all', '--message=all') expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/other.ts') + expectFixesStaged(stderr, 'src/extra.ts src/other.ts', 'oxlint, oxfmt, tsc') expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') expect(project.git('status', '--porcelain')).toBe('') }) + it('commits the fixes as the fixers left them, without what is saved while tsc runs', async () => { + const clean = `${app}src/clean.ts` + const project = stagePartially( + hooked().stage({ + [other]: 'export const other = 2\n', + [clean]: 'export const clean = 1;\n', + }), + file, + ) + + saveWhileTscRuns(project, [file, other, clean]) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=saved') + + expect(exitCode).toBe(0) + expect(report(stderr).slice(-3)).toEqual([ + '✔ staged the fixes to src/extra.ts src/other.ts', + '○ unstaged changes of src/extra.ts restored', + ALL_PASSED, + ]) + expect(project.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) + expect(project.git('show', `HEAD:${other}`)).toBe('export const other = 2;\n') + expect(project.git('show', `HEAD:${clean}`)).toBe('export const clean = 1;\n') + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.merged) + expect(project.read(other)).toBe(`${SAVED_LINE}export const other = 2;\n`) + expect(project.read(clean)).toBe(`${SAVED_LINE}export const clean = 1;\n`) + expect(project.git('status', '--porcelain')).toBe(` M ${clean}\n M ${file}\n M ${other}\n`) + }) + + it('ends a blocked commit with the summary of the checks', async () => { + const project = stagePartially( + hooked().stage({ [`${app}src/broken.ts`]: 'export const broken: number = "1";\n' }), + file, + ) + + const { exitCode, stderr } = await commitWithHooks(project, '--message=broken') + + expect(exitCode).toBe(1) + expect(report(stderr).slice(-3)).toEqual([ + '✔ staged the fixes to src/extra.ts', + '○ unstaged changes of src/extra.ts restored', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stderr).toMatch(/\n✘ 1 of 3 checks failed: tsc\n$/) + expect(project.git('log', '--format=%s')).toBe('init\n') + }) + + it('blocks a commit the fixes empty, which git amends or refuses when run again', async () => { + const project = hooked() + const unformatted = { [other]: 'export const other = 1\n' } + + project.stage(unformatted) + + const amend = await commitWithHooks(project, '--amend', '--message=reworded') + + expect(amend.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) + expect(amend.exitCode).toBe(1) + expect(project.git('log', '--format=%s')).toBe('init\n') + expect((await commitWithHooks(project, '--amend', '--message=reworded')).exitCode).toBe(0) + expect(project.git('log', '--format=%s')).toBe('reworded\n') + + project.stage(unformatted) + + const plain = await commitWithHooks(project, '--message=empty') + + expect(plain.stderr).toContain(cliError(EMPTY_COMMIT_ERROR)) + expect(plain.exitCode).toBe(1) + + const again = await commitWithHooks(project, '--message=empty') + + expect(again.stdout).toContain('nothing to commit') + expect(again.exitCode).toBe(1) + expect(project.git('log', '--format=%s')).toBe('reworded\n') + expect(project.git('status', '--porcelain')).toBe('') + }) + describe('in a linked worktree, whose hook git hands GIT_DIR', () => { it('sets the unstaged changes aside and puts them back', async () => { const project = hooked() const worktree = stagePartially(linkedWorktree(project, app), file) - const { exitCode, stderr } = await run(['git', 'commit', '--quiet', '--message=extra'], { - cwd: worktree.dir, - }) + const { exitCode, stderr } = await commitWithHooks(worktree, '--message=extra') expect(exitCode).toBe(0) - expect(report(stderr).slice(-2)).toEqual([ + expect(report(stderr).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', '○ unstaged changes of src/extra.ts restored', + ALL_PASSED, ]) expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(worktree.read(file)).toBe(VERSIONS.merged) @@ -75,13 +156,10 @@ describe.each(LAYOUTS)('uncheck staged as the pre-commit hook of a $name', ({ cr worktree.write({ [file]: VERSIONS.staged }) - const { exitCode, stderr } = await run( - ['git', 'commit', '--quiet', '--message=extra', '--', file], - { cwd: worktree.dir }, - ) + const { exitCode, stderr } = await commitWithHooks(worktree, '--message=extra', '--', file) expect(exitCode).toBe(0) - expect(report(stderr).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(stderr, 'src/extra.ts', 'oxlint, oxfmt, tsc') expect(worktree.git('show', `HEAD:${file}`)).toBe(VERSIONS.fixed) expect(inIndex(worktree, file)).toBe(VERSIONS.fixed) expect(worktree.git('status', '--porcelain')).toBe(`M ${other}\n`) diff --git a/packages/uncheck/tests/staged/deletions.test.ts b/packages/uncheck/tests/staged/deletions.test.ts new file mode 100644 index 0000000..88555f5 --- /dev/null +++ b/packages/uncheck/tests/staged/deletions.test.ts @@ -0,0 +1,138 @@ +import { mkdirSync } from 'node:fs' + +import { + monorepo, + report, + singleRepo, + SKIPPED_FOR_DELETIONS, + UTILS_NOT_FOUND, +} from '../utils/project' + +describe('uncheck staged with deleted files in a single repo', () => { + it('typechecks the project a staged deletion breaks', async () => { + const project = singleRepo() + + project.git('rm', '--quiet', '--', 'src/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) + + it('typechecks the project a file moves out of', async () => { + const project = singleRepo() + + mkdirSync(project.path('scripts')) + project.git('mv', 'src/utils.ts', 'scripts/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, no package.json among the given files', + '▶ oxlint --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxlint passed', + '▶ oxfmt --check --no-error-on-unmatched-pattern scripts/utils.ts', + '✔ oxfmt passed', + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) + + it.each([[[]], [['--require=oxlint', '--require=oxfmt']]])( + 'passes a staged deletion nothing imports, with %j', + async (flags) => { + const project = singleRepo({ 'src/unused.ts': 'export const unused = 1;\n' }) + + project.git('rm', '--quiet', '--', 'src/unused.ts') + + const { exitCode, stdout } = await project.uncheck(['staged', ...flags]) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(exitCode).toBe(0) + }, + ) + + it('passes a staged deletion no check covers with oxlint and oxfmt required', async () => { + const project = singleRepo({ 'NOTES.md': '# Notes\n' }) + + project.git('rm', '--quiet', '--', 'NOTES.md') + + const { exitCode, stdout } = await project.uncheck([ + 'staged', + '--only=oxlint', + '--only=oxfmt', + '--require=oxlint', + '--require=oxfmt', + ]) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', + '○ tsc skipped, not selected by --only', + '○ nothing to check: sherif not selected by --only, oxlint only deleted files, oxfmt only deleted files, tsc not selected by --only', + ]) + expect(exitCode).toBe(0) + }) + + it('never fixes or stages a file only removed from the index', async () => { + const legacy = 'export const legacy = 1\n' + const project = singleRepo({ 'src/legacy.ts': legacy }) + + project.git('rm', '--quiet', '--cached', '--', 'src/legacy.ts') + + const { exitCode, stdout } = await project.uncheck(['staged', '--fix']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✔ tsc passed', + '✔ all checks passed (tsc)', + ]) + expect(exitCode).toBe(0) + expect(project.read('src/legacy.ts')).toBe(legacy) + expect(project.git('status', '--porcelain')).toBe('D src/legacy.ts\n?? src/legacy.ts\n') + }) +}) + +describe('uncheck staged with deleted files in a monorepo', () => { + it('builds the projects that reference a deleted tsconfig.json', async () => { + const project = monorepo() + + project.git('rm', '--quiet', '--', 'packages/core/tsconfig.json') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(project.normalize(stdout)).toContain( + "error TS6053: File '/packages/core/tsconfig.json' not found.", + ) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/staged/fix.test.ts b/packages/uncheck/tests/staged/fix.test.ts index 445fdef..6632592 100644 --- a/packages/uncheck/tests/staged/fix.test.ts +++ b/packages/uncheck/tests/staged/fix.test.ts @@ -1,7 +1,7 @@ import { mkdirSync } from 'node:fs' import { cliError, git, LAYOUTS, report } from '../utils/project' -import { EMPTY_COMMIT_ERROR, folderOf, inIndex } from './utils' +import { EMPTY_COMMIT_ERROR, expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) @@ -24,8 +24,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) '✔ oxfmt passed', tsc, '✔ tsc passed', - '✔ all checks passed (oxlint, oxfmt, tsc)', '✔ staged the fixes to src/index.ts', + '✔ all checks passed (oxlint, oxfmt, tsc)', ]) expect(inIndex(project, `${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') expect(project.read(`${app}src/index.ts`)).toBe('const answer = 42;\nexport { answer };\n') @@ -45,8 +45,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(stderr).toBe('') expect(exitCode).toBe(1) expect(report(stdout).slice(-2)).toEqual([ - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/index.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(inIndex(project, `${app}src/index.ts`)).toBe('export const answer: number = "42";\n') }) @@ -73,7 +73,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(empty.stderr).toBe(cliError(EMPTY_COMMIT_ERROR)) expect(empty.exitCode).toBe(1) - expect(report(empty.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(empty.stdout, 'src/extra.ts', 'oxfmt') expect(project.git('status', '--porcelain')).toBe('') project.stage(unformatted) @@ -84,7 +84,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(allowed.stderr).toBe('') expect(allowed.exitCode).toBe(0) - expect(report(allowed.stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(allowed.stdout, 'src/extra.ts', 'oxfmt') expect(project.git('status', '--porcelain')).toBe('') }) @@ -105,7 +105,13 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) ).trimEnd(), ) expect(exitCode).toBe(1) - expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + ]) expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1\n') }) @@ -124,7 +130,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(inIndex(project, `${app}src/extra.ts`)).toBe('export const extra = 1;\n') expect(project.git('diff', '--name-only')).toBe('') }) @@ -142,7 +148,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/run.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/run.ts') + expectFixesStaged(stdout, 'src/run.ts', 'oxfmt') expect(project.git('ls-files', '--stage', '--', file)).toMatch(/^100755 /) expect(inIndex(project, file)).toBe('export const run = 1;\n') expect(project.mode(file)).toBe(0o755) @@ -163,7 +169,11 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(nothing.exitCode).toBe(0) expect(report(nothing.stdout)).toEqual([ `uncheck staged in ${project.path(folder)}`, - '○ nothing to check, no staged files', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '○ oxfmt skipped, only deleted files', + '○ tsc skipped, not selected by --only', + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt only deleted files, tsc not selected by --only', ]) project.write({ [`${app}src/link.ts`]: null }) @@ -175,7 +185,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/link.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/link.ts') + expectFixesStaged(stdout, 'src/link.ts', 'oxfmt') expect(inIndex(project, `${app}src/link.ts`)).toBe('export const link = 1;\n') expect(project.read(`${app}src/messy.ts`)).toBe('export const messy = 1\n') expect(project.git('status', '--porcelain')).toBe( @@ -183,6 +193,50 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) ) }) + it('leaves staged files under node_modules alone', async () => { + const dependency = `${app}vendor/node_modules/dep/index.js` + const project = create().write({ + [dependency]: 'export var dep = 1\n', + [`${app}src/extra.ts`]: 'export const extra = 1\n', + }) + + project.git('add', '--force', '--', dependency) + + const alone = await project.uncheck(['staged', '--fix', '--only=oxlint', '--only=oxfmt'], { + cwd: folder, + }) + + expect(alone.stderr).toBe('') + expect(alone.exitCode).toBe(0) + expect(report(alone.stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, only links and node_modules files are staged', + ]) + + project.git('add', '--', `${app}src/extra.ts`) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxlint', '--only=oxfmt'], + { cwd: folder }, + ) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ sherif skipped, not selected by --only', + '▶ oxlint --fix --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxlint passed', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + '✔ oxfmt passed', + '○ tsc skipped, not selected by --only', + '✔ staged the fixes to src/extra.ts', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(inIndex(project, dependency)).toBe('export var dep = 1\n') + expect(project.read(dependency)).toBe('export var dep = 1\n') + }) + it('fixes and stages exactly the staged files, whatever their names look like', async () => { const project = create({ [`${app}routes/i/page.ts`]: 'export const i = 1;\n' }) @@ -208,8 +262,8 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) '▶ oxfmt --no-error-on-unmatched-pattern !x.ts -x.ts routes/[id]/page.ts', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '✔ staged the fixes to !x.ts -x.ts routes/[id]/page.ts', + '✔ all checks passed (oxlint, oxfmt)', ]) expect(inIndex(project, `${app}!x.ts`)).toBe('export const bang = 1;\n') expect(inIndex(project, `${app}-x.ts`)).toBe('export const dash = 1;\n') @@ -242,7 +296,7 @@ describe.each(LAYOUTS)('uncheck staged --fix in a $name', ({ create, app, tsc }) }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(project.git('rev-parse', `:${app}vendor`)).toBe(before) }) }) diff --git a/packages/uncheck/tests/staged/interrupt.test.ts b/packages/uncheck/tests/staged/interrupt.test.ts index 4276ec5..e3cd514 100644 --- a/packages/uncheck/tests/staged/interrupt.test.ts +++ b/packages/uncheck/tests/staged/interrupt.test.ts @@ -1,25 +1,43 @@ -import { writeFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' -import { LAYOUTS, report } from '../utils/project' -import { folderOf, inIndex, stagePartially, startUncheck, VERSIONS } from './utils' +import { LAYOUTS, report, wrappedGit } from '../utils/project' +import { + expectRestored, + folderOf, + inIndex, + stagePartially, + startGit, + startUncheck, + VERSIONS, +} from './utils' -describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app }) => { +const SLOW_CHECK = "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n" + +describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app, tsc }) => { const folder = folderOf(app) const file = `${app}src/extra.ts` - it('puts the unstaged changes back when a closed terminal hangs up during a slow check', async () => { + it('puts the unstaged changes back when a closed terminal hangs up twice during a slow check', async () => { const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + const hungUp = project.path('.git/hung-up') - project.fake('oxlint', "console.log('waiting')\nsetTimeout(() => process.exit(1), 60_000)\n") + project.fake('oxlint', SLOW_CHECK) - const started = startUncheck(project, ['staged', '--only=oxlint'], { cwd: folder }) + // Two hang-ups sent back to back arrive as one, so the second comes from inside the merge that + // puts the changes back, the only hash-object call with --path. + const started = startUncheck(project, ['staged', '--only=oxlint'], { + cwd: folder, + env: wrappedGit( + `case " $* " in *" hash-object "*" --path="*) echo $PPID >'${hungUp}'; kill -HUP $PPID; sleep 1;; esac`, + ), + }) await started.printed('waiting') started.child.kill('SIGHUP') - started.child.kill('SIGHUP') const { exitCode, stdout } = await started.exited + expect(readFileSync(hungUp, 'utf8')).toBe(`${started.child.pid}\n`) expect(exitCode).toBe(130) expect(report(stdout)).toEqual([ `uncheck staged in ${project.path(folder)}`, @@ -28,8 +46,86 @@ describe.each(LAYOUTS)('uncheck staged interrupted in a $name', ({ create, app } '▶ oxlint --no-error-on-unmatched-pattern src/extra.ts', '○ unstaged changes of src/extra.ts restored', ]) + expectRestored(project, file) + }) + + it('puts the unstaged changes back after Ctrl-C during a slow check', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + + project.fake('oxlint', SLOW_CHECK) + + const { exitCode } = await project.uncheckInTerminal(['staged', '--only=oxlint'], { + cwd: folder, + waitFor: 'waiting', + keys: ['\u0003'], + }) + + expect(exitCode).toBe(130) + expectRestored(project, file) + }) + + it('puts the unstaged changes back over a clashing fix after Ctrl-C before the fixes are staged', async () => { + const project = stagePartially(create({ [file]: VERSIONS.committed }), file) + const clashing = VERSIONS.fixed.replace('const e = 1', 'const e = 3') + + project.fake( + 'oxfmt', + `require('node:fs').writeFileSync(${JSON.stringify(project.path(file))}, ${JSON.stringify(clashing)})\n${SLOW_CHECK}`, + ) + + const { exitCode, stdout } = await project.uncheckInTerminal( + ['staged', '--fix', '--only=oxfmt'], + { cwd: folder, waitFor: 'waiting', keys: ['\u0003'] }, + ) + + expect(exitCode).toBe(130) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts', + ]) + expectRestored(project, file) + }) + + it('puts the unstaged changes back as they were after Ctrl-C kills `git commit --include` and its index', async () => { + const other = `${app}src/other.ts` + const project = stagePartially( + create({ [file]: VERSIONS.committed, [other]: 'export const other = 1;\n' }), + file, + ) + .write({ + [other]: 'export const other = 2;\n', + '.git/hooks/pre-commit': `#!/bin/sh\n(cd "./${app}" && pnpm exec uncheck staged --fix --only=oxfmt --only=tsc) || exit 1\n`, + }) + .chmod('.git/hooks/pre-commit', 0o755) + .fake('typescript', SLOW_CHECK) + + const commit = startGit(project, ['commit', '--include', '--message=include', '--', other]) + + await commit.printed('waiting') + commit.interrupt() + + const { signal, stderr } = await commit.exited + + expect(signal).toBe('SIGINT') + expect(report(stderr)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ unstaged changes of src/extra.ts set aside until the checks finish', + '○ sherif skipped, not selected by --only', + '○ oxlint skipped, not selected by --only', + '▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts src/other.ts', + '✔ oxfmt passed', + tsc, + '○ unstaged changes of src/extra.ts restored', + ]) expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(project.read(other)).toBe('export const other = 2;\n') expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n M ${other}\n`) + expect(project.git('log', '--format=%s')).toBe('init\n') + expect(project.exists('.git/index.lock')).toBe(false) expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) diff --git a/packages/uncheck/tests/staged/many-files.test.ts b/packages/uncheck/tests/staged/many-files.test.ts index 23e7688..ac1f07c 100644 --- a/packages/uncheck/tests/staged/many-files.test.ts +++ b/packages/uncheck/tests/staged/many-files.test.ts @@ -42,9 +42,9 @@ describe.each(LAYOUTS)('uncheck staged with many files in a $name', ({ create, a '▶ oxfmt --no-error-on-unmatched-pattern [14 files]', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '✔ staged the fixes to [150 files]', '○ unstaged changes of [150 files] restored', + '✔ all checks passed (oxlint, oxfmt)', ]) for (const file of [fixed[0]!, fixed.at(-1)!]) { diff --git a/packages/uncheck/tests/staged/merge.test.ts b/packages/uncheck/tests/staged/merge.test.ts index 123025e..b56474c 100644 --- a/packages/uncheck/tests/staged/merge.test.ts +++ b/packages/uncheck/tests/staged/merge.test.ts @@ -1,5 +1,14 @@ -import { cliError, LAYOUTS, report, run } from '../utils/project' -import { commitOnSide, folderOf, inIndex, stagePartially, VERSIONS } from './utils' +import { + cliError, + commitOnSide, + LAYOUTS, + report, + run, + singleRepo, + SKIPPED_FOR_DELETIONS, + UTILS_NOT_FOUND, +} from '../utils/project' +import { expectFixesStaged, folderOf, inIndex, stagePartially, VERSIONS } from './utils' describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, app }) => { const folder = folderOf(app) @@ -29,11 +38,32 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/other.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expectFixesStaged(stdout, 'src/other.ts', 'oxfmt') expect(inIndex(project, other)).toBe('export const other = 4;\n') expect(inIndex(project, `${app}src/theirs.ts`)).toBe('export const theirs = 1\n') }) + it('leaves out what the branch being merged in deletes, even beside a file named MERGE_HEAD', async () => { + const project = commitOnSide( + create({ + [`${app}MERGE_HEAD`]: 'not a revision\n', + [`${app}src/gone.ts`]: 'export const gone = 1;\n', + }), + { [`${app}src/gone.ts`]: null }, + ) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + + const { exitCode, stdout, stderr } = await project.uncheck(['staged'], { cwd: folder }) + + expect(stderr).toBe('') + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.path(folder)}`, + '○ nothing to check, every staged file comes from the branch being merged in', + ]) + }) + it('never takes a merge whose fixes bring back the tree of HEAD for an empty commit', async () => { const project = commitOnSide(create({ [other]: 'export const other = 2;\n' }), { [other]: 'export const other = 3;\n', @@ -49,7 +79,7 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(stderr).toBe('') expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/other.ts') + expectFixesStaged(stdout, 'src/other.ts', 'oxfmt') expect(project.git('diff', '--cached', '--name-only')).toBe('') project.git('commit', '--quiet', '--no-edit', '--no-verify') @@ -92,3 +122,24 @@ describe.each(LAYOUTS)('uncheck staged during a merge in a $name', ({ create, ap expect(inIndex(project, file)).toBe(VERSIONS.staged) }) }) + +describe('uncheck staged during a merge in a single repo', () => { + it('typechecks a deletion of its own that the branch being merged in keeps', async () => { + const project = commitOnSide(singleRepo(), { 'src/theirs.ts': 'export const theirs = 1;\n' }) + + project.git('merge', '--quiet', '--no-commit', '--no-ff', 'side') + project.git('rm', '--quiet', '--', 'src/utils.ts') + + const { exitCode, stdout } = await project.uncheck(['staged']) + + expect(report(stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_FOR_DELETIONS, + '▶ tsc -p tsconfig.json --noEmit', + '✘ tsc failed', + '✘ 1 of 1 checks failed: tsc', + ]) + expect(stdout).toContain(UTILS_NOT_FOUND) + expect(exitCode).toBe(1) + }) +}) diff --git a/packages/uncheck/tests/staged/precedence.test.ts b/packages/uncheck/tests/staged/precedence.test.ts index 8a10309..6679a46 100644 --- a/packages/uncheck/tests/staged/precedence.test.ts +++ b/packages/uncheck/tests/staged/precedence.test.ts @@ -32,8 +32,8 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/extra.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(inIndex(project, file)).toBe('export const extra: number = "42"\n') expect(project.read(file)).toBe('export const extra: number = "43"\n') @@ -67,9 +67,9 @@ describe.each(LAYOUTS)( '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', + strandedLine, '✘ 1 of 1 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', - strandedLine, ]) expect(project.normalize(stdout)).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) @@ -94,8 +94,8 @@ describe.each(LAYOUTS)( expect(exitCode).toBe(1) expect(report(stdout).slice(-3)).toEqual([ '✘ tsc failed', - '✘ 1 of 2 checks failed: tsc', '✔ staged the fixes to src/extra.ts', + '✘ 1 of 2 checks failed: tsc', ]) expect(project.git('status', '--porcelain')).toBe('') }) diff --git a/packages/uncheck/tests/staged/sparse-checkout.test.ts b/packages/uncheck/tests/staged/sparse-checkout.test.ts index 2ed6b46..02f77d0 100644 --- a/packages/uncheck/tests/staged/sparse-checkout.test.ts +++ b/packages/uncheck/tests/staged/sparse-checkout.test.ts @@ -1,5 +1,5 @@ -import { cliError, LAYOUTS, report, run } from '../utils/project' -import { commitOnSide, conflictError, folderOf, inIndex } from './utils' +import { cliError, commitOnSide, LAYOUTS, report, run } from '../utils/project' +import { conflictError, expectFixesStaged, folderOf, inIndex } from './utils' describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ create, app }) => { const folder = folderOf(app) @@ -32,7 +32,7 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea expect(exitCode).toBe(0) expect(report(stdout)).toContain('▶ oxfmt --no-error-on-unmatched-pattern src/extra.ts') - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts') + expectFixesStaged(stdout, 'src/extra.ts', 'oxfmt') expect(project.exists(`${app}lib`)).toBe(false) expect(inIndex(project, outside)).toBe('export const lib = 2;\n') expect(inIndex(project, file)).toBe('export const extra = 43;\n') @@ -58,7 +58,7 @@ describe.each(LAYOUTS)('uncheck staged in a sparse checkout of a $name', ({ crea }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to lib/lib.ts') + expectFixesStaged(stdout, 'lib/lib.ts', 'oxfmt') expect(inIndex(project, outside)).toBe('export const lib = 4;\n') }) }) diff --git a/packages/uncheck/tests/staged/unstaged.test.ts b/packages/uncheck/tests/staged/unstaged.test.ts index 688940d..0a54d72 100644 --- a/packages/uncheck/tests/staged/unstaged.test.ts +++ b/packages/uncheck/tests/staged/unstaged.test.ts @@ -3,9 +3,12 @@ import { lstatSync, readdirSync } from 'node:fs' import { cliError, LAYOUTS, PERMISSIONS_ENFORCED, report, run, wrappedGit } from '../utils/project' import { conflictError, + expectFixesStaged, folderOf, inIndex, LEFTOVER_ERROR, + SAVED_LINE, + saveWhileTscRuns, stagePartially, stranded, STRANDED_HINT, @@ -39,8 +42,8 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre '▶ oxfmt --check --no-error-on-unmatched-pattern src/extra.ts', '✔ oxfmt passed', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint, oxfmt)', '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint, oxfmt)', ]) expect(project.read(file)).toBe(`${VERSIONS.fixed}var unstaged = 1\n`) expect(project.git('status', '--porcelain')).toBe(`MM ${file}\n`) @@ -69,9 +72,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre }) expect(exitCode).toBe(0) - expect(report(stdout).slice(-2)).toEqual([ + expect(report(stdout).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, file)).toBe(joined + fixed) expect(project.read(file)).toBe(joined + edit(fixed)) @@ -115,7 +119,7 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).at(-1)).toBe('✔ staged the fixes to src/extra.ts src/far.ts src/other.ts') + expectFixesStaged(stdout, 'src/extra.ts src/far.ts src/other.ts', 'oxfmt') expect(inIndex(project, file)).toBe('export const extra = 42\n') expect(inIndex(project, `${app}src/far.ts`)).toBe(stagedFar) expect(inIndex(project, `${app}src/other.ts`)).toBe('export const other = 2\n') @@ -129,6 +133,91 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre expect(project.exists('.git/uncheck-unstaged')).toBe(false) }) + it('undoes the fixes but keeps what is saved to a fully staged file while tsc runs', async () => { + const other = `${app}src/other.ts` + const plain = `${app}src/plain.ts` + const project = create({ [file]: 'export const extra = 1;\n' }) + + project.stage({ + [file]: 'export const extra = 42\n', + [other]: 'export const other = 2\n', + [plain]: 'export const plain = 3\n', + }) + project.write({ [file]: 'export const extra = 43\n' }) + saveWhileTscRuns(project, [other]) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(cliError(conflictError('src/extra.ts'))) + expect(exitCode).toBe(1) + expectFixesStaged(stdout, 'src/extra.ts src/other.ts src/plain.ts', 'oxfmt, tsc') + expect(inIndex(project, other)).toBe('export const other = 2\n') + expect(project.read(other)).toBe(`${SAVED_LINE}export const other = 2;\n`) + expect(inIndex(project, plain)).toBe('export const plain = 3\n') + expect(project.read(plain)).toBe('export const plain = 3\n') + expect(project.read(file)).toBe('export const extra = 43\n') + expect(project.git('status', '--porcelain')).toBe(`MM ${file}\nAM ${other}\nA ${plain}\n`) + }) + + it('undoes the fixes but keeps the merge of what is saved to a partially staged file while tsc runs', async () => { + const conflicting = `${app}src/conflicting.ts` + const project = stagePartially( + create({ [file]: VERSIONS.committed, [conflicting]: 'export const c = 1;\n' }), + file, + ) + + project.stage({ [conflicting]: 'export const c = 42\n' }) + project.write({ [conflicting]: 'export const c = 43\n' }) + saveWhileTscRuns(project, [file]) + + const { exitCode, stdout, stderr } = await project.uncheck( + ['staged', '--fix', '--only=oxfmt', '--only=tsc'], + { cwd: folder }, + ) + + expect(stderr).toBe(cliError(conflictError('src/conflicting.ts'))) + expect(exitCode).toBe(1) + expectFixesStaged(stdout, 'src/conflicting.ts src/extra.ts', 'oxfmt, tsc') + expect(project.read(file)).toBe(SAVED_LINE + VERSIONS.merged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.read(conflicting)).toBe('export const c = 43\n') + expect(inIndex(project, conflicting)).toBe('export const c = 42\n') + expect(project.exists('.git/uncheck-unstaged')).toBe(false) + }) + + it('keeps an edit saved next to the unstaged changes while tsc runs, and their copy aside', async () => { + const other = `${app}src/other.ts` + const project = stagePartially( + stagePartially(create({ [file]: VERSIONS.committed, [other]: VERSIONS.committed }), file), + other, + ) + + saveWhileTscRuns(project, [file], { atEnd: true }) + + const { exitCode, stdout, stderr } = await project.uncheck(['staged', '--only=tsc'], { + cwd: folder, + }) + + const strandedLine = stranded( + 'src/extra.ts', + 'they conflict with edits saved while the checks ran', + ) + + expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) + expect(exitCode).toBe(1) + expect(report(stdout).slice(-2)).toEqual([strandedLine, '✔ all checks passed (tsc)']) + expect(project.normalize(stdout)).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) + expect(project.read(file)).toBe(VERSIONS.staged + SAVED_LINE) + expect(project.read(`.git/uncheck-unstaged/${file}`)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.read(other)).toBe(VERSIONS.unstaged) + expect(project.exists(`.git/uncheck-unstaged/${other}`)).toBe(false) + expect(inIndex(project, other)).toBe(VERSIONS.staged) + }) + it('refuses unstaged changes that are not edits to a file', async () => { const project = create({ [`${app}src/a.ts`]: 'export const a = 1;\n', @@ -237,7 +326,10 @@ describe.each(LAYOUTS)('uncheck staged with unstaged changes in a $name', ({ cre }) expect(exitCode).toBe(0) - expect(report(stdout).at(-1)).toBe('○ unstaged changes of src/extra.ts restored') + expect(report(stdout).slice(-2)).toEqual([ + '○ unstaged changes of src/extra.ts restored', + '✔ all checks passed (oxlint)', + ]) expect(project.read(file)).toBe(VERSIONS.unstaged.replace('c = 1', 'c = 3')) expect(inIndex(project, file)).toBe(VERSIONS.staged) }) @@ -324,8 +416,8 @@ describe.each(LAYOUTS)( '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', - '✔ all checks passed (oxlint)', strandedLine, + '✔ all checks passed (oxlint)', ]) expect(output).toContain(`\n${strandedLine}\n${STRANDED_HINT}`) expect(project.read(saved)).toBe(VERSIONS.unstaged) @@ -381,12 +473,13 @@ describe.each(LAYOUTS)( expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).slice(-2)).toEqual([ + expect(report(stdout).slice(-3)).toEqual([ '✔ staged the fixes to src/extra.ts', stranded( 'src/extra.ts', `git cat-file --filters --path=${file} ${merged.stdout.trim()} failed: error: external filter 'if [ -e .git/smudged ]; then exit 1; fi; touch .git/smudged; cat' failed 1`, ), + '✔ all checks passed (oxfmt)', ]) expect(inIndex(project, file)).toBe(VERSIONS.fixed) expect(project.read(file)).toBe(VERSIONS.fixed) @@ -408,8 +501,9 @@ describe.each(LAYOUTS)( expect(stderr).toBe(cliError(strandedError('src/extra.ts'))) expect(exitCode).toBe(1) - expect(report(stdout).at(-2)).toBe('✔ staged the fixes to src/extra.ts') - expect(report(stdout).at(-1)).toMatch( + expect(report(stdout).at(-3)).toBe('✔ staged the fixes to src/extra.ts') + expect(report(stdout).at(-1)).toBe('✔ all checks passed (oxfmt)') + expect(report(stdout).at(-2)).toMatch( /^✘ could not put back the unstaged changes of src\/extra\.ts: git merge-file --quiet \S+\/result \S+\/base \S+\/fixed failed: merge-file broke$/, ) expect(inIndex(project, file)).toBe(VERSIONS.fixed) diff --git a/packages/uncheck/tests/staged/utils.ts b/packages/uncheck/tests/staged/utils.ts index a860251..43d9edc 100644 --- a/packages/uncheck/tests/staged/utils.ts +++ b/packages/uncheck/tests/staged/utils.ts @@ -1,8 +1,9 @@ import { spawn } from 'node:child_process' import type { ChildProcessWithoutNullStreams } from 'node:child_process' +import process from 'node:process' -import { CLI, environment } from '../utils/project' -import type { Env, Files, Project, Run } from '../utils/project' +import { CLI, environment, report } from '../utils/project' +import type { Env, Project, Run } from '../utils/project' /** The folder of a layout's package, relative to the top of the repository. */ export function folderOf(app: string): string { @@ -13,7 +14,7 @@ export const LEFTOVER_ERROR = 'An earlier run left the unstaged versions of your files in /.git/uncheck-unstaged, at their paths from the top of the repository. Unless another commit is running, copy back what your files are missing, delete the folder, then commit again.' export const EMPTY_COMMIT_ERROR = - 'The fixes undid every staged change, so the commit would be empty. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.' + 'The fixes undid every staged change and are staged now, so nothing new is left to commit. Commit again: git amends only the message, or refuses an empty commit. To allow empty commits, pass --allow-empty to `uncheck staged`, or to `uncheck prepare` for the hook it writes.' export function conflictError(files: string): string { return `The fixes conflict with the unstaged changes of ${files} and were undone. Stage the whole file, or stash its unstaged changes, then commit again.` @@ -50,61 +51,94 @@ export function stagePartially(project: Project, file: string): Project { return project.stage({ [file]: VERSIONS.staged }).write({ [file]: VERSIONS.unstaged }) } -/** Commits `files` on a new `side` branch, then goes back to `main`. */ -export function commitOnSide(project: Project, files: Files): Project { - project.git('checkout', '--quiet', '-b', 'side') - project.write(files).commit('side') - project.git('checkout', '--quiet', 'main') +export function expectRestored(project: Project, file: string): void { + expect(project.read(file)).toBe(VERSIONS.unstaged) + expect(inIndex(project, file)).toBe(VERSIONS.staged) + expect(project.exists('.git/uncheck-unstaged')).toBe(false) +} + +export const SAVED_LINE = '// saved while tsc ran\n' - return project +export function saveWhileTscRuns( + project: Project, + files: ReadonlyArray, + { atEnd = false }: { readonly atEnd?: boolean } = {}, +): Project { + const paths = JSON.stringify(files.map((file) => project.path(file))) + const line = JSON.stringify(SAVED_LINE) + const content = "fs.readFileSync(file, 'utf8')" + + return project.fake( + 'typescript', + `const fs = require('node:fs')\nfor (const file of ${paths}) fs.writeFileSync(file, ${atEnd ? `${content} + ${line}` : `${line} + ${content}`})\n`, + ) } export function inIndex(project: Project, file: string): string { return project.git('show', `:${file}`) } +export function expectFixesStaged(output: string, files: string, checks: string): void { + expect(report(output).slice(-2)).toEqual([ + `✔ staged the fixes to ${files}`, + `✔ all checks passed (${checks})`, + ]) +} + export interface Started { readonly child: ChildProcessWithoutNullStreams - /** Resolves once stdout has shown `marker`. */ + /** Resolves once stdout or stderr has shown `marker`. */ readonly printed: (marker: string) => Promise readonly exited: Promise } -/** Starts the CLI without waiting for it, for tests that act on the process while it runs. */ -export function startUncheck( - project: Project, - args: ReadonlyArray, - { cwd = '.', env }: { readonly cwd?: string; readonly env?: Env } = {}, +interface StartOptions { + readonly cwd: string + readonly env?: Env + readonly ownProcessGroup?: boolean +} + +function start( + command: ReadonlyArray, + { cwd, env, ownProcessGroup = false }: StartOptions, ): Started { - const child = spawn(CLI[0], [...CLI.slice(1), ...args], { - cwd: project.path(cwd), + const child = spawn(command[0]!, command.slice(1), { + cwd, env: environment(env), + detached: ownProcessGroup, }) const waiters: Array<{ readonly marker: string; readonly resolve: () => void }> = [] - let stdout = '' - let stderr = '' - - onTestFinished(() => { - child.kill('SIGKILL') - }) + const output = { stdout: '', stderr: '' } + const shown = (marker: string) => output.stdout.includes(marker) || output.stderr.includes(marker) + const collect = (stream: keyof typeof output) => (chunk: string) => { + output[stream] += chunk - child.stdout.setEncoding('utf8').on('data', (chunk: string) => { - stdout += chunk - - for (const waiter of waiters.filter(({ marker }) => stdout.includes(marker))) { + for (const waiter of waiters.filter(({ marker }) => shown(marker))) { waiters.splice(waiters.indexOf(waiter), 1) waiter.resolve() } - }) - child.stderr.setEncoding('utf8').on('data', (chunk: string) => { - stderr += chunk + } + + onTestFinished(() => { + if (!ownProcessGroup) { + child.kill('SIGKILL') + return + } + + // Throws once every process of the group has exited. + try { + process.kill(-child.pid!, 'SIGKILL') + } catch {} }) + child.stdout.setEncoding('utf8').on('data', collect('stdout')) + child.stderr.setEncoding('utf8').on('data', collect('stderr')) + return { child, printed: (marker) => new Promise((resolve) => { - if (stdout.includes(marker)) { + if (shown(marker)) { resolve() } else { waiters.push({ marker, resolve }) @@ -112,7 +146,29 @@ export function startUncheck( }), exited: new Promise((resolve, reject) => { child.on('error', reject) - child.on('close', (exitCode, signal) => resolve({ exitCode, signal, stdout, stderr })) + child.on('close', (exitCode, signal) => resolve({ exitCode, signal, ...output })) }), } } + +/** Starts the CLI without waiting for it, for tests that act on the process while it runs. */ +export function startUncheck( + project: Project, + args: ReadonlyArray, + { cwd = '.', env }: { readonly cwd?: string; readonly env?: Env } = {}, +): Started { + return start([...CLI, ...args], { cwd: project.path(cwd), env }) +} + +/** + * Starts `git ` in a process group of its own, which `interrupt` sends Ctrl-C to as a terminal + * would. `exited` waits for the hooks git started too, since they hold its output. + */ +export function startGit( + project: Project, + args: ReadonlyArray, +): Started & { readonly interrupt: () => void } { + const started = start(['git', ...args], { cwd: project.dir, ownProcessGroup: true }) + + return { ...started, interrupt: () => process.kill(-started.child.pid!, 'SIGINT') } +} diff --git a/packages/uncheck/tests/uncheck/cli.test.ts b/packages/uncheck/tests/uncheck/cli.test.ts index 0994105..54cd8b2 100644 --- a/packages/uncheck/tests/uncheck/cli.test.ts +++ b/packages/uncheck/tests/uncheck/cli.test.ts @@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs' import { CLI, cliError, + FULL_OXLINT, LAYOUTS, report, run, @@ -19,7 +20,7 @@ function onlyOxlint(dir: string): string[] { return [ `uncheck in ${dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', diff --git a/packages/uncheck/tests/uncheck/fix.test.ts b/packages/uncheck/tests/uncheck/fix.test.ts index 7507ffa..597ec54 100644 --- a/packages/uncheck/tests/uncheck/fix.test.ts +++ b/packages/uncheck/tests/uncheck/fix.test.ts @@ -1,10 +1,11 @@ import type { Project } from '../utils/project' -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT_FIX, FULL_OXLINT, FULL_OXLINT_FIX, LAYOUTS, report } from '../utils/project' import { CODE_WITH_TYPE_ERROR, CODE_WITH_VAR, layoutChecks, monorepoWithMismatchedVersions, + selectedReport, UNFORMATTED_CODE, } from './utils' @@ -22,9 +23,9 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix', + FULL_OXLINT_FIX, '✔ oxlint passed', - '▶ oxfmt', + FULL_OXFMT_FIX, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -76,7 +77,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✘ oxlint failed', '✘ oxfmt not installed', '○ tsc skipped, disabled with --skip=tsc', @@ -98,7 +99,7 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint --fix', + FULL_OXLINT_FIX, '✘ oxlint failed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -107,6 +108,27 @@ describe.each(LAYOUTS)('uncheck --fix in a $name', ({ create, app, tsc }) => { expect(exitCode).toBe(1) expect(project.read(`${app}src/log.ts`)).toBe('console.log("hello");\n') }) + + it('never lints or fixes installed packages that no ignore rule covers', async () => { + const dependency = 'packages/a/node_modules/dep/index.js' + const project = create({ '.gitignore': '/node_modules\n' }).write({ + [dependency]: CODE_WITH_VAR, + }) + + const check = await project.uncheck(['--only=oxlint']) + const fix = await project.uncheck(['--fix', '--only=oxlint']) + + expect(check.stdout).not.toContain(dependency) + expect(selectedReport(check.stdout)).toEqual([ + `uncheck in ${project.dir}`, + FULL_OXLINT, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(check.exitCode).toBe(0) + expect(fix.exitCode).toBe(0) + expect(project.read(dependency)).toBe(CODE_WITH_VAR) + }) }) function zodVersions(project: Project) { @@ -163,9 +185,9 @@ describe('uncheck --fix in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint --fix', + FULL_OXLINT_FIX, '✔ oxlint passed', - '▶ oxfmt', + FULL_OXFMT_FIX, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', '✘ 1 of 3 checks failed: sherif', diff --git a/packages/uncheck/tests/uncheck/output.test.ts b/packages/uncheck/tests/uncheck/output.test.ts index 419f66b..bdc8e35 100644 --- a/packages/uncheck/tests/uncheck/output.test.ts +++ b/packages/uncheck/tests/uncheck/output.test.ts @@ -1,23 +1,24 @@ import { availableParallelism } from 'node:os' import { styleText } from 'node:util' -import { LAYOUTS, report, singleRepo } from '../utils/project' +import { + compilerOptions, + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + report, + singleRepo, +} from '../utils/project' import { CODE_WITH_VAR, layoutChecks } from './utils' -const STANDALONE_TSCONFIG = { - compilerOptions: { - strict: true, - module: 'esnext', - moduleResolution: 'bundler', - types: [], - noEmit: true, - }, -} +const STANDALONE_TSCONFIG = { compilerOptions: compilerOptions({ noEmit: true }) } const PRINT_FORCE_COLOR = "console.log('the tool sees FORCE_COLOR=' + process.env.FORCE_COLOR);\n" const STANDALONE_PROJECTS = [1, 2, 3, 4, 5].map((index) => `project-${index}`) +const HOLD_AT_CAP_MS = 300 + function countConcurrentRuns(cap: number, total: number): string { return `const { mkdirSync, readdirSync, renameSync, writeFileSync } = require('node:fs'); @@ -26,6 +27,7 @@ const marker = config.replaceAll('/', '_'); const count = (dir) => readdirSync(dir).length; const giveUp = Date.now() + 10_000; let most = 0; +let reachedCap; console.log(\`\${config} started\`); mkdirSync('running', { recursive: true }); @@ -36,7 +38,13 @@ writeFileSync(\`running/\${marker}\`, ''); const running = count('running'); most = Math.max(most, running); - if (running === ${cap} || running + count('finished') === ${total}) { + if (running >= ${cap}) { + reachedCap ??= Date.now(); + } + + const heldAtCap = reachedCap !== undefined && Date.now() - reachedCap >= ${HOLD_AT_CAP_MS}; + + if (heldAtCap || running + count('finished') === ${total}) { console.log(\`\${config} saw \${most} running\`); renameSync(\`running/\${marker}\`, \`finished/\${marker}\`); } else if (Date.now() > giveUp) { @@ -61,14 +69,14 @@ describe.each(LAYOUTS)('uncheck output in a $name', ({ create, app, tsc }) => { const { exitCode, stdout } = await project.uncheck(['--skip=tsc']) expect(stdout).toMatch( - /^▶ oxlint\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, + /^▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\nProcess interrupted due to receipt of signal: 'SIGKILL'\n✘ oxlint failed /m, ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, @@ -152,7 +160,7 @@ describe('uncheck output', () => { `${paint('dim', '○')} ${paint('bold', 'sherif')} ${paint('dim', 'skipped, not selected by --only')}\n`, ) expect(stdout).toContain( - `${paint('dim', '▶')} ${paint('bold', 'oxfmt')} ${paint('dim', '--check')}\nthe tool sees FORCE_COLOR=1\n`, + `${paint('dim', '▶')} ${paint('bold', 'oxfmt')} ${paint('dim', '--check --no-error-on-unmatched-pattern')}\nthe tool sees FORCE_COLOR=1\n`, ) expect(stdout).toContain( `${paint('green', '✔')} ${paint('bold', 'oxfmt')} ${paint('green', 'passed')} `, @@ -162,7 +170,7 @@ describe('uncheck output', () => { `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', @@ -200,7 +208,9 @@ describe('uncheck output', () => { const { exitCode, stdout } = await project.uncheck(['--only=oxlint']) - expect(stdout).toContain('▶ oxlint\nthe tool sees FORCE_COLOR=undefined\n') + expect(stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\nthe tool sees FORCE_COLOR=undefined\n', + ) expect(stdout).not.toContain('\u001B[') expect(exitCode).toBe(0) }) diff --git a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts index d8fa3e1..9dd0f32 100644 --- a/packages/uncheck/tests/uncheck/paths-exclusions.test.ts +++ b/packages/uncheck/tests/uncheck/paths-exclusions.test.ts @@ -71,6 +71,15 @@ describe.each(LAYOUTS)('uncheck with exclusions in a $name', ({ create, app }) = ) }) + it('leaves out everything below the folders a glob matches', async () => { + const project = routesProject() + + expect(await check(project, '.', '!**/nested')).toEqual( + without('nested/deep.ts', 'nested/more/deeper.ts'), + ) + expect(await check(project, '.', '!*/more')).toEqual(without('nested/more/deeper.ts')) + }) + it('leaves out the files a glob matches, dot files included', async () => { const project = routesProject() diff --git a/packages/uncheck/tests/uncheck/paths-git.test.ts b/packages/uncheck/tests/uncheck/paths-git.test.ts index b648a4c..23a19ec 100644 --- a/packages/uncheck/tests/uncheck/paths-git.test.ts +++ b/packages/uncheck/tests/uncheck/paths-git.test.ts @@ -94,17 +94,21 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ } }) - it('checks a linked file but never a linked folder or a broken link, as the walk outside git', async () => { + it('checks a linked file inside the project but never a linked folder, a broken link or a link into node_modules, as the walk outside git', async () => { const store = temporaryDirectory() writeFileSync(join(store, 'vendor.ts'), CODE_WITH_VAR) const project = create({ [`${routes}/home.ts`]: CLEAN_CODE, [`${routes}/shared/util.ts`]: CODE_WITH_VAR, + 'node_modules/dep/index.ts': CODE_WITH_VAR, }) + project .link(`${routes}/alias.ts`, 'home.ts') .link(`${routes}/broken.ts`, 'missing.ts') .link(`${routes}/linked`, 'shared') .link(`${routes}/vendor`, store) + .link(`${routes}/outside.ts`, join(store, 'vendor.ts')) + .link(`${routes}/installed.ts`, project.path('node_modules/dep/index.ts')) .commit() const check = await project.uncheck(['--only=oxlint', routes]) @@ -113,6 +117,8 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ expect(check.stdout).toContain(`${routes}/shared/util.ts:1:1`) expect(check.stdout).not.toContain(`${routes}/linked/`) expect(check.stdout).not.toContain(`${routes}/vendor/`) + expect(check.stdout).not.toContain(`${routes}/outside.ts`) + expect(check.stdout).not.toContain(`${routes}/installed.ts`) expect(selectedReport(check.stdout)).toEqual([ `uncheck in ${project.dir}`, `▶ oxlint --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`, @@ -124,8 +130,12 @@ describe.each(LAYOUTS)('uncheck with paths in the git repository of a $name', ({ const fix = await project.uncheck(['--only=oxlint', '--fix', routes]) expect(fix.exitCode).toBe(0) + expect(selectedReport(fix.stdout)).toContain( + `▶ oxlint --fix --no-error-on-unmatched-pattern ${routes}/alias.ts ${routes}/home.ts ${routes}/shared/util.ts`, + ) expect(project.read(`${routes}/shared/util.ts`)).toBe('const count = 1;\nexport { count };\n') expect(readFileSync(join(store, 'vendor.ts'), 'utf8')).toBe(CODE_WITH_VAR) + expect(project.read('node_modules/dep/index.ts')).toBe(CODE_WITH_VAR) }) it('leaves out a linked node_modules that a folder-only ignore rule misses', async () => { diff --git a/packages/uncheck/tests/uncheck/paths-tools.test.ts b/packages/uncheck/tests/uncheck/paths-tools.test.ts index 4ea4638..efa3f5e 100644 --- a/packages/uncheck/tests/uncheck/paths-tools.test.ts +++ b/packages/uncheck/tests/uncheck/paths-tools.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, report } from '../utils/project' import { CLEAN_CODE, CODE_WITH_VAR, NOT_COVERED, selectedReport } from './utils' const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] @@ -52,15 +52,38 @@ describe.each(LAYOUTS)('uncheck handing files to the tools in a $name', ({ creat expect(everything.exitCode).toBe(1) expect(selectedReport(everything.stdout)).toEqual([ `uncheck in ${project.path(app, 'src/routes')}`, - '▶ oxlint', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '✘ 1 of 2 checks failed: oxlint', ' rerun with `--fix` to apply oxlint fixes', ]) }) + it('passes oxlint and oxfmt without paths in a folder holding nothing they handle', async () => { + const project = create({ + [`${app}config/base.json`]: { compilerOptions: { strict: true } }, + [`${app}notes/todo.txt`]: 'var draft\n', + }) + + for (const folder of ['config', 'notes']) { + const { exitCode, stdout } = await project.uncheck(ONLY_FILE_CHECKS, { + cwd: `${app}${folder}`, + }) + + expect(selectedReport(stdout)).toEqual([ + `uncheck in ${project.path(app, folder)}`, + FULL_OXLINT, + '✔ oxlint passed', + FULL_OXFMT, + '✔ oxfmt passed', + '✔ all checks passed (oxlint, oxfmt)', + ]) + expect(exitCode).toBe(0) + } + }) + it('fixes only the given files', async () => { const broken = 'var point = {x:1,\n y:2}\nexport { point }\n' const project = create({ [`${app}src/given.ts`]: broken, [`${app}src/other.ts`]: broken }) diff --git a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts index c66eb52..58ce873 100644 --- a/packages/uncheck/tests/uncheck/paths-unmatched.test.ts +++ b/packages/uncheck/tests/uncheck/paths-unmatched.test.ts @@ -1,6 +1,7 @@ import { mkdirSync } from 'node:fs' import { cliError, LAYOUTS, report } from '../utils/project' +import { CLEAN_CODE } from './utils' describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app }) => { it('fails before checking anything and names every path that matched no file', async () => { @@ -24,6 +25,30 @@ describe.each(LAYOUTS)('uncheck with unmatched paths in a $name', ({ create, app ) }) + it('fails a glob or directory whose only git entries are no files', async () => { + const project = create({ + [`${app}legacy/old.ts`]: CLEAN_CODE, + [`${app}shared/util.ts`]: CLEAN_CODE, + }) + .link(`${app}links/shared`, '../shared') + .commit() + .write({ [`${app}legacy/old.ts`]: null }) + + const { exitCode, stdout, stderr } = await project.uncheck([ + '--only=oxlint', + `${app}legacy/*.ts`, + `${app}links`, + ]) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([`uncheck in ${project.dir}`]) + expect(stderr).toBe( + cliError( + `No files match ${app}legacy/*.ts, ${app}links. Pass --no-error-on-unmatched-pattern to run with whatever matched.`, + ), + ) + }) + it('checks whatever matched with --no-error-on-unmatched-pattern', async () => { const project = create() diff --git a/packages/uncheck/tests/uncheck/paths-walk.test.ts b/packages/uncheck/tests/uncheck/paths-walk.test.ts index f3951e9..f9a4901 100644 --- a/packages/uncheck/tests/uncheck/paths-walk.test.ts +++ b/packages/uncheck/tests/uncheck/paths-walk.test.ts @@ -1,5 +1,8 @@ +import { writeFileSync } from 'node:fs' +import { join } from 'node:path' + import type { Project } from '../utils/project' -import { LAYOUTS, PERMISSIONS_ENFORCED } from '../utils/project' +import { LAYOUTS, PERMISSIONS_ENFORCED, temporaryDirectory } from '../utils/project' import { checkedFiles, listingProject } from './utils' describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, app }) => { @@ -52,16 +55,28 @@ describe.each(LAYOUTS)('uncheck with paths outside git in a $name', ({ create, a ]) }) - it('checks a file the walk skips when it is named', async () => { - const project = walkedProject('.env.ts', 'node_modules/dep/index.ts') - const named = [`${routes}/.env.ts`, `${routes}/node_modules/dep/index.ts`] + it('checks a file the walk skips or a link leaving the project when it is named', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'vendor.ts'), '') + const project = walkedProject('.env.ts', 'node_modules/dep/index.ts').link( + `${routes}/vendor.ts`, + join(store, 'vendor.ts'), + ) + const named = [ + `${routes}/.env.ts`, + `${routes}/node_modules/dep/index.ts`, + `${routes}/vendor.ts`, + ] expect(await check(project, ...named)).toEqual(named) }) - it('lists a linked file but never enters a linked folder or follows a broken link', async () => { + it('lists a linked file inside the project but never enters a linked folder or follows a broken link', async () => { + const store = temporaryDirectory() + writeFileSync(join(store, 'vendor.ts'), '') const project = walkedProject('home.ts', 'shared/util.ts') .link(`${routes}/alias.ts`, 'home.ts') + .link(`${routes}/vendor.ts`, join(store, 'vendor.ts')) .link(`${routes}/broken.ts`, 'missing.ts') .link(`${routes}/self.ts`, 'self.ts') .link(`${routes}/up`, '..') diff --git a/packages/uncheck/tests/uncheck/paths.test.ts b/packages/uncheck/tests/uncheck/paths.test.ts index c4807c2..bd7e52c 100644 --- a/packages/uncheck/tests/uncheck/paths.test.ts +++ b/packages/uncheck/tests/uncheck/paths.test.ts @@ -1,7 +1,14 @@ -import { writeFileSync } from 'node:fs' -import { join } from 'node:path' - -import { cliError, LAYOUTS, monorepo, report, temporaryDirectory } from '../utils/project' +import { chmodSync, mkdirSync, symlinkSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' + +import { + cliError, + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + report, + temporaryDirectory, +} from '../utils/project' import { CLEAN_CODE, CODE_WITH_VAR, selectedReport } from './utils' describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, app }) => { @@ -101,16 +108,70 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, ]) }) + it('takes an absolute path through a linked folder to the directory it runs in', async () => { + const routes = `${app}src/routes` + const project = create({ + [`${routes}/home.ts`]: CLEAN_CODE, + [`${routes}/legacy.ts`]: CODE_WITH_VAR, + }) + const links = temporaryDirectory() + const linked = join(links, 'linked') + const elsewhere = temporaryDirectory() + writeFileSync(join(elsewhere, 'shared.ts'), CLEAN_CODE) + symlinkSync(project.dir, linked) + symlinkSync(elsewhere, join(links, 'elsewhere')) + + const through = await project.uncheck([ + '--only=oxlint', + join(linked, routes, 'home.ts'), + join(linked, routes, '*.ts'), + `!${join(linked, routes, 'legacy.ts')}`, + ]) + const fromLink = await project.uncheck([ + '--only=oxlint', + `--cwd=${linked}`, + project.path(routes, 'home.ts'), + ]) + const outside = join(links, 'elsewhere/shared.ts') + const rejected = await project.uncheck(['--only=oxlint', outside]) + + expect(through.stderr).toBe('') + expect(through.exitCode).toBe(0) + expect(selectedReport(through.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(fromLink.stderr).toBe('') + expect(fromLink.exitCode).toBe(0) + expect(selectedReport(fromLink.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + expect(rejected.exitCode).toBe(1) + expect(rejected.stderr).toBe( + cliError( + `${outside} is outside ${project.dir}, run from a folder that contains it or pass one with --cwd`, + ), + ) + }) + it('fails naming a file, directory or glob above the directory it runs in', async () => { const project = create({ [`${app}src/routes/home.ts`]: CLEAN_CODE, [`${app}src/legacy.ts`]: CODE_WITH_VAR, }) - const outside = join(temporaryDirectory(), 'shared.ts') + const elsewhere = temporaryDirectory() + const outside = join(elsewhere, 'shared.ts') + const looping = join(elsewhere, 'loop') writeFileSync(outside, CLEAN_CODE) + symlinkSync(looping, looping) const routes = `${app}src/routes` - for (const pattern of ['../legacy.ts', '..', '../*.ts', outside]) { + for (const pattern of ['../legacy.ts', '..', '../*.ts', outside, join(looping, 'shared.ts')]) { const fromInside = await project.uncheck(['--only=oxlint', 'home.ts', pattern], { cwd: routes, }) @@ -132,6 +193,37 @@ describe.each(LAYOUTS)('uncheck selecting files by path in a $name', ({ create, } } }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'fails naming, and ignores leaving out, a file in a folder outside it cannot look into', + async () => { + const routes = `${app}src/routes` + const project = create({ [`${routes}/home.ts`]: CLEAN_CODE }) + const locked = temporaryDirectory() + const hidden = join(locked, 'inner/shared.ts') + mkdirSync(dirname(hidden)) + writeFileSync(hidden, CLEAN_CODE) + chmodSync(locked, 0) + + const named = await project.uncheck(['--only=oxlint', hidden]) + const excluded = await project.uncheck(['--only=oxlint', routes, `!${hidden}`]) + + expect(named.exitCode).toBe(1) + expect(named.stderr).toBe( + cliError( + `${hidden} is outside ${project.dir}, run from a folder that contains it or pass one with --cwd`, + ), + ) + expect(excluded.stderr).toBe('') + expect(excluded.exitCode).toBe(0) + expect(selectedReport(excluded.stdout)).toEqual([ + `uncheck in ${project.dir}`, + `▶ oxlint --no-error-on-unmatched-pattern ${routes}/home.ts`, + '✔ oxlint passed', + '✔ all checks passed (oxlint)', + ]) + }, + ) }) describe('uncheck selecting files by path in a monorepo package', () => { diff --git a/packages/uncheck/tests/uncheck/presets.test.ts b/packages/uncheck/tests/uncheck/presets.test.ts index 734b43d..3afd259 100644 --- a/packages/uncheck/tests/uncheck/presets.test.ts +++ b/packages/uncheck/tests/uncheck/presets.test.ts @@ -1,4 +1,11 @@ -import { LAYOUTS, report } from '../utils/project' +import { + FULL_OXFMT, + FULL_OXFMT_FIX, + FULL_OXLINT, + FULL_OXLINT_FIX, + LAYOUTS, + report, +} from '../utils/project' import { layoutChecks } from './utils' const PRESET_CONFIGS = { @@ -110,9 +117,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✘ tsc failed', @@ -158,9 +165,9 @@ describe.each(LAYOUTS)('uncheck with the middleapi presets in a $name', ({ creat expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...layoutChecks(app, { fix: true }).sherif, - '▶ oxlint --fix', + FULL_OXLINT_FIX, '✘ oxlint failed', - '▶ oxfmt', + FULL_OXFMT_FIX, '✔ oxfmt passed', '○ tsc skipped, disabled with --skip=tsc', `✘ 1 of ${checks.length - 1} checks failed: oxlint`, diff --git a/packages/uncheck/tests/uncheck/project.test.ts b/packages/uncheck/tests/uncheck/project.test.ts index c6873e4..6357d34 100644 --- a/packages/uncheck/tests/uncheck/project.test.ts +++ b/packages/uncheck/tests/uncheck/project.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, report } from '../utils/project' import { CODE_WITH_TYPE_ERROR, CODE_WITH_VAR, @@ -19,9 +19,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -41,9 +41,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✘ oxlint failed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -62,9 +62,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✘ oxfmt failed', tsc, '✔ tsc passed', @@ -85,9 +85,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✘ tsc failed', @@ -105,9 +105,9 @@ describe.each(LAYOUTS)('uncheck in a $name', ({ create, app, tsc }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', `✘ tsc found ${tsconfigs} tsconfig.json but typescript is not installed`, `✘ 1 of ${checks.length} checks failed: tsc`, @@ -127,9 +127,9 @@ describe('uncheck in a monorepo', () => { `uncheck in ${project.dir}`, '▶ sherif', '✘ sherif failed', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '▶ tsc -b tsconfig.json', '✔ tsc passed', diff --git a/packages/uncheck/tests/uncheck/selection.test.ts b/packages/uncheck/tests/uncheck/selection.test.ts index f07f190..6770e9f 100644 --- a/packages/uncheck/tests/uncheck/selection.test.ts +++ b/packages/uncheck/tests/uncheck/selection.test.ts @@ -1,4 +1,13 @@ -import { cliError, LAYOUTS, project as bareProject, report, singleRepo } from '../utils/project' +import { + cliError, + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + project as bareProject, + report, + singleRepo, + TOOLS, +} from '../utils/project' import { layoutChecks, NOT_COVERED, SKIPPED_BESIDE_TSC, UNFORMATTED_CODE } from './utils' describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc }) => { @@ -12,7 +21,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', tsc, @@ -30,7 +39,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, ...sherif, - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, disabled with --skip=oxfmt', '○ tsc skipped, disabled with --skip=tsc', @@ -47,7 +56,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(skipped.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not installed', '○ tsc skipped, not selected by --only', @@ -60,7 +69,7 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(required.stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '✘ oxfmt not installed', '○ tsc skipped, not selected by --only', @@ -81,9 +90,9 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', @@ -143,6 +152,47 @@ describe.each(LAYOUTS)('uncheck check selection in a $name', ({ create, app, tsc ]) expect(exitCode).toBe(0) }) + + it.each([ + { + outcome: 'passes', + typescript: 'installed', + tools: TOOLS, + tscReason: 'no tsconfig.json covers the given files', + mark: '○', + expectedExitCode: 0, + }, + { + outcome: 'fails', + typescript: 'not installed', + tools: TOOLS.filter((tool) => tool !== 'typescript'), + tscReason: 'not installed', + mark: '✘', + expectedExitCode: 1, + }, + ])( + '$outcome on a source file no tsconfig.json covers when typescript is $typescript and unmatched patterns are allowed', + async ({ tools, tscReason, mark, expectedExitCode }) => { + const project = create( + { [`${app}scripts/release.ts`]: 'export const release = 1;\n' }, + { tools }, + ) + + const { exitCode, stdout } = await project.uncheck([ + '--no-error-on-unmatched-pattern', + '--only=tsc', + `${app}scripts/release.ts`, + ]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + `○ tsc skipped, ${tscReason}`, + `${mark} nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc ${tscReason}`, + ]) + expect(exitCode).toBe(expectedExitCode) + }, + ) }) describe('uncheck check selection', () => { @@ -162,6 +212,36 @@ describe('uncheck check selection', () => { expect(exitCode).toBe(1) }) + it.each([ + { file: 'src/index.ts', typescript: 'not installed', tools: [] }, + { file: 'README.md', typescript: 'not installed', tools: [] }, + { file: 'README.md', typescript: 'installed', tools: ['typescript'] as const }, + ])( + 'fails on $file in a project that uses none of the tools with typescript $typescript, even when unmatched patterns are allowed', + async ({ file, tools }) => { + const project = bareProject( + { + 'package.json': { name: 'bare', private: true }, + 'README.md': '# Bare\n', + 'src/index.ts': 'export const answer = 42;\n', + }, + { tools }, + ) + + const { exitCode, stdout } = await project.uncheck(['--no-error-on-unmatched-pattern', file]) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not installed', + '○ oxlint skipped, not installed', + '○ oxfmt skipped, not installed', + '○ tsc skipped, no tsconfig.json found', + '✘ nothing to check: sherif not installed, oxlint not installed, oxfmt not installed, tsc no tsconfig.json found', + ]) + expect(exitCode).toBe(1) + }, + ) + it.each([ { contradiction: 'a required check it skips', diff --git a/packages/uncheck/tests/uncheck/sherif.test.ts b/packages/uncheck/tests/uncheck/sherif.test.ts index af4fb87..a32d9cb 100644 --- a/packages/uncheck/tests/uncheck/sherif.test.ts +++ b/packages/uncheck/tests/uncheck/sherif.test.ts @@ -1,4 +1,5 @@ -import { LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, monorepo, report, singleRepo } from '../utils/project' +import { monorepoWithMismatchedVersions } from './utils' const OTHERS_NOT_SELECTED = [ '○ oxlint skipped, not selected by --only', @@ -24,7 +25,7 @@ describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', '○ oxfmt skipped, not selected by --only', '○ tsc skipped, not selected by --only', @@ -74,6 +75,18 @@ describe.each(LAYOUTS)('uncheck sherif in a $name', ({ create, app }) => { expect(exitCode).toBe(1) }) + it.each([ + ['only holds settings', 'onlyBuiltDependencies:\n - esbuild\n'], + ['is empty', ''], + ])('skips sherif where the pnpm-workspace.yaml %s', async (_, workspace) => { + const project = create({ 'pnpm-workspace.yaml': workspace }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual(sherifSkipped(project.dir, 'not a workspace root')) + expect(exitCode).toBe(1) + }) + it('takes a pnpm-workspace.yaml link that loops for no workspace', async () => { const project = create({ 'pnpm-workspace.yaml': null }).link( 'pnpm-workspace.yaml', @@ -96,9 +109,9 @@ describe('uncheck sherif in a single repo', () => { expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '✘ sherif not a workspace root', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '▶ tsc -p tsconfig.json --noEmit', '✔ tsc passed', @@ -127,6 +140,58 @@ describe('uncheck sherif in a monorepo', () => { }, ) + it('finds the packages of a pnpm-workspace.yaml with a byte order mark and a quoted key', async () => { + const project = monorepo({ 'pnpm-workspace.yaml': '\uFEFF"packages":\n - packages/*\n' }) + + const { exitCode, stdout } = await project.uncheck(['--only=sherif']) + + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '▶ sherif', + '✔ sherif passed', + ...OTHERS_NOT_SELECTED, + '✔ all checks passed (sherif)', + ]) + expect(exitCode).toBe(0) + }) + + it('refuses to only report when the sherif field sets fix, which makes sherif fix files', async () => { + const project = monorepoWithMismatchedVersions({ fix: true, select: 'highest' }) + + project.git('add', '--', 'packages/app/package.json') + + const manifests = ['package.json', 'packages/app/package.json', 'packages/core/package.json'] + const before = manifests.map((file) => project.read(file)) + const status = project.git('status', '--porcelain') + const refused = + '✘ sherif would fix files while uncheck only reports, remove "fix": true from the sherif field of package.json' + + const runs = [ + { header: `uncheck in ${project.dir}`, run: await project.uncheck(['--only=sherif']) }, + { + header: `uncheck in ${project.dir}`, + run: await project.uncheck(['--fix', '--only=sherif'], { env: { CI: '1' } }), + }, + { + header: `uncheck staged in ${project.dir}`, + run: await project.uncheck(['staged', '--only=sherif']), + }, + ] + + for (const { header, run } of runs) { + expect(report(run.stdout)).toEqual([ + header, + refused, + ...OTHERS_NOT_SELECTED, + '✘ 1 of 1 checks failed: sherif', + ]) + expect(run.exitCode).toBe(1) + } + + expect(manifests.map((file) => project.read(file))).toEqual(before) + expect(project.git('status', '--porcelain')).toBe(status) + }) + it('finds a workspace declared in the workspaces field of package.json', async () => { const project = monorepo({ 'pnpm-workspace.yaml': null }) .update('package.json', (manifest) => ({ ...manifest, workspaces: ['packages/*'] })) diff --git a/packages/uncheck/tests/uncheck/tools.test.ts b/packages/uncheck/tests/uncheck/tools.test.ts index b81c8fa..93c3ab2 100644 --- a/packages/uncheck/tests/uncheck/tools.test.ts +++ b/packages/uncheck/tests/uncheck/tools.test.ts @@ -1,4 +1,4 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' +import { FULL_OXFMT, FULL_OXLINT, LAYOUTS, monorepo, report } from '../utils/project' import { layoutChecks } from './utils' const ONLY_FILE_CHECKS = ['--only=oxlint', '--only=oxfmt'] @@ -49,12 +49,14 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } const { exitCode, stdout } = await project.uncheck(['--only=oxfmt']) - expect(stdout).toContain('▶ oxfmt --check\noxfmt ran with --check\n') + expect(stdout).toContain( + '▶ oxfmt --check --no-error-on-unmatched-pattern\noxfmt ran with --check --no-error-on-unmatched-pattern\n', + ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not selected by --only', '○ oxlint skipped, not selected by --only', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxfmt)', @@ -94,9 +96,9 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } expect(report(stdout)).toEqual([ `uncheck in ${project.path(app, 'src')}`, '○ sherif skipped, not selected by --only', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', '○ tsc skipped, not selected by --only', '✔ all checks passed (oxlint, oxfmt)', @@ -119,13 +121,15 @@ describe.each(LAYOUTS)('uncheck finding tools in a $name', ({ create, app, tsc } env: { NODE_OPTIONS: `--require ${project.path('.pnp.cjs')}` }, }) - expect(stdout).toContain('▶ oxlint\noxlint from the Yarn cache\n') + expect(stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\noxlint from the Yarn cache\n', + ) expect(report(stdout)).toEqual([ `uncheck in ${project.dir}`, '○ sherif skipped, not installed', - '▶ oxlint', + FULL_OXLINT, '✔ oxlint passed', - '▶ oxfmt --check', + FULL_OXFMT, '✔ oxfmt passed', tsc, '✔ tsc passed', @@ -147,7 +151,9 @@ describe('uncheck finding tools in a monorepo', () => { const inPackage = await project.uncheck(['--only=oxlint'], { cwd: 'packages/app' }) - expect(inPackage.stdout).toContain('▶ oxlint\noxlint of packages/app\n') + expect(inPackage.stdout).toContain( + '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern\noxlint of packages/app\n', + ) expect(inPackage.exitCode).toBe(0) const atRoot = await project.uncheck(['--only=oxlint']) diff --git a/packages/uncheck/tests/uncheck/tsc-extends.test.ts b/packages/uncheck/tests/uncheck/tsc-extends.test.ts index d9d8781..2ff7a4c 100644 --- a/packages/uncheck/tests/uncheck/tsc-extends.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-extends.test.ts @@ -1,5 +1,13 @@ -import { LAYOUTS, monorepo } from '../utils/project' -import { ALLOW_JS, CONFIG_DIR, NOT_COVERED, tscPlan, withFakeTsc } from './utils' +import { LAYOUTS, monorepo, report } from '../utils/project' +import { + ALLOW_JS, + CONFIG_DIR, + NOT_COVERED, + OUT_DIR, + tscOnlyReport, + tscPlan, + withFakeTsc, +} from './utils' const NO_JS = { compilerOptions: { allowJs: false } } @@ -226,6 +234,27 @@ describe.each(LAYOUTS)('tsc extends in a $name', ({ create, app }) => { ]) expect(await tscPlan(project, app, ['web/src/index.ts'])).toEqual([NOT_COVERED]) }) + + it('typechecks the projects extending a base deleted with its folder', async () => { + const project = withFakeTsc(create, { + [`${app}config/tsconfig.base.json`]: NO_JS, + [`${app}web/tsconfig.json`]: { extends: '../config/tsconfig.base.json', include: ['src'] }, + [`${app}server/tsconfig.json`]: { include: ['src'] }, + }) + + project.git('rm', '--quiet', '-r', '--', `${app}config`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc'], { cwd: app }) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.path(app, '.')}`, + ['▶ tsc -p web/tsconfig.json --noEmit'], + 'passed', + ), + ) + }) }) describe('tsc extends across the packages of a monorepo', () => { @@ -238,8 +267,8 @@ describe('tsc extends across the packages of a monorepo', () => { version: '2.0.0', }, 'packages/app/node_modules/@acme/tsconfig/tsconfig.json': NO_JS, - 'packages/app/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, - 'packages/core/tsconfig.json': { extends: '@acme/tsconfig', include: ['src'] }, + 'packages/app/tsconfig.json': { extends: '@acme/tsconfig', ...OUT_DIR, include: ['src'] }, + 'packages/core/tsconfig.json': { extends: '@acme/tsconfig', ...OUT_DIR, include: ['src'] }, }) expect(await tscPlan(project, '', ['packages/core/src/a.js'])).toEqual([ diff --git a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts index 5bc1974..a699675 100644 --- a/packages/uncheck/tests/uncheck/tsc-inputs.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-inputs.test.ts @@ -4,6 +4,7 @@ import { CONFIG_DIR, NOT_COVERED, SKIPPED_BESIDE_TSC, + tscOnlyReport, tscPlan, withFakeTsc, } from './utils' @@ -185,13 +186,13 @@ describe.each(LAYOUTS)('tsc inputs in a $name', ({ create, app }) => { expect(project.exists(`${app}web/data.json`)).toBe(false) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p web/tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p web/tsconfig.json --noEmit'], + 'passed', + ), + ) }) }) @@ -203,7 +204,9 @@ describe('tsc inputs across the packages of a monorepo', () => { version: '1.0.0', private: true, }, - 'packages/tsconfig/base.json': { compilerOptions: { strict: true } }, + 'packages/tsconfig/base.json': { + compilerOptions: { strict: true, outDir: `${CONFIG_DIR}/dist` }, + }, 'packages/app/tsconfig.json': { extends: '@repo/tsconfig/base.json', include: ['src'] }, }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') @@ -212,4 +215,89 @@ describe('tsc inputs across the packages of a monorepo', () => { ]) expect(await tscPlan(project, '', ['packages/tsconfig/package.json'])).toEqual([NOT_COVERED]) }) + + it.each([ + { spelling: 'a file of it', spec: '@repo/tsconfig/base.json', base: 'base.json' }, + { spelling: 'a file of it without .json', spec: '@repo/tsconfig/base', base: 'base.json' }, + { spelling: 'its tsconfig.json', spec: '@repo/tsconfig', base: 'tsconfig.json' }, + { + spelling: 'an export of it', + spec: '@repo/tsconfig/strict', + base: 'strict.json', + exports: { './strict': './strict.json' }, + }, + ])( + 'typechecks the projects extending a deleted base they name by its package and $spelling', + async ({ spec, base, exports }) => { + const project = withFakeTsc(monorepo, { + 'tsconfig.json': null, + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + exports, + }, + [`packages/tsconfig/${base}`]: { compilerOptions: { strict: true } }, + 'packages/app/tsconfig.json': { extends: spec, include: ['src'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + project.git('rm', '--quiet', '--', `packages/tsconfig/${base}`) + + const { exitCode, stdout } = await project.uncheck(['staged', '--only=tsc']) + + expect(exitCode).toBe(0) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.dir}`, + ['▶ tsc -p packages/app/tsconfig.json --noEmit'], + 'passed', + ), + ) + }, + ) + + it('checks a shared base kept as a tsconfig.json with no sources only through the configs extending it', async () => { + const project = withFakeTsc(monorepo, { + 'packages/tsconfig/package.json': { + name: '@repo/tsconfig', + version: '1.0.0', + private: true, + }, + 'packages/tsconfig/tsconfig.json': { + compilerOptions: { strict: true, outDir: `${CONFIG_DIR}/dist` }, + }, + 'packages/app/tsconfig.json': { + extends: '@repo/tsconfig', + references: [{ path: '../core' }], + include: ['src'], + }, + 'packages/web/tsconfig.json': { include: ['src'] }, + 'packages/web/src/index.ts': '', + 'packages/web/test/tsconfig.json': { extends: '../tsconfig.json', include: ['.'] }, + 'packages/docs/tsconfig.json': { include: ['scr'] }, + }).link('packages/app/node_modules/@repo/tsconfig', '../../../tsconfig') + + expect(await tscPlan(project, '')).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p packages/docs/tsconfig.json --noEmit', + '▶ tsc -p packages/web/test/tsconfig.json --noEmit', + '▶ tsc -p packages/web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/tsconfig/tsconfig.json'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + + project.write({ 'packages/tsconfig/legacy.ts': '' }).commit() + project.git('rm', '--quiet', '--', 'packages/tsconfig/legacy.ts') + + const deleted = await project.uncheck(['staged', '--only=tsc']) + + expect(deleted.exitCode).toBe(0) + expect(report(deleted.stdout)).toEqual([ + `uncheck staged in ${project.dir}`, + ...SKIPPED_BESIDE_TSC, + NOT_COVERED, + '○ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers the given files', + ]) + }) }) diff --git a/packages/uncheck/tests/uncheck/tsc-references.test.ts b/packages/uncheck/tests/uncheck/tsc-references.test.ts index eb71e0b..f4412b7 100644 --- a/packages/uncheck/tests/uncheck/tsc-references.test.ts +++ b/packages/uncheck/tests/uncheck/tsc-references.test.ts @@ -1,5 +1,22 @@ -import { LAYOUTS, monorepo, report } from '../utils/project' -import { CONFIG_DIR, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' +import { + compilerOptions, + FULL_OXFMT, + FULL_OXLINT, + LAYOUTS, + monorepo, + report, + singleRepo, +} from '../utils/project' +import { + CODE_WITH_TYPE_ERROR, + CONFIG_DIR, + NO_EMIT, + NOT_COVERED, + OUT_DIR, + tscOnlyReport, + tscPlan, + withFakeTsc, +} from './utils' describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => { it('builds a solution-style tsconfig.json whose references have other names', async () => { @@ -8,8 +25,8 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => files: [], references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }], }, - [`${app}tsconfig.app.json`]: { include: ['src'] }, - [`${app}tsconfig.node.json`]: { include: ['vite.config.ts'] }, + [`${app}tsconfig.app.json`]: { ...NO_EMIT, include: ['src'] }, + [`${app}tsconfig.node.json`]: { ...NO_EMIT, include: ['vite.config.ts'] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -17,6 +34,106 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => expect(await tscPlan(project, app, ['scripts/release.ts'])).toEqual([NOT_COVERED]) }) + it('builds a references graph whose projects keep their output away from their sources, also through extends', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: ['no-emit', 'declarations', 'out-dir', 'out-file'].map((dir) => ({ + path: `./${dir}`, + })), + }, + [`${app}tsconfig.base.json`]: NO_EMIT, + [`${app}tsconfig.dist.json`]: { compilerOptions: { outDir: `${CONFIG_DIR}/dist` } }, + [`${app}no-emit/tsconfig.json`]: { extends: '../tsconfig.base.json', include: ['src'] }, + [`${app}declarations/tsconfig.json`]: { + extends: '../tsconfig.base.json', + compilerOptions: { noEmit: false, emitDeclarationOnly: true }, + include: ['src'], + }, + [`${app}out-dir/tsconfig.json`]: { extends: '../tsconfig.dist.json', include: ['src'] }, + [`${app}out-file/tsconfig.json`]: { + compilerOptions: { outFile: 'index.js' }, + include: ['src'], + }, + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + }) + + it('checks with -p the projects whose tsc -b would write JavaScript next to sources, after building the projects they reference', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { + files: [], + references: [{ path: './web' }, { path: './node' }, { path: './shared' }], + }, + [`${app}tsconfig.base.json`]: NO_EMIT, + [`${app}web/tsconfig.json`]: { + ...NO_EMIT, + include: ['src'], + references: [{ path: '../node' }, { path: '../ui' }, { path: '../legacy' }], + }, + [`${app}node/tsconfig.json`]: { + extends: '../tsconfig.base.json', + compilerOptions: { composite: true, noEmit: false }, + include: ['vite.config.ts'], + }, + [`${app}ui/tsconfig.json`]: { + compilerOptions: { composite: true, outDir: 'dist' }, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, + [`${app}docs/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + }) + + expect(await tscPlan(project, app)).toEqual([ + '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', + '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['node/vite.config.ts'])).toEqual([ + '▶ tsc -b ui/tsconfig.json', + '▶ tsc -p node/tsconfig.json --noEmit --composite false --declaration', + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['shared/src/index.ts'])).toEqual([ + '▶ tsc -b docs/tsconfig.json shared/tsconfig.json ui/tsconfig.json', + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p web/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, app, ['tsconfig.json'])).toEqual([ + '▶ tsc -p tsconfig.json --noEmit', + ]) + }) + + it('builds with tsc -b the projects whose JavaScript next to their sources git ignores, also from the folder of one', async () => { + const project = withFakeTsc(create, { + [`${app}tsconfig.json`]: { files: [], references: [{ path: './lib' }, { path: './web' }] }, + [`${app}lib/.gitignore`]: '*.js\n', + [`${app}lib/tsconfig.json`]: { compilerOptions: { composite: true }, include: ['src'] }, + [`${app}lib/src/index.ts`]: '', + [`${app}web/.gitignore`]: '*.js\n', + [`${app}web/tsconfig.json`]: { + compilerOptions: { composite: true }, + include: ['src'], + references: [{ path: '../lib' }], + }, + [`${app}web/src/index.ts`]: '', + }) + + expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, `${app}web`)).toEqual(['▶ tsc -b tsconfig.json']) + expect(await tscPlan(project, `${app}web`, ['src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + ]) + }) + it('follows references to a folder, to a .json file and with backslashes', async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { @@ -24,15 +141,17 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => references: [{ path: 'web' }, { path: '.\\server\\tsconfig.build.json' }], }, [`${app}web/tsconfig.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '../ui/tsconfig.json' }], }, - [`${app}ui/tsconfig.json`]: { include: ['src'] }, + [`${app}ui/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}server/tsconfig.build.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '..\\shared' }], }, - [`${app}shared/tsconfig.json`]: { include: ['src'] }, + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -45,8 +164,8 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it(`follows a reference path with ${CONFIG_DIR} as written, like tsc`, async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { include: ['src'] }, - [`${app}web/tsconfig.json`]: { references: [{ path: `${CONFIG_DIR}/../lib` }] }, - [`${app}web/lib/tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { ...OUT_DIR, references: [{ path: `${CONFIG_DIR}/../lib` }] }, + [`${app}web/lib/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}lib/tsconfig.json`]: { include: ['src'] }, }) @@ -63,7 +182,7 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => files: [], references: ['./scripts', { path: 42 }, null, { prepend: true }, { path: './lib' }], }, - [`${app}lib/tsconfig.json`]: { include: ['src'], references: './scripts' }, + [`${app}lib/tsconfig.json`]: { ...OUT_DIR, include: ['src'], references: './scripts' }, [`${app}scripts/tsconfig.json`]: { include: ['.'] }, }) @@ -76,11 +195,24 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('builds the roots of the reference graph that depend on the given files', async () => { const project = withFakeTsc(create, { [`${app}tsconfig.json`]: { include: ['scripts'] }, - [`${app}shared/tsconfig.json`]: { include: ['src'] }, - [`${app}web/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, - [`${app}server/tsconfig.json`]: { include: ['src'], references: [{ path: '../shared' }] }, - [`${app}cli/tsconfig.json`]: { include: ['src'], references: [{ path: '../server' }] }, + [`${app}shared/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, + [`${app}web/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}server/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../shared' }], + }, + [`${app}cli/tsconfig.json`]: { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../server' }], + }, [`${app}e2e/tsconfig.json`]: { + ...OUT_DIR, include: ['src'], references: [{ path: '../web' }, { path: '../server' }], }, @@ -135,7 +267,7 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('leaves a reference to a missing config to tsc -b', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './legacy' }] }, + [`${app}tsconfig.json`]: { ...NO_EMIT, include: ['src'], references: [{ path: './legacy' }] }, }) expect(await tscPlan(project, app)).toEqual(['▶ tsc -b tsconfig.json']) @@ -144,7 +276,11 @@ describe.each(LAYOUTS)('tsc project references in a $name', ({ create, app }) => it('leaves a reference to a folder named like a config file to tsc -b', async () => { const project = withFakeTsc(create, { - [`${app}tsconfig.json`]: { include: ['src'], references: [{ path: './lib.json' }] }, + [`${app}tsconfig.json`]: { + ...NO_EMIT, + include: ['src'], + references: [{ path: './lib.json' }], + }, [`${app}lib.json/index.ts`]: '', }) @@ -157,7 +293,11 @@ describe('tsc project references across the packages of a monorepo', () => { const project = withFakeTsc(monorepo, { 'tsconfig.json': { include: ['scripts'] }, 'packages/web/package.json': { name: '@repo/web', version: '1.0.0', private: true }, - 'packages/web/tsconfig.json': { include: ['src'], references: [{ path: '../core' }] }, + 'packages/web/tsconfig.json': { + ...OUT_DIR, + include: ['src'], + references: [{ path: '../core' }], + }, 'packages/cli/package.json': { name: '@repo/cli', version: '1.0.0', private: true }, 'packages/cli/tsconfig.json': { include: ['src'] }, }) @@ -179,12 +319,217 @@ describe('tsc project references across the packages of a monorepo', () => { const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: 'packages/core' }) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path('packages/core')}`, - ...SKIPPED_BESIDE_TSC, + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path('packages/core')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'passed', + ), + ) + }) +}) + +describe('tsc project references with the real compiler in a single repo', () => { + const IN_PLACE = compilerOptions({ composite: true }) + const COMPOSITE = { ...IN_PLACE, emitDeclarationOnly: true, outDir: 'dist', rootDir: 'src' } + + it('builds the library a Vite 4 app imports before checking the app with -p, writing no JavaScript', async () => { + const project = singleRepo({ + 'tsconfig.node.json': { + compilerOptions: { + composite: true, + skipLibCheck: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + }, + include: ['vite.config.ts'], + }, + 'vite.config.ts': 'export default { base: "/" };\n', + 'lib/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, + 'lib/src/index.ts': 'export const one = 1;\n', + 'src/index.ts': + 'import { one } from "../lib/src/index";\n\nexport const two: number = one + 1;\n', + }) + .update('tsconfig.json', (config) => ({ + ...config, + references: [{ path: './tsconfig.node.json' }, { path: './lib' }], + })) + .commit() + const plan = [ + '▶ tsc -b lib/tsconfig.json', '▶ tsc -p tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', + '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', + ] + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, plan, 'passed'), + ) + expect(project.git('status', '--porcelain', '--ignored')).toBe( + '!! lib/dist/\n!! lib/tsconfig.tsbuildinfo\n!! node_modules/\n', + ) + + project.write({ + 'lib/src/index.ts': 'export const one = "1";\n', + 'vite.config.ts': CODE_WITH_TYPE_ERROR, + }) + + const failed = await project.uncheck(['--only=tsc']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, plan, 'failed'), + ) + expect(failed.stdout).toContain( + "src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + expect(failed.stdout).toContain( + "vite.config.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + expect(project.exists('vite.config.js')).toBe(false) + }) + + it('builds with tsc -b the projects whose JavaScript next to their sources git ignores, so a break across them fails after a build', async () => { + const project = singleRepo({ + '.gitignore': 'node_modules\n*.js\n*.d.ts\n*.tsbuildinfo\n', + 'tsconfig.json': { files: [], references: [{ path: './lib' }, { path: './app' }] }, + 'lib/tsconfig.json': { compilerOptions: IN_PLACE, include: ['src'] }, + 'lib/src/index.ts': 'export const one = 1;\n', + 'app/tsconfig.json': { + compilerOptions: IN_PLACE, + include: ['src'], + references: [{ path: '../lib' }], + }, + 'app/src/index.ts': + 'import { one } from "../../lib/src/index";\n\nexport const two: number = one + 1;\n', + }) + + const passed = await project.uncheck(['--only=tsc']) + + expect(passed.exitCode).toBe(0) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'passed'), + ) + expect(project.exists('lib/src/index.d.ts')).toBe(true) + expect(project.git('status', '--porcelain')).toBe('') + + project.write({ 'lib/src/index.ts': 'export const one = "1";\n' }) + + const failed = await project.uncheck(['--only=tsc', 'lib/src/index.ts']) + + expect(failed.exitCode).toBe(1) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'failed'), + ) + expect(failed.stdout).toContain( + "app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }) + + it('checks with -p a solution that references a project writing JavaScript next to its sources, so a reference to a deleted config fails', async () => { + const project = singleRepo({ + 'tsconfig.json': { + files: [], + references: [{ path: './tsconfig.node.json' }, { path: './tsconfig.app.json' }], + }, + 'tsconfig.node.json': { compilerOptions: IN_PLACE, include: ['vite.config.ts'] }, + 'tsconfig.app.json': { + compilerOptions: { ...IN_PLACE, noEmit: true }, + include: ['src'], + }, + 'vite.config.ts': 'export default { base: "/" };\n', + }) + + project.git('rm', '--quiet', '--', 'tsconfig.app.json') + + const missing = `error TS6053: File '${project.path('tsconfig.app.json')}' not found.` + const full = await project.uncheck(['--only=tsc']) + + expect(full.exitCode).toBe(1) + expect(report(full.stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + [ + '▶ tsc -p tsconfig.json --noEmit', + '▶ tsc -p tsconfig.node.json --noEmit --composite false --declaration', + ], + 'failed', + ), + ) + expect(full.stdout).toContain(missing) + + const staged = await project.uncheck(['staged', '--only=tsc']) + + expect(staged.exitCode).toBe(1) + expect(report(staged.stdout)).toEqual( + tscOnlyReport( + `uncheck staged in ${project.dir}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) + expect(staged.stdout).toContain(missing) + expect(project.exists('vite.config.js')).toBe(false) + }) + + it('builds roots in folders starting with - or @, which tsc would read as an option or a file of arguments', async () => { + const project = singleRepo({ + 'core/tsconfig.json': { compilerOptions: COMPOSITE, include: ['src'] }, + 'core/src/index.ts': 'export const one = 1;\n', + ...Object.fromEntries( + ['-pkg', '@app'].flatMap((folder) => [ + [ + `${folder}/tsconfig.json`, + { compilerOptions: COMPOSITE, include: ['src'], references: [{ path: '../core' }] }, + ], + [`${folder}/src/index.ts`, CODE_WITH_TYPE_ERROR], + ]), + ), + }) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + ['▶ tsc -b ./-pkg/tsconfig.json ./@app/tsconfig.json', '▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) + + for (const folder of ['-pkg', '@app']) { + expect(stdout).toContain( + `\n${folder}/src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'.`, + ) + } + }) + + it('runs the other checks when a reference names a file, and tsc reports it', async () => { + const project = singleRepo({ 'README.md': '# App\n' }).update('tsconfig.json', (config) => ({ + ...config, + references: [{ path: './README.md' }], + })) + + const { exitCode, stdout } = await project.uncheck() + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual([ + `uncheck in ${project.dir}`, + '○ sherif skipped, not a workspace root', + FULL_OXLINT, + '✔ oxlint passed', + FULL_OXFMT, + '✔ oxfmt passed', + '▶ tsc -b tsconfig.json', + '✘ tsc failed', + '✘ 1 of 3 checks failed: tsc', ]) + expect(stdout).toContain( + `error TS6053: File '${project.path('README.md/tsconfig.json')}' not found.`, + ) }) }) diff --git a/packages/uncheck/tests/uncheck/tsc-workspace.test.ts b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts new file mode 100644 index 0000000..e951227 --- /dev/null +++ b/packages/uncheck/tests/uncheck/tsc-workspace.test.ts @@ -0,0 +1,121 @@ +import type { Files, ProjectOptions } from '../utils/project' +import { compilerOptions, monorepo, report } from '../utils/project' +import { tscOnlyReport, tscPlan, withFakeTsc } from './utils' + +const PACKAGE_CONFIG = { compilerOptions: compilerOptions({ noEmit: true }), include: ['src'] } + +function linkedMonorepo(files: Files = {}, options?: ProjectOptions) { + return monorepo( + { + 'tsconfig.json': null, + 'packages/core/package.json': { + name: '@repo/core', + version: '1.0.0', + private: true, + type: 'module', + exports: './src/index.ts', + }, + 'packages/core/tsconfig.json': PACKAGE_CONFIG, + 'packages/app/tsconfig.json': PACKAGE_CONFIG, + ...files, + }, + options, + ) +} + +describe('tsc across the packages of a workspace', () => { + it('checks the packages that depend on a changed one, directly or through others', async () => { + const project = withFakeTsc(linkedMonorepo, { + 'packages/e2e/package.json': { + name: '@repo/e2e', + version: '1.0.0', + private: true, + devDependencies: { '@repo/app': 'workspace:*' }, + }, + 'packages/e2e/tsconfig.json': PACKAGE_CONFIG, + 'packages/web/package.json': { + name: '@repo/web', + version: '1.0.0', + private: true, + peerDependencies: { react: '*' }, + }, + 'packages/web/tsconfig.json': PACKAGE_CONFIG, + 'packages/ui/package.json': { + name: '@repo/ui', + version: '1.0.0', + private: true, + peerDependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/ui/tsconfig.json': PACKAGE_CONFIG, + 'packages/cli/package.json': { + name: '@repo/cli', + version: '1.0.0', + private: true, + optionalDependencies: { '@repo/core': 'workspace:*' }, + }, + 'packages/cli/tsconfig.json': PACKAGE_CONFIG, + 'packages/broken/package.json': 'oops\n', + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/cli/tsconfig.json --noEmit', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + '▶ tsc -p packages/e2e/tsconfig.json --noEmit', + '▶ tsc -p packages/ui/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/app/src/index.ts'])).toEqual([ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/e2e/tsconfig.json --noEmit', + ]) + expect(await tscPlan(project, '', ['packages/web/src/index.ts'])).toEqual([ + '▶ tsc -p packages/web/tsconfig.json --noEmit', + ]) + }) + + it('builds the configs that reference the config of a package depending on a changed one', async () => { + const project = withFakeTsc(linkedMonorepo, { + 'tsconfig.json': { files: [], references: [{ path: 'packages/app' }] }, + }) + + expect(await tscPlan(project, '', ['packages/core/src/index.ts'])).toEqual([ + '▶ tsc -b tsconfig.json', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + ]) + }) + + it.each([ + { + core: 'its own tsconfig.json', + coreConfig: PACKAGE_CONFIG, + plan: [ + '▶ tsc -p packages/app/tsconfig.json --noEmit', + '▶ tsc -p packages/core/tsconfig.json --noEmit', + ], + }, + { + core: 'no tsconfig.json', + coreConfig: null, + plan: ['▶ tsc -p packages/app/tsconfig.json --noEmit'], + }, + ])( + 'fails a change that breaks a package importing it through its workspace link, with $core', + async ({ coreConfig, plan }) => { + const project = linkedMonorepo({ 'packages/core/tsconfig.json': coreConfig }).write({ + 'packages/core/src/index.ts': + 'export function double(value: number): string {\n return String(value);\n}\n', + }) + + const { exitCode, stdout } = await project.uncheck([ + '--only=tsc', + 'packages/core/src/index.ts', + ]) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual(tscOnlyReport(`uncheck in ${project.dir}`, plan, 'failed')) + expect(stdout).toContain( + "packages/app/src/index.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.", + ) + }, + ) +}) diff --git a/packages/uncheck/tests/uncheck/tsc.test.ts b/packages/uncheck/tests/uncheck/tsc.test.ts index 9679ca3..ea1f5c4 100644 --- a/packages/uncheck/tests/uncheck/tsc.test.ts +++ b/packages/uncheck/tests/uncheck/tsc.test.ts @@ -1,8 +1,31 @@ -import { LAYOUTS, monorepo, PERMISSIONS_ENFORCED, report, singleRepo } from '../utils/project' -import { ALLOW_JS, NOT_COVERED, SKIPPED_BESIDE_TSC, tscPlan, withFakeTsc } from './utils' +import { + commitOnSide, + compilerOptions, + git, + LAYOUTS, + monorepo, + PERMISSIONS_ENFORCED, + report, + run, + singleRepo, +} from '../utils/project' +import { + ALLOW_JS, + CLEAN_CODE, + CODE_WITH_TYPE_ERROR, + NOT_COVERED, + OUT_DIR, + SKIPPED_BESIDE_TSC, + tscOnlyReport, + tscPlan, + withFakeTsc, +} from './utils' -describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { - it('skips the check when the project has no tsconfig.json', async () => { +describe.each(LAYOUTS)('tsc in a $name', ({ create, app, tsc }) => { + const uncoveredFolder = + app === '' ? 'no tsconfig.json found' : 'no tsconfig.json covers this folder' + + it('skips the check when no tsconfig.json covers the folder', async () => { const project = create({ [`${app}tsconfig.json`]: null }) const { exitCode, stdout } = await project.uncheck(['--only=tsc'], { cwd: app }) @@ -11,8 +34,8 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { expect(report(stdout)).toEqual([ `uncheck in ${project.path(app, '.')}`, ...SKIPPED_BESIDE_TSC, - '○ tsc skipped, no tsconfig.json found', - '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json found', + `○ tsc skipped, ${uncoveredFolder}`, + `✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc ${uncoveredFolder}`, ]) }) @@ -20,7 +43,38 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { const project = withFakeTsc(create).write({ [`${app}tsconfig.json`]: null }) expect(project.git('ls-files', `${app}tsconfig.json`)).toBe(`${app}tsconfig.json\n`) - expect(await tscPlan(project, app)).toEqual(['○ tsc skipped, no tsconfig.json found']) + expect(await tscPlan(project, app)).toEqual([`○ tsc skipped, ${uncoveredFolder}`]) + }) + + it('checks a folder below its tsconfig.json with that config, if it includes files of the folder', async () => { + const project = withFakeTsc(create, { [`${app}scripts/release.ts`]: '' }) + const inherited = tsc.replace('tsconfig.json', '../tsconfig.json') + + expect(await tscPlan(project, `${app}src`)).toEqual([inherited]) + expect(await tscPlan(project, `${app}src`, ['index.ts'])).toEqual([inherited]) + expect(await tscPlan(project, `${app}scripts`)).toEqual([ + '○ tsc skipped, no tsconfig.json covers this folder', + ]) + expect(await tscPlan(project, `${app}scripts`, ['release.ts'])).toEqual([NOT_COVERED]) + }) + + it('checks a tsconfig.json with merge conflicts once', async () => { + const project = withFakeTsc(create) + const config = `${app}tsconfig.json` + const including = (folder: string) => ({ + ...(JSON.parse(project.read(config)) as object), + include: ['src', folder], + }) + + commitOnSide(project, { [config]: including('side') }) + project.write({ [config]: including('main') }).commit('main') + + expect((await run(['git', 'merge', 'side'], { cwd: project.dir })).exitCode).toBe(1) + + project.git('checkout', '--theirs', '--', config) + + expect(project.git('ls-files', '--', config)).toBe(`${config}\n`.repeat(3)) + expect(await tscPlan(project, app)).toEqual([tsc]) }) it('skips the check when no tsconfig.json covers the given files', async () => { @@ -55,13 +109,13 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { }) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(stdout).toContain("tsconfig.json(1,1): error TS1005: '{' expected.") }) @@ -75,13 +129,13 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { }) expect(exitCode).toBe(1) - expect(report(stdout)).toEqual([ - `uncheck in ${project.path(app, '.')}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.path(app, '.')}`, + ['▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) expect(stdout).toContain( `error TS5083: Cannot read file '${project.path(app, 'tsconfig.json')}'.`, ) @@ -93,7 +147,7 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { create, { [`${app}tsconfig.json`]: { files: [], references: [{ path: './web' }] }, - [`${app}web/tsconfig.json`]: { include: ['src'] }, + [`${app}web/tsconfig.json`]: { ...OUT_DIR, include: ['src'] }, [`${app}scripts/tsconfig.json`]: { include: ['.'] }, [`${app}node_modules/@tsconfig/node22/tsconfig.json`]: ALLOW_JS, [`${app}web/node_modules/lib/tsconfig.json`]: {}, @@ -109,11 +163,115 @@ describe.each(LAYOUTS)('tsc in a $name', ({ create, app }) => { }) }) +describe('tsc in a folder below a tsconfig.json of another project', () => { + it('never uses a tsconfig.json above the git repository, or above a folder outside one', async () => { + const project = withFakeTsc( + singleRepo, + { 'tsconfig.json': {}, 'repo/src/index.ts': '' }, + { git: 'none' }, + ) + const notFound = ['○ tsc skipped, no tsconfig.json found'] + + expect(await tscPlan(project, 'repo/src')).toEqual(notFound) + + git(project.path('repo'), ['init', '--quiet']) + + expect(await tscPlan(project, 'repo')).toEqual(notFound) + expect(await tscPlan(project, 'repo/src', ['index.ts'])).toEqual(notFound) + }) +}) + describe('tsc with the real compiler in a single repo', () => { + it('typechecks a folder below the tsconfig.json with it, as tsc does', async () => { + const project = singleRepo({ + 'src/index.ts': CODE_WITH_TYPE_ERROR, + 'scripts/release.ts': CLEAN_CODE, + }) + const failedInSrc = tscOnlyReport( + `uncheck in ${project.path('src')}`, + ['▶ tsc -p ../tsconfig.json --noEmit'], + 'failed', + ) + const diagnostic = + "index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." + + const inside = await project.uncheck(['--only=tsc'], { cwd: 'src' }) + + expect(inside.exitCode).toBe(1) + expect(report(inside.stdout)).toEqual(failedInSrc) + expect(inside.stdout).toContain(`\n${diagnostic}`) + + const given = await project.uncheck(['--only=tsc', '--cwd', 'src', 'index.ts']) + + expect(given.exitCode).toBe(1) + expect(report(given.stdout)).toEqual(failedInSrc) + expect(given.stdout).toContain(`\n${diagnostic}`) + + const outside = await project.uncheck(['--only=tsc'], { cwd: 'scripts' }) + + expect(outside.exitCode).toBe(1) + expect(report(outside.stdout)).toEqual([ + `uncheck in ${project.path('scripts')}`, + ...SKIPPED_BESIDE_TSC, + '○ tsc skipped, no tsconfig.json covers this folder', + '✘ nothing to check: sherif not selected by --only, oxlint not selected by --only, oxfmt not selected by --only, tsc no tsconfig.json covers this folder', + ]) + }) + + it('typechecks a folder without its own tsconfig.json with the one above it, next to the ones inside it', async () => { + const project = singleRepo({ + 'web/src/index.ts': CODE_WITH_TYPE_ERROR, + 'web/cypress/tsconfig.json': { + compilerOptions: { strict: true, noEmit: true, types: [] }, + include: ['**/*.ts'], + }, + 'web/cypress/e2e.ts': CLEAN_CODE, + }).update('tsconfig.json', (config) => ({ ...config, include: ['web/src'] })) + const failed = (plan: ReadonlyArray) => + tscOnlyReport(`uncheck in ${project.path('web')}`, plan, 'failed') + const diagnostic = + "src/index.ts(1,14): error TS2322: Type 'string' is not assignable to type 'number'." + + const full = await project.uncheck(['--only=tsc'], { cwd: 'web' }) + + expect(full.exitCode).toBe(1) + expect(report(full.stdout)).toEqual( + failed(['▶ tsc -p ../tsconfig.json --noEmit', '▶ tsc -p cypress/tsconfig.json --noEmit']), + ) + expect(full.stdout).toContain(`\n${diagnostic}`) + + const given = await project.uncheck(['--only=tsc', 'src/index.ts'], { cwd: 'web' }) + + expect(given.exitCode).toBe(1) + expect(report(given.stdout)).toEqual(failed(['▶ tsc -p ../tsconfig.json --noEmit'])) + expect(given.stdout).toContain(`\n${diagnostic}`) + }) + + it.runIf(PERMISSIONS_ENFORCED)( + 'hands a tsconfig.json in a folder it cannot open to tsc, which reports it', + async () => { + const project = singleRepo({ 'locked/tsconfig.json': { include: ['.'] } }).chmod('locked', 0) + + const { exitCode, stdout } = await project.uncheck(['--only=tsc']) + + expect(exitCode).toBe(1) + expect(report(stdout)).toEqual( + tscOnlyReport( + `uncheck in ${project.dir}`, + ['▶ tsc -p locked/tsconfig.json --noEmit', '▶ tsc -p tsconfig.json --noEmit'], + 'failed', + ), + ) + expect(stdout).toContain( + `error TS5058: The specified path does not exist: '${project.path('locked/tsconfig.json')}'.`, + ) + }, + ) + it('typechecks a tsconfig.json with -p --noEmit, emitting nothing', async () => { const project = singleRepo({ 'tsconfig.json': { - compilerOptions: { strict: true, module: 'esnext', moduleResolution: 'bundler', types: [] }, + compilerOptions: compilerOptions(), include: ['src'], }, }) @@ -121,13 +279,9 @@ describe('tsc with the real compiler in a single repo', () => { const { exitCode, stdout } = await project.uncheck(['--only=tsc']) expect(exitCode).toBe(0) - expect(report(stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -p tsconfig.json --noEmit', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -p tsconfig.json --noEmit'], 'passed'), + ) expect(project.exists('src/index.js')).toBe(false) }) }) @@ -139,13 +293,9 @@ describe('tsc with the real compiler in a monorepo', () => { const passed = await project.uncheck(['--only=tsc']) expect(passed.exitCode).toBe(0) - expect(report(passed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✔ tsc passed', - '✔ all checks passed (tsc)', - ]) + expect(report(passed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'passed'), + ) expect(project.exists('packages/core/dist/index.d.ts')).toBe(true) project.write({ @@ -156,13 +306,9 @@ describe('tsc with the real compiler in a monorepo', () => { const failed = await project.uncheck(['--only=tsc', 'packages/core/src/index.ts']) expect(failed.exitCode).toBe(1) - expect(report(failed.stdout)).toEqual([ - `uncheck in ${project.dir}`, - ...SKIPPED_BESIDE_TSC, - '▶ tsc -b tsconfig.json', - '✘ tsc failed', - '✘ 1 of 1 checks failed: tsc', - ]) + expect(report(failed.stdout)).toEqual( + tscOnlyReport(`uncheck in ${project.dir}`, ['▶ tsc -b tsconfig.json'], 'failed'), + ) expect(failed.stdout).toContain( "packages/core/src/index.ts(2,3): error TS2322: Type 'string' is not assignable to type 'number'.", ) diff --git a/packages/uncheck/tests/uncheck/utils.ts b/packages/uncheck/tests/uncheck/utils.ts index 5580887..0075010 100644 --- a/packages/uncheck/tests/uncheck/utils.ts +++ b/packages/uncheck/tests/uncheck/utils.ts @@ -77,11 +77,31 @@ export const SKIPPED_BESIDE_TSC = [ '○ oxfmt skipped, not selected by --only', ] +export function tscOnlyReport( + heading: string, + plan: ReadonlyArray, + outcome: 'passed' | 'failed', +): string[] { + return [ + heading, + ...SKIPPED_BESIDE_TSC, + ...plan, + ...(outcome === 'passed' + ? ['✔ tsc passed', '✔ all checks passed (tsc)'] + : ['✘ tsc failed', '✘ 1 of 1 checks failed: tsc']), + ] +} + // oxlint-disable-next-line no-template-curly-in-string export const CONFIG_DIR = '${configDir}' export const ALLOW_JS = { compilerOptions: { allowJs: true } } +/** Without it, the members of a references graph are checked with `-p` instead of built with `-b`. */ +export const OUT_DIR = { compilerOptions: { outDir: 'dist' } } + +export const NO_EMIT = { compilerOptions: { noEmit: true } } + export function withFakeTsc( create: Layout['create'], files?: Files, diff --git a/packages/uncheck/tests/utils/project.test.ts b/packages/uncheck/tests/utils/project.test.ts new file mode 100644 index 0000000..8da4eca --- /dev/null +++ b/packages/uncheck/tests/utils/project.test.ts @@ -0,0 +1,29 @@ +import { chmodSync, mkdtempSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { Project, temporaryDirectory } from './project' + +describe('test projects', () => { + it('change nothing through a link to a folder outside their temporary folder', () => { + const outside = mkdtempSync(join(tmpdir(), 'uncheck-outside-')) + const manifest = join(outside, 'package.json') + + onTestFinished(() => rmSync(outside, { recursive: true, force: true })) + writeFileSync(manifest, '{}\n') + chmodSync(manifest, 0o644) + + const project = new Project(temporaryDirectory()).link('linked', outside) + + expect(() => project.write({ 'linked/dist/cli.js': '' })).toThrow(' is outside ') + expect(() => project.write({ 'linked/package.json': null })).toThrow(' is outside ') + expect(() => project.chmod('linked/package.json', 0o600)).toThrow(' is outside ') + expect(() => project.link('linked/bin/cli', 'cli.js')).toThrow(' is outside ') + + project.write({ linked: null }) + + expect(project.exists('linked')).toBe(false) + expect(readdirSync(outside)).toEqual(['package.json']) + expect(statSync(manifest).mode & 0o7777).toBe(0o644) + }) +}) diff --git a/packages/uncheck/tests/utils/project.ts b/packages/uncheck/tests/utils/project.ts index 28441b3..8d87c2b 100644 --- a/packages/uncheck/tests/utils/project.ts +++ b/packages/uncheck/tests/utils/project.ts @@ -57,7 +57,7 @@ const PACKAGE = fileURLToPath(new URL('../..', import.meta.url)) const REGISTER = fileURLToPath(new URL('register.ts', import.meta.url)) export const CLI = [process.execPath, '--import', REGISTER, join(PACKAGE, 'src/bin.ts')] as const -/** What the CLI looks for in every folder above a project, so one above the tests leaks into them all. */ +/** What the CLI and the tools it runs read above a project, so one above the tests leaks into them all. */ const PROJECT_MARKERS = [ 'package.json', 'node_modules', @@ -68,6 +68,17 @@ const PROJECT_MARKERS = [ 'bun.lock', 'bun.lockb', 'package-lock.json', + '.editorconfig', + '.oxfmtrc.json', + '.oxfmtrc.jsonc', + 'oxfmt.config.ts', + 'oxfmt.config.mts', + '.oxlintrc.json', + '.oxlintrc.jsonc', + 'oxlint.config.ts', + 'oxlint.config.mts', + '.eslintignore', + '.gitignore', ] for (let dir = realpathSync(tmpdir()); ; dir = dirname(dir)) { @@ -134,6 +145,19 @@ function inside(path: string): string { return path } +// Tools are linked from the shared pnpm store, which a change made through the link would corrupt. +function landsInside(path: string): string { + let existing = path + + while (!existsSync(existing)) { + existing = dirname(existing) + } + + inside(realpathSync(existing)) + + return path +} + export function temporaryDirectory(): string { return mkdtempSync(join(ROOT, 'tmp-')) } @@ -153,7 +177,11 @@ export function environment(overrides: Env = {}): NodeJS.ProcessEnv { TERM: 'xterm-256color', GIT_CEILING_DIRECTORIES: ROOT, GIT_CONFIG_GLOBAL: GIT_CONFIG, + // git still reads ~/.config/git/ignore and attributes when GIT_CONFIG_GLOBAL points elsewhere, and + // /etc/gitattributes under GIT_CONFIG_NOSYSTEM. + XDG_CONFIG_HOME: join(ROOT, 'config'), GIT_CONFIG_NOSYSTEM: '1', + GIT_ATTR_NOSYSTEM: '1', GIT_AUTHOR_NAME: 'uncheck', GIT_AUTHOR_EMAIL: 'uncheck@example.com', GIT_COMMITTER_NAME: 'uncheck', @@ -193,6 +221,8 @@ export function git(cwd: string, args: ReadonlyArray, env?: Env): string }) } +export const DUBIOUS_OWNERSHIP: Env = { GIT_TEST_ASSUME_DIFFERENT_OWNER: '1' } + export function run( command: ReadonlyArray, { cwd, input = '', env }: Omit & { readonly cwd: string }, @@ -226,10 +256,11 @@ export function runInTerminal( command: ReadonlyArray, { cwd, env, keys = [], waitFor }: Omit & { readonly cwd: string }, ): Promise { + // Without exec, the shell that script starts also gets Ctrl-C and exits 130 whatever the command does. const args = process.platform === 'darwin' ? ['-q', '/dev/null', ...command] - : ['-qfec', command.map(shellQuote).join(' '), '/dev/null'] + : ['-qfec', `exec ${command.map(shellQuote).join(' ')}`, '/dev/null'] return new Promise((resolve, reject) => { const child = spawn('script', args, { cwd: inside(cwd), env: environment(env) }) @@ -295,13 +326,13 @@ export class Project { const target = this.path(file) if (content === null) { + landsInside(dirname(target)) rmSync(target, { recursive: true, force: true }) continue } + landsInside(target) mkdirSync(dirname(target), { recursive: true }) - // Tools are linked from the shared pnpm store, which a write through the link would corrupt. - inside(realpathSync(existsSync(target) ? target : dirname(target))) writeFileSync(target, typeof content === 'string' ? content : json(content, file)) } @@ -322,7 +353,7 @@ export class Project { } chmod(file: string, mode: number): this { - chmodSync(this.path(file), mode) + chmodSync(landsInside(this.path(file)), mode) return this } @@ -337,7 +368,7 @@ export class Project { } link(file: string, target: string): this { - mkdirSync(dirname(this.path(file)), { recursive: true }) + mkdirSync(landsInside(dirname(this.path(file))), { recursive: true }) symlinkSync(target, this.path(file)) return this @@ -382,6 +413,18 @@ export class Project { } } +export function commitWithHooks(project: Project, ...args: ReadonlyArray): Promise { + return run(['git', 'commit', '--quiet', ...args], { cwd: project.dir }) +} + +export function commitOnSide(project: Project, files: Files): Project { + project.git('checkout', '--quiet', '-b', 'side') + project.write(files).commit('side') + project.git('checkout', '--quiet', 'main') + + return project +} + function install(project: Project, tools: ReadonlyArray): void { for (const tool of tools) { project.link(`node_modules/${tool}`, realpathSync(join(PACKAGE, 'node_modules', tool))) @@ -433,7 +476,7 @@ export function project(files: Files = {}, { tools = TOOLS, git = 'commit' }: Pr const OXLINT_CONFIG = { rules: { 'no-var': 'error' } } -function compilerOptions(extra: object = {}) { +export function compilerOptions(extra: object = {}) { return { strict: true, module: 'esnext', @@ -474,6 +517,9 @@ export function singleRepo(files: Files = {}, options?: ProjectOptions): Project ) } +export const UTILS_NOT_FOUND = + "src/index.ts(1,24): error TS2307: Cannot find module './utils' or its corresponding type declarations." + /** A pnpm workspace whose `app` package builds on its `core` package through project references. */ export function monorepo(files: Files = {}, options?: ProjectOptions): Project { const composite = compilerOptions({ @@ -568,3 +614,18 @@ export const LAYOUTS: ReadonlyArray = [ { name: 'single repo', create: singleRepo, app: '', tsc: '▶ tsc -p tsconfig.json --noEmit' }, { name: 'monorepo', create: monorepo, app: 'packages/app/', tsc: '▶ tsc -b tsconfig.json' }, ] + +export const FULL_OXLINT = '▶ oxlint --ignore-pattern=node_modules --no-error-on-unmatched-pattern' + +export const FULL_OXLINT_FIX = + '▶ oxlint --fix --ignore-pattern=node_modules --no-error-on-unmatched-pattern' + +export const FULL_OXFMT = '▶ oxfmt --check --no-error-on-unmatched-pattern' + +export const FULL_OXFMT_FIX = '▶ oxfmt --no-error-on-unmatched-pattern' + +export const SKIPPED_FOR_DELETIONS = [ + '○ sherif skipped, no package.json among the given files', + '○ oxlint skipped, only deleted files', + '○ oxfmt skipped, only deleted files', +]