mirror of
https://github.com/morluto/rea.git
synced 2026-10-11 21:39:55 +00:00
1096 lines
37 KiB
TypeScript
1096 lines
37 KiB
TypeScript
import { copyFile, mkdir, writeFile } from "node:fs/promises";
|
|
import { join, resolve } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js";
|
|
import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js";
|
|
import { compareJavaScriptExportShapes } from "../../../src/domain/javascript/javascriptExportShapeComparison.js";
|
|
import {
|
|
javaScriptExportShapeComparisonResultSchema,
|
|
projectedExportReturnShapesSchema,
|
|
} from "../../../src/domain/javascript/javascriptExportShapeComparisonSchemas.js";
|
|
import {
|
|
createJavaScriptApplicationGraph,
|
|
createJavaScriptApplicationNode,
|
|
} from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
|
|
|
describe("JavaScript export return-shape comparison", () => {
|
|
it("reports exactly the heading depth addition from source-owned parser fixtures", async () => {
|
|
const root = await temporaryRoot();
|
|
const leftRoot = join(root, "left");
|
|
const rightRoot = join(root, "right");
|
|
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
|
await Promise.all([
|
|
copyFile(
|
|
resolve("tests/fixtures/replay/parser.mjs"),
|
|
join(leftRoot, "parser.mjs"),
|
|
),
|
|
copyFile(
|
|
resolve("tests/fixtures/replay/parser-v2.mjs"),
|
|
join(rightRoot, "parser.mjs"),
|
|
),
|
|
]);
|
|
const [left, right] = await Promise.all([
|
|
analyzeGraph(leftRoot),
|
|
analyzeGraph(rightRoot),
|
|
]);
|
|
|
|
const first = compare(left, right);
|
|
const second = compare(left, right);
|
|
|
|
expect(first.comparison_id).toBe(second.comparison_id);
|
|
expect(() =>
|
|
javaScriptExportShapeComparisonResultSchema.parse(first),
|
|
).not.toThrow();
|
|
expect(first.summary).toEqual({
|
|
added: 1,
|
|
removed: 0,
|
|
changed: 0,
|
|
unknown: 0,
|
|
});
|
|
expect(first.changes).toEqual([
|
|
expect.objectContaining({
|
|
status: "added",
|
|
path: "/depth",
|
|
discriminant: { path: "/type", value: "heading" },
|
|
presence: { left: "absent", right: "present" },
|
|
left: { availability: "absent" },
|
|
right: { availability: "literal", value: 1 },
|
|
}),
|
|
]);
|
|
expect(first.changes.some(({ path }) => path === "/text")).toBe(false);
|
|
expect(first.coverage).toMatchObject({
|
|
status: "complete-within-inputs",
|
|
paired_variants: 3,
|
|
unpaired_left_variants: 0,
|
|
unpaired_right_variants: 0,
|
|
});
|
|
expect(first.limitations).toContain(
|
|
"This static comparison cannot establish runtime semantics; run behavioral probes directly against the relevant application versions when that evidence is required.",
|
|
);
|
|
});
|
|
|
|
it("keeps additions unknown when spread coverage is incomplete", async () => {
|
|
const [left, right] = await analyzeSources({
|
|
left: `
|
|
const dynamic = getDynamic();
|
|
export default () => ({ ...dynamic, type: "heading" });
|
|
`,
|
|
right: `
|
|
const dynamic = getDynamic();
|
|
export default () => ({ depth: 1, ...dynamic, type: "heading" });
|
|
`,
|
|
});
|
|
const result = compare(left, right);
|
|
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
path: "/depth",
|
|
presence: { left: "unknown-coverage", right: "present" },
|
|
}),
|
|
]);
|
|
expect(result.coverage.status).toBe("partial");
|
|
});
|
|
|
|
it("orders multiple changed paths deterministically across Unicode names", async () => {
|
|
const graphs = await analyzeSources({
|
|
left: `export default () => ({ type: "item", zeta: 1, alpha: 1, "éclair": 1 });`,
|
|
right: `export default () => ({ type: "item", zeta: 2, alpha: 2, "éclair": 2 });`,
|
|
});
|
|
const first = compare(...graphs);
|
|
const second = compare(...graphs);
|
|
|
|
expect(new Set(first.changes.map(({ path }) => path))).toEqual(
|
|
new Set(["/alpha", "/zeta", "/éclair"]),
|
|
);
|
|
expect(first.changes).toEqual(second.changes);
|
|
expect(first.comparison_id).toBe(second.comparison_id);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape pairing limits", () => {
|
|
it("does not pair an inline discriminant with absent or unknown presence", async () => {
|
|
const rows = [
|
|
{ presence: "absent", side: "left" },
|
|
{ presence: "unknown-coverage", side: "left" },
|
|
{ presence: "absent", side: "right" },
|
|
{ presence: "unknown-coverage", side: "right" },
|
|
] as const;
|
|
const [left, right] = await analyzeSources({
|
|
left: 'export default () => ({ kind: "result", count: 1 });',
|
|
right: 'export default () => ({ kind: "result", total: 2 });',
|
|
});
|
|
|
|
for (const { presence, side } of rows) {
|
|
const label = `${side} discriminant presence ${presence}`;
|
|
const selected = side === "left" ? left : right;
|
|
const graph = createJavaScriptApplicationGraph({
|
|
schema: "JavaScriptApplicationGraph",
|
|
root_node_ids: selected.graph.root_node_ids,
|
|
edges: selected.graph.edges,
|
|
coverage: selected.graph.coverage,
|
|
limitations: selected.graph.limitations,
|
|
nodes: selected.graph.nodes.map((node) =>
|
|
createJavaScriptApplicationNode({
|
|
kind: node.kind,
|
|
identity: node.identity,
|
|
observations: node.observations.map(
|
|
({ label, properties, evidence }) => ({
|
|
label,
|
|
evidence,
|
|
properties:
|
|
properties.semantic_role !== "export-return-shapes"
|
|
? properties
|
|
: {
|
|
...properties,
|
|
static_return_shapes: projectedExportReturnShapesSchema
|
|
.parse(properties)
|
|
.static_return_shapes.map((shape) => ({
|
|
...shape,
|
|
fields: shape.fields.map((field) =>
|
|
field.path === "/kind"
|
|
? { ...field, presence }
|
|
: field,
|
|
),
|
|
})),
|
|
},
|
|
}),
|
|
),
|
|
}),
|
|
),
|
|
});
|
|
const result =
|
|
side === "left"
|
|
? compare({ ...left, graph }, right)
|
|
: compare(left, { ...right, graph });
|
|
expect(result.coverage, label).toMatchObject({
|
|
status: "partial",
|
|
paired_variants: 0,
|
|
unpaired_left_variants: 1,
|
|
unpaired_right_variants: 1,
|
|
});
|
|
expect(result.changes, label).toHaveLength(2);
|
|
expect(
|
|
result.changes.every(
|
|
({ status, discriminant }) =>
|
|
status === "unknown" && discriminant === null,
|
|
),
|
|
label,
|
|
).toBe(true);
|
|
expect(
|
|
result.property_inventories.every(
|
|
({ paired, discriminant }) => !paired && discriminant === null,
|
|
),
|
|
label,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("does not pair discriminants that an unknown trailing spread can overwrite", async () => {
|
|
const [left, right] = await analyzeSources({
|
|
left: `
|
|
const dynamic = getDynamic();
|
|
export default () => ({ type: "heading", ...dynamic });
|
|
`,
|
|
right: `
|
|
const dynamic = getDynamic();
|
|
export default () => ({ type: "heading", depth: 1, ...dynamic });
|
|
`,
|
|
});
|
|
const result = compare(left, right);
|
|
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
path: "",
|
|
discriminant: null,
|
|
}),
|
|
expect.objectContaining({
|
|
status: "unknown",
|
|
path: "",
|
|
discriminant: null,
|
|
}),
|
|
]);
|
|
expect(result.summary).toEqual({
|
|
added: 0,
|
|
removed: 0,
|
|
changed: 0,
|
|
unknown: 2,
|
|
});
|
|
expect(result.coverage).toMatchObject({
|
|
status: "partial",
|
|
paired_variants: 0,
|
|
unpaired_left_variants: 1,
|
|
unpaired_right_variants: 1,
|
|
});
|
|
});
|
|
|
|
it("does not pair ambiguous variants or invent behavior for no-return exports", async () => {
|
|
const ambiguous = await analyzeSources({
|
|
left: `
|
|
export default function parse(value) {
|
|
if (value) return { type: "heading", text: render(value) };
|
|
return { type: "heading", text: render(value) };
|
|
}
|
|
`,
|
|
right: `export default (value) => ({ type: "heading", text: render(value) });`,
|
|
});
|
|
const compared = compare(...ambiguous);
|
|
expect(compared.summary.unknown).toBe(3);
|
|
expect(compared.coverage).toMatchObject({
|
|
status: "partial",
|
|
paired_variants: 0,
|
|
unpaired_left_variants: 2,
|
|
unpaired_right_variants: 1,
|
|
});
|
|
|
|
const noReturn = await analyzeSources({
|
|
left: `export default function stop() { throw new Error("stop"); }`,
|
|
right: `export default function stop() { throw new Error("stop"); }`,
|
|
});
|
|
const unknown = compare(...noReturn);
|
|
expect(unknown.changes).toEqual([
|
|
expect.objectContaining({ status: "unknown", path: "" }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape uncertain presence", () => {
|
|
it.each([false, true])(
|
|
"keeps deleted property presence unknown (reversed: %s)",
|
|
async (reversed) => {
|
|
const plain = 'export default () => ({ kind: "result" });';
|
|
const mutated = `export default function make() {
|
|
const result = { kind: "result", count: 1 };
|
|
delete result.count;
|
|
return result;
|
|
}`;
|
|
const graphs = await analyzeSources({
|
|
left: reversed ? mutated : plain,
|
|
right: reversed ? plain : mutated,
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.changes).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "/count",
|
|
status: "unknown",
|
|
presence: reversed
|
|
? { left: "unknown-coverage", right: "absent" }
|
|
: { left: "absent", right: "unknown-coverage" },
|
|
}),
|
|
);
|
|
expect(
|
|
inventoryProperties(result, reversed ? "left" : "right", 0),
|
|
).not.toContain("/count");
|
|
expect(result.summary.added + result.summary.removed).toBe(0);
|
|
expect(result.coverage.status).toBe("partial");
|
|
},
|
|
);
|
|
|
|
it("excludes array holes while preserving observed unknown elements", async () => {
|
|
const holes = 'export default () => ({ kind: "result", items: [,] });';
|
|
const empty = await analyzeSources({
|
|
left: holes,
|
|
right: 'export default () => ({ kind: "result", items: [] });',
|
|
});
|
|
const result = compare(...empty);
|
|
expect(result.coverage.status).toBe("complete-within-inputs");
|
|
expect(inventoryProperties(result, "left", 0)).not.toContain("/items/0");
|
|
expect(result.changes.some(({ path }) => path === "/items/0")).toBe(false);
|
|
const filled = await analyzeSources({
|
|
left: holes,
|
|
right: 'export default () => ({ kind: "result", items: [call()] });',
|
|
});
|
|
expect(compare(...filled).changes).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "/items/0",
|
|
status: "added",
|
|
presence: { left: "absent", right: "present" },
|
|
left: { availability: "absent" },
|
|
right: expect.objectContaining({ availability: "unknown" }),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("retains unpaired inventory locations when the opposite export has no return", async () => {
|
|
const graphs = await analyzeSources({
|
|
left: "export default function f() {}",
|
|
right: `export default function f(flag) {
|
|
if (flag) return { count: call() };
|
|
return { total: call() };
|
|
}`,
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.property_inventories).toEqual([
|
|
expect.objectContaining({
|
|
side: "right",
|
|
paired: false,
|
|
variant_index: 0,
|
|
properties: expect.arrayContaining(["/count"]),
|
|
source_range: expect.objectContaining({
|
|
start: expect.objectContaining({ line: 2 }),
|
|
}),
|
|
}),
|
|
expect.objectContaining({
|
|
side: "right",
|
|
paired: false,
|
|
variant_index: 1,
|
|
properties: expect.arrayContaining(["/total"]),
|
|
source_range: expect.objectContaining({
|
|
start: expect.objectContaining({ line: 3 }),
|
|
}),
|
|
}),
|
|
]);
|
|
expect(() =>
|
|
javaScriptExportShapeComparisonResultSchema.parse(result),
|
|
).not.toThrow();
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export matching uncertain projections", () => {
|
|
it.each([
|
|
["Object.assign", "Object.assign(result, { extra: 1 });"],
|
|
["local helper", "mutate(result);"],
|
|
["alias", "const alias = result; mutate(alias);"],
|
|
["destructuring", "const { value } = { value: result }; mutate(value);"],
|
|
["shared wrapper", "mutate({ value: result });"],
|
|
["spread arguments", "mutate(...[result]);"],
|
|
["optional call", "mutate?.(result);"],
|
|
["constructor", "new Mutator(result);"],
|
|
["tagged template", "tag`${result}`;"],
|
|
])(
|
|
"does not infer an addition after %s can mutate the return value",
|
|
async (_description, invocation) => {
|
|
const graphs = await analyzeSources({
|
|
left: `
|
|
function mutate(value) { (value.value ?? value).extra = 1; }
|
|
function Mutator(value) { value.extra = 1; }
|
|
function tag(_strings, value) { value.extra = 1; }
|
|
export default function make() {
|
|
const result = { kind: "record" };
|
|
${invocation}
|
|
return result;
|
|
}
|
|
`,
|
|
right: 'export default () => ({ kind: "record", extra: 1 });',
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.summary).toMatchObject({
|
|
added: 0,
|
|
removed: 0,
|
|
changed: 0,
|
|
});
|
|
expect(result.coverage.status).toBe("partial");
|
|
expect(result.changes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ status: "unknown" }),
|
|
]),
|
|
);
|
|
},
|
|
);
|
|
|
|
it("keeps sibling properties known when a method can mutate its receiver", async () => {
|
|
const graphs = await analyzeSources({
|
|
left: `export default function make() {
|
|
const result = { kind: "record", tokens: [] };
|
|
result.tokens.push({ extra: 1 });
|
|
return result;
|
|
}`,
|
|
right:
|
|
'export default () => ({ kind: "record", tokens: [{ extra: 1 }] });',
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.summary).toMatchObject({ added: 0, removed: 0, changed: 0 });
|
|
expect(result.coverage.status).toBe("partial");
|
|
for (const inventory of result.property_inventories)
|
|
expect(inventory.properties).toContain("/kind");
|
|
});
|
|
|
|
it.each(["delete result.count;", "result.count = query();"])(
|
|
"omits matching uncertain projections after %s",
|
|
async (mutation) => {
|
|
const source = `export default function make() {
|
|
const result = { kind: "result", count: 1 };
|
|
${mutation}
|
|
return result;
|
|
}`;
|
|
const graphs = await analyzeSources({ left: source, right: source });
|
|
const result = compare(...graphs);
|
|
expect(result.changes).toEqual([]);
|
|
expect(result.summary).toEqual({
|
|
added: 0,
|
|
removed: 0,
|
|
changed: 0,
|
|
unknown: 0,
|
|
});
|
|
expect(result.coverage.status).toBe("partial");
|
|
expect(
|
|
result.property_inventories.every(
|
|
({ properties }) => !properties.includes("/count"),
|
|
),
|
|
).toBe(true);
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("JavaScript export localized mutation uncertainty", () => {
|
|
it.each([
|
|
{
|
|
initializer: '{ kind: "result", count: 1 }',
|
|
mutation: "delete result.count;",
|
|
uncertain: "/count",
|
|
right: '{ kind: "result", total: 1 }',
|
|
added: "/total",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result" }',
|
|
mutation: "result.count = query();",
|
|
uncertain: "/count",
|
|
right: '{ kind: "result", total: 1 }',
|
|
added: "/total",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result" }',
|
|
mutation: "delete result.count;",
|
|
uncertain: "/count",
|
|
right: '{ kind: "result", total: 1 }',
|
|
added: "/total",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result", nested: {} }',
|
|
mutation: "result.nested.count = query();",
|
|
uncertain: "/nested/count",
|
|
right: '{ kind: "result", nested: { total: 1 } }',
|
|
added: "/nested/total",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result", items: [] }',
|
|
mutation: "result.items[1] = query();",
|
|
uncertain: "/items/1",
|
|
right: '{ kind: "result", items: [,,1] }',
|
|
added: "/items/2",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result", items: [] }',
|
|
mutation: "delete result.items[1];",
|
|
uncertain: "/items/1",
|
|
right: '{ kind: "result", items: [,,1] }',
|
|
added: "/items/2",
|
|
},
|
|
{
|
|
initializer: '{ kind: "result", items: [] }',
|
|
mutation: "result.items[1000000000] = query();",
|
|
uncertain: "/items/1000000000",
|
|
right: '{ kind: "result", items: [,,1] }',
|
|
added: "/items/2",
|
|
},
|
|
])(
|
|
"localizes $mutation without losing sibling absence",
|
|
async ({ initializer, mutation, uncertain, right, added }) => {
|
|
const graphs = await analyzeSources({
|
|
left: `export default function make() {
|
|
const result = ${initializer};
|
|
${mutation}
|
|
return result;
|
|
}`,
|
|
right: `export default () => (${right});`,
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.changes).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
path: uncertain,
|
|
status: "unknown",
|
|
presence: { left: "unknown-coverage", right: "absent" },
|
|
}),
|
|
expect.objectContaining({
|
|
path: added,
|
|
status: "added",
|
|
presence: { left: "absent", right: "present" },
|
|
}),
|
|
]),
|
|
);
|
|
expect(result.summary).toEqual({
|
|
added: 1,
|
|
removed: 0,
|
|
changed: 0,
|
|
unknown: 1,
|
|
});
|
|
},
|
|
);
|
|
});
|
|
|
|
describe("JavaScript export property presence boundaries", () => {
|
|
it("rejects return-shape observations that omit required slot presence", async () => {
|
|
const source =
|
|
'export default () => ({ kind: "result", count: flag ? 1 : 2, nested: { enabled: true } });';
|
|
const [left, right] = await analyzeSources({ left: source, right: source });
|
|
const graph = createJavaScriptApplicationGraph({
|
|
schema: "JavaScriptApplicationGraph",
|
|
root_node_ids: left.graph.root_node_ids,
|
|
edges: left.graph.edges,
|
|
coverage: left.graph.coverage,
|
|
limitations: left.graph.limitations,
|
|
nodes: left.graph.nodes.map((node) =>
|
|
createJavaScriptApplicationNode({
|
|
kind: node.kind,
|
|
identity: node.identity,
|
|
observations: node.observations.map(
|
|
({ label, properties, evidence }) => {
|
|
if (properties.semantic_role !== "export-return-shapes")
|
|
return { label, properties, evidence };
|
|
const projection =
|
|
projectedExportReturnShapesSchema.parse(properties);
|
|
return {
|
|
label,
|
|
evidence,
|
|
properties: {
|
|
...properties,
|
|
static_return_shapes: projection.static_return_shapes.map(
|
|
(shape) => ({
|
|
...shape,
|
|
fields: shape.fields.map(
|
|
({ path, state, value, reason }) => ({
|
|
path,
|
|
state,
|
|
value,
|
|
reason,
|
|
}),
|
|
),
|
|
}),
|
|
),
|
|
},
|
|
};
|
|
},
|
|
),
|
|
}),
|
|
),
|
|
});
|
|
for (const sides of [
|
|
[{ ...left, graph }, right],
|
|
[right, { ...left, graph }],
|
|
] as const) {
|
|
const result = compare(sides[0], sides[1]);
|
|
expect(result.coverage.paired_variants).toBe(0);
|
|
expect([result.left.status, result.right.status]).toContain(
|
|
"unavailable",
|
|
);
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({ status: "unknown", path: "" }),
|
|
]);
|
|
expect(result.coverage.status).toBe("partial");
|
|
}
|
|
});
|
|
|
|
it("proves absence beyond literal holes while retaining unknown spread coverage", async () => {
|
|
const right =
|
|
'export default () => ({ kind: "result", items: [,, call()] });';
|
|
const literal = await analyzeSources({
|
|
left: 'export default () => ({ kind: "result", items: [,] });',
|
|
right,
|
|
});
|
|
const result = compare(...literal);
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({
|
|
path: "/items/2",
|
|
status: "added",
|
|
presence: { left: "absent", right: "present" },
|
|
}),
|
|
]);
|
|
expect(result.coverage.status).toBe("complete-within-inputs");
|
|
const spread = await analyzeSources({
|
|
left: 'export default () => ({ kind: "result", items: [, ...rest] });',
|
|
right,
|
|
});
|
|
expect(compare(...spread).changes).toContainEqual(
|
|
expect.objectContaining({
|
|
path: "/items/2",
|
|
status: "unknown",
|
|
presence: { left: "unknown-coverage", right: "present" },
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("excludes the return root but retains empty property names and nested containers", async () => {
|
|
const source =
|
|
'export default () => ({ kind: "result", "": call(), options: {} });';
|
|
const graphs = await analyzeSources({ left: source, right: source });
|
|
const result = compare(...graphs);
|
|
expect(inventoryProperties(result, "left", 0)).toEqual([
|
|
"/",
|
|
"/kind",
|
|
"/options",
|
|
]);
|
|
expect(result.property_inventories[0]?.property_coverage).toContainEqual({
|
|
path: "",
|
|
status: "complete",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape property presence", () => {
|
|
it("lists observed names without pairing untagged search variants", async () => {
|
|
const graphs = await analyzeSources(
|
|
{
|
|
left: untaggedSearchSource("count"),
|
|
right: untaggedSearchSource("total"),
|
|
},
|
|
"search.js",
|
|
);
|
|
const first = compare(...graphs, {
|
|
modulePath: "search.js",
|
|
leftExportName: "search",
|
|
rightExportName: "search",
|
|
});
|
|
const second = compare(...graphs, {
|
|
modulePath: "search.js",
|
|
leftExportName: "search",
|
|
rightExportName: "search",
|
|
});
|
|
|
|
expect(first.comparison_id).toBe(second.comparison_id);
|
|
expect(first.coverage).toMatchObject({
|
|
paired_variants: 0,
|
|
unpaired_left_variants: 1,
|
|
unpaired_right_variants: 1,
|
|
});
|
|
expect(first.summary).toEqual({
|
|
added: 0,
|
|
removed: 0,
|
|
changed: 0,
|
|
unknown: 2,
|
|
});
|
|
expect(
|
|
first.changes.every(({ discriminant }) => discriminant === null),
|
|
).toBe(true);
|
|
expect(inventoryProperties(first, "left", 0)).toEqual(
|
|
expect.arrayContaining(["/count", "/matches"]),
|
|
);
|
|
expect(inventoryProperties(first, "right", 0)).toEqual(
|
|
expect.arrayContaining(["/matches", "/query", "/total"]),
|
|
);
|
|
expect(inventoryProperties(first, "left", 0)).not.toEqual(
|
|
inventoryProperties(first, "right", 0),
|
|
);
|
|
});
|
|
|
|
it("reports presence-only added and removed names for tagged search variants", async () => {
|
|
const graphs = await analyzeSources(
|
|
{
|
|
left: taggedSearchSource({ count: true }),
|
|
right: taggedSearchSource({ total: true, query: true }),
|
|
},
|
|
"search.js",
|
|
);
|
|
const first = compare(...graphs, {
|
|
modulePath: "search.js",
|
|
leftExportName: "search",
|
|
rightExportName: "search",
|
|
});
|
|
const second = compare(...graphs, {
|
|
modulePath: "search.js",
|
|
leftExportName: "search",
|
|
rightExportName: "search",
|
|
});
|
|
|
|
expect(first.comparison_id).toBe(second.comparison_id);
|
|
expect(() =>
|
|
javaScriptExportShapeComparisonResultSchema.parse(first),
|
|
).not.toThrow();
|
|
expect(first.coverage.paired_variants).toBe(1);
|
|
expect(first.summary.added).toBe(2);
|
|
expect(first.summary.removed).toBe(1);
|
|
expect(
|
|
first.changes.filter(({ path }) =>
|
|
["/count", "/total", "/query"].includes(path),
|
|
),
|
|
).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
status: "removed",
|
|
path: "/count",
|
|
presence: { left: "present", right: "absent" },
|
|
left: expect.objectContaining({ availability: "unknown" }),
|
|
right: { availability: "absent" },
|
|
}),
|
|
expect.objectContaining({
|
|
status: "added",
|
|
path: "/total",
|
|
presence: { left: "absent", right: "present" },
|
|
left: { availability: "absent" },
|
|
right: expect.objectContaining({ availability: "unknown" }),
|
|
}),
|
|
expect.objectContaining({
|
|
status: "added",
|
|
path: "/query",
|
|
presence: { left: "absent", right: "present" },
|
|
left: { availability: "absent" },
|
|
right: expect.objectContaining({ availability: "unknown" }),
|
|
}),
|
|
]),
|
|
);
|
|
expect(
|
|
first.changes.some(
|
|
({ path, status }) =>
|
|
["/count", "/total", "/query"].includes(path) && status === "unknown",
|
|
),
|
|
).toBe(false);
|
|
expect(inventoryProperties(first, "left", 0)).toEqual(
|
|
expect.arrayContaining(["/count", "/kind", "/matches"]),
|
|
);
|
|
expect(inventoryProperties(first, "right", 0)).toEqual(
|
|
expect.arrayContaining(["/kind", "/matches", "/query", "/total"]),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape container presence", () => {
|
|
it.each(["{}", "[]", "{ nested: 1 }"])(
|
|
"does not report a retained %s container property as removed",
|
|
async (container) => {
|
|
const graphs = await analyzeSources({
|
|
left: 'export default () => ({ type: "item", options: false });',
|
|
right: `export default () => ({ type: "item", options: ${container} });`,
|
|
});
|
|
const result = compare(...graphs);
|
|
const change = result.changes.find(({ path }) => path === "/options");
|
|
expect(change).toMatchObject({
|
|
status: "unknown",
|
|
left: { availability: "literal", value: false },
|
|
right: { availability: "unknown" },
|
|
});
|
|
expect(() =>
|
|
javaScriptExportShapeComparisonResultSchema.parse(result),
|
|
).not.toThrow();
|
|
},
|
|
);
|
|
|
|
it.each(["{}", "[]", "{ nested: 1 }"])(
|
|
"does not report a retained %s container becoming a primitive as added",
|
|
async (container) => {
|
|
const graphs = await analyzeSources({
|
|
left: `export default () => ({ type: "item", options: ${container} });`,
|
|
right: 'export default () => ({ type: "item", options: false });',
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(
|
|
result.changes.find(({ path }) => path === "/options"),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
left: { availability: "unknown" },
|
|
right: { availability: "literal", value: false },
|
|
});
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ left: "", right: ", options: false", status: "added" },
|
|
{ left: ", options: false", right: "", status: "removed" },
|
|
])("preserves genuine primitive $status", async ({ left, right, status }) => {
|
|
const graphs = await analyzeSources({
|
|
left: `export default () => ({ type: "item"${left} });`,
|
|
right: `export default () => ({ type: "item"${right} });`,
|
|
});
|
|
expect(compare(...graphs).changes).toEqual([
|
|
expect.objectContaining({ path: "/options", status }),
|
|
]);
|
|
});
|
|
|
|
it("keeps a container transition unknown under incomplete spread coverage", async () => {
|
|
const graphs = await analyzeSources({
|
|
left: 'export default () => ({ type: "item", options: false });',
|
|
right:
|
|
'export default () => ({ type: "item", options: { ...dynamic } });',
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(
|
|
result.changes.find(({ path }) => path === "/options"),
|
|
).toMatchObject({
|
|
status: "unknown",
|
|
right: { availability: "unknown" },
|
|
});
|
|
expect(result.coverage.status).toBe("partial");
|
|
});
|
|
|
|
it("preserves identical container leaves and real primitive changes", async () => {
|
|
const graphs = await analyzeSources({
|
|
left: 'export default () => ({ type: "item", options: {}, enabled: false });',
|
|
right:
|
|
'export default () => ({ type: "item", options: {}, enabled: true });',
|
|
});
|
|
const result = compare(...graphs);
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({ path: "/enabled", status: "changed" }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape selection", () => {
|
|
it("returns complete candidate, variant, and change inventories", async () => {
|
|
const candidates = await analyzeSources({
|
|
left: `
|
|
export const first = () => ({ type: "first" });
|
|
export const second = () => ({ type: "second" });
|
|
export const third = () => ({ type: "third" });
|
|
`,
|
|
right: `export default () => ({ type: "default" });`,
|
|
});
|
|
const missing = compare(candidates[0], candidates[1], {
|
|
leftExportName: "missing",
|
|
});
|
|
expect(missing.left).toMatchObject({
|
|
status: "missing",
|
|
omitted_candidates: 0,
|
|
candidates: expect.arrayContaining([
|
|
expect.objectContaining({ export_name: "first" }),
|
|
expect.objectContaining({ export_name: "second" }),
|
|
expect.objectContaining({ export_name: "third" }),
|
|
]),
|
|
});
|
|
|
|
const parsers = await sourceOwnedParsers();
|
|
const variants = compare(parsers[0], parsers[1]);
|
|
expect(variants.coverage.omitted_left_variants).toBe(0);
|
|
expect(variants.coverage.omitted_right_variants).toBe(0);
|
|
|
|
const changes = await analyzeSources({
|
|
left: `export default () => ({ type: "item" });`,
|
|
right: `export default () => ({ type: "item", depth: 1, level: 2 });`,
|
|
});
|
|
const complete = compare(changes[0], changes[1]);
|
|
expect(complete.summary.added).toBe(2);
|
|
expect(complete.changes).toHaveLength(2);
|
|
expect(complete.coverage.omitted_changes).toBe(0);
|
|
});
|
|
|
|
it("refuses an exact selector that resolves to multiple graph nodes", async () => {
|
|
const [left, right] = await analyzeSources({
|
|
left: `export default () => ({ type: "item" });`,
|
|
right: `export default () => ({ type: "item" });`,
|
|
});
|
|
const exported = left.graph.nodes.find((node) =>
|
|
node.observations.some(
|
|
({ properties }) => properties.semantic_role === "export-binding",
|
|
),
|
|
);
|
|
if (
|
|
exported === undefined ||
|
|
exported.identity.strategy !== "artifact-local-key"
|
|
)
|
|
throw new Error("Expected one artifact-local export fixture node");
|
|
const duplicate = createJavaScriptApplicationNode({
|
|
kind: exported.kind,
|
|
identity: {
|
|
...exported.identity,
|
|
key: `${exported.identity.key}:duplicate`,
|
|
},
|
|
observations: exported.observations.map(
|
|
({ label, properties, evidence }) => ({ label, properties, evidence }),
|
|
),
|
|
});
|
|
const ambiguousGraph = createJavaScriptApplicationGraph({
|
|
schema: "JavaScriptApplicationGraph",
|
|
root_node_ids: left.graph.root_node_ids,
|
|
nodes: [...left.graph.nodes, duplicate],
|
|
edges: left.graph.edges,
|
|
coverage: left.graph.coverage,
|
|
limitations: left.graph.limitations,
|
|
});
|
|
const result = compare({ ...left, graph: ambiguousGraph }, right);
|
|
|
|
expect(result.left).toMatchObject({
|
|
status: "ambiguous",
|
|
selected_node_id: null,
|
|
candidates: [expect.any(Object), expect.any(Object)],
|
|
});
|
|
expect(result.changes).toEqual([
|
|
expect.objectContaining({ status: "unknown", path: "" }),
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("JavaScript export return-shape projection", () => {
|
|
it("retains more than 64 inferred return fields", async () => {
|
|
const properties = Array.from(
|
|
{ length: 70 },
|
|
(_, index) => `field${String(index)}: ${String(index)}`,
|
|
).join(",");
|
|
const root = await temporaryRoot();
|
|
await writeFile(
|
|
join(root, "parser.mjs"),
|
|
`export const parse = () => ({ ${properties} });`,
|
|
);
|
|
const analyzed = await analyzeGraph(root);
|
|
const returnShape = analyzed.graph.nodes
|
|
.flatMap(({ observations }) => observations)
|
|
.find(
|
|
({ properties: value }) =>
|
|
value.semantic_role === "export-return-shapes",
|
|
);
|
|
|
|
const shapes = returnShape?.properties.static_return_shapes;
|
|
expect(Array.isArray(shapes)).toBe(true);
|
|
if (!Array.isArray(shapes)) throw new TypeError("Missing return shapes");
|
|
const firstShape = shapes[0];
|
|
if (
|
|
firstShape === null ||
|
|
typeof firstShape !== "object" ||
|
|
Array.isArray(firstShape)
|
|
)
|
|
throw new TypeError("Missing first return shape");
|
|
expect(firstShape.fields).toHaveLength(70);
|
|
expect(returnShape?.properties.return_shape_coverage).toMatchObject({
|
|
omitted_fields: 0,
|
|
projection_complete: true,
|
|
});
|
|
});
|
|
|
|
it("returns every source property within the supplied inputs", async () => {
|
|
const properties = [
|
|
'a_type: "item"',
|
|
...Array.from(
|
|
{ length: 70 },
|
|
(_, index) => `field${String(index)}: ${String(index)}`,
|
|
),
|
|
].join(",");
|
|
const [left, right] = await analyzeSources({
|
|
left: `export default () => ({ ${properties} });`,
|
|
right: `export default () => ({ ${properties} });`,
|
|
});
|
|
const result = compare(left, right);
|
|
|
|
expect(result.coverage.status).toBe("complete-within-inputs");
|
|
expect(result.coverage.left_omitted_fields).toBe(0);
|
|
expect(result.coverage.right_omitted_fields).toBe(0);
|
|
});
|
|
});
|
|
|
|
type GraphSource = Awaited<ReturnType<typeof analyzeGraph>>;
|
|
|
|
const compare = (
|
|
left: GraphSource,
|
|
right: GraphSource,
|
|
options: {
|
|
readonly modulePath?: string;
|
|
readonly leftExportName?: string;
|
|
readonly rightExportName?: string;
|
|
} = {},
|
|
) =>
|
|
compareJavaScriptExportShapes({
|
|
left: {
|
|
evidenceId: left.evidence.evidence_id,
|
|
graph: left.graph,
|
|
modulePath: options.modulePath ?? "parser.mjs",
|
|
exportName: options.leftExportName ?? "default",
|
|
},
|
|
right: {
|
|
evidenceId: right.evidence.evidence_id,
|
|
graph: right.graph,
|
|
modulePath: options.modulePath ?? "parser.mjs",
|
|
exportName: options.rightExportName ?? "default",
|
|
},
|
|
});
|
|
|
|
const analyzeGraph = async (root: string) => {
|
|
const result = await analyzeJavaScriptApplication({
|
|
input_path: root,
|
|
});
|
|
if (!result.ok) throw result.error;
|
|
const parsed = parseApplicationGraphEvidence(result.value);
|
|
if (!parsed.ok) throw new Error("Analysis Evidence must parse");
|
|
return parsed.value;
|
|
};
|
|
|
|
const analyzeSources = async (
|
|
sources: {
|
|
readonly left: string;
|
|
readonly right: string;
|
|
},
|
|
fileName = "parser.mjs",
|
|
): Promise<[GraphSource, GraphSource]> => {
|
|
const root = await temporaryRoot();
|
|
const leftRoot = join(root, "left");
|
|
const rightRoot = join(root, "right");
|
|
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
|
await Promise.all([
|
|
writeFile(join(leftRoot, fileName), sources.left),
|
|
writeFile(join(rightRoot, fileName), sources.right),
|
|
]);
|
|
return Promise.all([analyzeGraph(leftRoot), analyzeGraph(rightRoot)]);
|
|
};
|
|
|
|
const inventoryProperties = (
|
|
result: ReturnType<typeof compareJavaScriptExportShapes>,
|
|
side: "left" | "right",
|
|
variantIndex: number,
|
|
): string[] => {
|
|
const inventory = result.property_inventories.find(
|
|
(entry) => entry.side === side && entry.variant_index === variantIndex,
|
|
);
|
|
if (inventory === undefined)
|
|
throw new Error(
|
|
`Missing ${side} property inventory ${String(variantIndex)}`,
|
|
);
|
|
return inventory.properties;
|
|
};
|
|
|
|
const untaggedSearchSource = (countKey: "count" | "total"): string =>
|
|
countKey === "count"
|
|
? `
|
|
export function search(items, q) {
|
|
const matches = items.filter((item) => item.includes(q));
|
|
return { matches, count: matches.length };
|
|
}
|
|
`
|
|
: `
|
|
export function search(items, q) {
|
|
const matches = items.filter((item) => item.includes(q));
|
|
return { matches, total: matches.length, query: String(q) };
|
|
}
|
|
`;
|
|
|
|
const taggedSearchSource = (fields: {
|
|
readonly count?: boolean;
|
|
readonly total?: boolean;
|
|
readonly query?: boolean;
|
|
}): string => {
|
|
const extra = [
|
|
fields.count === true ? "count: matches.length" : null,
|
|
fields.total === true ? "total: matches.length" : null,
|
|
fields.query === true ? "query: String(q)" : null,
|
|
]
|
|
.filter((field): field is string => field !== null)
|
|
.join(", ");
|
|
return `
|
|
export function search(items, q) {
|
|
const matches = items.filter((item) => item.includes(q));
|
|
return { kind: "results", matches${extra.length > 0 ? `, ${extra}` : ""} };
|
|
}
|
|
`;
|
|
};
|
|
|
|
const sourceOwnedParsers = async (): Promise<[GraphSource, GraphSource]> => {
|
|
const root = await temporaryRoot();
|
|
const leftRoot = join(root, "left");
|
|
const rightRoot = join(root, "right");
|
|
await Promise.all([mkdir(leftRoot), mkdir(rightRoot)]);
|
|
await Promise.all([
|
|
copyFile(
|
|
resolve("tests/fixtures/replay/parser.mjs"),
|
|
join(leftRoot, "parser.mjs"),
|
|
),
|
|
copyFile(
|
|
resolve("tests/fixtures/replay/parser-v2.mjs"),
|
|
join(rightRoot, "parser.mjs"),
|
|
),
|
|
]);
|
|
return Promise.all([analyzeGraph(leftRoot), analyzeGraph(rightRoot)]);
|
|
};
|
|
|
|
const temporaryRoot = (): Promise<string> =>
|
|
createTestTempDirectory("rea-export-shapes-");
|