fix(javascript): leave empty application values unlabeled

A synthetic `""` label for an empty application-graph value could still
be matched by a literal `""` feature seed, which also matches the label of
a real two-quote value, so `trace_application_feature` combined
`fetch('')` with `fetch('""')`. Emit no label (`null`) for empty values;
the exact value stays in the identity and properties.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 21:11:51 -07:00
parent 63012eeb60
commit 527b76ef78
2 changed files with 33 additions and 12 deletions

View File

@ -5,6 +5,7 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js";
import { findApplicationFeatureSeeds } from "../domain/javascriptFeatureSeed.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const analyzeSource = async (
@ -50,7 +51,7 @@ it.each([
await expect(analyzeSource(source)).resolves.toBeDefined();
});
it("displays empty literals as nonempty text while retaining exact values", async () => {
it("keeps exact values for empty literals without synthetic application labels", async () => {
const output = await analyzeSource(
"fetch(''); fetch('\"\"'); fetch('/api'); process.on('', () => 1);",
);
@ -61,11 +62,11 @@ it("displays empty literals as nonempty text while retaining exact values", asyn
labels: observations.map(({ label }) => label),
values: observations.map(({ properties }) => properties.value),
}));
// The empty endpoint and a literal two-quote endpoint share display text
// but remain distinct nodes with exact identities.
// The empty endpoint has no display label, so it cannot be confused with
// a literal two-quote endpoint; both keep exact identities.
expect(endpoints).toEqual(
expect.arrayContaining([
{ key: "", labels: ['""'], values: [""] },
{ key: "", labels: [null], values: [""] },
{ key: '""', labels: ['""'], values: ['""'] },
{ key: "/api", labels: ["/api"], values: ["/api"] },
]),
@ -128,3 +129,26 @@ it("keeps empty and two-quote source-map names distinct", async () => {
.sort(),
).toEqual(["", '""']);
});
it("does not match an empty endpoint with a literal two-quote seed", async () => {
const output = await analyzeSource("fetch(''); fetch('\"\"');");
const endpoints = new Map(
output.graph.nodes
.filter(({ kind }) => kind === "endpoint")
.map((node) => [
node.node_id,
node.identity.strategy === "artifact-local-key"
? node.identity.key
: null,
]),
);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "string",
value: '""',
match: "exact",
case_sensitive: true,
}).filter(({ node_id: nodeId }) => endpoints.has(nodeId));
expect(matches.map(({ node_id: nodeId }) => endpoints.get(nodeId))).toEqual([
'""',
]);
});

View File

@ -4,7 +4,6 @@ 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 {
@ -73,9 +72,10 @@ interface DisplayableNodeInput {
/**
* Static findings can carry legal empty strings, such as `fetch("")`,
* `require("")`, or a source map's `sources: [""]`. Labels are nonempty display
* text, so the empty value is shown as `""`. Identities and observation
* properties keep the exact value, so distinct values never merge.
* `require("")`, or a source map's `sources: [""]`. Labels must be nonempty,
* so an empty value has no label rather than synthetic text that a real value
* could share; literal feature seeds match labels. Identities and observation
* properties keep the exact value.
*/
const displayableNode = (
input: DisplayableNodeInput,
@ -83,9 +83,6 @@ const displayableNode = (
...input,
observations: input.observations.map((observation) => ({
...observation,
label:
observation.label === null
? null
: javascriptDisplayText(observation.label),
label: observation.label === "" ? null : observation.label,
})),
});