diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 2faaaca41c..eb4d8a25f7 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -440,10 +440,12 @@ result's `_meta`, whether the call succeeded or failed: ``` The marker is declared once per tool contract (`returnsTargetContent`) and is -on by default: only `export_evidence_bundle` and `record_unknown`, whose -results and errors contain only REA session state or caller-supplied values, -omit it. Tools whose results or errors can include provider, launcher, or -device output stay marked, including `binary_session` (provider failure +on by default: `export_evidence_bundle`, `record_unknown`, `search_guidance`, +and `read_guidance_section` omit it. The first two contain only REA session +state or caller-supplied values; the guidance tools return REA's own installed +documentation, not text from an analysis target. Tools whose results or errors +can include provider, launcher, or device output stay marked, including +`binary_session` (provider failure reasons and availability diagnostics), `close_binary` (provider cleanup diagnostics with captured launcher output), `inspect_adb_client` and `inspect_apktool_client` (version output and stderr excerpts), and diff --git a/docs/mcp-prompts.md b/docs/mcp-prompts.md index 55b66da374..ef630404bf 100644 --- a/docs/mcp-prompts.md +++ b/docs/mcp-prompts.md @@ -18,6 +18,26 @@ non-string values are rejected with field paths. `action: "list"` accepts no workflow identifier or arguments. The MCP adapter respects the same `REA_MCP_TOOL_FAMILIES` scope as native prompts, including render requests. +To find a section in the installed guidance bundle by analyst question, use +`search_guidance` and pass a returned locator to `read_guidance_section`. The +CLI equivalent is `rea guidance --query "exact JavaScript module"`, followed +by `rea guidance --action read --locator LOCATOR`. Search is limited to the +packaged managed skill files, works without providers or a target, and returns +deterministically paged matches. Every non-generic query word must occur in the +section; generic-only queries return no matches. Results are documentation; locators +are bound to the installed bundle and report stale or unavailable guidance without +returning cached section content. A no-match result says nothing about analysis +capability availability. + +Search returns at most 20 matches per page with 500-code-unit context excerpts. +Read retrieves the complete selected section. Admission permits 1 MiB per file, +4 MiB per bundle, 4,096 sections, and 25,165,824 expanded UTF-16 code units before +section text is materialized. Unreadable or oversized bundles return +`bundle_unavailable`, with no partial index or inferred absence. Cleanup failures +remain errors with their retained retry owner. The bundle and complete section +are bound by fixed-size hashes in locators, so a long heading does not make its own +locator unreadable. + ## Prompt inventory | Prompt | Purpose | diff --git a/scripts/lib/development-tests.mjs b/scripts/lib/development-tests.mjs index dacd818201..84d094043b 100644 --- a/scripts/lib/development-tests.mjs +++ b/scripts/lib/development-tests.mjs @@ -38,6 +38,11 @@ export const parseDevelopmentTestRequest = (mode, arguments_) => { // Test-only outputs are declared beside the test planner rather than becoming // runtime or typecheck prerequisites. Add a consumer here when it reads one. const artifactConsumers = { + "artifacts:skills": [ + "tests/boundary/cli/guidanceCli.test.ts", + "tests/boundary/guidanceSearch.test.ts", + "tests/boundary/mcp/registerGuidanceTools.test.ts", + ], "artifacts:mcp-catalog": [ "tests/boundary/mcp/deferredRuntime.test.ts", "tests/boundary/mcp/contractPresentation.test.ts", diff --git a/scripts/rea.mjs b/scripts/rea.mjs index 71006980a7..57e2b02485 100755 --- a/scripts/rea.mjs +++ b/scripts/rea.mjs @@ -128,7 +128,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { ); const configurationError = cliConfigurationError(process.env); let dispatchFailure; - let closeCliJavaScriptResources; + let closeCliResources; // Incur can exit from the callback below before this function returns. // That callback owns the status, including a closed-pipe exit of 141. let exiting = false; @@ -150,7 +150,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { writeStdout(versionOutput); } else { const cliRuntime = await import("../dist/cli-runtime/cli.js"); - closeCliJavaScriptResources = cliRuntime.closeCliJavaScriptResources; + closeCliResources = cliRuntime.closeCliResources; const { createCli } = cliRuntime; const { createCliResultOutput } = await import("../dist/cli-runtime/cliResultOutput.js"); @@ -176,10 +176,10 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { void (async () => { let status = code; try { - await closeCliJavaScriptResources(); + await closeCliResources(); } catch (cleanup) { process.stderr.write( - `Failed to close the JavaScript analysis worker: ${cleanupFailureText(cleanup)}\n`, + `Failed to close CLI resources: ${cleanupFailureText(cleanup)}\n`, ); if (status === 0) status = 1; } @@ -225,7 +225,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { // Serve and output rendering can throw after a worker is parked. Stop it // on that path too, and keep the dispatch failure when both fail. try { - await closeCliJavaScriptResources?.(); + await closeCliResources?.(); } catch (cleanup) { // The exit callback reports this same shared failure and then exits. if (!exiting) { diff --git a/scripts/run-development-tests.mjs b/scripts/run-development-tests.mjs index 8237f8ad58..499f73c479 100644 --- a/scripts/run-development-tests.mjs +++ b/scripts/run-development-tests.mjs @@ -61,8 +61,10 @@ try { process.execPath, resolve("scripts/run-turbo.mjs"), "run", - ...(plan.needsBuild ? ["artifacts:skills"] : []), - ...plan.artifactTasks, + ...new Set([ + ...(plan.needsBuild ? ["artifacts:skills"] : []), + ...plan.artifactTasks, + ]), ]); if (prepared.code !== 0) process.exitCode = prepared.code; } diff --git a/src/application/GuidanceAccess.boundaries.test.ts b/src/application/GuidanceAccess.boundaries.test.ts new file mode 100644 index 0000000000..2602c6986e --- /dev/null +++ b/src/application/GuidanceAccess.boundaries.test.ts @@ -0,0 +1,139 @@ +import { expect, it } from "vitest"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { readGuidanceSectionInputSchema } from "../contracts/guidanceContracts.js"; +import { MANAGED_SKILL_FILES } from "./SetupSkill.js"; +import { + readGuidanceSectionWithReader, + searchGuidanceWithReader, +} from "./GuidanceAccess.js"; + +const reader = (text: string) => async (path: string) => + path === MANAGED_SKILL_FILES[0] ? text : "# Other\n"; + +it("does not substitute a common domain word for an unknown query subject", async () => { + const resources = new ArtifactResourceScope(); + expect( + await searchGuidanceWithReader( + { query: "how do I inspect module frobnicate qzxv" }, + reader("# Modules\nInspect a module with retained facts.\n"), + resources, + ), + ).toMatchObject({ ok: true, value: { status: "no_matches" } }); + await resources.close(); +}); + +it.each([ + ["## C#", "C#"], + ["## C# ###", "C#"], + ["## C\\#", "C\\#"], + [" ## C#", "C#"], + ["## C# \t ### \t", "C#"], + ["## ###", ""], +])("preserves the source title in %s", async (line, heading) => { + const resources = new ArtifactResourceScope(); + const source = reader(`${line}\nneedle instructions\n`); + const result = await searchGuidanceWithReader( + { query: "needle" }, + source, + resources, + ); + if (!result.ok || result.value.status !== "matches") + throw new Error("Expected matching documentation"); + expect(result.value.matches[0]?.heading).toBe(heading); + const value = await readGuidanceSectionWithReader( + { locator: result.value.matches[0]?.locator }, + source, + resources, + ); + expect(value).toMatchObject({ + ok: true, + value: { status: "section", heading }, + }); + await resources.close(); +}); + +it("issues readable locators independently of heading length", async () => { + const resources = new ArtifactResourceScope(); + const heading = `needle ${"x".repeat(3000)}`; + const source = reader(`## ${heading}\nInstructions\n`); + const result = await searchGuidanceWithReader( + { query: "needle" }, + source, + resources, + ); + if (!result.ok || result.value.status !== "matches") + throw new Error("Expected match"); + const locator = result.value.matches[0]?.locator; + expect(readGuidanceSectionInputSchema.safeParse({ locator }).success).toBe( + true, + ); + expect( + await readGuidanceSectionWithReader({ locator }, source, resources), + ).toMatchObject({ + ok: true, + value: { status: "section", heading }, + }); + await resources.close(); +}); + +it.each([2048, 2049])( + "bounds the combined index across guides with %i sections each", + async (count) => { + const resources = new ArtifactResourceScope(); + const result = await searchGuidanceWithReader( + { query: "needle", limit: 1 }, + async (path) => + path === MANAGED_SKILL_FILES[0] || path === MANAGED_SKILL_FILES[1] + ? "# needle\n".repeat(count) + : "", + resources, + ); + expect(result).toMatchObject({ + ok: true, + value: { status: count === 2048 ? "matches" : "bundle_unavailable" }, + }); + await resources.close(); + }, +); + +it("preserves retry cleanup errors and their live owner in both operations", async () => { + const resources = new ArtifactResourceScope(); + const source = reader("# needle\nInstructions\n"); + const initial = await searchGuidanceWithReader( + { query: "needle" }, + source, + resources, + ); + if (!initial.ok || initial.value.status !== "matches") + throw new Error("Expected match"); + const locator = initial.value.matches[0]?.locator; + let blocked = true; + resources.retain({ + kind: "lease", + resource: "guidance descriptor", + lease: { + release: async () => { + if (blocked) throw new Error("still owned"); + }, + }, + }); + const retryingReader = (path: string) => resources.run(() => source(path)); + const errors = []; + for (const result of [ + await searchGuidanceWithReader( + { query: "needle" }, + retryingReader, + resources, + ), + await readGuidanceSectionWithReader({ locator }, retryingReader, resources), + ]) { + if (result.ok) throw new Error("Cleanup failures must remain errors"); + expect(result.error).toMatchObject({ cleanupIncomplete: true }); + expect(result.error.cleanupResources).toContain("guidance descriptor"); + errors.push(result.error); + } + blocked = false; + await resources.close(); + expect(resources.hasPending).toBe(false); + for (const error of errors) expect(error.cleanupIncomplete).toBe(false); +}); diff --git a/src/application/GuidanceAccess.lifecycle.test.ts b/src/application/GuidanceAccess.lifecycle.test.ts new file mode 100644 index 0000000000..f60bed2fd9 --- /dev/null +++ b/src/application/GuidanceAccess.lifecycle.test.ts @@ -0,0 +1,183 @@ +import { expect, it } from "vitest"; +import { awaitAcknowledgement } from "../../tests/fixtures/lifecycleAcknowledgement.js"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; +import { RetainedRegularFileCleanupFailure } from "./RegularFileRead.js"; +import { MANAGED_SKILL_FILES } from "./SetupSkill.js"; +import { + readGuidanceSectionWithReader, + searchGuidanceWithReader, +} from "./GuidanceAccess.js"; + +const gate = () => { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +}; +const content = (path: string) => + path === MANAGED_SKILL_FILES[0] ? "# needle\nInstructions\n" : "# Other\n"; + +it.each(["search", "read"] as const)( + "finishes every guide in an admitted %s while shutdown waits", + async (action) => { + const resources = new ArtifactResourceScope(); + + const reached = gate(); + const resume = gate(); + const paths: string[] = []; + const reader = (path: string, admitted = resources) => + admitted.run(async () => { + paths.push(path); + if (path === MANAGED_SKILL_FILES[1]) { + reached.release(); + await resume.promise; + } + return content(path); + }); + const launched: Promise[] = []; + try { + const initial = await searchGuidanceWithReader( + { query: "needle" }, + async (path) => content(path), + resources, + ); + if (!initial.ok || initial.value.status !== "matches") + throw new Error("Expected match"); + const operation = + action === "search" + ? searchGuidanceWithReader({ query: "needle" }, reader, resources) + : readGuidanceSectionWithReader( + { locator: initial.value.matches[0]?.locator }, + reader, + resources, + ); + launched.push(operation); + await awaitAcknowledgement( + reached.promise, + operation, + "second guidance read", + ); + let closed = false; + const closing = resources.close().then(() => { + closed = true; + }); + launched.push(closing); + await Promise.resolve(); + expect(closed).toBe(false); + resume.release(); + expect(await operation).toMatchObject({ + ok: true, + value: { status: action === "search" ? "matches" : "section" }, + }); + await closing; + expect(paths).toEqual(MANAGED_SKILL_FILES); + expect(closed).toBe(true); + expect(resources.hasPending).toBe(false); + } finally { + resume.release(); + await Promise.allSettled(launched); + await resources.close(); + } + }, +); + +it("retains a failed child while another admitted request reaches later reads", async () => { + const resources = new ArtifactResourceScope(); + const reached = gate(); + const resume = gate(); + const paths: string[] = []; + let blocked = true; + const failure = new RetainedRegularFileCleanupFailure( + new RegularFileCleanupFailure({ + path: "owned guidance descriptor", + owner: { + close: async () => { + if (blocked) throw new Error("close denied"); + }, + } as never, + outcome: { kind: "failed", cause: new Error("read failed") }, + cleanupCause: new Error("close denied"), + }), + ); + const launched: Promise[] = []; + try { + const active = searchGuidanceWithReader( + { query: "needle" }, + (path, child) => + child.run(async () => { + paths.push(path); + if (path === MANAGED_SKILL_FILES[1]) { + reached.release(); + await resume.promise; + } + return content(path); + }), + resources, + ); + launched.push(active); + await awaitAcknowledgement( + reached.promise, + active, + "overlapping guidance read", + ); + const failing = searchGuidanceWithReader( + { query: "needle" }, + async (_path, child) => { + child.retain(failure.cleanupOwner); + throw failure; + }, + resources, + ); + launched.push(failing); + const failed = await failing; + if (failed.ok) throw new Error("Expected cleanup failure"); + expect(failed.error.cleanupResources).toEqual([ + "owned guidance descriptor", + ]); + expect(failed.error.cleanupIncomplete).toBe(true); + const closing = resources.close().then( + () => undefined, + (cause: unknown) => cause, + ); + launched.push(closing); + resume.release(); + expect(await active).toMatchObject({ + ok: true, + value: { status: "matches" }, + }); + expect(paths).toEqual(MANAGED_SKILL_FILES); + expect(await closing).toMatchObject({ + cleanup: { resources: ["owned guidance descriptor"] }, + }); + expect(resources.hasPending).toBe(true); + expect(failed.error.cleanupIncomplete).toBe(true); + blocked = false; + await resources.close(); + expect(resources.hasPending).toBe(false); + expect(failed.error.cleanupIncomplete).toBe(false); + } finally { + resume.release(); + blocked = false; + await Promise.allSettled(launched); + await resources.close(); + } +}); + +it("refuses new work after shutdown without reporting a missing bundle", async () => { + const resources = new ArtifactResourceScope(); + await resources.close(); + let reads = 0; + await expect( + searchGuidanceWithReader( + { query: "needle" }, + async (path) => { + reads += 1; + return content(path); + }, + resources, + ), + ).rejects.toMatchObject({ reason: "unavailable" }); + expect(reads).toBe(0); +}); diff --git a/src/application/GuidanceAccess.test.ts b/src/application/GuidanceAccess.test.ts new file mode 100644 index 0000000000..3ceca83f02 --- /dev/null +++ b/src/application/GuidanceAccess.test.ts @@ -0,0 +1,320 @@ +import { expect, it } from "vitest"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { RegularFileCleanupFailure } from "../filesystem/OwnedRegularFile.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { MANAGED_SKILL_FILES } from "./SetupSkill.js"; +import { searchGuidanceOutputSchema } from "../contracts/guidanceContracts.js"; +import { RetainedRegularFileCleanupFailure } from "./RegularFileRead.js"; +import { + readGuidanceSectionWithReader, + searchGuidanceWithReader, +} from "./GuidanceAccess.js"; + +const moduleGuide = `# JavaScript applications\n\n## Read a module without repeating analysis\n\nFor an exact JavaScript module (javascript-module), select its source path and module identity:\n\n\`\`\`json\n{"node_id":"module:main.js","kind":"javascript-module"}\n\`\`\`\n`; +const guideFixture = async (path: string): Promise => + path === "references/javascript-applications.md" + ? moduleGuide + : `# ${path}\n\nGeneral module guidance for pagination.\n`; + +const valueOf = async ( + request: ReturnType, +) => { + const result = await request; + if (!result.ok) throw result.error; + return searchGuidanceOutputSchema.parse(result.value); +}; + +it("ranks a focused JavaScript module section and retrieves it with its locator", async () => { + const resources = new ArtifactResourceScope(); + const result = await valueOf( + searchGuidanceWithReader( + { query: "exact JavaScript module" }, + guideFixture, + resources, + ), + ); + expect(result.status).toBe("matches"); + if (result.status !== "matches") throw new Error("Expected guide matches"); + expect(result.matches[0]).toMatchObject({ + guide: "references/javascript-applications.md", + heading: "Read a module without repeating analysis", + matched_terms: ["exact", "javascript", "module"], + context: expect.stringContaining("javascript-module"), + }); + const selected = result.matches[0]; + if (selected === undefined) throw new Error("Expected selected guidance"); + const retrieved = await readGuidanceSectionWithReader( + { locator: selected.locator }, + guideFixture, + resources, + ); + if (!retrieved.ok) throw retrieved.error; + expect(retrieved.value).toMatchObject({ + status: "section", + source: "installed_rea_guidance", + guide: selected.guide, + heading: selected.heading, + locator: selected.locator, + content: expect.stringContaining('"node_id":"module:main.js"'), + }); +}); + +it("returns an honest no-match result and rejects empty queries and invalid bounds", async () => { + const resources = new ArtifactResourceScope(); + expect( + await valueOf( + searchGuidanceWithReader( + { query: "unlikely qzxv term" }, + guideFixture, + resources, + ), + ), + ).toMatchObject({ + status: "no_matches", + total_matches: 0, + message: expect.stringContaining("does not indicate"), + }); + expect( + await valueOf( + searchGuidanceWithReader( + { query: "how do I frobnicate qzxv" }, + guideFixture, + resources, + ), + ), + ).toMatchObject({ + status: "no_matches", + total_matches: 0, + }); + expect( + await searchGuidanceWithReader({ query: " " }, guideFixture, resources), + ).toMatchObject({ ok: false }); + expect( + await searchGuidanceWithReader( + { query: "x".repeat(2049) }, + guideFixture, + resources, + ), + ).toMatchObject({ ok: false }); + expect( + await searchGuidanceWithReader( + { query: "module", limit: 21 }, + guideFixture, + resources, + ), + ).toMatchObject({ ok: false }); +}); + +it("does not treat Markdown headings inside fenced code as section boundaries", async () => { + const result = await valueOf( + searchGuidanceWithReader( + { query: "phantom-heading-token" }, + async (path) => + `${await guideFixture(path)}\n\n\`\`\`markdown\n# phantom-heading-token\n\`\`\`\n`, + new ArtifactResourceScope(), + ), + ); + expect(result.status).toBe("matches"); + if (result.status !== "matches") throw new Error("Expected guide matches"); + expect(result.matches.map(({ heading }) => heading)).not.toContain( + "phantom-heading-token", + ); +}); + +it("refuses an oversized guide bundle instead of indexing partial content", async () => { + const result = await searchGuidanceWithReader( + { query: "module" }, + async (path) => + path === "SKILL.md" ? "x".repeat(4_194_305) : guideFixture(path), + new ArtifactResourceScope(), + ); + expect(result).toMatchObject({ + ok: true, + value: { status: "bundle_unavailable" }, + }); +}); + +it("paginates deterministically without losing, duplicating, or reordering matches", async () => { + const readGuide = guideFixture; + const first = await valueOf( + searchGuidanceWithReader( + { query: "module", limit: 2 }, + readGuide, + new ArtifactResourceScope(), + ), + ); + const repeat = await valueOf( + searchGuidanceWithReader( + { query: "module", limit: 2 }, + readGuide, + new ArtifactResourceScope(), + ), + ); + expect(first).toEqual(repeat); + if (first.status !== "matches") throw new Error("Expected guide matches"); + expect(first.has_more).toBe(true); + const all = first.matches.map(({ locator }) => locator); + let cursor = first.next_cursor; + while (cursor !== undefined) { + const page = await valueOf( + searchGuidanceWithReader( + { query: "module", limit: 2, cursor }, + readGuide, + new ArtifactResourceScope(), + ), + ); + if (page.status !== "matches") throw new Error("Expected guide matches"); + all.push(...page.matches.map(({ locator }) => locator)); + cursor = page.next_cursor; + } + const complete = await valueOf( + searchGuidanceWithReader( + { query: "module", limit: 20 }, + readGuide, + new ArtifactResourceScope(), + ), + ); + if (complete.status !== "matches") throw new Error("Expected guide matches"); + expect(new Set(all).size).toBe(all.length); + expect(all).toEqual(complete.matches.map(({ locator }) => locator)); +}); + +it("rejects malformed locators and reports changed or missing installed guides", async () => { + const resources = new ArtifactResourceScope(); + const search = await valueOf( + searchGuidanceWithReader( + { query: "exact JavaScript module" }, + guideFixture, + resources, + ), + ); + if (search.status !== "matches") throw new Error("Expected guide matches"); + expect( + await readGuidanceSectionWithReader( + { locator: "../../outside.md" }, + guideFixture, + resources, + ), + ).toMatchObject({ ok: false }); + const changedReader = async (path: string) => + `${await guideFixture(path)}\nChanged after search.\n`; + const stale = await readGuidanceSectionWithReader( + { locator: search.matches[0]?.locator }, + changedReader, + resources, + ); + expect(stale).toMatchObject({ ok: true, value: { status: "stale_locator" } }); + const firstPage = await valueOf( + searchGuidanceWithReader( + { query: "module", limit: 1 }, + guideFixture, + resources, + ), + ); + if (firstPage.status !== "matches" || firstPage.next_cursor === undefined) + throw new Error("Expected a continuation token"); + expect( + await searchGuidanceWithReader( + { query: "module", limit: 1, cursor: firstPage.next_cursor }, + changedReader, + resources, + ), + ).toMatchObject({ ok: true, value: { status: "stale_continuation" } }); + expect( + await searchGuidanceWithReader( + { query: "different query", cursor: firstPage.next_cursor }, + guideFixture, + resources, + ), + ).toMatchObject({ ok: false }); + expect( + await searchGuidanceWithReader( + { query: "module", cursor: "garbage" }, + guideFixture, + resources, + ), + ).toMatchObject({ ok: false }); + const missingReader = async (): Promise => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }; + expect( + await searchGuidanceWithReader( + { query: "module" }, + missingReader, + resources, + ), + ).toMatchObject({ ok: true, value: { status: "bundle_unavailable" } }); + expect( + await readGuidanceSectionWithReader( + { locator: search.matches[0]?.locator }, + missingReader, + resources, + ), + ).toMatchObject({ ok: true, value: { status: "bundle_unavailable" } }); +}); + +it("returns cleanup failures separately and retains their owner until retry succeeds", async () => { + const resources = new ArtifactResourceScope(); + let failRelease = true; + const failure = new RegularFileCleanupFailure({ + path: "guidance file handle", + owner: { + close: async () => { + if (failRelease) throw new Error("descriptor close failed"); + }, + } as never, + outcome: { kind: "failed", cause: new Error("guide read failed") }, + cleanupCause: new Error("descriptor close failed"), + }); + const retainedFailure = new RetainedRegularFileCleanupFailure(failure); + resources.retain(retainedFailure.cleanupOwner); + const result = await searchGuidanceWithReader( + { query: "module" }, + async () => { + throw retainedFailure; + }, + resources, + ); + if (result.ok) throw new Error("Expected a retained cleanup failure"); + expect(result.error).toMatchObject({ cleanupIncomplete: true }); + expect(result.error.cleanupResources).toEqual(["guidance file handle"]); + expect(resources.hasPending).toBe(true); + failRelease = false; + await resources.close(); + expect(resources.hasPending).toBe(false); + expect(result.error.cleanupIncomplete).toBe(false); + expect(result.error.cleanupResources).toEqual([]); + + const freshResources = new ArtifactResourceScope(); + freshResources.retain({ + kind: "lease", + resource: "unrelated pending resource", + lease: { release: async () => undefined }, + }); + expect( + await searchGuidanceWithReader( + { query: "module" }, + async () => { + throw new Error("guide is unreadable"); + }, + freshResources, + ), + ).toMatchObject({ + ok: true, + value: { status: "bundle_unavailable" }, + }); + await freshResources.close(); +}); + +it("uses only the fixed managed guide inventory for injected reads", async () => { + const requested: string[] = []; + await searchGuidanceWithReader( + { query: "module" }, + async (path) => { + requested.push(path); + return guideFixture(path); + }, + new ArtifactResourceScope(), + ); + expect(requested).toEqual([...MANAGED_SKILL_FILES]); +}); diff --git a/src/application/GuidanceAccess.ts b/src/application/GuidanceAccess.ts new file mode 100644 index 0000000000..3087102d7d --- /dev/null +++ b/src/application/GuidanceAccess.ts @@ -0,0 +1,602 @@ +import { createHash } from "node:crypto"; +import { fileURLToPath } from "node:url"; +import type { z } from "zod"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js"; +import { MANAGED_SKILL_FILES, readSkillFile } from "./SetupSkill.js"; +import { PRODUCT_IDENTITY } from "../identity.js"; +import { + readGuidanceSectionInputSchema, + readGuidanceSectionOutputSchema, + type ReadGuidanceSectionOutput, + type SearchGuidanceOutput, + searchGuidanceInputSchema, + searchGuidanceOutputSchema, +} from "../contracts/guidanceContracts.js"; +import { AnalysisInputError } from "../domain/analysisErrorCore.js"; +import { ProviderCleanupError } from "../domain/providerCleanupError.js"; +import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js"; +import { + bindRegularFileCleanup, + RetainedRegularFileCleanupFailure, +} from "./RegularFileRead.js"; +import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js"; +import { err, ok, type Result } from "../domain/result.js"; +import { + parseGuidanceSections, + type GuideSection, +} from "./GuidanceSections.js"; + +const SKILL_ROOT = `../../skills/${PRODUCT_IDENTITY.skillName}/`; +const SOURCE = "installed_rea_guidance" as const; +const MAX_GUIDE_FILE_BYTES = 1_048_576; +const MAX_GUIDE_BUNDLE_BYTES = 4_194_304; +const sha256 = (value: string | Uint8Array): string => + createHash("sha256").update(value).digest("hex"); + +interface GuidanceBundle { + readonly digest: string; + readonly sections: readonly GuideSection[]; +} + +const readPackagedGuide = ( + relativePath: string, + resources: ArtifactResourceScope, +): Promise => + readSkillFile( + fileURLToPath(new URL(`${SKILL_ROOT}${relativePath}`, import.meta.url)), + resources, + MAX_GUIDE_FILE_BYTES, + ); + +type GuidanceBundleRead = + | { readonly kind: "available"; readonly bundle: GuidanceBundle } + | { readonly kind: "unavailable"; readonly message: string } + | { readonly kind: "cleanup"; readonly error: ProviderCleanupError }; + +const guidanceCleanupFailure = ( + cause: unknown, + resources: ArtifactResourceScope, + operation: string, +): ProviderCleanupError => { + const observation = ArtifactReaderFailure.cleanupObservation( + cause, + resources.cleanupResources, + ); + const error = new ProviderCleanupError( + "rea", + resources.cleanupResources, + { phase: "managed_guidance_read", reason: observation.reason }, + { + operation, + cause, + cleanup: observation, + remediationAction: + "Close REA so it can retry releasing the retained guidance file handle. Do not repeat the request until cleanup completes.", + }, + ); + bindRegularFileCleanup(cause, error); + return error; +}; + +const readBundle = async ( + readGuide: (relativePath: string) => Promise, + resources: ArtifactResourceScope, + operation: string, +): Promise => { + const files = new Map(); + let bundleBytes = 0; + try { + for (const relativePath of MANAGED_SKILL_FILES) { + const content = await readGuide(relativePath); + bundleBytes += Buffer.byteLength(content, "utf8"); + if (bundleBytes > MAX_GUIDE_BUNDLE_BYTES) + return { + kind: "unavailable", + message: + "Installed REA guidance exceeds the 4 MiB bundle allowance; no partial bundle was indexed.", + }; + files.set(relativePath, content.replaceAll("\r\n", "\n")); + } + } catch (cause: unknown) { + return cause instanceof RetainedRegularFileCleanupFailure || + (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + ? { + kind: "cleanup", + error: guidanceCleanupFailure(cause, resources, operation), + } + : { + kind: "unavailable", + message: + "Installed REA guidance is missing or unreadable; no complete bundle could be indexed.", + }; + } + const digest = sha256( + [...files] + .map(([path, content]) => `${path}\0${sha256(content)}\n`) + .join(""), + ); + const sections = parseGuidanceSections(files); + if (sections === undefined) + return { + kind: "unavailable", + message: + "Installed REA guidance exceeds the 4,096-section or 25,165,824 expanded UTF-16-code-unit index allowance; no partial index was used.", + }; + return { kind: "available", bundle: { digest, sections } }; +}; + +const words = (text: string): string[] => [ + ...new Set(text.toLowerCase().match(/[\p{L}\p{N}]+/gu) ?? []), +]; + +const GENERIC_QUERY_WORDS: ReadonlySet = new Set([ + "a", + "about", + "an", + "and", + "are", + "as", + "at", + "be", + "been", + "by", + "can", + "could", + "did", + "do", + "does", + "for", + "from", + "how", + "i", + "in", + "is", + "it", + "my", + "of", + "on", + "or", + "should", + "so", + "that", + "the", + "their", + "there", + "they", + "this", + "to", + "was", + "we", + "were", + "what", + "when", + "where", + "which", + "who", + "why", + "will", + "with", + "would", + "you", + "your", +]); + +const sectionLocator = ( + bundle: GuidanceBundle, + section: GuideSection, +): string => { + const identity = JSON.stringify({ + v: 1, + bundle: bundle.digest, + guide: section.guide, + ordinal: section.ordinal, + section: sha256(section.content), + }); + return `rea-guide-v1.${Buffer.from(identity).toString("base64url")}`; +}; + +type GuidanceReader = ( + relativePath: string, + resources: ArtifactResourceScope, +) => Promise; +type GuidanceResult = Result< + Value, + AnalysisInputError | ProviderCleanupError +>; + +/** Admit the complete request once; file reads belong to its independent child. */ +const runGuidanceOperation = async ( + resources: ArtifactResourceScope, + operationName: string, + operation: (child: ArtifactResourceScope) => Promise>, +): Promise> => { + try { + return await resources.run(async () => { + const child = new ArtifactResourceScope(); + try { + return await operation(child); + } finally { + if (child.hasPending) + resources.retain({ + kind: "lease", + resource: "managed guidance operation", + lease: new AnalysisCleanupLease(() => child.close(), { + get cleanupResources() { + return child.cleanupResources; + }, + }), + }); + else await child.close(); + } + }); + } catch (cause: unknown) { + if (cause instanceof ArtifactReaderFailure && cause.cleanup !== undefined) + return err(guidanceCleanupFailure(cause, resources, operationName)); + throw cause; + } +}; + +/** Reader seams receive the operation child rather than re-entering the root. */ +export const searchGuidanceWithReader = ( + input: unknown, + readGuide: GuidanceReader, + resources: ArtifactResourceScope, +): Promise> => + runGuidanceOperation(resources, "search_guidance", (child) => + searchGuidanceInScope(input, (path) => readGuide(path, child), child), + ); + +export const readGuidanceSectionWithReader = ( + input: unknown, + readGuide: GuidanceReader, + resources: ArtifactResourceScope, +): Promise> => + runGuidanceOperation(resources, "read_guidance_section", (child) => + readGuidanceSectionInScope(input, (path) => readGuide(path, child), child), + ); + +const invalidInput = ( + operation: string, + input: unknown, + issues: readonly z.core.$ZodIssue[], +): Result => + err(analysisInputErrorFromIssues(operation, issues, input)); + +export const searchGuidance = async ( + input: unknown, + resources: ArtifactResourceScope, +): Promise< + Result +> => searchGuidanceWithReader(input, readPackagedGuide, resources); + +/** Reader seam keeps packaged-guide drift and missing-file behavior testable. */ +const searchGuidanceInScope = async ( + input: unknown, + readGuide: (relativePath: string) => Promise, + resources: ArtifactResourceScope, +): Promise< + Result +> => { + const parsed = searchGuidanceInputSchema.safeParse(input); + if (!parsed.success) + return invalidInput("search_guidance", input, parsed.error.issues); + const cursor = + parsed.data.cursor === undefined + ? undefined + : decodeGuidanceCursor(parsed.data.cursor); + if (parsed.data.cursor !== undefined && cursor === undefined) + return invalidInput("search_guidance", input, [ + { + code: "custom", + path: ["cursor"], + message: "Invalid guidance continuation token", + }, + ]); + const queryDigest = sha256(parsed.data.query); + if (cursor !== undefined && cursor.query !== queryDigest) + return invalidInput("search_guidance", input, [ + { + code: "custom", + path: ["cursor"], + message: "The continuation token belongs to a different query", + }, + ]); + const loaded = await readBundle(readGuide, resources, "search_guidance"); + if (loaded.kind === "cleanup") return err(loaded.error); + if (loaded.kind === "unavailable") + return ok( + searchGuidanceOutputSchema.parse({ + status: "bundle_unavailable", + source: SOURCE, + content_kind: "documentation", + message: loaded.message, + }), + ); + const bundle = loaded.bundle; + + if (cursor !== undefined && cursor.bundle !== bundle.digest) + return ok( + searchGuidanceOutputSchema.parse({ + status: "stale_continuation", + source: SOURCE, + content_kind: "documentation", + message: + "The installed guidance changed since this search page was issued. Restart the search to continue with current content.", + }), + ); + + const queryWords = words(parsed.data.query); + const specificQueryWords = queryWords.filter( + (word) => !GENERIC_QUERY_WORDS.has(word), + ); + const queryWordsToMatch = specificQueryWords; + const ranked = bundle.sections + .flatMap((section) => { + const sectionWords = new Set(words(section.content)); + const matchedSpecific = queryWordsToMatch.filter((word) => + sectionWords.has(word), + ); + if ( + queryWordsToMatch.length === 0 || + matchedSpecific.length !== queryWordsToMatch.length + ) + return []; + const matched = queryWords.filter((word) => sectionWords.has(word)); + const headingWords = new Set(words(section.heading)); + const score = + matched.length + + matched.filter((word) => headingWords.has(word)).length * 2; + return { + section, + matched, + score, + wordCount: sectionWords.size, + }; + }) + .sort( + (left, right) => + right.score - left.score || + // Prefer a focused recipe over a long overview when term scores tie. + left.wordCount - right.wordCount || + compareText(left.section.guide, right.section.guide) || + left.section.ordinal - right.section.ordinal, + ); + + if (ranked.length === 0) + return ok( + searchGuidanceOutputSchema.parse({ + status: "no_matches", + source: SOURCE, + content_kind: "documentation", + query: parsed.data.query, + message: + "No bundled guidance sections matched this query. This does not indicate whether an analysis capability is available.", + total_matches: 0, + }), + ); + + const offset = cursor?.offset ?? 0; + if (cursor !== undefined && offset >= ranked.length) + return invalidInput("search_guidance", input, [ + { + code: "custom", + path: ["cursor"], + message: + "The continuation token does not select a remaining result page", + }, + ]); + const page = ranked.slice(offset, offset + parsed.data.limit); + const hasMore = offset + page.length < ranked.length; + return ok( + searchGuidanceOutputSchema.parse({ + status: "matches", + source: SOURCE, + content_kind: "documentation", + package: `${PRODUCT_IDENTITY.packageName}@${PRODUCT_IDENTITY.packageVersion}`, + bundle_sha256: bundle.digest, + query: parsed.data.query, + matches: page.map(({ section, matched }) => ({ + guide: section.guide, + heading: section.heading, + locator: sectionLocator(bundle, section), + matched_terms: matched, + relevance: `Matched ${matched.join(", ")} in this section.`, + context: contextFor(section.content, matched), + })), + total_matches: ranked.length, + offset, + limit: parsed.data.limit, + has_more: hasMore, + ...(hasMore + ? { + next_cursor: encodeGuidanceCursor({ + v: 1, + bundle: bundle.digest, + query: queryDigest, + offset: offset + page.length, + }), + } + : {}), + }), + ); +}; + +export const readGuidanceSection = async ( + input: unknown, + resources: ArtifactResourceScope, +): Promise< + Result +> => readGuidanceSectionWithReader(input, readPackagedGuide, resources); + +/** Reader seam keeps packaged-guide drift and missing-file behavior testable. */ +const readGuidanceSectionInScope = async ( + input: unknown, + readGuide: (relativePath: string) => Promise, + resources: ArtifactResourceScope, +): Promise< + Result +> => { + const parsed = readGuidanceSectionInputSchema.safeParse(input); + if (!parsed.success) + return invalidInput("read_guidance_section", input, parsed.error.issues); + const identity = decodeLocator(parsed.data.locator); + if (identity === undefined) + return invalidInput("read_guidance_section", input, [ + { + code: "custom", + path: ["locator"], + message: "Malformed or unsupported guidance locator", + }, + ]); + const loaded = await readBundle( + readGuide, + resources, + "read_guidance_section", + ); + if (loaded.kind === "cleanup") return err(loaded.error); + if (loaded.kind === "unavailable") + return ok( + readGuidanceSectionOutputSchema.parse({ + status: "bundle_unavailable", + source: SOURCE, + content_kind: "documentation", + message: loaded.message, + }), + ); + const bundle = loaded.bundle; + if (identity.bundle !== bundle.digest) + return ok( + readGuidanceSectionOutputSchema.parse({ + status: "stale_locator", + source: SOURCE, + content_kind: "documentation", + message: + "The installed guidance changed since this locator was issued. Search again for a current locator.", + }), + ); + const section = bundle.sections.find( + (candidate) => + candidate.guide === identity.guide && + candidate.ordinal === identity.ordinal && + sha256(candidate.content) === identity.section, + ); + if (section === undefined) + return ok( + readGuidanceSectionOutputSchema.parse({ + status: "stale_locator", + source: SOURCE, + content_kind: "documentation", + message: + "The selected section is no longer present in the installed guidance. Search again.", + }), + ); + return ok( + readGuidanceSectionOutputSchema.parse({ + status: "section", + source: SOURCE, + content_kind: "documentation", + package: `${PRODUCT_IDENTITY.packageName}@${PRODUCT_IDENTITY.packageVersion}`, + bundle_sha256: bundle.digest, + guide: section.guide, + heading: section.heading, + locator: parsed.data.locator, + content: section.content, + }), + ); +}; + +interface LocatorIdentity { + readonly v: 1; + readonly bundle: string; + readonly guide: string; + readonly ordinal: number; + readonly section: string; +} + +interface GuidanceCursor { + readonly v: 1; + readonly bundle: string; + readonly query: string; + readonly offset: number; +} + +const encodeGuidanceCursor = (cursor: GuidanceCursor): string => + `rea-guidance-page-v1.${Buffer.from(JSON.stringify(cursor)).toString("base64url")}`; + +const decodeGuidanceCursor = (cursor: string): GuidanceCursor | undefined => { + const prefix = "rea-guidance-page-v1."; + if (!cursor.startsWith(prefix)) return undefined; + try { + const value: unknown = JSON.parse( + Buffer.from(cursor.slice(prefix.length), "base64url").toString("utf8"), + ); + if (typeof value !== "object" || value === null) return undefined; + const item = value as Partial; + if ( + item.v !== 1 || + typeof item.bundle !== "string" || + !/^[a-f0-9]{64}$/u.test(item.bundle) || + typeof item.query !== "string" || + !/^[a-f0-9]{64}$/u.test(item.query) || + typeof item.offset !== "number" || + !Number.isInteger(item.offset) || + item.offset < 1 + ) + return undefined; + return item as GuidanceCursor; + } catch { + return undefined; + } +}; + +const decodeLocator = (locator: string): LocatorIdentity | undefined => { + if (!locator.startsWith("rea-guide-v1.")) return undefined; + try { + const value: unknown = JSON.parse( + Buffer.from(locator.slice("rea-guide-v1.".length), "base64url").toString( + "utf8", + ), + ); + if (typeof value !== "object" || value === null) return undefined; + const item = value as Partial; + if ( + item.v !== 1 || + typeof item.bundle !== "string" || + !/^[a-f0-9]{64}$/u.test(item.bundle) || + typeof item.guide !== "string" || + !(MANAGED_SKILL_FILES as readonly string[]).includes(item.guide) || + !Number.isInteger(item.ordinal) || + typeof item.section !== "string" || + !/^[a-f0-9]{64}$/u.test(item.section) + ) + return undefined; + return item as LocatorIdentity; + } catch { + return undefined; + } +}; + +const contextFor = (content: string, matched: readonly string[]): string => { + const lines = content.split("\n"); + const heading = lines[0] ?? ""; + const terms = new Set(matched); + let bestIndex = -1; + let bestScore = 0; + lines.slice(1).forEach((line, index) => { + const score = words(line).filter((word) => terms.has(word)).length; + if (score > bestScore) { + bestScore = score; + bestIndex = index + 1; + } + }); + const excerptStart = bestIndex < 0 ? 1 : Math.max(1, bestIndex - 1); + const excerptEnd = bestIndex < 0 ? 4 : bestIndex + 2; + return [heading, ...lines.slice(excerptStart, excerptEnd)] + .join(" ") + .replaceAll(/\s+/gu, " ") + .slice(0, 500); +}; + +const compareText = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; diff --git a/src/application/GuidanceSections.ts b/src/application/GuidanceSections.ts new file mode 100644 index 0000000000..629bdc60f2 --- /dev/null +++ b/src/application/GuidanceSections.ts @@ -0,0 +1,123 @@ +/** Parsed documentation facts; callers never reinterpret Markdown boundaries. */ +export interface GuideSection { + readonly guide: string; + readonly heading: string; + readonly content: string; + readonly ordinal: number; +} + +// The shipped bundle has tens of headings. These ceilings bound object count +// and UTF-16 text retained by a complete index before allocating section text. +const MAX_SECTIONS = 4096; +// ATX has at most six enclosing levels over the admitted 4 MiB bundle. +const MAX_EXPANDED_CHARACTERS = 6 * 4_194_304; + +const blank = (value: string | undefined): boolean => + value === " " || value === "\t"; + +/** ATX closing hashes and ASCII whitespace, scanned once from each end. */ +const headingTitle = (raw: string): string => { + let start = 0; + let end = raw.length; + while (start < end && blank(raw[start])) start += 1; + while (end > start && blank(raw[end - 1])) end -= 1; + let hashes = end; + while (hashes > start && raw[hashes - 1] === "#") hashes -= 1; + if (hashes < end && (hashes === start || blank(raw[hashes - 1]))) { + end = hashes; + while (end > start && blank(raw[end - 1])) end -= 1; + } + return raw.slice(start, end); +}; + +interface Heading { + readonly level: number; + readonly title: string; + readonly start: number; + end: number; +} + +const atxHeading = ( + line: string, +): Pick | undefined => { + const match = /^ {0,3}(#{1,6})(?:[ \t]+(.*)|$)/u.exec(line); + if (match?.[1] === undefined) return undefined; + const raw = match[2] ?? ""; + // Closing hashes need whitespace; literal C# and escaped hashes survive. + const title = headingTitle(raw); + return { level: match[1].length, title }; +}; + +const readHeadings = ( + content: string, + remaining: number, +): Heading[] | undefined => { + const headings: Heading[] = []; + const enclosing: Heading[] = []; + let fence: { marker: string; length: number } | undefined; + let start = 0; + while (start < content.length) { + const newline = content.indexOf("\n", start); + const end = newline < 0 ? content.length : newline; + const line = content.slice(start, end); + const fenceMatch = /^ {0,3}(`{3,}|~{3,})(.*)$/u.exec(line); + const marker = fenceMatch?.[1]; + if (fence !== undefined) { + if ( + marker?.[0] === fence.marker && + marker.length >= fence.length && + /^[ \t]*$/u.test(fenceMatch?.[2] ?? "") + ) + fence = undefined; + } else if ( + marker !== undefined && + !(marker[0] === "`" && fenceMatch?.[2]?.includes("`")) + ) { + fence = { marker: marker[0] ?? "", length: marker.length }; + } else { + const parsed = atxHeading(line); + if (parsed !== undefined) { + if (headings.length >= remaining) return undefined; + while ((enclosing.at(-1)?.level ?? 0) >= parsed.level) { + const parent = enclosing.pop(); + if (parent !== undefined) parent.end = start; + } + const heading = { ...parsed, start, end: content.length }; + headings.push(heading); + enclosing.push(heading); + } + } + start = newline < 0 ? content.length : newline + 1; + } + return headings; +}; + +/** Bound the whole bundle before materializing an expanded section index. */ +export const parseGuidanceSections = ( + files: ReadonlyMap, +): readonly GuideSection[] | undefined => { + const documents: { guide: string; content: string; headings: Heading[] }[] = + []; + let count = 0; + let expanded = 0; + for (const [guide, content] of files) { + const headings = readHeadings(content, MAX_SECTIONS - count); + if (headings === undefined) return undefined; + count += headings.length; + for (const heading of headings) { + expanded += heading.end - heading.start; + if (expanded > MAX_EXPANDED_CHARACTERS) return undefined; + } + documents.push({ guide, content, headings }); + } + const sections: GuideSection[] = []; + for (const { guide, content, headings } of documents) + for (const [ordinal, heading] of headings.entries()) + sections.push({ + guide, + ordinal, + heading: heading.title, + content: content.slice(heading.start, heading.end).trim(), + }); + return sections; +}; diff --git a/src/application/SetupSkill.ts b/src/application/SetupSkill.ts index bc4c83b339..14883e8105 100644 --- a/src/application/SetupSkill.ts +++ b/src/application/SetupSkill.ts @@ -43,21 +43,27 @@ export const isManagedSkillManifest = (content: string): boolean => export const readSkillFile = ( path: string, resources?: ArtifactResourceScope, + maxBytes = Number.MAX_SAFE_INTEGER, ): Promise => - readSkillFileSnapshot(path, resources).then(({ bytes }) => + readSkillFileSnapshot(path, resources, maxBytes).then(({ bytes }) => bytes.toString("utf8"), ); const readSkillFileSnapshot = async ( path: string, resources?: ArtifactResourceScope, + maxBytes = Number.MAX_SAFE_INTEGER, ): Promise => withRegularFile( path, async (handle, stats) => { - const bytes = await readBoundedFileBytes(handle, stats.size, { - expectedSize: stats.size, - }); + const bytes = await readBoundedFileBytes( + handle, + Math.min(stats.size, maxBytes), + { + expectedSize: stats.size, + }, + ); if (bytes === undefined || bytes.length !== stats.size) throw new RegularFileChangedError(path); return { bytes, mode: stats.mode & 0o7777, state: stats }; diff --git a/src/cli.ts b/src/cli.ts index 6d6b70286c..cf0a386dae 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -79,9 +79,26 @@ import { excludeDeclaredSensitiveArguments } from "./cli/explicitSensitiveArgume import { rejectSurplusCliArguments } from "./cli/strictPositionals.js"; import { presentCliArgumentErrors } from "./cli/argumentErrors.js"; import { completionHelpNote, createCliCompletions } from "./cli/completions.js"; +import { + closeCliGuidanceResources, + registerGuidanceCommands, +} from "./cli/guidanceCommands.js"; +import { closeCliJavaScriptResources } from "./cli/javascriptApplicationAnalysis.js"; import { registerGuidedWorkflowCommands } from "./cli/guidedWorkflowCommands.js"; -export { closeCliJavaScriptResources } from "./cli/javascriptApplicationAnalysis.js"; +/** Close every process-owned resource used by CLI commands. */ +export const closeCliResources = async (): Promise => { + const results = await Promise.allSettled([ + closeCliJavaScriptResources(), + closeCliGuidanceResources(), + ]); + const failures = results.flatMap((result) => + result.status === "rejected" ? [result.reason] : [], + ); + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "CLI resource cleanup failed"); +}; /** * Build the one-shot Incur CLI without starting Hopper at import time. @@ -153,6 +170,7 @@ export const createCli = ( const cli = timedCli as unknown as CliInstance; registerSetupCommands(cli, logger, environment); + registerGuidanceCommands(cli, logger); registerGuidedWorkflowCommands(cli, logger); registerReverseEngineeringCommands(cli, logger, environment); registerCutterCommands(cli, logger, environment); diff --git a/src/cli/guidanceCommands.ts b/src/cli/guidanceCommands.ts new file mode 100644 index 0000000000..78b14a43d4 --- /dev/null +++ b/src/cli/guidanceCommands.ts @@ -0,0 +1,120 @@ +import { z } from "incur"; +import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { + readGuidanceSection, + searchGuidance, +} from "../application/GuidanceAccess.js"; +import { + readGuidanceSectionInputSchema, + searchGuidanceLimitSchema, + searchGuidanceInputSchema, +} from "../contracts/guidanceContracts.js"; +import { CLI_COMMANDS } from "../cliCommandNames.js"; +import { logCliCommand } from "../cliLogging.js"; +import { projectAnalysisError } from "../domain/analysisErrorProjection.js"; +import type { Logger } from "pino"; +import type { CliInstance } from "./types.js"; + +let retainedGuidanceResources: ArtifactResourceScope | undefined; + +const guidanceResources = (): ArtifactResourceScope => { + retainedGuidanceResources ??= new ArtifactResourceScope(); + return retainedGuidanceResources; +}; + +/** Release CLI-owned guidance descriptors, retaining failed cleanup for retry. */ +export const closeCliGuidanceResources = async (): Promise => { + const resources = retainedGuidanceResources; + if (resources === undefined) return; + await resources.close(); + if (retainedGuidanceResources === resources) + retainedGuidanceResources = undefined; +}; + +/** Register offline search and retrieval for the installed REA guide bundle. */ +export const registerGuidanceCommands = ( + cli: CliInstance, + logger: Logger, +): void => { + cli.command(CLI_COMMANDS.guidance, { + description: "Search or read the installed REA workflow guidance", + args: z.object({}), + options: z + .object({ + action: z + .enum(["search", "read"]) + .default("search") + .describe("Guidance action to perform: search or read"), + query: searchGuidanceInputSchema.shape.query + .optional() + .describe( + "Required with search; all non-generic query words must match", + ), + locator: readGuidanceSectionInputSchema.shape.locator + .optional() + .describe("Required with --action read; locator returned by search"), + limit: searchGuidanceLimitSchema + .optional() + .describe("Maximum search matches; search only, defaults to 8"), + cursor: searchGuidanceInputSchema.shape.cursor + .optional() + .describe("Continue a search page; search only"), + }) + .superRefine((options, context) => { + if (options.action === "search") { + if (options.query === undefined) + context.addIssue({ + code: "custom", + path: ["query"], + message: "--query is required with --action search", + }); + if (options.locator !== undefined) + context.addIssue({ + code: "custom", + path: ["locator"], + message: "--locator can only be used with --action read", + }); + } else { + if (options.locator === undefined) + context.addIssue({ + code: "custom", + path: ["locator"], + message: "--locator is required with --action read", + }); + for (const option of ["query", "limit", "cursor"] as const) + if (options[option] !== undefined) + context.addIssue({ + code: "custom", + path: [option], + message: `--${option} can only be used with --action search`, + }); + } + }), + run: ({ options }) => + logCliCommand(logger, CLI_COMMANDS.guidance, async () => { + const result = + options.action === "search" + ? await searchGuidance( + { + ...(options.query === undefined + ? {} + : { query: options.query }), + ...(options.limit === undefined + ? {} + : { limit: options.limit }), + ...(options.cursor === undefined + ? {} + : { cursor: options.cursor }), + }, + guidanceResources(), + ) + : await readGuidanceSection( + { + locator: options.locator, + }, + guidanceResources(), + ); + return result.ok ? result.value : projectAnalysisError(result.error); + }), + }); +}; diff --git a/src/cliCommandNames.ts b/src/cliCommandNames.ts index 383731647b..63d91892b4 100644 --- a/src/cliCommandNames.ts +++ b/src/cliCommandNames.ts @@ -29,6 +29,7 @@ export const CLI_COMMANDS = Object.freeze({ inspectNativeDispatchMetadata: "inspect-native-dispatch-metadata", capabilities: "capabilities", providers: "providers", + guidance: "guidance", guidedWorkflows: "guided-workflows", function: "function", annotateNativeFunction: "annotate-native-function", diff --git a/src/contracts/applicationToolContracts.ts b/src/contracts/applicationToolContracts.ts index c47144c83c..762a11b06c 100644 --- a/src/contracts/applicationToolContracts.ts +++ b/src/contracts/applicationToolContracts.ts @@ -27,6 +27,12 @@ import { appleApplicationProjectionResultSchema } from "../domain/apple/appleApp import { harmonyApplicationProjectionResultSchema } from "../domain/harmony/harmonyApplication.js"; import type { ToolContract } from "./toolContractTypes.js"; import { toolContractMetadata } from "./toolEffects.js"; +import { + readGuidanceSectionInputSchema, + readGuidanceSectionOutputSchema, + searchGuidanceInputSchema, + searchGuidanceOutputSchema, +} from "./guidanceContracts.js"; import { guidedWorkflowsInputSchema, guidedWorkflowsOutputSchema, @@ -73,6 +79,21 @@ const retained = (evidence_id: string) => ({ /** Provider-neutral graph workflow contracts shared by MCP and CLI adapters. */ export const APPLICATION_TOOL_CONTRACTS = [ + { + name: "search_guidance", + ...toolContractMetadata("search_guidance"), + description: + "Search only the installed REA workflow guidance. Every non-generic query word must occur in a section. Returns ranked sections, bounded pages, and locators for read_guidance_section. No target or provider is accessed.", + kind: "application", + inputSchema: searchGuidanceInputSchema, + outputSchema: searchGuidanceOutputSchema, + examples: [ + { + title: "Find exact JavaScript module guidance", + input: { query: "exact JavaScript module" }, + }, + ], + }, { name: "guided_workflows", ...toolContractMetadata("guided_workflows"), @@ -96,6 +117,16 @@ export const APPLICATION_TOOL_CONTRACTS = [ }, ], }, + { + name: "read_guidance_section", + ...toolContractMetadata("read_guidance_section"), + description: + "Retrieve a section selected by search_guidance from the installed REA guidance bundle. Reports stale locators and unavailable bundles explicitly. Returns documentation, not target observations or Evidence.", + kind: "application", + inputSchema: readGuidanceSectionInputSchema, + outputSchema: readGuidanceSectionOutputSchema, + examples: [], + }, { name: "trace_application_feature", ...toolContractMetadata("trace_application_feature"), diff --git a/src/contracts/guidanceContracts.ts b/src/contracts/guidanceContracts.ts new file mode 100644 index 0000000000..8bfdc57d56 --- /dev/null +++ b/src/contracts/guidanceContracts.ts @@ -0,0 +1,91 @@ +import { z } from "zod"; + +export const searchGuidanceLimitSchema = z.number().int().min(1).max(20); + +export const searchGuidanceInputSchema = z.strictObject({ + query: z + .string() + .min(1) + .max(2048) + .describe( + "Lexical query: every non-generic word must occur in the section; generic-only queries return no matches.", + ) + .refine((value) => value.trim().length > 0), + limit: searchGuidanceLimitSchema.default(8), + cursor: z.string().min(1).max(4096).optional(), +}); + +export const readGuidanceSectionInputSchema = z.strictObject({ + locator: z.string().min(1).max(4096), +}); + +export const searchGuidanceOutputSchema = z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("matches"), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + package: z.string(), + bundle_sha256: z.string().length(64), + query: z.string(), + matches: z.array( + z.strictObject({ + guide: z.string(), + heading: z.string(), + locator: z.string(), + matched_terms: z.array(z.string()), + relevance: z.string(), + context: z.string(), + }), + ), + total_matches: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + limit: z.number().int().positive(), + has_more: z.boolean(), + next_cursor: z.string().min(1).max(4096).optional(), + }), + z.strictObject({ + status: z.literal("no_matches"), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + query: z.string(), + message: z.string(), + total_matches: z.literal(0), + }), + z.strictObject({ + status: z.literal("bundle_unavailable"), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + message: z.string(), + }), + z.strictObject({ + status: z.literal("stale_continuation"), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + message: z.string(), + }), +]); + +export const readGuidanceSectionOutputSchema = z.discriminatedUnion("status", [ + z.strictObject({ + status: z.literal("section"), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + package: z.string(), + bundle_sha256: z.string().length(64), + guide: z.string(), + heading: z.string(), + locator: z.string(), + content: z.string(), + }), + z.strictObject({ + status: z.enum(["stale_locator", "bundle_unavailable"]), + source: z.literal("installed_rea_guidance"), + content_kind: z.literal("documentation"), + message: z.string(), + }), +]); + +export type SearchGuidanceOutput = z.output; +export type ReadGuidanceSectionOutput = z.output< + typeof readGuidanceSectionOutputSchema +>; diff --git a/src/contracts/toolEffects.ts b/src/contracts/toolEffects.ts index 81c4c731a9..1c274399f8 100644 --- a/src/contracts/toolEffects.ts +++ b/src/contracts/toolEffects.ts @@ -704,6 +704,8 @@ export const TOOL_EFFECTS: Readonly> = { trace_application_feature: evidence, trace_javascript_semantics: evidence, compare_application_versions: evidence, + search_guidance: effects(), + read_guidance_section: effects(), guided_workflows: effects(), compare_source_to_bundle: evidence, compare_javascript_export_shapes: evidence, @@ -793,6 +795,8 @@ export const TARGET_CONTENT_FREE_TOOLS: ReadonlySet = new Set([ "inspect_analysis_settings", "update_analysis_settings", "export_evidence_bundle", + "search_guidance", + "read_guidance_section", "guided_workflows", "record_unknown", ]); diff --git a/src/server/registerApplicationTools.ts b/src/server/registerApplicationTools.ts index f9d6450ab3..a68f218579 100644 --- a/src/server/registerApplicationTools.ts +++ b/src/server/registerApplicationTools.ts @@ -10,6 +10,7 @@ import { registerEnvironmentBundleTool } from "./registerApplicationTools/enviro import { registerReconstructionObligationLedgerTool } from "./registerApplicationTools/obligationLedger.js"; import { registerProjectMobileApplicationGraphTools } from "./registerApplicationTools/projectMobileApplicationGraph.js"; import type { ApplicationToolRegistration } from "./registerApplicationTools/types.js"; +import { registerGuidanceTools } from "./registerGuidanceTools.js"; import { registerGuidedWorkflowTool } from "./registerGuidedWorkflowTool.js"; /** Register provider-neutral JavaScript application graph workflows. */ @@ -17,6 +18,7 @@ export const registerApplicationTools = ( server: EvidenceMcpServer, options: ApplicationToolRegistration, ): void => { + registerGuidanceTools(server, options.artifactResources); registerGuidedWorkflowTool(server); registerTraceFeatureTool(server, options); registerTraceJavaScriptSemanticsTool(server, options); diff --git a/src/server/registerApplicationTools/types.ts b/src/server/registerApplicationTools/types.ts index a0979aae58..802900dfa9 100644 --- a/src/server/registerApplicationTools/types.ts +++ b/src/server/registerApplicationTools/types.ts @@ -9,7 +9,7 @@ import type { WithAdmittedAnalysis } from "../analysisAdmission.js"; /** Shared services for registering JavaScript application graph workflows. */ export interface ApplicationToolRegistration { - readonly artifactResources?: ArtifactResourceScope; + readonly artifactResources: ArtifactResourceScope; readonly withAdmittedAnalysis?: WithAdmittedAnalysis; readonly logger: Logger; readonly evidenceById: EvidenceReader["evidenceById"] | undefined; diff --git a/src/server/registerGuidanceTools.ts b/src/server/registerGuidanceTools.ts new file mode 100644 index 0000000000..304da79a6e --- /dev/null +++ b/src/server/registerGuidanceTools.ts @@ -0,0 +1,45 @@ +import { + readGuidanceSection, + searchGuidance, +} from "../application/GuidanceAccess.js"; +import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js"; +import { toolContract } from "../contracts/toolContracts.js"; +import { jsonValueSchema } from "../domain/jsonValue.js"; +import type { EvidenceMcpServer } from "./EvidenceMcpServer.js"; +import { applicationToolRegistrationOptions } from "./toolRegistrationOptions.js"; + +/** Register provider-free search and retrieval over the installed guide bundle. */ +export const registerGuidanceTools = ( + server: EvidenceMcpServer, + resources: ArtifactResourceScope, +): void => { + const search = toolContract("search_guidance"); + server.registerTool( + search.name, + applicationToolRegistrationOptions(search), + async (input) => { + const result = await searchGuidance(input, resources); + return server.delivery.toCallToolResult( + result.ok + ? { ok: true, value: jsonValueSchema.parse(result.value) } + : result, + search, + ); + }, + ); + + const read = toolContract("read_guidance_section"); + server.registerTool( + read.name, + applicationToolRegistrationOptions(read), + async (input) => { + const result = await readGuidanceSection(input, resources); + return server.delivery.toCallToolResult( + result.ok + ? { ok: true, value: jsonValueSchema.parse(result.value) } + : result, + read, + ); + }, + ); +}; diff --git a/tests/boundary/cli/cliResourceClose.test.ts b/tests/boundary/cli/cliResourceClose.test.ts new file mode 100644 index 0000000000..4a1a9005c7 --- /dev/null +++ b/tests/boundary/cli/cliResourceClose.test.ts @@ -0,0 +1,39 @@ +import { afterEach, expect, it, vi } from "vitest"; +import { closeCliResources } from "../../../src/cli.js"; +import * as javascript from "../../../src/cli/javascriptApplicationAnalysis.js"; +import * as guidance from "../../../src/cli/guidanceCommands.js"; + +afterEach(() => vi.restoreAllMocks()); + +it.each([ + [false, false], + [true, false], + [false, true], + [true, true], +])( + "settles both CLI owners when JavaScript fails=%s and guidance fails=%s", + async (jsFails, guideFails) => { + const jsError = new Error("JavaScript resources retained"); + const guideError = new Error("Guidance descriptor retained"); + const js = vi + .spyOn(javascript, "closeCliJavaScriptResources") + .mockImplementation(async () => { + if (jsFails) throw jsError; + }); + const guides = vi + .spyOn(guidance, "closeCliGuidanceResources") + .mockImplementation(async () => { + if (guideFails) throw guideError; + }); + const closing = closeCliResources(); + if (jsFails && guideFails) + await expect(closing).rejects.toMatchObject({ + errors: [jsError, guideError], + }); + else if (jsFails || guideFails) + await expect(closing).rejects.toBe(jsFails ? jsError : guideError); + else await expect(closing).resolves.toBeUndefined(); + expect(js).toHaveBeenCalledOnce(); + expect(guides).toHaveBeenCalledOnce(); + }, +); diff --git a/tests/boundary/cli/cliToolCoverage.test.ts b/tests/boundary/cli/cliToolCoverage.test.ts index fa138f2cf0..2965c75798 100644 --- a/tests/boundary/cli/cliToolCoverage.test.ts +++ b/tests/boundary/cli/cliToolCoverage.test.ts @@ -8,6 +8,7 @@ import { CLI_COMMAND_NAMES } from "../../../src/cliCommandNames.js"; /** Canonical MCP operations reached by CLI commands with different names. */ const CLI_COMMAND_TOOL_ALIASES = Object.freeze({ "environment-build": ["export_environment_bundle"], + guidance: ["search_guidance", "read_guidance_section"], analyze: ["binary_overview"], inspect: ["binary_overview"], decompile: ["procedure_pseudo_code"], diff --git a/tests/boundary/cli/dispatcher.test.ts b/tests/boundary/cli/dispatcher.test.ts index 215bf2e36f..c2b1ca2bb9 100644 --- a/tests/boundary/cli/dispatcher.test.ts +++ b/tests/boundary/cli/dispatcher.test.ts @@ -105,7 +105,7 @@ describe("doctor configuration dispatch", () => { const imported = "CLI command runtime imported"; await installCliRuntime( workspace, - `process.stderr.write(${JSON.stringify(`${imported}\n`)}); export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (args, options) => options.stdout(JSON.stringify({ adapter: "cli", args })) });\n`, + `process.stderr.write(${JSON.stringify(`${imported}\n`)}); export const closeCliResources = () => {}; export const createCli = () => ({ serve: (args, options) => options.stdout(JSON.stringify({ adapter: "cli", args })) });\n`, ); // Both token ownership and output/configuration admission use production code. await workspace.write( @@ -194,7 +194,7 @@ describe("executable dispatcher", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', + 'export const closeCliResources = () => {}; export const createCli = () => ({ serve: (_args, options) => { options.stdout("first document\\n"); throw new RangeError("Invalid string length"); } });\n', 'export const createCliResultOutput = () => ({ handled: false, failed: false, captureSdkOutput: () => false, finishSdkOutput: async () => {}, recoverFormattingFailure: () => { process.stdout.write("unexpected second document"); return true; } });\n', ); const result = await processes.run( @@ -213,7 +213,7 @@ describe("executable dispatcher", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const closeCliJavaScriptResources = () => {}; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', + 'export const closeCliResources = () => {}; export const createCli = () => ({ serve: (args) => process.stdout.write(JSON.stringify({ adapter: "cli", args })) });\n', ); const scripts = workspace.path("scripts"); await workspace.mkdir("dist/mcp"); @@ -335,7 +335,7 @@ describe("executable dispatcher JavaScript shutdown", () => { async (cleanupFails, { processes, workspace }) => { await installCliRuntime( workspace, - `export const closeCliJavaScriptResources = async () => { ${cleanupFails ? 'throw new Error("worker still owned");' : ""} }; export const createCli = () => ({ serve: () => { throw Object.assign(new Error("broken pipe"), { code: "EPIPE" }); } });\n`, + `export const closeCliResources = async () => { ${cleanupFails ? 'throw new Error("worker still owned");' : ""} }; export const createCli = () => ({ serve: () => { throw Object.assign(new Error("broken pipe"), { code: "EPIPE" }); } });\n`, ); const result = await processes.run( process.execPath, @@ -368,7 +368,7 @@ describe("executable dispatcher JavaScript shutdown", () => { : 'process.stdout.write("complete document\\n");'; await installCliRuntime( workspace, - `export const closeCliJavaScriptResources = () => { globalThis.cleaned = true; ${cleanupFails ? 'return Promise.reject(new Error("worker still owned"));' : ""} }; export const createCli = () => ({ serve: (_args, options) => { options.stdout("pending document"); options.exit(0); } });\n`, + `export const closeCliResources = () => { globalThis.cleaned = true; ${cleanupFails ? 'return Promise.reject(new Error("worker still owned"));' : ""} }; export const createCli = () => ({ serve: (_args, options) => { options.stdout("pending document"); options.exit(0); } });\n`, `export const createCliResultOutput = () => ({ handled: false, failed: false, captureSdkOutput: () => true, finishSdkOutput: async () => { if (!globalThis.cleaned) throw new Error("output preceded cleanup"); await new Promise(resolve => setTimeout(resolve, 10)); ${finish} } });\n`, ); const result = await processes.run( @@ -395,7 +395,7 @@ describe("executable dispatcher JavaScript shutdown", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { options.exit(0); } });\n', + 'export const closeCliResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { options.exit(0); } });\n', ); const result = await processes.run( process.execPath, @@ -412,7 +412,7 @@ describe("executable dispatcher JavaScript shutdown", () => { async ({ processes, workspace }) => { await installCliRuntime( workspace, - 'export const closeCliJavaScriptResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { process.stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); options.exit(0); } });\n', + 'export const closeCliResources = () => Promise.reject(new Error("worker still owned")); export const createCli = () => ({ serve: (_args, options) => { process.stdout.emit("error", Object.assign(new Error("broken pipe"), { code: "EPIPE" })); options.exit(0); } });\n', ); const result = await processes.run( process.execPath, diff --git a/tests/boundary/cli/guidanceCli.test.ts b/tests/boundary/cli/guidanceCli.test.ts new file mode 100644 index 0000000000..b80f737330 --- /dev/null +++ b/tests/boundary/cli/guidanceCli.test.ts @@ -0,0 +1,128 @@ +import { afterAll, expect, it } from "vitest"; +import { createCli, closeCliResources } from "../../../src/cli.js"; + +afterAll(async () => closeCliResources()); + +const run = async (arguments_: string[]) => { + const cli = createCli({}); + let stdout = ""; + let exitCode = 0; + const previousExitCode = process.exitCode; + process.exitCode = 0; + try { + await cli.serve([...arguments_, "--json"], { + env: {}, + exit: (code) => { + exitCode = code; + }, + stdout: (chunk) => { + stdout += chunk; + }, + }); + return { exitCode: process.exitCode || exitCode, stdout }; + } finally { + process.exitCode = previousExitCode ?? 0; + } +}; + +it("searches and reads packaged guidance through the provider-free CLI", async () => { + const found = await run(["guidance", "--query", "exact JavaScript module"]); + expect(found.exitCode, found.stdout).toBe(0); + const search = JSON.parse(found.stdout) as { + readonly status: string; + readonly matches: readonly { + readonly heading: string; + readonly locator: string; + }[]; + }; + expect(search.status).toBe("matches"); + const selected = search.matches.find( + ({ heading }) => heading === "Read a module without repeating analysis", + ); + expect(selected).toBeDefined(); + const read = await run([ + "guidance", + "--action", + "read", + "--locator", + selected?.locator ?? "", + ]); + expect(read.exitCode, read.stdout).toBe(0); + expect(JSON.parse(read.stdout)).toMatchObject({ + status: "section", + heading: selected?.heading, + locator: selected?.locator, + source: "installed_rea_guidance", + }); + const ignoredLimit = await run([ + "guidance", + "--action", + "read", + "--locator", + selected?.locator ?? "", + "--limit", + "2", + ]); + expect(ignoredLimit.exitCode).not.toBe(0); +}); + +it("reports no matches and rejects invalid CLI inputs", async () => { + const absent = await run(["guidance", "--query", "qzxv"]); + expect(absent.exitCode).toBe(0); + expect(JSON.parse(absent.stdout)).toMatchObject({ + status: "no_matches", + total_matches: 0, + }); + const empty = await run(["guidance", "--query", " "]); + expect(empty.exitCode).not.toBe(0); + const malformed = await run([ + "guidance", + "--action", + "read", + "--locator", + "../guide.md", + ]); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.stdout).toContain("locator"); + const incompatible = await run([ + "guidance", + "--action", + "read", + "--locator", + "looks-like-a-locator", + "--query", + "module", + ]); + expect(incompatible.exitCode).not.toBe(0); +}); + +it("continues a CLI search with a query- and bundle-bound cursor", async () => { + const first = await run(["guidance", "--query", "module", "--limit", "1"]); + const firstPage = JSON.parse(first.stdout) as { + readonly next_cursor?: string; + readonly matches: readonly { readonly locator: string }[]; + }; + expect(firstPage.next_cursor).toBeDefined(); + const next = await run([ + "guidance", + "--query", + "module", + "--limit", + "1", + "--cursor", + firstPage.next_cursor ?? "", + ]); + expect(next.exitCode, next.stdout).toBe(0); + const nextPage = JSON.parse(next.stdout) as { + readonly matches: readonly { readonly locator: string }[]; + }; + expect(nextPage.matches[0]?.locator).not.toBe(firstPage.matches[0]?.locator); + const wrongQuery = await run([ + "guidance", + "--query", + "different", + "--cursor", + firstPage.next_cursor ?? "", + ]); + expect(wrongQuery.exitCode).not.toBe(0); +}); diff --git a/tests/boundary/filesystem/setupSkill.test.ts b/tests/boundary/filesystem/setupSkill.test.ts index 37832c546f..f4166f17c5 100644 --- a/tests/boundary/filesystem/setupSkill.test.ts +++ b/tests/boundary/filesystem/setupSkill.test.ts @@ -22,6 +22,7 @@ import { canonicalSkillNeedsInstall, existingSkillDestinations, installCanonicalSkill, + readSkillFile, readInstalledSkillIdentity, skillDestinations, type SkillInstallFileOperations, @@ -37,6 +38,21 @@ import { SkillFilePublication } from "../../../src/application/SkillFilePublicat import { z } from "zod"; import { skillReferenceIssues } from "../../../scripts/lib/docs-facts.mjs"; +describe("managed skill file read boundary", () => { + it("rejects linked and oversized managed guide files", async () => { + const root = await createTestTempDirectory("rea-guidance-file-boundary-"); + const target = join(root, "guide.md"); + const link = join(root, "linked-guide.md"); + const oversized = join(root, "oversized-guide.md"); + await writeFile(target, "safe guide content"); + await symlink(target, link); + await writeFile(oversized, "larger than limit"); + + await expect(readSkillFile(link)).rejects.toThrow(); + await expect(readSkillFile(oversized, undefined, 4)).rejects.toThrow(); + }); +}); + const expectPermissionMode = async ( path: string, expected: number, diff --git a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts index a7ac0e4dbd..f142fe5edc 100644 --- a/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts +++ b/tests/boundary/filesystem/setupSkillCleanupOwnership.test.ts @@ -12,10 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; import { PRODUCT_IDENTITY } from "../../../src/identity.js"; import { createDoctorHostFixture } from "../../../src/application/Doctor.fixture.js"; -import { - runDoctorOwned, - type DoctorHost, -} from "../../../src/application/Doctor.js"; +import { runDoctorOwned } from "../../../src/application/Doctor.js"; import { readSkillFile, skillDestinations, @@ -96,7 +93,7 @@ describe("setup skill backup cleanup ownership", () => { if (stage !== "verification") await writeFile(backup, original); const close = failFirstCloses(backup); const host = systemSetupHost( - { platform: "linux", homeDirectory: home } as DoctorHost, + createDoctorHostFixture({ platform: "linux", homeDirectory: home }), { HOME: home }, ); @@ -127,6 +124,7 @@ describe("setup skill backup cleanup ownership", () => { { approved: true, installHopper: false, structured: true }, { ...host, + nodeVersion: "24.22.1", initialSetupState: async () => { throw primary; }, @@ -188,7 +186,7 @@ describe("skill regular-file cleanup ownership", () => { await writeFile(manifest, "stale managed skill\n"); const close = failFirstCloses(manifest); const host = systemSetupHost( - { platform: "linux", homeDirectory: home } as DoctorHost, + createDoctorHostFixture({ platform: "linux", homeDirectory: home }), { HOME: home }, ); diff --git a/tests/boundary/guidanceSearch.test.ts b/tests/boundary/guidanceSearch.test.ts new file mode 100644 index 0000000000..6a4148621c --- /dev/null +++ b/tests/boundary/guidanceSearch.test.ts @@ -0,0 +1,53 @@ +import { expect, it } from "vitest"; +import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js"; +import { + readGuidanceSection, + searchGuidance, +} from "../../src/application/GuidanceAccess.js"; + +it("finds and retrieves the selected section from the generated package bundle", async () => { + const resources = new ArtifactResourceScope(); + try { + const result = await searchGuidance( + { query: "exact JavaScript module" }, + resources, + ); + if (!result.ok) throw result.error; + expect(result.value).toMatchObject({ + status: "matches", + source: "installed_rea_guidance", + package: expect.stringMatching(/^rea-agents@/u), + }); + if (result.value.status !== "matches") + throw new Error("Expected guidance matches"); + const match = result.value.matches.find( + ({ heading }) => heading === "Read a module without repeating analysis", + ); + expect(match).toBeDefined(); + expect(result.value.matches[0]?.heading).toBe( + "Read a module without repeating analysis", + ); + expect(match).toMatchObject({ + guide: "references/javascript-applications.md", + matched_terms: expect.arrayContaining(["exact", "javascript", "module"]), + context: expect.stringContaining("javascript-module"), + }); + if (match === undefined) throw new Error("Expected selected guidance"); + + const retrieved = await readGuidanceSection( + { locator: match.locator }, + resources, + ); + if (!retrieved.ok) throw retrieved.error; + expect(retrieved.value).toMatchObject({ + status: "section", + source: "installed_rea_guidance", + guide: match.guide, + heading: match.heading, + locator: match.locator, + content: expect.stringContaining("node_id"), + }); + } finally { + await resources.close(); + } +}); diff --git a/tests/boundary/javascriptSemanticTraceEvidence.test.ts b/tests/boundary/javascriptSemanticTraceEvidence.test.ts index 7cb46c65f1..a5863efbe3 100644 --- a/tests/boundary/javascriptSemanticTraceEvidence.test.ts +++ b/tests/boundary/javascriptSemanticTraceEvidence.test.ts @@ -7,7 +7,7 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { createApplicationMcpHarness } from "../fixtures/applicationMcpHarness.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; -import { closeCliJavaScriptResources, createCli } from "../../src/cli.js"; +import { closeCliResources, createCli } from "../../src/cli.js"; import { createEvidence, parseEvidence } from "../../src/domain/evidence.js"; import type { JsonValue } from "../../src/domain/jsonValue.js"; import { analysisCliErrorEnvelopeSchema } from "../../src/contracts/errorSchemas.js"; @@ -20,7 +20,7 @@ const SEMANTIC_GRAPH_ID = `jsrg_${"0".repeat(64)}`; // In-process serve keeps the CLI worker for this file. Stop it before the // worker process exits so the private runtime root is not left behind. -afterAll(closeCliJavaScriptResources); +afterAll(closeCliResources); it("reports invalid nested semantic graph Evidence as caller input through CLI and SDK MCP", async () => { const root = await createTestTempDirectory("rea-semantic-trace-input-"); diff --git a/tests/boundary/mcp/promptMcp.test.ts b/tests/boundary/mcp/promptMcp.test.ts index 5e69332415..7b732a78d0 100644 --- a/tests/boundary/mcp/promptMcp.test.ts +++ b/tests/boundary/mcp/promptMcp.test.ts @@ -19,14 +19,12 @@ import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { createAnalysisExecution, type AnalysisClient, + type AnalysisProvider, + type AnalysisProviderCandidate, + type CapabilityDescriptor, + type ProviderIdentity, } from "../../../src/application/AnalysisProvider.js"; import type { JsonValue } from "../../../src/domain/jsonValue.js"; -import type { - AnalysisProvider, - AnalysisProviderCandidate, - CapabilityDescriptor, - ProviderIdentity, -} from "../../../src/application/AnalysisProvider.js"; import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js"; import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js"; import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js"; diff --git a/tests/boundary/mcp/registerGuidanceTools.test.ts b/tests/boundary/mcp/registerGuidanceTools.test.ts new file mode 100644 index 0000000000..174f6b3372 --- /dev/null +++ b/tests/boundary/mcp/registerGuidanceTools.test.ts @@ -0,0 +1,127 @@ +import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { afterEach, expect, it } from "vitest"; +import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js"; +import { + readGuidanceSectionOutputSchema, + searchGuidanceInputSchema, + searchGuidanceOutputSchema, +} from "../../../src/contracts/guidanceContracts.js"; +import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js"; +import { registerGuidanceTools } from "../../../src/server/registerGuidanceTools.js"; +import { ToolResultDelivery } from "../../../src/server/ToolResultDelivery.js"; + +const open: { + client: Client; + server: EvidenceMcpServer; + resources: ArtifactResourceScope; +}[] = []; +const guidanceOutput = ( + schema: { parse(value: unknown): T }, + value: unknown, +): T => { + // MCP's legacy output-schema projection wraps root-level union schemas. + // Newer transports pass the object unchanged. + if (typeof value === "object" && value !== null && "result" in value) + return schema.parse(value.result); + return schema.parse(value); +}; +afterEach(async () => { + await Promise.all( + open + .splice(0) + .flatMap(({ client, server, resources }) => [ + client.close(), + server.close(), + resources.close(), + ]), + ); +}); + +it("advertises strict provider-free contracts and returns packaged guide results over MCP", async () => { + const server = new EvidenceMcpServer( + { name: "guidance-test", version: "1" }, + { capabilities: { tools: { listChanged: true } } }, + undefined, + new ToolResultDelivery(1024 * 1024), + ); + const resources = new ArtifactResourceScope(); + registerGuidanceTools(server, resources); + const client = new Client({ name: "guidance-client", version: "1" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + open.push({ client, server, resources }); + + const listed = await client.listTools(); + expect(listed.tools.map(({ name }) => name)).toEqual( + expect.arrayContaining(["search_guidance", "read_guidance_section"]), + ); + expect( + searchGuidanceInputSchema.safeParse({ + query: "module", + unexpected: true, + }).success, + ).toBe(false); + const invalidLocator = await client.callTool({ + name: "read_guidance_section", + arguments: { locator: "../../outside.md" }, + }); + expect(invalidLocator.isError).toBe(true); + const invalidLimit = await client.callTool({ + name: "search_guidance", + arguments: { query: "module", limit: 21 }, + }); + expect(invalidLimit.isError).toBe(true); + const response = await client.callTool({ + name: "search_guidance", + arguments: { query: "exact JavaScript module" }, + }); + expect(response.isError).not.toBe(true); + const parsed = guidanceOutput( + searchGuidanceOutputSchema, + response.structuredContent, + ); + expect(parsed).toMatchObject({ + status: "matches", + source: "installed_rea_guidance", + }); + if (parsed.status !== "matches") throw new Error("Expected guidance matches"); + const selected = parsed.matches.find( + ({ heading }) => heading === "Read a module without repeating analysis", + ); + expect(selected).toBeDefined(); + const section = await client.callTool({ + name: "read_guidance_section", + arguments: { locator: selected?.locator }, + }); + expect(section.isError).not.toBe(true); + expect( + guidanceOutput(readGuidanceSectionOutputSchema, section.structuredContent), + ).toMatchObject({ + status: "section", + heading: selected?.heading, + source: "installed_rea_guidance", + }); + const firstPage = await client.callTool({ + name: "search_guidance", + arguments: { query: "module", limit: 1 }, + }); + const firstResult = guidanceOutput( + searchGuidanceOutputSchema, + firstPage.structuredContent, + ); + if (firstResult.status !== "matches" || firstResult.next_cursor === undefined) + throw new Error("Expected a guidance continuation token"); + const nextPage = await client.callTool({ + name: "search_guidance", + arguments: { + query: "module", + limit: 1, + cursor: firstResult.next_cursor, + }, + }); + expect( + guidanceOutput(searchGuidanceOutputSchema, nextPage.structuredContent), + ).toMatchObject({ status: "matches", offset: 1 }); +}); diff --git a/tests/conformance/contracts/targetContentProvenance.test.ts b/tests/conformance/contracts/targetContentProvenance.test.ts index c41ebcef58..3041892013 100644 --- a/tests/conformance/contracts/targetContentProvenance.test.ts +++ b/tests/conformance/contracts/targetContentProvenance.test.ts @@ -21,7 +21,9 @@ describe("target-content provenance audit", () => { "export_evidence_bundle", "guided_workflows", "inspect_analysis_settings", + "read_guidance_section", "record_unknown", + "search_guidance", "update_analysis_settings", ]); }); diff --git a/tests/support/cli/cliCommandSession.ts b/tests/support/cli/cliCommandSession.ts index 96ed81b521..21b890b886 100644 --- a/tests/support/cli/cliCommandSession.ts +++ b/tests/support/cli/cliCommandSession.ts @@ -129,7 +129,7 @@ const stopChild = async ( graceMs: number, ): Promise => { if (child.exitCode !== null || child.signalCode !== null) return; - // EOF lets the worker await closeCliJavaScriptResources before exiting. + // EOF lets the worker await closeCliResources before exiting. if (!child.stdin.writableEnded) child.stdin.end(); if (await waitForExit(child, graceMs)) return; signalChild(child, "SIGTERM"); diff --git a/tests/support/cli/cliCommandSessionWorker.mjs b/tests/support/cli/cliCommandSessionWorker.mjs index 88742a5900..c98d0f6ba1 100644 --- a/tests/support/cli/cliCommandSessionWorker.mjs +++ b/tests/support/cli/cliCommandSessionWorker.mjs @@ -40,7 +40,7 @@ if (!(await compiledRuntimeExists(runtimeFiles))) { process.exit(1); } -const { closeCliJavaScriptResources, createCli } = +const { closeCliResources, createCli } = await import("../../../dist/cli-runtime/cli.js"); const { createCliResultOutput } = await import("../../../dist/cli-runtime/cliResultOutput.js"); @@ -106,7 +106,7 @@ try { await inputStopped; await chain; if (completion.kind === "failed") throw completion.cause; - }, [closeCliJavaScriptResources]); + }, [closeCliResources]); } catch (cause) { console.error(cause); process.exit(1);