From dd0853e4f4cf2cc69d6456d1ec9fbdcc7bfae7a9 Mon Sep 17 00:00:00 2001 From: Apex Studio-He <2855213763@qq.com> Date: Tue, 28 Jul 2026 22:23:31 +0800 Subject: [PATCH 1/4] Build initial PatchSlim CLI --- .codex/skills/use-patchslim/SKILL.md | 45 + .../skills/use-patchslim/agents/openai.yaml | 4 + .github/workflows/ci.yml | 25 + .prettierignore | 4 + CONTRIBUTING.md | 36 + SECURITY.md | 21 + package.json | 56 + pnpm-lock.yaml | 1645 +++++++++++++++++ pnpm-workspace.yaml | 4 + src/cli.ts | 344 ++++ src/core/commands.ts | 52 + src/core/config.ts | 263 +++ src/core/engine.ts | 452 +++++ src/core/errors.ts | 27 + src/core/git.ts | 243 +++ src/core/output.ts | 48 + src/core/patch.ts | 305 +++ src/core/process.ts | 151 ++ src/core/reducer.ts | 73 + src/core/report.ts | 154 ++ src/core/types.ts | 135 ++ test/commands.test.ts | 19 + test/config.test.ts | 102 + test/engine.test.ts | 110 ++ test/helpers.ts | 116 ++ test/process.test.ts | 30 + test/reducer.test.ts | 23 + test/report.test.ts | 41 + tsconfig.json | 18 + vitest.config.ts | 11 + 30 files changed, 4557 insertions(+) create mode 100644 .codex/skills/use-patchslim/SKILL.md create mode 100644 .codex/skills/use-patchslim/agents/openai.yaml create mode 100644 .github/workflows/ci.yml create mode 100644 .prettierignore create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md create mode 100644 package.json create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml create mode 100644 src/cli.ts create mode 100644 src/core/commands.ts create mode 100644 src/core/config.ts create mode 100644 src/core/engine.ts create mode 100644 src/core/errors.ts create mode 100644 src/core/git.ts create mode 100644 src/core/output.ts create mode 100644 src/core/patch.ts create mode 100644 src/core/process.ts create mode 100644 src/core/reducer.ts create mode 100644 src/core/report.ts create mode 100644 src/core/types.ts create mode 100644 test/commands.test.ts create mode 100644 test/config.test.ts create mode 100644 test/engine.test.ts create mode 100644 test/helpers.ts create mode 100644 test/process.test.ts create mode 100644 test/reducer.test.ts create mode 100644 test/report.test.ts create mode 100644 tsconfig.json create mode 100644 vitest.config.ts diff --git a/.codex/skills/use-patchslim/SKILL.md b/.codex/skills/use-patchslim/SKILL.md new file mode 100644 index 0000000..c52667c --- /dev/null +++ b/.codex/skills/use-patchslim/SKILL.md @@ -0,0 +1,45 @@ +--- +name: use-patchslim +description: Inspect and minimize committed Git branch diffs with the PatchSlim CLI while preserving protected tests and configured checks. Use when a user asks to shrink a large pull request, remove unnecessary branch changes, test whether diff hunks are required, or review a PatchSlim report. +--- + +# Use PatchSlim + +Verify the installation and repository before minimizing: + +```bash +command -v patchslim +patchslim --json doctor +patchslim --json inspect --base main +``` + +Do not start minimization until the user has identified a feature-specific +oracle. Prefer a targeted check that fails at the base revision and passes at +the branch head. + +Run the reducer with layered checks: + +```bash +patchslim --json minimize \ + --base main \ + --oracle "pnpm vitest run path/to/regression.test.ts" \ + --quick "pnpm typecheck" \ + --gate "pnpm test" +``` + +After completion: + +1. Read the generated JSON report. +2. Inspect the candidate patch. +3. Run `git apply --check `. +4. Explain that the result is oracle-backed evidence, not proof of equivalence. + +Follow these rules: + +- Prefer `--json` for stable output. +- Keep tests, fixtures, migrations, lockfiles, and CI configuration protected + unless the user explicitly changes that boundary. +- Do not apply, commit, push, or open a pull request unless the user asks. +- Do not run PatchSlim against untrusted repository content. +- Stop when the oracle is weak, unstable, or produces an unexpected baseline + result. diff --git a/.codex/skills/use-patchslim/agents/openai.yaml b/.codex/skills/use-patchslim/agents/openai.yaml new file mode 100644 index 0000000..157f528 --- /dev/null +++ b/.codex/skills/use-patchslim/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Use PatchSlim" + short_description: "Minimize Git diffs with test-guided checks" + default_prompt: "Use $use-patchslim to inspect and safely minimize this branch diff." diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4d03b45 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + +permissions: + contents: read + +jobs: + check: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + with: + version: 11.9.0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm check diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..3dd1b20 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,4 @@ +dist +node_modules +pnpm-lock.yaml +.patchslim diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5863f27 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,36 @@ +# Contributing + +Thanks for taking the time to improve PatchSlim. + +## Development setup + +PatchSlim requires Node.js 20 or newer and pnpm. + +```bash +pnpm install +pnpm check +``` + +Keep pull requests focused. Include tests for behavior changes and explain any +change to candidate construction, protection rules, or command execution. + +## Safety invariants + +Changes to the reducer must preserve these guarantees: + +- the user's current checkout is never reset or cleaned; +- candidate evaluation happens in a temporary worktree; +- protected changes remain in every candidate; +- minimization stops when the full branch fails or the protected-only baseline + passes; +- a candidate is never applied automatically; +- command output remains bounded and machine-readable output remains stable. + +Use temporary repositories in tests for Git behavior. Tests must not rely on a +developer's global Git configuration or modify repositories outside their own +temporary directory. + +## Commit and pull request style + +Write short, imperative commit subjects. In the pull request, describe the +observable behavior, the checks you ran, and any known limitation. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..6157c72 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security + +## Trust model + +PatchSlim is a local developer tool. It executes the setup, oracle, quick-check, +and gate commands configured by a repository. It also checks out repository +content into a temporary Git worktree. + +Run PatchSlim only against repositories and revisions you trust. Reviewing a +branch with PatchSlim has similar code-execution risk to installing its +dependencies and running its test suite. + +PatchSlim filters common secret-like environment variable names before spawning +commands, but this is defense in depth rather than a sandbox. Commands still +run with the current user's filesystem and network permissions. + +## Reporting a vulnerability + +Please report suspected vulnerabilities privately through the repository's +security advisory page. Include a minimal reproduction, affected version, and +the impact you observed. Avoid opening a public issue before a fix is available. diff --git a/package.json b/package.json new file mode 100644 index 0000000..31ab704 --- /dev/null +++ b/package.json @@ -0,0 +1,56 @@ +{ + "name": "patchslim", + "version": "0.1.0", + "description": "Test-guided minimization for Git diffs.", + "type": "module", + "bin": { + "patchslim": "./dist/cli.js" + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "scripts": { + "build": "tsup src/cli.ts --format esm --dts --clean", + "dev": "tsx src/cli.ts", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "check": "pnpm format:check && pnpm typecheck && pnpm test && pnpm build" + }, + "engines": { + "node": ">=20" + }, + "keywords": [ + "git", + "diff", + "delta-debugging", + "testing", + "cli" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/Apex-Studio-He/patchslim.git" + }, + "bugs": { + "url": "https://github.com/Apex-Studio-He/patchslim/issues" + }, + "homepage": "https://github.com/Apex-Studio-He/patchslim#readme", + "license": "MIT", + "dependencies": { + "commander": "^14.0.0", + "minimatch": "^10.0.3", + "yaml": "^2.8.1" + }, + "devDependencies": { + "@types/node": "^24.1.0", + "prettier": "^3.6.2", + "tsx": "^4.20.3", + "tsup": "^8.5.0", + "typescript": "^5.8.3", + "vitest": "^3.2.4" + }, + "packageManager": "pnpm@11.9.0" +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..737fd26 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1645 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + commander: + specifier: ^14.0.0 + version: 14.0.3 + minimatch: + specifier: ^10.0.3 + version: 10.2.5 + yaml: + specifier: ^2.8.1 + version: 2.9.0 + devDependencies: + '@types/node': + specifier: ^24.1.0 + version: 24.13.3 + prettier: + specifier: ^3.6.2 + version: 3.9.6 + tsup: + specifier: ^8.5.0 + version: 8.5.1(postcss@8.5.23)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0) + tsx: + specifier: ^4.20.3 + version: 4.23.1 + typescript: + specifier: ^5.8.3 + version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0) + +packages: + + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/aix-ppc64@0.28.1': + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm64@0.28.1': + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-arm@0.28.1': + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/android-x64@0.28.1': + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-arm64@0.28.1': + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/darwin-x64@0.28.1': + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-arm64@0.28.1': + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.28.1': + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm64@0.28.1': + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-arm@0.28.1': + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-ia32@0.28.1': + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-loong64@0.28.1': + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-mips64el@0.28.1': + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-ppc64@0.28.1': + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-riscv64@0.28.1': + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-s390x@0.28.1': + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/linux-x64@0.28.1': + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-arm64@0.28.1': + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.28.1': + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-arm64@0.28.1': + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.28.1': + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/openharmony-arm64@0.28.1': + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/sunos-x64@0.28.1': + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-arm64@0.28.1': + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-ia32@0.28.1': + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@esbuild/win32-x64@0.28.1': + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/gen-mapping@0.3.13': + resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} + + '@jridgewell/resolve-uri@3.1.2': + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@jridgewell/trace-mapping@0.3.31': + resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + + '@rollup/rollup-android-arm-eabi@4.62.3': + resolution: {integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.62.3': + resolution: {integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.62.3': + resolution: {integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.62.3': + resolution: {integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.62.3': + resolution: {integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.62.3': + resolution: {integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + resolution: {integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + resolution: {integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + resolution: {integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.62.3': + resolution: {integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + resolution: {integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.62.3': + resolution: {integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + resolution: {integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + resolution: {integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + resolution: {integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + resolution: {integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + resolution: {integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.62.3': + resolution: {integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.62.3': + resolution: {integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.62.3': + resolution: {integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.62.3': + resolution: {integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + resolution: {integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + resolution: {integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.62.3': + resolution: {integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.62.3': + resolution: {integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==} + cpu: [x64] + os: [win32] + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.13.3': + resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + + '@vitest/expect@3.2.7': + resolution: {integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==} + + '@vitest/mocker@3.2.7': + resolution: {integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==} + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@3.2.7': + resolution: {integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==} + + '@vitest/runner@3.2.7': + resolution: {integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==} + + '@vitest/snapshot@3.2.7': + resolution: {integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==} + + '@vitest/spy@3.2.7': + resolution: {integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==} + + '@vitest/utils@3.2.7': + resolution: {integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==} + + acorn@8.17.0: + resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + engines: {node: '>=0.4.0'} + hasBin: true + + any-promise@1.3.0: + resolution: {integrity: sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + brace-expansion@5.0.8: + resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==} + engines: {node: 20 || >=22} + + bundle-require@5.1.0: + resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + peerDependencies: + esbuild: '>=0.18' + + cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + + chai@5.3.3: + resolution: {integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==} + engines: {node: '>=18'} + + check-error@2.1.3: + resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} + engines: {node: '>= 16'} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + commander@14.0.3: + resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==} + engines: {node: '>=20'} + + commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} + + consola@3.4.2: + resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} + engines: {node: ^14.18.0 || >=16.10.0} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} + engines: {node: '>=18'} + hasBin: true + + esbuild@0.28.1: + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fix-dts-default-cjs-exports@1.0.1: + resolution: {integrity: sha512-pVIECanWFC61Hzl2+oOCtoJ3F17kglZC/6N94eRWycFgBH35hHx0Li604ZIzhseh97mf2p0cv7vVrOZGoqhlEg==} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + joycon@3.1.1: + resolution: {integrity: sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==} + engines: {node: '>=10'} + + js-tokens@9.0.1: + resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==} + + lilconfig@3.1.3: + resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==} + engines: {node: '>=14'} + + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + + load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + + loupe@3.2.1: + resolution: {integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + mlly@1.8.2: + resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + mz@2.7.0: + resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} + + nanoid@3.3.16: + resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + pathval@2.0.1: + resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} + engines: {node: '>= 14.16'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + pirates@4.0.7: + resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} + engines: {node: '>= 6'} + + pkg-types@1.3.1: + resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} + + postcss-load-config@6.0.1: + resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==} + engines: {node: '>= 18'} + peerDependencies: + jiti: '>=1.21.0' + postcss: '>=8.0.9' + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + jiti: + optional: true + postcss: + optional: true + tsx: + optional: true + yaml: + optional: true + + postcss@8.5.23: + resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==} + engines: {node: ^10 || ^12 || >=14} + + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + resolve-from@5.0.0: + resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} + engines: {node: '>=8'} + + rollup@4.62.3: + resolution: {integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + source-map@0.7.6: + resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==} + engines: {node: '>= 12'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + strip-literal@3.1.0: + resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + + sucrase@3.35.1: + resolution: {integrity: sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==} + engines: {node: '>=16 || 14 >=14.17'} + hasBin: true + + thenify-all@1.6.0: + resolution: {integrity: sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==} + engines: {node: '>=0.8'} + + thenify@3.3.1: + resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinypool@1.1.1: + resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} + engines: {node: ^18.0.0 || >=20.0.0} + + tinyrainbow@2.0.0: + resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} + engines: {node: '>=14.0.0'} + + tinyspy@4.0.4: + resolution: {integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==} + engines: {node: '>=14.0.0'} + + tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + + ts-interface-checker@0.1.13: + resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + + tsup@8.5.1: + resolution: {integrity: sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==} + engines: {node: '>=18'} + hasBin: true + peerDependencies: + '@microsoft/api-extractor': ^7.36.0 + '@swc/core': ^1 + postcss: ^8.4.12 + typescript: '>=4.5.0' + peerDependenciesMeta: + '@microsoft/api-extractor': + optional: true + '@swc/core': + optional: true + postcss: + optional: true + typescript: + optional: true + + tsx@4.23.1: + resolution: {integrity: sha512-GQHnkIfxyx1wYCOS/wonik5MVRZU9hi1TEZmzGZSCJB1y9YgoZ8H6itNE/u4suE+yLmOzuE4E5S4TZ/ZX2wcWQ==} + engines: {node: '>=18.0.0'} + hasBin: true + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + ufo@1.6.4: + resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + + undici-types@7.18.2: + resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} + + vite-node@3.2.4: + resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + + vite@7.3.6: + resolution: {integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: {integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/debug': ^4.1.12 + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + '@vitest/browser': 3.2.7 + '@vitest/ui': 3.2.7 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/debug': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + yaml@2.9.0: + resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} + engines: {node: '>= 14.6'} + hasBin: true + +snapshots: + + '@esbuild/aix-ppc64@0.27.7': + optional: true + + '@esbuild/aix-ppc64@0.28.1': + optional: true + + '@esbuild/android-arm64@0.27.7': + optional: true + + '@esbuild/android-arm64@0.28.1': + optional: true + + '@esbuild/android-arm@0.27.7': + optional: true + + '@esbuild/android-arm@0.28.1': + optional: true + + '@esbuild/android-x64@0.27.7': + optional: true + + '@esbuild/android-x64@0.28.1': + optional: true + + '@esbuild/darwin-arm64@0.27.7': + optional: true + + '@esbuild/darwin-arm64@0.28.1': + optional: true + + '@esbuild/darwin-x64@0.27.7': + optional: true + + '@esbuild/darwin-x64@0.28.1': + optional: true + + '@esbuild/freebsd-arm64@0.27.7': + optional: true + + '@esbuild/freebsd-arm64@0.28.1': + optional: true + + '@esbuild/freebsd-x64@0.27.7': + optional: true + + '@esbuild/freebsd-x64@0.28.1': + optional: true + + '@esbuild/linux-arm64@0.27.7': + optional: true + + '@esbuild/linux-arm64@0.28.1': + optional: true + + '@esbuild/linux-arm@0.27.7': + optional: true + + '@esbuild/linux-arm@0.28.1': + optional: true + + '@esbuild/linux-ia32@0.27.7': + optional: true + + '@esbuild/linux-ia32@0.28.1': + optional: true + + '@esbuild/linux-loong64@0.27.7': + optional: true + + '@esbuild/linux-loong64@0.28.1': + optional: true + + '@esbuild/linux-mips64el@0.27.7': + optional: true + + '@esbuild/linux-mips64el@0.28.1': + optional: true + + '@esbuild/linux-ppc64@0.27.7': + optional: true + + '@esbuild/linux-ppc64@0.28.1': + optional: true + + '@esbuild/linux-riscv64@0.27.7': + optional: true + + '@esbuild/linux-riscv64@0.28.1': + optional: true + + '@esbuild/linux-s390x@0.27.7': + optional: true + + '@esbuild/linux-s390x@0.28.1': + optional: true + + '@esbuild/linux-x64@0.27.7': + optional: true + + '@esbuild/linux-x64@0.28.1': + optional: true + + '@esbuild/netbsd-arm64@0.27.7': + optional: true + + '@esbuild/netbsd-arm64@0.28.1': + optional: true + + '@esbuild/netbsd-x64@0.27.7': + optional: true + + '@esbuild/netbsd-x64@0.28.1': + optional: true + + '@esbuild/openbsd-arm64@0.27.7': + optional: true + + '@esbuild/openbsd-arm64@0.28.1': + optional: true + + '@esbuild/openbsd-x64@0.27.7': + optional: true + + '@esbuild/openbsd-x64@0.28.1': + optional: true + + '@esbuild/openharmony-arm64@0.27.7': + optional: true + + '@esbuild/openharmony-arm64@0.28.1': + optional: true + + '@esbuild/sunos-x64@0.27.7': + optional: true + + '@esbuild/sunos-x64@0.28.1': + optional: true + + '@esbuild/win32-arm64@0.27.7': + optional: true + + '@esbuild/win32-arm64@0.28.1': + optional: true + + '@esbuild/win32-ia32@0.27.7': + optional: true + + '@esbuild/win32-ia32@0.28.1': + optional: true + + '@esbuild/win32-x64@0.27.7': + optional: true + + '@esbuild/win32-x64@0.28.1': + optional: true + + '@jridgewell/gen-mapping@0.3.13': + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + '@jridgewell/trace-mapping': 0.3.31 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@rollup/rollup-android-arm-eabi@4.62.3': + optional: true + + '@rollup/rollup-android-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-arm64@4.62.3': + optional: true + + '@rollup/rollup-darwin-x64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-arm64@4.62.3': + optional: true + + '@rollup/rollup-freebsd-x64@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.62.3': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-linux-x64-musl@4.62.3': + optional: true + + '@rollup/rollup-openbsd-x64@4.62.3': + optional: true + + '@rollup/rollup-openharmony-arm64@4.62.3': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.62.3': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.62.3': + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.13.3': + dependencies: + undici-types: 7.18.2 + + '@vitest/expect@3.2.7': + dependencies: + '@types/chai': 5.2.3 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + '@vitest/mocker@3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0) + + '@vitest/pretty-format@3.2.7': + dependencies: + tinyrainbow: 2.0.0 + + '@vitest/runner@3.2.7': + dependencies: + '@vitest/utils': 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + '@vitest/snapshot@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@3.2.7': + dependencies: + tinyspy: 4.0.4 + + '@vitest/utils@3.2.7': + dependencies: + '@vitest/pretty-format': 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn@8.17.0: {} + + any-promise@1.3.0: {} + + assertion-error@2.0.1: {} + + balanced-match@4.0.4: {} + + brace-expansion@5.0.8: + dependencies: + balanced-match: 4.0.4 + + bundle-require@5.1.0(esbuild@0.27.7): + dependencies: + esbuild: 0.27.7 + load-tsconfig: 0.2.5 + + cac@6.7.14: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + check-error@2.1.3: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + commander@14.0.3: {} + + commander@4.1.1: {} + + confbox@0.1.8: {} + + consola@3.4.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.27.7: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 + + esbuild@0.28.1: + optionalDependencies: + '@esbuild/aix-ppc64': 0.28.1 + '@esbuild/android-arm': 0.28.1 + '@esbuild/android-arm64': 0.28.1 + '@esbuild/android-x64': 0.28.1 + '@esbuild/darwin-arm64': 0.28.1 + '@esbuild/darwin-x64': 0.28.1 + '@esbuild/freebsd-arm64': 0.28.1 + '@esbuild/freebsd-x64': 0.28.1 + '@esbuild/linux-arm': 0.28.1 + '@esbuild/linux-arm64': 0.28.1 + '@esbuild/linux-ia32': 0.28.1 + '@esbuild/linux-loong64': 0.28.1 + '@esbuild/linux-mips64el': 0.28.1 + '@esbuild/linux-ppc64': 0.28.1 + '@esbuild/linux-riscv64': 0.28.1 + '@esbuild/linux-s390x': 0.28.1 + '@esbuild/linux-x64': 0.28.1 + '@esbuild/netbsd-arm64': 0.28.1 + '@esbuild/netbsd-x64': 0.28.1 + '@esbuild/openbsd-arm64': 0.28.1 + '@esbuild/openbsd-x64': 0.28.1 + '@esbuild/openharmony-arm64': 0.28.1 + '@esbuild/sunos-x64': 0.28.1 + '@esbuild/win32-arm64': 0.28.1 + '@esbuild/win32-ia32': 0.28.1 + '@esbuild/win32-x64': 0.28.1 + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fix-dts-default-cjs-exports@1.0.1: + dependencies: + magic-string: 0.30.21 + mlly: 1.8.2 + rollup: 4.62.3 + + fsevents@2.3.3: + optional: true + + joycon@3.1.1: {} + + js-tokens@9.0.1: {} + + lilconfig@3.1.3: {} + + lines-and-columns@1.2.4: {} + + load-tsconfig@0.2.5: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.8 + + mlly@1.8.2: + dependencies: + acorn: 8.17.0 + pathe: 2.0.3 + pkg-types: 1.3.1 + ufo: 1.6.4 + + ms@2.1.3: {} + + mz@2.7.0: + dependencies: + any-promise: 1.3.0 + object-assign: 4.1.1 + thenify-all: 1.6.0 + + nanoid@3.3.16: {} + + object-assign@4.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + pirates@4.0.7: {} + + pkg-types@1.3.1: + dependencies: + confbox: 0.1.8 + mlly: 1.8.2 + pathe: 2.0.3 + + postcss-load-config@6.0.1(postcss@8.5.23)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + lilconfig: 3.1.3 + optionalDependencies: + postcss: 8.5.23 + tsx: 4.23.1 + yaml: 2.9.0 + + postcss@8.5.23: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prettier@3.9.6: {} + + readdirp@4.1.2: {} + + resolve-from@5.0.0: {} + + rollup@4.62.3: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.62.3 + '@rollup/rollup-android-arm64': 4.62.3 + '@rollup/rollup-darwin-arm64': 4.62.3 + '@rollup/rollup-darwin-x64': 4.62.3 + '@rollup/rollup-freebsd-arm64': 4.62.3 + '@rollup/rollup-freebsd-x64': 4.62.3 + '@rollup/rollup-linux-arm-gnueabihf': 4.62.3 + '@rollup/rollup-linux-arm-musleabihf': 4.62.3 + '@rollup/rollup-linux-arm64-gnu': 4.62.3 + '@rollup/rollup-linux-arm64-musl': 4.62.3 + '@rollup/rollup-linux-loong64-gnu': 4.62.3 + '@rollup/rollup-linux-loong64-musl': 4.62.3 + '@rollup/rollup-linux-ppc64-gnu': 4.62.3 + '@rollup/rollup-linux-ppc64-musl': 4.62.3 + '@rollup/rollup-linux-riscv64-gnu': 4.62.3 + '@rollup/rollup-linux-riscv64-musl': 4.62.3 + '@rollup/rollup-linux-s390x-gnu': 4.62.3 + '@rollup/rollup-linux-x64-gnu': 4.62.3 + '@rollup/rollup-linux-x64-musl': 4.62.3 + '@rollup/rollup-openbsd-x64': 4.62.3 + '@rollup/rollup-openharmony-arm64': 4.62.3 + '@rollup/rollup-win32-arm64-msvc': 4.62.3 + '@rollup/rollup-win32-ia32-msvc': 4.62.3 + '@rollup/rollup-win32-x64-gnu': 4.62.3 + '@rollup/rollup-win32-x64-msvc': 4.62.3 + fsevents: 2.3.3 + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + source-map@0.7.6: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + sucrase@3.35.1: + dependencies: + '@jridgewell/gen-mapping': 0.3.13 + commander: 4.1.1 + lines-and-columns: 1.2.4 + mz: 2.7.0 + pirates: 4.0.7 + tinyglobby: 0.2.17 + ts-interface-checker: 0.1.13 + + thenify-all@1.6.0: + dependencies: + thenify: 3.3.1 + + thenify@3.3.1: + dependencies: + any-promise: 1.3.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + tree-kill@1.2.2: {} + + ts-interface-checker@0.1.13: {} + + tsup@8.5.1(postcss@8.5.23)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): + dependencies: + bundle-require: 5.1.0(esbuild@0.27.7) + cac: 6.7.14 + chokidar: 4.0.3 + consola: 3.4.2 + debug: 4.4.3 + esbuild: 0.27.7 + fix-dts-default-cjs-exports: 1.0.1 + joycon: 3.1.1 + picocolors: 1.1.1 + postcss-load-config: 6.0.1(postcss@8.5.23)(tsx@4.23.1)(yaml@2.9.0) + resolve-from: 5.0.0 + rollup: 4.62.3 + source-map: 0.7.6 + sucrase: 3.35.1 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tree-kill: 1.2.2 + optionalDependencies: + postcss: 8.5.23 + typescript: 5.9.3 + transitivePeerDependencies: + - jiti + - supports-color + - tsx + - yaml + + tsx@4.23.1: + dependencies: + esbuild: 0.28.1 + optionalDependencies: + fsevents: 2.3.3 + + typescript@5.9.3: {} + + ufo@1.6.4: {} + + undici-types@7.18.2: {} + + vite-node@3.2.4(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0) + transitivePeerDependencies: + - '@types/node' + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.23 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + '@types/node': 24.13.3 + fsevents: 2.3.3 + tsx: 4.23.1 + yaml: 2.9.0 + + vitest@3.2.7(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.7 + '@vitest/mocker': 3.2.7(vite@7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0)) + '@vitest/pretty-format': 3.2.7 + '@vitest/runner': 3.2.7 + '@vitest/snapshot': 3.2.7 + '@vitest/spy': 3.2.7 + '@vitest/utils': 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0) + vite-node: 3.2.4(@types/node@24.13.3)(tsx@4.23.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.3 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + yaml@2.9.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..09a02ca --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,4 @@ +allowBuilds: + esbuild: true +onlyBuiltDependencies: + - esbuild diff --git a/src/cli.ts b/src/cli.ts new file mode 100644 index 0000000..553d561 --- /dev/null +++ b/src/cli.ts @@ -0,0 +1,344 @@ +#!/usr/bin/env node + +import { access, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { Command, Option } from "commander"; + +import { parseDuration } from "./core/commands.js"; +import { findConfigPath, resolveSettings } from "./core/config.js"; +import { asCliError, CliError } from "./core/errors.js"; +import { minimize } from "./core/engine.js"; +import { + findRepositoryRoot, + gitVersion, + inspectRepository, +} from "./core/git.js"; +import { writeError, writeSuccess } from "./core/output.js"; +import { + DEFAULT_PROTECT_PATTERNS, + parseChanges, + statsFromPatch, +} from "./core/patch.js"; +import { readRunReport, renderHumanSummary } from "./core/report.js"; +import type { JsonValue } from "./core/types.js"; + +const VERSION = "0.1.0"; + +interface GlobalOptions { + json?: boolean; + cwd?: string; +} + +const program = new Command(); +program + .name("patchslim") + .description("Test-guided minimization for Git diffs.") + .version(VERSION) + .option("--json", "emit stable JSON to stdout") + .option("-C, --cwd ", "run as if PatchSlim started in this directory"); + +program + .command("doctor") + .description("Check Git, repository, configuration, and runtime readiness.") + .action(async () => { + const globals = globalOptions(); + const cwd = resolveCwd(globals.cwd); + const [git, repositoryRoot, configPath] = await Promise.all([ + gitVersion(), + findRepositoryRoot(cwd), + findConfigPath(cwd), + ]); + const data = { + version: VERSION, + node: process.version, + git: git ?? null, + cwd, + repositoryRoot: repositoryRoot ?? null, + configPath: configPath ?? null, + ready: git !== undefined && repositoryRoot !== undefined, + missing: [ + ...(git ? [] : ["git"]), + ...(repositoryRoot ? [] : ["repository"]), + ], + }; + + writeSuccess("doctor", toJson(data), outputOptions(), () => { + const lines = [ + `PatchSlim ${VERSION}`, + `Node: ${process.version}`, + `Git: ${git ?? "not found"}`, + `Repository: ${repositoryRoot ?? "not found"}`, + `Config: ${configPath ?? "not found"}`, + ]; + if (!data.ready) { + lines.push("", `Needs setup: ${data.missing.join(", ")}`); + } + return lines.join("\n"); + }); + }); + +program + .command("init") + .description("Create a conservative .patchslim.yml configuration.") + .option("--dry-run", "print the configuration without writing it") + .option("--force", "replace an existing configuration") + .action(async (options: { dryRun?: boolean; force?: boolean }) => { + const cwd = resolveCwd(globalOptions().cwd); + const destination = path.join(cwd, ".patchslim.yml"); + const content = defaultConfiguration(); + + if (!options.dryRun && !options.force && (await exists(destination))) { + throw new CliError( + "CONFIG_EXISTS", + `${destination} already exists. Pass --force to replace it.`, + ); + } + + if (!options.dryRun) { + await writeFile(destination, content, "utf8"); + } + + writeSuccess( + "init", + toJson({ + path: destination, + written: !options.dryRun, + content, + }), + outputOptions(), + () => + options.dryRun + ? content + : `Created ${destination}\nReview the oracle and protect patterns before running minimize.`, + ); + }); + +program + .command("inspect") + .description("Inspect the committed diff and its protection classification.") + .option("--base ", "base branch or revision") + .option("--head ", "head branch or revision", "HEAD") + .option("--protect ", "additional protected path pattern", collect, []) + .action( + async (options: { base?: string; head: string; protect: string[] }) => { + const cwd = resolveCwd(globalOptions().cwd); + const snapshot = await inspectRepository(cwd, options.base, options.head); + const changes = parseChanges(snapshot.diff, snapshot.nameStatus, [ + ...DEFAULT_PROTECT_PATTERNS, + ...options.protect, + ]); + const data = { + repository: snapshot.info, + stats: statsFromPatch(snapshot.diff), + dirtyWorkingTree: snapshot.originalStatus.length > 0, + changes: changes.map((change) => ({ + path: change.path, + status: change.status, + additions: change.additions, + deletions: change.deletions, + hunks: change.hunks.length, + atomic: change.atomic, + protected: change.protected, + protectReason: change.protectReason ?? null, + })), + }; + + writeSuccess("inspect", toJson(data), outputOptions(), () => { + const stats = data.stats; + const rows = data.changes.map( + (change) => + `${change.protected ? "P" : "R"} ${change.status.padEnd(12)} ${String(change.hunks).padStart(3)} hunks ${change.path}`, + ); + return [ + `${stats.files} files, +${stats.additions}/-${stats.deletions}`, + data.dirtyWorkingTree + ? "Note: uncommitted working-tree changes are not included." + : "", + "", + "P = protected, R = reducible", + ...rows, + ] + .filter(Boolean) + .join("\n"); + }); + }, + ); + +program + .command("minimize") + .description( + "Find a smaller committed diff that passes the configured checks.", + ) + .option("--config ", "configuration file") + .option("--base ", "base branch or revision") + .option("--head ", "head branch or revision") + .option("--oracle ", "feature-preserving oracle command") + .option("--setup ", "one-time dependency setup command") + .option("--quick ", "quick gate run for each candidate", collect, []) + .option("--gate ", "full final-validation gate", collect, []) + .option("--protect ", "additional protected path pattern", collect, []) + .option("--no-default-protect", "disable built-in protection patterns") + .addOption( + new Option("--runs ", "stable head-oracle run count").argParser( + positiveInteger, + ), + ) + .option("--budget ", "reduction time budget") + .option("--timeout ", "default command timeout") + .option("--out ", "artifact output directory") + .option( + "--expect-base-failure ", + "required pattern in the protected-base oracle failure", + ) + .action( + async (options: { + config?: string; + base?: string; + head?: string; + oracle?: string; + setup?: string; + quick: string[]; + gate: string[]; + protect: string[]; + defaultProtect: boolean; + runs?: number; + budget?: string; + timeout?: string; + out?: string; + expectBaseFailure?: string; + }) => { + const cwd = resolveCwd(globalOptions().cwd); + const settings = await resolveSettings({ + cwd, + ...(options.config ? { configPath: options.config } : {}), + ...(options.base ? { baseRef: options.base } : {}), + ...(options.head ? { headRef: options.head } : {}), + ...(options.oracle ? { oracle: options.oracle } : {}), + ...(options.setup ? { setup: options.setup } : {}), + quickGates: options.quick, + fullGates: options.gate, + protectPatterns: options.protect, + includeDefaultProtect: options.defaultProtect, + ...(options.runs ? { runs: options.runs } : {}), + ...(options.budget ? { budget: options.budget } : {}), + ...(options.timeout ? { timeout: options.timeout } : {}), + ...(options.out ? { outputDir: options.out } : {}), + ...(options.expectBaseFailure + ? { expectedBaseFailure: options.expectBaseFailure } + : {}), + }); + const report = await minimize(settings); + writeSuccess("minimize", toJson(report), outputOptions(), () => + renderHumanSummary(report), + ); + }, + ); + +program + .command("report") + .description("Read a PatchSlim JSON report.") + .argument("", "path to report.json") + .action(async (reportPath: string) => { + const cwd = resolveCwd(globalOptions().cwd); + const report = await readRunReport(path.resolve(cwd, reportPath)); + writeSuccess("report", toJson(report), outputOptions(), () => + renderHumanSummary(report), + ); + }); + +program + .showHelpAfterError() + .configureHelp({ sortOptions: true, sortSubcommands: true }); + +main().catch((error: unknown) => { + const cliError = asCliError(error); + writeError( + cliError.code, + cliError.message, + cliError.details, + outputOptions(), + ); + process.exitCode = 1; +}); + +async function main(): Promise { + await program.parseAsync(process.argv); +} + +function globalOptions(): GlobalOptions { + return program.opts(); +} + +function outputOptions(): { json: boolean } { + return { json: globalOptions().json === true }; +} + +function resolveCwd(value: string | undefined): string { + const cwd = path.resolve(value ?? process.cwd()); + return cwd; +} + +function collect(value: string, previous: string[]): string[] { + return [...previous, value]; +} + +function positiveInteger(value: string): number { + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 1) { + throw new CliError( + "INVALID_NUMBER", + `"${value}" is not a positive integer.`, + ); + } + return parsed; +} + +async function exists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +function toJson(value: unknown): JsonValue { + return JSON.parse(JSON.stringify(value)) as JsonValue; +} + +function defaultConfiguration(): string { + return `version: 1 + +# The oracle must pass on HEAD and fail when reducible production changes +# are removed while protected tests remain. +oracle: + command: [pnpm, test] + timeout: 5m + +# setup: +# command: [pnpm, install, --frozen-lockfile] +# timeout: 15m + +quickGates: + - command: [pnpm, typecheck] + timeout: 5m + +fullGates: + - command: [pnpm, test] + timeout: 15m + - command: [pnpm, lint] + timeout: 5m + +protect: + - "tests/**" + - "migrations/**" + +runs: 2 +budget: 30m +`; +} + +// Validate duration parsing during startup so malformed implementation defaults +// cannot silently reach the runner. +parseDuration("5m"); diff --git a/src/core/commands.ts b/src/core/commands.ts new file mode 100644 index 0000000..8f8b55c --- /dev/null +++ b/src/core/commands.ts @@ -0,0 +1,52 @@ +import { CliError } from "./errors.js"; +import type { CommandSpec } from "./types.js"; + +const DURATION_RE = /^(\d+)(ms|s|m|h)$/; + +export function parseDuration(value: string): number { + const match = DURATION_RE.exec(value); + if (!match) { + throw new CliError( + "INVALID_DURATION", + `Invalid duration "${value}". Use values such as 500ms, 30s, 5m, or 1h.`, + ); + } + + const amount = Number(match[1]); + const unit = match[2]; + const multiplier = + unit === "ms" + ? 1 + : unit === "s" + ? 1_000 + : unit === "m" + ? 60_000 + : 3_600_000; + return amount * multiplier; +} + +export function commandSpec( + command: string | string[], + timeoutMs: number, +): CommandSpec { + if (Array.isArray(command)) { + if (command.length === 0 || command.some((part) => part.length === 0)) { + throw new CliError("INVALID_COMMAND", "Command arrays cannot be empty."); + } + } else if (command.trim().length === 0) { + throw new CliError("INVALID_COMMAND", "Commands cannot be empty."); + } + + return { command, timeoutMs }; +} + +export function formatCommand(command: string | string[]): string { + return Array.isArray(command) ? command.map(shellQuote).join(" ") : command; +} + +function shellQuote(value: string): string { + if (/^[A-Za-z0-9_./:@%+=,-]+$/.test(value)) { + return value; + } + return `'${value.replaceAll("'", `'\\''`)}'`; +} diff --git a/src/core/config.ts b/src/core/config.ts new file mode 100644 index 0000000..518bbd1 --- /dev/null +++ b/src/core/config.ts @@ -0,0 +1,263 @@ +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; + +import YAML from "yaml"; + +import { commandSpec, parseDuration } from "./commands.js"; +import { CliError } from "./errors.js"; +import { DEFAULT_PROTECT_PATTERNS } from "./patch.js"; +import type { CommandSpec, MinimizeSettings } from "./types.js"; + +interface RawCommand { + command?: unknown; + timeout?: unknown; +} + +interface RawConfig { + base?: unknown; + head?: unknown; + oracle?: unknown; + setup?: unknown; + quickGates?: unknown; + fullGates?: unknown; + protect?: unknown; + runs?: unknown; + budget?: unknown; + output?: unknown; + expectedBaseFailure?: unknown; +} + +export interface CliSettingsInput { + cwd: string; + configPath?: string; + baseRef?: string; + headRef?: string; + oracle?: string; + setup?: string; + quickGates: string[]; + fullGates: string[]; + protectPatterns: string[]; + includeDefaultProtect: boolean; + runs?: number; + budget?: string; + timeout?: string; + outputDir?: string; + expectedBaseFailure?: string; +} + +export async function resolveSettings( + input: CliSettingsInput, +): Promise { + const raw = await loadRawConfig(input.cwd, input.configPath); + const defaultTimeout = parseDuration(input.timeout ?? "5m"); + const rawOracle = input.oracle ?? raw.oracle; + if (rawOracle === undefined) { + throw new CliError( + "ORACLE_REQUIRED", + "An oracle command is required. Pass --oracle or configure oracle in .patchslim.yml.", + ); + } + + const oracle = normalizeCommand(rawOracle, defaultTimeout, "oracle"); + const setupValue = input.setup ?? raw.setup; + const setup = + setupValue === undefined + ? undefined + : normalizeCommand(setupValue, parseDuration("15m"), "setup"); + const quickGates = + input.quickGates.length > 0 + ? input.quickGates.map((value) => commandSpec(value, defaultTimeout)) + : normalizeCommandList(raw.quickGates, defaultTimeout, "quickGates"); + const fullGates = + input.fullGates.length > 0 + ? input.fullGates.map((value) => commandSpec(value, defaultTimeout)) + : normalizeCommandList(raw.fullGates, defaultTimeout, "fullGates"); + const configuredProtect = stringList(raw.protect, "protect"); + const protectPatterns = [ + ...(input.includeDefaultProtect ? DEFAULT_PROTECT_PATTERNS : []), + ...configuredProtect, + ...input.protectPatterns, + ]; + const runs = input.runs ?? positiveInteger(raw.runs, "runs") ?? 2; + const budgetText = + input.budget ?? + (typeof raw.budget === "string" ? raw.budget : undefined) ?? + "30m"; + const expectedFailureText = + input.expectedBaseFailure ?? + optionalString(raw.expectedBaseFailure, "expectedBaseFailure"); + + return { + cwd: path.resolve(input.cwd), + ...((input.baseRef ?? optionalString(raw.base, "base")) + ? { baseRef: input.baseRef ?? String(raw.base) } + : {}), + headRef: input.headRef ?? optionalString(raw.head, "head") ?? "HEAD", + oracle, + ...(setup ? { setup } : {}), + quickGates, + fullGates, + protectPatterns: [...new Set(protectPatterns)], + runs, + budgetMs: parseDuration(budgetText), + ...((input.outputDir ?? optionalString(raw.output, "output")) + ? { outputDir: input.outputDir ?? String(raw.output) } + : {}), + ...(expectedFailureText + ? { expectedBaseFailure: compileRegex(expectedFailureText) } + : {}), + }; +} + +export async function findConfigPath(cwd: string): Promise { + let directory = path.resolve(cwd); + + while (true) { + const candidate = path.join(directory, ".patchslim.yml"); + try { + await access(candidate); + return candidate; + } catch { + const parent = path.dirname(directory); + if (parent === directory) { + return undefined; + } + directory = parent; + } + } +} + +async function loadRawConfig( + cwd: string, + requested?: string, +): Promise { + const configPath = requested + ? path.resolve(cwd, requested) + : await findConfigPath(cwd); + if (!configPath) { + return {}; + } + + let content: string; + try { + content = await readFile(configPath, "utf8"); + } catch (error) { + throw new CliError("CONFIG_READ_FAILED", `Cannot read ${configPath}.`, { + cause: error instanceof Error ? error.message : String(error), + }); + } + + let parsed: unknown; + try { + parsed = YAML.parse(content); + } catch (error) { + throw new CliError("CONFIG_PARSE_FAILED", `Cannot parse ${configPath}.`, { + cause: error instanceof Error ? error.message : String(error), + }); + } + + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + throw new CliError( + "CONFIG_PARSE_FAILED", + `${configPath} must contain a YAML object.`, + ); + } + + return parsed as RawConfig; +} + +function normalizeCommand( + value: unknown, + defaultTimeout: number, + field: string, +): CommandSpec { + if (typeof value === "string") { + return commandSpec(value, defaultTimeout); + } + if ( + Array.isArray(value) && + value.every((part): part is string => typeof part === "string") + ) { + return commandSpec(value, defaultTimeout); + } + if (value && typeof value === "object" && !Array.isArray(value)) { + const raw = value as RawCommand; + const timeout = + raw.timeout === undefined + ? defaultTimeout + : typeof raw.timeout === "string" + ? parseDuration(raw.timeout) + : invalid(field, "timeout must be a duration string"); + return normalizeCommand(raw.command, timeout, `${field}.command`); + } + + return invalid(field, "must be a command string, string array, or object"); +} + +function normalizeCommandList( + value: unknown, + defaultTimeout: number, + field: string, +): CommandSpec[] { + if (value === undefined) { + return []; + } + if (!Array.isArray(value)) { + return invalid(field, "must be an array"); + } + return value.map((entry, index) => + normalizeCommand(entry, defaultTimeout, `${field}[${index}]`), + ); +} + +function stringList(value: unknown, field: string): string[] { + if (value === undefined) { + return []; + } + if ( + !Array.isArray(value) || + !value.every((entry): entry is string => typeof entry === "string") + ) { + return invalid(field, "must be an array of strings"); + } + return value; +} + +function optionalString(value: unknown, field: string): string | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value !== "string" || value.trim().length === 0) { + return invalid(field, "must be a non-empty string"); + } + return value; +} + +function positiveInteger(value: unknown, field: string): number | undefined { + if (value === undefined) { + return undefined; + } + if (!Number.isInteger(value) || Number(value) < 1) { + return invalid(field, "must be a positive integer"); + } + return Number(value); +} + +function compileRegex(value: string): RegExp { + try { + return new RegExp(value); + } catch (error) { + throw new CliError( + "INVALID_FAILURE_PATTERN", + `Invalid expected base failure pattern: ${value}`, + { cause: error instanceof Error ? error.message : String(error) }, + ); + } +} + +function invalid(field: string, expectation: string): never { + throw new CliError( + "INVALID_CONFIG", + `Configuration field "${field}" ${expectation}.`, + ); +} diff --git a/src/core/engine.ts b/src/core/engine.ts new file mode 100644 index 0000000..cecc11d --- /dev/null +++ b/src/core/engine.ts @@ -0,0 +1,452 @@ +import { writeFile } from "node:fs/promises"; + +import { formatCommand } from "./commands.js"; +import { CliError, asCliError } from "./errors.js"; +import { + createRunDirectory, + createTemporaryWorktree, + inspectRepository, + materializePatch, + removeTemporaryWorktree, + stagedPatch, +} from "./git.js"; +import { + atomIdsForFiles, + buildAtomCandidate, + buildFileCandidate, + hashCandidate, + parseChanges, + statsFromPatch, +} from "./patch.js"; +import { runCommand } from "./process.js"; +import { ddmin } from "./reducer.js"; +import { reportPaths, writeRunReport } from "./report.js"; +import type { + Evaluation, + FileChange, + MinimizeSettings, + PreflightReport, + ProcessResult, + RunReport, +} from "./types.js"; + +interface EvaluationContext { + settings: MinimizeSettings; + repository: Awaited>["info"]; + worktree: Awaited>; + cache: Map; + cacheHits: number; + commandsKey: string; +} + +export async function minimize(settings: MinimizeSettings): Promise { + const startedAt = new Date(); + const snapshot = await inspectRepository( + settings.cwd, + settings.baseRef, + settings.headRef, + ); + if (snapshot.diff.length === 0) { + throw new CliError( + "EMPTY_DIFF", + `There are no committed changes between ${snapshot.info.baseRef} and ${snapshot.info.headRef}.`, + ); + } + + const changes = parseChanges( + snapshot.diff, + snapshot.nameStatus, + settings.protectPatterns, + ); + const runId = createRunId(startedAt, snapshot.info.headSha); + const directory = await createRunDirectory( + snapshot.info, + runId, + settings.outputDir, + ); + const paths = reportPaths(directory); + let preflight: PreflightReport = { headRuns: [], passed: false }; + let worktree: Awaited> | undefined; + + const baseReport = { + schemaVersion: 1 as const, + runId, + startedAt: startedAt.toISOString(), + repository: snapshot.info, + before: statsFromPatch(snapshot.diff), + protected: changes + .filter((change) => change.protected) + .map((change) => ({ + path: change.path, + reason: change.protectReason ?? "protected by policy", + })), + artifacts: { + directory, + reportJson: paths.reportJson, + reportMarkdown: paths.reportMarkdown, + }, + }; + + try { + worktree = await createTemporaryWorktree(snapshot.info); + const context: EvaluationContext = { + settings, + repository: snapshot.info, + worktree, + cache: new Map(), + cacheHits: 0, + commandsKey: commandsKey(settings), + }; + const reducibleFileIds = changes + .filter((change) => !change.protected) + .map((change) => change.id); + const fullPatch = buildFileCandidate(changes, new Set(reducibleFileIds)); + + await materializePatch(snapshot.info, worktree, fullPatch); + if (settings.setup) { + const setupResult = await runCommand(settings.setup, { + cwd: worktree.path, + }); + if (!passed(setupResult)) { + throw new CliError( + "SETUP_FAILED", + `Setup command failed: ${setupResult.command}`, + { result: setupResult }, + ); + } + } + + preflight = await runPreflight(context, changes, fullPatch); + if (!preflight.passed) { + throw new CliError( + preflight.code ?? "PREFLIGHT_FAILED", + preflight.message ?? "Preflight checks failed.", + ); + } + + const deadline = Date.now() + settings.budgetMs; + const fileResult = await ddmin( + reducibleFileIds, + async (ids) => + ( + await evaluateCandidate( + context, + buildFileCandidate(changes, new Set(ids)), + false, + true, + ) + ).passed, + deadline, + ); + const keptFileIds = new Set(fileResult.kept); + const initialAtomIds = atomIdsForFiles(changes, keptFileIds); + const hunkResult = await ddmin( + initialAtomIds, + async (ids) => + ( + await evaluateCandidate( + context, + buildAtomCandidate(changes, new Set(ids)), + false, + true, + ) + ).passed, + deadline, + ); + const keptAtomIds = new Set(hunkResult.kept); + const finalPatchInput = buildAtomCandidate(changes, keptAtomIds); + const validation = await evaluateCandidate( + context, + finalPatchInput, + true, + false, + ); + if (!validation.passed) { + throw new CliError( + "FINAL_VALIDATION_FAILED", + `The best candidate failed final validation at ${validation.failedStage ?? "an unknown stage"}.`, + ); + } + + const finalPatch = await stagedPatch(snapshot.info, worktree.path); + await writeFile(paths.patch, finalPatch, "utf8"); + + const keptFiles = changes + .filter( + (change) => change.protected || candidateIncludes(change, keptAtomIds), + ) + .map((change) => change.path); + const removedFiles = changes + .filter( + (change) => + !change.protected && !candidateIncludes(change, keptAtomIds), + ) + .map((change) => change.path); + const allHunkIds = changes.flatMap((change) => + change.atomic || change.hunks.length <= 1 + ? [change.id] + : change.hunks.map((hunk) => hunk.id), + ); + + const report: RunReport = { + ...baseReport, + status: "completed", + completedAt: new Date().toISOString(), + after: statsFromPatch(finalPatch), + preflight, + artifacts: { + ...baseReport.artifacts, + patch: paths.patch, + }, + reduction: { + fileEvaluations: fileResult.evaluations, + hunkEvaluations: hunkResult.evaluations, + cacheHits: context.cacheHits, + keptFiles, + removedFiles, + keptHunks: hunkResult.kept, + removedHunks: allHunkIds.filter( + (id) => !keptAtomIds.has(id) && !isProtectedAtom(changes, id), + ), + }, + validation, + }; + await writeRunReport(report); + return report; + } catch (error) { + const cliError = asCliError(error); + const report: RunReport = { + ...baseReport, + status: "failed", + completedAt: new Date().toISOString(), + preflight, + error: { + code: cliError.code, + message: cliError.message, + }, + }; + await writeRunReport(report); + throw new CliError(cliError.code, cliError.message, { + ...cliError.details, + report: paths.reportJson, + }); + } finally { + if (worktree) { + await removeTemporaryWorktree(snapshot.info.root, worktree); + } + } +} + +async function runPreflight( + context: EvaluationContext, + changes: FileChange[], + fullPatch: string, +): Promise { + const headRuns: ProcessResult[] = []; + + for (let index = 0; index < context.settings.runs; index += 1) { + await materializePatch(context.repository, context.worktree, fullPatch); + const result = await runCommand(context.settings.oracle, { + cwd: context.worktree.path, + }); + headRuns.push(result); + if (!passed(result)) { + return { + headRuns, + passed: false, + code: "HEAD_ORACLE_UNSTABLE", + message: `The oracle did not pass consistently on ${context.repository.headRef}.`, + }; + } + } + + const protectedOnlyPatch = buildFileCandidate(changes, new Set()); + await materializePatch( + context.repository, + context.worktree, + protectedOnlyPatch, + ); + const baseRun = await runCommand(context.settings.oracle, { + cwd: context.worktree.path, + }); + if (passed(baseRun)) { + return { + headRuns, + baseRun, + passed: false, + code: "WEAK_ORACLE", + message: + "The oracle also passes with all reducible production changes removed.", + }; + } + + if ( + context.settings.expectedBaseFailure && + !context.settings.expectedBaseFailure.test( + `${baseRun.stdout}\n${baseRun.stderr}`, + ) + ) { + return { + headRuns, + baseRun, + passed: false, + code: "UNEXPECTED_BASE_FAILURE", + message: + "The base candidate failed, but its output did not match the expected failure pattern.", + }; + } + + return { headRuns, baseRun, passed: true }; +} + +async function evaluateCandidate( + context: EvaluationContext, + patch: string, + includeFullGates: boolean, + useCache: boolean, +): Promise { + const candidateHash = hashCandidate(patch, context.commandsKey); + const cached = useCache ? context.cache.get(candidateHash) : undefined; + if (cached) { + context.cacheHits += 1; + return { ...cached, cached: true }; + } + + const startedAt = Date.now(); + const results: ProcessResult[] = []; + try { + await materializePatch(context.repository, context.worktree, patch); + } catch (error) { + const evaluation: Evaluation = { + candidateHash, + materialized: false, + passed: false, + cached: false, + durationMs: Date.now() - startedAt, + failedStage: "materialize", + results, + error: error instanceof Error ? error.message : String(error), + }; + if (useCache) { + context.cache.set(candidateHash, evaluation); + } + return evaluation; + } + + const stages = [ + ...context.settings.quickGates.map((spec, index) => ({ + name: `quick-gate-${index + 1}`, + spec, + })), + ...Array.from( + { length: includeFullGates ? context.settings.runs : 1 }, + (_, index) => ({ + name: + includeFullGates && context.settings.runs > 1 + ? `oracle-${index + 1}` + : "oracle", + spec: context.settings.oracle, + }), + ), + ...(includeFullGates + ? context.settings.fullGates.map((spec, index) => ({ + name: `full-gate-${index + 1}`, + spec, + })) + : []), + ]; + + for (const stage of stages) { + let result: ProcessResult; + try { + result = await runCommand(stage.spec, { cwd: context.worktree.path }); + } catch (error) { + const evaluation: Evaluation = { + candidateHash, + materialized: true, + passed: false, + cached: false, + durationMs: Date.now() - startedAt, + failedStage: stage.name, + results, + error: error instanceof Error ? error.message : String(error), + }; + if (useCache) { + context.cache.set(candidateHash, evaluation); + } + return evaluation; + } + + results.push(result); + if (!passed(result)) { + const evaluation: Evaluation = { + candidateHash, + materialized: true, + passed: false, + cached: false, + durationMs: Date.now() - startedAt, + failedStage: stage.name, + results, + }; + if (useCache) { + context.cache.set(candidateHash, evaluation); + } + return evaluation; + } + } + + const evaluation: Evaluation = { + candidateHash, + materialized: true, + passed: true, + cached: false, + durationMs: Date.now() - startedAt, + results, + }; + if (useCache) { + context.cache.set(candidateHash, evaluation); + } + return evaluation; +} + +function passed(result: ProcessResult): boolean { + return !result.timedOut && result.exitCode === 0; +} + +function candidateIncludes( + change: FileChange, + selectedAtomIds: ReadonlySet, +): boolean { + if (change.protected) { + return true; + } + if (change.atomic || change.hunks.length <= 1) { + return selectedAtomIds.has(change.id); + } + return change.hunks.some((hunk) => selectedAtomIds.has(hunk.id)); +} + +function isProtectedAtom(changes: FileChange[], id: string): boolean { + return changes.some( + (change) => + change.protected && + (change.id === id || change.hunks.some((hunk) => hunk.id === id)), + ); +} + +function commandsKey(settings: MinimizeSettings): string { + return JSON.stringify({ + oracle: formatCommand(settings.oracle.command), + quick: settings.quickGates.map((gate) => formatCommand(gate.command)), + full: settings.fullGates.map((gate) => formatCommand(gate.command)), + }); +} + +function createRunId(date: Date, headSha: string): string { + const timestamp = date + .toISOString() + .replaceAll("-", "") + .replaceAll(":", "") + .replace(/\.\d{3}Z$/, "Z"); + return `${timestamp}-${headSha.slice(0, 8)}`; +} diff --git a/src/core/errors.ts b/src/core/errors.ts new file mode 100644 index 0000000..093d5e6 --- /dev/null +++ b/src/core/errors.ts @@ -0,0 +1,27 @@ +export class CliError extends Error { + readonly code: string; + readonly details: Record | undefined; + + constructor( + code: string, + message: string, + details?: Record, + ) { + super(message); + this.name = "CliError"; + this.code = code; + this.details = details; + } +} + +export function asCliError(error: unknown): CliError { + if (error instanceof CliError) { + return error; + } + + if (error instanceof Error) { + return new CliError("UNEXPECTED_ERROR", error.message); + } + + return new CliError("UNEXPECTED_ERROR", String(error)); +} diff --git a/src/core/git.ts b/src/core/git.ts new file mode 100644 index 0000000..0aa0090 --- /dev/null +++ b/src/core/git.ts @@ -0,0 +1,243 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; + +import { CliError } from "./errors.js"; +import type { RepositoryInfo } from "./types.js"; + +const execFileAsync = promisify(execFile); +const MAX_GIT_OUTPUT = 64 * 1024 * 1024; + +export interface RepositorySnapshot { + info: RepositoryInfo; + diff: string; + nameStatus: string; + originalStatus: string; +} + +export interface TemporaryWorktree { + parent: string; + path: string; +} + +export async function inspectRepository( + cwd: string, + baseRef: string | undefined, + headRef: string, +): Promise { + const root = await gitText(["rev-parse", "--show-toplevel"], cwd); + const resolvedBase = baseRef ?? (await detectDefaultBase(root)); + const baseSha = await gitText( + ["merge-base", resolvedBase, headRef], + root, + ).catch(() => { + throw new CliError( + "INVALID_BASE", + `Cannot find a merge base between "${resolvedBase}" and "${headRef}".`, + ); + }); + const headSha = await gitText(["rev-parse", `${headRef}^{commit}`], root); + const commonGitDirRaw = await gitText( + ["rev-parse", "--git-common-dir"], + root, + ); + const commonGitDir = path.resolve(root, commonGitDirRaw); + + const [diff, nameStatus, originalStatus] = await Promise.all([ + gitRaw( + [ + "diff", + "--binary", + "--full-index", + "--no-ext-diff", + baseSha, + headSha, + "--", + ], + root, + ), + gitRaw(["diff", "--name-status", "-z", baseSha, headSha, "--"], root), + gitRaw(["status", "--porcelain=v1", "-z"], root), + ]); + + return { + info: { + root, + commonGitDir, + baseRef: resolvedBase, + baseSha, + headRef, + headSha, + }, + diff, + nameStatus, + originalStatus, + }; +} + +export async function createTemporaryWorktree( + repository: RepositoryInfo, +): Promise { + const parent = await mkdtemp(path.join(tmpdir(), "patchslim-")); + const worktreePath = path.join(parent, "candidate"); + try { + await gitText( + ["worktree", "add", "--detach", worktreePath, repository.baseSha], + repository.root, + ); + return { parent, path: worktreePath }; + } catch (error) { + validateTemporaryParent(parent); + await rm(parent, { recursive: true, force: true }); + throw error; + } +} + +export async function removeTemporaryWorktree( + repositoryRoot: string, + worktree: TemporaryWorktree, +): Promise { + validateTemporaryParent(worktree.parent); + await gitRaw( + ["worktree", "remove", "--force", worktree.path], + repositoryRoot, + ).catch(() => ""); + await rm(worktree.parent, { recursive: true, force: true }); +} + +export async function materializePatch( + repository: RepositoryInfo, + worktree: TemporaryWorktree, + patch: string, +): Promise { + await gitText(["reset", "--hard", repository.baseSha], worktree.path); + await gitText(["clean", "-ffd"], worktree.path); + + if (patch.length === 0) { + return; + } + + const patchPath = path.join(worktree.parent, "candidate.patch"); + await writeFile(patchPath, patch, "utf8"); + try { + await gitText( + ["apply", "--index", "--recount", "--whitespace=nowarn", patchPath], + worktree.path, + ); + } catch (error) { + throw new CliError( + "PATCH_APPLY_FAILED", + "A candidate patch could not be applied to the merge base.", + { cause: error instanceof Error ? error.message : String(error) }, + ); + } +} + +export async function stagedPatch( + repository: RepositoryInfo, + worktreePath: string, +): Promise { + return await gitRaw( + ["diff", "--cached", "--binary", "--full-index", repository.baseSha, "--"], + worktreePath, + ); +} + +export async function createRunDirectory( + repository: RepositoryInfo, + runId: string, + requested?: string, +): Promise { + const directory = requested + ? path.resolve(repository.root, requested) + : path.join(repository.commonGitDir, "patchslim", "runs", runId); + await mkdir(directory, { recursive: true }); + return directory; +} + +export async function gitVersion(): Promise { + try { + return await gitText(["--version"], process.cwd()); + } catch { + return undefined; + } +} + +export async function findRepositoryRoot( + cwd: string, +): Promise { + try { + return await gitText(["rev-parse", "--show-toplevel"], cwd); + } catch { + return undefined; + } +} + +async function detectDefaultBase(root: string): Promise { + const candidates: string[] = []; + + try { + const remoteHead = await gitText( + ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], + root, + ); + candidates.push(remoteHead); + } catch { + // A repository does not need a configured remote. + } + + candidates.push("main", "master", "HEAD^"); + for (const candidate of candidates) { + try { + await gitText(["rev-parse", "--verify", `${candidate}^{commit}`], root); + return candidate; + } catch { + // Try the next conventional base. + } + } + + throw new CliError( + "BASE_REQUIRED", + "PatchSlim could not detect a base revision. Pass --base .", + ); +} + +async function gitText(args: string[], cwd: string): Promise { + return (await gitRaw(args, cwd)).trim(); +} + +async function gitRaw(args: string[], cwd: string): Promise { + try { + const { stdout } = await execFileAsync("git", args, { + cwd, + encoding: "utf8", + maxBuffer: MAX_GIT_OUTPUT, + }); + return stdout; + } catch (error) { + const message = + error instanceof Error && "stderr" in error + ? String((error as Error & { stderr?: string }).stderr ?? error.message) + : String(error); + throw new CliError("GIT_ERROR", message.trim() || "Git command failed.", { + args, + cwd, + }); + } +} + +function validateTemporaryParent(parent: string): void { + const resolved = path.resolve(parent); + const expectedRoot = `${path.resolve(tmpdir())}${path.sep}`; + if ( + !resolved.startsWith(expectedRoot) || + !path.basename(resolved).startsWith("patchslim-") + ) { + throw new CliError( + "UNSAFE_TEMP_PATH", + `Refusing to remove unexpected temporary path: ${resolved}`, + ); + } +} diff --git a/src/core/output.ts b/src/core/output.ts new file mode 100644 index 0000000..ec5148a --- /dev/null +++ b/src/core/output.ts @@ -0,0 +1,48 @@ +import type { JsonValue } from "./types.js"; + +export interface OutputOptions { + json: boolean; +} + +export function writeSuccess( + command: string, + data: JsonValue, + options: OutputOptions, + human: () => string, +): void { + if (options.json) { + process.stdout.write( + `${JSON.stringify({ ok: true, command, data }, null, 2)}\n`, + ); + return; + } + + process.stdout.write(`${human()}\n`); +} + +export function writeError( + code: string, + message: string, + details: Record | undefined, + options: OutputOptions, +): void { + if (options.json) { + process.stdout.write( + `${JSON.stringify( + { + ok: false, + error: { + code, + message, + ...(details ? { details } : {}), + }, + }, + null, + 2, + )}\n`, + ); + return; + } + + process.stderr.write(`patchslim: ${message}\n`); +} diff --git a/src/core/patch.ts b/src/core/patch.ts new file mode 100644 index 0000000..e7228f5 --- /dev/null +++ b/src/core/patch.ts @@ -0,0 +1,305 @@ +import { createHash } from "node:crypto"; + +import { minimatch } from "minimatch"; + +import { CliError } from "./errors.js"; +import type { ChangeStatus, DiffStats, FileChange, Hunk } from "./types.js"; + +export const DEFAULT_PROTECT_PATTERNS = [ + "**/__tests__/**", + "**/test/**", + "**/tests/**", + "**/*test.*", + "**/*spec.*", + "**/__snapshots__/**", + "**/fixtures/**", + "**/migrations/**", + "docs/**", + ".github/**", + "**/*.md", + "**/package.json", + "**/pnpm-lock.yaml", + "**/package-lock.json", + "**/yarn.lock", + "**/bun.lockb", + "**/pyproject.toml", + "**/requirements*.txt", + "**/uv.lock", + "**/go.mod", + "**/go.sum", + "**/Cargo.toml", + "**/Cargo.lock", +]; + +interface NameStatusEntry { + status: ChangeStatus; + path: string; + oldPath?: string; +} + +export function parseChanges( + diff: string, + nameStatus: string, + protectPatterns: string[], +): FileChange[] { + const blocks = splitDiffBlocks(diff); + const entries = parseNameStatus(nameStatus); + + if (blocks.length !== entries.length) { + throw new CliError( + "DIFF_PARSE_FAILED", + `Git returned ${blocks.length} patch blocks and ${entries.length} name-status entries.`, + ); + } + + return blocks.map((raw, order) => { + const entry = entries[order]; + if (!entry) { + throw new CliError("DIFF_PARSE_FAILED", "Missing name-status entry."); + } + + const { header, hunks } = parseHunks(raw, entry.path); + const binary = /(?:^|\n)(?:GIT binary patch|Binary files )/.test(raw); + const specialHeader = + /(?:^|\n)(?:old mode|new mode|similarity index|dissimilarity index|rename from|rename to|copy from|copy to|Submodule )/.test( + header, + ); + const atomic = + entry.status !== "modified" || + binary || + specialHeader || + hunks.length === 0; + const protectPattern = protectPatterns.find((pattern) => + minimatch(entry.path, pattern, { + dot: true, + matchBase: pattern.includes("/") === false, + }), + ); + const protectedChange = protectPattern !== undefined; + const stats = statsFromPatch(raw); + + return { + id: `file:${order}:${entry.path}`, + order, + path: entry.path, + ...(entry.oldPath ? { oldPath: entry.oldPath } : {}), + status: entry.status, + raw, + header, + hunks, + binary, + atomic, + protected: protectedChange, + ...(protectPattern + ? { protectReason: `matched protect pattern "${protectPattern}"` } + : {}), + additions: stats.additions, + deletions: stats.deletions, + }; + }); +} + +export function buildFileCandidate( + changes: FileChange[], + selectedFileIds: ReadonlySet, +): string { + return changes + .filter((change) => change.protected || selectedFileIds.has(change.id)) + .map((change) => ensureTrailingNewline(change.raw)) + .join(""); +} + +export function atomIdsForFiles( + changes: FileChange[], + selectedFileIds: ReadonlySet, +): string[] { + const ids: string[] = []; + + for (const change of changes) { + if (change.protected || !selectedFileIds.has(change.id)) { + continue; + } + + if (change.atomic || change.hunks.length <= 1) { + ids.push(change.id); + } else { + ids.push(...change.hunks.map((hunk) => hunk.id)); + } + } + + return ids; +} + +export function buildAtomCandidate( + changes: FileChange[], + selectedAtomIds: ReadonlySet, +): string { + const parts: string[] = []; + + for (const change of changes) { + if (change.protected) { + parts.push(ensureTrailingNewline(change.raw)); + continue; + } + + if (change.atomic || change.hunks.length <= 1) { + if (selectedAtomIds.has(change.id)) { + parts.push(ensureTrailingNewline(change.raw)); + } + continue; + } + + const selectedHunks = change.hunks.filter((hunk) => + selectedAtomIds.has(hunk.id), + ); + if (selectedHunks.length > 0) { + parts.push( + ensureTrailingNewline( + `${change.header}${selectedHunks.map((hunk) => hunk.raw).join("")}`, + ), + ); + } + } + + return parts.join(""); +} + +export function statsFromPatch(patch: string): DiffStats { + let files = 0; + let additions = 0; + let deletions = 0; + + for (const line of patch.split("\n")) { + if (line.startsWith("diff --git ")) { + files += 1; + } else if (line.startsWith("+") && !line.startsWith("+++")) { + additions += 1; + } else if (line.startsWith("-") && !line.startsWith("---")) { + deletions += 1; + } + } + + return { files, additions, deletions }; +} + +export function hashCandidate(patch: string, commandsKey: string): string { + return createHash("sha256") + .update(patch) + .update("\0") + .update(commandsKey) + .digest("hex"); +} + +function splitDiffBlocks(diff: string): string[] { + if (diff.trim().length === 0) { + return []; + } + + const starts = [...diff.matchAll(/^diff --git /gm)].map( + (match) => match.index, + ); + if (starts.length === 0 || starts[0] !== 0) { + throw new CliError( + "DIFF_PARSE_FAILED", + "Unified diff does not start with a Git file header.", + ); + } + + return starts.map((start, index) => + diff.slice(start, starts[index + 1] ?? diff.length), + ); +} + +function parseNameStatus(raw: string): NameStatusEntry[] { + const tokens = raw.split("\0"); + if (tokens.at(-1) === "") { + tokens.pop(); + } + + const entries: NameStatusEntry[] = []; + let index = 0; + while (index < tokens.length) { + let statusToken = tokens[index++] ?? ""; + let pathToken: string | undefined; + + const tabIndex = statusToken.indexOf("\t"); + if (tabIndex >= 0) { + pathToken = statusToken.slice(tabIndex + 1); + statusToken = statusToken.slice(0, tabIndex); + } + + const code = statusToken[0] ?? "?"; + const status = mapStatus(code); + if (code === "R" || code === "C") { + const oldPath = pathToken ?? tokens[index++]; + const newPath = tokens[index++]; + if (!oldPath || !newPath) { + throw new CliError( + "DIFF_PARSE_FAILED", + "Malformed rename/copy entry in git name-status output.", + ); + } + entries.push({ status, path: newPath, oldPath }); + } else { + const path = pathToken ?? tokens[index++]; + if (!path) { + throw new CliError( + "DIFF_PARSE_FAILED", + "Malformed entry in git name-status output.", + ); + } + entries.push({ status, path }); + } + } + + return entries; +} + +function parseHunks( + raw: string, + filePath: string, +): { header: string; hunks: Hunk[] } { + const starts = [...raw.matchAll(/^@@ .*@@.*$/gm)].map((match) => match.index); + if (starts.length === 0) { + return { header: raw, hunks: [] }; + } + + const header = raw.slice(0, starts[0]); + const hunks = starts.map((start, index) => { + const hunkRaw = raw.slice(start, starts[index + 1] ?? raw.length); + const firstNewline = hunkRaw.indexOf("\n"); + const hunkHeader = + firstNewline >= 0 ? hunkRaw.slice(0, firstNewline) : hunkRaw; + return { + id: `hunk:${filePath}:${index}`, + raw: hunkRaw, + header: hunkHeader, + }; + }); + return { header, hunks }; +} + +function mapStatus(code: string): ChangeStatus { + switch (code) { + case "A": + return "added"; + case "C": + return "copied"; + case "D": + return "deleted"; + case "M": + return "modified"; + case "R": + return "renamed"; + case "T": + return "type-changed"; + case "U": + return "unmerged"; + default: + return "unknown"; + } +} + +function ensureTrailingNewline(value: string): string { + return value.endsWith("\n") ? value : `${value}\n`; +} diff --git a/src/core/process.ts b/src/core/process.ts new file mode 100644 index 0000000..1111484 --- /dev/null +++ b/src/core/process.ts @@ -0,0 +1,151 @@ +import { spawn } from "node:child_process"; +import { performance } from "node:perf_hooks"; + +import { formatCommand } from "./commands.js"; +import type { CommandSpec, ProcessResult } from "./types.js"; + +const MAX_CAPTURE_BYTES = 2 * 1024 * 1024; +const REDACTED_ENV_RE = + /(?:TOKEN|SECRET|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|SESSION|COOKIE|CREDENTIAL)/i; +const SAFE_ENV_NAMES = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TMPDIR", + "TMP", + "TEMP", + "LANG", + "LC_ALL", + "TERM", + "COLORTERM", + "XDG_CACHE_HOME", + "XDG_CONFIG_HOME", +]); + +export interface RunOptions { + cwd: string; + extraEnv?: NodeJS.ProcessEnv; +} + +export async function runCommand( + spec: CommandSpec, + options: RunOptions, +): Promise { + const startedAt = performance.now(); + const command = Array.isArray(spec.command) ? spec.command[0] : spec.command; + const args = Array.isArray(spec.command) ? spec.command.slice(1) : []; + const shell = !Array.isArray(spec.command); + + if (!command) { + throw new Error("Cannot execute an empty command."); + } + + return await new Promise((resolve, reject) => { + const child = spawn(command, args, { + cwd: options.cwd, + env: buildSafeEnvironment(options.extraEnv), + shell, + detached: process.platform !== "win32", + stdio: ["ignore", "pipe", "pipe"], + }); + + let stdout = ""; + let stderr = ""; + let timedOut = false; + let settled = false; + + child.stdout?.on("data", (chunk: Buffer) => { + stdout = appendBounded(stdout, chunk.toString()); + }); + child.stderr?.on("data", (chunk: Buffer) => { + stderr = appendBounded(stderr, chunk.toString()); + }); + + const timer = setTimeout(() => { + timedOut = true; + terminateProcess(child.pid); + }, spec.timeoutMs); + + child.once("error", (error) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + reject(error); + }); + + child.once("close", (exitCode, signal) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + resolve({ + command: formatCommand(spec.command), + exitCode, + signal, + timedOut, + durationMs: Math.round(performance.now() - startedAt), + stdout, + stderr, + }); + }); + }); +} + +function buildSafeEnvironment(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { CI: "1", PATCHSLIM: "1" }; + + for (const [name, value] of Object.entries(process.env)) { + if ( + value !== undefined && + (SAFE_ENV_NAMES.has(name) || !REDACTED_ENV_RE.test(name)) + ) { + env[name] = value; + } + } + + return { ...env, ...extraEnv }; +} + +function appendBounded(current: string, next: string): string { + if (current.length >= MAX_CAPTURE_BYTES) { + return current; + } + + const combined = current + next; + if (combined.length <= MAX_CAPTURE_BYTES) { + return combined; + } + + return `${combined.slice(0, MAX_CAPTURE_BYTES)}\n[output truncated]\n`; +} + +function terminateProcess(pid: number | undefined): void { + if (pid === undefined) { + return; + } + + try { + if (process.platform === "win32") { + const killer = spawn("taskkill", ["/pid", String(pid), "/T", "/F"], { + stdio: "ignore", + }); + killer.unref(); + } else { + process.kill(-pid, "SIGTERM"); + setTimeout(() => { + try { + process.kill(-pid, "SIGKILL"); + } catch { + // The process group has already exited. + } + }, 1_000).unref(); + } + } catch { + // The process may have exited between timeout and termination. + } +} diff --git a/src/core/reducer.ts b/src/core/reducer.ts new file mode 100644 index 0000000..9c69ec1 --- /dev/null +++ b/src/core/reducer.ts @@ -0,0 +1,73 @@ +export interface ReductionResult { + kept: T[]; + evaluations: number; +} + +export async function ddmin( + values: readonly T[], + predicate: (candidate: readonly T[]) => Promise, + deadline: number, +): Promise> { + let current = [...values]; + let granularity = 2; + let evaluations = 0; + + if (current.length === 0) { + return { kept: current, evaluations }; + } + + while (current.length >= 2 && Date.now() < deadline) { + const chunks = partition(current, granularity); + let reduced = false; + + for (const chunk of chunks) { + if (Date.now() >= deadline) { + break; + } + + const removed = new Set(chunk); + const candidate = current.filter((value) => !removed.has(value)); + evaluations += 1; + if (await predicate(candidate)) { + current = candidate; + granularity = Math.max(2, granularity - 1); + reduced = true; + break; + } + } + + if (reduced) { + continue; + } + + if (granularity >= current.length) { + break; + } + granularity = Math.min(current.length, granularity * 2); + } + + for (const value of [...current]) { + if (Date.now() >= deadline) { + break; + } + + const candidate = current.filter((item) => item !== value); + evaluations += 1; + if (await predicate(candidate)) { + current = candidate; + } + } + + return { kept: current, evaluations }; +} + +function partition(values: readonly T[], count: number): T[][] { + const chunks: T[][] = []; + const chunkSize = Math.ceil(values.length / count); + + for (let index = 0; index < values.length; index += chunkSize) { + chunks.push(values.slice(index, index + chunkSize)); + } + + return chunks; +} diff --git a/src/core/report.ts b/src/core/report.ts new file mode 100644 index 0000000..4922231 --- /dev/null +++ b/src/core/report.ts @@ -0,0 +1,154 @@ +import { readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; + +import { CliError } from "./errors.js"; +import type { RunReport } from "./types.js"; + +export async function writeRunReport(report: RunReport): Promise { + await writeFile( + report.artifacts.reportJson, + `${JSON.stringify(report, null, 2)}\n`, + "utf8", + ); + + if (report.artifacts.reportMarkdown) { + await writeFile( + report.artifacts.reportMarkdown, + renderMarkdownReport(report), + "utf8", + ); + } +} + +export async function readRunReport(reportPath: string): Promise { + let parsed: unknown; + try { + parsed = JSON.parse(await readFile(reportPath, "utf8")); + } catch (error) { + throw new CliError("REPORT_READ_FAILED", `Cannot read ${reportPath}.`, { + cause: error instanceof Error ? error.message : String(error), + }); + } + + if ( + !parsed || + typeof parsed !== "object" || + !("schemaVersion" in parsed) || + (parsed as { schemaVersion?: unknown }).schemaVersion !== 1 + ) { + throw new CliError( + "REPORT_INVALID", + `${reportPath} is not a supported PatchSlim report.`, + ); + } + + return parsed as RunReport; +} + +export function renderHumanSummary(report: RunReport): string { + if (report.status === "failed") { + return [ + "PatchSlim stopped without producing a candidate.", + "", + `${report.error?.code ?? "FAILED"}: ${report.error?.message ?? "Unknown error"}`, + `Report: ${report.artifacts.reportJson}`, + ].join("\n"); + } + + const after = report.after; + const reduction = report.reduction; + const changedLinesBefore = report.before.additions + report.before.deletions; + const changedLinesAfter = after ? after.additions + after.deletions : 0; + return [ + "PatchSlim completed", + "", + `Original: ${formatStats(report.before)}`, + `Candidate: ${after ? formatStats(after) : "n/a"}`, + `Smaller by: ${Math.max(0, report.before.files - (after?.files ?? 0))} files, ${Math.max(0, changedLinesBefore - changedLinesAfter)} changed lines`, + "", + `Oracle evaluations: ${(reduction?.fileEvaluations ?? 0) + (reduction?.hunkEvaluations ?? 0)}`, + `Cache hits: ${reduction?.cacheHits ?? 0}`, + "", + `Candidate patch: ${report.artifacts.patch ?? "n/a"}`, + `Report: ${report.artifacts.reportMarkdown ?? report.artifacts.reportJson}`, + ].join("\n"); +} + +export function renderMarkdownReport(report: RunReport): string { + const lines = [ + "# PatchSlim report", + "", + `Status: **${report.status}**`, + "", + "## Scope", + "", + `- Base: \`${report.repository.baseRef}\` (\`${report.repository.baseSha.slice(0, 12)}\`)`, + `- Head: \`${report.repository.headRef}\` (\`${report.repository.headSha.slice(0, 12)}\`)`, + `- Original: ${formatStats(report.before)}`, + ]; + + if (report.after) { + lines.push(`- Candidate: ${formatStats(report.after)}`); + } + + if (report.protected.length > 0) { + lines.push("", "## Protected changes", ""); + for (const item of report.protected) { + lines.push(`- \`${item.path}\`: ${item.reason}`); + } + } + + if (report.reduction) { + lines.push( + "", + "## Reduction", + "", + `- File evaluations: ${report.reduction.fileEvaluations}`, + `- Hunk evaluations: ${report.reduction.hunkEvaluations}`, + `- Cache hits: ${report.reduction.cacheHits}`, + `- Removed files: ${report.reduction.removedFiles.length}`, + `- Removed hunks: ${report.reduction.removedHunks.length}`, + ); + } + + if (report.error) { + lines.push( + "", + "## Failure", + "", + `**${report.error.code}**: ${report.error.message}`, + ); + } + + lines.push( + "", + "## Interpretation", + "", + report.status === "completed" + ? "The candidate passed the configured oracle and gates. This is evidence relative to those checks, not a proof of complete behavioral equivalence." + : "PatchSlim stopped before producing a validated candidate. Review the failure above before trying another run.", + "", + ); + + return `${lines.join("\n")}\n`; +} + +export function reportPaths(directory: string): { + patch: string; + reportJson: string; + reportMarkdown: string; +} { + return { + patch: path.join(directory, "candidate.patch"), + reportJson: path.join(directory, "report.json"), + reportMarkdown: path.join(directory, "report.md"), + }; +} + +function formatStats(stats: { + files: number; + additions: number; + deletions: number; +}): string { + return `${stats.files} files, +${stats.additions}/-${stats.deletions}`; +} diff --git a/src/core/types.ts b/src/core/types.ts new file mode 100644 index 0000000..a7e3412 --- /dev/null +++ b/src/core/types.ts @@ -0,0 +1,135 @@ +export type JsonPrimitive = string | number | boolean | null; +export type JsonValue = + JsonPrimitive | JsonValue[] | { [key: string]: JsonValue | undefined }; + +export interface CommandSpec { + command: string | string[]; + timeoutMs: number; +} + +export interface ProcessResult { + command: string; + exitCode: number | null; + signal: NodeJS.Signals | null; + timedOut: boolean; + durationMs: number; + stdout: string; + stderr: string; +} + +export type ChangeStatus = + | "added" + | "copied" + | "deleted" + | "modified" + | "renamed" + | "type-changed" + | "unmerged" + | "unknown"; + +export interface Hunk { + id: string; + raw: string; + header: string; +} + +export interface FileChange { + id: string; + order: number; + path: string; + oldPath?: string; + status: ChangeStatus; + raw: string; + header: string; + hunks: Hunk[]; + binary: boolean; + atomic: boolean; + protected: boolean; + protectReason?: string; + additions: number; + deletions: number; +} + +export interface DiffStats { + files: number; + additions: number; + deletions: number; +} + +export interface Evaluation { + candidateHash: string; + materialized: boolean; + passed: boolean; + cached: boolean; + durationMs: number; + failedStage?: string; + results: ProcessResult[]; + error?: string; +} + +export interface RepositoryInfo { + root: string; + commonGitDir: string; + baseRef: string; + baseSha: string; + headRef: string; + headSha: string; +} + +export interface MinimizeSettings { + cwd: string; + baseRef?: string; + headRef: string; + oracle: CommandSpec; + setup?: CommandSpec; + quickGates: CommandSpec[]; + fullGates: CommandSpec[]; + protectPatterns: string[]; + runs: number; + budgetMs: number; + outputDir?: string; + expectedBaseFailure?: RegExp; +} + +export interface PreflightReport { + headRuns: ProcessResult[]; + baseRun?: ProcessResult; + passed: boolean; + code?: string; + message?: string; +} + +export interface ReductionReport { + fileEvaluations: number; + hunkEvaluations: number; + cacheHits: number; + keptFiles: string[]; + removedFiles: string[]; + keptHunks: string[]; + removedHunks: string[]; +} + +export interface RunReport { + schemaVersion: 1; + runId: string; + status: "completed" | "failed"; + startedAt: string; + completedAt: string; + repository: RepositoryInfo; + before: DiffStats; + after?: DiffStats; + protected: Array<{ path: string; reason: string }>; + preflight: PreflightReport; + reduction?: ReductionReport; + validation?: Evaluation; + artifacts: { + directory: string; + patch?: string; + reportJson: string; + reportMarkdown?: string; + }; + error?: { + code: string; + message: string; + }; +} diff --git a/test/commands.test.ts b/test/commands.test.ts new file mode 100644 index 0000000..8a912bb --- /dev/null +++ b/test/commands.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; + +import { parseDuration } from "../src/core/commands.js"; +import { CliError } from "../src/core/errors.js"; + +describe("parseDuration", () => { + it.each([ + ["500ms", 500], + ["30s", 30_000], + ["5m", 300_000], + ["2h", 7_200_000], + ])("parses %s", (input, expected) => { + expect(parseDuration(input)).toBe(expected); + }); + + it("rejects ambiguous durations", () => { + expect(() => parseDuration("5")).toThrowError(CliError); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts new file mode 100644 index 0000000..effc11c --- /dev/null +++ b/test/config.test.ts @@ -0,0 +1,102 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { findConfigPath, resolveSettings } from "../src/core/config.js"; + +describe("resolveSettings", () => { + it("loads structured commands and lets command-line values take precedence", async () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-config-")); + writeFileSync( + path.join(root, ".patchslim.yml"), + `base: develop +head: feature +oracle: + command: [node, test.mjs] + timeout: 12s +quickGates: + - command: [node, quick.mjs] + timeout: 3s +fullGates: + - node full.mjs +protect: + - generated/** +runs: 3 +budget: 8m +expectedBaseFailure: missing feature +`, + "utf8", + ); + + const settings = await resolveSettings({ + cwd: root, + oracle: "node override.mjs", + quickGates: [], + fullGates: [], + protectPatterns: ["vendor/**"], + includeDefaultProtect: false, + runs: 4, + timeout: "20s", + }); + + expect(settings.baseRef).toBe("develop"); + expect(settings.headRef).toBe("feature"); + expect(settings.oracle).toEqual({ + command: "node override.mjs", + timeoutMs: 20_000, + }); + expect(settings.quickGates).toEqual([ + { command: ["node", "quick.mjs"], timeoutMs: 3_000 }, + ]); + expect(settings.fullGates).toEqual([ + { command: "node full.mjs", timeoutMs: 20_000 }, + ]); + expect(settings.protectPatterns).toEqual(["generated/**", "vendor/**"]); + expect(settings.runs).toBe(4); + expect(settings.budgetMs).toBe(480_000); + expect(settings.expectedBaseFailure?.test("missing feature")).toBe(true); + }); + + it("rejects an invalid expected failure expression", async () => { + await expect( + resolveSettings({ + cwd: process.cwd(), + oracle: "node test.mjs", + quickGates: [], + fullGates: [], + protectPatterns: [], + includeDefaultProtect: false, + expectedBaseFailure: "[", + }), + ).rejects.toMatchObject({ + code: "INVALID_FAILURE_PATTERN", + }); + }); + + it("discovers the nearest configuration from a nested directory", async () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-config-")); + const nested = path.join(root, "packages", "example"); + mkdirSync(nested, { recursive: true }); + const configPath = path.join(root, ".patchslim.yml"); + writeFileSync(configPath, "oracle: node test.mjs\n", "utf8"); + + await expect(findConfigPath(nested)).resolves.toBe(configPath); + }); + + it("reports an explicitly requested missing configuration", async () => { + await expect( + resolveSettings({ + cwd: process.cwd(), + configPath: "missing.patchslim.yml", + quickGates: [], + fullGates: [], + protectPatterns: [], + includeDefaultProtect: false, + }), + ).rejects.toMatchObject({ + code: "CONFIG_READ_FAILED", + }); + }); +}); diff --git a/test/engine.test.ts b/test/engine.test.ts new file mode 100644 index 0000000..f0208c8 --- /dev/null +++ b/test/engine.test.ts @@ -0,0 +1,110 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { commandSpec } from "../src/core/commands.js"; +import { minimize } from "../src/core/engine.js"; +import { DEFAULT_PROTECT_PATTERNS } from "../src/core/patch.js"; +import { createFixtureRepository, git } from "./helpers.js"; + +describe("minimize", () => { + it("removes a redundant file and redundant hunk while preserving tests", async () => { + const fixture = createFixtureRepository(); + writeFileSync(path.join(fixture.root, "notes.local"), "keep me\n", "utf8"); + const statusBefore = git(fixture.root, ["status", "--porcelain=v1", "-z"]); + const report = await minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec("node tests/feature.test.mjs", 10_000), + quickGates: [], + fullGates: [commandSpec("node tests/feature.test.mjs", 10_000)], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }); + + expect(report.status).toBe("completed"); + expect(report.reduction?.removedFiles).toContain("src/redundant.mjs"); + expect(report.reduction?.removedHunks).toContain("hunk:src/math.mjs:1"); + expect(report.reduction?.keptHunks).toContain("hunk:src/math.mjs:0"); + expect(report.validation?.results).toHaveLength(3); + expect(report.protected.map((item) => item.path)).toContain( + "tests/feature.test.mjs", + ); + + const patch = readFileSync(report.artifacts.patch!, "utf8"); + expect(patch).toContain("return value * 3"); + expect(patch).not.toContain("src/redundant.mjs"); + expect(patch).not.toContain("return String(value)"); + expect(git(fixture.root, ["status", "--porcelain=v1", "-z"])).toBe( + statusBefore, + ); + expect(readFileSync(path.join(fixture.root, "notes.local"), "utf8")).toBe( + "keep me\n", + ); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); + + it("fails closed when the oracle also passes without production changes", async () => { + const fixture = createFixtureRepository(); + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec('node -e "process.exit(0)"', 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "WEAK_ORACLE", + }); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); + + it("fails closed when repeated head runs are unstable", async () => { + const fixture = createFixtureRepository(); + const stateFile = path.join( + mkdtempSync(path.join(tmpdir(), "patchslim-oracle-")), + "runs", + ); + const script = `const fs=require("node:fs");const p=${JSON.stringify(stateFile)};const n=fs.existsSync(p)?Number(fs.readFileSync(p,"utf8")):0;fs.writeFileSync(p,String(n+1));process.exit(n===0?0:1)`; + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec([process.execPath, "-e", script], 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "HEAD_ORACLE_UNSTABLE", + }); + expect(readFileSync(stateFile, "utf8")).toBe("2"); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts new file mode 100644 index 0000000..6405e07 --- /dev/null +++ b/test/helpers.ts @@ -0,0 +1,116 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +export interface FixtureRepository { + root: string; + baseSha: string; + headSha: string; +} + +export function createFixtureRepository(): FixtureRepository { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-fixture-")); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "PatchSlim Tests"]); + git(root, ["config", "user.email", "patchslim@example.invalid"]); + + mkdirSync(path.join(root, "src"), { recursive: true }); + mkdirSync(path.join(root, "tests"), { recursive: true }); + write(root, "src/math.mjs", baseMathSource()); + write(root, "tests/feature.test.mjs", baseTestSource()); + git(root, ["add", "."]); + git(root, ["commit", "-m", "initial fixture"]); + const baseSha = git(root, ["rev-parse", "HEAD"]); + + write(root, "src/math.mjs", headMathSource()); + write(root, "src/redundant.mjs", "export const noise = true;\n"); + write(root, "tests/feature.test.mjs", headTestSource()); + git(root, ["add", "."]); + git(root, ["commit", "-m", "add triple"]); + const headSha = git(root, ["rev-parse", "HEAD"]); + + return { root, baseSha, headSha }; +} + +export function git(root: string, args: string[]): string { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +function write(root: string, relativePath: string, content: string): void { + writeFileSync(path.join(root, relativePath), content, "utf8"); +} + +function baseMathSource(): string { + return `export function double(value) { + return value * 2; +} + +export function triple(value) { + return value * 2; +} + +export const padding01 = 1; +export const padding02 = 2; +export const padding03 = 3; +export const padding04 = 4; +export const padding05 = 5; +export const padding06 = 6; +export const padding07 = 7; +export const padding08 = 8; + +export function label(value) { + return value; +} +`; +} + +function headMathSource(): string { + return `export function double(value) { + return value * 2; +} + +export function triple(value) { + return value * 3; +} + +export const padding01 = 1; +export const padding02 = 2; +export const padding03 = 3; +export const padding04 = 4; +export const padding05 = 5; +export const padding06 = 6; +export const padding07 = 7; +export const padding08 = 8; + +export function label(value) { + return String(value); +} +`; +} + +function baseTestSource(): string { + return `import { double } from "../src/math.mjs"; + +if (double(3) !== 6) { + throw new Error("double should multiply by two"); +} +`; +} + +function headTestSource(): string { + return `import { double, triple } from "../src/math.mjs"; + +if (double(3) !== 6) { + throw new Error("double should multiply by two"); +} + +if (triple(3) !== 9) { + throw new Error("triple should multiply by three"); +} +`; +} diff --git a/test/process.test.ts b/test/process.test.ts new file mode 100644 index 0000000..e59b08f --- /dev/null +++ b/test/process.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { commandSpec } from "../src/core/commands.js"; +import { runCommand } from "../src/core/process.js"; + +describe("runCommand", () => { + it("captures successful output", async () => { + const result = await runCommand( + commandSpec( + [process.execPath, "-e", 'process.stdout.write("ok")'], + 5_000, + ), + { cwd: process.cwd() }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("ok"); + expect(result.timedOut).toBe(false); + }); + + it("terminates commands that exceed their timeout", async () => { + const result = await runCommand( + commandSpec([process.execPath, "-e", "setTimeout(() => {}, 10_000)"], 50), + { cwd: process.cwd() }, + ); + + expect(result.timedOut).toBe(true); + expect(result.exitCode).not.toBe(0); + }); +}); diff --git a/test/reducer.test.ts b/test/reducer.test.ts new file mode 100644 index 0000000..c224ff9 --- /dev/null +++ b/test/reducer.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; + +import { ddmin } from "../src/core/reducer.js"; + +describe("ddmin", () => { + it("finds a one-minimal satisfying subset", async () => { + const result = await ddmin( + ["required", "noise-a", "noise-b", "noise-c"], + async (candidate) => candidate.includes("required"), + Date.now() + 5_000, + ); + + expect(result.kept).toEqual(["required"]); + expect(result.evaluations).toBeGreaterThan(0); + }); + + it("honors an exhausted deadline", async () => { + const result = await ddmin(["a", "b"], async () => true, Date.now() - 1); + + expect(result.kept).toEqual(["a", "b"]); + expect(result.evaluations).toBe(0); + }); +}); diff --git a/test/report.test.ts b/test/report.test.ts new file mode 100644 index 0000000..bd2272e --- /dev/null +++ b/test/report.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; + +import { renderMarkdownReport } from "../src/core/report.js"; +import type { RunReport } from "../src/core/types.js"; + +describe("renderMarkdownReport", () => { + it("does not describe a failed run as a passing candidate", () => { + const report: RunReport = { + schemaVersion: 1, + runId: "example", + status: "failed", + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + repository: { + root: "/repo", + commonGitDir: "/repo/.git", + baseRef: "main", + baseSha: "a".repeat(40), + headRef: "HEAD", + headSha: "b".repeat(40), + }, + before: { files: 1, additions: 1, deletions: 0 }, + protected: [], + preflight: { headRuns: [], passed: false }, + artifacts: { + directory: "/repo/.git/patchslim/runs/example", + reportJson: "/repo/.git/patchslim/runs/example/report.json", + }, + error: { + code: "WEAK_ORACLE", + message: "The oracle also passed on the base candidate.", + }, + }; + + const markdown = renderMarkdownReport(report); + expect(markdown).toContain( + "stopped before producing a validated candidate", + ); + expect(markdown).not.toContain("candidate passed"); + }); +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3359572 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true, + "declaration": true, + "outDir": "dist", + "types": ["node"] + }, + "include": ["src/**/*.ts", "test/**/*.ts", "vitest.config.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..b3decef --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + coverage: { + enabled: false, + }, + include: ["test/**/*.test.ts"], + testTimeout: 30_000, + }, +}); From fbce4f4ae7a1db5a6cf787cebf469ed63b037de2 Mon Sep 17 00:00:00 2001 From: Apex Studio-He <2855213763@qq.com> Date: Tue, 28 Jul 2026 22:42:29 +0800 Subject: [PATCH 2/4] Harden patch application and isolation --- .codex/skills/use-patchslim/SKILL.md | 2 +- README.md | 15 +- SECURITY.md | 3 + pnpm-lock.yaml | 281 +-------------------------- pnpm-workspace.yaml | 2 + src/cli.ts | 24 ++- src/core/config.ts | 2 + src/core/engine.ts | 115 ++++++++--- src/core/git.ts | 67 ++++++- src/core/process.ts | 29 ++- src/core/report.ts | 13 ++ src/core/types.ts | 3 + test/engine.test.ts | 229 +++++++++++++++++++++- test/helpers.ts | 91 +++++++++ test/patch.test.ts | 97 +++++++++ test/process.test.ts | 37 ++++ test/reducer.test.ts | 23 +++ test/report.test.ts | 2 +- 18 files changed, 706 insertions(+), 329 deletions(-) create mode 100644 test/patch.test.ts diff --git a/.codex/skills/use-patchslim/SKILL.md b/.codex/skills/use-patchslim/SKILL.md index c52667c..a94c9e5 100644 --- a/.codex/skills/use-patchslim/SKILL.md +++ b/.codex/skills/use-patchslim/SKILL.md @@ -31,7 +31,7 @@ After completion: 1. Read the generated JSON report. 2. Inspect the candidate patch. -3. Run `git apply --check `. +3. Run `git apply --check ` against the original head. 4. Explain that the result is oracle-backed evidence, not proof of equivalence. Follow these rules: diff --git a/README.md b/README.md index 4d9c26f..36a14ae 100644 --- a/README.md +++ b/README.md @@ -67,12 +67,16 @@ patchslim minimize \ --gate "pnpm test" ``` -PatchSlim prints the report and candidate patch paths when it finishes. Inspect -the patch before applying it: +PatchSlim writes two patches: + +- `apply.patch` transforms the original head into the minimized result; +- `candidate.patch` recreates the minimized result from the merge base. + +To slim the current branch, inspect `apply.patch` before applying it: ```bash -git apply --check /path/to/candidate.patch -git apply /path/to/candidate.patch +git apply --check /path/to/apply.patch +git apply /path/to/apply.patch ``` It never applies the candidate to your current checkout automatically. @@ -147,6 +151,9 @@ content in a temporary worktree. Run it only in repositories you trust. See - The reducer seeks a locally minimal passing patch; it does not guarantee the globally smallest patch. - Test coverage and oracle quality determine the quality of the result. +- Ignored directories created by `setup` are preserved between candidates. + Disable mutable caches inside dependency directories when reproducibility is + critical. ## Development diff --git a/SECURITY.md b/SECURITY.md index 6157c72..842c62f 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -14,6 +14,9 @@ PatchSlim filters common secret-like environment variable names before spawning commands, but this is defense in depth rather than a sandbox. Commands still run with the current user's filesystem and network permissions. +Run reports include captured command output. Check reports before sharing them +if tests may print credentials or private data. + ## Reporting a vulnerability Please report suspected vulnerabilities privately through the repository's diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 737fd26..69fe818 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + esbuild: ^0.28.1 + importers: .: @@ -39,312 +42,156 @@ importers: packages: - '@esbuild/aix-ppc64@0.27.7': - resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.1': resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.7': - resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.1': resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.7': - resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.1': resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.7': - resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.1': resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.7': - resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.1': resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.7': - resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.1': resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.7': - resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.1': resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.7': - resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.7': - resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.1': resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.7': - resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.1': resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.7': - resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.1': resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.7': - resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.1': resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.7': - resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.1': resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.7': - resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.1': resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.7': - resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.1': resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.7': - resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.1': resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.7': - resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.1': resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.7': - resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.1': resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.7': - resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.7': - resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.1': resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.7': - resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.7': - resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.1': resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.7': - resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.1': resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.7': - resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.1': resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.7': - resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.1': resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.7': - resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.1': resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} engines: {node: '>=18'} @@ -567,7 +414,7 @@ packages: resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} peerDependencies: - esbuild: '>=0.18' + esbuild: ^0.28.1 cac@6.7.14: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} @@ -616,11 +463,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - esbuild@0.27.7: - resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.1: resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} engines: {node: '>=18'} @@ -938,159 +780,81 @@ packages: snapshots: - '@esbuild/aix-ppc64@0.27.7': - optional: true - '@esbuild/aix-ppc64@0.28.1': optional: true - '@esbuild/android-arm64@0.27.7': - optional: true - '@esbuild/android-arm64@0.28.1': optional: true - '@esbuild/android-arm@0.27.7': - optional: true - '@esbuild/android-arm@0.28.1': optional: true - '@esbuild/android-x64@0.27.7': - optional: true - '@esbuild/android-x64@0.28.1': optional: true - '@esbuild/darwin-arm64@0.27.7': - optional: true - '@esbuild/darwin-arm64@0.28.1': optional: true - '@esbuild/darwin-x64@0.27.7': - optional: true - '@esbuild/darwin-x64@0.28.1': optional: true - '@esbuild/freebsd-arm64@0.27.7': - optional: true - '@esbuild/freebsd-arm64@0.28.1': optional: true - '@esbuild/freebsd-x64@0.27.7': - optional: true - '@esbuild/freebsd-x64@0.28.1': optional: true - '@esbuild/linux-arm64@0.27.7': - optional: true - '@esbuild/linux-arm64@0.28.1': optional: true - '@esbuild/linux-arm@0.27.7': - optional: true - '@esbuild/linux-arm@0.28.1': optional: true - '@esbuild/linux-ia32@0.27.7': - optional: true - '@esbuild/linux-ia32@0.28.1': optional: true - '@esbuild/linux-loong64@0.27.7': - optional: true - '@esbuild/linux-loong64@0.28.1': optional: true - '@esbuild/linux-mips64el@0.27.7': - optional: true - '@esbuild/linux-mips64el@0.28.1': optional: true - '@esbuild/linux-ppc64@0.27.7': - optional: true - '@esbuild/linux-ppc64@0.28.1': optional: true - '@esbuild/linux-riscv64@0.27.7': - optional: true - '@esbuild/linux-riscv64@0.28.1': optional: true - '@esbuild/linux-s390x@0.27.7': - optional: true - '@esbuild/linux-s390x@0.28.1': optional: true - '@esbuild/linux-x64@0.27.7': - optional: true - '@esbuild/linux-x64@0.28.1': optional: true - '@esbuild/netbsd-arm64@0.27.7': - optional: true - '@esbuild/netbsd-arm64@0.28.1': optional: true - '@esbuild/netbsd-x64@0.27.7': - optional: true - '@esbuild/netbsd-x64@0.28.1': optional: true - '@esbuild/openbsd-arm64@0.27.7': - optional: true - '@esbuild/openbsd-arm64@0.28.1': optional: true - '@esbuild/openbsd-x64@0.27.7': - optional: true - '@esbuild/openbsd-x64@0.28.1': optional: true - '@esbuild/openharmony-arm64@0.27.7': - optional: true - '@esbuild/openharmony-arm64@0.28.1': optional: true - '@esbuild/sunos-x64@0.27.7': - optional: true - '@esbuild/sunos-x64@0.28.1': optional: true - '@esbuild/win32-arm64@0.27.7': - optional: true - '@esbuild/win32-arm64@0.28.1': optional: true - '@esbuild/win32-ia32@0.27.7': - optional: true - '@esbuild/win32-ia32@0.28.1': optional: true - '@esbuild/win32-x64@0.27.7': - optional: true - '@esbuild/win32-x64@0.28.1': optional: true @@ -1250,9 +1014,9 @@ snapshots: dependencies: balanced-match: 4.0.4 - bundle-require@5.1.0(esbuild@0.27.7): + bundle-require@5.1.0(esbuild@0.28.1): dependencies: - esbuild: 0.27.7 + esbuild: 0.28.1 load-tsconfig: 0.2.5 cac@6.7.14: {} @@ -1287,35 +1051,6 @@ snapshots: es-module-lexer@1.7.0: {} - esbuild@0.27.7: - optionalDependencies: - '@esbuild/aix-ppc64': 0.27.7 - '@esbuild/android-arm': 0.27.7 - '@esbuild/android-arm64': 0.27.7 - '@esbuild/android-x64': 0.27.7 - '@esbuild/darwin-arm64': 0.27.7 - '@esbuild/darwin-x64': 0.27.7 - '@esbuild/freebsd-arm64': 0.27.7 - '@esbuild/freebsd-x64': 0.27.7 - '@esbuild/linux-arm': 0.27.7 - '@esbuild/linux-arm64': 0.27.7 - '@esbuild/linux-ia32': 0.27.7 - '@esbuild/linux-loong64': 0.27.7 - '@esbuild/linux-mips64el': 0.27.7 - '@esbuild/linux-ppc64': 0.27.7 - '@esbuild/linux-riscv64': 0.27.7 - '@esbuild/linux-s390x': 0.27.7 - '@esbuild/linux-x64': 0.27.7 - '@esbuild/netbsd-arm64': 0.27.7 - '@esbuild/netbsd-x64': 0.27.7 - '@esbuild/openbsd-arm64': 0.27.7 - '@esbuild/openbsd-x64': 0.27.7 - '@esbuild/openharmony-arm64': 0.27.7 - '@esbuild/sunos-x64': 0.27.7 - '@esbuild/win32-arm64': 0.27.7 - '@esbuild/win32-ia32': 0.27.7 - '@esbuild/win32-x64': 0.27.7 - esbuild@0.28.1: optionalDependencies: '@esbuild/aix-ppc64': 0.28.1 @@ -1523,12 +1258,12 @@ snapshots: tsup@8.5.1(postcss@8.5.23)(tsx@4.23.1)(typescript@5.9.3)(yaml@2.9.0): dependencies: - bundle-require: 5.1.0(esbuild@0.27.7) + bundle-require: 5.1.0(esbuild@0.28.1) cac: 6.7.14 chokidar: 4.0.3 consola: 3.4.2 debug: 4.4.3 - esbuild: 0.27.7 + esbuild: 0.28.1 fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 09a02ca..9c00220 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,3 +2,5 @@ allowBuilds: esbuild: true onlyBuiltDependencies: - esbuild +overrides: + esbuild: ^0.28.1 diff --git a/src/cli.ts b/src/cli.ts index 553d561..6adfd1a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,10 @@ import { readRunReport, renderHumanSummary } from "./core/report.js"; import type { JsonValue } from "./core/types.js"; const VERSION = "0.1.0"; +const shutdown = new AbortController(); + +process.once("SIGINT", () => shutdown.abort()); +process.once("SIGTERM", () => shutdown.abort()); interface GlobalOptions { json?: boolean; @@ -227,6 +231,7 @@ program ...(options.expectBaseFailure ? { expectedBaseFailure: options.expectBaseFailure } : {}), + signal: shutdown.signal, }); const report = await minimize(settings); writeSuccess("minimize", toJson(report), outputOptions(), () => @@ -259,7 +264,7 @@ main().catch((error: unknown) => { cliError.details, outputOptions(), ); - process.exitCode = 1; + process.exitCode = cliError.code === "INTERRUPTED" ? 130 : 1; }); async function main(): Promise { @@ -320,15 +325,14 @@ oracle: # command: [pnpm, install, --frozen-lockfile] # timeout: 15m -quickGates: - - command: [pnpm, typecheck] - timeout: 5m - -fullGates: - - command: [pnpm, test] - timeout: 15m - - command: [pnpm, lint] - timeout: 5m +# Optional checks: +# quickGates: +# - command: [pnpm, typecheck] +# timeout: 5m +# +# fullGates: +# - command: [pnpm, lint] +# timeout: 5m protect: - "tests/**" diff --git a/src/core/config.ts b/src/core/config.ts index 518bbd1..9aace2a 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -43,6 +43,7 @@ export interface CliSettingsInput { timeout?: string; outputDir?: string; expectedBaseFailure?: string; + signal?: AbortSignal; } export async function resolveSettings( @@ -106,6 +107,7 @@ export async function resolveSettings( ...(expectedFailureText ? { expectedBaseFailure: compileRegex(expectedFailureText) } : {}), + ...(input.signal ? { signal: input.signal } : {}), }; } diff --git a/src/core/engine.ts b/src/core/engine.ts index cecc11d..ab2d3a9 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -3,6 +3,7 @@ import { writeFile } from "node:fs/promises"; import { formatCommand } from "./commands.js"; import { CliError, asCliError } from "./errors.js"; import { + captureSetupArtifacts, createRunDirectory, createTemporaryWorktree, inspectRepository, @@ -58,6 +59,12 @@ export async function minimize(settings: MinimizeSettings): Promise { snapshot.nameStatus, settings.protectPatterns, ); + if (changes.every((change) => change.protected)) { + throw new CliError( + "NO_REDUCIBLE_CHANGES", + "Every changed file is protected; there is nothing PatchSlim can minimize.", + ); + } const runId = createRunId(startedAt, snapshot.info.headSha); const directory = await createRunDirectory( snapshot.info, @@ -65,7 +72,11 @@ export async function minimize(settings: MinimizeSettings): Promise { settings.outputDir, ); const paths = reportPaths(directory); - let preflight: PreflightReport = { headRuns: [], passed: false }; + let preflight: PreflightReport = { + headRuns: [], + headGateRuns: [], + passed: false, + }; let worktree: Awaited> | undefined; const baseReport = { @@ -104,9 +115,11 @@ export async function minimize(settings: MinimizeSettings): Promise { await materializePatch(snapshot.info, worktree, fullPatch); if (settings.setup) { - const setupResult = await runCommand(settings.setup, { - cwd: worktree.path, - }); + const expectedStagedPatch = await stagedPatch( + worktree.path, + snapshot.info.baseSha, + ); + const setupResult = await runConfiguredCommand(context, settings.setup); if (!passed(setupResult)) { throw new CliError( "SETUP_FAILED", @@ -114,6 +127,11 @@ export async function minimize(settings: MinimizeSettings): Promise { { result: setupResult }, ); } + await captureSetupArtifacts( + worktree, + snapshot.info.baseSha, + expectedStagedPatch, + ); } preflight = await runPreflight(context, changes, fullPatch); @@ -168,8 +186,11 @@ export async function minimize(settings: MinimizeSettings): Promise { ); } - const finalPatch = await stagedPatch(snapshot.info, worktree.path); + await materializePatch(snapshot.info, worktree, finalPatchInput); + const finalPatch = await stagedPatch(worktree.path, snapshot.info.baseSha); + const applyPatch = await stagedPatch(worktree.path, snapshot.info.headSha); await writeFile(paths.patch, finalPatch, "utf8"); + await writeFile(paths.applyPatch, applyPatch, "utf8"); const keptFiles = changes .filter( @@ -197,6 +218,7 @@ export async function minimize(settings: MinimizeSettings): Promise { artifacts: { ...baseReport.artifacts, patch: paths.patch, + applyPatch: paths.applyPatch, }, reduction: { fileEvaluations: fileResult.evaluations, @@ -243,16 +265,16 @@ async function runPreflight( fullPatch: string, ): Promise { const headRuns: ProcessResult[] = []; + const headGateRuns: ProcessResult[] = []; for (let index = 0; index < context.settings.runs; index += 1) { await materializePatch(context.repository, context.worktree, fullPatch); - const result = await runCommand(context.settings.oracle, { - cwd: context.worktree.path, - }); + const result = await runConfiguredCommand(context, context.settings.oracle); headRuns.push(result); if (!passed(result)) { return { headRuns, + headGateRuns, passed: false, code: "HEAD_ORACLE_UNSTABLE", message: `The oracle did not pass consistently on ${context.repository.headRef}.`, @@ -260,18 +282,36 @@ async function runPreflight( } } + const headGates = [ + ...context.settings.quickGates, + ...context.settings.fullGates, + ]; + for (const gate of headGates) { + await materializePatch(context.repository, context.worktree, fullPatch); + const result = await runConfiguredCommand(context, gate); + headGateRuns.push(result); + if (!passed(result)) { + return { + headRuns, + headGateRuns, + passed: false, + code: "HEAD_GATE_FAILED", + message: `A configured gate failed on ${context.repository.headRef}: ${result.command}`, + }; + } + } + const protectedOnlyPatch = buildFileCandidate(changes, new Set()); await materializePatch( context.repository, context.worktree, protectedOnlyPatch, ); - const baseRun = await runCommand(context.settings.oracle, { - cwd: context.worktree.path, - }); + const baseRun = await runConfiguredCommand(context, context.settings.oracle); if (passed(baseRun)) { return { headRuns, + headGateRuns, baseRun, passed: false, code: "WEAK_ORACLE", @@ -288,6 +328,7 @@ async function runPreflight( ) { return { headRuns, + headGateRuns, baseRun, passed: false, code: "UNEXPECTED_BASE_FAILURE", @@ -296,7 +337,7 @@ async function runPreflight( }; } - return { headRuns, baseRun, passed: true }; + return { headRuns, headGateRuns, baseRun, passed: true }; } async function evaluateCandidate( @@ -314,25 +355,6 @@ async function evaluateCandidate( const startedAt = Date.now(); const results: ProcessResult[] = []; - try { - await materializePatch(context.repository, context.worktree, patch); - } catch (error) { - const evaluation: Evaluation = { - candidateHash, - materialized: false, - passed: false, - cached: false, - durationMs: Date.now() - startedAt, - failedStage: "materialize", - results, - error: error instanceof Error ? error.message : String(error), - }; - if (useCache) { - context.cache.set(candidateHash, evaluation); - } - return evaluation; - } - const stages = [ ...context.settings.quickGates.map((spec, index) => ({ name: `quick-gate-${index + 1}`, @@ -357,9 +379,28 @@ async function evaluateCandidate( ]; for (const stage of stages) { + try { + await materializePatch(context.repository, context.worktree, patch); + } catch (error) { + const evaluation: Evaluation = { + candidateHash, + materialized: false, + passed: false, + cached: false, + durationMs: Date.now() - startedAt, + failedStage: "materialize", + results, + error: error instanceof Error ? error.message : String(error), + }; + if (useCache) { + context.cache.set(candidateHash, evaluation); + } + return evaluation; + } + let result: ProcessResult; try { - result = await runCommand(stage.spec, { cwd: context.worktree.path }); + result = await runConfiguredCommand(context, stage.spec); } catch (error) { const evaluation: Evaluation = { candidateHash, @@ -413,6 +454,16 @@ function passed(result: ProcessResult): boolean { return !result.timedOut && result.exitCode === 0; } +async function runConfiguredCommand( + context: EvaluationContext, + spec: MinimizeSettings["oracle"], +): Promise { + return await runCommand(spec, { + cwd: context.worktree.path, + ...(context.settings.signal ? { signal: context.settings.signal } : {}), + }); +} + function candidateIncludes( change: FileChange, selectedAtomIds: ReadonlySet, diff --git a/src/core/git.ts b/src/core/git.ts index 0aa0090..a95dfff 100644 --- a/src/core/git.ts +++ b/src/core/git.ts @@ -20,6 +20,7 @@ export interface RepositorySnapshot { export interface TemporaryWorktree { parent: string; path: string; + preservePaths: string[]; } export async function inspectRepository( @@ -87,7 +88,7 @@ export async function createTemporaryWorktree( ["worktree", "add", "--detach", worktreePath, repository.baseSha], repository.root, ); - return { parent, path: worktreePath }; + return { parent, path: worktreePath, preservePaths: [] }; } catch (error) { validateTemporaryParent(parent); await rm(parent, { recursive: true, force: true }); @@ -113,7 +114,17 @@ export async function materializePatch( patch: string, ): Promise { await gitText(["reset", "--hard", repository.baseSha], worktree.path); - await gitText(["clean", "-ffd"], worktree.path); + await gitText( + [ + "clean", + "-ffdx", + ...worktree.preservePaths.flatMap((entry) => [ + "-e", + cleanExcludePattern(entry), + ]), + ], + worktree.path, + ); if (patch.length === 0) { return; @@ -135,12 +146,51 @@ export async function materializePatch( } } +export async function captureSetupArtifacts( + worktree: TemporaryWorktree, + baselineRef: string, + expectedStagedPatch: string, +): Promise { + const [actualStagedPatch, modified, untracked] = await Promise.all([ + stagedPatch(worktree.path, baselineRef), + gitRaw(["diff", "--name-only", "-z"], worktree.path), + gitRaw(["ls-files", "--others", "--exclude-standard", "-z"], worktree.path), + ]); + if ( + actualStagedPatch !== expectedStagedPatch || + modified.length > 0 || + untracked.length > 0 + ) { + throw new CliError( + "SETUP_DIRTY", + "The setup command changed tracked files or created unignored files.", + { + stagedPatchChanged: actualStagedPatch !== expectedStagedPatch, + modified: splitNullPaths(modified), + untracked: splitNullPaths(untracked), + }, + ); + } + + const ignored = await gitRaw( + ["status", "--porcelain=v1", "--ignored", "-z"], + worktree.path, + ); + const paths = ignored + .split("\0") + .filter((entry) => entry.startsWith("!! ")) + .map((entry) => entry.slice(3)) + .filter(Boolean); + worktree.preservePaths = [...new Set(paths)]; + return worktree.preservePaths; +} + export async function stagedPatch( - repository: RepositoryInfo, worktreePath: string, + fromRef: string, ): Promise { return await gitRaw( - ["diff", "--cached", "--binary", "--full-index", repository.baseSha, "--"], + ["diff", "--cached", "--binary", "--full-index", fromRef, "--"], worktreePath, ); } @@ -241,3 +291,12 @@ function validateTemporaryParent(parent: string): void { ); } } + +function cleanExcludePattern(relativePath: string): string { + const escaped = relativePath.replaceAll("\\", "\\\\").replaceAll("*", "\\*"); + return `/${escaped.replaceAll("?", "\\?").replaceAll("[", "\\[")}`; +} + +function splitNullPaths(raw: string): string[] { + return raw.split("\0").filter(Boolean); +} diff --git a/src/core/process.ts b/src/core/process.ts index 1111484..ea7d5d5 100644 --- a/src/core/process.ts +++ b/src/core/process.ts @@ -2,6 +2,7 @@ import { spawn } from "node:child_process"; import { performance } from "node:perf_hooks"; import { formatCommand } from "./commands.js"; +import { CliError } from "./errors.js"; import type { CommandSpec, ProcessResult } from "./types.js"; const MAX_CAPTURE_BYTES = 2 * 1024 * 1024; @@ -27,12 +28,17 @@ const SAFE_ENV_NAMES = new Set([ export interface RunOptions { cwd: string; extraEnv?: NodeJS.ProcessEnv; + signal?: AbortSignal; } export async function runCommand( spec: CommandSpec, options: RunOptions, ): Promise { + if (options.signal?.aborted) { + throw interruptedError(); + } + const startedAt = performance.now(); const command = Array.isArray(spec.command) ? spec.command[0] : spec.command; const args = Array.isArray(spec.command) ? spec.command.slice(1) : []; @@ -54,6 +60,7 @@ export async function runCommand( let stdout = ""; let stderr = ""; let timedOut = false; + let aborted = false; let settled = false; child.stdout?.on("data", (chunk: Buffer) => { @@ -67,6 +74,13 @@ export async function runCommand( timedOut = true; terminateProcess(child.pid); }, spec.timeoutMs); + const abortHandler = (): void => { + if (!settled) { + aborted = true; + terminateProcess(child.pid); + } + }; + options.signal?.addEventListener("abort", abortHandler, { once: true }); child.once("error", (error) => { if (settled) { @@ -74,7 +88,8 @@ export async function runCommand( } settled = true; clearTimeout(timer); - reject(error); + options.signal?.removeEventListener("abort", abortHandler); + reject(aborted ? interruptedError() : error); }); child.once("close", (exitCode, signal) => { @@ -83,6 +98,11 @@ export async function runCommand( } settled = true; clearTimeout(timer); + options.signal?.removeEventListener("abort", abortHandler); + if (aborted) { + reject(interruptedError()); + return; + } resolve({ command: formatCommand(spec.command), exitCode, @@ -96,6 +116,13 @@ export async function runCommand( }); } +function interruptedError(): CliError { + return new CliError( + "INTERRUPTED", + "PatchSlim was interrupted while a command was running.", + ); +} + function buildSafeEnvironment(extraEnv?: NodeJS.ProcessEnv): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { CI: "1", PATCHSLIM: "1" }; diff --git a/src/core/report.ts b/src/core/report.ts index 4922231..a0a486e 100644 --- a/src/core/report.ts +++ b/src/core/report.ts @@ -69,6 +69,7 @@ export function renderHumanSummary(report: RunReport): string { `Oracle evaluations: ${(reduction?.fileEvaluations ?? 0) + (reduction?.hunkEvaluations ?? 0)}`, `Cache hits: ${reduction?.cacheHits ?? 0}`, "", + `Apply to HEAD: ${report.artifacts.applyPatch ?? "n/a"}`, `Candidate patch: ${report.artifacts.patch ?? "n/a"}`, `Report: ${report.artifacts.reportMarkdown ?? report.artifacts.reportJson}`, ].join("\n"); @@ -111,6 +112,16 @@ export function renderMarkdownReport(report: RunReport): string { ); } + if (report.status === "completed") { + lines.push( + "", + "## Artifacts", + "", + `- Apply to the original head: \`${report.artifacts.applyPatch ?? "n/a"}\``, + `- Recreate the candidate from the base: \`${report.artifacts.patch ?? "n/a"}\``, + ); + } + if (report.error) { lines.push( "", @@ -135,11 +146,13 @@ export function renderMarkdownReport(report: RunReport): string { export function reportPaths(directory: string): { patch: string; + applyPatch: string; reportJson: string; reportMarkdown: string; } { return { patch: path.join(directory, "candidate.patch"), + applyPatch: path.join(directory, "apply.patch"), reportJson: path.join(directory, "report.json"), reportMarkdown: path.join(directory, "report.md"), }; diff --git a/src/core/types.ts b/src/core/types.ts index a7e3412..b33dea2 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -89,10 +89,12 @@ export interface MinimizeSettings { budgetMs: number; outputDir?: string; expectedBaseFailure?: RegExp; + signal?: AbortSignal; } export interface PreflightReport { headRuns: ProcessResult[]; + headGateRuns: ProcessResult[]; baseRun?: ProcessResult; passed: boolean; code?: string; @@ -125,6 +127,7 @@ export interface RunReport { artifacts: { directory: string; patch?: string; + applyPatch?: string; reportJson: string; reportMarkdown?: string; }; diff --git a/test/engine.test.ts b/test/engine.test.ts index f0208c8..7e57064 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -1,4 +1,5 @@ -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -7,20 +8,26 @@ import { describe, expect, it } from "vitest"; import { commandSpec } from "../src/core/commands.js"; import { minimize } from "../src/core/engine.js"; import { DEFAULT_PROTECT_PATTERNS } from "../src/core/patch.js"; -import { createFixtureRepository, git } from "./helpers.js"; +import { + createFixtureRepository, + createPythonFixtureRepository, + git, +} from "./helpers.js"; describe("minimize", () => { it("removes a redundant file and redundant hunk while preserving tests", async () => { const fixture = createFixtureRepository(); writeFileSync(path.join(fixture.root, "notes.local"), "keep me\n", "utf8"); const statusBefore = git(fixture.root, ["status", "--porcelain=v1", "-z"]); + const mutatingGate = + '(async()=>{const fs=require("node:fs");const {triple}=await import("./src/math.mjs");if(triple(3)!==9)process.exit(1);fs.appendFileSync("src/math.mjs","\\n// gate output\\n")})()'; const report = await minimize({ cwd: fixture.root, baseRef: fixture.baseSha, headRef: fixture.headSha, oracle: commandSpec("node tests/feature.test.mjs", 10_000), quickGates: [], - fullGates: [commandSpec("node tests/feature.test.mjs", 10_000)], + fullGates: [commandSpec([process.execPath, "-e", mutatingGate], 10_000)], protectPatterns: DEFAULT_PROTECT_PATTERNS, runs: 2, budgetMs: 30_000, @@ -28,9 +35,11 @@ describe("minimize", () => { expect(report.status).toBe("completed"); expect(report.reduction?.removedFiles).toContain("src/redundant.mjs"); + expect(report.reduction?.removedFiles).toContain("src/data.bin"); expect(report.reduction?.removedHunks).toContain("hunk:src/math.mjs:1"); expect(report.reduction?.keptHunks).toContain("hunk:src/math.mjs:0"); expect(report.validation?.results).toHaveLength(3); + expect(report.preflight.headGateRuns).toHaveLength(1); expect(report.protected.map((item) => item.path)).toContain( "tests/feature.test.mjs", ); @@ -39,6 +48,41 @@ describe("minimize", () => { expect(patch).toContain("return value * 3"); expect(patch).not.toContain("src/redundant.mjs"); expect(patch).not.toContain("return String(value)"); + expect(patch).not.toContain("gate output"); + const applyPatch = readFileSync(report.artifacts.applyPatch!, "utf8"); + expect(applyPatch).toContain("src/redundant.mjs"); + expect(applyPatch).not.toContain("return value * 2"); + + const applyParent = mkdtempSync(path.join(tmpdir(), "patchslim-apply-")); + const applyWorktree = path.join(applyParent, "checkout"); + git(fixture.root, [ + "worktree", + "add", + "--detach", + applyWorktree, + fixture.headSha, + ]); + try { + git(applyWorktree, ["apply", "--check", report.artifacts.applyPatch!]); + git(applyWorktree, ["apply", "--index", report.artifacts.applyPatch!]); + execFileSync(process.execPath, ["tests/feature.test.mjs"], { + cwd: applyWorktree, + stdio: "pipe", + }); + expect( + git(applyWorktree, [ + "diff", + "--cached", + "--binary", + "--full-index", + fixture.baseSha, + "--", + ]), + ).toBe(patch.trim()); + } finally { + git(fixture.root, ["worktree", "remove", "--force", applyWorktree]); + } + expect(git(fixture.root, ["status", "--porcelain=v1", "-z"])).toBe( statusBefore, ); @@ -52,6 +96,106 @@ describe("minimize", () => { ).toHaveLength(1); }); + it("preserves setup dependencies while clearing ignored oracle output", async () => { + const fixture = createFixtureRepository(); + const setupScript = + 'const fs=require("node:fs");fs.mkdirSync(".deps",{recursive:true});fs.writeFileSync(".deps/ready","yes")'; + const oracleScript = + '(async()=>{const fs=require("node:fs");if(!fs.existsSync(".deps/ready"))process.exit(2);const {triple}=await import("./src/math.mjs");if(triple(3)===9){fs.mkdirSync(".oracle-cache",{recursive:true});fs.writeFileSync(".oracle-cache/pass","yes");process.exit(0)}process.exit(fs.existsSync(".oracle-cache/pass")?0:1)})()'; + + const report = await minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec([process.execPath, "-e", oracleScript], 10_000), + setup: commandSpec([process.execPath, "-e", setupScript], 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }); + + expect(report.status).toBe("completed"); + expect(report.reduction?.keptHunks).toContain("hunk:src/math.mjs:0"); + }); + + it("isolates quick-gate side effects from the oracle", async () => { + const fixture = createFixtureRepository(); + const quickScript = + 'const fs=require("node:fs");fs.mkdirSync(".oracle-cache",{recursive:true});fs.writeFileSync(".oracle-cache/pass","yes")'; + const oracleScript = + '(async()=>{const fs=require("node:fs");const {triple}=await import("./src/math.mjs");process.exit(triple(3)===9||fs.existsSync(".oracle-cache/pass")?0:1)})()'; + const report = await minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec([process.execPath, "-e", oracleScript], 10_000), + quickGates: [commandSpec([process.execPath, "-e", quickScript], 10_000)], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }); + + expect(report.status).toBe("completed"); + expect(report.reduction?.keptHunks).toContain("hunk:src/math.mjs:0"); + }); + + it("minimizes a Python change with the same language-agnostic engine", async () => { + const fixture = createPythonFixtureRepository(); + const report = await minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec("python3 tests/feature_test.py", 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }); + + expect(report.status).toBe("completed"); + expect(report.reduction?.removedFiles).toContain("src/redundant.py"); + expect(readFileSync(report.artifacts.patch!, "utf8")).not.toContain( + "return str(value)", + ); + }); + + it("rejects setup commands that leave unignored files behind", async () => { + const fixture = createFixtureRepository(); + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec("node tests/feature.test.mjs", 10_000), + setup: commandSpec( + [ + process.execPath, + "-e", + 'require("node:fs").writeFileSync("setup-output.txt","dirty")', + ], + 10_000, + ), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "SETUP_DIRTY", + }); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); + it("fails closed when the oracle also passes without production changes", async () => { const fixture = createFixtureRepository(); @@ -107,4 +251,83 @@ describe("minimize", () => { ), ).toHaveLength(1); }); + + it("fails before reduction when a configured gate is already red", async () => { + const fixture = createFixtureRepository(); + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec("node tests/feature.test.mjs", 10_000), + quickGates: [], + fullGates: [commandSpec('node -e "process.exit(1)"', 10_000)], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "HEAD_GATE_FAILED", + }); + }); + + it("cleans up its worktree when interrupted", async () => { + const fixture = createFixtureRepository(); + const controller = new AbortController(); + const reduction = minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec( + [process.execPath, "-e", "setTimeout(() => {}, 10_000)"], + 20_000, + ), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + signal: controller.signal, + }); + setTimeout(() => controller.abort(), 150); + + await expect(reduction).rejects.toMatchObject({ + code: "INTERRUPTED", + }); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); + + it("stops clearly when every change is protected", async () => { + const fixture = createFixtureRepository(); + mkdirSync(path.join(fixture.root, "docs"), { recursive: true }); + writeFileSync( + path.join(fixture.root, "docs", "usage.md"), + "documentation only\n", + "utf8", + ); + git(fixture.root, ["add", "."]); + git(fixture.root, ["commit", "-m", "document usage"]); + const docsHead = git(fixture.root, ["rev-parse", "HEAD"]); + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.headSha, + headRef: docsHead, + oracle: commandSpec("node tests/feature.test.mjs", 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "NO_REDUCIBLE_CHANGES", + }); + }); }); diff --git a/test/helpers.ts b/test/helpers.ts index 6405e07..abaec65 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -17,13 +17,16 @@ export function createFixtureRepository(): FixtureRepository { mkdirSync(path.join(root, "src"), { recursive: true }); mkdirSync(path.join(root, "tests"), { recursive: true }); + write(root, ".gitignore", ".deps/\n.oracle-cache/\n"); write(root, "src/math.mjs", baseMathSource()); + writeFileSync(path.join(root, "src", "data.bin"), Buffer.from([0, 1, 2])); write(root, "tests/feature.test.mjs", baseTestSource()); git(root, ["add", "."]); git(root, ["commit", "-m", "initial fixture"]); const baseSha = git(root, ["rev-parse", "HEAD"]); write(root, "src/math.mjs", headMathSource()); + writeFileSync(path.join(root, "src", "data.bin"), Buffer.from([0, 9, 8, 7])); write(root, "src/redundant.mjs", "export const noise = true;\n"); write(root, "tests/feature.test.mjs", headTestSource()); git(root, ["add", "."]); @@ -33,6 +36,94 @@ export function createFixtureRepository(): FixtureRepository { return { root, baseSha, headSha }; } +export function createPythonFixtureRepository(): FixtureRepository { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-python-fixture-")); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "PatchSlim Tests"]); + git(root, ["config", "user.email", "patchslim@example.invalid"]); + + mkdirSync(path.join(root, "src"), { recursive: true }); + mkdirSync(path.join(root, "tests"), { recursive: true }); + write( + root, + "src/message.py", + `def greet(name): + return f"Hello, {name}" + + +padding01 = 1 +padding02 = 2 +padding03 = 3 +padding04 = 4 +padding05 = 5 +padding06 = 6 +padding07 = 7 +padding08 = 8 + + +def label(value): + return value +`, + ); + write( + root, + "tests/feature_test.py", + `import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from src.message import greet + +assert greet("Ada") == "Hello, Ada" +`, + ); + git(root, ["add", "."]); + git(root, ["commit", "-m", "initial fixture"]); + const baseSha = git(root, ["rev-parse", "HEAD"]); + + write( + root, + "src/message.py", + `def greet(name): + return f"Hello, {name}!" + + +padding01 = 1 +padding02 = 2 +padding03 = 3 +padding04 = 4 +padding05 = 5 +padding06 = 6 +padding07 = 7 +padding08 = 8 + + +def label(value): + return str(value) +`, + ); + write(root, "src/redundant.py", "noise = True\n"); + write( + root, + "tests/feature_test.py", + `import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parents[1])) + +from src.message import greet + +assert greet("Ada") == "Hello, Ada!" +`, + ); + git(root, ["add", "."]); + git(root, ["commit", "-m", "add punctuation"]); + const headSha = git(root, ["rev-parse", "HEAD"]); + + return { root, baseSha, headSha }; +} + export function git(root: string, args: string[]): string { return execFileSync("git", args, { cwd: root, diff --git a/test/patch.test.ts b/test/patch.test.ts new file mode 100644 index 0000000..2ec5dbc --- /dev/null +++ b/test/patch.test.ts @@ -0,0 +1,97 @@ +import { execFileSync } from "node:child_process"; +import { + chmodSync, + mkdirSync, + mkdtempSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { DEFAULT_PROTECT_PATTERNS, parseChanges } from "../src/core/patch.js"; +import { git } from "./helpers.js"; + +describe("parseChanges", () => { + it("keeps renames, binaries, modes, and protected paths atomic", () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-special-")); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "PatchSlim Tests"]); + git(root, ["config", "user.email", "patchslim@example.invalid"]); + mkdirSync(path.join(root, "src"), { recursive: true }); + mkdirSync(path.join(root, "docs"), { recursive: true }); + writeFileSync(path.join(root, "src", "old name.txt"), "hello\n", "utf8"); + writeFileSync(path.join(root, "src", "binary.dat"), Buffer.from([0, 1, 2])); + writeFileSync(path.join(root, "src", "script.sh"), "#!/bin/sh\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + const base = git(root, ["rev-parse", "HEAD"]); + + renameSync( + path.join(root, "src", "old name.txt"), + path.join(root, "src", "new name.txt"), + ); + writeFileSync( + path.join(root, "src", "binary.dat"), + Buffer.from([0, 9, 8, 7]), + ); + chmodSync(path.join(root, "src", "script.sh"), 0o755); + writeFileSync(path.join(root, "docs", "notes.md"), "keep this\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "special changes"]); + const head = git(root, ["rev-parse", "HEAD"]); + + const diff = gitOutput(root, [ + "diff", + "--binary", + "--full-index", + base, + head, + "--", + ]); + const nameStatus = gitOutput(root, [ + "diff", + "--name-status", + "-z", + base, + head, + "--", + ]); + const changes = parseChanges(diff, nameStatus, DEFAULT_PROTECT_PATTERNS); + + expect( + changes.find((change) => change.path === "src/new name.txt"), + ).toMatchObject({ + oldPath: "src/old name.txt", + status: "renamed", + atomic: true, + }); + expect( + changes.find((change) => change.path === "src/binary.dat"), + ).toMatchObject({ + binary: true, + atomic: true, + }); + expect( + changes.find((change) => change.path === "src/script.sh"), + ).toMatchObject({ + atomic: true, + }); + expect( + changes.find((change) => change.path === "docs/notes.md"), + ).toMatchObject({ + protected: true, + atomic: true, + }); + }); +}); + +function gitOutput(root: string, args: string[]): string { + return execFileSync("git", args, { + cwd: root, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); +} diff --git a/test/process.test.ts b/test/process.test.ts index e59b08f..4a6144e 100644 --- a/test/process.test.ts +++ b/test/process.test.ts @@ -27,4 +27,41 @@ describe("runCommand", () => { expect(result.timedOut).toBe(true); expect(result.exitCode).not.toBe(0); }); + + it("terminates commands when the caller aborts", async () => { + const controller = new AbortController(); + const result = runCommand( + commandSpec( + [process.execPath, "-e", "setTimeout(() => {}, 10_000)"], + 5_000, + ), + { cwd: process.cwd(), signal: controller.signal }, + ); + setTimeout(() => controller.abort(), 50); + + await expect(result).rejects.toMatchObject({ + code: "INTERRUPTED", + }); + }); + + it("does not forward secret-like environment variables", async () => { + process.env.PATCHSLIM_TEST_TOKEN = "do-not-forward"; + try { + const result = await runCommand( + commandSpec( + [ + process.execPath, + "-e", + "process.stdout.write(String(process.env.PATCHSLIM_TEST_TOKEN))", + ], + 5_000, + ), + { cwd: process.cwd() }, + ); + + expect(result.stdout).toBe("undefined"); + } finally { + delete process.env.PATCHSLIM_TEST_TOKEN; + } + }); }); diff --git a/test/reducer.test.ts b/test/reducer.test.ts index c224ff9..ae71a6c 100644 --- a/test/reducer.test.ts +++ b/test/reducer.test.ts @@ -20,4 +20,27 @@ describe("ddmin", () => { expect(result.kept).toEqual(["a", "b"]); expect(result.evaluations).toBe(0); }); + + it("removes all irrelevant atoms across varied monotonic predicates", async () => { + for (let size = 1; size <= 32; size += 1) { + const values = Array.from( + { length: size }, + (_, index) => `atom-${index}`, + ); + const required = values.filter( + (_, index) => (index * 17 + size * 11) % 7 === 0, + ); + if (required.length === 0) { + required.push(values[size % values.length]!); + } + + const result = await ddmin( + values, + async (candidate) => required.every((atom) => candidate.includes(atom)), + Date.now() + 5_000, + ); + + expect(result.kept).toEqual(required); + } + }); }); diff --git a/test/report.test.ts b/test/report.test.ts index bd2272e..96b6d77 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -21,7 +21,7 @@ describe("renderMarkdownReport", () => { }, before: { files: 1, additions: 1, deletions: 0 }, protected: [], - preflight: { headRuns: [], passed: false }, + preflight: { headRuns: [], headGateRuns: [], passed: false }, artifacts: { directory: "/repo/.git/patchslim/runs/example", reportJson: "/repo/.git/patchslim/runs/example/report.json", From e8979be72c9eaa656a01accde4aa0549e8ff9ac8 Mon Sep 17 00:00:00 2001 From: Apex Studio-He <2855213763@qq.com> Date: Tue, 28 Jul 2026 23:16:04 +0800 Subject: [PATCH 3/4] Keep CI compatible with Node 20 --- .github/workflows/ci.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d03b45..c10b23c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,7 +16,7 @@ jobs: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 with: - version: 11.9.0 + version: 10.34.5 - uses: actions/setup-node@v4 with: node-version: 20 diff --git a/package.json b/package.json index 31ab704..9fccf42 100644 --- a/package.json +++ b/package.json @@ -52,5 +52,5 @@ "typescript": "^5.8.3", "vitest": "^3.2.4" }, - "packageManager": "pnpm@11.9.0" + "packageManager": "pnpm@10.34.5" } From c0c5dc9d1270d60fc605eba8d6a1b44047e95765 Mon Sep 17 00:00:00 2001 From: Apex Studio-He <2855213763@qq.com> Date: Wed, 29 Jul 2026 00:20:15 +0800 Subject: [PATCH 4/4] Prepare PatchSlim 0.1.0 for release Repeat protected-base checks, preserve rename protection, and validate configuration and report inputs. Add release documentation, package smoke coverage, and a Node 20/22/24 CI matrix. --- .github/workflows/ci.yml | 16 ++- .markdownlint.json | 3 + CHANGELOG.md | 38 +++++ README.md | 293 ++++++++++++++++++++++++++++----------- docs/patchslim-flow.svg | 71 ++++++++++ package.json | 8 +- pnpm-lock.yaml | 10 +- src/core/config.ts | 43 +++++- src/core/engine.ts | 57 ++++++-- src/core/patch.ts | 30 ++-- src/core/report.ts | 99 ++++++++++++- src/core/types.ts | 1 + test/cli.test.ts | 101 ++++++++++++++ test/config.test.ts | 46 ++++++ test/engine.test.ts | 33 +++++ test/patch.test.ts | 90 ++++++++++++ test/report.test.ts | 27 +++- 17 files changed, 848 insertions(+), 118 deletions(-) create mode 100644 .markdownlint.json create mode 100644 CHANGELOG.md create mode 100644 docs/patchslim-flow.svg create mode 100644 test/cli.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c10b23c..171de41 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,15 +11,23 @@ permissions: jobs: check: + name: Node ${{ matrix.node-version }} runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: + - 20 + - 22 + - 24 steps: - - uses: actions/checkout@v4 - - uses: pnpm/action-setup@v4 + - uses: actions/checkout@v7.0.1 + - uses: pnpm/action-setup@v6.0.9 with: version: 10.34.5 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@v7.0.0 with: - node-version: 20 + node-version: ${{ matrix.node-version }} cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm check diff --git a/.markdownlint.json b/.markdownlint.json new file mode 100644 index 0000000..67d2ae5 --- /dev/null +++ b/.markdownlint.json @@ -0,0 +1,3 @@ +{ + "MD013": false +} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41e4ce7 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,38 @@ +# Changelog + +All notable changes to PatchSlim are documented here. + +## 0.1.0 — 2026-07-29 + +Initial preview release. + +### Added + +- File-level and hunk-level delta debugging for committed Git branch diffs. +- `doctor`, `init`, `inspect`, `minimize`, and `report` commands with stable + JSON output. +- Merge-base discovery, isolated temporary worktrees, candidate caching, and + bounded command execution. +- Conservative protection for tests, fixtures, snapshots, migrations, + documentation, CI, manifests, lockfiles, PatchSlim configuration, and Git + control files. +- Head-pass/protected-base-fail preflight with repeated instability checks. +- Quick candidate gates, repeated final oracles, and full validation gates. +- `apply.patch`, `candidate.patch`, JSON reports, and Markdown reports. + +### Safety + +- Rejects weak or unstable oracles, red head gates, dirty setup output, + candidate materialization failures, timeouts, and interruptions. +- Reconstructs every command stage from the merge base and clears ignored + side effects between stages. +- Never applies a candidate to the current checkout automatically. + +### Verified + +- JavaScript and Python fixtures. +- Text hunks, added files, binary diffs, renames, mode changes, paths with + spaces, and Unicode paths. +- Exact application of `apply.patch` to the original head. +- Node.js 20, 22, and 24, package linting, clean production dependency audit, + and GitHub Actions. diff --git a/README.md b/README.md index 36a14ae..c6ddae0 100644 --- a/README.md +++ b/README.md @@ -1,63 +1,89 @@ # PatchSlim -PatchSlim finds a smaller Git diff that still passes the checks you care about. +[![CI](https://github.com/Apex-Studio-He/patchslim/actions/workflows/ci.yml/badge.svg)](https://github.com/Apex-Studio-He/patchslim/actions/workflows/ci.yml) +[![Node.js 20+](https://img.shields.io/badge/Node.js-20%2B-339933?logo=node.js&logoColor=white)](package.json) +[![License: MIT](https://img.shields.io/badge/License-MIT-0f172a.svg)](LICENSE) -Large refactors, speculative abstractions, and machine-assisted edits often leave -behind changes that are unrelated to the behavior a pull request is meant to -deliver. PatchSlim treats your test command as an oracle, removes files and hunks -in a temporary worktree, and keeps only candidates that continue to pass. +PatchSlim finds a smaller committed Git diff that still passes the checks you +care about. -> PatchSlim is an early preview. Its result is evidence from the checks you -> provide, not proof of behavioral equivalence. Review every candidate patch. +Large refactors and experimental changes often leave unrelated files, defensive +abstractions, or redundant hunks in a pull request. PatchSlim treats a focused +test command as an oracle, searches the branch diff in an isolated worktree, and +produces a smaller candidate with the evidence used to accept it. -## How it works +> PatchSlim is an early preview. A passing candidate is evidence relative to +> the configured checks, not proof of complete behavioral equivalence. Review +> every generated patch. -PatchSlim builds each candidate from: +![PatchSlim reduction workflow](docs/patchslim-flow.svg) -```text -merge base + protected changes + selected reducible changes -``` +## What it does + +PatchSlim answers a narrow, practical question: + +> Which committed changes can be removed while this behavior and its required +> checks still pass? + +It does not rewrite code or judge style. It searches subsets of the existing +diff, first by file and then by text hunk. + +In the repository fixture used by the test suite: + +| | Original branch | Minimized candidate | +| ------------- | --------------: | ------------------: | +| Changed files | 4 | 2 | +| Added lines | 8 | 6 | +| Deleted lines | 3 | 2 | -Before minimizing anything, it verifies that the configured oracle: +The required feature hunk and its protected test remain. A redundant source +file, a binary change, and an unrelated hunk are removed. This is a +deterministic fixture result, not a general reduction benchmark. -1. passes on the full branch; -2. fails when only protected changes remain. +## Install -That second check catches weak tests before they can produce an empty or -misleading patch. Candidates are evaluated in an isolated Git worktree, so the -original checkout is left alone. +PatchSlim requires Node.js 20 or newer. -By default, tests, fixtures, snapshots, migrations, CI configuration, package -manifests, and lockfiles are protected. Reports and candidate patches are stored -under `.git/patchslim/runs/`. +Install the signed release artifact from GitHub: -## Install from source +```bash +npm install --global \ + https://github.com/Apex-Studio-He/patchslim/releases/download/v0.1.0/patchslim-0.1.0.tgz +``` -PatchSlim requires Node.js 20 or newer and pnpm. +Or build it from source: ```bash git clone https://github.com/Apex-Studio-He/patchslim.git cd patchslim -pnpm install +corepack enable +pnpm install --frozen-lockfile pnpm build npm link ``` -Confirm the command is available from another directory: +Confirm the command is available: ```bash +patchslim --version patchslim --json doctor ``` ## Quick start -Inspect the diff and default protection rules: +Inspect the committed branch diff and the default protection rules: ```bash patchslim inspect --base main ``` -Minimize it with a feature-specific test: +Create an optional starter configuration: + +```bash +patchslim init +``` + +Minimize the diff with a feature-specific test: ```bash patchslim minimize \ @@ -67,102 +93,213 @@ patchslim minimize \ --gate "pnpm test" ``` -PatchSlim writes two patches: +PatchSlim prints the artifact paths after successful validation. It never +applies the candidate to the current checkout. -- `apply.patch` transforms the original head into the minimized result; -- `candidate.patch` recreates the minimized result from the merge base. +## Commands -To slim the current branch, inspect `apply.patch` before applying it: +| Command | Purpose | +| ------------------------- | ----------------------------------------------------------- | +| `patchslim doctor` | Check Git, repository, configuration, and runtime readiness | +| `patchslim init` | Create a conservative `.patchslim.yml` | +| `patchslim inspect` | Show the committed diff and protection classification | +| `patchslim minimize` | Search for a smaller passing candidate | +| `patchslim report ` | Read a JSON run report in human or JSON form | -```bash -git apply --check /path/to/apply.patch -git apply /path/to/apply.patch +Every command supports `--json`. Use `patchslim --help` for all +options. + +## How the search works + +Each candidate is built from: + +```text +merge base + protected changes + selected reducible changes ``` -It never applies the candidate to your current checkout automatically. +The run has four stages: + +1. **Preflight.** The oracle must repeatedly pass on the full head and + repeatedly fail when all reducible production changes are removed. +2. **File reduction.** Delta debugging searches removable file groups. +3. **Hunk reduction.** Remaining ordinary text files are split into hunks and + searched again. +4. **Final validation.** The best candidate repeats the oracle and runs every + full gate before artifacts are written. + +Every candidate and every command stage starts from a clean reconstruction in a +temporary Git worktree. Identical candidate states are cached. + +## Safety model + +PatchSlim fails closed instead of presenting an unsafe candidate when: + +| Condition | Result | +| ------------------------------------------------------ | -------------------------------- | +| The oracle fails or changes result on the full head | `HEAD_ORACLE_UNSTABLE` | +| The oracle passes without reducible production changes | `WEAK_ORACLE` | +| Protected-base oracle runs disagree | `BASE_ORACLE_UNSTABLE` | +| A configured gate is already red on the head | `HEAD_GATE_FAILED` | +| Setup changes tracked state or creates unignored files | `SETUP_DIRTY` | +| A candidate cannot be reconstructed | Candidate rejected | +| A command times out or the run is interrupted | Run stopped and worktree cleaned | + +Tests, fixtures, snapshots, migrations, documentation, CI configuration, +manifests, lockfiles, `.patchslim.yml`, and Git control files are protected by +default. Renames remain protected when either their old or new path matches a +protection rule. + +Use `--no-default-protect` only after reviewing the resulting safety boundary. + +## Choosing a useful oracle + +The oracle defines what “still works” means. Prefer the narrowest deterministic +check that captures the behavior introduced by the branch: + +- a focused regression test; +- a package-level test command; +- a reproducible script that exits non-zero when the feature is missing. + +A useful feature oracle passes on the branch head and fails on the protected +base candidate. Avoid checks that pass before and after the feature, unstable +tests, and commands that modify external systems. + +Use `--runs` to control repeated head, protected-base, and final-candidate +checks. Use `--expect-base-failure` when the base must fail for a specific +reason. ## Configuration -Run `patchslim init` to create `.patchslim.yml`: +`.patchslim.yml` uses a versioned, strict schema. Unknown keys and unsupported +versions are rejected so misspelled safety settings cannot be ignored silently. ```yaml +version: 1 base: main -oracle: pnpm vitest run src/auth/login.test.ts -quick: - - pnpm typecheck -gates: - - pnpm test + +oracle: + command: [pnpm, vitest, run, src/auth/login.test.ts] + timeout: 5m + +setup: + command: [pnpm, install, --frozen-lockfile] + timeout: 15m + +quickGates: + - command: [pnpm, typecheck] + timeout: 5m + +fullGates: + - command: [pnpm, test] + timeout: 10m + +protect: + - "src/auth/fixtures/**" + runs: 2 budget: 30m -timeout: 10m -protect: - - src/auth/fixtures/** +expectedBaseFailure: "login is not implemented" ``` -Command-line options override configuration values. Run -`patchslim minimize --help` for the complete option list. - -## Choosing an oracle +Command-line values override configuration values. A CLI `--timeout` becomes +the default timeout for oracle, setup, and gate commands that do not specify +their own timeout. -The oracle defines what “still works” means. Prefer the narrowest deterministic -check that captures the intended behavior: +## Artifacts -- a focused regression test; -- a package-level test suite; -- a reproducible script that exits non-zero when behavior is missing. +Successful runs write: -Avoid broad checks that pass both before and after the feature, unstable tests, -and commands that modify external systems. Use `--runs` to repeat the oracle -when occasional flakiness is a concern. +| Artifact | Use | +| ----------------- | -------------------------------------------------------------- | +| `apply.patch` | Transform the original head into the minimized candidate | +| `candidate.patch` | Recreate the minimized candidate from the merge base | +| `report.json` | Machine-readable inputs, checks, timings, reduction, and paths | +| `report.md` | Review-friendly run summary | -## Protected files +The default location is `.git/patchslim/runs//`. -Protection is a safety boundary, not an optimization hint. Protected changes are -included in every candidate and are never offered to the reducer. +Inspect and apply the head-relative patch manually: -Add repository-specific patterns with repeated `--protect` flags or the -configuration file. Use `--no-default-protect` only when you have reviewed the -consequences. +```bash +git apply --check /path/to/apply.patch +git apply /path/to/apply.patch +``` ## JSON output -Every command supports `--json` for scripts and coding agents: +Successful commands return: -```bash -patchslim --json inspect --base main -patchslim --json minimize --oracle "pnpm test" -patchslim --json report .git/patchslim/runs//report.json +```json +{ + "ok": true, + "command": "inspect", + "data": {} +} +``` + +Failures return a non-zero exit status and: + +```json +{ + "ok": false, + "error": { + "code": "WEAK_ORACLE", + "message": "The oracle also passes with all reducible production changes removed." + } +} ``` -Successful responses use `{ "ok": true, "command": "...", "data": ... }`. -Failures use `{ "ok": false, "error": { "code": "...", "message": "..." } }` -and a non-zero exit status. +## Verification + +The v0.1.0 release is covered by 38 automated tests across eight test files. + +| Area | Verified behavior | +| --------------- | ------------------------------------------------------------------------ | +| Reduction | File and hunk minimization, caching, deterministic results | +| Preflight | Weak oracle, unstable head, unstable protected base, red gates | +| Isolation | Dirty setup rejection, ignored-cache cleanup, mutating gate isolation | +| Git changes | Text, added files, binary data, renames, modes, spaces, Unicode | +| Artifacts | `apply.patch` recreates the exact candidate tree from the original head | +| Process control | Timeouts, secret-like environment filtering, SIGINT cleanup | +| Configuration | Precedence, strict schema, duration parsing, expected failure regex | +| CLI and package | Stable JSON, malformed reports, Node 20/22/24, package lint, clean audit | + +Run the same release checks locally: + +```bash +pnpm install --frozen-lockfile +pnpm release:check +``` ## Security -PatchSlim executes repository-provided commands and checks out repository -content in a temporary worktree. Run it only in repositories you trust. See -[SECURITY.md](SECURITY.md) for details. +PatchSlim executes repository-provided setup, oracle, and gate commands with the +current user's host permissions. Run it only in repositories and revisions you +trust. Reports contain captured command output, so review them before sharing. + +See [SECURITY.md](SECURITY.md) for the complete trust model. ## Current limitations - Only committed changes between the base and head revisions are minimized. -- Renames, binary files, and protected paths are treated as atomic changes. +- Renames, binary files, mode changes, and protected paths are atomic. - The reducer seeks a locally minimal passing patch; it does not guarantee the globally smallest patch. - Test coverage and oracle quality determine the quality of the result. -- Ignored directories created by `setup` are preserved between candidates. +- Ignored directories created by setup are preserved between candidates. Disable mutable caches inside dependency directories when reproducibility is critical. +- There is no sandboxed or container executor in v0.1.0. -## Development +## Contributing ```bash -pnpm install +pnpm install --frozen-lockfile pnpm check ``` -See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing changes. +See [CONTRIBUTING.md](CONTRIBUTING.md) before proposing a change. Release history +is available in [CHANGELOG.md](CHANGELOG.md). ## License diff --git a/docs/patchslim-flow.svg b/docs/patchslim-flow.svg new file mode 100644 index 0000000..01bb264 --- /dev/null +++ b/docs/patchslim-flow.svg @@ -0,0 +1,71 @@ + + PatchSlim reduction workflow + A branch diff is split into protected and reducible changes. PatchSlim tests file and hunk candidates in a temporary worktree, keeps passing candidates, and writes patches plus a report. + + + + + + + + + + + + + TEST-GUIDED DIFF MINIMIZATION + Keep the behavior. Remove the unnecessary change. + + + + + + + + + + + INPUT + Branch diff + merge base → HEAD + committed changes only + + ALWAYS KEEP + Protected + tests · CI · manifests + + SEARCH SPACE + Reducible + source files and hunks + + REDUCER + file → hunk ddmin + rebuild every candidate + cache identical states + + EVIDENCE + Oracle + quick gates + full validation + + OUTPUT + Artifacts + apply.patch + report.json + + + + + + + + failing candidate → restore changes + + + Every check runs in an isolated temporary Git worktree. The current checkout is never reset, cleaned, or modified. + diff --git a/package.json b/package.json index 9fccf42..15153e0 100644 --- a/package.json +++ b/package.json @@ -12,13 +12,15 @@ "LICENSE" ], "scripts": { - "build": "tsup src/cli.ts --format esm --dts --clean", + "build": "tsup src/cli.ts --format esm --clean", "dev": "tsx src/cli.ts", "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "tsc --noEmit", "test": "vitest run", - "check": "pnpm format:check && pnpm typecheck && pnpm test && pnpm build" + "check": "pnpm format:check && pnpm typecheck && pnpm test && pnpm build", + "prepack": "npm run build", + "release:check": "pnpm check && pnpm audit --prod && npm pack --dry-run" }, "engines": { "node": ">=20" @@ -41,7 +43,7 @@ "license": "MIT", "dependencies": { "commander": "^14.0.0", - "minimatch": "^10.0.3", + "minimatch": "^10.2.6", "yaml": "^2.8.1" }, "devDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69fe818..32366b2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ importers: specifier: ^14.0.0 version: 14.0.3 minimatch: - specifier: ^10.0.3 - version: 10.2.5 + specifier: ^10.2.6 + version: 10.2.6 yaml: specifier: ^2.8.1 version: 2.9.0 @@ -516,8 +516,8 @@ packages: magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - minimatch@10.2.5: - resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + minimatch@10.2.6: + resolution: {integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==} engines: {node: 18 || 20 || >=22} mlly@1.8.2: @@ -1115,7 +1115,7 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - minimatch@10.2.5: + minimatch@10.2.6: dependencies: brace-expansion: 5.0.8 diff --git a/src/core/config.ts b/src/core/config.ts index 9aace2a..e10b0ff 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -14,6 +14,7 @@ interface RawCommand { } interface RawConfig { + version?: unknown; base?: unknown; head?: unknown; oracle?: unknown; @@ -27,6 +28,21 @@ interface RawConfig { expectedBaseFailure?: unknown; } +const CONFIG_KEYS = new Set([ + "version", + "base", + "head", + "oracle", + "setup", + "quickGates", + "fullGates", + "protect", + "runs", + "budget", + "output", + "expectedBaseFailure", +]); + export interface CliSettingsInput { cwd: string; configPath?: string; @@ -61,10 +77,12 @@ export async function resolveSettings( const oracle = normalizeCommand(rawOracle, defaultTimeout, "oracle"); const setupValue = input.setup ?? raw.setup; + const setupDefaultTimeout = + input.timeout === undefined ? parseDuration("15m") : defaultTimeout; const setup = setupValue === undefined ? undefined - : normalizeCommand(setupValue, parseDuration("15m"), "setup"); + : normalizeCommand(setupValue, setupDefaultTimeout, "setup"); const quickGates = input.quickGates.length > 0 ? input.quickGates.map((value) => commandSpec(value, defaultTimeout)) @@ -165,9 +183,32 @@ async function loadRawConfig( ); } + validateRawConfig(parsed as Record, configPath); return parsed as RawConfig; } +function validateRawConfig( + value: Record, + configPath: string, +): void { + const unknownKeys = Object.keys(value).filter( + (key) => !CONFIG_KEYS.has(key as keyof RawConfig), + ); + if (unknownKeys.length > 0) { + throw new CliError( + "INVALID_CONFIG", + `${configPath} contains unknown configuration ${unknownKeys.length === 1 ? "field" : "fields"}: ${unknownKeys.join(", ")}.`, + ); + } + + if (value.version !== undefined && value.version !== 1) { + throw new CliError( + "INVALID_CONFIG", + `${configPath} uses unsupported configuration version ${String(value.version)}; expected version 1.`, + ); + } +} + function normalizeCommand( value: unknown, defaultTimeout: number, diff --git a/src/core/engine.ts b/src/core/engine.ts index ab2d3a9..921c674 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -75,6 +75,7 @@ export async function minimize(settings: MinimizeSettings): Promise { let preflight: PreflightReport = { headRuns: [], headGateRuns: [], + baseRuns: [], passed: false, }; let worktree: Awaited> | undefined; @@ -266,6 +267,7 @@ async function runPreflight( ): Promise { const headRuns: ProcessResult[] = []; const headGateRuns: ProcessResult[] = []; + const baseRuns: ProcessResult[] = []; for (let index = 0; index < context.settings.runs; index += 1) { await materializePatch(context.repository, context.worktree, fullPatch); @@ -275,6 +277,7 @@ async function runPreflight( return { headRuns, headGateRuns, + baseRuns, passed: false, code: "HEAD_ORACLE_UNSTABLE", message: `The oracle did not pass consistently on ${context.repository.headRef}.`, @@ -294,6 +297,7 @@ async function runPreflight( return { headRuns, headGateRuns, + baseRuns, passed: false, code: "HEAD_GATE_FAILED", message: `A configured gate failed on ${context.repository.headRef}: ${result.command}`, @@ -302,16 +306,27 @@ async function runPreflight( } const protectedOnlyPatch = buildFileCandidate(changes, new Set()); - await materializePatch( - context.repository, - context.worktree, - protectedOnlyPatch, - ); - const baseRun = await runConfiguredCommand(context, context.settings.oracle); - if (passed(baseRun)) { + for (let index = 0; index < context.settings.runs; index += 1) { + await materializePatch( + context.repository, + context.worktree, + protectedOnlyPatch, + ); + baseRuns.push(await runConfiguredCommand(context, context.settings.oracle)); + } + const baseRun = baseRuns[0]; + if (!baseRun) { + throw new CliError( + "INVALID_RUNS", + "The oracle run count must be at least one.", + ); + } + const passingBaseRuns = baseRuns.filter(passed).length; + if (passingBaseRuns === baseRuns.length) { return { headRuns, headGateRuns, + baseRuns, baseRun, passed: false, code: "WEAK_ORACLE", @@ -319,16 +334,32 @@ async function runPreflight( "The oracle also passes with all reducible production changes removed.", }; } + if (passingBaseRuns > 0) { + return { + headRuns, + headGateRuns, + baseRuns, + baseRun, + passed: false, + code: "BASE_ORACLE_UNSTABLE", + message: + "The oracle did not fail consistently with all reducible production changes removed.", + }; + } if ( context.settings.expectedBaseFailure && - !context.settings.expectedBaseFailure.test( - `${baseRun.stdout}\n${baseRun.stderr}`, + baseRuns.some( + (result) => + !context.settings.expectedBaseFailure!.test( + `${result.stdout}\n${result.stderr}`, + ), ) ) { return { headRuns, headGateRuns, + baseRuns, baseRun, passed: false, code: "UNEXPECTED_BASE_FAILURE", @@ -337,7 +368,13 @@ async function runPreflight( }; } - return { headRuns, headGateRuns, baseRun, passed: true }; + return { + headRuns, + headGateRuns, + baseRuns, + baseRun, + passed: true, + }; } async function evaluateCandidate( diff --git a/src/core/patch.ts b/src/core/patch.ts index e7228f5..3165f2c 100644 --- a/src/core/patch.ts +++ b/src/core/patch.ts @@ -14,6 +14,10 @@ export const DEFAULT_PROTECT_PATTERNS = [ "**/__snapshots__/**", "**/fixtures/**", "**/migrations/**", + "**/.patchslim.yml", + "**/.gitignore", + "**/.gitattributes", + "**/.gitmodules", "docs/**", ".github/**", "**/*.md", @@ -69,13 +73,19 @@ export function parseChanges( binary || specialHeader || hunks.length === 0; - const protectPattern = protectPatterns.find((pattern) => - minimatch(entry.path, pattern, { - dot: true, - matchBase: pattern.includes("/") === false, - }), - ); - const protectedChange = protectPattern !== undefined; + const protectedPath = [entry.path, entry.oldPath] + .filter((candidate): candidate is string => candidate !== undefined) + .map((candidate) => ({ + path: candidate, + pattern: protectPatterns.find((pattern) => + minimatch(candidate, pattern, { + dot: true, + matchBase: pattern.includes("/") === false, + }), + ), + })) + .find((match) => match.pattern !== undefined); + const protectedChange = protectedPath !== undefined; const stats = statsFromPatch(raw); return { @@ -90,8 +100,10 @@ export function parseChanges( binary, atomic, protected: protectedChange, - ...(protectPattern - ? { protectReason: `matched protect pattern "${protectPattern}"` } + ...(protectedPath?.pattern + ? { + protectReason: `matched protect pattern "${protectedPath.pattern}"${protectedPath.path === entry.path ? "" : ` on original path "${protectedPath.path}"`}`, + } : {}), additions: stats.additions, deletions: stats.deletions, diff --git a/src/core/report.ts b/src/core/report.ts index a0a486e..66ead98 100644 --- a/src/core/report.ts +++ b/src/core/report.ts @@ -30,12 +30,7 @@ export async function readRunReport(reportPath: string): Promise { }); } - if ( - !parsed || - typeof parsed !== "object" || - !("schemaVersion" in parsed) || - (parsed as { schemaVersion?: unknown }).schemaVersion !== 1 - ) { + if (!isRunReport(parsed)) { throw new CliError( "REPORT_INVALID", `${reportPath} is not a supported PatchSlim report.`, @@ -45,6 +40,98 @@ export async function readRunReport(reportPath: string): Promise { return parsed as RunReport; } +function isRunReport(value: unknown): value is RunReport { + if (!isRecord(value)) { + return false; + } + + const status = value.status; + if ( + value.schemaVersion !== 1 || + (status !== "completed" && status !== "failed") || + !isNonEmptyString(value.runId) || + !isNonEmptyString(value.startedAt) || + !isNonEmptyString(value.completedAt) || + !isRepository(value.repository) || + !isDiffStats(value.before) || + !Array.isArray(value.protected) || + !value.protected.every( + (entry) => + isRecord(entry) && + isNonEmptyString(entry.path) && + isNonEmptyString(entry.reason), + ) || + !isPreflight(value.preflight) || + !isArtifacts(value.artifacts) + ) { + return false; + } + + if (status === "completed") { + return ( + isDiffStats(value.after) && + isRecord(value.reduction) && + isRecord(value.validation) + ); + } + + return ( + isRecord(value.error) && + isNonEmptyString(value.error.code) && + isNonEmptyString(value.error.message) + ); +} + +function isRepository(value: unknown): boolean { + return ( + isRecord(value) && + ["root", "commonGitDir", "baseRef", "baseSha", "headRef", "headSha"].every( + (key) => isNonEmptyString(value[key]), + ) + ); +} + +function isDiffStats(value: unknown): boolean { + return ( + isRecord(value) && + ["files", "additions", "deletions"].every( + (key) => + typeof value[key] === "number" && + Number.isInteger(value[key]) && + Number(value[key]) >= 0, + ) + ); +} + +function isPreflight(value: unknown): boolean { + return ( + isRecord(value) && + Array.isArray(value.headRuns) && + Array.isArray(value.headGateRuns) && + (value.baseRuns === undefined || Array.isArray(value.baseRuns)) && + typeof value.passed === "boolean" + ); +} + +function isArtifacts(value: unknown): boolean { + return ( + isRecord(value) && + isNonEmptyString(value.directory) && + isNonEmptyString(value.reportJson) && + ["patch", "applyPatch", "reportMarkdown"].every( + (key) => value[key] === undefined || isNonEmptyString(value[key]), + ) + ); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + export function renderHumanSummary(report: RunReport): string { if (report.status === "failed") { return [ diff --git a/src/core/types.ts b/src/core/types.ts index b33dea2..cfd17f1 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -95,6 +95,7 @@ export interface MinimizeSettings { export interface PreflightReport { headRuns: ProcessResult[]; headGateRuns: ProcessResult[]; + baseRuns: ProcessResult[]; baseRun?: ProcessResult; passed: boolean; code?: string; diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..024060e --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,101 @@ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { createFixtureRepository, git } from "./helpers.js"; + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); +const cliPath = path.join(projectRoot, "src", "cli.ts"); + +describe("PatchSlim CLI", () => { + it("emits stable JSON for init dry runs", () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-cli-")); + const result = runCli(["--json", "-C", root, "init", "--dry-run"]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "init", + data: { + path: path.join(root, ".patchslim.yml"), + written: false, + }, + }); + }); + + it("returns a structured error for malformed reports", () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-cli-")); + writeFileSync( + path.join(root, "broken.json"), + JSON.stringify({ schemaVersion: 1, status: "completed" }), + "utf8", + ); + const result = runCli(["--json", "-C", root, "report", "broken.json"]); + + expect(result.status).toBe(1); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: false, + error: { + code: "REPORT_INVALID", + }, + }); + }); + + it("inspects committed paths containing spaces and Unicode", () => { + const fixture = createFixtureRepository(); + const relativePath = "src/with space ü.mjs"; + writeFileSync( + path.join(fixture.root, relativePath), + "export const value = 1;\n", + "utf8", + ); + git(fixture.root, ["add", "."]); + git(fixture.root, ["commit", "-m", "add unicode path"]); + const head = git(fixture.root, ["rev-parse", "HEAD"]); + + const result = runCli([ + "--json", + "-C", + fixture.root, + "inspect", + "--base", + fixture.headSha, + "--head", + head, + ]); + + expect(result.status).toBe(0); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toMatchObject({ + ok: true, + command: "inspect", + data: { + stats: { files: 1, additions: 1, deletions: 0 }, + changes: [ + { + path: relativePath, + status: "added", + protected: false, + }, + ], + }, + }); + }); +}); + +function runCli(args: string[]) { + return spawnSync(process.execPath, ["--import", "tsx", cliPath, ...args], { + cwd: projectRoot, + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + }); +} diff --git a/test/config.test.ts b/test/config.test.ts index effc11c..c55613a 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -99,4 +99,50 @@ expectedBaseFailure: missing feature code: "CONFIG_READ_FAILED", }); }); + + it("uses the CLI timeout as the default for setup commands", async () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-config-")); + writeFileSync( + path.join(root, ".patchslim.yml"), + `oracle: node test.mjs +setup: + command: [pnpm, install] +`, + "utf8", + ); + + const settings = await resolveSettings({ + cwd: root, + quickGates: [], + fullGates: [], + protectPatterns: [], + includeDefaultProtect: false, + timeout: "20s", + }); + + expect(settings.setup?.timeoutMs).toBe(20_000); + }); + + it.each([ + ["an unsupported version", "version: 2\noracle: node test.mjs\n"], + [ + "an unknown field", + "version: 1\noracle: node test.mjs\nquickGate: node quick.mjs\n", + ], + ])("rejects %s", async (_label, content) => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-config-")); + writeFileSync(path.join(root, ".patchslim.yml"), content, "utf8"); + + await expect( + resolveSettings({ + cwd: root, + quickGates: [], + fullGates: [], + protectPatterns: [], + includeDefaultProtect: false, + }), + ).rejects.toMatchObject({ + code: "INVALID_CONFIG", + }); + }); }); diff --git a/test/engine.test.ts b/test/engine.test.ts index 7e57064..e11f7d5 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -34,6 +34,8 @@ describe("minimize", () => { }); expect(report.status).toBe("completed"); + expect(report.before).toEqual({ files: 4, additions: 8, deletions: 3 }); + expect(report.after).toEqual({ files: 2, additions: 6, deletions: 2 }); expect(report.reduction?.removedFiles).toContain("src/redundant.mjs"); expect(report.reduction?.removedFiles).toContain("src/data.bin"); expect(report.reduction?.removedHunks).toContain("hunk:src/math.mjs:1"); @@ -252,6 +254,37 @@ describe("minimize", () => { ).toHaveLength(1); }); + it("fails closed when protected-base runs are unstable", async () => { + const fixture = createFixtureRepository(); + const stateFile = path.join( + mkdtempSync(path.join(tmpdir(), "patchslim-base-oracle-")), + "runs", + ); + const script = `const fs=require("node:fs");const p=${JSON.stringify(stateFile)};const n=fs.existsSync(p)?Number(fs.readFileSync(p,"utf8")):0;fs.writeFileSync(p,String(n+1));process.exit(n<2?0:n===2?1:0)`; + + await expect( + minimize({ + cwd: fixture.root, + baseRef: fixture.baseSha, + headRef: fixture.headSha, + oracle: commandSpec([process.execPath, "-e", script], 10_000), + quickGates: [], + fullGates: [], + protectPatterns: DEFAULT_PROTECT_PATTERNS, + runs: 2, + budgetMs: 30_000, + }), + ).rejects.toMatchObject({ + code: "BASE_ORACLE_UNSTABLE", + }); + expect(readFileSync(stateFile, "utf8")).toBe("4"); + expect( + git(fixture.root, ["worktree", "list", "--porcelain"]).match( + /^worktree /gm, + ), + ).toHaveLength(1); + }); + it("fails before reduction when a configured gate is already red", async () => { const fixture = createFixtureRepository(); diff --git a/test/patch.test.ts b/test/patch.test.ts index 2ec5dbc..2f79b3c 100644 --- a/test/patch.test.ts +++ b/test/patch.test.ts @@ -86,6 +86,96 @@ describe("parseChanges", () => { atomic: true, }); }); + + it("keeps a rename protected when its original path is protected", () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-rename-")); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "PatchSlim Tests"]); + git(root, ["config", "user.email", "patchslim@example.invalid"]); + mkdirSync(path.join(root, "tests"), { recursive: true }); + mkdirSync(path.join(root, "src"), { recursive: true }); + writeFileSync( + path.join(root, "tests", "feature.test.mjs"), + "test\n", + "utf8", + ); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + const base = git(root, ["rev-parse", "HEAD"]); + + renameSync( + path.join(root, "tests", "feature.test.mjs"), + path.join(root, "src", "feature.mjs"), + ); + git(root, ["add", "."]); + git(root, ["commit", "-m", "move test"]); + const head = git(root, ["rev-parse", "HEAD"]); + + const diff = gitOutput(root, [ + "diff", + "--binary", + "--full-index", + base, + head, + "--", + ]); + const nameStatus = gitOutput(root, [ + "diff", + "--name-status", + "-z", + base, + head, + "--", + ]); + const changes = parseChanges(diff, nameStatus, DEFAULT_PROTECT_PATTERNS); + + expect(changes).toHaveLength(1); + expect(changes[0]).toMatchObject({ + path: "src/feature.mjs", + oldPath: "tests/feature.test.mjs", + status: "renamed", + atomic: true, + protected: true, + }); + }); + + it("protects PatchSlim and Git control files by default", () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-control-")); + git(root, ["init", "-b", "main"]); + git(root, ["config", "user.name", "PatchSlim Tests"]); + git(root, ["config", "user.email", "patchslim@example.invalid"]); + writeFileSync(path.join(root, "source.txt"), "base\n", "utf8"); + git(root, ["add", "."]); + git(root, ["commit", "-m", "base"]); + const base = git(root, ["rev-parse", "HEAD"]); + + const controlFiles: Array<[string, string]> = [ + [".patchslim.yml", "oracle: node test.mjs\n"], + [".gitignore", ".cache/\n"], + [".gitattributes", "*.bin binary\n"], + [".gitmodules", '[submodule "example"]\n'], + ]; + for (const [file, content] of controlFiles) { + writeFileSync(path.join(root, file), content, "utf8"); + } + git(root, ["add", "."]); + git(root, ["commit", "-m", "add control files"]); + const head = git(root, ["rev-parse", "HEAD"]); + + const changes = parseChanges( + gitOutput(root, ["diff", "--binary", "--full-index", base, head, "--"]), + gitOutput(root, ["diff", "--name-status", "-z", base, head, "--"]), + DEFAULT_PROTECT_PATTERNS, + ); + + expect(changes.map((change) => change.path)).toEqual([ + ".gitattributes", + ".gitignore", + ".gitmodules", + ".patchslim.yml", + ]); + expect(changes.every((change) => change.protected)).toBe(true); + }); }); function gitOutput(root: string, args: string[]): string { diff --git a/test/report.test.ts b/test/report.test.ts index 96b6d77..ab6d896 100644 --- a/test/report.test.ts +++ b/test/report.test.ts @@ -1,6 +1,10 @@ +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + import { describe, expect, it } from "vitest"; -import { renderMarkdownReport } from "../src/core/report.js"; +import { readRunReport, renderMarkdownReport } from "../src/core/report.js"; import type { RunReport } from "../src/core/types.js"; describe("renderMarkdownReport", () => { @@ -21,7 +25,12 @@ describe("renderMarkdownReport", () => { }, before: { files: 1, additions: 1, deletions: 0 }, protected: [], - preflight: { headRuns: [], headGateRuns: [], passed: false }, + preflight: { + headRuns: [], + headGateRuns: [], + baseRuns: [], + passed: false, + }, artifacts: { directory: "/repo/.git/patchslim/runs/example", reportJson: "/repo/.git/patchslim/runs/example/report.json", @@ -38,4 +47,18 @@ describe("renderMarkdownReport", () => { ); expect(markdown).not.toContain("candidate passed"); }); + + it("rejects reports that have a version but not the required shape", async () => { + const root = mkdtempSync(path.join(tmpdir(), "patchslim-report-")); + const reportPath = path.join(root, "report.json"); + writeFileSync( + reportPath, + JSON.stringify({ schemaVersion: 1, status: "completed" }), + "utf8", + ); + + await expect(readRunReport(reportPath)).rejects.toMatchObject({ + code: "REPORT_INVALID", + }); + }); });