Merge pull request #839 from morluto/fix/empty-literal-graph-labels

fix(javascript): analyze sources containing empty string literals
This commit is contained in:
Kaoru0822 2026-10-06 20:55:11 -07:00 committed by GitHub
commit e7cbfe04fd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 167 additions and 21 deletions

View File

@ -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<Record<string, string>> = {},
) => {
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: [""] }]);
});

View File

@ -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<string, ApplicationEdge>();
/** Create or merge one canonical node. */
addNode(input: unknown): ApplicationNode {
const created = createJavaScriptApplicationNode(input);
addNode<Input extends DisplayableNodeInput>(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;
};

View File

@ -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,

View File

@ -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<string, JavaScriptSemanticGraphNode>;
@ -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),
});

View File

@ -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

View File

@ -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: {

View File

@ -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;