diff --git a/docs/javascript-runtime-observation.md b/docs/javascript-runtime-observation.md index 2cb3cbff1e..4eab065373 100644 --- a/docs/javascript-runtime-observation.md +++ b/docs/javascript-runtime-observation.md @@ -105,7 +105,9 @@ The direct observations are: - `Debugger.scriptParsed`, establishing that a bounded script location was parsed in the observed execution context; - `Debugger.scriptFailedToParse`, retained only as a bounded invalid-script - count; + count in `scripts.parse_failed_total`. A well-formed parse-failure + event is distinct from malformed protocol data and does not make loaded + script coverage unavailable. Older Evidence may omit this count; - `Runtime.executionContextCreated`, `Runtime.executionContextDestroyed`, and `Runtime.executionContextsCleared`, establishing context lifecycle within @@ -119,6 +121,12 @@ handlers, or script-unload events. Those facts remain explicit unknowns. `scriptParsed` proves script presence, not the importing module, feature execution, initialization order, or causality. +For an authorized file script, `location.file_path` is the verified filesystem +identity and optional script-item `reported_url` preserves the exact Inspector +URL. Script identity includes that reported URL, so query and fragment +variants of one file remain separate observations. Older Evidence may omit +`reported_url` and remains readable. + ## Node and Electron roles The caller declares one role: `node`, `electron-main`, `electron-preload`, or diff --git a/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts b/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts index 399f20b3f1..a5f0d9a2a8 100644 --- a/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts +++ b/src/application/javascript/JavaScriptRuntimeObservationEvidence.ts @@ -10,6 +10,7 @@ import type { ListJavaScriptRuntimeTargetsInput, ObserveJavaScriptRuntimeInput, } from "../../domain/javascript/javascriptRuntimeObservation.js"; +import { jsonValueSchema } from "../../domain/jsonValue.js"; type RuntimeObservationOperation = | "list_javascript_runtime_targets" @@ -29,7 +30,7 @@ export const createJavaScriptRuntimeObservationEvidence = ( : "rea.javascript-runtime-observation", operation, parameters: parameters(input), - result, + result: jsonValueSchema.parse(result), rawResult: null, confidence: "observed", authority: "external-service", diff --git a/src/contracts/javascript/javascriptRuntimeObservationToolContracts.ts b/src/contracts/javascript/javascriptRuntimeObservationToolContracts.ts index 8aa758804d..d03328b64a 100644 --- a/src/contracts/javascript/javascriptRuntimeObservationToolContracts.ts +++ b/src/contracts/javascript/javascriptRuntimeObservationToolContracts.ts @@ -33,7 +33,7 @@ export const JAVASCRIPT_RUNTIME_OBSERVATION_TOOL_CONTRACTS = [ name: "observe_javascript_runtime", ...toolContractMetadata("observe_javascript_runtime"), description: - "Attach passively to one Node/Electron V8 Inspector target by supplying its loopback Inspector endpoint and target ID. The provider rechecks target presence and attachability; a Node discovery file location may remain unresolved. Loaded script locations are validated independently from Debugger.scriptParsed. Captures script and Runtime execution-context metadata. REA never evaluates, pauses, resumes, reads source, or instruments the target; require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns. Reconcile the result with static Application Graph Evidence using reconcile_javascript_runtime. The selected endpoint exposes every target it serves to this tool.", + "Attach passively to one Node/Electron V8 Inspector target by supplying its loopback Inspector endpoint and target ID. The provider rechecks target presence and attachability; a Node discovery file location may remain unresolved. Loaded script locations are validated independently from Debugger.scriptParsed. Authorized file script items retain their exact Inspector URL in optional reported_url separately from verified location.file_path, and query/fragment variants remain distinct script identities. Valid Debugger.scriptFailedToParse events are counted at scripts.parse_failed_total separately from malformed protocol values. Captures script and Runtime execution-context metadata. REA never evaluates, pauses, resumes, reads source, or instruments the target; require/import edges, EventEmitter activity, and Electron IPC remain explicit unknowns. Reconcile the result with static Application Graph Evidence using reconcile_javascript_runtime. The selected endpoint exposes every target it serves to this tool.", kind: "runtime-provider", inputSchema: observeJavaScriptRuntimeInputSchema, outputSchema: evidenceResultOf(javascriptRuntimeObservationSchema), diff --git a/src/domain/javascript/javascriptRuntimeObservation.ts b/src/domain/javascript/javascriptRuntimeObservation.ts index 089649e179..76e1bd6dd7 100644 --- a/src/domain/javascript/javascriptRuntimeObservation.ts +++ b/src/domain/javascript/javascriptRuntimeObservation.ts @@ -115,6 +115,12 @@ export type JavaScriptRuntimeTargetList = z.infer< const javascriptRuntimeScriptSchema = z.strictObject({ script_key: prefixedDigestSchema("v8_script"), location: javascriptRuntimeTargetLocationSchema, + reported_url: z + .string() + .optional() + .describe( + "Exact Inspector URL for an authorized file script, retained separately from its verified filesystem path.", + ), execution_context_key: z.string().nullable(), cdp_hash: z.string().nullable(), length: z @@ -160,6 +166,13 @@ export const javascriptRuntimeObservationSchema = z.strictObject({ scripts: z.strictObject({ items: z.array(javascriptRuntimeScriptSchema), observed_total: z.int().min(0), + parse_failed_total: z + .int() + .min(0) + .optional() + .describe( + "Valid scriptFailedToParse events observed; absent in legacy snapshots.", + ), excluded: z.strictObject({ unsupported_location: z.int().min(0), invalid_protocol_value: z.int().min(0), diff --git a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts index 066f4acd00..66d93b65b5 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.ts @@ -79,7 +79,7 @@ interface NormalizedV8Inspection { source: { readonly included: false; readonly reason: string }; }> & ( - | { readonly file_path: string } + | { readonly file_path: string; readonly reported_url?: string } | { readonly url: string } | { readonly unresolved_location: Extract< @@ -335,6 +335,9 @@ const normalizeV8Inspection = ( script_key: script.script_key, frame_id: script.execution_context_key, ...runtimeLocation(script.location), + ...("reported_url" in script && script.reported_url !== undefined + ? { reported_url: script.reported_url } + : {}), cdp_hash: script.cdp_hash, length: script.length, is_module: script.is_module, diff --git a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts index 8c01377013..44b8960c42 100644 --- a/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts +++ b/src/domain/javascript/javascriptRuntimeReconciliationRuntime.ts @@ -173,6 +173,9 @@ const scriptEntities = ( location_reason: script.unresolved_location.reason, } : { location: location.value }), + ...("reported_url" in script && script.reported_url !== undefined + ? { reported_url: script.reported_url } + : {}), cdp_hash: script.cdp_hash, length: script.length, is_module: script.is_module, diff --git a/src/inspector/V8InspectorCaptureProjection.test.ts b/src/inspector/V8InspectorCaptureProjection.test.ts index 2c86ece1d5..3b263f7430 100644 --- a/src/inspector/V8InspectorCaptureProjection.test.ts +++ b/src/inspector/V8InspectorCaptureProjection.test.ts @@ -34,6 +34,7 @@ const stateFor = (scripts: CaptureState["scripts"]): CaptureState => ({ metadataBytes: 0, scriptsObserved: scripts.length, invalidScripts: 0, + parseFailedScripts: 0, invalidContexts: 0, truncated: false, truncationReasons: new Set(), @@ -102,13 +103,20 @@ it("authorizes distinct Inspector locations and preserves stable deduplication", ]), ); - expect(result.scripts.items.map(({ location }) => location)).toEqual( + expect(result.scripts.items).toEqual( expect.arrayContaining([ - { kind: "file", file_path: await realpath(firstPath) }, - { kind: "file", file_path: await realpath(secondPath) }, + expect.objectContaining({ + location: { kind: "file", file_path: await realpath(firstPath) }, + reported_url: firstUrl, + }), + expect.objectContaining({ + location: { kind: "file", file_path: await realpath(secondPath) }, + reported_url: secondUrl, + }), ]), ); expect(result.scripts.items).toHaveLength(2); + expect(result.scripts.parse_failed_total).toBe(0); expect(result.scripts.excluded).toEqual({ unsupported_location: 1, invalid_protocol_value: 0, @@ -154,6 +162,7 @@ it("authorizes each unique script location through a bounded worker pool", async expect(activeUrls.size).toBe(0); expect(result.scripts.items).toHaveLength(200); expect(result.scripts.observed_total).toBe(202); + expect(result.scripts.parse_failed_total).toBe(0); expect(result.scripts.excluded).toEqual({ unsupported_location: 1, invalid_protocol_value: 0, diff --git a/src/inspector/V8InspectorCaptureProjection.ts b/src/inspector/V8InspectorCaptureProjection.ts index 4b05caa337..6dbf0870cc 100644 --- a/src/inspector/V8InspectorCaptureProjection.ts +++ b/src/inspector/V8InspectorCaptureProjection.ts @@ -191,6 +191,7 @@ export const finalizeInspectorCapture = async ({ scripts: { items, observed_total: state.scriptsObserved, + parse_failed_total: state.parseFailedScripts, excluded: { ...exclusions, invalid_protocol_value: state.invalidScripts, @@ -241,6 +242,7 @@ const scriptFromDraft = ( ): JavaScriptRuntimeObservation["scripts"]["items"][number] => { const stable = JSON.stringify({ location, + ...(location.kind === "file" ? { reported_url: draft.rawUrl } : {}), execution_context_key: draft.executionContextKey, cdp_hash: draft.cdpHash, length: draft.length, @@ -249,6 +251,7 @@ const scriptFromDraft = ( return { script_key: `v8_script_${createHash("sha256").update(stable).digest("hex")}`, location, + ...(location.kind === "file" ? { reported_url: draft.rawUrl } : {}), execution_context_key: draft.executionContextKey, cdp_hash: draft.cdpHash, length: draft.length, diff --git a/src/inspector/V8InspectorProvider.ts b/src/inspector/V8InspectorProvider.ts index 4f7c6e09c1..ca556289ba 100644 --- a/src/inspector/V8InspectorProvider.ts +++ b/src/inspector/V8InspectorProvider.ts @@ -160,6 +160,7 @@ export interface CaptureState { metadataBytes: number; scriptsObserved: number; invalidScripts: number; + parseFailedScripts: number; invalidContexts: number; truncated: boolean; readonly truncationReasons: Set; @@ -589,6 +590,7 @@ const emptyCaptureState = (): CaptureState => ({ metadataBytes: 0, scriptsObserved: 0, invalidScripts: 0, + parseFailedScripts: 0, invalidContexts: 0, truncated: false, truncationReasons: new Set(), @@ -609,7 +611,16 @@ const ingestEvent = (event: CdpEvent, state: CaptureState): void => { return; } if (event.method === "Debugger.scriptFailedToParse") { - state.invalidScripts += 1; + const params = recordValue(event.params); + if ( + typeof params?.scriptId !== "string" || + typeof params.url !== "string" + ) { + state.invalidScripts += 1; + retainEvent(state, 0); + return; + } + state.parseFailedScripts += 1; retainEvent(state, 0); return; } diff --git a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts index d840628a63..3e83ace512 100644 --- a/tests/boundary/inspector/javascriptRuntimeObservation.test.ts +++ b/tests/boundary/inspector/javascriptRuntimeObservation.test.ts @@ -19,10 +19,14 @@ import type { JavaScriptRuntimeObservation, ObserveJavaScriptRuntimeInput, } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; -import { observeJavaScriptRuntimeInputSchema } from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; +import { + javascriptRuntimeObservationSchema, + observeJavaScriptRuntimeInputSchema, +} from "../../../src/domain/javascript/javascriptRuntimeObservation.js"; import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import { createEvidence } from "../../../src/domain/evidence.js"; import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js"; +import { parseRuntimeCaptures } from "../../../src/domain/javascript/javascriptRuntimeReconciliationCaptureParsing.js"; import { startFakeV8Inspector } from "../../fixtures/inspector/fakeV8Inspector.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -761,6 +765,143 @@ describe("passive V8 Inspector evidence", () => { }); }); +test("keeps Inspector parse failures separate and preserves file URL script identity through reconciliation", async () => { + const fixture = await runtimeFixture(); + const firstUrl = `${pathToFileURL(fixture.entry).href}?instance=1#first`; + const secondUrl = `${pathToFileURL(fixture.entry).href}?instance=2#second`; + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + scriptEvents: [ + ...[firstUrl, secondUrl].map((url, index) => ({ + method: "Debugger.scriptParsed", + params: { + scriptId: String(index + 1), + url, + executionContextId: 1, + hash: "same-source-hash", + length: 42, + isModule: true, + }, + })), + { + method: "Debugger.scriptFailedToParse", + params: { scriptId: "failed", url: firstUrl }, + }, + ], + }); + try { + const observed = await new V8InspectorProvider().observe( + observeInput(fake.endpoint, fake.targetId, "node"), + ); + if (!observed.ok) throw observed.error; + expect(observed.value.scripts.items).toHaveLength(2); + expect(observed.value.scripts.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + location: { kind: "file", file_path: fixture.entry }, + reported_url: firstUrl, + }), + expect.objectContaining({ + location: { kind: "file", file_path: fixture.entry }, + reported_url: secondUrl, + }), + ]), + ); + expect( + new Set(observed.value.scripts.items.map(({ script_key }) => script_key)) + .size, + ).toBe(2); + expect(observed.value.scripts).toMatchObject({ + parse_failed_total: 1, + excluded: { + unsupported_location: 0, + invalid_protocol_value: 0, + }, + }); + + const evidence = createJavaScriptRuntimeObservationEvidence( + "observe_javascript_runtime", + { + inspector_endpoint: fake.endpoint, + target_id: fake.targetId, + runtime_kind: "node", + observation_ms: 100, + }, + observed.value, + V8_INSPECTOR_PROVIDER_IDENTITY, + ); + const reconciled = reconcileJavaScriptRuntimeEvidence({ + static_layers: JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE.static_layers, + runtime_observations: [evidence], + }); + if (!reconciled.ok) throw reconciled.error; + const result = javascriptRuntimeReconciliationResultSchema.parse( + reconciled.value.normalized_result, + ); + const runtimeScripts = result.graph.nodes.filter( + (node) => node.kind === "runtime-script-instance", + ); + const reportedUrls = runtimeScripts.flatMap((node) => + node.observations + .map(({ properties }) => properties.reported_url) + .filter((url): url is string => typeof url === "string"), + ); + expect(reportedUrls).toEqual(expect.arrayContaining([firstUrl, secondUrl])); + expect(new Set(reportedUrls).size).toBe(2); + const scriptKeys = runtimeScripts.flatMap((node) => + node.identity.strategy === "runtime-instance" + ? [node.identity.runtime_key] + : [], + ); + expect(new Set(scriptKeys).size).toBe(2); + + const parsedCapture = parseRuntimeCaptures([evidence])[0]; + if (parsedCapture?.kind !== "v8-inspector") + throw new Error("Expected the Inspector Evidence to parse as V8 capture"); + expect( + parsedCapture.inspection.completeness.unavailable_sections, + ).not.toContain("scripts"); + + const legacySnapshot = { + ...observed.value, + scripts: { + ...observed.value.scripts, + }, + }; + delete legacySnapshot.scripts.parse_failed_total; + const parsedLegacySnapshot = + javascriptRuntimeObservationSchema.parse(legacySnapshot); + expect("parse_failed_total" in parsedLegacySnapshot.scripts).toBe(false); + } finally { + await fake.close(); + } +}); + +test("keeps malformed scriptFailedToParse protocol values in the invalid count", async () => { + const fixture = await runtimeFixture(); + const fake = await startFakeV8Inspector({ + targetUrl: pathToFileURL(fixture.entry).href, + scriptEvents: [ + { + method: "Debugger.scriptFailedToParse", + params: { url: pathToFileURL(fixture.entry).href }, + }, + ], + }); + try { + const observed = await new V8InspectorProvider().observe( + observeInput(fake.endpoint, fake.targetId, "node"), + ); + if (!observed.ok) throw observed.error; + expect(observed.value.scripts.excluded).toMatchObject({ + invalid_protocol_value: 1, + }); + expect(observed.value.scripts.parse_failed_total).toBe(0); + } finally { + await fake.close(); + } +}); + const runtimeFixture = async () => { const root = await createTestTempDirectory("rea-v8-runtime-"); const entry = join(root, "entry.js"); diff --git a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts index 77c9d59ecd..859334de2e 100644 --- a/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts +++ b/tests/boundary/mcp/javascriptRuntimeObservationMcp.test.ts @@ -4,6 +4,7 @@ import { join } from "node:path"; import { pathToFileURL } from "node:url"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { afterEach, describe, expect, test } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -134,6 +135,7 @@ describe("JavaScript runtime observation MCP tools", () => { }, }); expect(observedRuntime.isError).not.toBe(true); + await assertSdkObservationSchema(client, observedRuntime.structuredContent); expect(observedRuntime.structuredContent).toMatchObject({ normalized_result: { target: { @@ -144,7 +146,11 @@ describe("JavaScript runtime observation MCP tools", () => { scripts: { items: [ expect.objectContaining({ - location: { kind: "file", file_path: entry }, + location: expect.objectContaining({ + kind: "file", + file_path: entry, + }), + reported_url: pathToFileURL(entry).href, }), ], }, @@ -165,6 +171,21 @@ describe("JavaScript runtime observation MCP tools", () => { }); }); +const assertSdkObservationSchema = async ( + client: Client, + content: unknown, +): Promise => { + const observeTool = (await client.listTools()).tools.find( + ({ name }) => name === "observe_javascript_runtime", + ); + if (observeTool?.outputSchema === undefined) + throw new Error("Missing Inspector observation output schema"); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const schema: Record = observeTool.outputSchema; + expect(ajv.validateSchema(schema)).toBe(true); + expect(ajv.validate(schema, content), JSON.stringify(ajv.errors)).toBe(true); +}; + const createObservationClient = async ( root: string, resources: Array<{ close(): Promise }>,