From 11fc3b981509e8107b10b2e75363f5bb489879db Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 Date: Tue, 6 Oct 2026 20:43:08 -0700 Subject: [PATCH 1/2] fix(javascript): analyze sources containing empty string literals A single legal empty literal such as `fetch('')`, `require('')`, `bus.on('', f)`, `localStorage.getItem('')`, or `ipcMain.handle('', f)` made the whole application analysis fail with `unreadable_output`: the literal became an application-graph observation label, an artifact-local identity key, or a semantic node label, all of which must be nonempty. Normalize display at the two graph construction seams instead of at each producer: `JavaScriptArtifactGraphAccumulator.addNode` and `constructSemanticGraphNode` show the empty value as `""` via a shared `javascriptDisplayText` helper. Observation and node properties keep the exact empty value. This replaces the per-site `semanticNameLabel` calls. Co-Authored-By: Claude Opus 5.5 --- ...JavaScriptApplicationEmptyLiterals.test.ts | 73 +++++++++++++++++++ .../JavaScriptArtifactGraphAccumulator.ts | 35 ++++++++- .../JavaScriptSemanticGraphBuilder.ts | 4 +- .../JavaScriptSemanticGraphConstruction.ts | 11 +-- .../JavaScriptSemanticGraphDataProjection.ts | 6 +- .../JavaScriptSemanticGraphValueProjection.ts | 3 +- src/domain/javascriptAstValues.ts | 8 ++ 7 files changed, 119 insertions(+), 21 deletions(-) create mode 100644 src/application/JavaScriptApplicationEmptyLiterals.test.ts diff --git a/src/application/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/JavaScriptApplicationEmptyLiterals.test.ts new file mode 100644 index 000000000..dfcd06b76 --- /dev/null +++ b/src/application/JavaScriptApplicationEmptyLiterals.test.ts @@ -0,0 +1,73 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js"; +import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; + +const analyzeSource = async (source: string) => { + const inputPath = await createTestTempDirectory("rea-js-empty-literal-"); + await writeFile(join(inputPath, "app.js"), `${source}\n`); + const result = await analyzeJavaScriptApplication({ + input_path: inputPath, + format: "directory", + }); + if (!result.ok) + throw new Error(`Expected analysis success: ${result.error.message}`); + return javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); +}; + +it.each([ + "fetch('');", + "new WebSocket('');", + "navigator.serviceWorker.register('');", + "new Worker('');", + "const x = require('');", + "import y from ''; y();", + "import('');", + "localStorage.getItem('');", + "localStorage.setItem('', 1);", + "indexedDB.open('');", + "win.loadFile('');", + "win.loadURL('');", + "app.get('', handler);", + "process.on('', () => 1);", + "const { EventEmitter } = require('events'); new EventEmitter().on('', () => 1);", + "const { spawn } = require('child_process'); spawn('/bin/x').on('', () => 1);", + "const { ipcMain } = require('electron'); ipcMain.handle('', () => 1);", + "const { ipcRenderer } = require('electron'); ipcRenderer.invoke('');", + "const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {});", +])("analyzes the legal empty string literal in %s", async (source) => { + await expect(analyzeSource(source)).resolves.toBeDefined(); +}); + +it("displays empty literals as nonempty text while retaining exact values", async () => { + const output = await analyzeSource( + "fetch(''); fetch('/api'); process.on('', () => 1);", + ); + const endpoints = output.graph.nodes + .filter(({ kind }) => kind === "endpoint") + .map(({ identity, observations }) => ({ + key: identity.strategy === "artifact-local-key" ? identity.key : null, + labels: observations.map(({ label }) => label), + values: observations.map(({ properties }) => properties.value), + })); + expect(endpoints).toEqual( + expect.arrayContaining([ + { key: '""', labels: ['""'], values: [""] }, + { key: "/api", labels: ["/api"], values: ["/api"] }, + ]), + ); + expect( + output.semantic_graph?.nodes + .filter(({ kind }) => kind === "event") + .map(({ label, properties }) => ({ + label, + eventName: properties.event_name, + })), + ).toEqual([{ label: '""', eventName: "" }]); +}); diff --git a/src/application/JavaScriptArtifactGraphAccumulator.ts b/src/application/JavaScriptArtifactGraphAccumulator.ts index 8b88ab30c..5d52403d0 100644 --- a/src/application/JavaScriptArtifactGraphAccumulator.ts +++ b/src/application/JavaScriptArtifactGraphAccumulator.ts @@ -4,6 +4,7 @@ import { type ApplicationEdge, type ApplicationNode, } from "../domain/javascriptApplicationGraph.js"; +import { javascriptDisplayText } from "../domain/javascriptAstValues.js"; /** Deduplicate graph entities while retaining distinct evidence observations. */ export class JavaScriptArtifactGraphAccumulator { @@ -11,8 +12,8 @@ export class JavaScriptArtifactGraphAccumulator { readonly #edges = new Map(); /** Create or merge one canonical node. */ - addNode(input: unknown): ApplicationNode { - const created = createJavaScriptApplicationNode(input); + addNode(input: Input): ApplicationNode { + const created = createJavaScriptApplicationNode(displayableNode(input)); const existing = this.#nodes.get(created.node_id); if (existing === undefined) { this.#nodes.set(created.node_id, created); @@ -64,3 +65,33 @@ const observationInput = ( } = observation; return input; }; + +/** Node fields whose display form is normalized before validation. */ +interface DisplayableNodeInput { + readonly identity: { readonly strategy: string; readonly key?: string }; + readonly observations: readonly { readonly label: string | null }[]; +} + +/** + * Static findings can carry legal empty strings, such as `fetch("")` or + * `require("")`. Labels and artifact-local keys must be nonempty, so display + * the empty value as `""`; each observation keeps the exact value in its + * properties. + */ +const displayableNode = ( + input: DisplayableNodeInput, +): DisplayableNodeInput => ({ + ...input, + identity: + input.identity.strategy === "artifact-local-key" && + input.identity.key !== undefined + ? { ...input.identity, key: javascriptDisplayText(input.identity.key) } + : input.identity, + observations: input.observations.map((observation) => ({ + ...observation, + label: + observation.label === null + ? null + : javascriptDisplayText(observation.label), + })), +}); diff --git a/src/application/JavaScriptSemanticGraphBuilder.ts b/src/application/JavaScriptSemanticGraphBuilder.ts index 74be8f654..a9df118cf 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.ts @@ -48,7 +48,6 @@ import { addSemanticGraphRelation as addRelation, constructSemanticGraphNode as semanticNode, createSemanticGraphProjectionState as emptyState, - semanticNameLabel, type SemanticGraphProjectionState as BuilderState, } from "./JavaScriptSemanticGraphConstruction.js"; @@ -166,8 +165,7 @@ const projectFile = ( kind: "function", roleKey: `callable:${callable.callableId}`, location: callable.location, - label: - callable.name === null ? null : semanticNameLabel(callable.name), + label: callable.name, functionNodeId: null, }, state, diff --git a/src/application/JavaScriptSemanticGraphConstruction.ts b/src/application/JavaScriptSemanticGraphConstruction.ts index f52f5374f..df6e6e48e 100644 --- a/src/application/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/JavaScriptSemanticGraphConstruction.ts @@ -1,5 +1,6 @@ import type { JavaScriptApplicationGraph } from "../domain/javascriptApplicationGraph.js"; import type { JsonValue } from "../domain/jsonValue.js"; +import { javascriptDisplayText } from "../domain/javascriptAstValues.js"; import { createJavaScriptSemanticGraphNode, createJavaScriptSemanticGraphRelation, @@ -18,14 +19,6 @@ import { unavailableSemanticRootEvidence, } from "./JavaScriptSemanticGraphEvidence.js"; -/** - * Display an exact JavaScript key or name as a nonempty graph label. The legal - * empty key is shown as `""`; callers keep the exact value where the node - * carries it. - */ -export const semanticNameLabel = (name: string): string => - name === "" ? '""' : name; - /** Mutable local projection state hidden from graph callers. */ export interface SemanticGraphProjectionState { readonly nodes: Map; @@ -85,7 +78,7 @@ export const constructSemanticGraphNode = ( }, function_node_id: input.functionNodeId, application_node_ids: matchingApplicationNodeIds(file, input, state), - label: input.label, + label: input.label === null ? null : javascriptDisplayText(input.label), properties: input.properties ?? {}, evidence: observedSemanticEvidence(file, input.location), }); diff --git a/src/application/JavaScriptSemanticGraphDataProjection.ts b/src/application/JavaScriptSemanticGraphDataProjection.ts index fb2db56e4..2bc1a9413 100644 --- a/src/application/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/JavaScriptSemanticGraphDataProjection.ts @@ -10,7 +10,6 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, constructSemanticGraphNode, - semanticNameLabel, } from "./JavaScriptSemanticGraphConstruction.js"; import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; import { @@ -79,10 +78,7 @@ const addConfigurationNode = ( kind: "config-source", roleKey: operation.configId, location: operation.location, - label: - operation.key === null - ? operation.kind - : semanticNameLabel(operation.key), + label: operation.key ?? operation.kind, functionNodeId: operation.ownerCallableId === null ? null diff --git a/src/application/JavaScriptSemanticGraphValueProjection.ts b/src/application/JavaScriptSemanticGraphValueProjection.ts index ad49c9ec8..4b61dcd4a 100644 --- a/src/application/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/JavaScriptSemanticGraphValueProjection.ts @@ -7,7 +7,6 @@ import { addSemanticGraphNode, addSemanticGraphRelation, constructSemanticGraphNode, - semanticNameLabel, } from "./JavaScriptSemanticGraphConstruction.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; @@ -117,7 +116,7 @@ export const semanticPropertySlot = ( kind: "property-slot", roleKey: `property:${objectBindingId}:${name}`, location: null, - label: semanticNameLabel(name), + label: name, functionNodeId: context.bindingNodes.get(objectBindingId)?.function_node_id ?? null, properties: { diff --git a/src/domain/javascriptAstValues.ts b/src/domain/javascriptAstValues.ts index 9319f5505..76c194bf5 100644 --- a/src/domain/javascriptAstValues.ts +++ b/src/domain/javascriptAstValues.ts @@ -23,3 +23,11 @@ export const semanticStaticPropertyKey = ( return String(property.value); return !computed && t.isIdentifier(property) ? property.name : null; }; + +/** + * Display an exact JavaScript string as nonempty label text. Graph labels and + * artifact-local keys are nonempty, so the legal empty value is shown as `""`; + * producers keep the exact value in their properties. + */ +export const javascriptDisplayText = (value: string): string => + value === "" ? '""' : value; From 58ef8ac3aa803d7b64dd12a5b2a2e8ec24261060 Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 Date: Tue, 6 Oct 2026 20:47:27 -0700 Subject: [PATCH 2/2] fix(javascript): analyze source maps with empty source names A source map whose `sources` contains `""` (a legal relative reference, emitted for virtual or generated modules) made the whole application analysis fail: the empty name became the `source-map-original` identity's `original_source`, which must be nonempty. Display it as `""` at the same accumulator seam; the observation's `properties.source` keeps the exact empty name. Co-Authored-By: Claude Opus 5.5 --- ...JavaScriptApplicationEmptyLiterals.test.ts | 34 +++++++++++++++++- .../JavaScriptArtifactGraphAccumulator.ts | 36 +++++++++++++------ 2 files changed, 59 insertions(+), 11 deletions(-) diff --git a/src/application/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/JavaScriptApplicationEmptyLiterals.test.ts index dfcd06b76..887da4e54 100644 --- a/src/application/JavaScriptApplicationEmptyLiterals.test.ts +++ b/src/application/JavaScriptApplicationEmptyLiterals.test.ts @@ -7,9 +7,14 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js"; import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; -const analyzeSource = async (source: string) => { +const analyzeSource = async ( + source: string, + extraFiles: Readonly> = {}, +) => { const inputPath = await createTestTempDirectory("rea-js-empty-literal-"); await writeFile(join(inputPath, "app.js"), `${source}\n`); + for (const [path, text] of Object.entries(extraFiles)) + await writeFile(join(inputPath, path), text); const result = await analyzeJavaScriptApplication({ input_path: inputPath, format: "directory", @@ -71,3 +76,30 @@ it("displays empty literals as nonempty text while retaining exact values", asyn })), ).toEqual([{ label: '""', eventName: "" }]); }); + +it.each([ + { version: 3, sources: [""], names: [], mappings: "AAAA" }, + { + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { version: 3, sources: [""], names: [], mappings: "AAAA" }, + }, + ], + }, +])("projects an empty source-map source name: %j", async (map) => { + const output = await analyzeSource("//# sourceMappingURL=app.js.map", { + "app.js.map": JSON.stringify(map), + }); + const originals = output.graph.nodes + .filter(({ kind }) => kind === "source-module") + .map(({ identity, observations }) => ({ + originalSource: + identity.strategy === "source-map-original" + ? identity.original_source + : null, + sources: observations.map(({ properties }) => properties.source), + })); + expect(originals).toEqual([{ originalSource: '""', sources: [""] }]); +}); diff --git a/src/application/JavaScriptArtifactGraphAccumulator.ts b/src/application/JavaScriptArtifactGraphAccumulator.ts index 5d52403d0..85a917877 100644 --- a/src/application/JavaScriptArtifactGraphAccumulator.ts +++ b/src/application/JavaScriptArtifactGraphAccumulator.ts @@ -68,25 +68,25 @@ const observationInput = ( /** Node fields whose display form is normalized before validation. */ interface DisplayableNodeInput { - readonly identity: { readonly strategy: string; readonly key?: string }; + readonly identity: { + readonly strategy: string; + readonly key?: string; + readonly original_source?: string; + }; readonly observations: readonly { readonly label: string | null }[]; } /** - * Static findings can carry legal empty strings, such as `fetch("")` or - * `require("")`. Labels and artifact-local keys must be nonempty, so display - * the empty value as `""`; each observation keeps the exact value in its - * properties. + * Static findings can carry legal empty strings, such as `fetch("")`, + * `require("")`, or a source map's `sources: [""]`. Labels, artifact-local + * keys, and original source names must be nonempty, so display the empty value + * as `""`; each observation keeps the exact value in its properties. */ const displayableNode = ( input: DisplayableNodeInput, ): DisplayableNodeInput => ({ ...input, - identity: - input.identity.strategy === "artifact-local-key" && - input.identity.key !== undefined - ? { ...input.identity, key: javascriptDisplayText(input.identity.key) } - : input.identity, + identity: displayableIdentity(input.identity), observations: input.observations.map((observation) => ({ ...observation, label: @@ -95,3 +95,19 @@ const displayableNode = ( : javascriptDisplayText(observation.label), })), }); + +const displayableIdentity = ( + identity: DisplayableNodeInput["identity"], +): DisplayableNodeInput["identity"] => { + if (identity.strategy === "artifact-local-key" && identity.key !== undefined) + return { ...identity, key: javascriptDisplayText(identity.key) }; + if ( + identity.strategy === "source-map-original" && + identity.original_source !== undefined + ) + return { + ...identity, + original_source: javascriptDisplayText(identity.original_source), + }; + return identity; +};