diff --git a/docs/javascript-application-workflows.md b/docs/javascript-application-workflows.md index 68280abf19..b2fb8c3f82 100644 --- a/docs/javascript-application-workflows.md +++ b/docs/javascript-application-workflows.md @@ -161,6 +161,17 @@ reached through `contains` from one of its children only leads further up its containment chain, so a shared asset or artifact root does not pull in its other children. +When a string seed has no application match and the supplied Evidence has a +semantic graph, `semantic_discovery` lists matching retained semantic node +labels and direct string call arguments using the same matching mode and case +rules. These are candidate starting +points, not application trace matches. Its graph coverage and total describe the +retained graph; missing or partial source coverage cannot establish absence. +Results follow retained node order and default to offset 0 and limit 64. Supply +`semantic_page` and follow `next_offset` for further matches. Use a returned +`trace_seed` with the same application Evidence and `trace_javascript_semantics` +to examine relationships from that node. Other typed seeds do not search labels. + The result contains the matching basis, complete reachable subgraph, terminal paths, authority summaries, and native handoffs. A handoff binds the exact native artifact digest and requested exports from the application @@ -273,7 +284,14 @@ mapped candidate, but structural reachability, semantic influence, runtime observation, and causal proof remain separate claims. Semantic literal nodes keep their complete value in `properties.value`; literal -queries match that field. String labels use `string literal` when that is +queries match that field. Call-site nodes also retain direct scalar arguments in +`properties.arguments`, with source argument indexes; literal queries can start +at those call sites. These are syntactic facts, not runtime values, and an index +after a spread is not necessarily the runtime argument position. Nonliteral +arguments and values not representable in JSON are omitted. The primitive byte +bound and the worker transfer record ceiling can also omit direct values; the +call-site unknown reports the applicable limit. String labels use +`string literal` when that is shorter than the complete JSON value. A literal's `identity.role_key` commits its canonical JSON value as `value-sha256:` when the digest key is shorter; smaller values keep their existing JSON key. This keeps large values diff --git a/src/application/javascript/JavaScriptApplicationWorkflowService.ts b/src/application/javascript/JavaScriptApplicationWorkflowService.ts index 11c6c6d78c..908137a855 100644 --- a/src/application/javascript/JavaScriptApplicationWorkflowService.ts +++ b/src/application/javascript/JavaScriptApplicationWorkflowService.ts @@ -158,6 +158,10 @@ export const traceApplicationFeatureEvidence = ( const result = traceApplicationFeature({ sourceEvidenceId: source.evidence.evidence_id, graph: source.graph, + semanticGraph: source.semanticGraph, + ...(input.semantic_page === undefined + ? {} + : { semanticPage: input.semantic_page }), nativeEvidence, seed: input.seed, direction: input.direction, @@ -168,6 +172,9 @@ export const traceApplicationFeatureEvidence = ( native_evidence_ids: nativeEvidence.map(({ evidence_id: id }) => id), seed: input.seed, direction: input.direction, + ...(input.semantic_page === undefined + ? {} + : { semantic_page: input.semantic_page }), }, result, ); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index b1d1d688a2..84aee2f524 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -31,6 +31,99 @@ import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterE const SHA256 = "a".repeat(64); +it("retains direct call literals and resolves literal queries to their call site", () => { + const source = + 'api.rpc("proc_0", 7, false, null, dynamic, ...values, `fixed`, -3);'; + const graph = graphFor(source); + const call = graph.nodes.find(({ kind }) => kind === "call-site"); + const argumentsValue = call?.properties.arguments; + expect(argumentsValue).toEqual([ + expect.objectContaining({ + index: 0, + value: "proc_0", + source_range: expect.any(Object), + }), + expect.objectContaining({ index: 1, value: 7 }), + expect.objectContaining({ index: 2, value: false }), + expect.objectContaining({ index: 3, value: null }), + expect.objectContaining({ index: 6, value: "fixed" }), + expect.objectContaining({ index: 7, value: -3 }), + ]); + if (call === undefined) throw new Error("Expected call site"); + const trace = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: "proc_0" }, + direction: "forward-influence", + }), + ); + expect(trace.seed_node_ids).toContain(call.node_id); +}); + +it("reports the primitive byte bound when a direct argument is too large", () => { + const source = `rpc(${JSON.stringify("x".repeat(175_000))});`; + const ir = analyzeJavaScriptSemantics(source); + expect(ir.coverage.resourceLimits).toContain("primitive-bytes"); + const graph = graphFor(source, ir); + const call = graph.nodes.find(({ kind }) => kind === "call-site"); + expect(call?.properties.arguments).toBeUndefined(); + expect(graph.coverage.status).not.toBe("complete"); + const unknown = graph.unknowns.find( + (entry) => + entry.node_id === call?.node_id && entry.reason === "resource-limit", + ); + expect(unknown?.detail).toMatch(/primitive string-byte budget exceeded/i); + expect( + graph.evidence_contexts.find( + (entry) => entry.context_id === unknown?.evidence.context_id, + ), + ).toMatchObject({ + coverage: { + limits: expect.arrayContaining([ + expect.objectContaining({ + name: "javascript_semantic_primitive_bytes", + }), + ]), + }, + }); +}); + +it("keeps a large call's graph within the worker record limit and reports omitted arguments", () => { + const value = JSON.stringify("x".repeat(140_000)); + const source = `rpc(${Array.from({ length: 64 }, () => value).join(",")});`; + const graph = graphFor(source); + const call = graph.nodes.find(({ kind }) => kind === "call-site"); + if (call === undefined) throw new Error("Expected call site"); + const values = call.properties.arguments; + expect(Array.isArray(values)).toBe(true); + if (!Array.isArray(values)) throw new Error("Expected retained arguments"); + expect(values.length).toBeGreaterThan(0); + expect(values.length).toBeLessThan(64); + expect( + Buffer.byteLength(`${JSON.stringify({ kind: "node", value: call })}\n`), + ).toBeLessThanOrEqual(8 * 1024 * 1024); + const unknown = graph.unknowns.find( + (entry) => + entry.node_id === call.node_id && + entry.detail.includes("worker transfer record ceiling"), + ); + expect(unknown).toMatchObject({ reason: "resource-limit" }); + expect( + graph.evidence_contexts.find( + (entry) => entry.context_id === unknown?.evidence.context_id, + ), + ).toMatchObject({ + coverage: { + limits: expect.arrayContaining([ + expect.objectContaining({ + name: "javascript_analysis_transfer_record_bytes", + value: 8 * 1024 * 1024, + }), + ]), + }, + }); +}); + it("projects bounded alias selection as a resource-limit unknown", () => { const source = aliasSelectionBudgetSource(); const ir = analyzeJavaScriptSemantics(source); diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index ae1dc849b7..fb9d4830d4 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -51,6 +51,7 @@ import { import type { JsonValue } from "../../domain/jsonValue.js"; import { JAVASCRIPT_ANALYSIS_RESOURCE_DEFAULTS, + JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES, JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, javaScriptAnalysisCoverageLimits, javaScriptSemanticCeilingCoverage, @@ -71,6 +72,7 @@ import { semanticFamilyStatus, } from "./JavaScriptSemanticGraphProjection.js"; import { + constructSemanticGraphNode, addSemanticFallbackRoot as addFallbackRoot, addSemanticGraphUnknown, recordSemanticUnknownOmission, @@ -771,14 +773,102 @@ function* createCallSiteNodes( call.callerCallableId === null ? null : (callables.get(call.callerCallableId)?.node_id ?? null); - const node = retainNode(state, file, { - kind: "call-site", + const nodeInput = { + kind: "call-site" as const, roleKey: `call:${call.callSiteId}`, location: call.location, label: call.kind, functionNodeId: owner, + }; + // Include the complete node and worker JSON envelope before retaining any + // arguments. Each entry is charged by its encoded size and comma, so even + // worst-case escaping cannot overflow the worker's single-record ceiling. + let recordBytes = 0; + if (call.arguments.some((argument) => Object.hasOwn(argument, "literal"))) { + const baseline = constructSemanticGraphNode(file, nodeInput, state); + recordBytes = Buffer.byteLength( + `${JSON.stringify({ kind: "node", value: { ...baseline, properties: { arguments: [] } } })}\n`, + ); + } + const argumentsValue: JsonValue[] = []; + let transferOmitted = 0; + for (const argument of call.arguments) { + if (!Object.hasOwn(argument, "literal")) continue; + const item = { + index: argument.index, + value: argument.literal ?? null, + source_range: + argument.valueExpressionLocation === null + ? null + : { + start: { ...argument.valueExpressionLocation.start }, + end: { ...argument.valueExpressionLocation.end }, + }, + }; + const size = + Buffer.byteLength(JSON.stringify(item)) + + (argumentsValue.length === 0 ? 0 : 1); + if (recordBytes + size > JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES) { + transferOmitted += 1; + continue; + } + argumentsValue.push(item); + recordBytes += size; + } + const node = retainNode(state, file, { + ...nodeInput, + ...(argumentsValue.length === 0 + ? {} + : { properties: { arguments: argumentsValue } }), }); - if (node !== null) result.set(call.callSiteId, node); + if (node !== null) { + result.set(call.callSiteId, node); + if (transferOmitted > 0) { + const detail = `${String(transferOmitted)} direct call arguments exceeded the ${String(JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES)}-byte worker transfer record ceiling.`; + addSemanticGraphUnknown(state, { + node_id: node.node_id, + family: "call-flow", + relation_kinds: ["argument-to-parameter"], + reason: "resource-limit", + detail, + candidate_node_ids: [], + evidence: { + ...unknownSemanticEvidence(file, call.location), + coverage: partialApplicationCoverage( + javaScriptAnalysisCoverageLimits({ + transfer_record_bytes: + JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES, + }), + null, + ), + limitations: [detail], + }, + }); + } + if ( + call.arguments.some( + (argument) => argument.literalResourceLimit === "primitive-bytes", + ) + ) { + const limit = "primitive-bytes"; + addSemanticGraphUnknown(state, { + node_id: node.node_id, + family: "call-flow", + relation_kinds: ["argument-to-parameter"], + reason: "resource-limit", + detail: semanticResourceLimitReason(limit), + candidate_node_ids: [], + evidence: { + ...unknownSemanticEvidence(file, call.location), + coverage: partialApplicationCoverage( + semanticResourceLimitCoverage([limit]), + null, + ), + limitations: [semanticResourceLimitReason(limit)], + }, + }); + } + } } return result; } diff --git a/src/contracts/javascript/applicationWorkflowInputContracts.ts b/src/contracts/javascript/applicationWorkflowInputContracts.ts index c4a7cbbe50..848d5b50d6 100644 --- a/src/contracts/javascript/applicationWorkflowInputContracts.ts +++ b/src/contracts/javascript/applicationWorkflowInputContracts.ts @@ -17,6 +17,7 @@ const traceApplicationFeatureFacts = { native_observations: traceApplicationFeatureInputSchema.shape.native_observations, seed: traceApplicationFeatureInputSchema.shape.seed, + semantic_page: traceApplicationFeatureInputSchema.shape.semantic_page, direction: traceApplicationFeatureInputSchema.shape.direction, } as const; diff --git a/src/domain/javascript/javascriptAnalysisResourceControls.ts b/src/domain/javascript/javascriptAnalysisResourceControls.ts index a7bf25b51d..bd5dd36786 100644 --- a/src/domain/javascript/javascriptAnalysisResourceControls.ts +++ b/src/domain/javascript/javascriptAnalysisResourceControls.ts @@ -2,6 +2,8 @@ import { z } from "zod"; /** Complete static checkpoint / per-script result transport ceiling. */ export const JAVASCRIPT_STATIC_FACT_BYTES = 8 * 1024 * 1024; +/** Maximum encoded worker transfer record, including the JSON envelope and newline. */ +export const JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES = 8 * 1024 * 1024; import type { JsonValue } from "../jsonValue.js"; import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas.js"; diff --git a/src/domain/javascript/javascriptAstValues.ts b/src/domain/javascript/javascriptAstValues.ts index 3cade5b062..332dcf9b83 100644 --- a/src/domain/javascript/javascriptAstValues.ts +++ b/src/domain/javascript/javascriptAstValues.ts @@ -71,6 +71,17 @@ export const readExactJavaScriptLiteral = ( return { found: true, value: node.value }; if (t.isBooleanLiteral(node)) return { found: true, value: node.value }; if (t.isNullLiteral(node)) return { found: true, value: null }; + if ( + t.isUnaryExpression(node) && + (node.operator === "+" || node.operator === "-") + ) { + const operand = unwrapJavaScriptExpression(node.argument).node; + if (t.isNumericLiteral(operand)) { + const value = node.operator === "-" ? -operand.value : operand.value; + if (Number.isFinite(value) && !Object.is(value, -0)) + return { found: true, value }; + } + } if ( t.isTemplateLiteral(node) && node.expressions.length === 0 && diff --git a/src/domain/javascript/javascriptCallLiteral.test.ts b/src/domain/javascript/javascriptCallLiteral.test.ts new file mode 100644 index 0000000000..e9a8212978 --- /dev/null +++ b/src/domain/javascript/javascriptCallLiteral.test.ts @@ -0,0 +1,45 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; +import { graphForJavaScript } from "../../../tests/fixtures/javascriptSemanticGraph.js"; +import { readExactJavaScriptLiteral } from "./javascriptAstValues.js"; +import { analyzeParsedJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { queryJavaScriptSemanticGraph } from "./javascriptSemanticQuery.js"; +import { javaScriptSemanticQueryInputSchema } from "./javascriptSemanticQuerySchemas.js"; + +it("recognizes wrapped unary numeric literals without collapsing negative zero", () => { + const wrapped = t.tsAsExpression(t.numericLiteral(7), t.tsNumberKeyword()); + expect(readExactJavaScriptLiteral(t.unaryExpression("-", wrapped))).toEqual({ + found: true, + value: -7, + }); + expect( + readExactJavaScriptLiteral(t.unaryExpression("-", t.numericLiteral(0))), + ).toEqual({ + found: false, + }); +}); + +it("retains wrapped TypeScript call literals for semantic queries", () => { + const source = "rpc(-(7 as number), +(8 as number), -0, variable);"; + const parsed = parseJavaScriptSource(source, "example.ts"); + if (parsed === null) throw new Error("Expected TypeScript source to parse"); + const graph = graphForJavaScript( + source, + analyzeParsedJavaScriptSemantics(parsed), + ); + const call = graph.nodes.find(({ kind }) => kind === "call-site"); + expect(call?.properties.arguments).toEqual([ + expect.objectContaining({ index: 0, value: -7 }), + expect.objectContaining({ index: 1, value: 8 }), + ]); + if (call === undefined) throw new Error("Expected call site"); + const result = queryJavaScriptSemanticGraph( + graph, + javaScriptSemanticQueryInputSchema.parse({ + seed: { kind: "literal", value: -7 }, + direction: "forward-influence", + }), + ); + expect(result.seed_node_ids).toContain(call.node_id); +}); diff --git a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts index f0616b718f..6bad09007f 100644 --- a/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts +++ b/src/domain/javascript/javascriptFeatureSeed.array_members.test.ts @@ -365,7 +365,7 @@ describe("feature trace projection from contextBridge source members", () => { }); expect(result.evidence_links).toEqual([sourceEvidenceId]); expect(result.limitations).toContain( - "No graph entity matched the literal seed; this is not evidence that the feature is absent.", + "No application graph entity matched the literal seed; this is not evidence that the feature is absent.", ); }); diff --git a/src/domain/javascript/javascriptFeatureSeed.ts b/src/domain/javascript/javascriptFeatureSeed.ts index 98fb76477d..519f546a59 100644 --- a/src/domain/javascript/javascriptFeatureSeed.ts +++ b/src/domain/javascript/javascriptFeatureSeed.ts @@ -42,7 +42,7 @@ const seedMatch = ( ): ApplicationFeatureSeedMatch[] => { const mode = featureSeedMatchMode(seed); const caseSensitive = seed.case_sensitive; - const query = normalized(seed.value, caseSensitive); + const query = normalizeFeatureSeedText(seed.value, caseSensitive); if (!kindMatches(node, seed.kind)) return []; const requestedEntryPath = seed.kind === "api" && "entry_path" in seed ? seed.entry_path : undefined; @@ -62,7 +62,7 @@ const seedMatch = ( ) return []; const match = candidateFields(node, seed.kind).find((candidate) => { - const value = normalized(candidate.value, caseSensitive); + const value = normalizeFeatureSeedText(candidate.value, caseSensitive); return mode === "exact" ? value === query : value.includes(query); }); return match === undefined @@ -272,7 +272,11 @@ const propertyFields = ( return output; }; -const normalized = (value: string, caseSensitive: boolean): string => { +/** Normalize literal matching identically across application and semantic labels. */ +export const normalizeFeatureSeedText = ( + value: string, + caseSensitive: boolean, +): string => { const normalizedValue = value.normalize(caseSensitive ? "NFC" : "NFKC"); return caseSensitive ? normalizedValue diff --git a/src/domain/javascript/javascriptFeatureTrace.ts b/src/domain/javascript/javascriptFeatureTrace.ts index b643eb476e..cad6890ed2 100644 --- a/src/domain/javascript/javascriptFeatureTrace.ts +++ b/src/domain/javascript/javascriptFeatureTrace.ts @@ -12,14 +12,18 @@ import type { } from "./javascriptApplicationGraphSchemas.js"; import { applicationFeatureTraceResultSchema, + featureSeedMatchMode, type ApplicationFeatureSeed, type ApplicationFeatureTraceResult, type TraceApplicationFeatureInput, } from "./javascriptFeatureTraceSchemas.js"; import { findApplicationFeatureSeeds, + normalizeFeatureSeedText, type ApplicationFeatureSeedMatch, } from "./javascriptFeatureSeed.js"; +import type { JavaScriptSemanticGraph } from "./javascriptSemanticGraph.js"; +import { semanticCallLiteralValues } from "./javascriptSemanticCallLiterals.js"; import { traverseApplicationFeature, type ApplicationFeatureTraversal, @@ -30,6 +34,8 @@ import { buildJavaScriptNativeHandoffs } from "./javascriptNativeHandoff.js"; export interface ApplicationFeatureTraceProjectionInput { readonly sourceEvidenceId: string; readonly graph: JavaScriptApplicationGraph; + readonly semanticGraph?: JavaScriptSemanticGraph | null; + readonly semanticPage?: { readonly offset: number; readonly limit: number }; readonly nativeEvidence: readonly Evidence[]; readonly seed: ApplicationFeatureSeed; readonly direction: TraceApplicationFeatureInput["direction"]; @@ -98,12 +104,14 @@ export const traceApplicationFeature = ( const noMatchResult = ( input: ApplicationFeatureTraceProjectionInput, ): ApplicationFeatureTraceResult => { + const discovery = discoverSemanticLabels(input); const semantic = { source_evidence_id: input.sourceEvidenceId, source_graph_id: input.graph.graph_id, seed: input.seed, direction: input.direction, seed_matches: [], + ...(discovery === undefined ? {} : { semantic_discovery: discovery }), graph: null, paths: [], native_handoffs: [], @@ -125,7 +133,12 @@ const noMatchResult = ( }, evidence_links: [input.sourceEvidenceId], limitations: uniqueSorted([ - "No graph entity matched the literal seed; this is not evidence that the feature is absent.", + "No application graph entity matched the literal seed; this is not evidence that the feature is absent.", + ...(discovery === undefined + ? [] + : [ + "Semantic discovery matches retained node labels and direct string call arguments; matches are starting points, not application-graph feature traces. Use trace_javascript_semantics with a returned trace_seed and the same application Evidence.", + ]), ...sourceCoverageLimitations(input.graph), ]), }; @@ -135,6 +148,56 @@ const noMatchResult = ( }); }; +/** Semantic discovery is independent of the application graph traversal. */ +const discoverSemanticLabels = ( + input: ApplicationFeatureTraceProjectionInput, +): ApplicationFeatureTraceResult["semantic_discovery"] => { + const graph = input.semanticGraph; + const seed = input.seed; + if (graph === undefined || graph === null || seed.kind !== "string") + return undefined; + const query = normalizeFeatureSeedText(seed.value, seed.case_sensitive); + const mode = featureSeedMatchMode(seed); + const { offset, limit } = input.semanticPage ?? { offset: 0, limit: 64 }; + const matches: NonNullable< + ApplicationFeatureTraceResult["semantic_discovery"] + >["matches"] = []; + let total = 0; + for (const node of graph.nodes) { + const { label, node_id, kind } = node; + const values = [ + ...(label === null ? [] : [label]), + ...semanticCallLiteralValues(node).filter( + (value): value is string => typeof value === "string", + ), + ]; + if ( + !values.some((entry) => { + const value = normalizeFeatureSeedText(entry, seed.case_sensitive); + return mode === "exact" ? value === query : value.includes(query); + }) + ) + continue; + if (total >= offset && matches.length < limit) + matches.push({ + node_id, + kind, + trace_seed: { kind: "semantic-node", node_id }, + }); + total += 1; + } + return { + source_graph_id: graph.graph_id, + source_coverage: graph.coverage.status, + total, + offset, + limit, + next_offset: + offset + matches.length < total ? offset + matches.length : null, + matches, + }; +}; + const traceGraph = ( input: ApplicationFeatureTraceProjectionInput, seedMatches: readonly ApplicationFeatureSeedMatch[], diff --git a/src/domain/javascript/javascriptFeatureTraceSchemas.ts b/src/domain/javascript/javascriptFeatureTraceSchemas.ts index f2f5a17cd0..8b6a90393b 100644 --- a/src/domain/javascript/javascriptFeatureTraceSchemas.ts +++ b/src/domain/javascript/javascriptFeatureTraceSchemas.ts @@ -106,6 +106,15 @@ export const traceApplicationFeatureInputSchema = z application: evidenceSchema, native_observations: z.array(evidenceSchema).default([]), seed: applicationFeatureSeedSchema, + semantic_page: z + .strictObject({ + offset: z.int().nonnegative().default(0), + limit: z.int().positive().default(64), + }) + .optional() + .describe( + "Page retained semantic label matches when a string seed has no application match. Defaults to offset 0 and limit 64.", + ), direction: z.enum(["outgoing", "incoming", "both"]).default("both"), }) .superRefine(validateApplicationTraceIdentities); @@ -156,6 +165,34 @@ export const applicationFeatureTraceResultSchema = z.strictObject({ seed: applicationFeatureSeedSchema, direction: z.enum(["outgoing", "incoming", "both"]), seed_matches: z.array(seedMatchSchema), + semantic_discovery: z + .strictObject({ + source_graph_id: prefixedDigestSchema("jsrg"), + source_coverage: z.enum([ + "complete", + "partial", + "unknown", + "unavailable", + ]), + total: z.int().nonnegative(), + offset: z.int().nonnegative(), + limit: z.int().positive(), + next_offset: z.int().nonnegative().nullable(), + matches: z.array( + z.strictObject({ + node_id: prefixedDigestSchema("jsrg_node"), + kind: z.string().min(1), + trace_seed: z.strictObject({ + kind: z.literal("semantic-node"), + node_id: prefixedDigestSchema("jsrg_node"), + }), + }), + ), + }) + .optional() + .describe( + "On an unmatched string seed, retained semantic labels and direct string call arguments use the requested match mode and case sensitivity. Use a trace_seed with trace_javascript_semantics; source coverage limits absence claims.", + ), graph: javascriptApplicationGraphSchema.nullable(), paths: z.array(tracePathSchema), native_handoffs: z.array(nativeHandoffSchema), diff --git a/src/domain/javascript/javascriptSemanticCallLiterals.ts b/src/domain/javascript/javascriptSemanticCallLiterals.ts new file mode 100644 index 0000000000..bb45c3a234 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticCallLiterals.ts @@ -0,0 +1,25 @@ +import type { JavaScriptSemanticGraphNode } from "./javascriptSemanticGraphSchemas.js"; + +/** Read retained direct call literals without interpreting other node properties. */ +export const semanticCallLiteralValues = ( + node: JavaScriptSemanticGraphNode, +): (string | number | boolean | null)[] => { + if (node.kind !== "call-site" || !Array.isArray(node.properties.arguments)) + return []; + return node.properties.arguments.flatMap((argument) => { + if ( + argument === null || + typeof argument !== "object" || + Array.isArray(argument) || + !Object.hasOwn(argument, "value") + ) + return []; + const value = argument.value; + return value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ? [value] + : []; + }); +}; diff --git a/src/domain/javascript/javascriptSemanticCalls.ts b/src/domain/javascript/javascriptSemanticCalls.ts index ef85b6a1b3..ca53ffa67c 100644 --- a/src/domain/javascript/javascriptSemanticCalls.ts +++ b/src/domain/javascript/javascriptSemanticCalls.ts @@ -12,12 +12,12 @@ import type { import { semanticIdentifierRole } from "./javascriptSemanticProjection.js"; import { createSemanticCallableStack } from "./javascriptSemanticCallableStack.js"; import { + readExactJavaScriptLiteral, semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { resolveSemanticBindingState, - isUnshadowedGlobal, type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; @@ -30,6 +30,7 @@ import { traverseJavaScriptValueAst } from "./javascriptSemanticTraversal.js"; import { range } from "./javascriptStaticAnalysisHelpers.js"; import { semanticCallableResultKind } from "./javascriptSemanticCallableResults.js"; import { assignedSemanticResultBindings } from "./javascriptSemanticDataEffectHelpers.js"; +import { semanticPrimitiveSet } from "./javascriptSemanticPrimitives.js"; /** Local call, flow, capture, and unsupported-frontier facts. */ export interface JavaScriptSemanticCallAnalysis { @@ -138,7 +139,7 @@ const collectCallSite = ( bindingCache: context.bindingResolutionCache, }); const callSiteId = semanticCallSiteId(node); - const argumentsValue = retainedArguments(node.arguments); + const argumentsValue = retainedArguments(node.arguments, state); const site: JavaScriptSemanticCallSite = { callSiteId, kind: t.isNewExpression(node) ? "construct" : "call", @@ -209,6 +210,7 @@ const retainedArguments = ( | t.ArgumentPlaceholder | null )[], + state: JavaScriptSemanticAnalysisState, ): JavaScriptSemanticCallSite["arguments"] => { const retained: JavaScriptSemanticCallSite["arguments"][number][] = []; for (const [index, node] of nodes.entries()) { @@ -223,11 +225,34 @@ const retainedArguments = ( location: range(node), valueExpressionLocation: valueNode === null ? null : range(valueNode), spread: t.isSpreadElement(node), + ...(valueNode === null ? {} : directLiteral(valueNode, state)), }); } return retained; }; +/** Retain direct scalar syntax only; do not infer values through bindings. */ +const directLiteral = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): { + readonly literal?: string | number | boolean | null; + readonly literalResourceLimit?: "primitive-bytes"; +} => { + const literal = readExactJavaScriptLiteral(node); + if (!literal.found) return {}; + const parsed = semanticPrimitiveSet([literal.value]); + if (parsed.status === "literal") return { literal: parsed.value }; + if ( + parsed.status === "unknown" && + parsed.resourceLimit === "primitive-bytes" + ) { + state.valueResourceLimits.add(parsed.resourceLimit); + return { literalResourceLimit: parsed.resourceLimit }; + } + return {}; +}; + const collectArgumentFlows = ( site: JavaScriptSemanticCallSite, node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, diff --git a/src/domain/javascript/javascriptSemanticIr.ts b/src/domain/javascript/javascriptSemanticIr.ts index 63a2fba66b..fb8d99dee7 100644 --- a/src/domain/javascript/javascriptSemanticIr.ts +++ b/src/domain/javascript/javascriptSemanticIr.ts @@ -138,6 +138,10 @@ export interface JavaScriptSemanticCallArgument { /** Unwrapped value expression; null for a spread or non-expression slot. */ readonly valueExpressionLocation: JavaScriptSourceRange | null; readonly spread: boolean; + /** Direct scalar syntax, before runtime evaluation; omitted for other expressions. */ + readonly literal?: string | number | boolean | null; + /** A direct literal was omitted under a semantic resource bound. */ + readonly literalResourceLimit?: "primitive-bytes"; } /** One positional argument that may initialize one local parameter binding. */ diff --git a/src/domain/javascript/javascriptSemanticQueryIdentity.ts b/src/domain/javascript/javascriptSemanticQueryIdentity.ts index 4d9bf374a1..e67fdbda96 100644 --- a/src/domain/javascript/javascriptSemanticQueryIdentity.ts +++ b/src/domain/javascript/javascriptSemanticQueryIdentity.ts @@ -5,6 +5,7 @@ import type { JavaScriptSemanticGraphNode } from "./javascriptSemanticGraphSchem import { uniqueSorted } from "../canonicalOrdering.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import type { JavaScriptSemanticQueryInput } from "./javascriptSemanticQuerySchemas.js"; +import { semanticCallLiteralValues } from "./javascriptSemanticCallLiterals.js"; /** Derive the stable identity for one graph-bound query. */ export const javaScriptSemanticQueryIdentifier = ( @@ -74,8 +75,12 @@ const seedMatches = ( if (seed.kind === "application-node") return node.application_node_ids.includes(seed.node_id); if (seed.kind === "function") return fingerprintFunctions.has(node.node_id); - if (seed.kind === "literal") - return node.kind === "literal" && node.properties.value === seed.value; + if (seed.kind === "literal") { + if (node.kind === "literal") return node.properties.value === seed.value; + return semanticCallLiteralValues(node).some( + (value) => value === seed.value, + ); + } if (seed.kind === "property") { if (node.kind === "property-slot") return node.properties.name === seed.name; diff --git a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts index d5437323cd..719d0f5ca0 100644 --- a/src/javascript/analysis/JavaScriptAnalysisTransfer.ts +++ b/src/javascript/analysis/JavaScriptAnalysisTransfer.ts @@ -9,6 +9,7 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA import type { JavaScriptModuleSemanticIr } from "../../domain/javascript/javascriptModuleSemanticIr.js"; import { JAVASCRIPT_PARENT_HEAP_NODE_OPTIONS, + JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES, JAVASCRIPT_SEMANTIC_ITEM_MAXIMUM, } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; import type { @@ -24,8 +25,7 @@ import { } from "./JavaScriptAnalysisWorkerSchemas.js"; // Budgets bound transfer representation, independently of source length. -/** Record bound whose transient decoding expansion is reserved by the consumer. */ -export const JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES = 8 * 1024 * 1024; +export { JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES } from "../../domain/javascript/javascriptAnalysisResourceControls.js"; const FRAME_BYTES = JAVASCRIPT_ANALYSIS_TRANSFER_RECORD_BYTES; const TRANSFER_BYTES = 512 * 1024 * 1024; const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024; diff --git a/tests/acceptance/applications/javascriptCallLiteralTransfer.test.ts b/tests/acceptance/applications/javascriptCallLiteralTransfer.test.ts new file mode 100644 index 0000000000..90ceba64dd --- /dev/null +++ b/tests/acceptance/applications/javascriptCallLiteralTransfer.test.ts @@ -0,0 +1,61 @@ +import { readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "retains a partial call-site record when its arguments exceed transfer capacity", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-call-transfer-input-"); + const output = await createTestTempDirectory("rea-call-transfer-output-"); + const source = `rpc(${Array.from({ length: 64 }, () => JSON.stringify("x".repeat(140_000))).join(",")});`; + await writeFile(join(root, "large.js"), source); + const bundlePath = join(output, "evidence.json"); + const response = await cli.run({ + arguments: [ + "analyze-javascript-application", + root, + "--artifact-format", + "directory", + "--max-heap-mb", + "1024", + "--analysis-timeout-ms", + "30000", + "--detail", + "summary", + "--evidence_output", + bundlePath, + "--json", + ], + timeoutMs: 60_000, + }); + expect(response.exitCode).toBe(0); + const bundle = evidenceBundleSchema.parse( + JSON.parse(await readFile(bundlePath, "utf8")), + ); + const evidence = bundle.records.find( + (record) => record.operation === "analyze_javascript_application", + ); + if (evidence === undefined) + throw new Error("Expected application Evidence"); + const result = javascriptApplicationAnalysisResultSchema.parse( + evidence.normalized_result, + ); + const graph = result.semantic_graph; + const call = graph.nodes.find((node) => node.kind === "call-site"); + expect(call?.properties.arguments).toEqual(expect.any(Array)); + expect(graph.unknowns).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + node_id: call?.node_id, + reason: "resource-limit", + detail: expect.stringMatching(/worker transfer record ceiling/u), + }), + ]), + ); + }, + 90_000, +); diff --git a/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts index 40591eff0b..50001cce6e 100644 --- a/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts +++ b/tests/acceptance/applications/javascriptFeatureTraceSeedSelectionCli.test.ts @@ -9,6 +9,7 @@ import { applicationFeatureTraceResultSchema, } from "../../../src/domain/javascript/javascriptFeatureTraceSchemas.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { findApplicationFeatureSeeds } from "../../../src/domain/javascript/javascriptFeatureSeed.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest, @@ -227,6 +228,155 @@ const expectTrace = (trace: Trace, scenario: SeedScenario): void => { ); }; +cliTest( + "discovers and traces direct call argument literals", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-call-literal-"); + await writeFile( + join(root, "module.js"), + 'client.rpc("proc_named", 7, false, unknown);\n', + ); + const { evidence, result } = await analyzeFixture(cli, root); + const callSite = result.semantic_graph.nodes.find( + (node) => + node.kind === "call-site" && + Array.isArray(node.properties.arguments) && + node.properties.arguments.some( + (entry) => + entry !== null && + typeof entry === "object" && + !Array.isArray(entry) && + entry.value === "proc_named", + ), + ); + if (callSite === undefined) + throw new Error("Expected retained call argument"); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected MCP session"); + const feature = await call("trace_application_feature", { + application: evidence, + seed: { kind: "string", value: "proc_named", match: "exact" }, + direction: "both", + }); + expect(feature.isError).not.toBe(true); + const featureEvidence = toolContract( + "trace_application_feature", + ).outputSchema.parse(feature.structuredContent); + const trace = applicationFeatureTraceResultSchema.parse( + featureEvidence.normalized_result, + ); + expect(trace.semantic_discovery?.matches).toContainEqual({ + node_id: callSite.node_id, + kind: "call-site", + trace_seed: { kind: "semantic-node", node_id: callSite.node_id }, + }); + const semantic = await call("trace_javascript_semantics", { + application: evidence, + query: { + seed: { kind: "literal", value: "proc_named" }, + direction: "forward-influence", + }, + }); + expect(semantic.isError).not.toBe(true); + const semanticEvidence = toolContract( + "trace_javascript_semantics", + ).outputSchema.parse(semantic.structuredContent); + expect(semanticEvidence.normalized_result).toMatchObject({ + seed_node_ids: expect.arrayContaining([callSite.node_id]), + }); + }, +); + +cliTest( + "discovers semantic-only labels with paged CLI and MCP follow-up", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-feature-semantic-label-"); + await writeFile( + join(root, "module.js"), + "function uniqueSemanticCandidate(uniqueSemanticCandidate) { return uniqueSemanticCandidate; }\nuniqueSemanticCandidate(1);\n", + ); + const { evidence, result } = await analyzeFixture(cli, root); + const seed = applicationFeatureSeedSchema.parse({ + kind: "string", + value: "uniqueSemanticCandidate", + match: "exact", + case_sensitive: true, + }); + expect(findApplicationFeatureSeeds(result.graph.nodes, seed)).toEqual([]); + const expected = result.semantic_graph.nodes.filter( + (node) => node.label === seed.value, + ); + expect(expected.length).toBeGreaterThan(0); + const request = { + application: evidence, + seed, + direction: "both", + semantic_page: { offset: 0, limit: 1 }, + }; + const input = join(root, "request.json"); + await writeFile(input, JSON.stringify(request)); + const cliResult = await cli.run({ + arguments: ["trace-application-feature", input, "--json"], + }); + expect(cliResult.exitCode).toBe(0); + const cliEvidence = toolContract( + "trace_application_feature", + ).outputSchema.parse(cliResult.json); + const trace = applicationFeatureTraceResultSchema.parse( + cliEvidence.normalized_result, + ); + expect(trace.coverage.status).toBe("no-match"); + expect(trace.semantic_discovery).toMatchObject({ + source_graph_id: result.semantic_graph.graph_id, + source_coverage: result.semantic_graph.coverage.status, + total: expected.length, + offset: 0, + limit: 1, + next_offset: expected.length > 1 ? 1 : null, + matches: [{ node_id: expected[0]?.node_id, kind: expected[0]?.kind }], + }); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected file MCP session"); + const mcpResult = await call("trace_application_feature", request); + expect(mcpResult.isError).not.toBe(true); + const mcpEvidence = toolContract( + "trace_application_feature", + ).outputSchema.parse(mcpResult.structuredContent); + expect(mcpEvidence.normalized_result).toEqual(trace); + const afterFirst = await call("trace_application_feature", { + ...request, + semantic_page: { offset: 1, limit: 1 }, + }); + expect(afterFirst.isError).not.toBe(true); + const afterFirstEvidence = toolContract( + "trace_application_feature", + ).outputSchema.parse(afterFirst.structuredContent); + const afterFirstTrace = applicationFeatureTraceResultSchema.parse( + afterFirstEvidence.normalized_result, + ); + expect(afterFirstTrace.semantic_discovery).toMatchObject({ + total: expected.length, + offset: 1, + matches: expected + .slice(1, 2) + .map(({ node_id, kind }) => ({ node_id, kind })), + }); + const match = trace.semantic_discovery?.matches[0]; + if (match === undefined) throw new Error("Expected semantic match"); + const semantic = await call("trace_javascript_semantics", { + application: evidence, + query: { seed: match.trace_seed, direction: "backward-provenance" }, + }); + expect(semantic.isError).not.toBe(true); + const semanticEvidence = toolContract( + "trace_javascript_semantics", + ).outputSchema.parse(semantic.structuredContent); + expect(semanticEvidence.normalized_result).toMatchObject({ + seed_node_ids: [match.node_id], + }); + }, +); + cliTest( "matches feature seeds against node IDs and preserved route values through CLI and MCP", async ({ cli }) => { diff --git a/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts b/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts index e57f135996..f65250d81c 100644 --- a/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts +++ b/tests/boundary/mcp/retainedApplicationEvidenceMcp.test.ts @@ -2,6 +2,7 @@ import { parseMcpToolError } from "../../fixtures/mcpToolError.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import { afterEach, expect, it } from "vitest"; import { Ajv2020 } from "ajv/dist/2020.js"; +import type { AnySchema } from "ajv"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; import { createServer } from "../../../src/server/createServer.js"; import { @@ -40,6 +41,30 @@ async function connect(profile?: "full" | "compact") { return { client, session }; } +it("advertises semantic discovery paging and validates the returned Evidence", async () => { + const { client } = await connect(); + const { tools } = await client.listTools(); + const tool = tools.find(({ name }) => name === "trace_application_feature"); + if (tool?.outputSchema === undefined) + throw new Error("Missing feature trace output schema"); + const input = { + application, + seed: { kind: "string", value: "missing-semantic-name", match: "exact" }, + semantic_page: { offset: 0, limit: 1 }, + }; + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const outputSchema = tool.outputSchema as AnySchema; + expect(ajv.validateSchema(tool.inputSchema)).toBe(true); + expect(ajv.validateSchema(outputSchema)).toBe(true); + expect(ajv.compile(tool.inputSchema)(input)).toBe(true); + const result = await client.callTool({ name: tool.name, arguments: input }); + expect(result.isError).not.toBe(true); + expect(ajv.compile(outputSchema)(result.structuredContent)).toBe(true); + expect(result.structuredContent).toMatchObject({ + normalized_result: { semantic_discovery: { total: 0, next_offset: null } }, + }); +}); + it.each(["full", "compact"] as const)( "advertises the primitive literal seeds accepted by semantic tracing (%s)", async (profile) => {