From cd64e24ff21a3a3351254dc20cee4b6256041780 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:29:15 +0800 Subject: [PATCH 1/6] fix(verifiers): align WABT operation timeout (#4117) * fix(verifiers): align WABT MCP request timeout * fix(verifiers): bound packaged WABT workflow --- scripts/lib/wabt-verifier-timeouts.mjs | 4 ++++ scripts/verify-packaged-wasm-artifact.mjs | 7 ++++++- scripts/verify-wasm-artifact.mjs | 13 ++++++++----- 3 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 scripts/lib/wabt-verifier-timeouts.mjs diff --git a/scripts/lib/wabt-verifier-timeouts.mjs b/scripts/lib/wabt-verifier-timeouts.mjs new file mode 100644 index 0000000000..4ee3ab9548 --- /dev/null +++ b/scripts/lib/wabt-verifier-timeouts.mjs @@ -0,0 +1,4 @@ +// A single inspection can include native admission plus several WABT commands. +// The aggregate verifier covers seven complete fixtures and their failure paths. +export const WABT_OPERATION_TIMEOUT_MS = 4 * 60_000; +export const WABT_WORKFLOW_TIMEOUT_MS = 15 * 60_000; diff --git a/scripts/verify-packaged-wasm-artifact.mjs b/scripts/verify-packaged-wasm-artifact.mjs index d425ef9246..95bdc138ac 100644 --- a/scripts/verify-packaged-wasm-artifact.mjs +++ b/scripts/verify-packaged-wasm-artifact.mjs @@ -3,6 +3,7 @@ import { join } from "node:path"; import { mkdir } from "node:fs/promises"; import { PrivateRuntimeRoot } from "../dist/process/PrivateRuntimeRoot.js"; import { exec } from "./lib/verify-package-core.mjs"; +import { WABT_WORKFLOW_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs"; import { verifyPackagePack } from "./verify-package-pack.mjs"; const root = await PrivateRuntimeRoot.create({ @@ -49,7 +50,11 @@ try { const verified = await exec( process.execPath, ["scripts/verify-wasm-artifact.mjs", entrypoint, "--require-tools"], - { env: environment, timeout: 180_000, maxBuffer: 4 * 1024 * 1024 }, + { + env: environment, + timeout: WABT_WORKFLOW_TIMEOUT_MS, + maxBuffer: 4 * 1024 * 1024, + }, ); process.stdout.write(verified.stdout); } finally { diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index c05952118f..9bc5436aff 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -24,6 +24,7 @@ import { requireMcpToolError, } from "./lib/mcp-verifier-results.mjs"; import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs"; +import { WABT_OPERATION_TIMEOUT_MS } from "./lib/wabt-verifier-timeouts.mjs"; const directory = process.env.REA_WABT_BIN_DIRECTORY; if (!isAbsolute(directory ?? "")) { @@ -76,6 +77,8 @@ try { throw cause; } const client = new Client({ name: "wabt-artifact-verifier", version: "1" }); +const callTool = (request) => + client.callTool(request, { timeout: WABT_OPERATION_TIMEOUT_MS }); const transport = new StdioClientTransport({ command: process.execPath, args: [entrypoint, "mcp"], @@ -128,7 +131,7 @@ try { TMP: childTempRoot, TEMP: childTempRoot, }, - timeout: 120_000, + timeout: WABT_OPERATION_TIMEOUT_MS, maxBuffer: 64 * 1024 * 1024, }, ) @@ -238,7 +241,7 @@ try { const cliValue = await inspectCli(input); assert.equal(cliValue.category, undefined, JSON.stringify(cliValue)); const cli = parseEvidence(cliValue); - const reply = await client.callTool({ + const reply = await callTool({ name: "inspect_wasm_artifact", arguments: input, }); @@ -332,7 +335,7 @@ try { }, ); } - const bundleReply = await client.callTool({ + const bundleReply = await callTool({ name: "get_evidence_bundle", arguments: {}, }); @@ -362,7 +365,7 @@ try { JSON.stringify(cliError), ); const error = requireMcpToolError( - await client.callTool({ + await callTool({ name: "inspect_wasm_artifact", arguments: invalidInput, }), @@ -370,7 +373,7 @@ try { assert.equal(error.category, "invalid_input"); const partial = error.details.partial_observation; assert.equal(partial.kind, "retained-evidence"); - const failedBundleReply = await client.callTool({ + const failedBundleReply = await callTool({ name: "get_evidence_bundle", arguments: {}, }); From f7250fe35f20108cbb1b278422d4f35e44f35d17 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 07:37:20 -0700 Subject: [PATCH 2/6] fix(kuna): say why Kuna cannot answer instead of a generic message (#4113) Kuna capability gaps such as "no code at this address" or "no function node at this address" were built without a user message, so MCP and the CLI showed only "This analysis is unavailable for the current target. Choose another analysis or target." and kept the reason in details. One Kuna helper now presents the reason as the message for every such gap, and the unsupported-operation reason names call relationships and xrefs, which Kuna now answers. Refs #1957 Co-authored-by: Claude Opus 5.5 --- src/kuna/KunaProjectReader.ts | 5 +-- src/kuna/KunaProvider.ts | 23 ++--------- src/kuna/KunaReferenceQueries.ts | 5 +-- src/kuna/KunaSessionClient.ts | 21 ++-------- src/kuna/KunaUnavailable.ts | 18 +++++++++ .../kuna/kunaCommandFailures.test.ts | 38 +++++++++++++++++++ 6 files changed, 68 insertions(+), 42 deletions(-) create mode 100644 src/kuna/KunaUnavailable.ts diff --git a/src/kuna/KunaProjectReader.ts b/src/kuna/KunaProjectReader.ts index d4608b11f2..6b534ef6e0 100644 --- a/src/kuna/KunaProjectReader.ts +++ b/src/kuna/KunaProjectReader.ts @@ -4,7 +4,6 @@ import { join } from "node:path"; import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { - AnalysisCapabilityUnavailableError, AnalysisCancelledError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; @@ -19,6 +18,7 @@ import { } from "./KunaProjectFiles.js"; import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; import { KUNA_FUNCTION_TEXT_BYTES } from "./KunaLimits.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** The exact producing range for one facet, including a failed C block. */ export interface KunaProjectFileSpan { @@ -134,8 +134,7 @@ export class KunaProjectReader { const span = index.value.code.get(address); if (span === undefined) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, `Kuna's export lists no code at ${address}; import slots and data entries have no listing.`, ), diff --git a/src/kuna/KunaProvider.ts b/src/kuna/KunaProvider.ts index ac5c0bc5f8..c2a103431a 100644 --- a/src/kuna/KunaProvider.ts +++ b/src/kuna/KunaProvider.ts @@ -8,10 +8,7 @@ import type { ProviderAvailability, ProviderTargetSupport, } from "../application/AnalysisProvider.js"; -import { - AnalysisCancelledError, - AnalysisCapabilityUnavailableError, -} from "../domain/analysisErrorCore.js"; +import { AnalysisCancelledError } from "../domain/analysisErrorCore.js"; import { createAnalysisProfile, type AnalysisProfileCommitment, @@ -33,6 +30,7 @@ import { import { KUNA_COMMAND_REMEDIATION, KUNA_PROVIDER_ID } from "./KunaRelease.js"; import { KunaRunSlots } from "./KunaRunSlots.js"; import { KunaSessionClient } from "./KunaSessionClient.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** Architectures REA verified Kuna on: x86-64 and AArch64 ELF and Mach-O, plus ARM Thumb. */ const VERIFIED_ARCHITECTURES: ReadonlySet = new Set([ @@ -169,14 +167,7 @@ export class KunaProvider implements AnalysisProviderCandidate { ...(options?.signal === undefined ? {} : { signal: options.signal }), }); if (!installation.ok) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - "open_binary", - installation.error.reason, - { userMessage: installation.error.reason }, - ), - ); + return err(kunaUnavailable("open_binary", installation.error.reason)); const { value } = installation; return ok({ profile: createAnalysisProfile( @@ -218,13 +209,7 @@ export class KunaProvider implements AnalysisProviderCandidate { if (!configuration.ok) return { execute: async (operation) => - err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - operation, - configuration.error.reason, - ), - ), + err(kunaUnavailable(operation, configuration.error.reason)), close: async () => ok(null), }; return new KunaSessionClient(target, profile, { diff --git a/src/kuna/KunaReferenceQueries.ts b/src/kuna/KunaReferenceQueries.ts index ffc8b45b4d..44256f705e 100644 --- a/src/kuna/KunaReferenceQueries.ts +++ b/src/kuna/KunaReferenceQueries.ts @@ -1,7 +1,6 @@ import type { AnalysisError } from "../domain/analysisErrorBase.js"; import { AnalysisCancelledError, - AnalysisCapabilityUnavailableError, AnalysisResourceConstraintError, } from "../domain/analysisErrorCore.js"; import type { JsonValue } from "../domain/jsonValue.js"; @@ -21,6 +20,7 @@ import { } from "./KunaReferenceFiles.js"; import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; import type { KunaPendingQuery, KunaQueryWork } from "./KunaQueryWork.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** Charge raw JSON bytes conservatively; retained projections are smaller. */ const XREF_CACHE_BYTES = 16 * 1024 * 1024; @@ -158,8 +158,7 @@ export class KunaReferenceQueries { const node = graph.nodes.get(address); if (node === undefined) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, `Kuna's graph has no function node at ${address}; its relationships are unknown.`, ), diff --git a/src/kuna/KunaSessionClient.ts b/src/kuna/KunaSessionClient.ts index 07254050a2..e8a44a7627 100644 --- a/src/kuna/KunaSessionClient.ts +++ b/src/kuna/KunaSessionClient.ts @@ -69,6 +69,7 @@ import { KunaProjectReader, type KunaProjectFileSpan, } from "./KunaProjectReader.js"; +import { kunaUnavailable } from "./KunaUnavailable.js"; /** How often a request waiting on the project export reports that it still waits. */ const WAIT_PROGRESS_INTERVAL_MS = 10_000; @@ -278,13 +279,7 @@ export class KunaSessionClient implements AnalysisClient { options: ExecutionOptions = {}, ): Promise> { if (this.#closing) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - operation, - "This Kuna session is closed.", - ), - ); + return err(kunaUnavailable(operation, "This Kuna session is closed.")); return this.#own( this.#execute(operation, parameters, { ...options, @@ -312,8 +307,7 @@ export class KunaSessionClient implements AnalysisClient { return err(new AnalysisCancelledError(operation)); if (operation !== "health" && !isKunaOperation(operation)) return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, + kunaUnavailable( operation, "Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.", ), @@ -1229,14 +1223,7 @@ export class KunaSessionClient implements AnalysisClient { this.#installation ??= inspectKunaInstallation(this.#environment).then( (inspected): Result => { if (!inspected.ok) - return err( - new AnalysisCapabilityUnavailableError( - KUNA_PROVIDER_ID, - "health", - inspected.error.reason, - { userMessage: inspected.error.reason }, - ), - ); + return err(kunaUnavailable("health", inspected.error.reason)); const committed = this.#profile?.parameters["kuna_sha256"]; if (committed !== undefined && committed !== inspected.value.sha256) return err( diff --git a/src/kuna/KunaUnavailable.ts b/src/kuna/KunaUnavailable.ts new file mode 100644 index 0000000000..514c7c486c --- /dev/null +++ b/src/kuna/KunaUnavailable.ts @@ -0,0 +1,18 @@ +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import type { AnalysisErrorOptions } from "../domain/analysisErrorBase.js"; +import { KUNA_PROVIDER_ID } from "./KunaRelease.js"; + +/** + * A Kuna capability gap whose reason is what the caller needs to read. Without + * a user message the projection shows only "choose another analysis or + * target", which hides facts such as "no code at this address". + */ +export const kunaUnavailable = ( + operation: string, + reason: string, + options: AnalysisErrorOptions = {}, +): AnalysisCapabilityUnavailableError => + new AnalysisCapabilityUnavailableError(KUNA_PROVIDER_ID, operation, reason, { + ...options, + userMessage: reason, + }); diff --git a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts index 30aec2b7a1..426f18eb8a 100644 --- a/tests/boundary/providers/kuna/kunaCommandFailures.test.ts +++ b/tests/boundary/providers/kuna/kunaCommandFailures.test.ts @@ -80,3 +80,41 @@ it("reports a graph beyond REA's buffer once per session instead of rebuilding i expect(await client.close()).toEqual({ ok: true, value: null }); } }); + +it("says why Kuna cannot answer instead of a generic unavailable message", async () => { + const { open } = await session("rea-kuna-unavailable-reasons-"); + const client = open(); + try { + const cases = [ + { + operation: "procedure_assembly", + parameters: { procedure: "0x2000" }, + message: + "Kuna's export lists no code at 0x2000; import slots and data entries have no listing.", + }, + { + operation: "procedure_callers", + parameters: { procedure: "0x1004" }, + message: + "Kuna's graph has no function node at 0x1004; its relationships are unknown.", + }, + { + operation: "list_segments", + parameters: {}, + message: + "Kuna answers procedure and string queries, pseudocode, assembly and references; this operation needs another provider.", + }, + ] as const; + for (const { operation, parameters, message } of cases) { + const result = await client.execute(operation, parameters); + if (result.ok) throw new Error(`${operation} unexpectedly succeeded`); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "capability_unavailable", + message, + retryable: false, + }); + } + } finally { + expect(await client.close()).toEqual({ ok: true, value: null }); + } +}); From 3776f218e5e5638f8d6d6950f442be9caaa54427 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 07:38:07 -0700 Subject: [PATCH 3/6] fix(apple): bound Interface Builder XML plists by shared value depth (#4114) * fix(apple): bound Interface Builder XML plists by shared value depth The Interface Builder preflight rejected XML archives whose raw element depth exceeded 128, but the shared plist parser bounds zero-based value depth at 128; the plist envelope and dictionary keys add elements, not value depth. Archives whose deepest value sat at depth 127 or 128 were therefore omitted as over budget although keyed-archive inspection decodes them. The preflight's duplicate depth rule is removed; the shared parser's depth limit is mapped to the existing budget omission, so deeper archives stay explicitly omitted. Fixes #3949 Co-Authored-By: Claude Opus 5.5 * fix(apple): return the decode reservation of a depth-rejected archive Review follow-up. The removed element-depth preflight rejected an archive before its plist representation was reserved; the shared depth limit now rejects it during decoding, after the reservation. A large over-depth archive therefore kept its share of the aggregate budget and could starve later valid archives. A document omitted by the budget after reserving now releases that reservation. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../apple/InterfaceBuilderAnalysis.ts | 26 ++++++++- .../apple/InterfaceBuilderDecodeBudget.ts | 28 ++++------ .../filesystem/interfaceBuilderBundle.test.ts | 54 ++++++++++++++++++- 3 files changed, 89 insertions(+), 19 deletions(-) diff --git a/src/artifacts/apple/InterfaceBuilderAnalysis.ts b/src/artifacts/apple/InterfaceBuilderAnalysis.ts index 32760e0820..c8837f969e 100644 --- a/src/artifacts/apple/InterfaceBuilderAnalysis.ts +++ b/src/artifacts/apple/InterfaceBuilderAnalysis.ts @@ -84,6 +84,8 @@ export const analyzeInterfaceBuilderBundle = async (input: { recordOmittedArchive(entry.path, "max_documents"); unreportedOmittedArchives += selection.unreportedOmitted; for (const entry of selection.selected) { + // Plist representation reserved for this document before decoding. + let plistReservation = 0; try { const remainingInputBytes = MAX_AGGREGATE_INPUT_BYTES - aggregateInputBytes; @@ -132,6 +134,7 @@ export const analyzeInterfaceBuilderBundle = async (input: { decodedReservation.estimatedBytes, "plist representation exceeds the aggregate Interface Builder decode budget", ); + plistReservation = decodedReservation.estimatedBytes; binaryPrototypeKeyObserved = decodedReservation.binaryPrototypeKeyObserved; } @@ -165,6 +168,9 @@ export const analyzeInterfaceBuilderBundle = async (input: { } catch (cause: unknown) { if (input.signal?.aborted === true) throw cause; if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) { + // A document rejected while decoding, e.g. beyond the shared depth + // limit, retains no representation; later documents keep its share. + decodeBudget.release(plistReservation); omitted += 1; recordOmittedArchive(entry.path, cause.reason); continue; @@ -700,6 +706,24 @@ const readEntry = async ( return Buffer.concat(chunks, length); }; +/** + * The shared parser bounds plist value nesting before its recursive decode and + * reports the limit as a RangeError; that is an omitted archive, not a + * malformed one. + */ +const parseXmlWithinDecodeBudget = (text: string) => { + try { + return parseXmlPropertyList(text); + } catch (cause: unknown) { + if (cause instanceof RangeError) + throw new InterfaceBuilderDecodeBudgetExceeded( + "aggregate_decode_budget_exhausted", + cause.message, + ); + throw cause; + } +}; + /** Project decoded plist data and dates while retaining omitted-key coverage. */ const decodePlist = ( bytes: Buffer, @@ -716,7 +740,7 @@ const decodePlist = ( value: parseBinary(bytes), omittedPrototypeKeys: binaryPrototypeKeyObserved ? null : 0, } - : parseXmlPropertyList(xmlText ?? decodeXmlPlistText(bytes)); + : parseXmlWithinDecodeBudget(xmlText ?? decodeXmlPlistText(bytes)); const projected = projectPlistValue(value); return { value: projected.value, diff --git a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts index 5d143f52a2..2eb11bf117 100644 --- a/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts +++ b/src/artifacts/apple/InterfaceBuilderDecodeBudget.ts @@ -49,6 +49,14 @@ export class InterfaceBuilderDecodeBudget { this.#remainingBytes -= bytes; } + /** Return a reservation whose representation was rejected and not retained. */ + release(bytes: number): void { + this.#remainingBytes = Math.min( + this.#initialBytes, + this.#remainingBytes + bytes, + ); + } + /** Reserve an upper bound for one JSON-serialized string occurrence. */ reserveJsonStringOccurrence(value: string): void { // JSON escaping uses at most six ASCII bytes per UTF-16 code unit, plus @@ -117,7 +125,8 @@ const estimateXmlPlistExpansion = ( const xml = xmlText ?? decodeXmlPlistText(bytes); const decodedSourceBytes = xml.length * 2; let elementCount = 0; - let xmlDepth = 0; + // Value nesting is bounded by the shared plist parser, which counts plist + // value depth; raw element depth would also count the envelope and keys. for (const token of xmlPreflightTokens(xml)) { if (token.doctype) { // Only a declaration outside opaque text can introduce an internal @@ -130,15 +139,6 @@ const estimateXmlPlistExpansion = ( continue; } elementCount += 1; - if (token.closing) xmlDepth -= 1; - else if (!token.selfClosing) { - xmlDepth += 1; - if (xmlDepth > 128) - throw new InterfaceBuilderDecodeBudgetExceeded( - "aggregate_decode_budget_exhausted", - "XML plist nesting exceeds the aggregate Interface Builder decode budget", - ); - } if (decodedSourceBytes * 2 + elementCount * 128 > maxBytes) return maxBytes + 1; } @@ -179,13 +179,7 @@ function* xmlPreflightTokens(xml: string) { } if (end === xml.length) return; cursor = end + 1; - if (doctype || element) - yield { - doctype, - internalSubset, - closing: xml[start + 1] === "/", - selfClosing: xml[end - 1] === "/", - }; + if (doctype || element) yield { doctype, internalSubset }; } } diff --git a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts index 1b2954b7b6..a418c8f0bd 100644 --- a/tests/boundary/filesystem/interfaceBuilderBundle.test.ts +++ b/tests/boundary/filesystem/interfaceBuilderBundle.test.ts @@ -4,9 +4,10 @@ import { join } from "node:path"; import { promisify } from "node:util"; import { buildBinary } from "plist"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { analyzeInterfaceBuilderBundle } from "../../../src/artifacts/apple/InterfaceBuilderAnalysis.js"; +import { InterfaceBuilderDecodeBudget } from "../../../src/artifacts/apple/InterfaceBuilderDecodeBudget.js"; import { encodeNibArchiveFixture } from "../../../src/artifacts/apple/NibArchive.fixture.js"; import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; import { xcodeIbtoolAvailable } from "../../fixtures/ibtool.js"; @@ -907,6 +908,57 @@ describe("aggregate Interface Builder archive retention budget", () => { }); }); +describe("Interface Builder XML plist depth", () => { + it("bounds XML archives by plist value depth, not element depth", async () => { + const root = await createTestTempDirectory("rea-ib-depth-test-"); + const bundle = join(root, "Example.app"); + const resources = join(bundle, "Contents", "Resources"); + await mkdir(resources, { recursive: true }); + // The root dict is value depth 0, so the leaf sits at `nested + 1`. The + // plist envelope and keys add XML elements but not value depth. + const document = (nested: number) => + `${"child".repeat(nested)}leaf1${"".repeat(nested)}`; + await writeFile(join(resources, "AtLimit.nib"), document(127)); + await writeFile(join(resources, "BeyondLimit.nib"), document(128)); + + const release = vi.spyOn(InterfaceBuilderDecodeBudget.prototype, "release"); + const result = await analyzeInterfaceBuilderBundle({ + bundlePath: bundle, + targetSha256: "e".repeat(64), + }); + const released = release.mock.calls.map(([bytes]) => bytes); + release.mockRestore(); + + // The rejected archive returns its reservation to later documents. + expect(released).toHaveLength(1); + expect(released[0]).toBeGreaterThan(0); + expect(result.documents.map(({ relative_path }) => relative_path)).toEqual([ + "Contents/Resources/AtLimit.nib", + ]); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "archive_decode", + status: "partial", + reason: "aggregate_decode_budget_exhausted", + examined: 2, + omitted: 1, + }), + ); + expect(result.limitations).toContain( + "Contents/Resources/BeyondLimit.nib: omitted because aggregate decode budget exhausted.", + ); + }); + + it("restores released representation bytes up to the initial budget", () => { + const budget = new InterfaceBuilderDecodeBudget(100); + budget.reserve(60, "reserve"); + budget.release(60); + expect(budget.remainingBytes).toBe(100); + budget.release(10); + expect(budget.remainingBytes).toBe(100); + }); +}); + describe("bounded Interface Builder archive decoding", () => { it("counts malformed archives against the document limit", async () => { const root = await createTestTempDirectory("rea-ib-test-"); From cfd3c35a50ec276850c2baed5d6eed52473db9e1 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 07:38:33 -0700 Subject: [PATCH 4/6] fix(kuna): keep the real lane's wide-string marker independent of layout (#4116) On macOS arm64 the lane's UTF-8 marker ends right before the UTF-16 array, and its last byte plus NUL decode as the UTF-16LE unit 'r'. Kuna therefore reports "rREA wide marker", and the lane, which requires the exact text, failed with "Required string \"REA wide marker\" was not recovered". A leading NUL unit now ends any run the preceding bytes start, so the marker is recovered exactly on any layout. Refs #1957 Co-authored-by: Claude Opus 5.5 --- scripts/verify-real-kuna.mjs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/scripts/verify-real-kuna.mjs b/scripts/verify-real-kuna.mjs index 2361c98ac2..289073c9ae 100644 --- a/scripts/verify-real-kuna.mjs +++ b/scripts/verify-real-kuna.mjs @@ -83,8 +83,11 @@ const FIXTURE_SOURCE = String.raw` #include #include static const char rea_kuna_utf8[] __attribute__((used)) = "_φ( °-°)/ REA unicode marker"; +/* The leading NUL unit ends any UTF-16 run that bytes before this array would + start: the UTF-8 marker's last byte and NUL decode as the unit 'r', so on + layouts where it precedes this array Kuna reports "rREA wide marker". */ static const unsigned short rea_kuna_wide[] __attribute__((used)) = { - 'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0 + 0,'R','E','A',' ','w','i','d','e',' ','m','a','r','k','e','r',0 }; #include __attribute__((noinline)) unsigned rea_checksum(const unsigned char *p, size_t n) { From a90c9eac6a4381f3695b460223cbe4fde3f058e3 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 07:40:10 -0700 Subject: [PATCH 5/6] fix(apple): keep Interface Builder objects whose archived class is unresolved (#4118) * fix(apple): keep Interface Builder objects whose archived class is unresolved Interface Builder keyed-archive projection skipped every object-table entry without a resolved class name, so an archived object whose $class reference dangled vanished from the objects, its count and its coverage, which still reported complete; as a hierarchy root it lost its label. Entries that carry their own $class are archived objects: they are now kept with class_name null and the generic kind, their fields and labels preserved. Scalars and class descriptors remain excluded. Fixes #3958 Co-Authored-By: Claude Opus 5.5 * fix(apple): keep the dangling class reference of an unknown IB object Review follow-up. An archived object kept with an unresolved class lost its original `$class` reference, because class resolution replaces it with a null class name. The object's attributes now retain that raw reference when the class cannot be resolved. A graph-level test covers the reported hierarchy-root case: the unresolved root keeps its label, unknown kind, containment edge, object count and coverage. Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .../apple/interfaceBuilderGraph.test.ts | 84 +++++++++++++++++++ .../apple/interfaceBuilderKeyedArchive.ts | 8 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/src/domain/apple/interfaceBuilderGraph.test.ts b/src/domain/apple/interfaceBuilderGraph.test.ts index 84a8ea493e..cffcd8db93 100644 --- a/src/domain/apple/interfaceBuilderGraph.test.ts +++ b/src/domain/apple/interfaceBuilderGraph.test.ts @@ -156,6 +156,90 @@ describe("compiled Interface Builder graph projection", () => { }); }); +describe("keyed archive objects with unresolved classes", () => { + it("keeps an archived object whose class reference cannot be resolved", () => { + const parsed = parseInterfaceBuilderRecords({ + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 99 }, label: "Unresolved object" }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + { $class: { UID: 2 }, "NS.string": "ok" }, + "plain scalar", + ], + $top: { root: { UID: 3 } }, + }); + // The class descriptor and the scalar are not archived objects. + expect(parsed.objects).toMatchObject([ + { + id: "1", + kind: "other", + class_name: null, + name: "Unresolved object", + attributes: { label: "Unresolved object", $class: { UID: 99 } }, + }, + { id: "3", kind: "view", class_name: "UIView" }, + ]); + expect(parsed.objects[1]?.attributes).not.toHaveProperty("$class"); + expect(parsed.objectCount).toBe(2); + }); + + it("keeps an unresolved hierarchy root's label and coverage in the graph", () => { + const result = buildInterfaceBuilderAnalysis({ + targetSha256: hash, + toolVersion: "test", + documents: [ + { + relativePath: "Main.nib", + archiveSha256: hash, + documentKind: "nib", + raw: { + $archiver: "NSKeyedArchiver", + $version: 100000, + $objects: [ + "$null", + { $class: { UID: 99 }, label: "Unresolved object" }, + { $classname: "UIView", $classes: ["UIView", "NSObject"] }, + { $class: { UID: 2 }, "NS.string": "ok" }, + ], + $top: { root: { UID: 1 } }, + }, + }, + ], + limits: interfaceBuilderLimitsSchema.parse({}), + }); + const objectNodes = result.graph.nodes.filter( + ({ attributes }) => attributes.interface_builder_object_id !== undefined, + ); + expect(objectNodes).toMatchObject([ + { + kind: "unknown", + name: "Unresolved object", + attributes: { interface_builder_object_id: "1", class_name: null }, + }, + { kind: "view", attributes: { interface_builder_object_id: "3" } }, + ]); + expect(result.documents[0]?.object_count).toBe(2); + expect( + result.graph.edges.some( + ({ relation, to, resolution }) => + relation === "contains" && + to === objectNodes[0]?.id && + resolution === "observed", + ), + ).toBe(true); + expect(result.graph.coverage).toContainEqual( + expect.objectContaining({ + facet: "objects:Main.nib", + status: "complete", + examined: 2, + omitted: 0, + }), + ); + }); +}); + describe("Interface Builder parser bounds", () => { it("reports connections discarded by the parser's hard ceiling", () => { const connections = Array.from({ length: 40_001 }, (_, index) => ({ diff --git a/src/domain/apple/interfaceBuilderKeyedArchive.ts b/src/domain/apple/interfaceBuilderKeyedArchive.ts index 3c0e977494..e6145a0d27 100644 --- a/src/domain/apple/interfaceBuilderKeyedArchive.ts +++ b/src/domain/apple/interfaceBuilderKeyedArchive.ts @@ -327,11 +327,16 @@ const parseKeyedArchive = ( if (index === 0) continue; const resolved = record(resolve(raw)); const className = firstString(resolved.className); - if (className === null) continue; + // An archived object names its class with its own `$class` reference; + // when that reference cannot be resolved the object is still observed, + // with an unknown class. Scalars and class descriptors are not objects. + if (className === null && !Object.hasOwn(record(raw), "$class")) continue; objectCount += 1; const fields = Object.fromEntries( Object.entries(resolved).filter(([key]) => key !== "className"), ); + // Keep the unresolvable class reference itself, e.g. a dangling UID. + if (className === null) fields.$class = record(raw).$class; const authoredId = firstString(fields.objectID, fields["object-id"]); const objectId = authoredId ?? @@ -357,6 +362,7 @@ const parseKeyedArchive = ( }), ); if ( + className !== null && /(?:Outlet|Connection|Segue|ActionConnection|ControlConnector)/iu.test( className, ) From 9d6651e5b5296da31a88042a061bdc6f4e769f10 Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Thu, 15 Oct 2026 07:41:04 -0700 Subject: [PATCH 6/6] fix(artifacts): never treat a directory named *.map as a source map (#4131) Artifact inventory decided source-map edges from the `.map` path suffix, so an archive directory such as `main.js.map/` received a maps-source edge whose parent was the sibling `main.js`, although the same occurrence was classified as a directory container. Edge construction now uses the occurrence's own artifact_kind, and the duplicate suffix rule in relationFor is removed; such directories are contained by their parent, and real source-map files keep maps-source. Fixes #3909 Co-authored-by: Claude Opus 5.5 --- .../ArtifactGraphConstruction.test.ts | 37 +++++++++++++++++++ .../inventory/ArtifactGraphConstruction.ts | 19 +++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts index 2b9b0bd817..64b928900a 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.test.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.test.ts @@ -121,6 +121,43 @@ describe("source-map artifact relationships", () => { ); }, ); + + it("keeps a directory named like a source map contained by its parent", () => { + const archive = createOccurrence(entry("app", "directory"), "app", null); + archive.artifact_id = contentId("1"); + const script = createOccurrence( + entry("app/main.js", "file"), + "app/main.js", + archive.occurrence_id, + ); + script.artifact_id = contentId("2"); + const directory = createOccurrence( + entry("app/main.js.map", "directory"), + "app/main.js.map", + archive.occurrence_id, + ); + directory.artifact_id = contentId("3"); + + const { edges, limitations } = createArtifactEdges(contentId("4"), [ + archive, + script, + directory, + ]); + + expect(directory).toMatchObject({ + artifact_kind: "container", + artifact_format: "directory", + }); + expect( + edges.find( + ({ occurrence_id }) => occurrence_id === directory.occurrence_id, + ), + ).toMatchObject({ + relation: "contains", + parent_artifact_id: contentId("1"), + }); + expect(limitations).toEqual([]); + }); }); const fat = (wide = false): Buffer => diff --git a/src/artifacts/inventory/ArtifactGraphConstruction.ts b/src/artifacts/inventory/ArtifactGraphConstruction.ts index 654a402446..4d80479151 100644 --- a/src/artifacts/inventory/ArtifactGraphConstruction.ts +++ b/src/artifacts/inventory/ArtifactGraphConstruction.ts @@ -295,7 +295,9 @@ export const createArtifactEdges = ( const edges: ArtifactEdge[] = []; const limitations: string[] = []; for (const occurrence of occurrences) { - const isSourceMap = occurrence.logical_path.toLowerCase().endsWith(".map"); + // Use the occurrence's own classification: a directory named `x.map` is + // a container, not a source map of its sibling. + const isSourceMap = occurrence.artifact_kind === "source-map"; const sourcePath = isSourceMap ? occurrence.logical_path.slice(0, -".map".length) : undefined; @@ -337,8 +339,10 @@ export const createArtifactEdges = ( relation: occurrence.entry_kind === "slice" ? ("slice-of" as const) - : isSourceMap && mappedSource === undefined - ? ("contains" as const) + : isSourceMap + ? mappedSource === undefined + ? ("contains" as const) + : ("maps-source" as const) : relationFor(occurrence.logical_path), occurrence_id: occurrence.occurrence_id, }; @@ -587,11 +591,8 @@ const classifyFatArtifact = ( return identity.state === "mach-o" ? "mach-o-universal" : "unknown"; }; -const relationFor = (path: string): ArtifactEdge["relation"] => { - const lower = path.toLowerCase(); - if (lower.endsWith(".map")) return "maps-source"; - if (lower.includes(".framework/")) return "embeds"; - return "contains"; -}; +/** Relation of a non-source-map occurrence to its parent. */ +const relationFor = (path: string): ArtifactEdge["relation"] => + path.toLowerCase().includes(".framework/") ? "embeds" : "contains"; const depth = (path: string): number => path.split("/").length;