diff --git a/docs/mcp-prompts.md b/docs/mcp-prompts.md index 5d0068272..160f97522 100644 --- a/docs/mcp-prompts.md +++ b/docs/mcp-prompts.md @@ -25,6 +25,13 @@ Tool results include their Evidence inline. Prompts do not require a bundle or resource fetch to read a result; an Evidence ID is only a reference when a prompt argument or tool explicitly accepts one. +For a JavaScript/Electron application directory, `investigate_feature` points +directly to `analyze_javascript_application` with `input_path`; it does not +require `open_binary`. The CLI equivalent is `rea analyze `. +`open_binary` and the doctor target check admit files and macOS app bundles. +A directory outside that opening route is reported with an available analysis +action; this does not establish support for every directory format. + For target paths in `compare_application_versions`, the prompt suggests opening each target before calling `inspect_artifact`; inspection operates on the active target and returns the graph and findings together inline. diff --git a/docs/product-catalog.json b/docs/product-catalog.json index 658557640..3156660a4 100644 --- a/docs/product-catalog.json +++ b/docs/product-catalog.json @@ -562,8 +562,8 @@ }, "digests": { "tools_sha256": "bbd9aacf7bb0bc54a8f8743491583482e1176252513d99780a309d0fddeb3df0", - "prompts_sha256": "4762db348821fec787a5b8c6423f2c48a71f6f0ec2afee97b16ad69030ec504b", - "combined_sha256": "b4f7e5860d90b029876b58fe7cd4a8041fef1f2add23440e37e90827d808e503", + "prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d", + "combined_sha256": "8778381e23b001485ddab201a9a41bdc3c2a0631ccadb1afa83371f6c2fcbe6c", "providers_sha256": "e094c361b60ec930a6062a1a90ee05536ce411541dba5b51ace7e2da2ba25af0" } } diff --git a/docs/user-visible-errors.csv b/docs/user-visible-errors.csv index b4c48f58d..1fa26521d 100644 --- a/docs/user-visible-errors.csv +++ b/docs/user-visible-errors.csv @@ -7,7 +7,7 @@ harm_rank,location,trigger,current_copy,user_risk,proposed_replacement,implement 6,src/domain/errors.ts:AnalysisProtocolError,Application protocol fails,Raw constructor message,Can expose protocol internals,"Analysis returned an unreadable result. Retry once; if it continues, run `rea doctor`.",implemented,focused assertions passed; full `npm run check` passed 7,src/domain/errors.ts:HopperProcessError,Analysis subprocess stops,"Hopper bridge stopped unexpectedly with code {exitCode}",Exposes provider bridge and process code,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed 8,src/domain/errors.ts:HopperStartError,Analysis subprocess cannot start,Hopper application bridge could not be started,Exposes provider and bridge implementation,"Analysis could not start or stopped unexpectedly. Run `rea doctor`, then try again.",implemented,focused assertions passed; full `npm run check` passed -9,src/domain/errors.ts:BinaryTargetError,Target cannot open,"Cannot open artifact: {reason}",May expose raw reason and lacks checks,"REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file.",implemented,focused assertions passed; full `npm run check` passed +9,src/domain/errors.ts:BinaryTargetError,Target cannot open,"Cannot open artifact: {reason}",May expose raw reason and lacks checks,"Preserve the target admission reason; directories name analyze_javascript_application and rea analyze as available alternatives.",implemented,focused assertions passed; full `npm run check` passed 10,src/domain/errors.ts:ConfigurationError,Environment configuration invalid,Raw constructor message,May expose provider-specific variables and lacks recovery,"REA configuration is invalid. Run `rea doctor` and fix the reported setting.",implemented,focused assertions passed; full `npm run check` passed 11,src/domain/errors.ts:ArtifactOperationError,Artifact inventory or extraction fails,"Artifact {operation} failed: {reason} at {logicalPath} (declared_sha256=...)",Exposes paths hashes and internal reason,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed 12,src/domain/errors.ts:EvidenceFileError,Evidence file operation fails,"Evidence bundle {operation} failed: {reason}",Internal reason token gives weak recovery,"Reason-specific plain message with safe recovery step.",implemented,focused assertions passed; full `npm run check` passed diff --git a/docs/verification/managed-conformance-manifest.json b/docs/verification/managed-conformance-manifest.json index 57c1ea486..62476f277 100644 --- a/docs/verification/managed-conformance-manifest.json +++ b/docs/verification/managed-conformance-manifest.json @@ -1,5 +1,5 @@ { - "manifest_id": "ecm_125743de92b0cbb08a3cb11ee0d9305f60e5c4fb81152b8b748d348f91f99ee8", + "manifest_id": "ecm_c47a9e9f524d47b6eee9639d0cda8bba3c4d1a4dd0d6f38d15b5b9ef2a9c765e", "verifier": { "id": "rea-managed-conformance", "version": "1" @@ -13,7 +13,7 @@ "skill_digests": [ { "skill_id": "reverse-engineer-anything", - "sha256": "26338313efd5c941e1f3e1f1d35e2605d47749f674c359458e76d5a470b0cbb1" + "sha256": "3912bba1d6a2269d514b913d8cb0f0c64d2621d5f225904a3194d0d4a7aecf80" } ], "claims": [ diff --git a/skills/reverse-engineer-anything/SKILL.md b/skills/reverse-engineer-anything/SKILL.md index 7e51beb20..4d01615b8 100644 --- a/skills/reverse-engineer-anything/SKILL.md +++ b/skills/reverse-engineer-anything/SKILL.md @@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, an metadata: version: "24" tool_count: 118 - catalog_digest: "b4f7e5860d90b029876b58fe7cd4a8041fef1f2add23440e37e90827d808e503" + catalog_digest: "8778381e23b001485ddab201a9a41bdc3c2a0631ccadb1afa83371f6c2fcbe6c" --- # REA diff --git a/src/application/BinaryTargetResolver.ts b/src/application/BinaryTargetResolver.ts index 76a43820b..af556aff4 100644 --- a/src/application/BinaryTargetResolver.ts +++ b/src/application/BinaryTargetResolver.ts @@ -217,7 +217,15 @@ const resolveAppBundle = async ( const metadata = await stat(path); if (!metadata.isDirectory()) return ok(path); if (extname(path).toLowerCase() !== ".app") - return err(new BinaryTargetError(path, "target is not an app or file")); + return err( + new BinaryTargetError( + path, + "this opening route requires a file or macOS app bundle", + { + constraint: "directory_requires_file", + }, + ), + ); const plistPath = join(path, "Contents", "Info.plist"); let name: string; try { diff --git a/src/application/Doctor.test.ts b/src/application/Doctor.test.ts index bd6628a75..37975f21e 100644 --- a/src/application/Doctor.test.ts +++ b/src/application/Doctor.test.ts @@ -500,3 +500,25 @@ describe("doctor Linux and Hopper discovery", () => { ); }); }); + +describe("doctor target admission diagnostics", () => { + it("preserves route-specific target diagnostics from the host", async () => { + const targetCheck = { + name: "target", + ok: false, + classification: "unsupported_target" as const, + detail: "/fixture/javascript-app", + remediation: "Use analyze_javascript_application with input_path.", + }; + const result = await runDoctor( + targetCheck.detail, + host({ + inspectTarget: () => Promise.resolve(targetCheck), + validTarget: () => { + throw new Error("the detailed target check should be used"); + }, + }), + ); + expect(result.checks).toContainEqual(targetCheck); + }); +}); diff --git a/src/application/Doctor.ts b/src/application/Doctor.ts index b1864fc7a..1bd40b04c 100644 --- a/src/application/Doctor.ts +++ b/src/application/Doctor.ts @@ -3,6 +3,7 @@ import { access, readFile, readdir } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, join } from "node:path"; +import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js"; import { parseBinaryTarget } from "./BinaryTargetResolver.js"; import { execFileOutput } from "../process/ExecFileOutput.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -40,6 +41,7 @@ export interface DoctorCheck { | "healthy" | "missing_dependency" | "unsupported_host" + | "unsupported_target" | "missing_analysis_engine" | "config_drift"; readonly detail?: string; @@ -79,6 +81,8 @@ export interface DoctorHost { macosVersion(): Promise; linuxDistribution(): Promise; validTarget(path: string): Promise; + /** Route-specific target admission details, when supplied by the host. */ + inspectTarget?(path: string): Promise; executable(path: string): Promise; supportedLinuxHopper(path: string): Promise; linuxDemoRuntimeCheck(): Promise; @@ -333,6 +337,33 @@ export const systemDoctorHost = ( async validTarget(path) { return (await parseBinaryTarget(path, process.cwd(), architecture)).ok; }, + async inspectTarget(path) { + const result = await parseBinaryTarget(path, process.cwd(), architecture); + if (result.ok) + return { + name: "target", + ok: true, + classification: "healthy", + detail: path, + }; + const error = result.error; + return { + name: "target", + ok: false, + classification: + error.constraint === "directory_requires_file" + ? "unsupported_target" + : "config_drift", + detail: path, + details: { + reason: error.reason, + ...(error.constraint === undefined + ? {} + : { constraint: error.constraint }), + }, + remediation: analysisErrorRemediationAction(error), + }; + }, executable: (path) => executableAvailable( path, diff --git a/src/application/DoctorDiagnostics.ts b/src/application/DoctorDiagnostics.ts index 23d19fddf..aa6eae092 100644 --- a/src/application/DoctorDiagnostics.ts +++ b/src/application/DoctorDiagnostics.ts @@ -217,10 +217,12 @@ const optionalTargetCheck = async ( ): Promise => target === undefined ? undefined - : check("target", await host.validTarget(target), target, { - remediation: "Supply a readable local app or program path.", - classification: "config_drift", - }); + : host.inspectTarget !== undefined + ? host.inspectTarget(target) + : check("target", await host.validTarget(target), target, { + remediation: "Supply a readable local app or program path.", + classification: "config_drift", + }); const check = ( name: string, diff --git a/src/contracts/promptContracts.ts b/src/contracts/promptContracts.ts index 18b1992b0..477be2d66 100644 --- a/src/contracts/promptContracts.ts +++ b/src/contracts/promptContracts.ts @@ -118,7 +118,7 @@ export const PROMPT_CONTRACTS = [ { tools: ["open_binary", "analyze_javascript_application"], instruction: - "For a JavaScript/Electron artifact, reconstruct its application graph without execution when useful; open target_path only when the required target is not active.", + "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path=target_path directly; no open_binary call is needed. For a file or macOS app bundle, open target_path only when the required target is not active.", }, { tools: ["list_documents", "set_current_document"], diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index 9fc1c1879..02ebf0364 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -36,6 +36,11 @@ export const analysisErrorRemediationAction = ( : `Use the active REA session ${error.ownerRunId} or close it before opening this target again.`; if (error instanceof HopperRemoteError) return "Review the Hopper diagnostic details; correct the request or retry if the failure was transient."; + if ( + error instanceof BinaryTargetError && + error.constraint === "directory_requires_file" + ) + return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze `. For binary analysis, select its executable file."; if (error instanceof AnalysisInputError) return "Correct the listed arguments and retry."; if (error instanceof UnknownRegistryError && error.reason === "not-found") @@ -129,7 +134,9 @@ export const analysisErrorUserMessage = (error: AnalysisError): string => { return "REA configuration is invalid. Run `rea doctor` and fix the reported setting."; if (error instanceof NoBinaryOpenError) return error.message; if (error instanceof BinaryTargetError) - return "REA could not open that app or binary. Check that the path exists, is readable, and points to a supported file."; + return error.constraint === "directory_requires_file" + ? "open_binary accepts files and macOS app bundles. This target is a directory; JavaScript/Electron application directories can be analyzed directly with analyze_javascript_application or `rea analyze `." + : `${error.message}. Check that the path exists, is readable, and points to a supported file.`; if (error._tag === "ProcessCaptureError") return ( error.userMessage ?? diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 7bc9eed76..6f29499b4 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -334,7 +334,14 @@ const lifecycleErrorDetails = ( error.userCategory === "cancelled" ) return { operation: "process_capture", cleanup: "complete" }; - if (error instanceof BinaryTargetError) return { path: error.path }; + if (error instanceof BinaryTargetError) + return { + path: error.path, + reason: error.reason, + ...(error.constraint === undefined + ? {} + : { constraint: error.constraint }), + }; return undefined; }; diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index 4592715ec..444693b99 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -15,14 +15,21 @@ export class NoBinaryOpenError extends AnalysisError { } } +/** Additional constraints identified while admitting a filesystem target. */ +export interface BinaryTargetErrorOptions extends ErrorOptions { + readonly constraint?: "directory_requires_file"; +} + /** A supplied target path could not be safely opened as a supported app or binary. */ export class BinaryTargetError extends AnalysisError { readonly _tag = "BinaryTargetError"; + readonly constraint: BinaryTargetErrorOptions["constraint"]; constructor( readonly path: string, - reason: string, - options?: ErrorOptions, + readonly reason: string, + options?: BinaryTargetErrorOptions, ) { super(`Cannot open artifact: ${reason}`, options); + this.constraint = options?.constraint; } } diff --git a/tests/boundary/filesystem/targetDiagnostics.test.ts b/tests/boundary/filesystem/targetDiagnostics.test.ts new file mode 100644 index 000000000..645846fe5 --- /dev/null +++ b/tests/boundary/filesystem/targetDiagnostics.test.ts @@ -0,0 +1,71 @@ +import { chmod, realpath, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { systemDoctorHost } from "../../../src/application/Doctor.js"; +import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +describe("target route diagnostics", () => { + it("identifies the directory opening constraint and gives the direct analysis route", async () => { + const directory = await createTestTempDirectory("rea-js-target-"); + await writeFile(join(directory, "index.js"), "export const answer = 42;\n"); + const result = await parseBinaryTarget(directory); + if (result.ok) throw new Error("directory was admitted as a file"); + const projected = projectAnalysisError(result.error); + expect(projected).toMatchObject({ + code: "target_unavailable", + details: { + path: await realpath(directory), + constraint: "directory_requires_file", + }, + }); + expect(projected.message).toContain("analyze_javascript_application"); + expect(projected.remediation.action).toContain("input_path"); + const doctor = systemDoctorHost(); + expect(await doctor.inspectTarget?.(directory)).toMatchObject({ + name: "target", + ok: false, + classification: "unsupported_target", + details: { constraint: "directory_requires_file" }, + remediation: projected.remediation.action, + }); + const missing = await parseBinaryTarget(join(directory, "missing")); + if (missing.ok) throw new Error("missing target was admitted"); + expect(projectAnalysisError(missing.error)).toMatchObject({ + details: { reason: expect.stringContaining("does not exist") }, + message: expect.stringContaining("does not exist"), + }); + expect( + await doctor.inspectTarget?.(join(directory, "missing")), + ).toMatchObject({ + ok: false, + classification: "config_drift", + details: { reason: expect.stringContaining("does not exist") }, + }); + }); + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "preserves a real filesystem permission denial", + async () => { + const directory = await createTestTempDirectory("rea-target-denied-"); + const path = join(directory, "index.js"); + await writeFile(path, "export const answer = 42;"); + await chmod(path, 0o000); + try { + const result = await parseBinaryTarget(path); + if (result.ok) throw new Error("unreadable target was admitted"); + expect(projectAnalysisError(result.error)).toMatchObject({ + details: { reason: expect.stringContaining("permission denied") }, + message: expect.stringContaining("permission denied"), + }); + expect(await systemDoctorHost().inspectTarget?.(path)).toMatchObject({ + ok: false, + classification: "config_drift", + details: { reason: expect.stringContaining("permission denied") }, + }); + } finally { + await chmod(path, 0o600); + } + }, + ); +}); diff --git a/tests/boundary/mcp/promptMcp.test.ts b/tests/boundary/mcp/promptMcp.test.ts index 5c3b1579d..0916ad728 100644 --- a/tests/boundary/mcp/promptMcp.test.ts +++ b/tests/boundary/mcp/promptMcp.test.ts @@ -70,6 +70,9 @@ describe("guided prompts over MCP", () => { if (content?.type !== "text") throw new Error("missing prompt text"); expect(content.text).toContain('"feature":"license validation"'); expect(content.text).toContain("`trace_feature`"); + expect(content.text).toContain( + "input_path=target_path directly; no open_binary call is needed", + ); }); it("renders every workflow without executing analysis or accepting extra context", async () => {