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/dist/bin/cc-safety-net.js b/dist/bin/cc-safety-net.js index 6ce2a8a..a291ae1 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 hasDynamicReplacement = replacementToken !== null && (childTokens.some((token) => token.includes(replacementToken)) || Array.from(childCommand.envAssignments.values()).some((value) => value.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..56a019f 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 hasDynamicReplacement = replacementToken !== null && (childTokens.some((token) => token.includes(replacementToken)) || Array.from(childCommand.envAssignments.values()).some((value) => value.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) { diff --git a/package.json b/package.json index e0db91b..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", @@ -49,6 +50,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/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 }); } /** 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); } 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) { 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..69118bf 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,25 @@ 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)); + replacementToken !== null && + (childTokens.some((token) => token.includes(replacementToken)) || + Array.from(childCommand.envAssignments.values()).some((value) => + value.includes(replacementToken), + )); const gitResult = analyzeGit(gitTokens, { - cwd: childCwd, - envAssignments: childEnvAssignments, + cwd: childCommand.cwd, + envAssignments: childCommand.envAssignments, worktreeMode: replacementToken === null || hasDynamicReplacement ? false : context.worktreeMode, }); 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)) { diff --git a/tests/bin/cli-statusline.test.ts b/tests/bin/cli-statusline.test.ts index a1c221f..cf455ad 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,202 +48,67 @@ 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', + 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: '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); - }); + output: '🛡️ Safety Net 🔒👁️', + }, + ]; - // 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', - }, + modes.forEach((mode) => { + test(`shows ${mode.name}`, async () => { + await expectStatusline( + { CLAUDE_SETTINGS_PATH: enabledSettingsPath, ...mode.env }, + mode.output, + ); }); - - 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', - 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); }); }); @@ -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..b381ed0 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 { + 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([]); 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 { + test('detects valid project config', async () => { + await 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 { + 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); 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 { + test('excludes rules from invalid config (wrong version)', async () => { + await 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 { + test('handles malformed JSON in config', async () => { + await 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 { + 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([]); - } 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 { + 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([]); - } 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..933336b 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,14 @@ 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 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); - 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 +123,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..c71680b 100644 --- a/tests/bin/explain/command.test.ts +++ b/tests/bin/explain/command.test.ts @@ -11,7 +11,61 @@ 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(); +} + +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, + }); + 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 +94,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 +127,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 +155,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 +170,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 +180,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 +189,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 +206,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 +216,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 +334,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 +347,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 +358,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 +376,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 +384,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(); }); @@ -349,61 +397,26 @@ 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 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 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 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(); + expect(recursionLimitErrorStep(level5)).toBeFalsy(); }); 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 +445,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 +455,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 +477,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 +495,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 +567,66 @@ 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(); - } + 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', () => { - 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(); - } + 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', () => { - 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(); - } + 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', () => { - 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(); - } + 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', () => { - 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(); - } + 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', () => { - 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(); - } + 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', () => { - 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(); - } + 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', () => { - const fixture = createLinkedWorktreeFixture(); - try { + 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, @@ -716,14 +635,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 { + 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 @@ -734,9 +650,7 @@ describe('explainCommand worktree parity', () => { expect(result.reason).toContain('git clean -f'); expect(worktreeStep).toBeUndefined(); }); - } finally { - fixture.cleanup(); - } + }); }); }); @@ -754,12 +668,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 +689,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 +711,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 +747,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 +755,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 +765,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 +782,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..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,30 +132,14 @@ 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; + const { output, result } = captureOutput(() => 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; + const { output, result } = captureOutput(() => 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..093d110 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,40 @@ 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 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 +82,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..931846a 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: [] }; +async 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. @@ -163,28 +180,15 @@ 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(); - } + 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', () => { - const fixture = createLinkedWorktreeFixture(); - try { + test('command inspection with no executable target leaves later git context unchanged', async () => { + await 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 { + test('export option parsing tracks only valid export operands', async () => { + await withLinkedWorktreeFixture((fixture) => { withEnv({ SAFETY_NET_WORKTREE: '1' }, () => { expect( analyzeCommand( @@ -236,14 +237,11 @@ describe('analyzeCommand (coverage)', () => { ); 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 { + 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, @@ -252,9 +250,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/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 { 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 128697d..be5c682 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,61 @@ export function withEnv(env: Record, fn: () => T): T { } } +export async function withTempDir(prefix: string, fn: (dir: string) => T | Promise) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + try { + const result = await fn(dir); + return result; + } 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 +203,18 @@ export function createLinkedWorktreeFixture(): LinkedWorktreeFixture { }; } +export async function withLinkedWorktreeFixture( + fn: (fixture: LinkedWorktreeFixture) => T | Promise, +) { + const fixture = createLinkedWorktreeFixture(); + try { + const result = await fn(fixture); + return result; + } 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([]); });