From e83e3fa8a075484cbdfd46c4c563b7c4875fc3cf Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 15 Nov 2026 03:36:14 +0800 Subject: [PATCH] feat(errors): type refusals at the producer with a total recovery (#10577) * feat(errors): type refusals at the producer with a total recovery A resource refusal without authored text fell back to resource-kind advice: a fixed parser ceiling reported as memory told the caller to add host memory, an upstream IDA reply ceiling pointed at REA's MCP transport, and a fixed Kuna text ceiling reported as capacity was marked retryable. An ambiguous selector was reported as invalid input that pointed at the schema. Add AnalysisRefusal with three variants built from facts producers already own: fixed_ceiling (FixedAllowance, now with limit, unit and narrowing), adjustable_budget (AnalysisSettingLimit) and ambiguous_selector (new AnalysisInputError option). Each variant is attributed to the error's operation and projected under details.refusal. Presentation is a total switch over the variants, and authored text still takes precedence. Migrate the first producers. GraphQL nesting, Playwright trace input, the Kuna function text and the IDA upstream reply become fixed ceilings. jadx overloads and Kuna ambiguous names become ambiguous selectors, replacing the two Kuna-specific ambiguity texts. Seven occupied admissions declare measuredExhaustion. The rea/typed-resource-refusal lint rule rejects new untyped resource constraints, and the remaining 24 are counted in a baseline that can only shrink. * fix(errors): honor refusal recovery ownership --- .oxlintrc.json | 2 + docs/design/limits-and-recovery.md | 29 +++ scripts/lint/index.mjs | 2 + .../typed-resource-refusal-baseline.json | 21 ++ .../lint/rules/typed-resource-refusal.d.mts | 4 + scripts/lint/rules/typed-resource-refusal.mjs | 92 +++++++ src/android/JadxAnalysis.ts | 36 ++- src/apkSignature/ApkSignatureAdmission.ts | 1 + src/application/ntfs/NtfsJournalService.ts | 2 +- src/browser/IsolatedWebBundleAnalyzer.ts | 2 + .../playwrightTrace/PlaywrightTraceDecoder.ts | 6 + src/domain/analysisErrorCore.ts | 28 +- src/domain/analysisErrorPresentation.ts | 60 +++-- src/domain/analysisErrorProjection.ts | 6 + src/domain/analysisErrorRefusal.ts | 47 ++++ src/domain/analysisRefusal.test.ts | 244 ++++++++++++++++++ src/domain/analysisRefusal.ts | 157 +++++++++++ src/domain/apiContract/GraphqlDocuments.ts | 6 + src/domain/fixedAllowanceRemediation.ts | 19 +- src/gpu/GpuCodeObjectsProvider.ts | 2 +- src/ida/IdaMcpConnection.ts | 17 +- src/kuna/KunaProjectReader.ts | 11 + src/kuna/KunaReferenceQueries.ts | 1 + src/kuna/KunaSelectorGuidance.ts | 7 - src/kuna/KunaSessionClient.ts | 22 +- src/model/ModelArtifactProvider.ts | 2 +- .../boundary/process/jadxIntegration.test.ts | 19 ++ .../providers/kuna/kunaAliasSearchMcp.test.ts | 2 +- .../providers/kuna/kunaMachoInventory.test.ts | 20 +- .../providers/kuna/kunaProvider.test.ts | 18 +- .../typedResourceRefusalRule.test.ts | 68 +++++ 31 files changed, 896 insertions(+), 57 deletions(-) create mode 100644 scripts/lint/rules/typed-resource-refusal-baseline.json create mode 100644 scripts/lint/rules/typed-resource-refusal.d.mts create mode 100644 scripts/lint/rules/typed-resource-refusal.mjs create mode 100644 src/domain/analysisErrorRefusal.ts create mode 100644 src/domain/analysisRefusal.test.ts create mode 100644 src/domain/analysisRefusal.ts create mode 100644 tests/evaluation/typedResourceRefusalRule.test.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 9a3e7a801e..7f9f60d76e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -88,6 +88,7 @@ { "files": ["src/**/*.ts"], "rules": { + "rea/typed-resource-refusal": "error", "rea/no-babel-visitor-keys": "error", "typescript/no-misused-promises": "error" } @@ -100,6 +101,7 @@ "src/**/*.fixture.ts" ], "rules": { + "rea/typed-resource-refusal": "off", "rea/no-babel-visitor-keys": "off", "max-lines": "off", "max-lines-per-function": [ diff --git a/docs/design/limits-and-recovery.md b/docs/design/limits-and-recovery.md index e4bb6a0b3d..c7a7d9d907 100644 --- a/docs/design/limits-and-recovery.md +++ b/docs/design/limits-and-recovery.md @@ -371,6 +371,35 @@ JSON values stop at `MAX_JSON_DEPTH` (1000) in "JSON value exceeds maximum nesting depth of 1000. This release has no runtime override." +## Typed refusals + +A producer that refuses a request types the refusal instead of leaving +recovery to the resource kind. The domain owner is +[`analysisRefusal.ts`](https://github.com/morluto/rea/blob/main/src/domain/analysisRefusal.ts). +It has three variants. Each one projects under `details.refusal` with the +`operation` the error is attributed to: + +| Variant | Producer fact | Projected fields | +| -------------------- | ---------------------------------------------------------------------- | ----------------------------------------------------------- | +| `fixed_ceiling` | `fixedAllowanceOptions(name, kind, { limit, unit, narrowing })` | `allowance`, `allowance_kind`, `limit`, `unit`, `narrowing` | +| `adjustable_budget` | an `AnalysisSettingLimit` bound as `limit` | `control`, `setting`, `effective_value`, `unit`, `range` | +| `ambiguous_selector` | `AnalysisInputError` option `ambiguousSelector: { field, candidates }` | `field`, `candidates` | + +Presentation is total over these variants. Authored remediation still wins. +Without it, the variant names the recovery: fixed ceilings and their narrowing +step, the budget's control and range for the caller's lifetime, or the selector +field and its candidates. A resource refusal never falls back to text about +worker limits, the MCP transport, or doctor. An ambiguous selector conforms to +the input contract, so it does not ask the caller to inspect the schema. + +A measured exhaustion or occupied admission is not a refusal. It declares +`measuredExhaustion: true` and keeps the resource kind's recovery. The +`rea/typed-resource-refusal` lint rule rejects a new +`AnalysisResourceConstraintError` that names none of these. Older untyped +constructions are counted in +`scripts/lint/rules/typed-resource-refusal-baseline.json`, and those counts may +only shrink. + ## Recovery text The string the caller sees includes: diff --git a/scripts/lint/index.mjs b/scripts/lint/index.mjs index 82e12c33d0..6d47f85116 100644 --- a/scripts/lint/index.mjs +++ b/scripts/lint/index.mjs @@ -8,6 +8,7 @@ import noSharedModuleMock from "./rules/no-shared-module-mock.mjs"; import noLocaleCompare from "./rules/no-locale-compare.mjs"; import noBabelVisitorKeys from "./rules/no-babel-visitor-keys.mjs"; import projectWaitDeadline from "./rules/project-wait-deadline.mjs"; +import typedResourceRefusal from "./rules/typed-resource-refusal.mjs"; export default { meta: { name: "rea" }, @@ -21,5 +22,6 @@ export default { "no-locale-compare": noLocaleCompare, "no-babel-visitor-keys": noBabelVisitorKeys, "project-wait-deadline": projectWaitDeadline, + "typed-resource-refusal": typedResourceRefusal, }, }; diff --git a/scripts/lint/rules/typed-resource-refusal-baseline.json b/scripts/lint/rules/typed-resource-refusal-baseline.json new file mode 100644 index 0000000000..e6e3ada12b --- /dev/null +++ b/scripts/lint/rules/typed-resource-refusal-baseline.json @@ -0,0 +1,21 @@ +{ + "src/application/JsonAuthenticationHeadroom.ts": 1, + "src/application/desktop/DesktopBuildSolves.ts": 1, + "src/application/ntfs/NtfsJournalService.ts": 1, + "src/browser/WebBundleAnalysisWorker.ts": 4, + "src/browser/history/playwrightTrace/TraceStream.ts": 2, + "src/composition/javascriptAnalysisWorkerProcess.ts": 2, + "src/domain/apiContract/parseGraphqlContract.ts": 1, + "src/domain/native/nativeAotPe.ts": 1, + "src/ghidra/GhidraExecutableHeader.ts": 1, + "src/ghidra/GhidraFileArtifacts.ts": 1, + "src/il2cpp/Il2cppGhidraSeedWriter.ts": 1, + "src/model/ModelArtifactProvider.ts": 1, + "src/native/NativeMacOSProvider.ts": 1, + "src/native/NativePlistSource.ts": 1, + "src/native/SignatureTargetBinding.ts": 1, + "src/native/parsers/lipo.ts": 1, + "src/process/ArtifactAnalysisResources.ts": 1, + "src/server/http/NodeHttpRequestOwner.ts": 1, + "src/sqlite/SqliteDatabaseWorkerExecution.ts": 1 +} diff --git a/scripts/lint/rules/typed-resource-refusal.d.mts b/scripts/lint/rules/typed-resource-refusal.d.mts new file mode 100644 index 0000000000..86d61729e2 --- /dev/null +++ b/scripts/lint/rules/typed-resource-refusal.d.mts @@ -0,0 +1,4 @@ +import type { RuleTester } from "oxlint/plugins-dev"; + +declare const rule: Parameters[1]; +export default rule; diff --git a/scripts/lint/rules/typed-resource-refusal.mjs b/scripts/lint/rules/typed-resource-refusal.mjs new file mode 100644 index 0000000000..980b2134f0 --- /dev/null +++ b/scripts/lint/rules/typed-resource-refusal.mjs @@ -0,0 +1,92 @@ +import { readFileSync } from "node:fs"; +import { relative } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("../../../", import.meta.url)); +/** + * Files that predate the rule, with their count of untyped constructions. A + * count may only shrink: the rule reports a file above or below its entry. + */ +const baseline = JSON.parse( + readFileSync( + new URL("./typed-resource-refusal-baseline.json", import.meta.url), + "utf8", + ), +); + +/** Option keys that type a resource constraint's recovery. */ +const TYPED_KEYS = new Set([ + "fixedAllowance", + "limit", + "remediationAction", + "measuredExhaustion", +]); + +const keyName = (property) => + property.key?.type === "Identifier" + ? property.key.name + : property.key?.type === "Literal" + ? String(property.key.value) + : undefined; + +/** + * Options typed elsewhere (a helper call, a variable or a spread) are trusted; + * the rule flags only constructions that visibly name no recovery. + */ +const isTyped = (options) => { + if (options === undefined) return false; + if (options.type !== "ObjectExpression") return true; + return options.properties.some( + (property) => + property.type === "SpreadElement" || TYPED_KEYS.has(keyName(property)), + ); +}; + +export default { + meta: { + type: "problem", + docs: { + description: + "Type each resource refusal so recovery never falls back to generic worker-limit text.", + }, + schema: [], + messages: { + untyped: + "Type this resource constraint: pass fixedAllowanceOptions(...) for a fixed ceiling, an AnalysisSettingLimit `limit` for an adjustable budget, an authored remediationAction, or measuredExhaustion: true for an observed exhaustion or occupied admission.", + stale: + "typed-resource-refusal-baseline.json allows {{allowed}} untyped resource constraints in this file but {{actual}} remain; lower the entry to {{actual}}.", + }, + }, + create(context) { + const filename = relative( + root, + context.physicalFilename ?? context.filename, + ).replaceAll("\\", "/"); + const untyped = []; + return { + NewExpression(node) { + if ( + node.callee.type !== "Identifier" || + node.callee.name !== "AnalysisResourceConstraintError" + ) + return; + if (!isTyped(node.arguments[4])) untyped.push(node); + }, + "Program:exit"(program) { + const allowed = baseline[filename] ?? 0; + if (untyped.length > allowed) + for (const node of untyped) + context.report({ node, messageId: "untyped" }); + else if (untyped.length < allowed) + context.report({ + node: program, + messageId: "stale", + data: { + allowed: String(allowed), + actual: String(untyped.length), + }, + }); + }, + }; + }, +}; diff --git a/src/android/JadxAnalysis.ts b/src/android/JadxAnalysis.ts index 592e11dab0..736353d927 100644 --- a/src/android/JadxAnalysis.ts +++ b/src/android/JadxAnalysis.ts @@ -374,11 +374,33 @@ const matchingMethods = ( return candidates; }; -const overloadChoices = ( +/** The method name matched several overloads and no overload_index chose one. */ +const ambiguousOverload = ( + request: AndroidRequest, candidates: ReturnType, external = false, -): string => - `Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`; +): never => { + throw new AnalysisInputError( + request.operation, + { + ambiguousSelector: { + field: ["overload_index"], + candidates: candidates.map((method) => ({ + overload_index: method.overload_index, + reported_signature: method.reported_signature, + dex_descriptor: method.dex_descriptor, + })), + }, + }, + [ + { + path: ["overload_index"], + reason: "invalid_value", + message: `Select one of ${candidates.length} overloads: ${candidates.map((method) => `${method.overload_index}: ${method.reported_signature}${external ? ` [${method.dex_descriptor}]` : ""}`).join("; ")}`, + }, + ], + ); +}; const inspectMethod = async ( tools: JadxToolPort, @@ -388,7 +410,7 @@ const inspectMethod = async ( ): Promise => { const candidates = matchingMethods(summary, request); if (candidates.length > 1 && request.input.overload_index === undefined) - inputFailure(request, "overload_index", overloadChoices(candidates)); + ambiguousOverload(request, candidates); const index = request.input.overload_index ?? 0; const selected = candidates[index] ?? @@ -531,11 +553,7 @@ const traceReferences = async ( "Method name is required for an overload index.", ); if (candidates.length > 1 && request.input.overload_index === undefined) - inputFailure( - request, - "overload_index", - overloadChoices(candidates, external), - ); + ambiguousOverload(request, candidates, external); const index = request.input.overload_index ?? 0; const selected = methodName === undefined ? undefined : candidates[index]; if (methodName !== undefined && selected === undefined) diff --git a/src/apkSignature/ApkSignatureAdmission.ts b/src/apkSignature/ApkSignatureAdmission.ts index dffa7bc9ca..9c3803bc0d 100644 --- a/src/apkSignature/ApkSignatureAdmission.ts +++ b/src/apkSignature/ApkSignatureAdmission.ts @@ -31,6 +31,7 @@ class ApkSignatureAdmission { max_active_scans: 1, max_waiting_scans: APK_SIGNATURE_LIMITS.waitingScans, }, + { measuredExhaustion: true }, ), ); return new Promise((resolve, reject) => { diff --git a/src/application/ntfs/NtfsJournalService.ts b/src/application/ntfs/NtfsJournalService.ts index 5b472db699..0a69cda116 100644 --- a/src/application/ntfs/NtfsJournalService.ts +++ b/src/application/ntfs/NtfsJournalService.ts @@ -231,7 +231,7 @@ export const inspectNtfsJournalEvidence = async ( "capacity", "One NTFS inspection is already active in this scope; retry after it completes.", { maximum_concurrent_inspections: 1 }, - { retryable: true }, + { retryable: true, measuredExhaustion: true }, ), ); try { diff --git a/src/browser/IsolatedWebBundleAnalyzer.ts b/src/browser/IsolatedWebBundleAnalyzer.ts index c40576e2db..d5473a8bc0 100644 --- a/src/browser/IsolatedWebBundleAnalyzer.ts +++ b/src/browser/IsolatedWebBundleAnalyzer.ts @@ -99,6 +99,7 @@ export class IsolatedWebBundleAnalyzer { "capacity", "Another web-bundle capture or analysis owns this admission's source and result reservation", { concurrent_web_bundle_analyses: 1 }, + { measuredExhaustion: true }, ), ); // Claim the slot before awaiting cleanup so another host cannot pass the check. @@ -154,6 +155,7 @@ export class IsolatedWebBundleAnalyzer { "capacity", "This web analyzer already owns an active analysis", { concurrent_web_bundle_analyses: 1 }, + { measuredExhaustion: true }, ), ), ); diff --git a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts index 69940c21b6..8f0ed2ecdc 100644 --- a/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts +++ b/src/browser/history/playwrightTrace/PlaywrightTraceDecoder.ts @@ -16,6 +16,7 @@ import { AnalysisCancelledError, AnalysisUnsupportedTargetError, } from "../../../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../../../domain/fixedAllowanceRemediation.js"; import { err, ok } from "../../../domain/result.js"; import { PLAYWRIGHT_TRACE_LIMITS, @@ -137,6 +138,11 @@ class TraceArchiveRead { input_bytes: source.initial.size, max_archive_bytes: PLAYWRIGHT_TRACE_LIMITS.archiveBytes, }, + fixedAllowanceOptions( + `The Playwright trace archive input allowance of ${String(PLAYWRIGHT_TRACE_LIMITS.archiveBytes)} bytes`, + "input", + { limit: PLAYWRIGHT_TRACE_LIMITS.archiveBytes, unit: "bytes" }, + ), ); const hash = await hashStableRootArtifactHandle( this.path, diff --git a/src/domain/analysisErrorCore.ts b/src/domain/analysisErrorCore.ts index a69243fc1b..ff53875629 100644 --- a/src/domain/analysisErrorCore.ts +++ b/src/domain/analysisErrorCore.ts @@ -1,5 +1,6 @@ import type { JsonValue } from "./jsonValue.js"; import type { FixedAllowance } from "./fixedAllowanceRemediation.js"; +import type { AmbiguousSelector } from "./analysisRefusal.js"; import { AnalysisError, type AnalysisErrorOptions, @@ -96,6 +97,9 @@ const resourceConstraintRecoveryFrom = ( ...(options?.remediationAction === undefined ? {} : { remediationAction: options.remediationAction }), + ...(options?.measuredExhaustion === undefined + ? {} + : { measuredExhaustion: options.measuredExhaustion }), ...(options?.retryable === undefined ? {} : { retryable: options.retryable }), @@ -133,8 +137,11 @@ export const resourceConstraintRecovery = ( return recovery.fixedAllowance !== undefined ? { fixedAllowance: { + ...recovery.fixedAllowance, name: text(recovery.fixedAllowance.name), - kind: recovery.fixedAllowance.kind, + ...(recovery.fixedAllowance.narrowing === undefined + ? {} + : { narrowing: text(recovery.fixedAllowance.narrowing) }), }, ...(recovery.retryable === undefined ? {} @@ -144,17 +151,25 @@ export const resourceConstraintRecovery = ( ...(recovery.remediationAction === undefined ? {} : { remediationAction: text(recovery.remediationAction) }), + ...(recovery.measuredExhaustion === undefined + ? {} + : { measuredExhaustion: recovery.measuredExhaustion }), ...(recovery.retryable === undefined ? {} : { retryable: recovery.retryable }), }; }; -/** Producer-specific recovery, or a fixed allowance whose recovery is derived. */ +/** + * Producer-specific recovery, or a fixed allowance whose recovery is derived. + * `measuredExhaustion` declares an observed runtime exhaustion or occupied + * admission rather than a refusal, so recovery follows the resource kind. + */ export type ResourceConstraintRecovery = | { readonly remediationAction?: string; readonly fixedAllowance?: never; + readonly measuredExhaustion?: true; readonly retryable?: boolean; } | { @@ -180,6 +195,8 @@ export class AnalysisInputError extends AnalysisError { * correct such a refusal, so it carries no input-contract recovery. */ readonly conformsToInputContract: boolean; + /** A schema-valid selector that matched several candidates. */ + readonly ambiguousSelector: AmbiguousSelector | undefined; constructor( readonly operation: string, @@ -187,13 +204,18 @@ export class AnalysisInputError extends AnalysisError { readonly userMessage?: string; readonly remediationAction?: string; readonly conformsToInputContract?: boolean; + readonly ambiguousSelector?: AmbiguousSelector; }, readonly issues: readonly AnalysisInputIssue[] = [], ) { super(`Invalid analysis input for ${operation}`, options); this.userMessage = options?.userMessage; this.remediationAction = options?.remediationAction; - this.conformsToInputContract = options?.conformsToInputContract ?? false; + this.ambiguousSelector = options?.ambiguousSelector; + // An ambiguous selection conforms to the schema; the contract is not at fault. + this.conformsToInputContract = + options?.conformsToInputContract ?? + options?.ambiguousSelector !== undefined; } } diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index fa62575757..cd1c8a96a0 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -37,7 +37,11 @@ import { ProviderSelectionError } from "./providerSelectionError.js"; import { ProviderAdapterError } from "./providerAdapterError.js"; import { UnknownRegistryError } from "./unknownRegistryError.js"; import { providerRetryAction } from "./providerOperationHealth.js"; -import { fixedAllowanceRemediation } from "./fixedAllowanceRemediation.js"; +import { analysisErrorRefusal } from "./analysisErrorRefusal.js"; +import { + analysisRefusalMessage, + analysisRefusalRemediation, +} from "./analysisRefusal.js"; import { primaryAnalysisError, resolvedAnalysisError, @@ -127,24 +131,13 @@ export const analysisErrorRemediationAction = ( error.remediationAction ?? "Select a target supported by this operation or choose an operation supporting the reported target format." ); - if (error instanceof AnalysisResourceConstraintError) - return ( - error.remediationAction ?? - (error.fixedAllowance !== undefined - ? fixedAllowanceRemediation( - error.fixedAllowance.name, - error.fixedAllowance.kind, - ) - : error.resource === "capacity" - ? "Wait for an active analysis to complete, then retry within the reported admission limits." - : error.resource === "transport" - ? MCP_TRANSPORT_EVIDENCE_DISCOVERY - : error.resource === "cpu" - ? "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits." - : error.resource === "file-size" - ? "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits." - : "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits.") - ); + if (error instanceof AnalysisResourceConstraintError) { + if (error.remediationAction !== undefined) return error.remediationAction; + const refusal = analysisErrorRefusal(error); + return refusal === undefined + ? measuredResourceRemediation(error.resource) + : analysisRefusalRemediation(refusal, callerLifetime); + } if ( error instanceof AnalysisTimeoutError && error.remediationAction !== undefined @@ -253,6 +246,9 @@ export const analysisErrorRemediationAction = ( : analysisErrorUserMessage(error)) ); if (error instanceof AnalysisInputError) { + const refusal = analysisErrorRefusal(error); + if (error.remediationAction === undefined && refusal !== undefined) + return analysisRefusalRemediation(refusal, callerLifetime); const mistakeRemediation = knownArgumentMistakeRemediation( error.operation, error.issues, @@ -311,6 +307,27 @@ export const analysisErrorRemediationAction = ( return analysisErrorUserMessage(error); }; +/** + * Recovery for a measured exhaustion or occupied admission. A typed refusal + * never reaches this: its own variant names the recovery. + */ +const measuredResourceRemediation = ( + resource: AnalysisResourceConstraintError["resource"], +): string => { + switch (resource) { + case "capacity": + return "Wait for an active analysis to complete, then retry within the reported admission limits."; + case "transport": + return MCP_TRANSPORT_EVIDENCE_DISCOVERY; + case "cpu": + return "Review the reported worker CPU limits and observed signal. Retry with sufficient CPU time or a smaller artifact; REA retains tighter inherited limits."; + case "file-size": + return "Review the reported worker file-size limits and write failure. Retry with a sufficient file-size allowance for the evidence reply; REA retains tighter inherited limits."; + case "memory": + return "Review the reported worker memory limits and available host memory. Retry with sufficient memory or a smaller artifact; REA retains tighter inherited limits."; + } +}; + export const analysisErrorCategory = ( error: AnalysisError, ): AnalysisErrorProjection["category"] => { @@ -520,11 +537,14 @@ const outcomeUserMessage = (error: AnalysisError): string => { return "Host filesystem permissions denied read access to the selected path."; if (error instanceof AnalysisArtifactChangedError) return "The selected artifact changed during acquisition; no stable snapshot was decoded."; - if (error instanceof AnalysisInputError) + if (error instanceof AnalysisInputError) { + const refusal = analysisErrorRefusal(error); return ( error.userMessage ?? + (refusal === undefined ? undefined : analysisRefusalMessage(refusal)) ?? "Analysis input is invalid. Check the arguments and try again." ); + } if (error instanceof AnalysisTimeoutError) return error.userMessage ?? timeoutUserMessage(error); const hopperMessage = hopperErrorUserMessage(error); diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index ec9c25a68b..f7368f80b6 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -60,6 +60,8 @@ import { type AnalysisErrorTag, } from "./analysisErrorBase.js"; import { invocationSettingLimit } from "./analysisSettingDefinitions.js"; +import { analysisErrorRefusal } from "./analysisErrorRefusal.js"; +import { analysisRefusalDetails } from "./analysisRefusal.js"; /** * Project expected failures into exhaustive, secret-safe caller fields. The @@ -404,6 +406,7 @@ const projectError = ( const underlyingCode = underlyingErrorCode(primary); const code = error.cleanupIncomplete ? "cleanup_incomplete" : underlyingCode; const primaryDetails = errorDetails(primary); + const refusal = analysisErrorRefusal(primary); const unknownPublication = composed.workflowUnknownPublication ?? primary.workflowUnknownPublication; const details = { @@ -428,6 +431,9 @@ const projectError = ( ? { ...invocationSettingLimit(primary.limit) } : { ...primary.limit }, }), + ...(refusal === undefined + ? {} + : { refusal: analysisRefusalDetails(refusal) }), ...(!options.secondary || secondary.size === 0 ? {} : { diff --git a/src/domain/analysisErrorRefusal.ts b/src/domain/analysisErrorRefusal.ts new file mode 100644 index 0000000000..befcd627b4 --- /dev/null +++ b/src/domain/analysisErrorRefusal.ts @@ -0,0 +1,47 @@ +import { + AnalysisInputError, + AnalysisResourceConstraintError, +} from "./analysisErrorCore.js"; +import type { AnalysisError } from "./analysisErrorBase.js"; +import type { AnalysisRefusal } from "./analysisRefusal.js"; + +/** + * The typed refusal an error carries, attributed to the error's operation so + * a re-attributed error names the request that was refused. Occupied capacity + * keeps its wait-and-retry recovery even when a setting bounds it. + */ +export const analysisErrorRefusal = ( + error: AnalysisError, +): AnalysisRefusal | undefined => { + if (error instanceof AnalysisResourceConstraintError) { + if (error.fixedAllowance !== undefined) + return { + kind: "fixed_ceiling", + operation: error.operation, + ceiling: error.fixedAllowance, + }; + if ( + error.limit !== undefined && + !( + "measuredExhaustion" in error.recovery && + error.recovery.measuredExhaustion === true + ) + ) + return { + kind: "adjustable_budget", + operation: error.operation, + budget: error.limit, + }; + return undefined; + } + if ( + error instanceof AnalysisInputError && + error.ambiguousSelector !== undefined + ) + return { + kind: "ambiguous_selector", + operation: error.operation, + selector: error.ambiguousSelector, + }; + return undefined; +}; diff --git a/src/domain/analysisRefusal.test.ts b/src/domain/analysisRefusal.test.ts new file mode 100644 index 0000000000..141f8cf4b7 --- /dev/null +++ b/src/domain/analysisRefusal.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from "vitest"; +import { + AnalysisInputError, + AnalysisResourceConstraintError, + reattributedResourceConstraint, +} from "./analysisErrorCore.js"; +import { projectAnalysisError } from "./analysisErrorProjection.js"; +import { analysisSettingLimit } from "./analysisSettingDefinitions.js"; +import { fixedAllowanceOptions } from "./fixedAllowanceRemediation.js"; +import { + analysisRefusalRemediation, + type AnalysisRefusal, +} from "./analysisRefusal.js"; +import { analysisErrorProjectionSchema } from "../contracts/errorSchemas.js"; + +const budgetError = ( + setting: Parameters[0], + value: number, + source: Parameters[2], + operation = "inspect_artifact", +) => + new AnalysisResourceConstraintError( + operation, + "memory", + "Budget hit.", + null, + { + limit: analysisSettingLimit(setting, value, source), + }, + ); + +describe("typed refusals", () => { + it("names a fixed ceiling, its facts and the narrowing step", () => { + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_api_contract", + "memory", + "GraphQL AST nesting exceeds the 128-level traversal allowance.", + { max_ast_depth: 128 }, + fixedAllowanceOptions("The GraphQL nesting allowance", "parser", { + limit: 128, + unit: "levels", + narrowing: "Select a shallower document.", + }), + ), + ); + + expect(analysisErrorProjectionSchema.parse(projected)).toBeDefined(); + expect(projected.retryable).toBe(false); + expect(projected.remediation.action).toMatch( + /^The GraphQL nesting allowance is a fixed allowance\..*Select a shallower document\.$/u, + ); + expect(projected.remediation.action).not.toMatch(/worker memory|doctor/u); + expect(projected.details?.refusal).toEqual({ + kind: "fixed_ceiling", + operation: "inspect_api_contract", + allowance: "The GraphQL nesting allowance", + allowance_kind: "parser", + limit: 128, + unit: "levels", + narrowing: "Select a shallower document.", + }); + }); + + it("names an adjustable budget's control and range for the attributed operation", () => { + const error = reattributedResourceConstraint( + "analyze_javascript_application", + budgetError("javascript.max_heap_mb", 128, "request", "worker"), + ); + const projected = projectAnalysisError(error); + const limit = analysisSettingLimit( + "javascript.max_heap_mb", + 128, + "request", + ); + + expect(projected.remediation.action).toBe( + `analyze_javascript_application reached its max_heap_mb budget of 128 MiB (source request). Pass a larger max_heap_mb on the next analyze_javascript_application call (range ${String(limit.minimum)}..${String(limit.maximum)} MiB), then retry.`, + ); + expect(projected.details?.refusal).toEqual({ + kind: "adjustable_budget", + operation: "analyze_javascript_application", + control: "max_heap_mb", + setting: "javascript.max_heap_mb", + effective_value: 128, + unit: "MiB", + range: { minimum: limit.minimum, maximum: limit.maximum }, + }); + }); + + it("names the control each caller lifetime can change", () => { + const error = budgetError("asar.max_listing_bytes", 1024, "runtime"); + + expect(projectAnalysisError(error).remediation.action).toContain( + "Raise asar.max_listing_bytes with update_analysis_settings", + ); + expect( + projectAnalysisError(error, "invocation").remediation.action, + ).toContain("Set REA_ASAR_MAX_LISTING_BYTES for the next invocation"); + }); + + it.each(["request", "default", "runtime"] as const)( + "uses the per-call heap control for invocation source %s", + (source) => { + expect( + projectAnalysisError( + budgetError("javascript.max_heap_mb", 128, source), + "invocation", + ).remediation.action, + ).toContain("Pass a larger max_heap_mb"); + }, + ); + + it("distinguishes measured exhaustion from a capacity budget refusal", () => { + const limit = analysisSettingLimit( + "asar.max_listing_bytes", + 1024, + "runtime", + ); + const measured = new AnalysisResourceConstraintError( + "inspect_artifact", + "memory", + "Exhausted.", + null, + { limit, measuredExhaustion: true }, + ); + const capacity = new AnalysisResourceConstraintError( + "inspect_artifact", + "capacity", + "Limit reached.", + null, + { limit }, + ); + expect(projectAnalysisError(measured).details).not.toHaveProperty( + "refusal", + ); + expect(projectAnalysisError(capacity).details?.refusal).toMatchObject({ + kind: "adjustable_budget", + }); + }); + + it("does not suggest raising a budget already at its maximum", () => { + const maximum = analysisSettingLimit( + "asar.max_listing_bytes", + 0, + "runtime", + ).maximum; + const action = projectAnalysisError( + budgetError("asar.max_listing_bytes", maximum, "runtime"), + ).remediation.action; + + expect(action).toContain("already at its maximum"); + expect(action).not.toContain("update_analysis_settings"); + }); + + it("keeps wait-and-retry recovery for occupied capacity bounded by a setting", () => { + const projected = projectAnalysisError( + new AnalysisResourceConstraintError( + "inspect_artifact", + "capacity", + "Another inspection is active.", + null, + { + limit: analysisSettingLimit("asar.max_listing_bytes", 1, "runtime"), + measuredExhaustion: true, + }, + ), + ); + + expect(projected.retryable).toBe(true); + expect(projected.remediation.action).toMatch(/^Wait for an active/u); + expect(projected.details).not.toHaveProperty("refusal"); + }); +}); + +describe("typed selector refusals", () => { + it("names an ambiguous selector's field and candidates without schema repair", () => { + const candidates = [ + { overload_index: 0, reported_signature: "choose(): void" }, + { overload_index: 1, reported_signature: "choose(int): void" }, + ]; + const projected = projectAnalysisError( + new AnalysisInputError( + "inspect_android_method", + { ambiguousSelector: { field: ["overload_index"], candidates } }, + [ + { + path: ["overload_index"], + reason: "invalid_value", + message: "Select one of 2 overloads.", + }, + ], + ), + ); + + expect(analysisErrorProjectionSchema.parse(projected)).toBeDefined(); + expect(projected).toMatchObject({ + code: "invalid_request", + message: + "overload_index matches 2 candidates; inspect_android_method needs exactly one.", + remediation: { + action: + "Set overload_index to exactly one of the 2 candidates in details.refusal.candidates, then retry inspect_android_method. Repeating the unchanged request is not expected to succeed.", + }, + details: { + issues: [ + { path: ["overload_index"], message: "Select one of 2 overloads." }, + ], + refusal: { + kind: "ambiguous_selector", + operation: "inspect_android_method", + field: ["overload_index"], + candidates, + }, + }, + }); + expect(projected.recovery).toBeUndefined(); + }); + + it("gives every refusal variant its own recovery", () => { + const refusals: readonly AnalysisRefusal[] = [ + { + kind: "fixed_ceiling", + operation: "op", + ceiling: { name: "The allowance", kind: "count" }, + }, + { + kind: "adjustable_budget", + operation: "op", + budget: analysisSettingLimit("asar.max_listing_bytes", 1, "default"), + }, + { + kind: "ambiguous_selector", + operation: "op", + selector: { field: ["name"], candidates: ["a", "b"] }, + }, + ]; + for (const refusal of refusals) { + const action = analysisRefusalRemediation(refusal); + expect(action.length).toBeGreaterThan(0); + expect(action).not.toMatch(/doctor|worker memory/u); + } + }); +}); diff --git a/src/domain/analysisRefusal.ts b/src/domain/analysisRefusal.ts new file mode 100644 index 0000000000..bf913bc900 --- /dev/null +++ b/src/domain/analysisRefusal.ts @@ -0,0 +1,157 @@ +import type { AnalysisCallerLifetime } from "./analysisErrorBase.js"; +import type { AnalysisSettingLimit } from "./analysisSettings.js"; +import { analysisSettingDefinition } from "./analysisSettingDefinitions.js"; +import { + fixedAllowanceRemediation, + type FixedAllowance, +} from "./fixedAllowanceRemediation.js"; +import type { JsonValue } from "./jsonValue.js"; + +/** A caller-addressable request field, as a path into the operation's input. */ +export type RefusalField = readonly (string | number)[]; + +/** A selector that matched several candidates where the operation needs one. */ +export interface AmbiguousSelector { + /** Request field whose value would choose one candidate. */ + readonly field: RefusalField; + /** Every matching candidate, in the producer's order. */ + readonly candidates: readonly JsonValue[]; +} + +/** + * A deliberate refusal the producer typed, attributed to the request's + * operation. Each variant owns its recovery, so presentation needs no generic + * fallback for it: + * + * - `fixed_ceiling`: a release-fixed allowance (`FixedAllowance`) with its + * optional limit, unit and narrowing step. + * - `adjustable_budget`: an agent-adjustable setting or per-call parameter + * (`AnalysisSettingLimit`) with its control, unit and range. + * - `ambiguous_selector`: a selector that matched several candidates. + */ +export type AnalysisRefusal = + | { + readonly kind: "fixed_ceiling"; + readonly operation: string; + readonly ceiling: FixedAllowance; + } + | { + readonly kind: "adjustable_budget"; + readonly operation: string; + readonly budget: AnalysisSettingLimit; + } + | { + readonly kind: "ambiguous_selector"; + readonly operation: string; + readonly selector: AmbiguousSelector; + }; + +const fieldName = (field: RefusalField): string => field.map(String).join("."); + +const UNCHANGED = "Repeating the unchanged request is not expected to succeed."; + +const adjustableBudgetRemediation = ( + operation: string, + budget: AnalysisSettingLimit, + callerLifetime: AnalysisCallerLifetime, +): string => { + const { unit, minimum, maximum, effective_value: value } = budget; + const control = budget.parameter ?? budget.setting; + const range = `${String(minimum)}..${String(maximum)} ${unit}`; + const effective = + value === null + ? `The effective ${control} value is unknown` + : `${operation} reached its ${control} budget of ${String(value)} ${unit}${budget.source === undefined ? "" : ` (source ${budget.source})`}`; + if (value !== null && value >= maximum) + return `${effective}. ${control} is already at its maximum (${range}); narrow the request or select a smaller input. ${UNCHANGED}`; + const environment = analysisSettingDefinition(budget.setting).environment; + const change = + budget.parameter !== undefined && + (budget.update_mode === "parameter" || + (callerLifetime === "invocation" && environment === undefined)) + ? `Pass a larger ${budget.parameter} on the next ${operation} call` + : callerLifetime === "invocation" + ? `Set ${environment ?? budget.setting} for the next invocation` + : budget.update_mode === "runtime" + ? `Raise ${budget.setting} with update_analysis_settings` + : `Set ${environment ?? budget.setting} in the environment that starts REA, then restart REA`; + return `${effective}. ${change} (range ${range}), then retry.`; +}; + +/** + * The one recovery for a typed refusal. Total over every variant: adding a + * variant fails compilation here until it has its own recovery. + */ +export const analysisRefusalRemediation = ( + refusal: AnalysisRefusal, + callerLifetime: AnalysisCallerLifetime = "continuing", +): string => { + switch (refusal.kind) { + case "fixed_ceiling": { + const { ceiling } = refusal; + const base = fixedAllowanceRemediation(ceiling.name, ceiling.kind); + return ceiling.narrowing === undefined + ? base + : `${base} ${ceiling.narrowing}`; + } + case "adjustable_budget": + return adjustableBudgetRemediation( + refusal.operation, + refusal.budget, + callerLifetime, + ); + case "ambiguous_selector": { + const { field, candidates } = refusal.selector; + return `Set ${fieldName(field)} to exactly one of the ${String(candidates.length)} candidates in details.refusal.candidates, then retry ${refusal.operation}. ${UNCHANGED}`; + } + } +}; + +/** Outcome sentence for a refusal whose producer authored none. */ +export const analysisRefusalMessage = ( + refusal: AnalysisRefusal, +): string | undefined => + refusal.kind === "ambiguous_selector" + ? `${fieldName(refusal.selector.field)} matches ${String(refusal.selector.candidates.length)} candidates; ${refusal.operation} needs exactly one.` + : undefined; + +/** Machine-readable refusal facts projected under `details.refusal`. */ +export const analysisRefusalDetails = ( + refusal: AnalysisRefusal, +): Readonly> => { + switch (refusal.kind) { + case "fixed_ceiling": { + const { ceiling } = refusal; + return { + kind: refusal.kind, + operation: refusal.operation, + allowance: ceiling.name, + allowance_kind: ceiling.kind, + ...(ceiling.limit === undefined ? {} : { limit: ceiling.limit }), + ...(ceiling.unit === undefined ? {} : { unit: ceiling.unit }), + ...(ceiling.narrowing === undefined + ? {} + : { narrowing: ceiling.narrowing }), + }; + } + case "adjustable_budget": { + const { budget } = refusal; + return { + kind: refusal.kind, + operation: refusal.operation, + control: budget.parameter ?? budget.setting, + setting: budget.setting, + effective_value: budget.effective_value, + unit: budget.unit, + range: { minimum: budget.minimum, maximum: budget.maximum }, + }; + } + case "ambiguous_selector": + return { + kind: refusal.kind, + operation: refusal.operation, + field: [...refusal.selector.field], + candidates: [...refusal.selector.candidates], + }; + } +}; diff --git a/src/domain/apiContract/GraphqlDocuments.ts b/src/domain/apiContract/GraphqlDocuments.ts index 564123b622..7aef7c6d57 100644 --- a/src/domain/apiContract/GraphqlDocuments.ts +++ b/src/domain/apiContract/GraphqlDocuments.ts @@ -1,5 +1,6 @@ import { GraphQLError, Source, parse, visit, type DocumentNode } from "graphql"; import { AnalysisResourceConstraintError } from "../analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../fixedAllowanceRemediation.js"; import { API_CONTRACT_OPERATION, API_CONTRACT_MAX_FILE_BYTES, @@ -61,6 +62,11 @@ export const parseGraphqlDocument = ( "memory", "GraphQL AST nesting exceeds the 128-level traversal allowance.", { max_ast_depth: GRAPHQL_CONTRACT_LIMITS.astDepth }, + fixedAllowanceOptions( + `The GraphQL AST nesting allowance of ${String(GRAPHQL_CONTRACT_LIMITS.astDepth)} levels`, + "parser", + { limit: GRAPHQL_CONTRACT_LIMITS.astDepth, unit: "levels" }, + ), ); }, leave() { diff --git a/src/domain/fixedAllowanceRemediation.ts b/src/domain/fixedAllowanceRemediation.ts index 4236a03887..f9f75fefd5 100644 --- a/src/domain/fixedAllowanceRemediation.ts +++ b/src/domain/fixedAllowanceRemediation.ts @@ -39,20 +39,35 @@ export const fixedAllowanceRemediation = ( } }; -/** A named deterministic allowance and the recovery family it belongs to. */ +/** + * A named deterministic allowance (a fixed ceiling) and the recovery family it + * belongs to. The optional facts let a caller act without parsing the name. + */ export interface FixedAllowance { /** Display name, such as "The tshark JSON decoding allowance". */ readonly name: string; readonly kind: FixedAllowanceKind; + /** The ceiling's value, in `unit`. */ + readonly limit?: number; + readonly unit?: string; + /** A request change that stays within the ceiling, as one sentence. */ + readonly narrowing?: string; } +/** Facts a fixed ceiling may carry beyond its name and recovery family. */ +export type FixedCeilingFacts = Pick< + FixedAllowance, + "limit" | "unit" | "narrowing" +>; + /** Non-retryable options for a fixed allowance. Occupied capacity stays retryable. */ export const fixedAllowanceOptions = ( allowance: string, kind: FixedAllowanceKind, + facts: FixedCeilingFacts = {}, ): { readonly retryable: false; readonly fixedAllowance: FixedAllowance } => ({ retryable: false, - fixedAllowance: { name: allowance, kind }, + fixedAllowance: { name: allowance, kind, ...facts }, }); /** diff --git a/src/gpu/GpuCodeObjectsProvider.ts b/src/gpu/GpuCodeObjectsProvider.ts index b8a30d548e..e8aede6590 100644 --- a/src/gpu/GpuCodeObjectsProvider.ts +++ b/src/gpu/GpuCodeObjectsProvider.ts @@ -87,7 +87,7 @@ export class GpuCodeObjectsProvider implements GpuCodeObjectsPort { "capacity", "One GPU inspection is already active", { concurrent_inspections: 1 }, - { retryable: true }, + { retryable: true, measuredExhaustion: true }, ), ); this.#busy = true; diff --git a/src/ida/IdaMcpConnection.ts b/src/ida/IdaMcpConnection.ts index eda57d9f10..989e6125bc 100644 --- a/src/ida/IdaMcpConnection.ts +++ b/src/ida/IdaMcpConnection.ts @@ -48,6 +48,7 @@ import { AnalysisProtocolError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { McpHttpUtf8Error, @@ -533,7 +534,21 @@ export const createIdaMcpConnection = ( "transport", `Upstream IDA MCP reply exceeded the ${bufferLimit[1]}-byte transport limit; no partial Evidence was returned.`, { max_response_bytes: Number(bufferLimit[1]) }, - { cause, capturedOutput }, + { + cause, + capturedOutput, + // The upstream MCP SDK owns this buffer; REA cannot raise it. + ...fixedAllowanceOptions( + `The upstream IDA MCP reply allowance of ${bufferLimit[1]} bytes`, + "bytes", + { + limit: Number(bufferLimit[1]), + unit: "bytes", + narrowing: + "Request a smaller result from IDA, such as a narrower address range or fewer items.", + }, + ), + }, ); if (stderrBytes === 0) throw failure; throw new ProviderAdapterError("ida", "transport", { diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index 49c5dc77b6..74c323be6a 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -16,6 +16,7 @@ import { AnalysisCancelledError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; +import { fixedAllowanceOptions } from "../domain/fixedAllowanceRemediation.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { ProviderCleanupError } from "../domain/providerCleanupError.js"; import { err, ok, type Result } from "../domain/result.js"; @@ -96,6 +97,16 @@ const readSpan = async ( file: path, byte_offset: offset, }, + fixedAllowanceOptions( + `Kuna's buffered function-text allowance of ${String(KUNA_FUNCTION_TEXT_BYTES)} bytes`, + "bytes", + { + limit: KUNA_FUNCTION_TEXT_BYTES, + unit: "bytes", + narrowing: + "Read the complete export locally at details.reported_limits.file from details.reported_limits.byte_offset.", + }, + ), ), ); const callerAborted = (): AbortSignal | undefined => diff --git a/src/kuna/KunaReferenceQueries.ts b/src/kuna/KunaReferenceQueries.ts index 2077dc1aef..6ad204d989 100644 --- a/src/kuna/KunaReferenceQueries.ts +++ b/src/kuna/KunaReferenceQueries.ts @@ -247,6 +247,7 @@ export class KunaReferenceQueries { "capacity", "The session's native reference-query queue is full; await an existing query before starting another.", { limit_queries: PENDING_XREF_QUERIES }, + { measuredExhaustion: true }, ), ); pending = this.work.start( diff --git a/src/kuna/KunaSelectorGuidance.ts b/src/kuna/KunaSelectorGuidance.ts index 8d9cadbad5..d234606e27 100644 --- a/src/kuna/KunaSelectorGuidance.ts +++ b/src/kuna/KunaSelectorGuidance.ts @@ -2,19 +2,12 @@ export const KUNA_XREF_MISSING_NAME_REMEDIATION = "Inspect target symbols or exports where available, verify address coordinates, and call xrefs with an address. Absence from Kuna's inventory does not establish absence from the target."; -/** Recovery for a name with multiple observed inventory entries. */ -export const KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION = - "Select one of the reported addresses and call xrefs again."; - export const KUNA_PROCEDURE_MISSING_NAME_REMEDIATION = "Use search_procedures or list_procedures to inspect Kuna's current inventory. An absent name does not establish that the function is absent from the target."; export const KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION = "Use list_procedures or search_procedures to inspect Kuna's current inventory and verify the selected address. An absent entry does not establish that the function is absent from the target; REA cannot use it as a Kuna procedure in this session."; -export const KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION = - "Select one of the reported exact addresses and retry the operation."; - export const missingKunaXrefNameMessage = (selector: string): string => `Kuna found no inventory entry named ${selector}.`; diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 5e2465e6b8..56a7487c80 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -108,10 +108,8 @@ import { KunaStringQueries } from "./KunaStringQueries.js"; import { normalizeKunaAddress } from "./KunaAddress.js"; import { ambiguousKunaNameMessage, - KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION, KUNA_PROCEDURE_MISSING_ADDRESS_REMEDIATION, KUNA_PROCEDURE_MISSING_NAME_REMEDIATION, - KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION, KUNA_XREF_MISSING_NAME_REMEDIATION, missingKunaXrefNameMessage, } from "./KunaSelectorGuidance.js"; @@ -1344,15 +1342,15 @@ export class KunaSessionClient implements AnalysisClient { selector, matches.map(({ address }) => address), ); - const remediationAction = - field === "address" + // An ambiguous name is a typed selector refusal; its recovery is derived. + const remediationAction = ambiguous + ? undefined + : field === "address" ? missing ? KUNA_XREF_MISSING_NAME_REMEDIATION - : KUNA_XREF_AMBIGUOUS_NAME_REMEDIATION + : "Select one of the reported addresses and call xrefs again." : procedureLookupFailure - ? missing - ? KUNA_PROCEDURE_MISSING_NAME_REMEDIATION - : KUNA_PROCEDURE_AMBIGUOUS_NAME_REMEDIATION + ? KUNA_PROCEDURE_MISSING_NAME_REMEDIATION : matches.length === 0 ? "Inspect the data addresses with address_name or xrefs, or select a procedure entry from list_procedures. Do not repeat the unchanged call." : undefined; @@ -1364,6 +1362,14 @@ export class KunaSessionClient implements AnalysisClient { ...(remediationAction === undefined ? {} : { remediationAction, conformsToInputContract: true }), + ...(ambiguous + ? { + ambiguousSelector: { + field: [field], + candidates: matches.map(({ address }) => address), + }, + } + : {}), }, procedureLookupFailure ? [ diff --git a/src/model/ModelArtifactProvider.ts b/src/model/ModelArtifactProvider.ts index a5b6512aad..d5faaace31 100644 --- a/src/model/ModelArtifactProvider.ts +++ b/src/model/ModelArtifactProvider.ts @@ -86,7 +86,7 @@ export class ModelArtifactProvider implements ModelArtifactPort { "capacity", "One model inspection is already active", { concurrent_inspections: 1 }, - { retryable: true }, + { retryable: true, measuredExhaustion: true }, ), ); this.#busy = true; diff --git a/tests/boundary/process/jadxIntegration.test.ts b/tests/boundary/process/jadxIntegration.test.ts index b972bb3e05..b5ecc77e99 100644 --- a/tests/boundary/process/jadxIntegration.test.ts +++ b/tests/boundary/process/jadxIntegration.test.ts @@ -712,6 +712,25 @@ it("exposes overload candidates and executes only an explicitly selected ambiguo ], }, }); + if (ambiguous.ok) throw new Error("Expected an ambiguous overload"); + // A schema-valid ambiguous selector names its candidates, not the schema. + const ambiguousProjection = projectAnalysisError(ambiguous.error); + expect(ambiguousProjection.recovery).toBeUndefined(); + expect(ambiguousProjection.remediation.action).toMatch( + /^Set overload_index to exactly one of the \d+ candidates/u, + ); + expect(ambiguousProjection.details?.refusal).toMatchObject({ + kind: "ambiguous_selector", + operation: "inspect_android_method", + field: ["overload_index"], + candidates: expect.arrayContaining([ + { + overload_index: 1, + reported_signature: "choose(int): void", + dex_descriptor: "(I)V", + }, + ]) as unknown, + }); const selected = await service.execute("inspect_android_method", { ...input, overload_index: 1, diff --git a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts index d09fa87918..2e3b0a2674 100644 --- a/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts +++ b/tests/boundary/providers/kuna/kunaAliasSearchMcp.test.ts @@ -263,7 +263,7 @@ test("reports lookup failures with recovery for the selected target, not the inp for (const [procedure, guidance] of [ ["0x1335", "list_procedures"], ["absent_name", "search_procedures"], - ["audit_transform", "exact address"], + ["audit_transform", "details.refusal.candidates"], ]) { const response = await client.callTool({ name: "procedure_address", diff --git a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts index 731e09fd68..d06bea19e1 100644 --- a/tests/boundary/providers/kuna/kunaMachoInventory.test.ts +++ b/tests/boundary/providers/kuna/kunaMachoInventory.test.ts @@ -171,7 +171,7 @@ it("guides valid ambiguous and missing procedure selectors without schema repair const client = open(); try { for (const [procedure, expected] of [ - ["puts", "address"], + ["puts", "exactly one of the 2 candidates"], ["missing_procedure", "search_procedures"], ["0x00000001", "list_procedures"], ] as const) { @@ -189,6 +189,12 @@ it("guides valid ambiguous and missing procedure selectors without schema repair if (procedure === "puts") { expect(projected.message).toContain("0x100000480"); expect(projected.message).toContain("0x100004000"); + expect(projected.details?.refusal).toEqual({ + kind: "ambiguous_selector", + operation: "procedure_address", + field: ["procedure"], + candidates: ["0x100000480", "0x100004000"], + }); } } expect( @@ -226,7 +232,17 @@ it("reports missing and ambiguous xref names within Kuna's inventory", async () code: "invalid_request", message: expect.stringMatching(/puts.*2 Kuna entries/iu), remediation: { - action: expect.stringMatching(/select.*addresses.*xrefs/iu), + action: expect.stringMatching( + /address.*exactly one of the 2 candidates.*retry xrefs/iu, + ), + }, + details: { + refusal: { + kind: "ambiguous_selector", + operation: "xrefs", + field: ["address"], + candidates: ["0x100000480", "0x100004000"], + }, }, }); expect(ambiguousProjection.message).toContain("0x100000480"); diff --git a/tests/boundary/providers/kuna/kunaProvider.test.ts b/tests/boundary/providers/kuna/kunaProvider.test.ts index f6ea12dcf5..40a22ea153 100644 --- a/tests/boundary/providers/kuna/kunaProvider.test.ts +++ b/tests/boundary/providers/kuna/kunaProvider.test.ts @@ -559,12 +559,28 @@ it("rejects oversized cached text before buffering while retaining its full expo ] as const) { const result = await client.execute(operation, { procedure: "main" }); expect(result.ok).toBe(false); - if (!result.ok) + if (!result.ok) { expect(result.error).toMatchObject({ _tag: "AnalysisResourceConstraintError", resource: "capacity", reportedLimits: { limit_bytes: KUNA_FUNCTION_TEXT_BYTES }, }); + // A fixed ceiling is not occupied capacity: waiting cannot admit it. + expect(projectAnalysisError(result.error)).toMatchObject({ + retryable: false, + remediation: { + action: expect.stringContaining("Read the complete export locally"), + }, + details: { + refusal: { + kind: "fixed_ceiling", + operation, + limit: KUNA_FUNCTION_TEXT_BYTES, + unit: "bytes", + }, + }, + }); + } } const [key] = await readdir(join(cacheRoot, "projects")); const bytes = await readFile( diff --git a/tests/evaluation/typedResourceRefusalRule.test.ts b/tests/evaluation/typedResourceRefusalRule.test.ts new file mode 100644 index 0000000000..68f7bb703b --- /dev/null +++ b/tests/evaluation/typedResourceRefusalRule.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { RuleTester } from "oxlint/plugins-dev"; +import rule from "../../scripts/lint/rules/typed-resource-refusal.mjs"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const construct = (options: string) => + `new AnalysisResourceConstraintError("op", "memory", "reason", null${options});`; + +new RuleTester({ languageOptions: { parserOptions: { lang: "ts" } } }).run( + "typed-resource-refusal", + rule, + { + valid: [ + { + code: construct(""), + filename: resolve("src/application/JsonAuthenticationHeadroom.ts"), + }, + construct(', fixedAllowanceOptions("The allowance", "count")'), + construct( + ', { cause, ...fixedAllowanceOptions("The allowance", "count") }', + ), + construct(", { limit: analysisSettingLimit(setting, value, source) }"), + construct(', { remediationAction: "Raise the setting." }'), + construct(", { retryable: true, measuredExhaustion: true }"), + construct(", recovery"), + 'new AnalysisInputError("op", { cause });', + ], + invalid: [ + { + code: construct("") + construct(""), + filename: resolve("src/application/JsonAuthenticationHeadroom.ts"), + errors: [{ messageId: "untyped" }, { messageId: "untyped" }], + }, + { + code: "const value = 1;", + filename: resolve("src/application/JsonAuthenticationHeadroom.ts"), + errors: [{ messageId: "stale", data: { allowed: "1", actual: "0" } }], + }, + { code: construct(""), errors: [{ messageId: "untyped" }] }, + { code: construct(", { cause }"), errors: [{ messageId: "untyped" }] }, + { + code: construct(", { retryable: false, diagnostics: {} }"), + errors: [{ messageId: "untyped" }], + }, + ], + }, +); + +it("keeps the untyped-refusal baseline to files that still need migration", async () => { + const baseline = JSON.parse( + await readFile( + "scripts/lint/rules/typed-resource-refusal-baseline.json", + "utf8", + ), + ) as Record; + for (const [file, count] of Object.entries(baseline)) { + expect(file).toMatch(/^src\/.+\.ts$/u); + expect(count).toBeGreaterThan(0); + // A file whose untyped constructions are gone must leave the baseline. + expect(await readFile(file, "utf8")).toContain( + "new AnalysisResourceConstraintError(", + ); + } +});