diff --git a/src/application/JavaScriptApplicationEmptyLiterals.test.ts b/src/application/JavaScriptApplicationEmptyLiterals.test.ts new file mode 100644 index 000000000..887da4e54 --- /dev/null +++ b/src/application/JavaScriptApplicationEmptyLiterals.test.ts @@ -0,0 +1,105 @@ +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, + 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", + }); + 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: "" }]); +}); + +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 8b88ab30c..85a917877 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,49 @@ 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 original_source?: string; + }; + readonly observations: readonly { readonly label: string | null }[]; +} + +/** + * 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: displayableIdentity(input.identity), + observations: input.observations.map((observation) => ({ + ...observation, + label: + observation.label === null + ? null + : 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; +}; 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;