From 0802675379046652e52d23df1cf1a5b82d62d008 Mon Sep 17 00:00:00 2001 From: x-Spartacus Date: Thu, 3 Sep 2026 02:10:07 +0300 Subject: [PATCH] Keep cleanup retention under global configuration Prevent project config from changing automatic cleanup settings. Add validation and regression tests. --- src/config.ts | 17 ++++++++++--- tests/config-resolution.test.ts | 42 +++++++++++++++++++++++++++++++++ tests/config.test.ts | 10 ++++++++ 3 files changed, 66 insertions(+), 3 deletions(-) diff --git a/src/config.ts b/src/config.ts index b6784ad1..b2c9d0fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -600,6 +600,13 @@ export function normalizeAutoCaptureMaxContextBytes(value: number): number { return value; } +export function normalizeAutoCleanupRetentionDays(value: number): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`Invalid autoCleanupRetentionDays config: ${value}`); + } + return value; +} + function buildConfig(fileConfig: OpenCodeMemConfig) { const memoryApiKey = resolveSecretValue(fileConfig.memoryApiKey); const embeddingDimensions = @@ -676,8 +683,9 @@ function buildConfig(fileConfig: OpenCodeMemConfig) { : undefined, maxVectorsPerShard: fileConfig.maxVectorsPerShard ?? DEFAULTS.maxVectorsPerShard, autoCleanupEnabled: fileConfig.autoCleanupEnabled ?? DEFAULTS.autoCleanupEnabled, - autoCleanupRetentionDays: - fileConfig.autoCleanupRetentionDays ?? DEFAULTS.autoCleanupRetentionDays, + autoCleanupRetentionDays: normalizeAutoCleanupRetentionDays( + fileConfig.autoCleanupRetentionDays ?? DEFAULTS.autoCleanupRetentionDays + ), deduplicationEnabled: fileConfig.deduplicationEnabled ?? DEFAULTS.deduplicationEnabled, deduplicationSimilarityThreshold: fileConfig.deduplicationSimilarityThreshold ?? DEFAULTS.deduplicationSimilarityThreshold, @@ -823,7 +831,10 @@ export function initConfig(directory: string): void { ]; const globalConfig = loadConfigFromPaths(CONFIG_FILES); const projectConfig = loadConfigFromPaths(projectPaths); - const merged: OpenCodeMemConfig = { ...globalConfig, ...projectConfig }; + const projectOverrides = { ...projectConfig }; + delete projectOverrides.autoCleanupEnabled; + delete projectOverrides.autoCleanupRetentionDays; + const merged: OpenCodeMemConfig = { ...globalConfig, ...projectOverrides }; CONFIG = buildConfig(merged); } diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index b944685a..485ef5d6 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -47,6 +47,48 @@ describe("project-scoped config resolution", () => { expect(CONFIG.opencodeModel).toBe("project-model"); }); + it("keeps automatic cleanup policy under global configuration", () => { + existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); + readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { + const path = normalizePath(p); + if (path.includes(".opencode/opencode-mem")) { + return JSON.stringify({ + opencodeModel: "project-model", + autoCleanupEnabled: true, + autoCleanupRetentionDays: 0, + }) as any; + } + return JSON.stringify({ + opencodeModel: "global-model", + autoCleanupEnabled: false, + autoCleanupRetentionDays: 90, + }) as any; + }); + + initConfig("/my/project"); + + expect(CONFIG.opencodeModel).toBe("project-model"); + expect(CONFIG.autoCleanupEnabled).toBe(false); + expect(CONFIG.autoCleanupRetentionDays).toBe(90); + }); + + it("uses safe cleanup defaults when only project cleanup settings exist", () => { + existsSpy = spyOn(fs, "existsSync").mockImplementation((p) => + normalizePath(p).includes("/my/project/.opencode/opencode-mem") + ); + readSpy = spyOn(fs, "readFileSync").mockReturnValue( + JSON.stringify({ + autoCleanupEnabled: true, + autoCleanupRetentionDays: -1, + }) + ); + + initConfig("/my/project"); + + expect(CONFIG.autoCleanupEnabled).toBe(true); + expect(CONFIG.autoCleanupRetentionDays).toBe(30); + }); + it("shallow merge: project adds fields, global fields preserved when not overridden", () => { existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { diff --git a/tests/config.test.ts b/tests/config.test.ts index 3438710a..f1304a29 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -15,6 +15,7 @@ const { hasAutoCaptureProviderConfig, isConfigured, isPlaceholderApiKey, + normalizeAutoCleanupRetentionDays, normalizeAutoCaptureMaxContextBytes, } = await import("../src/config.js"); @@ -99,6 +100,15 @@ describe("config", () => { expect(() => normalizeAutoCaptureMaxContextBytes(16 * 1024 * 1024 + 1)).toThrow(); }); + it("should reject unsafe automatic cleanup retention periods", () => { + expect(() => normalizeAutoCleanupRetentionDays(0)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(-1)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(1.5)).toThrow(); + expect(() => normalizeAutoCleanupRetentionDays(Number.POSITIVE_INFINITY)).toThrow(); + expect(normalizeAutoCleanupRetentionDays(1)).toBe(1); + expect(normalizeAutoCleanupRetentionDays(30)).toBe(30); + }); + it("should expose memory scope config", () => { const defaultScope = CONFIG.memory.defaultScope ?? "project"; expect(["project", "all-projects"]).toContain(defaultScope);