From 9fd0256193ca5ccb9d4848a2d12917ad17e8cd21 Mon Sep 17 00:00:00 2001 From: Yurii Bakurov <45154988+Yurii201811@users.noreply.github.com> Date: Wed, 23 Sep 2026 08:17:25 +0200 Subject: [PATCH] fix: report ignore-file write errors cleanly --- CHANGELOG.md | 7 +++++++ README.md | 2 ++ package-lock.json | 12 ++---------- package.json | 2 +- src/cli.js | 4 +++- test/write-errors.test.js | 40 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 55 insertions(+), 12 deletions(-) create mode 100644 test/write-errors.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 966d236..3d5a00b 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.17] - 2026-09-23 + +### Fixed + +- Report ignore-file write failures with a concise error and exit code 2 instead of an uncaught stack trace. +- Repair unresolved merge markers in the package lockfile while updating release metadata. + ## [0.1.16] - 2026-09-17 ### Fixed diff --git a/README.md b/README.md index 89e3050..fccc336 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ errors (exit code 2), detected before scanning or writing files. **Ignore files it writes** (all real formats the tools honor): `.cursorignore` (Cursor), `.aiexclude` (Gemini Code Assist / Firebase Studio), `.aiignore` (generic). Writes are **idempotent** — a managed block between markers, so your own rules are preserved and re-runs just update the block. +If an ignore file cannot be read or written, the command prints a concise error and exits with code 2 without reporting success. Targets written before a later failure remain updated; writes are not rolled back. + Repeated names in `--targets` are ignored; each ignore file is written and reported once, in the order first requested. ## What it is (and isn't) diff --git a/package-lock.json b/package-lock.json index 20a1a7d..4f11004 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,12 @@ { "name": "ctxtrim", -<<<<<<< HEAD - "version": "0.1.16", -======= - "version": "0.1.15", ->>>>>>> origin/main + "version": "0.1.17", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "ctxtrim", -<<<<<<< HEAD - "version": "0.1.16", -======= - "version": "0.1.15", ->>>>>>> origin/main + "version": "0.1.17", "license": "MIT", "bin": { "ctxtrim": "bin/ctxtrim.js" diff --git a/package.json b/package.json index 8f855ca..726111b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ctxtrim", - "version": "0.1.16", + "version": "0.1.17", "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 90eb5e6..e002b67 100644 --- a/src/cli.js +++ b/src/cli.js @@ -89,7 +89,9 @@ export function run(argv, { version }) { try { scan = scanRepo(target, { maxTokens: o.maxTokens }); } catch (error) { process.stderr.write(`ctxtrim: ${error.message}\n`); return 2; } let wrote = null; - if (o.write && scan.patterns.length) wrote = writeIgnores(scan.root, scan.patterns, targets); + try { + if (o.write && scan.patterns.length) wrote = writeIgnores(scan.root, scan.patterns, targets); + } catch (error) { process.stderr.write(`ctxtrim: ${error.message}\n`); return 2; } if (o.format === "json") process.stdout.write(jsonReport(scan, { price: o.price, wrote }) + "\n"); else process.stdout.write(textReport(scan, { price: o.price, top: o.top, wrote })); diff --git a/test/write-errors.test.js b/test/write-errors.test.js new file mode 100644 index 0000000..4470688 --- /dev/null +++ b/test/write-errors.test.js @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import { syncBuiltinESMExports } from "node:module"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { run } from "../src/cli.js"; + +for (const format of ["text", "json"]) { + for (const failTarget of [".cursorignore", ".aiexclude"]) { + test(`CLI reports write failure cleanly (${format}, ${failTarget})`, (t) => { + const root = fs.mkdtempSync(join(tmpdir(), "ctxtrim-write-error-")); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + fs.writeFileSync(join(root, "package-lock.json"), "{}\n"); + const originalWrite = fs.writeFileSync; + let stdout = "", stderr = ""; + t.mock.method(process.stdout, "write", (chunk) => { stdout += chunk; return true; }); + t.mock.method(process.stderr, "write", (chunk) => { stderr += chunk; return true; }); + t.mock.method(fs, "writeFileSync", (file, ...args) => { + if (file === join(root, failTarget)) { + const error = new Error(`EACCES: permission denied, open '${file}'`); + error.code = "EACCES"; + throw error; + } + return originalWrite(file, ...args); + }); + syncBuiltinESMExports(); + let code; + try { code = run([root, "--write", "--format", format], { version: "test" }); } + finally { t.mock.restoreAll(); syncBuiltinESMExports(); } + + assert.equal(code, 2); + assert.equal(stdout, ""); + assert.equal(stderr, `ctxtrim: EACCES: permission denied, open '${join(root, failTarget)}'\n`); + assert.equal(fs.existsSync(join(root, failTarget)), false); + // Earlier targets can already have been written; error handling is not rollback. + assert.equal(fs.existsSync(join(root, ".cursorignore")), failTarget === ".aiexclude"); + }); + } +}