Merge pull request #731 from kaoru0822-kitauji/fix/semantic-union-unknown-branches
fix(javascript): preserve unknown branches in primitive unions
This commit is contained in:
commit
75a75cbb20
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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." };
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue