diff --git a/src/application/JavaScriptReturnShapeProjection.test.ts b/src/application/JavaScriptReturnShapeProjection.test.ts new file mode 100644 index 000000000..91a168025 --- /dev/null +++ b/src/application/JavaScriptReturnShapeProjection.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "../domain/javascriptSemanticAnalysis.js"; +import { completeApplicationCoverage } from "../domain/javascriptApplicationEvidenceSchemas.js"; +import { projectJavaScriptExportReturnShapes } from "./JavaScriptReturnShapeProjection.js"; + +const projectReturns = (body: string) => { + const ir = analyzeJavaScriptSemantics(`export function result() { ${body} }`); + const link = ir.moduleLinks.find( + ({ exportedName }) => exportedName === "result", + ); + if (link === undefined) throw new Error("Expected the result export"); + const projected = projectJavaScriptExportReturnShapes({ + ir, + link, + modulePath: "fixture.js", + baseCoverage: completeApplicationCoverage(), + }); + if (projected === null) throw new Error("Expected exported return shapes"); + return projected; +}; + +const unknownBranchField = { + path: "", + state: "unknown", + value: null, + reason: "Branches have incompatible values.", +}; + +describe("exported primitive union return shapes", () => { + const conditionalCases = [ + "missing", + "unsupported()", + '({ key: "value" })', + '["value"]', + '+"invalid"', + ].flatMap((other) => [ + `condition ? (choice ? "one" : "two") : ${other}`, + `condition ? ${other} : (choice ? "one" : "two")`, + ]); + const logicalCases = [ + { operator: "||", known: '(choice ? "" : "one")' }, + { operator: "&&", known: '(choice ? 0 : "one")' }, + { operator: "??", known: '(choice ? null : "one")' }, + ].flatMap(({ operator, known }) => [ + `${known} ${operator} missing`, + `missing ${operator} ${known}`, + ]); + + it.each([...conditionalCases, ...logicalCases])( + "projects an unknown field instead of a definite union for %s", + (expression) => { + const projected = projectReturns(`return ${expression};`); + + expect(projected.properties.static_return_shapes).toEqual([ + { + source_range: expect.any(Object), + value_status: "ambiguous", + fields: [unknownBranchField], + property_coverage: [], + }, + ]); + // Complete return-site collection does not make its values known. + expect(projected.properties.return_shape_coverage).toEqual({ + status: "complete", + retained_return_sites: 1, + omitted_return_sites: 0, + omitted_fields: 0, + omitted_property_coverage: 0, + projection_complete: true, + }); + expect(projected.coverage).toEqual(completeApplicationCoverage()); + expect(projected.limitations).toContain( + "Return shapes are inferred from inert syntax and do not prove runtime behavior.", + ); + }, + ); + + it("keeps an incompatible object field unknown beside a known literal", () => { + const projected = projectReturns(` + return { + kind: "result", + value: condition ? (choice ? "one" : "two") : missing, + }; + `); + expect(projected.properties.static_return_shapes).toEqual([ + { + source_range: expect.any(Object), + value_status: "object", + fields: [ + { path: "/kind", state: "literal", value: "result", reason: null }, + { ...unknownBranchField, path: "/value" }, + ], + property_coverage: [{ path: "", status: "complete", omitted: 0 }], + }, + ]); + }); + + it("retains valid nested unions, duplicates, and null alternatives", () => { + const projected = projectReturns(` + return condition + ? (choice ? "two" : "one") + : (other ? "one" : null); + `); + expect(projected.properties.static_return_shapes).toEqual([ + { + source_range: expect.any(Object), + value_status: "union", + fields: [ + { + path: "", + state: "union", + value: [null, "one", "two"], + reason: null, + }, + ], + property_coverage: [], + }, + ]); + }); + + it("preserves three independent direct returns without merging their values", () => { + const projected = projectReturns(` + if (condition) return choice ? "one" : "two"; + if (other) return missing; + return { kind: "object" }; + `); + expect(projected.properties.static_return_shapes).toEqual([ + { + source_range: expect.any(Object), + value_status: "union", + fields: [ + { path: "", state: "union", value: ["one", "two"], reason: null }, + ], + property_coverage: [], + }, + { + source_range: expect.any(Object), + value_status: "unknown", + fields: [ + { + path: "", + state: "unknown", + value: null, + reason: "Unbound identifier missing.", + }, + ], + property_coverage: [], + }, + { + source_range: expect.any(Object), + value_status: "object", + fields: [ + { path: "/kind", state: "literal", value: "object", reason: null }, + ], + property_coverage: [{ path: "", status: "complete", omitted: 0 }], + }, + ]); + expect(projected.properties.return_shape_coverage).toMatchObject({ + status: "complete", + retained_return_sites: 3, + omitted_return_sites: 0, + projection_complete: true, + }); + }); +}); diff --git a/src/domain/javascriptSemanticAnalysis.unions.test.ts b/src/domain/javascriptSemanticAnalysis.unions.test.ts new file mode 100644 index 000000000..25e02aefa --- /dev/null +++ b/src/domain/javascriptSemanticAnalysis.unions.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; + +const conditionalCandidates = [ + '"one"', + '(choice ? "one" : "two")', + '(choice ? "one" : other ? "two" : "three")', +]; +const incompatibleValue = { + status: "ambiguous", + reason: "Branches have incompatible values.", +}; + +const valueOf = (expression: string) => + topLevelBinding( + analyzeJavaScriptSemantics(`const answer = ${expression};`), + "answer", + ).value; + +describe("conditional primitive unions", () => { + const cases = conditionalCandidates.flatMap((known) => + [ + "missing", + "unsupported()", + '({ key: "value" })', + '["value"]', + '+"invalid"', + "1e309", + "1e308 + 1e308", + ].flatMap((other) => [ + `condition ? ${known} : ${other}`, + `condition ? ${other} : ${known}`, + ]), + ); + + it.each(cases)("preserves incompatible alternatives in %s", (expression) => { + expect(valueOf(expression)).toEqual(incompatibleValue); + }); + + it.each([ + [ + 'condition ? (choice ? "two" : "one") : "three"', + { status: "union", values: ["one", "three", "two"] }, + ], + [ + 'condition ? "three" : (choice ? "two" : "one")', + { status: "union", values: ["one", "three", "two"] }, + ], + [ + 'condition ? (choice ? "two" : "one") : (other ? "one" : "three")', + { status: "union", values: ["one", "three", "two"] }, + ], + [ + 'condition ? (choice ? "two" : "one") : (other ? "one" : "two")', + { status: "union", values: ["one", "two"] }, + ], + [ + 'condition ? (choice ? "one" : "one") : "one"', + { status: "literal", value: "one" }, + ], + [ + 'condition ? (choice ? "one" : "two") : null', + { status: "union", values: [null, "one", "two"] }, + ], + [ + 'condition ? null : (choice ? "one" : "two")', + { status: "union", values: [null, "one", "two"] }, + ], + ["condition ? null : null", { status: "literal", value: null }], + ])("retains exact primitive candidates in %s", (expression, expected) => { + expect(valueOf(expression)).toEqual(expected); + }); + + it("propagates an incompatible nested branch through an outer union", () => { + expect( + valueOf( + 'condition ? (choice ? "one" : "two") : (other ? "three" : missing)', + ), + ).toEqual(incompatibleValue); + }); +}); + +describe("logical primitive unions", () => { + // Each left-hand set can select the unresolved right-hand operand at runtime. + const operators = [ + { + operator: "||", + alternatives: [ + '""', + '(choice ? "" : "one")', + '(choice ? "" : other ? "one" : "two")', + ], + }, + { + operator: "&&", + alternatives: [ + '"one"', + '(choice ? 0 : "one")', + '(choice ? 0 : other ? "one" : "two")', + ], + }, + { + operator: "??", + alternatives: [ + "null", + '(choice ? null : "one")', + '(choice ? null : other ? "one" : "two")', + ], + }, + ]; + const cases = operators.flatMap(({ operator, alternatives }) => + alternatives.flatMap((known) => + ["missing", "unsupported()"].flatMap((other) => [ + `${known} ${operator} ${other}`, + `${other} ${operator} ${known}`, + ]), + ), + ); + + it.each(cases)("preserves unresolved alternatives in %s", (expression) => { + expect(valueOf(expression)).toEqual(incompatibleValue); + }); + + it.each([ + [ + '(choice ? "" : "one") || "two"', + { status: "union", values: ["", "one", "two"] }, + ], + [ + '(choice ? 0 : "one") && "two"', + { status: "union", values: [0, "one", "two"] }, + ], + [ + '(choice ? null : "one") ?? "two"', + { status: "union", values: [null, "one", "two"] }, + ], + ['"one" || "one"', { status: "literal", value: "one" }], + ["null ?? null", { status: "literal", value: null }], + ])( + "retains the conservative primitive union in %s", + (expression, expected) => { + expect(valueOf(expression)).toEqual(expected); + }, + ); + + it.each([ + 'true ? "one" : missing', + '"one" || missing', + "false && missing", + '"one" ?? missing', + ])("does not introduce short-circuit evaluation for %s", (expression) => { + expect(valueOf(expression)).toEqual(incompatibleValue); + }); +}); diff --git a/src/domain/javascriptSemanticValues.ts b/src/domain/javascriptSemanticValues.ts index 9e7247016..8b56d6ceb 100644 --- a/src/domain/javascriptSemanticValues.ts +++ b/src/domain/javascriptSemanticValues.ts @@ -481,10 +481,9 @@ const mergeValues = ( const primitives = values.flatMap( (value) => primitiveCandidates(value) ?? [], ); - return primitives.length === values.length || - values.every( - (value) => value.status === "union" || value.status === "literal", - ) + return values.every( + (value) => value.status === "union" || value.status === "literal", + ) ? primitiveSet(primitives) : { status: "ambiguous", reason: "Branches have incompatible values." }; };