Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 23 additions & 14 deletions packages/loopover-miner/lib/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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"),
);
}

Expand Down
43 changes: 43 additions & 0 deletions test/unit/miner-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down