Skip to content

Commit b6c6842

Browse files
authored
fix(mcp): one issue per broken object, and a missing rotation still opens (#457)
1 parent 70aac9c commit b6c6842

6 files changed

Lines changed: 123 additions & 7 deletions

File tree

‎packages/core/src/lib/designFile.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export type DesignFileError = "parse_error" | "invalid_schema" | "fn_slots_exhau
3232
export interface DesignFileFailure {
3333
ok: false;
3434
error: DesignFileError;
35-
/** Schema issues as "path: reason", capped. Only a schema failure carries them. */
35+
/** From schemaIssues. Only a schema failure carries them. */
3636
issues?: string[];
3737
}
3838

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { describe, it, expect } from "vitest";
2+
import { z } from "zod";
3+
import { schemaIssues } from "./schemaIssues";
4+
5+
const leaf = z.object({ id: z.string(), x: z.number(), y: z.number(), rotation: z.number(), props: z.object({ content: z.string() }) });
6+
const schema = z.object({
7+
label: z.object({ widthMm: z.number(), heightMm: z.number(), dpmm: z.number() }),
8+
pages: z.array(z.object({ objects: z.array(z.union([leaf, z.object({ id: z.string(), children: z.array(leaf) })])) })),
9+
});
10+
11+
const label = { widthMm: 1, heightMm: 1, dpmm: 1 };
12+
/** Four issues: id, x, y, props.content. */
13+
const broken = { id: 1, x: "a", y: "b", rotation: 0, props: { content: 1 } };
14+
15+
const issuesOf = (value: unknown) => {
16+
const parsed = schema.safeParse(value);
17+
if (parsed.success) throw new Error("fixture parses");
18+
return schemaIssues(parsed.error, "design");
19+
};
20+
const paths = (issues: string[]) => issues.map((line) => line.split(":")[0]);
21+
22+
describe("schemaIssues", () => {
23+
it("lists one cause per broken object first and admits what it cut", () => {
24+
const issues = issuesOf({ label: { ...label, widthMm: "wide" }, pages: [{ objects: [broken, broken, broken, broken, broken, broken] }] });
25+
expect(paths(issues)).toEqual([
26+
"label.widthMm",
27+
"pages.0.objects.0.id",
28+
"pages.0.objects.1.id",
29+
"pages.0.objects.2.id",
30+
"pages.0.objects.3.id",
31+
"and 20 more issues",
32+
]);
33+
});
34+
35+
it("names every broken object before any object's second field", () => {
36+
const issues = issuesOf({ label: { widthMm: "a", heightMm: "b", dpmm: "c" }, pages: [{ objects: [broken, broken] }] });
37+
expect(paths(issues)).toEqual([
38+
"label.widthMm",
39+
"pages.0.objects.0.id",
40+
"pages.0.objects.1.id",
41+
"label.heightMm",
42+
"label.dpmm",
43+
"and 6 more issues",
44+
]);
45+
});
46+
47+
it("keys a group's children as elements of their own", () => {
48+
const issues = issuesOf({ label, pages: [{ objects: [{ id: "g", children: [broken, broken] }] }] });
49+
expect(paths(issues)).toEqual([
50+
"pages.0.objects.0.children.0.id",
51+
"pages.0.objects.0.children.1.id",
52+
"pages.0.objects.0.children.0.x",
53+
"pages.0.objects.0.children.0.y",
54+
"pages.0.objects.0.children.0.props.content",
55+
"and 3 more issues",
56+
]);
57+
});
58+
59+
it("counts a single hidden issue in the singular", () => {
60+
const issues = issuesOf({ label: { ...label, widthMm: "a", heightMm: "b" }, pages: [{ objects: [broken] }] });
61+
expect(issues[5]).toBe("and 1 more issue");
62+
});
63+
64+
it("fills the free places with an object's other fields, without a marker", () => {
65+
const issues = issuesOf({ label, pages: [{ objects: [broken] }] });
66+
expect(paths(issues)).toEqual(["pages.0.objects.0.id", "pages.0.objects.0.x", "pages.0.objects.0.y", "pages.0.objects.0.props.content"]);
67+
});
68+
69+
it("names a top-level failure by its root", () => {
70+
const parsed = z.object({ a: z.number() }).safeParse(5);
71+
if (parsed.success) throw new Error("fixture parses");
72+
expect(schemaIssues(parsed.error, "design")).toEqual([expect.stringMatching(/^design: /)]);
73+
});
74+
});

‎packages/core/src/lib/schemaIssues.ts‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,26 @@ function flattenIssues(issues: readonly z.core.$ZodIssue[], prefix: readonly Pro
1616
});
1717
}
1818

19-
/** Schema issues as "path: reason", the first few. `root` names a failure at the top level. */
19+
/** The containing array element, else the parent object, so a broken one costs one slot, not one per field. */
20+
function issueKey(path: readonly PropertyKey[]): string {
21+
const lastIndex = path.reduce<number>((last, seg, i) => (typeof seg === "number" ? i : last), -1);
22+
return (lastIndex >= 0 ? path.slice(0, lastIndex + 1) : path.slice(0, -1)).map(String).join(".");
23+
}
24+
25+
/** Schema issues as "path: reason", the first few, one per element first. A cut list ends with how many it hides. */
2026
export function schemaIssues(error: z.ZodError, root: string): string[] {
21-
return flattenIssues(error.issues)
22-
.slice(0, MAX_ISSUES)
23-
.map(({ path, message }) => `${path.map(String).join(".") || root}: ${message}`);
27+
const all = flattenIssues(error.issues);
28+
const seen = new Set<string>();
29+
const firsts: typeof all = [];
30+
const rest: typeof all = [];
31+
for (const issue of all) {
32+
const key = issueKey(issue.path);
33+
(seen.has(key) ? rest : firsts).push(issue);
34+
seen.add(key);
35+
}
36+
const ordered = [...firsts, ...rest];
37+
const shown = ordered.slice(0, MAX_ISSUES).map(({ path, message }) => `${path.map(String).join(".") || root}: ${message}`);
38+
const hidden = all.length - shown.length;
39+
if (hidden === 0) return shown;
40+
return [...shown, hidden === 1 ? "and 1 more issue" : `and ${hidden} more issues`];
2441
}

‎packages/core/src/types/LabelObject.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ export const labelObjectBaseSchema = z.object({
66
type: z.string(),
77
x: z.number(),
88
y: z.number(),
9-
rotation: z.number(),
9+
// props.rotation drives orientation, so a file that omits or mangles this still opens.
10+
rotation: z.number().default(0).catch(0),
1011
/** 'FT' = field typeset (baseline), 'FO' = field origin (top-left). Defaults to 'FO'. */
1112
positionType: z.enum(['FO', 'FT']).optional(),
1213
/** ^FO/^FT z-justification (spec p.201/205): 'R' = right edge (z=1), absent

‎packages/mcp-server/src/openInApp.test.ts‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
55
import { buildServer } from "./server";
66
import { openInApp } from "./tools";
77
import { resolveDraftReceipt } from "./appBridge";
8-
import { designFile } from "./testFixtures";
8+
import { designFile, textObject } from "./testFixtures";
99
import { CURRENT_DESIGN_SCHEMA_VERSION } from "@zplab/core/lib/designFile";
1010

1111
async function connect(server: ReturnType<typeof buildServer>): Promise<Client> {
@@ -94,6 +94,13 @@ describe("openInApp validation", () => {
9494
expect(result.designFile).toEqual({ ...designFile, schemaVersion: CURRENT_DESIGN_SCHEMA_VERSION });
9595
});
9696

97+
it("accepts an envelope built the way get_schema describes, without a top-level rotation", () => {
98+
const withoutRotation = JSON.parse(JSON.stringify(designFile)) as { pages: { objects: { rotation?: unknown }[] }[] };
99+
for (const o of withoutRotation.pages[0]!.objects) delete o.rotation;
100+
withoutRotation.pages[0]!.objects.push({ ...textObject("n", "north"), rotation: "N" });
101+
expect(openInApp(withoutRotation).ok).toBe(true);
102+
});
103+
97104
it("returns errors for a malformed design file", () => {
98105
const result = openInApp({ schemaVersion: 3, label: { widthMm: 10 } });
99106
expect(result.ok).toBe(false);

‎src/lib/designFile.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,23 @@ describe('parseDesignFile', () => {
654654
expect(result.value.pages[0]?.objects).toEqual(designWithGroups);
655655
});
656656

657+
it('opens an object whose top-level rotation is missing or mangled, as 0', () => {
658+
const text = JSON.stringify({
659+
schemaVersion: 6,
660+
label: { widthMm: 100, heightMm: 60, dpmm: 8 },
661+
pages: [{ objects: [
662+
{ id: 'a', type: 'text', x: 0, y: 0, props: { content: 'x', fontHeight: 30, fontWidth: 0, rotation: 'R' } },
663+
{ id: 'b', type: 'text', x: 0, y: 0, rotation: 'N', props: { content: 'y', fontHeight: 30, fontWidth: 0, rotation: 'N' } },
664+
] }],
665+
});
666+
const result = parseDesignFile(text);
667+
expect(result.ok).toBe(true);
668+
if (!result.ok) return;
669+
expect(result.value.pages[0]?.objects.map((o) => o.rotation)).toEqual([0, 0]);
670+
expect((result.value.pages[0]?.objects[0] as { props: { rotation: string } }).props.rotation).toBe('R');
671+
expect(JSON.parse(serializeDesign(result.value.label, result.value.pages)).pages[0].objects[0].rotation).toBe(0);
672+
});
673+
657674
it('rejects a leaf object that is missing its props', () => {
658675
const malformed = JSON.stringify({
659676
label: { widthMm: 100, heightMm: 60, dpmm: 8 },

0 commit comments

Comments
 (0)