diff --git a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts index 2e32b0fe3d..c8cda54bf8 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.dataflow.test.ts @@ -547,6 +547,62 @@ describe("nonfinite static values", () => { }); describe("binding write invalidation", () => { + it.each(["in", "of"])( + "retains prototype uncertainty for an empty template in for-%s", + (operator) => { + const ir = analyzeJavaScriptSemantics(` + String.prototype.extra = "possible inherited key"; + let selected = "before"; + for (selected ${operator} \`\`) {} + const observed = selected; + `); + expect( + topLevelBinding(ir, "selected").definitions.some( + ({ kind }) => kind === "assignment", + ), + ).toBe(true); + expect(topLevelBinding(ir, "observed").value.status).not.toBe("literal"); + }, + ); + + it.each( + [ + { input: '"abc"', empty: false }, + { input: "`abc`", empty: false }, + { input: "`\\u0061`", empty: false }, + { input: '""', empty: true }, + { input: "``", empty: true }, + { input: "`\\\n`", empty: true }, + ].flatMap((literal) => + ["in", "of"].flatMap((operator) => + ["selected", "[selected]"].map((head) => ({ + ...literal, + operator, + head, + })), + ), + ), + )( + "preserves writes for $head $operator $input (empty=$empty)", + ({ input, empty, operator, head }) => { + const ir = analyzeJavaScriptSemantics(` + let selected = "before"; + for (${head} ${operator} ${input}) {} + const observed = selected; + export function result() { return observed; } + `); + expect( + topLevelBinding(ir, "selected").definitions.some( + ({ kind }) => kind === "assignment", + ), + ).toBe(!empty); + const value = topLevelBinding(ir, "observed").value; + if (empty) expect(value).toEqual({ status: "literal", value: "before" }); + else expect(value).not.toEqual({ status: "literal", value: "before" }); + expect(onlyCallable(ir, "result").returnSites[0]?.value).toEqual(value); + }, + ); + it.each([ ["let value = 1; value++; const observed = value;", "unknown"], ["let value = 1; --value; const observed = value;", "unknown"], diff --git a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts index 9ddef42fae..62013fc95b 100644 --- a/src/domain/javascript/javascriptSemanticEvaluationOrder.ts +++ b/src/domain/javascript/javascriptSemanticEvaluationOrder.ts @@ -19,6 +19,7 @@ export const semanticIterationCardinality = ( prototypeMayAffectEnumeration: boolean | undefined, ): JavaScriptSemanticIterationCardinality => { const iterable = unwrapJavaScriptExpression(loop.right).node; + const literal = readExactJavaScriptLiteral(iterable); if (t.isForOfStatement(loop)) { if (prototypeMayAffectEnumeration !== false) return "unknown"; if (t.isArrayExpression(iterable)) { @@ -31,8 +32,8 @@ export const semanticIterationCardinality = ( return "nonempty"; return "unknown"; } - if (t.isStringLiteral(iterable)) - return iterable.value.length === 0 ? "empty" : "nonempty"; + if (literal.found && typeof literal.value === "string") + return literal.value.length === 0 ? "empty" : "nonempty"; return "unknown"; } if (t.isObjectExpression(iterable)) { @@ -57,10 +58,14 @@ export const semanticIterationCardinality = ( return "unknown"; return prototypeMayAffectEnumeration === false ? "empty" : "unknown"; } - if (t.isNullLiteral(iterable)) return "empty"; - if (t.isStringLiteral(iterable) && iterable.value.length > 0) + if (literal.found && literal.value === null) return "empty"; + if ( + literal.found && + typeof literal.value === "string" && + literal.value.length > 0 + ) return "nonempty"; - if (readExactJavaScriptLiteral(iterable).found) + if (literal.found) return prototypeMayAffectEnumeration === false ? "empty" : "unknown"; return "unknown"; }; diff --git a/tests/boundary/cli/javascriptIterationCardinality.test.ts b/tests/boundary/cli/javascriptIterationCardinality.test.ts new file mode 100644 index 0000000000..72b9c8ab44 --- /dev/null +++ b/tests/boundary/cli/javascriptIterationCardinality.test.ts @@ -0,0 +1,75 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +cliTest( + "preserves template loop assignments in public semantic Evidence", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-js-iteration-cardinality-"); + await writeFile( + join(root, "main.js"), + ` + export function template() { let selected = "before"; for ([selected] in \`abc\`) {} return selected; } + export function quoted() { let selected = "before"; for ([selected] in "abc") {} return selected; } + export function empty() { let selected = "before"; for ([selected] in \`\`) {} return selected; } + export function ordinaryTemplate() { let selected = "before"; for (selected in \`abc\`) {} return selected; } + for (const [templateKey] in \`abc\`) { void templateKey; } + for (const [quotedKey] in "abc") { void quotedKey; } + `, + ); + const output = await cli.run({ + arguments: ["analyze-javascript-application", root, "--json"], + }); + expect(output.exitCode).toBe(0); + const envelope = z + .object({ normalized_result: javascriptApplicationAnalysisResultSchema }) + .parse(output.json); + const result = envelope.normalized_result; + const shapeSchema = z.object({ + semantic_role: z.literal("export-return-shapes"), + exported_name: z.string(), + static_return_shapes: z.array( + z.object({ + fields: z.array(z.object({ state: z.string(), value: z.unknown() })), + }), + ), + }); + const shapes = result.graph.nodes.flatMap(({ observations }) => + observations.flatMap(({ properties }) => { + const parsed = shapeSchema.safeParse(properties); + return parsed.success ? [parsed.data] : []; + }), + ); + for (const name of ["template", "quoted", "ordinaryTemplate"]) { + const shape = shapes.find(({ exported_name }) => exported_name === name); + expect(shape?.static_return_shapes[0]?.fields[0]).toMatchObject({ + state: "unknown", + value: null, + }); + } + expect( + shapes.find(({ exported_name }) => exported_name === "empty") + ?.static_return_shapes[0]?.fields[0], + ).toMatchObject({ state: "literal", value: "before" }); + const graph = result.semantic_graph; + for (const label of ["templateKey", "quotedKey"]) { + const binding = graph.nodes.find( + (node) => node.kind === "binding" && node.label === label, + ); + expect(binding).toBeDefined(); + const assignment = graph.relations.find( + (relation) => + relation.relation === "defines" && + relation.target_node_id === binding?.node_id && + graph.nodes.find((node) => node.node_id === relation.source_node_id) + ?.label === "assignment", + ); + expect(assignment).toBeDefined(); + } + }, +);