Merge pull request #839 from morluto/fix/empty-literal-graph-labels
fix(javascript): analyze sources containing empty string literals
This commit is contained in:
commit
e7cbfe04fd
|
|
@ -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: [""] }]);
|
||||
});
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue