diff --git a/scripts/verify-package-discovery.d.mts b/scripts/verify-package-discovery.d.mts index 7c5d27821..1531e9dba 100644 --- a/scripts/verify-package-discovery.d.mts +++ b/scripts/verify-package-discovery.d.mts @@ -1,12 +1,31 @@ -/** Derive agent and Hopper setup support from the corresponding doctor checks. */ +/** + * Host facts the packaged setup support is derived from. + * + * `linuxDistribution` is the distribution record the Hopper installer owner + * parses; it is absent when no distribution could be read, which leaves + * installer applicability unknown rather than unsupported. + */ +export interface SetupHostFacts { + readonly platform: NodeJS.Platform; + readonly linuxDistribution?: { + readonly hopperSetupSupported: boolean; + }; +} + +/** + * Derive agent setup support from the doctor node check and automated Hopper + * installer applicability from the facts their owners parse. Applicability is + * undefined when its owning fact is unavailable. + */ export function packageSetupSupport( checks: | readonly { readonly name: string; readonly ok: boolean }[] | undefined, - platform: NodeJS.Platform, + host: SetupHostFacts, ): { supportedSetupHost: boolean; - hopperSetupSupported: boolean; + hopperReadinessRequired: boolean; + hopperSetupSupported: boolean | undefined; }; /** Validate --help, --llms, and doctor output and determine host setup support. */ @@ -15,5 +34,5 @@ export function verifyPackageDiscovery(input: { readonly environment: Readonly>; }): Promise<{ supportedSetupHost: boolean; - hopperSetupSupported: boolean; + hopperSetupSupported: boolean | undefined; }>; diff --git a/scripts/verify-package-discovery.mjs b/scripts/verify-package-discovery.mjs index 299b9d07c..9f2a5d7a8 100644 --- a/scripts/verify-package-discovery.mjs +++ b/scripts/verify-package-discovery.mjs @@ -27,15 +27,56 @@ const REQUIRED_LLM_TOPICS = [ "analyze-javascript-application", ]; -/** Derive agent and Hopper setup support from the corresponding doctor checks. */ -export function packageSetupSupport(checks, platform) { +/** + * Derive agent setup support from the doctor node check and automated Hopper + * installer applicability from the facts their owners parse. + * + * Host readiness and installer applicability are different facts: a + * distribution REA supports can still be absent from the Hopper package's + * tested list, so the doctor host check cannot answer the second question. + * Applicability stays undefined when its owning fact is unavailable, which is + * distinct from a host known to lack an automated installer. + */ +export function packageSetupSupport(checks, host) { const supportedSetupHost = checks?.find(({ name }) => name === "node")?.ok === true; - const hopperSetupSupported = - supportedSetupHost && - checks?.find(({ name }) => name === "host")?.ok === true && - platform !== "win32"; - return { supportedSetupHost, hopperSetupSupported }; + const hopperReadinessRequired = + host.platform !== "win32" && + checks?.find(({ name }) => name === "host")?.ok === true; + return { + supportedSetupHost, + hopperReadinessRequired, + hopperSetupSupported: hopperInstallerSupported( + host, + checks?.find(({ name }) => name === "host")?.ok, + ), + }; +} + +/** + * Whether `rea setup --install-hopper` can install Hopper automatically here. + * On macOS the doctor host check already is the owner's installer predicate + * (macOS 12 or later); on Linux it reports REA host support instead, so the + * distribution's own parsed applicability governs. + */ +function hopperInstallerSupported(host, hostCheckOk) { + if (host.platform === "win32") return false; + if (host.platform === "linux") + return host.linuxDistribution?.hopperSetupSupported; + return hostCheckOk; +} + +/** + * The distribution facts the Hopper installer owner parses, taken from the + * built application so the verifier follows the predicate the package ships. + * Loaded only where a distribution can exist, which also keeps the derivation + * above testable without a build. + */ +async function readHostLinuxDistribution() { + if (process.platform !== "linux") return undefined; + const { readLinuxDistribution } = + await import("../dist/application/LinuxHopper.js"); + return readLinuxDistribution(); } /** Validate --help, --llms, and doctor output and determine host setup support. */ @@ -48,10 +89,11 @@ export async function verifyPackageDiscovery({ cli, environment }) { environment, ); const doctor = json(doctorExecution.stdout); - const { supportedSetupHost, hopperSetupSupported } = packageSetupSupport( - doctor.checks, - process.platform, - ); + const { supportedSetupHost, hopperReadinessRequired, hopperSetupSupported } = + packageSetupSupport(doctor.checks, { + platform: process.platform, + linuxDistribution: await readHostLinuxDistribution(), + }); const hopperReady = doctor.checks?.find(({ name }) => name === "hopper")?.ok; const expectedDoctorHealth = doctor.checks?.every(({ ok }) => ok) === true; const missingHelp = REQUIRED_HELP_COMMANDS.filter( @@ -65,7 +107,7 @@ export async function verifyPackageDiscovery({ cli, environment }) { missingLlms.length !== 0 || doctor.healthy !== expectedDoctorHealth || doctorExecution.status !== (expectedDoctorHealth ? 0 : 1) || - (hopperSetupSupported && hopperReady !== true) + (hopperReadinessRequired && hopperReady !== true) ) throw new Error( `packaged CLI discovery or doctor failed: ${JSON.stringify({ helpSetup: help.includes("setup"), llmsDecompile: llms.includes("decompile"), doctor })}`, diff --git a/scripts/verify-package-setup.mjs b/scripts/verify-package-setup.mjs index b415965e0..9a87a1695 100644 --- a/scripts/verify-package-setup.mjs +++ b/scripts/verify-package-setup.mjs @@ -356,9 +356,8 @@ const verifySetupApply = async ({ hopperSetupSupported, }) => { const status = "ready"; - const hopperStatus = hopperSetupSupported - ? "needs_confirmation" - : "needs_human"; + const hopperStatus = + hopperSetupSupported === true ? "needs_confirmation" : "needs_human"; const firstExecution = await runWithStatus( cli, ["setup", "--yes", "--all-detected", "--json"], diff --git a/tests/evaluation/package/packageSetupSupport.test.ts b/tests/evaluation/package/packageSetupSupport.test.ts new file mode 100644 index 000000000..2103e5f95 --- /dev/null +++ b/tests/evaluation/package/packageSetupSupport.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from "vitest"; + +import { packageSetupSupport } from "../../../scripts/verify-package-discovery.mjs"; +import { parseLinuxDistribution } from "../../../src/application/LinuxHopper.js"; + +const osRelease = (fields: Record) => + parseLinuxDistribution( + Object.entries(fields) + .map(([key, value]) => `${key}="${value}"`) + .join("\n"), + ); + +/** A Debian-family host REA supports that the Hopper package never tests. */ +const KALI = osRelease({ ID: "kali", VERSION_ID: "2026.3", ID_LIKE: "debian" }); +const UBUNTU_24 = osRelease({ ID: "ubuntu", VERSION_ID: "24.04" }); + +const checks = ({ node = true, host = true } = {}) => [ + { name: "node", ok: node }, + { name: "host", ok: host }, +]; + +describe("packaged setup support derivation", () => { + it("keeps an installer-untested REA-supported distribution out of the automated Hopper path", () => { + // The doctor host check passes on Kali, so reading installer applicability + // from it promised an automated install the packaged setup never offers. + expect( + packageSetupSupport(checks(), { + platform: "linux", + linuxDistribution: KALI, + }), + ).toEqual({ + supportedSetupHost: true, + hopperReadinessRequired: true, + hopperSetupSupported: false, + }); + }); + + it("follows the distribution's own applicability, not host readiness", () => { + expect( + packageSetupSupport(checks({ host: false }), { + platform: "linux", + linuxDistribution: UBUNTU_24, + }), + ).toEqual({ + supportedSetupHost: true, + hopperReadinessRequired: false, + hopperSetupSupported: true, + }); + }); + + it("keeps unreadable Linux applicability unknown", () => { + expect( + packageSetupSupport(checks(), { platform: "linux" }).hopperSetupSupported, + ).toBeUndefined(); + }); + + it("uses the owner's macOS predicate and excludes Windows", () => { + expect( + packageSetupSupport(checks(), { platform: "darwin" }) + .hopperSetupSupported, + ).toBe(true); + expect( + packageSetupSupport(checks({ host: false }), { platform: "darwin" }) + .hopperSetupSupported, + ).toBe(false); + expect( + packageSetupSupport([], { platform: "darwin" }).hopperSetupSupported, + ).toBeUndefined(); + expect( + packageSetupSupport(checks(), { platform: "win32" }).hopperSetupSupported, + ).toBe(false); + }); + + it("reports agent setup support from the node check alone", () => { + expect( + packageSetupSupport(checks({ node: false }), { + platform: "linux", + linuxDistribution: UBUNTU_24, + }), + ).toEqual({ + supportedSetupHost: false, + hopperReadinessRequired: true, + hopperSetupSupported: true, + }); + }); +});