Merge pull request #698 from kaoru0822-kitauji/fix/export-container-coverage
fix(comparison): retain container-only export shape paths
This commit is contained in:
commit
a55b5bb8ee
|
|
@ -249,7 +249,12 @@ const diffPair = (
|
|||
): JavaScriptExportShapeComparisonChange[] => {
|
||||
const leftFields = new Map(left.fields.map((field) => [field.path, field]));
|
||||
const rightFields = new Map(right.fields.map((field) => [field.path, field]));
|
||||
const paths = uniqueSorted([...leftFields.keys(), ...rightFields.keys()]);
|
||||
const paths = uniqueSorted([
|
||||
...leftFields.keys(),
|
||||
...rightFields.keys(),
|
||||
...left.property_coverage.map(({ path }) => path),
|
||||
...right.property_coverage.map(({ path }) => path),
|
||||
]);
|
||||
return paths.flatMap((path) => {
|
||||
const leftField = fieldAtPath(left, leftFields, path);
|
||||
const rightField = fieldAtPath(right, rightFields, path);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,285 @@
|
|||
import { join } from "node:path";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseApplicationGraphEvidence } from "../../../src/application/JavaScriptApplicationEvidenceGraph.js";
|
||||
import { analyzeJavaScriptApplication } from "../../../src/application/JavaScriptApplicationService.js";
|
||||
import { compareJavaScriptExportShapesEvidence } from "../../../src/application/JavaScriptApplicationWorkflowService.js";
|
||||
import { parseEvidence } from "../../../src/domain/evidence.js";
|
||||
import {
|
||||
javaScriptExportShapeComparisonResultSchema,
|
||||
projectedExportReturnShapesSchema,
|
||||
} from "../../../src/domain/javascriptExportShapeComparisonSchemas.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
|
||||
|
||||
describe("JavaScript export container coverage through authenticated Evidence", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "empty object",
|
||||
before: "",
|
||||
after: ", options: {}",
|
||||
paths: ["/options"],
|
||||
},
|
||||
{
|
||||
name: "empty array",
|
||||
before: "",
|
||||
after: ", options: []",
|
||||
paths: ["/options"],
|
||||
},
|
||||
{
|
||||
name: "nested object",
|
||||
before: ", options: {}",
|
||||
after: ", options: { nested: {} }",
|
||||
paths: ["/options/nested"],
|
||||
},
|
||||
{
|
||||
name: "nested array",
|
||||
before: ", options: []",
|
||||
after: ", options: [[]]",
|
||||
paths: ["/options/0"],
|
||||
},
|
||||
{
|
||||
name: "container subtree",
|
||||
before: "",
|
||||
after: ", options: { nested: {} }",
|
||||
paths: ["/options", "/options/nested"],
|
||||
},
|
||||
{
|
||||
name: "escaped property keys",
|
||||
before: ', "a/b~c": {}',
|
||||
after: ', "a/b~c": { "d/e~f": [] }',
|
||||
paths: ["/a~1b~0c/d~1e~0f"],
|
||||
},
|
||||
])(
|
||||
"reports absent versus $name as unknown in both directions",
|
||||
async ({ before, after, paths }) => {
|
||||
const [absent, retained] = await analyzeReturns(before, after);
|
||||
for (const path of paths) {
|
||||
expect(
|
||||
absent.shape.property_coverage.some(
|
||||
(coverage) => coverage.path === path,
|
||||
),
|
||||
).toBe(false);
|
||||
expect(retained.shape.property_coverage).toContainEqual({
|
||||
path,
|
||||
status: "complete",
|
||||
omitted: 0,
|
||||
});
|
||||
expect(retained.shape.fields.some((field) => field.path === path)).toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
for (const [left, right, leftAvailability, rightAvailability] of [
|
||||
[absent, retained, "absent", "unknown"],
|
||||
[retained, absent, "unknown", "absent"],
|
||||
] as const) {
|
||||
const result = compareEvidence(left, right);
|
||||
expect(result.summary).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unknown: paths.length,
|
||||
});
|
||||
expect(result.changes).toHaveLength(paths.length);
|
||||
expect(result.changes).toEqual(
|
||||
expect.arrayContaining(
|
||||
paths.map((path) =>
|
||||
expect.objectContaining({
|
||||
path,
|
||||
status: "unknown",
|
||||
discriminant: { path: "/type", value: "item" },
|
||||
left: expect.objectContaining({
|
||||
availability: leftAvailability,
|
||||
}),
|
||||
right: expect.objectContaining({
|
||||
availability: rightAvailability,
|
||||
}),
|
||||
left_source_range: left.shape.source_range,
|
||||
right_source_range: right.shape.source_range,
|
||||
evidence_links: [
|
||||
left.evidence.evidence_id,
|
||||
right.evidence.evidence_id,
|
||||
],
|
||||
limitations: [
|
||||
"The static value or relevant parent-property coverage is incomplete.",
|
||||
],
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(result.coverage).toMatchObject({
|
||||
status: "partial",
|
||||
paired_variants: 1,
|
||||
unpaired_left_variants: 0,
|
||||
unpaired_right_variants: 0,
|
||||
omitted_changes: 0,
|
||||
});
|
||||
expect(compareEvidence(left, right)).toEqual(result);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("JavaScript export container coverage controls", () => {
|
||||
it.each(["{}", "[]", "{ nested: {} }", "[[]]"])(
|
||||
"keeps identical %s containers and primitive fields unchanged",
|
||||
async (container) => {
|
||||
const properties = `, options: ${container}, enabled: false`;
|
||||
const sources = await analyzeReturns(properties, properties);
|
||||
const result = compareEvidence(...sources);
|
||||
expect(result.changes).toEqual([]);
|
||||
expect(result.summary).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unknown: 0,
|
||||
});
|
||||
expect(result.coverage.status).toBe("complete-within-inputs");
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ before: "", after: ", enabled: false", status: "added" },
|
||||
{ before: ", enabled: false", after: "", status: "removed" },
|
||||
{ before: ", enabled: false", after: ", enabled: true", status: "changed" },
|
||||
])(
|
||||
"preserves primitive $status beside an unchanged container",
|
||||
async ({ before, after, status }) => {
|
||||
const sources = await analyzeReturns(
|
||||
`, options: {}${before}`,
|
||||
`, options: {}${after}`,
|
||||
);
|
||||
const result = compareEvidence(...sources);
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({ path: "/enabled", status }),
|
||||
]);
|
||||
expect(result.summary).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unknown: 0,
|
||||
[status]: 1,
|
||||
});
|
||||
expect(result.coverage.status).toBe("complete-within-inputs");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["{}", "[]", "false"])(
|
||||
"keeps a %s leaf unknown when the opposite parent has partial coverage",
|
||||
async (leaf) => {
|
||||
const [partial, complete] = await analyzeReturns(
|
||||
", options: { ...dynamic }",
|
||||
`, options: { nested: ${leaf} }`,
|
||||
);
|
||||
expect(partial.shape.property_coverage).toContainEqual({
|
||||
path: "/options",
|
||||
status: "partial",
|
||||
omitted: null,
|
||||
});
|
||||
for (const sources of [
|
||||
[partial, complete],
|
||||
[complete, partial],
|
||||
] as const) {
|
||||
const result = compareEvidence(...sources);
|
||||
expect(result.changes).toEqual([
|
||||
expect.objectContaining({
|
||||
path: "/options/nested",
|
||||
status: "unknown",
|
||||
}),
|
||||
]);
|
||||
expect(result.summary).toEqual({
|
||||
added: 0,
|
||||
removed: 0,
|
||||
changed: 0,
|
||||
unknown: 1,
|
||||
});
|
||||
expect(result.coverage.status).toBe("partial");
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
type Source = Awaited<ReturnType<typeof analyzeReturn>>;
|
||||
|
||||
const analyzeReturns = async (
|
||||
left: string,
|
||||
right: string,
|
||||
): Promise<[Source, Source]> => {
|
||||
const root = await createTestTempDirectory("rea-export-container-coverage-");
|
||||
return Promise.all([
|
||||
analyzeReturn(join(root, "left"), left),
|
||||
analyzeReturn(join(root, "right"), right),
|
||||
]);
|
||||
};
|
||||
|
||||
const analyzeReturn = async (root: string, properties: string) => {
|
||||
const expression = `{ type: "item"${properties} }`;
|
||||
const prefix = "export default () => (";
|
||||
await writeFixtureFiles(root, { "parser.mjs": `${prefix}${expression});\n` });
|
||||
const analyzed = await analyzeJavaScriptApplication({ input_path: root });
|
||||
if (!analyzed.ok) throw analyzed.error;
|
||||
const source = parseApplicationGraphEvidence(analyzed.value);
|
||||
const observation = source.graph.nodes
|
||||
.flatMap(({ observations }) => observations)
|
||||
.find(
|
||||
({ properties: value }) => value.semantic_role === "export-return-shapes",
|
||||
);
|
||||
const projection = projectedExportReturnShapesSchema.parse(
|
||||
observation?.properties,
|
||||
);
|
||||
expect(projection).toMatchObject({
|
||||
module_path: "parser.mjs",
|
||||
exported_name: "default",
|
||||
});
|
||||
expect(projection.static_return_shapes).toHaveLength(1);
|
||||
const shape = projection.static_return_shapes[0];
|
||||
if (shape === undefined) throw new TypeError("Missing fixture return shape");
|
||||
expect(shape.source_range).toEqual({
|
||||
start: { line: 1, column: prefix.length },
|
||||
end: { line: 1, column: prefix.length + expression.length },
|
||||
});
|
||||
return { ...source, shape };
|
||||
};
|
||||
|
||||
const compareEvidence = (left: Source, right: Source) => {
|
||||
const compared = compareJavaScriptExportShapesEvidence({
|
||||
left: left.evidence,
|
||||
right: right.evidence,
|
||||
left_module_path: "parser.mjs",
|
||||
left_export_name: "default",
|
||||
right_module_path: "parser.mjs",
|
||||
right_export_name: "default",
|
||||
});
|
||||
if (!compared.ok) throw compared.error;
|
||||
const evidence = parseEvidence(compared.value);
|
||||
const result = javaScriptExportShapeComparisonResultSchema.parse(
|
||||
evidence.normalized_result,
|
||||
);
|
||||
const evidenceLinks = [left.evidence.evidence_id, right.evidence.evidence_id];
|
||||
expect(evidence).toMatchObject({
|
||||
operation: "compare_javascript_export_shapes",
|
||||
predicate_type: "rea.javascript-export-shape-comparison",
|
||||
authority: "analyst-inference",
|
||||
confidence: "inferred",
|
||||
evidence_links: evidenceLinks,
|
||||
});
|
||||
expect(result.evidence_links).toEqual(evidenceLinks);
|
||||
for (const [selected, source] of [
|
||||
[result.left, left],
|
||||
[result.right, right],
|
||||
] as const) {
|
||||
expect(selected).toMatchObject({
|
||||
status: "selected",
|
||||
evidence_id: source.evidence.evidence_id,
|
||||
graph_id: source.graph.graph_id,
|
||||
});
|
||||
expect(
|
||||
source.graph.nodes.some(
|
||||
({ node_id }) => node_id === selected.selected_node_id,
|
||||
),
|
||||
).toBe(true);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
Loading…
Reference in New Issue