From 47a6ba23bad8f78632b257ddfcda282a5c74d046 Mon Sep 17 00:00:00 2001 From: bitfathers94 <237535319+bitfathers94@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:04:06 +0000 Subject: [PATCH] fix(miner): resolve status.ts's monorepo-sibling paths at the compiled dist/lib depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-24 out-of-place dist/ emit migration (c89679761) added a directory level between the compiled CLI and its monorepo siblings, and introduced resolveMonorepoSiblingPath to try both the source (lib/) and compiled (dist/lib/) depths. That helper was wired into two of the four sibling-path call sites in status.ts, but the other two were left on the pre-migration hardcoded join(moduleDir(), "../../loopover-engine/package.json"), which only resolves correctly from lib/: - readInstalledEnginePackageVersion's catch-all workspace fallback returned null from the real compiled CLI instead of finding the workspace engine version. - readExpectedEnginePackageVersion's monorepo-engine check always missed, so it always fell through to the static expected-engine.version pin even when a live packages/loopover-engine/package.json was present. Route both through resolveMonorepoSiblingPath (now taking the module dir as an explicit argument so tests can simulate the dist/lib depth without a real build), and add regression tests covering both fixed call sites from a simulated dist/lib depth — including every branch of the now-reachable catch block, whose /* v8 ignore next 9 */ annotation is removed. Closes #8630 --- packages/loopover-miner/lib/status.ts | 37 ++++++++++++++--------- test/unit/miner-status.test.ts | 43 +++++++++++++++++++++++++++ 2 files changed, 66 insertions(+), 14 deletions(-) diff --git a/packages/loopover-miner/lib/status.ts b/packages/loopover-miner/lib/status.ts index 8c299dcb1c..d35e3b4df6 100644 --- a/packages/loopover-miner/lib/status.ts +++ b/packages/loopover-miner/lib/status.ts @@ -58,11 +58,13 @@ function moduleDir(): string { // 2026-07-24 dist/ migration added one more level of nesting; see tsconfig.json's outDir comment). A // single hardcoded relative depth can only ever be correct for one of those two contexts, so this // tries both, preferring whichever the current on-disk layout actually has -- robust to either -// execution mode without needing to detect which one is active. -function resolveMonorepoSiblingPath(...segments: string[]): string { - const fromLib = join(moduleDir(), "..", ...segments); +// execution mode without needing to detect which one is active. `dir` defaults to moduleDir() and is +// only passed explicitly by tests, which simulate the compiled dist/lib/ depth without running the +// real CLI build. +function resolveMonorepoSiblingPath(dir: string, ...segments: string[]): string { + const fromLib = join(dir, "..", ...segments); if (existsSync(fromLib)) return fromLib; - return join(moduleDir(), "..", "..", ...segments); + return join(dir, "..", "..", ...segments); } const PACKAGE_NAME = "@loopover/miner"; @@ -171,17 +173,24 @@ export function readInstalledEnginePackageVersionFromPaths( return null; } -/** Installed @loopover/engine semver from node_modules (not the declared dependency range). */ -/* v8 ignore next -- Node resolver failure cannot be induced after this module's require is initialized; fallback is defensive */ -export function readInstalledEnginePackageVersion(): string | null { +/** + * Installed @loopover/engine semver from node_modules (not the declared dependency range). `dir` + * (the on-disk module dir) and `resolveEntry` (the installed-package resolver) default to the real + * ones and are only injected by tests -- exercising the catch-all monorepo-workspace fallback + * requires forcing `require.resolve` to fail and pointing the sibling lookup at a simulated + * compiled dist/lib/ depth, neither of which can be induced from the source (`lib/`) context. + */ +export function readInstalledEnginePackageVersion( + dir: string = moduleDir(), + resolveEntry: () => string = () => requireFromHere().resolve(ENGINE_PACKAGE), +): string | null { try { return readInstalledEnginePackageVersionFromPaths( - requireFromHere().resolve(ENGINE_PACKAGE), - resolveMonorepoSiblingPath("..", "loopover-engine", "package.json"), + resolveEntry(), + resolveMonorepoSiblingPath(dir, "..", "loopover-engine", "package.json"), ); } catch { - /* v8 ignore next 9 -- only reaches when Node cannot resolve the installed package at all */ - const workspacePkg = join(moduleDir(), "../../loopover-engine/package.json"); + const workspacePkg = resolveMonorepoSiblingPath(dir, "..", "loopover-engine", "package.json"); if (existsSync(workspacePkg)) { try { return (JSON.parse(readFileSync(workspacePkg, "utf8")) as PackageJsonShape).version ?? null; @@ -217,10 +226,10 @@ export function readExpectedEnginePackageVersionFromPaths( } } -export function readExpectedEnginePackageVersion(): string | null { +export function readExpectedEnginePackageVersion(dir: string = moduleDir()): string | null { return readExpectedEnginePackageVersionFromPaths( - join(moduleDir(), "../../loopover-engine/package.json"), - resolveMonorepoSiblingPath("expected-engine.version"), + resolveMonorepoSiblingPath(dir, "..", "loopover-engine", "package.json"), + resolveMonorepoSiblingPath(dir, "expected-engine.version"), ); } diff --git a/test/unit/miner-status.test.ts b/test/unit/miner-status.test.ts index b60a27016e..139f044efd 100644 --- a/test/unit/miner-status.test.ts +++ b/test/unit/miner-status.test.ts @@ -303,6 +303,49 @@ describe("loopover-miner status/doctor (#2288)", () => { ).toBeNull(); }); + it("readInstalledEnginePackageVersion's catch-all fallback resolves the monorepo engine from a dist/lib-depth module dir (#8630)", () => { + // Regression for #8630: the compiled CLI runs from packages/loopover-miner/dist/lib (one level + // deeper than the source lib/), so the catch-all monorepo-sibling lookup must use the + // depth-flexible resolveMonorepoSiblingPath — not a single hardcoded ../../ that only lands the + // real packages/loopover-engine/package.json from the source context. Simulate that dist/lib depth + // and force require.resolve(@loopover/engine) to fail so the fallback is the one under test. + const root = tempRoot(); + const distLib = join(root, "packages", "loopover-miner", "dist", "lib"); + const engineDir = join(root, "packages", "loopover-engine"); + mkdirSync(engineDir, { recursive: true }); + const enginePkg = join(engineDir, "package.json"); + const unresolvable = () => { + throw new Error("Cannot find module '@loopover/engine'"); + }; + + writeFileSync(enginePkg, JSON.stringify({ version: "9.9.9" })); + expect(readInstalledEnginePackageVersion(distLib, unresolvable)).toBe("9.9.9"); + + // present but versionless -> the `?? null` arm; malformed -> the inner JSON.parse catch. + writeFileSync(enginePkg, "{}"); + expect(readInstalledEnginePackageVersion(distLib, unresolvable)).toBeNull(); + writeFileSync(enginePkg, "not json"); + expect(readInstalledEnginePackageVersion(distLib, unresolvable)).toBeNull(); + + // no monorepo sibling present at all -> the existsSync-false arm returns null. + rmSync(enginePkg); + expect(readInstalledEnginePackageVersion(distLib, unresolvable)).toBeNull(); + }); + + it("readExpectedEnginePackageVersion prefers the live monorepo engine over the pin from a dist/lib-depth module dir (#8630)", () => { + // Regression for #8630: from the compiled CLI's dist/lib depth the "monorepo engine package.json + // when present" branch of the doc contract was dead — the hardcoded ../../ resolved to a + // nonexistent path so it always fell through to the static expected-engine.version pin. With the + // depth-flexible helper the live monorepo version must win when the file is present. + const root = tempRoot(); + const distLib = join(root, "packages", "loopover-miner", "dist", "lib"); + const engineDir = join(root, "packages", "loopover-engine"); + mkdirSync(engineDir, { recursive: true }); + writeFileSync(join(engineDir, "package.json"), JSON.stringify({ version: "9.9.9" })); + + expect(readExpectedEnginePackageVersion(distLib)).toBe("9.9.9"); + }); + it("reports credential and token availability across configured provider variants without exposing secrets", () => { expect(checkGitHubTokenPresent({ GITHUB_TOKEN: "present" }).ok).toBe(true); expect(checkCodingAgentCredential({ MINER_CODING_AGENT_PROVIDER: "noop" }).detail).toContain("needs no credential");