From 7ad75bdc80d1d45dd9fb4c0b3153997cb392a710 Mon Sep 17 00:00:00 2001 From: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:17:54 -0700 Subject: [PATCH] fix: restore JavaScript analysis and current regression checks (#3080) Restore source-position-aware ambient aliases, scalar env/argv reads, candidate endpoint worker data, Chrome DOMSnapshot empty strings, and accumulated Ghidra LaunchSupport diagnostics. Align regression fixtures with declared current contracts, while preserving identity, provenance, cancellation, and cleanup checks. Assert workflow caveats independently at both established locations. Validation: original five source failures reproduced on main; 307 focused tests passed with one platform skip, 22 fixture follow-up tests and 22 workflow/MCP tests passed; full static and docs checks passed. Real public http-server CLI analyses preserve identical artifact identity, statistics, and 581-token selected output. Real Chromium confirms restored DOM form destinations; isolated real service-worker verification passes on main and the change. The complete local browser run retained an unclassified later marker timeout; current-head CI provides complete real-browser and recovery lane results. Native IDA/Ghidra execution and complete local suite execution are not claimed. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com> Co-authored-by: N0zoM1z0 <161784452+N0zoM1z0@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- src/browser/CdpCaptureDocuments.test.ts | 62 ++++++++++++++++++- src/browser/CdpCaptureDocuments.ts | 35 ++++------- src/browser/CdpCaptureDomSvg.ts | 40 +++++------- src/browser/CdpSnapshotStrings.ts | 39 ++++++++++++ .../electronWrappedCallPaths.test.ts | 12 +++- .../javascriptSemanticAmbientWrites.ts | 27 +++++--- ...ptSemanticAnalysis.native_bindings.test.ts | 40 ++++++++++++ .../javascriptSemanticMemberMutations.ts | 10 +++ src/ghidra/GhidraSessionError.ts | 4 +- src/ida/IdaNativeProtocol.test.ts | 22 +++---- .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../applicationWorkflowCli.test.ts | 14 ++++- .../applications/blockSourceMapCli.test.ts | 7 ++- .../javascriptEndpointPayload.test.ts | 4 +- tests/boundary/browser/sazWebCapture.test.ts | 20 ++++-- tests/boundary/cli/developmentTests.test.ts | 20 ++++-- .../directoryArtifactReaderOwnership.test.ts | 9 ++- ...javascriptApplicationEmptyLiterals.test.ts | 14 +++-- .../javascriptSourceMapLocations.test.ts | 34 +++++++--- .../filesystem/productCatalog.test.ts | 11 ++-- .../mcp/browserComparisonInput.test.ts | 29 +++++---- .../enhancedTools.callPathsAndBatches.test.ts | 24 +++++-- .../providers/ghidra/ghidraJavaLaunch.test.ts | 20 ++++-- .../hopperClient.startupFailures.test.ts | 27 ++++++-- .../cleanLinuxHopperRuntime.test.ts | 15 ++--- 25 files changed, 395 insertions(+), 145 deletions(-) create mode 100644 src/browser/CdpSnapshotStrings.ts diff --git a/src/browser/CdpCaptureDocuments.test.ts b/src/browser/CdpCaptureDocuments.test.ts index fe67f90aa8..be8656e8a4 100644 --- a/src/browser/CdpCaptureDocuments.test.ts +++ b/src/browser/CdpCaptureDocuments.test.ts @@ -130,14 +130,13 @@ describe("CDP DOM string and namespace validity", () => { { field: "nodeName", index: -1 }, { field: "nodeName", index: 20 }, { field: "nodeName", index: 2, malformedString: true }, - { field: "nodeValue", index: -1 }, { field: "nodeValue", index: 20 }, { field: "nodeValue", index: 5, malformedString: true }, { field: "attributeName", index: -1 }, { field: "attributeName", index: 20 }, - { field: "attributeValue", index: -1 }, { field: "attributeValue", index: 20 }, ] as const)( + // -1 is DOMSnapshot's empty string for values, never for names. "omits a node when its $field string index is invalid ($index)", ({ field, @@ -353,3 +352,62 @@ describe("CDP accessibility capture", () => { }); }); }); + +describe("CDP DOM snapshot empty strings", () => { + it("reads Chrome's -1 string index as an empty node or attribute value", () => { + const completeness = new CdpCaptureCompleteness(); + const generator = captureDom( + { + strings: [ + origin + "/app", + "#document", + "A", + "href", + "hidden", + "#text", + "go", + ], + documents: [ + { + documentURL: 0, + baseURL: -1, + nodes: { + nodeType: [9, 1, 3], + nodeName: [1, 2, 5], + nodeValue: [-1, -1, 6], + parentIndex: [-1, 0, 1], + attributes: [[], [3, -1, 4, -1], []], + }, + }, + ], + }, + new Set([origin]), + inspectWebPageInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + }), + completeness, + ); + let captured = generator.next([]); + while (!captured.done) captured = generator.next([]); + expect( + captured.value.nodes.map( + ({ node_name, node_value_length, attribute_names }) => ({ + node_name, + node_value_length, + attribute_names, + }), + ), + ).toEqual([ + { node_name: "#document", node_value_length: 0, attribute_names: [] }, + { + node_name: "A", + node_value_length: 0, + attribute_names: ["href", "hidden"], + }, + { node_name: "#text", node_value_length: 2, attribute_names: [] }, + ]); + expect(completeness.snapshot().excluded).toEqual([]); + }); +}); diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 8aeee7e9a7..f9f97d3750 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -9,6 +9,11 @@ import { cdpStringValue, type UnknownRecord, } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; import { exclusionReasonForUrl } from "./CdpCaptureEventHelpers.js"; import type { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { @@ -183,7 +188,7 @@ export function* captureDom( let total = 0; let unresolvedSvgNodes = 0; for (const document of recordsValue(root.documents)) { - const documentUrl = indexedString(strings, document.documentURL); + const documentUrl = cdpSnapshotString(strings, document.documentURL); if (documentUrl === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -195,7 +200,7 @@ export function* captureDom( const baseUrlValue = document.baseURL === undefined ? documentUrl - : indexedString(strings, document.baseURL); + : cdpSnapshotValueString(strings, document.baseURL); if (baseUrlValue === undefined) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -248,8 +253,8 @@ export function* captureDom( const nodeValueIndex = rawNodeValues[index]; const parent = rawParents[index]; const attributeIndexes = numberArray(rawAttributes[index]); - const nodeName = indexedString(strings, nodeNameIndex); - const nodeValue = indexedString(strings, nodeValueIndex); + const nodeName = cdpSnapshotString(strings, nodeNameIndex); + const nodeValue = cdpSnapshotValueString(strings, nodeValueIndex); if ( nodeType === undefined || nodeName === undefined || @@ -259,11 +264,7 @@ export function* captureDom( parent === index || parent >= rawNodeTypes.length || attributeIndexes === undefined || - attributeIndexes.length % 2 !== 0 || - attributeIndexes.some( - (value) => - value === undefined || indexedString(strings, value) === undefined, - ) + !cdpSnapshotAttributesResolve(strings, attributeIndexes) ) { completeness?.exclude("dom", "invalid_protocol_value"); continue; @@ -514,16 +515,6 @@ const accessibilityStates = ( const axText = (value: unknown): string | null => cdpStringValue(recordValue(value)?.value) ?? null; -const indexedString = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const uniqueAttributeNames = ( attributeIndexes: readonly number[], strings: readonly (string | undefined)[], @@ -531,7 +522,7 @@ const uniqueAttributeNames = ( const names: string[] = []; const seen = new Set(); for (let index = 0; index < attributeIndexes.length; index += 2) { - const name = indexedString(strings, attributeIndexes[index]); + const name = cdpSnapshotString(strings, attributeIndexes[index]); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -597,8 +588,8 @@ const domMetadata = ( const pairs = new Map(); for (let index = 0; index + 1 < attributes.length; index += 2) { // Chrome already folds HTML names; SVG/XML names remain case-sensitive. - const name = indexedString(strings, attributes[index]); - const value = indexedString(strings, attributes[index + 1]); + const name = cdpSnapshotString(strings, attributes[index]); + const value = cdpSnapshotValueString(strings, attributes[index + 1]); if (name === undefined || value === undefined) continue; // HTML keeps the first declaration. A later repeat must not move dom_urls. if (!pairs.has(name)) pairs.set(name, value); diff --git a/src/browser/CdpCaptureDomSvg.ts b/src/browser/CdpCaptureDomSvg.ts index 2ffd8e31fd..877bab06d1 100644 --- a/src/browser/CdpCaptureDomSvg.ts +++ b/src/browser/CdpCaptureDomSvg.ts @@ -1,4 +1,9 @@ import { numberValue, recordValue } from "./CdpCaptureValues.js"; +import { + cdpSnapshotAttributesResolve, + cdpSnapshotString, + cdpSnapshotValueString, +} from "./CdpSnapshotStrings.js"; const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; const XLINK_NAMESPACE = "http://www.w3.org/1999/xlink"; @@ -52,16 +57,6 @@ interface DomSvgLink { readonly xlinkHref: string | undefined; } -const stringAt = ( - strings: readonly (string | undefined)[], - index: unknown, -): string | undefined => { - const integer = numberValue(index); - if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) - return undefined; - return strings[integer]; -}; - const numberArray = (value: unknown): readonly (number | undefined)[] => Array.isArray(value) ? value.map((item) => { @@ -86,7 +81,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { for (let index = 0; index < nodes.nodeTypes.length; index += 1) { if (nodes.nodeTypes[index] !== 1) continue; if (nodes.parents[index] !== documentIndex) continue; - const name = stringAt(nodes.strings, nodes.nodeNames[index]); + const name = cdpSnapshotString(nodes.strings, nodes.nodeNames[index]); if (name === undefined || !validSvgSourceNode(nodes, index)) return undefined; if (name === "HTML") return false; @@ -96,7 +91,7 @@ const xmlDocument = (nodes: DomSnapshotNodes): boolean | undefined => { const attributes = numberArray(nodes.attributes[index]); for (let offset = 0; offset + 1 < attributes.length; offset += 2) { const value = attributes[offset]; - const attributeName = stringAt(nodes.strings, value); + const attributeName = cdpSnapshotString(nodes.strings, value); if (attributeName === undefined) return undefined; if (xmlnsPrefix(attributeName) !== undefined) return true; } @@ -195,7 +190,7 @@ const resolveXmlElements = ( const svgElement = nodes.nodeTypes[node] === 1 && elementNamespace( - stringAt(nodes.strings, nodes.nodeNames[node]), + cdpSnapshotString(nodes.strings, nodes.nodeNames[node]), bindings, ) === SVG_NAMESPACE; resolved.set(node, { @@ -223,8 +218,8 @@ const applyDeclarations = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); - const value = stringAt(strings, valueIndex); + const name = cdpSnapshotString(strings, nameIndex); + const value = cdpSnapshotValueString(strings, valueIndex); if (name === undefined || value === undefined) continue; const prefix = xmlnsPrefix(name); if (prefix === undefined || seen.has(prefix)) continue; @@ -277,7 +272,7 @@ const xlinkHrefValue = ( const nameIndex = attributes[index]; const valueIndex = attributes[index + 1]; if (nameIndex === undefined || valueIndex === undefined) continue; - const name = stringAt(strings, nameIndex); + const name = cdpSnapshotString(strings, nameIndex); if (name === undefined) continue; if (seen.has(name)) continue; seen.add(name); @@ -286,7 +281,7 @@ const xlinkHrefValue = ( const prefix = name.slice(0, colon); const local = name.slice(colon + 1); if (local !== "href" || bindings.get(prefix) !== XLINK_NAMESPACE) continue; - return stringAt(strings, valueIndex); + return cdpSnapshotValueString(strings, valueIndex); } return undefined; }; @@ -297,19 +292,14 @@ const validSvgSourceNode = ( ): boolean => { if ( nodes.nodeTypes[index] === undefined || - stringAt(nodes.strings, nodes.nodeNames[index]) === undefined + cdpSnapshotString(nodes.strings, nodes.nodeNames[index]) === undefined ) return false; const rawAttributes = nodes.attributes[index]; - if (!Array.isArray(rawAttributes) || rawAttributes.length % 2 !== 0) - return false; + if (!Array.isArray(rawAttributes)) return false; const attributes = numberArray(rawAttributes); return ( attributes.length === rawAttributes.length && - attributes.every( - (attribute) => - attribute !== undefined && - stringAt(nodes.strings, attribute) !== undefined, - ) + cdpSnapshotAttributesResolve(nodes.strings, attributes) ); }; diff --git a/src/browser/CdpSnapshotStrings.ts b/src/browser/CdpSnapshotStrings.ts new file mode 100644 index 0000000000..6e83147277 --- /dev/null +++ b/src/browser/CdpSnapshotStrings.ts @@ -0,0 +1,39 @@ +import { numberValue } from "./CdpCaptureValues.js"; + +// DOMSnapshot interns an empty or null string as index -1 instead of a table +// entry. Chrome uses it for element node values and empty attribute values. +const CDP_EMPTY_STRING_INDEX = -1; + +/** Resolve a DOMSnapshot name index, which must reference a string entry. */ +export const cdpSnapshotString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => { + const integer = numberValue(index); + if (integer === undefined || !Number.isSafeInteger(integer) || integer < 0) + return undefined; + return strings[integer]; +}; + +/** Resolve a DOMSnapshot value index, where -1 is the protocol's empty string. */ +export const cdpSnapshotValueString = ( + strings: readonly (string | undefined)[], + index: unknown, +): string | undefined => + numberValue(index) === CDP_EMPTY_STRING_INDEX + ? "" + : cdpSnapshotString(strings, index); + +/** Whether flattened name/value attribute indexes all resolve. */ +export const cdpSnapshotAttributesResolve = ( + strings: readonly (string | undefined)[], + attributes: readonly (number | undefined)[], +): boolean => + attributes.length % 2 === 0 && + attributes.every( + (attribute, offset) => + attribute !== undefined && + (offset % 2 === 0 + ? cdpSnapshotString(strings, attribute) + : cdpSnapshotValueString(strings, attribute)) !== undefined, + ); diff --git a/src/domain/javascript/electronWrappedCallPaths.test.ts b/src/domain/javascript/electronWrappedCallPaths.test.ts index 6d30a10f52..b92aa990f6 100644 --- a/src/domain/javascript/electronWrappedCallPaths.test.ts +++ b/src/domain/javascript/electronWrappedCallPaths.test.ts @@ -147,7 +147,9 @@ describe("wrapped contextBridge exposure methods", () => { `const worldId = selectWorld(); electron.contextBridge.exposeInIsolatedWorld(worldId, "isolatedApi", {ping: true});`, ), ); - expect(result.electron.context_bridge_apis).toContainEqual( + // The proven Webpack bootstrap resolves `load(2)` directly, so the complete + // direct observation is retained instead of a wrapper substitution. + expect(result.electron.context_bridge_apis).toEqual([ expect.objectContaining({ world: "isolated", world_id: { @@ -156,9 +158,15 @@ describe("wrapped contextBridge exposure methods", () => { expression: "worldId", }, api_key: "isolatedApi", + api_status: "object-literal", members: ["ping"], - resolution_basis: "wrapper-substitution", + unknown_members: 0, + module_key: "9", + module_runtime: "webpack-bootstrap@0", }), + ]); + expect(result.electron.context_bridge_apis[0]).not.toHaveProperty( + "resolution_basis", ); expect(result.parse_status).toBe("partial"); }); diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index f7c4a8e7ae..e582f07a85 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -56,9 +56,13 @@ const createAmbientPathResolver = ( const prior = possibleWrites ? undefined : cached.get(node); if (prior !== undefined) return prior; const result = new Map(); - const pending: { node: t.Node; suffix: AmbientProjection }[] = [ - { node, suffix: [] }, - ]; + // The caller's write site selects initializers for the written target. + // Reads reached through an initializer occur at their own position. + const pending: { + node: t.Node; + suffix: AmbientProjection; + readsAtSelf?: true; + }[] = [{ node, suffix: [] }]; // Join projections at their producing expression, rather than enumerating // alias histories. Each key can only widen to a wildcard, and conflicting // depths widen to unknown, so diamonds and cycles have finite state. @@ -105,7 +109,11 @@ const createAmbientPathResolver = ( // A widened projection is a possible write, never proof of an intrinsic. if (suffix === undefined && !possibleWrites) continue; const continueWith = (node: t.Node, path: AmbientProjection = suffix) => - pending.push({ node, suffix: path }); + pending.push({ + node, + suffix: path, + ...(current.readsAtSelf && { readsAtSelf: true }), + }); if ( t.isMetaProperty(root) && root.meta.name === "import" && @@ -126,20 +134,19 @@ const createAmbientPathResolver = ( const binding = resolveSemanticBindingState(state, root, root.name); if (binding === undefined) continue; const stable = semanticStableInitializer(binding, state); + const readAt = current.readsAtSelf ? root : capture; const initializers = possibleWrites - ? capture === undefined + ? readAt === undefined ? binding.initializers - : semanticMutationInitializers( - binding, - capture, - state.parentsByNode, - ).initializers + : semanticMutationInitializers(binding, readAt, state.parentsByNode) + .initializers : stable === undefined ? [] : [stable]; for (const initializer of initializers) pending.push({ node: initializer.node, + readsAtSelf: true, suffix: suffix === undefined ? undefined diff --git a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts index 784a5a5b6f..3058533014 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.native_bindings.test.ts @@ -206,3 +206,43 @@ describe("JavaScript semantic analysis: child-process signals", () => { ]); }); }); + +describe("JavaScript semantic analysis: configuration value escapes", () => { + const configurationReads = (body: string) => + analyzeJavaScriptSemantics(`function run() { ${body} }`) + .configurationOperations.filter(({ kind }) => kind !== "default") + .map(({ kind, key }) => [kind, key]); + + it.each([ + [ + "an environment value", + "return Number(process.env.PORT);", + "environment", + "PORT", + ], + ["an argv entry", "return Number(process.argv[2]);", "argv", "2"], + ])("keeps a read after passing %s to a call", (_name, body, kind, key) => { + expect(configurationReads(body)).toEqual([[kind, key]]); + }); + + it.each([ + [ + "a direct argv entry", + "process.argv[2] = {}; return Number(process.argv[2]);", + ], + [ + "an aliased argv entry", + "const argv = process.argv; argv[2] = {}; return Number(process.argv[2]);", + ], + [ + "the argv container", + "mutate(process.argv); return Number(process.argv[2]);", + ], + [ + "the env container", + "mutate(process.env); return Number(process.env.PORT);", + ], + ])("leaves a read unknown after replacing %s", (_name, body) => { + expect(configurationReads(body)).toEqual([]); + }); +}); diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index f3d7063b76..34666e65c7 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -69,6 +69,16 @@ export function* collectSemanticMemberMutationsSteps( node: t.Node | undefined, ): void => { const identity = semanticRequireExportsIdentity(specifier, path); + // Node stores process.env values and process.argv entries as strings, so + // passing one to a call exposes no object that the callee could mutate. + // Replacing an entry records its own write or receiver escape. + if ( + effect === "escape" && + identity.specifier === "process" && + identity.path.length === 2 && + (identity.path[0] === "env" || identity.path[0] === "argv") + ) + return; const key = JSON.stringify([ "required", identity.specifier, diff --git a/src/ghidra/GhidraSessionError.ts b/src/ghidra/GhidraSessionError.ts index dedf27bd42..9f4abfb52d 100644 --- a/src/ghidra/GhidraSessionError.ts +++ b/src/ghidra/GhidraSessionError.ts @@ -263,7 +263,6 @@ const causeLaunchOutcome = ( redact: (value: string, incomplete?: boolean) => string, ): Record => { if (!(cause instanceof GhidraLaunchError)) return {}; - if (cause.cause instanceof GhidraLaunchSupportError) return {}; const launchSupport = cause.launchSupport.length === 0 ? {} @@ -273,6 +272,9 @@ const causeLaunchOutcome = ( stderr: redact(output.stderr), })), }; + // A failed LaunchSupport helper reports its own command outcome in the + // nested cause; earlier helper warnings still belong to this launch. + if (cause.cause instanceof GhidraLaunchSupportError) return launchSupport; const output = cause.capturedOutput; if (output === undefined) return launchSupport; const stdout = boundedUtf8Text( diff --git a/src/ida/IdaNativeProtocol.test.ts b/src/ida/IdaNativeProtocol.test.ts index 466a09ad56..a1ba9fc0ce 100644 --- a/src/ida/IdaNativeProtocol.test.ts +++ b/src/ida/IdaNativeProtocol.test.ts @@ -779,18 +779,18 @@ describe("native IDA attached operation admission", () => { await client.close(); }); - it("does not admit the headless-only containment and document operations", async () => { - const { client, producer } = await fixture(); - for (const [operation, parameters] of [ - ["resolve_containing_procedure", { address: "0x1000" }], - ["list_documents", {}], - ] as const) { - const result = await client.execute(operation, parameters); + it("reports the bound document but not headless-only address resolution", async () => { + const { client, producer, target } = await fixture(); + const containing = await client.execute("resolve_containing_procedure", { + address: "0x1000", + }); + expect(containing.ok).toBe(false); + if (!containing.ok) + expect(containing.error._tag).toBe("AnalysisCapabilityUnavailableError"); - expect(result.ok).toBe(false); - if (!result.ok) - expect(result.error._tag).toBe("AnalysisCapabilityUnavailableError"); - } + const documents = await client.execute("list_documents", {}); + if (!documents.ok) throw documents.error; + expect(documents.value.result).toEqual([target.path]); expect( producer.calls.some(({ name }) => name === "lookup_funcs"), "nothing is dispatched upstream", diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index b8358ec476..63ad24b629 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -328,6 +328,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z kind: z.enum(["route", "network"]), value: z.string(), mechanism: z.string(), + interpretation: z.literal("candidate").exactOptional(), }), ), storage: z.array( diff --git a/tests/acceptance/applications/applicationWorkflowCli.test.ts b/tests/acceptance/applications/applicationWorkflowCli.test.ts index ed1ca8d169..eefa8a7f95 100644 --- a/tests/acceptance/applications/applicationWorkflowCli.test.ts +++ b/tests/acceptance/applications/applicationWorkflowCli.test.ts @@ -485,6 +485,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async declared_url: declaredUrl, declared_url_sha256: digest, resolved_path: "app.js.map", + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -507,6 +508,7 @@ it("preserves complete line-comment source-map URLs through CLI and MCP", async properties: { declared_url_sha256: digest, resolved_path: "app.js.map", + resolution_status: "resolved", }, }), ); @@ -565,6 +567,7 @@ it("resolves source-map URL paths once without changing their declarations", asy declared_url: declaredUrl, declared_url_sha256: digest, resolved_path: path, + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -587,7 +590,11 @@ it("resolves source-map URL paths once without changing their declarations", asy expect.objectContaining({ relation: "maps_to", target_node_id: map?.node_id, - properties: { declared_url_sha256: digest, resolved_path: path }, + properties: { + declared_url_sha256: digest, + resolved_path: path, + resolution_status: "resolved", + }, }), ); } @@ -1576,8 +1583,11 @@ it("retains bundled isolated-world contextBridge APIs through dedicated and gene world: "isolated", world_id: { status: "literal", value: 1001, expression: null }, api_key: "isolatedApi", + api_status: "object-literal", members: ["ping"], - resolution_basis: "wrapper-substitution", + unknown_members: 0, + // The proven Webpack bootstrap resolves `load(2)` directly. + resolution_basis: "direct-syntax", }), evidence: expect.objectContaining({ location: expect.objectContaining({ diff --git a/tests/acceptance/applications/blockSourceMapCli.test.ts b/tests/acceptance/applications/blockSourceMapCli.test.ts index 957cd49c26..4e7cb7e115 100644 --- a/tests/acceptance/applications/blockSourceMapCli.test.ts +++ b/tests/acceptance/applications/blockSourceMapCli.test.ts @@ -73,6 +73,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( declared_url: url, declared_url_sha256: digest, resolved_path: url, + resolution_status: "resolved", available: true, }, evidence: expect.objectContaining({ @@ -92,7 +93,11 @@ cliTest.for(["CLI", "stdio MCP"] as const)( expect.objectContaining({ relation: "maps_to", target_node_id: map?.node_id, - properties: { declared_url_sha256: digest, resolved_path: url }, + properties: { + declared_url_sha256: digest, + resolved_path: url, + resolution_status: "resolved", + }, }), ); }, diff --git a/tests/acceptance/applications/javascriptEndpointPayload.test.ts b/tests/acceptance/applications/javascriptEndpointPayload.test.ts index 6a980aa7ad..75b54e26e4 100644 --- a/tests/acceptance/applications/javascriptEndpointPayload.test.ts +++ b/tests/acceptance/applications/javascriptEndpointPayload.test.ts @@ -108,7 +108,9 @@ cliTest( ), ).toHaveLength(1); expect(repeatedNode.observations).toHaveLength(2); - expect(repeatedNode.identity).toMatchObject({ key: "/ok" }); + expect(repeatedNode.identity).toMatchObject({ + key: JSON.stringify(["literal", "/ok"]), + }); expect( repeatedNode.observations.every(({ label }) => label === "/ok"), ).toBe(true); diff --git a/tests/boundary/browser/sazWebCapture.test.ts b/tests/boundary/browser/sazWebCapture.test.ts index 69eca33b84..d6192e2e69 100644 --- a/tests/boundary/browser/sazWebCapture.test.ts +++ b/tests/boundary/browser/sazWebCapture.test.ts @@ -10,7 +10,10 @@ import { Ajv2020 } from "ajv/dist/2020.js"; import { expect, it as runtimeTest } from "vitest"; import { z } from "zod"; import { parseEvidence } from "../../../src/domain/evidence.js"; -import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js"; +import { + WEB_NETWORK_CAPTURE_LIMITS, + webNetworkCaptureSchema, +} from "../../../src/domain/webNetworkCapture.js"; import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -478,12 +481,19 @@ it.each([ expect(JSON.parse(result.stdout).category).toBe( ["encrypted", "unknown-member", "dtd"].includes(variant) ? "unsupported_provider" - : variant === "expanded-limit" - ? "execution_failure" - : "invalid_input", + : "invalid_input", ); + // Expanded size is a caller-correctable input bound, not a decoder failure. if (variant === "expanded-limit") - expect(JSON.parse(result.stdout).code).toBe("unreadable_output"); + expect(JSON.parse(result.stdout).details.issues).toEqual([ + expect.objectContaining({ + path: ["capture_path", ""], + reason: "out_of_range", + expected: { + maximum_expanded_capture_bytes: WEB_NETWORK_CAPTURE_LIMITS.inputBytes, + }, + }), + ]); expect(JSON.stringify(result)).not.toContain("malformed-private-input"); }); diff --git a/tests/boundary/cli/developmentTests.test.ts b/tests/boundary/cli/developmentTests.test.ts index d9b9fa7f18..3976781d5a 100644 --- a/tests/boundary/cli/developmentTests.test.ts +++ b/tests/boundary/cli/developmentTests.test.ts @@ -13,12 +13,18 @@ import { describe, expect, it } from "vitest"; import { execFileOutput } from "../../../src/process/ExecFileOutput.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; -const run = (root: string, command: string, arguments_: readonly string[]) => +const run = ( + root: string, + command: string, + arguments_: readonly string[], + env?: NodeJS.ProcessEnv, +) => execFileOutput(command, arguments_, { cwd: root, stopSignal: "SIGTERM", timeout: 30_000, maxBuffer: 1024 * 1024, + ...(env === undefined ? {} : { env }), }); const git = (root: string, ...arguments_: string[]) => @@ -72,11 +78,15 @@ const fixture = async () => { return root; }; +// Assertions read the child's plain-text summary. An inherited color setting +// styled the counts in the CI lane; NO_COLOR takes precedence over FORCE_COLOR. const testCommand = (root: string, ...arguments_: string[]) => - run(root, process.execPath, [ - "scripts/run-development-tests.mjs", - ...arguments_, - ]); + run( + root, + process.execPath, + ["scripts/run-development-tests.mjs", ...arguments_], + { ...process.env, NO_COLOR: "1" }, + ); describe("development test runner exact selection", () => { it("matches selected path casing to the host filesystem", async () => { diff --git a/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts b/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts index 0ca2e791ae..852275c2c0 100644 --- a/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts +++ b/tests/boundary/filesystem/directoryArtifactReaderOwnership.test.ts @@ -55,8 +55,13 @@ describe("DirectoryArtifactReader resource ownership", () => { const movedRoot = `${root}-original`; try { const entries = reader.entries()[Symbol.asyncIterator](); - expect((await entries.next()).value?.path).toBe("sub"); - expect((await entries.next()).value?.path).toBe("sample.txt"); + // Root entries follow the filesystem's directory order; descendants are + // read only after the root is exhausted. + const rootEntries = [ + (await entries.next()).value?.path, + (await entries.next()).value?.path, + ]; + expect(rootEntries.sort()).toEqual(["sample.txt", "sub"]); await rename(root, movedRoot); await mkdir(root); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts index 0490ae2116..8387acdaae 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptApplicationEmptyLiterals.test.ts @@ -79,6 +79,10 @@ it("retains empty literal evidence across the application boundary surfaces", as ); }); +/** Endpoint identity keys tag literal values apart from candidates. */ +const literalEndpointKey = (value: string): string => + JSON.stringify(["literal", value]); + it("keeps bridge entry paths stable through worker validation and API seed matching", async () => { const output = await analyzeSource(` const { contextBridge, ipcRenderer } = require("electron"); @@ -144,7 +148,7 @@ it("analyzes a plain long-line loader without package metadata", async () => { for (const node of nodes) { expect(node.identity).toMatchObject({ strategy: "artifact-local-key", - key: "", + key: node.kind === "endpoint" ? literalEndpointKey("") : "", }); expect(node.observations.map(({ label }) => label)).toEqual([null]); } @@ -165,9 +169,9 @@ it("keeps exact values for empty literals without synthetic application labels", // a literal two-quote endpoint; both keep exact identities. expect(endpoints).toEqual( expect.arrayContaining([ - { key: "", labels: [null], values: [""] }, - { key: '""', labels: ['""'], values: ['""'] }, - { key: "/api", labels: ["/api"], values: ["/api"] }, + { key: literalEndpointKey(""), labels: [null], values: [""] }, + { key: literalEndpointKey('""'), labels: ['""'], values: ['""'] }, + { key: literalEndpointKey("/api"), labels: ["/api"], values: ["/api"] }, ]), ); expect(endpoints).toHaveLength(3); @@ -252,7 +256,7 @@ it("does not match an empty endpoint with a literal two-quote seed", async () => case_sensitive: true, }).filter(({ node_id: nodeId }) => endpoints.has(nodeId)); expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([ - '""', + literalEndpointKey('""'), ]); }); diff --git a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts index 69a5af6621..d9d4c8ffb5 100644 --- a/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts +++ b/tests/boundary/filesystem/javascriptSourceMapLocations.test.ts @@ -134,7 +134,9 @@ it("associates each source-map URL with its edge when aliases share one physical ); expect(mapEdges).toHaveLength(2); expect(mapEdges[0]?.target_node_id).toBe(mapEdges[1]?.target_node_id); - expect( + // Edge order follows content-derived edge IDs, so pair each edge with the + // script whose comment declared it. + const declaredBySource = new Map( mapEdges.map((edge) => { const target = nodes.get(edge.target_node_id); const observation = target?.observations.find( @@ -142,15 +144,28 @@ it("associates each source-map URL with its edge when aliases share one physical properties.declared_url_sha256 === edge.properties.declared_url_sha256, ); - return { - declared_url: observation?.properties.declared_url, - resolved_path: edge.properties.resolved_path, - }; + const location = edge.evidence.location; + return [ + location.available && location.value.kind === "source-range" + ? location.value.source + : null, + { + declared_url: observation?.properties.declared_url, + resolved_path: edge.properties.resolved_path, + }, + ]; }), - ).toEqual([ - { declared_url: "app.js.map", resolved_path: "dist/app.js.map" }, - { declared_url: "./app.js.map", resolved_path: "dist/app.js.map" }, - ]); + ); + expect(Object.fromEntries(declaredBySource)).toEqual({ + "dist/one.js": { + declared_url: "app.js.map", + resolved_path: "dist/app.js.map", + }, + "dist/two.js": { + declared_url: "./app.js.map", + resolved_path: "dist/app.js.map", + }, + }); }); it("resolves a bare source-map file name and worker URL next to the script", async () => { @@ -191,6 +206,7 @@ it("resolves a bare source-map file name and worker URL next to the script", asy .update("app.js.map") .digest("hex"), resolved_path: "dist/app.js.map", + resolution_status: "resolved", }); expect( nodes diff --git a/tests/boundary/filesystem/productCatalog.test.ts b/tests/boundary/filesystem/productCatalog.test.ts index cabf725709..ecdf5e5123 100644 --- a/tests/boundary/filesystem/productCatalog.test.ts +++ b/tests/boundary/filesystem/productCatalog.test.ts @@ -57,17 +57,18 @@ const README_LINK_DRIFT_MUTATIONS = [ "(../installation.md#supported-agents)", "(../installation.md#missing-agents)", ], + ["(../native-investigation.md)", "(../missing-investigation.md)"], [ - "https://rea.tools/zh-CN/guides/native/", + "https://rea.tools/zh-CN/showcase/dx-ball/", "https://rea.tools/zh-CN/missing/", ], [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-TW/guides/native/", + "https://rea.tools/zh-CN/showcase/dx-ball/", + "https://rea.tools/zh-TW/showcase/dx-ball/", ], [ - "https://rea.tools/zh-CN/guides/native/", - "https://rea.tools/zh-CN/guides/native/?other=1", + "https://rea.tools/zh-CN/showcase/dx-ball/", + "https://rea.tools/zh-CN/showcase/dx-ball/?other=1", ], [ 'src="../assets/rea-hopper-analysis.png"', diff --git a/tests/boundary/mcp/browserComparisonInput.test.ts b/tests/boundary/mcp/browserComparisonInput.test.ts index 0dba6faffb..7e916c9f53 100644 --- a/tests/boundary/mcp/browserComparisonInput.test.ts +++ b/tests/boundary/mcp/browserComparisonInput.test.ts @@ -110,15 +110,6 @@ describe("browser comparison input boundary", () => { }, { before_scenario: {}, after_scenario: {} }, { before_scenario: scenario.before_scenario }, - { ...passive, normalization: { rules: [] } }, - { - before_scenario: scenario.before_scenario, - normalization: { rules: [] }, - }, - { - after_scenario: scenario.after_scenario, - normalization: { rules: [] }, - }, { ...passive, unexpected: true }, { ...scenario, @@ -133,8 +124,24 @@ describe("browser comparison input boundary", () => { }, }, ]; - // The projection leaves group exclusion to the canonical runtime parser. - const mixedGroups = [{ ...passive, ...scenario }]; + // Strict clients reject combinators and dependentRequired, so the + // projection states the groups in its description and leaves group + // completeness and exclusion to the canonical runtime parser. + expect(advertised.inputSchema.description).toContain( + "before + after; before_scenario + after_scenario + [normalization]", + ); + const mixedGroups = [ + { ...passive, ...scenario }, + { ...passive, normalization: { rules: [] } }, + { + before_scenario: scenario.before_scenario, + normalization: { rules: [] }, + }, + { + after_scenario: scenario.after_scenario, + normalization: { rules: [] }, + }, + ]; for (const { input, advertisedValid, runtimeValid } of [ ...[passive, scenario, withoutNormalization].map((input) => ({ input, diff --git a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts index f4915afd13..f9d9afcd59 100644 --- a/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts +++ b/tests/boundary/mcp/enhancedTools.callPathsAndBatches.test.ts @@ -91,7 +91,9 @@ describe("enhanced MCP tools", () => { }, }); }); +}); +describe("enhanced MCP graph caveats", () => { it("keeps generic provider caveats visible without truncating complete relationships", async () => { const caveat = "Decompiler output may omit indirect calls."; const client = await connect({ @@ -113,21 +115,33 @@ describe("enhanced MCP tools", () => { arguments: { address: "0x1", direction: "forward" }, }); expect(graph.structuredContent).toMatchObject({ + limitations: [ + "Artifact identity is unavailable for this observation.", + "Derived by an REA composed workflow.", + caveat, + ], normalized_result: { graph: { "0": [ - { status: "ok", relationship_coverage: { status: "complete" } }, + { + address: "0x1", + calls: ["0x2"], + status: "ok", + relationship_coverage: { status: "complete" }, + }, ], "1": [ - { status: "ok", relationship_coverage: { status: "complete" } }, + { + address: "0x2", + calls: [], + status: "ok", + relationship_coverage: { status: "complete" }, + }, ], }, limitations: [caveat], }, }); - expect(JSON.stringify(graph.structuredContent)?.split(caveat)).toHaveLength( - 2, - ); const trace = await client.callTool({ name: "trace_call_path", diff --git a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts index 9b80722e7d..09ad518ab2 100644 --- a/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts +++ b/tests/boundary/providers/ghidra/ghidraJavaLaunch.test.ts @@ -188,9 +188,13 @@ describe.skipIf(process.platform === "win32")( const cause = failure.diagnostics.failure_cause; if (mode === "env-failure") { expect(cause).toMatchObject({ - launch_outcome: { - exit_code: 3, - stderr: expect.stringContaining(`-envvars: ${warning}`), + cause: { + name: "GhidraLaunchSupportError", + command_outcome: { + killed: false, + stderr: expect.stringContaining(`-envvars: ${warning}`), + }, + cause: { code: 3 }, }, }); } else { @@ -205,9 +209,13 @@ describe.skipIf(process.platform === "win32")( } if (mode === "vm-failure") expect(cause).toMatchObject({ - launch_outcome: { - exit_code: 3, - stderr: expect.stringContaining(`-vmargs: ${warning}`), + cause: { + name: "GhidraLaunchSupportError", + command_outcome: { + killed: false, + stderr: expect.stringContaining(`-vmargs: ${warning}`), + }, + cause: { code: 3 }, }, }); if (mode === "vm-malformed") diff --git a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts index 3d96ddb67b..391a7a47bd 100644 --- a/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts +++ b/tests/boundary/providers/hopper/hopperClient.startupFailures.test.ts @@ -41,6 +41,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher { readonly firstLaunch = new Promise((resolve) => { this.#firstLaunchReady = resolve; }); + retriedProcess: ChildProcess | undefined; async launch(session: BridgeSession) { this.#launches += 1; @@ -49,6 +50,7 @@ class CancelThenFixtureLauncher implements BridgeLauncher { : new FixtureLauncher().launch(session)); if (this.#launches === 1 && launched.ok) this.#firstLaunchReady?.(launched.value.process); + else if (launched.ok) this.retriedProcess = launched.value.process; return launched; } } @@ -256,9 +258,17 @@ describe("HopperClient startup failures", () => { expect( firstProcess.exitCode !== null || firstProcess.signalCode !== null, ).toBe(true); - await expect(client.start()).resolves.toEqual({ + const restarted = await client.start(); + const retriedProcess = launcher.retriedProcess; + expect(retriedProcess).toBeDefined(); + expect(retriedProcess).not.toBe(firstProcess); + expect(restarted).toEqual({ ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, + value: { + name: "REA Hopper bridge", + version: "1.0.0", + processId: retriedProcess?.pid, + }, }); await expect( client.callTool("echo", { value: "retried" }), @@ -282,11 +292,16 @@ describe("HopperClient startup failures", () => { error: { _tag: "HopperCancelledError" }, }); expect(launcher.processes).toHaveLength(0); - await expect(client.start()).resolves.toEqual({ - ok: true, - value: { name: "REA Hopper bridge", version: "1.0.0" }, - }); + const started = await client.start(); expect(launcher.processes).toHaveLength(1); + expect(started).toEqual({ + ok: true, + value: { + name: "REA Hopper bridge", + version: "1.0.0", + processId: launcher.processes[0]?.pid, + }, + }); await expect( client.callTool("echo", { value: "retried" }), ).resolves.toEqual({ diff --git a/tests/process-global/cleanLinuxHopperRuntime.test.ts b/tests/process-global/cleanLinuxHopperRuntime.test.ts index 55fdf0ec9e..00f02d6ecd 100644 --- a/tests/process-global/cleanLinuxHopperRuntime.test.ts +++ b/tests/process-global/cleanLinuxHopperRuntime.test.ts @@ -74,15 +74,12 @@ it const { stdout } = await parent; const { pid } = JSON.parse(stdout) as { pid: number }; orphanPid = pid; - if (alternateMode) { - const processEntry = join(procRoot, String(pid)); - await mkdir(processEntry); - for (const name of ["cmdline", "comm", "exe"]) - await symlink( - `/proc/${String(pid)}/${name}`, - join(processEntry, name), - ); - } + // The runtime check reads only the supplied proc root, so expose the + // fixture there in both modes. + const processEntry = join(procRoot, String(pid)); + await mkdir(processEntry); + for (const name of ["cmdline", "comm", "exe"]) + await symlink(`/proc/${String(pid)}/${name}`, join(processEntry, name)); const { stdout: coordinates } = await execFileAsync("ps", [ "-p", String(pid),