mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
feat(guidance): search installed workflow sections (#6996)
* feat(guidance): search bundled REA workflow guidance * fix(guidance): address review findings * test(guidance): fix boundary and dispatcher coverage * test(setup): use supported doctor fixture runtime * fix(guidance): resolve CI conformance, coverage, and review findings * fix(guidance): own complete requests through shutdown * test(guidance): bound lifecycle gates and join failed work --------- Co-authored-by: Deepak-Kambala <[email protected]> Co-authored-by: gensokyo-dev <[email protected]>
This commit is contained in:
33 files changed
+2117
-41
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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",
|
||||
|
||||
+5
-5
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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<void>((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<unknown>[] = [];
|
||||
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<unknown>[] = [];
|
||||
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);
|
||||
});
|
||||
@@ -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<string> =>
|
||||
path === "references/javascript-applications.md"
|
||||
? moduleGuide
|
||||
: `# ${path}\n\nGeneral module guidance for pagination.\n`;
|
||||
|
||||
const valueOf = async (
|
||||
request: ReturnType<typeof searchGuidanceWithReader>,
|
||||
) => {
|
||||
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<string> => {
|
||||
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]);
|
||||
});
|
||||
@@ -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<string> =>
|
||||
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<string>,
|
||||
resources: ArtifactResourceScope,
|
||||
operation: string,
|
||||
): Promise<GuidanceBundleRead> => {
|
||||
const files = new Map<string, string>();
|
||||
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<string> = 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<string>;
|
||||
type GuidanceResult<Value> = Result<
|
||||
Value,
|
||||
AnalysisInputError | ProviderCleanupError
|
||||
>;
|
||||
|
||||
/** Admit the complete request once; file reads belong to its independent child. */
|
||||
const runGuidanceOperation = async <Value>(
|
||||
resources: ArtifactResourceScope,
|
||||
operationName: string,
|
||||
operation: (child: ArtifactResourceScope) => Promise<GuidanceResult<Value>>,
|
||||
): Promise<GuidanceResult<Value>> => {
|
||||
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<GuidanceResult<SearchGuidanceOutput>> =>
|
||||
runGuidanceOperation(resources, "search_guidance", (child) =>
|
||||
searchGuidanceInScope(input, (path) => readGuide(path, child), child),
|
||||
);
|
||||
|
||||
export const readGuidanceSectionWithReader = (
|
||||
input: unknown,
|
||||
readGuide: GuidanceReader,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<GuidanceResult<ReadGuidanceSectionOutput>> =>
|
||||
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<never, AnalysisInputError> =>
|
||||
err(analysisInputErrorFromIssues(operation, issues, input));
|
||||
|
||||
export const searchGuidance = async (
|
||||
input: unknown,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<
|
||||
Result<SearchGuidanceOutput, AnalysisInputError | ProviderCleanupError>
|
||||
> => searchGuidanceWithReader(input, readPackagedGuide, resources);
|
||||
|
||||
/** Reader seam keeps packaged-guide drift and missing-file behavior testable. */
|
||||
const searchGuidanceInScope = async (
|
||||
input: unknown,
|
||||
readGuide: (relativePath: string) => Promise<string>,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<
|
||||
Result<SearchGuidanceOutput, AnalysisInputError | ProviderCleanupError>
|
||||
> => {
|
||||
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<ReadGuidanceSectionOutput, AnalysisInputError | ProviderCleanupError>
|
||||
> => readGuidanceSectionWithReader(input, readPackagedGuide, resources);
|
||||
|
||||
/** Reader seam keeps packaged-guide drift and missing-file behavior testable. */
|
||||
const readGuidanceSectionInScope = async (
|
||||
input: unknown,
|
||||
readGuide: (relativePath: string) => Promise<string>,
|
||||
resources: ArtifactResourceScope,
|
||||
): Promise<
|
||||
Result<ReadGuidanceSectionOutput, AnalysisInputError | ProviderCleanupError>
|
||||
> => {
|
||||
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<GuidanceCursor>;
|
||||
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<LocatorIdentity>;
|
||||
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;
|
||||
@@ -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<Heading, "level" | "title"> | 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<string, string>,
|
||||
): 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;
|
||||
};
|
||||
@@ -43,21 +43,27 @@ export const isManagedSkillManifest = (content: string): boolean =>
|
||||
export const readSkillFile = (
|
||||
path: string,
|
||||
resources?: ArtifactResourceScope,
|
||||
maxBytes = Number.MAX_SAFE_INTEGER,
|
||||
): Promise<string> =>
|
||||
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<SkillFileSnapshot> =>
|
||||
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 };
|
||||
|
||||
+19
-1
@@ -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<void> => {
|
||||
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);
|
||||
|
||||
@@ -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<void> => {
|
||||
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);
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<typeof searchGuidanceOutputSchema>;
|
||||
export type ReadGuidanceSectionOutput = z.output<
|
||||
typeof readGuidanceSectionOutputSchema
|
||||
>;
|
||||
@@ -704,6 +704,8 @@ export const TOOL_EFFECTS: Readonly<Record<string, ToolEffects>> = {
|
||||
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<string> = new Set([
|
||||
"inspect_analysis_settings",
|
||||
"update_analysis_settings",
|
||||
"export_evidence_bundle",
|
||||
"search_guidance",
|
||||
"read_guidance_section",
|
||||
"guided_workflows",
|
||||
"record_unknown",
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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-");
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 = <T>(
|
||||
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 });
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -129,7 +129,7 @@ const stopChild = async (
|
||||
graceMs: number,
|
||||
): Promise<void> => {
|
||||
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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user