diff --git a/CHANGELOG.md b/CHANGELOG.md index 59a2224..1bc00af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ All notable changes to this project are documented here, following ### Fixed +- Reject non-finite and negative numeric CLI options instead of scanning with + invalid thresholds or silently ignoring invalid waste gates. - Reject unknown `--targets` values with a clear error instead of silently skipping all ignore-file writes. diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..f51e515 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,19 @@ +{ + "name": "ctxtrim", + "version": "0.1.3", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ctxtrim", + "version": "0.1.3", + "license": "MIT", + "bin": { + "ctxtrim": "bin/ctxtrim.js" + }, + "engines": { + "node": ">=18" + } + } + } +} diff --git a/src/cli.js b/src/cli.js index 200b096..c357b08 100644 --- a/src/cli.js +++ b/src/cli.js @@ -37,26 +37,41 @@ function parse(argv) { for (let i = 0; i < argv.length; i++) { const a = argv[i]; const val = () => (a.includes("=") ? a.split("=")[1] : argv[++i]); + const has = (name) => a === name || a.startsWith(`${name}=`); if (a === "-h" || a === "--help") o.help = true; else if (a === "-v" || a === "--version") o.version = true; else if (a === "--write") o.write = true; - else if (a.startsWith("--targets")) o.targets = val(); - else if (a.startsWith("--price")) o.price = Number(val()); - else if (a.startsWith("--max-tokens")) o.maxTokens = Number(val()); - else if (a.startsWith("--top")) o.top = Number(val()); - else if (a.startsWith("--format")) o.format = val(); - else if (a.startsWith("--fail-on-waste")) o.failOnWaste = Number(val()); + else if (has("--targets")) o.targets = val(); + else if (has("--price")) o.price = Number(val()); + else if (has("--max-tokens")) o.maxTokens = Number(val()); + else if (has("--top")) o.top = Number(val()); + else if (has("--format")) o.format = val(); + else if (has("--fail-on-waste")) o.failOnWaste = Number(val()); else if (!a.startsWith("-")) o.path = a; } return o; } +function numericOptionError(o) { + if (!Number.isFinite(o.price) || o.price < 0) + return "ctxtrim: invalid --price: expected a non-negative number\n"; + if (!Number.isFinite(o.maxTokens) || o.maxTokens < 0) + return "ctxtrim: invalid --max-tokens: expected a non-negative number\n"; + if (!Number.isInteger(o.top) || o.top < 0) + return "ctxtrim: invalid --top: expected a non-negative integer\n"; + if (o.failOnWaste != null && (!Number.isFinite(o.failOnWaste) || o.failOnWaste < 0 || o.failOnWaste > 100)) + return "ctxtrim: invalid --fail-on-waste: expected a percentage from 0 to 100\n"; + return null; +} + export function run(argv, { version }) { const o = parse(argv); if (o.help) { process.stdout.write(HELP); return 0; } if (o.version) { process.stdout.write(version + "\n"); return 0; } const target = o.path || "."; if (!existsSync(target)) { process.stderr.write(`ctxtrim: path not found: ${target}\n`); return 2; } + const numberError = numericOptionError(o); + if (numberError) { process.stderr.write(numberError); return 2; } if (!["text", "json"].includes(o.format)) { process.stderr.write(`ctxtrim: unknown --format\n`); return 2; } const targets = o.targets.split(",").map((s) => s.trim()).filter(Boolean); const unknownTargets = targets.filter((s) => !TARGETS[s]); diff --git a/test/ctxtrim.test.js b/test/ctxtrim.test.js index 6afc630..b03da1b 100644 --- a/test/ctxtrim.test.js +++ b/test/ctxtrim.test.js @@ -1,5 +1,4 @@ -import fs, { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; -import { existsSync } from "node:fs"; +import fs, { existsSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; @@ -137,6 +136,23 @@ test("clean repo (only source) reports nothing to trim", () => { assert.equal(s.totals.trimTokens, 0); }); +test("CLI rejects non-finite and negative numeric options", () => { + const invalid = [ + ["--max-tokens", "abc"], + ["--max-tokens", "-5"], + ["--price", "-3"], + ["--top", "-1"], + ["--fail-on-waste", "abc"], + ["--fail-on-waste", "-1"], + ]; + + for (const args of invalid) { + const result = spawnSync(process.execPath, [cli, repo, ...args], { encoding: "utf8" }); + assert.equal(result.status, 2, `${args.join(" ")} should fail with exit 2\n${result.stderr}`); + assert.match(result.stderr, /ctxtrim: invalid --/); + } +}); + test("unknown --targets values fail before writing ignore files", (t) => { const root = mkdtempSync(join(tmpdir(), "ctxtrim-invalid-target-")); t.after(() => rmSync(root, { recursive: true, force: true }));