From 547ce9ecd1d3fbfa5b2cc19a73ffb64dd5e512e3 Mon Sep 17 00:00:00 2001 From: MimiTechSolutions Date: Tue, 25 Aug 2026 03:52:00 +0000 Subject: [PATCH 1/3] perf(core): memoize Stellar CLI version checks and feature probes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Memoize checkStellarCliVersion() results for the process lifetime, cache probeMissingStellarCliFeatures() results keyed by version, and make checkBinary() pass skipStellarVersionCheck: true for stellar to avoid redundant subprocess invocations. Before this change every Stellar command triggered the full version check + 3 feature probes at least twice (once from checkBinary and once from the runCommand gate). Now they run only once per process. Closes #143 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- README.md | 8 +- packages/cli/src/commands/identity.command.ts | 5 +- .../build-generate-network-args.test.ts | 6 +- .../contracts/estimate-deploy-cost.test.ts | 8 +- .../src/contracts/estimate-deploy-cost.ts | 7 +- packages/core/src/contracts/wasm.test.ts | 6 +- packages/core/src/shell/check-binary.test.ts | 49 ++++++++- packages/core/src/shell/check-binary.ts | 4 +- .../check-stellar-cli-version.test.ts | 99 ++++++++++++++++++- .../stellar-cli/check-stellar-cli-version.ts | 19 ++++ .../probe-stellar-cli-features.test.ts | 65 +++++++++++- .../stellar-cli/probe-stellar-cli-features.ts | 12 ++- 12 files changed, 259 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index acf7b175..2276cd13 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,11 @@ my-dapp/ ## Packages -| Package | Role | -| ------------------ | ------------------------------------------------------------------------------------------------------- | +| Package | Role | +| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `@caatinga/cli` | `caatinga` / `ctg` command — init, build, deploy, upgrade, dev, doctor, generate, invoke, read, status, migrate, rollback, estimate, inspect, wire, sync-env, smoke, regression, ci, identity, zk, version | -| `@caatinga/core` | Config, shell orchestration, Stellar CLI adapters, error catalog | -| `@caatinga/client` | Browser/Node contract client, wallet adapters, React hooks | +| `@caatinga/core` | Config, shell orchestration, Stellar CLI adapters, error catalog | +| `@caatinga/client` | Browser/Node contract client, wallet adapters, React hooks | Full export map: [Packages](./docs/packages.md). Public errors use stable `CAATINGA_*` codes — see [Errors](./docs/errors.md). diff --git a/packages/cli/src/commands/identity.command.ts b/packages/cli/src/commands/identity.command.ts index cbf3071c..f3bdc84c 100644 --- a/packages/cli/src/commands/identity.command.ts +++ b/packages/cli/src/commands/identity.command.ts @@ -44,10 +44,7 @@ async function assertNoPathTraversal(archiveFile: string, targetDir: string): Pr } const resolvedEntry = path.resolve(resolvedTarget, entry); - if ( - resolvedEntry !== resolvedTarget && - !resolvedEntry.startsWith(resolvedTarget + path.sep) - ) { + if (resolvedEntry !== resolvedTarget && !resolvedEntry.startsWith(resolvedTarget + path.sep)) { throw new Error( `Refusing to import archive: entry "${entry}" would extract outside ${resolvedTarget}` ); diff --git a/packages/core/src/contracts/build-generate-network-args.test.ts b/packages/core/src/contracts/build-generate-network-args.test.ts index 8262c8c8..82b61511 100644 --- a/packages/core/src/contracts/build-generate-network-args.test.ts +++ b/packages/core/src/contracts/build-generate-network-args.test.ts @@ -46,11 +46,7 @@ describe("buildGenerateNetworkArgs", () => { it("defaults an unrecognized passphrase to --network localnet and adds --allow-http for http RPCs", () => { expect( buildGenerateNetworkArgs( - network( - "local", - "http://localhost:8000/soroban/rpc", - "Standalone Network ; February 2017" - ) + network("local", "http://localhost:8000/soroban/rpc", "Standalone Network ; February 2017") ) ).toEqual([ "--network", diff --git a/packages/core/src/contracts/estimate-deploy-cost.test.ts b/packages/core/src/contracts/estimate-deploy-cost.test.ts index fe707971..4896f315 100644 --- a/packages/core/src/contracts/estimate-deploy-cost.test.ts +++ b/packages/core/src/contracts/estimate-deploy-cost.test.ts @@ -96,10 +96,12 @@ describe("estimateDeployCost", () => { }); it("should_throw_ESTIMATE_FAILED_when_build_only_fails", async () => { - const original = new CaatingaError("build failed", CaatingaErrorCode.ESTIMATE_FAILED, "fix wasm"); - runCommand.mockRejectedValue( - original + const original = new CaatingaError( + "build failed", + CaatingaErrorCode.ESTIMATE_FAILED, + "fix wasm" ); + runCommand.mockRejectedValue(original); await expect( estimateDeployCost({ diff --git a/packages/core/src/contracts/estimate-deploy-cost.ts b/packages/core/src/contracts/estimate-deploy-cost.ts index 76e63cec..400af1b4 100644 --- a/packages/core/src/contracts/estimate-deploy-cost.ts +++ b/packages/core/src/contracts/estimate-deploy-cost.ts @@ -148,10 +148,9 @@ export async function estimateDeployCost( resourceFeeStroops, totalFeeStroops, simulation, - advisory: - simulation.ok - ? "Advisory estimate only — actual fees may differ under network congestion or contract complexity." - : "Fee estimate unavailable — simulation did not produce a parseable inclusion fee.", + advisory: simulation.ok + ? "Advisory estimate only — actual fees may differ under network congestion or contract complexity." + : "Fee estimate unavailable — simulation did not produce a parseable inclusion fee.", rawOutput, }; } diff --git a/packages/core/src/contracts/wasm.test.ts b/packages/core/src/contracts/wasm.test.ts index bd888ea9..80a9b067 100644 --- a/packages/core/src/contracts/wasm.test.ts +++ b/packages/core/src/contracts/wasm.test.ts @@ -194,8 +194,8 @@ describe("isMissingRustWasmTargetError", () => { }); it("should_return_false_when_the_phrase_matches_but_the_target_is_not_named", () => { - expect( - isMissingRustWasmTargetError(buildFailure({ message: "linker `cc` not found" })) - ).toBe(false); + expect(isMissingRustWasmTargetError(buildFailure({ message: "linker `cc` not found" }))).toBe( + false + ); }); }); diff --git a/packages/core/src/shell/check-binary.test.ts b/packages/core/src/shell/check-binary.test.ts index aae4deeb..0bd9fcba 100644 --- a/packages/core/src/shell/check-binary.test.ts +++ b/packages/core/src/shell/check-binary.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { CaatingaErrorCode } from "../errors/CaatingaError.js"; const runCommand = vi.hoisted(() => vi.fn()); @@ -10,6 +10,10 @@ vi.mock("./run-command.js", () => ({ import { checkBinary } from "./check-binary.js"; describe("checkBinary", () => { + beforeEach(() => { + runCommand.mockReset(); + }); + it("should_throw_RUST_NOT_FOUND_when_rustc_is_missing", async () => { runCommand.mockRejectedValueOnce(new Error("not found")); @@ -17,6 +21,47 @@ describe("checkBinary", () => { code: CaatingaErrorCode.RUST_NOT_FOUND, }); - expect(runCommand).toHaveBeenCalledWith("rustc", ["--version"], {}); + expect(runCommand).toHaveBeenCalledWith("rustc", ["--version"], { + skipStellarVersionCheck: undefined, + }); + }); + + it("passes skipStellarVersionCheck: true when checking the stellar binary", async () => { + runCommand.mockResolvedValueOnce({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + await checkBinary("stellar", "hint"); + + expect(runCommand).toHaveBeenCalledWith("stellar", ["--version"], { + skipStellarVersionCheck: true, + }); + }); + + it("preserves explicit skipStellarVersionCheck for non-stellar binaries", async () => { + runCommand.mockResolvedValueOnce({ stdout: "rustc 1.85.0", stderr: "", all: "rustc 1.85.0" }); + + await checkBinary("rustc", "hint", { skipStellarVersionCheck: false }); + + expect(runCommand).toHaveBeenCalledWith("rustc", ["--version"], { + skipStellarVersionCheck: false, + }); + }); + + it("ignores explicit skipStellarVersionCheck: false for stellar binary", async () => { + runCommand.mockResolvedValueOnce({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + await checkBinary("stellar", "hint", { skipStellarVersionCheck: false }); + + // Stellar always skips version check; explicit false is overridden. + expect(runCommand).toHaveBeenCalledWith("stellar", ["--version"], { + skipStellarVersionCheck: true, + }); }); }); diff --git a/packages/core/src/shell/check-binary.ts b/packages/core/src/shell/check-binary.ts index 408b3eb4..ff50de6f 100644 --- a/packages/core/src/shell/check-binary.ts +++ b/packages/core/src/shell/check-binary.ts @@ -11,7 +11,9 @@ export async function checkBinary( options: CheckBinaryOptions = {} ): Promise { try { - await runCommand(binary, ["--version"], options); + await runCommand(binary, ["--version"], { + skipStellarVersionCheck: binary === "stellar" ? true : options.skipStellarVersionCheck, + }); } catch (error) { if (error instanceof CaatingaError) { throw error; diff --git a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts index b937a084..67ae46dc 100644 --- a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts +++ b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts @@ -7,12 +7,16 @@ vi.mock("../shell/run-command.js", () => ({ runCommand: runCommandMock, })); -import { checkStellarCliVersion } from "./check-stellar-cli-version.js"; +import { + checkStellarCliVersion, + _clearStellarCliVersionCache, +} from "./check-stellar-cli-version.js"; import { parseStellarCliVersion } from "./version.js"; describe("checkStellarCliVersion", () => { beforeEach(() => { runCommandMock.mockReset(); + _clearStellarCliVersionCache(); }); it("returns a supported report for the last-tested version", async () => { @@ -108,3 +112,96 @@ describe("checkStellarCliVersion", () => { }); }); }); + +// Memoization tests use fresh module state via vi.resetModules(). +const memoRunCommandMock = vi.hoisted(() => vi.fn()); + +describe("checkStellarCliVersion memoization", () => { + beforeEach(() => { + vi.resetModules(); + memoRunCommandMock.mockReset(); + }); + + it("runs stellar --version only once and returns the cached report on subsequent calls", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + const report1 = await checkStellarCliVersion(); + const report2 = await checkStellarCliVersion(); + + expect(report1).toBe(report2); + expect(report1.status).toBe("supported"); + expect(report1.version).toBe("25.2.0"); + // 1 call for --version, 3 calls for feature probes = 4 total. + // The second call to checkStellarCliVersion hits the cache, so no extra calls. + expect(memoRunCommandMock).toHaveBeenCalledTimes(4); + }); + + it("still emits warnings on each call when returning from cache", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 28.0.0", + stderr: "", + all: "stellar 28.0.0", + }); + + const onWarning1 = vi.fn(); + const onWarning2 = vi.fn(); + + const report1 = await checkStellarCliVersion({ onWarning: onWarning1 }); + const report2 = await checkStellarCliVersion({ onWarning: onWarning2 }); + + expect(report1).toBe(report2); + expect(report1.status).toBe("untested"); + expect(onWarning1).toHaveBeenCalledWith( + expect.objectContaining({ code: "STELLAR_CLI_UNTESTED_VERSION" }) + ); + expect(onWarning2).toHaveBeenCalledWith( + expect.objectContaining({ code: "STELLAR_CLI_UNTESTED_VERSION" }) + ); + // 1 call for --version, 3 calls for feature probes = 4 total. + expect(memoRunCommandMock).toHaveBeenCalledTimes(4); + }); + + it("does not cache errors — a second call retries stellar --version", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockRejectedValueOnce( + Object.assign(new Error("spawn ENOENT"), { code: "ENOENT" }) + ); + + await expect(checkStellarCliVersion()).rejects.toMatchObject({ + code: CaatingaErrorCode.STELLAR_CLI_NOT_FOUND, + }); + + // Second call should try again (not return a cached error) + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + const report = await checkStellarCliVersion(); + expect(report.status).toBe("supported"); + // 1 failed call + (1 --version + 3 feature probes) = 5 total. + expect(memoRunCommandMock).toHaveBeenCalledTimes(5); + }); +}); diff --git a/packages/core/src/stellar-cli/check-stellar-cli-version.ts b/packages/core/src/stellar-cli/check-stellar-cli-version.ts index 9f3ae244..79ca9d6c 100644 --- a/packages/core/src/stellar-cli/check-stellar-cli-version.ts +++ b/packages/core/src/stellar-cli/check-stellar-cli-version.ts @@ -16,9 +16,26 @@ export type CheckStellarCliVersionOptions = { probeFeatures?: boolean; }; +let cachedReport: CompatibilityReport | undefined; + +/** @internal — exposed for tests that need to invalidate the module-level cache. */ +export function _clearStellarCliVersionCache(): void { + cachedReport = undefined; +} + export async function checkStellarCliVersion( input: CheckStellarCliVersionOptions = {} ): Promise { + if (cachedReport) { + for (const warning of cachedReport.warnings) { + if (input.onWarning) { + input.onWarning(warning); + } else { + defaultEmitWarning(warning); + } + } + return cachedReport; + } let rawOutput: string; try { @@ -50,6 +67,8 @@ export async function checkStellarCliVersion( lastTestedVersion: input.lastTestedVersion, }); + cachedReport = report; + for (const warning of report.warnings) { if (input.onWarning) { input.onWarning(warning); diff --git a/packages/core/src/stellar-cli/probe-stellar-cli-features.test.ts b/packages/core/src/stellar-cli/probe-stellar-cli-features.test.ts index f6c56a54..ad2de236 100644 --- a/packages/core/src/stellar-cli/probe-stellar-cli-features.test.ts +++ b/packages/core/src/stellar-cli/probe-stellar-cli-features.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, beforeAll } from "vitest"; +import { beforeEach, describe, expect, it, beforeAll, vi } from "vitest"; import { checkBinary } from "../shell/check-binary.js"; import { checkStellarCliVersion } from "./check-stellar-cli-version.js"; import { @@ -41,3 +41,66 @@ describe("probeMissingStellarCliFeatures (live Stellar CLI)", () => { expect(parseStellarCliVersion(`stellar ${report.version}`)).toBe(report.version); }); }); + +// Caching tests use fresh module state via vi.resetModules(). +const probeRunCommandMock = vi.hoisted(() => vi.fn()); + +describe("probeMissingStellarCliFeatures caching", () => { + beforeEach(() => { + vi.resetModules(); + probeRunCommandMock.mockReset(); + }); + + it("caches feature probe results for a given version", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: probeRunCommandMock, + })); + + const { probeMissingStellarCliFeatures } = await import("./probe-stellar-cli-features.js"); + + // All three features available. + probeRunCommandMock.mockResolvedValue({ stdout: "", stderr: "", all: "" }); + + const missing1 = await probeMissingStellarCliFeatures("25.2.0"); + const missing2 = await probeMissingStellarCliFeatures("25.2.0"); + + expect(missing1).toEqual([]); + expect(missing2).toBe(missing1); + // Only 3 calls (one per feature), not 6. + expect(probeRunCommandMock).toHaveBeenCalledTimes(3); + }); + + it("does not share cached results between different versions", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: probeRunCommandMock, + })); + + const { probeMissingStellarCliFeatures } = await import("./probe-stellar-cli-features.js"); + + probeRunCommandMock.mockResolvedValue({ stdout: "", stderr: "", all: "" }); + + const missing1 = await probeMissingStellarCliFeatures("25.2.0"); + const missing2 = await probeMissingStellarCliFeatures("26.0.0"); + + expect(missing1).toEqual([]); + expect(missing2).toEqual([]); + // 3 calls for 25.2.0, 3 calls for 26.0.0. + expect(probeRunCommandMock).toHaveBeenCalledTimes(6); + }); + + it("caches below-minimum version result without probing", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: probeRunCommandMock, + })); + + const { probeMissingStellarCliFeatures } = await import("./probe-stellar-cli-features.js"); + + const missing1 = await probeMissingStellarCliFeatures("22.0.1"); + const missing2 = await probeMissingStellarCliFeatures("22.0.1"); + + expect(missing1).toEqual(["contract-invoke-sign"]); + expect(missing2).toBe(missing1); + // No subprocess calls at all for below-minimum versions. + expect(probeRunCommandMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/stellar-cli/probe-stellar-cli-features.ts b/packages/core/src/stellar-cli/probe-stellar-cli-features.ts index 97ff5f69..8c7eedc2 100644 --- a/packages/core/src/stellar-cli/probe-stellar-cli-features.ts +++ b/packages/core/src/stellar-cli/probe-stellar-cli-features.ts @@ -16,15 +16,24 @@ const FEATURE_COMMANDS: Record = { "contract-invoke-sign": ["contract", "invoke", "--help"], }; +const cachedMissingByVersion = new Map(); + /** * Probes the installed Stellar CLI for subcommands Caatinga depends on. * Returns feature ids that are missing or unreachable. */ export async function probeMissingStellarCliFeatures(version: string): Promise { + const cached = cachedMissingByVersion.get(version); + if (cached) { + return cached; + } + const missing: string[] = []; if (semver.valid(version) && semver.lt(version, STELLAR_CLI_MIN_VERSION)) { - return ["contract-invoke-sign"]; + const result = ["contract-invoke-sign"]; + cachedMissingByVersion.set(version, result); + return result; } for (const feature of STELLAR_CLI_REQUIRED_FEATURES) { @@ -37,5 +46,6 @@ export async function probeMissingStellarCliFeatures(version: string): Promise Date: Wed, 26 Aug 2026 13:13:48 +0000 Subject: [PATCH 2/3] fix(core): memoize stellar CLI version probe instead of the compatibility report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cache only the resolved `stellar --version` string so repeated calls avoid the subprocess spawn while still honoring per-call options (features, lastTestedVersion, probeFeatures, onWarning) on every evaluation. Per-version feature probe results stay cached in probeMissingStellarCliFeatures. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../check-stellar-cli-version.test.ts | 22 +++++--- .../stellar-cli/check-stellar-cli-version.ts | 52 ++++++++----------- 2 files changed, 39 insertions(+), 35 deletions(-) diff --git a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts index 67ae46dc..10036691 100644 --- a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts +++ b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts @@ -138,11 +138,13 @@ describe("checkStellarCliVersion memoization", () => { const report1 = await checkStellarCliVersion(); const report2 = await checkStellarCliVersion(); - expect(report1).toBe(report2); - expect(report1.status).toBe("supported"); expect(report1.version).toBe("25.2.0"); - // 1 call for --version, 3 calls for feature probes = 4 total. - // The second call to checkStellarCliVersion hits the cache, so no extra calls. + expect(report2.version).toBe(report1.version); + expect(report1.status).toBe("supported"); + // The --version subprocess is memoized and the per-version feature probe + // results are cached, so the second call spawns nothing: + // 1 --version call + 3 feature probes = 4 total. + expect(stellarVersionProbeCount()).toBe(1); expect(memoRunCommandMock).toHaveBeenCalledTimes(4); }); @@ -165,7 +167,7 @@ describe("checkStellarCliVersion memoization", () => { const report1 = await checkStellarCliVersion({ onWarning: onWarning1 }); const report2 = await checkStellarCliVersion({ onWarning: onWarning2 }); - expect(report1).toBe(report2); + expect(report1.version).toBe(report2.version); expect(report1.status).toBe("untested"); expect(onWarning1).toHaveBeenCalledWith( expect.objectContaining({ code: "STELLAR_CLI_UNTESTED_VERSION" }) @@ -173,7 +175,9 @@ describe("checkStellarCliVersion memoization", () => { expect(onWarning2).toHaveBeenCalledWith( expect.objectContaining({ code: "STELLAR_CLI_UNTESTED_VERSION" }) ); - // 1 call for --version, 3 calls for feature probes = 4 total. + // The --version subprocess is memoized and the per-version feature probe + // results are cached, so the second call spawns nothing. + expect(stellarVersionProbeCount()).toBe(1); expect(memoRunCommandMock).toHaveBeenCalledTimes(4); }); @@ -204,4 +208,10 @@ describe("checkStellarCliVersion memoization", () => { // 1 failed call + (1 --version + 3 feature probes) = 5 total. expect(memoRunCommandMock).toHaveBeenCalledTimes(5); }); + + function stellarVersionProbeCount(): number { + return memoRunCommandMock.mock.calls.filter( + (call) => Array.isArray(call[1]) && call[1][0] === "--version" + ).length; + } }); diff --git a/packages/core/src/stellar-cli/check-stellar-cli-version.ts b/packages/core/src/stellar-cli/check-stellar-cli-version.ts index 79ca9d6c..83e7b2ab 100644 --- a/packages/core/src/stellar-cli/check-stellar-cli-version.ts +++ b/packages/core/src/stellar-cli/check-stellar-cli-version.ts @@ -16,47 +16,43 @@ export type CheckStellarCliVersionOptions = { probeFeatures?: boolean; }; -let cachedReport: CompatibilityReport | undefined; +let cachedVersion: string | undefined; /** @internal — exposed for tests that need to invalidate the module-level cache. */ export function _clearStellarCliVersionCache(): void { - cachedReport = undefined; + cachedVersion = undefined; } export async function checkStellarCliVersion( input: CheckStellarCliVersionOptions = {} ): Promise { - if (cachedReport) { - for (const warning of cachedReport.warnings) { - if (input.onWarning) { - input.onWarning(warning); - } else { - defaultEmitWarning(warning); + let version = cachedVersion; + + if (!version) { + let rawOutput: string; + + try { + const result = await runCommand("stellar", ["--version"], { + skipStellarVersionCheck: true, + }); + rawOutput = result.all || result.stdout || result.stderr; + } catch (error) { + if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { + throw new CaatingaError( + "Stellar CLI was not found.", + CaatingaErrorCode.STELLAR_CLI_NOT_FOUND, + "Install Stellar CLI before running Caatinga-backed commands.", + error + ); } - } - return cachedReport; - } - let rawOutput: string; - try { - const result = await runCommand("stellar", ["--version"], { - skipStellarVersionCheck: true, - }); - rawOutput = result.all || result.stdout || result.stderr; - } catch (error) { - if (typeof error === "object" && error && "code" in error && error.code === "ENOENT") { - throw new CaatingaError( - "Stellar CLI was not found.", - CaatingaErrorCode.STELLAR_CLI_NOT_FOUND, - "Install Stellar CLI before running Caatinga-backed commands.", - error - ); + throw error; } - throw error; + version = parseStellarCliVersion(rawOutput); + cachedVersion = version; } - const version = parseStellarCliVersion(rawOutput); const probedMissing = input.probeFeatures === false ? [] : await probeMissingStellarCliFeatures(version); const missingFeatures = [...(input.features ?? []), ...probedMissing]; @@ -67,8 +63,6 @@ export async function checkStellarCliVersion( lastTestedVersion: input.lastTestedVersion, }); - cachedReport = report; - for (const warning of report.warnings) { if (input.onWarning) { input.onWarning(warning); From bbc7f8de5b445f7884870039bf189541643ae19a Mon Sep 17 00:00:00 2001 From: Okorie2000-code Date: Thu, 27 Aug 2026 19:15:03 +0100 Subject: [PATCH 3/3] test(core): add regression tests for version-only caching of stellar CLI checks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prove that different caller options (features, lastTestedVersion, probeFeatures) are independently evaluated even when the stellar CLI version subprocess is memoized. This confirms the reviewer-requested cache-granularity fix: only the resolved version string is cached, not the entire CompatibilityReport. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- .../check-stellar-cli-version.test.ts | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts index 10036691..93110837 100644 --- a/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts +++ b/packages/core/src/stellar-cli/check-stellar-cli-version.test.ts @@ -209,6 +209,154 @@ describe("checkStellarCliVersion memoization", () => { expect(memoRunCommandMock).toHaveBeenCalledTimes(5); }); + it("does not reuse the CompatibilityReport when features differ between calls (regression)", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + // First call: no extra features + const report1 = await checkStellarCliVersion(); + expect(report1.warnings.filter((w) => w.code === "STELLAR_CLI_MISSING_FEATURE")).toHaveLength( + 0 + ); + + // Second call: request a feature that doesn't exist — must produce a warning + const report2 = await checkStellarCliVersion({ features: ["nonexistent-feature"] }); + const featureWarnings2 = report2.warnings.filter( + (w) => w.code === "STELLAR_CLI_MISSING_FEATURE" + ); + expect(featureWarnings2).toHaveLength(1); + expect(featureWarnings2[0].message).toContain("nonexistent-feature"); + + // Third call: different features — must reflect its own options, not previous ones + const report3 = await checkStellarCliVersion({ features: ["another-feature"] }); + const featureWarnings3 = report3.warnings.filter( + (w) => w.code === "STELLAR_CLI_MISSING_FEATURE" + ); + expect(featureWarnings3).toHaveLength(1); + expect(featureWarnings3[0].message).toContain("another-feature"); + expect(featureWarnings3[0].message).not.toContain("nonexistent-feature"); + + // Only one --version subprocess should have been spawned across all three calls + expect(stellarVersionProbeCount()).toBe(1); + }); + + it("respects different lastTestedVersion values on each call (regression)", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + // First call: version 25.2.0 is within lastTestedVersion 26.0.0 — supported + const report1 = await checkStellarCliVersion({ lastTestedVersion: "26.0.0" }); + expect(report1.status).toBe("supported"); + expect(report1.lastTestedVersion).toBe("26.0.0"); + expect(report1.warnings).toHaveLength(0); + + // Second call: same version but lastTestedVersion 24.0.0 — untested (25.2.0 > 24.0.0) + const report2 = await checkStellarCliVersion({ lastTestedVersion: "24.0.0" }); + expect(report2.status).toBe("untested"); + expect(report2.lastTestedVersion).toBe("24.0.0"); + expect(report2.warnings).toHaveLength(1); + expect(report2.warnings[0].code).toBe("STELLAR_CLI_UNTESTED_VERSION"); + + // Only one --version subprocess across both calls + expect(stellarVersionProbeCount()).toBe(1); + }); + + it("respects probeFeatures: false on a subsequent call (regression)", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + // First call: default probeFeatures (true) — triggers feature probing + const report1 = await checkStellarCliVersion(); + expect(report1.version).toBe("25.2.0"); + const callsAfterFirst = memoRunCommandMock.mock.calls.length; + + // Second call: probeFeatures: false — must NOT trigger feature probes + const report2 = await checkStellarCliVersion({ probeFeatures: false }); + expect(report2.version).toBe("25.2.0"); + // No additional calls should have been made (version is cached, probes skipped) + expect(memoRunCommandMock.mock.calls.length).toBe(callsAfterFirst); + }); + + it("cache invalidation forces a fresh version probe", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion, _clearStellarCliVersionCache } = + await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + await checkStellarCliVersion(); + expect(stellarVersionProbeCount()).toBe(1); + + // Clear the cache + _clearStellarCliVersionCache(); + + await checkStellarCliVersion(); + // After invalidation, a fresh --version probe should occur + expect(stellarVersionProbeCount()).toBe(2); + }); + + it("caches the version string, not the full CompatibilityReport (regression)", async () => { + vi.doMock("../shell/run-command.js", () => ({ + runCommand: memoRunCommandMock, + })); + + const { checkStellarCliVersion } = await import("./check-stellar-cli-version.js"); + + memoRunCommandMock.mockResolvedValue({ + stdout: "stellar 25.2.0", + stderr: "", + all: "stellar 25.2.0", + }); + + const report1 = await checkStellarCliVersion(); + const report2 = await checkStellarCliVersion({ lastTestedVersion: "24.0.0" }); + + // The two reports must be different objects with different evaluation results + expect(report1).not.toBe(report2); + expect(report1.status).toBe("supported"); + expect(report2.status).toBe("untested"); + expect(report1.warnings).toHaveLength(0); + expect(report2.warnings).toHaveLength(1); + // The version is the same because it was cached + expect(report1.version).toBe(report2.version); + // Only one --version subprocess was spawned + expect(stellarVersionProbeCount()).toBe(1); + }); + function stellarVersionProbeCount(): number { return memoRunCommandMock.mock.calls.filter( (call) => Array.isArray(call[1]) && call[1][0] === "--version"