From df22dc5f66840582b0e9b3f8c856d650f244232a Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:46:04 +0900 Subject: [PATCH 01/12] test: deduplicate test helpers for jscpd Extract shared CLI, hook, environment, color, trace, and fixture helpers used across the test suite. Refactor repeated test setup and assertions so the tests pass the normal jscpd duplicate scan without changing product behavior. Also add jscpd as a dev dependency. --- bun.lock | 213 ++++++++++- package.json | 1 + tests/bin/cli-statusline.test.ts | 395 +++++--------------- tests/bin/colors.test.ts | 387 ++++++------------- tests/bin/doctor/activity.test.ts | 12 +- tests/bin/doctor/config.test.ts | 143 +++---- tests/bin/doctor/format.test.ts | 225 +++-------- tests/bin/doctor/system-info.test.ts | 78 ++-- tests/bin/explain/cli.test.ts | 99 +---- tests/bin/explain/command.test.ts | 367 +++++++----------- tests/bin/explain/format.test.ts | 134 +++---- tests/bin/explain/redact.test.ts | 52 +-- tests/bin/help.test.ts | 26 +- tests/bin/hooks/claude-code-hook.test.ts | 60 +-- tests/bin/hooks/copilot-cli-hook.test.ts | 111 ++---- tests/bin/hooks/gemini-cli-hook.test.ts | 69 +--- tests/bin/hooks/hook-helpers.ts | 50 +++ tests/core/analyze/analyze-coverage.test.ts | 69 ++-- tests/core/audit.test.ts | 40 +- tests/core/config.test.ts | 121 ++---- tests/core/rules-custom-integration.test.ts | 39 +- tests/core/rules-custom.test.ts | 30 +- tests/helpers.ts | 65 +++- tests/scripts/generate-changelog.test.ts | 73 ++-- 24 files changed, 1085 insertions(+), 1774 deletions(-) diff --git a/bun.lock b/bun.lock index 207416c..ce9e7a9 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,7 @@ "@types/bun": "latest", "@types/shell-quote": "^1.7.5", "husky": "^9.1.7", + "jscpd": "^4.0.9", "knip": "^5.79.0", "lint-staged": "^16.2.7", "zod": "^4.3.5", @@ -43,6 +44,14 @@ "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.4", "", { "os": "win32", "cpu": "x64" }, "sha512-7Fay4iNE3GvaPDtypedfXhSRfMgtfL/BKYeNVoW/JMTNmXDQHzbzQ36Y3FxVb+6u51MF/LdZwk9ofVZEquRYMA=="], + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/parser": ["@babel/parser@7.29.3", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA=="], + + "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.3.10", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.3.10", "@biomejs/cli-darwin-x64": "2.3.10", "@biomejs/cli-linux-arm64": "2.3.10", "@biomejs/cli-linux-arm64-musl": "2.3.10", "@biomejs/cli-linux-x64": "2.3.10", "@biomejs/cli-linux-x64-musl": "2.3.10", "@biomejs/cli-win32-arm64": "2.3.10", "@biomejs/cli-win32-x64": "2.3.10" }, "bin": { "biome": "bin/biome" } }, "sha512-/uWSUd1MHX2fjqNLHNL6zLYWBbrJeG412/8H7ESuK8ewoRoMPUgHDebqKrPTx/5n6f17Xzqc9hdg3MEqA5hXnQ=="], "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.3.10", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M6xUjtCVnNGFfK7HMNKa593nb7fwNm43fq1Mt71kpLpb+4mE7odO8W/oWVDyBVO4ackhresy1ZYO7OJcVo/B7w=="], @@ -61,12 +70,24 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.3.10", "", { "os": "win32", "cpu": "x64" }, "sha512-pHEFgq7dUEsKnqG9mx9bXihxGI49X+ar+UBrEIj3Wqj3UCZp1rNgV+OoyjFgcXsjCWpuEAF4VJdkZr3TrWdCbQ=="], + "@colors/colors": ["@colors/colors@1.5.0", "", {}, "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ=="], + "@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="], "@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="], "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + "@jscpd/badge-reporter": ["@jscpd/badge-reporter@4.0.5", "", { "dependencies": { "badgen": "^3.2.3", "colors": "^1.4.0", "fs-extra": "^11.2.0" } }, "sha512-SLVhP00R9lkQ//Ivaanfm7k0L9sewpBven670kk1uGec2SWUOa7MVQcuad/TV59KEZ73UIC1lXvi6O9hAnbpUw=="], + + "@jscpd/core": ["@jscpd/core@4.0.5", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-Udvym21nWzxjYRVXwwpYNBqZ6b50QV2zHN3fFNzOPPg4cfQVYOZerILB7xNDUsXHC1PCr/N52Tq3q7AElvjWWA=="], + + "@jscpd/finder": ["@jscpd/finder@4.0.5", "", { "dependencies": { "@jscpd/core": "4.0.5", "@jscpd/tokenizer": "4.0.5", "blamer": "^1.0.6", "bytes": "^3.1.2", "cli-table3": "^0.6.5", "colors": "^1.4.0", "fast-glob": "^3.3.2", "fs-extra": "^11.2.0", "markdown-table": "^2.0.0", "pug": "^3.0.3" } }, "sha512-/2VkRoVrrfya+51sitZo5I9MdwsRaPKB8X3L3khAYoHFXk4L/mUuG81RmGazDHjUIGg22ItlkQtwzorNZ2+aPw=="], + + "@jscpd/html-reporter": ["@jscpd/html-reporter@4.0.5", "", { "dependencies": { "colors": "1.4.0", "fs-extra": "^11.2.0", "pug": "^3.0.3" } }, "sha512-drK2J8KyPIW9wvaElSIobZFp4dBO9GA++JW4gx3oihvLdDSp8qSo/CNqH47Dw0XkjQTxND3j/+Wz5JWvYRBgFQ=="], + + "@jscpd/tokenizer": ["@jscpd/tokenizer@4.0.5", "", { "dependencies": { "@jscpd/core": "4.0.5", "reprism": "^0.0.11", "spark-md5": "^3.0.2" } }, "sha512-WzRujQtN5WedxZVDKuoanxmKAFrxcLrHpcA6kaM4z8AhGtWXZ325yseqgL5TZ8OK7Auwu7kQLlqhfk05fGYG7A=="], + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" } }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -125,8 +146,12 @@ "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + "@types/sarif": ["@types/sarif@2.1.7", "", {}, "sha512-kRz0VEkJqWLf1LLVN4pT1cg1Z9wAuvI6L97V3m2f5B76Tg8d413ddvLBPTEHAZJlnn4XSvu0FkZtViCQGVyrXQ=="], + "@types/shell-quote": ["@types/shell-quote@1.7.5", "", {}, "sha512-+UE8GAGRPbJVQDdxi16dgadcBfQ+KG2vgZhV1+3A1XmHbmwcdwhCUwIdy+d3pAGrbvgRoVSjeI9vOWyq376Yzw=="], + "acorn": ["acorn@7.4.1", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A=="], + "ansi-escapes": ["ansi-escapes@7.2.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-g6LhBsl+GBPRWGWsBtutpzBYuIIdBkLEvad5C/va/74Db018+5TZiyA26cZJAr3Rft5lprVqOIPxf5Vid6tqAw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], @@ -135,26 +160,66 @@ "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + "asap": ["asap@2.0.6", "", {}, "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA=="], + + "assert-never": ["assert-never@1.4.0", "", {}, "sha512-5oJg84os6NMQNl27T9LnZkvvqzvAnHu03ShCnoj6bsJwS7L8AO4lf+C/XjK/nvzEqQB744moC6V128RucQd1jA=="], + + "babel-walk": ["babel-walk@3.0.0-canary-5", "", { "dependencies": { "@babel/types": "^7.9.6" } }, "sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw=="], + + "badgen": ["badgen@3.3.1", "", {}, "sha512-8y2Av4AP7G6jtwvRcPcEuPPigRouY6izfXy8qEp+4kMN4Va08VkCAbAvcFXwtHXsTSxbLHD4nglH5TmdKXaEkw=="], + + "blamer": ["blamer@1.0.7", "", { "dependencies": { "execa": "^4.0.0", "which": "^2.0.2" } }, "sha512-GbBStl/EVlSWkiJQBZps3H1iARBrC7vt++Jb/TTmCNu/jZ04VW7tSN1nScbFXBUy1AN+jzeL7Zep9sbQxLhXKA=="], + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "character-parser": ["character-parser@2.2.0", "", { "dependencies": { "is-regex": "^1.0.3" } }, "sha512-+UqJQjFEFaTAs3bNsF2j2kEN1baG/zghZbdqoYEDxGZtJo9LBzl1A+m0D4n3qKx8N2FNv8/Xp6yV9mQmBuptaw=="], + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + "cli-table3": ["cli-table3@0.6.5", "", { "dependencies": { "string-width": "^4.2.0" }, "optionalDependencies": { "@colors/colors": "1.5.0" } }, "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ=="], + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], - "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "colors": ["colors@1.4.0", "", {}, "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="], + + "commander": ["commander@5.1.0", "", {}, "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg=="], + + "constantinople": ["constantinople@4.0.1", "", { "dependencies": { "@babel/parser": "^7.6.0", "@babel/types": "^7.6.1" } }, "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + "doctypes": ["doctypes@1.1.0", "", {}, "sha512-LLBi6pEqS6Do3EKQ3J0NqHWV5hhb78Pi8vvESYwyOy2c31ZEZVdtitdzsQsKb7878PEERhzUk0ftqGhG6Mz+pQ=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], + "execa": ["execa@4.1.0", "", { "dependencies": { "cross-spawn": "^7.0.0", "get-stream": "^5.0.0", "human-signals": "^1.1.1", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.0", "onetime": "^5.1.0", "signal-exit": "^3.0.2", "strip-final-newline": "^2.0.0" } }, "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA=="], + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], "fastq": ["fastq@1.20.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw=="], @@ -165,24 +230,68 @@ "formatly": ["formatly@0.3.0", "", { "dependencies": { "fd-package-json": "^2.0.0" }, "bin": { "formatly": "bin/index.mjs" } }, "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w=="], + "fs-extra": ["fs-extra@11.3.5", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", "universalify": "^2.0.0" } }, "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], + "glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.3", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg=="], + + "human-signals": ["human-signals@1.1.1", "", {}, "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw=="], + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="], + + "is-expression": ["is-expression@4.0.0", "", { "dependencies": { "acorn": "^7.1.1", "object-assign": "^4.1.1" } }, "sha512-zMIXX63sxzG3XrkHkrAPvm/OVZVSCPNkwMHU8oTX7/U3AL78I0QXCEICXUM13BIa8TYGZ68PiTKfQz3yaTNr4A=="], + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], - "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], + + "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "js-stringify": ["js-stringify@1.0.2", "", {}, "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="], + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "jscpd": ["jscpd@4.0.9", "", { "dependencies": { "@jscpd/badge-reporter": "4.0.5", "@jscpd/core": "4.0.5", "@jscpd/finder": "4.0.5", "@jscpd/html-reporter": "4.0.5", "@jscpd/tokenizer": "4.0.5", "colors": "^1.4.0", "commander": "^5.0.0", "fs-extra": "^11.2.0", "jscpd-sarif-reporter": "4.0.7" }, "bin": { "jscpd": "bin/jscpd" } }, "sha512-fp6Sh42W3mIPoQgZmgYmKDLQzEDnnX2vaGlTN4haILkB2vsi+ewcCHEtWR/2CR/QbsBvAvsNo8U5Sa+p9aHiGw=="], + + "jscpd-sarif-reporter": ["jscpd-sarif-reporter@4.0.7", "", { "dependencies": { "colors": "^1.4.0", "fs-extra": "^11.2.0", "node-sarif-builder": "^3.4.0" } }, "sha512-Q/VlfTI/Nbjc8dZ/2pDVIf1aRi2bM2CTYujcAoeYr7brRnS4o5ZeW86W8q7MM7cQu40gezlNckl+E9wKFSMFiA=="], + + "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + + "jstransformer": ["jstransformer@1.0.0", "", { "dependencies": { "is-promise": "^2.0.0", "promise": "^7.0.1" } }, "sha512-C9YK3Rf8q6VAPDCCU9fnqo3mAfOH6vUGnMcP4AQAYIEpWtfGLpwOTmZ+igtdK5y+VvI2n3CyYSzy4Qh34eq24A=="], + "knip": ["knip@5.79.0", "", { "dependencies": { "@nodelib/fs.walk": "^1.2.3", "fast-glob": "^3.3.3", "formatly": "^0.3.0", "jiti": "^2.6.0", "js-yaml": "^4.1.1", "minimist": "^1.2.8", "oxc-resolver": "^11.15.0", "picocolors": "^1.1.1", "picomatch": "^4.0.1", "smol-toml": "^1.5.2", "strip-json-comments": "5.0.3", "zod": "^4.1.11" }, "peerDependencies": { "@types/node": ">=18", "typescript": ">=5.0.4 <7" }, "bin": { "knip": "bin/knip.js", "knip-bun": "bin/knip-bun.js" } }, "sha512-rcg+mNdqm6UiTuRVyy6UuuHw1n4ABMpNXDtrfGaCeUtJoRBAvAENIebr8YMtOz6XE7iVHZ8+rY7skgEtosczhQ=="], "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], @@ -191,28 +300,82 @@ "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + "markdown-table": ["markdown-table@2.0.0", "", { "dependencies": { "repeat-string": "^1.0.0" } }, "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "merge-stream": ["merge-stream@2.0.0", "", {}, "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w=="], + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], - "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + "node-sarif-builder": ["node-sarif-builder@3.4.0", "", { "dependencies": { "@types/sarif": "^2.1.7", "fs-extra": "^11.1.1" } }, "sha512-tGnJW6OKRii9u/b2WiUViTJS+h7Apxx17qsMUjsUeNDiMMX5ZFf8F8Fcz7PAQ6omvOxHZtvDTmOYKJQwmfpjeg=="], + + "npm-run-path": ["npm-run-path@4.0.1", "", { "dependencies": { "path-key": "^3.0.0" } }, "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@5.1.2", "", { "dependencies": { "mimic-fn": "^2.1.0" } }, "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg=="], "oxc-resolver": ["oxc-resolver@11.16.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.16.2", "@oxc-resolver/binding-android-arm64": "11.16.2", "@oxc-resolver/binding-darwin-arm64": "11.16.2", "@oxc-resolver/binding-darwin-x64": "11.16.2", "@oxc-resolver/binding-freebsd-x64": "11.16.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.16.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.16.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.16.2", "@oxc-resolver/binding-linux-arm64-musl": "11.16.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.16.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.16.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-gnu": "11.16.2", "@oxc-resolver/binding-linux-x64-musl": "11.16.2", "@oxc-resolver/binding-openharmony-arm64": "11.16.2", "@oxc-resolver/binding-wasm32-wasi": "11.16.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.16.2", "@oxc-resolver/binding-win32-ia32-msvc": "11.16.2", "@oxc-resolver/binding-win32-x64-msvc": "11.16.2" } }, "sha512-Uy76u47vwhhF7VAmVY61Srn+ouiOobf45MU9vGct9GD2ARy6hKoqEElyHDB0L+4JOM6VLuZ431KiLwyjI/A21g=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], + + "pug": ["pug@3.0.4", "", { "dependencies": { "pug-code-gen": "^3.0.4", "pug-filters": "^4.0.0", "pug-lexer": "^5.0.1", "pug-linker": "^4.0.0", "pug-load": "^3.0.0", "pug-parser": "^6.0.0", "pug-runtime": "^3.0.1", "pug-strip-comments": "^2.0.0" } }, "sha512-kFfq5mMzrS7+wrl5pLJzZEzemx34OQ0w4SARfhy/3yxTlhbstsudDwJzhf1hP02yHzbjoVMSXUj/Sz6RNfMyXg=="], + + "pug-attrs": ["pug-attrs@3.0.0", "", { "dependencies": { "constantinople": "^4.0.1", "js-stringify": "^1.0.2", "pug-runtime": "^3.0.0" } }, "sha512-azINV9dUtzPMFQktvTXciNAfAuVh/L/JCl0vtPCwvOA21uZrC08K/UnmrL+SXGEVc1FwzjW62+xw5S/uaLj6cA=="], + + "pug-code-gen": ["pug-code-gen@3.0.4", "", { "dependencies": { "constantinople": "^4.0.1", "doctypes": "^1.1.0", "js-stringify": "^1.0.2", "pug-attrs": "^3.0.0", "pug-error": "^2.1.0", "pug-runtime": "^3.0.1", "void-elements": "^3.1.0", "with": "^7.0.0" } }, "sha512-6okWYIKdasTyXICyEtvobmTZAVX57JkzgzIi4iRJlin8kmhG+Xry2dsus+Mun/nGCn6F2U49haHI5mkELXB14g=="], + + "pug-error": ["pug-error@2.1.0", "", {}, "sha512-lv7sU9e5Jk8IeUheHata6/UThZ7RK2jnaaNztxfPYUY+VxZyk/ePVaNZ/vwmH8WqGvDz3LrNYt/+gA55NDg6Pg=="], + + "pug-filters": ["pug-filters@4.0.0", "", { "dependencies": { "constantinople": "^4.0.1", "jstransformer": "1.0.0", "pug-error": "^2.0.0", "pug-walk": "^2.0.0", "resolve": "^1.15.1" } }, "sha512-yeNFtq5Yxmfz0f9z2rMXGw/8/4i1cCFecw/Q7+D0V2DdtII5UvqE12VaZ2AY7ri6o5RNXiweGH79OCq+2RQU4A=="], + + "pug-lexer": ["pug-lexer@5.0.1", "", { "dependencies": { "character-parser": "^2.2.0", "is-expression": "^4.0.0", "pug-error": "^2.0.0" } }, "sha512-0I6C62+keXlZPZkOJeVam9aBLVP2EnbeDw3An+k0/QlqdwH6rv8284nko14Na7c0TtqtogfWXcRoFE4O4Ff20w=="], + + "pug-linker": ["pug-linker@4.0.0", "", { "dependencies": { "pug-error": "^2.0.0", "pug-walk": "^2.0.0" } }, "sha512-gjD1yzp0yxbQqnzBAdlhbgoJL5qIFJw78juN1NpTLt/mfPJ5VgC4BvkoD3G23qKzJtIIXBbcCt6FioLSFLOHdw=="], + + "pug-load": ["pug-load@3.0.0", "", { "dependencies": { "object-assign": "^4.1.1", "pug-walk": "^2.0.0" } }, "sha512-OCjTEnhLWZBvS4zni/WUMjH2YSUosnsmjGBB1An7CsKQarYSWQ0GCVyd4eQPMFJqZ8w9xgs01QdiZXKVjk92EQ=="], + + "pug-parser": ["pug-parser@6.0.0", "", { "dependencies": { "pug-error": "^2.0.0", "token-stream": "1.0.0" } }, "sha512-ukiYM/9cH6Cml+AOl5kETtM9NR3WulyVP2y4HOU45DyMim1IeP/OOiyEWRr6qk5I5klpsBnbuHpwKmTx6WURnw=="], + + "pug-runtime": ["pug-runtime@3.0.1", "", {}, "sha512-L50zbvrQ35TkpHwv0G6aLSuueDRwc/97XdY8kL3tOT0FmhgG7UypU3VztfV/LATAvmUfYi4wNxSajhSAeNN+Kg=="], + + "pug-strip-comments": ["pug-strip-comments@2.0.0", "", { "dependencies": { "pug-error": "^2.0.0" } }, "sha512-zo8DsDpH7eTkPHCXFeAk1xZXJbyoTfdPlNR0bK7rpOMuhBYb0f5qUVCO1xlsitYd3w5FQTK7zpNVKb3rZoUrrQ=="], + + "pug-walk": ["pug-walk@2.0.0", "", {}, "sha512-yYELe9Q5q9IQhuvqsZNwA5hfPkMJ8u92bQLIMcsMxf/VADjNtEYptU+inlufAFYcWdHlwNfZOEnOOQrZrcyJCQ=="], + + "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + "repeat-string": ["repeat-string@1.6.1", "", {}, "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w=="], + + "reprism": ["reprism@0.0.11", "", {}, "sha512-VsxDR5QxZo08M/3nRypNlScw5r3rKeSOPdU/QhDmu3Ai3BJxHn/qgfXGWQp/tAxUtzwYNo9W6997JZR0tPLZsA=="], + + "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], @@ -221,44 +384,82 @@ "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="], - "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], "smol-toml": ["smol-toml@1.6.0", "", {}, "sha512-4zemZi0HvTnYwLfrpk/CF9LOd9Lt87kAt50GnqhMpyF9U3poDAP2+iukq2bZsO/ufegbYehBkqINbsWxj4l4cw=="], + "spark-md5": ["spark-md5@3.0.2", "", {}, "sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw=="], + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + "strip-final-newline": ["strip-final-newline@2.0.0", "", {}, "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA=="], + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "token-stream": ["token-stream@1.0.0", "", {}, "sha512-VSsyNPPW74RpHwR8Fc21uubwHY7wMDeJLys2IX5zJNih+OnAnaifKHo+1LHT7DAdloQ7apeaaWg8l7qnf/TnEg=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + "walk-up-path": ["walk-up-path@4.0.0", "", {}, "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A=="], + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "with": ["with@7.0.2", "", { "dependencies": { "@babel/parser": "^7.9.6", "@babel/types": "^7.9.6", "assert-never": "^1.2.1", "babel-walk": "3.0.0-canary-5" } }, "sha512-RNGKj82nUPg3g5ygxkQl0R937xLyho1J24ItRCBTr/m1YnZkzJy1hUiHUJrc/VlsDQzsCnInEGSg3bci0Lmd4w=="], + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="], + "cli-truncate/string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], + "knip/zod": ["zod@4.3.4", "", {}, "sha512-Zw/uYiiyF6pUT1qmKbZziChgNPRu+ZRneAsMUDU6IwmXdWt5JwcUfy2bvLOCUtz5UniaN/Zx5aFttZYbYc7O/A=="], + "lint-staged/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/string-width/emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], } } diff --git a/package.json b/package.json index e0db91b..ed5fe85 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "@types/bun": "latest", "@types/shell-quote": "^1.7.5", "husky": "^9.1.7", + "jscpd": "^4.0.9", "knip": "^5.79.0", "lint-staged": "^16.2.7", "zod": "^4.3.5" diff --git a/tests/bin/cli-statusline.test.ts b/tests/bin/cli-statusline.test.ts index a1c221f..7d43e8a 100644 --- a/tests/bin/cli-statusline.test.ts +++ b/tests/bin/cli-statusline.test.ts @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +import { runSafetyNetCli } from '../helpers.ts'; function clearEnv(): void { delete process.env.SAFETY_NET_STRICT; @@ -12,6 +13,17 @@ function clearEnv(): void { delete process.env.CLAUDE_SETTINGS_PATH; } +async function runStatusline(env: Record) { + const result = await runSafetyNetCli(['--statusline'], env); + return { output: result.output.trim(), exitCode: result.exitCode }; +} + +async function expectStatusline(env: Record, output: string) { + const result = await runStatusline(env); + expect(result.output).toBe(output); + expect(result.exitCode).toBe(0); +} + describe('--statusline flag', () => { // Create a temp settings file with plugin enabled to test statusline modes // When settings file doesn't exist, isPluginEnabled() defaults to false (disabled) @@ -36,203 +48,68 @@ describe('--statusline flag', () => { await rm(tempDir, { recursive: true, force: true }); }); - // 1. Enabled with no mode flags → ✅ - test('outputs enabled status with no env flags', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ✅'); - expect(exitCode).toBe(0); - }); - - // 3. Enabled + Strict → 🔒 (replaces ✅) - test('shows strict mode emoji when SAFETY_NET_STRICT=1', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1' }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒'); - expect(exitCode).toBe(0); - }); - - // 4. Enabled + Paranoid → 👁️ - test('shows paranoid emoji when SAFETY_NET_PARANOID=1', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_PARANOID: '1' }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 👁️'); - expect(exitCode).toBe(0); - }); - - test('shows worktree emoji when SAFETY_NET_WORKTREE=1', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_WORKTREE: '1' }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🌳'); - expect(exitCode).toBe(0); - }); - - // 7. Enabled + Strict + Paranoid → 🔒👁️ (concatenated) - test('shows strict + paranoid emojis when both set', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_STRICT: '1', - SAFETY_NET_PARANOID: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️'); - expect(exitCode).toBe(0); - }); - - // 5. Enabled + Paranoid RM only → 🗑️ - test('shows rm emoji when SAFETY_NET_PARANOID_RM=1 only', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_PARANOID_RM: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🗑️'); - expect(exitCode).toBe(0); - }); - - // 8. Enabled + Strict + Paranoid RM only → 🔒🗑️ - test('shows strict + rm emoji when STRICT and PARANOID_RM set', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_STRICT: '1', - SAFETY_NET_PARANOID_RM: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒🗑️'); - expect(exitCode).toBe(0); - }); - - // 6. Enabled + Paranoid Interpreters only → 🐚 - test('shows interpreters emoji when SAFETY_NET_PARANOID_INTERPRETERS=1', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_PARANOID_INTERPRETERS: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🐚'); - expect(exitCode).toBe(0); - }); - - // 9. Enabled + Strict + Paranoid Interpreters only → 🔒🐚 - test('shows strict + interpreters emoji', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_STRICT: '1', - SAFETY_NET_PARANOID_INTERPRETERS: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒🐚'); - expect(exitCode).toBe(0); - }); - - // 4/7. PARANOID_RM + PARANOID_INTERPRETERS together → 👁️ (same as PARANOID) - test('shows paranoid emoji when both PARANOID_RM and PARANOID_INTERPRETERS set', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, - SAFETY_NET_PARANOID_RM: '1', - SAFETY_NET_PARANOID_INTERPRETERS: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 👁️'); - expect(exitCode).toBe(0); - }); - - // 7. Strict + PARANOID_RM + PARANOID_INTERPRETERS → 🔒👁️ - test('shows strict + paranoid when all three flags set', async () => { - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', + const modes: Array<{ name: string; env: Record; output: string }> = [ + { name: 'no env flags', env: {}, output: '🛡️ Safety Net ✅' }, + { name: 'SAFETY_NET_STRICT=1', env: { SAFETY_NET_STRICT: '1' }, output: '🛡️ Safety Net 🔒' }, + { + name: 'SAFETY_NET_PARANOID=1', + env: { SAFETY_NET_PARANOID: '1' }, + output: '🛡️ Safety Net 👁️', + }, + { + name: 'SAFETY_NET_WORKTREE=1', + env: { SAFETY_NET_WORKTREE: '1' }, + output: '🛡️ Safety Net 🌳', + }, + { + name: 'strict and paranoid', + env: { SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID: '1' }, + output: '🛡️ Safety Net 🔒👁️', + }, + { + name: 'SAFETY_NET_PARANOID_RM=1 only', + env: { SAFETY_NET_PARANOID_RM: '1' }, + output: '🛡️ Safety Net 🗑️', + }, + { + name: 'strict and paranoid rm', + env: { SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_RM: '1' }, + output: '🛡️ Safety Net 🔒🗑️', + }, + { + name: 'SAFETY_NET_PARANOID_INTERPRETERS=1', + env: { SAFETY_NET_PARANOID_INTERPRETERS: '1' }, + output: '🛡️ Safety Net 🐚', + }, + { + name: 'strict and paranoid interpreters', + env: { SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1' }, + output: '🛡️ Safety Net 🔒🐚', + }, + { + name: 'both granular paranoid flags', + env: { SAFETY_NET_PARANOID_RM: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1' }, + output: '🛡️ Safety Net 👁️', + }, + { + name: 'strict and both granular paranoid flags', env: { - ...process.env, - CLAUDE_SETTINGS_PATH: enabledSettingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID_RM: '1', SAFETY_NET_PARANOID_INTERPRETERS: '1', }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒👁️'); - expect(exitCode).toBe(0); - }); + output: '🛡️ Safety Net 🔒👁️', + }, + ]; + + for (const mode of modes) { + test(`shows ${mode.name}`, async () => { + await expectStatusline( + { CLAUDE_SETTINGS_PATH: enabledSettingsPath, ...mode.env }, + mode.output, + ); + }); + } }); describe('--statusline enabled/disabled detection', () => { @@ -250,139 +127,55 @@ describe('--statusline enabled/disabled detection', () => { test('shows ❌ when plugin is disabled in settings', async () => { const settingsPath = join(tempDir, 'settings.json'); - await writeFile( - settingsPath, - JSON.stringify({ - enabledPlugins: { - 'safety-net@cc-marketplace': false, - }, - }), - ); - - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ❌'); - expect(exitCode).toBe(0); + await writePluginSettings(settingsPath, false); + await expectStatusline({ CLAUDE_SETTINGS_PATH: settingsPath }, '🛡️ Safety Net ❌'); }); test('shows ✅ when plugin is enabled in settings', async () => { const settingsPath = join(tempDir, 'settings.json'); - await writeFile( - settingsPath, - JSON.stringify({ - enabledPlugins: { - 'safety-net@cc-marketplace': true, - }, - }), - ); - - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ✅'); - expect(exitCode).toBe(0); + await writePluginSettings(settingsPath, true); + await expectStatusline({ CLAUDE_SETTINGS_PATH: settingsPath }, '🛡️ Safety Net ✅'); }); test('shows ❌ when settings file does not exist (default disabled)', async () => { const settingsPath = join(tempDir, 'nonexistent.json'); - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ❌'); - expect(exitCode).toBe(0); + await expectStatusline({ CLAUDE_SETTINGS_PATH: settingsPath }, '🛡️ Safety Net ❌'); }); test('shows ❌ when enabledPlugins key is missing (default disabled)', async () => { const settingsPath = join(tempDir, 'settings.json'); await writeFile(settingsPath, JSON.stringify({ model: 'opus' })); - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { ...process.env, CLAUDE_SETTINGS_PATH: settingsPath }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ❌'); - expect(exitCode).toBe(0); + await expectStatusline({ CLAUDE_SETTINGS_PATH: settingsPath }, '🛡️ Safety Net ❌'); }); test('disabled plugin ignores mode flags (shows ❌ only)', async () => { const settingsPath = join(tempDir, 'settings.json'); - await writeFile( - settingsPath, - JSON.stringify({ - enabledPlugins: { - 'safety-net@cc-marketplace': false, - }, - }), + await writePluginSettings(settingsPath, false); + await expectStatusline( + { CLAUDE_SETTINGS_PATH: settingsPath, SAFETY_NET_STRICT: '1', SAFETY_NET_PARANOID: '1' }, + '🛡️ Safety Net ❌', ); - - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: settingsPath, - SAFETY_NET_STRICT: '1', - SAFETY_NET_PARANOID: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net ❌'); - expect(exitCode).toBe(0); }); test('enabled plugin with modes shows mode emojis', async () => { const settingsPath = join(tempDir, 'settings.json'); - await writeFile( - settingsPath, - JSON.stringify({ - enabledPlugins: { - 'safety-net@cc-marketplace': true, - }, - }), + await writePluginSettings(settingsPath, true); + await expectStatusline( + { CLAUDE_SETTINGS_PATH: settingsPath, SAFETY_NET_STRICT: '1' }, + '🛡️ Safety Net 🔒', ); - - const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', '--statusline'], { - stdout: 'pipe', - stderr: 'pipe', - env: { - ...process.env, - CLAUDE_SETTINGS_PATH: settingsPath, - SAFETY_NET_STRICT: '1', - }, - }); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - expect(output.trim()).toBe('🛡️ Safety Net 🔒'); - expect(exitCode).toBe(0); }); }); + +async function writePluginSettings(path: string, enabled: boolean) { + await writeFile( + path, + JSON.stringify({ + enabledPlugins: { + 'safety-net@cc-marketplace': enabled, + }, + }), + ); +} diff --git a/tests/bin/colors.test.ts b/tests/bin/colors.test.ts index e3bea49..9b285d1 100644 --- a/tests/bin/colors.test.ts +++ b/tests/bin/colors.test.ts @@ -1,5 +1,6 @@ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { colorizeToken, colors, generateDistinctColor, shouldUseColor } from '@/bin/utils/colors'; +import { withStdoutColor } from '../helpers.ts'; /** * Test the colors module. @@ -7,383 +8,239 @@ import { colorizeToken, colors, generateDistinctColor, shouldUseColor } from '@/ */ describe('colors', () => { describe('shouldUseColor', () => { - let originalIsTTY: boolean | undefined; - let originalNoColor: string | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - originalNoColor = process.env.NO_COLOR; - }); - - afterEach(() => { - // Restore original values - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - if (originalNoColor === undefined) { - delete process.env.NO_COLOR; - } else { - process.env.NO_COLOR = originalNoColor; - } - }); - test('returns true when TTY and NO_COLOR not set', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, + withStdoutColor(true, () => { + expect(shouldUseColor()).toBe(true); }); - delete process.env.NO_COLOR; - expect(shouldUseColor()).toBe(true); }); test('returns false when not a TTY', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, + withStdoutColor(false, () => { + expect(shouldUseColor()).toBe(false); }); - delete process.env.NO_COLOR; - expect(shouldUseColor()).toBe(false); }); test('returns false when NO_COLOR is set', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, + withStdoutColor(true, () => { + process.env.NO_COLOR = '1'; + expect(shouldUseColor()).toBe(false); }); - process.env.NO_COLOR = '1'; - expect(shouldUseColor()).toBe(false); }); test('returns false when isTTY is undefined', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: undefined, - writable: true, - configurable: true, + withStdoutColor(false, () => { + Object.defineProperty(process.stdout, 'isTTY', { + value: undefined, + writable: true, + configurable: true, + }); + expect(shouldUseColor()).toBe(false); }); - delete process.env.NO_COLOR; - expect(shouldUseColor()).toBe(false); }); }); describe('generateDistinctColor (with colors enabled)', () => { - let originalIsTTY: boolean | undefined; - let originalNoColor: string | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - originalNoColor = process.env.NO_COLOR; - // Enable colors for these tests - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }); - delete process.env.NO_COLOR; - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - if (originalNoColor === undefined) { - delete process.env.NO_COLOR; - } else { - process.env.NO_COLOR = originalNoColor; - } - }); - test('returns ANSI escape sequence for index 0', () => { - const color = generateDistinctColor(0); - // Check it starts with ANSI 256-color escape and ends with 'm' - expect(color.startsWith('\x1b[38;5;')).toBe(true); - expect(color.endsWith('m')).toBe(true); + withStdoutColor(true, () => { + const color = generateDistinctColor(0); + // Check it starts with ANSI 256-color escape and ends with 'm' + expect(color.startsWith('\x1b[38;5;')).toBe(true); + expect(color.endsWith('m')).toBe(true); + }); }); test('returns different colors for different indices', () => { - const color0 = generateDistinctColor(0); - const color1 = generateDistinctColor(1); - const color2 = generateDistinctColor(2); - expect(color0).not.toBe(color1); - expect(color1).not.toBe(color2); - expect(color0).not.toBe(color2); + withStdoutColor(true, () => { + const color0 = generateDistinctColor(0); + const color1 = generateDistinctColor(1); + const color2 = generateDistinctColor(2); + expect(color0).not.toBe(color1); + expect(color1).not.toBe(color2); + expect(color0).not.toBe(color2); + }); }); test('produces consistent colors for same index and default seed', () => { - const color1 = generateDistinctColor(5); - const color2 = generateDistinctColor(5); - expect(color1).toBe(color2); + withStdoutColor(true, () => { + expect(generateDistinctColor(5)).toBe(generateDistinctColor(5)); + }); }); test('produces consistent colors for same index and specific seed', () => { - const seed = 0.5; - const color1 = generateDistinctColor(5, seed); - const color2 = generateDistinctColor(5, seed); - expect(color1).toBe(color2); + withStdoutColor(true, () => { + expect(generateDistinctColor(5, 0.5)).toBe(generateDistinctColor(5, 0.5)); + }); }); test('produces different colors for same index with different seeds', () => { - // With our shuffle logic, different seeds should produce different permutations - // It's possible for index 0 to map to the same color by chance, but unlikely for all indices - // Check a few indices to be sure - const seed1 = 0.1; - const seed2 = 0.9; - - let different = false; - for (let i = 0; i < 5; i++) { - if (generateDistinctColor(i, seed1) !== generateDistinctColor(i, seed2)) { - different = true; - break; - } - } - expect(different).toBe(true); + withStdoutColor(true, () => { + expect( + [0, 1, 2, 3, 4].some( + (i) => generateDistinctColor(i, 0.1) !== generateDistinctColor(i, 0.9), + ), + ).toBe(true); + }); }); test('handles large indices', () => { - const color = generateDistinctColor(1000); - expect(color.startsWith('\x1b[38;5;')).toBe(true); - expect(color.endsWith('m')).toBe(true); + withStdoutColor(true, () => { + const color = generateDistinctColor(1000); + expect(color.startsWith('\x1b[38;5;')).toBe(true); + expect(color.endsWith('m')).toBe(true); + }); }); }); describe('generateDistinctColor (with colors disabled)', () => { - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - }); - test('returns empty string when colors disabled', () => { - const color = generateDistinctColor(0); - expect(color).toBe(''); + withStdoutColor(false, () => { + expect(generateDistinctColor(0)).toBe(''); + }); }); }); describe('colorizeToken (with colors enabled)', () => { - let originalIsTTY: boolean | undefined; - let originalNoColor: string | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - originalNoColor = process.env.NO_COLOR; - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }); - delete process.env.NO_COLOR; - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - if (originalNoColor === undefined) { - delete process.env.NO_COLOR; - } else { - process.env.NO_COLOR = originalNoColor; - } - }); - test('wraps token in color codes and quotes', () => { - const result = colorizeToken('test', 0); - // Check format: ANSI color + quoted token + reset - expect(result.startsWith('\x1b[38;5;')).toBe(true); - expect(result).toContain('"test"'); - expect(result.endsWith('\x1b[0m')).toBe(true); + withStdoutColor(true, () => { + const result = colorizeToken('test', 0); + // Check format: ANSI color + quoted token + reset + expect(result.startsWith('\x1b[38;5;')).toBe(true); + expect(result).toContain('"test"'); + expect(result.endsWith('\x1b[0m')).toBe(true); + }); }); test('uses different colors for different indices', () => { - const result0 = colorizeToken('a', 0); - const result1 = colorizeToken('a', 1); - // Should have different color codes - expect(result0).not.toBe(result1); + withStdoutColor(true, () => { + expect(colorizeToken('a', 0)).not.toBe(colorizeToken('a', 1)); + }); }); test('handles special characters in token', () => { - const result = colorizeToken('hello world', 0); - expect(result).toContain('hello world'); + withStdoutColor(true, () => { + expect(colorizeToken('hello world', 0)).toContain('hello world'); + }); }); test('handles empty token', () => { - const result = colorizeToken('', 0); - expect(result).toContain('""'); + withStdoutColor(true, () => { + expect(colorizeToken('', 0)).toContain('""'); + }); }); }); describe('colorizeToken (with colors disabled)', () => { - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - }); - test('returns quoted token without color codes', () => { - const result = colorizeToken('test', 0); - expect(result).toBe('"test"'); + withStdoutColor(false, () => { + expect(colorizeToken('test', 0)).toBe('"test"'); + }); }); test('returns same result for any index when disabled', () => { - const result0 = colorizeToken('test', 0); - const result1 = colorizeToken('test', 1); - expect(result0).toBe('"test"'); - expect(result1).toBe('"test"'); + withStdoutColor(false, () => { + expect(colorizeToken('test', 0)).toBe('"test"'); + expect(colorizeToken('test', 1)).toBe('"test"'); + }); }); }); describe('colors object (with colors enabled)', () => { - let originalIsTTY: boolean | undefined; - let originalNoColor: string | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - originalNoColor = process.env.NO_COLOR; - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, - }); - delete process.env.NO_COLOR; - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - if (originalNoColor === undefined) { - delete process.env.NO_COLOR; - } else { - process.env.NO_COLOR = originalNoColor; - } - }); - test('green applies green color code', () => { - const result = colors.green('text'); - expect(result).toBe('\x1b[32mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.green('text')).toBe('\x1b[32mtext\x1b[0m'); + }); }); test('yellow applies yellow color code', () => { - const result = colors.yellow('text'); - expect(result).toBe('\x1b[33mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.yellow('text')).toBe('\x1b[33mtext\x1b[0m'); + }); }); test('blue applies blue color code', () => { - const result = colors.blue('text'); - expect(result).toBe('\x1b[34mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.blue('text')).toBe('\x1b[34mtext\x1b[0m'); + }); }); test('magenta applies magenta color code', () => { - const result = colors.magenta('text'); - expect(result).toBe('\x1b[35mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.magenta('text')).toBe('\x1b[35mtext\x1b[0m'); + }); }); test('cyan applies cyan color code', () => { - const result = colors.cyan('text'); - expect(result).toBe('\x1b[36mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.cyan('text')).toBe('\x1b[36mtext\x1b[0m'); + }); }); test('red applies red color code', () => { - const result = colors.red('text'); - expect(result).toBe('\x1b[31mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.red('text')).toBe('\x1b[31mtext\x1b[0m'); + }); }); test('dim applies dim code', () => { - const result = colors.dim('text'); - expect(result).toBe('\x1b[2mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.dim('text')).toBe('\x1b[2mtext\x1b[0m'); + }); }); test('bold applies bold code', () => { - const result = colors.bold('text'); - expect(result).toBe('\x1b[1mtext\x1b[0m'); + withStdoutColor(true, () => { + expect(colors.bold('text')).toBe('\x1b[1mtext\x1b[0m'); + }); }); }); describe('colors object (with colors disabled)', () => { - let originalIsTTY: boolean | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - Object.defineProperty(process.stdout, 'isTTY', { - value: false, - writable: true, - configurable: true, - }); - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - }); - test('green returns plain text', () => { - expect(colors.green('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.green('text')).toBe('text'); + }); }); test('yellow returns plain text', () => { - expect(colors.yellow('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.yellow('text')).toBe('text'); + }); }); test('blue returns plain text', () => { - expect(colors.blue('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.blue('text')).toBe('text'); + }); }); test('magenta returns plain text', () => { - expect(colors.magenta('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.magenta('text')).toBe('text'); + }); }); test('cyan returns plain text', () => { - expect(colors.cyan('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.cyan('text')).toBe('text'); + }); }); test('red returns plain text', () => { - expect(colors.red('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.red('text')).toBe('text'); + }); }); test('dim returns plain text', () => { - expect(colors.dim('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.dim('text')).toBe('text'); + }); }); test('bold returns plain text', () => { - expect(colors.bold('text')).toBe('text'); + withStdoutColor(false, () => { + expect(colors.bold('text')).toBe('text'); + }); }); }); }); diff --git a/tests/bin/doctor/activity.test.ts b/tests/bin/doctor/activity.test.ts index 375d35e..44bd832 100644 --- a/tests/bin/doctor/activity.test.ts +++ b/tests/bin/doctor/activity.test.ts @@ -8,6 +8,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getActivitySummary } from '@/bin/doctor/activity'; +function createLogsDir(): string { + const logsDir = join(tmpdir(), `doctor-logs-${Date.now()}`); + mkdirSync(logsDir, { recursive: true }); + return logsDir; +} + describe('getActivitySummary', () => { test('returns activity summary structure', () => { const activity = getActivitySummary(7); @@ -27,8 +33,7 @@ describe('getActivitySummary', () => { }); test('reads and parses log files from directory', () => { - const logsDir = join(tmpdir(), `doctor-logs-${Date.now()}`); - mkdirSync(logsDir, { recursive: true }); + const logsDir = createLogsDir(); const now = new Date(); const entry1 = { @@ -61,8 +66,7 @@ describe('getActivitySummary', () => { }); test('filters entries older than specified days', () => { - const logsDir = join(tmpdir(), `doctor-logs-${Date.now()}`); - mkdirSync(logsDir, { recursive: true }); + const logsDir = createLogsDir(); const now = new Date(); const recentEntry = { diff --git a/tests/bin/doctor/config.test.ts b/tests/bin/doctor/config.test.ts index 2fa521e..9e5fa05 100644 --- a/tests/bin/doctor/config.test.ts +++ b/tests/bin/doctor/config.test.ts @@ -7,147 +7,98 @@ import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getConfigInfo } from '@/bin/doctor/config'; +import { withTempDir } from '../../helpers.ts'; describe('getConfigInfo', () => { test('handles missing config files', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - try { + withTempDir('doctor-test-', (tmpDir) => { const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(false); expect(info.effectiveRules).toEqual([]); expect(info.shadowedRules).toEqual([]); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('detects valid project config', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - writeFileSync( - configPath, - JSON.stringify({ - version: 1, - rules: [ - { - name: 'test-rule', - command: 'test', - block_args: ['--dangerous'], - reason: 'Test reason', - }, - ], - }), - ); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync( + join(tmpDir, '.safety-net.json'), + JSON.stringify({ + version: 1, + rules: [ + { + name: 'test-rule', + command: 'test', + block_args: ['--dangerous'], + reason: 'Test reason', + }, + ], + }), + ); const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(true); expect(info.projectConfig.valid).toBe(true); expect(info.projectConfig.ruleCount).toBe(1); expect(info.effectiveRules.length).toBe(1); expect(info.effectiveRules[0]?.source).toBe('project'); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('detects invalid project config', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - writeFileSync(configPath, '{ "version": 2 }'); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync(join(tmpDir, '.safety-net.json'), '{ "version": 2 }'); const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(true); expect(info.projectConfig.valid).toBe(false); expect(info.projectConfig.errors?.length).toBeGreaterThan(0); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('excludes rules from invalid config (wrong version)', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - // Invalid config: version 2 is not supported, but contains valid-looking rules - writeFileSync( - configPath, - JSON.stringify({ - version: 2, - rules: [ - { - name: 'should-not-appear', - command: 'test', - block_args: ['--dangerous'], - reason: 'This rule should not be shown as effective', - }, - ], - }), - ); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync( + join(tmpDir, '.safety-net.json'), + JSON.stringify({ + version: 2, + rules: [ + { + name: 'should-not-appear', + command: 'test', + block_args: ['--dangerous'], + reason: 'This rule should not be shown as effective', + }, + ], + }), + ); const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(true); expect(info.projectConfig.valid).toBe(false); - // Rules from invalid configs should NOT appear as effective expect(info.effectiveRules).toEqual([]); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('handles malformed JSON in config', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - writeFileSync(configPath, '{ invalid json }'); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync(join(tmpDir, '.safety-net.json'), '{ invalid json }'); const info = getConfigInfo(tmpDir); - // Malformed JSON means rules can't be loaded expect(info.effectiveRules).toEqual([]); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('handles empty config file', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - writeFileSync(configPath, ' '); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync(join(tmpDir, '.safety-net.json'), ' '); const info = getConfigInfo(tmpDir); expect(info.effectiveRules).toEqual([]); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('handles config without rules array', () => { - const tmpDir = join(tmpdir(), `doctor-test-${Date.now()}`); - mkdirSync(tmpDir, { recursive: true }); - - const configPath = join(tmpDir, '.safety-net.json'); - writeFileSync(configPath, '{ "version": 1 }'); - - try { + withTempDir('doctor-test-', (tmpDir) => { + writeFileSync(join(tmpDir, '.safety-net.json'), '{ "version": 1 }'); const info = getConfigInfo(tmpDir); expect(info.effectiveRules).toEqual([]); - } finally { - rmSync(tmpDir, { recursive: true, force: true }); - } + }); }); test('merges user and project rules with shadowing', () => { diff --git a/tests/bin/doctor/format.test.ts b/tests/bin/doctor/format.test.ts index 73b140d..2da1102 100644 --- a/tests/bin/doctor/format.test.ts +++ b/tests/bin/doctor/format.test.ts @@ -2,7 +2,7 @@ * Tests for the doctor command formatting functions. */ -import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; import { getEnvironmentInfo } from '@/bin/doctor/environment'; import { formatActivitySection, @@ -16,7 +16,35 @@ import { } from '@/bin/doctor/format'; import { getSystemInfo } from '@/bin/doctor/system-info'; import type { DoctorReport, EffectiveRule, HookStatus } from '@/bin/doctor/types'; -import { mockVersionFetcher } from '../../helpers.ts'; +import { mockVersionFetcher, withStdoutColor } from '../../helpers.ts'; + +function createDoctorReport(overrides: Partial = {}): DoctorReport { + return { + hooks: [], + userConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, + projectConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, + effectiveRules: [], + shadowedRules: [], + environment: [], + activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, + update: { currentVersion: '0.6.0', latestVersion: '0.6.0', updateAvailable: false }, + system: { + version: '0.6.0', + claudeCodeVersion: null, + claudePluginListOutput: null, + openCodeVersion: null, + geminiCliVersion: null, + geminiExtensionsListOutput: null, + copilotCliVersion: null, + nodeVersion: '22.0.0', + npmVersion: '10.0.0', + bunVersion: '1.0.0', + copilotPluginInstalled: false, + platform: 'darwin', + }, + ...overrides, + }; +} describe('formatRulesTable', () => { test('formats rules as ASCII table', () => { @@ -71,27 +99,6 @@ describe('formatRulesTable', () => { }); describe('formatHooksSection', () => { - let originalIsTTY: boolean | undefined; - let originalNoColor: string | undefined; - - beforeEach(() => { - originalIsTTY = process.stdout.isTTY; - originalNoColor = process.env.NO_COLOR; - }); - - afterEach(() => { - Object.defineProperty(process.stdout, 'isTTY', { - value: originalIsTTY, - writable: true, - configurable: true, - }); - if (originalNoColor === undefined) { - delete process.env.NO_COLOR; - return; - } - process.env.NO_COLOR = originalNoColor; - }); - test('formats configured hooks with self-test', () => { const hooks: HookStatus[] = [ { @@ -161,19 +168,12 @@ describe('formatHooksSection', () => { }); test('shows hook errors in red when colors are enabled', () => { - Object.defineProperty(process.stdout, 'isTTY', { - value: true, - writable: true, - configurable: true, + withStdoutColor(true, () => { + const hooks: HookStatus[] = [ + { platform: 'codex', status: 'disabled', errors: ['Parse error'] }, + ]; + expect(formatHooksSection(hooks)).toContain('\x1b[31m Error (Codex): Parse error\x1b[0m'); }); - delete process.env.NO_COLOR; - - const hooks: HookStatus[] = [ - { platform: 'codex', status: 'disabled', errors: ['Parse error'] }, - ]; - - const output = formatHooksSection(hooks); - expect(output).toContain('\x1b[31m Error (Codex): Parse error\x1b[0m'); }); test('shows warning for configured hooks with errors', () => { @@ -420,8 +420,7 @@ describe('formatSystemInfoSection', () => { describe('formatConfigSection', () => { test('formats config with no rules', () => { - const report: DoctorReport = { - hooks: [], + const report = createDoctorReport({ userConfig: { path: '/home/user/.cc-safety-net/config.json', exists: false, @@ -434,30 +433,7 @@ describe('formatConfigSection', () => { valid: false, ruleCount: 0, }, - effectiveRules: [], - shadowedRules: [], - environment: [], - activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, - update: { - currentVersion: '0.6.0', - latestVersion: '0.6.0', - updateAvailable: false, - }, - system: { - version: '0.6.0', - claudeCodeVersion: '1.0.0', - claudePluginListOutput: null, - openCodeVersion: '0.1.0', - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin arm64', - }, - }; + }); const output = formatConfigSection(report); expect(output).toContain('Configuration'); expect(output).toContain('User'); @@ -466,8 +442,7 @@ describe('formatConfigSection', () => { }); test('formats config with shadow warnings', () => { - const report: DoctorReport = { - hooks: [], + const report = createDoctorReport({ userConfig: { path: '/home/user/.cc-safety-net/config.json', exists: true, @@ -490,35 +465,13 @@ describe('formatConfigSection', () => { }, ], shadowedRules: [{ name: 'test-rule', shadowedBy: 'project' }], - environment: [], - activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, - update: { - currentVersion: '0.6.0', - latestVersion: '0.6.0', - updateAvailable: false, - }, - system: { - version: '0.6.0', - claudeCodeVersion: '1.0.0', - claudePluginListOutput: null, - openCodeVersion: '0.1.0', - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin arm64', - }, - }; + }); const output = formatConfigSection(report); expect(output).toContain('shadows user rule'); }); test('formats config with invalid config showing errors', () => { - const report: DoctorReport = { - hooks: [], + const report = createDoctorReport({ userConfig: { path: '/home/user/.cc-safety-net/config.json', exists: true, @@ -533,30 +486,7 @@ describe('formatConfigSection', () => { ruleCount: 0, errors: ['Malformed JSON'], }, - effectiveRules: [], - shadowedRules: [], - environment: [], - activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, - update: { - currentVersion: '0.6.0', - latestVersion: '0.6.0', - updateAvailable: false, - }, - system: { - version: '0.6.0', - claudeCodeVersion: '1.0.0', - claudePluginListOutput: null, - openCodeVersion: '0.1.0', - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin arm64', - }, - }; + }); const output = formatConfigSection(report); expect(output).toContain('Invalid'); expect(output).toContain('Invalid version: expected 1, got 99'); @@ -566,88 +496,27 @@ describe('formatConfigSection', () => { describe('formatSummary', () => { test('formats all passed', () => { - const report: DoctorReport = { + const report = createDoctorReport({ hooks: [{ platform: 'claude-code', status: 'configured' }], - userConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - projectConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - effectiveRules: [], - shadowedRules: [], - environment: [], activity: { totalBlocked: 1, sessionCount: 1, recentEntries: [] }, - update: { currentVersion: '0.6.0', latestVersion: '0.6.0', updateAvailable: false }, - system: { - version: '0.6.0', - claudeCodeVersion: null, - claudePluginListOutput: null, - openCodeVersion: null, - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin', - }, - }; + }); const output = formatSummary(report); expect(output).toContain('All checks passed'); }); test('formats with warnings', () => { - const report: DoctorReport = { + const report = createDoctorReport({ hooks: [{ platform: 'claude-code', status: 'configured' }], - userConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - projectConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - effectiveRules: [], - shadowedRules: [], - environment: [], - activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, update: { currentVersion: '0.6.0', latestVersion: '0.7.0', updateAvailable: true }, - system: { - version: '0.6.0', - claudeCodeVersion: null, - claudePluginListOutput: null, - openCodeVersion: null, - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin', - }, - }; + }); const output = formatSummary(report); expect(output).toContain('warning'); }); test('formats with failures', () => { - const report: DoctorReport = { + const report = createDoctorReport({ hooks: [], - userConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - projectConfig: { path: '', exists: false, valid: false, ruleCount: 0 }, - effectiveRules: [], - shadowedRules: [], - environment: [], - activity: { totalBlocked: 0, sessionCount: 0, recentEntries: [] }, - update: { currentVersion: '0.6.0', latestVersion: '0.6.0', updateAvailable: false }, - system: { - version: '0.6.0', - claudeCodeVersion: null, - claudePluginListOutput: null, - openCodeVersion: null, - geminiCliVersion: null, - geminiExtensionsListOutput: null, - copilotCliVersion: null, - nodeVersion: '22.0.0', - npmVersion: '10.0.0', - bunVersion: '1.0.0', - copilotPluginInstalled: false, - platform: 'darwin', - }, - }; + }); const output = formatSummary(report); expect(output).toContain('failed'); }); diff --git a/tests/bin/doctor/system-info.test.ts b/tests/bin/doctor/system-info.test.ts index 36c52e4..5550678 100644 --- a/tests/bin/doctor/system-info.test.ts +++ b/tests/bin/doctor/system-info.test.ts @@ -20,6 +20,28 @@ function createDeferred(): { return { promise, resolve, reject }; } +function createCopilotDeferredFetcher() { + const calls: string[][] = []; + const binaryVersion = createDeferred(); + const fallbackVersion = createDeferred(); + const fetcher = (args: string[]): Promise => { + calls.push(args); + if (args[0] === 'copilot' && args[1] === '--binary-version') { + return binaryVersion.promise; + } + if (args[0] === 'copilot' && args[1] === '--version') { + return fallbackVersion.promise; + } + return Promise.resolve(null); + }; + return { binaryVersion, calls, fallbackVersion, fetcher }; +} + +function expectCopilotVersionProbesStarted(calls: string[][]): void { + expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--binary-version')).toBe(true); + expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--version')).toBe(true); +} + describe('getSystemInfo', () => { test('returns all required fields', async () => { const sysInfo = await getSystemInfo(mockVersionFetcher); @@ -74,30 +96,15 @@ describe('getSystemInfo', () => { }); test('starts both copilot version probes immediately and prefers --binary-version', async () => { - const calls: string[][] = []; - const binaryVersion = createDeferred(); - const fallbackVersion = createDeferred(); - const fetcher = (args: string[]): Promise => { - calls.push(args); - if (args[0] === 'copilot' && args[1] === '--binary-version') { - return binaryVersion.promise; - } - if (args[0] === 'copilot' && args[1] === '--version') { - return fallbackVersion.promise; - } - return Promise.resolve(null); - }; - + const probes = createCopilotDeferredFetcher(); + const fetcher = probes.fetcher; const sysInfoPromise = getSystemInfo(fetcher); await Promise.resolve(); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--binary-version')).toBe( - true, - ); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--version')).toBe(true); + expectCopilotVersionProbesStarted(probes.calls); - fallbackVersion.resolve('copilot 1.0.8'); - binaryVersion.resolve('Copilot binary version: 1.0.9'); + probes.fallbackVersion.resolve('copilot 1.0.8'); + probes.binaryVersion.resolve('Copilot binary version: 1.0.9'); const sysInfo = await sysInfoPromise; @@ -117,42 +124,23 @@ describe('getSystemInfo', () => { const sysInfo = await getSystemInfo(fetcher); expect(sysInfo.copilotCliVersion).toBe('1.0.8'); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--binary-version')).toBe( - true, - ); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--version')).toBe(true); + expectCopilotVersionProbesStarted(calls); }); test('does not wait for copilot --version when --binary-version succeeds', async () => { - const calls: string[][] = []; - const binaryVersion = createDeferred(); - const fallbackVersion = createDeferred(); - const fetcher = (args: string[]): Promise => { - calls.push(args); - if (args[0] === 'copilot' && args[1] === '--binary-version') { - return binaryVersion.promise; - } - if (args[0] === 'copilot' && args[1] === '--version') { - return fallbackVersion.promise; - } - return Promise.resolve(null); - }; - - const sysInfoPromise = getSystemInfo(fetcher); + const probes = createCopilotDeferredFetcher(); + const sysInfoPromise = getSystemInfo(probes.fetcher); await Promise.resolve(); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--binary-version')).toBe( - true, - ); - expect(calls.some((args) => args[0] === 'copilot' && args[1] === '--version')).toBe(true); + expectCopilotVersionProbesStarted(probes.calls); - binaryVersion.resolve('Copilot binary version: 1.0.9'); + probes.binaryVersion.resolve('Copilot binary version: 1.0.9'); const sysInfo = await sysInfoPromise; expect(sysInfo.copilotCliVersion).toBe('1.0.9'); - fallbackVersion.resolve('copilot 1.0.8'); + probes.fallbackVersion.resolve('copilot 1.0.8'); }, 100); test('handles commands that exit with non-zero code', async () => { diff --git a/tests/bin/explain/cli.test.ts b/tests/bin/explain/cli.test.ts index 8fee6d0..3f5a656 100644 --- a/tests/bin/explain/cli.test.ts +++ b/tests/bin/explain/cli.test.ts @@ -2,97 +2,47 @@ * Tests for the explain command CLI flag parsing. */ import { describe, expect, test } from 'bun:test'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { createLinkedWorktreeFixture } from '../../helpers.ts'; +import { createLinkedWorktreeFixture, runSafetyNetCli, withTempDir } from '../../helpers.ts'; + +async function explainJson(args: string[]) { + const result = await runSafetyNetCli(['explain', '--json', ...args]); + return { + parsed: JSON.parse(result.output), + exitCode: result.exitCode, + }; +} describe('explain CLI flag parsing', () => { test('explain preserves --debug in command when it appears after first positional arg', async () => { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', 'echo', '--debug'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + const { parsed, exitCode } = await explainJson(['echo', '--debug']); const parseStep = parsed.trace.steps.find((s: { type: string }) => s.type === 'parse'); expect(parseStep.input).toBe('echo --debug'); expect(exitCode).toBe(0); }); test('explain preserves --json in command when after positional arg', async () => { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', 'git', 'push', '--json'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + const { parsed, exitCode } = await explainJson(['git', 'push', '--json']); const parseStep = parsed.trace.steps.find((s: { type: string }) => s.type === 'parse'); expect(parseStep.input).toBe('git push --json'); expect(exitCode).toBe(0); }); test('explain with -- separator treats everything after as command', async () => { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', '--', '--debug'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + const { parsed, exitCode } = await explainJson(['--', '--debug']); const parseStep = parsed.trace.steps.find((s: { type: string }) => s.type === 'parse'); expect(parseStep.input).toBe('--debug'); expect(exitCode).toBe(0); }); test('explain unknown flag is treated as start of command', async () => { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', '--unknown-flag', 'foo'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + const { parsed, exitCode } = await explainJson(['--unknown-flag', 'foo']); const parseStep = parsed.trace.steps.find((s: { type: string }) => s.type === 'parse'); expect(parseStep.input).toBe('--unknown-flag foo'); expect(exitCode).toBe(0); }); test('explain single-arg command with pipe preserves shell operators', async () => { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', 'git status | rm -rf /'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + const { parsed, exitCode } = await explainJson(['git status | rm -rf /']); const parseStep = parsed.trace.steps.find((s: { type: string }) => s.type === 'parse'); expect(parseStep.input).toBe('git status | rm -rf /'); expect(parseStep.segments).toEqual([ @@ -104,26 +54,11 @@ describe('explain CLI flag parsing', () => { }); test('explain --cwd passes cwd to analysis', async () => { - // Use platform-appropriate temp directory instead of hardcoded /tmp - const tempDir = mkdtempSync(join(tmpdir(), 'safety-net-explain-')); - try { - const proc = Bun.spawn( - ['bun', 'src/bin/cc-safety-net.ts', 'explain', '--json', '--cwd', tempDir, 'rm -rf ./foo'], - { - stdout: 'pipe', - stderr: 'pipe', - }, - ); - - const output = await new Response(proc.stdout).text(); - const exitCode = await proc.exited; - - const parsed = JSON.parse(output); + await withTempDir('safety-net-explain-', async (tempDir) => { + const { parsed, exitCode } = await explainJson(['--cwd', tempDir, 'rm -rf ./foo']); expect(parsed.result).toBe('allowed'); expect(exitCode).toBe(0); - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } + }); }); test('explain --json reports worktree relaxation', async () => { diff --git a/tests/bin/explain/command.test.ts b/tests/bin/explain/command.test.ts index 226c999..3b8a02a 100644 --- a/tests/bin/explain/command.test.ts +++ b/tests/bin/explain/command.test.ts @@ -11,7 +11,58 @@ import { explainSegment } from '@/bin/explain/segment'; import { REASON_RECURSION_LIMIT } from '@/core/analyze/analyze-command'; import type { TraceStep } from '@/types'; import { MAX_RECURSION_DEPTH } from '@/types'; -import { createLinkedWorktreeFixture, toShellPath, withEnv } from '../../helpers.ts'; +import { getTraceSteps, toShellPath, withEnv, withLinkedWorktreeFixture } from '../../helpers.ts'; + +function nestedBashCommand(command: string, levels: number): string { + return Array.from({ length: levels }).reduce( + (cmd) => `bash -c ${JSON.stringify(cmd)}`, + command, + ); +} + +function recursionLimitErrorStep(command: string) { + return getTraceSteps(explainCommand(command)).find( + (s) => s.type === 'error' && s.message?.includes('exceeds maximum recursion depth'), + ); +} + +function expectDangerousTextStep(command: string): void { + const result = explainCommand(command); + expect(result.result).toBe('blocked'); + expect( + getTraceSteps(result).find((s) => s.type === 'dangerous-text' && s.matched === true), + ).toBeDefined(); +} + +function expectWorktreeExplainBlocked(command: (mainWorktree: string) => string, reason: string) { + withLinkedWorktreeFixture((fixture) => { + withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { + const result = explainCommand(command(toShellPath(fixture.mainWorktree)), { + cwd: fixture.linkedWorktree, + }); + expect(result.result).toBe('blocked'); + expect(result.reason).toContain(reason); + }); + }); +} + +function expectFallbackScan(command: string, embeddedCommand?: string): void { + const result = explainCommand(command); + expect(result.result).toBe('blocked'); + const fallbackStep = getTraceSteps(result).find((s) => s.type === 'fallback-scan'); + expect(fallbackStep).toBeDefined(); + if (embeddedCommand && fallbackStep?.type === 'fallback-scan') { + expect(fallbackStep.embeddedCommandFound).toBe(embeddedCommand); + } +} + +function expectParallelRuleStep(command: string) { + const result = explainCommand(command); + expect(result.result).toBe('blocked'); + return getTraceSteps(result).find( + (s) => s.type === 'rule-check' && s.ruleModule === 'analyze/parallel.ts', + ); +} describe('explainCommand', () => { test('git status returns allowed', () => { @@ -40,7 +91,7 @@ describe('explainCommand', () => { test('sudo git reset --hard traces wrapper stripping', () => { const result = explainCommand('sudo git reset --hard'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const stripStep = allSteps.find((s) => s.type === 'leading-tokens-stripped'); expect(stripStep).toBeDefined(); }); @@ -73,14 +124,14 @@ describe('explainCommand', () => { test('bash -c with inner command traces shell wrapper', () => { const result = explainCommand('bash -c "git status"'); expect(result.result).toBe('allowed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const shellStep = allSteps.find((s) => s.type === 'shell-wrapper'); expect(shellStep).toBeDefined(); }); test('env variables are redacted', () => { const result = explainCommand('SECRET=password git status'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const envStep = allSteps.find((s) => s.type === 'env-strip'); if (envStep && envStep.type === 'env-strip') { expect(envStep.envVars.SECRET).toBe(''); @@ -101,14 +152,14 @@ describe('explainCommand', () => { describe('explainCommand edge cases', () => { test('python interpreter command traces interpreter step', () => { const result = explainCommand('python -c "print(1)"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const interpStep = allSteps.find((s) => s.type === 'interpreter'); expect(interpStep).toBeDefined(); }); test('busybox rm traces busybox step', () => { const result = explainCommand('busybox rm -rf /tmp/test'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const busyboxStep = allSteps.find((s) => s.type === 'busybox'); expect(busyboxStep).toBeDefined(); }); @@ -116,7 +167,7 @@ describe('explainCommand edge cases', () => { test('rm command traces rule check', () => { const result = explainCommand('rm -rf /'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const ruleStep = allSteps.find( (s) => s.type === 'rule-check' && s.ruleModule === 'rules-rm.ts', ); @@ -126,7 +177,7 @@ describe('explainCommand edge cases', () => { test('find -delete traces rule check', () => { const result = explainCommand('find . -delete'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const ruleStep = allSteps.find( (s) => s.type === 'rule-check' && s.ruleModule === 'analyze/find.ts', ); @@ -135,14 +186,14 @@ describe('explainCommand edge cases', () => { test('xargs rm traces rule check and tmpdir check', () => { const result = explainCommand('echo | xargs rm -rf /'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const tmpStep = allSteps.find((s) => s.type === 'tmpdir-check'); expect(tmpStep).toBeDefined(); }); test('parallel command traces rule check', () => { const result = explainCommand('parallel rm -rf ::: /'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const ruleStep = allSteps.find( (s) => s.type === 'rule-check' && s.ruleModule === 'analyze/parallel.ts', ); @@ -152,7 +203,7 @@ describe('explainCommand edge cases', () => { test('custom-rules-check shows rulesChecked false when no config', () => { // Pass explicit empty config to avoid picking up real .safety-net.json const result = explainCommand('echo hello', { config: { version: 1, rules: [] } }); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const customStep = allSteps.find((s) => s.type === 'custom-rules-check'); expect(customStep).toBeDefined(); if (customStep && customStep.type === 'custom-rules-check') { @@ -162,7 +213,7 @@ describe('explainCommand edge cases', () => { test('deeply nested bash -c commands trace multiple recurse steps', () => { const result = explainCommand('bash -c "bash -c \\"git status\\""'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseSteps = allSteps.filter((s) => s.type === 'recurse'); expect(recurseSteps.length).toBeGreaterThanOrEqual(1); }); @@ -280,7 +331,7 @@ describe('explainCommand rm with home directory', () => { const result = explainCommand('rm -rf .', { cwd: homeDir }); expect(result.result).toBe('blocked'); expect(result.reason).toContain('home directory'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const ruleStep = allSteps.find( (s) => s.type === 'rule-check' && s.ruleModule === 'rules-rm.ts' && s.ruleFunction === 'analyzeRm', @@ -293,7 +344,7 @@ describe('explainCommand rm with home directory', () => { if (!homeDir) return; const result = explainCommand('rm -rf /tmp/test-dir', { cwd: homeDir }); expect(result.result).toBe('allowed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const analyzeRmStep = allSteps.find( (s) => s.type === 'rule-check' && s.ruleModule === 'rules-rm.ts' && s.ruleFunction === 'analyzeRm', @@ -304,19 +355,13 @@ describe('explainCommand rm with home directory', () => { describe('explainCommand parallel with nested blocked', () => { test('parallel rm -rf / is blocked', () => { - const result = explainCommand('parallel rm -rf ::: /'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const parallelStep = allSteps.find( - (s) => s.type === 'rule-check' && s.ruleModule === 'analyze/parallel.ts', - ); - expect(parallelStep).toBeDefined(); + expect(expectParallelRuleStep('parallel rm -rf ::: /')).toBeDefined(); }); test('sem is not treated as parallel (matches actual guard behavior)', () => { const result = explainCommand('sem rm -rf /'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const tmpStep = allSteps.find((s) => s.type === 'tmpdir-check'); expect(tmpStep).toBeUndefined(); const fallbackStep = allSteps.find((s) => s.type === 'fallback-scan'); @@ -328,7 +373,7 @@ describe('explainCommand shell wrapper edge cases', () => { test('bash without -c argument returns null for wrapper', () => { const result = explainCommand('bash script.sh'); expect(result.result).toBe('allowed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const wrapperStep = allSteps.find((s) => s.type === 'shell-wrapper'); expect(wrapperStep).toBeUndefined(); }); @@ -336,7 +381,7 @@ describe('explainCommand shell wrapper edge cases', () => { test('sh -c with blocked inner command blocks', () => { const result = explainCommand('sh -c "git reset --hard"'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const wrapperStep = allSteps.find((s) => s.type === 'shell-wrapper'); expect(wrapperStep).toBeDefined(); }); @@ -351,59 +396,32 @@ describe('explainCommand max recursion depth', () => { test('deeply nested command hits max recursion', () => { const deepNested = 'bash -c "bash -c \\"bash -c \\\\\\"bash -c \\\\\\\\\\\\\\"bash -c \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"echo deep\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\\\\\\\\\\\\\\"\\\\\\"\\"" '; - const result = explainCommand(deepNested); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const errorStep = allSteps.find( - (s) => s.type === 'error' && s.message?.includes('exceeds maximum recursion depth'), - ); - const recurseSteps = allSteps.filter((s) => s.type === 'recurse'); - expect(recurseSteps.length + (errorStep ? 1 : 0)).toBeGreaterThan(0); + const steps = getTraceSteps(explainCommand(deepNested)); + expect( + steps.filter((s) => s.type === 'recurse').length + + (recursionLimitErrorStep(deepNested) ? 1 : 0), + ).toBeGreaterThan(0); }); test('hits exact max recursion depth of 5', () => { const level5 = 'bash -c "bash -c \\"bash -c \\\\\\"bash -c \\\\\\\\\\\\\\"bash -c \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"echo hi\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\\\\\\\\\\\\\\"\\\\\\"\\"" '; - const result = explainCommand(level5); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const errorStep = allSteps.find( - (s) => s.type === 'error' && s.message?.includes('exceeds maximum recursion depth'), - ); - const recurseSteps = allSteps.filter((s) => s.type === 'recurse'); - expect(recurseSteps.length >= 3 || errorStep).toBeTruthy(); + const steps = getTraceSteps(explainCommand(level5)); + expect( + steps.filter((s) => s.type === 'recurse').length >= 3 || recursionLimitErrorStep(level5), + ).toBeTruthy(); }); test('hits max recursion depth with 10 nested bash -c calls', () => { - let cmd = 'echo ok'; - for (let i = 0; i < 10; i++) { - cmd = `bash -c ${JSON.stringify(cmd)}`; - } - const result = explainCommand(cmd); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const errorStep = allSteps.find( - (s) => s.type === 'error' && s.message?.includes('exceeds maximum recursion depth'), - ); - expect(errorStep).toBeDefined(); + expect(recursionLimitErrorStep(nestedBashCommand('echo ok', 10))).toBeDefined(); }); test('9 nested levels does not hit max recursion depth', () => { - let cmd = 'echo ok'; - for (let i = 0; i < 9; i++) { - cmd = `bash -c ${JSON.stringify(cmd)}`; - } - const result = explainCommand(cmd); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const errorStep = allSteps.find( - (s) => s.type === 'error' && s.message?.includes('exceeds maximum recursion depth'), - ); - expect(errorStep).toBeUndefined(); + expect(recursionLimitErrorStep(nestedBashCommand('echo ok', 9))).toBeUndefined(); }); test('unparseable inner command at depth limit is blocked by recursion limit', () => { - let cmd = "echo 'unclosed"; - for (let i = 0; i < 10; i++) { - cmd = `bash -c ${JSON.stringify(cmd)}`; - } - const result = explainCommand(cmd); + const result = explainCommand(nestedBashCommand("echo 'unclosed", 10)); expect(result.result).toBe('blocked'); expect(result.reason).toContain('exceeds maximum recursion depth'); }); @@ -432,7 +450,7 @@ describe('explainCommand guard parity fixes', () => { test('Fix #2: CWD changes tracked between segments - cd then rm', () => { const result = explainCommand('cd /tmp && rm -rf ./foo'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdStep = allSteps.find((s) => s.type === 'cwd-change'); expect(cwdStep).toBeDefined(); if (cwdStep && cwdStep.type === 'cwd-change') { @@ -442,7 +460,7 @@ describe('explainCommand guard parity fixes', () => { test('Fix #2: pushd changes CWD to unknown', () => { const result = explainCommand('pushd /tmp && rm -rf ./foo'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdStep = allSteps.find((s) => s.type === 'cwd-change'); expect(cwdStep).toBeDefined(); }); @@ -464,29 +482,17 @@ describe('explainCommand guard parity fixes', () => { }); test('Fix #4: fallback scan finds embedded git in non-head position', () => { - const result = explainCommand('nice git reset --hard'); - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const fallbackStep = allSteps.find((s) => s.type === 'fallback-scan'); - expect(fallbackStep).toBeDefined(); - if (fallbackStep && fallbackStep.type === 'fallback-scan') { - expect(fallbackStep.embeddedCommandFound).toBe('git'); - } + expectFallbackScan('nice git reset --hard', 'git'); }); test('Fix #4: fallback scan finds embedded rm in non-head position', () => { - const result = explainCommand('nice rm -rf /'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const fallbackStep = allSteps.find((s) => s.type === 'fallback-scan'); - expect(fallbackStep).toBeDefined(); + expectFallbackScan('nice rm -rf /'); }); test('Fix #5: shell wrapper recurses and blocks dangerous nested commands', () => { const result = explainCommand('bash -c "git reset --hard"'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse' && s.reason === 'shell-wrapper'); expect(recurseStep).toBeDefined(); }); @@ -494,7 +500,7 @@ describe('explainCommand guard parity fixes', () => { test('Fix #5: interpreter recurses for nested dangerous code', () => { const result = explainCommand('bash -c "rm -rf /"'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse'); expect(recurseStep).toBeDefined(); }); @@ -566,148 +572,63 @@ describe('explainCommand strict mode inner commands', () => { describe('explainCommand fallback scan with find', () => { test('fallback scan finds embedded find -delete in non-head position', () => { - const result = explainCommand('nice find . -delete'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const fallbackStep = allSteps.find((s) => s.type === 'fallback-scan'); - expect(fallbackStep).toBeDefined(); - if (fallbackStep && fallbackStep.type === 'fallback-scan') { - expect(fallbackStep.embeddedCommandFound).toBe('find'); - } + expectFallbackScan('nice find . -delete', 'find'); }); test('fallback scan finds find -exec with dangerous cmd in non-head position', () => { - const result = explainCommand('nice find . -name test -delete'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const fallbackStep = allSteps.find((s) => s.type === 'fallback-scan'); - expect(fallbackStep).toBeDefined(); + expectFallbackScan('nice find . -name test -delete'); }); }); describe('explainCommand worktree parity', () => { test('uses wrapper cwd when explaining worktree relaxation', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `env -C ${toShellPath(fixture.mainWorktree)} git reset --hard`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked((main) => `env -C ${main} git reset --hard`, 'git reset --hard'); }); test('carries exported git context overrides into later segments', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `export GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}; git reset --hard`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `export GIT_WORK_TREE=${main}; git reset --hard`, + 'git reset --hard', + ); }); test('passes wrapper cwd into recursive explain analysis', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `env -C ${toShellPath(fixture.mainWorktree)} sh -c "git reset --hard"`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `env -C ${main} sh -c "git reset --hard"`, + 'git reset --hard', + ); }); test('passes stripped env into recursive explain analysis', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)} sh -c "git reset --hard"`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `GIT_WORK_TREE=${main} sh -c "git reset --hard"`, + 'git reset --hard', + ); }); test('carries nested exported git context overrides across inner segments', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `sh -c "export GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}; git reset --hard"`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `sh -c "export GIT_WORK_TREE=${main}; git reset --hard"`, + 'git reset --hard', + ); }); test('includes keyword-export git context overrides in current segment', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `set -k; git restore file.txt GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git restore'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `set -k; git restore file.txt GIT_WORK_TREE=${main}`, + 'git restore', + ); }); test('includes nested keyword-export git context overrides in current segment', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = explainCommand( - `sh -c "set -k; git restore file.txt GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}"`, - { cwd: fixture.linkedWorktree }, - ); - - expect(result.result).toBe('blocked'); - expect(result.reason).toContain('git restore'); - }); - } finally { - fixture.cleanup(); - } + expectWorktreeExplainBlocked( + (main) => `sh -c "set -k; git restore file.txt GIT_WORK_TREE=${main}"`, + 'git restore', + ); }); test('honors parallel nested overrides when explaining remote commands', () => { - const fixture = createLinkedWorktreeFixture(); - try { + withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = explainCommand('parallel -S host sh -c "git reset --hard" ::: x', { cwd: fixture.linkedWorktree, @@ -716,14 +637,11 @@ describe('explainCommand worktree parity', () => { expect(result.result).toBe('blocked'); expect(result.reason).toContain('git reset --hard'); }); - } finally { - fixture.cleanup(); - } + }); }); test('does not report worktree relaxation for fallback embedded git', () => { - const fixture = createLinkedWorktreeFixture(); - try { + withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = explainCommand('ssh host git clean -f', { cwd: fixture.linkedWorktree }); const worktreeStep = result.trace.segments @@ -734,9 +652,7 @@ describe('explainCommand worktree parity', () => { expect(result.reason).toContain('git clean -f'); expect(worktreeStep).toBeUndefined(); }); - } finally { - fixture.cleanup(); - } + }); }); }); @@ -754,12 +670,7 @@ describe('explainCommand env from wrapper stripping', () => { describe('explainCommand parallel with analyzeNested', () => { test('parallel commands mode triggers analyzeNested', () => { - const result = explainCommand("parallel ::: 'rm -rf /'"); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const parallelStep = allSteps.find( - (s) => s.type === 'rule-check' && s.ruleModule === 'analyze/parallel.ts', - ); + const parallelStep = expectParallelRuleStep("parallel ::: 'rm -rf /'"); expect(parallelStep).toBeDefined(); if (parallelStep && parallelStep.type === 'rule-check') { expect(parallelStep.matched).toBe(true); @@ -780,24 +691,20 @@ describe('explainCommand parallel with analyzeNested', () => { describe('explainCommand nested segment CWD tracking', () => { test('shell wrapper with cd then rm tracks CWD change in nested segments', () => { const result = explainCommand('bash -c "cd /somewhere && rm -rf foo"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdSteps = allSteps.filter((s) => s.type === 'cwd-change'); expect(cwdSteps.length).toBeGreaterThan(0); }); test('interpreter with cd then rm tracks CWD change in nested segments', () => { const result = explainCommand('python -c "cd /tmp && rm -rf foo"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdSteps = allSteps.filter((s) => s.type === 'cwd-change'); expect(cwdSteps.length).toBeGreaterThan(0); }); test('nested unparseable segment with dangerous text is blocked', () => { - const result = explainCommand('bash -c "\'rm -rf /tmp/cache"'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const dangerousStep = allSteps.find((s) => s.type === 'dangerous-text' && s.matched === true); - expect(dangerousStep).toBeDefined(); + expectDangerousTextStep('bash -c "\'rm -rf /tmp/cache"'); }); test('nested unparseable segment without dangerous patterns is allowed', () => { @@ -806,43 +713,31 @@ describe('explainCommand nested segment CWD tracking', () => { }); test('interpreter nested unparseable segment with git reset is blocked', () => { - const result = explainCommand('python -c "\'git reset --hard HEAD"'); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const dangerousStep = allSteps.find((s) => s.type === 'dangerous-text' && s.matched === true); - expect(dangerousStep).toBeDefined(); + expectDangerousTextStep('python -c "\'git reset --hard HEAD"'); }); }); describe('explainCommand unparseable segments', () => { test('unparseable segment with dangerous rm -rf pattern is blocked', () => { - const result = explainCommand("'rm -rf /tmp/cache"); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const dangerousStep = allSteps.find((s) => s.type === 'dangerous-text' && s.matched === true); - expect(dangerousStep).toBeDefined(); + expectDangerousTextStep("'rm -rf /tmp/cache"); }); test('unparseable segment with cd command triggers cwd-change step', () => { const result = explainCommand('cd /tmp "unclosed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdStep = allSteps.find((s) => s.type === 'cwd-change'); expect(cwdStep).toBeDefined(); }); test('unparseable segment with pushd triggers cwd-change', () => { const result = explainCommand('pushd /somewhere "unclosed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdStep = allSteps.find((s) => s.type === 'cwd-change'); expect(cwdStep).toBeDefined(); }); test('unparseable segment with git reset --hard is blocked', () => { - const result = explainCommand("'git reset --hard HEAD"); - expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const dangerousStep = allSteps.find((s) => s.type === 'dangerous-text' && s.matched === true); - expect(dangerousStep).toBeDefined(); + expectDangerousTextStep("'git reset --hard HEAD"); }); test('unparseable segment without dangerous patterns is allowed', () => { @@ -854,7 +749,7 @@ describe('explainCommand unparseable segments', () => { describe('explainInnerSegments nested unparseable with cwd change', () => { test('nested unparseable segment with cd triggers cwd-change without dangerous text', () => { const result = explainCommand('bash -c "cd /tmp \'unclosed"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdSteps = allSteps.filter((s) => s.type === 'cwd-change'); expect(cwdSteps.length).toBeGreaterThan(0); expect(result.result).toBe('allowed'); @@ -862,7 +757,7 @@ describe('explainInnerSegments nested unparseable with cwd change', () => { test('nested unparseable segment with pushd triggers cwd-change', () => { const result = explainCommand('bash -c "pushd /somewhere \'unclosed"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdSteps = allSteps.filter((s) => s.type === 'cwd-change'); expect(cwdSteps.length).toBeGreaterThan(0); }); @@ -872,7 +767,7 @@ describe('interpreter code not dangerous returns null', () => { test('interpreter with safe code returns allowed', () => { const result = explainCommand('python -c "x = 1 + 2"'); expect(result.result).toBe('allowed'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const interpStep = allSteps.find((s) => s.type === 'interpreter'); expect(interpStep).toBeDefined(); const dangerousStep = allSteps.find((s) => s.type === 'dangerous-text' && s.matched === true); @@ -889,7 +784,7 @@ describe('explainCommand interpreter with dangerous code', () => { test('python -c with rm -rf traces recurse and blocks', () => { const result = explainCommand('python -c "import os; os.system(\\"rm -rf /\\")"'); expect(result.result).toBe('blocked'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse' && s.reason === 'interpreter'); expect(recurseStep).toBeDefined(); }); diff --git a/tests/bin/explain/format.test.ts b/tests/bin/explain/format.test.ts index 07aac5e..40ec9b5 100644 --- a/tests/bin/explain/format.test.ts +++ b/tests/bin/explain/format.test.ts @@ -5,7 +5,26 @@ import { describe, expect, test } from 'bun:test'; import { formatStepStyleD, getBoxChars } from '@/bin/explain/format-helpers'; import { explainCommand, formatTraceHuman, formatTraceJson } from '@/bin/explain/index'; import type { ExplainResult, TraceStep } from '@/types'; -import { withEnv } from '../../helpers.ts'; +import { getTraceSteps, withEnv } from '../../helpers.ts'; + +function mockExplainResult( + input: string, + segments: string[][], + step: TraceStep, + result: ExplainResult['result'] = 'allowed', + reason?: string, +): ExplainResult { + return { + trace: { + steps: [{ type: 'parse', input, segments }], + segments: [{ index: 0, steps: [step] }], + }, + result, + reason, + configSource: null, + configValid: true, + }; +} describe('formatTraceHuman', () => { test('includes Status: BLOCKED for blocked commands', () => { @@ -158,25 +177,16 @@ describe('formatTraceHuman step formatting', () => { originalReason: 'git reset --hard destroys uncommitted changes', gitCwd: '/tmp/linked-worktree', }; - const mockResult: ExplainResult = { - trace: { - steps: [ - { type: 'parse', input: 'git reset --hard', segments: [['git', 'reset', '--hard']] }, - ], - segments: [{ index: 0, steps: [worktreeStep] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman( + mockExplainResult('git reset --hard', [['git', 'reset', '--hard']], worktreeStep), + ); expect(output).toContain('Worktree relaxation'); expect(output).toContain('SAFETY_NET_WORKTREE'); }); test('tmpdir-check is an internal detail not shown in human output', () => { const result = explainCommand('rm -rf /tmp/test'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const tmpStep = allSteps.find((s) => s.type === 'tmpdir-check'); expect(tmpStep).toBeDefined(); }); @@ -200,16 +210,9 @@ describe('formatTraceHuman step formatting', () => { tokensScanned: ['echo', 'rm', '-rf'], embeddedCommandFound: 'rm', }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'echo rm -rf', segments: [['echo', 'rm', '-rf']] }], - segments: [{ index: 0, steps: [step] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman( + mockExplainResult('echo rm -rf', [['echo', 'rm', '-rf']], step), + ); expect(output).toContain('Fallback scan'); expect(output).toContain('Found: rm'); }); @@ -220,16 +223,7 @@ describe('formatTraceHuman step formatting', () => { tokensScanned: ['echo', 'hello'], embeddedCommandFound: undefined, }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'echo hello', segments: [['echo', 'hello']] }], - segments: [{ index: 0, steps: [step] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman(mockExplainResult('echo hello', [['echo', 'hello']], step)); expect(output).not.toContain('Fallback scan'); }); @@ -239,15 +233,7 @@ describe('formatTraceHuman step formatting', () => { segment: 'cd /tmp', effectiveCwdNowUnknown: true, }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'cd /tmp', segments: [['cd', '/tmp']] }], - segments: [{ index: 0, steps: [step] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; + const mockResult = mockExplainResult('cd /tmp', [['cd', '/tmp']], step); expect(mockResult.trace.segments[0]?.steps[0]?.type).toBe('cwd-change'); }); @@ -258,17 +244,9 @@ describe('formatTraceHuman step formatting', () => { matched: true, reason: 'contains dangerous rm command', }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'rm -rf /', segments: [['rm', '-rf', '/']] }], - segments: [{ index: 0, steps: [step] }], - }, - result: 'blocked', - reason: 'contains dangerous rm command', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman( + mockExplainResult('rm -rf /', [['rm', '-rf', '/']], step, 'blocked', step.reason), + ); expect(output).toContain('Dangerous text check'); expect(output).toContain('MATCHED'); }); @@ -279,16 +257,7 @@ describe('formatTraceHuman step formatting', () => { token: 'echo hello', matched: false, }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'echo hello', segments: [['echo', 'hello']] }], - segments: [{ index: 0, steps: [step] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman(mockExplainResult('echo hello', [['echo', 'hello']], step)); expect(output).not.toContain('Dangerous text check'); }); @@ -298,19 +267,15 @@ describe('formatTraceHuman step formatting', () => { rawCommand: 'bash -c "unclosed', reason: 'unparseable command in strict mode', }; - const mockResult: ExplainResult = { - trace: { - steps: [ - { type: 'parse', input: 'bash -c "unclosed', segments: [['bash', '-c', '"unclosed']] }, - ], - segments: [{ index: 0, steps: [step] }], - }, - result: 'blocked', - reason: 'unparseable command in strict mode', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman( + mockExplainResult( + 'bash -c "unclosed', + [['bash', '-c', '"unclosed']], + step, + 'blocked', + step.reason, + ), + ); expect(output).toContain('Strict mode check'); expect(output).toContain('UNPARSEABLE'); }); @@ -325,7 +290,7 @@ describe('formatTraceHuman coverage for internal step types', () => { test('tmpdir-check step is internal and not shown in human output', () => { const result = explainCommand('rm -rf /tmp/test'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const tmpStep = allSteps.find((s) => s.type === 'tmpdir-check'); expect(tmpStep).toBeDefined(); const output = formatTraceHuman(result); @@ -334,7 +299,7 @@ describe('formatTraceHuman coverage for internal step types', () => { test('cwd-change step is internal and not shown in human output', () => { const result = explainCommand('cd /tmp && echo hello'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const cwdStep = allSteps.find((s) => s.type === 'cwd-change'); expect(cwdStep).toBeDefined(); const output = formatTraceHuman(result); @@ -353,16 +318,7 @@ describe('formatTraceHuman coverage for internal step types', () => { type: 'error', message: 'Test error message', }; - const mockResult: ExplainResult = { - trace: { - steps: [{ type: 'parse', input: 'test cmd', segments: [['test', 'cmd']] }], - segments: [{ index: 0, steps: [errorStep] }], - }, - result: 'allowed', - configSource: null, - configValid: true, - }; - const output = formatTraceHuman(mockResult); + const output = formatTraceHuman(mockExplainResult('test cmd', [['test', 'cmd']], errorStep)); expect(output).toContain('ERROR: Test error message'); }); diff --git a/tests/bin/explain/redact.test.ts b/tests/bin/explain/redact.test.ts index b2f75b3..0f6fc02 100644 --- a/tests/bin/explain/redact.test.ts +++ b/tests/bin/explain/redact.test.ts @@ -3,30 +3,32 @@ */ import { describe, expect, test } from 'bun:test'; import { explainCommand, formatTraceHuman, formatTraceJson } from '@/bin/explain/index'; +import { getTraceSteps } from '../../helpers.ts'; + +function expectLeadingTokenRedacted(command: string, secret: string, redacted: string): void { + const result = explainCommand(command); + const stripStep = getTraceSteps(result).find((s) => s.type === 'leading-tokens-stripped'); + expect(stripStep).toBeDefined(); + if (stripStep?.type !== 'leading-tokens-stripped') return; + expect(stripStep.removed.join(', ')).not.toContain(secret); + expect(stripStep.removed.join(', ')).toContain(redacted); +} describe('explainCommand env wrapper redaction', () => { test('env wrapper with secret is redacted in leading-tokens-stripped step', () => { - const result = explainCommand('env TOKEN=supersecret git status'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const stripStep = allSteps.find((s) => s.type === 'leading-tokens-stripped'); - expect(stripStep).toBeDefined(); - if (stripStep && stripStep.type === 'leading-tokens-stripped') { - const removedStr = stripStep.removed.join(', '); - expect(removedStr).not.toContain('supersecret'); - expect(removedStr).toContain('TOKEN='); - } + expectLeadingTokenRedacted( + 'env TOKEN=supersecret git status', + 'supersecret', + 'TOKEN=', + ); }); test('sudo env with secret is redacted in leading-tokens-stripped step', () => { - const result = explainCommand('sudo env API_KEY=my-api-key-123 git status'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); - const stripStep = allSteps.find((s) => s.type === 'leading-tokens-stripped'); - expect(stripStep).toBeDefined(); - if (stripStep && stripStep.type === 'leading-tokens-stripped') { - const removedStr = stripStep.removed.join(', '); - expect(removedStr).not.toContain('my-api-key-123'); - expect(removedStr).toContain('API_KEY='); - } + expectLeadingTokenRedacted( + 'sudo env API_KEY=my-api-key-123 git status', + 'my-api-key-123', + 'API_KEY=', + ); }); test('formatTraceHuman does not leak secrets from env wrapper', () => { @@ -40,7 +42,7 @@ describe('explainCommand env wrapper redaction', () => { const result = explainCommand('env SECRET=topsecret git status'); const json = formatTraceJson(result); const parsed = JSON.parse(json); - const allSteps = parsed.trace.segments.flatMap((s: { steps: unknown[] }) => s.steps); + const allSteps = getTraceSteps(parsed); const stripStep = allSteps.find( (s: { type: string }) => s.type === 'leading-tokens-stripped', ) as { input: string[]; removed: string[] } | undefined; @@ -57,7 +59,7 @@ describe('explainCommand env wrapper redaction', () => { describe('secret redaction in shell wrappers and interpreters', () => { test('shell-wrapper step redacts env assignments in innerCommand', () => { const result = explainCommand('bash -c "TOKEN=secret git status"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const wrapperStep = allSteps.find((s) => s.type === 'shell-wrapper'); expect(wrapperStep).toBeDefined(); expect(wrapperStep?.type === 'shell-wrapper' && wrapperStep.innerCommand).toBe( @@ -70,7 +72,7 @@ describe('secret redaction in shell wrappers and interpreters', () => { test('interpreter step redacts env assignments in codeArg', () => { const result = explainCommand('python -c "API_KEY=xyz123 print(1)"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const interpStep = allSteps.find((s) => s.type === 'interpreter'); expect(interpStep).toBeDefined(); expect(interpStep?.type === 'interpreter' && interpStep.codeArg).toBe( @@ -81,7 +83,7 @@ describe('secret redaction in shell wrappers and interpreters', () => { test('recurse step for shell-wrapper redacts innerCommand', () => { const result = explainCommand('bash -c "SECRET=abc123 echo test"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse' && s.reason === 'shell-wrapper'); expect(recurseStep).toBeDefined(); expect(recurseStep?.type === 'recurse' && recurseStep.innerCommand).toBe( @@ -91,7 +93,7 @@ describe('secret redaction in shell wrappers and interpreters', () => { test('recurse step for interpreter redacts innerCommand', () => { const result = explainCommand('node -e "PASSWORD=hunter2 console.log(1)"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse' && s.reason === 'interpreter'); expect(recurseStep).toBeDefined(); expect(recurseStep?.type === 'recurse' && recurseStep.innerCommand).toBe( @@ -101,7 +103,7 @@ describe('secret redaction in shell wrappers and interpreters', () => { test('busybox recurse step redacts env assignments', () => { const result = explainCommand('busybox TOKEN=secret rm -rf /'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const recurseStep = allSteps.find((s) => s.type === 'recurse' && s.reason === 'busybox'); expect(recurseStep).toBeDefined(); expect(recurseStep?.type === 'recurse' && recurseStep.innerCommand).toBe( @@ -125,7 +127,7 @@ describe('secret redaction in shell wrappers and interpreters', () => { test('redaction handles quoted env values in shell wrapper', () => { const result = explainCommand('bash -c "TOKEN=\\"secret value\\" git status"'); - const allSteps = result.trace.segments.flatMap((s) => s.steps); + const allSteps = getTraceSteps(result); const wrapperStep = allSteps.find((s) => s.type === 'shell-wrapper'); expect(wrapperStep?.type === 'shell-wrapper' && wrapperStep.innerCommand).toContain( 'TOKEN=', diff --git a/tests/bin/help.test.ts b/tests/bin/help.test.ts index ca98873..d754f3f 100644 --- a/tests/bin/help.test.ts +++ b/tests/bin/help.test.ts @@ -132,30 +132,20 @@ describe('help output', () => { describe('showCommandHelp', () => { test('returns true and prints help for valid command', () => { - let output = ''; - const originalLog = console.log; - console.log = (...args: unknown[]) => { - output += `${args.map(String).join(' ')}\n`; - }; - - const result = showCommandHelp('doctor'); - - console.log = originalLog; + let result = false; + const output = captureOutput(() => { + result = showCommandHelp('doctor'); + }); expect(result).toBe(true); expect(output).toContain('cc-safety-net doctor'); }); test('returns true for alias', () => { - let output = ''; - const originalLog = console.log; - console.log = (...args: unknown[]) => { - output += `${args.map(String).join(' ')}\n`; - }; - - const result = showCommandHelp('-cc'); - - console.log = originalLog; + let result = false; + const output = captureOutput(() => { + result = showCommandHelp('-cc'); + }); expect(result).toBe(true); expect(output).toContain('cc-safety-net claude-code'); diff --git a/tests/bin/hooks/claude-code-hook.test.ts b/tests/bin/hooks/claude-code-hook.test.ts index c1edab0..9143b81 100644 --- a/tests/bin/hooks/claude-code-hook.test.ts +++ b/tests/bin/hooks/claude-code-hook.test.ts @@ -1,18 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { runClaudeCodeHook } from './hook-helpers'; +import { claudeCodeBashInput, expectNoHookOutput, runClaudeCodeHook } from './hook-helpers'; describe('Claude Code hook', () => { describe('blocked commands', () => { test('blocked command produces correct JSON structure', async () => { - const input = { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { - command: 'git reset --hard', - }, - }; - - const { stdout, exitCode } = await runClaudeCodeHook(input); + const { stdout, exitCode } = await runClaudeCodeHook(claudeCodeBashInput('git reset --hard')); const parsed = JSON.parse(stdout); expect(exitCode).toBe(0); @@ -26,18 +18,7 @@ describe('Claude Code hook', () => { describe('allowed commands', () => { test('allowed command produces no output', async () => { - const input = { - hook_event_name: 'PreToolUse', - tool_name: 'Bash', - tool_input: { - command: 'git status', - }, - }; - - const { stdout, exitCode } = await runClaudeCodeHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, claudeCodeBashInput('git status')); }); }); @@ -51,26 +32,17 @@ describe('Claude Code hook', () => { }, }; - const { stdout, exitCode } = await runClaudeCodeHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, input); }); }); describe('empty stdin', () => { test('empty input produces no output', async () => { - const { stdout, exitCode } = await runClaudeCodeHook(''); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, ''); }); test('whitespace-only input produces no output', async () => { - const { stdout, exitCode } = await runClaudeCodeHook(' \n\t '); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, ' \n\t '); }); }); @@ -89,10 +61,7 @@ describe('Claude Code hook', () => { }); test('non-strict mode silently ignores invalid JSON', async () => { - const { stdout, exitCode } = await runClaudeCodeHook('{invalid json'); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, '{invalid json'); }); }); @@ -104,10 +73,7 @@ describe('Claude Code hook', () => { tool_input: {}, }; - const { stdout, exitCode } = await runClaudeCodeHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, input); }); test('null tool_input produces no output', async () => { @@ -117,10 +83,7 @@ describe('Claude Code hook', () => { tool_input: null, }; - const { stdout, exitCode } = await runClaudeCodeHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, input); }); test('missing tool_input produces no output', async () => { @@ -129,10 +92,7 @@ describe('Claude Code hook', () => { tool_name: 'Bash', }; - const { stdout, exitCode } = await runClaudeCodeHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runClaudeCodeHook, input); }); }); }); diff --git a/tests/bin/hooks/copilot-cli-hook.test.ts b/tests/bin/hooks/copilot-cli-hook.test.ts index 214a55c..957d7ae 100644 --- a/tests/bin/hooks/copilot-cli-hook.test.ts +++ b/tests/bin/hooks/copilot-cli-hook.test.ts @@ -1,17 +1,23 @@ import { describe, expect, test } from 'bun:test'; -import { runCopilotHook } from './hook-helpers'; +import { + copilotBashInput, + copilotRawToolArgsInput, + expectNoHookOutput, + runCopilotHook, +} from './hook-helpers'; + +async function expectStrictDeny(input: object | string, reason: string) { + const { stdout, exitCode } = await runCopilotHook(input, { SAFETY_NET_STRICT: '1' }); + expect(exitCode).toBe(0); + const output = JSON.parse(stdout); + expect(output.permissionDecision).toBe('deny'); + expect(output.permissionDecisionReason).toContain(reason); +} describe('Copilot CLI hook', () => { describe('blocked commands', () => { test('blocks rm -rf via bash tool', async () => { - const input = { - timestamp: Date.now(), - cwd: process.cwd(), - toolName: 'bash', - toolArgs: JSON.stringify({ command: 'rm -rf /' }), - }; - - const { stdout, exitCode } = await runCopilotHook(input); + const { stdout, exitCode } = await runCopilotHook(copilotBashInput('rm -rf /')); expect(exitCode).toBe(0); const output = JSON.parse(stdout); @@ -22,17 +28,7 @@ describe('Copilot CLI hook', () => { describe('allowed commands', () => { test('allows safe commands (no output)', async () => { - const input = { - timestamp: Date.now(), - cwd: process.cwd(), - toolName: 'bash', - toolArgs: JSON.stringify({ command: 'ls -la' }), - }; - - const { stdout, exitCode } = await runCopilotHook(input); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + await expectNoHookOutput(runCopilotHook, copilotBashInput('ls -la')); }); }); @@ -45,84 +41,40 @@ describe('Copilot CLI hook', () => { toolArgs: JSON.stringify({ path: '/etc/passwd' }), }; - const { stdout, exitCode } = await runCopilotHook(input); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + await expectNoHookOutput(runCopilotHook, input); }); }); describe('empty stdin', () => { test('empty input produces no output', async () => { - const { stdout, exitCode } = await runCopilotHook(''); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, ''); }); test('whitespace-only input produces no output', async () => { - const { stdout, exitCode } = await runCopilotHook(' \n\t '); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, ' \n\t '); }); }); describe('invalid outer JSON', () => { test('strict mode blocks invalid outer JSON', async () => { - const { stdout, exitCode } = await runCopilotHook('{invalid json', { - SAFETY_NET_STRICT: '1', - }); - - expect(exitCode).toBe(0); - const output = JSON.parse(stdout); - expect(output.permissionDecision).toBe('deny'); - expect(output.permissionDecisionReason).toContain( - 'Failed to parse hook input JSON (strict mode)', - ); + await expectStrictDeny('{invalid json', 'Failed to parse hook input JSON (strict mode)'); }); test('non-strict mode silently ignores invalid outer JSON', async () => { - const { stdout, exitCode } = await runCopilotHook('{invalid json'); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, '{invalid json'); }); }); describe('invalid toolArgs', () => { test('strict mode blocks invalid toolArgs JSON', async () => { - const input = { - timestamp: Date.now(), - cwd: process.cwd(), - toolName: 'bash', - toolArgs: '{invalid', - }; - - const { stdout, exitCode } = await runCopilotHook(input, { - SAFETY_NET_STRICT: '1', - }); - - expect(exitCode).toBe(0); - const output = JSON.parse(stdout); - expect(output.permissionDecision).toBe('deny'); - expect(output.permissionDecisionReason).toContain( + await expectStrictDeny( + copilotRawToolArgsInput('{invalid'), 'Failed to parse toolArgs JSON (strict mode)', ); }); test('non-strict mode silently ignores invalid toolArgs JSON', async () => { - const input = { - timestamp: Date.now(), - cwd: process.cwd(), - toolName: 'bash', - toolArgs: '{invalid', - }; - - const { stdout, exitCode } = await runCopilotHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, copilotRawToolArgsInput('{invalid')); }); }); @@ -135,10 +87,7 @@ describe('Copilot CLI hook', () => { toolArgs: JSON.stringify({}), }; - const { stdout, exitCode } = await runCopilotHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, input); }); test('null command in toolArgs produces no output', async () => { @@ -149,10 +98,7 @@ describe('Copilot CLI hook', () => { toolArgs: JSON.stringify({ command: null }), }; - const { stdout, exitCode } = await runCopilotHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, input); }); test('empty string command in toolArgs produces no output', async () => { @@ -163,10 +109,7 @@ describe('Copilot CLI hook', () => { toolArgs: JSON.stringify({ command: '' }), }; - const { stdout, exitCode } = await runCopilotHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runCopilotHook, input); }); }); }); diff --git a/tests/bin/hooks/gemini-cli-hook.test.ts b/tests/bin/hooks/gemini-cli-hook.test.ts index 608b87a..d00c02a 100644 --- a/tests/bin/hooks/gemini-cli-hook.test.ts +++ b/tests/bin/hooks/gemini-cli-hook.test.ts @@ -1,16 +1,10 @@ import { describe, expect, test } from 'bun:test'; -import { runGeminiHook } from './hook-helpers'; +import { expectNoHookOutput, geminiShellInput, runGeminiHook } from './hook-helpers'; describe('Gemini CLI hook', () => { describe('blocked commands', () => { test('blocks rm -rf via run_shell_command', async () => { - const input = { - hook_event_name: 'BeforeTool', - tool_name: 'run_shell_command', - tool_input: { command: 'rm -rf /' }, - }; - - const { stdout, exitCode } = await runGeminiHook(input); + const { stdout, exitCode } = await runGeminiHook(geminiShellInput('rm -rf /')); expect(exitCode).toBe(0); const output = JSON.parse(stdout); @@ -19,13 +13,7 @@ describe('Gemini CLI hook', () => { }); test('outputs Gemini format with decision: deny', async () => { - const input = { - hook_event_name: 'BeforeTool', - tool_name: 'run_shell_command', - tool_input: { command: 'git reset --hard' }, - }; - - const { stdout, exitCode } = await runGeminiHook(input); + const { stdout, exitCode } = await runGeminiHook(geminiShellInput('git reset --hard')); expect(exitCode).toBe(0); const output = JSON.parse(stdout); @@ -37,16 +25,7 @@ describe('Gemini CLI hook', () => { describe('allowed commands', () => { test('allows safe commands (no output)', async () => { - const input = { - hook_event_name: 'BeforeTool', - tool_name: 'run_shell_command', - tool_input: { command: 'ls -la' }, - }; - - const { stdout, exitCode } = await runGeminiHook(input); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + await expectNoHookOutput(runGeminiHook, geminiShellInput('ls -la')); }); }); @@ -58,10 +37,7 @@ describe('Gemini CLI hook', () => { tool_input: { path: '/etc/passwd' }, }; - const { stdout, exitCode } = await runGeminiHook(input); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + await expectNoHookOutput(runGeminiHook, input); }); }); @@ -73,26 +49,17 @@ describe('Gemini CLI hook', () => { tool_input: { command: 'rm -rf /' }, }; - const { stdout, exitCode } = await runGeminiHook(input); - - expect(exitCode).toBe(0); - expect(stdout).toBe(''); + await expectNoHookOutput(runGeminiHook, input); }); }); describe('empty stdin', () => { test('empty input produces no output', async () => { - const { stdout, exitCode } = await runGeminiHook(''); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, ''); }); test('whitespace-only input produces no output', async () => { - const { stdout, exitCode } = await runGeminiHook(' \n\t '); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, ' \n\t '); }); }); @@ -109,10 +76,7 @@ describe('Gemini CLI hook', () => { }); test('non-strict mode silently ignores invalid JSON', async () => { - const { stdout, exitCode } = await runGeminiHook('{invalid json'); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, '{invalid json'); }); }); @@ -124,10 +88,7 @@ describe('Gemini CLI hook', () => { tool_input: {}, }; - const { stdout, exitCode } = await runGeminiHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, input); }); test('null tool_input produces no output', async () => { @@ -137,10 +98,7 @@ describe('Gemini CLI hook', () => { tool_input: null, }; - const { stdout, exitCode } = await runGeminiHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, input); }); test('missing tool_input produces no output', async () => { @@ -149,10 +107,7 @@ describe('Gemini CLI hook', () => { tool_name: 'run_shell_command', }; - const { stdout, exitCode } = await runGeminiHook(input); - - expect(stdout).toBe(''); - expect(exitCode).toBe(0); + await expectNoHookOutput(runGeminiHook, input); }); }); }); diff --git a/tests/bin/hooks/hook-helpers.ts b/tests/bin/hooks/hook-helpers.ts index be32dd4..303dc5e 100644 --- a/tests/bin/hooks/hook-helpers.ts +++ b/tests/bin/hooks/hook-helpers.ts @@ -1,3 +1,5 @@ +import { expect } from 'bun:test'; + /** * Shared test helpers for CLI hook integration tests. */ @@ -8,6 +10,44 @@ export type HookResult = { exitCode: number; }; +export function copilotBashInput(command: string) { + return { + timestamp: Date.now(), + cwd: process.cwd(), + toolName: 'bash', + toolArgs: JSON.stringify({ command }), + }; +} + +export function copilotRawToolArgsInput(toolArgs: string) { + return { + timestamp: Date.now(), + cwd: process.cwd(), + toolName: 'bash', + toolArgs, + }; +} + +export function copilotToolArgsInput(toolArgs: object) { + return copilotRawToolArgsInput(JSON.stringify(toolArgs)); +} + +export function geminiShellInput(command: string) { + return { + hook_event_name: 'BeforeTool', + tool_name: 'run_shell_command', + tool_input: { command }, + }; +} + +export function claudeCodeBashInput(command: string) { + return { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command }, + }; +} + /** * Runs a hook CLI with the given input and optional environment variables. * @param flag - CLI flag (e.g., '--claude-code', '-gc', '-cp') @@ -46,6 +86,16 @@ export async function runHook( return { stdout: stdout.trim(), stderr: stderr.trim(), exitCode }; } +export async function expectNoHookOutput( + run: (input: object | string, env?: Record) => Promise, + input: object | string, + env?: Record, +): Promise { + const { stdout, exitCode } = await run(input, env); + expect(stdout).toBe(''); + expect(exitCode).toBe(0); +} + /** * Runs the Claude Code hook. */ diff --git a/tests/core/analyze/analyze-coverage.test.ts b/tests/core/analyze/analyze-coverage.test.ts index 8e91b3e..d71a1c4 100644 --- a/tests/core/analyze/analyze-coverage.test.ts +++ b/tests/core/analyze/analyze-coverage.test.ts @@ -2,10 +2,27 @@ import { describe, expect, test } from 'bun:test'; import { homedir } from 'node:os'; import { analyzeCommand } from '@/core/analyze'; import type { Config } from '@/types'; -import { createLinkedWorktreeFixture, toShellPath, withEnv } from '../../helpers.ts'; +import { + createLinkedWorktreeFixture, + toShellPath, + withEnv, + withLinkedWorktreeFixture, +} from '../../helpers.ts'; const EMPTY_CONFIG: Config = { version: 1, rules: [] }; +function analyzeInLinkedWorktree(command: (mainWorktree: string) => string) { + return withLinkedWorktreeFixture((fixture) => + withEnv({ SAFETY_NET_WORKTREE: '1' }, () => + analyzeCommand(command(toShellPath(fixture.mainWorktree)), { + cwd: fixture.linkedWorktree, + config: EMPTY_CONFIG, + worktreeMode: true, + }), + ), + ); +} + describe('analyzeCommand (coverage)', () => { test('unclosed-quote cd segment handled', () => { // Ensures cwd-tracking fallback runs for unparseable cd segments. @@ -164,27 +181,14 @@ describe('analyzeCommand (coverage)', () => { describe('shell git context env state branches', () => { test('command -- export target is tracked across segments', () => { - const fixture = createLinkedWorktreeFixture(); - try { - withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { - const result = analyzeCommand( - `command -- export GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}; git reset --hard`, - { - cwd: fixture.linkedWorktree, - config: EMPTY_CONFIG, - worktreeMode: true, - }, - ); - expect(result?.reason).toContain('git reset --hard'); - }); - } finally { - fixture.cleanup(); - } + const result = analyzeInLinkedWorktree( + (main) => `command -- export GIT_WORK_TREE=${main}; git reset --hard`, + ); + expect(result?.reason).toContain('git reset --hard'); }); test('command inspection with no executable target leaves later git context unchanged', () => { - const fixture = createLinkedWorktreeFixture(); - try { + withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { expect( analyzeCommand( @@ -206,14 +210,11 @@ describe('analyzeCommand (coverage)', () => { }), ).toBeNull(); }); - } finally { - fixture.cleanup(); - } + }); }); test('export option parsing tracks only valid export operands', () => { - const fixture = createLinkedWorktreeFixture(); - try { + withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { expect( analyzeCommand( @@ -226,24 +227,16 @@ describe('analyzeCommand (coverage)', () => { ), ).toBeNull(); - const result = analyzeCommand( - `export -- GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}; git reset --hard`, - { - cwd: fixture.linkedWorktree, - config: EMPTY_CONFIG, - worktreeMode: true, - }, + const result = analyzeInLinkedWorktree( + (main) => `export -- GIT_WORK_TREE=${main}; git reset --hard`, ); expect(result?.reason).toContain('git reset --hard'); }); - } finally { - fixture.cleanup(); - } + }); }); test('exporting an unset tracked name uses an empty effective value', () => { - const fixture = createLinkedWorktreeFixture(); - try { + withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = analyzeCommand('export GIT_WORK_TREE; git reset --hard', { cwd: fixture.linkedWorktree, @@ -252,9 +245,7 @@ describe('analyzeCommand (coverage)', () => { }); expect(result?.reason).toContain('git reset --hard'); }); - } finally { - fixture.cleanup(); - } + }); }); test('typeset and readonly forms update tracked env state only when exported', () => { diff --git a/tests/core/audit.test.ts b/tests/core/audit.test.ts index ac12b9a..c386b98 100644 --- a/tests/core/audit.test.ts +++ b/tests/core/audit.test.ts @@ -107,6 +107,17 @@ describe('writeAuditLog', () => { return join(testDir, '.cc-safety-net', 'logs', `${sessionId}.jsonl`); } + function expectAuditLogStayedInLogsDir(escapedPath: string): void { + expect(existsSync(escapedPath)).toBe(false); + const logsDir = join(testDir, '.cc-safety-net', 'logs'); + if (!existsSync(logsDir)) return; + const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); + expect(files.length).toBe(1); + for (const file of files) { + expect(join(logsDir, file).startsWith(logsDir)).toBe(true); + } + } + function readLogEntries(sessionId: string): AuditLogEntry[] { const logFile = getLogFile(sessionId); if (!existsSync(logFile)) { @@ -214,20 +225,7 @@ describe('writeAuditLog', () => { homeDir: testDir, }); - // Verify no file was created outside the logs dir - expect(existsSync(join(testDir, 'outside.jsonl'))).toBe(false); - - // Verify log was created in the correct location - const logsDir = join(testDir, '.cc-safety-net', 'logs'); - if (existsSync(logsDir)) { - const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); - expect(files.length).toBe(1); - // The file should be inside logs dir - for (const file of files) { - const fullPath = join(logsDir, file); - expect(fullPath.startsWith(logsDir)).toBe(true); - } - } + expectAuditLogStayedInLogsDir(join(testDir, 'outside.jsonl')); }); test('session id absolute path does not escape logs dir', () => { @@ -236,19 +234,7 @@ describe('writeAuditLog', () => { homeDir: testDir, }); - // Verify no file was created at the escaped location - expect(existsSync(join(testDir, 'escaped.jsonl'))).toBe(false); - - // Verify log was created in the correct location - const logsDir = join(testDir, '.cc-safety-net', 'logs'); - if (existsSync(logsDir)) { - const files = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl')); - expect(files.length).toBe(1); - for (const file of files) { - const fullPath = join(logsDir, file); - expect(fullPath.startsWith(logsDir)).toBe(true); - } - } + expectAuditLogStayedInLogsDir(join(testDir, 'escaped.jsonl')); }); test('cwd null when not provided', () => { diff --git a/tests/core/config.test.ts b/tests/core/config.test.ts index b89eb33..3fcd2ba 100644 --- a/tests/core/config.test.ts +++ b/tests/core/config.test.ts @@ -416,68 +416,55 @@ describe('config scope merging', () => { writeFileSync(join(tempDir, '.safety-net.json'), JSON.stringify(data), 'utf-8'); } + function configWithRule(rule: { + name: string; + command: string; + block_args: string[]; + reason: string; + }): object { + return { version: 1, rules: [rule] }; + } + + const userRule = { + name: 'user-rule', + command: 'git', + block_args: ['-A'], + reason: 'user', + }; + + const projectRule = { + name: 'project-rule', + command: 'npm', + block_args: ['-g'], + reason: 'project', + }; + + function expectLoadedRuleName(name: string): void { + const config = loadConfig(tempDir, loadOptions); + expect(config.rules.length).toBe(1); + expect(config.rules[0]?.name).toBe(name); + } + test('no config returns default', () => { const config = loadConfig(tempDir, loadOptions); expect(config.rules).toEqual([]); }); test('user scope only', () => { - writeUserConfig({ - version: 1, - rules: [ - { - name: 'user-rule', - command: 'git', - block_args: ['-A'], - reason: 'user', - }, - ], - }); + writeUserConfig(configWithRule(userRule)); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(1); expect(config.rules[0]?.name).toBe('user-rule'); }); test('project scope only', () => { - writeProjectConfig({ - version: 1, - rules: [ - { - name: 'project-rule', - command: 'npm', - block_args: ['-g'], - reason: 'project', - }, - ], - }); - const config = loadConfig(tempDir, loadOptions); - expect(config.rules.length).toBe(1); - expect(config.rules[0]?.name).toBe('project-rule'); + writeProjectConfig(configWithRule(projectRule)); + expectLoadedRuleName('project-rule'); }); test('both scopes merged', () => { - writeUserConfig({ - version: 1, - rules: [ - { - name: 'user-rule', - command: 'git', - block_args: ['-A'], - reason: 'user', - }, - ], - }); - writeProjectConfig({ - version: 1, - rules: [ - { - name: 'project-rule', - command: 'npm', - block_args: ['-g'], - reason: 'project', - }, - ], - }); + writeUserConfig(configWithRule(userRule)); + writeProjectConfig(configWithRule(projectRule)); const config = loadConfig(tempDir, loadOptions); expect(config.rules.length).toBe(2); const ruleNames = new Set(config.rules.map((r) => r.name)); @@ -590,34 +577,12 @@ describe('config scope merging', () => { mkdirSync(userConfigDir, { recursive: true }); writeFileSync(join(userConfigDir, 'config.json'), '{"version": 2}', 'utf-8'); - writeProjectConfig({ - version: 1, - rules: [ - { - name: 'project-rule', - command: 'npm', - block_args: ['-g'], - reason: 'project', - }, - ], - }); - const config = loadConfig(tempDir, loadOptions); - expect(config.rules.length).toBe(1); - expect(config.rules[0]?.name).toBe('project-rule'); + writeProjectConfig(configWithRule(projectRule)); + expectLoadedRuleName('project-rule'); }); test('invalid project config ignored', () => { - writeUserConfig({ - version: 1, - rules: [ - { - name: 'user-rule', - command: 'git', - block_args: ['-A'], - reason: 'user', - }, - ], - }); + writeUserConfig(configWithRule(userRule)); writeFileSync(join(tempDir, '.safety-net.json'), '{"version": 2}', 'utf-8'); const config = loadConfig(tempDir, loadOptions); @@ -635,17 +600,7 @@ describe('config scope merging', () => { }); test('empty project rules still merges', () => { - writeUserConfig({ - version: 1, - rules: [ - { - name: 'user-rule', - command: 'git', - block_args: ['-A'], - reason: 'user', - }, - ], - }); + writeUserConfig(configWithRule(userRule)); writeProjectConfig({ version: 1, rules: [] }); const config = loadConfig(tempDir, loadOptions); diff --git a/tests/core/rules-custom-integration.test.ts b/tests/core/rules-custom-integration.test.ts index 2998d48..f7e3fd4 100644 --- a/tests/core/rules-custom-integration.test.ts +++ b/tests/core/rules-custom-integration.test.ts @@ -26,6 +26,19 @@ function assertAllowed(command: string, cwd?: string): void { expect(result).toBeNull(); } +const blockGitAddAllConfig = { + version: 1, + rules: [ + { + name: 'block-git-add-all', + command: 'git', + subcommand: 'add', + block_args: ['-A', '--all', '.'], + reason: 'Use specific files.', + }, + ], +}; + describe('custom rules integration', () => { let tempDir: string; @@ -38,34 +51,12 @@ describe('custom rules integration', () => { }); test('custom rule blocks command', () => { - writeConfig(tempDir, { - version: 1, - rules: [ - { - name: 'block-git-add-all', - command: 'git', - subcommand: 'add', - block_args: ['-A', '--all', '.'], - reason: 'Use specific files.', - }, - ], - }); + writeConfig(tempDir, blockGitAddAllConfig); assertBlocked('git add -A', '[block-git-add-all] Use specific files.', tempDir); }); test('custom rule blocks with dot', () => { - writeConfig(tempDir, { - version: 1, - rules: [ - { - name: 'block-git-add-all', - command: 'git', - subcommand: 'add', - block_args: ['-A', '--all', '.'], - reason: 'Use specific files.', - }, - ], - }); + writeConfig(tempDir, blockGitAddAllConfig); assertBlocked('git add .', '[block-git-add-all]', tempDir); }); diff --git a/tests/core/rules-custom.test.ts b/tests/core/rules-custom.test.ts index 26f3a3c..cd1f301 100644 --- a/tests/core/rules-custom.test.ts +++ b/tests/core/rules-custom.test.ts @@ -2,32 +2,22 @@ import { describe, expect, test } from 'bun:test'; import { checkCustomRules } from '@/core/rules-custom'; import type { CustomRule } from '@/types'; +const blockGitAddAllRule: CustomRule = { + name: 'block-git-add-all', + command: 'git', + subcommand: 'add', + block_args: ['-A', '--all'], + reason: 'Use specific files.', +}; + describe('custom rule matching', () => { test('basic command match', () => { - const rules: CustomRule[] = [ - { - name: 'block-git-add-all', - command: 'git', - subcommand: 'add', - block_args: ['-A', '--all'], - reason: 'Use specific files.', - }, - ]; - const result = checkCustomRules(['git', 'add', '-A'], rules); + const result = checkCustomRules(['git', 'add', '-A'], [blockGitAddAllRule]); expect(result).toBe('[block-git-add-all] Use specific files.'); }); test('match with long option form', () => { - const rules: CustomRule[] = [ - { - name: 'block-git-add-all', - command: 'git', - subcommand: 'add', - block_args: ['-A', '--all'], - reason: 'Use specific files.', - }, - ]; - const result = checkCustomRules(['git', 'add', '--all'], rules); + const result = checkCustomRules(['git', 'add', '--all'], [blockGitAddAllRule]); expect(result).toBe('[block-git-add-all] Use specific files.'); }); diff --git a/tests/helpers.ts b/tests/helpers.ts index 128697d..44e8a99 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -7,7 +7,7 @@ import type { VersionFetcher } from '@/bin/doctor/system-info'; import { analyzeCommand } from '@/core/analyze'; import { loadConfig } from '@/core/config'; import { envTruthy } from '@/core/env'; -import type { AnalyzeOptions, Config } from '@/types'; +import type { AnalyzeOptions, Config, ExplainResult, TraceStep } from '@/types'; // Default empty config for tests that don't specify a cwd // This prevents loading the project's .safety-net.json @@ -64,6 +64,60 @@ export function withEnv(env: Record, fn: () => T): T { } } +export function withTempDir(prefix: string, fn: (dir: string) => T): T { + const dir = mkdtempSync(join(tmpdir(), prefix)); + try { + return fn(dir); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +export async function runSafetyNetCli( + args: string[], + env?: Record, +): Promise<{ output: string; exitCode: number }> { + const proc = Bun.spawn(['bun', 'src/bin/cc-safety-net.ts', ...args], { + stdout: 'pipe', + stderr: 'pipe', + env: { ...process.env, ...(env ?? {}) }, + }); + const output = await new Response(proc.stdout).text(); + const exitCode = await proc.exited; + return { output, exitCode }; +} + +export function withStdoutColor(enabled: boolean, fn: () => T): T { + const originalIsTTY = process.stdout.isTTY; + const originalNoColor = process.env.NO_COLOR; + Object.defineProperty(process.stdout, 'isTTY', { + value: enabled, + writable: true, + configurable: true, + }); + if (enabled) { + delete process.env.NO_COLOR; + } + try { + return fn(); + } finally { + Object.defineProperty(process.stdout, 'isTTY', { + value: originalIsTTY, + writable: true, + configurable: true, + }); + if (originalNoColor === undefined) { + delete process.env.NO_COLOR; + } else { + process.env.NO_COLOR = originalNoColor; + } + } +} + +export function getTraceSteps(result: Pick): TraceStep[] { + return result.trace.segments.flatMap((segment) => segment.steps); +} + /** * Mock version fetcher for testing. * Returns predefined versions instantly without spawning processes. @@ -148,6 +202,15 @@ export function createLinkedWorktreeFixture(): LinkedWorktreeFixture { }; } +export function withLinkedWorktreeFixture(fn: (fixture: LinkedWorktreeFixture) => T): T { + const fixture = createLinkedWorktreeFixture(); + try { + return fn(fixture); + } finally { + fixture.cleanup(); + } +} + export interface FakeGitFileFixture { rootDir: string; cwd: string; diff --git a/tests/scripts/generate-changelog.test.ts b/tests/scripts/generate-changelog.test.ts index 0a1ba33..effd9df 100644 --- a/tests/scripts/generate-changelog.test.ts +++ b/tests/scripts/generate-changelog.test.ts @@ -33,6 +33,17 @@ function createRunner(responses: Record): CommandRunner }; } +function contributorRunner(compare: string): CommandRunner { + return createRunner({ + 'gh api "/repos/example/repo/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'': + compare, + }); +} + +function compareCommit(login: string | null, message: string): string { + return JSON.stringify({ login, message }); +} + describe('isIncludedCommit', () => { describe('simple prefixes', () => { test('includes feat: commits', () => { @@ -202,38 +213,19 @@ describe('generateChangelog', () => { describe('getContributorsForRepo', () => { test('includes unique contributors and their commits', async () => { const compare = [ - JSON.stringify({ - login: 'alice', - message: 'feat: add thing\n\nBody', - }), - JSON.stringify({ - login: 'bob', - message: 'fix: resolve issue', - }), - JSON.stringify({ - login: 'alice', - message: 'feat: follow-up', - }), - JSON.stringify({ - login: 'kenryu42', - message: 'feat: excluded author', - }), - JSON.stringify({ - login: null, - message: 'feat: missing author', - }), - JSON.stringify({ - login: 'carol', - message: 'chore: ignore', - }), + compareCommit('alice', 'feat: add thing\n\nBody'), + compareCommit('bob', 'fix: resolve issue'), + compareCommit('alice', 'feat: follow-up'), + compareCommit('kenryu42', 'feat: excluded author'), + compareCommit(null, 'feat: missing author'), + compareCommit('carol', 'chore: ignore'), ].join('\n'); - const runner = createRunner({ - 'gh api "/repos/example/repo/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'': - compare, - }); - - const notes = await getContributorsForRepo('v1.0.0', 'example/repo', runner); + const notes = await getContributorsForRepo( + 'v1.0.0', + 'example/repo', + contributorRunner(compare), + ); expect(notes).toEqual([ '', @@ -248,22 +240,15 @@ describe('getContributorsForRepo', () => { test('returns empty list when no contributors qualify', async () => { const compare = [ - JSON.stringify({ - login: 'kenryu42', - message: 'feat: excluded author', - }), - JSON.stringify({ - login: 'carol', - message: 'chore: ignore', - }), + compareCommit('kenryu42', 'feat: excluded author'), + compareCommit('carol', 'chore: ignore'), ].join('\n'); - const runner = createRunner({ - 'gh api "/repos/example/repo/compare/v1.0.0...HEAD" --jq \'.commits[] | {login: .author.login, message: .commit.message}\'': - compare, - }); - - const notes = await getContributorsForRepo('v1.0.0', 'example/repo', runner); + const notes = await getContributorsForRepo( + 'v1.0.0', + 'example/repo', + contributorRunner(compare), + ); expect(notes).toEqual([]); }); From 346b6852036f49b38a0ce0d7aa1ad2c3f46598ec Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:55:41 +0900 Subject: [PATCH 02/12] refactor(doctor): share ASCII table formatting --- src/bin/doctor/format.ts | 195 ++++++++------------------------------- 1 file changed, 37 insertions(+), 158 deletions(-) diff --git a/src/bin/doctor/format.ts b/src/bin/doctor/format.ts index ff8348a..711e5eb 100644 --- a/src/bin/doctor/format.ts +++ b/src/bin/doctor/format.ts @@ -22,6 +22,36 @@ const PLATFORM_NAMES: Record = { codex: 'Codex', }; +interface TableOptions { + headers?: string[]; + rows: string[][]; + rawRows?: string[][]; +} + +function formatAsciiTable(options: TableOptions): string { + const rawRows = options.rawRows ?? options.rows; + const colWidths = (options.headers ?? rawRows[0] ?? []).map((h, i) => { + const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); + return Math.max(h.length, maxDataWidth); + }); + const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); + const line = (char: string, corners: [string, string, string]) => + corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; + const formatRow = (cells: string[], rawCells: string[]) => + `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; + + const headerLines = options.headers + ? [` ${formatRow(options.headers, options.headers)}`, ` ${line('─', ['├', '┼', '┤'])}`] + : []; + + return [ + ` ${line('─', ['┌', '┬', '┐'])}`, + ...headerLines, + ...options.rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), + ` ${line('─', ['└', '┴', '┘'])}`, + ].join('\n'); +} + /** * Format the hooks section as a table with failure details below. */ @@ -120,29 +150,7 @@ function formatHooksTable(hooks: HookStatus[]): string { const rows = rowData.map((r) => r.colored); const rawRows = rowData.map((r) => r.raw); - // Calculate column widths (using raw text without ANSI codes for width calc) - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[], rawCells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers, headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows, rawRows }); } /** @@ -162,29 +170,7 @@ export function formatRulesTable(rules: EffectiveRule[]): string { r.blockArgs.join(', '), ]); - // Calculate column widths - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number) => s.padEnd(w); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0)).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r) => ` ${formatRow(r)}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows }); } /** @@ -245,29 +231,7 @@ function formatConfigTable(userConfig: ConfigSourceInfo, projectConfig: ConfigSo ['Project', projectStatus.text], ]; - // Calculate column widths (using raw text without ANSI codes for width calc) - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[], rawCells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers, headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows, rawRows }); } /** @@ -291,30 +255,8 @@ function formatEnvironmentTable(envVars: EnvVarInfo[]): string { return [v.name, statusIcon]; }); - // Calculate column widths (using raw text without ANSI codes for width calc) const rawRows = envVars.map((v) => [v.name, v.isSet ? '✓' : '✗']); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[], rawCells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers, headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows, rawRows }); } /** @@ -350,29 +292,7 @@ function formatActivityTable(entries: Array<{ relativeTime: string; command: str return [e.relativeTime, cmd]; }); - // Calculate column widths - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number) => s.padEnd(w); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0)).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r) => ` ${formatRow(r)}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows }); } /** @@ -470,26 +390,7 @@ function formatUpdateTable( const rows = rowData.map((r) => [r.label, r.value]); const rawRows = rowData.map((r) => [r.label, r.rawValue]); - // Calculate column widths (using raw text without ANSI codes for width calc) - const colWidths = [0, 1].map((i) => { - return Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - }); - - const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[], rawCells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ rows, rawRows }); } /** @@ -533,29 +434,7 @@ function formatSystemInfoTable(system: SystemInfo): string { const rows = rowData.map((r) => [r.label, formatValue(r.value)]); const rawRows = rowData.map((r) => [r.label, rawValue(r.value)]); - // Calculate column widths (using raw text without ANSI codes for width calc) - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - - const pad = (s: string, w: number, raw: string) => s + ' '.repeat(Math.max(0, w - raw.length)); - - const line = (char: string, corners: [string, string, string]) => - corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - - const formatRow = (cells: string[], rawCells: string[]) => - `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? '')).join(' │ ')} │`; - - const tableLines = [ - ` ${line('─', ['┌', '┬', '┐'])}`, - ` ${formatRow(headers, headers)}`, - ` ${line('─', ['├', '┼', '┤'])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line('─', ['└', '┴', '┘'])}`, - ]; - - return tableLines.join('\n'); + return formatAsciiTable({ headers, rows, rawRows }); } /** From 41a8d203965bf593a530c90e9d15b0258d8af351 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:57:06 +0900 Subject: [PATCH 03/12] refactor(hooks): share hook command handling --- src/bin/hooks/claude-code.ts | 52 +++------------------------ src/bin/hooks/common.ts | 61 +++++++++++++++++++++++++++++++ src/bin/hooks/copilot-cli.ts | 69 +++++++++--------------------------- src/bin/hooks/gemini-cli.ts | 52 +++------------------------ 4 files changed, 87 insertions(+), 147 deletions(-) create mode 100644 src/bin/hooks/common.ts diff --git a/src/bin/hooks/claude-code.ts b/src/bin/hooks/claude-code.ts index e5b99f2..bfdf7f6 100644 --- a/src/bin/hooks/claude-code.ts +++ b/src/bin/hooks/claude-code.ts @@ -1,6 +1,5 @@ -import { analyzeCommand, loadConfig } from '@/core/analyze'; -import { redactSecrets, writeAuditLog } from '@/core/audit'; -import { envTruthy } from '@/core/env'; +import { handleBlockedHookCommand, readHookInput } from '@/bin/hooks/common'; +import { redactSecrets } from '@/core/audit'; import { formatBlockedMessage } from '@/core/format'; import type { HookInput, HookOutput } from '@/types'; @@ -24,25 +23,8 @@ function outputDeny(reason: string, command?: string, segment?: string): void { } export async function runClaudeCodeHook(): Promise { - const chunks: Buffer[] = []; - - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); - } - - const inputText = Buffer.concat(chunks).toString('utf-8').trim(); - - if (!inputText) { - return; - } - - let input: HookInput; - try { - input = JSON.parse(inputText) as HookInput; - } catch { - if (envTruthy('SAFETY_NET_STRICT')) { - outputDeny('Failed to parse hook input JSON (strict mode)'); - } + const input = await readHookInput(outputDeny); + if (!input) { return; } @@ -55,29 +37,5 @@ export async function runClaudeCodeHook(): Promise { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy('SAFETY_NET_STRICT'); - const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); - const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'); - const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'); - const worktreeMode = envTruthy('SAFETY_NET_WORKTREE'); - - const config = loadConfig(cwd); - - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode, - }); - - if (result) { - const sessionId = input.session_id; - if (sessionId) { - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - } - outputDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand(command, input.cwd ?? process.cwd(), input.session_id, outputDeny); } diff --git a/src/bin/hooks/common.ts b/src/bin/hooks/common.ts new file mode 100644 index 0000000..e117f9a --- /dev/null +++ b/src/bin/hooks/common.ts @@ -0,0 +1,61 @@ +import { analyzeCommand, loadConfig } from '@/core/analyze'; +import { writeAuditLog } from '@/core/audit'; +import { envTruthy } from '@/core/env'; + +export async function readHookInput(outputDeny: (reason: string) => void): Promise { + const chunks: Buffer[] = []; + + for await (const chunk of process.stdin) { + chunks.push(chunk as Buffer); + } + + const inputText = Buffer.concat(chunks).toString('utf-8').trim(); + + if (!inputText) { + return null; + } + + return parseHookJson(inputText, outputDeny, 'Failed to parse hook input JSON (strict mode)'); +} + +export function parseHookJson( + inputText: string, + outputDeny: (reason: string) => void, + strictReason: string, +): T | null { + try { + return JSON.parse(inputText) as T; + } catch { + if (envTruthy('SAFETY_NET_STRICT')) outputDeny(strictReason); + return null; + } +} + +function analyzeHookCommand(command: string, cwd: string) { + const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); + return analyzeCommand(command, { + cwd, + config: loadConfig(cwd), + strict: envTruthy('SAFETY_NET_STRICT'), + paranoidRm: paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'), + paranoidInterpreters: paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'), + worktreeMode: envTruthy('SAFETY_NET_WORKTREE'), + }); +} + +export function handleBlockedHookCommand( + command: string, + cwd: string, + sessionId: string | undefined, + outputDeny: (reason: string, command?: string, segment?: string) => void, +): void { + const result = analyzeHookCommand(command, cwd); + if (!result) { + return; + } + + if (sessionId) { + writeAuditLog(sessionId, command, result.segment, result.reason, cwd); + } + outputDeny(result.reason, command, result.segment); +} diff --git a/src/bin/hooks/copilot-cli.ts b/src/bin/hooks/copilot-cli.ts index 0416d76..a0840d6 100644 --- a/src/bin/hooks/copilot-cli.ts +++ b/src/bin/hooks/copilot-cli.ts @@ -1,6 +1,5 @@ -import { analyzeCommand, loadConfig } from '@/core/analyze'; -import { redactSecrets, writeAuditLog } from '@/core/audit'; -import { envTruthy } from '@/core/env'; +import { handleBlockedHookCommand, parseHookJson, readHookInput } from '@/bin/hooks/common'; +import { redactSecrets } from '@/core/audit'; import { formatBlockedMessage } from '@/core/format'; import type { CopilotCliHookInput, CopilotCliHookOutput } from '@/types'; @@ -21,25 +20,8 @@ function outputCopilotDeny(reason: string, command?: string, segment?: string): } export async function runCopilotCliHook(): Promise { - const chunks: Buffer[] = []; - - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); - } - - const inputText = Buffer.concat(chunks).toString('utf-8').trim(); - - if (!inputText) { - return; - } - - let input: CopilotCliHookInput; - try { - input = JSON.parse(inputText) as CopilotCliHookInput; - } catch { - if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDeny('Failed to parse hook input JSON (strict mode)'); - } + const input = await readHookInput(outputCopilotDeny); + if (!input) { return; } @@ -49,13 +31,12 @@ export async function runCopilotCliHook(): Promise { } // Parse toolArgs which is a JSON string containing {command: string} - let toolArgs: { command?: string }; - try { - toolArgs = JSON.parse(input.toolArgs) as { command?: string }; - } catch { - if (envTruthy('SAFETY_NET_STRICT')) { - outputCopilotDeny('Failed to parse toolArgs JSON (strict mode)'); - } + const toolArgs = parseHookJson<{ command?: string }>( + input.toolArgs, + outputCopilotDeny, + 'Failed to parse toolArgs JSON (strict mode)', + ); + if (!toolArgs) { return; } @@ -64,28 +45,10 @@ export async function runCopilotCliHook(): Promise { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy('SAFETY_NET_STRICT'); - const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); - const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'); - const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'); - const worktreeMode = envTruthy('SAFETY_NET_WORKTREE'); - - const config = loadConfig(cwd); - - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode, - }); - - if (result) { - // Generate a session ID from timestamp for audit logging - const sessionId = `copilot-${input.timestamp ?? Date.now()}`; - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand( + command, + input.cwd ?? process.cwd(), + `copilot-${input.timestamp ?? Date.now()}`, + outputCopilotDeny, + ); } diff --git a/src/bin/hooks/gemini-cli.ts b/src/bin/hooks/gemini-cli.ts index fb102ab..c753a6b 100644 --- a/src/bin/hooks/gemini-cli.ts +++ b/src/bin/hooks/gemini-cli.ts @@ -1,6 +1,5 @@ -import { analyzeCommand, loadConfig } from '@/core/analyze'; -import { redactSecrets, writeAuditLog } from '@/core/audit'; -import { envTruthy } from '@/core/env'; +import { handleBlockedHookCommand, readHookInput } from '@/bin/hooks/common'; +import { redactSecrets } from '@/core/audit'; import { formatBlockedMessage } from '@/core/format'; import type { GeminiHookInput, GeminiHookOutput } from '@/types'; @@ -23,25 +22,8 @@ function outputGeminiDeny(reason: string, command?: string, segment?: string): v } export async function runGeminiCLIHook(): Promise { - const chunks: Buffer[] = []; - - for await (const chunk of process.stdin) { - chunks.push(chunk as Buffer); - } - - const inputText = Buffer.concat(chunks).toString('utf-8').trim(); - - if (!inputText) { - return; - } - - let input: GeminiHookInput; - try { - input = JSON.parse(inputText) as GeminiHookInput; - } catch { - if (envTruthy('SAFETY_NET_STRICT')) { - outputGeminiDeny('Failed to parse hook input JSON (strict mode)'); - } + const input = await readHookInput(outputGeminiDeny); + if (!input) { return; } @@ -58,29 +40,5 @@ export async function runGeminiCLIHook(): Promise { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy('SAFETY_NET_STRICT'); - const paranoidAll = envTruthy('SAFETY_NET_PARANOID'); - const paranoidRm = paranoidAll || envTruthy('SAFETY_NET_PARANOID_RM'); - const paranoidInterpreters = paranoidAll || envTruthy('SAFETY_NET_PARANOID_INTERPRETERS'); - const worktreeMode = envTruthy('SAFETY_NET_WORKTREE'); - - const config = loadConfig(cwd); - - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode, - }); - - if (result) { - const sessionId = input.session_id; - if (sessionId) { - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - } - outputGeminiDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand(command, input.cwd ?? process.cwd(), input.session_id, outputGeminiDeny); } From 009c12b1702466905826263b97fb3c4c843a2af3 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:57:10 +0900 Subject: [PATCH 04/12] refactor(analyze): share child command normalization --- src/core/analyze/child-command.ts | 44 +++++++++++++++++++++ src/core/analyze/parallel.ts | 63 ++++++++++--------------------- src/core/analyze/xargs.ts | 33 +++++----------- 3 files changed, 73 insertions(+), 67 deletions(-) create mode 100644 src/core/analyze/child-command.ts diff --git a/src/core/analyze/child-command.ts b/src/core/analyze/child-command.ts new file mode 100644 index 0000000..7df4e0c --- /dev/null +++ b/src/core/analyze/child-command.ts @@ -0,0 +1,44 @@ +import { getBasename, stripWrappersWithInfo } from '@/core/shell'; + +export interface ChildCommandContext { + cwd: string | undefined; + envAssignments?: ReadonlyMap; +} + +export function normalizeChildCommand(tokens: readonly string[], context: ChildCommandContext) { + const wrapperInfo = stripWrappersWithInfo([...tokens], context.cwd); + const envAssignments = new Map(context.envAssignments ?? []); + for (const [k, v] of wrapperInfo.envAssignments) { + envAssignments.set(k, v); + } + + const childTokens = + getBasename(wrapperInfo.tokens[0] ?? '').toLowerCase() === 'busybox' && + wrapperInfo.tokens.length > 1 + ? wrapperInfo.tokens.slice(1) + : wrapperInfo.tokens; + + return { + tokens: childTokens, + cwd: wrapperInfo.cwd === null ? undefined : (wrapperInfo.cwd ?? context.cwd), + wrapperCwd: wrapperInfo.cwd, + envAssignments, + head: getBasename(childTokens[0] ?? '').toLowerCase(), + }; +} + +export function collectCommandTemplate(tokens: readonly string[], start: number) { + const templateTokens: string[] = []; + let i = start; + while (i < tokens.length) { + const token = tokens[i]; + if (token === undefined || token === ':::') break; + templateTokens.push(token); + i++; + } + + return { + markerIndex: i < tokens.length && tokens[i] === ':::' ? i : -1, + templateTokens, + }; +} diff --git a/src/core/analyze/parallel.ts b/src/core/analyze/parallel.ts index ed4def2..d85e10d 100644 --- a/src/core/analyze/parallel.ts +++ b/src/core/analyze/parallel.ts @@ -1,9 +1,9 @@ +import { collectCommandTemplate, normalizeChildCommand } from '@/core/analyze/child-command'; import { analyzeFind } from '@/core/analyze/find'; import { hasRecursiveForceFlags } from '@/core/analyze/rm-flags'; import { extractDashCArg } from '@/core/analyze/shell-wrappers'; import { analyzeGit } from '@/core/rules-git'; import { analyzeRm } from '@/core/rules-rm'; -import { getBasename, stripWrappersWithInfo } from '@/core/shell'; import { type AnalyzeNestedOverrides, SHELL_WRAPPERS } from '@/types'; const REASON_PARALLEL_RM = @@ -48,28 +48,16 @@ export function analyzeParallel( return null; } - const childWrapperInfo = stripWrappersWithInfo([...template], context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = - childWrapperInfo.cwd === null ? undefined : (childWrapperInfo.cwd ?? context.cwd); + const childCommand = normalizeChildCommand(template, context); + const childTokens = childCommand.tokens; const nestedOverrides = buildNestedOverrides( - childEnvAssignments, - childWrapperInfo.cwd, + childCommand.envAssignments, + childCommand.wrapperCwd, runsRemotely || hasDynamicStdinPlaceholder, ); - let head = getBasename(childTokens[0] ?? '').toLowerCase(); - - if (head === 'busybox' && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? '').toLowerCase(); - } // Check for shell wrapper with -c - if (SHELL_WRAPPERS.has(head)) { + if (SHELL_WRAPPERS.has(childCommand.head)) { const dashCArg = extractDashCArg(childTokens); if (dashCArg) { // If script IS just the placeholder, stdin provides entire script - dangerous @@ -123,13 +111,13 @@ export function analyzeParallel( } // For rm -rf, expand with actual args and analyze each expansion - if (head === 'rm' && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === 'rm' && hasRecursiveForceFlags(childTokens)) { if (hasPlaceholder && args.length > 0) { // Expand template with each arg and analyze for (const arg of args) { const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg)); const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar, @@ -145,7 +133,7 @@ export function analyzeParallel( if (args.length > 0) { const expandedTokens = [...childTokens, args[0] ?? '']; const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar, @@ -158,14 +146,14 @@ export function analyzeParallel( return REASON_PARALLEL_RM; } - if (head === 'find') { + if (childCommand.head === 'find') { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === 'git') { + if (childCommand.head === 'git') { const gitTokenSets = hasPlaceholder && args.length > 0 ? args.map((arg) => childTokens.map((token) => replaceParallelPlaceholder(token, arg))) @@ -175,8 +163,8 @@ export function analyzeParallel( const dynamicGitArgs = usesStdin || hasPlaceholder; for (const gitTokens of gitTokenSets) { const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: runsRemotely || dynamicGitArgs ? false : context.worktreeMode, }); if (gitResult) { @@ -274,16 +262,9 @@ function parseParallelCommand(tokens: readonly string[]): ParallelParseResult | if (token === '--') { // Everything after -- until ::: is the template - i++; - while (i < tokens.length) { - const token = tokens[i]; - if (token === undefined || token === ':::') break; - templateTokens.push(token); - i++; - } - if (i < tokens.length && tokens[i] === ':::') { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i + 1); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } @@ -343,15 +324,9 @@ function parseParallelCommand(tokens: readonly string[]): ParallelParseResult | i++; } else { // Start of template - while (i < tokens.length) { - const token = tokens[i]; - if (token === undefined || token === ':::') break; - templateTokens.push(token); - i++; - } - if (i < tokens.length && tokens[i] === ':::') { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } } diff --git a/src/core/analyze/xargs.ts b/src/core/analyze/xargs.ts index 1eeeef7..5d87f3c 100644 --- a/src/core/analyze/xargs.ts +++ b/src/core/analyze/xargs.ts @@ -1,8 +1,8 @@ +import { normalizeChildCommand } from '@/core/analyze/child-command'; import { analyzeFind } from '@/core/analyze/find'; import { hasRecursiveForceFlags } from '@/core/analyze/rm-flags'; import { analyzeGit } from '@/core/rules-git'; import { analyzeRm } from '@/core/rules-rm'; -import { getBasename, stripWrappersWithInfo } from '@/core/shell'; import { SHELL_WRAPPERS } from '@/types'; const REASON_XARGS_RM = @@ -26,36 +26,23 @@ export function analyzeXargs( const { childTokens: rawChildTokens, replacementToken } = extractXargsChildCommandWithInfo(tokens); - const childWrapperInfo = stripWrappersWithInfo(rawChildTokens, context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = - childWrapperInfo.cwd === null ? undefined : (childWrapperInfo.cwd ?? context.cwd); + const childCommand = normalizeChildCommand(rawChildTokens, context); + const childTokens = childCommand.tokens; if (childTokens.length === 0) { return null; } - let head = getBasename(childTokens[0] ?? '').toLowerCase(); - - if (head === 'busybox' && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? '').toLowerCase(); - } - // Check for shell wrapper with -c - if (SHELL_WRAPPERS.has(head)) { + if (SHELL_WRAPPERS.has(childCommand.head)) { // xargs bash -c is always dangerous - stdin feeds into the shell execution // Either no script arg (stdin IS the script) or script with dynamic input return REASON_XARGS_SHELL; } - if (head === 'rm' && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === 'rm' && hasRecursiveForceFlags(childTokens)) { const rmResult = analyzeRm(childTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar, @@ -68,21 +55,21 @@ export function analyzeXargs( return REASON_XARGS_RM; } - if (head === 'find') { + if (childCommand.head === 'find') { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === 'git') { + if (childCommand.head === 'git') { const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; const hasDynamicReplacement = replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: replacementToken === null || hasDynamicReplacement ? false : context.worktreeMode, }); From ff751bb354050b7f181efe79423cb5368a6fbaa6 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:57:14 +0900 Subject: [PATCH 05/12] refactor(analyze): share git env export helper --- src/core/analyze/analyze-command.ts | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/src/core/analyze/analyze-command.ts b/src/core/analyze/analyze-command.ts index dbf308b..05b6e99 100644 --- a/src/core/analyze/analyze-command.ts +++ b/src/core/analyze/analyze-command.ts @@ -377,13 +377,7 @@ function addExportedGitContextEnvAssignment(state: ShellGitContextEnvState, toke } if (isTrackedGitEnvName(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: '' }); - } + exportTrackedGitContextEnvName(state, token); } } @@ -413,16 +407,18 @@ function addTypesetGitContextEnvAssignment( } if (exports && isTrackedGitEnvName(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: '' }); - } + exportTrackedGitContextEnvName(state, token); } } +function exportTrackedGitContextEnvName(state: ShellGitContextEnvState, name: string): void { + state.exportedNames.add(name); + setEffectiveGitContextAssignment(state, { + name, + value: state.shellAssignments.get(name) ?? '', + }); +} + function getExportOperandsStart(tokens: readonly string[], commandIndex: number): number | null { let i = commandIndex + 1; while (i < tokens.length) { From 03e8952bbd8431ed6773e50f7612fb89b110bdf2 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:57:18 +0900 Subject: [PATCH 06/12] refactor(git): share dotgit ancestor lookup --- src/core/rules-git.ts | 19 ++----------------- src/core/worktree.ts | 6 ++++-- 2 files changed, 6 insertions(+), 19 deletions(-) diff --git a/src/core/rules-git.ts b/src/core/rules-git.ts index 7aa633d..5738fa0 100644 --- a/src/core/rules-git.ts +++ b/src/core/rules-git.ts @@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { dirname, isAbsolute, join, resolve } from 'node:path'; import { extractShortOpts, getBasename } from '@/core/shell'; import { + findDotGitInAncestors, GIT_CONFIG_AFFECTING_ENV_NAMES, GIT_GLOBAL_OPTS_WITH_VALUE, getGitExecutionContext, @@ -686,7 +687,7 @@ function isGitConfigUnsetError(error: unknown): boolean { } function getLocalGitConfigPaths(cwd: string): string[] | null { - const dotGitPath = findDotGitPath(cwd); + const dotGitPath = findDotGitInAncestors(cwd); if (dotGitPath === null) { return null; } @@ -704,22 +705,6 @@ function getLocalGitConfigPaths(cwd: string): string[] | null { return [join(commonDir, 'config'), join(gitDir, 'config.worktree')]; } -function findDotGitPath(cwd: string): string | null { - let current = cwd; - while (true) { - const dotGitPath = join(current, '.git'); - if (existsSync(dotGitPath)) { - return dotGitPath; - } - - const parent = dirname(current); - if (parent === current) { - return null; - } - current = parent; - } -} - function resolveGitDirFromDotGit(dotGitPath: string): string | null { try { const content = readFileSync(dotGitPath, 'utf-8'); diff --git a/src/core/worktree.ts b/src/core/worktree.ts index c2abc9c..3cd3df0 100644 --- a/src/core/worktree.ts +++ b/src/core/worktree.ts @@ -326,13 +326,15 @@ function isDirectory(path: string): boolean { } function findDotGit(cwd: string): string | null { - let current: string; try { - current = realpathSync(cwd); + return findDotGitInAncestors(realpathSync(cwd)); } catch { return null; } +} +export function findDotGitInAncestors(cwd: string): string | null { + let current = cwd; while (true) { const dotGitPath = join(current, '.git'); if (existsSync(dotGitPath)) { From 87cc689014dd1d365ccbcf6f0dc228585e257932 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 17:57:29 +0900 Subject: [PATCH 07/12] chore: rebuild distribution --- dist/bin/cc-safety-net.js | 468 +++++++++------------------ dist/bin/hooks/common.d.ts | 3 + dist/core/analyze/child-command.d.ts | 15 + dist/core/worktree.d.ts | 1 + dist/index.js | 164 +++++----- 5 files changed, 242 insertions(+), 409 deletions(-) create mode 100644 dist/bin/hooks/common.d.ts create mode 100644 dist/core/analyze/child-command.d.ts diff --git a/dist/bin/cc-safety-net.js b/dist/bin/cc-safety-net.js index 6ce2a8a..96d6e9f 100755 --- a/dist/bin/cc-safety-net.js +++ b/dist/bin/cc-safety-net.js @@ -977,6 +977,24 @@ var PLATFORM_NAMES = { "copilot-cli": "Copilot CLI", codex: "Codex" }; +function formatAsciiTable(options) { + const rawRows = options.rawRows ?? options.rows; + const colWidths = (options.headers ?? rawRows[0] ?? []).map((h, i) => { + const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); + return Math.max(h.length, maxDataWidth); + }); + const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); + const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; + const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; + const headerLines = options.headers ? [` ${formatRow(options.headers, options.headers)}`, ` ${line("─", ["├", "┼", "┤"])}`] : []; + return [ + ` ${line("─", ["┌", "┬", "┐"])}`, + ...headerLines, + ...options.rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), + ` ${line("─", ["└", "┴", "┘"])}` + ].join(` +`); +} function formatHooksSection(hooks) { const lines = []; lines.push("Hook Integration"); @@ -1047,22 +1065,7 @@ function formatHooksTable(hooks) { }); const rows = rowData.map((r) => r.colored); const rawRows = rowData.map((r) => r.raw); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers, headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows, rawRows }); } function formatRulesTable(rules) { if (rules.length === 0) { @@ -1075,22 +1078,7 @@ function formatRulesTable(rules) { r.subcommand ? `${r.command} ${r.subcommand}` : r.command, r.blockArgs.join(", ") ]); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w) => s.padEnd(w); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0)).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r) => ` ${formatRow(r)}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows }); } function formatConfigSection(report) { const lines = []; @@ -1133,22 +1121,7 @@ function formatConfigTable(userConfig, projectConfig) { ["User", userStatus.text], ["Project", projectStatus.text] ]; - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers, headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows, rawRows }); } function formatEnvironmentSection(envVars) { const lines = []; @@ -1164,22 +1137,7 @@ function formatEnvironmentTable(envVars) { return [v.name, statusIcon]; }); const rawRows = envVars.map((v) => [v.name, v.isSet ? "✓" : "✗"]); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers, headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows, rawRows }); } function formatActivitySection(activity) { const lines = []; @@ -1200,22 +1158,7 @@ function formatActivityTable(entries) { const cmd = e.command.length > 40 ? `${e.command.slice(0, 37)}...` : e.command; return [e.relativeTime, cmd]; }); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w) => s.padEnd(w); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0)).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r) => ` ${formatRow(r)}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows }); } function formatUpdateSection(update) { const lines = []; @@ -1296,19 +1239,7 @@ function formatUpdateSection(update) { function formatUpdateTable(rowData) { const rows = rowData.map((r) => [r.label, r.value]); const rawRows = rowData.map((r) => [r.label, r.rawValue]); - const colWidths = [0, 1].map((i) => { - return Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - }); - const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ rows, rawRows }); } function formatSystemInfoSection(system) { const lines = []; @@ -1340,22 +1271,7 @@ function formatSystemInfoTable(system) { ]; const rows = rowData.map((r) => [r.label, formatValue(r.value)]); const rawRows = rowData.map((r) => [r.label, rawValue(r.value)]); - const colWidths = headers.map((h, i) => { - const maxDataWidth = Math.max(...rawRows.map((r) => r[i]?.length ?? 0)); - return Math.max(h.length, maxDataWidth); - }); - const pad = (s, w, raw) => s + " ".repeat(Math.max(0, w - raw.length)); - const line = (char, corners) => corners[0] + colWidths.map((w) => char.repeat(w + 2)).join(corners[1]) + corners[2]; - const formatRow = (cells, rawCells) => `│ ${cells.map((c, i) => pad(c, colWidths[i] ?? 0, rawCells[i] ?? "")).join(" │ ")} │`; - const tableLines = [ - ` ${line("─", ["┌", "┬", "┐"])}`, - ` ${formatRow(headers, headers)}`, - ` ${line("─", ["├", "┼", "┤"])}`, - ...rows.map((r, i) => ` ${formatRow(r, rawRows[i] ?? [])}`), - ` ${line("─", ["└", "┴", "┘"])}` - ]; - return tableLines.join(` -`); + return formatAsciiTable({ headers, rows, rawRows }); } function formatSummary(report) { const hooksFailed = report.hooks.every((h) => h.status !== "configured"); @@ -1873,12 +1789,14 @@ function isDirectory(path) { } } function findDotGit(cwd) { - let current; try { - current = realpathSync2(cwd); + return findDotGitInAncestors(realpathSync2(cwd)); } catch { return null; } +} +function findDotGitInAncestors(cwd) { + let current = cwd; while (true) { const dotGitPath = join3(current, ".git"); if (existsSync4(dotGitPath)) { @@ -2978,6 +2896,38 @@ function containsDangerousCode(code) { return false; } +// src/core/analyze/child-command.ts +function normalizeChildCommand(tokens, context) { + const wrapperInfo = stripWrappersWithInfo([...tokens], context.cwd); + const envAssignments = new Map(context.envAssignments ?? []); + for (const [k, v] of wrapperInfo.envAssignments) { + envAssignments.set(k, v); + } + const childTokens = getBasename(wrapperInfo.tokens[0] ?? "").toLowerCase() === "busybox" && wrapperInfo.tokens.length > 1 ? wrapperInfo.tokens.slice(1) : wrapperInfo.tokens; + return { + tokens: childTokens, + cwd: wrapperInfo.cwd === null ? undefined : wrapperInfo.cwd ?? context.cwd, + wrapperCwd: wrapperInfo.cwd, + envAssignments, + head: getBasename(childTokens[0] ?? "").toLowerCase() + }; +} +function collectCommandTemplate(tokens, start) { + const templateTokens = []; + let i = start; + while (i < tokens.length) { + const token = tokens[i]; + if (token === undefined || token === ":::") + break; + templateTokens.push(token); + i++; + } + return { + markerIndex: i < tokens.length && tokens[i] === ":::" ? i : -1, + templateTokens + }; +} + // src/core/analyze/shell-wrappers.ts function extractDashCArg(tokens) { for (let i = 1;i < tokens.length; i++) { @@ -3499,7 +3449,7 @@ function isGitConfigUnsetError(error) { return typeof error === "object" && error !== null && "status" in error && error.status === 1; } function getLocalGitConfigPaths(cwd) { - const dotGitPath = findDotGitPath(cwd); + const dotGitPath = findDotGitInAncestors(cwd); if (dotGitPath === null) { return null; } @@ -3513,20 +3463,6 @@ function getLocalGitConfigPaths(cwd) { } return [join4(commonDir, "config"), join4(gitDir, "config.worktree")]; } -function findDotGitPath(cwd) { - let current = cwd; - while (true) { - const dotGitPath = join4(current, ".git"); - if (existsSync5(dotGitPath)) { - return dotGitPath; - } - const parent = dirname3(current); - if (parent === current) { - return null; - } - current = parent; - } -} function resolveGitDirFromDotGit(dotGitPath) { try { const content = readFileSync5(dotGitPath, "utf-8"); @@ -3968,20 +3904,10 @@ function analyzeParallel(tokens, context) { } return null; } - const childWrapperInfo = stripWrappersWithInfo([...template], context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = childWrapperInfo.cwd === null ? undefined : childWrapperInfo.cwd ?? context.cwd; - const nestedOverrides = buildNestedOverrides(childEnvAssignments, childWrapperInfo.cwd, runsRemotely || hasDynamicStdinPlaceholder); - let head = getBasename(childTokens[0] ?? "").toLowerCase(); - if (head === "busybox" && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? "").toLowerCase(); - } - if (SHELL_WRAPPERS.has(head)) { + const childCommand = normalizeChildCommand(template, context); + const childTokens = childCommand.tokens; + const nestedOverrides = buildNestedOverrides(childCommand.envAssignments, childCommand.wrapperCwd, runsRemotely || hasDynamicStdinPlaceholder); + if (SHELL_WRAPPERS.has(childCommand.head)) { const dashCArg = extractDashCArg(childTokens); if (dashCArg) { if (isOnlyParallelPlaceholder(dashCArg)) { @@ -4021,12 +3947,12 @@ function analyzeParallel(tokens, context) { } return null; } - if (head === "rm" && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === "rm" && hasRecursiveForceFlags(childTokens)) { if (hasPlaceholder && args.length > 0) { for (const arg of args) { const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg)); const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -4040,7 +3966,7 @@ function analyzeParallel(tokens, context) { if (args.length > 0) { const expandedTokens = [...childTokens, args[0] ?? ""]; const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -4052,19 +3978,19 @@ function analyzeParallel(tokens, context) { } return REASON_PARALLEL_RM; } - if (head === "find") { + if (childCommand.head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === "git") { + if (childCommand.head === "git") { const gitTokenSets = hasPlaceholder && args.length > 0 ? args.map((arg) => childTokens.map((token) => replaceParallelPlaceholder(token, arg))) : !hasPlaceholder && args.length > 0 ? args.map((arg) => [...childTokens, arg]) : [childTokens]; const dynamicGitArgs = usesStdin || hasPlaceholder; for (const gitTokens of gitTokenSets) { const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: runsRemotely || dynamicGitArgs ? false : context.worktreeMode }); if (gitResult) { @@ -4134,17 +4060,9 @@ function parseParallelCommand(tokens) { break; } if (token === "--") { - i++; - while (i < tokens.length) { - const token2 = tokens[i]; - if (token2 === undefined || token2 === ":::") - break; - templateTokens.push(token2); - i++; - } - if (i < tokens.length && tokens[i] === ":::") { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i + 1); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } if (token.startsWith("-")) { @@ -4181,16 +4099,9 @@ function parseParallelCommand(tokens) { } i++; } else { - while (i < tokens.length) { - const token2 = tokens[i]; - if (token2 === undefined || token2 === ":::") - break; - templateTokens.push(token2); - i++; - } - if (i < tokens.length && tokens[i] === ":::") { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } } @@ -4248,27 +4159,17 @@ var REASON_XARGS_SHELL = "xargs with shell -c can execute arbitrary commands fro var XARGS_APPENDED_INPUT = "__CC_SAFETY_NET_XARGS_INPUT__"; function analyzeXargs(tokens, context) { const { childTokens: rawChildTokens, replacementToken } = extractXargsChildCommandWithInfo(tokens); - const childWrapperInfo = stripWrappersWithInfo(rawChildTokens, context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = childWrapperInfo.cwd === null ? undefined : childWrapperInfo.cwd ?? context.cwd; + const childCommand = normalizeChildCommand(rawChildTokens, context); + const childTokens = childCommand.tokens; if (childTokens.length === 0) { return null; } - let head = getBasename(childTokens[0] ?? "").toLowerCase(); - if (head === "busybox" && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? "").toLowerCase(); - } - if (SHELL_WRAPPERS.has(head)) { + if (SHELL_WRAPPERS.has(childCommand.head)) { return REASON_XARGS_SHELL; } - if (head === "rm" && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === "rm" && hasRecursiveForceFlags(childTokens)) { const rmResult = analyzeRm(childTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -4278,18 +4179,18 @@ function analyzeXargs(tokens, context) { } return REASON_XARGS_RM; } - if (head === "find") { + if (childCommand.head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === "git") { + if (childCommand.head === "git") { const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; const hasDynamicReplacement = replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: replacementToken === null || hasDynamicReplacement ? false : context.worktreeMode }); if (gitResult) { @@ -4908,13 +4809,7 @@ function addExportedGitContextEnvAssignment(state, token) { return; } if (isTrackedGitEnvName2(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: "" }); - } + exportTrackedGitContextEnvName(state, token); } } function addTypesetGitContextEnvAssignment(state, token, exports, readonlyLeadingAssignments) { @@ -4936,15 +4831,16 @@ function addTypesetGitContextEnvAssignment(state, token, exports, readonlyLeadin return; } if (exports && isTrackedGitEnvName2(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: "" }); - } + exportTrackedGitContextEnvName(state, token); } } +function exportTrackedGitContextEnvName(state, name) { + state.exportedNames.add(name); + setEffectiveGitContextAssignment(state, { + name, + value: state.shellAssignments.get(name) ?? "" + }); +} function getExportOperandsStart(tokens, commandIndex) { let i = commandIndex + 1; while (i < tokens.length) { @@ -6907,6 +6803,49 @@ function redactSecrets(text) { return result; } +// src/bin/hooks/common.ts +async function readHookInput(outputDeny) { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(chunk); + } + const inputText = Buffer.concat(chunks).toString("utf-8").trim(); + if (!inputText) { + return null; + } + return parseHookJson(inputText, outputDeny, "Failed to parse hook input JSON (strict mode)"); +} +function parseHookJson(inputText, outputDeny, strictReason) { + try { + return JSON.parse(inputText); + } catch { + if (envTruthy("SAFETY_NET_STRICT")) + outputDeny(strictReason); + return null; + } +} +function analyzeHookCommand(command, cwd) { + const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); + return analyzeCommand(command, { + cwd, + config: loadConfig(cwd), + strict: envTruthy("SAFETY_NET_STRICT"), + paranoidRm: paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"), + paranoidInterpreters: paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"), + worktreeMode: envTruthy("SAFETY_NET_WORKTREE") + }); +} +function handleBlockedHookCommand(command, cwd, sessionId, outputDeny) { + const result = analyzeHookCommand(command, cwd); + if (!result) { + return; + } + if (sessionId) { + writeAuditLog(sessionId, command, result.segment, result.reason, cwd); + } + outputDeny(result.reason, command, result.segment); +} + // src/core/format.ts function formatBlockedMessage(input) { const { reason, command, segment } = input; @@ -6954,21 +6893,8 @@ function outputDeny(reason, command, segment) { console.log(JSON.stringify(output)); } async function runClaudeCodeHook() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - const inputText = Buffer.concat(chunks).toString("utf-8").trim(); - if (!inputText) { - return; - } - let input; - try { - input = JSON.parse(inputText); - } catch { - if (envTruthy("SAFETY_NET_STRICT")) { - outputDeny("Failed to parse hook input JSON (strict mode)"); - } + const input = await readHookInput(outputDeny); + if (!input) { return; } if (input.tool_name !== "Bash") { @@ -6978,28 +6904,7 @@ async function runClaudeCodeHook() { if (!command) { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy("SAFETY_NET_STRICT"); - const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); - const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); - const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); - const worktreeMode = envTruthy("SAFETY_NET_WORKTREE"); - const config = loadConfig(cwd); - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode - }); - if (result) { - const sessionId = input.session_id; - if (sessionId) { - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - } - outputDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand(command, input.cwd ?? process.cwd(), input.session_id, outputDeny); } // src/bin/hooks/copilot-cli.ts @@ -7017,59 +6922,22 @@ function outputCopilotDeny(reason, command, segment) { console.log(JSON.stringify(output)); } async function runCopilotCliHook() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - const inputText = Buffer.concat(chunks).toString("utf-8").trim(); - if (!inputText) { - return; - } - let input; - try { - input = JSON.parse(inputText); - } catch { - if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDeny("Failed to parse hook input JSON (strict mode)"); - } + const input = await readHookInput(outputCopilotDeny); + if (!input) { return; } if (input.toolName !== "bash") { return; } - let toolArgs; - try { - toolArgs = JSON.parse(input.toolArgs); - } catch { - if (envTruthy("SAFETY_NET_STRICT")) { - outputCopilotDeny("Failed to parse toolArgs JSON (strict mode)"); - } + const toolArgs = parseHookJson(input.toolArgs, outputCopilotDeny, "Failed to parse toolArgs JSON (strict mode)"); + if (!toolArgs) { return; } const command = toolArgs.command; if (!command) { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy("SAFETY_NET_STRICT"); - const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); - const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); - const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); - const worktreeMode = envTruthy("SAFETY_NET_WORKTREE"); - const config = loadConfig(cwd); - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode - }); - if (result) { - const sessionId = `copilot-${input.timestamp ?? Date.now()}`; - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - outputCopilotDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand(command, input.cwd ?? process.cwd(), `copilot-${input.timestamp ?? Date.now()}`, outputCopilotDeny); } // src/bin/hooks/gemini-cli.ts @@ -7088,21 +6956,8 @@ function outputGeminiDeny(reason, command, segment) { console.log(JSON.stringify(output)); } async function runGeminiCLIHook() { - const chunks = []; - for await (const chunk of process.stdin) { - chunks.push(chunk); - } - const inputText = Buffer.concat(chunks).toString("utf-8").trim(); - if (!inputText) { - return; - } - let input; - try { - input = JSON.parse(inputText); - } catch { - if (envTruthy("SAFETY_NET_STRICT")) { - outputGeminiDeny("Failed to parse hook input JSON (strict mode)"); - } + const input = await readHookInput(outputGeminiDeny); + if (!input) { return; } if (input.hook_event_name !== "BeforeTool") { @@ -7115,28 +6970,7 @@ async function runGeminiCLIHook() { if (!command) { return; } - const cwd = input.cwd ?? process.cwd(); - const strict = envTruthy("SAFETY_NET_STRICT"); - const paranoidAll = envTruthy("SAFETY_NET_PARANOID"); - const paranoidRm = paranoidAll || envTruthy("SAFETY_NET_PARANOID_RM"); - const paranoidInterpreters = paranoidAll || envTruthy("SAFETY_NET_PARANOID_INTERPRETERS"); - const worktreeMode = envTruthy("SAFETY_NET_WORKTREE"); - const config = loadConfig(cwd); - const result = analyzeCommand(command, { - cwd, - config, - strict, - paranoidRm, - paranoidInterpreters, - worktreeMode - }); - if (result) { - const sessionId = input.session_id; - if (sessionId) { - writeAuditLog(sessionId, command, result.segment, result.reason, cwd); - } - outputGeminiDeny(result.reason, command, result.segment); - } + handleBlockedHookCommand(command, input.cwd ?? process.cwd(), input.session_id, outputGeminiDeny); } // src/bin/statusline.ts diff --git a/dist/bin/hooks/common.d.ts b/dist/bin/hooks/common.d.ts new file mode 100644 index 0000000..bd26e7e --- /dev/null +++ b/dist/bin/hooks/common.d.ts @@ -0,0 +1,3 @@ +export declare function readHookInput(outputDeny: (reason: string) => void): Promise; +export declare function parseHookJson(inputText: string, outputDeny: (reason: string) => void, strictReason: string): T | null; +export declare function handleBlockedHookCommand(command: string, cwd: string, sessionId: string | undefined, outputDeny: (reason: string, command?: string, segment?: string) => void): void; diff --git a/dist/core/analyze/child-command.d.ts b/dist/core/analyze/child-command.d.ts new file mode 100644 index 0000000..db53be5 --- /dev/null +++ b/dist/core/analyze/child-command.d.ts @@ -0,0 +1,15 @@ +export interface ChildCommandContext { + cwd: string | undefined; + envAssignments?: ReadonlyMap; +} +export declare function normalizeChildCommand(tokens: readonly string[], context: ChildCommandContext): { + tokens: string[]; + cwd: string | undefined; + wrapperCwd: string | null | undefined; + envAssignments: Map; + head: string; +}; +export declare function collectCommandTemplate(tokens: readonly string[], start: number): { + markerIndex: number; + templateTokens: string[]; +}; diff --git a/dist/core/worktree.d.ts b/dist/core/worktree.d.ts index 84d47eb..1b550e8 100644 --- a/dist/core/worktree.d.ts +++ b/dist/core/worktree.d.ts @@ -11,5 +11,6 @@ export declare function isLinkedWorktree(cwd: string): boolean; /** @internal Exported for testing */ export declare function normalizePathForComparison(path: string): string; declare function parseGitConfigValue(value: string): string; +export declare function findDotGitInAncestors(cwd: string): string | null; /** @internal Exported for testing */ export { parseGitConfigValue as _parseGitConfigValue }; diff --git a/dist/index.js b/dist/index.js index 6a1724e..da2a39a 100644 --- a/dist/index.js +++ b/dist/index.js @@ -726,12 +726,14 @@ function isDirectory(path) { } } function findDotGit(cwd) { - let current; try { - current = realpathSync2(cwd); + return findDotGitInAncestors(realpathSync2(cwd)); } catch { return null; } +} +function findDotGitInAncestors(cwd) { + let current = cwd; while (true) { const dotGitPath = join(current, ".git"); if (existsSync(dotGitPath)) { @@ -1831,6 +1833,38 @@ function containsDangerousCode(code) { return false; } +// src/core/analyze/child-command.ts +function normalizeChildCommand(tokens, context) { + const wrapperInfo = stripWrappersWithInfo([...tokens], context.cwd); + const envAssignments = new Map(context.envAssignments ?? []); + for (const [k, v] of wrapperInfo.envAssignments) { + envAssignments.set(k, v); + } + const childTokens = getBasename(wrapperInfo.tokens[0] ?? "").toLowerCase() === "busybox" && wrapperInfo.tokens.length > 1 ? wrapperInfo.tokens.slice(1) : wrapperInfo.tokens; + return { + tokens: childTokens, + cwd: wrapperInfo.cwd === null ? undefined : wrapperInfo.cwd ?? context.cwd, + wrapperCwd: wrapperInfo.cwd, + envAssignments, + head: getBasename(childTokens[0] ?? "").toLowerCase() + }; +} +function collectCommandTemplate(tokens, start) { + const templateTokens = []; + let i = start; + while (i < tokens.length) { + const token = tokens[i]; + if (token === undefined || token === ":::") + break; + templateTokens.push(token); + i++; + } + return { + markerIndex: i < tokens.length && tokens[i] === ":::" ? i : -1, + templateTokens + }; +} + // src/core/analyze/shell-wrappers.ts function extractDashCArg(tokens) { for (let i = 1;i < tokens.length; i++) { @@ -2352,7 +2386,7 @@ function isGitConfigUnsetError(error) { return typeof error === "object" && error !== null && "status" in error && error.status === 1; } function getLocalGitConfigPaths(cwd) { - const dotGitPath = findDotGitPath(cwd); + const dotGitPath = findDotGitInAncestors(cwd); if (dotGitPath === null) { return null; } @@ -2366,20 +2400,6 @@ function getLocalGitConfigPaths(cwd) { } return [join2(commonDir, "config"), join2(gitDir, "config.worktree")]; } -function findDotGitPath(cwd) { - let current = cwd; - while (true) { - const dotGitPath = join2(current, ".git"); - if (existsSync2(dotGitPath)) { - return dotGitPath; - } - const parent = dirname3(current); - if (parent === current) { - return null; - } - current = parent; - } -} function resolveGitDirFromDotGit(dotGitPath) { try { const content = readFileSync2(dotGitPath, "utf-8"); @@ -2821,20 +2841,10 @@ function analyzeParallel(tokens, context) { } return null; } - const childWrapperInfo = stripWrappersWithInfo([...template], context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = childWrapperInfo.cwd === null ? undefined : childWrapperInfo.cwd ?? context.cwd; - const nestedOverrides = buildNestedOverrides(childEnvAssignments, childWrapperInfo.cwd, runsRemotely || hasDynamicStdinPlaceholder); - let head = getBasename(childTokens[0] ?? "").toLowerCase(); - if (head === "busybox" && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? "").toLowerCase(); - } - if (SHELL_WRAPPERS.has(head)) { + const childCommand = normalizeChildCommand(template, context); + const childTokens = childCommand.tokens; + const nestedOverrides = buildNestedOverrides(childCommand.envAssignments, childCommand.wrapperCwd, runsRemotely || hasDynamicStdinPlaceholder); + if (SHELL_WRAPPERS.has(childCommand.head)) { const dashCArg = extractDashCArg(childTokens); if (dashCArg) { if (isOnlyParallelPlaceholder(dashCArg)) { @@ -2874,12 +2884,12 @@ function analyzeParallel(tokens, context) { } return null; } - if (head === "rm" && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === "rm" && hasRecursiveForceFlags(childTokens)) { if (hasPlaceholder && args.length > 0) { for (const arg of args) { const expandedTokens = childTokens.map((t) => t.replace(/{}/g, arg)); const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -2893,7 +2903,7 @@ function analyzeParallel(tokens, context) { if (args.length > 0) { const expandedTokens = [...childTokens, args[0] ?? ""]; const rmResult = analyzeRm(expandedTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -2905,19 +2915,19 @@ function analyzeParallel(tokens, context) { } return REASON_PARALLEL_RM; } - if (head === "find") { + if (childCommand.head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === "git") { + if (childCommand.head === "git") { const gitTokenSets = hasPlaceholder && args.length > 0 ? args.map((arg) => childTokens.map((token) => replaceParallelPlaceholder(token, arg))) : !hasPlaceholder && args.length > 0 ? args.map((arg) => [...childTokens, arg]) : [childTokens]; const dynamicGitArgs = usesStdin || hasPlaceholder; for (const gitTokens of gitTokenSets) { const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: runsRemotely || dynamicGitArgs ? false : context.worktreeMode }); if (gitResult) { @@ -2987,17 +2997,9 @@ function parseParallelCommand(tokens) { break; } if (token === "--") { - i++; - while (i < tokens.length) { - const token2 = tokens[i]; - if (token2 === undefined || token2 === ":::") - break; - templateTokens.push(token2); - i++; - } - if (i < tokens.length && tokens[i] === ":::") { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i + 1); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } if (token.startsWith("-")) { @@ -3034,16 +3036,9 @@ function parseParallelCommand(tokens) { } i++; } else { - while (i < tokens.length) { - const token2 = tokens[i]; - if (token2 === undefined || token2 === ":::") - break; - templateTokens.push(token2); - i++; - } - if (i < tokens.length && tokens[i] === ":::") { - markerIndex = i; - } + const template = collectCommandTemplate(tokens, i); + templateTokens.push(...template.templateTokens); + markerIndex = template.markerIndex; break; } } @@ -3101,27 +3096,17 @@ var REASON_XARGS_SHELL = "xargs with shell -c can execute arbitrary commands fro var XARGS_APPENDED_INPUT = "__CC_SAFETY_NET_XARGS_INPUT__"; function analyzeXargs(tokens, context) { const { childTokens: rawChildTokens, replacementToken } = extractXargsChildCommandWithInfo(tokens); - const childWrapperInfo = stripWrappersWithInfo(rawChildTokens, context.cwd); - let childTokens = childWrapperInfo.tokens; - const childEnvAssignments = new Map(context.envAssignments ?? []); - for (const [k, v] of childWrapperInfo.envAssignments) { - childEnvAssignments.set(k, v); - } - const childCwd = childWrapperInfo.cwd === null ? undefined : childWrapperInfo.cwd ?? context.cwd; + const childCommand = normalizeChildCommand(rawChildTokens, context); + const childTokens = childCommand.tokens; if (childTokens.length === 0) { return null; } - let head = getBasename(childTokens[0] ?? "").toLowerCase(); - if (head === "busybox" && childTokens.length > 1) { - childTokens = childTokens.slice(1); - head = getBasename(childTokens[0] ?? "").toLowerCase(); - } - if (SHELL_WRAPPERS.has(head)) { + if (SHELL_WRAPPERS.has(childCommand.head)) { return REASON_XARGS_SHELL; } - if (head === "rm" && hasRecursiveForceFlags(childTokens)) { + if (childCommand.head === "rm" && hasRecursiveForceFlags(childTokens)) { const rmResult = analyzeRm(childTokens, { - cwd: childCwd, + cwd: childCommand.cwd, originalCwd: context.originalCwd, paranoid: context.paranoidRm, allowTmpdirVar: context.allowTmpdirVar @@ -3131,18 +3116,18 @@ function analyzeXargs(tokens, context) { } return REASON_XARGS_RM; } - if (head === "find") { + if (childCommand.head === "find") { const findResult = analyzeFind(childTokens); if (findResult) { return findResult; } } - if (head === "git") { + if (childCommand.head === "git") { const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; const hasDynamicReplacement = replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: replacementToken === null || hasDynamicReplacement ? false : context.worktreeMode }); if (gitResult) { @@ -3761,13 +3746,7 @@ function addExportedGitContextEnvAssignment(state, token) { return; } if (isTrackedGitEnvName2(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: "" }); - } + exportTrackedGitContextEnvName(state, token); } } function addTypesetGitContextEnvAssignment(state, token, exports, readonlyLeadingAssignments) { @@ -3789,15 +3768,16 @@ function addTypesetGitContextEnvAssignment(state, token, exports, readonlyLeadin return; } if (exports && isTrackedGitEnvName2(token)) { - state.exportedNames.add(token); - const value = state.shellAssignments.get(token); - if (value !== undefined) { - setEffectiveGitContextAssignment(state, { name: token, value }); - } else { - setEffectiveGitContextAssignment(state, { name: token, value: "" }); - } + exportTrackedGitContextEnvName(state, token); } } +function exportTrackedGitContextEnvName(state, name) { + state.exportedNames.add(name); + setEffectiveGitContextAssignment(state, { + name, + value: state.shellAssignments.get(name) ?? "" + }); +} function getExportOperandsStart(tokens, commandIndex) { let i = commandIndex + 1; while (i < tokens.length) { From be9c3578198e16c0caa9977b652d4fa5c8747cc4 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 18:37:46 +0900 Subject: [PATCH 08/12] chore: add jscpd duplicate detection to check scripts Integrate jscpd via a new 'check-duplicates' script and wire it into both 'check' and 'check:ci' so duplicate code is caught in CI. --- package.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index ed5fe85..9bdbe1d 100644 --- a/package.json +++ b/package.json @@ -16,12 +16,13 @@ "build:types": "tsc --project tsconfig.build.json --emitDeclarationOnly --declaration", "build:schema": "bun run scripts/build-schema.ts", "clean": "rm -rf dist", - "check": "bun run lint && bun run typecheck && bun run knip && bun run sg:scan && AGENT=1 bun test --coverage", - "check:ci": "bun run lint:ci && bun run typecheck && bun run knip && bun run sg:scan && AGENT=1 bun test --coverage --coverage-reporter=lcov", + "check": "bun run lint && bun run typecheck && bun run knip && bun run check-duplicates && bun run sg:scan && AGENT=1 bun test --coverage", + "check:ci": "bun run lint:ci && bun run typecheck && bun run knip && bun run check-duplicates && bun run sg:scan && AGENT=1 bun test --coverage --coverage-reporter=lcov", "lint": "biome check --write", "lint:ci": "biome ci .", "typecheck": "tsc --noEmit", "knip": "knip --production", + "check-duplicates": "bunx jscpd src tests --exitCode 1 --reporters ai", "sg:scan": "ast-grep scan", "test": "bun test", "publish:dry-run": "bun run scripts/publish.ts --dry-run", From cdc9fe0e421de8552b9248f8a5f82c606ccd786c Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 21:56:56 +0900 Subject: [PATCH 09/12] test: remove unused copilotToolArgsInput helper --- tests/bin/hooks/hook-helpers.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/bin/hooks/hook-helpers.ts b/tests/bin/hooks/hook-helpers.ts index 303dc5e..093d110 100644 --- a/tests/bin/hooks/hook-helpers.ts +++ b/tests/bin/hooks/hook-helpers.ts @@ -28,10 +28,6 @@ export function copilotRawToolArgsInput(toolArgs: string) { }; } -export function copilotToolArgsInput(toolArgs: object) { - return copilotRawToolArgsInput(JSON.stringify(toolArgs)); -} - export function geminiShellInput(command: string) { return { hook_event_name: 'BeforeTool', From 2b8d4d13c5745752ce27d66f0d434a0ad520d8e7 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 22:05:09 +0900 Subject: [PATCH 10/12] fix(analyze): detect replacement token in xargs env assignments The hasDynamicReplacement check now also looks at envAssignments for the replacement token, catching cases like where the token appears in an environment variable value rather than a direct argument. --- dist/bin/cc-safety-net.js | 2 +- dist/index.js | 2 +- src/core/analyze/xargs.ts | 6 +++++- tests/core/rules-git.test.ts | 15 +++++++++++++++ 4 files changed, 22 insertions(+), 3 deletions(-) diff --git a/dist/bin/cc-safety-net.js b/dist/bin/cc-safety-net.js index 96d6e9f..a291ae1 100755 --- a/dist/bin/cc-safety-net.js +++ b/dist/bin/cc-safety-net.js @@ -4187,7 +4187,7 @@ function analyzeXargs(tokens, context) { } if (childCommand.head === "git") { const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; - const hasDynamicReplacement = replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); + const hasDynamicReplacement = replacementToken !== null && (childTokens.some((token) => token.includes(replacementToken)) || Array.from(childCommand.envAssignments.values()).some((value) => value.includes(replacementToken))); const gitResult = analyzeGit(gitTokens, { cwd: childCommand.cwd, envAssignments: childCommand.envAssignments, diff --git a/dist/index.js b/dist/index.js index da2a39a..56a019f 100644 --- a/dist/index.js +++ b/dist/index.js @@ -3124,7 +3124,7 @@ function analyzeXargs(tokens, context) { } if (childCommand.head === "git") { const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; - const hasDynamicReplacement = replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); + const hasDynamicReplacement = replacementToken !== null && (childTokens.some((token) => token.includes(replacementToken)) || Array.from(childCommand.envAssignments.values()).some((value) => value.includes(replacementToken))); const gitResult = analyzeGit(gitTokens, { cwd: childCommand.cwd, envAssignments: childCommand.envAssignments, diff --git a/src/core/analyze/xargs.ts b/src/core/analyze/xargs.ts index 5d87f3c..69118bf 100644 --- a/src/core/analyze/xargs.ts +++ b/src/core/analyze/xargs.ts @@ -66,7 +66,11 @@ export function analyzeXargs( const gitTokens = replacementToken === null ? [...childTokens, XARGS_APPENDED_INPUT] : childTokens; const hasDynamicReplacement = - replacementToken !== null && childTokens.some((token) => token.includes(replacementToken)); + replacementToken !== null && + (childTokens.some((token) => token.includes(replacementToken)) || + Array.from(childCommand.envAssignments.values()).some((value) => + value.includes(replacementToken), + )); const gitResult = analyzeGit(gitTokens, { cwd: childCommand.cwd, envAssignments: childCommand.envAssignments, diff --git a/tests/core/rules-git.test.ts b/tests/core/rules-git.test.ts index ab2e1d6..1f61a9f 100644 --- a/tests/core/rules-git.test.ts +++ b/tests/core/rules-git.test.ts @@ -1050,6 +1050,21 @@ describe('git linked worktree mode', () => { } }); + test('SAFETY_NET_WORKTREE fails closed on dynamic xargs git env assignments', () => { + const fixture = createLinkedWorktreeFixture(); + try { + withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { + assertBlocked( + 'echo ignored | xargs -I{} env EXTRA={} git reset --hard', + 'git reset --hard', + fixture.linkedWorktree, + ); + }); + } finally { + fixture.cleanup(); + } + }); + test('SAFETY_NET_WORKTREE fails closed on dynamic parallel git arguments', () => { const fixture = createLinkedWorktreeFixture(); try { From a038201dc8df79d5187a31bd82f5e417a5941a47 Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 22:05:33 +0900 Subject: [PATCH 11/12] test: make withTempDir and withLinkedWorktreeFixture async-safe Convert test helpers to properly await async callbacks before cleanup. This prevents race conditions where temp directories or worktrees are cleaned up before async operations complete. Adds dedicated tests validating cleanup waits for pending promises. --- tests/bin/doctor/config.test.ts | 28 +++++----- tests/bin/explain/command.test.ts | 60 ++++++++++----------- tests/core/analyze/analyze-coverage.test.ts | 27 ++++++---- tests/helpers.test.ts | 29 ++++++++++ tests/helpers.ts | 12 +++-- 5 files changed, 96 insertions(+), 60 deletions(-) create mode 100644 tests/helpers.test.ts diff --git a/tests/bin/doctor/config.test.ts b/tests/bin/doctor/config.test.ts index 9e5fa05..b381ed0 100644 --- a/tests/bin/doctor/config.test.ts +++ b/tests/bin/doctor/config.test.ts @@ -10,8 +10,8 @@ import { getConfigInfo } from '@/bin/doctor/config'; import { withTempDir } from '../../helpers.ts'; describe('getConfigInfo', () => { - test('handles missing config files', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('handles missing config files', async () => { + await withTempDir('doctor-test-', (tmpDir) => { const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(false); expect(info.effectiveRules).toEqual([]); @@ -19,8 +19,8 @@ describe('getConfigInfo', () => { }); }); - test('detects valid project config', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('detects valid project config', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync( join(tmpDir, '.safety-net.json'), JSON.stringify({ @@ -44,8 +44,8 @@ describe('getConfigInfo', () => { }); }); - test('detects invalid project config', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('detects invalid project config', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync(join(tmpDir, '.safety-net.json'), '{ "version": 2 }'); const info = getConfigInfo(tmpDir); expect(info.projectConfig.exists).toBe(true); @@ -54,8 +54,8 @@ describe('getConfigInfo', () => { }); }); - test('excludes rules from invalid config (wrong version)', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('excludes rules from invalid config (wrong version)', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync( join(tmpDir, '.safety-net.json'), JSON.stringify({ @@ -77,24 +77,24 @@ describe('getConfigInfo', () => { }); }); - test('handles malformed JSON in config', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('handles malformed JSON in config', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync(join(tmpDir, '.safety-net.json'), '{ invalid json }'); const info = getConfigInfo(tmpDir); expect(info.effectiveRules).toEqual([]); }); }); - test('handles empty config file', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('handles empty config file', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync(join(tmpDir, '.safety-net.json'), ' '); const info = getConfigInfo(tmpDir); expect(info.effectiveRules).toEqual([]); }); }); - test('handles config without rules array', () => { - withTempDir('doctor-test-', (tmpDir) => { + test('handles config without rules array', async () => { + await withTempDir('doctor-test-', (tmpDir) => { writeFileSync(join(tmpDir, '.safety-net.json'), '{ "version": 1 }'); const info = getConfigInfo(tmpDir); expect(info.effectiveRules).toEqual([]); diff --git a/tests/bin/explain/command.test.ts b/tests/bin/explain/command.test.ts index 3b8a02a..c71680b 100644 --- a/tests/bin/explain/command.test.ts +++ b/tests/bin/explain/command.test.ts @@ -34,8 +34,11 @@ function expectDangerousTextStep(command: string): void { ).toBeDefined(); } -function expectWorktreeExplainBlocked(command: (mainWorktree: string) => string, reason: string) { - withLinkedWorktreeFixture((fixture) => { +async function expectWorktreeExplainBlocked( + command: (mainWorktree: string) => string, + reason: string, +) { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = explainCommand(command(toShellPath(fixture.mainWorktree)), { cwd: fixture.linkedWorktree, @@ -394,22 +397,14 @@ describe('explainCommand shell wrapper edge cases', () => { describe('explainCommand max recursion depth', () => { test('deeply nested command hits max recursion', () => { - const deepNested = - 'bash -c "bash -c \\"bash -c \\\\\\"bash -c \\\\\\\\\\\\\\"bash -c \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"echo deep\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\\\\\\\\\\\\\\"\\\\\\"\\"" '; - const steps = getTraceSteps(explainCommand(deepNested)); - expect( - steps.filter((s) => s.type === 'recurse').length + - (recursionLimitErrorStep(deepNested) ? 1 : 0), - ).toBeGreaterThan(0); + const deepNested = nestedBashCommand('echo deep', 10); + expect(recursionLimitErrorStep(deepNested)).toBeTruthy(); }); test('hits exact max recursion depth of 5', () => { const level5 = 'bash -c "bash -c \\"bash -c \\\\\\"bash -c \\\\\\\\\\\\\\"bash -c \\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"echo hi\\\\\\\\\\\\\\\\\\\\\\\\\\\\\\"\\\\\\\\\\\\\\"\\\\\\"\\"" '; - const steps = getTraceSteps(explainCommand(level5)); - expect( - steps.filter((s) => s.type === 'recurse').length >= 3 || recursionLimitErrorStep(level5), - ).toBeTruthy(); + expect(recursionLimitErrorStep(level5)).toBeFalsy(); }); test('hits max recursion depth with 10 nested bash -c calls', () => { @@ -581,54 +576,57 @@ describe('explainCommand fallback scan with find', () => { }); describe('explainCommand worktree parity', () => { - test('uses wrapper cwd when explaining worktree relaxation', () => { - expectWorktreeExplainBlocked((main) => `env -C ${main} git reset --hard`, 'git reset --hard'); + test('uses wrapper cwd when explaining worktree relaxation', async () => { + await expectWorktreeExplainBlocked( + (main) => `env -C ${main} git reset --hard`, + 'git reset --hard', + ); }); - test('carries exported git context overrides into later segments', () => { - expectWorktreeExplainBlocked( + test('carries exported git context overrides into later segments', async () => { + await expectWorktreeExplainBlocked( (main) => `export GIT_WORK_TREE=${main}; git reset --hard`, 'git reset --hard', ); }); - test('passes wrapper cwd into recursive explain analysis', () => { - expectWorktreeExplainBlocked( + test('passes wrapper cwd into recursive explain analysis', async () => { + await expectWorktreeExplainBlocked( (main) => `env -C ${main} sh -c "git reset --hard"`, 'git reset --hard', ); }); - test('passes stripped env into recursive explain analysis', () => { - expectWorktreeExplainBlocked( + test('passes stripped env into recursive explain analysis', async () => { + await expectWorktreeExplainBlocked( (main) => `GIT_WORK_TREE=${main} sh -c "git reset --hard"`, 'git reset --hard', ); }); - test('carries nested exported git context overrides across inner segments', () => { - expectWorktreeExplainBlocked( + test('carries nested exported git context overrides across inner segments', async () => { + await expectWorktreeExplainBlocked( (main) => `sh -c "export GIT_WORK_TREE=${main}; git reset --hard"`, 'git reset --hard', ); }); - test('includes keyword-export git context overrides in current segment', () => { - expectWorktreeExplainBlocked( + test('includes keyword-export git context overrides in current segment', async () => { + await expectWorktreeExplainBlocked( (main) => `set -k; git restore file.txt GIT_WORK_TREE=${main}`, 'git restore', ); }); - test('includes nested keyword-export git context overrides in current segment', () => { - expectWorktreeExplainBlocked( + test('includes nested keyword-export git context overrides in current segment', async () => { + await expectWorktreeExplainBlocked( (main) => `sh -c "set -k; git restore file.txt GIT_WORK_TREE=${main}"`, 'git restore', ); }); - test('honors parallel nested overrides when explaining remote commands', () => { - withLinkedWorktreeFixture((fixture) => { + test('honors parallel nested overrides when explaining remote commands', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = explainCommand('parallel -S host sh -c "git reset --hard" ::: x', { cwd: fixture.linkedWorktree, @@ -640,8 +638,8 @@ describe('explainCommand worktree parity', () => { }); }); - test('does not report worktree relaxation for fallback embedded git', () => { - withLinkedWorktreeFixture((fixture) => { + test('does not report worktree relaxation for fallback embedded git', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = explainCommand('ssh host git clean -f', { cwd: fixture.linkedWorktree }); const worktreeStep = result.trace.segments diff --git a/tests/core/analyze/analyze-coverage.test.ts b/tests/core/analyze/analyze-coverage.test.ts index d71a1c4..931846a 100644 --- a/tests/core/analyze/analyze-coverage.test.ts +++ b/tests/core/analyze/analyze-coverage.test.ts @@ -11,7 +11,7 @@ import { const EMPTY_CONFIG: Config = { version: 1, rules: [] }; -function analyzeInLinkedWorktree(command: (mainWorktree: string) => string) { +async function analyzeInLinkedWorktree(command: (mainWorktree: string) => string) { return withLinkedWorktreeFixture((fixture) => withEnv({ SAFETY_NET_WORKTREE: '1' }, () => analyzeCommand(command(toShellPath(fixture.mainWorktree)), { @@ -180,15 +180,15 @@ describe('analyzeCommand (coverage)', () => { }); describe('shell git context env state branches', () => { - test('command -- export target is tracked across segments', () => { - const result = analyzeInLinkedWorktree( + test('command -- export target is tracked across segments', async () => { + const result = await analyzeInLinkedWorktree( (main) => `command -- export GIT_WORK_TREE=${main}; git reset --hard`, ); expect(result?.reason).toContain('git reset --hard'); }); - test('command inspection with no executable target leaves later git context unchanged', () => { - withLinkedWorktreeFixture((fixture) => { + test('command inspection with no executable target leaves later git context unchanged', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { expect( analyzeCommand( @@ -213,8 +213,8 @@ describe('analyzeCommand (coverage)', () => { }); }); - test('export option parsing tracks only valid export operands', () => { - withLinkedWorktreeFixture((fixture) => { + test('export option parsing tracks only valid export operands', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { expect( analyzeCommand( @@ -227,16 +227,21 @@ describe('analyzeCommand (coverage)', () => { ), ).toBeNull(); - const result = analyzeInLinkedWorktree( - (main) => `export -- GIT_WORK_TREE=${main}; git reset --hard`, + const result = analyzeCommand( + `export -- GIT_WORK_TREE=${toShellPath(fixture.mainWorktree)}; git reset --hard`, + { + cwd: fixture.linkedWorktree, + config: EMPTY_CONFIG, + worktreeMode: true, + }, ); expect(result?.reason).toContain('git reset --hard'); }); }); }); - test('exporting an unset tracked name uses an empty effective value', () => { - withLinkedWorktreeFixture((fixture) => { + test('exporting an unset tracked name uses an empty effective value', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { const result = analyzeCommand('export GIT_WORK_TREE; git reset --hard', { cwd: fixture.linkedWorktree, diff --git a/tests/helpers.test.ts b/tests/helpers.test.ts new file mode 100644 index 0000000..3232a5c --- /dev/null +++ b/tests/helpers.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from 'bun:test'; +import { existsSync } from 'node:fs'; +import { withLinkedWorktreeFixture, withTempDir } from './helpers.ts'; + +describe('test helpers', () => { + test('withTempDir waits for async callbacks before cleanup', async () => { + let tempDir = ''; + + await withTempDir('safety-net-helper-', async (dir) => { + tempDir = dir; + await Promise.resolve(); + expect(existsSync(dir)).toBe(true); + }); + + expect(existsSync(tempDir)).toBe(false); + }); + + test('withLinkedWorktreeFixture waits for async callbacks before cleanup', async () => { + let rootDir = ''; + + await withLinkedWorktreeFixture(async (fixture) => { + rootDir = fixture.rootDir; + await Promise.resolve(); + expect(existsSync(fixture.rootDir)).toBe(true); + }); + + expect(existsSync(rootDir)).toBe(false); + }); +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 44e8a99..be5c682 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -64,10 +64,11 @@ export function withEnv(env: Record, fn: () => T): T { } } -export function withTempDir(prefix: string, fn: (dir: string) => T): T { +export async function withTempDir(prefix: string, fn: (dir: string) => T | Promise) { const dir = mkdtempSync(join(tmpdir(), prefix)); try { - return fn(dir); + const result = await fn(dir); + return result; } finally { rmSync(dir, { recursive: true, force: true }); } @@ -202,10 +203,13 @@ export function createLinkedWorktreeFixture(): LinkedWorktreeFixture { }; } -export function withLinkedWorktreeFixture(fn: (fixture: LinkedWorktreeFixture) => T): T { +export async function withLinkedWorktreeFixture( + fn: (fixture: LinkedWorktreeFixture) => T | Promise, +) { const fixture = createLinkedWorktreeFixture(); try { - return fn(fixture); + const result = await fn(fixture); + return result; } finally { fixture.cleanup(); } From 4c684a8f308175c51b035bbca845fb81eb88c14c Mon Sep 17 00:00:00 2001 From: kenryu42 Date: Fri, 8 May 2026 22:05:39 +0900 Subject: [PATCH 12/12] refactor(tests): simplify test utilities and assertions - Use forEach instead of for...of in statusline test loop - Inline fetcher variable in system-info test - Simplify captureOutput to return both output and result via object destructuring, eliminating mutable let bindings in callers --- tests/bin/cli-statusline.test.ts | 4 +-- tests/bin/doctor/system-info.test.ts | 3 +- tests/bin/help.test.ts | 46 ++++++++++++---------------- 3 files changed, 23 insertions(+), 30 deletions(-) diff --git a/tests/bin/cli-statusline.test.ts b/tests/bin/cli-statusline.test.ts index 7d43e8a..cf455ad 100644 --- a/tests/bin/cli-statusline.test.ts +++ b/tests/bin/cli-statusline.test.ts @@ -102,14 +102,14 @@ describe('--statusline flag', () => { }, ]; - for (const mode of modes) { + modes.forEach((mode) => { test(`shows ${mode.name}`, async () => { await expectStatusline( { CLAUDE_SETTINGS_PATH: enabledSettingsPath, ...mode.env }, mode.output, ); }); - } + }); }); describe('--statusline enabled/disabled detection', () => { diff --git a/tests/bin/doctor/system-info.test.ts b/tests/bin/doctor/system-info.test.ts index 5550678..933336b 100644 --- a/tests/bin/doctor/system-info.test.ts +++ b/tests/bin/doctor/system-info.test.ts @@ -97,8 +97,7 @@ describe('getSystemInfo', () => { test('starts both copilot version probes immediately and prefers --binary-version', async () => { const probes = createCopilotDeferredFetcher(); - const fetcher = probes.fetcher; - const sysInfoPromise = getSystemInfo(fetcher); + const sysInfoPromise = getSystemInfo(probes.fetcher); await Promise.resolve(); expectCopilotVersionProbesStarted(probes.calls); diff --git a/tests/bin/help.test.ts b/tests/bin/help.test.ts index d754f3f..76ee84d 100644 --- a/tests/bin/help.test.ts +++ b/tests/bin/help.test.ts @@ -5,34 +5,34 @@ import { printCommandHelp, printHelp, printVersion, showCommandHelp } from '@/bi /** * Capture console.log output during a function call. */ -function captureOutput(fn: () => void): string { +function captureOutput(fn: () => T) { const originalLog = console.log; let output = ''; console.log = (...args: unknown[]) => { output += `${args.map(String).join(' ')}\n`; }; try { - fn(); + const result = fn(); + return { output, result }; } finally { console.log = originalLog; } - return output; } describe('help output', () => { describe('printHelp (main help)', () => { test('contains version header', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('cc-safety-net v'); }); test('contains description', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('Blocks destructive git and filesystem commands'); }); test('lists all visible commands', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('doctor'); expect(output).toContain('explain'); expect(output).toContain('claude-code'); @@ -40,26 +40,26 @@ describe('help output', () => { }); test('contains COMMANDS section', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('COMMANDS:'); }); test('contains GLOBAL OPTIONS section', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('GLOBAL OPTIONS:'); expect(output).toContain('--help'); expect(output).toContain('--version'); }); test('contains HELP section with usage hints', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('HELP:'); expect(output).toContain('help '); expect(output).toContain(' --help'); }); test('contains ENVIRONMENT VARIABLES section', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('ENVIRONMENT VARIABLES:'); expect(output).toContain('SAFETY_NET_STRICT'); expect(output).toContain('SAFETY_NET_PARANOID'); @@ -67,7 +67,7 @@ describe('help output', () => { }); test('contains CONFIG FILES section', () => { - const output = captureOutput(() => printHelp()); + const { output } = captureOutput(() => printHelp()); expect(output).toContain('CONFIG FILES:'); expect(output).toContain('.safety-net.json'); }); @@ -75,7 +75,7 @@ describe('help output', () => { describe('printVersion', () => { test('prints version string', () => { - const output = captureOutput(() => printVersion()); + const { output } = captureOutput(() => printVersion()); // Version is either "dev" or a semver string expect(output.trim()).toMatch(/^(dev|\d+\.\d+\.\d+.*)$/); }); @@ -85,21 +85,21 @@ describe('help output', () => { test('prints command name', () => { const cmd = findCommand('doctor'); if (!cmd) throw new Error('doctor command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('cc-safety-net doctor'); }); test('prints description', () => { const cmd = findCommand('doctor'); if (!cmd) throw new Error('doctor command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('Run diagnostic checks'); }); test('prints USAGE section', () => { const cmd = findCommand('doctor'); if (!cmd) throw new Error('doctor command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('USAGE:'); expect(output).toContain('doctor [options]'); }); @@ -107,7 +107,7 @@ describe('help output', () => { test('prints OPTIONS section', () => { const cmd = findCommand('doctor'); if (!cmd) throw new Error('doctor command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('OPTIONS:'); expect(output).toContain('--json'); expect(output).toContain('--skip-update-check'); @@ -116,7 +116,7 @@ describe('help output', () => { test('prints EXAMPLES section when available', () => { const cmd = findCommand('doctor'); if (!cmd) throw new Error('doctor command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('EXAMPLES:'); expect(output).toContain('cc-safety-net doctor'); }); @@ -124,7 +124,7 @@ describe('help output', () => { test('explain command shows --cwd option with argument', () => { const cmd = findCommand('explain'); if (!cmd) throw new Error('explain command not found'); - const output = captureOutput(() => printCommandHelp(cmd)); + const { output } = captureOutput(() => printCommandHelp(cmd)); expect(output).toContain('--cwd'); expect(output).toContain(''); }); @@ -132,20 +132,14 @@ describe('help output', () => { describe('showCommandHelp', () => { test('returns true and prints help for valid command', () => { - let result = false; - const output = captureOutput(() => { - result = showCommandHelp('doctor'); - }); + const { output, result } = captureOutput(() => showCommandHelp('doctor')); expect(result).toBe(true); expect(output).toContain('cc-safety-net doctor'); }); test('returns true for alias', () => { - let result = false; - const output = captureOutput(() => { - result = showCommandHelp('-cc'); - }); + const { output, result } = captureOutput(() => showCommandHelp('-cc')); expect(result).toBe(true); expect(output).toContain('cc-safety-net claude-code');