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(", + ); + } +});