diff --git a/package.json b/package.json index 0ce9daa..a61bbc6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "kcode", - "version": "2.10.82", + "version": "2.10.83", "description": "AI-powered coding assistant for the terminal - by Astrolexis", "author": "Astrolexis", "module": "src/index.ts", diff --git a/src/core/file-watcher.test.ts b/src/core/file-watcher.test.ts new file mode 100644 index 0000000..1f25358 --- /dev/null +++ b/src/core/file-watcher.test.ts @@ -0,0 +1,64 @@ +// Regression test for the v2.10.82 forensic audit finding about +// unbounded memory growth in FileChangeSuggester.suggestions. The +// pending buffer was already capped at 500; suggestions was not. +// Under high-churn scenarios (node_modules reinstall, git checkout +// between large branches) with a slow consumer, the array could +// grow without bound. +// +// This test does NOT cover FileWatcher's fs-backed behavior because +// that requires a real filesystem and is out of scope for a unit +// test. The suggester is pure in-memory logic. + +import { describe, expect, test } from "bun:test"; +import { FileChangeSuggester } from "./file-watcher"; +import type { FileChangeEvent } from "./file-watcher"; + +function makeChanges(n: number, baseName: string): FileChangeEvent[] { + const events: FileChangeEvent[] = []; + for (let i = 0; i < n; i++) { + events.push({ + type: "modify", + path: `/project/src/${baseName}-${i}.test.ts`, + timestamp: Date.now(), + }); + } + return events; +} + +describe("FileChangeSuggester memory caps", () => { + test("suggestions buffer is capped to 200 after repeated pushes", async () => { + const suggester = new FileChangeSuggester(); + + // 300 distinct test-file changes → ~300 unique suggestions + // (each uses the file path in the message so they dedupe per + // batch but are distinct across batches). + for (let batch = 0; batch < 6; batch++) { + suggester.addChanges(makeChanges(50, `b${batch}`)); + // Wait out the debounce so the internal process() runs and + // flushes the batch into suggestions. + await new Promise((r) => setTimeout(r, 600)); + } + + // suggestions should be capped even though we never drained it + const drained = suggester.getSuggestions(); + expect(drained.length).toBeLessThanOrEqual(200); + }); + + test("drained suggestions are cleared so subsequent batches start fresh", async () => { + const suggester = new FileChangeSuggester(); + suggester.addChanges(makeChanges(10, "a")); + await new Promise((r) => setTimeout(r, 600)); + const first = suggester.getSuggestions(); + expect(first.length).toBeGreaterThan(0); + // After draining, hasSuggestions should be false + expect(suggester.hasSuggestions).toBe(false); + }); + + test("clear() resets both pending and suggestions", async () => { + const suggester = new FileChangeSuggester(); + suggester.addChanges(makeChanges(10, "a")); + suggester.clear(); + await new Promise((r) => setTimeout(r, 600)); + expect(suggester.hasSuggestions).toBe(false); + }); +}); diff --git a/src/core/file-watcher.ts b/src/core/file-watcher.ts index a6e5338..80f4074 100644 --- a/src/core/file-watcher.ts +++ b/src/core/file-watcher.ts @@ -375,6 +375,16 @@ export class FileChangeSuggester { if (newSuggestions.length > 0) { this.suggestions.push(...newSuggestions); + // Cap the suggestions buffer. Without this, if nothing ever + // drains via getSuggestions (e.g. the callback is broken or + // the UI stops consuming them), the array grows unbounded + // under high-churn scenarios like node_modules reinstalls. + // v2.10.82 audit flagged this as a memory-pressure risk — + // keeping the most recent 200 is more than the UI can show + // anyway. + if (this.suggestions.length > 200) { + this.suggestions = this.suggestions.slice(-200); + } if (this.onSuggestion) { try { this.onSuggestion(newSuggestions);