From f131899086c0bd603ab553df144d5470765807b5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Sun, 20 Sep 2026 21:56:38 +0700 Subject: [PATCH 01/10] feat(uncheck): implement the CLI, agent hooks and dogfood it in the repo One `uncheck` command runs oxlint, oxfmt --check and tsc, each only when the project uses it, with --fix, per-tool --oxlint/--oxfmt/--tsc switches and paths forwarded to oxlint and oxfmt while tsc runs only the projects whose files/include/exclude take them. tsconfig references are built with tsc -b first, standalone projects are checked with tsc -p afterwards. `uncheck hooks` writes Claude Code, CodeBuddy, Cursor, Windsurf and Copilot hook configs, and `--hook` checks the files an agent just edited and hands remaining problems back to it. The repo now runs uncheck instead of eslint and is on TypeScript 7. --- .github/workflows/ci.yaml | 6 +- .github/workflows/pullfrog.yml | 3 +- .gitignore | 2 + .oxfmtrc.json | 7 + .oxlintrc.json | 12 + .zed/settings.json | 56 +- eslint.config.js | 22 - package.json | 36 +- packages/uncheck/README.md | 49 +- packages/uncheck/package.json | 74 +- packages/uncheck/src/bin.ts | 18 + packages/uncheck/src/command.test.ts | 509 ++++ packages/uncheck/src/command.ts | 376 +++ packages/uncheck/src/exec.ts | 45 + packages/uncheck/src/hooks.test.ts | 37 + packages/uncheck/src/hooks.ts | 259 ++ packages/uncheck/src/index.ts | 2 + packages/uncheck/src/options.ts | 25 + packages/uncheck/src/tools.ts | 54 + packages/uncheck/src/tsconfig.test.ts | 193 ++ packages/uncheck/src/tsconfig.ts | 393 +++ packages/uncheck/src/typecheck.test.ts | 113 + packages/uncheck/src/typecheck.ts | 273 ++ packages/uncheck/src/ui.ts | 43 + pnpm-lock.yaml | 3360 +++++------------------- scripts/sync-sponsors.ts | 27 +- tsconfig.json | 6 +- 27 files changed, 3159 insertions(+), 2841 deletions(-) create mode 100644 .oxfmtrc.json create mode 100644 .oxlintrc.json delete mode 100644 eslint.config.js create mode 100644 packages/uncheck/src/bin.ts create mode 100644 packages/uncheck/src/command.test.ts create mode 100644 packages/uncheck/src/command.ts create mode 100644 packages/uncheck/src/exec.ts create mode 100644 packages/uncheck/src/hooks.test.ts create mode 100644 packages/uncheck/src/hooks.ts create mode 100644 packages/uncheck/src/options.ts create mode 100644 packages/uncheck/src/tools.ts create mode 100644 packages/uncheck/src/tsconfig.test.ts create mode 100644 packages/uncheck/src/tsconfig.ts create mode 100644 packages/uncheck/src/typecheck.test.ts create mode 100644 packages/uncheck/src/typecheck.ts create mode 100644 packages/uncheck/src/ui.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5e5980b..70ab06c 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -10,7 +10,7 @@ permissions: contents: read jobs: - lint_and_typecheck: + check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 @@ -19,9 +19,7 @@ jobs: - run: pnpm i - - run: pnpm run lint - - - run: pnpm run type:check + - run: pnpm run check test_node_matrix: runs-on: ubuntu-latest diff --git a/.github/workflows/pullfrog.yml b/.github/workflows/pullfrog.yml index 68fbdca..c84c88c 100644 --- a/.github/workflows/pullfrog.yml +++ b/.github/workflows/pullfrog.yml @@ -34,8 +34,7 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} - GOOGLE_GENERATIVE_AI_API_KEY: - ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} + GOOGLE_GENERATIVE_AI_API_KEY: ${{ secrets.GOOGLE_GENERATIVE_AI_API_KEY }} GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }} DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }} diff --git a/.gitignore b/.gitignore index 4ede76d..e1b5334 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,8 @@ !.vscode/ !.github/ !.zed/ +!.oxfmtrc.json +!.oxlintrc.json # Common generated folders logs/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json new file mode 100644 index 0000000..809cad8 --- /dev/null +++ b/.oxfmtrc.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "semi": false, + "singleQuote": true, + "arrowParens": "avoid", + "printWidth": 120 +} diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..627b819 --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,12 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["typescript", "unicorn", "oxc", "import"], + "categories": { + "correctness": "error", + "suspicious": "warn" + }, + "rules": { + // `Array#toSorted` is ES2023, the repo targets ES2022. + "unicorn/no-array-sort": "off" + } +} diff --git a/.zed/settings.json b/.zed/settings.json index 73d48d2..3abbcbb 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -1,55 +1,13 @@ { "format_on_save": "on", - // Use ESLint's --fix: - "code_actions_on_format": { - "source.fixAll.eslint": true - }, - "formatter": [], - // Enable eslint for all supported languages - // Defaults only include https://github.com/search?q=repo%3Azed-industries%2Fzed%20eslint_languages&type=code - "languages": { - "HTML": { - "language_servers": ["...", "eslint"] - }, - "Markdown": { - "language_servers": ["...", "eslint"] - }, - "Markdown-Inline": { - "language_servers": ["...", "eslint"] - }, - "JSON": { - "language_servers": ["...", "eslint"] - }, - "JSONC": { - "language_servers": ["...", "eslint"] - }, - "YAML": { - "language_servers": ["...", "eslint"] - }, - "CSS": { - "language_servers": ["...", "eslint"] + "formatter": { + "external": { + "command": "./node_modules/.bin/oxfmt", + "arguments": ["--stdin-filepath", "{buffer_path}"] } - // Add other languages as needed }, - "lsp": { - "eslint": { - "settings": { - "workingDirectories": ["./"], - - // Silent the stylistic rules in your IDE, but still auto fix them - "rulesCustomizations": [ - { "rule": "style/*", "severity": "off", "fixable": true }, - { "rule": "format/*", "severity": "off", "fixable": true }, - { "rule": "*-indent", "severity": "off", "fixable": true }, - { "rule": "*-spacing", "severity": "off", "fixable": true }, - { "rule": "*-spaces", "severity": "off", "fixable": true }, - { "rule": "*-order", "severity": "off", "fixable": true }, - { "rule": "*-dangle", "severity": "off", "fixable": true }, - { "rule": "*-newline", "severity": "off", "fixable": true }, - { "rule": "*quotes", "severity": "off", "fixable": true }, - { "rule": "*semi", "severity": "off", "fixable": true } - ] - } - } + // Apply oxlint's auto fixes on save: + "code_actions_on_format": { + "source.fixAll.oxc": true } } diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index 34db9a0..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,22 +0,0 @@ -import antfu from '@antfu/eslint-config' - -export default antfu({ - formatters: true, - rules: { - 'yaml/sort-keys': 'off', - 'pnpm/json-enforce-catalog': 'off', - 'pnpm/yaml-enforce-settings': 'off', - 'ts/method-signature-style': 'off', - 'guard-for-in': 'error', - }, -}, { - files: ['**/*.test.ts', '**/*.test.tsx', '**/*.test-d.ts', '**/*.test-d.tsx', 'playgrounds/**', 'packages/*/playground/**'], - rules: { - 'unused-imports/no-unused-vars': 'off', - 'antfu/no-top-level-await': 'off', - 'no-alert': 'off', - 'ban/ban': 'off', - 'no-restricted-globals': 'off', - 'no-console': 'off', - }, -}) diff --git a/package.json b/package.json index 33ebb2a..2b75dd4 100644 --- a/package.json +++ b/package.json @@ -1,48 +1,48 @@ { "name": "@uncheck/repo", - "type": "module", "version": "0.0.1", "private": true, "license": "MIT", - "devEngines": { - "packageManager": { - "name": "pnpm", - "version": "12.5.1", - "onFail": "download" - } - }, + "type": "module", "scripts": { - "prepare": "simple-git-hooks", - "type:check": "pnpm -r run type:check && tsc", + "prepare": "simple-git-hooks && pnpm -r run build --stub", + "check": "uncheck", + "check:fix": "uncheck --fix", "test": "pnpm run -r test && vitest run", "test:coverage": "pnpm run -r test && vitest run --coverage", "bench": "vitest bench --run", - "lint": "eslint .", - "lint:fix": "eslint --fix .", "repo:fix": "sherif --fix", - "sponsors:sync": "node scripts/sync-sponsors.ts && eslint --fix **/README.md" + "sponsors:sync": "node scripts/sync-sponsors.ts && pnpm check:fix '**/README.md'" }, "devDependencies": { - "@antfu/eslint-config": "^9.5.1", "@codspeed/vitest-plugin": "^5.7.1", "@types/node": "^26.6.2", + "@typescript/typescript6": "^6.0.2", "@vitest/coverage-v8": "^5.0.1", "bumpp": "^12.3.0", "changelogithub": "^15.0.5", - "eslint": "^10.11.0", - "eslint-plugin-format": "^2.0.1", "lint-staged": "^17.5.1", + "oxfmt": "^0.68.0", + "oxlint": "^1.83.0", "pkg-pr-new": "^0.0.88", "sherif": "^1.13.0", "simple-git-hooks": "^2.14.0", - "typescript": "^6.0.3", + "typescript": "^7.0.2", "unbuild": "^3.6.1", + "uncheck": "workspace:*", "vitest": "^5.0.1" }, "simple-git-hooks": { "pre-commit": "pnpm lint-staged" }, "lint-staged": { - "*": "eslint --no-warn-ignored --fix" + "*": "uncheck --fix" + }, + "devEngines": { + "packageManager": { + "name": "pnpm", + "version": "12.5.1", + "onFail": "download" + } } } diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index a27c4f9..2d09c90 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -5,7 +5,7 @@ codecov - weekly downloads + weekly downloads CodSpeed @@ -23,6 +23,53 @@ `uncheck` is a single command that lints, formats, and type checks your project. It detects which tools the project already has — [`oxlint` and `oxfmt`](https://oxc.rs) for linting and formatting, `tsc` for types — and runs them together, so humans and coding agents have one command to remember instead of three. +## Usage + +```sh +npm i -D uncheck + +npx uncheck # oxlint, oxfmt --check and tsc for the whole project +npx uncheck --fix # oxlint --fix, then rewrite formatting with oxfmt +npx uncheck src/app # forward paths or globs to oxlint and oxfmt, like you would call them directly +npx uncheck --tsc=false # skip a step; --oxlint, --oxfmt and --tsc are auto-detected by default +npx uncheck --oxlint # require a step: fail when it cannot run instead of skipping it +``` + +Steps run in order and every step runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any step fails. + +| Step | Runs when | Command | +| -------- | ------------------------------------- | -------------------------------------------------------------- | +| `oxlint` | `oxlint` is installed | `oxlint [--fix] [paths...]` | +| `oxfmt` | `oxfmt` is installed | `oxfmt --check [paths...]`, or `oxfmt [paths...]` with `--fix` | +| `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p` for the rest | + +Tools are resolved from `node_modules` the way Node does, so the versions your project already depends on are used. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped. + +### Typecheck in monorepos + +Every `tsconfig.json` in the project is discovered (through `git ls-files`, so ignored folders are skipped) and its `references` are followed recursively to build the project graph: + +- Projects that use `references`, or are referenced, are built with `tsc -b` on the roots of that graph. `tsc` builds the referenced projects first, in dependency order, exactly like running `tsc -b` in each package. +- Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p`. +- Circular references are reported as an error. + +When paths are given, `tsc` runs only the projects it would actually check for them. A file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. A directory, or the static prefix of a glob, selects the projects whose inputs can live under it. Files `tsc` never checks, such as Markdown or CSS, select no project. + +## Agent hooks + +```sh +npx uncheck hooks # pick agents interactively +npx uncheck hooks claude cursor # or name them: claude, codebuddy, cursor, windsurf, copilot +``` + +This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json`, `.windsurf/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. After every file the agent edits, the hook runs: + +```sh +uncheck --fix --hook +``` + +through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). `--hook` reads the agent's payload from stdin, applies lint fixes and formatting to the edited files, typechecks the projects containing them, and prints the report on stderr. When problems remain, they are handed back to the agent as additional context so it can fix them right away (Claude Code and CodeBuddy), while the exit code stays 0 so no agent treats findings as a broken hook. + ## Sponsors Like what we build over at [middleapi](https://github.com/middleapi)? You can help keep it going through [GitHub Sponsors](https://github.com/sponsors/dinwwwh) or [Open Collective](https://opencollective.com/middleapi). Every bit helps! šŸš€ diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index e287111..e03710e 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -1,28 +1,38 @@ { "name": "uncheck", - "type": "module", "version": "0.0.1", - "description": "Internal types and utilities shared across the Standard Server ecosystem", - "license": "MIT", - "funding": [ - "https://github.com/sponsors/dinwwwh", - "https://opencollective.com/middleapi" + "description": "One command to lint, format check and typecheck a project with oxlint, oxfmt and tsc", + "keywords": [ + "oxc", + "oxfmt", + "oxlint", + "tsc", + "typecheck", + "uncheck", + "unified check command" ], + "license": "MIT", "repository": { "type": "git", "url": "git+https://github.com/middleapi/uncheck.git", "directory": "packages/uncheck" }, - "keywords": [ - "uncheck", - "unified check command", - "oxc", - "oxlint", - "oxfmt", - "tsc", - "typecheck" + "funding": [ + "https://github.com/sponsors/dinwwwh", + "https://opencollective.com/middleapi" ], + "bin": { + "uncheck": "./dist/bin.mjs" + }, + "files": [ + "dist" + ], + "type": "module", "sideEffects": false, + "exports": { + "./package.json": "./package.json", + ".": "./src/index.ts" + }, "publishConfig": { "exports": { "./package.json": "./package.json", @@ -33,15 +43,33 @@ } } }, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "files": [ - "dist" - ], "scripts": { - "build": "unbuild", - "type:check": "tsc -b" + "build": "unbuild" + }, + "dependencies": { + "@effect/platform-node": "^4.0.0-rc.116", + "effect": "^4.0.0-rc.116", + "jsonc-parser": "^3.3.1" + }, + "devDependencies": { + "oxfmt": "^0.68.0", + "oxlint": "^1.83.0", + "typescript": "^7.0.2" + }, + "peerDependencies": { + "oxfmt": "*", + "oxlint": "*", + "typescript": "*" + }, + "peerDependenciesMeta": { + "oxfmt": { + "optional": true + }, + "oxlint": { + "optional": true + }, + "typescript": { + "optional": true + } } } diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts new file mode 100644 index 0000000..69ddc9b --- /dev/null +++ b/packages/uncheck/src/bin.ts @@ -0,0 +1,18 @@ +#!/usr/bin/env node +import process from 'node:process' +import { NodeRuntime, NodeServices } from '@effect/platform-node' +import { Effect } from 'effect' +import { Command } from 'effect/unstable/cli' +import pkg from '../package.json' +import { command } from './command' + +Command.run(command, { version: pkg.version }).pipe( + // The summary is already printed, only the exit code is left to report. + Effect.catchTag('CheckFailed', () => + Effect.sync(() => { + process.exitCode = 1 + }), + ), + Effect.provide(NodeServices.layer), + NodeRuntime.runMain, +) diff --git a/packages/uncheck/src/command.test.ts b/packages/uncheck/src/command.test.ts new file mode 100644 index 0000000..b48fff1 --- /dev/null +++ b/packages/uncheck/src/command.test.ts @@ -0,0 +1,509 @@ +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { stripVTControlCharacters } from 'node:util' +import { NodeServices } from '@effect/platform-node' +import { Effect, Layer, Sink, Stdio, Stream, Terminal } from 'effect' +import { Command } from 'effect/unstable/cli' +import { CheckFailed, command, UncheckOptions } from './index' + +const TOOLS = ['oxlint', 'oxfmt', 'typescript'] as const + +const packageNodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) +const fixtures: string[] = [] + +afterAll(() => { + for (const dir of fixtures) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** + * Creates a throwaway project in the OS temp dir. The requested tools are symlinked into its + * `node_modules` so they resolve exactly like a regular install would. Config files are kept out + * of the format step so tests only see the formatting issues they put in. + */ +function fixture(files: Record, tools: ReadonlyArray<(typeof TOOLS)[number]> = TOOLS): string { + const dir = mkdtempSync(join(tmpdir(), 'uncheck-')) + fixtures.push(dir) + + if (tools.length > 0) { + mkdirSync(join(dir, 'node_modules')) + + for (const tool of tools) { + symlinkSync(realpathSync(join(packageNodeModules, tool)), join(dir, 'node_modules', tool)) + } + } + + const defaults = { + '.gitignore': 'node_modules\ndist\n', + '.prettierignore': 'tsconfig.json\n.oxlintrc.json\n', + } + + for (const [relative, content] of Object.entries({ ...defaults, ...files })) { + mkdirSync(dirname(join(dir, relative)), { recursive: true }) + writeFileSync(join(dir, relative), content) + } + + return dir +} + +interface RunResult { + readonly result: 'ok' | CheckFailed + /** Everything written through the terminal, without ANSI styling. */ + readonly output: string + readonly stdout: string + readonly stderr: string +} + +async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): Promise { + const output: string[] = [] + const stdout: string[] = [] + const stderr: string[] = [] + const decoder = new TextDecoder() + const collect = (into: string[]) => () => + Sink.forEach((chunk: string | Uint8Array) => + Effect.sync(() => { + into.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk)) + }), + ) + + const terminal = Terminal.make({ + columns: Effect.succeed(80), + rows: Effect.succeed(24), + readInput: Effect.die('unused'), + readLine: Effect.die('unused'), + display: text => + Effect.sync(() => { + output.push(text) + }), + }) + + const result = await Effect.runPromise( + Command.runWith(command, { version: '0.0.0' })(args).pipe( + Effect.map(() => 'ok' as const), + Effect.catchTag('CheckFailed', error => Effect.succeed(error)), + Effect.provideService(UncheckOptions, { cwd, stdio: 'pipe' }), + // Inner layers take precedence, so these replace the Node terminal and stdio. + Effect.provide(Layer.succeed(Terminal.Terminal, terminal)), + Effect.provide( + Stdio.layerTest({ + stdin: Stream.make(new TextEncoder().encode(stdin)), + stdout: collect(stdout), + stderr: collect(stderr), + }), + ), + Effect.provide(NodeServices.layer), + ), + ) + + return { + result, + output: stripVTControlCharacters(output.join('')), + stdout: stdout.join(''), + stderr: stderr.join(''), + } +} + +const oxlintrc = JSON.stringify({ rules: { 'no-var': 'error' } }) + +const standaloneTsconfig = JSON.stringify({ + compilerOptions: { noEmit: true, strict: true, module: 'esnext', moduleResolution: 'bundler', types: [] }, + include: ['src', 'scripts'], +}) + +function compositeTsconfig(references: string[] = []) { + return JSON.stringify({ + compilerOptions: { + composite: true, + declaration: true, + emitDeclarationOnly: true, + outDir: 'dist', + rootDir: 'src', + strict: true, + module: 'esnext', + moduleResolution: 'bundler', + types: [], + }, + references: references.map(path => ({ path })), + include: ['src'], + }) +} + +describe('uncheck', { timeout: 120_000 }, () => { + it('runs lint, format and typecheck and passes on a clean project', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + }) + + const { result, output } = await run(dir) + + expect(result).toBe('ok') + expect(output).toContain('ā–¶ oxlint\n') + expect(output).toContain('ā–¶ oxfmt --check\n') + expect(output).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(output).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') + }) + + it('reports lint and format failures, then fixes them with --fix', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/legacy.ts': 'var count = 1;\nexport { count };\n', + 'src/ugly.ts': 'export const ugly = {a:1,\n b:2}\n', + }) + + const check = await run(dir) + + expect(check.result).toBeInstanceOf(CheckFailed) + expect((check.result as CheckFailed).outcomes).toEqual([ + { name: 'oxlint', status: 'failed' }, + { name: 'oxfmt', status: 'failed' }, + { name: 'tsc', status: 'passed' }, + ]) + expect(check.output).toContain('no-var') + expect(check.output).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') + expect(check.output).toContain('run `uncheck --fix` to apply oxlint and oxfmt fixes') + + const fix = await run(dir, ['--fix']) + + expect(fix.result).toBe('ok') + expect(fix.output).toContain('ā–¶ oxlint --fix\n') + expect(fix.output).toContain('ā–¶ oxfmt\n') + expect(readFileSync(join(dir, 'src/legacy.ts'), 'utf8')).toBe('const count = 1;\nexport { count };\n') + expect(readFileSync(join(dir, 'src/ugly.ts'), 'utf8')).toBe('export const ugly = { a: 1, b: 2 };\n') + + expect((await run(dir)).result).toBe('ok') + }) + + it('builds referenced projects with tsc -b before checking standalone ones with tsc -p', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'scripts/hello.ts': 'export const greeting = "hi";\n', + 'packages/lib/tsconfig.json': compositeTsconfig(), + 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', + 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), + 'packages/app/src/index.ts': + 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', + }) + + const { result, output } = await run(dir) + + expect(result).toBe('ok') + expect(output).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nā–¶ tsc -p tsconfig.json\n') + expect(output).not.toContain('packages/lib/tsconfig.json') + + writeFileSync(join(dir, 'packages/lib/src/index.ts'), 'export const answer: number = "42";\n') + + const broken = await run(dir) + + expect(broken.result).toBeInstanceOf(CheckFailed) + expect((broken.result as CheckFailed).outcomes).toEqual([ + { name: 'oxlint', status: 'passed' }, + { name: 'oxfmt', status: 'passed' }, + { name: 'tsc', status: 'failed' }, + ]) + expect(broken.output).toContain('packages/lib/src/index.ts') + expect(broken.output).toContain('TS2322') + }) + + it('forwards paths to oxlint and oxfmt and narrows typecheck to the projects containing them', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'scripts/hello.ts': 'export const greeting = "hi";\n', + 'scripts/legacy.ts': 'var count = 1;\nexport { count };\n', + 'README.md': '# Hi\n', + 'packages/lib/tsconfig.json': compositeTsconfig(), + 'packages/lib/src/index.ts': 'export const answer: number = 42;\n', + 'packages/app/tsconfig.json': compositeTsconfig(['../lib']), + 'packages/app/src/index.ts': + 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', + }) + + const scoped = await run(dir, ['packages/app']) + + expect(scoped.result).toBe('ok') + expect(scoped.output).not.toContain('no-var') + expect(scoped.output).toContain('ā–¶ oxlint --no-error-on-unmatched-pattern packages/app\n') + expect(scoped.output).toContain('ā–¶ oxfmt --check --no-error-on-unmatched-pattern packages/app\n') + // The root project's inputs (src, scripts) do not reach into packages/app, so only app is built. + expect(scoped.output).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nāœ” tsc') + expect(scoped.output).not.toContain('tsc -p tsconfig.json') + + const single = await run(dir, ['--fix', 'scripts/hello.ts']) + + expect(single.result).toBe('ok') + expect(single.output).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n') + expect(single.output).toContain('ā–¶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') + expect(single.output).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(single.output).not.toContain('tsc -b') + + const docs = await run(dir, ['README.md']) + + expect(docs.result).toBe('ok') + expect(docs.output).toContain('ā—‹ tsc skipped, no tsconfig.json covers the given paths\n') + expect(docs.output).toContain('āœ” all checks passed (oxlint, oxfmt)') + }) + + it('discovers tsconfig files through git so ignored folders are skipped', async () => { + const files = { + '.gitignore': 'node_modules\ndist\nignored\n', + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + 'ignored/tsconfig.json': standaloneTsconfig, + 'ignored/src/index.ts': 'export const answer: number = "42";\n', + } + + const plain = fixture(files, ['typescript']) + const walked = await run(plain) + + expect(walked.result).toBeInstanceOf(CheckFailed) + expect(walked.output).toContain('ā–¶ tsc -p ignored/tsconfig.json\n') + + const repo = fixture(files, ['typescript']) + execFileSync('git', ['init', '--quiet'], { cwd: repo }) + const tracked = await run(repo) + + expect(tracked.result).toBe('ok') + expect(tracked.output).not.toContain('ignored/tsconfig.json') + expect(tracked.output).toContain('ā—‹ oxlint skipped, not installed\n') + expect(tracked.output).toContain('ā—‹ oxfmt skipped, not installed\n') + }) + + it('fails on circular project references', async () => { + const dir = fixture( + { + 'packages/a/tsconfig.json': compositeTsconfig(['../b']), + 'packages/a/src/index.ts': 'export const a = 1;\n', + 'packages/b/tsconfig.json': compositeTsconfig(['../a']), + 'packages/b/src/index.ts': 'export const b = 2;\n', + }, + ['typescript'], + ) + + const { result, output } = await run(dir) + + expect(result).toBeInstanceOf(CheckFailed) + expect(output).toContain( + '✘ tsc circular project references between packages/a/tsconfig.json, packages/b/tsconfig.json\n', + ) + }) + + it('fails when a tsconfig.json exists but typescript is not installed', async () => { + const dir = fixture( + { + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer = 42;\n', + }, + [], + ) + + const { result, output } = await run(dir) + + expect(result).toBeInstanceOf(CheckFailed) + expect((result as CheckFailed).outcomes.map(outcome => outcome.status)).toEqual(['skipped', 'skipped', 'failed']) + expect(output).toContain('found 1 tsconfig.json but typescript is not installed') + }) + + it('fails when there is nothing to check', async () => { + const dir = fixture({ 'package.json': '{}\n' }, []) + + const { result, output } = await run(dir) + + expect(result).toBeInstanceOf(CheckFailed) + expect((result as CheckFailed).outcomes.map(outcome => outcome.status)).toEqual(['skipped', 'skipped', 'skipped']) + expect(output).toContain('nothing to check: oxlint not installed, oxfmt not installed, tsc no tsconfig.json found') + }) +}) + +describe('uncheck hooks', { timeout: 120_000 }, () => { + it('writes hook configs for the named agents through the detected package manager', async () => { + const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) + + const { result, output } = await run(dir, ['hooks', 'claude', 'cursor', 'windsurf', 'copilot']) + + expect(result).toBe('ok') + expect(output).toContain('āœ” Claude Code .claude/settings.json created\n') + expect(output).toContain('āœ” Cursor .cursor/hooks.json created\n') + expect(output).toContain('pnpm exec uncheck --fix --hook') + + expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ + hooks: { + PostToolUse: [ + { + matcher: 'Edit|Write|MultiEdit|NotebookEdit', + hooks: [{ type: 'command', command: 'pnpm exec uncheck --fix --hook' }], + }, + ], + }, + }) + expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({ + version: 1, + hooks: { afterFileEdit: [{ command: 'pnpm exec uncheck --fix --hook' }] }, + }) + expect(JSON.parse(readFileSync(join(dir, '.windsurf/hooks.json'), 'utf8'))).toEqual({ + hooks: { post_write_code: [{ command: 'pnpm exec uncheck --fix --hook', show_output: true }] }, + }) + expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ + version: 1, + hooks: { + postToolUse: [ + { type: 'command', bash: 'pnpm exec uncheck --fix --hook', powershell: 'pnpm exec uncheck --fix --hook' }, + ], + }, + }) + + const again = await run(dir, ['hooks', 'claude']) + + expect(again.result).toBe('ok') + expect(again.output).toContain('āœ” Claude Code .claude/settings.json unchanged\n') + }) + + it('merges into existing settings and keeps other hooks', async () => { + const dir = fixture( + { + 'package.json': '{}\n', + '.claude/settings.json': `{ + // keep me + "permissions": { "allow": ["Bash(pnpm test)"] }, + "hooks": { "PostToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "echo done" }] }] }, +} +`, + }, + [], + ) + + const { result, output } = await run(dir, ['hooks', 'codebuddy', 'claude']) + + expect(result).toBe('ok') + expect(output).toContain('āœ” Claude Code .claude/settings.json updated\n') + expect(output).toContain('āœ” CodeBuddy .codebuddy/settings.json created\n') + expect(output).toContain('npx uncheck --fix --hook') + + expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ + permissions: { allow: ['Bash(pnpm test)'] }, + hooks: { + PostToolUse: [ + { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }, + { + matcher: 'Edit|Write|MultiEdit|NotebookEdit', + hooks: [{ type: 'command', command: 'npx uncheck --fix --hook' }], + }, + ], + }, + }) + }) + + it('rejects unknown agents', async () => { + const dir = fixture({ 'package.json': '{}\n' }, []) + + await expect(run(dir, ['hooks', 'emacs'])).rejects.toThrow() + }) +}) + +function claudePayload(filePath: string) { + return JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: filePath } }) +} + +describe('uncheck --hook', { timeout: 120_000 }, () => { + it('fixes the edited file and hands remaining problems back to the agent', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: string = 1\n', + 'src/other.ts': 'export const other = 2\n', + }) + + const { result, output, stdout, stderr } = await run( + dir, + ['--fix', '--hook'], + claudePayload(join(dir, 'src/index.ts')), + ) + + expect(result).toBe('ok') + expect(output).toBe('') + expect(stderr).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') + expect(stderr).toContain('ā–¶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') + expect(stderr).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(stderr).toContain('TS2322') + expect(stderr).toContain('✘ 1 of 3 checks failed: tsc') + + const hookOutput = JSON.parse(stdout) as { additionalContext: string; hookSpecificOutput: Record } + + expect(hookOutput.additionalContext).toContain('uncheck found problems in src/index.ts') + expect(hookOutput.additionalContext).toContain('TS2322') + expect(hookOutput.hookSpecificOutput).toEqual({ + hookEventName: 'PostToolUse', + additionalContext: hookOutput.additionalContext, + }) + + expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe('export const answer: string = 1;\n') + expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 2\n') + }) + + it('stays silent when everything passes and when the payload names no project file', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + }) + + const clean = await run(dir, ['--fix', '--hook'], JSON.stringify({ file_path: join(dir, 'src/index.ts') })) + + expect(clean.result).toBe('ok') + expect(clean.stdout).toBe('') + expect(clean.stderr).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') + + for (const payload of [ + claudePayload('/etc/hosts'), + claudePayload(join(dir, 'src/missing.ts')), + JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } }), + 'not json', + ]) { + const ignored = await run(dir, ['--fix', '--hook'], payload) + + expect(ignored.result).toBe('ok') + expect(ignored.stdout).toBe('') + expect(ignored.stderr).toBe('') + } + }) +}) + +describe('uncheck step flags', { timeout: 120_000 }, () => { + it('skips a step with --=false and requires it with --', async () => { + const dir = fixture( + { + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: string = 1;\n', + }, + ['oxlint', 'typescript'], + ) + + const skipped = await run(dir, ['--tsc=false']) + + expect(skipped.result).toBe('ok') + expect(skipped.output).toContain('ā—‹ oxfmt skipped, not installed\n') + expect(skipped.output).toContain('ā—‹ tsc skipped, disabled with --tsc=false\n') + expect(skipped.output).toContain('āœ” all checks passed (oxlint)') + + const required = await run(dir, ['--oxfmt', '--tsc=false']) + + expect(required.result).toBeInstanceOf(CheckFailed) + expect(required.output).toContain('✘ oxfmt not installed\n') + expect(required.output).toContain('✘ 1 of 2 checks failed: oxfmt') + + const none = await run(dir, ['--no-oxlint', '--no-oxfmt', '--no-tsc']) + + expect(none.result).toBeInstanceOf(CheckFailed) + expect(none.output).toContain( + 'nothing to check: oxlint disabled with --oxlint=false, oxfmt disabled with --oxfmt=false, tsc disabled with --tsc=false', + ) + }) +}) diff --git a/packages/uncheck/src/command.ts b/packages/uncheck/src/command.ts new file mode 100644 index 0000000..0bafb43 --- /dev/null +++ b/packages/uncheck/src/command.ts @@ -0,0 +1,376 @@ +import type { PlatformError } from 'effect' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import type { Tool } from './tools' +import type { Ui } from './ui' +import { stripVTControlCharacters } from 'node:util' +import { Data, Duration, Effect, FileSystem, Option, Path, Stdio, Stream, Terminal } from 'effect' +import { Argument, CliError, Command, Flag } from 'effect/unstable/cli' +import { execTool } from './exec' +import { extractHookPaths, hooksCommand } from './hooks' +import { UncheckOptions } from './options' +import { resolveTool } from './tools' +import { discoverTsconfigs, isWithin, loadTsProjects, planTypecheck, selectTsconfigs } from './typecheck' +import { makeUi } from './ui' + +export type StepName = 'oxlint' | 'oxfmt' | 'tsc' + +export interface StepOutcome { + readonly name: StepName + readonly status: 'passed' | 'failed' | 'skipped' + /** Why the step was skipped, or why it failed before any tool ran. */ + readonly reason?: string +} + +export class CheckFailed extends Data.TaggedError('CheckFailed')<{ + readonly outcomes: ReadonlyArray +}> {} + +/** + * Per-step switch from the `--oxlint`, `--oxfmt` and `--tsc` flags: unset auto-detects, + * `false` skips the step, `true` requires it and fails when it cannot run. + */ +type StepFlags = Readonly>> + +interface ToolCommand { + readonly tool: Tool + readonly args: ReadonlyArray +} + +interface RunPlan { + readonly name: StepName + readonly status: 'run' + readonly commands: ReadonlyArray +} + +/** A step that is either ready to run, or already settled without running anything. */ +type StepPlan = RunPlan | (StepOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) + +interface ToolStep { + readonly name: 'oxlint' | 'oxfmt' + readonly args: (fix: boolean) => ReadonlyArray +} + +/** The steps that simply run their tool, in run order. Fixes apply to exactly these. */ +const TOOL_STEPS: ReadonlyArray = [ + { name: 'oxlint', args: fix => (fix ? ['--fix'] : []) }, + { name: 'oxfmt', args: fix => (fix ? [] : ['--check']) }, +] + +/** Explicit paths may name files a tool does not handle (a Markdown file for oxlint), which is not a failure. */ +const UNMATCHED_PATHS_OK = '--no-error-on-unmatched-pattern' + +function stepFlag(name: StepName, what: string, when: string) { + return Flag.Boolean(name).pipe( + Flag.optional, + Flag.withDescription(`${what}. On by default when ${when}; --${name}=false skips it, --${name} requires it`), + ) +} + +export const command = Command.make( + 'uncheck', + { + fix: Flag.Boolean('fix').pipe( + Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), + Flag.withDefault(false), + ), + hook: Flag.Boolean('hook').pipe( + Flag.withDescription( + 'Run as an agent hook: check the files named in the JSON payload on stdin, report on stderr and hand remaining problems back to the agent. Set up with `uncheck hooks`.', + ), + Flag.withDefault(false), + ), + oxlint: stepFlag('oxlint', 'Lint with oxlint', 'oxlint is installed'), + oxfmt: stepFlag('oxfmt', 'Check formatting with oxfmt', 'oxfmt is installed'), + tsc: stepFlag('tsc', 'Typecheck with tsc', 'a tsconfig.json is found'), + paths: Argument.String('paths').pipe( + Argument.variadic(), + Argument.withDescription( + 'Files, directories or glob patterns, forwarded to oxlint and oxfmt as-is. tsc runs only the projects that take them as input. Defaults to the whole project.', + ), + ), + }, + ({ fix, hook, oxlint, oxfmt, tsc, paths }) => + Effect.gen(function* () { + const flags: StepFlags = { oxlint, oxfmt, tsc } + + if (hook) { + return yield* runHook(fix, flags) + } + + yield* runChecks(fix, paths, flags) + }), +).pipe( + Command.withDescription( + 'Lint (oxlint), format check (oxfmt) and typecheck (tsc) a project with one command. Each step runs only when the project uses that tool.', + ), + Command.withSubcommands([hooksCommand]), +) + +function runChecks(fix: boolean, paths: ReadonlyArray, flags: StepFlags) { + return Effect.gen(function* () { + const options = yield* UncheckOptions + const ui = yield* makeUi + const cwd = options.cwd + + const [toolPlans, typescript, tsconfigs] = yield* Effect.all( + [ + Effect.forEach( + TOOL_STEPS, + step => + Effect.map(resolveTool(step.name, cwd), tool => planToolStep(step, tool, flags[step.name], fix, paths)), + { concurrency: 'unbounded' }, + ), + resolveTool('typescript', cwd, 'tsc'), + discoverTsconfigs(cwd), + ], + { concurrency: 'unbounded' }, + ) + + const plans = [...toolPlans, yield* planTscStep(typescript, tsconfigs, flags.tsc, paths, cwd)] + const outcomes = yield* Effect.forEach(plans, plan => runStep(ui, options, plan)) + + const ran = outcomes.filter(outcome => outcome.status !== 'skipped') + const failed = outcomes.filter(outcome => outcome.status === 'failed') + + yield* ui.line('') + + if (ran.length === 0) { + const reasons = outcomes.map(outcome => `${outcome.name} ${outcome.reason}`).join(', ') + + yield* ui.line(`${ui.red('✘')} nothing to check: ${reasons}`) + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + if (failed.length > 0) { + yield* ui.line( + `${ui.red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map(outcome => outcome.name).join(', ')}`, + ) + + if (!fix && failed.some(outcome => TOOL_STEPS.some(step => step.name === outcome.name))) { + yield* ui.line(ui.dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) + } + + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + yield* ui.line(`${ui.green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) + }) +} + +/** + * Agent hook mode. The payload on stdin names the files the agent just edited; only those inside + * the project are checked. Output is captured and mirrored to stderr, and when problems remain + * they are returned on stdout as `additionalContext`, which Claude Code and CodeBuddy show to + * the agent while every other agent ignores it. The exit code stays 0 so no agent treats a + * finding as a hook failure. + */ +function runHook(fix: boolean, flags: StepFlags) { + return Effect.gen(function* () { + const options = yield* UncheckOptions + const stdio = yield* Stdio.Stdio + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const terminal = yield* Terminal.Terminal + + if (yield* stdio.stdinIsTerminal) { + const userMessage = '--hook expects the agent hook payload as JSON on stdin' + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + const cwd = path.resolve(options.cwd) + + const payload = yield* Stream.mkString(Stream.decodeText(stdio.stdin)).pipe( + Effect.flatMap(text => Effect.try(() => JSON.parse(text) as unknown)), + Effect.orElseSucceed(() => undefined), + ) + + const files = yield* Effect.filter( + extractHookPaths(payload) + .map(file => path.resolve(cwd, file)) + .filter(file => isWithin(path, file, cwd)), + file => fs.exists(file).pipe(Effect.orElseSucceed(() => false)), + ) + + if (files.length === 0) { + return + } + + const chunks: string[] = [] + + const capture = Terminal.make({ + columns: terminal.columns, + rows: terminal.rows, + readInput: terminal.readInput, + readLine: terminal.readLine, + display: text => + Effect.sync(() => { + chunks.push(text) + }), + }) + + const relativeFiles = files.map(file => path.relative(cwd, file)) + + const failed = yield* runChecks(fix, relativeFiles, flags).pipe( + Effect.map(() => false), + Effect.catchTag('CheckFailed', () => Effect.succeed(true)), + Effect.provideService(Terminal.Terminal, capture), + Effect.provideService(UncheckOptions, { ...options, stdio: 'pipe' }), + ) + + const report = stripVTControlCharacters(chunks.join('')) + + yield* Stream.make(report).pipe(Stream.run(stdio.stderr())) + + if (failed) { + const additionalContext = `uncheck found problems in ${relativeFiles.join(', ')}, fix them before moving on:\n\n${report}` + const output = { additionalContext, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext } } + + yield* Stream.make(`${JSON.stringify(output)}\n`).pipe(Stream.run(stdio.stdout())) + } + }) +} + +function disabled(name: StepName, enabled: Option.Option): StepPlan | undefined { + return Option.isSome(enabled) && !enabled.value + ? { name, status: 'skipped', reason: `disabled with --${name}=false` } + : undefined +} + +function planToolStep( + step: ToolStep, + tool: Option.Option, + enabled: Option.Option, + fix: boolean, + paths: ReadonlyArray, +): StepPlan { + const off = disabled(step.name, enabled) + + if (off !== undefined) { + return off + } + + if (Option.isNone(tool)) { + return { name: step.name, status: Option.isSome(enabled) ? 'failed' : 'skipped', reason: 'not installed' } + } + + return { + name: step.name, + status: 'run', + commands: [ + { tool: tool.value, args: [...step.args(fix), ...(paths.length > 0 ? [UNMATCHED_PATHS_OK] : []), ...paths] }, + ], + } +} + +function planTscStep( + typescript: Option.Option, + tsconfigs: ReadonlyArray, + enabled: Option.Option, + paths: ReadonlyArray, + cwd: string, +): Effect.Effect { + return Effect.gen(function* () { + const off = disabled('tsc', enabled) + + if (off !== undefined) { + return off + } + + const path = yield* Path.Path + const nothingToCheck = (reason: string): StepPlan => ({ + name: 'tsc', + status: Option.isSome(enabled) ? 'failed' : 'skipped', + reason, + }) + + if (tsconfigs.length === 0) { + return nothingToCheck('no tsconfig.json found') + } + + const selected = yield* selectTsconfigs(tsconfigs, paths, cwd) + + if (selected.length === 0) { + return nothingToCheck('no tsconfig.json covers the given paths') + } + + if (Option.isNone(typescript)) { + return { + name: 'tsc', + status: 'failed', + reason: `found ${selected.length} tsconfig.json but typescript is not installed`, + } + } + + const tool = typescript.value + const relative = (configPath: string) => path.relative(cwd, configPath) || '.' + + const projects = yield* loadTsProjects(selected) + + return yield* planTypecheck(selected, projects).pipe( + Effect.map((plan): StepPlan => { + const commands: ToolCommand[] = [] + + if (plan.build.length > 0) { + commands.push({ tool, args: ['-b', ...plan.build.map(relative)] }) + } + + for (const configPath of plan.check) { + commands.push({ tool, args: ['-p', relative(configPath)] }) + } + + return { name: 'tsc', status: 'run', commands } + }), + Effect.catchTag('CircularProjectReferences', error => + Effect.succeed({ + name: 'tsc', + status: 'failed', + reason: `circular project references between ${error.projects.map(relative).join(', ')}`, + }), + ), + ) + }) +} + +function runStep( + ui: Ui, + options: UncheckOptions, + plan: StepPlan, +): Effect.Effect< + StepOutcome, + PlatformError.PlatformError, + ChildProcessSpawner.ChildProcessSpawner | Terminal.Terminal +> { + return Effect.gen(function* () { + if (plan.status !== 'run') { + yield* ui.line( + plan.status === 'skipped' + ? `${ui.dim('ā—‹')} ${ui.bold(plan.name)} ${ui.dim(`skipped, ${plan.reason}`)}` + : `${ui.red('✘')} ${ui.bold(plan.name)} ${ui.red(plan.reason)}`, + ) + return plan + } + + const [duration, exitCodes] = yield* Effect.timed( + Effect.forEach(plan.commands, ({ tool, args }) => + ui + .line(`${ui.dim('ā–¶')} ${ui.bold(tool.name)} ${ui.dim(args.join(' '))}`.trimEnd()) + .pipe(Effect.flatMap(() => execTool(tool, args, options))), + ), + ) + + const failed = exitCodes.some(exitCode => exitCode !== 0) + const elapsed = ui.dim(formatDuration(Duration.toMillis(duration))) + + yield* ui.line( + failed + ? `${ui.red('✘')} ${ui.bold(plan.name)} ${ui.red('failed')} ${elapsed}` + : `${ui.green('āœ”')} ${ui.bold(plan.name)} ${ui.green('passed')} ${elapsed}`, + ) + + return { name: plan.name, status: failed ? 'failed' : 'passed' } satisfies StepOutcome + }) +} + +function formatDuration(ms: number): string { + return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s` +} diff --git a/packages/uncheck/src/exec.ts b/packages/uncheck/src/exec.ts new file mode 100644 index 0000000..abcb0ae --- /dev/null +++ b/packages/uncheck/src/exec.ts @@ -0,0 +1,45 @@ +import type { PlatformError } from 'effect' +import type { UncheckOptions } from './options' +import type { Tool } from './tools' +import process from 'node:process' +import { Effect, Stream, Terminal } from 'effect' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' + +/** + * Runs `tool` with `args` and resolves with its exit code. + * A non-zero exit code is a normal result, not a failure. + */ +export function execTool( + tool: Tool, + args: ReadonlyArray, + options: UncheckOptions, +): Effect.Effect { + const inherit = options.stdio === 'inherit' + + const command = ChildProcess.make(process.execPath, [tool.bin, ...args], { + cwd: options.cwd, + stdin: 'ignore', + stdout: inherit ? 'inherit' : 'pipe', + stderr: inherit ? 'inherit' : 'pipe', + }) + + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + if (inherit) { + return yield* spawner.exitCode(command) + } + + const terminal = yield* Terminal.Terminal + + return yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn(command) + + yield* Stream.runForEach(Stream.decodeText(handle.all), chunk => terminal.display(chunk)) + + return yield* handle.exitCode + }), + ) + }) +} diff --git a/packages/uncheck/src/hooks.test.ts b/packages/uncheck/src/hooks.test.ts new file mode 100644 index 0000000..816aa12 --- /dev/null +++ b/packages/uncheck/src/hooks.test.ts @@ -0,0 +1,37 @@ +import { extractHookPaths } from './hooks' + +describe('extractHookPaths', () => { + it('finds the edited file in every agent payload shape', () => { + expect( + extractHookPaths({ + hook_event_name: 'PostToolUse', + transcript_path: '/tmp/transcript.jsonl', + tool_name: 'Edit', + tool_input: { file_path: '/repo/src/a.ts', old_string: 'x', new_string: 'y' }, + tool_response: { filePath: '/repo/src/a.ts', success: true }, + }), + ).toEqual(['/repo/src/a.ts']) + + expect(extractHookPaths({ tool_name: 'NotebookEdit', tool_input: { notebook_path: '/repo/nb.ipynb' } })).toEqual([ + '/repo/nb.ipynb', + ]) + + expect( + extractHookPaths({ hook_event_name: 'afterFileEdit', file_path: '/repo/src/b.ts', workspace_roots: ['/repo'] }), + ).toEqual(['/repo/src/b.ts']) + + expect( + extractHookPaths({ agent_action_name: 'post_write_code', tool_info: { file_path: '/repo/src/c.py', edits: [] } }), + ).toEqual(['/repo/src/c.py']) + + expect(extractHookPaths({ toolName: 'edit', toolArgs: { path: 'src/d.ts', command: 'str_replace' } })).toEqual([ + 'src/d.ts', + ]) + }) + + it('returns nothing for payloads without file paths', () => { + expect(extractHookPaths({ tool_name: 'Bash', tool_input: { command: 'ls' } })).toEqual([]) + expect(extractHookPaths(undefined)).toEqual([]) + expect(extractHookPaths('not json')).toEqual([]) + }) +}) diff --git a/packages/uncheck/src/hooks.ts b/packages/uncheck/src/hooks.ts new file mode 100644 index 0000000..8e6bb2a --- /dev/null +++ b/packages/uncheck/src/hooks.ts @@ -0,0 +1,259 @@ +import type { PlatformError } from 'effect' +import { Effect, FileSystem, Option, Path, Stdio } from 'effect' +import { Argument, CliError, Command, Prompt } from 'effect/unstable/cli' +import { parse as parseJsonc } from 'jsonc-parser' +import { UncheckOptions } from './options' +import { makeUi } from './ui' + +export const HOOK_AGENTS = ['claude', 'codebuddy', 'cursor', 'windsurf', 'copilot'] as const + +export type HookAgent = (typeof HOOK_AGENTS)[number] + +export interface HookIntegration { + readonly id: HookAgent + readonly name: string + /** Config file the agent reads, relative to the project root. */ + readonly path: string + /** Hook config to write, or to merge into the existing file. */ + readonly content: (command: string) => Record +} + +function claudeStyle(command: string): Record { + return { + hooks: { + PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [{ type: 'command', command }] }], + }, + } +} + +export const HOOK_INTEGRATIONS: ReadonlyArray = [ + { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', content: claudeStyle }, + { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', content: claudeStyle }, + { + id: 'cursor', + name: 'Cursor', + path: '.cursor/hooks.json', + content: command => ({ version: 1, hooks: { afterFileEdit: [{ command }] } }), + }, + { + id: 'windsurf', + name: 'Windsurf', + path: '.windsurf/hooks.json', + content: command => ({ hooks: { post_write_code: [{ command, show_output: true }] } }), + }, + { + id: 'copilot', + name: 'GitHub Copilot', + path: '.github/hooks/uncheck.json', + content: command => ({ + version: 1, + hooks: { postToolUse: [{ type: 'command', bash: command, powershell: command }] }, + }), + }, +] + +export const hooksCommand = Command.make( + 'hooks', + { + agents: Argument.Literals('agents', HOOK_AGENTS).pipe( + Argument.variadic(), + Argument.withDescription(`Agents to configure: ${HOOK_AGENTS.join(', ')}. Prompts for a selection when omitted.`), + ), + }, + ({ agents }) => + Effect.gen(function* () { + const options = yield* UncheckOptions + const ui = yield* makeUi + const selected = agents.length > 0 ? agents : yield* promptAgents + const command = yield* hookCommand(options.cwd) + + for (const integration of HOOK_INTEGRATIONS) { + if (!selected.includes(integration.id)) { + continue + } + + const result = yield* installHook(integration, command, options.cwd) + + yield* ui.line(`${ui.green('āœ”')} ${ui.bold(integration.name)} ${ui.dim(`${integration.path} ${result}`)}`) + } + + yield* ui.line('') + yield* ui.line(`${ui.dim('The hooks run')} ${ui.bold(command)} ${ui.dim('after every file the agent edits.')}`) + }), +).pipe( + Command.withDescription( + 'Set up agent hooks that run `uncheck --fix` on every file an AI agent edits and hand remaining problems back to it', + ), +) + +const promptAgents = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio + + if (!(yield* stdio.stdinIsTerminal)) { + const userMessage = `Pass the agents to configure, for example: uncheck hooks ${HOOK_AGENTS.join(' ')}` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + return yield* Prompt.run( + Prompt.MultiSelect({ + message: 'Which agents should run uncheck after editing files?', + choices: HOOK_INTEGRATIONS.map(integration => ({ title: integration.name, value: integration.id })), + min: 1, + }), + ) +}) + +const PACKAGE_MANAGER_EXEC: Record = { + pnpm: 'pnpm exec', + yarn: 'yarn', + bun: 'bunx', + npm: 'npx', +} + +const LOCKFILES: ReadonlyArray = [ + ['pnpm-lock.yaml', 'pnpm'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bun'], + ['bun.lockb', 'bun'], + ['package-lock.json', 'npm'], +] + +/** The hook command, invoking the project's own `uncheck` through the package manager in use. */ +export function hookCommand(cwd: string): Effect.Effect { + return Effect.gen(function* () { + const exec = yield* detectPackageManager(cwd) + + return `${PACKAGE_MANAGER_EXEC[exec] ?? PACKAGE_MANAGER_EXEC.npm} uncheck --fix --hook` + }) +} + +function detectPackageManager(cwd: string): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + let dir = path.resolve(cwd) + + while (true) { + const manifest = yield* fs.readFileString(path.join(dir, 'package.json')).pipe( + Effect.map(text => JSON.parse(text) as { packageManager?: string }), + Effect.orElseSucceed(() => undefined), + ) + + const declared = manifest?.packageManager?.split('@')[0] + + if (declared !== undefined && declared in PACKAGE_MANAGER_EXEC) { + return declared + } + + for (const [file, packageManager] of LOCKFILES) { + if (yield* fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false))) { + return packageManager + } + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return 'npm' + } + + dir = parent + } + }) +} + +export type HookInstallResult = 'created' | 'updated' | 'unchanged' + +/** + * Writes the agent's hook config, merging into an existing file so other hooks are kept. + * A file that already mentions `uncheck` is left alone, which makes reruns safe. + */ +export function installHook( + integration: HookIntegration, + command: string, + cwd: string, +): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const file = path.join(cwd, integration.path) + const addition = integration.content(command) + + const existing = yield* fs.readFileString(file).pipe(Effect.option) + + if (Option.isNone(existing)) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, `${JSON.stringify(addition, null, 2)}\n`) + return 'created' + } + + if (existing.value.includes('uncheck')) { + return 'unchanged' + } + + const current = parseJsonc(existing.value, undefined, { allowTrailingComma: true }) as unknown + const merged = mergeJson(isRecord(current) ? current : {}, addition) + + yield* fs.writeFileString(file, `${JSON.stringify(merged, null, 2)}\n`) + + return 'updated' + }) +} + +/** Deep-merges plain objects and concatenates arrays, so hook lists grow instead of being replaced. */ +function mergeJson(base: unknown, addition: unknown): unknown { + if (Array.isArray(base) && Array.isArray(addition)) { + return [...base, ...addition] + } + + if (isRecord(base) && isRecord(addition)) { + const merged: Record = { ...base } + + for (const [key, value] of Object.entries(addition)) { + merged[key] = key in base ? mergeJson(base[key], value) : value + } + + return merged + } + + return addition +} + +const PATH_KEYS = new Set(['file_path', 'filePath', 'notebook_path', 'notebookPath', 'path']) + +/** + * Every file path mentioned in an agent hook payload. The agents disagree on the envelope + * (`tool_input`, `tool_info`, `toolArgs`, or the top level) but all name the edited file with + * one of a few keys, so the payload is searched recursively instead of per agent. + */ +export function extractHookPaths(payload: unknown): string[] { + const found = new Set() + + const visit = (value: unknown): void => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + + if (!isRecord(value)) { + return + } + + for (const [key, child] of Object.entries(value)) { + if (PATH_KEYS.has(key) && typeof child === 'string') { + found.add(child) + } else { + visit(child) + } + } + } + + visit(payload) + + return [...found] +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/packages/uncheck/src/index.ts b/packages/uncheck/src/index.ts index e69de29..d0e9ac1 100644 --- a/packages/uncheck/src/index.ts +++ b/packages/uncheck/src/index.ts @@ -0,0 +1,2 @@ +export * from './command' +export * from './options' diff --git a/packages/uncheck/src/options.ts b/packages/uncheck/src/options.ts new file mode 100644 index 0000000..2526d35 --- /dev/null +++ b/packages/uncheck/src/options.ts @@ -0,0 +1,25 @@ +import process from 'node:process' +import { Context } from 'effect' + +export type StdioMode = 'inherit' | 'pipe' + +export interface UncheckOptions { + /** + * Directory the checks run in. Tools and `tsconfig.json` files are discovered from here. + * + * @default process.cwd() + */ + readonly cwd: string + /** + * How tool output is handled: + * - `inherit` streams it straight to the terminal (keeps colors and live progress). + * - `pipe` captures it and forwards it through the `Terminal` service (useful for tests and embedding). + * + * @default 'inherit' + */ + readonly stdio: StdioMode +} + +export const UncheckOptions = Context.Reference('uncheck/UncheckOptions', { + defaultValue: () => ({ cwd: process.cwd(), stdio: 'inherit' }), +}) diff --git a/packages/uncheck/src/tools.ts b/packages/uncheck/src/tools.ts new file mode 100644 index 0000000..eb618b0 --- /dev/null +++ b/packages/uncheck/src/tools.ts @@ -0,0 +1,54 @@ +import { Effect, FileSystem, Option, Path } from 'effect' + +export interface Tool { + readonly name: string + /** Absolute path to the JS entry to execute with `process.execPath`. */ + readonly bin: string +} + +interface PackageJson { + readonly bin?: string | Readonly> +} + +/** + * Locates `pkg` the way Node resolves packages from `cwd`: walk up looking for + * `node_modules//package.json`. Reading the manifest directly (instead of + * `require.resolve`) keeps this independent from the package's `exports` map. + */ +export function resolveTool( + pkg: string, + cwd: string, + binName: string = pkg, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + + let dir = path.resolve(cwd) + + while (true) { + const pkgDir = path.join(dir, 'node_modules', pkg) + + const manifest = yield* fs.readFileString(path.join(pkgDir, 'package.json')).pipe( + Effect.map(text => JSON.parse(text) as PackageJson), + Effect.orElseSucceed(() => undefined), + ) + + if (manifest !== undefined) { + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName] + + return Option.fromNullishOr(bin).pipe( + Option.map(entry => ({ name: binName, bin: path.resolve(pkgDir, entry) })), + ) + } + + const parent = path.dirname(dir) + + if (parent === dir) { + return Option.none() + } + + dir = parent + } + }) +} diff --git a/packages/uncheck/src/tsconfig.test.ts b/packages/uncheck/src/tsconfig.test.ts new file mode 100644 index 0000000..c1b88f2 --- /dev/null +++ b/packages/uncheck/src/tsconfig.test.ts @@ -0,0 +1,193 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import { Effect, Layer } from 'effect' +import { coversDirectory, includesFile, loadTsconfigInputs } from './tsconfig' +import { selectTsconfigs } from './typecheck' + +const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) +const fixtures: string[] = [] + +afterAll(() => { + for (const dir of fixtures) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +function fixture(files: Record): string { + const dir = mkdtempSync(join(tmpdir(), 'uncheck-tsconfig-')) + fixtures.push(dir) + + for (const [relative, content] of Object.entries(files)) { + mkdirSync(dirname(join(dir, relative)), { recursive: true }) + writeFileSync(join(dir, relative), typeof content === 'string' ? content : JSON.stringify(content)) + } + + return dir +} + +function inputs(configPath: string) { + return Effect.runPromise(Effect.provide(loadTsconfigInputs(configPath), layer)) +} + +function select(dir: string, entries: string[], paths: string[]) { + return Effect.runPromise( + Effect.provide( + selectTsconfigs( + entries.map(entry => join(dir, entry)), + paths, + dir, + ), + layer, + ), + ).then(selected => selected.map(entry => entry.slice(dir.length + 1))) +} + +describe('loadTsconfigInputs', () => { + it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { + const dir = fixture({ + 'tsconfig.json': { include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], exclude: ['src/legacy'] }, + }) + + const project = await inputs(join(dir, 'tsconfig.json')) + const has = (file: string) => includesFile(project, join(dir, file)) + + expect(has('src/index.ts')).toBe(true) + expect(has('src/deep/nested/component.tsx')).toBe(true) + expect(has('src/types.d.ts')).toBe(true) + expect(has('src/index.js')).toBe(false) + expect(has('src/README.md')).toBe(false) + expect(has('src/.hidden/index.ts')).toBe(false) + expect(has('src/node_modules/dep/index.ts')).toBe(false) + expect(has('src/legacy/old.ts')).toBe(false) + expect(has('scripts/build.ts')).toBe(true) + expect(has('scripts/nested/deep/build.ts')).toBe(false) + expect(has('config/app.json')).toBe(true) + expect(has('src/data.json')).toBe(false) + expect(has('packages/app/build.config.ts')).toBe(true) + expect(has('packages/app/src/index.ts')).toBe(false) + expect(has('node_modules/dep/index.ts')).toBe(false) + }) + + it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { + const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }) + + const project = await inputs(join(dir, 'tsconfig.json')) + const has = (file: string) => includesFile(project, join(dir, file)) + + expect(has('index.ts')).toBe(true) + expect(has('deep/index.ts')).toBe(true) + expect(has('dist/index.d.ts')).toBe(false) + expect(has('node_modules/dep/index.d.ts')).toBe(false) + expect(has('.git/hooks/pre-commit.ts')).toBe(false) + }) + + it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { + const dir = fixture({ + 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, + 'node_modules/@shared/tsconfig/base.json': { + compilerOptions: { allowJs: true }, + include: ['${configDir}/lib'], + exclude: ['${configDir}/lib/vendor'], + }, + 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, + 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, + 'packages/a/tsconfig.files.json': { files: ['entry.ts'], compilerOptions: { allowJs: false } }, + 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, + }) + + const a = await inputs(join(dir, 'packages/a/tsconfig.json')) + const b = await inputs(join(dir, 'packages/b/tsconfig.json')) + + // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. + expect(includesFile(a, join(dir, 'packages/a/entry.ts'))).toBe(true) + expect(includesFile(a, join(dir, 'src/index.ts'))).toBe(true) + expect(includesFile(a, join(dir, 'packages/a/src/index.ts'))).toBe(false) + expect(includesFile(a, join(dir, 'src/index.js'))).toBe(false) + // `exclude` is still the base's, with `${configDir}` pointing at the leaf. + expect(includesFile(a, join(dir, 'packages/a/lib/vendor/x.ts'))).toBe(false) + + expect(includesFile(b, join(dir, 'packages/b/lib/util.ts'))).toBe(true) + expect(includesFile(b, join(dir, 'packages/b/lib/util.js'))).toBe(true) + expect(includesFile(b, join(dir, 'packages/b/lib/vendor/x.ts'))).toBe(false) + expect(includesFile(b, join(dir, 'packages/b/src/index.ts'))).toBe(false) + }) + + it('tells whether a directory can hold inputs', async () => { + const dir = fixture({ 'tsconfig.json': { include: ['src', 'tests/**/*.spec.ts'], exclude: ['src/generated'] } }) + + const project = await inputs(join(dir, 'tsconfig.json')) + const covers = (folder: string) => coversDirectory(project, join(dir, folder)) + + expect(covers('src')).toBe(true) + expect(covers('src/components')).toBe(true) + expect(covers('tests/unit')).toBe(true) + expect(covers('.')).toBe(true) + expect(covers('docs')).toBe(false) + expect(covers('src/generated')).toBe(false) + expect(covers('src/generated/api')).toBe(false) + }) +}) + +describe('selectTsconfigs', () => { + const root = { + include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'], + } + + const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } + + function monorepo() { + return fixture({ + 'tsconfig.base.json': { compilerOptions: { strict: true } }, + 'tsconfig.json': root, + 'packages/a/tsconfig.json': lib, + 'packages/b/tsconfig.json': lib, + }) + } + + const entries = ['packages/a/tsconfig.json', 'packages/b/tsconfig.json', 'tsconfig.json'] + + it('returns every project when no paths are given', async () => { + const dir = monorepo() + + expect(await select(dir, entries, [])).toEqual(entries) + }) + + it('selects only the projects whose inputs contain a file', async () => { + const dir = monorepo() + + expect(await select(dir, entries, ['packages/a/src/index.ts'])).toEqual(['packages/a/tsconfig.json']) + // Tests are excluded by the package and picked up by the root instead. + expect(await select(dir, entries, ['packages/a/src/index.test.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, entries, ['packages/a/build.config.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, entries, ['scripts/release.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, entries, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ + 'packages/a/tsconfig.json', + 'packages/b/tsconfig.json', + ]) + }) + + it('ignores negations and files tsc would never check', async () => { + const dir = monorepo() + + expect(await select(dir, entries, ['README.md', 'packages/a/styles.css'])).toEqual([]) + expect(await select(dir, entries, ['**/README.md', 'packages/**/*.css'])).toEqual([]) + expect(await select(dir, entries, ['!**/*.test.ts'])).toEqual([]) + expect(await select(dir, entries, ['packages/a/src/index.ts', 'docs/notes.md'])).toEqual([ + 'packages/a/tsconfig.json', + ]) + }) + + it('selects the projects whose inputs overlap a directory or glob', async () => { + const dir = monorepo() + + expect(await select(dir, entries, ['packages/b/src'])).toEqual(['packages/b/tsconfig.json', 'tsconfig.json']) + expect(await select(dir, entries, ['packages/a/src/**/*.ts'])).toEqual([ + 'packages/a/tsconfig.json', + 'tsconfig.json', + ]) + expect(await select(dir, entries, ['docs'])).toEqual(['tsconfig.json']) + expect(await select(dir, entries, ['**/*.ts'])).toEqual(entries) + }) +}) diff --git a/packages/uncheck/src/tsconfig.ts b/packages/uncheck/src/tsconfig.ts new file mode 100644 index 0000000..3092aaa --- /dev/null +++ b/packages/uncheck/src/tsconfig.ts @@ -0,0 +1,393 @@ +import process from 'node:process' +import { Effect, FileSystem, Option, Path } from 'effect' +import { parse as parseJsonc } from 'jsonc-parser' + +export interface RawTsconfig { + readonly extends?: unknown + readonly files?: unknown + readonly include?: unknown + readonly exclude?: unknown + readonly references?: unknown + readonly compilerOptions?: unknown +} + +/** Parses a config file as JSONC. Unreadable or malformed files read as empty and are left for `tsc` to report. */ +export function readTsconfig(configPath: string): Effect.Effect { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + + return yield* fs.readFileString(configPath).pipe( + Effect.map(text => parseJsonc(text, undefined, { allowTrailingComma: true }) as unknown), + Effect.map((value): RawTsconfig => (isRecord(value) ? value : {})), + Effect.orElseSucceed((): RawTsconfig => ({})), + ) + }) +} + +/** + * The `references` of one config file, resolved like `tsc` does: relative to the config's directory, + * and a path without a `.json` extension means that folder's `tsconfig.json`. + * `references` are never inherited through `extends`, so the file itself is all that matters. + */ +export function readReferences( + configPath: string, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const path = yield* Path.Path + const configDir = path.dirname(configPath) + const { references } = yield* readTsconfig(configPath) + + return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { + if (!isRecord(reference) || typeof reference.path !== 'string') { + return [] + } + + const target = path.resolve(configDir, reference.path.replaceAll('${configDir}', configDir)) + + return [target.endsWith('.json') ? target : path.join(target, 'tsconfig.json')] + }) + }) +} + +export interface InputPattern { + /** Absolute pattern with forward slashes. */ + readonly spec: string + readonly regex: RegExp + /** Absolute directory before the first wildcard. */ + readonly prefix: string +} + +/** What `tsc` would take as input files for one project, with `extends` applied. */ +export interface TsconfigInputs { + readonly dir: string + /** Absolute paths listed in `files`, always included. */ + readonly files: ReadonlyArray + readonly include: ReadonlyArray + readonly exclude: ReadonlyArray + /** Extensions `tsc` picks up through `include`, JSON aside. */ + readonly extensions: ReadonlySet +} + +const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] +const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] +const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages'] + +interface Specs { + /** Directory of the config that defined the specs, which relative specs resolve against. */ + readonly dir: string + readonly specs: ReadonlyArray +} + +/** + * Resolves `files`, `include`, `exclude` and the relevant compiler options of a config the way + * `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. + */ +export function loadTsconfigInputs( + configPath: string, +): Effect.Effect { + return Effect.gen(function* () { + const path = yield* Path.Path + const chain = yield* loadExtendsChain(configPath, new Set()) + const leafDir = path.dirname(configPath) + + let files: Specs | undefined + let include: Specs | undefined + let exclude: Specs | undefined + let outDir: Specs | undefined + let allowJs = false + + for (const { dir, raw } of chain) { + if (isStringArray(raw.files)) { + files = { dir, specs: raw.files } + } + + if (isStringArray(raw.include)) { + include = { dir, specs: raw.include } + } + + if (isStringArray(raw.exclude)) { + exclude = { dir, specs: raw.exclude } + } + + if (isRecord(raw.compilerOptions)) { + const { compilerOptions } = raw + + if (typeof compilerOptions.allowJs === 'boolean') { + allowJs = compilerOptions.allowJs + } + + if (compilerOptions.checkJs === true) { + allowJs = true + } + + if (typeof compilerOptions.outDir === 'string') { + outDir = { dir, specs: [compilerOptions.outDir] } + } + } + } + + const resolve = (specs: Specs | undefined): string[] => + specs?.specs.map(spec => toPosix(path.resolve(specs.dir, spec.replaceAll('${configDir}', leafDir)))) ?? [] + + const includeSpecs = include === undefined && files === undefined ? [`${toPosix(leafDir)}/**/*`] : resolve(include) + + const excludeSpecs = + exclude === undefined + ? [...DEFAULT_EXCLUDES.map(name => `${toPosix(leafDir)}/${name}`), ...resolve(outDir)] + : resolve(exclude) + + return { + dir: leafDir, + files: resolve(files), + include: includeSpecs.flatMap(spec => { + const regex = compileGlob(spec, 'files') + return regex === undefined ? [] : [{ spec, regex, prefix: staticPrefixDir(spec) }] + }), + exclude: excludeSpecs.map(spec => ({ + spec, + regex: compileGlob(spec, 'exclude')!, + prefix: staticPrefixDir(spec), + })), + extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), + } + }) +} + +/** Whether `tsc -p` on this config would take `file` as an input. */ +export function includesFile(inputs: TsconfigInputs, file: string): boolean { + const target = toPosix(file) + + if (inputs.files.includes(target)) { + return true + } + + const extension = extensionOf(target) + const json = extension === '.json' + + if (!json && !inputs.extensions.has(extension)) { + return false + } + + if (inputs.exclude.some(pattern => pattern.regex.test(target))) { + return false + } + + // JSON files only come in through an `include` that names the extension explicitly. + return inputs.include.some(pattern => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json'))) +} + +/** Whether some input of this config could live under `dir`, judged by where its patterns start. */ +export function coversDirectory(inputs: TsconfigInputs, dir: string): boolean { + const target = toPosix(dir) + + // Exclude patterns match the files below a folder, so probe the folder as a prefix. + if (inputs.exclude.some(pattern => pattern.regex.test(`${target}/`))) { + return false + } + + return ( + inputs.files.some(file => file.startsWith(`${target}/`)) || + inputs.include.some(pattern => overlaps(pattern.prefix, target)) + ) +} + +function overlaps(a: string, b: string): boolean { + return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) +} + +interface ChainEntry { + readonly dir: string + readonly raw: RawTsconfig +} + +/** Base configs first, the config itself last, so later entries override earlier ones. */ +function loadExtendsChain( + configPath: string, + visited: Set, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + if (visited.has(configPath)) { + return [] + } + + visited.add(configPath) + + 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, dir).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed>([]), + onSome: base => loadExtendsChain(base, visited), + }), + ), + ), + ) + + return [...bases.flat(), { dir, raw }] + }) +} + +/** Resolves an `extends` entry: a relative path (with an optional `.json`), or a package like `tsc` looks it up. */ +function resolveExtends( + spec: string, + dir: string, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + 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.gen(function* () { + for (const candidate of candidates) { + if ((yield* kind(candidate)) === 'File') { + return Option.some(candidate) + } + } + + return Option.none() + }) + + if (spec.startsWith('./') || spec.startsWith('../') || path.isAbsolute(spec)) { + const target = path.resolve(dir, spec) + return yield* firstFile([target, `${target}.json`]) + } + + let current = dir + + while (true) { + const base = path.join(current, 'node_modules', spec) + const asFile = yield* firstFile([base, `${base}.json`]) + + if (Option.isSome(asFile)) { + return asFile + } + + if ((yield* kind(base)) === 'Directory') { + const manifest = yield* fs.readFileString(path.join(base, 'package.json')).pipe( + Effect.map(text => JSON.parse(text) as { tsconfig?: unknown }), + Effect.orElseSucceed(() => ({}) as { tsconfig?: unknown }), + ) + + const entry = typeof manifest.tsconfig === 'string' ? manifest.tsconfig : 'tsconfig.json' + const found = yield* firstFile([path.resolve(base, entry)]) + + if (Option.isSome(found)) { + return found + } + } + + const parent = path.dirname(current) + + if (parent === current) { + return Option.none() + } + + current = parent + } + }) +} + +const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' +const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` +const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' +const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' + +/** + * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: + * `*` and `?` never cross a directory, a leading wildcard never matches a dot file, `**` skips + * `node_modules` and dot folders, and a pattern without extension or wildcard means the whole folder. + * `exclude` patterns also match every path below them. + */ +function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { + const components = pattern.replace(/\/+$/, '').split('/') + const last = components[components.length - 1]! + + if (usage === 'files' && last === '**') { + return undefined + } + + if (!/[*?.]/.test(last)) { + components.push('**', '*') + } + + let source = '' + let written = false + + for (const component of components) { + if (component === '**') { + source += usage === 'files' ? FILES_DOUBLE_ASTERISK : EXCLUDE_DOUBLE_ASTERISK + } else { + if (written) { + source += '/' + } + + source += usage === 'files' ? filesComponent(component) : wildcards(component) + } + + written = true + } + + return new RegExp(`^${source}${usage === 'exclude' ? '(?:$|/)' : '$'}`, CASE_INSENSITIVE ? 'i' : '') +} + +function filesComponent(component: string): string { + let source = '' + let rest = component + + if (rest.startsWith('*')) { + source += '(?:[^./][^/]*)?' + rest = rest.slice(1) + } else if (rest.startsWith('?')) { + source += '[^./]' + rest = rest.slice(1) + } + + source += wildcards(rest) + + return /[*?]/.test(component) ? IMPLICIT_EXCLUDE + source : source +} + +/** Escapes a path component for a regular expression, mapping `*` and `?` to their glob meaning. */ +function wildcards(component: string): string { + return component.replace(/[.*?+^${}()|[\]\\]/g, char => + char === '*' ? '[^/]*' : char === '?' ? '[^/]' : `\\${char}`, + ) +} + +function staticPrefixDir(spec: string): string { + const components = spec.split('/') + const firstWildcard = components.findIndex(component => /[*?]/.test(component)) + + return (firstWildcard === -1 ? components : components.slice(0, firstWildcard)).join('/') +} + +function extensionOf(file: string): string { + const name = file.slice(file.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + + return dot <= 0 ? '' : name.slice(dot).toLowerCase() +} + +function toPosix(target: string): string { + return target.replaceAll('\\', '/') +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every(item => typeof item === 'string') +} diff --git a/packages/uncheck/src/typecheck.test.ts b/packages/uncheck/src/typecheck.test.ts new file mode 100644 index 0000000..96414a9 --- /dev/null +++ b/packages/uncheck/src/typecheck.test.ts @@ -0,0 +1,113 @@ +import type { TsProject } from './typecheck' +import { Effect } from 'effect' +import { CircularProjectReferences, planTypecheck } from './typecheck' + +type Graph = Record + +function projects(graph: Graph): ReadonlyMap { + return new Map(Object.entries(graph).map(([config, references]) => [config, { path: config, references }])) +} + +function plan(graph: Graph, entries: string[] = Object.keys(graph)) { + return Effect.runSync(planTypecheck(entries, projects(graph))) +} + +function planError(graph: Graph, entries: string[] = Object.keys(graph)) { + return Effect.runSync(Effect.flip(planTypecheck(entries, projects(graph)))) +} + +describe('planTypecheck', () => { + it('checks standalone projects with tsc -p', () => { + expect( + plan({ + '/repo/tsconfig.json': [], + '/repo/packages/a/tsconfig.json': [], + }), + ).toEqual({ + build: [], + check: ['/repo/packages/a/tsconfig.json', '/repo/tsconfig.json'], + }) + }) + + it('builds only the roots of the reference graph and leaves the rest to tsc -b', () => { + expect( + plan({ + '/repo/tsconfig.json': [], + '/repo/packages/shared/tsconfig.json': [], + '/repo/packages/client/tsconfig.json': ['/repo/packages/shared/tsconfig.json'], + '/repo/packages/server/tsconfig.json': [ + '/repo/packages/client/tsconfig.json', + '/repo/packages/shared/tsconfig.json', + ], + '/repo/packages/nest/tsconfig.json': ['/repo/packages/server/tsconfig.json'], + }), + ).toEqual({ + build: ['/repo/packages/nest/tsconfig.json'], + check: ['/repo/tsconfig.json'], + }) + }) + + it('builds a solution-style root that references non-entry configs', () => { + expect( + plan( + { + '/app/tsconfig.json': ['/app/tsconfig.app.json', '/app/tsconfig.node.json'], + '/app/tsconfig.app.json': [], + '/app/tsconfig.node.json': [], + }, + ['/app/tsconfig.json'], + ), + ).toEqual({ + build: ['/app/tsconfig.json'], + check: [], + }) + }) + + it('keeps a root with references in build mode even when it is not composite', () => { + expect( + plan({ + '/repo/tsconfig.json': ['/repo/packages/a/tsconfig.json', '/repo/packages/b/tsconfig.json'], + '/repo/packages/a/tsconfig.json': [], + '/repo/packages/b/tsconfig.json': ['/repo/packages/a/tsconfig.json'], + }), + ).toEqual({ + build: ['/repo/tsconfig.json'], + check: [], + }) + }) + + it('handles diamonds and multiple roots', () => { + expect( + plan({ + '/r/a/tsconfig.json': [], + '/r/b/tsconfig.json': ['/r/a/tsconfig.json'], + '/r/c/tsconfig.json': ['/r/a/tsconfig.json'], + '/r/d/tsconfig.json': ['/r/b/tsconfig.json', '/r/c/tsconfig.json'], + '/r/e/tsconfig.json': ['/r/a/tsconfig.json'], + }), + ).toEqual({ + build: ['/r/d/tsconfig.json', '/r/e/tsconfig.json'], + check: [], + }) + }) + + it('fails on circular references instead of silently skipping them', () => { + expect( + planError({ + '/r/a/tsconfig.json': ['/r/b/tsconfig.json'], + '/r/b/tsconfig.json': ['/r/a/tsconfig.json'], + '/r/c/tsconfig.json': [], + }), + ).toEqual(new CircularProjectReferences({ projects: ['/r/a/tsconfig.json', '/r/b/tsconfig.json'] })) + }) + + it('reports only the cyclic part when a root also exists', () => { + expect( + planError({ + '/r/a/tsconfig.json': ['/r/b/tsconfig.json'], + '/r/b/tsconfig.json': ['/r/c/tsconfig.json'], + '/r/c/tsconfig.json': ['/r/b/tsconfig.json'], + }).projects, + ).toEqual(['/r/b/tsconfig.json', '/r/c/tsconfig.json']) + }) +}) diff --git a/packages/uncheck/src/typecheck.ts b/packages/uncheck/src/typecheck.ts new file mode 100644 index 0000000..8cb5424 --- /dev/null +++ b/packages/uncheck/src/typecheck.ts @@ -0,0 +1,273 @@ +import type { PlatformError } from 'effect' +import { Data, Effect, FileSystem, Graph, Option, Path, Stream } from 'effect' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' +import { coversDirectory, includesFile, loadTsconfigInputs, readReferences } from './tsconfig' + +export interface TsProject { + /** Absolute path to the config file. */ + readonly path: string + /** Absolute paths of the config files listed in `references`. */ + readonly references: ReadonlyArray +} + +export interface TypecheckPlan { + /** + * Roots of the project-reference graph (projects nobody references). + * `tsc -b` builds every referenced project first, in dependency order. + */ + readonly build: ReadonlyArray + /** Projects outside the reference graph, checked with `tsc -p` after the build step. */ + readonly check: ReadonlyArray +} + +export class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ + readonly projects: ReadonlyArray +}> {} + +/** + * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. + * + * Every project that has references, or is referenced, belongs to the build graph. + * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. + */ +export function planTypecheck( + entries: ReadonlyArray, + projects: ReadonlyMap, +): Effect.Effect { + const members = new Set() + const referenced = new Set() + + for (const project of projects.values()) { + if (project.references.length === 0) { + continue + } + + members.add(project.path) + + for (const reference of project.references) { + members.add(reference) + referenced.add(reference) + } + } + + const cycle = findCycle([...members].sort(), projects) + + if (Option.isSome(cycle)) { + return Effect.fail(new CircularProjectReferences({ projects: cycle.value })) + } + + return Effect.succeed({ + build: [...members].filter(member => !referenced.has(member)).sort(), + check: entries.filter(entry => !members.has(entry)).sort(), + }) +} + +/** The first cycle among `nodes` following `references`, in visiting order. */ +function findCycle(nodes: ReadonlyArray, projects: ReadonlyMap): Option.Option { + const graph = Graph.directed(mutable => { + const indexes = new Map(nodes.map(node => [node, Graph.addNode(mutable, node)] as const)) + + for (const node of nodes) { + for (const reference of projects.get(node)?.references ?? []) { + Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) + } + } + }) + + return Option.map(Graph.findCycle(graph), ({ path }) => + // The path closes on its first node, drop that repetition. + path.slice(0, -1).map(index => Option.getOrThrow(Graph.getNode(graph, index))), + ) +} + +/** + * Narrows `entries` to the projects `tsc` would actually check for the given `paths`. + * + * A file selects the projects whose `files`/`include`/`exclude` (with `extends` applied) take it as + * input, exactly like `tsc` decides. A directory, or the static prefix of a glob, selects the + * projects whose inputs can live under it. Negations are ignored, and no paths means every project. + */ +export function selectTsconfigs( + entries: ReadonlyArray, + paths: ReadonlyArray, + cwd: string, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + if (paths.length === 0) { + return Effect.succeed(entries) + } + + return Effect.gen(function* () { + const path = yield* Path.Path + + const targets = paths + .filter(pattern => !pattern.startsWith('!') && !namesUncheckableExtension(pattern)) + .map(pattern => path.resolve(cwd, staticPrefix(pattern))) + + if (targets.length === 0) { + return [] + } + + const selected = yield* Effect.filter( + entries, + entry => + Effect.map(loadTsconfigInputs(entry), inputs => + targets.some(target => + path.extname(target) === '' ? coversDirectory(inputs, target) : includesFile(inputs, target), + ), + ), + { concurrency: 'unbounded' }, + ) + + return [...selected].sort() + }) +} + +const CHECKABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']) + +/** Whether a path or glob ends in a literal extension `tsc` never reads, like `README.md` or `**\/*.css`. */ +function namesUncheckableExtension(pattern: string): boolean { + const name = pattern.slice(pattern.lastIndexOf('/') + 1) + const dot = name.lastIndexOf('.') + const extension = dot <= 0 ? '' : name.slice(dot).toLowerCase() + + return extension !== '' && !/[*?[\]{}()]/.test(extension) && !CHECKABLE_EXTENSIONS.has(extension) +} + +/** The leading path segments of a glob pattern that contain no wildcard. */ +function staticPrefix(pattern: string): string { + const segments = pattern.split('/') + const firstWildcard = segments.findIndex(segment => /[*?[\]{}()]/.test(segment)) + + return (firstWildcard === -1 ? segments : segments.slice(0, firstWildcard)).join('/') +} + +/** Whether `child` is `parent` itself or lies somewhere below it. */ +export function isWithin(path: Path.Path, child: string, parent: string): boolean { + const relative = path.relative(parent, child) + + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +/** Parses `entries` and everything they reference, transitively, into a graph keyed by config path. */ +export function loadTsProjects( + entries: ReadonlyArray, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const projects = new Map() + const queue = [...entries] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (projects.has(configPath)) { + continue + } + + const references = yield* readReferences(configPath) + + projects.set(configPath, { path: configPath, references }) + queue.push(...references) + } + + return projects + }) +} + +/** + * Finds every `tsconfig.json` under `cwd`. + * + * Prefers `git ls-files` so ignored folders (build output, playgrounds, ...) are excluded exactly + * like the user configured, and falls back to a plain directory walk outside git repositories. + */ +export function discoverTsconfigs( + cwd: string, +): Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + return listTsconfigsWithGit(cwd).pipe( + Effect.catch(() => walkTsconfigs(cwd)), + Effect.map(found => [...found].sort()), + ) +} + +function listTsconfigsWithGit( + cwd: string, +): Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError | number, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const output = yield* Effect.scoped( + Effect.gen(function* () { + const handle = yield* spawner.spawn( + ChildProcess.make( + 'git', + ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '*tsconfig.json'], + { cwd, stdin: 'ignore', stderr: 'ignore' }, + ), + ) + + const stdout = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + const exitCode = yield* handle.exitCode + + return exitCode === 0 ? stdout : yield* Effect.fail(exitCode) + }), + ) + + const candidates = output + .split('\0') + .filter(relative => path.basename(relative) === 'tsconfig.json' && !relative.split('/').includes('node_modules')) + .map(relative => path.resolve(cwd, relative)) + + // Tracked files can be deleted from the working tree without being staged yet. + return yield* Effect.filter(candidates, candidate => fs.exists(candidate), { concurrency: 'unbounded' }) + }) +} + +const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'tmp', 'temp']) + +function walkTsconfigs( + cwd: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const found: string[] = [] + + const visit = (dir: string): Effect.Effect => + fs + .readDirectory(dir) + .pipe( + Effect.flatMap(names => + Effect.forEach(names, name => visitEntry(dir, name), { concurrency: 16, discard: true }), + ), + ) + + const visitEntry = (dir: string, name: string): Effect.Effect => + Effect.gen(function* () { + if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { + return + } + + const full = path.join(dir, name) + const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) + + if (info?.type === 'Directory') { + yield* visit(full) + } else if (info !== undefined && name === 'tsconfig.json') { + found.push(full) + } + }) + + yield* visit(path.resolve(cwd)) + + return found + }) +} diff --git a/packages/uncheck/src/ui.ts b/packages/uncheck/src/ui.ts new file mode 100644 index 0000000..9905b37 --- /dev/null +++ b/packages/uncheck/src/ui.ts @@ -0,0 +1,43 @@ +import type { PlatformError } from 'effect' +import process from 'node:process' +import { styleText } from 'node:util' +import { Effect, Stdio, Terminal } from 'effect' + +export interface Ui { + readonly line: (text: string) => Effect.Effect + readonly bold: (text: string) => string + readonly dim: (text: string) => string + readonly red: (text: string) => string + readonly green: (text: string) => string +} + +export const makeUi: Effect.Effect = Effect.gen(function* () { + const stdio = yield* Stdio.Stdio + const terminal = yield* Terminal.Terminal + const colors = yield* supportsColor(stdio) + + // Color support is decided here (from the Stdio service), so skip styleText's own stream check. + const paint = (style: Parameters[0]) => (text: string) => + colors ? styleText(style, text, { validateStream: false }) : text + + return { + // `display` resolves once the write completed, so a header never trails the tool output that follows it. + line: text => terminal.display(`${text}\n`), + bold: paint('bold'), + dim: paint('dim'), + red: paint('red'), + green: paint('green'), + } +}) + +function supportsColor(stdio: Stdio.Stdio): Effect.Effect { + if ('NO_COLOR' in process.env) { + return Effect.succeed(false) + } + + if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') { + return Effect.succeed(true) + } + + return stdio.stdoutIsTerminal +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 88a9bc2..ed92e9b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -166,15 +166,15 @@ importers: .: devDependencies: - '@antfu/eslint-config': - specifier: ^9.5.1 - version: 9.5.1(@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3))(@typescript-eslint/utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.43)(eslint-plugin-format@2.0.1(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.1) '@codspeed/vitest-plugin': specifier: ^5.7.1 version: 5.7.1(debug@4.4.3)(tinybench@6.1.4)(vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1))(vitest@5.0.1) '@types/node': specifier: ^26.6.2 version: 26.6.2 + '@typescript/typescript6': + specifier: ^6.0.2 + version: 6.0.2 '@vitest/coverage-v8': specifier: ^5.0.1 version: 5.0.1(vitest@5.0.1) @@ -184,15 +184,15 @@ importers: changelogithub: specifier: ^15.0.5 version: 15.0.5 - eslint: - specifier: ^10.11.0 - version: 10.11.0(jiti@2.7.0) - eslint-plugin-format: - specifier: ^2.0.1 - version: 2.0.1(eslint@10.11.0(jiti@2.7.0)) lint-staged: specifier: ^17.5.1 version: 17.5.1 + oxfmt: + specifier: ^0.68.0 + version: 0.68.0 + oxlint: + specifier: ^1.83.0 + version: 1.83.0 pkg-pr-new: specifier: ^0.0.88 version: 0.0.88 @@ -203,92 +203,42 @@ importers: specifier: ^2.14.0 version: 2.14.0 typescript: - specifier: ^6.0.3 - version: 6.0.3 + specifier: ^7.0.2 + version: 7.0.2 unbuild: specifier: ^3.6.1 - version: 3.6.1(typescript@6.0.3) + version: 3.6.1(@typescript/typescript6@6.0.2)(typescript@7.0.2) + uncheck: + specifier: workspace:* + version: link:packages/uncheck vitest: specifier: ^5.0.1 version: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) - packages/uncheck: {} + packages/uncheck: + dependencies: + '@effect/platform-node': + specifier: ^4.0.0-rc.116 + version: 4.0.0-rc.116(effect@4.0.0-rc.116)(redis@6.2.1) + effect: + specifier: ^4.0.0-rc.116 + version: 4.0.0-rc.116 + jsonc-parser: + specifier: ^3.3.1 + version: 3.3.1 + devDependencies: + oxfmt: + specifier: ^0.68.0 + version: 0.68.0 + oxlint: + specifier: ^1.83.0 + version: 1.83.0 + typescript: + specifier: ^7.0.2 + version: 7.0.2 packages: - '@antfu/eslint-config@9.5.1': - resolution: {integrity: sha512-BoQGe5lY9spYmGqtUOC6hWohxob4SNn+7o5nHsOXoLn+9vJqNGqC/2/vRoXbcOa3dT4Rx/ZV/Qkr6uf72KTbxQ==} - hasBin: true - peerDependencies: - '@angular-eslint/eslint-plugin': ^21.1.0 - '@angular-eslint/eslint-plugin-template': ^21.1.0 - '@angular-eslint/template-parser': ^21.1.0 - '@eslint-react/eslint-plugin': ^5.6.0 - '@next/eslint-plugin-next': '>=15.0.0' - '@prettier/plugin-xml': ^3.4.1 - '@unocss/eslint-plugin': '>=0.50.0' - astro-eslint-parser: '>=1.0.2' - eslint: ^9.10.0 || ^10.0.0 - eslint-plugin-astro: '>=1.2.0' - eslint-plugin-erasable-syntax-only: ^0.7.1 - eslint-plugin-format: '>=0.1.0' - eslint-plugin-jsx-a11y: '>=6.10.2' - eslint-plugin-react-refresh: ^0.5.0 - eslint-plugin-slop: '>=0.1.1' - eslint-plugin-solid: ^0.17.0 - eslint-plugin-sonarjs: '>=4.0.0' - eslint-plugin-svelte: '>=2.35.1' - eslint-plugin-vuejs-accessibility: ^2.4.1 - prettier-plugin-astro: ^0.14.0 - prettier-plugin-slidev: ^1.0.5 - svelte-eslint-parser: '>=0.37.0' - peerDependenciesMeta: - '@angular-eslint/eslint-plugin': - optional: true - '@angular-eslint/eslint-plugin-template': - optional: true - '@angular-eslint/template-parser': - optional: true - '@eslint-react/eslint-plugin': - optional: true - '@next/eslint-plugin-next': - optional: true - '@prettier/plugin-xml': - optional: true - '@unocss/eslint-plugin': - optional: true - astro-eslint-parser: - optional: true - eslint-plugin-astro: - optional: true - eslint-plugin-erasable-syntax-only: - optional: true - eslint-plugin-format: - optional: true - eslint-plugin-jsx-a11y: - optional: true - eslint-plugin-react-refresh: - optional: true - eslint-plugin-slop: - optional: true - eslint-plugin-solid: - optional: true - eslint-plugin-sonarjs: - optional: true - eslint-plugin-svelte: - optional: true - eslint-plugin-vuejs-accessibility: - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-slidev: - optional: true - svelte-eslint-parser: - optional: true - - '@antfu/install-pkg@2.1.0': - resolution: {integrity: sha512-sdg9NxU3zR4Mnawfbc/x6GB5Wf17WYud5qOuEuxXjaKpYpMkISSJEjItGebXJ2bQ4DIcly4NYH23mtkGJjvKUw==} - '@babel/code-frame@8.0.6': resolution: {integrity: sha512-uMHhvmDmwFiLuiozaoRYTZHqMFuu3fem/X9JY78lohAuLoh8LSGGC8JqUzo2cVuBsBxkjMZNk56CE7ock8Xxkw==} engines: {node: ^22.18.0 || >=24.11.0} @@ -318,20 +268,6 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} - '@cacheable/memory@2.2.0': - resolution: {integrity: sha512-CTLKqLItRCEixEAewD3/j9DB3/o96gpTPD4eJ1v+DGOlxZRZncRQkGYqqnAGCscYd6RNeXfGeiuCphsPtqyIfQ==} - - '@cacheable/utils@2.5.0': - resolution: {integrity: sha512-buipgOVDkkPXNR5+xBpDw7Zk2n1EvU7qBJCNUcL7rhQ//kfpOXPAvQ511Os0vpLYJ1pZnvudNytkQt2hst3wqA==} - - '@clack/core@1.5.1': - resolution: {integrity: sha512-iHTrHA8MtVuLl2TfZySmcKv1qO2PoyC9Z7pfSDozEuV5vtY3/wcOPKJXlqJ5Oq2Cx5DDGQGAMVx6HZfRRoVEbQ==} - engines: {node: '>= 20.12.0'} - - '@clack/prompts@1.8.1': - resolution: {integrity: sha512-dlT1m5e/0yUL0kRNcQn7yGLVThkgbB0Ga/1AmfDDC/8ik6AIiSf2QLQO2zPYvefsHP0aFgxO93cVLCCfDp7kzQ==} - engines: {node: '>= 20.12.0'} - '@codspeed/core@5.7.1': resolution: {integrity: sha512-7mrFkqaY14rXu3XHv3o7bK3btLK0LFRxfeK3bQC8dLduI/Ty9eq8Hv8rx04FAY/onQbRTx3rUUQg5xprYHpATQ==} @@ -345,37 +281,18 @@ packages: '@colordx/core@5.8.0': resolution: {integrity: sha512-cG0QJAO6VkaRUlIb0zOzX9gfJgs1pjoOL9gZ/PK1kfvBs0GCkADu5oQh6gPxXgQnZ3gV575h+lQRC0NlMDTclA==} - '@dprint/formatter@0.5.1': - resolution: {integrity: sha512-cdZUrm0iv/FnnY3CKE2dEcVhNEzrC551aE2h2mTFwQCRBrqyARLDnb7D+3PlXTUVp3s34ftlnGOVCmhLT9DeKA==} - - '@dprint/markdown@0.21.1': - resolution: {integrity: sha512-XbZ/R7vRrBaZFYXG6vAvLvtaMVXHu8XB+xwie7OYrG+dPoGDP8UADGirIbzUyX8TmrAZcl6QBmalipTGlpzRmQ==} - - '@dprint/toml@0.7.0': - resolution: {integrity: sha512-eFaQTcfxKHB+YyTh83x7GEv+gDPuj9q5NFOTaoj5rZmQTbj6OgjjMxUicmS1R8zYcx8YAq5oA9J3YFa5U6x2gA==} - - '@e18e/eslint-plugin@0.8.1': - resolution: {integrity: sha512-OV3T7l0r52/IglskdAuoO+BJyzsCs7AcIHpwpBMlIMO0+3RHskM2h5MuHdLuesIhSRhwCURGMKtp/XwnfXrfxg==} + '@effect/platform-node-shared@4.0.0-rc.116': + resolution: {integrity: sha512-4FB6UriGqArWFUaFqVchhfCRmmtOVzmD/k/bH6LErlKY8a9amt5dk6c3Arsi7AvUpChsdO+3jsG+P85HsxMkNA==} + engines: {node: '>=18.0.0'} peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - oxlint: ^1.72.0 - peerDependenciesMeta: - eslint: - optional: true - oxlint: - optional: true - - '@es-joy/jsdoccomment@0.92.0': - resolution: {integrity: sha512-WivZqV5jPTYDQJgIMp0hTsyQESKYNY6yCJr4b0l84tYtQntkAMkc6zARh2vlRQyeg4aYHPUkCUDpvr2IWAiGpQ==} - engines: {node: ^22.22.2 || >=24.15.0} - - '@es-joy/jsdoccomment@0.98.0': - resolution: {integrity: sha512-QS+b7I2aaSM+gZfDz1b9E4B367JJ2qTjFXRKF289iKkh9sYdZ21Q5MiF/Duzouy3J0C4VhM3I/8f2F4+zT8gYQ==} - engines: {node: ^22.22.2 || >=24.15.0} + effect: ^4.0.0-rc.116 - '@es-joy/resolve.exports@1.2.0': - resolution: {integrity: sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g==} - engines: {node: '>=10'} + '@effect/platform-node@4.0.0-rc.116': + resolution: {integrity: sha512-uA66Nh998OcChBVX83tQDkBDvQQcVaMXodP9gNGWVeIwWWquy7bzIkAq/fMZGKs/BVFhFQ0XCp3VIqYjnXu5ng==} + engines: {node: '>=18.0.0'} + peerDependencies: + effect: ^4.0.0-rc.116 + redis: '>=5.0.0 <7.0.0' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -689,248 +606,312 @@ packages: cpu: [x64] os: [win32] - '@eslint-community/eslint-plugin-eslint-comments@4.8.1': - resolution: {integrity: sha512-vafrdi15JHBFh1qWOAucyJwlEEuhdoMof28jOdKdgnwZ7bXwd6v4wzHYSmAVLwnx0yc7bqAtfJ0EZQV1WAzV/Q==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - '@eslint-community/eslint-utils@4.10.1': - resolution: {integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + '@jridgewell/remapping@2.3.5': + resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - '@eslint-community/regexpp@4.12.2': - resolution: {integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} - '@eslint/compat@2.1.1': - resolution: {integrity: sha512-rMcy8GSrwNzcISX/BlTDY/GLB4eCopEuy9woIls3To+15OLxykZrxxq+WUcylCPCQ6F4MujjBM1DX5V1aqI3Vw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: ^8.40 || 9 || 10 - peerDependenciesMeta: - eslint: - optional: true + '@jridgewell/sourcemap-codec@1.6.0': + resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} - '@eslint/config-array@0.23.5': - resolution: {integrity: sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@eslint/config-helpers@0.5.5': - resolution: {integrity: sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] - '@eslint/config-helpers@0.7.0': - resolution: {integrity: sha512-DObd/KKUsU+FaFv4PLxSRenpXfQWmPXXP3pPZ6/K1PCrMu2vQpMDMuQe/BqYeoLcz8ro0bVDF1RxOJgfVEdhUw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-android-arm-eabi@0.68.0': + resolution: {integrity: sha512-dhfYPbzv/h9JgHjNkl2R6sOjUfxDyLGOZVb3g8/ScaTNwwJcYgmHh8kcYFDUhinuy1QAoANCWUvw1jlk+z6gAg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [android] - '@eslint/core@1.2.1': - resolution: {integrity: sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-android-arm64@0.68.0': + resolution: {integrity: sha512-v3Njdi6qY0O/5eGfg01ww2w6gTn2mUvZ72Bnx1/UN53A9wruh3Nk6otc3WkgJLkXD4Qgz1SOcVQieH1oD03V9Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] - '@eslint/css-tree@4.1.1': - resolution: {integrity: sha512-A+s89eP+yDAPkKjnIOfjlkGifg5J7GPn01nWx14UjBVWktfkxOU4975F9ZZ13xNxkGiblNcBaAqgKhnQrW6SWA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-darwin-arm64@0.68.0': + resolution: {integrity: sha512-ei4MCMzHFREmZwPJ7KuWUB4kBuHdsgDrnXGJVcEAopU7fj7S42I8BChdFILWdHvhFqR08FLJtOfbZIr2CDw0cA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] - '@eslint/markdown@8.0.3': - resolution: {integrity: sha512-rBTSSShrq7e4O+PWfeE4azH4/CWPNrC+VGxBXiW00o3vYVJnznsZiDayj3KC9JztIMVRZxZHn2nrDIUau/4j7A==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-darwin-x64@0.68.0': + resolution: {integrity: sha512-UrKgzZxYhwB9DSvTX+vdgl9M32wLUNKJcAKIoiyx/Kzn/zveqi7W6kYVcRroFMhS3Kwz0KhTk3WBeSuQn4YCTg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] - '@eslint/object-schema@3.0.5': - resolution: {integrity: sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-freebsd-x64@0.68.0': + resolution: {integrity: sha512-6jrEKgpJbilM1QaRv7hEtKXr4p4AK4jvvyOtajwyhu0kOz3e0O7OLnSTk6tBotRqCcUC4ehZRJ1Zx+Y99wieLw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] - '@eslint/plugin-kit@0.7.3': - resolution: {integrity: sha512-IkO+/KEUvwbVpiURZg+P7zF74z5Jxe0UgJxVni+RtoHQ6IZieXaO02kmadomap/q+l6bc/jdPGGqTjhuZnuz1Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@oxfmt/binding-linux-arm-gnueabihf@0.68.0': + resolution: {integrity: sha512-YOIVnKOBaLeGullskS179N12hjSAdFYnzLjOaKiLhAKNWgnShq9w4xRdtmUm6BlnP65l2/EA9Aw/KlftNxDM7Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] - '@humanfs/core@0.19.2': - resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} - engines: {node: '>=18.18.0'} + '@oxfmt/binding-linux-arm-musleabihf@0.68.0': + resolution: {integrity: sha512-xW5XoEHVNqydPBv2KXvk9lmEzyAlOQHVEazKoXUuAacqekjya+OdiaFjjEBl0oJD02raG8g3TRl9OVCh9PDIHA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] - '@humanfs/node@0.16.8': - resolution: {integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==} - engines: {node: '>=18.18.0'} + '@oxfmt/binding-linux-arm64-gnu@0.68.0': + resolution: {integrity: sha512-QCvYwVVQieu6oyJglAgV9vH/YMDxZyR4cwVSYtoq9oOXd5N+D3TDUBjNwxFrLn5AdcJZOcvn/7IB17vJGp+2Og==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] - '@humanfs/types@0.15.0': - resolution: {integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==} - engines: {node: '>=18.18.0'} + '@oxfmt/binding-linux-arm64-musl@0.68.0': + resolution: {integrity: sha512-4TVz5iFQ8ndrHnhX50UXiz9BIWAtUSOHJ6Nus4qWFfJBXq/Ed/krXbF/ehJu42BXM5tIvBs99jNIM22s9agY5A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} + '@oxfmt/binding-linux-ppc64-gnu@0.68.0': + resolution: {integrity: sha512-qLe3ao0RP84bnPxBvRI+GnlK/jybo538NWu0Xrm+zYeTmJtpzqLhnnd5BH21NafqKnplbGZjtN1cnrOlWF73lw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} + '@oxfmt/binding-linux-riscv64-gnu@0.68.0': + resolution: {integrity: sha512-Yvyl7a6gbb0vM6r925KW2dO+/CmXySO5TVbcX7o/uZJ+d108HFOG0TxIyHApmn5USuj5mhDPxzhiVwOlGP7uSA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [glibc] - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + '@oxfmt/binding-linux-riscv64-musl@0.68.0': + resolution: {integrity: sha512-mJlFuFVCxzrYM5sFStN433D/s/mb6Wq4aCQAM02vs/OudHywnaSAd2rb1vlYUJtqdYIciJtiasuxvfbYkv5fLg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [riscv64] + os: [linux] + libc: [musl] - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} + '@oxfmt/binding-linux-s390x-gnu@0.68.0': + resolution: {integrity: sha512-RlfSg++qs1hbKltRR6lYvV9EoI3MdlfSQD9w1hdHVYjHqjIn1tkH4FWOpMSmjKGN20zr+nI+W9o4ARogCDudGQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} + '@oxfmt/binding-linux-x64-gnu@0.68.0': + resolution: {integrity: sha512-nyzRB9U+dlYUKu3pMo3afHzZBUv/oTHZMG36ZfJViNVfOIzp70Q4GS8FFRGgYJ/p0zcyDCgpBvYISOdJOMh+jQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] - '@jridgewell/sourcemap-codec@1.6.0': - resolution: {integrity: sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==} + '@oxfmt/binding-linux-x64-musl@0.68.0': + resolution: {integrity: sha512-iCx3sbZRIvGrL1RafphEiUKBaW1lc0/tAjKOIB/Wjw2+STRBEdu5+fH1Gc1faEWEmc2k5Ks4iUUV54Zd5C9a1A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@oxfmt/binding-openharmony-arm64@0.68.0': + resolution: {integrity: sha512-x2X5AZez7OgyLLFpwIgItoXBUqudDM7yiaTsxv8R8vKQ6e81l0jVw0NFeUCXzcl1sAJq8h+tC8N4mY8EiMeL4w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] - '@keyv/bigmap@1.3.1': - resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} - engines: {node: '>= 18'} - peerDependencies: - keyv: ^5.6.0 + '@oxfmt/binding-win32-arm64-msvc@0.68.0': + resolution: {integrity: sha512-AHVPjXkenLPQUh6kB8zSC8pX2ct9r4T1Edk9r/RNJyov6wPsS5uAfYtipwG4chn6+3bPFG5rI/3DxEeH8vib1w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] - '@keyv/serialize@1.1.1': - resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@oxfmt/binding-win32-ia32-msvc@0.68.0': + resolution: {integrity: sha512-n09SjEk5VH7z8Hl4WVP7hho+cCwGViENkQFiM45vbW85dJd7kEhWaHRUobaPzEmrWyu6uumd4EuNfNyDKLtzDA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ia32] + os: [win32] - '@napi-rs/lzma-linux-x64-gnu@1.5.1': - resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} - engines: {node: ^22.20 || ^24.12 || >=25} + '@oxfmt/binding-win32-x64-msvc@0.68.0': + resolution: {integrity: sha512-gPe+dJLXaPuWPWqlpklDAJp0k+K9KhQPYiQLHfb+i2rmFuUGfJ/5Qlj6tr1mO6of5g0DiLjG/XCFHIaPhotqqA==} + engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] - os: [linux] - libc: [glibc] - - '@ota-meshi/ast-token-store@0.3.0': - resolution: {integrity: sha512-XRO0zi2NIUKq2lUk3T1ecFSld1fMWRKE6naRFGkgkdeosx7IslyUKNv5Dcb5PJTja9tHJoFu0v/7yEpAkrkrTg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} + os: [win32] - '@oxfmt/binding-android-arm-eabi@0.35.0': - resolution: {integrity: sha512-BaRKlM3DyG81y/xWTsE6gZiv89F/3pHe2BqX2H4JbiB8HNVlWWtplzgATAE5IDSdwChdeuWLDTQzJ92Lglw3ZA==} + '@oxlint/binding-android-arm-eabi@1.83.0': + resolution: {integrity: sha512-0yGY24EwsLk5YDe6F+VkmZyRHSwJDALa3nIrPpq7FXmp2lV2d0TzvBCGeZk+wgiULRGr5blhyr4QMp5KCXJUqA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] - '@oxfmt/binding-android-arm64@0.35.0': - resolution: {integrity: sha512-/O+EbuAJYs6nde/anv+aID6uHsGQApyE9JtYBo/79KyU8e6RBN3DMbT0ix97y1SOnCglurmL2iZ+hlohjP2PnQ==} + '@oxlint/binding-android-arm64@1.83.0': + resolution: {integrity: sha512-hHfJ0vc17A4iUjH5p9BsTUPYbYRNxGpvD2lbu1aBRk54bzNIx9o5TtYF39QPZcV95DagZd+4DEAw2RH3G2ZsMg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] - '@oxfmt/binding-darwin-arm64@0.35.0': - resolution: {integrity: sha512-pGqRtqlNdn9d4VrmGUWVyQjkw79ryhI6je9y2jfqNUIZCfqceob+R97YYAoG7C5TFyt8ILdLVoN+L2vw/hSFyA==} + '@oxlint/binding-darwin-arm64@1.83.0': + resolution: {integrity: sha512-hsOjYjszLb/3zym/TkzUMPAoQlTJcuzSyEPOAyA+skXJIX9M0o+4JfOtqopX/Vf4hSLrJ98j0nvFo23gzk8auQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] - '@oxfmt/binding-darwin-x64@0.35.0': - resolution: {integrity: sha512-8GmsDcSozTPjrCJeGpp+sCmS9+9V5yRrdEZ1p/sTWxPG5nYeAfSLuS0nuEYjXSO+CtdSbStIW6dxa+4NM58yRw==} + '@oxlint/binding-darwin-x64@1.83.0': + resolution: {integrity: sha512-mjh5oH2EA+wl5yRJYT9K9G61O2zFlpuv+yf2JwZOi0+dq2FnTUtm1h8i+5Ik0fXPWIu/k84I1psZR9aQsLAnyA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] - '@oxfmt/binding-freebsd-x64@0.35.0': - resolution: {integrity: sha512-QyfKfTe0ytHpFKHAcHCGQEzN45QSqq1AHJOYYxQMgLM3KY4xu8OsXHpCnINjDsV4XGnQzczJDU9e04Zmd8XqIQ==} + '@oxlint/binding-freebsd-x64@1.83.0': + resolution: {integrity: sha512-fNHr64/YaO8YssuoDVC8+F4Uk5enR86q5uxfHkQrjAPs1dbAILOrD2uaud+J7MO8Fx774g44ERLD0IGIvZE48w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': - resolution: {integrity: sha512-u+kv3JD6P3J38oOyUaiCqgY5TNESzBRZJ5lyZQ6c2czUW2v5SIN9E/KWWa9vxoc+P8AFXQFUVrdzGy1tK+nbPQ==} + '@oxlint/binding-linux-arm-gnueabihf@1.83.0': + resolution: {integrity: sha512-Qpwy3zzAwMj+8/lyYItHmkSMwbkprFNWTK7jPYDOxSyxEhaSLOWYUTCMkjF334J8/WD0nznCCsoBbIH6hpsuIw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': - resolution: {integrity: sha512-1NiZroCiV57I7Pf8kOH4XGR366kW5zir3VfSMBU2D0V14GpYjiYmPYFAoJboZvp8ACnZKUReWyMkNKSa5ad58A==} + '@oxlint/binding-linux-arm-musleabihf@1.83.0': + resolution: {integrity: sha512-s+BirYLFq7JL2k9sP0XI3ZXJ9dYvJ8sX3jLCLoag7tt+zrSHpZxP0jqznfL+Gdgwu7ay0dYgGYJXrQvq3iWloA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] - '@oxfmt/binding-linux-arm64-gnu@0.35.0': - resolution: {integrity: sha512-7Q0Xeg7ZnW2nxnZ4R7aF6DEbCFls4skgHZg+I63XitpNvJCbVIU8MFOTZlvZGRsY9+rPgWPQGeUpLHlyx0wvMA==} + '@oxlint/binding-linux-arm64-gnu@1.83.0': + resolution: {integrity: sha512-7lihXt3vKr+GIyapNbHrnFHm/biiW30le6Zv/DExbAFPF6YwCQXVFlONPFehxs0CpGO4CBfYPM9rdDT+XMoIlg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-arm64-musl@0.35.0': - resolution: {integrity: sha512-5Okqi+uhYFxwKz8hcnUftNNwdm8BCkf6GSCbcz9xJxYMm87k1E4p7PEmAAbhLTk7cjSdDre6TDL0pDzNX+Y22Q==} + '@oxlint/binding-linux-arm64-musl@1.83.0': + resolution: {integrity: sha512-q63JalLYVkZiZvls1z3PPUnpmQluOMXp0khqQMznCeAPLGydfNY8JhvuA4WlK57JfrvikU8wB5lPVveqpIXvew==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': - resolution: {integrity: sha512-9k66pbZQXM/lBJWys3Xbc5yhl4JexyfqkEf/tvtq8976VIJnLAAL3M127xHA3ifYSqxdVHfVGTg84eiBHCGcNw==} + '@oxlint/binding-linux-ppc64-gnu@1.83.0': + resolution: {integrity: sha512-krQmDF+dRbxvdqVPV88ZuOoPPu8X5BuqDA8Hd+qcS4YMRQCb+nexA57DazgGsc/rGdKBe3QmV0mnv0bdpW/p5g==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': - resolution: {integrity: sha512-aUcY9ofKPtjO52idT6t0SAQvEF6ctjzUQa1lLp7GDsRpSBvuTrBQGeq0rYKz3gN8dMIQ7mtMdGD9tT4LhR8jAQ==} + '@oxlint/binding-linux-riscv64-gnu@1.83.0': + resolution: {integrity: sha512-MmOl8Y6txEAXZU1RG8Rr264jQ6D7VPmqFsU/45x/FeWsGe32hklTqGrLE6UxHzp5Rjt0wP+20tY8YXKgSFB3mw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-riscv64-musl@0.35.0': - resolution: {integrity: sha512-C6yhY5Hvc2sGM+mCPek9ZLe5xRUOC/BvhAt2qIWFAeXMn4il04EYIjl3DsWiJr0xDMTJhvMOmD55xTRPlNp39w==} + '@oxlint/binding-linux-riscv64-musl@1.83.0': + resolution: {integrity: sha512-u1rMymh0W3JZkq370kzQsYPULGWqhE09pZRqnZvUSoYaI9pVO5yVX+iYIslmWuEgwuzH9YAaOsScJiobWCHoOw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] - '@oxfmt/binding-linux-s390x-gnu@0.35.0': - resolution: {integrity: sha512-RG2hlvOMK4OMZpO3mt8MpxLQ0AAezlFqhn5mI/g5YrVbPFyoCv9a34AAvbSJS501ocOxlFIRcKEuw5hFvddf9g==} + '@oxlint/binding-linux-s390x-gnu@1.83.0': + resolution: {integrity: sha512-y0zK3HNwGysu7rqtE+BQG/d0bx5gh/KwlOtghN8oWeK1KcWzeaLqtZrbm8owqdma1lFyrce/hTO5ismuNu+INQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-gnu@0.35.0': - resolution: {integrity: sha512-wzmh90Pwvqj9xOKHJjkQYBpydRkaXG77ZvDz+iFDRRQpnqIEqGm5gmim2s6vnZIkDGsvKCuTdtxm0GFmBjM1+w==} + '@oxlint/binding-linux-x64-gnu@1.83.0': + resolution: {integrity: sha512-rS5gM0NgD7ngmuJmbIehsidtrOwKkLFwCQbKEeb9KuyQrrWNq5Zkn0uV6AYdXOMJ0grrWEiLwBuvMxt8w5vsNw==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] - '@oxfmt/binding-linux-x64-musl@0.35.0': - resolution: {integrity: sha512-+HCqYCJPCUy5I+b2cf+gUVaApfgtoQT3HdnSg/l7NIcLHOhKstlYaGyrFZLmUpQt4WkFbpGKZZayG6zjRU0KFA==} + '@oxlint/binding-linux-x64-musl@1.83.0': + resolution: {integrity: sha512-W2IH4EtpcPaWcvNGCA95YoDg4vxqE/ZiPCi3arrxEEpsK7+JQN9WYwrlYFx9pcdP6KPXqRqkv3zdQPHcx7b6YQ==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] - '@oxfmt/binding-openharmony-arm64@0.35.0': - resolution: {integrity: sha512-kFYmWfR9YL78XyO5ws+1dsxNvZoD973qfVMNFOS4e9bcHXGF7DvGC2tY5UDFwyMCeB33t3sDIuGONKggnVNSJA==} + '@oxlint/binding-openharmony-arm64@1.83.0': + resolution: {integrity: sha512-6LyKkUyoajssTPLlZmDbZIbu4IZ5B4bGuRUnBgCGpEvHP3FQMaYITncHA/unPUo7q+Z+pIu2HhdkQ+8d1SG7iA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] - '@oxfmt/binding-win32-arm64-msvc@0.35.0': - resolution: {integrity: sha512-uD/NGdM65eKNCDGyTGdO8e9n3IHX+wwuorBvEYrPJXhDXL9qz6gzddmXH8EN04ejUXUujlq4FsoSeCfbg0Y+Jg==} + '@oxlint/binding-win32-arm64-msvc@1.83.0': + resolution: {integrity: sha512-Uz/fObEtF0jmNJQJ8CGRBKfefYstS0/wjD3s6IGzP8nUwsJykHQJBiN3npHwKiGRGn/vvBEgNr4B3cCzmmatvg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] - '@oxfmt/binding-win32-ia32-msvc@0.35.0': - resolution: {integrity: sha512-oSRD2k8J2uxYDEKR2nAE/YTY9PobOEnhZgCmspHu0+yBQ665yH8lFErQVSTE7fcGJmJp/cC6322/gc8VFuQf7g==} + '@oxlint/binding-win32-ia32-msvc@1.83.0': + resolution: {integrity: sha512-u7XcvPW6Bk58tY5iWs2ESb0vJjoE/kuSpHxopbwp/p3ZtWVQXZ6wor5w3ssVTHOqd/v8b+QdhSFWQ4grEUNWpA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] - '@oxfmt/binding-win32-x64-msvc@0.35.0': - resolution: {integrity: sha512-WCDJjlS95NboR0ugI2BEwzt1tYvRDorDRM9Lvctls1SLyKYuNRCyrPwp1urUPFBnwgBNn9p2/gnmo7gFMySRoQ==} + '@oxlint/binding-win32-x64-msvc@1.83.0': + resolution: {integrity: sha512-LZRubd7ph13QmAg4fFecTYVZkiYbROR2Htaxh/ufWRkDhPOm2wrwaEYR89e0YpPFD3dqBrPoxS7myBw5hmYA7Q==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] - '@pkgr/core@0.3.6': - resolution: {integrity: sha512-SEeaJLb3qBNF/OaXnaR1NmmBbFYk1zC0ZH/52fATcRPLFg/p791YrcyFFy44Bo9sLaGuSuLp5Q6axbb/O+v/RA==} - engines: {node: ^14.18.0 || >=16.0.0} - '@quansync/fs@1.1.0': resolution: {integrity: sha512-qAPG/t3HqML1TlN7sY/pTbEjzFVAKsMjNNMGheyDosro+kT4iw2KCUoHcVdmliwWjorm4elZbgNQyU2eD97eDg==} + '@redis/bloom@6.2.1': + resolution: {integrity: sha512-huQgNLaCIZfQ9SeLn4q9124uOUd8HbZDYHwwUzNcRgHqCHiHKl2dDxMqJCeWh8cMqZAoWuHR8XnWbDMIf+o7ag==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/client@6.2.1': + resolution: {integrity: sha512-LzxBY7SIBvvJiyCgcaJZZakE3fJrZZ++i24+EDW9fKpCl68D35uJcKFpZZwCfOoG9WZTbyZlMzMeM0gtOAMU9Q==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@node-rs/xxhash': ^1.1.0 + '@opentelemetry/api': '>=1 <2' + peerDependenciesMeta: + '@node-rs/xxhash': + optional: true + '@opentelemetry/api': + optional: true + + '@redis/json@6.2.1': + resolution: {integrity: sha512-AFIUJ8Gj0DaaSBHYuSt8+O0oYWM+50OK1c0OmodB7XERIA8+BbyV3O4v76f9iccWasd1/7qjfZTpuzexUaZtrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/search@6.2.1': + resolution: {integrity: sha512-2vfOAOyYFE7UUw3sBBlkqqruBtOUS4HRY5MtW4hp83llrwvtrTE4r22CEqXddlV+54zkLxBE4nmsIJ/dpezQrQ==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + + '@redis/time-series@6.2.1': + resolution: {integrity: sha512-kiYniph04dJOole+L359B6C9E+jYS2uDP7hca6Onj0xF38ZIpyxARO0Iq0W4ZRn1e8Q6vqW00QFZVSMRA/2Ijw==} + engines: {node: '>= 20.0.0'} + peerDependencies: + '@redis/client': ^6.2.1 + '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} engines: {node: '>=14.0.0'} @@ -1123,113 +1104,147 @@ packages: cpu: [x64] os: [win32] - '@sindresorhus/base62@1.0.0': - resolution: {integrity: sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA==} - engines: {node: '>=18'} - - '@stylistic/eslint-plugin@5.10.0': - resolution: {integrity: sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} - '@types/debug@4.1.13': - resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} - '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/esrecurse@4.3.1': - resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} - '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} - '@types/hast@3.0.5': - resolution: {integrity: sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==} + '@types/node@26.6.2': + resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + '@types/resolve@1.20.2': + resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} - '@types/katex@0.16.8': - resolution: {integrity: sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==} + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} - '@types/mdast@4.0.4': - resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@typescript/typescript-aix-ppc64@7.0.2': + resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [aix] - '@types/ms@2.1.0': - resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@typescript/typescript-darwin-arm64@7.0.2': + resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [darwin] - '@types/node@26.6.2': - resolution: {integrity: sha512-X1P21scMv4zGKLYqjdGjaKa7COa0RKVYYZZN/NfvLQ1JegxFhdhpZG/Lyn8AXx6CDUavKAd11v6BvfpkDByK8g==} + '@typescript/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] - '@types/resolve@1.20.2': - resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@typescript/typescript-freebsd-arm64@7.0.2': + resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [freebsd] - '@types/unist@3.0.3': - resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript/typescript-freebsd-x64@7.0.2': + resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [freebsd] - '@typescript-eslint/eslint-plugin@8.70.0': - resolution: {integrity: sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.70.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-linux-arm64@7.0.2': + resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [linux] - '@typescript-eslint/parser@8.70.0': - resolution: {integrity: sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] - '@typescript-eslint/project-service@8.70.0': - resolution: {integrity: sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-linux-loong64@7.0.2': + resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==} + engines: {node: '>=16.20.0'} + cpu: [loong64] + os: [linux] + + '@typescript/typescript-linux-mips64el@7.0.2': + resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==} + engines: {node: '>=16.20.0'} + cpu: [mips64el] + os: [linux] - '@typescript-eslint/scope-manager@8.70.0': - resolution: {integrity: sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-linux-ppc64@7.0.2': + resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==} + engines: {node: '>=16.20.0'} + cpu: [ppc64] + os: [linux] - '@typescript-eslint/tsconfig-utils@8.70.0': - resolution: {integrity: sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-linux-riscv64@7.0.2': + resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==} + engines: {node: '>=16.20.0'} + cpu: [riscv64] + os: [linux] - '@typescript-eslint/type-utils@8.70.0': - resolution: {integrity: sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-linux-s390x@7.0.2': + resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==} + engines: {node: '>=16.20.0'} + cpu: [s390x] + os: [linux] - '@typescript-eslint/types@8.70.0': - resolution: {integrity: sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript-linux-x64@7.0.2': + resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [linux] - '@typescript-eslint/typescript-estree@8.70.0': - resolution: {integrity: sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-netbsd-arm64@7.0.2': + resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [netbsd] - '@typescript-eslint/utils@8.70.0': - resolution: {integrity: sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - typescript: '>=4.8.4 <6.1.0' + '@typescript/typescript-netbsd-x64@7.0.2': + resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [netbsd] + + '@typescript/typescript-openbsd-arm64@7.0.2': + resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [openbsd] + + '@typescript/typescript-openbsd-x64@7.0.2': + resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [openbsd] + + '@typescript/typescript-sunos-x64@7.0.2': + resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [sunos] + + '@typescript/typescript-win32-arm64@7.0.2': + resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==} + engines: {node: '>=16.20.0'} + cpu: [arm64] + os: [win32] + + '@typescript/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] - '@typescript-eslint/visitor-keys@8.70.0': - resolution: {integrity: sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript/typescript6@6.0.2': + resolution: {integrity: sha512-mbCddXd+jm7hfx7w2YU64/Av4/NqqeG3GoRZgxPcgoTxYjhrcfJRw9ULch71SS4G+Q3bOXFhRvPqjguN0Hyp5w==} + hasBin: true '@vitest/coverage-v8@5.0.1': resolution: {integrity: sha512-FRC8ACiudC3dI6MTplzRSYWHDRnIv2IPfbzs4FdoJNsMal/35sWV8hwIfV8ZcqzSPy+uXHeMVONt9CEqtOU17w==} @@ -1240,22 +1255,6 @@ packages: '@vitest/browser': optional: true - '@vitest/eslint-plugin@1.6.27': - resolution: {integrity: sha512-X1RCAfwbatG4GFbJ/1PIHP9MSZMt0JJThqbYID+vS4L783k6QGlLPe421wQE8dHXuGCcenVLsydiM4qzsYWxhg==} - engines: {node: '>=18'} - peerDependencies: - '@typescript-eslint/eslint-plugin': '*' - eslint: '>=8.57.0' - typescript: '>=5.0.0' - vitest: '*' - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - typescript: - optional: true - vitest: - optional: true - '@vitest/istanbul-lib-coverage@1.0.1': resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} engines: {node: '>=22'} @@ -1278,26 +1277,6 @@ packages: '@vitest/spy@5.0.1': resolution: {integrity: sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==} - '@vue/compiler-core@3.5.43': - resolution: {integrity: sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==} - - '@vue/compiler-dom@3.5.43': - resolution: {integrity: sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==} - - '@vue/compiler-sfc@3.5.43': - resolution: {integrity: sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==} - - '@vue/compiler-ssr@3.5.43': - resolution: {integrity: sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==} - - '@vue/shared@3.5.43': - resolution: {integrity: sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.18.0: resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} @@ -1307,9 +1286,6 @@ packages: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - ajv@6.15.0: - resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==} - ansis@4.4.0: resolution: {integrity: sha512-9k3v7xcHwgdO/DruxGIg4HtjvlAZlcnsX/mzqUb1t3NkYnl9kK2UJ+Gq0io+vQf7iT//BD/HB/NBkUR1LWxoeA==} engines: {node: '>=14'} @@ -1318,10 +1294,6 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - are-docs-informative@0.1.1: - resolution: {integrity: sha512-sqRsNQBwbKLRX0jV5Cu5uzmtflf892n4Vukz7T659ebL4pz3mpOqCMU7lxMoBTFwnp10E3YB5ZcyHM41W5bcDA==} - engines: {node: '>=18'} - args-tokenizer@0.3.0: resolution: {integrity: sha512-xXAd7G2Mll5W8uo37GETpQ2VrE84M181Z7ugHFGQnJZ50M2mbOv0osSZ9VsSgPfJQ+LVG0prSi0th+ELMsno7Q==} @@ -1345,10 +1317,6 @@ packages: axios@1.20.0: resolution: {integrity: sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==} - balanced-match@4.0.4: - resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} - engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.25: resolution: {integrity: sha512-gMmEShwwq7FJqMwvfRwvCl00v4kN+KOfJqXn+f4nrufak5gNHJOksd/60Dvjuz7sI8Y5WiSFBa8FEYr+zoyqCw==} engines: {node: '>=6.0.0'} @@ -1361,10 +1329,6 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@5.0.12: - resolution: {integrity: sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==} - engines: {node: 20 || >=22} - braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -1374,10 +1338,6 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - builtin-modules@5.4.0: - resolution: {integrity: sha512-JCWSCdun+4Ovd9BhM/Vtlmwb7oD6Wl4YiPRXXwhAuwlPhW1un/MKgXn7GZoFm53ginnoNzus69V8JWx0K393jg==} - engines: {node: '>=18.20'} - bumpp@12.3.0: resolution: {integrity: sha512-YgW09sgbIGQmAD/Jk7FEGP0tm0PeDz+Zgk+IAk+MOHhb/98pj/l3CgkxSjd7C1O99JPxTNwS+bPOTNaHuNh6lQ==} engines: {node: ^22.18.0 || ^24.11.0 || >=26.0.0} @@ -1407,9 +1367,6 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} - cacheable@2.5.0: - resolution: {integrity: sha512-60cyAOytib/OzBw1JNSoSV/boK1AtHryDIjvVBk7XbN4ugfkM3+Sry7fEjNgPMGgOjuaZPAp8ruZ0Cxafwyq9g==} - call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1420,16 +1377,10 @@ packages: caniuse-lite@1.0.30001810: resolution: {integrity: sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==} - ccount@2.0.1: - resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} - chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} - change-case@5.4.4: - resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} - changelogen@0.5.7: resolution: {integrity: sha512-cTZXBcJMl3pudE40WENOakXkcVtrbBpbkmSkM20NdRiUqa4+VYRdXdEsgQ0BNQ6JBE2YymTNWtPKVF7UCTN5+g==} hasBin: true @@ -1439,9 +1390,6 @@ packages: engines: {node: ^22.19.0 || ^24.11.0 || >=26.0.0} hasBin: true - character-entities@2.0.2: - resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} - chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} @@ -1454,13 +1402,13 @@ packages: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} - ci-info@4.4.0: - resolution: {integrity: sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg==} - engines: {node: '>=8'} - citty@0.1.6: resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} + cluster-key-slot@1.1.2: + resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} + engines: {node: '>=0.10.0'} + colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} @@ -1472,18 +1420,6 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - commander@8.3.0: - resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} - engines: {node: '>= 12'} - - comment-parser@1.4.7: - resolution: {integrity: sha512-0h+uSNtQGW3D98eQt3jJ8L06Fves8hncB4V/PKdw/Qb8Hnk19VaKuTr55UNRYiSoVa7WwrFls+rh3ux9agmkeQ==} - engines: {node: '>= 12.0.0'} - - comment-parser@1.4.9: - resolution: {integrity: sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==} - engines: {node: '>= 12.0.0'} - commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -1503,21 +1439,9 @@ packages: convert-gitmoji@0.1.5: resolution: {integrity: sha512-4wqOafJdk2tqZC++cjcbGcaJ13BZ3kwldf06PTiAQRAB76Z1KJwZNL1SaRZMi2w1FM9RYTgZ6QErS8NUl/GBmQ==} - convert-hrtime@5.0.0: - resolution: {integrity: sha512-lOETlkIeYSJWcbbcvjRKGxVMXJR+8+OQb/mTPbA4ObPMytYIsUbuOE0Jzy60hjARYszq1id0j8KgVhC+WGZVTg==} - engines: {node: '>=12'} - convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} - core-js-compat@3.50.0: - resolution: {integrity: sha512-XGpFGbMLHwSt74YLTKho7Ib242qi6O8MSX+sRokV4oz7iKXvQWGYZthjIhjRGMxjzVkAubBO512dKGYcefmX3Q==} - engines: {node: '>=6.4.0'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - css-declaration-sorter@7.4.0: resolution: {integrity: sha512-LTuzjPoyA2vMGKKcaOqKSp7Ub2eGrNfKiZH4LpezxpNrsICGCSFvsQOI29psISxNZtaXibkC2CXzrQ5enMeGGw==} engines: {node: ^14 || ^16 || >=18} @@ -1575,12 +1499,6 @@ packages: supports-color: optional: true - decode-named-character-reference@1.3.0: - resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - deepmerge@4.3.1: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} @@ -1604,24 +1522,9 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} - dequal@2.0.3: - resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} - engines: {node: '>=6'} - destr@2.0.5: resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==} - detect-indent@7.0.2: - resolution: {integrity: sha512-y+8xyqdGLL+6sh0tVeHcfP/QDd8gUgbasolJJpY7NgeQGSZ739bDtSiaiDgtoicy+mtYB81dKLxO9xRhCyIB3A==} - engines: {node: '>=12.20'} - - devlop@1.1.0: - resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} - - diff-sequences@29.6.3: - resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} - engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dom-serializer@2.0.0: resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==} @@ -1647,29 +1550,16 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + effect@4.0.0-rc.116: + resolution: {integrity: sha512-nawqJHSjHV8XIBRZNZ+D7cLZpN3kkSjzy6aiT9ofKENsl7xAMFKEoDa0itN5JFl3GUQ1PIN1T1HNlva5+xyO/A==} + electron-to-chromium@1.5.433: resolution: {integrity: sha512-5lCAbyZBjtmUt/RAGHRqrL2q0oEFRThDAsZHHDn9XHa89Qw7gMYOeSicBTy+AHfvo0r6vwsZvqNJTQIQy1BLzA==} - empathic@2.1.0: - resolution: {integrity: sha512-AnfC1ATldl49/cvZdLPDjBfrRNwbDO05aibiOtzQu3qtlbJtomNLhF30HEtn/7iBz50dlMECqATo3fG0LrdEgw==} - engines: {node: '>=14'} - - enhanced-resolve@5.25.1: - resolution: {integrity: sha512-nGXts5znJzmWPu+mIE9izCOzdg63oJca2mDzGWWTth7sr4aCToKcoyFVBQwN75Ij5Pf6p510EwkTqViTRzDV+w==} - engines: {node: '>=10.13.0'} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} - entities@7.0.1: - resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} - engines: {node: '>=0.12'} - - entities@8.1.0: - resolution: {integrity: sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==} - engines: {node: '>=20.19.0'} - es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -1703,235 +1593,12 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - escape-string-regexp@5.0.0: - resolution: {integrity: sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==} - engines: {node: '>=12'} - - eslint-compat-utils@0.5.1: - resolution: {integrity: sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==} - engines: {node: '>=12'} - peerDependencies: - eslint: '>=6.0.0' - - eslint-config-flat-gitignore@2.4.0: - resolution: {integrity: sha512-JhYC+V7qEDiCDsbJcVP8fxNc62fk4+i91YLP/YvmVHlkaQ8Xo99olYN8MO5COdxl7onGlsrfZVONe5NYsZP1UA==} - peerDependencies: - eslint: ^9.5.0 || ^10.0.0 - - eslint-flat-config-utils@3.2.0: - resolution: {integrity: sha512-PHgo1X5uqIorJONLVD9BIaOSdoYFD3z/AeJljdqDPlWVRpeCYkDbK9k0AXoYVqqNJr6FEYIEr5Rm2TSktLQcHw==} - - eslint-formatting-reporter@0.0.0: - resolution: {integrity: sha512-k9RdyTqxqN/wNYVaTk/ds5B5rA8lgoAmvceYN7bcZMBwU7TuXx5ntewJv81eF3pIL/CiJE+pJZm36llG8yhyyw==} - peerDependencies: - eslint: '>=8.40.0' - - eslint-json-compat-utils@0.2.3: - resolution: {integrity: sha512-RbBmDFyu7FqnjE8F0ZxPNzx5UaptdeS9Uu50r7A+D7s/+FCX+ybiyViYEgFUaFIFqSWJgZRTpL5d8Kanxxl2lQ==} - engines: {node: '>=12'} - peerDependencies: - '@eslint/json': '*' - eslint: '*' - jsonc-eslint-parser: ^2.4.0 || ^3.0.0 - peerDependenciesMeta: - '@eslint/json': - optional: true - - eslint-merge-processors@2.0.0: - resolution: {integrity: sha512-sUuhSf3IrJdGooquEUB5TNpGNpBoQccbnaLHsb1XkBLUPPqCNivCpY05ZcpCOiV9uHwO2yxXEWVczVclzMxYlA==} - peerDependencies: - eslint: '*' - - eslint-parser-plain@0.1.1: - resolution: {integrity: sha512-KRgd6wuxH4U8kczqPp+Oyk4irThIhHWxgFgLDtpgjUGVIS3wGrJntvZW/p6hHq1T4FOwnOtCNkvAI4Kr+mQ/Hw==} - - eslint-plugin-antfu@3.2.3: - resolution: {integrity: sha512-U2fnz/H0gFPxpuC7QpaHa0Jv2AgCZ5hunp36SOP/yWo8yFzgvMh8X4pZ4uN4IKoqtBhk7G3HuVa93Urf51+sZg==} - peerDependencies: - eslint: '*' - - eslint-plugin-command@4.0.0: - resolution: {integrity: sha512-mM1UdKu39YF5nUBVbA+PaVvpB3R3Niwjes8CQ8rJTTbgIThgrfQILgDqmtpAAQL1xGaolCoES1SfiwzwPVGkXQ==} - engines: {node: ^22.22.2 || >=24.15.0} - peerDependencies: - '@typescript-eslint/typescript-estree': '*' - '@typescript-eslint/utils': '*' - eslint: '*' - - eslint-plugin-es-x@7.8.0: - resolution: {integrity: sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==} - engines: {node: ^14.18.0 || >=16.0.0} - peerDependencies: - eslint: '>=8' - - eslint-plugin-format@2.0.1: - resolution: {integrity: sha512-0BA65p5DAiuKtx5MmMJfPk9WaTjoHHbyVW7ZXRhaZoA1fdiMHhay9QRiDL2wr0hJWZxdF7CRThOK/70VUKVg2g==} - peerDependencies: - eslint: ^8.40.0 || ^9.0.0 || ^10.0.0 - - eslint-plugin-import-lite@0.6.0: - resolution: {integrity: sha512-80vevx2A7i3H7n1/6pqDO8cc5wRz6OwLDvIyVl9UflBV1N1f46e9Ihzi65IOLYoSxM6YykK2fTw1xm0Ixx6aTQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - - eslint-plugin-jsdoc@64.5.4: - resolution: {integrity: sha512-xfRhXPSSWT612muJ6XTvxuVJ8zYHv99qNgBqPVqF28aSqoy4s4XbSkrWXnYA2hl5ejC02yEpk6fqkj3BmJ6Xbg==} - engines: {node: ^22.22.2 || >=24.15.0} - peerDependencies: - eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - eslint-plugin-jsonc@3.4.2: - resolution: {integrity: sha512-q5xzjCYFQuhFomTbctXzd3STfDJ8XduvaigjQmMEbP2gzSKrc58y3Fv6D775/cIK1/sRUn+4kpljiU2iW8k0zw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: '>=9.38.0' - - eslint-plugin-n@18.3.0: - resolution: {integrity: sha512-cPVguuDe6DrIPb/qUXHf8P89MaVTUmiYWwpt5gX5AILsvRIiZAxMFXcFR6QHYBksqKJpjfUBlL/RleCJUWcD7w==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: '>=8.57.1' - ts-declaration-location: ^1.0.6 - typescript: '>=5.0.0' - peerDependenciesMeta: - ts-declaration-location: - optional: true - typescript: - optional: true - - eslint-plugin-no-only-tests@3.4.0: - resolution: {integrity: sha512-4S3/9Nb7A2tiMcpzEQE9bQSlpeOz6WJkgryBuou/SA8W2x2c8Zf4j0NvTKBjv6qNhF9T79tmkecm/0CHqV0UGg==} - engines: {node: '>=5.0.0'} - - eslint-plugin-perfectionist@5.11.1: - resolution: {integrity: sha512-xloHScnOpin9Jy3CegfzoBeY33ASk0spLyM2sJs0LyNHQjv9m0osUD2PYNT7NrJieZbUR2Ebn4A5Q0+0dOcv+A==} - engines: {node: ^20.0.0 || >=22.0.0} - peerDependencies: - eslint: ^8.45.0 || ^9.0.0 || ^10.0.0 - - eslint-plugin-pnpm@1.9.1: - resolution: {integrity: sha512-8++1Egww2cqc9Wylmt7P+xXNMQVCosAZ4m4W4Gc0V5lGea8HFByHTRvUI+ZvvpO8fLXGg2O4MQc90EhuI3mMdQ==} - peerDependencies: - eslint: ^9.0.0 || ^10.0.0 - - eslint-plugin-regexp@3.3.0: - resolution: {integrity: sha512-TT0JTQbW7CRKohntpGAsMdQ1K3MsmJK4gdAhKJtIwlJ0JvuYxauymFgJdvqxIY4lqNj0EuRZWVrymQtEdJYEFg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: '>=9.38.0' - - eslint-plugin-toml@1.5.0: - resolution: {integrity: sha512-qBjRywEkKxO2uYOjus//6GVF1r+Hg5QDkRO8RTY6XcaXgWfBU0DhwpFmJa2Ljf0Sz49r7DdZlpKwwHmJ4nmH1Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - peerDependencies: - eslint: '>=9.38.0' - - eslint-plugin-unicorn@74.0.0: - resolution: {integrity: sha512-AGnsGi2SxHg1HEAXxn9nSnZfyjvTWkxm8E8hpd/9tD6dLjBUdcD7+D6ZN64HmmCXTSXlrwVyUqe20Uyb2CaurA==} - engines: {node: '>=22'} - peerDependencies: - eslint: '>=10.4' - - eslint-plugin-unused-imports@4.4.1: - resolution: {integrity: sha512-oZGYUz1X3sRMGUB+0cZyK2VcvRX5lm/vB56PgNNcU+7ficUCKm66oZWKUubXWnOuPjQ8PvmXtCViXBMONPe7tQ==} - peerDependencies: - '@typescript-eslint/eslint-plugin': ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 - eslint: ^10.0.0 || ^9.0.0 || ^8.0.0 - peerDependenciesMeta: - '@typescript-eslint/eslint-plugin': - optional: true - - eslint-plugin-vue@10.11.0: - resolution: {integrity: sha512-fGt38SsaPCZ2FmA3bX/3vOW2qdsPNx0/9x+7Aab6NMX++PhynMtK4LWC4vzXgfQBCqpUAler18H38GA+NBAd0Q==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@stylistic/eslint-plugin': ^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0 - '@typescript-eslint/parser': ^7.0.0 || ^8.0.0 - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - vue-eslint-parser: ^10.3.0 - peerDependenciesMeta: - '@stylistic/eslint-plugin': - optional: true - '@typescript-eslint/parser': - optional: true - - eslint-plugin-yml@3.8.1: - resolution: {integrity: sha512-E/70psRwxz5EJ8dBtzrFfqSiiInuSytbdtFCjueb/GcKjsWzPBfxfhtQePImMAPjHwMA/VDurO7DJwStDJ4wWg==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} - peerDependencies: - eslint: '>=9.38.0' - - eslint-processor-vue-blocks@2.0.0: - resolution: {integrity: sha512-u4W0CJwGoWY3bjXAuFpc/b6eK3NQEI8MoeW7ritKj3G3z/WtHrKjkqf+wk8mPEy5rlMGS+k6AZYOw2XBoN/02Q==} - peerDependencies: - '@vue/compiler-sfc': ^3.3.0 - eslint: '>=9.0.0' - - eslint-scope@9.1.2: - resolution: {integrity: sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@5.0.1: - resolution: {integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - eslint@10.11.0: - resolution: {integrity: sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - espree@11.2.0: - resolution: {integrity: sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - esquery@1.7.0: - resolution: {integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==} - engines: {node: '>=0.10'} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} estree-walker@3.0.3: resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - expect-type@1.4.0: resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} engines: {node: '>=12.0.0'} @@ -1939,30 +1606,6 @@ packages: exsolve@1.1.1: resolution: {integrity: sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-diff@1.3.0: - resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fast-string-truncated-width@3.0.3: - resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} - - fast-string-width@3.0.2: - resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} - - fast-wrap-ansi@0.2.2: - resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} - - fault@2.0.1: - resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==} - fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -1972,21 +1615,10 @@ packages: picomatch: optional: true - file-entry-cache@11.1.5: - resolution: {integrity: sha512-+PFTHITI08JIGhnNpGNI8T8inUpgZfk3GNEqfT9R2zZV2iFXg3CvqzSl/uEhs7TSGujYRELEANyDvS8Fj7+S7Q==} - fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - find-up-simple@1.0.1: - resolution: {integrity: sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==} - engines: {node: '>=18'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - find-up@6.3.0: resolution: {integrity: sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -1994,12 +1626,6 @@ packages: fix-dts-default-cjs-exports@1.0.1: resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} - flat-cache@6.1.23: - resolution: {integrity: sha512-f++BY9pTk+983xK1FLzlLpmM0i0z+jHmx3QESGkURMXujQZz1k5wzwX6hjnQ8goaD0B+sYnDK1yZ6MTyZfUaqA==} - - flatted@3.4.4: - resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} - follow-redirects@1.16.0: resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==} engines: {node: '>=4.0'} @@ -2013,10 +1639,6 @@ packages: resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} engines: {node: '>= 6'} - format@0.2.2: - resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} - engines: {node: '>=0.4.x'} - fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} @@ -2032,10 +1654,6 @@ packages: function-bind@1.1.2: resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} - function-timeout@1.0.2: - resolution: {integrity: sha512-939eZS4gJ3htTHAldmyyuzlrD58P03fHG49v2JfFXbV6OhvZKRC9j2yAtdHw/zrp2zXHuv05zMIy40F0ge7spA==} - engines: {node: '>=18'} - get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2044,9 +1662,6 @@ packages: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.3: - resolution: {integrity: sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==} - giget@1.2.5: resolution: {integrity: sha512-r1ekGw/Bgpi3HLV3h1MRBIlSAdHoIMklpaQ3OQLFcRw9PwAj2rqigvIbg+dBUI51OxVI2jsEtDywDBjSiuf7Ug==} hasBin: true @@ -2055,35 +1670,14 @@ packages: resolution: {integrity: sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg==} hasBin: true - github-slugger@2.0.0: - resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} - glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@15.15.0: - resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} - engines: {node: '>=18'} - - globals@17.12.0: - resolution: {integrity: sha512-cezEd/DTyyht9cvSSURyygXPfy04GtWO/5e6ZPvH7fCtjKz9PYOmuawphw1Ctd1f6C+5JypXfGD7ahNMXvevBA==} - engines: {node: '>=18'} - - globrex@0.1.2: - resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} - gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - has-symbols@1.1.0: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} @@ -2092,10 +1686,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hashery@1.5.1: - resolution: {integrity: sha512-iZyKG96/JwPz1N55vj2Ie2vXbhu440zfUfJvSwEqEbeLluk7NnapfGqa7LH0mOsnDxTF85Mx8/dyR6HfqcbmbQ==} - engines: {node: '>=20'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -2103,47 +1693,14 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} - hookified@1.15.1: - resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} - - hookified@2.2.0: - resolution: {integrity: sha512-p/LgFzRN5FeoD3DLS6bkUapeye6E4SI6yJs6KetENd18S+FBthqYq2amJUWpt5z0EQwwHemidjY5OqJGEKm5uA==} - - html-entities@2.6.0: - resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} - https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} - identifier-regex@1.1.0: - resolution: {integrity: sha512-SLX4H/vtcYlYnL7XqnuJKHU7Z8517TgsW9nmQiGOgMCjQ8V/deLYu6bEmbGoXe7WMMhc9+EUGyFFneHja8KabA==} - engines: {node: '>=18'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.9: - resolution: {integrity: sha512-brTTsvFRt5C1gGHtPst/281UjPD5t9fBqbgoMPlVWy11ZLTPfu7HxK4ZYqO9H7o/yC9rSTCI85EaQ4OoY12qYw==} - engines: {node: '>= 4'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - indent-string@5.0.0: - resolution: {integrity: sha512-m6FAo/spmsW2Ab2fU35JTYwtOKa2yAwXSwgjSv1TJzh4Mh7mC3lzAOVLBprb72XsTrgkEIsl7YrFNAiDiRhIGg==} - engines: {node: '>=12'} - is-binary-path@2.1.0: resolution: {integrity: sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==} engines: {node: '>=8'} - is-builtin-module@5.0.0: - resolution: {integrity: sha512-f4RqJKBUe5rQkJ2eJEJBXSticB3hGbN9j0yxxMQFqIW89Jp9WYFtzfTcRlstDKVUTRzSOTLKRfO9vIztenwtxA==} - engines: {node: '>=18.20'} - is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} @@ -2161,10 +1718,6 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} - is-identifier@1.1.0: - resolution: {integrity: sha512-NhOds0mDx9lJu+1lBRO0xbwFo5nobA7GCk/0e5xjr6+6XugX985+0OyGX35BNrTkPAsdLcIKg02HUQJOK8D8kw==} - engines: {node: '>=18'} - is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -2184,9 +1737,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - jiti@1.21.7: resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==} hasBin: true @@ -2198,46 +1748,12 @@ packages: js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} - jsdoc-type-pratt-parser@9.0.1: - resolution: {integrity: sha512-1LJ/negRKJxH0+ulOsJYEpUgzIs1DAOz9QctuS4In2LW/Ro3l8C4ej5al+5FU2hBGEOAY3R3SCnP6x8226JMIw==} - engines: {node: ^22.22.2 || >=24.15.0} - - jsdoc-type-pratt-parser@9.2.2: - resolution: {integrity: sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==} - engines: {node: ^22.22.2 || >=24.15.0} - - jsesc@3.1.0: - resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} - engines: {node: '>=6'} - hasBin: true - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - jsonc-eslint-parser@3.3.0: - resolution: {integrity: sha512-hYTGkHGNRZnXOFZ1urhINADoqDrGfpy53cjw+dxk84QE0pUDujQzeUeamNs6Mz44/TKD49z2x6/GVSu4ZrtA+Q==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - jsonc-parser@3.3.1: resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} - katex@0.16.47: - resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} - hasBin: true - - keyv@5.6.0: - resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} - knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - lilconfig@3.1.3: resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} engines: {node: '>=14'} @@ -2247,14 +1763,6 @@ packages: engines: {node: '>=22.22.1'} hasBin: true - local-pkg@1.2.1: - resolution: {integrity: sha512-++gUqRDEvcnN6Zhqrr+y/CkVEHhlrR96vZn3nZZPYzMcBUyBtTKzB9NadClFIsIVSsu+3i9tfk/erqy9kAmt7Q==} - engines: {node: '>=14'} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - locate-path@7.2.0: resolution: {integrity: sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2265,9 +1773,6 @@ packages: lodash.uniq@4.5.0: resolution: {integrity: sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==} - longest-streak@3.1.0: - resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} - magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -2277,155 +1782,16 @@ packages: magicast@0.5.5: resolution: {integrity: sha512-UicdXN8zQ3JHlxVq+28afMXPr1z7WNY6+7EJnzTdQWkTAlMLF5fNCCKxJHBQwGaNGR11581EiQmQzx73+MvszA==} - make-asynchronous@1.1.0: - resolution: {integrity: sha512-ayF7iT+44LXdxJLTrTd3TLQpFDDvPCBxXxbv+pMUSuHA5Q8zyAfwkRP6aHHwNVFBUFWtxAHqwNJxF8vMZLAbVg==} - engines: {node: '>=18'} - - markdown-table@3.0.4: - resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - mdast-util-find-and-replace@3.0.2: - resolution: {integrity: sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==} - - mdast-util-from-markdown@2.0.3: - resolution: {integrity: sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==} - - mdast-util-frontmatter@2.0.1: - resolution: {integrity: sha512-LRqI9+wdgC25P0URIJY9vwocIzCcksduHQ9OF2joxQoyTNVduwLAFUzjoopuRJbJAReaKrNQKAZKL3uCMugWJA==} - - mdast-util-gfm-autolink-literal@2.0.1: - resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} - - mdast-util-gfm-footnote@2.1.0: - resolution: {integrity: sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==} - - mdast-util-gfm-strikethrough@2.0.0: - resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} - - mdast-util-gfm-table@2.0.0: - resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} - - mdast-util-gfm-task-list-item@2.0.0: - resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} - - mdast-util-gfm@3.1.0: - resolution: {integrity: sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==} - - mdast-util-math@3.0.0: - resolution: {integrity: sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==} - - mdast-util-phrasing@4.1.0: - resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - - mdast-util-to-markdown@2.1.2: - resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} - - mdast-util-to-string@4.0.0: - resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} - mdn-data@2.35.0: - resolution: {integrity: sha512-k+1+dEIm2z/BEfdvYzT/fIyAGeDBo/1AJNJI4ilFXJRYoxP/3Ds+GP8shAOIq8jYXY9N5nGB3Ag104NsagmLAA==} - - micromark-core-commonmark@2.0.3: - resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} - - micromark-extension-frontmatter@2.0.0: - resolution: {integrity: sha512-C4AkuM3dA58cgZha7zVnuVxBhDsbttIMiytjgsM2XbHAB2faRVaHRle40558FBN+DJcrLNCoqG5mlrpdU4cRtg==} - - micromark-extension-gfm-autolink-literal@2.1.0: - resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} - - micromark-extension-gfm-footnote@2.1.0: - resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} - - micromark-extension-gfm-strikethrough@2.1.0: - resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} - - micromark-extension-gfm-table@2.1.2: - resolution: {integrity: sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==} - - micromark-extension-gfm-tagfilter@2.0.0: - resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} - - micromark-extension-gfm-task-list-item@2.1.0: - resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} - - micromark-extension-gfm@3.0.0: - resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} - - micromark-extension-math@3.1.0: - resolution: {integrity: sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==} - - micromark-factory-destination@2.0.1: - resolution: {integrity: sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==} - - micromark-factory-label@2.0.1: - resolution: {integrity: sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==} - - micromark-factory-space@2.0.1: - resolution: {integrity: sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==} - - micromark-factory-title@2.0.1: - resolution: {integrity: sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==} - - micromark-factory-whitespace@2.0.1: - resolution: {integrity: sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==} - - micromark-util-character@2.1.1: - resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} - - micromark-util-chunked@2.0.1: - resolution: {integrity: sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==} - - micromark-util-classify-character@2.0.1: - resolution: {integrity: sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==} - - micromark-util-combine-extensions@2.0.1: - resolution: {integrity: sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==} - - micromark-util-decode-numeric-character-reference@2.0.2: - resolution: {integrity: sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==} - - micromark-util-decode-string@2.0.1: - resolution: {integrity: sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==} - - micromark-util-encode@2.0.1: - resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} - - micromark-util-html-tag-name@2.0.1: - resolution: {integrity: sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==} - - micromark-util-normalize-identifier@2.0.1: - resolution: {integrity: sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==} - - micromark-util-resolve-all@2.0.1: - resolution: {integrity: sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==} - - micromark-util-sanitize-uri@2.0.1: - resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} - - micromark-util-subtokenize@2.1.0: - resolution: {integrity: sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==} - - micromark-util-symbol@2.0.1: - resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} - - micromark-util-types@2.0.2: - resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} - - micromark@4.0.2: - resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} - mime-db@1.52.0: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} @@ -2434,10 +1800,6 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - minimatch@10.2.6: - resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} - engines: {node: 18 || 20 || >=22} - minipass@3.3.6: resolution: {integrity: sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==} engines: {node: '>=8'} @@ -2479,9 +1841,6 @@ packages: mlly@1.8.2: resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} - module-replacements@3.3.0: - resolution: {integrity: sha512-AVZL23uePazQOZlb/QMGwxsUa1oWA62Cfe6ApPzgasUoF4cdCWAiRPVq4KkaQzXbIDAlpLhLG61Jx8Lju4wjTg==} - mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -2494,13 +1853,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - natural-orderby@5.0.0: - resolution: {integrity: sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==} - engines: {node: '>=18'} - node-fetch-native@1.6.7: resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} @@ -2524,9 +1876,6 @@ packages: engines: {node: ^14.16.0 || >=16.10.0} hasBin: true - object-deep-merge@2.0.1: - resolution: {integrity: sha512-aKttDKcU3pyZqKcCkDhsMn70WmZFG2JGDQLP9EcLyTSIFQRCPWLAmBZRLJnrVUrhPG1jETEEbfdgbNtJf1LyMg==} - obug@2.2.1: resolution: {integrity: sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==} engines: {node: '>=12.20.0'} @@ -2544,64 +1893,47 @@ packages: resolution: {integrity: sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==} engines: {node: '>=18'} - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - oxfmt@0.35.0: - resolution: {integrity: sha512-QYeXWkP+aLt7utt5SLivNIk09glWx9QE235ODjgcEZ3sd1VMaUBSpLymh6ZRCA76gD2rMP4bXanUz/fx+nLM9Q==} + oxfmt@0.68.0: + resolution: {integrity: sha512-Z0XMofcXCGUXbcpBHnWyUiX93BGiw1B+lcHNbQDWEtOhX06ewoFfu4zXkyiLhRrNnMq0twqXRHUcJetf+GsiQQ==} engines: {node: ^20.19.0 || >=22.12.0} hasBin: true + peerDependencies: + svelte: ^5.0.0 + vite-plus: '*' + peerDependenciesMeta: + svelte: + optional: true + vite-plus: + optional: true - p-event@6.0.1: - resolution: {integrity: sha512-Q6Bekk5wpzW5qIyUP4gdMEujObYstZl6DMMOSenwBvV0BlE5LkDwkjs5yHbZmdCEq2o4RJx4tE1vwxFVf2FG1w==} - engines: {node: '>=16.17'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} + oxlint@1.83.0: + resolution: {integrity: sha512-cyDzSzaw3uzP0TeCeq3lLRPPoaUxkbB4ZOXj+kn+5r+BX9V+4bNVGk9lxer+WrgcpebH4JxLlJ3KQjveVztOLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + oxlint-tsgolint: '>=7.0.2001' + vite-plus: '*' + peerDependenciesMeta: + oxlint-tsgolint: + optional: true + vite-plus: + optional: true p-limit@4.0.0: resolution: {integrity: sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - p-locate@6.0.0: resolution: {integrity: sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - p-timeout@6.1.4: - resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} - engines: {node: '>=14.16'} - package-manager-detector@1.8.0: resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==} - parse-gitignore@2.0.0: - resolution: {integrity: sha512-RmVuCHWsfu0QPNW+mraxh/xjQVw/lhUCUru8Zni3Ctq3AoMhpDTq0OVdKS6iesd6Kqb7viCV3isAL43dciOSog==} - engines: {node: '>=14'} - - parse-imports-exports@0.2.4: - resolution: {integrity: sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ==} - - parse-statements@1.0.11: - resolution: {integrity: sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA==} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - path-exists@5.0.0: resolution: {integrity: sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-parse@1.0.7: resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} @@ -2638,13 +1970,6 @@ packages: pkg-types@2.3.3: resolution: {integrity: sha512-j/lCFdcppV0JxWpCEITdbDltBxPP6cHT+yNJ6Go2OgoSA9518X847X9z0p6LtA4Nc16+eQzCZjRrWanTGvHJ5w==} - pluralize@8.0.0: - resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} - engines: {node: '>=4'} - - pnpm-workspace-yaml@1.9.1: - resolution: {integrity: sha512-Xj/T2X6DNAWbtk/9UQ0/TJRswltiHZevmcMjqoL7WrpJi67lu3qaslU4+I+zJ8wtwqmuvAE0KkB8rbv2ZdogBg==} - postcss-calc@10.1.1: resolution: {integrity: sha512-NYEsLHh8DgG/PRH2+G9BTuUdtf9ViS+vdoQ0YA5OQdGsfN4ztiwtDWNtBl9EKeqNMFnIu8IKZ0cLxEQ5r5KVMw==} engines: {node: ^18.12 || ^20.9 || >=22.0} @@ -2824,19 +2149,6 @@ packages: resolution: {integrity: sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==} engines: {node: ^10 || ^12 || >=14} - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-linter-helpers@1.0.1: - resolution: {integrity: sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==} - engines: {node: '>=6.0.0'} - - prettier@3.9.8: - resolution: {integrity: sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==} - engines: {node: '>=14'} - hasBin: true - pretty-bytes@7.1.3: resolution: {integrity: sha512-U2KZ675nqba6XSBppSjU2pRgc9Ffx9+uUtd/RTWYmOawVkZuUbBYuwdtFZQmLv+yMgQ2TQxWWuOpyNje67J9tg==} engines: {node: '>=20'} @@ -2845,24 +2157,9 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - qified@0.10.1: - resolution: {integrity: sha512-+Owyggi9IxT1ePKGafcI87ubSmxol6smwJ+RAHDQlx9+9cPwFWDiKFFCPuWhr9ignlGpZ9vDQLw67N4dcTVFEA==} - engines: {node: '>=20'} - - quansync@0.2.11: - resolution: {integrity: sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==} - quansync@1.0.0: resolution: {integrity: sha512-5xZacEEufv3HSTPQuchrvV6soaiACMFnq1H8wkVioctoH3TRha9Sz66lOxRwPK/qZj7HPiSveih9yAyh98gvqA==} - quote-js-string@0.1.0: - resolution: {integrity: sha512-Y3NoRtprEEZQD8RfxMCfS0ZTqc4e+i18OrXEXAvpM6TfC/3y+0L5rNbZiSnbBBEkDfFzbpd8o+cE8q3/anjMGA==} - engines: {node: '>=22'} - rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} @@ -2877,24 +2174,9 @@ packages: resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} engines: {node: '>= 20.19.0'} - refa@0.12.1: - resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - regexp-ast-analysis@0.7.1: - resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - regjsparser@0.13.3: - resolution: {integrity: sha512-ycwFAS14Jw4mppvmK4GR/J6u3WpWpjkEApehuHtLc/8VpPNpDMbQ4WjqwplXifGeyKOzHSFLmSPqzksDQE2Sfg==} - hasBin: true - - reserved-identifiers@1.2.0: - resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==} - engines: {node: '>=18'} - - resolve-pkg-maps@1.0.0: - resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + redis@6.2.1: + resolution: {integrity: sha512-Z9VHtgYs48PiQC77X9O2Er8Hj4T+5BtFjT91/vi5Is1D04N72cA946ZslM1ImJw8ZctFBZWAVjM7S5wJNeHMpg==} + engines: {node: '>= 20.0.0'} resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} @@ -2925,10 +2207,6 @@ packages: resolution: {integrity: sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==} engines: {node: '>=11.0.0'} - scslre@0.3.0: - resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} - engines: {node: ^14.0.0 || >=16.0.0} - scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} @@ -2937,14 +2215,6 @@ packages: engines: {node: '>=10'} hasBin: true - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - sherif-darwin-arm64@1.13.0: resolution: {integrity: sha512-k38jpGpZIEWS5dpSRLSvVi53LZuGO3hDqB88jgdLMDN11LZM6yTDcP0GytnQ8OpE6Br/Js6bDCLsdWDarZdV9g==} cpu: [arm64] @@ -3000,22 +2270,10 @@ packages: resolution: {integrity: sha512-kkTORPAuxQz2g1QUuN0J8yGWT28tjGBfPOdJ4xT7Vbeu30veKUZQIoID2qGgCDAakaQn59UeZJJ4cFGB8PVP2A==} hasBin: true - sisteransi@1.0.5: - resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} - spdx-exceptions@2.5.0: - resolution: {integrity: sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==} - - spdx-expression-parse@5.0.0: - resolution: {integrity: sha512-vngmw3Rgn+o2arXNbnZaj5UtOEBuWBfvaI+Wc8GFfykIhA5/vdK9/Sp/XkLv63dykz2rxKDvKEHupF5P0FORcQ==} - - spdx-license-ids@3.0.24: - resolution: {integrity: sha512-cLS9TtWkIQFyLkJ3/5aFQAOHOSKTlOs/7WDut/XPSdjom1fJhUdkepeJAKXa9Y05+FabHd0sti+Et559vDtkpQ==} - stack-trace@1.0.0-pre2: resolution: {integrity: sha512-2ztBJRek8IVofG9DBJqdy2N5kulaacX30Nz7xmkYF6ale9WBVmIy6mFBchvGX7Vx/MyjBhx+Rcxqrj+dbOnQ6A==} engines: {node: '>=16'} @@ -3033,20 +2291,12 @@ packages: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} - strip-indent@4.1.1: - resolution: {integrity: sha512-SlyRoSkdh1dYP0PzclLE7r0M9sgbFKKMFXpFRUMNuKhQSbC6VQIGzq3E0qsfvGJaUFJPGv6Ws1NZ/haTAjfbMA==} - engines: {node: '>=12'} - stylehacks@7.0.11: resolution: {integrity: sha512-iODNfhXVLqc5LADs+Y6Oh5wJuK5ZcHbVng8aiK3y9pjMQdc5hLrBW0eFU6FtnpNrE6PoEg/MmFTU4waotj5WNg==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} peerDependencies: postcss: ^8.5.13 - super-regex@1.1.0: - resolution: {integrity: sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ==} - engines: {node: '>=18'} - supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -3056,23 +2306,11 @@ packages: engines: {node: '>=16'} hasBin: true - synckit@0.11.13: - resolution: {integrity: sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==} - engines: {node: ^14.18.0 || >=16.0.0} - - tapable@2.3.3: - resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} - engines: {node: '>=6'} - tar@6.2.1: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me - time-span@5.1.0: - resolution: {integrity: sha512-75voc/9G4rDIJleOo4jPvN4/YC4GRZrY8yy1uU4lwrB3XEQbWve8zXoO5No4eFrGcTAMYyoY67p8jRQdtA1HbA==} - engines: {node: '>=12'} - tinybench@6.1.4: resolution: {integrity: sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==} engines: {node: '>=20.0.0'} @@ -3092,8 +2330,8 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinypool@2.1.0: - resolution: {integrity: sha512-Pugqs6M0m7Lv1I7FtxN4aoyToKg1C4tu+/381vH35y8oENM/Ai7f7C4StcoK4/+BSw9ebcS8jRiVrORFKCALLw==} + tinypool@2.1.2: + resolution: {integrity: sha512-9YodfrxS9g9IbFr/KOjE5bAeJ0p61n3bW6mqvy0jtoeKd1kTW1Cxm0oulm6KX2lyM9Gl6WIe8nEbY7LWv5ZJww==} engines: {node: ^20.0.0 || >=22.0.0} tinyrainbow@3.1.1: @@ -3104,33 +2342,16 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - to-valid-identifier@1.0.0: - resolution: {integrity: sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw==} - engines: {node: '>=20'} - - toml-eslint-parser@1.0.3: - resolution: {integrity: sha512-A5F0cM6+mDleacLIEUkmfpkBbnHJFV1d2rprHU2MXNk7mlxHq2zGojA+SRvQD1RoMo9gqjZPWEaKG4v1BQ48lw==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - - ts-api-utils@2.5.0: - resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - type-fest@4.41.0: - resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} - engines: {node: '>=16'} - typescript@6.0.3: resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} @@ -3152,20 +2373,9 @@ packages: undici-types@8.9.0: resolution: {integrity: sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==} - unist-util-is@6.0.1: - resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==} - - unist-util-remove-position@5.0.0: - resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} - - unist-util-stringify-position@4.0.0: - resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} - - unist-util-visit-parents@6.0.2: - resolution: {integrity: sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==} - - unist-util-visit@5.1.0: - resolution: {integrity: sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==} + undici@8.10.2: + resolution: {integrity: sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==} + engines: {node: '>=22.19.0'} untyped@2.0.0: resolution: {integrity: sha512-nwNCjxJTjNuLCgFr42fEak5OcLuB3ecca+9ksPFNvtfYSLpjf+iJqSIaSnIile6ZPbKYxI5k2AfXqeopGudK/g==} @@ -3177,16 +2387,9 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - verkit@0.3.2: - resolution: {integrity: sha512-zj/ob3UsvJGN0whEAKFp53REA5X66hvffVqoCtVQAakJKnKlH+/PcOfMoFwIG/o4rElqLv/ycAFlx8ZlXUorCg==} - engines: {node: '>=18.12.0'} - verkit@0.4.1: resolution: {integrity: sha512-mMVzj0TXExtVdlDEq+Mzp0eyOuyznNpFobNM3uAqe3HVm/Ps2c+u17BligMkZjZCA6EiXpoon8iRTer3iu40SQ==} engines: {node: '>=18.12.0'} @@ -3272,119 +2475,41 @@ packages: jsdom: optional: true - vue-eslint-parser@10.4.1: - resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 - - web-worker@1.5.0: - resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} hasBin: true - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} + ws@8.21.3: + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - xml-name-validator@5.0.0: - resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} - engines: {node: '>=18'} - yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml-eslint-parser@2.1.0: - resolution: {integrity: sha512-1zo9KRfp6vIAXBEhWAz09ex3hUwh3T+/6dGbGteHvNseA0Uk4FgQnPES55klZ6cDzSy9FpgKS0D/CoLUvCCj4w==} - engines: {node: ^20.19.0 || ^22.13.0 || >=24} - yaml@2.9.1: resolution: {integrity: sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==} engines: {node: '>= 14.6'} hasBin: true - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - yocto-queue@1.2.2: resolution: {integrity: sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ==} engines: {node: '>=12.20'} - zwitch@2.0.4: - resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} - snapshots: - '@antfu/eslint-config@9.5.1(@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3))(@typescript-eslint/utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(@vue/compiler-sfc@3.5.43)(eslint-plugin-format@2.0.1(eslint@10.11.0(jiti@2.7.0)))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.1)': - dependencies: - '@antfu/install-pkg': 2.1.0 - '@clack/prompts': 1.8.1 - '@e18e/eslint-plugin': 0.8.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint-community/eslint-plugin-eslint-comments': 4.8.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint/markdown': 8.0.3 - '@stylistic/eslint-plugin': 5.10.0(eslint@10.11.0(jiti@2.7.0)) - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/parser': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@vitest/eslint-plugin': 1.6.27(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.1) - ansis: 4.4.0 - cac: 7.0.0 - eslint: 10.11.0(jiti@2.7.0) - eslint-config-flat-gitignore: 2.4.0(eslint@10.11.0(jiti@2.7.0)) - eslint-flat-config-utils: 3.2.0 - eslint-merge-processors: 2.0.0(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-antfu: 3.2.3(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-command: 4.0.0(@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3))(@typescript-eslint/utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-import-lite: 0.6.0(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-jsdoc: 64.5.4(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-jsonc: 3.4.2(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-n: 18.3.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-no-only-tests: 3.4.0 - eslint-plugin-perfectionist: 5.11.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint-plugin-pnpm: 1.9.1(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-regexp: 3.3.0(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-toml: 1.5.0(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-unicorn: 74.0.0(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-unused-imports: 4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)) - eslint-plugin-vue: 10.11.0(@stylistic/eslint-plugin@5.10.0(eslint@10.11.0(jiti@2.7.0)))(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0))) - eslint-plugin-yml: 3.8.1(eslint@10.11.0(jiti@2.7.0)) - eslint-processor-vue-blocks: 2.0.0(@vue/compiler-sfc@3.5.43)(eslint@10.11.0(jiti@2.7.0)) - globals: 17.12.0 - local-pkg: 1.2.1 - parse-gitignore: 2.0.0 - toml-eslint-parser: 1.0.3 - vue-eslint-parser: 10.4.1(eslint@10.11.0(jiti@2.7.0)) - yaml-eslint-parser: 2.1.0 - optionalDependencies: - eslint-plugin-format: 2.0.1(eslint@10.11.0(jiti@2.7.0)) - transitivePeerDependencies: - - '@eslint/json' - - '@typescript-eslint/typescript-estree' - - '@typescript-eslint/utils' - - '@vue/compiler-sfc' - - oxlint - - supports-color - - ts-declaration-location - - typescript - - vitest - - '@antfu/install-pkg@2.1.0': - dependencies: - package-manager-detector: 1.8.0 - tinyexec: 1.3.1 - '@babel/code-frame@8.0.6': dependencies: '@babel/helper-validator-identifier': 8.0.6 @@ -3409,30 +2534,6 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} - '@cacheable/memory@2.2.0': - dependencies: - '@cacheable/utils': 2.5.0 - '@keyv/bigmap': 1.3.1(keyv@5.6.0) - hookified: 1.15.1 - keyv: 5.6.0 - - '@cacheable/utils@2.5.0': - dependencies: - hashery: 1.5.1 - keyv: 5.6.0 - - '@clack/core@1.5.1': - dependencies: - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - - '@clack/prompts@1.8.1': - dependencies: - '@clack/core': 1.5.1 - fast-string-width: 3.0.2 - fast-wrap-ansi: 0.2.2 - sisteransi: 1.0.5 - '@codspeed/core@5.7.1(debug@4.4.3)': dependencies: axios: 1.20.0(debug@4.4.3) @@ -3456,37 +2557,24 @@ snapshots: '@colordx/core@5.8.0': {} - '@dprint/formatter@0.5.1': {} - - '@dprint/markdown@0.21.1': {} - - '@dprint/toml@0.7.0': {} - - '@e18e/eslint-plugin@0.8.1(eslint@10.11.0(jiti@2.7.0))': + '@effect/platform-node-shared@4.0.0-rc.116(effect@4.0.0-rc.116)': dependencies: - empathic: 2.1.0 - module-replacements: 3.3.0 - verkit: 0.4.1 - optionalDependencies: - eslint: 10.11.0(jiti@2.7.0) - - '@es-joy/jsdoccomment@0.92.0': - dependencies: - '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.70.0 - comment-parser: 1.4.7 - esquery: 1.7.0 - jsdoc-type-pratt-parser: 9.0.1 + '@types/ws': 8.18.1 + effect: 4.0.0-rc.116 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate - '@es-joy/jsdoccomment@0.98.0': + '@effect/platform-node@4.0.0-rc.116(effect@4.0.0-rc.116)(redis@6.2.1)': dependencies: - '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.70.0 - comment-parser: 1.4.9 - esquery: 1.7.0 - jsdoc-type-pratt-parser: 9.2.2 - - '@es-joy/resolve.exports@1.2.0': {} + '@effect/platform-node-shared': 4.0.0-rc.116(effect@4.0.0-rc.116) + effect: 4.0.0-rc.116 + redis: 6.2.1 + undici: 8.10.2 + transitivePeerDependencies: + - bufferutil + - utf-8-validate '@esbuild/aix-ppc64@0.25.12': optional: true @@ -3644,184 +2732,166 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@eslint-community/eslint-plugin-eslint-comments@4.8.1(eslint@10.11.0(jiti@2.7.0))': - dependencies: - escape-string-regexp: 4.0.0 - eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.9 - - '@eslint-community/eslint-utils@4.10.1(eslint@10.11.0(jiti@2.7.0))': + '@jridgewell/gen-mapping@0.3.13': dependencies: - eslint: 10.11.0(jiti@2.7.0) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.2': {} + '@jridgewell/sourcemap-codec': 1.6.0 + '@jridgewell/trace-mapping': 0.3.31 - '@eslint/compat@2.1.1(eslint@10.11.0(jiti@2.7.0))': + '@jridgewell/remapping@2.3.5': dependencies: - '@eslint/core': 1.2.1 - optionalDependencies: - eslint: 10.11.0(jiti@2.7.0) + '@jridgewell/gen-mapping': 0.3.13 + '@jridgewell/trace-mapping': 0.3.31 - '@eslint/config-array@0.23.5': - dependencies: - '@eslint/object-schema': 3.0.5 - debug: 4.4.3 - minimatch: 10.2.6 - transitivePeerDependencies: - - supports-color + '@jridgewell/resolve-uri@3.1.2': {} - '@eslint/config-helpers@0.5.5': - dependencies: - '@eslint/core': 1.2.1 + '@jridgewell/sourcemap-codec@1.6.0': {} - '@eslint/config-helpers@0.7.0': + '@jridgewell/trace-mapping@0.3.31': dependencies: - '@eslint/core': 1.2.1 + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.6.0 - '@eslint/core@1.2.1': - dependencies: - '@types/json-schema': 7.0.15 + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true - '@eslint/css-tree@4.1.1': - dependencies: - mdn-data: 2.35.0 - source-map-js: 1.2.1 + '@oxfmt/binding-android-arm-eabi@0.68.0': + optional: true - '@eslint/markdown@8.0.3': - dependencies: - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - github-slugger: 2.0.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-frontmatter: 2.0.1 - mdast-util-gfm: 3.1.0 - mdast-util-math: 3.0.0 - micromark-extension-frontmatter: 2.0.0 - micromark-extension-gfm: 3.0.0 - micromark-extension-math: 3.1.0 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color + '@oxfmt/binding-android-arm64@0.68.0': + optional: true - '@eslint/object-schema@3.0.5': {} + '@oxfmt/binding-darwin-arm64@0.68.0': + optional: true - '@eslint/plugin-kit@0.7.3': - dependencies: - '@eslint/core': 1.2.1 - levn: 0.4.1 + '@oxfmt/binding-darwin-x64@0.68.0': + optional: true - '@humanfs/core@0.19.2': - dependencies: - '@humanfs/types': 0.15.0 + '@oxfmt/binding-freebsd-x64@0.68.0': + optional: true - '@humanfs/node@0.16.8': - dependencies: - '@humanfs/core': 0.19.2 - '@humanfs/types': 0.15.0 - '@humanwhocodes/retry': 0.4.3 + '@oxfmt/binding-linux-arm-gnueabihf@0.68.0': + optional: true - '@humanfs/types@0.15.0': {} + '@oxfmt/binding-linux-arm-musleabihf@0.68.0': + optional: true - '@humanwhocodes/module-importer@1.0.1': {} + '@oxfmt/binding-linux-arm64-gnu@0.68.0': + optional: true - '@humanwhocodes/retry@0.4.3': {} + '@oxfmt/binding-linux-arm64-musl@0.68.0': + optional: true - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.6.0 - '@jridgewell/trace-mapping': 0.3.31 + '@oxfmt/binding-linux-ppc64-gnu@0.68.0': + optional: true - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 + '@oxfmt/binding-linux-riscv64-gnu@0.68.0': + optional: true - '@jridgewell/resolve-uri@3.1.2': {} + '@oxfmt/binding-linux-riscv64-musl@0.68.0': + optional: true - '@jridgewell/sourcemap-codec@1.6.0': {} + '@oxfmt/binding-linux-s390x-gnu@0.68.0': + optional: true - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.6.0 + '@oxfmt/binding-linux-x64-gnu@0.68.0': + optional: true - '@keyv/bigmap@1.3.1(keyv@5.6.0)': - dependencies: - hashery: 1.5.1 - hookified: 1.15.1 - keyv: 5.6.0 + '@oxfmt/binding-linux-x64-musl@0.68.0': + optional: true - '@keyv/serialize@1.1.1': {} + '@oxfmt/binding-openharmony-arm64@0.68.0': + optional: true - '@napi-rs/lzma-linux-x64-gnu@1.5.1': + '@oxfmt/binding-win32-arm64-msvc@0.68.0': optional: true - '@ota-meshi/ast-token-store@0.3.0': {} + '@oxfmt/binding-win32-ia32-msvc@0.68.0': + optional: true - '@oxfmt/binding-android-arm-eabi@0.35.0': + '@oxfmt/binding-win32-x64-msvc@0.68.0': optional: true - '@oxfmt/binding-android-arm64@0.35.0': + '@oxlint/binding-android-arm-eabi@1.83.0': optional: true - '@oxfmt/binding-darwin-arm64@0.35.0': + '@oxlint/binding-android-arm64@1.83.0': optional: true - '@oxfmt/binding-darwin-x64@0.35.0': + '@oxlint/binding-darwin-arm64@1.83.0': optional: true - '@oxfmt/binding-freebsd-x64@0.35.0': + '@oxlint/binding-darwin-x64@1.83.0': optional: true - '@oxfmt/binding-linux-arm-gnueabihf@0.35.0': + '@oxlint/binding-freebsd-x64@1.83.0': optional: true - '@oxfmt/binding-linux-arm-musleabihf@0.35.0': + '@oxlint/binding-linux-arm-gnueabihf@1.83.0': optional: true - '@oxfmt/binding-linux-arm64-gnu@0.35.0': + '@oxlint/binding-linux-arm-musleabihf@1.83.0': optional: true - '@oxfmt/binding-linux-arm64-musl@0.35.0': + '@oxlint/binding-linux-arm64-gnu@1.83.0': optional: true - '@oxfmt/binding-linux-ppc64-gnu@0.35.0': + '@oxlint/binding-linux-arm64-musl@1.83.0': optional: true - '@oxfmt/binding-linux-riscv64-gnu@0.35.0': + '@oxlint/binding-linux-ppc64-gnu@1.83.0': optional: true - '@oxfmt/binding-linux-riscv64-musl@0.35.0': + '@oxlint/binding-linux-riscv64-gnu@1.83.0': optional: true - '@oxfmt/binding-linux-s390x-gnu@0.35.0': + '@oxlint/binding-linux-riscv64-musl@1.83.0': optional: true - '@oxfmt/binding-linux-x64-gnu@0.35.0': + '@oxlint/binding-linux-s390x-gnu@1.83.0': optional: true - '@oxfmt/binding-linux-x64-musl@0.35.0': + '@oxlint/binding-linux-x64-gnu@1.83.0': optional: true - '@oxfmt/binding-openharmony-arm64@0.35.0': + '@oxlint/binding-linux-x64-musl@1.83.0': optional: true - '@oxfmt/binding-win32-arm64-msvc@0.35.0': + '@oxlint/binding-openharmony-arm64@1.83.0': optional: true - '@oxfmt/binding-win32-ia32-msvc@0.35.0': + '@oxlint/binding-win32-arm64-msvc@1.83.0': optional: true - '@oxfmt/binding-win32-x64-msvc@0.35.0': + '@oxlint/binding-win32-ia32-msvc@1.83.0': optional: true - '@pkgr/core@0.3.6': {} + '@oxlint/binding-win32-x64-msvc@1.83.0': + optional: true '@quansync/fs@1.1.0': dependencies: quansync: 1.0.0 + '@redis/bloom@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/client@6.2.1': + dependencies: + cluster-key-slot: 1.1.2 + + '@redis/json@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/search@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + + '@redis/time-series@6.2.1(@redis/client@6.2.1)': + dependencies: + '@redis/client': 6.2.1 + '@rollup/plugin-alias@5.1.1(rollup@4.63.4)': optionalDependencies: rollup: 4.63.4 @@ -3944,145 +3014,88 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.63.4': optional: true - '@sindresorhus/base62@1.0.0': {} - - '@stylistic/eslint-plugin@5.10.0(eslint@10.11.0(jiti@2.7.0))': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@typescript-eslint/types': 8.70.0 - eslint: 10.11.0(jiti@2.7.0) - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - estraverse: 5.3.0 - picomatch: 4.0.7 - '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 - '@types/debug@4.1.13': - dependencies: - '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} - '@types/esrecurse@4.3.1': {} - '@types/estree@1.0.9': {} - '@types/hast@3.0.5': + '@types/node@26.6.2': dependencies: - '@types/unist': 3.0.3 - - '@types/json-schema@7.0.15': {} + undici-types: 8.9.0 - '@types/katex@0.16.8': {} + '@types/resolve@1.20.2': {} - '@types/mdast@4.0.4': + '@types/ws@8.18.1': dependencies: - '@types/unist': 3.0.3 + '@types/node': 26.6.2 - '@types/ms@2.1.0': {} + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true - '@types/node@26.6.2': - dependencies: - undici-types: 8.9.0 + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true - '@types/resolve@1.20.2': {} + '@typescript/typescript-darwin-x64@7.0.2': + optional: true - '@types/unist@3.0.3': {} - - '@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/type-utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.0 - eslint: 10.11.0(jiti@2.7.0) - ignore: 7.0.9 - natural-compare: 1.4.0 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true - '@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true - '@typescript-eslint/project-service@8.70.0(typescript@6.0.3)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 - debug: 4.4.3 - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-linux-arm64@7.0.2': + optional: true - '@typescript-eslint/scope-manager@8.70.0': - dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 + '@typescript/typescript-linux-arm@7.0.2': + optional: true - '@typescript-eslint/tsconfig-utils@8.70.0(typescript@6.0.3)': - dependencies: - typescript: 6.0.3 + '@typescript/typescript-linux-loong64@7.0.2': + optional: true - '@typescript-eslint/type-utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-linux-mips64el@7.0.2': + optional: true - '@typescript-eslint/types@8.70.0': {} + '@typescript/typescript-linux-ppc64@7.0.2': + optional: true - '@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3)': - dependencies: - '@typescript-eslint/project-service': 8.70.0(typescript@6.0.3) - '@typescript-eslint/tsconfig-utils': 8.70.0(typescript@6.0.3) - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/visitor-keys': 8.70.0 - debug: 4.4.3 - minimatch: 10.2.6 - semver: 7.8.5 - tinyglobby: 0.2.17 - ts-api-utils: 2.5.0(typescript@6.0.3) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-linux-riscv64@7.0.2': + optional: true - '@typescript-eslint/utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)': - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/types': 8.70.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color + '@typescript/typescript-linux-s390x@7.0.2': + optional: true - '@typescript-eslint/visitor-keys@8.70.0': + '@typescript/typescript-linux-x64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-netbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-openbsd-x64@7.0.2': + optional: true + + '@typescript/typescript-sunos-x64@7.0.2': + optional: true + + '@typescript/typescript-win32-arm64@7.0.2': + optional: true + + '@typescript/typescript-win32-x64@7.0.2': + optional: true + + '@typescript/typescript6@6.0.2': dependencies: - '@typescript-eslint/types': 8.70.0 - eslint-visitor-keys: 5.0.1 + '@typescript/old': typescript@6.0.3 '@vitest/coverage-v8@5.0.1(vitest@5.0.1)': dependencies: @@ -4096,18 +3109,6 @@ snapshots: tinyrainbow: 3.1.1 vitest: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) - '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.1)': - dependencies: - '@typescript-eslint/scope-manager': 8.70.0 - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - typescript: 6.0.3 - vitest: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) - transitivePeerDependencies: - - supports-color - '@vitest/istanbul-lib-coverage@1.0.1': {} '@vitest/istanbul-lib-report@1.0.1': @@ -4125,42 +3126,6 @@ snapshots: '@vitest/spy@5.0.1': {} - '@vue/compiler-core@3.5.43': - dependencies: - '@babel/parser': 7.29.9 - '@vue/shared': 3.5.43 - entities: 7.0.1 - estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-dom@3.5.43': - dependencies: - '@vue/compiler-core': 3.5.43 - '@vue/shared': 3.5.43 - - '@vue/compiler-sfc@3.5.43': - dependencies: - '@babel/parser': 7.29.9 - '@vue/compiler-core': 3.5.43 - '@vue/compiler-dom': 3.5.43 - '@vue/compiler-ssr': 3.5.43 - '@vue/shared': 3.5.43 - estree-walker: 2.0.2 - magic-string: 0.30.21 - postcss: 8.5.28 - source-map-js: 1.2.1 - - '@vue/compiler-ssr@3.5.43': - dependencies: - '@vue/compiler-dom': 3.5.43 - '@vue/shared': 3.5.43 - - '@vue/shared@3.5.43': {} - - acorn-jsx@5.3.2(acorn@8.18.0): - dependencies: - acorn: 8.18.0 - acorn@8.18.0: {} agent-base@6.0.2: @@ -4169,13 +3134,6 @@ snapshots: transitivePeerDependencies: - supports-color - ajv@6.15.0: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - ansis@4.4.0: {} anymatch@3.1.3: @@ -4183,8 +3141,6 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.2 - are-docs-informative@0.1.1: {} - args-tokenizer@0.3.0: {} assertion-error@2.0.1: {} @@ -4216,18 +3172,12 @@ snapshots: - debug - supports-color - balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.25: {} binary-extensions@2.3.0: {} boolbase@1.0.0: {} - brace-expansion@5.0.12: - dependencies: - balanced-match: 4.0.4 - braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -4240,8 +3190,6 @@ snapshots: node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) - builtin-modules@5.4.0: {} - bumpp@12.3.0: dependencies: args-tokenizer: 0.3.0 @@ -4290,14 +3238,6 @@ snapshots: cac@7.0.0: {} - cacheable@2.5.0: - dependencies: - '@cacheable/memory': 2.2.0 - '@cacheable/utils': 2.5.0 - hookified: 1.15.1 - keyv: 5.6.0 - qified: 0.10.1 - call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -4312,12 +3252,8 @@ snapshots: caniuse-lite@1.0.30001810: {} - ccount@2.0.1: {} - chai@6.2.2: {} - change-case@5.4.4: {} - changelogen@0.5.7: dependencies: c12: 1.11.2 @@ -4351,8 +3287,6 @@ snapshots: transitivePeerDependencies: - magicast - character-entities@2.0.2: {} - chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -4371,12 +3305,12 @@ snapshots: chownr@2.0.0: {} - ci-info@4.4.0: {} - citty@0.1.6: dependencies: consola: 3.4.2 + cluster-key-slot@1.1.2: {} + colorette@2.0.20: {} combined-stream@1.0.8: @@ -4385,12 +3319,6 @@ snapshots: commander@11.1.0: {} - commander@8.3.0: {} - - comment-parser@1.4.7: {} - - comment-parser@1.4.9: {} - commondir@1.0.1: {} confbox@0.1.8: {} @@ -4403,20 +3331,8 @@ snapshots: convert-gitmoji@0.1.5: {} - convert-hrtime@5.0.0: {} - convert-source-map@2.0.0: {} - core-js-compat@3.50.0: - dependencies: - browserslist: 4.29.0 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - css-declaration-sorter@7.4.0(postcss@8.5.28): dependencies: postcss: 8.5.28 @@ -4495,12 +3411,6 @@ snapshots: dependencies: ms: 2.1.3 - decode-named-character-reference@1.3.0: - dependencies: - character-entities: 2.0.2 - - deep-is@0.1.4: {} - deepmerge@4.3.1: {} default-browser-id@5.0.1: {} @@ -4516,18 +3426,8 @@ snapshots: delayed-stream@1.0.0: {} - dequal@2.0.3: {} - destr@2.0.5: {} - detect-indent@7.0.2: {} - - devlop@1.1.0: - dependencies: - dequal: 2.0.3 - - diff-sequences@29.6.3: {} - dom-serializer@2.0.0: dependencies: domelementtype: 2.3.0 @@ -4556,21 +3456,12 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.433: {} - - empathic@2.1.0: {} + effect@4.0.0-rc.116: {} - enhanced-resolve@5.25.1: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.3.3 + electron-to-chromium@1.5.433: {} entities@4.5.0: {} - entities@7.0.1: {} - - entities@8.1.0: {} - es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -4648,361 +3539,24 @@ snapshots: escalade@3.2.0: {} - escape-string-regexp@4.0.0: {} - - escape-string-regexp@5.0.0: {} - - eslint-compat-utils@0.5.1(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - semver: 7.8.5 - - eslint-config-flat-gitignore@2.4.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint/compat': 2.1.1(eslint@10.11.0(jiti@2.7.0)) - eslint: 10.11.0(jiti@2.7.0) - - eslint-flat-config-utils@3.2.0: - dependencies: - '@eslint/config-helpers': 0.5.5 - pathe: 2.0.3 - - eslint-formatting-reporter@0.0.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - prettier-linter-helpers: 1.0.1 - - eslint-json-compat-utils@0.2.3(eslint@10.11.0(jiti@2.7.0))(jsonc-eslint-parser@3.3.0): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - esquery: 1.7.0 - jsonc-eslint-parser: 3.3.0 - - eslint-merge-processors@2.0.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - - eslint-parser-plain@0.1.1: {} - - eslint-plugin-antfu@3.2.3(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - - eslint-plugin-command@4.0.0(@typescript-eslint/typescript-estree@8.70.0(typescript@6.0.3))(@typescript-eslint/utils@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@es-joy/jsdoccomment': 0.92.0 - '@typescript-eslint/typescript-estree': 8.70.0(typescript@6.0.3) - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - - eslint-plugin-es-x@7.8.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint-community/regexpp': 4.12.2 - eslint: 10.11.0(jiti@2.7.0) - eslint-compat-utils: 0.5.1(eslint@10.11.0(jiti@2.7.0)) - - eslint-plugin-format@2.0.1(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@dprint/formatter': 0.5.1 - '@dprint/markdown': 0.21.1 - '@dprint/toml': 0.7.0 - eslint: 10.11.0(jiti@2.7.0) - eslint-formatting-reporter: 0.0.0(eslint@10.11.0(jiti@2.7.0)) - eslint-parser-plain: 0.1.1 - ohash: 2.0.12 - oxfmt: 0.35.0 - prettier: 3.9.8 - synckit: 0.11.13 - - eslint-plugin-import-lite@0.6.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - - eslint-plugin-jsdoc@64.5.4(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): - dependencies: - '@es-joy/jsdoccomment': 0.98.0 - '@es-joy/resolve.exports': 1.2.0 - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - are-docs-informative: 0.1.1 - comment-parser: 1.4.9 - debug: 4.4.3 - escape-string-regexp: 5.0.0 - eslint: 10.11.0(jiti@2.7.0) - espree: 11.2.0 - esquery: 1.7.0 - html-entities: 2.6.0 - object-deep-merge: 2.0.1 - parse-imports-exports: 0.2.4 - semver: 7.8.5 - spdx-expression-parse: 5.0.0 - to-valid-identifier: 1.0.0 - optionalDependencies: - typescript: 6.0.3 - transitivePeerDependencies: - - supports-color - - eslint-plugin-jsonc@3.4.2(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - '@ota-meshi/ast-token-store': 0.3.0 - diff-sequences: 29.6.3 - eslint: 10.11.0(jiti@2.7.0) - eslint-json-compat-utils: 0.2.3(eslint@10.11.0(jiti@2.7.0))(jsonc-eslint-parser@3.3.0) - jsonc-eslint-parser: 3.3.0 - natural-compare: 1.4.0 - synckit: 0.11.13 - transitivePeerDependencies: - - '@eslint/json' - - eslint-plugin-n@18.3.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - enhanced-resolve: 5.25.1 - eslint: 10.11.0(jiti@2.7.0) - eslint-plugin-es-x: 7.8.0(eslint@10.11.0(jiti@2.7.0)) - get-tsconfig: 4.14.3 - globals: 15.15.0 - globrex: 0.1.2 - ignore: 5.3.2 - semver: 7.8.5 - optionalDependencies: - typescript: 6.0.3 - - eslint-plugin-no-only-tests@3.4.0: {} - - eslint-plugin-perfectionist@5.11.1(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3): - dependencies: - '@typescript-eslint/utils': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 10.11.0(jiti@2.7.0) - natural-orderby: 5.0.0 - transitivePeerDependencies: - - supports-color - - typescript - - eslint-plugin-pnpm@1.9.1(eslint@10.11.0(jiti@2.7.0)): - dependencies: - empathic: 2.1.0 - eslint: 10.11.0(jiti@2.7.0) - eslint-json-compat-utils: 0.2.3(eslint@10.11.0(jiti@2.7.0))(jsonc-eslint-parser@3.3.0) - jsonc-eslint-parser: 3.3.0 - pathe: 2.0.3 - pnpm-workspace-yaml: 1.9.1 - tinyglobby: 0.2.17 - yaml: 2.9.1 - yaml-eslint-parser: 2.1.0 - transitivePeerDependencies: - - '@eslint/json' - - eslint-plugin-regexp@3.3.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint-community/regexpp': 4.12.2 - comment-parser: 1.4.9 - eslint: 10.11.0(jiti@2.7.0) - jsdoc-type-pratt-parser: 9.2.2 - refa: 0.12.1 - regexp-ast-analysis: 0.7.1 - scslre: 0.3.0 - - eslint-plugin-toml@1.5.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - '@ota-meshi/ast-token-store': 0.3.0 - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - toml-eslint-parser: 1.0.3 - transitivePeerDependencies: - - supports-color - - eslint-plugin-unicorn@74.0.0(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint/css-tree': 4.1.1 - browserslist: 4.29.0 - change-case: 5.4.4 - ci-info: 4.4.0 - core-js-compat: 3.50.0 - detect-indent: 7.0.2 - entities: 8.1.0 - eslint: 10.11.0(jiti@2.7.0) - find-up-simple: 1.0.1 - globals: 17.12.0 - indent-string: 5.0.0 - is-builtin-module: 5.0.0 - is-identifier: 1.1.0 - pluralize: 8.0.0 - quote-js-string: 0.1.0 - regjsparser: 0.13.3 - reserved-identifiers: 1.2.0 - semver: 7.8.5 - strip-indent: 4.1.1 - yaml: 2.9.1 - - eslint-plugin-unused-imports@4.4.1(@typescript-eslint/eslint-plugin@8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0)): - dependencies: - eslint: 10.11.0(jiti@2.7.0) - optionalDependencies: - '@typescript-eslint/eslint-plugin': 8.70.0(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - - eslint-plugin-vue@10.11.0(@stylistic/eslint-plugin@5.10.0(eslint@10.11.0(jiti@2.7.0)))(@typescript-eslint/parser@8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3))(eslint@10.11.0(jiti@2.7.0))(vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0))): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - eslint: 10.11.0(jiti@2.7.0) - natural-compare: 1.4.0 - nth-check: 2.1.1 - postcss-selector-parser: 7.1.6 - semver: 7.8.5 - vue-eslint-parser: 10.4.1(eslint@10.11.0(jiti@2.7.0)) - xml-name-validator: 5.0.0 - optionalDependencies: - '@stylistic/eslint-plugin': 5.10.0(eslint@10.11.0(jiti@2.7.0)) - '@typescript-eslint/parser': 8.70.0(eslint@10.11.0(jiti@2.7.0))(typescript@6.0.3) - - eslint-plugin-yml@3.8.1(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - '@ota-meshi/ast-token-store': 0.3.0 - diff-sequences: 29.6.3 - escape-string-regexp: 5.0.0 - eslint: 10.11.0(jiti@2.7.0) - natural-compare: 1.4.0 - yaml-eslint-parser: 2.1.0 - - eslint-processor-vue-blocks@2.0.0(@vue/compiler-sfc@3.5.43)(eslint@10.11.0(jiti@2.7.0)): - dependencies: - '@vue/compiler-sfc': 3.5.43 - eslint: 10.11.0(jiti@2.7.0) - - eslint-scope@9.1.2: - dependencies: - '@types/esrecurse': 4.3.1 - '@types/estree': 1.0.9 - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint-visitor-keys@5.0.1: {} - - eslint@10.11.0(jiti@2.7.0): - dependencies: - '@eslint-community/eslint-utils': 4.10.1(eslint@10.11.0(jiti@2.7.0)) - '@eslint-community/regexpp': 4.12.2 - '@eslint/config-array': 0.23.5 - '@eslint/config-helpers': 0.7.0 - '@eslint/core': 1.2.1 - '@eslint/plugin-kit': 0.7.3 - '@humanfs/node': 0.16.8 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.9 - ajv: 6.15.0 - cross-spawn: 7.0.6 - debug: 4.4.3 - escape-string-regexp: 4.0.0 - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 11.1.5 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - minimatch: 10.2.6 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.7.0 - transitivePeerDependencies: - - supports-color - - espree@10.4.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 4.2.1 - - espree@11.2.0: - dependencies: - acorn: 8.18.0 - acorn-jsx: 5.3.2(acorn@8.18.0) - eslint-visitor-keys: 5.0.1 - - esquery@1.7.0: - dependencies: - estraverse: 5.3.0 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - estree-walker@2.0.2: {} estree-walker@3.0.3: dependencies: '@types/estree': 1.0.9 - esutils@2.0.3: {} - expect-type@1.4.0: {} exsolve@1.1.1: {} - fast-deep-equal@3.1.3: {} - - fast-diff@1.3.0: {} - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fast-string-truncated-width@3.0.3: {} - - fast-string-width@3.0.2: - dependencies: - fast-string-truncated-width: 3.0.3 - - fast-wrap-ansi@0.2.2: - dependencies: - fast-string-width: 3.0.2 - - fault@2.0.1: - dependencies: - format: 0.2.2 - fdir@6.5.0(picomatch@4.0.7): optionalDependencies: picomatch: 4.0.7 - file-entry-cache@11.1.5: - dependencies: - flat-cache: 6.1.23 - fill-range@7.1.1: dependencies: to-regex-range: 5.0.1 - find-up-simple@1.0.1: {} - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - find-up@6.3.0: dependencies: locate-path: 7.2.0 @@ -5014,14 +3568,6 @@ snapshots: mlly: 1.8.2 rollup: 4.63.4 - flat-cache@6.1.23: - dependencies: - cacheable: 2.5.0 - flatted: 3.4.4 - hookified: 1.15.1 - - flatted@3.4.4: {} - follow-redirects@1.16.0(debug@4.4.3): optionalDependencies: debug: 4.4.3 @@ -5034,8 +3580,6 @@ snapshots: hasown: 2.0.4 mime-types: 2.1.35 - format@0.2.2: {} - fraction.js@5.3.4: {} fs-minipass@2.1.0: @@ -5047,8 +3591,6 @@ snapshots: function-bind@1.1.2: {} - function-timeout@1.0.2: {} - get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -5067,10 +3609,6 @@ snapshots: dunder-proto: 1.0.1 es-object-atoms: 1.1.2 - get-tsconfig@4.14.3: - dependencies: - resolve-pkg-maps: 1.0.0 - giget@1.2.5: dependencies: citty: 0.1.6 @@ -5083,48 +3621,24 @@ snapshots: giget@3.3.1: {} - github-slugger@2.0.0: {} - glob-parent@5.1.2: dependencies: is-glob: 4.0.3 - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@15.15.0: {} - - globals@17.12.0: {} - - globrex@0.1.2: {} - gopd@1.2.0: {} - graceful-fs@4.2.11: {} - has-symbols@1.1.0: {} has-tostringtag@1.0.2: dependencies: has-symbols: 1.1.0 - hashery@1.5.1: - dependencies: - hookified: 1.15.1 - hasown@2.0.4: dependencies: function-bind: 1.1.2 hookable@5.5.3: {} - hookified@1.15.1: {} - - hookified@2.2.0: {} - - html-entities@2.6.0: {} - https-proxy-agent@5.0.1: dependencies: agent-base: 6.0.2 @@ -5132,26 +3646,10 @@ snapshots: transitivePeerDependencies: - supports-color - identifier-regex@1.1.0: - dependencies: - reserved-identifiers: 1.2.0 - - ignore@5.3.2: {} - - ignore@7.0.9: {} - - imurmurhash@0.1.4: {} - - indent-string@5.0.0: {} - is-binary-path@2.1.0: dependencies: binary-extensions: 2.3.0 - is-builtin-module@5.0.0: - dependencies: - builtin-modules: 5.4.0 - is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -5164,11 +3662,6 @@ snapshots: dependencies: is-extglob: 2.1.1 - is-identifier@1.1.0: - dependencies: - identifier-regex: 1.1.0 - super-regex: 1.1.0 - is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -5185,52 +3678,16 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - jiti@1.21.7: {} jiti@2.7.0: {} js-tokens@10.0.0: {} - jsdoc-type-pratt-parser@9.0.1: - dependencies: - '@types/estree': 1.0.9 - - jsdoc-type-pratt-parser@9.2.2: - dependencies: - '@types/estree': 1.0.9 - '@types/node': 26.6.2 - - jsesc@3.1.0: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - jsonc-eslint-parser@3.3.0: - dependencies: - acorn: 8.18.0 - eslint-visitor-keys: 5.0.1 - verkit: 0.3.2 - jsonc-parser@3.3.1: {} - katex@0.16.47: - dependencies: - commander: 8.3.0 - - keyv@5.6.0: - dependencies: - '@keyv/serialize': 1.1.1 - knitwork@1.3.0: {} - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - lilconfig@3.1.3: {} lint-staged@17.5.1: @@ -5241,16 +3698,6 @@ snapshots: optionalDependencies: yaml: 2.9.1 - local-pkg@1.2.1: - dependencies: - mlly: 1.8.2 - pkg-types: 2.3.3 - quansync: 0.2.11 - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - locate-path@7.2.0: dependencies: p-locate: 6.0.0 @@ -5259,8 +3706,6 @@ snapshots: lodash.uniq@4.5.0: {} - longest-streak@3.1.0: {} - magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.6.0 @@ -5275,365 +3720,18 @@ snapshots: '@babel/types': 7.29.8 source-map-js: 1.2.1 - make-asynchronous@1.1.0: - dependencies: - p-event: 6.0.1 - type-fest: 4.41.0 - web-worker: 1.5.0 - - markdown-table@3.0.4: {} - math-intrinsics@1.1.0: {} - mdast-util-find-and-replace@3.0.2: - dependencies: - '@types/mdast': 4.0.4 - escape-string-regexp: 5.0.0 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 - - mdast-util-from-markdown@2.0.3: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - mdast-util-to-string: 4.0.0 - micromark: 4.0.2 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-decode-string: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - unist-util-stringify-position: 4.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-frontmatter@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - escape-string-regexp: 5.0.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-extension-frontmatter: 2.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-autolink-literal@2.0.1: - dependencies: - '@types/mdast': 4.0.4 - ccount: 2.0.1 - devlop: 1.1.0 - mdast-util-find-and-replace: 3.0.2 - micromark-util-character: 2.1.1 - - mdast-util-gfm-footnote@2.1.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - micromark-util-normalize-identifier: 2.0.1 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-strikethrough@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-table@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - markdown-table: 3.0.4 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm-task-list-item@2.0.0: - dependencies: - '@types/mdast': 4.0.4 - devlop: 1.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-gfm@3.1.0: - dependencies: - mdast-util-from-markdown: 2.0.3 - mdast-util-gfm-autolink-literal: 2.0.1 - mdast-util-gfm-footnote: 2.1.0 - mdast-util-gfm-strikethrough: 2.0.0 - mdast-util-gfm-table: 2.0.0 - mdast-util-gfm-task-list-item: 2.0.0 - mdast-util-to-markdown: 2.1.2 - transitivePeerDependencies: - - supports-color - - mdast-util-math@3.0.0: - dependencies: - '@types/hast': 3.0.5 - '@types/mdast': 4.0.4 - devlop: 1.1.0 - longest-streak: 3.1.0 - mdast-util-from-markdown: 2.0.3 - mdast-util-to-markdown: 2.1.2 - unist-util-remove-position: 5.0.0 - transitivePeerDependencies: - - supports-color - - mdast-util-phrasing@4.1.0: - dependencies: - '@types/mdast': 4.0.4 - unist-util-is: 6.0.1 - - mdast-util-to-markdown@2.1.2: - dependencies: - '@types/mdast': 4.0.4 - '@types/unist': 3.0.3 - longest-streak: 3.1.0 - mdast-util-phrasing: 4.1.0 - mdast-util-to-string: 4.0.0 - micromark-util-classify-character: 2.0.1 - micromark-util-decode-string: 2.0.1 - unist-util-visit: 5.1.0 - zwitch: 2.0.4 - - mdast-util-to-string@4.0.0: - dependencies: - '@types/mdast': 4.0.4 - mdn-data@2.0.28: {} mdn-data@2.27.1: {} - mdn-data@2.35.0: {} - - micromark-core-commonmark@2.0.3: - dependencies: - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-factory-destination: 2.0.1 - micromark-factory-label: 2.0.1 - micromark-factory-space: 2.0.1 - micromark-factory-title: 2.0.1 - micromark-factory-whitespace: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-html-tag-name: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-frontmatter@2.0.0: - dependencies: - fault: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-autolink-literal@2.1.0: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-footnote@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-strikethrough@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-classify-character: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-table@2.1.2: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm-tagfilter@2.0.0: - dependencies: - micromark-util-types: 2.0.2 - - micromark-extension-gfm-task-list-item@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-gfm@3.0.0: - dependencies: - micromark-extension-gfm-autolink-literal: 2.1.0 - micromark-extension-gfm-footnote: 2.1.0 - micromark-extension-gfm-strikethrough: 2.1.0 - micromark-extension-gfm-table: 2.1.2 - micromark-extension-gfm-tagfilter: 2.0.0 - micromark-extension-gfm-task-list-item: 2.1.0 - micromark-util-combine-extensions: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-extension-math@3.1.0: - dependencies: - '@types/katex': 0.16.8 - devlop: 1.1.0 - katex: 0.16.47 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-destination@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-label@2.0.1: - dependencies: - devlop: 1.1.0 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-space@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-types: 2.0.2 - - micromark-factory-title@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-factory-whitespace@2.0.1: - dependencies: - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-character@2.1.1: - dependencies: - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-chunked@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-classify-character@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-combine-extensions@2.0.1: - dependencies: - micromark-util-chunked: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-decode-numeric-character-reference@2.0.2: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-decode-string@2.0.1: - dependencies: - decode-named-character-reference: 1.3.0 - micromark-util-character: 2.1.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-symbol: 2.0.1 - - micromark-util-encode@2.0.1: {} - - micromark-util-html-tag-name@2.0.1: {} - - micromark-util-normalize-identifier@2.0.1: - dependencies: - micromark-util-symbol: 2.0.1 - - micromark-util-resolve-all@2.0.1: - dependencies: - micromark-util-types: 2.0.2 - - micromark-util-sanitize-uri@2.0.1: - dependencies: - micromark-util-character: 2.1.1 - micromark-util-encode: 2.0.1 - micromark-util-symbol: 2.0.1 - - micromark-util-subtokenize@2.1.0: - dependencies: - devlop: 1.1.0 - micromark-util-chunked: 2.0.1 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - - micromark-util-symbol@2.0.1: {} - - micromark-util-types@2.0.2: {} - - micromark@4.0.2: - dependencies: - '@types/debug': 4.1.13 - debug: 4.4.3 - decode-named-character-reference: 1.3.0 - devlop: 1.1.0 - micromark-core-commonmark: 2.0.3 - micromark-factory-space: 2.0.1 - micromark-util-character: 2.1.1 - micromark-util-chunked: 2.0.1 - micromark-util-combine-extensions: 2.0.1 - micromark-util-decode-numeric-character-reference: 2.0.2 - micromark-util-encode: 2.0.1 - micromark-util-normalize-identifier: 2.0.1 - micromark-util-resolve-all: 2.0.1 - micromark-util-sanitize-uri: 2.0.1 - micromark-util-subtokenize: 2.1.0 - micromark-util-symbol: 2.0.1 - micromark-util-types: 2.0.2 - transitivePeerDependencies: - - supports-color - mime-db@1.52.0: {} mime-types@2.1.35: dependencies: mime-db: 1.52.0 - minimatch@10.2.6: - dependencies: - brace-expansion: 5.0.12 - minipass@3.3.6: dependencies: yallist: 4.0.0 @@ -5647,7 +3745,7 @@ snapshots: mkdirp@1.0.4: {} - mkdist@2.4.1(typescript@6.0.3): + mkdist@2.4.1(typescript@7.0.2): dependencies: autoprefixer: 10.6.1(postcss@8.5.28) citty: 0.1.6 @@ -5663,7 +3761,7 @@ snapshots: semver: 7.8.5 tinyglobby: 0.2.17 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 mlly@1.8.2: dependencies: @@ -5672,18 +3770,12 @@ snapshots: pkg-types: 1.3.1 ufo: 1.6.4 - module-replacements@3.3.0: {} - mri@1.2.0: {} ms@2.1.3: {} nanoid@3.3.19: {} - natural-compare@1.4.0: {} - - natural-orderby@5.0.0: {} - node-fetch-native@1.6.7: {} node-gyp-build@4.8.4: {} @@ -5705,8 +3797,6 @@ snapshots: tinyexec: 0.3.2 ufo: 1.6.4 - object-deep-merge@2.0.1: {} - obug@2.2.1: {} ofetch@1.5.1: @@ -5726,77 +3816,64 @@ snapshots: is-inside-container: 1.0.0 wsl-utils: 0.1.0 - optionator@0.9.4: + oxfmt@0.68.0: dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - oxfmt@0.35.0: - dependencies: - tinypool: 2.1.0 + tinypool: 2.1.2 optionalDependencies: - '@oxfmt/binding-android-arm-eabi': 0.35.0 - '@oxfmt/binding-android-arm64': 0.35.0 - '@oxfmt/binding-darwin-arm64': 0.35.0 - '@oxfmt/binding-darwin-x64': 0.35.0 - '@oxfmt/binding-freebsd-x64': 0.35.0 - '@oxfmt/binding-linux-arm-gnueabihf': 0.35.0 - '@oxfmt/binding-linux-arm-musleabihf': 0.35.0 - '@oxfmt/binding-linux-arm64-gnu': 0.35.0 - '@oxfmt/binding-linux-arm64-musl': 0.35.0 - '@oxfmt/binding-linux-ppc64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-gnu': 0.35.0 - '@oxfmt/binding-linux-riscv64-musl': 0.35.0 - '@oxfmt/binding-linux-s390x-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-gnu': 0.35.0 - '@oxfmt/binding-linux-x64-musl': 0.35.0 - '@oxfmt/binding-openharmony-arm64': 0.35.0 - '@oxfmt/binding-win32-arm64-msvc': 0.35.0 - '@oxfmt/binding-win32-ia32-msvc': 0.35.0 - '@oxfmt/binding-win32-x64-msvc': 0.35.0 - - p-event@6.0.1: - dependencies: - p-timeout: 6.1.4 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 + '@oxfmt/binding-android-arm-eabi': 0.68.0 + '@oxfmt/binding-android-arm64': 0.68.0 + '@oxfmt/binding-darwin-arm64': 0.68.0 + '@oxfmt/binding-darwin-x64': 0.68.0 + '@oxfmt/binding-freebsd-x64': 0.68.0 + '@oxfmt/binding-linux-arm-gnueabihf': 0.68.0 + '@oxfmt/binding-linux-arm-musleabihf': 0.68.0 + '@oxfmt/binding-linux-arm64-gnu': 0.68.0 + '@oxfmt/binding-linux-arm64-musl': 0.68.0 + '@oxfmt/binding-linux-ppc64-gnu': 0.68.0 + '@oxfmt/binding-linux-riscv64-gnu': 0.68.0 + '@oxfmt/binding-linux-riscv64-musl': 0.68.0 + '@oxfmt/binding-linux-s390x-gnu': 0.68.0 + '@oxfmt/binding-linux-x64-gnu': 0.68.0 + '@oxfmt/binding-linux-x64-musl': 0.68.0 + '@oxfmt/binding-openharmony-arm64': 0.68.0 + '@oxfmt/binding-win32-arm64-msvc': 0.68.0 + '@oxfmt/binding-win32-ia32-msvc': 0.68.0 + '@oxfmt/binding-win32-x64-msvc': 0.68.0 + + oxlint@1.83.0: + optionalDependencies: + '@oxlint/binding-android-arm-eabi': 1.83.0 + '@oxlint/binding-android-arm64': 1.83.0 + '@oxlint/binding-darwin-arm64': 1.83.0 + '@oxlint/binding-darwin-x64': 1.83.0 + '@oxlint/binding-freebsd-x64': 1.83.0 + '@oxlint/binding-linux-arm-gnueabihf': 1.83.0 + '@oxlint/binding-linux-arm-musleabihf': 1.83.0 + '@oxlint/binding-linux-arm64-gnu': 1.83.0 + '@oxlint/binding-linux-arm64-musl': 1.83.0 + '@oxlint/binding-linux-ppc64-gnu': 1.83.0 + '@oxlint/binding-linux-riscv64-gnu': 1.83.0 + '@oxlint/binding-linux-riscv64-musl': 1.83.0 + '@oxlint/binding-linux-s390x-gnu': 1.83.0 + '@oxlint/binding-linux-x64-gnu': 1.83.0 + '@oxlint/binding-linux-x64-musl': 1.83.0 + '@oxlint/binding-openharmony-arm64': 1.83.0 + '@oxlint/binding-win32-arm64-msvc': 1.83.0 + '@oxlint/binding-win32-ia32-msvc': 1.83.0 + '@oxlint/binding-win32-x64-msvc': 1.83.0 p-limit@4.0.0: dependencies: yocto-queue: 1.2.2 - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - p-locate@6.0.0: dependencies: p-limit: 4.0.0 - p-timeout@6.1.4: {} - package-manager-detector@1.8.0: {} - parse-gitignore@2.0.0: {} - - parse-imports-exports@0.2.4: - dependencies: - parse-statements: 1.0.11 - - parse-statements@1.0.11: {} - - path-exists@4.0.0: {} - path-exists@5.0.0: {} - path-key@3.1.1: {} - path-parse@1.0.7: {} pathe@1.1.2: {} @@ -5827,12 +3904,6 @@ snapshots: exsolve: 1.1.1 pathe: 2.0.3 - pluralize@8.0.0: {} - - pnpm-workspace-yaml@1.9.1: - dependencies: - yaml: 2.9.1 - postcss-calc@10.1.1(postcss@8.5.28): dependencies: postcss: 8.5.28 @@ -6002,30 +4073,12 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - prelude-ls@1.2.1: {} - - prettier-linter-helpers@1.0.1: - dependencies: - fast-diff: 1.3.0 - - prettier@3.9.8: {} - pretty-bytes@7.1.3: {} proxy-from-env@2.1.0: {} - punycode@2.3.1: {} - - qified@0.10.1: - dependencies: - hookified: 2.2.0 - - quansync@0.2.11: {} - quansync@1.0.0: {} - quote-js-string@0.1.0: {} - rc9@2.1.2: dependencies: defu: 6.1.7 @@ -6042,22 +4095,16 @@ snapshots: readdirp@5.1.1: {} - refa@0.12.1: - dependencies: - '@eslint-community/regexpp': 4.12.2 - - regexp-ast-analysis@0.7.1: - dependencies: - '@eslint-community/regexpp': 4.12.2 - refa: 0.12.1 - - regjsparser@0.13.3: + redis@6.2.1: dependencies: - jsesc: 3.1.0 - - reserved-identifiers@1.2.0: {} - - resolve-pkg-maps@1.0.0: {} + '@redis/bloom': 6.2.1(@redis/client@6.2.1) + '@redis/client': 6.2.1 + '@redis/json': 6.2.1(@redis/client@6.2.1) + '@redis/search': 6.2.1(@redis/client@6.2.1) + '@redis/time-series': 6.2.1(@redis/client@6.2.1) + transitivePeerDependencies: + - '@node-rs/xxhash' + - '@opentelemetry/api' resolve@1.22.12: dependencies: @@ -6066,16 +4113,17 @@ snapshots: path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 - rollup-plugin-dts@6.5.1(rollup@4.63.4)(typescript@6.0.3): + rollup-plugin-dts@6.5.1(@typescript/typescript6@6.0.2)(rollup@4.63.4)(typescript@7.0.2): dependencies: '@jridgewell/remapping': 2.3.5 '@jridgewell/sourcemap-codec': 1.6.0 convert-source-map: 2.0.0 magic-string: 0.30.21 rollup: 4.63.4 - typescript: 6.0.3 + typescript: 7.0.2 optionalDependencies: '@babel/code-frame': 8.0.6 + '@typescript/typescript6': 6.0.2 rollup@4.63.4: dependencies: @@ -6113,22 +4161,10 @@ snapshots: sax@1.6.1: {} - scslre@0.3.0: - dependencies: - '@eslint-community/regexpp': 4.12.2 - refa: 0.12.1 - regexp-ast-analysis: 0.7.1 - scule@1.3.0: {} semver@7.8.5: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - sherif-darwin-arm64@1.13.0: optional: true @@ -6168,19 +4204,8 @@ snapshots: simple-git-hooks@2.14.0: {} - sisteransi@1.0.5: {} - source-map-js@1.2.1: {} - spdx-exceptions@2.5.0: {} - - spdx-expression-parse@5.0.0: - dependencies: - spdx-exceptions: 2.5.0 - spdx-license-ids: 3.0.24 - - spdx-license-ids@3.0.24: {} - stack-trace@1.0.0-pre2: {} stackback@0.0.2: {} @@ -6191,20 +4216,12 @@ snapshots: string-argv@0.3.2: {} - strip-indent@4.1.1: {} - stylehacks@7.0.11(postcss@8.5.28): dependencies: browserslist: 4.29.0 postcss: 8.5.28 postcss-selector-parser: 7.1.6 - super-regex@1.1.0: - dependencies: - function-timeout: 1.0.2 - make-asynchronous: 1.1.0 - time-span: 5.1.0 - supports-preserve-symlinks-flag@1.0.0: {} svgo@4.1.0: @@ -6217,12 +4234,6 @@ snapshots: picocolors: 1.1.1 sax: 1.6.1 - synckit@0.11.13: - dependencies: - '@pkgr/core': 0.3.6 - - tapable@2.3.3: {} - tar@6.2.1: dependencies: chownr: 2.0.0 @@ -6232,10 +4243,6 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - time-span@5.1.0: - dependencies: - convert-hrtime: 5.0.0 - tinybench@6.1.4: {} tinyexec@0.3.2: {} @@ -6249,7 +4256,7 @@ snapshots: fdir: 6.5.0(picomatch@4.0.7) picomatch: 4.0.7 - tinypool@2.1.0: {} + tinypool@2.1.2: {} tinyrainbow@3.1.1: {} @@ -6257,30 +4264,34 @@ snapshots: dependencies: is-number: 7.0.0 - to-valid-identifier@1.0.0: - dependencies: - '@sindresorhus/base62': 1.0.0 - reserved-identifiers: 1.2.0 - - toml-eslint-parser@1.0.3: - dependencies: - eslint-visitor-keys: 5.0.1 - - ts-api-utils@2.5.0(typescript@6.0.3): - dependencies: - typescript: 6.0.3 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - type-fest@4.41.0: {} - typescript@6.0.3: {} + typescript@7.0.2: + optionalDependencies: + '@typescript/typescript-aix-ppc64': 7.0.2 + '@typescript/typescript-darwin-arm64': 7.0.2 + '@typescript/typescript-darwin-x64': 7.0.2 + '@typescript/typescript-freebsd-arm64': 7.0.2 + '@typescript/typescript-freebsd-x64': 7.0.2 + '@typescript/typescript-linux-arm': 7.0.2 + '@typescript/typescript-linux-arm64': 7.0.2 + '@typescript/typescript-linux-loong64': 7.0.2 + '@typescript/typescript-linux-mips64el': 7.0.2 + '@typescript/typescript-linux-ppc64': 7.0.2 + '@typescript/typescript-linux-riscv64': 7.0.2 + '@typescript/typescript-linux-s390x': 7.0.2 + '@typescript/typescript-linux-x64': 7.0.2 + '@typescript/typescript-netbsd-arm64': 7.0.2 + '@typescript/typescript-netbsd-x64': 7.0.2 + '@typescript/typescript-openbsd-arm64': 7.0.2 + '@typescript/typescript-openbsd-x64': 7.0.2 + '@typescript/typescript-sunos-x64': 7.0.2 + '@typescript/typescript-win32-arm64': 7.0.2 + '@typescript/typescript-win32-x64': 7.0.2 + ufo@1.6.4: {} - unbuild@3.6.1(typescript@6.0.3): + unbuild@3.6.1(@typescript/typescript6@6.0.2)(typescript@7.0.2): dependencies: '@rollup/plugin-alias': 5.1.1(rollup@4.63.4) '@rollup/plugin-commonjs': 28.0.9(rollup@4.63.4) @@ -6296,18 +4307,18 @@ snapshots: hookable: 5.5.3 jiti: 2.7.0 magic-string: 0.30.21 - mkdist: 2.4.1(typescript@6.0.3) + mkdist: 2.4.1(typescript@7.0.2) mlly: 1.8.2 pathe: 2.0.3 pkg-types: 2.3.3 pretty-bytes: 7.1.3 rollup: 4.63.4 - rollup-plugin-dts: 6.5.1(rollup@4.63.4)(typescript@6.0.3) + rollup-plugin-dts: 6.5.1(@typescript/typescript6@6.0.2)(rollup@4.63.4)(typescript@7.0.2) scule: 1.3.0 tinyglobby: 0.2.17 untyped: 2.0.0 optionalDependencies: - typescript: 6.0.3 + typescript: 7.0.2 transitivePeerDependencies: - '@typescript/typescript6' - sass @@ -6330,29 +4341,7 @@ snapshots: undici-types@8.9.0: {} - unist-util-is@6.0.1: - dependencies: - '@types/unist': 3.0.3 - - unist-util-remove-position@5.0.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-visit: 5.1.0 - - unist-util-stringify-position@4.0.0: - dependencies: - '@types/unist': 3.0.3 - - unist-util-visit-parents@6.0.2: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - - unist-util-visit@5.1.0: - dependencies: - '@types/unist': 3.0.3 - unist-util-is: 6.0.1 - unist-util-visit-parents: 6.0.2 + undici@8.10.2: {} untyped@2.0.0: dependencies: @@ -6368,14 +4357,8 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - util-deprecate@1.0.2: {} - verkit@0.3.2: {} - verkit@0.4.1: {} vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1): @@ -6414,48 +4397,19 @@ snapshots: transitivePeerDependencies: - msw - vue-eslint-parser@10.4.1(eslint@10.11.0(jiti@2.7.0)): - dependencies: - debug: 4.4.3 - eslint: 10.11.0(jiti@2.7.0) - eslint-scope: 9.1.2 - eslint-visitor-keys: 5.0.1 - espree: 11.2.0 - esquery: 1.7.0 - semver: 7.8.5 - transitivePeerDependencies: - - supports-color - - web-worker@1.5.0: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 stackback: 0.0.2 - word-wrap@1.2.5: {} + ws@8.21.3: {} wsl-utils@0.1.0: dependencies: is-wsl: 3.1.1 - xml-name-validator@5.0.0: {} - yallist@4.0.0: {} - yaml-eslint-parser@2.1.0: - dependencies: - eslint-visitor-keys: 5.0.1 - yaml: 2.9.1 - yaml@2.9.1: {} - yocto-queue@0.1.0: {} - yocto-queue@1.2.2: {} - - zwitch@2.0.4: {} diff --git a/scripts/sync-sponsors.ts b/scripts/sync-sponsors.ts index 974acb6..1f4578e 100644 --- a/scripts/sync-sponsors.ts +++ b/scripts/sync-sponsors.ts @@ -17,7 +17,7 @@ interface Sponsor { /** Tagline, present on sponsors that bought an ad slot. */ description?: string /** Brand tint behind the ad card, both themes required together. */ - background?: { light: string, dark: string } + background?: { light: string; dark: string } /** 1-based ad-grid position the sponsor bought. */ slot?: number [key: string]: unknown @@ -44,8 +44,7 @@ async function findReadmes(dir: string): Promise { } subdirPromises.push(findReadmes(fullPath)) - } - else if (entry.isFile() && entry.name === README_FILE_NAME) { + } else if (entry.isFile() && entry.name === README_FILE_NAME) { result.push(fullPath) } } @@ -65,7 +64,7 @@ function escapeHtml(value: string): string { .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') - .replaceAll('\'', ''') + .replaceAll("'", ''') } function getTierImageSizeAndColumns(tierLevel: number, tierLevels: number[]): [columns: number, imageSize: number] { @@ -94,7 +93,9 @@ function buildSlotCards(slotSponsors: Sponsor[]): string[] { const description = escapeHtml(sponsor.description ?? '') lines.push(' ') - lines.push(` ${name}${name}
${description}`) + lines.push( + ` ${name}${name}
${description}`, + ) lines.push(' ') } @@ -110,9 +111,7 @@ function buildSponsorsSection(sponsors: Sponsor[]): string { // Slot sponsors are featured as cards up top; the tier tables below carry // everyone else so nobody appears twice. - const slotSponsors = activeSponsors - .filter(sponsor => sponsor.slot !== undefined) - .sort((a, b) => a.slot! - b.slot!) + const slotSponsors = activeSponsors.filter(sponsor => sponsor.slot !== undefined).sort((a, b) => a.slot! - b.slot!) const tieredSponsors = activeSponsors.filter(sponsor => sponsor.slot === undefined) const groupedSponsors = new Map() @@ -164,7 +163,9 @@ function buildSponsorsSection(sponsors: Sponsor[]): string { const displayName = sponsor.name ?? sponsor.login const escapedName = escapeHtml(displayName) - lines.push(` ${escapedName}
${escapedName}
`) + lines.push( + ` ${escapedName}
${escapedName}
`, + ) const isRowEnd = (index + 1) % columns === 0 const isLast = index === tierSponsors.length - 1 @@ -215,13 +216,11 @@ async function main(): Promise { } // Links arrive with their tracking params already baked in upstream. - const sponsors = await response.json() as Sponsor[] + const sponsors = (await response.json()) as Sponsor[] const readmeFiles = await findReadmes(ROOT_DIR) const replacement = buildSponsorsSection(sponsors) - const readmeContents = await Promise.all( - readmeFiles.map(readmePath => readFile(readmePath, 'utf8')), - ) + const readmeContents = await Promise.all(readmeFiles.map(readmePath => readFile(readmePath, 'utf8'))) const writePromises: Promise[] = [] let updatedCount = 0 @@ -239,7 +238,7 @@ async function main(): Promise { console.log(`Updated sponsors section in ${updatedCount} README files.`) } -main().catch((error) => { +main().catch(error => { console.error(error) process.exitCode = 1 }) diff --git a/tsconfig.json b/tsconfig.json index ac7162b..b06914f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,9 +23,5 @@ "*/*/tests", "*/*/*" ], - "exclude": [ - "playgrounds/**", - "tests/bun/**", - "tests/deno/**" - ] + "exclude": ["playgrounds/**", "tests/bun/**", "tests/deno/**"] } From 63f583a8f463d7af81608a5913e07a85b1ac0d47 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 11:03:02 +0700 Subject: [PATCH 02/10] refactor(uncheck): resolve paths centrally, split steps, stop hooks Paths and globs are resolved by uncheck into one file list that every tool receives, so tools never disagree on what a pattern means; a pattern matching nothing fails unless --no-error-on-unmatched-pattern is given. Matching uses Node's path.matchesGlob, which sets the floor at Node 22. Each tool is its own module under steps/ (oxlint, oxfmt, tsc) built on one Step contract, registered in checks.ts. Output goes through Effect's Console with children always piped, per the Effect CLI guide. Agent hooks now run once per turn instead of after every edit: `uncheck hooks install` writes stop-event configs and `uncheck hooks run` checks the working-tree changes with every step, then sends the agent back at most once (exit 2 for Claude Code and CodeBuddy, follow-up message for Cursor, block decision for Copilot). --- .github/workflows/ci.yaml | 2 +- package.json | 3 + packages/uncheck/README.md | 24 +- packages/uncheck/package.json | 3 + packages/uncheck/src/__tests__/fixture.ts | 48 ++ packages/uncheck/src/bin.ts | 6 + packages/uncheck/src/checks.ts | 166 ++++++ packages/uncheck/src/command.test.ts | 495 +++++++++--------- packages/uncheck/src/command.ts | 368 +------------ packages/uncheck/src/exec.ts | 67 +-- packages/uncheck/src/files.test.ts | 65 +++ packages/uncheck/src/files.ts | 207 ++++++++ packages/uncheck/src/hooks.test.ts | 45 +- packages/uncheck/src/hooks.ts | 271 ++++++---- packages/uncheck/src/index.ts | 3 + packages/uncheck/src/options.ts | 12 +- packages/uncheck/src/resolve.ts | 64 +++ packages/uncheck/src/step.ts | 132 +++++ packages/uncheck/src/steps/oxfmt.ts | 7 + packages/uncheck/src/steps/oxlint.ts | 7 + packages/uncheck/src/steps/tsc/index.ts | 221 ++++++++ .../tsc/tsc.test.ts} | 54 +- .../uncheck/src/steps/tsc/tsconfig.test.ts | 86 +++ .../uncheck/src/{ => steps/tsc}/tsconfig.ts | 119 ++--- packages/uncheck/src/tools.ts | 54 -- packages/uncheck/src/tsconfig.test.ts | 193 ------- packages/uncheck/src/typecheck.ts | 273 ---------- packages/uncheck/src/ui.ts | 50 +- 28 files changed, 1575 insertions(+), 1470 deletions(-) create mode 100644 packages/uncheck/src/__tests__/fixture.ts create mode 100644 packages/uncheck/src/checks.ts create mode 100644 packages/uncheck/src/files.test.ts create mode 100644 packages/uncheck/src/files.ts create mode 100644 packages/uncheck/src/resolve.ts create mode 100644 packages/uncheck/src/step.ts create mode 100644 packages/uncheck/src/steps/oxfmt.ts create mode 100644 packages/uncheck/src/steps/oxlint.ts create mode 100644 packages/uncheck/src/steps/tsc/index.ts rename packages/uncheck/src/{typecheck.test.ts => steps/tsc/tsc.test.ts} (63%) create mode 100644 packages/uncheck/src/steps/tsc/tsconfig.test.ts rename packages/uncheck/src/{ => steps/tsc}/tsconfig.ts (75%) delete mode 100644 packages/uncheck/src/tools.ts delete mode 100644 packages/uncheck/src/tsconfig.test.ts delete mode 100644 packages/uncheck/src/typecheck.ts diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 70ab06c..8dffe93 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [26, 24, 22, 20] + node-version: [26, 24, 22] fail-fast: false steps: - uses: actions/checkout@v7 diff --git a/package.json b/package.json index 2b75dd4..6fa2e17 100644 --- a/package.json +++ b/package.json @@ -44,5 +44,8 @@ "version": "12.5.1", "onFail": "download" } + }, + "engines": { + "node": ">=22" } } diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 2d09c90..452d223 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -26,11 +26,11 @@ ## Usage ```sh -npm i -D uncheck +npm i -D uncheck # Node 22 or newer npx uncheck # oxlint, oxfmt --check and tsc for the whole project npx uncheck --fix # oxlint --fix, then rewrite formatting with oxfmt -npx uncheck src/app # forward paths or globs to oxlint and oxfmt, like you would call them directly +npx uncheck src/app # check some files: paths, directories, globs and !exclusions npx uncheck --tsc=false # skip a step; --oxlint, --oxfmt and --tsc are auto-detected by default npx uncheck --oxlint # require a step: fail when it cannot run instead of skipping it ``` @@ -39,10 +39,12 @@ Steps run in order and every step runs even if an earlier one fails, so one run | Step | Runs when | Command | | -------- | ------------------------------------- | -------------------------------------------------------------- | -| `oxlint` | `oxlint` is installed | `oxlint [--fix] [paths...]` | -| `oxfmt` | `oxfmt` is installed | `oxfmt --check [paths...]`, or `oxfmt [paths...]` with `--fix` | +| `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` | +| `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` | | `tsc` | at least one `tsconfig.json` is found | `tsc -b` for projects using references, `tsc -p` for the rest | +Paths given on the command line are resolved by `uncheck` itself into one list of files that every tool receives, so tools never disagree about what a directory or glob means: a file must exist, a directory expands to the project files below it (ignored files stay out, like `git ls-files`), globs use the usual `**`/`*` syntax and `!pattern` excludes. A path that matches nothing fails the run, unless `--no-error-on-unmatched-pattern` is passed. + Tools are resolved from `node_modules` the way Node does, so the versions your project already depends on are used. A `tsconfig.json` without `typescript` installed is reported as a failure rather than silently skipped. ### Typecheck in monorepos @@ -53,22 +55,24 @@ Every `tsconfig.json` in the project is discovered (through `git ls-files`, so i - Remaining standalone projects (for example a root `tsconfig.json` that only covers tests and scripts) are checked afterwards with `tsc -p`. - Circular references are reported as an error. -When paths are given, `tsc` runs only the projects it would actually check for them. A file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. A directory, or the static prefix of a glob, selects the projects whose inputs can live under it. Files `tsc` never checks, such as Markdown or CSS, select no project. +When files are given, `tsc` runs only the projects it would actually check for them: a file selects the projects whose `files`, `include` and `exclude` (with `extends` applied) take it as input, so a test file excluded by its package config but included by the root config runs the root project only. Files `tsc` never checks, such as Markdown or CSS, select no project. ## Agent hooks ```sh -npx uncheck hooks # pick agents interactively -npx uncheck hooks claude cursor # or name them: claude, codebuddy, cursor, windsurf, copilot +npx uncheck hooks install # pick agents interactively +npx uncheck hooks install claude cursor # or name them: claude, codebuddy, cursor, windsurf, copilot ``` -This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json`, `.windsurf/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. After every file the agent edits, the hook runs: +This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settings.json`, `.cursor/hooks.json`, `.windsurf/hooks.json` or `.github/hooks/uncheck.json`), merging into an existing file so other hooks are kept. Whenever the agent finishes a turn, the hook runs: ```sh -uncheck --fix --hook +uncheck hooks run --fix ``` -through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). `--hook` reads the agent's payload from stdin, applies lint fixes and formatting to the edited files, typechecks the projects containing them, and prints the report on stderr. When problems remain, they are handed back to the agent as additional context so it can fix them right away (Claude Code and CodeBuddy), while the exit code stays 0 so no agent treats findings as a broken hook. +through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). It checks the files changed since the last commit (modified, staged and untracked, the whole project outside git) with every step, applies fixes, and prints the report on stderr. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop. Windsurf only shows the report. + +Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. ## Sponsors diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index e03710e..2c9593f 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -71,5 +71,8 @@ "typescript": { "optional": true } + }, + "engines": { + "node": ">=22" } } diff --git a/packages/uncheck/src/__tests__/fixture.ts b/packages/uncheck/src/__tests__/fixture.ts new file mode 100644 index 0000000..d1c4a22 --- /dev/null +++ b/packages/uncheck/src/__tests__/fixture.ts @@ -0,0 +1,48 @@ +import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' + +export const TOOLS = ['oxlint', 'oxfmt', 'typescript'] as const + +const packageNodeModules = fileURLToPath(new URL('../../node_modules', import.meta.url)) +const fixtures: string[] = [] + +afterAll(() => { + for (const dir of fixtures) { + rmSync(dir, { recursive: true, force: true }) + } +}) + +/** + * Creates a throwaway project in the OS temp dir. Objects are written as JSON, the requested tools + * are symlinked into `node_modules` so they resolve like a regular install, and config files are + * kept out of the format step so tests only see the formatting issues they put in. + */ +export function fixture( + files: Record, + tools: ReadonlyArray<(typeof TOOLS)[number]> = TOOLS, +): string { + const dir = mkdtempSync(join(tmpdir(), 'uncheck-')) + fixtures.push(dir) + + if (tools.length > 0) { + mkdirSync(join(dir, 'node_modules')) + + for (const tool of tools) { + symlinkSync(realpathSync(join(packageNodeModules, tool)), join(dir, 'node_modules', tool)) + } + } + + const defaults = { + '.gitignore': 'node_modules\ndist\n', + '.prettierignore': 'tsconfig.json\n.oxlintrc.json\n', + } + + for (const [relative, content] of Object.entries({ ...defaults, ...files })) { + mkdirSync(dirname(join(dir, relative)), { recursive: true }) + writeFileSync(join(dir, relative), typeof content === 'string' ? content : JSON.stringify(content)) + } + + return dir +} diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts index 69ddc9b..6055c63 100644 --- a/packages/uncheck/src/bin.ts +++ b/packages/uncheck/src/bin.ts @@ -13,6 +13,12 @@ Command.run(command, { version: pkg.version }).pipe( process.exitCode = 1 }), ), + // Exit code 2 is how Claude Code and CodeBuddy stop hooks send the agent back to work. + Effect.catchTag('StopBlocked', () => + Effect.sync(() => { + process.exitCode = 2 + }), + ), Effect.provide(NodeServices.layer), NodeRuntime.runMain, ) diff --git a/packages/uncheck/src/checks.ts b/packages/uncheck/src/checks.ts new file mode 100644 index 0000000..b7de992 --- /dev/null +++ b/packages/uncheck/src/checks.ts @@ -0,0 +1,166 @@ +import type { PlatformError } from 'effect' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import type { Step, StepName, StepOutcome, StepPlan } from './step' +import { Data, Duration, Effect, Option, Path } from 'effect' +import { CliError, Flag } from 'effect/unstable/cli' +import { execCommand } from './exec' +import { listProjectFiles, resolvePaths } from './files' +import { UncheckOptions } from './options' +import { describeArgs } from './step' +import { oxfmt } from './steps/oxfmt' +import { oxlint } from './steps/oxlint' +import { tsc } from './steps/tsc' +import { bold, dim, green, line, red } from './ui' + +/** Every step uncheck knows, in run order. A new tool is one file in `steps/` plus an entry here. */ +const STEPS: ReadonlyArray = [oxlint, oxfmt, tsc] + +export class CheckFailed extends Data.TaggedError('CheckFailed')<{ + readonly outcomes: ReadonlyArray +}> {} + +/** + * Per-step switch from the `--oxlint`, `--oxfmt` and `--tsc` flags: `undefined` auto-detects, + * `false` skips the step, `true` requires it and fails when it cannot run. + */ +export type StepFlags = Readonly> + +export interface RunSettings { + readonly fix: boolean + readonly flags: StepFlags + /** Run with whatever matched instead of failing when a given path or pattern matches no file. */ + readonly allowUnmatched: boolean +} + +export const fixFlag = Flag.Boolean('fix').pipe( + Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), + Flag.withDefault(false), +) + +function stepFlag(step: Step) { + return Flag.Boolean(step.name).pipe( + Flag.optional, + Flag.map(Option.getOrUndefined), + Flag.withDescription( + `${step.does}. On by default when ${step.when}; --${step.name}=false skips it, --${step.name} requires it`, + ), + ) +} + +/** One optional boolean flag per step, spread into a command's config. */ +export const stepFlags = Object.fromEntries(STEPS.map(step => [step.name, stepFlag(step)])) as Record< + StepName, + ReturnType +> + +/** + * Runs every step over `paths` (the whole project when empty), prints the run log and fails with + * `CheckFailed` when a step failed, so callers decide how to report it. + */ +export function runChecks(paths: ReadonlyArray, { fix, flags, allowUnmatched }: RunSettings) { + return Effect.gen(function* () { + const path = yield* Path.Path + const cwd = path.resolve((yield* UncheckOptions).cwd) + const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) + + yield* line(dim(`uncheck in ${cwd}`)) + + let files: ReadonlyArray | undefined + + if (paths.length > 0) { + const resolved = yield* resolvePaths(paths, cwd, projectFiles) + + if (resolved.unmatched.length > 0 && !allowUnmatched) { + const userMessage = `No files match ${resolved.unmatched.join(', ')}. Pass --no-error-on-unmatched-pattern to run with whatever matched.` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + if (resolved.files.length === 0) { + yield* line(`${dim('ā—‹')} nothing to check, no files match ${paths.join(' ')}`) + return + } + + files = resolved.files + } + + const plans = yield* Effect.all( + STEPS.map(step => + flags[step.name] === false + ? Effect.succeed(disabledPlan(step.name)) + : step.plan({ cwd, fix, files, required: flags[step.name] === true, projectFiles }), + ), + { concurrency: 'unbounded' }, + ) + + const outcomes = yield* Effect.forEach(plans, plan => runStep(plan, cwd)) + + const ran = outcomes.filter(outcome => outcome.status !== 'skipped') + const failed = outcomes.filter(outcome => outcome.status === 'failed') + + yield* line('') + + if (ran.length === 0) { + const reasons = outcomes.map(outcome => `${outcome.name} ${outcome.reason}`).join(', ') + + yield* line(`${red('✘')} nothing to check: ${reasons}`) + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + if (failed.length > 0) { + yield* line( + `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map(outcome => outcome.name).join(', ')}`, + ) + + if (!fix && failed.some(outcome => STEPS.find(step => step.name === outcome.name)?.fixes)) { + yield* line(dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) + } + + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + yield* line(`${green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) + }) +} + +function disabledPlan(name: StepName): StepPlan { + return { name, status: 'skipped', reason: `disabled with --${name}=false` } +} + +function runStep( + plan: StepPlan, + cwd: string, +): Effect.Effect { + return Effect.gen(function* () { + if (plan.status !== 'run') { + yield* line( + plan.status === 'skipped' + ? `${dim('ā—‹')} ${bold(plan.name)} ${dim(`skipped, ${plan.reason}`)}` + : `${red('✘')} ${bold(plan.name)} ${red(plan.reason)}`, + ) + return plan + } + + const [duration, exitCodes] = yield* Effect.timed( + Effect.forEach(plan.commands, invocation => + line(`${dim('ā–¶')} ${bold(invocation.bin.name)} ${dim(describeArgs(invocation))}`.trimEnd()).pipe( + Effect.flatMap(() => execCommand(invocation, cwd)), + ), + ), + ) + + const failed = exitCodes.some(exitCode => exitCode !== 0) + const elapsed = dim(formatDuration(Duration.toMillis(duration))) + + yield* line( + failed + ? `${red('✘')} ${bold(plan.name)} ${red('failed')} ${elapsed}` + : `${green('āœ”')} ${bold(plan.name)} ${green('passed')} ${elapsed}`, + ) + + return { name: plan.name, status: failed ? 'failed' : 'passed' } satisfies StepOutcome + }) +} + +function formatDuration(ms: number): string { + return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s` +} diff --git a/packages/uncheck/src/command.test.ts b/packages/uncheck/src/command.test.ts index b48fff1..fec6512 100644 --- a/packages/uncheck/src/command.test.ts +++ b/packages/uncheck/src/command.test.ts @@ -1,121 +1,62 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' +import { readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import { stripVTControlCharacters } from 'node:util' import { NodeServices } from '@effect/platform-node' -import { Effect, Layer, Sink, Stdio, Stream, Terminal } from 'effect' +import { Console, Effect, Stdio, Stream } from 'effect' import { Command } from 'effect/unstable/cli' +import { fixture } from './__tests__/fixture' import { CheckFailed, command, UncheckOptions } from './index' -const TOOLS = ['oxlint', 'oxfmt', 'typescript'] as const - -const packageNodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) -const fixtures: string[] = [] - -afterAll(() => { - for (const dir of fixtures) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -/** - * Creates a throwaway project in the OS temp dir. The requested tools are symlinked into its - * `node_modules` so they resolve exactly like a regular install would. Config files are kept out - * of the format step so tests only see the formatting issues they put in. - */ -function fixture(files: Record, tools: ReadonlyArray<(typeof TOOLS)[number]> = TOOLS): string { - const dir = mkdtempSync(join(tmpdir(), 'uncheck-')) - fixtures.push(dir) - - if (tools.length > 0) { - mkdirSync(join(dir, 'node_modules')) - - for (const tool of tools) { - symlinkSync(realpathSync(join(packageNodeModules, tool)), join(dir, 'node_modules', tool)) - } - } - - const defaults = { - '.gitignore': 'node_modules\ndist\n', - '.prettierignore': 'tsconfig.json\n.oxlintrc.json\n', - } - - for (const [relative, content] of Object.entries({ ...defaults, ...files })) { - mkdirSync(dirname(join(dir, relative)), { recursive: true }) - writeFileSync(join(dir, relative), content) - } - - return dir -} - interface RunResult { - readonly result: 'ok' | CheckFailed - /** Everything written through the terminal, without ANSI styling. */ - readonly output: string + readonly result: 'ok' | 'blocked' | CheckFailed + /** Everything logged, one line each, without ANSI styling. */ readonly stdout: string readonly stderr: string } async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): Promise { - const output: string[] = [] const stdout: string[] = [] const stderr: string[] = [] - const decoder = new TextDecoder() - const collect = (into: string[]) => () => - Sink.forEach((chunk: string | Uint8Array) => - Effect.sync(() => { - into.push(typeof chunk === 'string' ? chunk : decoder.decode(chunk)) - }), - ) - const terminal = Terminal.make({ - columns: Effect.succeed(80), - rows: Effect.succeed(24), - readInput: Effect.die('unused'), - readLine: Effect.die('unused'), - display: text => - Effect.sync(() => { - output.push(text) - }), + const capture: Console.Console = Object.assign(Object.create(globalThis.console), { + log: (...parts: ReadonlyArray) => { + stdout.push(parts.join(' ')) + }, + error: (...parts: ReadonlyArray) => { + stderr.push(parts.join(' ')) + }, }) const result = await Effect.runPromise( Command.runWith(command, { version: '0.0.0' })(args).pipe( Effect.map(() => 'ok' as const), Effect.catchTag('CheckFailed', error => Effect.succeed(error)), - Effect.provideService(UncheckOptions, { cwd, stdio: 'pipe' }), - // Inner layers take precedence, so these replace the Node terminal and stdio. - Effect.provide(Layer.succeed(Terminal.Terminal, terminal)), - Effect.provide( - Stdio.layerTest({ - stdin: Stream.make(new TextEncoder().encode(stdin)), - stdout: collect(stdout), - stderr: collect(stderr), - }), - ), + Effect.catchTag('StopBlocked', () => Effect.succeed('blocked' as const)), + Effect.provideService(UncheckOptions, { cwd }), + Effect.provideService(Console.Console, capture), + Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })), Effect.provide(NodeServices.layer), ), ) - return { - result, - output: stripVTControlCharacters(output.join('')), - stdout: stdout.join(''), - stderr: stderr.join(''), - } + return { result, stdout: text(stdout), stderr: text(stderr) } +} + +/** Joins captured lines the way a terminal would show them, without ANSI styling. */ +function text(lines: string[]): string { + return stripVTControlCharacters(lines.map(line => `${line}\n`).join('')) } -const oxlintrc = JSON.stringify({ rules: { 'no-var': 'error' } }) +const oxlintrc = { rules: { 'no-var': 'error' } } -const standaloneTsconfig = JSON.stringify({ +const standaloneTsconfig = { compilerOptions: { noEmit: true, strict: true, module: 'esnext', moduleResolution: 'bundler', types: [] }, include: ['src', 'scripts'], -}) +} function compositeTsconfig(references: string[] = []) { - return JSON.stringify({ + return { compilerOptions: { composite: true, declaration: true, @@ -129,27 +70,11 @@ function compositeTsconfig(references: string[] = []) { }, references: references.map(path => ({ path })), include: ['src'], - }) + } } describe('uncheck', { timeout: 120_000 }, () => { - it('runs lint, format and typecheck and passes on a clean project', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - }) - - const { result, output } = await run(dir) - - expect(result).toBe('ok') - expect(output).toContain('ā–¶ oxlint\n') - expect(output).toContain('ā–¶ oxfmt --check\n') - expect(output).toContain('ā–¶ tsc -p tsconfig.json\n') - expect(output).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') - }) - - it('reports lint and format failures, then fixes them with --fix', async () => { + it('reports oxlint and oxfmt failures, fixes them with --fix, then passes', async () => { const dir = fixture({ '.oxlintrc.json': oxlintrc, 'tsconfig.json': standaloneTsconfig, @@ -165,19 +90,26 @@ describe('uncheck', { timeout: 120_000 }, () => { { name: 'oxfmt', status: 'failed' }, { name: 'tsc', status: 'passed' }, ]) - expect(check.output).toContain('no-var') - expect(check.output).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') - expect(check.output).toContain('run `uncheck --fix` to apply oxlint and oxfmt fixes') + expect(check.stdout).toContain('no-var') + expect(check.stdout).toContain('✘ 2 of 3 checks failed: oxlint, oxfmt') + expect(check.stdout).toContain('run `uncheck --fix` to apply oxlint and oxfmt fixes') const fix = await run(dir, ['--fix']) expect(fix.result).toBe('ok') - expect(fix.output).toContain('ā–¶ oxlint --fix\n') - expect(fix.output).toContain('ā–¶ oxfmt\n') + expect(fix.stdout).toContain('ā–¶ oxlint --fix\n') + expect(fix.stdout).toContain('ā–¶ oxfmt\n') expect(readFileSync(join(dir, 'src/legacy.ts'), 'utf8')).toBe('const count = 1;\nexport { count };\n') expect(readFileSync(join(dir, 'src/ugly.ts'), 'utf8')).toBe('export const ugly = { a: 1, b: 2 };\n') - expect((await run(dir)).result).toBe('ok') + const clean = await run(dir) + + expect(clean.result).toBe('ok') + expect(clean.stdout.startsWith(`uncheck in ${dir}\nā–¶ oxlint\n`)).toBe(true) + expect(clean.stdout).toContain('ā–¶ oxlint\n') + expect(clean.stdout).toContain('ā–¶ oxfmt --check\n') + expect(clean.stdout).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(clean.stdout).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') }) it('builds referenced projects with tsc -b before checking standalone ones with tsc -p', async () => { @@ -192,11 +124,11 @@ describe('uncheck', { timeout: 120_000 }, () => { 'import { answer } from "../../lib/src/index";\n\nexport const double: number = answer * 2;\n', }) - const { result, output } = await run(dir) + const { result, stdout } = await run(dir) expect(result).toBe('ok') - expect(output).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nā–¶ tsc -p tsconfig.json\n') - expect(output).not.toContain('packages/lib/tsconfig.json') + expect(stdout).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nā–¶ tsc -p tsconfig.json\n') + expect(stdout).not.toContain('packages/lib/tsconfig.json') writeFileSync(join(dir, 'packages/lib/src/index.ts'), 'export const answer: number = "42";\n') @@ -208,11 +140,11 @@ describe('uncheck', { timeout: 120_000 }, () => { { name: 'oxfmt', status: 'passed' }, { name: 'tsc', status: 'failed' }, ]) - expect(broken.output).toContain('packages/lib/src/index.ts') - expect(broken.output).toContain('TS2322') + expect(broken.stdout).toContain('packages/lib/src/index.ts') + expect(broken.stdout).toContain('TS2322') }) - it('forwards paths to oxlint and oxfmt and narrows typecheck to the projects containing them', async () => { + it('forwards paths to oxlint and oxfmt and narrows tsc to the projects containing them', async () => { const dir = fixture({ '.oxlintrc.json': oxlintrc, 'tsconfig.json': standaloneTsconfig, @@ -229,26 +161,54 @@ describe('uncheck', { timeout: 120_000 }, () => { const scoped = await run(dir, ['packages/app']) expect(scoped.result).toBe('ok') - expect(scoped.output).not.toContain('no-var') - expect(scoped.output).toContain('ā–¶ oxlint --no-error-on-unmatched-pattern packages/app\n') - expect(scoped.output).toContain('ā–¶ oxfmt --check --no-error-on-unmatched-pattern packages/app\n') + expect(scoped.stdout).not.toContain('no-var') + expect(scoped.stdout).toContain('ā–¶ oxlint packages/app/src/index.ts packages/app/tsconfig.json\n') + expect(scoped.stdout).toContain('ā–¶ oxfmt --check packages/app/src/index.ts packages/app/tsconfig.json\n') // The root project's inputs (src, scripts) do not reach into packages/app, so only app is built. - expect(scoped.output).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nāœ” tsc') - expect(scoped.output).not.toContain('tsc -p tsconfig.json') + expect(scoped.stdout).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nāœ” tsc') + expect(scoped.stdout).not.toContain('tsc -p tsconfig.json') const single = await run(dir, ['--fix', 'scripts/hello.ts']) expect(single.result).toBe('ok') - expect(single.output).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n') - expect(single.output).toContain('ā–¶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') - expect(single.output).toContain('ā–¶ tsc -p tsconfig.json\n') - expect(single.output).not.toContain('tsc -b') + expect(single.stdout).toContain('ā–¶ oxlint --fix scripts/hello.ts\n') + expect(single.stdout).toContain('ā–¶ oxfmt scripts/hello.ts\n') + expect(single.stdout).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(single.stdout).not.toContain('tsc -b') + + const all = await run(dir, ['.']) + + expect(all.stdout).toMatch(/ā–¶ oxlint \[\d+ files\]\n/) + expect(all.stdout).toMatch(/ā–¶ oxfmt --check \[\d+ files\]\n/) const docs = await run(dir, ['README.md']) expect(docs.result).toBe('ok') - expect(docs.output).toContain('ā—‹ tsc skipped, no tsconfig.json covers the given paths\n') - expect(docs.output).toContain('āœ” all checks passed (oxlint, oxfmt)') + expect(docs.stdout).toContain('ā–¶ oxlint README.md\n') + expect(docs.stdout).toContain('ā—‹ tsc skipped, no tsconfig.json covers the given files\n') + expect(docs.stdout).toContain('āœ” all checks passed (oxlint, oxfmt)') + }) + + it('fails on paths that match nothing unless told otherwise', async () => { + const dir = fixture({ + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + }) + + await expect(run(dir, ['src/index.ts', 'missing.ts', 'lib/**'])).rejects.toThrow( + /No files match missing.ts, lib\/\*\*/, + ) + + const lenient = await run(dir, ['--no-error-on-unmatched-pattern', 'src/index.ts', 'missing.ts']) + + expect(lenient.result).toBe('ok') + expect(lenient.stdout).toContain('ā–¶ oxlint src/index.ts\n') + + const nothing = await run(dir, ['--no-error-on-unmatched-pattern', 'missing.ts']) + + expect(nothing.result).toBe('ok') + expect(nothing.stdout).toBe(`uncheck in ${dir}\nā—‹ nothing to check, no files match missing.ts\n`) }) it('discovers tsconfig files through git so ignored folders are skipped', async () => { @@ -264,16 +224,16 @@ describe('uncheck', { timeout: 120_000 }, () => { const walked = await run(plain) expect(walked.result).toBeInstanceOf(CheckFailed) - expect(walked.output).toContain('ā–¶ tsc -p ignored/tsconfig.json\n') + expect(walked.stdout).toContain('ā–¶ tsc -p ignored/tsconfig.json\n') const repo = fixture(files, ['typescript']) execFileSync('git', ['init', '--quiet'], { cwd: repo }) const tracked = await run(repo) expect(tracked.result).toBe('ok') - expect(tracked.output).not.toContain('ignored/tsconfig.json') - expect(tracked.output).toContain('ā—‹ oxlint skipped, not installed\n') - expect(tracked.output).toContain('ā—‹ oxfmt skipped, not installed\n') + expect(tracked.stdout).not.toContain('ignored/tsconfig.json') + expect(tracked.stdout).toContain('ā—‹ oxlint skipped, not installed\n') + expect(tracked.stdout).toContain('ā—‹ oxfmt skipped, not installed\n') }) it('fails on circular project references', async () => { @@ -287,82 +247,100 @@ describe('uncheck', { timeout: 120_000 }, () => { ['typescript'], ) - const { result, output } = await run(dir) + const { result, stdout } = await run(dir) expect(result).toBeInstanceOf(CheckFailed) - expect(output).toContain( + expect(stdout).toContain( '✘ tsc circular project references between packages/a/tsconfig.json, packages/b/tsconfig.json\n', ) }) it('fails when a tsconfig.json exists but typescript is not installed', async () => { - const dir = fixture( - { - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer = 42;\n', - }, - [], - ) + const dir = fixture({ 'tsconfig.json': standaloneTsconfig, 'src/index.ts': 'export const answer = 42;\n' }, []) - const { result, output } = await run(dir) + const { result, stdout } = await run(dir) expect(result).toBeInstanceOf(CheckFailed) expect((result as CheckFailed).outcomes.map(outcome => outcome.status)).toEqual(['skipped', 'skipped', 'failed']) - expect(output).toContain('found 1 tsconfig.json but typescript is not installed') + expect(stdout).toContain('found 1 tsconfig.json but typescript is not installed') }) it('fails when there is nothing to check', async () => { const dir = fixture({ 'package.json': '{}\n' }, []) - const { result, output } = await run(dir) + const { result, stdout } = await run(dir) expect(result).toBeInstanceOf(CheckFailed) expect((result as CheckFailed).outcomes.map(outcome => outcome.status)).toEqual(['skipped', 'skipped', 'skipped']) - expect(output).toContain('nothing to check: oxlint not installed, oxfmt not installed, tsc no tsconfig.json found') + expect(stdout).toContain('nothing to check: oxlint not installed, oxfmt not installed, tsc no tsconfig.json found') + }) +}) + +describe('uncheck step flags', { timeout: 120_000 }, () => { + it('skips a step with --=false and requires it with --', async () => { + const dir = fixture( + { + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: string = 1;\n', + }, + ['oxlint', 'typescript'], + ) + + const skipped = await run(dir, ['--tsc=false']) + + expect(skipped.result).toBe('ok') + expect(skipped.stdout).toContain('ā—‹ oxfmt skipped, not installed\n') + expect(skipped.stdout).toContain('ā—‹ tsc skipped, disabled with --tsc=false\n') + expect(skipped.stdout).toContain('āœ” all checks passed (oxlint)') + + const required = await run(dir, ['--oxfmt', '--tsc=false']) + + expect(required.result).toBeInstanceOf(CheckFailed) + expect(required.stdout).toContain('✘ oxfmt not installed\n') + expect(required.stdout).toContain('✘ 1 of 2 checks failed: oxfmt') + + const none = await run(dir, ['--no-oxlint', '--no-oxfmt', '--no-tsc']) + + expect(none.result).toBeInstanceOf(CheckFailed) + expect(none.stdout).toContain( + 'nothing to check: oxlint disabled with --oxlint=false, oxfmt disabled with --oxfmt=false, tsc disabled with --tsc=false', + ) }) }) -describe('uncheck hooks', { timeout: 120_000 }, () => { - it('writes hook configs for the named agents through the detected package manager', async () => { +describe('uncheck hooks install', { timeout: 120_000 }, () => { + it('writes stop hook configs for the named agents through the detected package manager', async () => { const dir = fixture({ 'package.json': '{}\n', 'pnpm-lock.yaml': '' }, []) - const { result, output } = await run(dir, ['hooks', 'claude', 'cursor', 'windsurf', 'copilot']) + const { result, stdout } = await run(dir, ['hooks', 'install', 'claude', 'cursor', 'windsurf', 'copilot']) expect(result).toBe('ok') - expect(output).toContain('āœ” Claude Code .claude/settings.json created\n') - expect(output).toContain('āœ” Cursor .cursor/hooks.json created\n') - expect(output).toContain('pnpm exec uncheck --fix --hook') + expect(stdout).toContain('āœ” Claude Code .claude/settings.json created\n') + expect(stdout).toContain('āœ” Cursor .cursor/hooks.json created\n') + expect(stdout).toContain('pnpm exec uncheck hooks run --fix') + + const hook = 'pnpm exec uncheck hooks run --fix' expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ - hooks: { - PostToolUse: [ - { - matcher: 'Edit|Write|MultiEdit|NotebookEdit', - hooks: [{ type: 'command', command: 'pnpm exec uncheck --fix --hook' }], - }, - ], - }, + hooks: { Stop: [{ hooks: [{ type: 'command', command: hook }] }] }, }) expect(JSON.parse(readFileSync(join(dir, '.cursor/hooks.json'), 'utf8'))).toEqual({ version: 1, - hooks: { afterFileEdit: [{ command: 'pnpm exec uncheck --fix --hook' }] }, + hooks: { stop: [{ command: hook }] }, }) expect(JSON.parse(readFileSync(join(dir, '.windsurf/hooks.json'), 'utf8'))).toEqual({ - hooks: { post_write_code: [{ command: 'pnpm exec uncheck --fix --hook', show_output: true }] }, + hooks: { post_cascade_response: [{ command: hook, show_output: true }] }, }) expect(JSON.parse(readFileSync(join(dir, '.github/hooks/uncheck.json'), 'utf8'))).toEqual({ version: 1, - hooks: { - postToolUse: [ - { type: 'command', bash: 'pnpm exec uncheck --fix --hook', powershell: 'pnpm exec uncheck --fix --hook' }, - ], - }, + hooks: { agentStop: [{ type: 'command', bash: hook, powershell: hook }] }, }) - const again = await run(dir, ['hooks', 'claude']) + const again = await run(dir, ['hooks', 'install', 'claude']) expect(again.result).toBe('ok') - expect(again.output).toContain('āœ” Claude Code .claude/settings.json unchanged\n') + expect(again.stdout).toContain('āœ” Claude Code .claude/settings.json unchanged\n') }) it('merges into existing settings and keeps other hooks', async () => { @@ -379,23 +357,18 @@ describe('uncheck hooks', { timeout: 120_000 }, () => { [], ) - const { result, output } = await run(dir, ['hooks', 'codebuddy', 'claude']) + const { result, stdout } = await run(dir, ['hooks', 'install', 'codebuddy', 'claude']) expect(result).toBe('ok') - expect(output).toContain('āœ” Claude Code .claude/settings.json updated\n') - expect(output).toContain('āœ” CodeBuddy .codebuddy/settings.json created\n') - expect(output).toContain('npx uncheck --fix --hook') + expect(stdout).toContain('āœ” Claude Code .claude/settings.json updated\n') + expect(stdout).toContain('āœ” CodeBuddy .codebuddy/settings.json created\n') + expect(stdout).toContain('npx uncheck hooks run --fix') expect(JSON.parse(readFileSync(join(dir, '.claude/settings.json'), 'utf8'))).toEqual({ permissions: { allow: ['Bash(pnpm test)'] }, hooks: { - PostToolUse: [ - { matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }, - { - matcher: 'Edit|Write|MultiEdit|NotebookEdit', - hooks: [{ type: 'command', command: 'npx uncheck --fix --hook' }], - }, - ], + PostToolUse: [{ matcher: 'Bash', hooks: [{ type: 'command', command: 'echo done' }] }], + Stop: [{ hooks: [{ type: 'command', command: 'npx uncheck hooks run --fix' }] }], }, }) }) @@ -403,107 +376,111 @@ describe('uncheck hooks', { timeout: 120_000 }, () => { it('rejects unknown agents', async () => { const dir = fixture({ 'package.json': '{}\n' }, []) - await expect(run(dir, ['hooks', 'emacs'])).rejects.toThrow() + await expect(run(dir, ['hooks', 'install', 'emacs'])).rejects.toThrow() }) }) -function claudePayload(filePath: string) { - return JSON.stringify({ hook_event_name: 'PostToolUse', tool_name: 'Edit', tool_input: { file_path: filePath } }) +/** A git repository with one clean commit, so later edits show up as working-tree changes. */ +function committed(files: Record) { + const dir = fixture(files) + const git = (...args: string[]) => + execFileSync('git', ['-c', 'user.name=uncheck', '-c', 'user.email=uncheck@example.com', ...args], { cwd: dir }) + + git('init', '--quiet') + git('add', '.') + git('commit', '--quiet', '-m', 'init') + + return dir } -describe('uncheck --hook', { timeout: 120_000 }, () => { - it('fixes the edited file and hands remaining problems back to the agent', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: string = 1\n', - 'src/other.ts': 'export const other = 2\n', - }) +describe('uncheck hooks run', { timeout: 120_000 }, () => { + const clean = { + '.oxlintrc.json': oxlintrc, + 'tsconfig.json': standaloneTsconfig, + 'src/index.ts': 'export const answer: number = 42;\n', + 'src/other.ts': 'export const other = 2;\n', + } - const { result, output, stdout, stderr } = await run( + it('checks the files changed since the last commit and sends the agent back once', async () => { + const dir = committed(clean) + writeFileSync(join(dir, 'src/index.ts'), 'export const answer: string = 1\n') + writeFileSync(join(dir, 'src/fresh.ts'), 'export const fresh = 3;\n') + + const claude = await run( dir, - ['--fix', '--hook'], - claudePayload(join(dir, 'src/index.ts')), + ['hooks', 'run', '--fix'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: false }), ) - expect(result).toBe('ok') - expect(output).toBe('') - expect(stderr).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern src/index.ts\n') - expect(stderr).toContain('ā–¶ oxfmt --no-error-on-unmatched-pattern src/index.ts\n') - expect(stderr).toContain('ā–¶ tsc -p tsconfig.json\n') - expect(stderr).toContain('TS2322') - expect(stderr).toContain('✘ 1 of 3 checks failed: tsc') - - const hookOutput = JSON.parse(stdout) as { additionalContext: string; hookSpecificOutput: Record } - - expect(hookOutput.additionalContext).toContain('uncheck found problems in src/index.ts') - expect(hookOutput.additionalContext).toContain('TS2322') - expect(hookOutput.hookSpecificOutput).toEqual({ - hookEventName: 'PostToolUse', - additionalContext: hookOutput.additionalContext, - }) - + expect(claude.result).toBe('blocked') + expect(claude.stdout).toBe('') + expect(claude.stderr).toContain('ā–¶ oxlint --fix src/fresh.ts src/index.ts\n') + expect(claude.stderr).toContain('ā–¶ tsc -p tsconfig.json\n') + expect(claude.stderr).toContain('TS2322') + expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc') expect(readFileSync(join(dir, 'src/index.ts'), 'utf8')).toBe('export const answer: string = 1;\n') - expect(readFileSync(join(dir, 'src/other.ts'), 'utf8')).toBe('export const other = 2\n') - }) - it('stays silent when everything passes and when the payload names no project file', async () => { - const dir = fixture({ - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: number = 42;\n', - }) + const continuing = await run( + dir, + ['hooks', 'run', '--fix'], + JSON.stringify({ hook_event_name: 'Stop', stop_hook_active: true }), + ) - const clean = await run(dir, ['--fix', '--hook'], JSON.stringify({ file_path: join(dir, 'src/index.ts') })) + expect(continuing.result).toBe('ok') + expect(continuing.stdout).toBe('') + expect(continuing.stderr).toContain('TS2322') - expect(clean.result).toBe('ok') - expect(clean.stdout).toBe('') - expect(clean.stderr).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') - - for (const payload of [ - claudePayload('/etc/hosts'), - claudePayload(join(dir, 'src/missing.ts')), - JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } }), - 'not json', - ]) { - const ignored = await run(dir, ['--fix', '--hook'], payload) - - expect(ignored.result).toBe('ok') - expect(ignored.stdout).toBe('') - expect(ignored.stderr).toBe('') - } - }) -}) + const cursor = await run(dir, ['hooks', 'run', '--fix'], JSON.stringify({ hook_event_name: 'stop', loop_count: 0 })) -describe('uncheck step flags', { timeout: 120_000 }, () => { - it('skips a step with --=false and requires it with --', async () => { - const dir = fixture( - { - '.oxlintrc.json': oxlintrc, - 'tsconfig.json': standaloneTsconfig, - 'src/index.ts': 'export const answer: string = 1;\n', - }, - ['oxlint', 'typescript'], + expect(cursor.result).toBe('ok') + expect((JSON.parse(cursor.stdout) as { followup_message: string }).followup_message).toContain('TS2322') + + const copilot = await run( + dir, + ['hooks', 'run', '--fix'], + JSON.stringify({ stopReason: 'end_turn', stop_hook_active: false }), ) - const skipped = await run(dir, ['--tsc=false']) + expect(copilot.result).toBe('ok') + expect(JSON.parse(copilot.stdout)).toMatchObject({ decision: 'block' }) - expect(skipped.result).toBe('ok') - expect(skipped.output).toContain('ā—‹ oxfmt skipped, not installed\n') - expect(skipped.output).toContain('ā—‹ tsc skipped, disabled with --tsc=false\n') - expect(skipped.output).toContain('āœ” all checks passed (oxlint)') + const windsurf = await run( + dir, + ['hooks', 'run', '--fix'], + JSON.stringify({ agent_action_name: 'post_cascade_response' }), + ) - const required = await run(dir, ['--oxfmt', '--tsc=false']) + expect(windsurf.result).toBe('ok') + expect(windsurf.stdout).toBe('') + expect(windsurf.stderr).toContain('TS2322') + }) - expect(required.result).toBeInstanceOf(CheckFailed) - expect(required.output).toContain('✘ oxfmt not installed\n') - expect(required.output).toContain('✘ 1 of 2 checks failed: oxfmt') + it('stays silent when nothing changed and passes quietly when the changes are clean', async () => { + const dir = committed(clean) - const none = await run(dir, ['--no-oxlint', '--no-oxfmt', '--no-tsc']) + const nothing = await run(dir, ['hooks', 'run', '--fix'], JSON.stringify({ hook_event_name: 'Stop' })) - expect(none.result).toBeInstanceOf(CheckFailed) - expect(none.output).toContain( - 'nothing to check: oxlint disabled with --oxlint=false, oxfmt disabled with --oxfmt=false, tsc disabled with --tsc=false', - ) + expect(nothing.result).toBe('ok') + expect(nothing.stdout).toBe('') + expect(nothing.stderr).toBe('') + + writeFileSync(join(dir, 'src/other.ts'), 'export const other = 3;\n') + + const ok = await run(dir, ['hooks', 'run', '--fix'], JSON.stringify({ hook_event_name: 'Stop' })) + + expect(ok.result).toBe('ok') + expect(ok.stdout).toBe('') + expect(ok.stderr).toContain('ā–¶ oxlint --fix src/other.ts\n') + expect(ok.stderr).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') + }) + + it('checks the whole project outside a git repository', async () => { + const dir = fixture(clean) + + const { result, stderr } = await run(dir, ['hooks', 'run'], '{}') + + expect(result).toBe('ok') + expect(stderr).toContain('ā–¶ oxlint\n') + expect(stderr).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') }) }) diff --git a/packages/uncheck/src/command.ts b/packages/uncheck/src/command.ts index 0bafb43..83547ad 100644 --- a/packages/uncheck/src/command.ts +++ b/packages/uncheck/src/command.ts @@ -1,376 +1,28 @@ -import type { PlatformError } from 'effect' -import type { ChildProcessSpawner } from 'effect/unstable/process' -import type { Tool } from './tools' -import type { Ui } from './ui' -import { stripVTControlCharacters } from 'node:util' -import { Data, Duration, Effect, FileSystem, Option, Path, Stdio, Stream, Terminal } from 'effect' -import { Argument, CliError, Command, Flag } from 'effect/unstable/cli' -import { execTool } from './exec' -import { extractHookPaths, hooksCommand } from './hooks' -import { UncheckOptions } from './options' -import { resolveTool } from './tools' -import { discoverTsconfigs, isWithin, loadTsProjects, planTypecheck, selectTsconfigs } from './typecheck' -import { makeUi } from './ui' - -export type StepName = 'oxlint' | 'oxfmt' | 'tsc' - -export interface StepOutcome { - readonly name: StepName - readonly status: 'passed' | 'failed' | 'skipped' - /** Why the step was skipped, or why it failed before any tool ran. */ - readonly reason?: string -} - -export class CheckFailed extends Data.TaggedError('CheckFailed')<{ - readonly outcomes: ReadonlyArray -}> {} - -/** - * Per-step switch from the `--oxlint`, `--oxfmt` and `--tsc` flags: unset auto-detects, - * `false` skips the step, `true` requires it and fails when it cannot run. - */ -type StepFlags = Readonly>> - -interface ToolCommand { - readonly tool: Tool - readonly args: ReadonlyArray -} - -interface RunPlan { - readonly name: StepName - readonly status: 'run' - readonly commands: ReadonlyArray -} - -/** A step that is either ready to run, or already settled without running anything. */ -type StepPlan = RunPlan | (StepOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) - -interface ToolStep { - readonly name: 'oxlint' | 'oxfmt' - readonly args: (fix: boolean) => ReadonlyArray -} - -/** The steps that simply run their tool, in run order. Fixes apply to exactly these. */ -const TOOL_STEPS: ReadonlyArray = [ - { name: 'oxlint', args: fix => (fix ? ['--fix'] : []) }, - { name: 'oxfmt', args: fix => (fix ? [] : ['--check']) }, -] - -/** Explicit paths may name files a tool does not handle (a Markdown file for oxlint), which is not a failure. */ -const UNMATCHED_PATHS_OK = '--no-error-on-unmatched-pattern' - -function stepFlag(name: StepName, what: string, when: string) { - return Flag.Boolean(name).pipe( - Flag.optional, - Flag.withDescription(`${what}. On by default when ${when}; --${name}=false skips it, --${name} requires it`), - ) -} +import { Effect } from 'effect' +import { Argument, Command, Flag } from 'effect/unstable/cli' +import { fixFlag, runChecks, stepFlags } from './checks' +import { hooksCommand } from './hooks' export const command = Command.make( 'uncheck', { - fix: Flag.Boolean('fix').pipe( - Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), - Flag.withDefault(false), - ), - hook: Flag.Boolean('hook').pipe( - Flag.withDescription( - 'Run as an agent hook: check the files named in the JSON payload on stdin, report on stderr and hand remaining problems back to the agent. Set up with `uncheck hooks`.', - ), + fix: fixFlag, + allowUnmatched: Flag.Boolean('no-error-on-unmatched-pattern').pipe( Flag.withDefault(false), + Flag.withDescription('Run with whatever matched instead of failing when a given path or pattern matches no file'), ), - oxlint: stepFlag('oxlint', 'Lint with oxlint', 'oxlint is installed'), - oxfmt: stepFlag('oxfmt', 'Check formatting with oxfmt', 'oxfmt is installed'), - tsc: stepFlag('tsc', 'Typecheck with tsc', 'a tsconfig.json is found'), + ...stepFlags, paths: Argument.String('paths').pipe( Argument.variadic(), Argument.withDescription( - 'Files, directories or glob patterns, forwarded to oxlint and oxfmt as-is. tsc runs only the projects that take them as input. Defaults to the whole project.', + 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to the whole project.', ), ), }, - ({ fix, hook, oxlint, oxfmt, tsc, paths }) => - Effect.gen(function* () { - const flags: StepFlags = { oxlint, oxfmt, tsc } - - if (hook) { - return yield* runHook(fix, flags) - } - - yield* runChecks(fix, paths, flags) - }), + ({ fix, allowUnmatched, paths, ...flags }) => Effect.suspend(() => runChecks(paths, { fix, flags, allowUnmatched })), ).pipe( Command.withDescription( 'Lint (oxlint), format check (oxfmt) and typecheck (tsc) a project with one command. Each step runs only when the project uses that tool.', ), Command.withSubcommands([hooksCommand]), ) - -function runChecks(fix: boolean, paths: ReadonlyArray, flags: StepFlags) { - return Effect.gen(function* () { - const options = yield* UncheckOptions - const ui = yield* makeUi - const cwd = options.cwd - - const [toolPlans, typescript, tsconfigs] = yield* Effect.all( - [ - Effect.forEach( - TOOL_STEPS, - step => - Effect.map(resolveTool(step.name, cwd), tool => planToolStep(step, tool, flags[step.name], fix, paths)), - { concurrency: 'unbounded' }, - ), - resolveTool('typescript', cwd, 'tsc'), - discoverTsconfigs(cwd), - ], - { concurrency: 'unbounded' }, - ) - - const plans = [...toolPlans, yield* planTscStep(typescript, tsconfigs, flags.tsc, paths, cwd)] - const outcomes = yield* Effect.forEach(plans, plan => runStep(ui, options, plan)) - - const ran = outcomes.filter(outcome => outcome.status !== 'skipped') - const failed = outcomes.filter(outcome => outcome.status === 'failed') - - yield* ui.line('') - - if (ran.length === 0) { - const reasons = outcomes.map(outcome => `${outcome.name} ${outcome.reason}`).join(', ') - - yield* ui.line(`${ui.red('✘')} nothing to check: ${reasons}`) - return yield* Effect.fail(new CheckFailed({ outcomes })) - } - - if (failed.length > 0) { - yield* ui.line( - `${ui.red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map(outcome => outcome.name).join(', ')}`, - ) - - if (!fix && failed.some(outcome => TOOL_STEPS.some(step => step.name === outcome.name))) { - yield* ui.line(ui.dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) - } - - return yield* Effect.fail(new CheckFailed({ outcomes })) - } - - yield* ui.line(`${ui.green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) - }) -} - -/** - * Agent hook mode. The payload on stdin names the files the agent just edited; only those inside - * the project are checked. Output is captured and mirrored to stderr, and when problems remain - * they are returned on stdout as `additionalContext`, which Claude Code and CodeBuddy show to - * the agent while every other agent ignores it. The exit code stays 0 so no agent treats a - * finding as a hook failure. - */ -function runHook(fix: boolean, flags: StepFlags) { - return Effect.gen(function* () { - const options = yield* UncheckOptions - const stdio = yield* Stdio.Stdio - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const terminal = yield* Terminal.Terminal - - if (yield* stdio.stdinIsTerminal) { - const userMessage = '--hook expects the agent hook payload as JSON on stdin' - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - const cwd = path.resolve(options.cwd) - - const payload = yield* Stream.mkString(Stream.decodeText(stdio.stdin)).pipe( - Effect.flatMap(text => Effect.try(() => JSON.parse(text) as unknown)), - Effect.orElseSucceed(() => undefined), - ) - - const files = yield* Effect.filter( - extractHookPaths(payload) - .map(file => path.resolve(cwd, file)) - .filter(file => isWithin(path, file, cwd)), - file => fs.exists(file).pipe(Effect.orElseSucceed(() => false)), - ) - - if (files.length === 0) { - return - } - - const chunks: string[] = [] - - const capture = Terminal.make({ - columns: terminal.columns, - rows: terminal.rows, - readInput: terminal.readInput, - readLine: terminal.readLine, - display: text => - Effect.sync(() => { - chunks.push(text) - }), - }) - - const relativeFiles = files.map(file => path.relative(cwd, file)) - - const failed = yield* runChecks(fix, relativeFiles, flags).pipe( - Effect.map(() => false), - Effect.catchTag('CheckFailed', () => Effect.succeed(true)), - Effect.provideService(Terminal.Terminal, capture), - Effect.provideService(UncheckOptions, { ...options, stdio: 'pipe' }), - ) - - const report = stripVTControlCharacters(chunks.join('')) - - yield* Stream.make(report).pipe(Stream.run(stdio.stderr())) - - if (failed) { - const additionalContext = `uncheck found problems in ${relativeFiles.join(', ')}, fix them before moving on:\n\n${report}` - const output = { additionalContext, hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext } } - - yield* Stream.make(`${JSON.stringify(output)}\n`).pipe(Stream.run(stdio.stdout())) - } - }) -} - -function disabled(name: StepName, enabled: Option.Option): StepPlan | undefined { - return Option.isSome(enabled) && !enabled.value - ? { name, status: 'skipped', reason: `disabled with --${name}=false` } - : undefined -} - -function planToolStep( - step: ToolStep, - tool: Option.Option, - enabled: Option.Option, - fix: boolean, - paths: ReadonlyArray, -): StepPlan { - const off = disabled(step.name, enabled) - - if (off !== undefined) { - return off - } - - if (Option.isNone(tool)) { - return { name: step.name, status: Option.isSome(enabled) ? 'failed' : 'skipped', reason: 'not installed' } - } - - return { - name: step.name, - status: 'run', - commands: [ - { tool: tool.value, args: [...step.args(fix), ...(paths.length > 0 ? [UNMATCHED_PATHS_OK] : []), ...paths] }, - ], - } -} - -function planTscStep( - typescript: Option.Option, - tsconfigs: ReadonlyArray, - enabled: Option.Option, - paths: ReadonlyArray, - cwd: string, -): Effect.Effect { - return Effect.gen(function* () { - const off = disabled('tsc', enabled) - - if (off !== undefined) { - return off - } - - const path = yield* Path.Path - const nothingToCheck = (reason: string): StepPlan => ({ - name: 'tsc', - status: Option.isSome(enabled) ? 'failed' : 'skipped', - reason, - }) - - if (tsconfigs.length === 0) { - return nothingToCheck('no tsconfig.json found') - } - - const selected = yield* selectTsconfigs(tsconfigs, paths, cwd) - - if (selected.length === 0) { - return nothingToCheck('no tsconfig.json covers the given paths') - } - - if (Option.isNone(typescript)) { - return { - name: 'tsc', - status: 'failed', - reason: `found ${selected.length} tsconfig.json but typescript is not installed`, - } - } - - const tool = typescript.value - const relative = (configPath: string) => path.relative(cwd, configPath) || '.' - - const projects = yield* loadTsProjects(selected) - - return yield* planTypecheck(selected, projects).pipe( - Effect.map((plan): StepPlan => { - const commands: ToolCommand[] = [] - - if (plan.build.length > 0) { - commands.push({ tool, args: ['-b', ...plan.build.map(relative)] }) - } - - for (const configPath of plan.check) { - commands.push({ tool, args: ['-p', relative(configPath)] }) - } - - return { name: 'tsc', status: 'run', commands } - }), - Effect.catchTag('CircularProjectReferences', error => - Effect.succeed({ - name: 'tsc', - status: 'failed', - reason: `circular project references between ${error.projects.map(relative).join(', ')}`, - }), - ), - ) - }) -} - -function runStep( - ui: Ui, - options: UncheckOptions, - plan: StepPlan, -): Effect.Effect< - StepOutcome, - PlatformError.PlatformError, - ChildProcessSpawner.ChildProcessSpawner | Terminal.Terminal -> { - return Effect.gen(function* () { - if (plan.status !== 'run') { - yield* ui.line( - plan.status === 'skipped' - ? `${ui.dim('ā—‹')} ${ui.bold(plan.name)} ${ui.dim(`skipped, ${plan.reason}`)}` - : `${ui.red('✘')} ${ui.bold(plan.name)} ${ui.red(plan.reason)}`, - ) - return plan - } - - const [duration, exitCodes] = yield* Effect.timed( - Effect.forEach(plan.commands, ({ tool, args }) => - ui - .line(`${ui.dim('ā–¶')} ${ui.bold(tool.name)} ${ui.dim(args.join(' '))}`.trimEnd()) - .pipe(Effect.flatMap(() => execTool(tool, args, options))), - ), - ) - - const failed = exitCodes.some(exitCode => exitCode !== 0) - const elapsed = ui.dim(formatDuration(Duration.toMillis(duration))) - - yield* ui.line( - failed - ? `${ui.red('✘')} ${ui.bold(plan.name)} ${ui.red('failed')} ${elapsed}` - : `${ui.green('āœ”')} ${ui.bold(plan.name)} ${ui.green('passed')} ${elapsed}`, - ) - - return { name: plan.name, status: failed ? 'failed' : 'passed' } satisfies StepOutcome - }) -} - -function formatDuration(ms: number): string { - return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s` -} diff --git a/packages/uncheck/src/exec.ts b/packages/uncheck/src/exec.ts index abcb0ae..badd54d 100644 --- a/packages/uncheck/src/exec.ts +++ b/packages/uncheck/src/exec.ts @@ -1,45 +1,36 @@ import type { PlatformError } from 'effect' -import type { UncheckOptions } from './options' -import type { Tool } from './tools' +import type { StepCommand } from './step' import process from 'node:process' -import { Effect, Stream, Terminal } from 'effect' +import { Console, Effect, Stream } from 'effect' import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' +import { colors } from './ui' /** - * Runs `tool` with `args` and resolves with its exit code. - * A non-zero exit code is a normal result, not a failure. + * Runs a tool command in `cwd` and resolves with its exit code; a non-zero exit code is a normal + * result, not a failure. Output is piped and re-emitted line by line through `Console`, so it stays + * in order with everything else uncheck prints and can be captured in hook mode. */ -export function execTool( - tool: Tool, - args: ReadonlyArray, - options: UncheckOptions, -): Effect.Effect { - const inherit = options.stdio === 'inherit' - - const command = ChildProcess.make(process.execPath, [tool.bin, ...args], { - cwd: options.cwd, - stdin: 'ignore', - stdout: inherit ? 'inherit' : 'pipe', - stderr: inherit ? 'inherit' : 'pipe', - }) - - return Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - if (inherit) { - return yield* spawner.exitCode(command) - } - - const terminal = yield* Terminal.Terminal - - return yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* spawner.spawn(command) - - yield* Stream.runForEach(Stream.decodeText(handle.all), chunk => terminal.display(chunk)) - - return yield* handle.exitCode - }), - ) - }) +export function execCommand( + { bin, args, files = [] }: StepCommand, + cwd: string, +): Effect.Effect { + return Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const handle = yield* spawner.spawn( + ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], { + cwd, + stdin: 'ignore', + // A piped tool cannot see the terminal, so tell it when colors are wanted. + env: colors ? { FORCE_COLOR: '1' } : {}, + extendEnv: true, + }), + ) + + yield* Stream.runForEach(Stream.splitLines(Stream.decodeText(handle.all)), text => Console.log(text)) + + return yield* handle.exitCode + }), + ) } diff --git a/packages/uncheck/src/files.test.ts b/packages/uncheck/src/files.test.ts new file mode 100644 index 0000000..aeece18 --- /dev/null +++ b/packages/uncheck/src/files.test.ts @@ -0,0 +1,65 @@ +import { execFileSync } from 'node:child_process' +import { NodeServices } from '@effect/platform-node' +import { Effect } from 'effect' +import { fixture } from './__tests__/fixture' +import { listProjectFiles, resolvePaths } from './files' + +const project = { + '.gitignore': 'node_modules\ndist\n', + 'src/a.ts': '', + 'src/b.ts': '', + 'src/sub/c.ts': '', + 'docs/readme.md': '', + 'dist/out.js': '', +} + +function resolve(dir: string, paths: string[]) { + return Effect.runPromise( + Effect.provide( + Effect.flatMap(Effect.cached(listProjectFiles(dir)), projectFiles => resolvePaths(paths, dir, projectFiles)), + NodeServices.layer, + ), + ) +} + +describe('resolvePaths', () => { + it('turns files, directories, globs and negations into one file list', async () => { + const dir = fixture(project, []) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + + expect(await resolve(dir, ['src/a.ts'])).toEqual({ files: ['src/a.ts'], unmatched: [] }) + expect(await resolve(dir, ['src'])).toEqual({ files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], unmatched: [] }) + expect(await resolve(dir, ['./src/', 'src/a.ts'])).toEqual({ + files: ['src/a.ts', 'src/b.ts', 'src/sub/c.ts'], + unmatched: [], + }) + expect(await resolve(dir, ['src/**/*.ts', '!src/sub'])).toEqual({ files: ['src/a.ts', 'src/b.ts'], unmatched: [] }) + expect(await resolve(dir, ['**/*.md'])).toEqual({ files: ['docs/readme.md'], unmatched: [] }) + expect(await resolve(dir, ['.'])).toEqual({ + files: ['.gitignore', '.prettierignore', 'docs/readme.md', 'src/a.ts', 'src/b.ts', 'src/sub/c.ts'], + unmatched: [], + }) + }) + + it('reports patterns that match nothing and keeps ignored files out unless named', async () => { + const dir = fixture(project, []) + execFileSync('git', ['init', '--quiet'], { cwd: dir }) + + expect(await resolve(dir, ['nope.ts', 'src/**/*.tsx', 'dist'])).toEqual({ + files: [], + unmatched: ['nope.ts', 'src/**/*.tsx', 'dist'], + }) + expect(await resolve(dir, ['src/a.ts', 'missing/'])).toEqual({ files: ['src/a.ts'], unmatched: ['missing/'] }) + // An explicitly named file counts even when git ignores it. + expect(await resolve(dir, ['dist/out.js'])).toEqual({ files: ['dist/out.js'], unmatched: [] }) + }) + + it('walks the tree outside a git repository', async () => { + const dir = fixture(project, []) + + expect(await resolve(dir, ['src', 'dist/**'])).toEqual({ + files: ['dist/out.js', 'src/a.ts', 'src/b.ts', 'src/sub/c.ts'], + unmatched: [], + }) + }) +}) diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts new file mode 100644 index 0000000..f84dd07 --- /dev/null +++ b/packages/uncheck/src/files.ts @@ -0,0 +1,207 @@ +import type { PlatformError } from 'effect' +import { posix } from 'node:path' +import { Effect, FileSystem, Path, Stream } from 'effect' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' + +export type ProjectFiles = Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> + +/** + * Every file in the project, relative to `cwd` with forward slashes. + * + * Prefers `git ls-files` so ignored files stay out and untracked ones count, exactly like the user + * configured, and falls back to a plain directory walk outside git repositories. + */ +export function listProjectFiles(cwd: string): ProjectFiles { + return git(cwd, ['ls-files', '--cached', '--others', '--exclude-standard', '-z']).pipe( + Effect.map(files => files.filter(file => !file.split('/').includes('node_modules'))), + Effect.catch(() => walk(cwd)), + Effect.map(files => [...files].sort()), + ) +} + +/** + * 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 "the whole project". + */ +export function listChangedFiles( + cwd: string, +): Effect.Effect | undefined, never, ChildProcessSpawner.ChildProcessSpawner> { + return Effect.all([ + git(cwd, ['diff', '--name-only', '--relative', '-z', 'HEAD']), + git(cwd, ['ls-files', '--others', '--exclude-standard', '-z']), + ]).pipe( + Effect.map(([tracked, untracked]) => [...new Set([...tracked, ...untracked])].sort()), + Effect.orElseSucceed(() => undefined), + ) +} + +export interface ResolvedPaths { + /** Matched files, relative to `cwd`, sorted and unique. */ + readonly files: ReadonlyArray + /** Patterns that matched nothing. */ + readonly unmatched: ReadonlyArray +} + +/** + * Turns the given paths into the project files they name, so every tool checks the same files. + * + * A file must exist, a directory expands to the project files below it, a glob is matched against + * the project files, and `!pattern` drops matches. Files deleted from the working tree are dropped. + */ +export function resolvePaths( + patterns: ReadonlyArray, + cwd: string, + projectFiles: ProjectFiles, +): Effect.Effect< + ResolvedPaths, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const relative = (pattern: string) => toPosix(path.relative(cwd, path.resolve(cwd, pattern))) + + const matched = new Set() + const unmatched: string[] = [] + let universe: ReadonlyArray | undefined + + for (const pattern of patterns) { + if (pattern.startsWith('!')) { + continue + } + + const target = relative(pattern) + + if (GLOB_CHARACTERS.test(target)) { + universe ??= yield* projectFiles + const hits = universe.filter(file => posix.matchesGlob(file, target)) + + if (hits.length === 0) { + unmatched.push(pattern) + } + + for (const hit of hits) { + matched.add(hit) + } + + continue + } + + const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe( + Effect.map(info => info.type), + Effect.orElseSucceed(() => undefined), + ) + + if (kind === 'File') { + matched.add(target) + continue + } + + if (kind === 'Directory') { + universe ??= yield* projectFiles + const inside = target === '' ? universe : universe.filter(file => file.startsWith(`${target}/`)) + + if (inside.length > 0) { + for (const file of inside) { + matched.add(file) + } + + continue + } + } + + unmatched.push(pattern) + } + + const excludes = patterns.filter(pattern => pattern.startsWith('!')).map(pattern => relative(pattern.slice(1))) + const excluded = (file: string) => + excludes.some(exclude => file === exclude || file.startsWith(`${exclude}/`) || posix.matchesGlob(file, exclude)) + + const files = yield* Effect.filter( + [...matched].filter(file => !excluded(file)), + file => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)), + { concurrency: 'unbounded' }, + ) + + return { files: files.sort(), unmatched } + }) +} + +/** Characters that make a pattern a glob rather than a plain path. */ +const GLOB_CHARACTERS = /[*?[\]{}()]/ + +/** Runs a git command in `cwd` and returns its NUL-separated output as a list, failing with the exit code. */ +function git( + cwd: string, + args: ReadonlyArray, +): Effect.Effect, PlatformError.PlatformError | number, ChildProcessSpawner.ChildProcessSpawner> { + return Effect.scoped( + Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const handle = yield* spawner.spawn(ChildProcess.make('git', args, { cwd, stdin: 'ignore', stderr: 'ignore' })) + + const stdout = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + const exitCode = yield* handle.exitCode + + if (exitCode !== 0) { + return yield* Effect.fail(exitCode) + } + + return stdout.split('\0').filter(file => file !== '') + }), + ) +} + +/** The folders `tsc` itself never looks into. Hidden entries are skipped as well. */ +const SKIPPED_DIRECTORIES = new Set(['node_modules', 'bower_components', 'jspm_packages']) + +function walk( + cwd: string, +): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.resolve(cwd) + const found: string[] = [] + + const visit = (dir: string): Effect.Effect => + fs + .readDirectory(dir) + .pipe( + Effect.flatMap(names => + Effect.forEach(names, name => visitEntry(dir, name), { concurrency: 16, discard: true }), + ), + ) + + const visitEntry = (dir: string, name: string): Effect.Effect => + Effect.gen(function* () { + if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { + return + } + + const full = path.join(dir, name) + const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) + + if (info?.type === 'Directory') { + yield* visit(full) + } else if (info?.type === 'File') { + found.push(toPosix(path.relative(root, full))) + } + }) + + yield* visit(root) + + return found + }) +} + +function toPosix(target: string): string { + return target.replaceAll('\\', '/') +} diff --git a/packages/uncheck/src/hooks.test.ts b/packages/uncheck/src/hooks.test.ts index 816aa12..740c72c 100644 --- a/packages/uncheck/src/hooks.test.ts +++ b/packages/uncheck/src/hooks.test.ts @@ -1,37 +1,12 @@ -import { extractHookPaths } from './hooks' - -describe('extractHookPaths', () => { - it('finds the edited file in every agent payload shape', () => { - expect( - extractHookPaths({ - hook_event_name: 'PostToolUse', - transcript_path: '/tmp/transcript.jsonl', - tool_name: 'Edit', - tool_input: { file_path: '/repo/src/a.ts', old_string: 'x', new_string: 'y' }, - tool_response: { filePath: '/repo/src/a.ts', success: true }, - }), - ).toEqual(['/repo/src/a.ts']) - - expect(extractHookPaths({ tool_name: 'NotebookEdit', tool_input: { notebook_path: '/repo/nb.ipynb' } })).toEqual([ - '/repo/nb.ipynb', - ]) - - expect( - extractHookPaths({ hook_event_name: 'afterFileEdit', file_path: '/repo/src/b.ts', workspace_roots: ['/repo'] }), - ).toEqual(['/repo/src/b.ts']) - - expect( - extractHookPaths({ agent_action_name: 'post_write_code', tool_info: { file_path: '/repo/src/c.py', edits: [] } }), - ).toEqual(['/repo/src/c.py']) - - expect(extractHookPaths({ toolName: 'edit', toolArgs: { path: 'src/d.ts', command: 'str_replace' } })).toEqual([ - 'src/d.ts', - ]) - }) - - it('returns nothing for payloads without file paths', () => { - expect(extractHookPaths({ tool_name: 'Bash', tool_input: { command: 'ls' } })).toEqual([]) - expect(extractHookPaths(undefined)).toEqual([]) - expect(extractHookPaths('not json')).toEqual([]) +import { stopAgent } from './hooks' + +describe('stopAgent', () => { + it('recognizes each agent family from the fields it documents', () => { + expect(stopAgent({ hook_event_name: 'Stop', session_id: 'x', stop_hook_active: false })).toBe('claude') + expect(stopAgent({ hook_event_name: 'Stop', generation_id: 'x', stop_hook_active: true })).toBe('claude') + expect(stopAgent({ hook_event_name: 'stop', status: 'completed', loop_count: 0 })).toBe('cursor') + expect(stopAgent({ sessionId: 'x', stopReason: 'end_turn', stop_hook_active: false })).toBe('copilot') + expect(stopAgent({ agent_action_name: 'post_cascade_response', tool_info: {} })).toBeUndefined() + expect(stopAgent({})).toBeUndefined() }) }) diff --git a/packages/uncheck/src/hooks.ts b/packages/uncheck/src/hooks.ts index 8e6bb2a..600276c 100644 --- a/packages/uncheck/src/hooks.ts +++ b/packages/uncheck/src/hooks.ts @@ -1,45 +1,41 @@ import type { PlatformError } from 'effect' -import { Effect, FileSystem, Option, Path, Stdio } from 'effect' +import { stripVTControlCharacters } from 'node:util' +import { Console, Data, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from 'effect' import { Argument, CliError, Command, Prompt } from 'effect/unstable/cli' import { parse as parseJsonc } from 'jsonc-parser' +import { fixFlag, runChecks, stepFlags } from './checks' +import { listChangedFiles } from './files' import { UncheckOptions } from './options' -import { makeUi } from './ui' +import { ancestors, readJson } from './resolve' +import { bold, dim, green, line } from './ui' -export const HOOK_AGENTS = ['claude', 'codebuddy', 'cursor', 'windsurf', 'copilot'] as const - -export type HookAgent = (typeof HOOK_AGENTS)[number] - -export interface HookIntegration { - readonly id: HookAgent +interface HookIntegration { + readonly id: string readonly name: string /** Config file the agent reads, relative to the project root. */ readonly path: string - /** Hook config to write, or to merge into the existing file. */ + /** Config for the hook that fires when the agent finishes a turn, written or merged into that file. */ readonly content: (command: string) => Record } function claudeStyle(command: string): Record { - return { - hooks: { - PostToolUse: [{ matcher: 'Edit|Write|MultiEdit|NotebookEdit', hooks: [{ type: 'command', command }] }], - }, - } + return { hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } } } -export const HOOK_INTEGRATIONS: ReadonlyArray = [ +const HOOK_INTEGRATIONS = [ { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', content: claudeStyle }, { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', content: claudeStyle }, { id: 'cursor', name: 'Cursor', path: '.cursor/hooks.json', - content: command => ({ version: 1, hooks: { afterFileEdit: [{ command }] } }), + content: command => ({ version: 1, hooks: { stop: [{ command }] } }), }, { id: 'windsurf', name: 'Windsurf', path: '.windsurf/hooks.json', - content: command => ({ hooks: { post_write_code: [{ command, show_output: true }] } }), + content: command => ({ hooks: { post_cascade_response: [{ command, show_output: true }] } }), }, { id: 'copilot', @@ -47,13 +43,17 @@ export const HOOK_INTEGRATIONS: ReadonlyArray = [ path: '.github/hooks/uncheck.json', content: command => ({ version: 1, - hooks: { postToolUse: [{ type: 'command', bash: command, powershell: command }] }, + hooks: { agentStop: [{ type: 'command', bash: command, powershell: command }] }, }), }, -] +] as const satisfies ReadonlyArray + +type HookAgent = (typeof HOOK_INTEGRATIONS)[number]['id'] -export const hooksCommand = Command.make( - 'hooks', +const HOOK_AGENTS = HOOK_INTEGRATIONS.map(integration => integration.id) + +const installCommand = Command.make( + 'install', { agents: Argument.Literals('agents', HOOK_AGENTS).pipe( Argument.variadic(), @@ -62,118 +62,201 @@ export const hooksCommand = Command.make( }, ({ agents }) => Effect.gen(function* () { - const options = yield* UncheckOptions - const ui = yield* makeUi - const selected = agents.length > 0 ? agents : yield* promptAgents - const command = yield* hookCommand(options.cwd) + const { cwd } = yield* UncheckOptions + const selected: ReadonlyArray = agents.length > 0 ? agents : yield* promptAgents + const command = yield* hookCommand(cwd) for (const integration of HOOK_INTEGRATIONS) { if (!selected.includes(integration.id)) { continue } - const result = yield* installHook(integration, command, options.cwd) + const result = yield* installHook(integration, command, cwd) - yield* ui.line(`${ui.green('āœ”')} ${ui.bold(integration.name)} ${ui.dim(`${integration.path} ${result}`)}`) + yield* line(`${green('āœ”')} ${bold(integration.name)} ${dim(`${integration.path} ${result}`)}`) } - yield* ui.line('') - yield* ui.line(`${ui.dim('The hooks run')} ${ui.bold(command)} ${ui.dim('after every file the agent edits.')}`) + yield* line('') + yield* line(`${dim('The hook runs')} ${bold(command)} ${dim('whenever the agent finishes a turn.')}`) }), +).pipe(Command.withDescription('Write the agent hook configs that run `uncheck hooks run` after every agent turn')) + +/** Raised when the agent must keep working; the entry point turns it into exit code 2. */ +export class StopBlocked extends Data.TaggedError('StopBlocked')<{}> {} + +const runCommand = Command.make('run', { fix: fixFlag, ...stepFlags }, ({ fix, ...flags }) => + Effect.gen(function* () { + const options = yield* UncheckOptions + const stdio = yield* Stdio.Stdio + const path = yield* Path.Path + + if (yield* stdio.stdinIsTerminal) { + const userMessage = '`uncheck hooks run` expects the agent hook payload as JSON on stdin' + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + const payload = yield* Stream.mkString(Stream.decodeText(stdio.stdin)).pipe( + Effect.flatMap(text => Effect.try(() => JSON.parse(text) as unknown)), + Effect.map(value => (Predicate.isObject(value) ? value : {})), + Effect.orElseSucceed((): Record => ({})), + ) + + const cwd = path.resolve(options.cwd) + const changed = yield* listChangedFiles(cwd) + + if (changed?.length === 0) { + return + } + + const lines: string[] = [] + + const capture: Console.Console = Object.assign(Object.create(globalThis.console), { + log: (...parts: ReadonlyArray) => { + lines.push(parts.join(' ')) + }, + }) + + const failed = yield* runChecks(changed ?? [], { fix, flags, allowUnmatched: true }).pipe( + Effect.map(() => false), + Effect.catchTag('CheckFailed', () => Effect.succeed(true)), + Effect.provideService(Console.Console, capture), + ) + + const report = stripVTControlCharacters(lines.join('\n')) + + yield* Console.error(report) + + if (!failed) { + return + } + + const reason = `uncheck found problems, fix them before finishing:\n\n${report}` + + yield* stopFeedback(payload, reason) + }), ).pipe( Command.withDescription( - 'Set up agent hooks that run `uncheck --fix` on every file an AI agent edits and hand remaining problems back to it', + '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', ), ) +export const hooksCommand = Command.make('hooks').pipe( + Command.withDescription('Agent hooks: `install` writes the configs, `run` is what they execute'), + Command.withSubcommands([installCommand, runCommand]), +) + +/** + * Sends the agent back to work in the way its family understands, at most once per turn. + * + * Claude Code and CodeBuddy block on exit code 2 with stderr as the message and mark the payload + * with `stop_hook_active` once they are already continuing. Cursor continues on a `followup_message` + * and counts its follow-ups in `loop_count`. Copilot continues on `decision: "block"` and also sets + * `stop_hook_active`. Anything else, such as Windsurf, only gets the report. + */ +export function stopFeedback(payload: Record, reason: string) { + const agent = stopAgent(payload) + const alreadyContinued = + payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) + + if (agent === undefined || alreadyContinued) { + return Effect.void + } + + switch (agent) { + case 'claude': + return Effect.fail(new StopBlocked()) + case 'cursor': + return Console.log(JSON.stringify({ followup_message: reason })) + case 'copilot': + return Console.log(JSON.stringify({ decision: 'block', reason })) + } +} + +/** Which family of stop payload this is, judged by the fields each agent documents. */ +export function stopAgent(payload: Record): 'claude' | 'cursor' | 'copilot' | undefined { + if (payload.hook_event_name === 'Stop') { + return 'claude' + } + + if (payload.hook_event_name === 'stop') { + return 'cursor' + } + + if (typeof payload.stopReason === 'string') { + return 'copilot' + } + + return undefined +} + const promptAgents = Effect.gen(function* () { const stdio = yield* Stdio.Stdio if (!(yield* stdio.stdinIsTerminal)) { - const userMessage = `Pass the agents to configure, for example: uncheck hooks ${HOOK_AGENTS.join(' ')}` + const userMessage = `Pass the agents to configure, for example: uncheck hooks install ${HOOK_AGENTS.join(' ')}` return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) } return yield* Prompt.run( Prompt.MultiSelect({ - message: 'Which agents should run uncheck after editing files?', + message: 'Which agents should run uncheck when they finish a turn?', choices: HOOK_INTEGRATIONS.map(integration => ({ title: integration.name, value: integration.id })), min: 1, }), ) }) -const PACKAGE_MANAGER_EXEC: Record = { +const EXEC_BY_PACKAGE_MANAGER: Readonly> = { pnpm: 'pnpm exec', yarn: 'yarn', bun: 'bunx', npm: 'npx', } -const LOCKFILES: ReadonlyArray = [ - ['pnpm-lock.yaml', 'pnpm'], +const EXEC_BY_LOCKFILE: ReadonlyArray = [ + ['pnpm-lock.yaml', 'pnpm exec'], ['yarn.lock', 'yarn'], - ['bun.lock', 'bun'], - ['bun.lockb', 'bun'], - ['package-lock.json', 'npm'], + ['bun.lock', 'bunx'], + ['bun.lockb', 'bunx'], + ['package-lock.json', 'npx'], ] /** The hook command, invoking the project's own `uncheck` through the package manager in use. */ -export function hookCommand(cwd: string): Effect.Effect { - return Effect.gen(function* () { - const exec = yield* detectPackageManager(cwd) - - return `${PACKAGE_MANAGER_EXEC[exec] ?? PACKAGE_MANAGER_EXEC.npm} uncheck --fix --hook` - }) -} - -function detectPackageManager(cwd: string): Effect.Effect { +function hookCommand(cwd: string): Effect.Effect { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path - let dir = path.resolve(cwd) + for (const dir of ancestors(path, cwd)) { + const manifest = yield* readJson<{ packageManager?: string }>(path.join(dir, 'package.json')) + const declared = EXEC_BY_PACKAGE_MANAGER[manifest?.packageManager?.split('@')[0] ?? ''] - while (true) { - const manifest = yield* fs.readFileString(path.join(dir, 'package.json')).pipe( - Effect.map(text => JSON.parse(text) as { packageManager?: string }), - Effect.orElseSucceed(() => undefined), - ) - - const declared = manifest?.packageManager?.split('@')[0] - - if (declared !== undefined && declared in PACKAGE_MANAGER_EXEC) { - return declared - } - - for (const [file, packageManager] of LOCKFILES) { - if (yield* fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false))) { - return packageManager - } + if (declared !== undefined) { + return `${declared} uncheck hooks run --fix` } - const parent = path.dirname(dir) + const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) => + fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), + ) - if (parent === dir) { - return 'npm' + if (Option.isSome(lockfile)) { + return `${lockfile.value[1]} uncheck hooks run --fix` } - - dir = parent } + + return 'npx uncheck hooks run --fix' }) } -export type HookInstallResult = 'created' | 'updated' | 'unchanged' - /** * Writes the agent's hook config, merging into an existing file so other hooks are kept. * A file that already mentions `uncheck` is left alone, which makes reruns safe. */ -export function installHook( +function installHook( integration: HookIntegration, command: string, cwd: string, -): Effect.Effect { +): Effect.Effect<'created' | 'updated' | 'unchanged', PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem const path = yield* Path.Path @@ -193,7 +276,7 @@ export function installHook( } const current = parseJsonc(existing.value, undefined, { allowTrailingComma: true }) as unknown - const merged = mergeJson(isRecord(current) ? current : {}, addition) + const merged = mergeJson(Predicate.isObject(current) ? current : {}, addition) yield* fs.writeFileString(file, `${JSON.stringify(merged, null, 2)}\n`) @@ -207,7 +290,7 @@ function mergeJson(base: unknown, addition: unknown): unknown { return [...base, ...addition] } - if (isRecord(base) && isRecord(addition)) { + if (Predicate.isObject(base) && Predicate.isObject(addition)) { const merged: Record = { ...base } for (const [key, value] of Object.entries(addition)) { @@ -219,41 +302,3 @@ function mergeJson(base: unknown, addition: unknown): unknown { return addition } - -const PATH_KEYS = new Set(['file_path', 'filePath', 'notebook_path', 'notebookPath', 'path']) - -/** - * Every file path mentioned in an agent hook payload. The agents disagree on the envelope - * (`tool_input`, `tool_info`, `toolArgs`, or the top level) but all name the edited file with - * one of a few keys, so the payload is searched recursively instead of per agent. - */ -export function extractHookPaths(payload: unknown): string[] { - const found = new Set() - - const visit = (value: unknown): void => { - if (Array.isArray(value)) { - value.forEach(visit) - return - } - - if (!isRecord(value)) { - return - } - - for (const [key, child] of Object.entries(value)) { - if (PATH_KEYS.has(key) && typeof child === 'string') { - found.add(child) - } else { - visit(child) - } - } - } - - visit(payload) - - return [...found] -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} diff --git a/packages/uncheck/src/index.ts b/packages/uncheck/src/index.ts index d0e9ac1..8b17f70 100644 --- a/packages/uncheck/src/index.ts +++ b/packages/uncheck/src/index.ts @@ -1,2 +1,5 @@ +export { CheckFailed } from './checks' export * from './command' +export { StopBlocked } from './hooks' export * from './options' +export type { StepName, StepOutcome } from './step' diff --git a/packages/uncheck/src/options.ts b/packages/uncheck/src/options.ts index 2526d35..cbb532e 100644 --- a/packages/uncheck/src/options.ts +++ b/packages/uncheck/src/options.ts @@ -1,8 +1,6 @@ import process from 'node:process' import { Context } from 'effect' -export type StdioMode = 'inherit' | 'pipe' - export interface UncheckOptions { /** * Directory the checks run in. Tools and `tsconfig.json` files are discovered from here. @@ -10,16 +8,8 @@ export interface UncheckOptions { * @default process.cwd() */ readonly cwd: string - /** - * How tool output is handled: - * - `inherit` streams it straight to the terminal (keeps colors and live progress). - * - `pipe` captures it and forwards it through the `Terminal` service (useful for tests and embedding). - * - * @default 'inherit' - */ - readonly stdio: StdioMode } export const UncheckOptions = Context.Reference('uncheck/UncheckOptions', { - defaultValue: () => ({ cwd: process.cwd(), stdio: 'inherit' }), + defaultValue: () => ({ cwd: process.cwd() }), }) diff --git a/packages/uncheck/src/resolve.ts b/packages/uncheck/src/resolve.ts new file mode 100644 index 0000000..10ec3a3 --- /dev/null +++ b/packages/uncheck/src/resolve.ts @@ -0,0 +1,64 @@ +import { Effect, FileSystem, Option, Path } from 'effect' + +/** An executable JS entry of an installed package, run with `process.execPath`. */ +export interface Bin { + readonly name: string + readonly entry: string +} + +interface PackageJson { + readonly bin?: string | Readonly> +} + +/** `from` and every directory above it, nearest first. */ +export function ancestors(path: Path.Path, from: string): string[] { + const dirs = [path.resolve(from)] + + for (let parent = path.dirname(dirs[0]!); parent !== dirs[dirs.length - 1]; parent = path.dirname(parent)) { + dirs.push(parent) + } + + return dirs +} + +/** Reads and parses a JSON file, `undefined` when it is missing or malformed. */ +export function readJson(file: string): Effect.Effect { + return Effect.flatMap(FileSystem.FileSystem, fs => + fs.readFileString(file).pipe( + Effect.map(text => JSON.parse(text) as A), + Effect.orElseSucceed(() => undefined), + ), + ) +} + +/** + * Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest + * `node_modules/`. Reading its manifest directly (instead of `require.resolve`) keeps this + * independent from the package's `exports` map. + */ +export function resolveBin( + pkg: string, + cwd: string, + binName: string = pkg, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const path = yield* Path.Path + + for (const dir of ancestors(path, cwd)) { + const pkgDir = path.join(dir, 'node_modules', pkg) + const manifest = yield* readJson(path.join(pkgDir, 'package.json')) + + if (manifest === undefined) { + continue + } + + const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName] + + return Option.fromNullishOr(bin).pipe( + Option.map(entry => ({ name: binName, entry: path.resolve(pkgDir, entry) })), + ) + } + + return Option.none() + }) +} diff --git a/packages/uncheck/src/step.ts b/packages/uncheck/src/step.ts new file mode 100644 index 0000000..0f491b5 --- /dev/null +++ b/packages/uncheck/src/step.ts @@ -0,0 +1,132 @@ +import type { PlatformError } from 'effect' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import type { ProjectFiles } from './files' +import type { Bin } from './resolve' +import { Effect, FileSystem, Option, Path } from 'effect' +import { resolveBin } from './resolve' + +export type StepName = 'oxlint' | 'oxfmt' | 'tsc' + +export interface StepOutcome { + readonly name: StepName + readonly status: 'passed' | 'failed' | 'skipped' + /** Why the step was skipped, or why it failed before any tool ran. */ + readonly reason?: string +} + +export interface StepCommand { + readonly bin: Bin + readonly args: ReadonlyArray + /** Files appended after `args`, kept apart so the run log can summarize them. */ + readonly files?: ReadonlyArray +} + +/** The arguments as a short description for the run log: user-facing flags, then the files or their count. */ +export function describeArgs({ args, files }: StepCommand): string { + const shown = args.filter(arg => arg !== UNMATCHED_PATHS_OK) + const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`] + + return [...shown, ...tail].join(' ') +} + +export interface RunPlan { + readonly name: StepName + readonly status: 'run' + readonly commands: ReadonlyArray +} + +/** A step that is either ready to run, or already settled without running anything. */ +export type StepPlan = RunPlan | (StepOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) + +export interface StepInput { + readonly cwd: string + readonly fix: boolean + /** Files to check, relative to `cwd`, or `undefined` for the whole project. */ + readonly files: ReadonlyArray | undefined + /** Whether the step was required with its flag, so it fails instead of skipping when it cannot run. */ + readonly required: boolean + /** Every file in the project, listed once and shared between steps. */ + readonly projectFiles: ProjectFiles +} + +/** One check uncheck can run. Steps live in `steps/` and are registered in `command.ts`. */ +export interface Step { + readonly name: StepName + /** What the step does, for its flag's help text. */ + readonly does: string + /** When the step runs by default, for its flag's help text. */ + readonly when: string + /** Whether `--fix` applies to this step. */ + readonly fixes: boolean + readonly plan: ( + input: StepInput, + ) => Effect.Effect< + StepPlan, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > +} + +/** A step that cannot run: a failure when it was required, a skip otherwise. */ +export function settled(name: StepName, required: boolean, reason: string): StepPlan { + return { name, status: required ? 'failed' : 'skipped', reason } +} + +/** Explicit files may include ones a tool does not handle (a Markdown file for oxlint), which is not a failure. */ +const UNMATCHED_PATHS_OK = '--no-error-on-unmatched-pattern' + +/** Command lines stay well below every platform's argument limit. */ +const MAX_ARGS_LENGTH = 65_536 + +/** Splits a file list into command-line sized batches. */ +export function batches(files: ReadonlyArray): string[][] { + const result: string[][] = [] + let current: string[] = [] + let length = 0 + + for (const file of files) { + if (current.length > 0 && length + file.length + 1 > MAX_ARGS_LENGTH) { + result.push(current) + current = [] + length = 0 + } + + current.push(file) + length += file.length + 1 + } + + if (current.length > 0) { + result.push(current) + } + + return result +} + +interface BinStepOptions { + readonly name: 'oxlint' | 'oxfmt' + readonly does: string + readonly args: (fix: boolean) => ReadonlyArray +} + +/** A step that runs one installed package's bin, over the given files or the whole project. */ +export function binStep({ name, does, args }: BinStepOptions): Step { + return { + name, + does, + when: `${name} is installed`, + fixes: true, + plan: ({ cwd, fix, files, required }) => + Effect.map(resolveBin(name, cwd), bin => { + if (Option.isNone(bin)) { + return settled(name, required, 'not installed') + } + + const commands = + files === undefined + ? [{ bin: bin.value, args: args(fix) }] + : batches(files).map(batch => ({ bin: bin.value, args: [...args(fix), UNMATCHED_PATHS_OK], files: batch })) + + return { name, status: 'run', commands } + }), + } +} diff --git a/packages/uncheck/src/steps/oxfmt.ts b/packages/uncheck/src/steps/oxfmt.ts new file mode 100644 index 0000000..64bec98 --- /dev/null +++ b/packages/uncheck/src/steps/oxfmt.ts @@ -0,0 +1,7 @@ +import { binStep } from '../step' + +export const oxfmt = binStep({ + name: 'oxfmt', + does: 'Check formatting with oxfmt', + args: fix => (fix ? [] : ['--check']), +}) diff --git a/packages/uncheck/src/steps/oxlint.ts b/packages/uncheck/src/steps/oxlint.ts new file mode 100644 index 0000000..b374afc --- /dev/null +++ b/packages/uncheck/src/steps/oxlint.ts @@ -0,0 +1,7 @@ +import { binStep } from '../step' + +export const oxlint = binStep({ + name: 'oxlint', + does: 'Lint with oxlint', + args: fix => (fix ? ['--fix'] : []), +}) diff --git a/packages/uncheck/src/steps/tsc/index.ts b/packages/uncheck/src/steps/tsc/index.ts new file mode 100644 index 0000000..bc459d4 --- /dev/null +++ b/packages/uncheck/src/steps/tsc/index.ts @@ -0,0 +1,221 @@ +import type { PlatformError } from 'effect' +import type { ProjectFiles } from '../../files' +import type { Step, StepCommand, StepPlan } from '../../step' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import { Data, Effect, FileSystem, Graph, Option, Path } from 'effect' +import { resolveBin } from '../../resolve' +import { settled } from '../../step' +import { extensionOf, includesFile, loadTsconfigInputs, readReferences } from './tsconfig' + +export const tsc: Step = { + name: 'tsc', + does: 'Typecheck with tsc', + when: 'a tsconfig.json is found', + fixes: false, + plan: ({ cwd, files, required, projectFiles }) => + Effect.gen(function* () { + const path = yield* Path.Path + const nothingToCheck = (reason: string) => settled('tsc', required, reason) + + const targets = files + ?.map(file => path.resolve(cwd, file)) + .filter(file => CHECKABLE_EXTENSIONS.has(extensionOf(file))) + + if (targets !== undefined && targets.length === 0) { + return nothingToCheck('no tsconfig.json covers the given files') + } + + const [typescript, tsconfigs] = yield* Effect.all( + [resolveBin('typescript', cwd, 'tsc'), discoverTsconfigs(projectFiles, cwd)], + { concurrency: 'unbounded' }, + ) + + if (tsconfigs.length === 0) { + return nothingToCheck('no tsconfig.json found') + } + + const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(tsconfigs, targets) + + if (selected.length === 0) { + return nothingToCheck('no tsconfig.json covers the given files') + } + + if (Option.isNone(typescript)) { + return { + name: 'tsc', + status: 'failed', + reason: `found ${selected.length} tsconfig.json but typescript is not installed`, + } + } + + const bin = typescript.value + const relative = (configPath: string) => path.relative(cwd, configPath) || '.' + const projects = yield* loadTsProjects(selected) + + return yield* planTypecheck(selected, projects).pipe( + Effect.map((plan): StepPlan => { + const commands: StepCommand[] = [] + + if (plan.build.length > 0) { + commands.push({ bin, args: ['-b', ...plan.build.map(relative)] }) + } + + for (const configPath of plan.check) { + commands.push({ bin, args: ['-p', relative(configPath)] }) + } + + return { name: 'tsc', status: 'run', commands } + }), + Effect.catchTag('CircularProjectReferences', error => + Effect.succeed({ + name: 'tsc', + status: 'failed', + reason: `circular project references between ${error.projects.map(relative).join(', ')}`, + }), + ), + ) + }), +} + +export interface TsProject { + /** Absolute path to the config file. */ + readonly path: string + /** Absolute paths of the config files listed in `references`. */ + readonly references: ReadonlyArray +} + +export interface TypecheckPlan { + /** + * Roots of the project-reference graph (projects nobody references). + * `tsc -b` builds every referenced project first, in dependency order. + */ + readonly build: ReadonlyArray + /** Projects outside the reference graph, checked with `tsc -p` after the build step. */ + readonly check: ReadonlyArray +} + +export class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ + readonly projects: ReadonlyArray +}> {} + +/** + * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. + * + * Every project that has references, or is referenced, belongs to the build graph. + * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. + */ +export function planTypecheck( + entries: ReadonlyArray, + projects: ReadonlyMap, +): Effect.Effect { + const members = new Set() + const referenced = new Set() + + for (const project of projects.values()) { + if (project.references.length === 0) { + continue + } + + members.add(project.path) + + for (const reference of project.references) { + members.add(reference) + referenced.add(reference) + } + } + + const cycle = findCycle([...members].sort(), projects) + + if (Option.isSome(cycle)) { + return Effect.fail(new CircularProjectReferences({ projects: cycle.value })) + } + + return Effect.succeed({ + build: [...members].filter(member => !referenced.has(member)).sort(), + check: entries.filter(entry => !members.has(entry)).sort(), + }) +} + +/** The first cycle among `nodes` following `references`, in visiting order. */ +function findCycle(nodes: ReadonlyArray, projects: ReadonlyMap): Option.Option { + const graph = Graph.directed(mutable => { + const indexes = new Map(nodes.map(node => [node, Graph.addNode(mutable, node)] as const)) + + for (const node of nodes) { + for (const reference of projects.get(node)?.references ?? []) { + Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) + } + } + }) + + return Option.map(Graph.findCycle(graph), ({ path }) => + // The path closes on its first node, drop that repetition. + path.slice(0, -1).map(index => Option.getOrThrow(Graph.getNode(graph, index))), + ) +} + +/** Files that can be inputs of some project; anything else never selects one. */ +const CHECKABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']) + +/** + * Narrows `entries` to the projects whose `files`/`include`/`exclude` (with `extends` applied) take + * one of the absolute `files` as input, exactly like `tsc` decides. + */ +export function selectTsconfigs( + entries: ReadonlyArray, + files: ReadonlyArray, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.filter( + entries, + entry => Effect.map(loadTsconfigInputs(entry), inputs => files.some(file => includesFile(inputs, file))), + { concurrency: 'unbounded' }, + ).pipe(Effect.map(selected => [...selected].sort())) +} + +/** Parses `entries` and everything they reference, transitively, into a graph keyed by config path. */ +function loadTsProjects( + entries: ReadonlyArray, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.gen(function* () { + const projects = new Map() + const queue = [...entries] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (projects.has(configPath)) { + continue + } + + const references = yield* readReferences(configPath) + + projects.set(configPath, { path: configPath, references }) + queue.push(...references) + } + + return projects + }) +} + +/** Every `tsconfig.json` in the project, as absolute paths. */ +function discoverTsconfigs( + projectFiles: ProjectFiles, + cwd: string, +): Effect.Effect< + ReadonlyArray, + PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner +> { + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const files = yield* projectFiles + + const candidates = files + .filter(file => path.basename(file) === 'tsconfig.json') + .map(file => path.resolve(cwd, file)) + + // Tracked files can be deleted from the working tree without being staged yet. + return yield* Effect.filter(candidates, candidate => fs.exists(candidate), { concurrency: 'unbounded' }) + }) +} diff --git a/packages/uncheck/src/typecheck.test.ts b/packages/uncheck/src/steps/tsc/tsc.test.ts similarity index 63% rename from packages/uncheck/src/typecheck.test.ts rename to packages/uncheck/src/steps/tsc/tsc.test.ts index 96414a9..b239f00 100644 --- a/packages/uncheck/src/typecheck.test.ts +++ b/packages/uncheck/src/steps/tsc/tsc.test.ts @@ -1,6 +1,9 @@ -import type { TsProject } from './typecheck' -import { Effect } from 'effect' -import { CircularProjectReferences, planTypecheck } from './typecheck' +import type { TsProject } from './index' +import { join } from 'node:path' +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import { Effect, Layer } from 'effect' +import { fixture } from '../../__tests__/fixture' +import { CircularProjectReferences, planTypecheck, selectTsconfigs } from './index' type Graph = Record @@ -111,3 +114,48 @@ describe('planTypecheck', () => { ).toEqual(['/r/b/tsconfig.json', '/r/c/tsconfig.json']) }) }) + +describe('selectTsconfigs', () => { + const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) + const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } + const entries = ['packages/a/tsconfig.json', 'packages/b/tsconfig.json', 'tsconfig.json'] + + function monorepo() { + return fixture( + { + 'tsconfig.base.json': { compilerOptions: { strict: true } }, + 'tsconfig.json': { include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'] }, + 'packages/a/tsconfig.json': lib, + 'packages/b/tsconfig.json': lib, + }, + [], + ) + } + + function select(dir: string, files: string[]) { + return Effect.runPromise( + Effect.provide( + selectTsconfigs( + entries.map(entry => join(dir, entry)), + files.map(file => join(dir, file)), + ), + layer, + ), + ).then(selected => selected.map(entry => entry.slice(dir.length + 1))) + } + + it('selects only the projects whose inputs contain a file', async () => { + const dir = monorepo() + + expect(await select(dir, ['packages/a/src/index.ts'])).toEqual(['packages/a/tsconfig.json']) + // Tests are excluded by the package and picked up by the root instead. + expect(await select(dir, ['packages/a/src/index.test.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, ['packages/a/build.config.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, ['scripts/release.ts'])).toEqual(['tsconfig.json']) + expect(await select(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ + 'packages/a/tsconfig.json', + 'packages/b/tsconfig.json', + ]) + expect(await select(dir, ['README.md', 'packages/a/styles.css'])).toEqual([]) + }) +}) diff --git a/packages/uncheck/src/steps/tsc/tsconfig.test.ts b/packages/uncheck/src/steps/tsc/tsconfig.test.ts new file mode 100644 index 0000000..21db2df --- /dev/null +++ b/packages/uncheck/src/steps/tsc/tsconfig.test.ts @@ -0,0 +1,86 @@ +import { join } from 'node:path' +import { NodeFileSystem, NodePath } from '@effect/platform-node' +import { Effect, Layer } from 'effect' +import { fixture } from '../../__tests__/fixture' +import { includesFile, loadTsconfigInputs } from './tsconfig' + +const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) + +function inputs(configPath: string) { + return Effect.runPromise(Effect.provide(loadTsconfigInputs(configPath), layer)) +} + +describe('loadTsconfigInputs', () => { + it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { + const dir = fixture( + { 'tsconfig.json': { include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], exclude: ['src/legacy'] } }, + [], + ) + + const project = await inputs(join(dir, 'tsconfig.json')) + const has = (file: string) => includesFile(project, join(dir, file)) + + expect(has('src/index.ts')).toBe(true) + expect(has('src/deep/nested/component.tsx')).toBe(true) + expect(has('src/types.d.ts')).toBe(true) + expect(has('src/index.js')).toBe(false) + expect(has('src/README.md')).toBe(false) + expect(has('src/.hidden/index.ts')).toBe(false) + expect(has('src/node_modules/dep/index.ts')).toBe(false) + expect(has('src/legacy/old.ts')).toBe(false) + expect(has('scripts/build.ts')).toBe(true) + expect(has('scripts/nested/deep/build.ts')).toBe(false) + expect(has('config/app.json')).toBe(true) + expect(has('src/data.json')).toBe(false) + expect(has('packages/app/build.config.ts')).toBe(true) + expect(has('packages/app/src/index.ts')).toBe(false) + expect(has('node_modules/dep/index.ts')).toBe(false) + }) + + it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { + const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }, []) + + const project = await inputs(join(dir, 'tsconfig.json')) + const has = (file: string) => includesFile(project, join(dir, file)) + + expect(has('index.ts')).toBe(true) + expect(has('deep/index.ts')).toBe(true) + expect(has('dist/index.d.ts')).toBe(false) + expect(has('node_modules/dep/index.d.ts')).toBe(false) + expect(has('.git/hooks/pre-commit.ts')).toBe(false) + }) + + it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { + const dir = fixture( + { + 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, + 'node_modules/@shared/tsconfig/base.json': { + compilerOptions: { allowJs: true }, + include: ['${configDir}/lib'], + exclude: ['${configDir}/lib/vendor'], + }, + 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, + 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, + 'packages/a/tsconfig.files.json': { files: ['entry.ts'], compilerOptions: { allowJs: false } }, + 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, + }, + [], + ) + + const a = await inputs(join(dir, 'packages/a/tsconfig.json')) + const b = await inputs(join(dir, 'packages/b/tsconfig.json')) + + // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. + expect(includesFile(a, join(dir, 'packages/a/entry.ts'))).toBe(true) + expect(includesFile(a, join(dir, 'src/index.ts'))).toBe(true) + expect(includesFile(a, join(dir, 'packages/a/src/index.ts'))).toBe(false) + expect(includesFile(a, join(dir, 'src/index.js'))).toBe(false) + // `exclude` is still the base's, with `${configDir}` pointing at the leaf. + expect(includesFile(a, join(dir, 'packages/a/lib/vendor/x.ts'))).toBe(false) + + expect(includesFile(b, join(dir, 'packages/b/lib/util.ts'))).toBe(true) + expect(includesFile(b, join(dir, 'packages/b/lib/util.js'))).toBe(true) + expect(includesFile(b, join(dir, 'packages/b/lib/vendor/x.ts'))).toBe(false) + expect(includesFile(b, join(dir, 'packages/b/src/index.ts'))).toBe(false) + }) +}) diff --git a/packages/uncheck/src/tsconfig.ts b/packages/uncheck/src/steps/tsc/tsconfig.ts similarity index 75% rename from packages/uncheck/src/tsconfig.ts rename to packages/uncheck/src/steps/tsc/tsconfig.ts index 3092aaa..f2e9417 100644 --- a/packages/uncheck/src/tsconfig.ts +++ b/packages/uncheck/src/steps/tsc/tsconfig.ts @@ -1,8 +1,10 @@ +import { posix } from 'node:path' import process from 'node:process' -import { Effect, FileSystem, Option, Path } from 'effect' +import { Effect, FileSystem, Option, Path, Predicate } from 'effect' import { parse as parseJsonc } from 'jsonc-parser' +import { ancestors, readJson } from '../../resolve' -export interface RawTsconfig { +interface RawTsconfig { readonly extends?: unknown readonly files?: unknown readonly include?: unknown @@ -12,16 +14,14 @@ export interface RawTsconfig { } /** Parses a config file as JSONC. Unreadable or malformed files read as empty and are left for `tsc` to report. */ -export function readTsconfig(configPath: string): Effect.Effect { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - - return yield* fs.readFileString(configPath).pipe( +function readTsconfig(configPath: string): Effect.Effect { + return Effect.flatMap(FileSystem.FileSystem, fs => + fs.readFileString(configPath).pipe( Effect.map(text => parseJsonc(text, undefined, { allowTrailingComma: true }) as unknown), - Effect.map((value): RawTsconfig => (isRecord(value) ? value : {})), + Effect.map((value): RawTsconfig => (Predicate.isObject(value) ? value : {})), Effect.orElseSucceed((): RawTsconfig => ({})), - ) - }) + ), + ) } /** @@ -38,7 +38,7 @@ export function readReferences( const { references } = yield* readTsconfig(configPath) return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { - if (!isRecord(reference) || typeof reference.path !== 'string') { + if (!Predicate.isObject(reference) || typeof reference.path !== 'string') { return [] } @@ -53,8 +53,6 @@ export interface InputPattern { /** Absolute pattern with forward slashes. */ readonly spec: string readonly regex: RegExp - /** Absolute directory before the first wildcard. */ - readonly prefix: string } /** What `tsc` would take as input files for one project, with `extends` applied. */ @@ -110,7 +108,7 @@ export function loadTsconfigInputs( exclude = { dir, specs: raw.exclude } } - if (isRecord(raw.compilerOptions)) { + if (Predicate.isObject(raw.compilerOptions)) { const { compilerOptions } = raw if (typeof compilerOptions.allowJs === 'boolean') { @@ -142,13 +140,9 @@ export function loadTsconfigInputs( files: resolve(files), include: includeSpecs.flatMap(spec => { const regex = compileGlob(spec, 'files') - return regex === undefined ? [] : [{ spec, regex, prefix: staticPrefixDir(spec) }] + return regex === undefined ? [] : [{ spec, regex }] }), - exclude: excludeSpecs.map(spec => ({ - spec, - regex: compileGlob(spec, 'exclude')!, - prefix: staticPrefixDir(spec), - })), + exclude: excludeSpecs.map(spec => ({ spec, regex: compileGlob(spec, 'exclude')! })), extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), } }) @@ -177,25 +171,6 @@ export function includesFile(inputs: TsconfigInputs, file: string): boolean { return inputs.include.some(pattern => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json'))) } -/** Whether some input of this config could live under `dir`, judged by where its patterns start. */ -export function coversDirectory(inputs: TsconfigInputs, dir: string): boolean { - const target = toPosix(dir) - - // Exclude patterns match the files below a folder, so probe the folder as a prefix. - if (inputs.exclude.some(pattern => pattern.regex.test(`${target}/`))) { - return false - } - - return ( - inputs.files.some(file => file.startsWith(`${target}/`)) || - inputs.include.some(pattern => overlaps(pattern.prefix, target)) - ) -} - -function overlaps(a: string, b: string): boolean { - return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`) -} - interface ChainEntry { readonly dir: string readonly raw: RawTsconfig @@ -249,53 +224,38 @@ function resolveExtends( ) const firstFile = (candidates: ReadonlyArray) => - Effect.gen(function* () { - for (const candidate of candidates) { - if ((yield* kind(candidate)) === 'File') { - return Option.some(candidate) - } - } - - return Option.none() - }) + 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`]) } - let current = dir - - while (true) { + for (const current of ancestors(path, dir)) { const base = path.join(current, 'node_modules', spec) - const asFile = yield* firstFile([base, `${base}.json`]) + const baseKind = yield* kind(base) - if (Option.isSome(asFile)) { - return asFile + if (baseKind === 'File') { + return Option.some(base) } - if ((yield* kind(base)) === 'Directory') { - const manifest = yield* fs.readFileString(path.join(base, 'package.json')).pipe( - Effect.map(text => JSON.parse(text) as { tsconfig?: unknown }), - Effect.orElseSucceed(() => ({}) as { tsconfig?: unknown }), - ) - - const entry = typeof manifest.tsconfig === 'string' ? manifest.tsconfig : 'tsconfig.json' - const found = yield* firstFile([path.resolve(base, entry)]) + const candidates = [`${base}.json`] - if (Option.isSome(found)) { - return found - } + if (baseKind === 'Directory') { + const manifest = yield* readJson<{ tsconfig?: unknown }>(path.join(base, 'package.json')) + candidates.push( + path.resolve(base, typeof manifest?.tsconfig === 'string' ? manifest.tsconfig : 'tsconfig.json'), + ) } - const parent = path.dirname(current) + const found = yield* firstFile(candidates) - if (parent === current) { - return Option.none() + if (Option.isSome(found)) { + return found } - - current = parent } + + return Option.none() }) } @@ -366,28 +326,15 @@ function wildcards(component: string): string { ) } -function staticPrefixDir(spec: string): string { - const components = spec.split('/') - const firstWildcard = components.findIndex(component => /[*?]/.test(component)) - - return (firstWildcard === -1 ? components : components.slice(0, firstWildcard)).join('/') -} - -function extensionOf(file: string): string { - const name = file.slice(file.lastIndexOf('/') + 1) - const dot = name.lastIndexOf('.') - - return dot <= 0 ? '' : name.slice(dot).toLowerCase() +/** The lower-cased extension of a file, `''` when there is none. */ +export function extensionOf(file: string): string { + return posix.extname(file).toLowerCase() } function toPosix(target: string): string { return target.replaceAll('\\', '/') } -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function isStringArray(value: unknown): value is ReadonlyArray { return Array.isArray(value) && value.every(item => typeof item === 'string') } diff --git a/packages/uncheck/src/tools.ts b/packages/uncheck/src/tools.ts deleted file mode 100644 index eb618b0..0000000 --- a/packages/uncheck/src/tools.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { Effect, FileSystem, Option, Path } from 'effect' - -export interface Tool { - readonly name: string - /** Absolute path to the JS entry to execute with `process.execPath`. */ - readonly bin: string -} - -interface PackageJson { - readonly bin?: string | Readonly> -} - -/** - * Locates `pkg` the way Node resolves packages from `cwd`: walk up looking for - * `node_modules//package.json`. Reading the manifest directly (instead of - * `require.resolve`) keeps this independent from the package's `exports` map. - */ -export function resolveTool( - pkg: string, - cwd: string, - binName: string = pkg, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - let dir = path.resolve(cwd) - - while (true) { - const pkgDir = path.join(dir, 'node_modules', pkg) - - const manifest = yield* fs.readFileString(path.join(pkgDir, 'package.json')).pipe( - Effect.map(text => JSON.parse(text) as PackageJson), - Effect.orElseSucceed(() => undefined), - ) - - if (manifest !== undefined) { - const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName] - - return Option.fromNullishOr(bin).pipe( - Option.map(entry => ({ name: binName, bin: path.resolve(pkgDir, entry) })), - ) - } - - const parent = path.dirname(dir) - - if (parent === dir) { - return Option.none() - } - - dir = parent - } - }) -} diff --git a/packages/uncheck/src/tsconfig.test.ts b/packages/uncheck/src/tsconfig.test.ts deleted file mode 100644 index c1b88f2..0000000 --- a/packages/uncheck/src/tsconfig.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { NodeFileSystem, NodePath } from '@effect/platform-node' -import { Effect, Layer } from 'effect' -import { coversDirectory, includesFile, loadTsconfigInputs } from './tsconfig' -import { selectTsconfigs } from './typecheck' - -const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) -const fixtures: string[] = [] - -afterAll(() => { - for (const dir of fixtures) { - rmSync(dir, { recursive: true, force: true }) - } -}) - -function fixture(files: Record): string { - const dir = mkdtempSync(join(tmpdir(), 'uncheck-tsconfig-')) - fixtures.push(dir) - - for (const [relative, content] of Object.entries(files)) { - mkdirSync(dirname(join(dir, relative)), { recursive: true }) - writeFileSync(join(dir, relative), typeof content === 'string' ? content : JSON.stringify(content)) - } - - return dir -} - -function inputs(configPath: string) { - return Effect.runPromise(Effect.provide(loadTsconfigInputs(configPath), layer)) -} - -function select(dir: string, entries: string[], paths: string[]) { - return Effect.runPromise( - Effect.provide( - selectTsconfigs( - entries.map(entry => join(dir, entry)), - paths, - dir, - ), - layer, - ), - ).then(selected => selected.map(entry => entry.slice(dir.length + 1))) -} - -describe('loadTsconfigInputs', () => { - it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { - const dir = fixture({ - 'tsconfig.json': { include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], exclude: ['src/legacy'] }, - }) - - const project = await inputs(join(dir, 'tsconfig.json')) - const has = (file: string) => includesFile(project, join(dir, file)) - - expect(has('src/index.ts')).toBe(true) - expect(has('src/deep/nested/component.tsx')).toBe(true) - expect(has('src/types.d.ts')).toBe(true) - expect(has('src/index.js')).toBe(false) - expect(has('src/README.md')).toBe(false) - expect(has('src/.hidden/index.ts')).toBe(false) - expect(has('src/node_modules/dep/index.ts')).toBe(false) - expect(has('src/legacy/old.ts')).toBe(false) - expect(has('scripts/build.ts')).toBe(true) - expect(has('scripts/nested/deep/build.ts')).toBe(false) - expect(has('config/app.json')).toBe(true) - expect(has('src/data.json')).toBe(false) - expect(has('packages/app/build.config.ts')).toBe(true) - expect(has('packages/app/src/index.ts')).toBe(false) - expect(has('node_modules/dep/index.ts')).toBe(false) - }) - - it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { - const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }) - - const project = await inputs(join(dir, 'tsconfig.json')) - const has = (file: string) => includesFile(project, join(dir, file)) - - expect(has('index.ts')).toBe(true) - expect(has('deep/index.ts')).toBe(true) - expect(has('dist/index.d.ts')).toBe(false) - expect(has('node_modules/dep/index.d.ts')).toBe(false) - expect(has('.git/hooks/pre-commit.ts')).toBe(false) - }) - - it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { - const dir = fixture({ - 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, - 'node_modules/@shared/tsconfig/base.json': { - compilerOptions: { allowJs: true }, - include: ['${configDir}/lib'], - exclude: ['${configDir}/lib/vendor'], - }, - 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, - 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, - 'packages/a/tsconfig.files.json': { files: ['entry.ts'], compilerOptions: { allowJs: false } }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, - }) - - const a = await inputs(join(dir, 'packages/a/tsconfig.json')) - const b = await inputs(join(dir, 'packages/b/tsconfig.json')) - - // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. - expect(includesFile(a, join(dir, 'packages/a/entry.ts'))).toBe(true) - expect(includesFile(a, join(dir, 'src/index.ts'))).toBe(true) - expect(includesFile(a, join(dir, 'packages/a/src/index.ts'))).toBe(false) - expect(includesFile(a, join(dir, 'src/index.js'))).toBe(false) - // `exclude` is still the base's, with `${configDir}` pointing at the leaf. - expect(includesFile(a, join(dir, 'packages/a/lib/vendor/x.ts'))).toBe(false) - - expect(includesFile(b, join(dir, 'packages/b/lib/util.ts'))).toBe(true) - expect(includesFile(b, join(dir, 'packages/b/lib/util.js'))).toBe(true) - expect(includesFile(b, join(dir, 'packages/b/lib/vendor/x.ts'))).toBe(false) - expect(includesFile(b, join(dir, 'packages/b/src/index.ts'))).toBe(false) - }) - - it('tells whether a directory can hold inputs', async () => { - const dir = fixture({ 'tsconfig.json': { include: ['src', 'tests/**/*.spec.ts'], exclude: ['src/generated'] } }) - - const project = await inputs(join(dir, 'tsconfig.json')) - const covers = (folder: string) => coversDirectory(project, join(dir, folder)) - - expect(covers('src')).toBe(true) - expect(covers('src/components')).toBe(true) - expect(covers('tests/unit')).toBe(true) - expect(covers('.')).toBe(true) - expect(covers('docs')).toBe(false) - expect(covers('src/generated')).toBe(false) - expect(covers('src/generated/api')).toBe(false) - }) -}) - -describe('selectTsconfigs', () => { - const root = { - include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'], - } - - const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } - - function monorepo() { - return fixture({ - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': root, - 'packages/a/tsconfig.json': lib, - 'packages/b/tsconfig.json': lib, - }) - } - - const entries = ['packages/a/tsconfig.json', 'packages/b/tsconfig.json', 'tsconfig.json'] - - it('returns every project when no paths are given', async () => { - const dir = monorepo() - - expect(await select(dir, entries, [])).toEqual(entries) - }) - - it('selects only the projects whose inputs contain a file', async () => { - const dir = monorepo() - - expect(await select(dir, entries, ['packages/a/src/index.ts'])).toEqual(['packages/a/tsconfig.json']) - // Tests are excluded by the package and picked up by the root instead. - expect(await select(dir, entries, ['packages/a/src/index.test.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, entries, ['packages/a/build.config.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, entries, ['scripts/release.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, entries, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ - 'packages/a/tsconfig.json', - 'packages/b/tsconfig.json', - ]) - }) - - it('ignores negations and files tsc would never check', async () => { - const dir = monorepo() - - expect(await select(dir, entries, ['README.md', 'packages/a/styles.css'])).toEqual([]) - expect(await select(dir, entries, ['**/README.md', 'packages/**/*.css'])).toEqual([]) - expect(await select(dir, entries, ['!**/*.test.ts'])).toEqual([]) - expect(await select(dir, entries, ['packages/a/src/index.ts', 'docs/notes.md'])).toEqual([ - 'packages/a/tsconfig.json', - ]) - }) - - it('selects the projects whose inputs overlap a directory or glob', async () => { - const dir = monorepo() - - expect(await select(dir, entries, ['packages/b/src'])).toEqual(['packages/b/tsconfig.json', 'tsconfig.json']) - expect(await select(dir, entries, ['packages/a/src/**/*.ts'])).toEqual([ - 'packages/a/tsconfig.json', - 'tsconfig.json', - ]) - expect(await select(dir, entries, ['docs'])).toEqual(['tsconfig.json']) - expect(await select(dir, entries, ['**/*.ts'])).toEqual(entries) - }) -}) diff --git a/packages/uncheck/src/typecheck.ts b/packages/uncheck/src/typecheck.ts deleted file mode 100644 index 8cb5424..0000000 --- a/packages/uncheck/src/typecheck.ts +++ /dev/null @@ -1,273 +0,0 @@ -import type { PlatformError } from 'effect' -import { Data, Effect, FileSystem, Graph, Option, Path, Stream } from 'effect' -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' -import { coversDirectory, includesFile, loadTsconfigInputs, readReferences } from './tsconfig' - -export interface TsProject { - /** Absolute path to the config file. */ - readonly path: string - /** Absolute paths of the config files listed in `references`. */ - readonly references: ReadonlyArray -} - -export interface TypecheckPlan { - /** - * Roots of the project-reference graph (projects nobody references). - * `tsc -b` builds every referenced project first, in dependency order. - */ - readonly build: ReadonlyArray - /** Projects outside the reference graph, checked with `tsc -p` after the build step. */ - readonly check: ReadonlyArray -} - -export class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ - readonly projects: ReadonlyArray -}> {} - -/** - * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. - * - * Every project that has references, or is referenced, belongs to the build graph. - * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. - */ -export function planTypecheck( - entries: ReadonlyArray, - projects: ReadonlyMap, -): Effect.Effect { - const members = new Set() - const referenced = new Set() - - for (const project of projects.values()) { - if (project.references.length === 0) { - continue - } - - members.add(project.path) - - for (const reference of project.references) { - members.add(reference) - referenced.add(reference) - } - } - - const cycle = findCycle([...members].sort(), projects) - - if (Option.isSome(cycle)) { - return Effect.fail(new CircularProjectReferences({ projects: cycle.value })) - } - - return Effect.succeed({ - build: [...members].filter(member => !referenced.has(member)).sort(), - check: entries.filter(entry => !members.has(entry)).sort(), - }) -} - -/** The first cycle among `nodes` following `references`, in visiting order. */ -function findCycle(nodes: ReadonlyArray, projects: ReadonlyMap): Option.Option { - const graph = Graph.directed(mutable => { - const indexes = new Map(nodes.map(node => [node, Graph.addNode(mutable, node)] as const)) - - for (const node of nodes) { - for (const reference of projects.get(node)?.references ?? []) { - Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) - } - } - }) - - return Option.map(Graph.findCycle(graph), ({ path }) => - // The path closes on its first node, drop that repetition. - path.slice(0, -1).map(index => Option.getOrThrow(Graph.getNode(graph, index))), - ) -} - -/** - * Narrows `entries` to the projects `tsc` would actually check for the given `paths`. - * - * A file selects the projects whose `files`/`include`/`exclude` (with `extends` applied) take it as - * input, exactly like `tsc` decides. A directory, or the static prefix of a glob, selects the - * projects whose inputs can live under it. Negations are ignored, and no paths means every project. - */ -export function selectTsconfigs( - entries: ReadonlyArray, - paths: ReadonlyArray, - cwd: string, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - if (paths.length === 0) { - return Effect.succeed(entries) - } - - return Effect.gen(function* () { - const path = yield* Path.Path - - const targets = paths - .filter(pattern => !pattern.startsWith('!') && !namesUncheckableExtension(pattern)) - .map(pattern => path.resolve(cwd, staticPrefix(pattern))) - - if (targets.length === 0) { - return [] - } - - const selected = yield* Effect.filter( - entries, - entry => - Effect.map(loadTsconfigInputs(entry), inputs => - targets.some(target => - path.extname(target) === '' ? coversDirectory(inputs, target) : includesFile(inputs, target), - ), - ), - { concurrency: 'unbounded' }, - ) - - return [...selected].sort() - }) -} - -const CHECKABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']) - -/** Whether a path or glob ends in a literal extension `tsc` never reads, like `README.md` or `**\/*.css`. */ -function namesUncheckableExtension(pattern: string): boolean { - const name = pattern.slice(pattern.lastIndexOf('/') + 1) - const dot = name.lastIndexOf('.') - const extension = dot <= 0 ? '' : name.slice(dot).toLowerCase() - - return extension !== '' && !/[*?[\]{}()]/.test(extension) && !CHECKABLE_EXTENSIONS.has(extension) -} - -/** The leading path segments of a glob pattern that contain no wildcard. */ -function staticPrefix(pattern: string): string { - const segments = pattern.split('/') - const firstWildcard = segments.findIndex(segment => /[*?[\]{}()]/.test(segment)) - - return (firstWildcard === -1 ? segments : segments.slice(0, firstWildcard)).join('/') -} - -/** Whether `child` is `parent` itself or lies somewhere below it. */ -export function isWithin(path: Path.Path, child: string, parent: string): boolean { - const relative = path.relative(parent, child) - - return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) -} - -/** Parses `entries` and everything they reference, transitively, into a graph keyed by config path. */ -export function loadTsProjects( - entries: ReadonlyArray, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const projects = new Map() - const queue = [...entries] - - while (queue.length > 0) { - const configPath = queue.pop()! - - if (projects.has(configPath)) { - continue - } - - const references = yield* readReferences(configPath) - - projects.set(configPath, { path: configPath, references }) - queue.push(...references) - } - - return projects - }) -} - -/** - * Finds every `tsconfig.json` under `cwd`. - * - * Prefers `git ls-files` so ignored folders (build output, playgrounds, ...) are excluded exactly - * like the user configured, and falls back to a plain directory walk outside git repositories. - */ -export function discoverTsconfigs( - cwd: string, -): Effect.Effect< - ReadonlyArray, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> { - return listTsconfigsWithGit(cwd).pipe( - Effect.catch(() => walkTsconfigs(cwd)), - Effect.map(found => [...found].sort()), - ) -} - -function listTsconfigsWithGit( - cwd: string, -): Effect.Effect< - ReadonlyArray, - PlatformError.PlatformError | number, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const output = yield* Effect.scoped( - Effect.gen(function* () { - const handle = yield* spawner.spawn( - ChildProcess.make( - 'git', - ['ls-files', '--cached', '--others', '--exclude-standard', '-z', '--', '*tsconfig.json'], - { cwd, stdin: 'ignore', stderr: 'ignore' }, - ), - ) - - const stdout = yield* Stream.mkString(Stream.decodeText(handle.stdout)) - const exitCode = yield* handle.exitCode - - return exitCode === 0 ? stdout : yield* Effect.fail(exitCode) - }), - ) - - const candidates = output - .split('\0') - .filter(relative => path.basename(relative) === 'tsconfig.json' && !relative.split('/').includes('node_modules')) - .map(relative => path.resolve(cwd, relative)) - - // Tracked files can be deleted from the working tree without being staged yet. - return yield* Effect.filter(candidates, candidate => fs.exists(candidate), { concurrency: 'unbounded' }) - }) -} - -const SKIPPED_DIRECTORIES = new Set(['node_modules', 'dist', 'build', 'out', 'coverage', 'tmp', 'temp']) - -function walkTsconfigs( - cwd: string, -): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const found: string[] = [] - - const visit = (dir: string): Effect.Effect => - fs - .readDirectory(dir) - .pipe( - Effect.flatMap(names => - Effect.forEach(names, name => visitEntry(dir, name), { concurrency: 16, discard: true }), - ), - ) - - const visitEntry = (dir: string, name: string): Effect.Effect => - Effect.gen(function* () { - if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { - return - } - - const full = path.join(dir, name) - const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) - - if (info?.type === 'Directory') { - yield* visit(full) - } else if (info !== undefined && name === 'tsconfig.json') { - found.push(full) - } - }) - - yield* visit(path.resolve(cwd)) - - return found - }) -} diff --git a/packages/uncheck/src/ui.ts b/packages/uncheck/src/ui.ts index 9905b37..a5c48d0 100644 --- a/packages/uncheck/src/ui.ts +++ b/packages/uncheck/src/ui.ts @@ -1,43 +1,21 @@ -import type { PlatformError } from 'effect' import process from 'node:process' import { styleText } from 'node:util' -import { Effect, Stdio, Terminal } from 'effect' +import { Console } from 'effect' -export interface Ui { - readonly line: (text: string) => Effect.Effect - readonly bold: (text: string) => string - readonly dim: (text: string) => string - readonly red: (text: string) => string - readonly green: (text: string) => string -} - -export const makeUi: Effect.Effect = Effect.gen(function* () { - const stdio = yield* Stdio.Stdio - const terminal = yield* Terminal.Terminal - const colors = yield* supportsColor(stdio) - - // Color support is decided here (from the Stdio service), so skip styleText's own stream check. - const paint = (style: Parameters[0]) => (text: string) => - colors ? styleText(style, text, { validateStream: false }) : text - - return { - // `display` resolves once the write completed, so a header never trails the tool output that follows it. - line: text => terminal.display(`${text}\n`), - bold: paint('bold'), - dim: paint('dim'), - red: paint('red'), - green: paint('green'), - } -}) +/** Whether output gets ANSI colors: `NO_COLOR` wins, then `FORCE_COLOR`, then whether stdout is a terminal. */ +export const colors = + !('NO_COLOR' in process.env) && + ((process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') || process.stdout.isTTY === true) -function supportsColor(stdio: Stdio.Stdio): Effect.Effect { - if ('NO_COLOR' in process.env) { - return Effect.succeed(false) - } +const paint = (style: Parameters[0]) => (text: string) => + colors ? styleText(style, text, { validateStream: false }) : text - if (process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') { - return Effect.succeed(true) - } +export const bold = paint('bold') +export const dim = paint('dim') +export const red = paint('red') +export const green = paint('green') - return stdio.stdoutIsTerminal +/** Prints one line through the `Console` service, which hook mode and tests swap out to capture it. */ +export function line(text: string) { + return Console.log(text) } From f3069d50c88b21770d20370479dea37a662853d6 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 14:52:19 +0700 Subject: [PATCH 03/10] refactor(uncheck): simplify modules, plain-object checks, --require/--skip Checks are plain objects that yield their commands or fail with NothingToCheck (skipped, or failed under --require) or CannotCheck (always failed); the runner decides the outcome. Per-check flags are replaced by repeatable --require and --skip, and --cwd sets the directory to run in. Every function that wrapped Effect.gen is an Effect.fn, tiny helpers are inlined, casts are gone, and JSON is narrowed by hand. The package is CLI-only: no index entry or exports, only the bin. Layout: types.ts, errors.ts, style.ts, tool.ts, files.ts, run.ts, checks/{oxlint,oxfmt,tsc}.ts, commands/{flags,uncheck}.ts and commands/hooks/. Tests live in tests/ and drive the tsc check through its plan instead of internals. --- packages/uncheck/README.md | 13 +- packages/uncheck/package.json | 14 - packages/uncheck/src/bin.ts | 6 +- packages/uncheck/src/checks.ts | 166 ------- packages/uncheck/src/checks/oxfmt.ts | 25 + packages/uncheck/src/checks/oxlint.ts | 25 + packages/uncheck/src/checks/tsc.ts | 469 ++++++++++++++++++ packages/uncheck/src/commands/flags.ts | 26 + packages/uncheck/src/commands/hooks/index.ts | 8 + .../uncheck/src/commands/hooks/install.ts | 160 ++++++ packages/uncheck/src/commands/hooks/run.ts | 79 +++ .../src/{command.ts => commands/uncheck.ts} | 20 +- packages/uncheck/src/errors.ts | 16 + packages/uncheck/src/exec.ts | 36 -- packages/uncheck/src/files.ts | 238 +++++---- packages/uncheck/src/hooks.test.ts | 12 - packages/uncheck/src/hooks.ts | 304 ------------ packages/uncheck/src/index.ts | 5 - packages/uncheck/src/options.ts | 15 - packages/uncheck/src/resolve.ts | 64 --- packages/uncheck/src/run.ts | 154 ++++++ packages/uncheck/src/step.ts | 132 ----- packages/uncheck/src/steps/oxfmt.ts | 7 - packages/uncheck/src/steps/oxlint.ts | 7 - packages/uncheck/src/steps/tsc/index.ts | 221 --------- packages/uncheck/src/steps/tsc/tsc.test.ts | 161 ------ .../uncheck/src/steps/tsc/tsconfig.test.ts | 86 ---- packages/uncheck/src/steps/tsc/tsconfig.ts | 340 ------------- packages/uncheck/src/{ui.ts => style.ts} | 7 - packages/uncheck/src/tool.ts | 56 +++ packages/uncheck/src/types.ts | 48 ++ .../uncheck/{src => tests}/command.test.ts | 53 +- packages/uncheck/{src => tests}/files.test.ts | 4 +- .../{src/__tests__ => tests}/fixture.ts | 4 +- packages/uncheck/tests/tsc.test.ts | 179 +++++++ 35 files changed, 1414 insertions(+), 1746 deletions(-) delete mode 100644 packages/uncheck/src/checks.ts create mode 100644 packages/uncheck/src/checks/oxfmt.ts create mode 100644 packages/uncheck/src/checks/oxlint.ts create mode 100644 packages/uncheck/src/checks/tsc.ts create mode 100644 packages/uncheck/src/commands/flags.ts create mode 100644 packages/uncheck/src/commands/hooks/index.ts create mode 100644 packages/uncheck/src/commands/hooks/install.ts create mode 100644 packages/uncheck/src/commands/hooks/run.ts rename packages/uncheck/src/{command.ts => commands/uncheck.ts} (56%) create mode 100644 packages/uncheck/src/errors.ts delete mode 100644 packages/uncheck/src/exec.ts delete mode 100644 packages/uncheck/src/hooks.test.ts delete mode 100644 packages/uncheck/src/hooks.ts delete mode 100644 packages/uncheck/src/index.ts delete mode 100644 packages/uncheck/src/options.ts delete mode 100644 packages/uncheck/src/resolve.ts create mode 100644 packages/uncheck/src/run.ts delete mode 100644 packages/uncheck/src/step.ts delete mode 100644 packages/uncheck/src/steps/oxfmt.ts delete mode 100644 packages/uncheck/src/steps/oxlint.ts delete mode 100644 packages/uncheck/src/steps/tsc/index.ts delete mode 100644 packages/uncheck/src/steps/tsc/tsc.test.ts delete mode 100644 packages/uncheck/src/steps/tsc/tsconfig.test.ts delete mode 100644 packages/uncheck/src/steps/tsc/tsconfig.ts rename packages/uncheck/src/{ui.ts => style.ts} (62%) create mode 100644 packages/uncheck/src/tool.ts create mode 100644 packages/uncheck/src/types.ts rename packages/uncheck/{src => tests}/command.test.ts (88%) rename packages/uncheck/{src => tests}/files.test.ts (95%) rename packages/uncheck/{src/__tests__ => tests}/fixture.ts (89%) create mode 100644 packages/uncheck/tests/tsc.test.ts diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 452d223..26cafbb 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -28,16 +28,17 @@ ```sh npm i -D uncheck # Node 22 or newer -npx uncheck # oxlint, oxfmt --check and tsc for the whole project +npx uncheck # oxlint, oxfmt --check and tsc for everything under the current directory npx uncheck --fix # oxlint --fix, then rewrite formatting with oxfmt npx uncheck src/app # check some files: paths, directories, globs and !exclusions -npx uncheck --tsc=false # skip a step; --oxlint, --oxfmt and --tsc are auto-detected by default -npx uncheck --oxlint # require a step: fail when it cannot run instead of skipping it +npx uncheck --skip=tsc # skip a check that would otherwise run +npx uncheck --require=oxfmt # require a check: fail when it cannot run instead of skipping it +npx uncheck --cwd packages/app # run in another directory, paths are relative to it ``` -Steps run in order and every step runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any step fails. +Checks run in order and every check runs even if an earlier one fails, so one run reports everything. The exit code is non-zero when any check fails. -| Step | Runs when | Command | +| Check | Runs when | Command | | -------- | ------------------------------------- | -------------------------------------------------------------- | | `oxlint` | `oxlint` is installed | `oxlint [--fix] [files...]` | | `oxfmt` | `oxfmt` is installed | `oxfmt --check [files...]`, or `oxfmt [files...]` with `--fix` | @@ -70,7 +71,7 @@ This writes the agent's hook config (`.claude/settings.json`, `.codebuddy/settin uncheck hooks run --fix ``` -through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). It checks the files changed since the last commit (modified, staged and untracked, the whole project outside git) with every step, applies fixes, and prints the report on stderr. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop. Windsurf only shows the report. +through your package manager (`pnpm exec`, `yarn`, `bunx` or `npx`, detected from the lockfile). It runs every check on the files changed since the last commit (modified, staged and untracked, everything under the directory outside git), applies fixes, and prints the report on stderr. When problems remain, the agent is sent back to fix them before it finishes: Claude Code and CodeBuddy through exit code 2, Cursor through a follow-up message, Copilot through a `block` decision. That happens at most once per turn, so an agent that cannot fix something is never trapped in a loop. Windsurf only shows the report. Running once per turn instead of after every edit keeps the agent fast: a typecheck costs seconds, and one run per turn covers everything the agent touched. diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index 2c9593f..ff323a0 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -29,20 +29,6 @@ ], "type": "module", "sideEffects": false, - "exports": { - "./package.json": "./package.json", - ".": "./src/index.ts" - }, - "publishConfig": { - "exports": { - "./package.json": "./package.json", - ".": { - "types": "./dist/index.d.mts", - "import": "./dist/index.mjs", - "default": "./dist/index.mjs" - } - } - }, "scripts": { "build": "unbuild" }, diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts index 6055c63..ed6f87c 100644 --- a/packages/uncheck/src/bin.ts +++ b/packages/uncheck/src/bin.ts @@ -4,16 +4,14 @@ import { NodeRuntime, NodeServices } from '@effect/platform-node' import { Effect } from 'effect' import { Command } from 'effect/unstable/cli' import pkg from '../package.json' -import { command } from './command' +import { uncheck } from './commands/uncheck' -Command.run(command, { version: pkg.version }).pipe( - // The summary is already printed, only the exit code is left to report. +Command.run(uncheck, { version: pkg.version }).pipe( Effect.catchTag('CheckFailed', () => Effect.sync(() => { process.exitCode = 1 }), ), - // Exit code 2 is how Claude Code and CodeBuddy stop hooks send the agent back to work. Effect.catchTag('StopBlocked', () => Effect.sync(() => { process.exitCode = 2 diff --git a/packages/uncheck/src/checks.ts b/packages/uncheck/src/checks.ts deleted file mode 100644 index b7de992..0000000 --- a/packages/uncheck/src/checks.ts +++ /dev/null @@ -1,166 +0,0 @@ -import type { PlatformError } from 'effect' -import type { ChildProcessSpawner } from 'effect/unstable/process' -import type { Step, StepName, StepOutcome, StepPlan } from './step' -import { Data, Duration, Effect, Option, Path } from 'effect' -import { CliError, Flag } from 'effect/unstable/cli' -import { execCommand } from './exec' -import { listProjectFiles, resolvePaths } from './files' -import { UncheckOptions } from './options' -import { describeArgs } from './step' -import { oxfmt } from './steps/oxfmt' -import { oxlint } from './steps/oxlint' -import { tsc } from './steps/tsc' -import { bold, dim, green, line, red } from './ui' - -/** Every step uncheck knows, in run order. A new tool is one file in `steps/` plus an entry here. */ -const STEPS: ReadonlyArray = [oxlint, oxfmt, tsc] - -export class CheckFailed extends Data.TaggedError('CheckFailed')<{ - readonly outcomes: ReadonlyArray -}> {} - -/** - * Per-step switch from the `--oxlint`, `--oxfmt` and `--tsc` flags: `undefined` auto-detects, - * `false` skips the step, `true` requires it and fails when it cannot run. - */ -export type StepFlags = Readonly> - -export interface RunSettings { - readonly fix: boolean - readonly flags: StepFlags - /** Run with whatever matched instead of failing when a given path or pattern matches no file. */ - readonly allowUnmatched: boolean -} - -export const fixFlag = Flag.Boolean('fix').pipe( - Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), - Flag.withDefault(false), -) - -function stepFlag(step: Step) { - return Flag.Boolean(step.name).pipe( - Flag.optional, - Flag.map(Option.getOrUndefined), - Flag.withDescription( - `${step.does}. On by default when ${step.when}; --${step.name}=false skips it, --${step.name} requires it`, - ), - ) -} - -/** One optional boolean flag per step, spread into a command's config. */ -export const stepFlags = Object.fromEntries(STEPS.map(step => [step.name, stepFlag(step)])) as Record< - StepName, - ReturnType -> - -/** - * Runs every step over `paths` (the whole project when empty), prints the run log and fails with - * `CheckFailed` when a step failed, so callers decide how to report it. - */ -export function runChecks(paths: ReadonlyArray, { fix, flags, allowUnmatched }: RunSettings) { - return Effect.gen(function* () { - const path = yield* Path.Path - const cwd = path.resolve((yield* UncheckOptions).cwd) - const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) - - yield* line(dim(`uncheck in ${cwd}`)) - - let files: ReadonlyArray | undefined - - if (paths.length > 0) { - const resolved = yield* resolvePaths(paths, cwd, projectFiles) - - if (resolved.unmatched.length > 0 && !allowUnmatched) { - const userMessage = `No files match ${resolved.unmatched.join(', ')}. Pass --no-error-on-unmatched-pattern to run with whatever matched.` - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - if (resolved.files.length === 0) { - yield* line(`${dim('ā—‹')} nothing to check, no files match ${paths.join(' ')}`) - return - } - - files = resolved.files - } - - const plans = yield* Effect.all( - STEPS.map(step => - flags[step.name] === false - ? Effect.succeed(disabledPlan(step.name)) - : step.plan({ cwd, fix, files, required: flags[step.name] === true, projectFiles }), - ), - { concurrency: 'unbounded' }, - ) - - const outcomes = yield* Effect.forEach(plans, plan => runStep(plan, cwd)) - - const ran = outcomes.filter(outcome => outcome.status !== 'skipped') - const failed = outcomes.filter(outcome => outcome.status === 'failed') - - yield* line('') - - if (ran.length === 0) { - const reasons = outcomes.map(outcome => `${outcome.name} ${outcome.reason}`).join(', ') - - yield* line(`${red('✘')} nothing to check: ${reasons}`) - return yield* Effect.fail(new CheckFailed({ outcomes })) - } - - if (failed.length > 0) { - yield* line( - `${red('✘')} ${failed.length} of ${ran.length} checks failed: ${failed.map(outcome => outcome.name).join(', ')}`, - ) - - if (!fix && failed.some(outcome => STEPS.find(step => step.name === outcome.name)?.fixes)) { - yield* line(dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) - } - - return yield* Effect.fail(new CheckFailed({ outcomes })) - } - - yield* line(`${green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) - }) -} - -function disabledPlan(name: StepName): StepPlan { - return { name, status: 'skipped', reason: `disabled with --${name}=false` } -} - -function runStep( - plan: StepPlan, - cwd: string, -): Effect.Effect { - return Effect.gen(function* () { - if (plan.status !== 'run') { - yield* line( - plan.status === 'skipped' - ? `${dim('ā—‹')} ${bold(plan.name)} ${dim(`skipped, ${plan.reason}`)}` - : `${red('✘')} ${bold(plan.name)} ${red(plan.reason)}`, - ) - return plan - } - - const [duration, exitCodes] = yield* Effect.timed( - Effect.forEach(plan.commands, invocation => - line(`${dim('ā–¶')} ${bold(invocation.bin.name)} ${dim(describeArgs(invocation))}`.trimEnd()).pipe( - Effect.flatMap(() => execCommand(invocation, cwd)), - ), - ), - ) - - const failed = exitCodes.some(exitCode => exitCode !== 0) - const elapsed = dim(formatDuration(Duration.toMillis(duration))) - - yield* line( - failed - ? `${red('✘')} ${bold(plan.name)} ${red('failed')} ${elapsed}` - : `${green('āœ”')} ${bold(plan.name)} ${green('passed')} ${elapsed}`, - ) - - return { name: plan.name, status: failed ? 'failed' : 'passed' } satisfies StepOutcome - }) -} - -function formatDuration(ms: number): string { - return ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s` -} diff --git a/packages/uncheck/src/checks/oxfmt.ts b/packages/uncheck/src/checks/oxfmt.ts new file mode 100644 index 0000000..9b0e6ee --- /dev/null +++ b/packages/uncheck/src/checks/oxfmt.ts @@ -0,0 +1,25 @@ +import type { Check } from '../types' +import { Effect } from 'effect' +import { NothingToCheck } from '../errors' +import { argvBatches, resolveBin } from '../tool' + +export const oxfmt: Check = { + name: 'oxfmt', + fixes: true, + plan: Effect.fn(function* ({ cwd, fix, files }) { + const bin = yield* resolveBin('oxfmt', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + const args = fix ? [] : ['--check'] + + if (files === undefined) { + return [{ bin, args }] + } + + // Given files may include ones oxfmt does not handle (a Markdown file), which is not a failure. + return argvBatches(files).map(batch => ({ bin, args: [...args, '--no-error-on-unmatched-pattern'], files: batch })) + }), +} diff --git a/packages/uncheck/src/checks/oxlint.ts b/packages/uncheck/src/checks/oxlint.ts new file mode 100644 index 0000000..49b7d22 --- /dev/null +++ b/packages/uncheck/src/checks/oxlint.ts @@ -0,0 +1,25 @@ +import type { Check } from '../types' +import { Effect } from 'effect' +import { NothingToCheck } from '../errors' +import { argvBatches, resolveBin } from '../tool' + +export const oxlint: Check = { + name: 'oxlint', + fixes: true, + plan: Effect.fn(function* ({ cwd, fix, files }) { + const bin = yield* resolveBin('oxlint', cwd) + + if (bin === undefined) { + return yield* Effect.fail(new NothingToCheck({ reason: 'not installed' })) + } + + const args = fix ? ['--fix'] : [] + + if (files === undefined) { + return [{ bin, args }] + } + + // Given files may include ones oxlint does not handle (a Markdown file), which is not a failure. + return argvBatches(files).map(batch => ({ bin, args: [...args, '--no-error-on-unmatched-pattern'], files: batch })) + }), +} diff --git a/packages/uncheck/src/checks/tsc.ts b/packages/uncheck/src/checks/tsc.ts new file mode 100644 index 0000000..e60f3c5 --- /dev/null +++ b/packages/uncheck/src/checks/tsc.ts @@ -0,0 +1,469 @@ +import type { Check, CheckCommand } from '../types' +import { posix } from 'node:path' +import process from 'node:process' +import { Data, Effect, FileSystem, Graph, Option, Path, Predicate } from 'effect' +import { parse as parseJsonc } from 'jsonc-parser' +import { ancestors, readJson } from '../files' +import { CannotCheck, NothingToCheck } from '../errors' +import { resolveBin } from '../tool' + +export const tsc: Check = { + name: 'tsc', + fixes: false, + plan: Effect.fn(function* ({ cwd, files, 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).toLowerCase())) + + if (targets !== undefined && targets.length === 0) { + return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json covers the given files' })) + } + + const [typescript, tsconfigs] = yield* Effect.all( + [ + resolveBin('typescript', cwd, 'tsc'), + Effect.flatMap(projectFiles, all => + // Tracked files can be deleted from the working tree without being staged yet. + Effect.filter( + all.filter(file => path.basename(file) === 'tsconfig.json').map(file => path.resolve(cwd, file)), + candidate => fs.exists(candidate), + { concurrency: 'unbounded' }, + ), + ), + ], + { concurrency: 'unbounded' }, + ) + + if (tsconfigs.length === 0) { + return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json found' })) + } + + const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(tsconfigs, targets) + + if (selected.length === 0) { + return yield* Effect.fail(new NothingToCheck({ reason: 'no tsconfig.json covers the given files' })) + } + + if (typescript === undefined) { + return yield* Effect.fail( + new CannotCheck({ reason: `found ${selected.length} tsconfig.json but typescript is not installed` }), + ) + } + + const bin = typescript + + const projects = new Map() + const queue = [...selected] + + while (queue.length > 0) { + const configPath = queue.pop()! + + if (!projects.has(configPath)) { + const references = yield* readReferences(configPath) + + projects.set(configPath, { path: configPath, references }) + queue.push(...references) + } + } + + const plan = yield* planTypecheck(selected, projects).pipe( + Effect.catchTag('CircularProjectReferences', error => { + const cycle = error.projects.map(configPath => path.relative(cwd, configPath) || '.').join(', ') + return Effect.fail(new CannotCheck({ reason: `circular project references between ${cycle}` })) + }), + ) + + const commands: CheckCommand[] = [] + + if (plan.build.length > 0) { + commands.push({ bin, args: ['-b', ...plan.build.map(configPath => path.relative(cwd, configPath) || '.')] }) + } + + for (const configPath of plan.check) { + commands.push({ bin, args: ['-p', path.relative(cwd, configPath) || '.'] }) + } + + return commands + }), +} + +const CHECKABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']) + +interface TsProject { + readonly path: string + readonly references: ReadonlyArray +} + +interface TypecheckPlan { + readonly build: ReadonlyArray + readonly check: ReadonlyArray +} + +class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ + readonly projects: ReadonlyArray +}> {} + +/** + * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. + * + * Every project that has references, or is referenced, belongs to the build graph. + * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. + */ +function planTypecheck( + entries: ReadonlyArray, + projects: ReadonlyMap, +): Effect.Effect { + const members = new Set() + const referenced = new Set() + + for (const project of projects.values()) { + if (project.references.length === 0) { + continue + } + + members.add(project.path) + + for (const reference of project.references) { + members.add(reference) + referenced.add(reference) + } + } + + const nodes = [...members].sort() + + const graph = Graph.directed(mutable => { + const indexes = new Map(nodes.map(node => [node, Graph.addNode(mutable, node)] as const)) + + for (const node of nodes) { + for (const reference of projects.get(node)?.references ?? []) { + Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) + } + } + }) + + const cycle = Graph.findCycle(graph) + + if (Option.isSome(cycle)) { + // The path closes on its first node, drop that repetition. + const path = cycle.value.path.slice(0, -1).map(index => Option.getOrThrow(Graph.getNode(graph, index))) + + return Effect.fail(new CircularProjectReferences({ projects: path })) + } + + return Effect.succeed({ + build: nodes.filter(member => !referenced.has(member)), + check: entries.filter(entry => !members.has(entry)).sort(), + }) +} + +function selectTsconfigs( + entries: ReadonlyArray, + files: ReadonlyArray, +): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { + return Effect.filter( + entries, + entry => Effect.map(loadTsconfigInputs(entry), inputs => files.some(file => includesFile(inputs, file))), + { concurrency: 'unbounded' }, + ).pipe(Effect.map(selected => [...selected].sort())) +} + +interface RawTsconfig { + readonly extends?: unknown + readonly files?: unknown + readonly include?: unknown + readonly exclude?: unknown + readonly references?: unknown + readonly compilerOptions?: unknown +} + +/** Parses a config file as JSONC. Unreadable or malformed files read as empty and are left for `tsc` to report. */ +const readTsconfig = Effect.fn( + function* (configPath: string) { + const fs = yield* FileSystem.FileSystem + const value: unknown = parseJsonc(yield* fs.readFileString(configPath), undefined, { allowTrailingComma: true }) + + return Predicate.isObject(value) ? value : {} + }, + Effect.orElseSucceed((): RawTsconfig => ({})), +) + +/** `references` are never inherited through `extends`, so only the file itself is read. */ +const readReferences = Effect.fn(function* (configPath: string) { + const path = yield* Path.Path + const configDir = path.dirname(configPath) + const { references } = yield* readTsconfig(configPath) + + return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { + if (!Predicate.isObject(reference) || typeof reference.path !== 'string') { + return [] + } + + const target = path.resolve(configDir, reference.path.replaceAll('${configDir}', configDir)) + + return [target.endsWith('.json') ? target : path.join(target, 'tsconfig.json')] + }) +}) + +interface InputPattern { + readonly spec: string + readonly regex: RegExp +} + +interface TsconfigInputs { + readonly dir: string + readonly files: ReadonlyArray + readonly include: ReadonlyArray + readonly exclude: ReadonlyArray + readonly extensions: ReadonlySet +} + +const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] +const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] +const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages'] + +interface Specs { + readonly dir: string + readonly specs: ReadonlyArray +} + +/** + * Resolves `files`, `include`, `exclude` and the relevant compiler options of a config the way + * `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 path = yield* Path.Path + const chain = yield* loadExtendsChain(configPath, new Set()) + const leafDir = path.dirname(configPath) + + let files: Specs | undefined + let include: Specs | undefined + let exclude: Specs | undefined + let outDir: Specs | undefined + let allowJs = false + + for (const { dir, raw } of chain) { + if (isStringArray(raw.files)) { + files = { dir, specs: raw.files } + } + + if (isStringArray(raw.include)) { + include = { dir, specs: raw.include } + } + + if (isStringArray(raw.exclude)) { + exclude = { dir, specs: raw.exclude } + } + + if (Predicate.isObject(raw.compilerOptions)) { + const { compilerOptions } = raw + + if (typeof compilerOptions.allowJs === 'boolean') { + allowJs = compilerOptions.allowJs + } + + if (compilerOptions.checkJs === true) { + allowJs = true + } + + if (typeof compilerOptions.outDir === 'string') { + outDir = { dir, specs: [compilerOptions.outDir] } + } + } + } + + const resolve = (specs: Specs | undefined): string[] => + specs?.specs.map(spec => path.resolve(specs.dir, spec.replaceAll('${configDir}', leafDir)).replaceAll('\\', '/')) ?? + [] + + const includeSpecs = + include === undefined && files === undefined ? [`${leafDir.replaceAll('\\', '/')}/**/*`] : resolve(include) + + const excludeSpecs = + exclude === undefined + ? [...DEFAULT_EXCLUDES.map(name => `${leafDir.replaceAll('\\', '/')}/${name}`), ...resolve(outDir)] + : resolve(exclude) + + return { + dir: leafDir, + files: resolve(files), + include: includeSpecs.flatMap(spec => { + const regex = compileGlob(spec, 'files') + return regex === undefined ? [] : [{ spec, regex }] + }), + exclude: excludeSpecs.map(spec => ({ spec, regex: compileGlob(spec, 'exclude')! })), + extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), + } +}) + +function includesFile(inputs: TsconfigInputs, file: string): boolean { + const target = file.replaceAll('\\', '/') + + if (inputs.files.includes(target)) { + return true + } + + const extension = posix.extname(target).toLowerCase() + const json = extension === '.json' + + if (!json && !inputs.extensions.has(extension)) { + return false + } + + if (inputs.exclude.some(pattern => pattern.regex.test(target))) { + return false + } + + // JSON files only come in through an `include` that names the extension explicitly. + return inputs.include.some(pattern => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json'))) +} + +interface ChainEntry { + readonly dir: string + readonly raw: RawTsconfig +} + +const loadExtendsChain = Effect.fn(function* ( + configPath: string, + visited: Set, +): Effect.fn.Return, never, FileSystem.FileSystem | Path.Path> { + if (visited.has(configPath)) { + return [] + } + + visited.add(configPath) + + 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, dir).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed>([]), + onSome: base => loadExtendsChain(base, visited), + }), + ), + ), + ) + + return [...bases.flat(), { dir, raw }] +}) + +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`]) + } + + for (const current of ancestors(path, dir)) { + const base = path.join(current, 'node_modules', spec) + const baseKind = yield* kind(base) + + if (baseKind === 'File') { + return Option.some(base) + } + + const candidates = [`${base}.json`] + + if (baseKind === 'Directory') { + const manifest = yield* readJson(path.join(base, 'package.json')) + candidates.push(path.resolve(base, typeof manifest?.tsconfig === 'string' ? manifest.tsconfig : 'tsconfig.json')) + } + + const found = yield* firstFile(candidates) + + if (Option.isSome(found)) { + return found + } + } + + return Option.none() +}) + +const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' +const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` +const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' +const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' + +/** + * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: + * `*` and `?` never cross a directory, a leading wildcard never matches a dot file, `**` skips + * `node_modules` and dot folders, and a pattern without extension or wildcard means the whole folder. + * `exclude` patterns also match every path below them. + */ +function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { + const components = pattern.replace(/\/+$/, '').split('/') + const last = components[components.length - 1]! + + if (usage === 'files' && last === '**') { + return undefined + } + + if (!/[*?.]/.test(last)) { + components.push('**', '*') + } + + let source = '' + let written = false + + for (const component of components) { + if (component === '**') { + source += usage === 'files' ? FILES_DOUBLE_ASTERISK : EXCLUDE_DOUBLE_ASTERISK + } else { + if (written) { + source += '/' + } + + source += usage === 'files' ? filesComponent(component) : wildcards(component) + } + + written = true + } + + return new RegExp(`^${source}${usage === 'exclude' ? '(?:$|/)' : '$'}`, CASE_INSENSITIVE ? 'i' : '') +} + +function filesComponent(component: string): string { + let source = '' + let rest = component + + if (rest.startsWith('*')) { + source += '(?:[^./][^/]*)?' + rest = rest.slice(1) + } else if (rest.startsWith('?')) { + source += '[^./]' + rest = rest.slice(1) + } + + source += wildcards(rest) + + return /[*?]/.test(component) ? IMPLICIT_EXCLUDE + source : source +} + +function wildcards(component: string): string { + return component.replace(/[.*?+^${}()|[\]\\]/g, char => + char === '*' ? '[^/]*' : char === '?' ? '[^/]' : `\\${char}`, + ) +} + +function isStringArray(value: unknown): value is ReadonlyArray { + return Array.isArray(value) && value.every(item => typeof item === 'string') +} diff --git a/packages/uncheck/src/commands/flags.ts b/packages/uncheck/src/commands/flags.ts new file mode 100644 index 0000000..6d485f0 --- /dev/null +++ b/packages/uncheck/src/commands/flags.ts @@ -0,0 +1,26 @@ +import process from 'node:process' +import { Effect } from 'effect' +import { Flag } from 'effect/unstable/cli' +import { CHECKS } from '../run' + +export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( + Flag.withDefault(Effect.sync(() => process.cwd())), + Flag.withDescription('Directory to run in. Defaults to the current one'), +) + +export const fixFlag = Flag.Boolean('fix').pipe( + Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), + Flag.withDefault(false), +) + +const CHECK_NAMES = CHECKS.map(check => check.name) + +export const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe( + Flag.atLeast(0), + Flag.withDescription('Require a check: fail when it cannot run instead of skipping it. Repeatable'), +) + +export const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe( + Flag.atLeast(0), + Flag.withDescription('Skip a check even when it could run. Repeatable'), +) diff --git a/packages/uncheck/src/commands/hooks/index.ts b/packages/uncheck/src/commands/hooks/index.ts new file mode 100644 index 0000000..6829019 --- /dev/null +++ b/packages/uncheck/src/commands/hooks/index.ts @@ -0,0 +1,8 @@ +import { Command } from 'effect/unstable/cli' +import { install } from './install' +import { run } from './run' + +export const hooks = Command.make('hooks').pipe( + Command.withDescription('Agent hooks: `install` writes the configs, `run` is what they execute'), + Command.withSubcommands([install, run]), +) diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts new file mode 100644 index 0000000..7fb3f4c --- /dev/null +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -0,0 +1,160 @@ +import { Console, Effect, FileSystem, Option, Path, Predicate, Stdio } from 'effect' +import { Argument, CliError, Command, Prompt } from 'effect/unstable/cli' +import { parse as parseJsonc } from 'jsonc-parser' +import { ancestors, readJson } from '../../files' +import { bold, dim, green } from '../../style' +import { cwdFlag } from '../flags' + +const AGENTS = [ + { + id: 'claude', + name: 'Claude Code', + path: '.claude/settings.json', + content: (command: string) => ({ hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } }), + }, + { + id: 'codebuddy', + name: 'CodeBuddy', + path: '.codebuddy/settings.json', + content: (command: string) => ({ hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } }), + }, + { + id: 'cursor', + name: 'Cursor', + path: '.cursor/hooks.json', + content: (command: string) => ({ version: 1, hooks: { stop: [{ command }] } }), + }, + { + id: 'windsurf', + name: 'Windsurf', + path: '.windsurf/hooks.json', + content: (command: string) => ({ hooks: { post_cascade_response: [{ command, show_output: true }] } }), + }, + { + id: 'copilot', + name: 'GitHub Copilot', + path: '.github/hooks/uncheck.json', + content: (command: string) => ({ + version: 1, + hooks: { agentStop: [{ type: 'command', bash: command, powershell: command }] }, + }), + }, +] as const + +const AGENT_IDS = AGENTS.map(agent => agent.id) + +const EXEC_BY_PACKAGE_MANAGER: Readonly> = { + pnpm: 'pnpm exec', + yarn: 'yarn', + bun: 'bunx', + npm: 'npx', +} + +const EXEC_BY_LOCKFILE: ReadonlyArray = [ + ['pnpm-lock.yaml', 'pnpm exec'], + ['yarn.lock', 'yarn'], + ['bun.lock', 'bunx'], + ['bun.lockb', 'bunx'], + ['package-lock.json', 'npx'], +] + +export const install = Command.make( + 'install', + { + cwd: cwdFlag, + agents: Argument.Literals('agents', AGENT_IDS).pipe( + Argument.variadic(), + Argument.withDescription(`Agents to configure: ${AGENT_IDS.join(', ')}. Prompts for a selection when omitted.`), + ), + }, + Effect.fn(function* ({ cwd, agents }) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const stdio = yield* Stdio.Stdio + + let selected: ReadonlyArray<(typeof AGENT_IDS)[number]> = agents + + if (selected.length === 0) { + if (!(yield* stdio.stdinIsTerminal)) { + const userMessage = `Pass the agents to configure, for example: uncheck hooks install ${AGENT_IDS.join(' ')}` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + selected = yield* Prompt.run( + Prompt.MultiSelect({ + message: 'Which agents should run uncheck when they finish a turn?', + choices: AGENTS.map(agent => ({ title: agent.name, value: agent.id })), + min: 1, + }), + ) + } + + let exec = 'npx' + + for (const dir of ancestors(path, cwd)) { + const manifest = yield* readJson(path.join(dir, 'package.json')) + const declared = + typeof manifest?.packageManager === 'string' + ? EXEC_BY_PACKAGE_MANAGER[manifest.packageManager.split('@')[0] ?? ''] + : undefined + + const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) => + fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), + ) + + if (declared !== undefined || Option.isSome(lockfile)) { + exec = declared ?? Option.getOrThrow(lockfile)[1] + break + } + } + + const command = `${exec} uncheck hooks run --fix` + + for (const agent of AGENTS) { + if (!selected.includes(agent.id)) { + continue + } + + const file = path.join(cwd, agent.path) + const existing = yield* fs.readFileString(file).pipe(Effect.option) + let result: 'created' | 'updated' | 'unchanged' + + if (Option.isNone(existing)) { + yield* fs.makeDirectory(path.dirname(file), { recursive: true }) + yield* fs.writeFileString(file, `${JSON.stringify(agent.content(command), null, 2)}\n`) + result = 'created' + } else if (existing.value.includes('uncheck')) { + result = 'unchanged' + } else { + const current: unknown = parseJsonc(existing.value, undefined, { allowTrailingComma: true }) + const merged = mergeJson(Predicate.isObject(current) ? current : {}, agent.content(command)) + + yield* fs.writeFileString(file, `${JSON.stringify(merged, null, 2)}\n`) + result = 'updated' + } + + yield* Console.log(`${green('āœ”')} ${bold(agent.name)} ${dim(`${agent.path} ${result}`)}`) + } + + yield* Console.log('') + yield* Console.log(`${dim('The hook runs')} ${bold(command)} ${dim('whenever the agent finishes a turn.')}`) + }), +).pipe(Command.withDescription('Write the agent hook configs that run `uncheck hooks run` after every agent turn')) + +function mergeJson(base: unknown, addition: unknown): unknown { + if (Array.isArray(base) && Array.isArray(addition)) { + return [...base, ...addition] + } + + if (Predicate.isObject(base) && Predicate.isObject(addition)) { + const merged: Record = { ...base } + + for (const [key, value] of Object.entries(addition)) { + merged[key] = key in base ? mergeJson(base[key], value) : value + } + + return merged + } + + return addition +} diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts new file mode 100644 index 0000000..d5029b2 --- /dev/null +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -0,0 +1,79 @@ +import { stripVTControlCharacters } from 'node:util' +import { Console, Effect, Predicate, Stdio, Stream } from 'effect' +import { CliError, Command } from 'effect/unstable/cli' +import { listChangedFiles } from '../../files' +import { StopBlocked } from '../../errors' +import { runChecks } from '../../run' +import { cwdFlag, fixFlag, requireFlag, skipFlag } from '../flags' + +export const run = Command.make( + 'run', + { cwd: cwdFlag, fix: fixFlag, required: requireFlag, skipped: skipFlag }, + Effect.fn(function* ({ cwd, ...settings }) { + const stdio = yield* Stdio.Stdio + + if (yield* stdio.stdinIsTerminal) { + const userMessage = '`uncheck hooks run` expects the agent hook payload as JSON on stdin' + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + const payload = yield* Stream.mkString(Stream.decodeText(stdio.stdin)).pipe( + Effect.flatMap(text => Effect.try((): unknown => JSON.parse(text))), + Effect.map(value => (Predicate.isObject(value) ? value : {})), + Effect.orElseSucceed((): Record => ({})), + ) + + const changed = yield* listChangedFiles(cwd) + + if (changed?.length === 0) { + return + } + + const lines: string[] = [] + + const capture: Console.Console = Object.assign(Object.create(globalThis.console), { + log: (...parts: ReadonlyArray) => { + lines.push(parts.join(' ')) + }, + }) + + const failed = yield* runChecks(changed ?? [], { ...settings, cwd, allowUnmatched: true }).pipe( + Effect.map(() => false), + Effect.catchTag('CheckFailed', () => Effect.succeed(true)), + Effect.provideService(Console.Console, capture), + ) + + const report = stripVTControlCharacters(lines.join('\n')) + + yield* Console.error(report) + + // Send the agent back at most once per turn, in the way its family understands. Claude Code and + // CodeBuddy block on exit code 2 with stderr as the message and set `stop_hook_active` once they + // are already continuing; Cursor continues on a follow-up message and counts them in `loop_count`; + // Copilot continues on a block decision and also sets `stop_hook_active`. Windsurf only shows the report. + const alreadyContinued = + payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) + + if (!failed || alreadyContinued) { + return + } + + const reason = `uncheck found problems, fix them before finishing:\n\n${report}` + + 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 })) + } + + if (typeof payload.stopReason === 'string') { + return yield* Console.log(JSON.stringify({ decision: 'block', reason })) + } + }), +).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', + ), +) diff --git a/packages/uncheck/src/command.ts b/packages/uncheck/src/commands/uncheck.ts similarity index 56% rename from packages/uncheck/src/command.ts rename to packages/uncheck/src/commands/uncheck.ts index 83547ad..ac35cc0 100644 --- a/packages/uncheck/src/command.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -1,28 +1,30 @@ -import { Effect } from 'effect' import { Argument, Command, Flag } from 'effect/unstable/cli' -import { fixFlag, runChecks, stepFlags } from './checks' -import { hooksCommand } from './hooks' +import { runChecks } from '../run' +import { cwdFlag, fixFlag, requireFlag, skipFlag } from './flags' +import { hooks } from './hooks' -export const command = Command.make( +export const uncheck = Command.make( 'uncheck', { + cwd: cwdFlag, fix: fixFlag, allowUnmatched: Flag.Boolean('no-error-on-unmatched-pattern').pipe( Flag.withDefault(false), Flag.withDescription('Run with whatever matched instead of failing when a given path or pattern matches no file'), ), - ...stepFlags, + required: requireFlag, + skipped: skipFlag, paths: Argument.String('paths').pipe( Argument.variadic(), Argument.withDescription( - 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to the whole project.', + 'Files, directories or glob patterns, `!pattern` excludes. uncheck resolves them to one file list that every tool checks, so tools never disagree on what a pattern means. Defaults to everything under the current directory.', ), ), }, - ({ fix, allowUnmatched, paths, ...flags }) => Effect.suspend(() => runChecks(paths, { fix, flags, allowUnmatched })), + ({ paths, ...settings }) => runChecks(paths, settings), ).pipe( Command.withDescription( - 'Lint (oxlint), format check (oxfmt) and typecheck (tsc) a project with one command. Each step runs only when the project uses that tool.', + 'Lint (oxlint), format check (oxfmt) and typecheck (tsc) a project with one command. Each check runs only when the project uses that tool.', ), - Command.withSubcommands([hooksCommand]), + Command.withSubcommands([hooks]), ) diff --git a/packages/uncheck/src/errors.ts b/packages/uncheck/src/errors.ts new file mode 100644 index 0000000..12ea422 --- /dev/null +++ b/packages/uncheck/src/errors.ts @@ -0,0 +1,16 @@ +import type { CheckOutcome } from './types' +import { Data } from 'effect' + +export class NothingToCheck extends Data.TaggedError('NothingToCheck')<{ + readonly reason: string +}> {} + +export class CannotCheck extends Data.TaggedError('CannotCheck')<{ + readonly reason: string +}> {} + +export class CheckFailed extends Data.TaggedError('CheckFailed')<{ + readonly outcomes: ReadonlyArray +}> {} + +export class StopBlocked extends Data.TaggedError('StopBlocked')<{}> {} diff --git a/packages/uncheck/src/exec.ts b/packages/uncheck/src/exec.ts deleted file mode 100644 index badd54d..0000000 --- a/packages/uncheck/src/exec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { PlatformError } from 'effect' -import type { StepCommand } from './step' -import process from 'node:process' -import { Console, Effect, Stream } from 'effect' -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' -import { colors } from './ui' - -/** - * Runs a tool command in `cwd` and resolves with its exit code; a non-zero exit code is a normal - * result, not a failure. Output is piped and re-emitted line by line through `Console`, so it stays - * in order with everything else uncheck prints and can be captured in hook mode. - */ -export function execCommand( - { bin, args, files = [] }: StepCommand, - cwd: string, -): Effect.Effect { - return Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const handle = yield* spawner.spawn( - ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], { - cwd, - stdin: 'ignore', - // A piped tool cannot see the terminal, so tell it when colors are wanted. - env: colors ? { FORCE_COLOR: '1' } : {}, - extendEnv: true, - }), - ) - - yield* Stream.runForEach(Stream.splitLines(Stream.decodeText(handle.all)), text => Console.log(text)) - - return yield* handle.exitCode - }), - ) -} diff --git a/packages/uncheck/src/files.ts b/packages/uncheck/src/files.ts index f84dd07..c94faf3 100644 --- a/packages/uncheck/src/files.ts +++ b/packages/uncheck/src/files.ts @@ -1,6 +1,6 @@ import type { PlatformError } from 'effect' import { posix } from 'node:path' -import { Effect, FileSystem, Path, Stream } from 'effect' +import { Effect, FileSystem, Path, Predicate, Stream } from 'effect' import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' export type ProjectFiles = Effect.Effect< @@ -26,7 +26,7 @@ export function listProjectFiles(cwd: string): ProjectFiles { /** * 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 "the whole project". + * commit, which callers treat as "everything under `cwd`". */ export function listChangedFiles( cwd: string, @@ -41,167 +41,157 @@ export function listChangedFiles( } export interface ResolvedPaths { - /** Matched files, relative to `cwd`, sorted and unique. */ readonly files: ReadonlyArray - /** Patterns that matched nothing. */ readonly unmatched: ReadonlyArray } -/** - * Turns the given paths into the project files they name, so every tool checks the same files. - * - * A file must exist, a directory expands to the project files below it, a glob is matched against - * the project files, and `!pattern` drops matches. Files deleted from the working tree are dropped. - */ -export function resolvePaths( +/** Turns the given paths into the project files they name, so every tool checks the same files. */ +export const resolvePaths = Effect.fn(function* ( patterns: ReadonlyArray, cwd: string, projectFiles: ProjectFiles, -): Effect.Effect< - ResolvedPaths, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const relative = (pattern: string) => toPosix(path.relative(cwd, path.resolve(cwd, pattern))) +) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const relative = (pattern: string) => path.relative(cwd, path.resolve(cwd, pattern)).replaceAll('\\', '/') + + const matched = new Set() + const unmatched: string[] = [] + let universe: ReadonlyArray | undefined + + for (const pattern of patterns) { + if (pattern.startsWith('!')) { + continue + } - const matched = new Set() - const unmatched: string[] = [] - let universe: ReadonlyArray | undefined + const target = relative(pattern) - for (const pattern of patterns) { - if (pattern.startsWith('!')) { - continue + if (GLOB_CHARACTERS.test(target)) { + universe ??= yield* projectFiles + const hits = universe.filter(file => posix.matchesGlob(file, target)) + + if (hits.length === 0) { + unmatched.push(pattern) } - const target = relative(pattern) + for (const hit of hits) { + matched.add(hit) + } - if (GLOB_CHARACTERS.test(target)) { - universe ??= yield* projectFiles - const hits = universe.filter(file => posix.matchesGlob(file, target)) + continue + } - if (hits.length === 0) { - unmatched.push(pattern) - } + const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe( + Effect.map(info => info.type), + Effect.orElseSucceed(() => undefined), + ) - for (const hit of hits) { - matched.add(hit) - } + if (kind === 'File') { + matched.add(target) + continue + } - continue - } + if (kind === 'Directory') { + universe ??= yield* projectFiles + const inside = target === '' ? universe : universe.filter(file => file.startsWith(`${target}/`)) - const kind = yield* fs.stat(path.resolve(cwd, pattern)).pipe( - Effect.map(info => info.type), - Effect.orElseSucceed(() => undefined), - ) + if (inside.length > 0) { + for (const file of inside) { + matched.add(file) + } - if (kind === 'File') { - matched.add(target) continue } + } - if (kind === 'Directory') { - universe ??= yield* projectFiles - const inside = target === '' ? universe : universe.filter(file => file.startsWith(`${target}/`)) + unmatched.push(pattern) + } - if (inside.length > 0) { - for (const file of inside) { - matched.add(file) - } + const excludes = patterns.filter(pattern => pattern.startsWith('!')).map(pattern => relative(pattern.slice(1))) + const excluded = (file: string) => + excludes.some(exclude => file === exclude || file.startsWith(`${exclude}/`) || posix.matchesGlob(file, exclude)) - continue - } - } + const files = yield* Effect.filter( + [...matched].filter(file => !excluded(file)), + file => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)), + { concurrency: 'unbounded' }, + ) - unmatched.push(pattern) - } + return { files: files.sort(), unmatched } +}) - const excludes = patterns.filter(pattern => pattern.startsWith('!')).map(pattern => relative(pattern.slice(1))) - const excluded = (file: string) => - excludes.some(exclude => file === exclude || file.startsWith(`${exclude}/`) || posix.matchesGlob(file, exclude)) +const GLOB_CHARACTERS = /[*?[\]{}()]/ - const files = yield* Effect.filter( - [...matched].filter(file => !excluded(file)), - file => fs.exists(path.resolve(cwd, file)).pipe(Effect.orElseSucceed(() => false)), - { concurrency: 'unbounded' }, - ) +export function ancestors(path: Path.Path, from: string): string[] { + const dirs = [path.resolve(from)] - return { files: files.sort(), unmatched } - }) + for (let parent = path.dirname(dirs[0]!); parent !== dirs[dirs.length - 1]; parent = path.dirname(parent)) { + dirs.push(parent) + } + + return dirs } -/** Characters that make a pattern a glob rather than a plain path. */ -const GLOB_CHARACTERS = /[*?[\]{}()]/ +export const readJson = Effect.fn( + function* (file: string) { + const fs = yield* FileSystem.FileSystem + const text = yield* fs.readFileString(file) + const value: unknown = yield* Effect.try(() => JSON.parse(text)) -/** Runs a git command in `cwd` and returns its NUL-separated output as a list, failing with the exit code. */ -function git( - cwd: string, - args: ReadonlyArray, -): Effect.Effect, PlatformError.PlatformError | number, ChildProcessSpawner.ChildProcessSpawner> { - return Effect.scoped( - Effect.gen(function* () { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + return Predicate.isObject(value) ? value : undefined + }, + Effect.orElseSucceed(() => undefined), +) - const handle = yield* spawner.spawn(ChildProcess.make('git', args, { cwd, stdin: 'ignore', stderr: 'ignore' })) +const git = Effect.fn(function* (cwd: string, args: ReadonlyArray) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - const stdout = yield* Stream.mkString(Stream.decodeText(handle.stdout)) - const exitCode = yield* handle.exitCode + const handle = yield* spawner.spawn(ChildProcess.make('git', args, { cwd, stdin: 'ignore', stderr: 'ignore' })) - if (exitCode !== 0) { - return yield* Effect.fail(exitCode) - } + const stdout = yield* Stream.mkString(Stream.decodeText(handle.stdout)) + const exitCode = yield* handle.exitCode - return stdout.split('\0').filter(file => file !== '') - }), - ) -} + if (exitCode !== 0) { + return yield* Effect.fail(exitCode) + } + + return stdout.split('\0').filter(file => file !== '') +}, Effect.scoped) -/** The folders `tsc` itself never looks into. Hidden entries are skipped as well. */ +/** The folders `tsc` itself never looks into. */ const SKIPPED_DIRECTORIES = new Set(['node_modules', 'bower_components', 'jspm_packages']) -function walk( - cwd: string, -): Effect.Effect, PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const root = path.resolve(cwd) - const found: string[] = [] - - const visit = (dir: string): Effect.Effect => - fs - .readDirectory(dir) - .pipe( - Effect.flatMap(names => - Effect.forEach(names, name => visitEntry(dir, name), { concurrency: 16, discard: true }), - ), - ) - - const visitEntry = (dir: string, name: string): Effect.Effect => - Effect.gen(function* () { - if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { - return - } +const walk = Effect.fn(function* (cwd: string) { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = path.resolve(cwd) + const found: string[] = [] - const full = path.join(dir, name) - const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) + const visit = Effect.fn(function* (dir: string): Effect.fn.Return { + const names = yield* fs.readDirectory(dir) - if (info?.type === 'Directory') { - yield* visit(full) - } else if (info?.type === 'File') { - found.push(toPosix(path.relative(root, full))) - } - }) + yield* Effect.forEach(names, name => visitEntry(dir, name), { concurrency: 16, discard: true }) + }) - yield* visit(root) + const visitEntry = Effect.fn(function* ( + dir: string, + name: string, + ): Effect.fn.Return { + if (name.startsWith('.') || SKIPPED_DIRECTORIES.has(name)) { + return + } - return found + const full = path.join(dir, name) + const info = yield* fs.stat(full).pipe(Effect.orElseSucceed(() => undefined)) + + if (info?.type === 'Directory') { + yield* visit(full) + } else if (info?.type === 'File') { + found.push(path.relative(root, full).replaceAll('\\', '/')) + } }) -} -function toPosix(target: string): string { - return target.replaceAll('\\', '/') -} + yield* visit(root) + + return found +}) diff --git a/packages/uncheck/src/hooks.test.ts b/packages/uncheck/src/hooks.test.ts deleted file mode 100644 index 740c72c..0000000 --- a/packages/uncheck/src/hooks.test.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { stopAgent } from './hooks' - -describe('stopAgent', () => { - it('recognizes each agent family from the fields it documents', () => { - expect(stopAgent({ hook_event_name: 'Stop', session_id: 'x', stop_hook_active: false })).toBe('claude') - expect(stopAgent({ hook_event_name: 'Stop', generation_id: 'x', stop_hook_active: true })).toBe('claude') - expect(stopAgent({ hook_event_name: 'stop', status: 'completed', loop_count: 0 })).toBe('cursor') - expect(stopAgent({ sessionId: 'x', stopReason: 'end_turn', stop_hook_active: false })).toBe('copilot') - expect(stopAgent({ agent_action_name: 'post_cascade_response', tool_info: {} })).toBeUndefined() - expect(stopAgent({})).toBeUndefined() - }) -}) diff --git a/packages/uncheck/src/hooks.ts b/packages/uncheck/src/hooks.ts deleted file mode 100644 index 600276c..0000000 --- a/packages/uncheck/src/hooks.ts +++ /dev/null @@ -1,304 +0,0 @@ -import type { PlatformError } from 'effect' -import { stripVTControlCharacters } from 'node:util' -import { Console, Data, Effect, FileSystem, Option, Path, Predicate, Stdio, Stream } from 'effect' -import { Argument, CliError, Command, Prompt } from 'effect/unstable/cli' -import { parse as parseJsonc } from 'jsonc-parser' -import { fixFlag, runChecks, stepFlags } from './checks' -import { listChangedFiles } from './files' -import { UncheckOptions } from './options' -import { ancestors, readJson } from './resolve' -import { bold, dim, green, line } from './ui' - -interface HookIntegration { - readonly id: string - readonly name: string - /** Config file the agent reads, relative to the project root. */ - readonly path: string - /** Config for the hook that fires when the agent finishes a turn, written or merged into that file. */ - readonly content: (command: string) => Record -} - -function claudeStyle(command: string): Record { - return { hooks: { Stop: [{ hooks: [{ type: 'command', command }] }] } } -} - -const HOOK_INTEGRATIONS = [ - { id: 'claude', name: 'Claude Code', path: '.claude/settings.json', content: claudeStyle }, - { id: 'codebuddy', name: 'CodeBuddy', path: '.codebuddy/settings.json', content: claudeStyle }, - { - id: 'cursor', - name: 'Cursor', - path: '.cursor/hooks.json', - content: command => ({ version: 1, hooks: { stop: [{ command }] } }), - }, - { - id: 'windsurf', - name: 'Windsurf', - path: '.windsurf/hooks.json', - content: command => ({ hooks: { post_cascade_response: [{ command, show_output: true }] } }), - }, - { - id: 'copilot', - name: 'GitHub Copilot', - path: '.github/hooks/uncheck.json', - content: command => ({ - version: 1, - hooks: { agentStop: [{ type: 'command', bash: command, powershell: command }] }, - }), - }, -] as const satisfies ReadonlyArray - -type HookAgent = (typeof HOOK_INTEGRATIONS)[number]['id'] - -const HOOK_AGENTS = HOOK_INTEGRATIONS.map(integration => integration.id) - -const installCommand = Command.make( - 'install', - { - agents: Argument.Literals('agents', HOOK_AGENTS).pipe( - Argument.variadic(), - Argument.withDescription(`Agents to configure: ${HOOK_AGENTS.join(', ')}. Prompts for a selection when omitted.`), - ), - }, - ({ agents }) => - Effect.gen(function* () { - const { cwd } = yield* UncheckOptions - const selected: ReadonlyArray = agents.length > 0 ? agents : yield* promptAgents - const command = yield* hookCommand(cwd) - - for (const integration of HOOK_INTEGRATIONS) { - if (!selected.includes(integration.id)) { - continue - } - - const result = yield* installHook(integration, command, cwd) - - yield* line(`${green('āœ”')} ${bold(integration.name)} ${dim(`${integration.path} ${result}`)}`) - } - - yield* line('') - yield* line(`${dim('The hook runs')} ${bold(command)} ${dim('whenever the agent finishes a turn.')}`) - }), -).pipe(Command.withDescription('Write the agent hook configs that run `uncheck hooks run` after every agent turn')) - -/** Raised when the agent must keep working; the entry point turns it into exit code 2. */ -export class StopBlocked extends Data.TaggedError('StopBlocked')<{}> {} - -const runCommand = Command.make('run', { fix: fixFlag, ...stepFlags }, ({ fix, ...flags }) => - Effect.gen(function* () { - const options = yield* UncheckOptions - const stdio = yield* Stdio.Stdio - const path = yield* Path.Path - - if (yield* stdio.stdinIsTerminal) { - const userMessage = '`uncheck hooks run` expects the agent hook payload as JSON on stdin' - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - const payload = yield* Stream.mkString(Stream.decodeText(stdio.stdin)).pipe( - Effect.flatMap(text => Effect.try(() => JSON.parse(text) as unknown)), - Effect.map(value => (Predicate.isObject(value) ? value : {})), - Effect.orElseSucceed((): Record => ({})), - ) - - const cwd = path.resolve(options.cwd) - const changed = yield* listChangedFiles(cwd) - - if (changed?.length === 0) { - return - } - - const lines: string[] = [] - - const capture: Console.Console = Object.assign(Object.create(globalThis.console), { - log: (...parts: ReadonlyArray) => { - lines.push(parts.join(' ')) - }, - }) - - const failed = yield* runChecks(changed ?? [], { fix, flags, allowUnmatched: true }).pipe( - Effect.map(() => false), - Effect.catchTag('CheckFailed', () => Effect.succeed(true)), - Effect.provideService(Console.Console, capture), - ) - - const report = stripVTControlCharacters(lines.join('\n')) - - yield* Console.error(report) - - if (!failed) { - return - } - - const reason = `uncheck found problems, fix them before finishing:\n\n${report}` - - yield* stopFeedback(payload, reason) - }), -).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', - ), -) - -export const hooksCommand = Command.make('hooks').pipe( - Command.withDescription('Agent hooks: `install` writes the configs, `run` is what they execute'), - Command.withSubcommands([installCommand, runCommand]), -) - -/** - * Sends the agent back to work in the way its family understands, at most once per turn. - * - * Claude Code and CodeBuddy block on exit code 2 with stderr as the message and mark the payload - * with `stop_hook_active` once they are already continuing. Cursor continues on a `followup_message` - * and counts its follow-ups in `loop_count`. Copilot continues on `decision: "block"` and also sets - * `stop_hook_active`. Anything else, such as Windsurf, only gets the report. - */ -export function stopFeedback(payload: Record, reason: string) { - const agent = stopAgent(payload) - const alreadyContinued = - payload.stop_hook_active === true || (typeof payload.loop_count === 'number' && payload.loop_count > 0) - - if (agent === undefined || alreadyContinued) { - return Effect.void - } - - switch (agent) { - case 'claude': - return Effect.fail(new StopBlocked()) - case 'cursor': - return Console.log(JSON.stringify({ followup_message: reason })) - case 'copilot': - return Console.log(JSON.stringify({ decision: 'block', reason })) - } -} - -/** Which family of stop payload this is, judged by the fields each agent documents. */ -export function stopAgent(payload: Record): 'claude' | 'cursor' | 'copilot' | undefined { - if (payload.hook_event_name === 'Stop') { - return 'claude' - } - - if (payload.hook_event_name === 'stop') { - return 'cursor' - } - - if (typeof payload.stopReason === 'string') { - return 'copilot' - } - - return undefined -} - -const promptAgents = Effect.gen(function* () { - const stdio = yield* Stdio.Stdio - - if (!(yield* stdio.stdinIsTerminal)) { - const userMessage = `Pass the agents to configure, for example: uncheck hooks install ${HOOK_AGENTS.join(' ')}` - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - return yield* Prompt.run( - Prompt.MultiSelect({ - message: 'Which agents should run uncheck when they finish a turn?', - choices: HOOK_INTEGRATIONS.map(integration => ({ title: integration.name, value: integration.id })), - min: 1, - }), - ) -}) - -const EXEC_BY_PACKAGE_MANAGER: Readonly> = { - pnpm: 'pnpm exec', - yarn: 'yarn', - bun: 'bunx', - npm: 'npx', -} - -const EXEC_BY_LOCKFILE: ReadonlyArray = [ - ['pnpm-lock.yaml', 'pnpm exec'], - ['yarn.lock', 'yarn'], - ['bun.lock', 'bunx'], - ['bun.lockb', 'bunx'], - ['package-lock.json', 'npx'], -] - -/** The hook command, invoking the project's own `uncheck` through the package manager in use. */ -function hookCommand(cwd: string): Effect.Effect { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - - for (const dir of ancestors(path, cwd)) { - const manifest = yield* readJson<{ packageManager?: string }>(path.join(dir, 'package.json')) - const declared = EXEC_BY_PACKAGE_MANAGER[manifest?.packageManager?.split('@')[0] ?? ''] - - if (declared !== undefined) { - return `${declared} uncheck hooks run --fix` - } - - const lockfile = yield* Effect.findFirst(EXEC_BY_LOCKFILE, ([file]) => - fs.exists(path.join(dir, file)).pipe(Effect.orElseSucceed(() => false)), - ) - - if (Option.isSome(lockfile)) { - return `${lockfile.value[1]} uncheck hooks run --fix` - } - } - - return 'npx uncheck hooks run --fix' - }) -} - -/** - * Writes the agent's hook config, merging into an existing file so other hooks are kept. - * A file that already mentions `uncheck` is left alone, which makes reruns safe. - */ -function installHook( - integration: HookIntegration, - command: string, - cwd: string, -): Effect.Effect<'created' | 'updated' | 'unchanged', PlatformError.PlatformError, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const file = path.join(cwd, integration.path) - const addition = integration.content(command) - - const existing = yield* fs.readFileString(file).pipe(Effect.option) - - if (Option.isNone(existing)) { - yield* fs.makeDirectory(path.dirname(file), { recursive: true }) - yield* fs.writeFileString(file, `${JSON.stringify(addition, null, 2)}\n`) - return 'created' - } - - if (existing.value.includes('uncheck')) { - return 'unchanged' - } - - const current = parseJsonc(existing.value, undefined, { allowTrailingComma: true }) as unknown - const merged = mergeJson(Predicate.isObject(current) ? current : {}, addition) - - yield* fs.writeFileString(file, `${JSON.stringify(merged, null, 2)}\n`) - - return 'updated' - }) -} - -/** Deep-merges plain objects and concatenates arrays, so hook lists grow instead of being replaced. */ -function mergeJson(base: unknown, addition: unknown): unknown { - if (Array.isArray(base) && Array.isArray(addition)) { - return [...base, ...addition] - } - - if (Predicate.isObject(base) && Predicate.isObject(addition)) { - const merged: Record = { ...base } - - for (const [key, value] of Object.entries(addition)) { - merged[key] = key in base ? mergeJson(base[key], value) : value - } - - return merged - } - - return addition -} diff --git a/packages/uncheck/src/index.ts b/packages/uncheck/src/index.ts deleted file mode 100644 index 8b17f70..0000000 --- a/packages/uncheck/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export { CheckFailed } from './checks' -export * from './command' -export { StopBlocked } from './hooks' -export * from './options' -export type { StepName, StepOutcome } from './step' diff --git a/packages/uncheck/src/options.ts b/packages/uncheck/src/options.ts deleted file mode 100644 index cbb532e..0000000 --- a/packages/uncheck/src/options.ts +++ /dev/null @@ -1,15 +0,0 @@ -import process from 'node:process' -import { Context } from 'effect' - -export interface UncheckOptions { - /** - * Directory the checks run in. Tools and `tsconfig.json` files are discovered from here. - * - * @default process.cwd() - */ - readonly cwd: string -} - -export const UncheckOptions = Context.Reference('uncheck/UncheckOptions', { - defaultValue: () => ({ cwd: process.cwd() }), -}) diff --git a/packages/uncheck/src/resolve.ts b/packages/uncheck/src/resolve.ts deleted file mode 100644 index 10ec3a3..0000000 --- a/packages/uncheck/src/resolve.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Effect, FileSystem, Option, Path } from 'effect' - -/** An executable JS entry of an installed package, run with `process.execPath`. */ -export interface Bin { - readonly name: string - readonly entry: string -} - -interface PackageJson { - readonly bin?: string | Readonly> -} - -/** `from` and every directory above it, nearest first. */ -export function ancestors(path: Path.Path, from: string): string[] { - const dirs = [path.resolve(from)] - - for (let parent = path.dirname(dirs[0]!); parent !== dirs[dirs.length - 1]; parent = path.dirname(parent)) { - dirs.push(parent) - } - - return dirs -} - -/** Reads and parses a JSON file, `undefined` when it is missing or malformed. */ -export function readJson(file: string): Effect.Effect { - return Effect.flatMap(FileSystem.FileSystem, fs => - fs.readFileString(file).pipe( - Effect.map(text => JSON.parse(text) as A), - Effect.orElseSucceed(() => undefined), - ), - ) -} - -/** - * Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest - * `node_modules/`. Reading its manifest directly (instead of `require.resolve`) keeps this - * independent from the package's `exports` map. - */ -export function resolveBin( - pkg: string, - cwd: string, - binName: string = pkg, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const path = yield* Path.Path - - for (const dir of ancestors(path, cwd)) { - const pkgDir = path.join(dir, 'node_modules', pkg) - const manifest = yield* readJson(path.join(pkgDir, 'package.json')) - - if (manifest === undefined) { - continue - } - - const bin = typeof manifest.bin === 'string' ? manifest.bin : manifest.bin?.[binName] - - return Option.fromNullishOr(bin).pipe( - Option.map(entry => ({ name: binName, entry: path.resolve(pkgDir, entry) })), - ) - } - - return Option.none() - }) -} diff --git a/packages/uncheck/src/run.ts b/packages/uncheck/src/run.ts new file mode 100644 index 0000000..609744a --- /dev/null +++ b/packages/uncheck/src/run.ts @@ -0,0 +1,154 @@ +import type { Check, CheckCommand, CheckName, CheckOutcome, RunSettings } from './types' +import path from 'node:path' +import process from 'node:process' +import { Console, Duration, Effect, Stream } from 'effect' +import { CliError } from 'effect/unstable/cli' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' +import { listProjectFiles, resolvePaths } from './files' +import { CheckFailed } from './errors' +import { bold, colors, dim, green, red } from './style' +import { oxfmt } from './checks/oxfmt' +import { oxlint } from './checks/oxlint' +import { tsc } from './checks/tsc' + +export const CHECKS: ReadonlyArray = [oxlint, oxfmt, tsc] + +type CheckPlan = + | { readonly name: CheckName; readonly status: 'run'; readonly commands: ReadonlyArray } + | (CheckOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) + +export const runChecks = Effect.fn(function* (paths: ReadonlyArray, settings: RunSettings) { + const { fix, required, skipped, allowUnmatched } = settings + const contradiction = required.find(name => skipped.includes(name)) + + if (contradiction !== undefined) { + const userMessage = `--require=${contradiction} and --skip=${contradiction} contradict each other.` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + const cwd = path.resolve(settings.cwd) + const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) + + yield* Console.log(dim(`uncheck in ${cwd}`)) + + let files: ReadonlyArray | undefined + + if (paths.length > 0) { + const resolved = yield* resolvePaths(paths, cwd, projectFiles) + + if (resolved.unmatched.length > 0 && !allowUnmatched) { + const userMessage = `No files match ${resolved.unmatched.join(', ')}. Pass --no-error-on-unmatched-pattern to run with whatever matched.` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + if (resolved.files.length === 0) { + yield* Console.log(`${dim('ā—‹')} nothing to check, no files match ${paths.join(' ')}`) + return + } + + files = resolved.files + } + + const plans = yield* Effect.all( + CHECKS.map(({ name, plan }) => + skipped.includes(name) + ? Effect.succeed({ name, status: 'skipped', reason: `disabled with --skip=${name}` }) + : plan({ cwd, fix, files, projectFiles }).pipe( + Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), + Effect.catchTag('NothingToCheck', ({ reason }) => + Effect.succeed({ name, status: required.includes(name) ? 'failed' : 'skipped', reason }), + ), + Effect.catchTag('CannotCheck', ({ reason }) => + Effect.succeed({ name, status: 'failed', reason }), + ), + ), + ), + { concurrency: 'unbounded' }, + ) + + const outcomes = yield* Effect.forEach(plans, plan => runCheck(plan, cwd)) + + 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(', ') + + yield* Console.log(`${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(', ')}`, + ) + + if ( + !fix && + failed.some(outcome => outcome.reason === undefined && CHECKS.find(check => check.name === outcome.name)?.fixes) + ) { + yield* Console.log(dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) + } + + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + yield* Console.log(`${green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) +}) + +const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { + if (plan.status !== 'run') { + yield* Console.log( + plan.status === 'skipped' + ? `${dim('ā—‹')} ${bold(plan.name)} ${dim(`skipped, ${plan.reason}`)}` + : `${red('✘')} ${bold(plan.name)} ${red(plan.reason)}`, + ) + return plan + } + + const [duration, exitCodes] = yield* Effect.timed( + Effect.forEach(plan.commands, invocation => { + const { bin, args, files } = invocation + const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`] + + return Console.log(`${dim('ā–¶')} ${bold(bin.name)} ${dim([...args, ...tail].join(' '))}`.trimEnd()).pipe( + Effect.flatMap(() => execute(invocation, cwd)), + ) + }), + ) + + const failed = exitCodes.some(exitCode => exitCode !== 0) + const ms = Duration.toMillis(duration) + const elapsed = dim(ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`) + + yield* Console.log( + failed + ? `${red('✘')} ${bold(plan.name)} ${red('failed')} ${elapsed}` + : `${green('āœ”')} ${bold(plan.name)} ${green('passed')} ${elapsed}`, + ) + + const outcome: CheckOutcome = { name: plan.name, status: failed ? 'failed' : 'passed' } + + return outcome +}) + +/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured in hook mode. */ +const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckCommand, cwd: string) { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner + + const handle = yield* spawner.spawn( + ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], { + cwd, + stdin: 'ignore', + // A piped tool cannot see the terminal, so tell it when colors are wanted. + env: colors ? { FORCE_COLOR: '1' } : {}, + extendEnv: true, + }), + ) + + yield* Stream.runForEach(Stream.splitLines(Stream.decodeText(handle.all)), text => Console.log(text)) + + return yield* handle.exitCode +}, Effect.scoped) diff --git a/packages/uncheck/src/step.ts b/packages/uncheck/src/step.ts deleted file mode 100644 index 0f491b5..0000000 --- a/packages/uncheck/src/step.ts +++ /dev/null @@ -1,132 +0,0 @@ -import type { PlatformError } from 'effect' -import type { ChildProcessSpawner } from 'effect/unstable/process' -import type { ProjectFiles } from './files' -import type { Bin } from './resolve' -import { Effect, FileSystem, Option, Path } from 'effect' -import { resolveBin } from './resolve' - -export type StepName = 'oxlint' | 'oxfmt' | 'tsc' - -export interface StepOutcome { - readonly name: StepName - readonly status: 'passed' | 'failed' | 'skipped' - /** Why the step was skipped, or why it failed before any tool ran. */ - readonly reason?: string -} - -export interface StepCommand { - readonly bin: Bin - readonly args: ReadonlyArray - /** Files appended after `args`, kept apart so the run log can summarize them. */ - readonly files?: ReadonlyArray -} - -/** The arguments as a short description for the run log: user-facing flags, then the files or their count. */ -export function describeArgs({ args, files }: StepCommand): string { - const shown = args.filter(arg => arg !== UNMATCHED_PATHS_OK) - const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`] - - return [...shown, ...tail].join(' ') -} - -export interface RunPlan { - readonly name: StepName - readonly status: 'run' - readonly commands: ReadonlyArray -} - -/** A step that is either ready to run, or already settled without running anything. */ -export type StepPlan = RunPlan | (StepOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) - -export interface StepInput { - readonly cwd: string - readonly fix: boolean - /** Files to check, relative to `cwd`, or `undefined` for the whole project. */ - readonly files: ReadonlyArray | undefined - /** Whether the step was required with its flag, so it fails instead of skipping when it cannot run. */ - readonly required: boolean - /** Every file in the project, listed once and shared between steps. */ - readonly projectFiles: ProjectFiles -} - -/** One check uncheck can run. Steps live in `steps/` and are registered in `command.ts`. */ -export interface Step { - readonly name: StepName - /** What the step does, for its flag's help text. */ - readonly does: string - /** When the step runs by default, for its flag's help text. */ - readonly when: string - /** Whether `--fix` applies to this step. */ - readonly fixes: boolean - readonly plan: ( - input: StepInput, - ) => Effect.Effect< - StepPlan, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner - > -} - -/** A step that cannot run: a failure when it was required, a skip otherwise. */ -export function settled(name: StepName, required: boolean, reason: string): StepPlan { - return { name, status: required ? 'failed' : 'skipped', reason } -} - -/** Explicit files may include ones a tool does not handle (a Markdown file for oxlint), which is not a failure. */ -const UNMATCHED_PATHS_OK = '--no-error-on-unmatched-pattern' - -/** Command lines stay well below every platform's argument limit. */ -const MAX_ARGS_LENGTH = 65_536 - -/** Splits a file list into command-line sized batches. */ -export function batches(files: ReadonlyArray): string[][] { - const result: string[][] = [] - let current: string[] = [] - let length = 0 - - for (const file of files) { - if (current.length > 0 && length + file.length + 1 > MAX_ARGS_LENGTH) { - result.push(current) - current = [] - length = 0 - } - - current.push(file) - length += file.length + 1 - } - - if (current.length > 0) { - result.push(current) - } - - return result -} - -interface BinStepOptions { - readonly name: 'oxlint' | 'oxfmt' - readonly does: string - readonly args: (fix: boolean) => ReadonlyArray -} - -/** A step that runs one installed package's bin, over the given files or the whole project. */ -export function binStep({ name, does, args }: BinStepOptions): Step { - return { - name, - does, - when: `${name} is installed`, - fixes: true, - plan: ({ cwd, fix, files, required }) => - Effect.map(resolveBin(name, cwd), bin => { - if (Option.isNone(bin)) { - return settled(name, required, 'not installed') - } - - const commands = - files === undefined - ? [{ bin: bin.value, args: args(fix) }] - : batches(files).map(batch => ({ bin: bin.value, args: [...args(fix), UNMATCHED_PATHS_OK], files: batch })) - - return { name, status: 'run', commands } - }), - } -} diff --git a/packages/uncheck/src/steps/oxfmt.ts b/packages/uncheck/src/steps/oxfmt.ts deleted file mode 100644 index 64bec98..0000000 --- a/packages/uncheck/src/steps/oxfmt.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { binStep } from '../step' - -export const oxfmt = binStep({ - name: 'oxfmt', - does: 'Check formatting with oxfmt', - args: fix => (fix ? [] : ['--check']), -}) diff --git a/packages/uncheck/src/steps/oxlint.ts b/packages/uncheck/src/steps/oxlint.ts deleted file mode 100644 index b374afc..0000000 --- a/packages/uncheck/src/steps/oxlint.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { binStep } from '../step' - -export const oxlint = binStep({ - name: 'oxlint', - does: 'Lint with oxlint', - args: fix => (fix ? ['--fix'] : []), -}) diff --git a/packages/uncheck/src/steps/tsc/index.ts b/packages/uncheck/src/steps/tsc/index.ts deleted file mode 100644 index bc459d4..0000000 --- a/packages/uncheck/src/steps/tsc/index.ts +++ /dev/null @@ -1,221 +0,0 @@ -import type { PlatformError } from 'effect' -import type { ProjectFiles } from '../../files' -import type { Step, StepCommand, StepPlan } from '../../step' -import type { ChildProcessSpawner } from 'effect/unstable/process' -import { Data, Effect, FileSystem, Graph, Option, Path } from 'effect' -import { resolveBin } from '../../resolve' -import { settled } from '../../step' -import { extensionOf, includesFile, loadTsconfigInputs, readReferences } from './tsconfig' - -export const tsc: Step = { - name: 'tsc', - does: 'Typecheck with tsc', - when: 'a tsconfig.json is found', - fixes: false, - plan: ({ cwd, files, required, projectFiles }) => - Effect.gen(function* () { - const path = yield* Path.Path - const nothingToCheck = (reason: string) => settled('tsc', required, reason) - - const targets = files - ?.map(file => path.resolve(cwd, file)) - .filter(file => CHECKABLE_EXTENSIONS.has(extensionOf(file))) - - if (targets !== undefined && targets.length === 0) { - return nothingToCheck('no tsconfig.json covers the given files') - } - - const [typescript, tsconfigs] = yield* Effect.all( - [resolveBin('typescript', cwd, 'tsc'), discoverTsconfigs(projectFiles, cwd)], - { concurrency: 'unbounded' }, - ) - - if (tsconfigs.length === 0) { - return nothingToCheck('no tsconfig.json found') - } - - const selected = targets === undefined ? tsconfigs : yield* selectTsconfigs(tsconfigs, targets) - - if (selected.length === 0) { - return nothingToCheck('no tsconfig.json covers the given files') - } - - if (Option.isNone(typescript)) { - return { - name: 'tsc', - status: 'failed', - reason: `found ${selected.length} tsconfig.json but typescript is not installed`, - } - } - - const bin = typescript.value - const relative = (configPath: string) => path.relative(cwd, configPath) || '.' - const projects = yield* loadTsProjects(selected) - - return yield* planTypecheck(selected, projects).pipe( - Effect.map((plan): StepPlan => { - const commands: StepCommand[] = [] - - if (plan.build.length > 0) { - commands.push({ bin, args: ['-b', ...plan.build.map(relative)] }) - } - - for (const configPath of plan.check) { - commands.push({ bin, args: ['-p', relative(configPath)] }) - } - - return { name: 'tsc', status: 'run', commands } - }), - Effect.catchTag('CircularProjectReferences', error => - Effect.succeed({ - name: 'tsc', - status: 'failed', - reason: `circular project references between ${error.projects.map(relative).join(', ')}`, - }), - ), - ) - }), -} - -export interface TsProject { - /** Absolute path to the config file. */ - readonly path: string - /** Absolute paths of the config files listed in `references`. */ - readonly references: ReadonlyArray -} - -export interface TypecheckPlan { - /** - * Roots of the project-reference graph (projects nobody references). - * `tsc -b` builds every referenced project first, in dependency order. - */ - readonly build: ReadonlyArray - /** Projects outside the reference graph, checked with `tsc -p` after the build step. */ - readonly check: ReadonlyArray -} - -export class CircularProjectReferences extends Data.TaggedError('CircularProjectReferences')<{ - readonly projects: ReadonlyArray -}> {} - -/** - * Splits the discovered `entries` into what `tsc -b` must build and what `tsc -p` can check on its own. - * - * Every project that has references, or is referenced, belongs to the build graph. - * Only the graph's roots are passed to `tsc -b` since it builds their references transitively. - */ -export function planTypecheck( - entries: ReadonlyArray, - projects: ReadonlyMap, -): Effect.Effect { - const members = new Set() - const referenced = new Set() - - for (const project of projects.values()) { - if (project.references.length === 0) { - continue - } - - members.add(project.path) - - for (const reference of project.references) { - members.add(reference) - referenced.add(reference) - } - } - - const cycle = findCycle([...members].sort(), projects) - - if (Option.isSome(cycle)) { - return Effect.fail(new CircularProjectReferences({ projects: cycle.value })) - } - - return Effect.succeed({ - build: [...members].filter(member => !referenced.has(member)).sort(), - check: entries.filter(entry => !members.has(entry)).sort(), - }) -} - -/** The first cycle among `nodes` following `references`, in visiting order. */ -function findCycle(nodes: ReadonlyArray, projects: ReadonlyMap): Option.Option { - const graph = Graph.directed(mutable => { - const indexes = new Map(nodes.map(node => [node, Graph.addNode(mutable, node)] as const)) - - for (const node of nodes) { - for (const reference of projects.get(node)?.references ?? []) { - Graph.addEdge(mutable, indexes.get(node)!, indexes.get(reference)!, null) - } - } - }) - - return Option.map(Graph.findCycle(graph), ({ path }) => - // The path closes on its first node, drop that repetition. - path.slice(0, -1).map(index => Option.getOrThrow(Graph.getNode(graph, index))), - ) -} - -/** Files that can be inputs of some project; anything else never selects one. */ -const CHECKABLE_EXTENSIONS = new Set(['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs', '.json']) - -/** - * Narrows `entries` to the projects whose `files`/`include`/`exclude` (with `extends` applied) take - * one of the absolute `files` as input, exactly like `tsc` decides. - */ -export function selectTsconfigs( - entries: ReadonlyArray, - files: ReadonlyArray, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.filter( - entries, - entry => Effect.map(loadTsconfigInputs(entry), inputs => files.some(file => includesFile(inputs, file))), - { concurrency: 'unbounded' }, - ).pipe(Effect.map(selected => [...selected].sort())) -} - -/** Parses `entries` and everything they reference, transitively, into a graph keyed by config path. */ -function loadTsProjects( - entries: ReadonlyArray, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const projects = new Map() - const queue = [...entries] - - while (queue.length > 0) { - const configPath = queue.pop()! - - if (projects.has(configPath)) { - continue - } - - const references = yield* readReferences(configPath) - - projects.set(configPath, { path: configPath, references }) - queue.push(...references) - } - - return projects - }) -} - -/** Every `tsconfig.json` in the project, as absolute paths. */ -function discoverTsconfigs( - projectFiles: ProjectFiles, - cwd: string, -): Effect.Effect< - ReadonlyArray, - PlatformError.PlatformError, - FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner -> { - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem - const path = yield* Path.Path - const files = yield* projectFiles - - const candidates = files - .filter(file => path.basename(file) === 'tsconfig.json') - .map(file => path.resolve(cwd, file)) - - // Tracked files can be deleted from the working tree without being staged yet. - return yield* Effect.filter(candidates, candidate => fs.exists(candidate), { concurrency: 'unbounded' }) - }) -} diff --git a/packages/uncheck/src/steps/tsc/tsc.test.ts b/packages/uncheck/src/steps/tsc/tsc.test.ts deleted file mode 100644 index b239f00..0000000 --- a/packages/uncheck/src/steps/tsc/tsc.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import type { TsProject } from './index' -import { join } from 'node:path' -import { NodeFileSystem, NodePath } from '@effect/platform-node' -import { Effect, Layer } from 'effect' -import { fixture } from '../../__tests__/fixture' -import { CircularProjectReferences, planTypecheck, selectTsconfigs } from './index' - -type Graph = Record - -function projects(graph: Graph): ReadonlyMap { - return new Map(Object.entries(graph).map(([config, references]) => [config, { path: config, references }])) -} - -function plan(graph: Graph, entries: string[] = Object.keys(graph)) { - return Effect.runSync(planTypecheck(entries, projects(graph))) -} - -function planError(graph: Graph, entries: string[] = Object.keys(graph)) { - return Effect.runSync(Effect.flip(planTypecheck(entries, projects(graph)))) -} - -describe('planTypecheck', () => { - it('checks standalone projects with tsc -p', () => { - expect( - plan({ - '/repo/tsconfig.json': [], - '/repo/packages/a/tsconfig.json': [], - }), - ).toEqual({ - build: [], - check: ['/repo/packages/a/tsconfig.json', '/repo/tsconfig.json'], - }) - }) - - it('builds only the roots of the reference graph and leaves the rest to tsc -b', () => { - expect( - plan({ - '/repo/tsconfig.json': [], - '/repo/packages/shared/tsconfig.json': [], - '/repo/packages/client/tsconfig.json': ['/repo/packages/shared/tsconfig.json'], - '/repo/packages/server/tsconfig.json': [ - '/repo/packages/client/tsconfig.json', - '/repo/packages/shared/tsconfig.json', - ], - '/repo/packages/nest/tsconfig.json': ['/repo/packages/server/tsconfig.json'], - }), - ).toEqual({ - build: ['/repo/packages/nest/tsconfig.json'], - check: ['/repo/tsconfig.json'], - }) - }) - - it('builds a solution-style root that references non-entry configs', () => { - expect( - plan( - { - '/app/tsconfig.json': ['/app/tsconfig.app.json', '/app/tsconfig.node.json'], - '/app/tsconfig.app.json': [], - '/app/tsconfig.node.json': [], - }, - ['/app/tsconfig.json'], - ), - ).toEqual({ - build: ['/app/tsconfig.json'], - check: [], - }) - }) - - it('keeps a root with references in build mode even when it is not composite', () => { - expect( - plan({ - '/repo/tsconfig.json': ['/repo/packages/a/tsconfig.json', '/repo/packages/b/tsconfig.json'], - '/repo/packages/a/tsconfig.json': [], - '/repo/packages/b/tsconfig.json': ['/repo/packages/a/tsconfig.json'], - }), - ).toEqual({ - build: ['/repo/tsconfig.json'], - check: [], - }) - }) - - it('handles diamonds and multiple roots', () => { - expect( - plan({ - '/r/a/tsconfig.json': [], - '/r/b/tsconfig.json': ['/r/a/tsconfig.json'], - '/r/c/tsconfig.json': ['/r/a/tsconfig.json'], - '/r/d/tsconfig.json': ['/r/b/tsconfig.json', '/r/c/tsconfig.json'], - '/r/e/tsconfig.json': ['/r/a/tsconfig.json'], - }), - ).toEqual({ - build: ['/r/d/tsconfig.json', '/r/e/tsconfig.json'], - check: [], - }) - }) - - it('fails on circular references instead of silently skipping them', () => { - expect( - planError({ - '/r/a/tsconfig.json': ['/r/b/tsconfig.json'], - '/r/b/tsconfig.json': ['/r/a/tsconfig.json'], - '/r/c/tsconfig.json': [], - }), - ).toEqual(new CircularProjectReferences({ projects: ['/r/a/tsconfig.json', '/r/b/tsconfig.json'] })) - }) - - it('reports only the cyclic part when a root also exists', () => { - expect( - planError({ - '/r/a/tsconfig.json': ['/r/b/tsconfig.json'], - '/r/b/tsconfig.json': ['/r/c/tsconfig.json'], - '/r/c/tsconfig.json': ['/r/b/tsconfig.json'], - }).projects, - ).toEqual(['/r/b/tsconfig.json', '/r/c/tsconfig.json']) - }) -}) - -describe('selectTsconfigs', () => { - const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) - const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } - const entries = ['packages/a/tsconfig.json', 'packages/b/tsconfig.json', 'tsconfig.json'] - - function monorepo() { - return fixture( - { - 'tsconfig.base.json': { compilerOptions: { strict: true } }, - 'tsconfig.json': { include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'] }, - 'packages/a/tsconfig.json': lib, - 'packages/b/tsconfig.json': lib, - }, - [], - ) - } - - function select(dir: string, files: string[]) { - return Effect.runPromise( - Effect.provide( - selectTsconfigs( - entries.map(entry => join(dir, entry)), - files.map(file => join(dir, file)), - ), - layer, - ), - ).then(selected => selected.map(entry => entry.slice(dir.length + 1))) - } - - it('selects only the projects whose inputs contain a file', async () => { - const dir = monorepo() - - expect(await select(dir, ['packages/a/src/index.ts'])).toEqual(['packages/a/tsconfig.json']) - // Tests are excluded by the package and picked up by the root instead. - expect(await select(dir, ['packages/a/src/index.test.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, ['packages/a/build.config.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, ['scripts/release.ts'])).toEqual(['tsconfig.json']) - expect(await select(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ - 'packages/a/tsconfig.json', - 'packages/b/tsconfig.json', - ]) - expect(await select(dir, ['README.md', 'packages/a/styles.css'])).toEqual([]) - }) -}) diff --git a/packages/uncheck/src/steps/tsc/tsconfig.test.ts b/packages/uncheck/src/steps/tsc/tsconfig.test.ts deleted file mode 100644 index 21db2df..0000000 --- a/packages/uncheck/src/steps/tsc/tsconfig.test.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { join } from 'node:path' -import { NodeFileSystem, NodePath } from '@effect/platform-node' -import { Effect, Layer } from 'effect' -import { fixture } from '../../__tests__/fixture' -import { includesFile, loadTsconfigInputs } from './tsconfig' - -const layer = Layer.mergeAll(NodeFileSystem.layer, NodePath.layer) - -function inputs(configPath: string) { - return Effect.runPromise(Effect.provide(loadTsconfigInputs(configPath), layer)) -} - -describe('loadTsconfigInputs', () => { - it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { - const dir = fixture( - { 'tsconfig.json': { include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], exclude: ['src/legacy'] } }, - [], - ) - - const project = await inputs(join(dir, 'tsconfig.json')) - const has = (file: string) => includesFile(project, join(dir, file)) - - expect(has('src/index.ts')).toBe(true) - expect(has('src/deep/nested/component.tsx')).toBe(true) - expect(has('src/types.d.ts')).toBe(true) - expect(has('src/index.js')).toBe(false) - expect(has('src/README.md')).toBe(false) - expect(has('src/.hidden/index.ts')).toBe(false) - expect(has('src/node_modules/dep/index.ts')).toBe(false) - expect(has('src/legacy/old.ts')).toBe(false) - expect(has('scripts/build.ts')).toBe(true) - expect(has('scripts/nested/deep/build.ts')).toBe(false) - expect(has('config/app.json')).toBe(true) - expect(has('src/data.json')).toBe(false) - expect(has('packages/app/build.config.ts')).toBe(true) - expect(has('packages/app/src/index.ts')).toBe(false) - expect(has('node_modules/dep/index.ts')).toBe(false) - }) - - it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { - const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }, []) - - const project = await inputs(join(dir, 'tsconfig.json')) - const has = (file: string) => includesFile(project, join(dir, file)) - - expect(has('index.ts')).toBe(true) - expect(has('deep/index.ts')).toBe(true) - expect(has('dist/index.d.ts')).toBe(false) - expect(has('node_modules/dep/index.d.ts')).toBe(false) - expect(has('.git/hooks/pre-commit.ts')).toBe(false) - }) - - it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { - const dir = fixture( - { - 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, - 'node_modules/@shared/tsconfig/base.json': { - compilerOptions: { allowJs: true }, - include: ['${configDir}/lib'], - exclude: ['${configDir}/lib/vendor'], - }, - 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, - 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, - 'packages/a/tsconfig.files.json': { files: ['entry.ts'], compilerOptions: { allowJs: false } }, - 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, - }, - [], - ) - - const a = await inputs(join(dir, 'packages/a/tsconfig.json')) - const b = await inputs(join(dir, 'packages/b/tsconfig.json')) - - // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. - expect(includesFile(a, join(dir, 'packages/a/entry.ts'))).toBe(true) - expect(includesFile(a, join(dir, 'src/index.ts'))).toBe(true) - expect(includesFile(a, join(dir, 'packages/a/src/index.ts'))).toBe(false) - expect(includesFile(a, join(dir, 'src/index.js'))).toBe(false) - // `exclude` is still the base's, with `${configDir}` pointing at the leaf. - expect(includesFile(a, join(dir, 'packages/a/lib/vendor/x.ts'))).toBe(false) - - expect(includesFile(b, join(dir, 'packages/b/lib/util.ts'))).toBe(true) - expect(includesFile(b, join(dir, 'packages/b/lib/util.js'))).toBe(true) - expect(includesFile(b, join(dir, 'packages/b/lib/vendor/x.ts'))).toBe(false) - expect(includesFile(b, join(dir, 'packages/b/src/index.ts'))).toBe(false) - }) -}) diff --git a/packages/uncheck/src/steps/tsc/tsconfig.ts b/packages/uncheck/src/steps/tsc/tsconfig.ts deleted file mode 100644 index f2e9417..0000000 --- a/packages/uncheck/src/steps/tsc/tsconfig.ts +++ /dev/null @@ -1,340 +0,0 @@ -import { posix } from 'node:path' -import process from 'node:process' -import { Effect, FileSystem, Option, Path, Predicate } from 'effect' -import { parse as parseJsonc } from 'jsonc-parser' -import { ancestors, readJson } from '../../resolve' - -interface RawTsconfig { - readonly extends?: unknown - readonly files?: unknown - readonly include?: unknown - readonly exclude?: unknown - readonly references?: unknown - readonly compilerOptions?: unknown -} - -/** Parses a config file as JSONC. Unreadable or malformed files read as empty and are left for `tsc` to report. */ -function readTsconfig(configPath: string): Effect.Effect { - return Effect.flatMap(FileSystem.FileSystem, fs => - fs.readFileString(configPath).pipe( - Effect.map(text => parseJsonc(text, undefined, { allowTrailingComma: true }) as unknown), - Effect.map((value): RawTsconfig => (Predicate.isObject(value) ? value : {})), - Effect.orElseSucceed((): RawTsconfig => ({})), - ), - ) -} - -/** - * The `references` of one config file, resolved like `tsc` does: relative to the config's directory, - * and a path without a `.json` extension means that folder's `tsconfig.json`. - * `references` are never inherited through `extends`, so the file itself is all that matters. - */ -export function readReferences( - configPath: string, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - const path = yield* Path.Path - const configDir = path.dirname(configPath) - const { references } = yield* readTsconfig(configPath) - - return (Array.isArray(references) ? references : []).flatMap((reference: unknown) => { - if (!Predicate.isObject(reference) || typeof reference.path !== 'string') { - return [] - } - - const target = path.resolve(configDir, reference.path.replaceAll('${configDir}', configDir)) - - return [target.endsWith('.json') ? target : path.join(target, 'tsconfig.json')] - }) - }) -} - -export interface InputPattern { - /** Absolute pattern with forward slashes. */ - readonly spec: string - readonly regex: RegExp -} - -/** What `tsc` would take as input files for one project, with `extends` applied. */ -export interface TsconfigInputs { - readonly dir: string - /** Absolute paths listed in `files`, always included. */ - readonly files: ReadonlyArray - readonly include: ReadonlyArray - readonly exclude: ReadonlyArray - /** Extensions `tsc` picks up through `include`, JSON aside. */ - readonly extensions: ReadonlySet -} - -const TS_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts'] -const JS_EXTENSIONS = ['.js', '.jsx', '.mjs', '.cjs'] -const DEFAULT_EXCLUDES = ['node_modules', 'bower_components', 'jspm_packages'] - -interface Specs { - /** Directory of the config that defined the specs, which relative specs resolve against. */ - readonly dir: string - readonly specs: ReadonlyArray -} - -/** - * Resolves `files`, `include`, `exclude` and the relevant compiler options of a config the way - * `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. - */ -export function loadTsconfigInputs( - configPath: string, -): Effect.Effect { - return Effect.gen(function* () { - const path = yield* Path.Path - const chain = yield* loadExtendsChain(configPath, new Set()) - const leafDir = path.dirname(configPath) - - let files: Specs | undefined - let include: Specs | undefined - let exclude: Specs | undefined - let outDir: Specs | undefined - let allowJs = false - - for (const { dir, raw } of chain) { - if (isStringArray(raw.files)) { - files = { dir, specs: raw.files } - } - - if (isStringArray(raw.include)) { - include = { dir, specs: raw.include } - } - - if (isStringArray(raw.exclude)) { - exclude = { dir, specs: raw.exclude } - } - - if (Predicate.isObject(raw.compilerOptions)) { - const { compilerOptions } = raw - - if (typeof compilerOptions.allowJs === 'boolean') { - allowJs = compilerOptions.allowJs - } - - if (compilerOptions.checkJs === true) { - allowJs = true - } - - if (typeof compilerOptions.outDir === 'string') { - outDir = { dir, specs: [compilerOptions.outDir] } - } - } - } - - const resolve = (specs: Specs | undefined): string[] => - specs?.specs.map(spec => toPosix(path.resolve(specs.dir, spec.replaceAll('${configDir}', leafDir)))) ?? [] - - const includeSpecs = include === undefined && files === undefined ? [`${toPosix(leafDir)}/**/*`] : resolve(include) - - const excludeSpecs = - exclude === undefined - ? [...DEFAULT_EXCLUDES.map(name => `${toPosix(leafDir)}/${name}`), ...resolve(outDir)] - : resolve(exclude) - - return { - dir: leafDir, - files: resolve(files), - include: includeSpecs.flatMap(spec => { - const regex = compileGlob(spec, 'files') - return regex === undefined ? [] : [{ spec, regex }] - }), - exclude: excludeSpecs.map(spec => ({ spec, regex: compileGlob(spec, 'exclude')! })), - extensions: new Set(allowJs ? [...TS_EXTENSIONS, ...JS_EXTENSIONS] : TS_EXTENSIONS), - } - }) -} - -/** Whether `tsc -p` on this config would take `file` as an input. */ -export function includesFile(inputs: TsconfigInputs, file: string): boolean { - const target = toPosix(file) - - if (inputs.files.includes(target)) { - return true - } - - const extension = extensionOf(target) - const json = extension === '.json' - - if (!json && !inputs.extensions.has(extension)) { - return false - } - - if (inputs.exclude.some(pattern => pattern.regex.test(target))) { - return false - } - - // JSON files only come in through an `include` that names the extension explicitly. - return inputs.include.some(pattern => pattern.regex.test(target) && (!json || pattern.spec.endsWith('.json'))) -} - -interface ChainEntry { - readonly dir: string - readonly raw: RawTsconfig -} - -/** Base configs first, the config itself last, so later entries override earlier ones. */ -function loadExtendsChain( - configPath: string, - visited: Set, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - if (visited.has(configPath)) { - return [] - } - - visited.add(configPath) - - 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, dir).pipe( - Effect.flatMap( - Option.match({ - onNone: () => Effect.succeed>([]), - onSome: base => loadExtendsChain(base, visited), - }), - ), - ), - ) - - return [...bases.flat(), { dir, raw }] - }) -} - -/** Resolves an `extends` entry: a relative path (with an optional `.json`), or a package like `tsc` looks it up. */ -function resolveExtends( - spec: string, - dir: string, -): Effect.Effect, never, FileSystem.FileSystem | Path.Path> { - return Effect.gen(function* () { - 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`]) - } - - for (const current of ancestors(path, dir)) { - const base = path.join(current, 'node_modules', spec) - const baseKind = yield* kind(base) - - if (baseKind === 'File') { - return Option.some(base) - } - - const candidates = [`${base}.json`] - - if (baseKind === 'Directory') { - const manifest = yield* readJson<{ tsconfig?: unknown }>(path.join(base, 'package.json')) - candidates.push( - path.resolve(base, typeof manifest?.tsconfig === 'string' ? manifest.tsconfig : 'tsconfig.json'), - ) - } - - const found = yield* firstFile(candidates) - - if (Option.isSome(found)) { - return found - } - } - - return Option.none() - }) -} - -const IMPLICIT_EXCLUDE = '(?!(?:node_modules|bower_components|jspm_packages)(?:/|$))' -const FILES_DOUBLE_ASTERISK = `(?:/${IMPLICIT_EXCLUDE}[^/.][^/]*)*?` -const EXCLUDE_DOUBLE_ASTERISK = '(?:/.+?)?' -const CASE_INSENSITIVE = process.platform === 'win32' || process.platform === 'darwin' - -/** - * Turns an absolute `include` or `exclude` pattern into the regular expression `tsc` uses for it: - * `*` and `?` never cross a directory, a leading wildcard never matches a dot file, `**` skips - * `node_modules` and dot folders, and a pattern without extension or wildcard means the whole folder. - * `exclude` patterns also match every path below them. - */ -function compileGlob(pattern: string, usage: 'files' | 'exclude'): RegExp | undefined { - const components = pattern.replace(/\/+$/, '').split('/') - const last = components[components.length - 1]! - - if (usage === 'files' && last === '**') { - return undefined - } - - if (!/[*?.]/.test(last)) { - components.push('**', '*') - } - - let source = '' - let written = false - - for (const component of components) { - if (component === '**') { - source += usage === 'files' ? FILES_DOUBLE_ASTERISK : EXCLUDE_DOUBLE_ASTERISK - } else { - if (written) { - source += '/' - } - - source += usage === 'files' ? filesComponent(component) : wildcards(component) - } - - written = true - } - - return new RegExp(`^${source}${usage === 'exclude' ? '(?:$|/)' : '$'}`, CASE_INSENSITIVE ? 'i' : '') -} - -function filesComponent(component: string): string { - let source = '' - let rest = component - - if (rest.startsWith('*')) { - source += '(?:[^./][^/]*)?' - rest = rest.slice(1) - } else if (rest.startsWith('?')) { - source += '[^./]' - rest = rest.slice(1) - } - - source += wildcards(rest) - - return /[*?]/.test(component) ? IMPLICIT_EXCLUDE + source : source -} - -/** Escapes a path component for a regular expression, mapping `*` and `?` to their glob meaning. */ -function wildcards(component: string): string { - return component.replace(/[.*?+^${}()|[\]\\]/g, char => - char === '*' ? '[^/]*' : char === '?' ? '[^/]' : `\\${char}`, - ) -} - -/** The lower-cased extension of a file, `''` when there is none. */ -export function extensionOf(file: string): string { - return posix.extname(file).toLowerCase() -} - -function toPosix(target: string): string { - return target.replaceAll('\\', '/') -} - -function isStringArray(value: unknown): value is ReadonlyArray { - return Array.isArray(value) && value.every(item => typeof item === 'string') -} diff --git a/packages/uncheck/src/ui.ts b/packages/uncheck/src/style.ts similarity index 62% rename from packages/uncheck/src/ui.ts rename to packages/uncheck/src/style.ts index a5c48d0..43cbbfa 100644 --- a/packages/uncheck/src/ui.ts +++ b/packages/uncheck/src/style.ts @@ -1,8 +1,6 @@ import process from 'node:process' import { styleText } from 'node:util' -import { Console } from 'effect' -/** Whether output gets ANSI colors: `NO_COLOR` wins, then `FORCE_COLOR`, then whether stdout is a terminal. */ export const colors = !('NO_COLOR' in process.env) && ((process.env.FORCE_COLOR !== undefined && process.env.FORCE_COLOR !== '0') || process.stdout.isTTY === true) @@ -14,8 +12,3 @@ export const bold = paint('bold') export const dim = paint('dim') export const red = paint('red') export const green = paint('green') - -/** Prints one line through the `Console` service, which hook mode and tests swap out to capture it. */ -export function line(text: string) { - return Console.log(text) -} diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts new file mode 100644 index 0000000..8e85997 --- /dev/null +++ b/packages/uncheck/src/tool.ts @@ -0,0 +1,56 @@ +import { Effect, Path, Predicate } from 'effect' +import { ancestors, readJson } from './files' + +export interface Bin { + readonly name: string + readonly entry: string +} + +/** + * Locates the `binName` executable of `pkg` the way Node resolves packages from `cwd`: the nearest + * `node_modules/`. Reading its manifest directly (instead of `require.resolve`) keeps this + * independent from the package's `exports` map. + */ +export const resolveBin = Effect.fn(function* (pkg: string, cwd: string, binName: string = pkg) { + const path = yield* Path.Path + + for (const dir of ancestors(path, cwd)) { + const pkgDir = path.join(dir, 'node_modules', pkg) + const manifest = yield* readJson(path.join(pkgDir, 'package.json')) + + if (manifest === undefined) { + continue + } + + const bin = + typeof manifest.bin === 'string' + ? manifest.bin + : Predicate.isObject(manifest.bin) + ? manifest.bin[binName] + : undefined + + return typeof bin === 'string' ? { name: binName, entry: path.resolve(pkgDir, bin) } : undefined + } + + return undefined +}) + +/** Command lines stay well below every platform's argument limit. */ +const MAX_ARGV_LENGTH = 65_536 + +export function argvBatches(args: ReadonlyArray): ReadonlyArray> { + const batches: string[][] = [[]] + let length = 0 + + for (const arg of args) { + if (length + arg.length + 1 > MAX_ARGV_LENGTH) { + batches.push([]) + length = 0 + } + + batches[batches.length - 1]!.push(arg) + length += arg.length + 1 + } + + return batches +} diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts new file mode 100644 index 0000000..f343d5d --- /dev/null +++ b/packages/uncheck/src/types.ts @@ -0,0 +1,48 @@ +import type { PlatformError } from 'effect' +import type { Effect, FileSystem, Path } from 'effect' +import type { ChildProcessSpawner } from 'effect/unstable/process' +import type { CannotCheck, NothingToCheck } from './errors' +import type { ProjectFiles } from './files' +import type { Bin } from './tool' + +export type CheckName = 'oxlint' | 'oxfmt' | 'tsc' + +export interface CheckOutcome { + readonly name: CheckName + readonly status: 'passed' | 'failed' | 'skipped' + readonly reason?: string +} + +export interface CheckCommand { + readonly bin: Bin + readonly args: ReadonlyArray + readonly files?: ReadonlyArray +} + +export interface CheckInput { + readonly cwd: string + readonly fix: boolean + /** Files to check, relative to `cwd`, or `undefined` for everything under it. */ + readonly files: ReadonlyArray | undefined + readonly projectFiles: ProjectFiles +} + +export interface Check { + readonly name: CheckName + readonly fixes: boolean + readonly plan: ( + input: CheckInput, + ) => Effect.Effect< + ReadonlyArray, + NothingToCheck | CannotCheck | PlatformError.PlatformError, + FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner + > +} + +export interface RunSettings { + readonly cwd: string + readonly fix: boolean + readonly required: ReadonlyArray + readonly skipped: ReadonlyArray + readonly allowUnmatched: boolean +} diff --git a/packages/uncheck/src/command.test.ts b/packages/uncheck/tests/command.test.ts similarity index 88% rename from packages/uncheck/src/command.test.ts rename to packages/uncheck/tests/command.test.ts index fec6512..0d6cf21 100644 --- a/packages/uncheck/src/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -5,8 +5,9 @@ import { stripVTControlCharacters } from 'node:util' import { NodeServices } from '@effect/platform-node' import { Console, Effect, Stdio, Stream } from 'effect' import { Command } from 'effect/unstable/cli' -import { fixture } from './__tests__/fixture' -import { CheckFailed, command, UncheckOptions } from './index' +import { fixture } from './fixture' +import { uncheck } from '../src/commands/uncheck' +import { CheckFailed } from '../src/errors' interface RunResult { readonly result: 'ok' | 'blocked' | CheckFailed @@ -15,7 +16,10 @@ interface RunResult { readonly stderr: string } +/** Runs the CLI in `cwd`; the flag goes after the subcommand names so it lands on the command that runs. */ async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): Promise { + const verbs = args[0] === 'hooks' ? 2 : 0 + const argv = [...args.slice(0, verbs), '--cwd', cwd, ...args.slice(verbs)] const stdout: string[] = [] const stderr: string[] = [] @@ -29,11 +33,10 @@ async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): P }) const result = await Effect.runPromise( - Command.runWith(command, { version: '0.0.0' })(args).pipe( + Command.runWith(uncheck, { version: '0.0.0' })(argv).pipe( Effect.map(() => 'ok' as const), Effect.catchTag('CheckFailed', error => Effect.succeed(error)), Effect.catchTag('StopBlocked', () => Effect.succeed('blocked' as const)), - Effect.provideService(UncheckOptions, { cwd }), Effect.provideService(Console.Console, capture), Effect.provide(Stdio.layerTest({ stdin: Stream.make(new TextEncoder().encode(stdin)) })), Effect.provide(NodeServices.layer), @@ -162,8 +165,12 @@ describe('uncheck', { timeout: 120_000 }, () => { expect(scoped.result).toBe('ok') expect(scoped.stdout).not.toContain('no-var') - expect(scoped.stdout).toContain('ā–¶ oxlint packages/app/src/index.ts packages/app/tsconfig.json\n') - expect(scoped.stdout).toContain('ā–¶ oxfmt --check packages/app/src/index.ts packages/app/tsconfig.json\n') + expect(scoped.stdout).toContain( + 'ā–¶ oxlint --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', + ) + expect(scoped.stdout).toContain( + 'ā–¶ oxfmt --check --no-error-on-unmatched-pattern packages/app/src/index.ts packages/app/tsconfig.json\n', + ) // The root project's inputs (src, scripts) do not reach into packages/app, so only app is built. expect(scoped.stdout).toContain('ā–¶ tsc -b packages/app/tsconfig.json\nāœ” tsc') expect(scoped.stdout).not.toContain('tsc -p tsconfig.json') @@ -171,20 +178,20 @@ describe('uncheck', { timeout: 120_000 }, () => { const single = await run(dir, ['--fix', 'scripts/hello.ts']) expect(single.result).toBe('ok') - expect(single.stdout).toContain('ā–¶ oxlint --fix scripts/hello.ts\n') - expect(single.stdout).toContain('ā–¶ oxfmt scripts/hello.ts\n') + expect(single.stdout).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern scripts/hello.ts\n') + expect(single.stdout).toContain('ā–¶ oxfmt --no-error-on-unmatched-pattern scripts/hello.ts\n') expect(single.stdout).toContain('ā–¶ tsc -p tsconfig.json\n') expect(single.stdout).not.toContain('tsc -b') const all = await run(dir, ['.']) - expect(all.stdout).toMatch(/ā–¶ oxlint \[\d+ files\]\n/) - expect(all.stdout).toMatch(/ā–¶ oxfmt --check \[\d+ files\]\n/) + expect(all.stdout).toMatch(/ā–¶ oxlint --no-error-on-unmatched-pattern \[\d+ files\]\n/) + expect(all.stdout).toMatch(/ā–¶ oxfmt --check --no-error-on-unmatched-pattern \[\d+ files\]\n/) const docs = await run(dir, ['README.md']) expect(docs.result).toBe('ok') - expect(docs.stdout).toContain('ā–¶ oxlint README.md\n') + expect(docs.stdout).toContain('ā–¶ oxlint --no-error-on-unmatched-pattern README.md\n') expect(docs.stdout).toContain('ā—‹ tsc skipped, no tsconfig.json covers the given files\n') expect(docs.stdout).toContain('āœ” all checks passed (oxlint, oxfmt)') }) @@ -203,7 +210,7 @@ describe('uncheck', { timeout: 120_000 }, () => { const lenient = await run(dir, ['--no-error-on-unmatched-pattern', 'src/index.ts', 'missing.ts']) expect(lenient.result).toBe('ok') - expect(lenient.stdout).toContain('ā–¶ oxlint src/index.ts\n') + expect(lenient.stdout).toContain('ā–¶ oxlint --no-error-on-unmatched-pattern src/index.ts\n') const nothing = await run(dir, ['--no-error-on-unmatched-pattern', 'missing.ts']) @@ -276,8 +283,8 @@ describe('uncheck', { timeout: 120_000 }, () => { }) }) -describe('uncheck step flags', { timeout: 120_000 }, () => { - it('skips a step with --=false and requires it with --', async () => { +describe('uncheck check flags', { timeout: 120_000 }, () => { + it('skips a check with --skip and requires it with --require', async () => { const dir = fixture( { '.oxlintrc.json': oxlintrc, @@ -287,24 +294,26 @@ describe('uncheck step flags', { timeout: 120_000 }, () => { ['oxlint', 'typescript'], ) - const skipped = await run(dir, ['--tsc=false']) + const skipped = await run(dir, ['--skip=tsc']) expect(skipped.result).toBe('ok') expect(skipped.stdout).toContain('ā—‹ oxfmt skipped, not installed\n') - expect(skipped.stdout).toContain('ā—‹ tsc skipped, disabled with --tsc=false\n') + expect(skipped.stdout).toContain('ā—‹ tsc skipped, disabled with --skip=tsc\n') expect(skipped.stdout).toContain('āœ” all checks passed (oxlint)') - const required = await run(dir, ['--oxfmt', '--tsc=false']) + const required = await run(dir, ['--require=oxfmt', '--skip=tsc']) expect(required.result).toBeInstanceOf(CheckFailed) expect(required.stdout).toContain('✘ oxfmt not installed\n') expect(required.stdout).toContain('✘ 1 of 2 checks failed: oxfmt') - const none = await run(dir, ['--no-oxlint', '--no-oxfmt', '--no-tsc']) + await expect(run(dir, ['--require=tsc', '--skip=tsc'])).rejects.toThrow(/--require=tsc and --skip=tsc/) + + const none = await run(dir, ['--skip=oxlint', '--skip=oxfmt', '--skip=tsc']) expect(none.result).toBeInstanceOf(CheckFailed) expect(none.stdout).toContain( - 'nothing to check: oxlint disabled with --oxlint=false, oxfmt disabled with --oxfmt=false, tsc disabled with --tsc=false', + 'nothing to check: oxlint disabled with --skip=oxlint, oxfmt disabled with --skip=oxfmt, tsc disabled with --skip=tsc', ) }) }) @@ -414,7 +423,7 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { expect(claude.result).toBe('blocked') expect(claude.stdout).toBe('') - expect(claude.stderr).toContain('ā–¶ oxlint --fix src/fresh.ts src/index.ts\n') + expect(claude.stderr).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern src/fresh.ts src/index.ts\n') expect(claude.stderr).toContain('ā–¶ tsc -p tsconfig.json\n') expect(claude.stderr).toContain('TS2322') expect(claude.stderr).toContain('✘ 1 of 3 checks failed: tsc') @@ -470,11 +479,11 @@ describe('uncheck hooks run', { timeout: 120_000 }, () => { expect(ok.result).toBe('ok') expect(ok.stdout).toBe('') - expect(ok.stderr).toContain('ā–¶ oxlint --fix src/other.ts\n') + expect(ok.stderr).toContain('ā–¶ oxlint --fix --no-error-on-unmatched-pattern src/other.ts\n') expect(ok.stderr).toContain('āœ” all checks passed (oxlint, oxfmt, tsc)') }) - it('checks the whole project outside a git repository', async () => { + it('checks everything under the directory outside a git repository', async () => { const dir = fixture(clean) const { result, stderr } = await run(dir, ['hooks', 'run'], '{}') diff --git a/packages/uncheck/src/files.test.ts b/packages/uncheck/tests/files.test.ts similarity index 95% rename from packages/uncheck/src/files.test.ts rename to packages/uncheck/tests/files.test.ts index aeece18..c446e03 100644 --- a/packages/uncheck/src/files.test.ts +++ b/packages/uncheck/tests/files.test.ts @@ -1,8 +1,8 @@ import { execFileSync } from 'node:child_process' import { NodeServices } from '@effect/platform-node' import { Effect } from 'effect' -import { fixture } from './__tests__/fixture' -import { listProjectFiles, resolvePaths } from './files' +import { fixture } from './fixture' +import { listProjectFiles, resolvePaths } from '../src/files' const project = { '.gitignore': 'node_modules\ndist\n', diff --git a/packages/uncheck/src/__tests__/fixture.ts b/packages/uncheck/tests/fixture.ts similarity index 89% rename from packages/uncheck/src/__tests__/fixture.ts rename to packages/uncheck/tests/fixture.ts index d1c4a22..bd337de 100644 --- a/packages/uncheck/src/__tests__/fixture.ts +++ b/packages/uncheck/tests/fixture.ts @@ -5,7 +5,7 @@ import { fileURLToPath } from 'node:url' export const TOOLS = ['oxlint', 'oxfmt', 'typescript'] as const -const packageNodeModules = fileURLToPath(new URL('../../node_modules', import.meta.url)) +const packageNodeModules = fileURLToPath(new URL('../node_modules', import.meta.url)) const fixtures: string[] = [] afterAll(() => { @@ -17,7 +17,7 @@ afterAll(() => { /** * Creates a throwaway project in the OS temp dir. Objects are written as JSON, the requested tools * are symlinked into `node_modules` so they resolve like a regular install, and config files are - * kept out of the format step so tests only see the formatting issues they put in. + * kept out of the format check so tests only see the formatting issues they put in. */ export function fixture( files: Record, diff --git a/packages/uncheck/tests/tsc.test.ts b/packages/uncheck/tests/tsc.test.ts new file mode 100644 index 0000000..0692719 --- /dev/null +++ b/packages/uncheck/tests/tsc.test.ts @@ -0,0 +1,179 @@ +import { NodeServices } from '@effect/platform-node' +import { Effect } from 'effect' +import { tsc } from '../src/checks/tsc' +import { listProjectFiles } from '../src/files' +import { fixture } from './fixture' + +/** Plans the tsc check in `dir`: the tsc command lines it would run, or the reason it does not run. */ +function plan(dir: string, files?: string[]): Promise { + return Effect.runPromise( + Effect.provide( + tsc.plan({ cwd: dir, fix: false, files, projectFiles: listProjectFiles(dir) }).pipe( + Effect.map(commands => commands.map(command => command.args.join(' '))), + Effect.catchTag('NothingToCheck', error => Effect.succeed(error.reason)), + Effect.catchTag('CannotCheck', error => Effect.succeed(error.reason)), + ), + NodeServices.layer, + ), + ) +} + +const NOT_COVERED = 'no tsconfig.json covers the given files' + +describe('tsc project references', () => { + it('checks standalone projects with tsc -p', async () => { + const dir = fixture({ 'tsconfig.json': {}, 'packages/a/tsconfig.json': {} }) + + expect(await plan(dir)).toEqual(['-p packages/a/tsconfig.json', '-p tsconfig.json']) + }) + + it('builds only the roots of the reference graph and leaves the rest to tsc -b', async () => { + const dir = fixture({ + 'tsconfig.json': {}, + 'packages/shared/tsconfig.json': {}, + 'packages/client/tsconfig.json': { references: [{ path: '../shared' }] }, + 'packages/server/tsconfig.json': { references: [{ path: '../client' }, { path: '../shared' }] }, + 'packages/nest/tsconfig.json': { references: [{ path: '../server/tsconfig.json' }] }, + }) + + expect(await plan(dir)).toEqual(['-b packages/nest/tsconfig.json', '-p tsconfig.json']) + }) + + it('builds a solution-style root that references configs not named tsconfig.json', async () => { + const dir = fixture({ + 'tsconfig.json': { references: [{ path: './tsconfig.app.json' }, { path: './tsconfig.node.json' }] }, + 'tsconfig.app.json': {}, + 'tsconfig.node.json': {}, + }) + + expect(await plan(dir)).toEqual(['-b tsconfig.json']) + }) + + it('keeps a root with references in build mode even when it is not composite', async () => { + const dir = fixture({ + 'tsconfig.json': { references: [{ path: './packages/a' }, { path: './packages/b' }] }, + 'packages/a/tsconfig.json': {}, + 'packages/b/tsconfig.json': { references: [{ path: '../a' }] }, + }) + + expect(await plan(dir)).toEqual(['-b tsconfig.json']) + }) + + it('handles diamonds and multiple roots', async () => { + const dir = fixture({ + 'a/tsconfig.json': {}, + 'b/tsconfig.json': { references: [{ path: '../a' }] }, + 'c/tsconfig.json': { references: [{ path: '../a' }] }, + 'd/tsconfig.json': { references: [{ path: '../b' }, { path: '../c' }] }, + 'e/tsconfig.json': { references: [{ path: '../a' }] }, + }) + + expect(await plan(dir)).toEqual(['-b d/tsconfig.json e/tsconfig.json']) + }) + + it('fails on circular references instead of silently skipping them', async () => { + const dir = fixture({ + 'a/tsconfig.json': { references: [{ path: '../b' }] }, + 'b/tsconfig.json': { references: [{ path: '../a' }] }, + 'c/tsconfig.json': {}, + }) + + expect(await plan(dir)).toBe('circular project references between a/tsconfig.json, b/tsconfig.json') + }) + + it('reports only the cyclic part when a root also exists', async () => { + const dir = fixture({ + 'a/tsconfig.json': { references: [{ path: '../b' }] }, + 'b/tsconfig.json': { references: [{ path: '../c' }] }, + 'c/tsconfig.json': { references: [{ path: '../b' }] }, + }) + + expect(await plan(dir)).toBe('circular project references between b/tsconfig.json, c/tsconfig.json') + }) +}) + +describe('tsc project selection', () => { + it('matches files like tsc: include folders, wildcards, extensions and default excludes', async () => { + const dir = fixture({ + 'tsconfig.json': { include: ['src', 'scripts/*.ts', 'config/*.json', '*/*/*'], exclude: ['src/legacy'] }, + }) + const covers = (file: string) => plan(dir, [file]).then(Array.isArray) + + expect(await covers('src/index.ts')).toBe(true) + expect(await covers('src/deep/nested/component.tsx')).toBe(true) + expect(await covers('src/types.d.ts')).toBe(true) + expect(await covers('src/index.js')).toBe(false) + expect(await covers('src/README.md')).toBe(false) + expect(await covers('src/.hidden/index.ts')).toBe(false) + expect(await covers('src/node_modules/dep/index.ts')).toBe(false) + expect(await covers('src/legacy/old.ts')).toBe(false) + expect(await covers('scripts/build.ts')).toBe(true) + expect(await covers('scripts/nested/deep/build.ts')).toBe(false) + expect(await covers('config/app.json')).toBe(true) + expect(await covers('src/data.json')).toBe(false) + expect(await covers('packages/app/build.config.ts')).toBe(true) + expect(await covers('packages/app/src/index.ts')).toBe(false) + expect(await covers('node_modules/dep/index.ts')).toBe(false) + }) + + it('falls back to everything, minus node_modules and outDir, when nothing is configured', async () => { + const dir = fixture({ 'tsconfig.json': { compilerOptions: { outDir: 'dist' } } }) + const covers = (file: string) => plan(dir, [file]).then(Array.isArray) + + expect(await covers('index.ts')).toBe(true) + expect(await covers('deep/index.ts')).toBe(true) + expect(await covers('dist/index.d.ts')).toBe(false) + expect(await covers('node_modules/dep/index.d.ts')).toBe(false) + expect(await covers('.git/hooks/pre-commit.ts')).toBe(false) + }) + + it('inherits files, include, exclude and allowJs through extends, relative to the defining config', async () => { + const dir = fixture({ + 'node_modules/@shared/tsconfig/package.json': { tsconfig: './base.json' }, + 'node_modules/@shared/tsconfig/base.json': { + compilerOptions: { allowJs: true }, + include: ['${configDir}/lib'], + exclude: ['${configDir}/lib/vendor'], + }, + 'tsconfig.lib.json': { extends: '@shared/tsconfig', include: ['src'] }, + 'packages/a/tsconfig.json': { extends: ['../../tsconfig.lib', './tsconfig.files.json'] }, + 'packages/a/tsconfig.files.json': { files: ['entry.ts'], compilerOptions: { allowJs: false } }, + 'packages/b/tsconfig.json': { extends: '@shared/tsconfig' }, + }) + const a = ['-p packages/a/tsconfig.json'] + const b = ['-p packages/b/tsconfig.json'] + + // `files` comes from the last extends entry, `include` from tsconfig.lib.json and resolves next to it. + expect(await plan(dir, ['packages/a/entry.ts'])).toEqual(a) + expect(await plan(dir, ['src/index.ts'])).toEqual(a) + expect(await plan(dir, ['packages/a/src/index.ts'])).toBe(NOT_COVERED) + expect(await plan(dir, ['src/index.js'])).toBe(NOT_COVERED) + + // The base's `${configDir}` include and exclude point at the leaf config's folder. + expect(await plan(dir, ['packages/b/lib/util.ts'])).toEqual(b) + expect(await plan(dir, ['packages/b/lib/util.js'])).toEqual(b) + expect(await plan(dir, ['packages/b/lib/vendor/x.ts'])).toBe(NOT_COVERED) + expect(await plan(dir, ['packages/b/src/index.ts'])).toBe(NOT_COVERED) + }) + + it('selects only the projects whose inputs contain a given file', async () => { + const lib = { extends: '../../tsconfig.base.json', include: ['src'], exclude: ['**/*.test.*'] } + const dir = fixture({ + 'tsconfig.base.json': { compilerOptions: { strict: true } }, + 'tsconfig.json': { include: ['scripts', '*', '*/*/src/**/*.test.*', '*/*/*'] }, + 'packages/a/tsconfig.json': lib, + 'packages/b/tsconfig.json': lib, + }) + + expect(await plan(dir, ['packages/a/src/index.ts'])).toEqual(['-p packages/a/tsconfig.json']) + // Tests are excluded by the package and picked up by the root instead. + expect(await plan(dir, ['packages/a/src/index.test.ts'])).toEqual(['-p tsconfig.json']) + expect(await plan(dir, ['packages/a/build.config.ts'])).toEqual(['-p tsconfig.json']) + expect(await plan(dir, ['scripts/release.ts'])).toEqual(['-p tsconfig.json']) + expect(await plan(dir, ['packages/a/src/index.ts', 'packages/b/src/index.ts'])).toEqual([ + '-p packages/a/tsconfig.json', + '-p packages/b/tsconfig.json', + ]) + expect(await plan(dir, ['README.md', 'packages/a/styles.css'])).toBe(NOT_COVERED) + }) +}) From 277b7641c898fb1236f925e345f9531d649517f5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 15:39:10 +0700 Subject: [PATCH 04/10] refactor(uncheck): fold the runner into the root command The uncheck command owns its flags, check registry and run loop; the hooks commands reuse them from there and the entry attaches hooks as a subcommand, so no root module knows about flags. Process execution moves to tool.ts next to bin resolution. --- packages/uncheck/src/bin.ts | 3 +- packages/uncheck/src/commands/flags.ts | 26 --- .../uncheck/src/commands/hooks/install.ts | 2 +- packages/uncheck/src/commands/hooks/run.ts | 3 +- packages/uncheck/src/commands/uncheck.ts | 170 +++++++++++++++++- packages/uncheck/src/run.ts | 154 ---------------- packages/uncheck/src/tool.ts | 25 ++- packages/uncheck/src/types.ts | 8 - packages/uncheck/tests/command.test.ts | 5 +- 9 files changed, 197 insertions(+), 199 deletions(-) delete mode 100644 packages/uncheck/src/commands/flags.ts delete mode 100644 packages/uncheck/src/run.ts diff --git a/packages/uncheck/src/bin.ts b/packages/uncheck/src/bin.ts index ed6f87c..f34d2a0 100644 --- a/packages/uncheck/src/bin.ts +++ b/packages/uncheck/src/bin.ts @@ -4,9 +4,10 @@ import { NodeRuntime, NodeServices } from '@effect/platform-node' import { Effect } from 'effect' import { Command } from 'effect/unstable/cli' import pkg from '../package.json' +import { hooks } from './commands/hooks' import { uncheck } from './commands/uncheck' -Command.run(uncheck, { version: pkg.version }).pipe( +Command.run(uncheck.pipe(Command.withSubcommands([hooks])), { version: pkg.version }).pipe( Effect.catchTag('CheckFailed', () => Effect.sync(() => { process.exitCode = 1 diff --git a/packages/uncheck/src/commands/flags.ts b/packages/uncheck/src/commands/flags.ts deleted file mode 100644 index 6d485f0..0000000 --- a/packages/uncheck/src/commands/flags.ts +++ /dev/null @@ -1,26 +0,0 @@ -import process from 'node:process' -import { Effect } from 'effect' -import { Flag } from 'effect/unstable/cli' -import { CHECKS } from '../run' - -export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( - Flag.withDefault(Effect.sync(() => process.cwd())), - Flag.withDescription('Directory to run in. Defaults to the current one'), -) - -export const fixFlag = Flag.Boolean('fix').pipe( - Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), - Flag.withDefault(false), -) - -const CHECK_NAMES = CHECKS.map(check => check.name) - -export const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe( - Flag.atLeast(0), - Flag.withDescription('Require a check: fail when it cannot run instead of skipping it. Repeatable'), -) - -export const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe( - Flag.atLeast(0), - Flag.withDescription('Skip a check even when it could run. Repeatable'), -) diff --git a/packages/uncheck/src/commands/hooks/install.ts b/packages/uncheck/src/commands/hooks/install.ts index 7fb3f4c..9b436b6 100644 --- a/packages/uncheck/src/commands/hooks/install.ts +++ b/packages/uncheck/src/commands/hooks/install.ts @@ -3,7 +3,7 @@ import { Argument, CliError, Command, Prompt } from 'effect/unstable/cli' import { parse as parseJsonc } from 'jsonc-parser' import { ancestors, readJson } from '../../files' import { bold, dim, green } from '../../style' -import { cwdFlag } from '../flags' +import { cwdFlag } from '../uncheck' const AGENTS = [ { diff --git a/packages/uncheck/src/commands/hooks/run.ts b/packages/uncheck/src/commands/hooks/run.ts index d5029b2..242e3f2 100644 --- a/packages/uncheck/src/commands/hooks/run.ts +++ b/packages/uncheck/src/commands/hooks/run.ts @@ -3,8 +3,7 @@ import { Console, Effect, Predicate, Stdio, Stream } from 'effect' import { CliError, Command } from 'effect/unstable/cli' import { listChangedFiles } from '../../files' import { StopBlocked } from '../../errors' -import { runChecks } from '../../run' -import { cwdFlag, fixFlag, requireFlag, skipFlag } from '../flags' +import { cwdFlag, fixFlag, requireFlag, runChecks, skipFlag } from '../uncheck' export const run = Command.make( 'run', diff --git a/packages/uncheck/src/commands/uncheck.ts b/packages/uncheck/src/commands/uncheck.ts index ac35cc0..273d833 100644 --- a/packages/uncheck/src/commands/uncheck.ts +++ b/packages/uncheck/src/commands/uncheck.ts @@ -1,7 +1,168 @@ -import { Argument, Command, Flag } from 'effect/unstable/cli' -import { runChecks } from '../run' -import { cwdFlag, fixFlag, requireFlag, skipFlag } from './flags' -import { hooks } from './hooks' +import type { Check, CheckCommand, CheckName, CheckOutcome } from '../types' +import path from 'node:path' +import process from 'node:process' +import { Console, Duration, Effect } from 'effect' +import { Argument, CliError, Command, Flag } from 'effect/unstable/cli' +import { oxfmt } from '../checks/oxfmt' +import { oxlint } from '../checks/oxlint' +import { tsc } from '../checks/tsc' +import { CheckFailed } from '../errors' +import { listProjectFiles, resolvePaths } from '../files' +import { bold, dim, green, red } from '../style' +import { execute } from '../tool' + +const CHECKS: ReadonlyArray = [oxlint, oxfmt, tsc] + +export const cwdFlag = Flag.Directory('cwd', { mustExist: true }).pipe( + Flag.withDefault(Effect.sync(() => process.cwd())), + Flag.withDescription('Directory to run in. Defaults to the current one'), +) + +export const fixFlag = Flag.Boolean('fix').pipe( + Flag.withDescription('Apply lint fixes (oxlint --fix) and rewrite formatting (oxfmt) instead of only reporting'), + Flag.withDefault(false), +) + +const CHECK_NAMES = CHECKS.map(check => check.name) + +export const requireFlag = Flag.Literals('require', CHECK_NAMES).pipe( + Flag.atLeast(0), + Flag.withDescription('Require a check: fail when it cannot run instead of skipping it. Repeatable'), +) + +export const skipFlag = Flag.Literals('skip', CHECK_NAMES).pipe( + Flag.atLeast(0), + Flag.withDescription('Skip a check even when it could run. Repeatable'), +) + +export interface RunSettings { + readonly cwd: string + readonly fix: boolean + readonly required: ReadonlyArray + readonly skipped: ReadonlyArray + readonly allowUnmatched: boolean +} + +type CheckPlan = + | { readonly name: CheckName; readonly status: 'run'; readonly commands: ReadonlyArray } + | (CheckOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) + +export const runChecks = Effect.fn(function* (paths: ReadonlyArray, settings: RunSettings) { + const { fix, required, skipped, allowUnmatched } = settings + const contradiction = required.find(name => skipped.includes(name)) + + if (contradiction !== undefined) { + const userMessage = `--require=${contradiction} and --skip=${contradiction} contradict each other.` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + const cwd = path.resolve(settings.cwd) + const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) + + yield* Console.log(dim(`uncheck in ${cwd}`)) + + let files: ReadonlyArray | undefined + + if (paths.length > 0) { + const resolved = yield* resolvePaths(paths, cwd, projectFiles) + + if (resolved.unmatched.length > 0 && !allowUnmatched) { + const userMessage = `No files match ${resolved.unmatched.join(', ')}. Pass --no-error-on-unmatched-pattern to run with whatever matched.` + return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) + } + + if (resolved.files.length === 0) { + yield* Console.log(`${dim('ā—‹')} nothing to check, no files match ${paths.join(' ')}`) + return + } + + files = resolved.files + } + + const plans = yield* Effect.all( + CHECKS.map(({ name, plan }) => + skipped.includes(name) + ? Effect.succeed({ name, status: 'skipped', reason: `disabled with --skip=${name}` }) + : plan({ cwd, fix, files, projectFiles }).pipe( + Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), + Effect.catchTag('NothingToCheck', ({ reason }) => + Effect.succeed({ name, status: required.includes(name) ? 'failed' : 'skipped', reason }), + ), + Effect.catchTag('CannotCheck', ({ reason }) => + Effect.succeed({ name, status: 'failed', reason }), + ), + ), + ), + { concurrency: 'unbounded' }, + ) + + const outcomes = yield* Effect.forEach(plans, plan => runCheck(plan, cwd)) + + 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(', ') + + yield* Console.log(`${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(', ')}`, + ) + + if ( + !fix && + failed.some(outcome => outcome.reason === undefined && CHECKS.find(check => check.name === outcome.name)?.fixes) + ) { + yield* Console.log(dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) + } + + return yield* Effect.fail(new CheckFailed({ outcomes })) + } + + yield* Console.log(`${green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) +}) + +const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { + if (plan.status !== 'run') { + yield* Console.log( + plan.status === 'skipped' + ? `${dim('ā—‹')} ${bold(plan.name)} ${dim(`skipped, ${plan.reason}`)}` + : `${red('✘')} ${bold(plan.name)} ${red(plan.reason)}`, + ) + return plan + } + + const [duration, exitCodes] = yield* Effect.timed( + Effect.forEach(plan.commands, invocation => { + const { bin, args, files } = invocation + const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`] + + return Console.log(`${dim('ā–¶')} ${bold(bin.name)} ${dim([...args, ...tail].join(' '))}`.trimEnd()).pipe( + Effect.flatMap(() => execute(invocation, cwd)), + ) + }), + ) + + const failed = exitCodes.some(exitCode => exitCode !== 0) + const ms = Duration.toMillis(duration) + const elapsed = dim(ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`) + + yield* Console.log( + failed + ? `${red('✘')} ${bold(plan.name)} ${red('failed')} ${elapsed}` + : `${green('āœ”')} ${bold(plan.name)} ${green('passed')} ${elapsed}`, + ) + + const outcome: CheckOutcome = { name: plan.name, status: failed ? 'failed' : 'passed' } + + return outcome +}) export const uncheck = Command.make( 'uncheck', @@ -26,5 +187,4 @@ export const uncheck = Command.make( Command.withDescription( 'Lint (oxlint), format check (oxfmt) and typecheck (tsc) a project with one command. Each check runs only when the project uses that tool.', ), - Command.withSubcommands([hooks]), ) diff --git a/packages/uncheck/src/run.ts b/packages/uncheck/src/run.ts deleted file mode 100644 index 609744a..0000000 --- a/packages/uncheck/src/run.ts +++ /dev/null @@ -1,154 +0,0 @@ -import type { Check, CheckCommand, CheckName, CheckOutcome, RunSettings } from './types' -import path from 'node:path' -import process from 'node:process' -import { Console, Duration, Effect, Stream } from 'effect' -import { CliError } from 'effect/unstable/cli' -import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' -import { listProjectFiles, resolvePaths } from './files' -import { CheckFailed } from './errors' -import { bold, colors, dim, green, red } from './style' -import { oxfmt } from './checks/oxfmt' -import { oxlint } from './checks/oxlint' -import { tsc } from './checks/tsc' - -export const CHECKS: ReadonlyArray = [oxlint, oxfmt, tsc] - -type CheckPlan = - | { readonly name: CheckName; readonly status: 'run'; readonly commands: ReadonlyArray } - | (CheckOutcome & { readonly status: 'skipped' | 'failed'; readonly reason: string }) - -export const runChecks = Effect.fn(function* (paths: ReadonlyArray, settings: RunSettings) { - const { fix, required, skipped, allowUnmatched } = settings - const contradiction = required.find(name => skipped.includes(name)) - - if (contradiction !== undefined) { - const userMessage = `--require=${contradiction} and --skip=${contradiction} contradict each other.` - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - const cwd = path.resolve(settings.cwd) - const projectFiles = yield* Effect.cached(listProjectFiles(cwd)) - - yield* Console.log(dim(`uncheck in ${cwd}`)) - - let files: ReadonlyArray | undefined - - if (paths.length > 0) { - const resolved = yield* resolvePaths(paths, cwd, projectFiles) - - if (resolved.unmatched.length > 0 && !allowUnmatched) { - const userMessage = `No files match ${resolved.unmatched.join(', ')}. Pass --no-error-on-unmatched-pattern to run with whatever matched.` - return yield* Effect.fail(new CliError.UserError({ cause: new Error(userMessage), userMessage })) - } - - if (resolved.files.length === 0) { - yield* Console.log(`${dim('ā—‹')} nothing to check, no files match ${paths.join(' ')}`) - return - } - - files = resolved.files - } - - const plans = yield* Effect.all( - CHECKS.map(({ name, plan }) => - skipped.includes(name) - ? Effect.succeed({ name, status: 'skipped', reason: `disabled with --skip=${name}` }) - : plan({ cwd, fix, files, projectFiles }).pipe( - Effect.map((commands): CheckPlan => ({ name, status: 'run', commands })), - Effect.catchTag('NothingToCheck', ({ reason }) => - Effect.succeed({ name, status: required.includes(name) ? 'failed' : 'skipped', reason }), - ), - Effect.catchTag('CannotCheck', ({ reason }) => - Effect.succeed({ name, status: 'failed', reason }), - ), - ), - ), - { concurrency: 'unbounded' }, - ) - - const outcomes = yield* Effect.forEach(plans, plan => runCheck(plan, cwd)) - - 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(', ') - - yield* Console.log(`${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(', ')}`, - ) - - if ( - !fix && - failed.some(outcome => outcome.reason === undefined && CHECKS.find(check => check.name === outcome.name)?.fixes) - ) { - yield* Console.log(dim(' run `uncheck --fix` to apply oxlint and oxfmt fixes')) - } - - return yield* Effect.fail(new CheckFailed({ outcomes })) - } - - yield* Console.log(`${green('āœ”')} all checks passed (${ran.map(outcome => outcome.name).join(', ')})`) -}) - -const runCheck = Effect.fn(function* (plan: CheckPlan, cwd: string) { - if (plan.status !== 'run') { - yield* Console.log( - plan.status === 'skipped' - ? `${dim('ā—‹')} ${bold(plan.name)} ${dim(`skipped, ${plan.reason}`)}` - : `${red('✘')} ${bold(plan.name)} ${red(plan.reason)}`, - ) - return plan - } - - const [duration, exitCodes] = yield* Effect.timed( - Effect.forEach(plan.commands, invocation => { - const { bin, args, files } = invocation - const tail = files === undefined ? [] : files.length <= 3 ? files : [`[${files.length} files]`] - - return Console.log(`${dim('ā–¶')} ${bold(bin.name)} ${dim([...args, ...tail].join(' '))}`.trimEnd()).pipe( - Effect.flatMap(() => execute(invocation, cwd)), - ) - }), - ) - - const failed = exitCodes.some(exitCode => exitCode !== 0) - const ms = Duration.toMillis(duration) - const elapsed = dim(ms < 1000 ? `${Math.round(ms)}ms` : `${(ms / 1000).toFixed(1)}s`) - - yield* Console.log( - failed - ? `${red('✘')} ${bold(plan.name)} ${red('failed')} ${elapsed}` - : `${green('āœ”')} ${bold(plan.name)} ${green('passed')} ${elapsed}`, - ) - - const outcome: CheckOutcome = { name: plan.name, status: failed ? 'failed' : 'passed' } - - return outcome -}) - -/** Output goes through `Console` so it stays in order with uncheck's own lines and can be captured in hook mode. */ -const execute = Effect.fn(function* ({ bin, args, files = [] }: CheckCommand, cwd: string) { - const spawner = yield* ChildProcessSpawner.ChildProcessSpawner - - const handle = yield* spawner.spawn( - ChildProcess.make(process.execPath, [bin.entry, ...args, ...files], { - cwd, - stdin: 'ignore', - // A piped tool cannot see the terminal, so tell it when colors are wanted. - env: colors ? { FORCE_COLOR: '1' } : {}, - extendEnv: true, - }), - ) - - yield* Stream.runForEach(Stream.splitLines(Stream.decodeText(handle.all)), text => Console.log(text)) - - return yield* handle.exitCode -}, Effect.scoped) diff --git a/packages/uncheck/src/tool.ts b/packages/uncheck/src/tool.ts index 8e85997..b83770c 100644 --- a/packages/uncheck/src/tool.ts +++ b/packages/uncheck/src/tool.ts @@ -1,5 +1,9 @@ -import { Effect, Path, Predicate } from 'effect' +import type { CheckCommand } from './types' +import process from 'node:process' +import { Console, Effect, Path, Predicate, Stream } from 'effect' +import { ChildProcess, ChildProcessSpawner } from 'effect/unstable/process' import { ancestors, readJson } from './files' +import { colors } from './style' export interface Bin { readonly name: string @@ -54,3 +58,22 @@ export function argvBatches(args: ReadonlyArray): ReadonlyArray Console.log(text)) + + return yield* handle.exitCode +}, Effect.scoped) diff --git a/packages/uncheck/src/types.ts b/packages/uncheck/src/types.ts index f343d5d..ee7054d 100644 --- a/packages/uncheck/src/types.ts +++ b/packages/uncheck/src/types.ts @@ -38,11 +38,3 @@ export interface Check { FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner > } - -export interface RunSettings { - readonly cwd: string - readonly fix: boolean - readonly required: ReadonlyArray - readonly skipped: ReadonlyArray - readonly allowUnmatched: boolean -} diff --git a/packages/uncheck/tests/command.test.ts b/packages/uncheck/tests/command.test.ts index 0d6cf21..8d4f4b8 100644 --- a/packages/uncheck/tests/command.test.ts +++ b/packages/uncheck/tests/command.test.ts @@ -6,9 +6,12 @@ import { NodeServices } from '@effect/platform-node' import { Console, Effect, Stdio, Stream } from 'effect' import { Command } from 'effect/unstable/cli' import { fixture } from './fixture' +import { hooks } from '../src/commands/hooks' import { uncheck } from '../src/commands/uncheck' import { CheckFailed } from '../src/errors' +const cli = uncheck.pipe(Command.withSubcommands([hooks])) + interface RunResult { readonly result: 'ok' | 'blocked' | CheckFailed /** Everything logged, one line each, without ANSI styling. */ @@ -33,7 +36,7 @@ async function run(cwd: string, args: ReadonlyArray = [], stdin = ''): P }) const result = await Effect.runPromise( - Command.runWith(uncheck, { version: '0.0.0' })(argv).pipe( + Command.runWith(cli, { version: '0.0.0' })(argv).pipe( Effect.map(() => 'ok' as const), Effect.catchTag('CheckFailed', error => Effect.succeed(error)), Effect.catchTag('StopBlocked', () => Effect.succeed('blocked' as const)), From 07877ac1af5b1af3b0cbbd34d81e0c79e3288b3c Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 15:42:08 +0700 Subject: [PATCH 05/10] fix(uncheck): require Node 22.20+ or 24.8+ for stable path.matchesGlob --- package.json | 2 +- packages/uncheck/README.md | 2 +- packages/uncheck/package.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 6fa2e17..4536550 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,6 @@ } }, "engines": { - "node": ">=22" + "node": ">=22.20 <23 || >=24.8" } } diff --git a/packages/uncheck/README.md b/packages/uncheck/README.md index 26cafbb..9679f88 100644 --- a/packages/uncheck/README.md +++ b/packages/uncheck/README.md @@ -26,7 +26,7 @@ ## Usage ```sh -npm i -D uncheck # Node 22 or newer +npm i -D uncheck # Node 22.20+ or 24.8+, for stable path.matchesGlob npx uncheck # oxlint, oxfmt --check and tsc for everything under the current directory npx uncheck --fix # oxlint --fix, then rewrite formatting with oxfmt diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index ff323a0..4cd8041 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -59,6 +59,6 @@ } }, "engines": { - "node": ">=22" + "node": ">=22.20 <23 || >=24.8" } } From 48bc263f7d25f85ac4060a5b4806dc3708505d3e Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 15:53:23 +0700 Subject: [PATCH 06/10] build(uncheck): stub on install, bundle on prepack, inline dependencies Like middleapi/orpc: the package's `prepare` stubs dist with jiti and `prepack` runs the real unbuild, so there is no `build` script and the root `prepare` only installs git hooks. Runtime dependencies become devDependencies and are inlined, so the published bin is self-contained. --- package.json | 2 +- packages/uncheck/build.config.ts | 7 +++++++ packages/uncheck/package.json | 9 ++++----- pnpm-lock.yaml | 3 +-- 4 files changed, 13 insertions(+), 8 deletions(-) create mode 100644 packages/uncheck/build.config.ts diff --git a/package.json b/package.json index 4536550..0abf22a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "license": "MIT", "type": "module", "scripts": { - "prepare": "simple-git-hooks && pnpm -r run build --stub", + "prepare": "simple-git-hooks", "check": "uncheck", "check:fix": "uncheck --fix", "test": "pnpm run -r test && vitest run", diff --git a/packages/uncheck/build.config.ts b/packages/uncheck/build.config.ts new file mode 100644 index 0000000..6143714 --- /dev/null +++ b/packages/uncheck/build.config.ts @@ -0,0 +1,7 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + rollup: { + inlineDependencies: true, + }, +}) diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index 4cd8041..4e24776 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -30,14 +30,13 @@ "type": "module", "sideEffects": false, "scripts": { - "build": "unbuild" + "prepare": "unbuild --stub", + "prepack": "unbuild" }, - "dependencies": { + "devDependencies": { "@effect/platform-node": "^4.0.0-rc.116", "effect": "^4.0.0-rc.116", - "jsonc-parser": "^3.3.1" - }, - "devDependencies": { + "jsonc-parser": "^3.3.1", "oxfmt": "^0.68.0", "oxlint": "^1.83.0", "typescript": "^7.0.2" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed92e9b..d267ff7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,7 +216,7 @@ importers: version: 5.0.1(@types/node@26.6.2)(@vitest/coverage-v8@5.0.1)(vite@7.3.6(@types/node@26.6.2)(jiti@2.7.0)(yaml@2.9.1)) packages/uncheck: - dependencies: + devDependencies: '@effect/platform-node': specifier: ^4.0.0-rc.116 version: 4.0.0-rc.116(effect@4.0.0-rc.116)(redis@6.2.1) @@ -226,7 +226,6 @@ importers: jsonc-parser: specifier: ^3.3.1 version: 3.3.1 - devDependencies: oxfmt: specifier: ^0.68.0 version: 0.68.0 From fdd390d42ed5de8438e69445f66480f86f0d3fb5 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 15:53:26 +0700 Subject: [PATCH 07/10] ci: temporarily disable the CodSpeed workflow --- .github/workflows/codspeed.yaml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/codspeed.yaml b/.github/workflows/codspeed.yaml index de63dc4..f2ce0ad 100644 --- a/.github/workflows/codspeed.yaml +++ b/.github/workflows/codspeed.yaml @@ -1,13 +1,13 @@ name: CodSpeed on: - push: - branches: [main] - pull_request: - branches: [main] + # push: + # branches: [main] + # pull_request: + # branches: [main] # `workflow_dispatch` allows CodSpeed to trigger backtest # performance analysis in order to generate initial data. - workflow_dispatch: + # workflow_dispatch: permissions: contents: read From 679c7c72898589cc36006b4dc214b3c02eb793e1 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 15:58:34 +0700 Subject: [PATCH 08/10] fix --- .github/workflows/ci.yaml | 2 -- .github/workflows/release.yaml | 2 -- 2 files changed, 4 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8dffe93..05ef27e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -57,6 +57,4 @@ jobs: - run: pnpm i - - run: pnpm --filter='./packages/*' run -r build - - run: pnpm exec pkg-pr-new publish './packages/*' --pnpm --compact --template './playgrounds/*' diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ae25301..e439ed8 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -78,6 +78,4 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - run: pnpm --filter='./packages/*' run -r build - - run: pnpm --filter='./packages/*' publish -r --provenance --access=public --tag=${{ github.event.inputs.tag || steps.version.outputs.preid || 'latest' }} ${{ github.event.inputs.skip_git_check == 'true' && '--no-git-checks' || '' }} From 2f53c8322f91dfb0f5c69002a31450cce4cb6f90 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 16:01:05 +0700 Subject: [PATCH 09/10] fix --- packages/uncheck/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/uncheck/package.json b/packages/uncheck/package.json index 4e24776..9c33e80 100644 --- a/packages/uncheck/package.json +++ b/packages/uncheck/package.json @@ -30,7 +30,6 @@ "type": "module", "sideEffects": false, "scripts": { - "prepare": "unbuild --stub", "prepack": "unbuild" }, "devDependencies": { From 3d9af7f2b3f6dd03eb8755023006d9d681142440 Mon Sep 17 00:00:00 2001 From: Dinh Le Date: Mon, 21 Sep 2026 16:05:54 +0700 Subject: [PATCH 10/10] fix --- package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0abf22a..c00f94f 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "type": "module", "scripts": { "prepare": "simple-git-hooks", - "check": "uncheck", - "check:fix": "uncheck --fix", + "check": "pnpm exec --filter=./packages/uncheck unbuild --stub && uncheck", + "fix": "pnpm run check --fix", "test": "pnpm run -r test && vitest run", "test:coverage": "pnpm run -r test && vitest run --coverage", "bench": "vitest bench --run",