From 5de66ff201816abca014274d9901b1852afc793b Mon Sep 17 00:00:00 2001 From: mikemikimike <13286568797@163.com> Date: Sun, 6 Sep 2026 21:05:39 +0800 Subject: [PATCH] fix: validate numeric CLI options (#8) --- CHANGELOG.md | 7 +++++++ package.json | 2 +- src/cli.js | 27 +++++++++++++++++++++------ test/ctxtrim.test.js | 19 +++++++++++++++++++ 4 files changed, 48 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 659ae1c..cb49b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,13 @@ All notable changes to this project are documented here, following [Keep a Changelog](https://keepachangelog.com/) and semantic versioning. +## [0.1.3] - 2026-09-06 + +### Fixed + +- Reject non-finite and negative numeric CLI options instead of scanning with + invalid thresholds or silently ignoring invalid waste gates. + ## [0.1.2] - 2026-08-09 ### Fixed diff --git a/package.json b/package.json index eba3b63..ec62996 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ctxtrim", - "version": "0.1.2", + "version": "0.1.3", "description": "Trim what bloats your AI coding context. Scan a repo, find the high-cost/low-value files ballooning your Claude Code / Cursor / Codex context, and write ignore files to cut token cost. Zero dependencies.", "type": "module", "bin": { diff --git a/src/cli.js b/src/cli.js index db5f35f..42f788a 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((s) => TARGETS[s]); diff --git a/test/ctxtrim.test.js b/test/ctxtrim.test.js index 72793df..d738f2f 100644 --- a/test/ctxtrim.test.js +++ b/test/ctxtrim.test.js @@ -1,4 +1,5 @@ import fs, { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { syncBuiltinESMExports } from "node:module"; import { tmpdir } from "node:os"; import { test } from "node:test"; @@ -10,6 +11,7 @@ import { classify, classifyPath } from "../src/classify.js"; import { merge, block } from "../src/ignore.js"; const repo = join(dirname(fileURLToPath(import.meta.url)), "fixtures", "sample-repo"); +const bin = fileURLToPath(new URL("../bin/ctxtrim.js", import.meta.url)); test("token estimate is ~chars/4", () => { assert.equal(estimateTokens("aaaaaaaa"), 2); // 8 chars @@ -133,3 +135,20 @@ test("clean repo (only source) reports nothing to trim", () => { const s = scanRepo(join(repo, "src")); 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, [bin, 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 --/); + } +});