Merge pull request #843 from morluto/fix/exact-empty-graph-identities

fix(javascript): keep exact empty values in graph identities
This commit is contained in:
Kaoru0822 2026-10-06 22:11:07 -07:00 committed by GitHub
commit f608127635
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 277 additions and 71 deletions

View File

@ -645,9 +645,9 @@
"mcp_prompts": 6
},
"digests": {
"tools_sha256": "1f1acf182aa87964404e65f3acb00c44f1c392373c5a8a6f40c250da8b94c274",
"tools_sha256": "5d4fa325e1649ae3f75aa6f9cbba7fac1175440a0e2792e6e1a469849e2c2dc3",
"prompts_sha256": "ef2de8814390b340623c32001610aa2709d90bed51537b6e562ab3abf144ab4d",
"combined_sha256": "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da",
"combined_sha256": "d8e9c865c40f7d8f96e21f575d7157cbd000e86991a1944ba2aa07c2f1d70bc7",
"providers_sha256": "0bccfa9588376a90350d18abded999db7e1d0e4424a61a21d03d392a1c5418fe"
}
}

View File

@ -1,5 +1,5 @@
{
"manifest_id": "ecm_d24d65186c309bbfa713e50851ddae373e1dd874ff444f717e8e379946ed2407",
"manifest_id": "ecm_6376482bb14d464efebd1947cd6820fac3edfdd17592cedb0ddfe5ad58ef2493",
"verifier": {
"id": "rea-managed-conformance",
"version": "1"
@ -13,7 +13,7 @@
"skill_digests": [
{
"skill_id": "reverse-engineer-anything",
"sha256": "6dfe4f9c24f9438982910f3170a7b0b1798761da6ad9a487d73e662f4c23e9ba"
"sha256": "f765c513b854f5ea2db449801cc39a27e34dc05c378760eda5a049b529e80392"
}
],
"claims": [

View File

@ -4,7 +4,7 @@ description: Reverse engineer native, managed, Electron/JavaScript, packaged, fi
metadata:
version: "26"
tool_count: 127
catalog_digest: "0efbf17dddbdf52f84d4ca1f9a3e007a0d77c2623be62ce0a69844d617bf16da"
catalog_digest: "d8e9c865c40f7d8f96e21f575d7157cbd000e86991a1944ba2aa07c2f1d70bc7"
---
# REA

View File

@ -1,10 +1,15 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { Ajv2020 } from "ajv/dist/2020.js";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js";
import { matchJavaScriptApplicationVersions } from "../domain/javascriptApplicationVersionKeys.js";
import { findApplicationFeatureSeeds } from "../domain/javascriptFeatureSeed.js";
import { traceApplicationFeatureInputSchema } from "../domain/javascriptFeatureTraceSchemas.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const analyzeSource = async (
@ -50,9 +55,9 @@ 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('/api'); process.on('', () => 1);",
"fetch(''); fetch('\"\"'); fetch('/api'); process.on('', () => 1);",
);
const endpoints = output.graph.nodes
.filter(({ kind }) => kind === "endpoint")
@ -61,12 +66,16 @@ 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 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"] },
]),
);
expect(endpoints).toHaveLength(3);
expect(
output.semantic_graph?.nodes
.filter(({ kind }) => kind === "event")
@ -101,5 +110,142 @@ it.each([
: null,
sources: observations.map(({ properties }) => properties.source),
}));
expect(originals).toEqual([{ originalSource: '""', sources: [""] }]);
expect(originals).toEqual([{ originalSource: "", sources: [""] }]);
});
it("keeps empty and two-quote source-map names distinct", async () => {
const output = await analyzeSource("//# sourceMappingURL=app.js.map", {
"app.js.map": JSON.stringify({
version: 3,
sources: ["", '""'],
names: [],
mappings: "AAAA,CCAA",
}),
});
expect(
output.graph.nodes
.filter(({ kind }) => kind === "source-module")
.map(({ identity }) =>
identity.strategy === "source-map-original"
? identity.original_source
: null,
)
.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([
'""',
]);
});
const IPC_SOURCE =
"const { ipcMain } = require('electron'); ipcMain.handle('', () => 1); ipcMain.handle('\"\"', () => 2);";
const ipcChannels = (
nodes: Awaited<ReturnType<typeof analyzeSource>>["graph"]["nodes"],
) =>
new Map(
nodes
.filter(({ kind }) => kind === "ipc-channel")
.map((node) => [node.node_id, node.observations[0]?.properties.channel]),
);
it("selects an empty IPC channel with an exact empty seed", async () => {
const output = await analyzeSource(IPC_SOURCE);
const channels = ipcChannels(output.graph.nodes);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "channel",
value: "",
match: "exact",
case_sensitive: true,
});
expect(matches.map(({ node_id: nodeId }) => channels.get(nodeId))).toEqual([
"",
]);
});
const seedSchema = traceApplicationFeatureInputSchema.shape.seed;
const validateAdvertisedSeed = new Ajv2020({
strict: false,
validateFormats: false,
}).compile(z.toJSONSchema(seedSchema, { io: "input" }));
it.each([
[{ kind: "channel", value: "", match: "exact" }, true],
[{ kind: "string", value: "", match: "exact" }, true],
[{ kind: "channel", value: "/x" }, true],
[{ kind: "string", value: "" }, false],
[{ kind: "channel", value: "" }, false],
[{ kind: "channel", value: "", match: "contains" }, false],
[{ kind: "node-id", value: "", match: "exact" }, false],
] as const)(
"validates empty seed %j at runtime and in the advertised schema",
(seed, accepted) => {
expect(seedSchema.safeParse(seed).success).toBe(accepted);
expect(validateAdvertisedSeed(seed)).toBe(accepted);
},
);
it("pairs the same empty IPC channel across application versions", async () => {
const left = await analyzeSource(IPC_SOURCE);
const right = await analyzeSource(`${IPC_SOURCE} // v2`);
const leftChannels = ipcChannels(left.graph.nodes);
const rightChannels = ipcChannels(right.graph.nodes);
const matching = matchJavaScriptApplicationVersions(
left.graph.nodes,
right.graph.nodes,
);
expect(
matching.pairs
.filter(({ left: node }) => node.kind === "ipc-channel")
.map(({ left: leftNode, right: rightNode }) => [
leftChannels.get(leftNode.node_id),
rightChannels.get(rightNode.node_id),
]),
).toEqual(
expect.arrayContaining([
["", ""],
['""', '""'],
]),
);
expect(matching.unmatchedLeft.map(({ kind }) => kind)).not.toContain(
"ipc-channel",
);
});
it("selects an empty context-bridge API with an exact empty seed", async () => {
const output = await analyzeSource(
"const { contextBridge } = require('electron'); contextBridge.exposeInMainWorld('', {}); contextBridge.exposeInMainWorld('\"\"', {});",
);
const apis = new Map(
output.graph.nodes
.filter(({ kind }) => kind === "context-bridge-api")
.map((node) => [node.node_id, node.observations[0]?.properties.api_key]),
);
const matches = findApplicationFeatureSeeds(output.graph.nodes, {
kind: "api",
value: "",
match: "exact",
case_sensitive: true,
});
expect(matches.map(({ node_id: nodeId }) => apis.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 {
@ -68,46 +67,22 @@ const observationInput = (
/** 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.
* `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,
): DisplayableNodeInput => ({
...input,
identity: displayableIdentity(input.identity),
observations: input.observations.map((observation) => ({
...observation,
label:
observation.label === null
? null
: javascriptDisplayText(observation.label),
label: observation.label === "" ? null : 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

@ -370,14 +370,24 @@ it("keeps exact empty environment keys distinct from dynamic keys", () => {
it("labels exact empty method names without inventing computed names", () => {
const graph = graphFor(`
const target = { ['']() { return 42; }, [key]() { return 1; } };
const target = {
['']() { return 42; },
['""']() { return 2; },
[key]() { return 1; },
};
`);
const labels = graph.nodes
const functions = graph.nodes
.filter(({ kind }) => kind === "function")
.map(({ label }) => label);
expect(labels).toContain('""');
.map(({ label, properties }) => ({ label, name: properties.name }));
// Both exact names display as `""`; only `properties.name` tells them apart.
expect(functions).toEqual(
expect.arrayContaining([
{ label: '""', name: "" },
{ label: '""', name: '""' },
]),
);
expect(
labels.filter((label) => label?.startsWith("[computed@")),
functions.filter(({ label }) => label?.startsWith("[computed@")),
).toHaveLength(1);
});

View File

@ -167,6 +167,8 @@ const projectFile = (
location: callable.location,
label: callable.name,
functionNodeId: null,
// The label is display text; keep the exact name, which may be "".
properties: { name: callable.name },
},
state,
),

View File

@ -26,4 +26,27 @@ describe("input issue projection", () => {
},
]);
});
it("reports union branch issues at the union's path once", () => {
const schema = z.object({
seed: z.union([
z.object({ value: z.string().min(1) }),
z.object({ value: z.literal(""), match: z.literal("exact") }),
]),
});
const missing = schema.safeParse({});
if (missing.success) throw new Error("expected invalid input");
expect(projectInputIssues(missing.error.issues, {})).toEqual([
{ path: ["seed"], reason: "missing_argument", expected: "object" },
]);
const input = { seed: { value: 1 } };
const nested = schema.safeParse(input);
if (nested.success) throw new Error("expected invalid input");
expect(projectInputIssues(nested.error.issues, input)).toEqual(
expect.arrayContaining([
{ path: ["seed", "value"], reason: "invalid_type", expected: "string" },
]),
);
});
});

View File

@ -15,9 +15,17 @@ const projectIssue = (
const path = issue.path.flatMap((part) =>
typeof part === "string" || typeof part === "number" ? [part] : [],
);
// Branch issue paths are relative to the union; branches often agree.
if (issue.code === "invalid_union")
return issue.errors.flatMap((branch) =>
branch.flatMap((branchIssue) => projectIssue(branchIssue, input)),
return uniqueIssues(
issue.errors.flatMap((branch) =>
branch.flatMap((branchIssue) =>
projectIssue(
{ ...branchIssue, path: [...issue.path, ...branchIssue.path] },
input,
),
),
),
);
if (issue.code === "unrecognized_keys")
return issue.keys.map((key) => ({
@ -58,6 +66,12 @@ const projectIssue = (
];
};
const uniqueIssues = (
issues: readonly AnalysisInputIssue[],
): AnalysisInputIssue[] => [
...new Map(issues.map((issue) => [JSON.stringify(issue), issue])).values(),
];
const valueAtPath = (
input: unknown,
path: readonly (string | number)[],

View File

@ -419,7 +419,8 @@ export const applicationNodeIdentitySchema = z.discriminatedUnion("strategy", [
strategy: z.literal("source-map-original"),
stability: z.literal("source-map-exact"),
source_map_sha256: digestSchema,
original_source: boundedTextSchema,
// The exact `sources` entry; `""` is a legal relative reference.
original_source: z.string(),
source_sha256: digestSchema.nullable(),
}),
z.strictObject({
@ -451,7 +452,8 @@ export const applicationNodeIdentitySchema = z.discriminatedUnion("strategy", [
stability: z.literal("artifact-version"),
artifact_sha256: digestSchema,
namespace: boundedKeySchema,
key: boundedTextSchema,
// The exact artifact-local value, such as `""` from `fetch("")`.
key: z.string(),
}),
z.strictObject({
strategy: z.literal("runtime-instance"),

View File

@ -229,8 +229,11 @@ const semanticValue = (
node: ApplicationNode,
properties: Readonly<Record<string, JsonValue>>,
): string | null => {
if (node.kind === "endpoint")
return joined(properties.endpoint_kind, properties.value);
if (node.kind === "endpoint") {
const kind = nonEmptyString(properties.endpoint_kind);
const value = exactString(properties.value);
return kind === null || value === null ? null : `${kind}\0${value}`;
}
if (node.kind === "storage")
return joined(
properties.storage_kind,
@ -239,9 +242,13 @@ const semanticValue = (
if (node.kind === "ipc-channel")
return properties.resolution === "dynamic"
? null
: (nonEmptyString(properties.channel) ?? firstLabel(node));
: (exactString(properties.channel) ?? firstLabel(node));
if (node.kind === "context-bridge-api")
return firstLabel(node) ?? nonEmptyString(properties.api_name);
return (
firstLabel(node) ??
exactString(properties.api_key) ??
nonEmptyString(properties.api_name)
);
if (node.kind === "native-export")
return (
joined(
@ -293,6 +300,10 @@ const joined = (...values: readonly unknown[]): string | null => {
const nonEmptyString = (value: unknown): string | null =>
typeof value === "string" && value !== "" ? value : null;
/** A literal value such as `ipcMain.handle("")` keeps its exact empty text. */
const exactString = (value: unknown): string | null =>
typeof value === "string" ? value : null;
const stringList = (value: unknown): string[] =>
Array.isArray(value)
? value

View File

@ -1,6 +1,9 @@
import { compareCodePoints } from "./canonicalOrdering.js";
import type { ApplicationNode } from "./javascriptApplicationGraph.js";
import type { ApplicationFeatureSeed } from "./javascriptFeatureTraceSchemas.js";
import {
featureSeedMatchMode,
type ApplicationFeatureSeed,
} from "./javascriptFeatureTraceSchemas.js";
import type { JsonValue } from "./jsonValue.js";
/** One explicit field that matched a feature seed. */
@ -49,7 +52,7 @@ const seedMatch = (
]
: [];
if (!kindMatches(node, seed.kind)) return [];
const mode = seed.match ?? (seed.kind === "string" ? "contains" : "exact");
const mode = featureSeedMatchMode(seed);
const query = normalized(seed.value, seed.case_sensitive);
const match = candidateFields(node, seed.kind).find((candidate) => {
const value = normalized(candidate.value, seed.case_sensitive);
@ -129,7 +132,7 @@ const memberFieldSuffixes = new Set([
const relevantFields = {
route: [".label", ".value", ".path"],
api: [".label", ".api_name", ".key", ".methods", ".members"],
api: [".label", ".api_name", ".api_key", ".key", ".methods", ".members"],
channel: [".label", ".channel", ".key"],
module: [
".label",

View File

@ -10,21 +10,41 @@ const nodeIdSchema = prefixedDigestSchema("jag_node");
const edgeIdSchema = prefixedDigestSchema("jag_edge");
const boundedTextSchema = z.string().min(1);
/** Literal starting point for one application feature trace. */
const applicationFeatureSeedSchema = z.strictObject({
kind: z.enum([
"node-id",
"route",
"string",
"api",
"channel",
"module",
"native-export",
]),
value: boundedTextSchema,
match: z.enum(["exact", "contains"]).nullable().default(null),
case_sensitive: z.boolean().default(false),
});
/** Matching mode for a literal seed: string seeds default to containment. */
export const featureSeedMatchMode = (seed: {
readonly kind: string;
readonly match: "exact" | "contains" | null;
}): "exact" | "contains" =>
seed.match ?? (seed.kind === "string" ? "contains" : "exact");
const literalSeedKinds = [
"route",
"string",
"api",
"channel",
"module",
"native-export",
] as const;
/**
* Literal starting point for one application feature trace. An empty value,
* such as the channel in `ipcMain.handle("")`, must be an explicit exact
* literal seed: empty containment would select every node.
*/
const applicationFeatureSeedSchema = z.union([
z.strictObject({
kind: z.enum(["node-id", ...literalSeedKinds]),
value: boundedTextSchema,
match: z.enum(["exact", "contains"]).nullable().default(null),
case_sensitive: z.boolean().default(false),
}),
z.strictObject({
kind: z.enum(literalSeedKinds),
value: z.literal(""),
match: z.literal("exact"),
case_sensitive: z.boolean().default(false),
}),
]);
/** Evidence-backed application graph and feature seed. */
export const traceApplicationFeatureInputSchema = z

File diff suppressed because one or more lines are too long