diff --git a/src/application/JavaScriptSemanticGraphBuilder.test.ts b/src/application/JavaScriptSemanticGraphBuilder.test.ts index 8b0c5ec90..d50720b41 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.test.ts @@ -349,6 +349,38 @@ it.each([ ); }, ); +it("keeps exact empty environment keys distinct from dynamic keys", () => { + const graph = graphFor(` + const empty = process.env['']; + const named = process.env.HOME; + const dynamic = process.env[name]; + `); + const sources = graph.nodes + .filter(({ kind }) => kind === "config-source") + .map(({ label, properties }) => ({ label, key: properties.key })); + expect(sources).toEqual( + expect.arrayContaining([ + { label: '""', key: "" }, + { label: "HOME", key: "HOME" }, + { label: "environment", key: null }, + ]), + ); + expect(sources).toHaveLength(3); +}); + +it("labels exact empty method names without inventing computed names", () => { + const graph = graphFor(` + const target = { ['']() { return 42; }, [key]() { return 1; } }; + `); + const labels = graph.nodes + .filter(({ kind }) => kind === "function") + .map(({ label }) => label); + expect(labels).toContain('""'); + expect( + labels.filter((label) => label?.startsWith("[computed@")), + ).toHaveLength(1); +}); + it.each([ "Promise.resolve(1)", "(Promise.resolve(1) as Promise)", diff --git a/src/application/JavaScriptSemanticGraphBuilder.ts b/src/application/JavaScriptSemanticGraphBuilder.ts index a9df118cf..74be8f654 100644 --- a/src/application/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/JavaScriptSemanticGraphBuilder.ts @@ -48,6 +48,7 @@ import { addSemanticGraphRelation as addRelation, constructSemanticGraphNode as semanticNode, createSemanticGraphProjectionState as emptyState, + semanticNameLabel, type SemanticGraphProjectionState as BuilderState, } from "./JavaScriptSemanticGraphConstruction.js"; @@ -165,7 +166,8 @@ const projectFile = ( kind: "function", roleKey: `callable:${callable.callableId}`, location: callable.location, - label: callable.name, + label: + callable.name === null ? null : semanticNameLabel(callable.name), functionNodeId: null, }, state, diff --git a/src/application/JavaScriptSemanticGraphConstruction.ts b/src/application/JavaScriptSemanticGraphConstruction.ts index fa6192e67..f52f5374f 100644 --- a/src/application/JavaScriptSemanticGraphConstruction.ts +++ b/src/application/JavaScriptSemanticGraphConstruction.ts @@ -18,6 +18,14 @@ 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; diff --git a/src/application/JavaScriptSemanticGraphDataProjection.ts b/src/application/JavaScriptSemanticGraphDataProjection.ts index 2bc1a9413..fb2db56e4 100644 --- a/src/application/JavaScriptSemanticGraphDataProjection.ts +++ b/src/application/JavaScriptSemanticGraphDataProjection.ts @@ -10,6 +10,7 @@ import { addSemanticGraphRelation, addSemanticGraphUnknown, constructSemanticGraphNode, + semanticNameLabel, } from "./JavaScriptSemanticGraphConstruction.js"; import { unknownSemanticEvidence } from "./JavaScriptSemanticGraphEvidence.js"; import { @@ -78,7 +79,10 @@ const addConfigurationNode = ( kind: "config-source", roleKey: operation.configId, location: operation.location, - label: operation.key ?? operation.kind, + label: + operation.key === null + ? operation.kind + : semanticNameLabel(operation.key), functionNodeId: operation.ownerCallableId === null ? null diff --git a/src/application/JavaScriptSemanticGraphValueProjection.ts b/src/application/JavaScriptSemanticGraphValueProjection.ts index d8bedec20..ad49c9ec8 100644 --- a/src/application/JavaScriptSemanticGraphValueProjection.ts +++ b/src/application/JavaScriptSemanticGraphValueProjection.ts @@ -7,6 +7,7 @@ import { addSemanticGraphNode, addSemanticGraphRelation, constructSemanticGraphNode, + semanticNameLabel, } from "./JavaScriptSemanticGraphConstruction.js"; import type { SemanticFlowProjectionContext } from "./JavaScriptSemanticGraphFlowProjection.js"; @@ -116,7 +117,7 @@ export const semanticPropertySlot = ( kind: "property-slot", roleKey: `property:${objectBindingId}:${name}`, location: null, - label: name === "" ? '""' : name, + label: semanticNameLabel(name), functionNodeId: context.bindingNodes.get(objectBindingId)?.function_node_id ?? null, properties: { diff --git a/src/domain/javascriptSemanticAnalysis.empty_keys.test.ts b/src/domain/javascriptSemanticAnalysis.empty_keys.test.ts index 969f02707..168b3a36c 100644 --- a/src/domain/javascriptSemanticAnalysis.empty_keys.test.ts +++ b/src/domain/javascriptSemanticAnalysis.empty_keys.test.ts @@ -31,3 +31,75 @@ it("keeps a computed identifier key unresolved", () => { ]), ); }); + +it("retains exact empty method names and environment keys", () => { + const ir = analyzeJavaScriptSemantics(` + const target = { ['']() { return 42; } }; + class Holder { ""() { return 2; } } + const empty = process.env['']; + const dynamic = process.env[key]; + `); + expect( + ir.callables + .filter(({ kind }) => kind === "method") + .map(({ name }) => name), + ).toEqual(["", ""]); + expect( + ir.configurationOperations.map(({ kind, key, resolution }) => ({ + kind, + key, + resolution, + })), + ).toEqual([ + { kind: "environment", key: "", resolution: "complete" }, + { kind: "environment", key: null, resolution: "partial" }, + ]); +}); + +it("retains exact empty require paths without dynamic-key links", () => { + const ir = analyzeJavaScriptSemantics(` + const { '': fromPattern } = require("./table.js"); + const fromMember = require("./table.js")['']; + const { [key]: dynamicPattern } = require("./table.js"); + const dynamicMember = require("./table.js")[key]; + module.exports.root = require("./table.js")['']; + `); + expect( + ir.moduleLinks.map(({ kind, importedName, localName, exportedName }) => ({ + kind, + importedName, + localName, + exportedName, + })), + ).toEqual([ + { + kind: "require", + importedName: "", + localName: "fromPattern", + exportedName: null, + }, + { + kind: "require", + importedName: "", + localName: "fromMember", + exportedName: null, + }, + { + kind: "commonjs-export", + importedName: "", + localName: null, + exportedName: "root", + }, + ]); +}); + +it("retains exact empty request field names without dynamic fields", () => { + const ir = analyzeJavaScriptSemantics(` + fetch("https://example.test", { '': value, [key]: 1, body: "x" }); + `); + expect( + ir.requestOperations.flatMap(({ fields }) => + fields.map(({ name }) => name), + ), + ).toEqual(["", "body"]); +}); diff --git a/src/domain/javascriptSemanticDataEffects.ts b/src/domain/javascriptSemanticDataEffects.ts index 6cebc959e..e3874fd52 100644 --- a/src/domain/javascriptSemanticDataEffects.ts +++ b/src/domain/javascriptSemanticDataEffects.ts @@ -9,7 +9,10 @@ import type { JavaScriptSemanticRequestOperation, } from "./javascriptSemanticIr.js"; import { compareCodePoints } from "./canonicalOrdering.js"; -import { semanticStaticPropertyName } from "./javascriptSemanticProjection.js"; +import { + semanticStaticPropertyKey, + semanticStaticPropertyName, +} from "./javascriptSemanticProjection.js"; import { resolveSemanticBindingState, semanticResolutionBlocked, @@ -145,7 +148,8 @@ const processConfigurationMember = ( if (!isProcessObject(object.object, state)) return null; const family = semanticStaticPropertyName(object.property, object.computed); if (family !== "env" && family !== "argv") return null; - const key = semanticStaticPropertyName(node.property, node.computed) || null; + // `process.env[""]` names an exact (empty) key; only a dynamic key is unknown. + const key = semanticStaticPropertyKey(node.property, node.computed); return { kind: family === "env" ? "environment" : "argv", key, @@ -401,8 +405,8 @@ const requestFields = ( if (!t.isObjectProperty(property)) return []; // `{[key]: 1}` sends no knowable field name; reporting `key` would invent // a request field that may not exist. - const name = semanticStaticPropertyName(property.key, property.computed); - if (name.length === 0) return []; + const name = semanticStaticPropertyKey(property.key, property.computed); + if (name === null) return []; return [ { name, diff --git a/src/domain/javascriptSemanticProjection.ts b/src/domain/javascriptSemanticProjection.ts index c44b95f6e..4db6d9bf8 100644 --- a/src/domain/javascriptSemanticProjection.ts +++ b/src/domain/javascriptSemanticProjection.ts @@ -25,10 +25,7 @@ import { range, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; -import { - semanticStaticPropertyKey, - semanticStaticPropertyName, -} from "./javascriptAstValues.js"; +import { semanticStaticPropertyKey } from "./javascriptAstValues.js"; interface CollectCallableInput { readonly node: t.Node; @@ -181,8 +178,8 @@ const semanticModuleOrigin = ( const nested = t.isNode(node.object) ? semanticModuleOrigin(node.object, projection, state) : undefined; - const member = semanticStaticPropertyName(node.property, node.computed); - return nested === undefined || member === "" + const member = semanticStaticPropertyKey(node.property, node.computed); + return nested === undefined || member === null ? undefined : { ...nested, importedPath: [...nested.importedPath, member] }; } @@ -240,9 +237,10 @@ const callableName = (node: t.Node, parent: t.Node | null): string | null => { ) { if (t.isPrivateName(node.key)) return `#${node.key.id.name}`; // `{[key]() {}}` names nothing statically; reading `key` off the key node - // would report the variable name as the method name. + // would report the variable name as the method name. `{[""]() {}}` names + // the exact empty key, which is retained rather than replaced. return ( - semanticStaticPropertyName(node.key, node.computed) || + semanticStaticPropertyKey(node.key, node.computed) ?? `[computed@${String(node.start ?? -1)}]` ); } @@ -340,8 +338,8 @@ const requirePatternBindings = ( if (t.isObjectPattern(pattern)) return pattern.properties.flatMap((property) => { if (t.isRestElement(property)) return []; - const name = semanticStaticPropertyName(property.key, property.computed); - return name === "" + const name = semanticStaticPropertyKey(property.key, property.computed); + return name === null ? [] : requirePatternBindings(property.value, [...path, name]); });