fix(web): detect duplicate object keys, which JSON.parse drops silently
Copilot's eleventh pass: three comments, one defect in three places.
`JSON.parse` accepts duplicate member names and keeps the last without
complaint, so `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` —
a document that renders, and submits, as *less* than it says. In the read-only
viewer that is the Inspector hiding what the server sent; in a draft it is the
editor showing more than the wire will carry. Nothing downstream could catch
it, because by the time `JSON.parse` returns the evidence is gone.
New `findDuplicateObjectKey` scans the source, scoped per object so
`{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine, and compares names by decoded
value so `"a"` and `"a"` collide as they should. It shares its
escape-handling with the number-literal scanner, which now reads strings
through the same helper rather than skipping them inline.
Wired into all four JSON-draft parsers and into the viewer's faithfulness
check, so the same document is refused, or shown unindented, everywhere.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
390e0c45ed
commit
1b28fc8218
8 changed files with 217 additions and 15 deletions
|
|
@ -116,6 +116,15 @@ describe("formatJson", () => {
|
||||||
expect(formatJson('{"limit":1e400}')).toBe('{"limit":1e400}');
|
expect(formatJson('{"limit":1e400}')).toBe('{"limit":1e400}');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The starkest instance of the same class: only the *second* `role` survives
|
||||||
|
// `JSON.parse`, so reindenting would show the reader less than the server
|
||||||
|
// sent.
|
||||||
|
it("leaves a payload alone when it names the same member twice", () => {
|
||||||
|
expect(formatJson('{"role":"user","role":"admin"}')).toBe(
|
||||||
|
'{"role":"user","role":"admin"}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
// The spelling of a number changes on a round trip and its *value* does not,
|
// The spelling of a number changes on a round trip and its *value* does not,
|
||||||
// so these still get indented: `0.10000000000000001` and `0.1` are the same
|
// so these still get indented: `0.10000000000000001` and `0.1` are the same
|
||||||
// double, and `1e2` and `100` are the same number.
|
// double, and `1e2` and `100` are the same number.
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,15 @@
|
||||||
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept
|
* Pure helpers backing the per-MIME dispatch in {@link ContentViewer}. Kept
|
||||||
* free of component dependencies so they can be unit-tested in isolation and
|
* free of component dependencies so they can be unit-tested in isolation and
|
||||||
* reused by the blob renderers (PDF / CSV / HTML) without dragging in React.
|
* reused by the blob renderers (PDF / CSV / HTML) without dragging in React.
|
||||||
* The two pure predicates imported below are the exception that proves it:
|
* The predicates imported below are the exception that proves it: plain
|
||||||
* plain functions over a string, shared so the viewer and the JSON editors
|
* functions over a string, shared so the viewer and the JSON editors cannot
|
||||||
* cannot disagree about which payloads survive a round trip.
|
* disagree about which payloads survive a round trip.
|
||||||
*/
|
*/
|
||||||
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
|
||||||
import { hasImpreciseIntegerLiteral } from "../../../utils/jsonObjectDraft";
|
import {
|
||||||
|
findDuplicateObjectKey,
|
||||||
|
hasImpreciseIntegerLiteral,
|
||||||
|
} from "../../../utils/jsonObjectDraft";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The renderer family a MIME type maps to. `ContentViewer` switches on this to
|
* The renderer family a MIME type maps to. `ContentViewer` switches on this to
|
||||||
|
|
@ -160,11 +163,18 @@ export function formatJson(content: string): string {
|
||||||
* as it arrived — unindented, but honest. The copy button already copied the
|
* as it arrived — unindented, but honest. The copy button already copied the
|
||||||
* original text either way; this makes what is on screen agree with it.
|
* original text either way; this makes what is on screen agree with it.
|
||||||
*
|
*
|
||||||
* The same two checks the draft parsers use, which is why they live in
|
* The same checks the draft parsers use, which is why they live in
|
||||||
* `utils/jsonObjectDraft` rather than beside either caller.
|
* `utils/jsonObjectDraft` rather than beside either caller.
|
||||||
*/
|
*/
|
||||||
function reformatsFaithfully(text: string, parsed: unknown): boolean {
|
function reformatsFaithfully(text: string, parsed: unknown): boolean {
|
||||||
return isSerializableJson(parsed) && !hasImpreciseIntegerLiteral(text);
|
return (
|
||||||
|
isSerializableJson(parsed) &&
|
||||||
|
!hasImpreciseIntegerLiteral(text) &&
|
||||||
|
// A duplicate member name is the same class and the starkest instance:
|
||||||
|
// `{"role":"user","role":"admin"}` would be *shown* as only the second,
|
||||||
|
// which is the viewer hiding what the server sent.
|
||||||
|
findDuplicateObjectKey(text) === null
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Heuristic: does this plain text (no MIME) look like a JSON document? */
|
/** Heuristic: does this plain text (no MIME) look like a JSON document? */
|
||||||
|
|
|
||||||
|
|
@ -265,6 +265,15 @@ describe("EditReplayModal", () => {
|
||||||
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
expect(screen.getByRole("button", { name: "Send" })).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `JSON.parse` keeps only the last occurrence, so the draft would show two
|
||||||
|
// cursors and Send would dispatch one.
|
||||||
|
it("disables Send for a document that names the same member twice", async () => {
|
||||||
|
renderWithMantine(<EditReplayModal {...baseProps} method="tools/list" />);
|
||||||
|
await setAceText('{"cursor":"first","cursor":"second"}');
|
||||||
|
expect(screen.getByRole("button", { name: "Send" })).toBeDisabled();
|
||||||
|
expect(screen.getByText(/`cursor` appears twice/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("closes without sending when cancelled", async () => {
|
it("closes without sending when cancelled", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@ import type { Tool } from "@modelcontextprotocol/client";
|
||||||
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
|
||||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||||
import {
|
import {
|
||||||
|
duplicateKeyError,
|
||||||
|
findDuplicateObjectKey,
|
||||||
hasImpreciseIntegerLiteral,
|
hasImpreciseIntegerLiteral,
|
||||||
isJsonObject,
|
isJsonObject,
|
||||||
IMPRECISE_INTEGER_ERROR,
|
IMPRECISE_INTEGER_ERROR,
|
||||||
|
|
@ -114,6 +116,10 @@ function parseParamsDraft(
|
||||||
if (hasImpreciseIntegerLiteral(text)) {
|
if (hasImpreciseIntegerLiteral(text)) {
|
||||||
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
||||||
}
|
}
|
||||||
|
const duplicate = findDuplicateObjectKey(text);
|
||||||
|
if (duplicate !== null) {
|
||||||
|
return { ok: false, error: duplicateKeyError(duplicate) };
|
||||||
|
}
|
||||||
// Seeding from the projection is only half of it: a key the dispatcher does
|
// Seeding from the projection is only half of it: a key the dispatcher does
|
||||||
// not read can also be *added* to the draft, and Send would then discard an
|
// not read can also be *added* to the draft, and Send would then discard an
|
||||||
// edit the user was looking at. Checked with the same function the seed is
|
// edit the user was looking at. Checked with the same function the seed is
|
||||||
|
|
|
||||||
|
|
@ -792,6 +792,9 @@ describe("SchemaForm", () => {
|
||||||
screen.getByText(/^This whole number cannot be represented exactly/),
|
screen.getByText(/^This whole number cannot be represented exactly/),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
|
await setAceTextByLabel(/Config/, '[{"a":1,"a":2}]');
|
||||||
|
expect(screen.getByText(/^`a` appears twice/)).toBeInTheDocument();
|
||||||
|
|
||||||
await setAceTextByLabel(/Config/, "[1,2]");
|
await setAceTextByLabel(/Config/, "[1,2]");
|
||||||
expect(screen.queryByText(/this field will be omitted/)).toBeNull();
|
expect(screen.queryByText(/this field will be omitted/)).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
@ -1978,6 +1981,22 @@ describe("SchemaForm raw JSON (#2151)", () => {
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `JSON.parse` keeps only the last occurrence, so the editor would show a
|
||||||
|
// document that submits as less than it says.
|
||||||
|
it("blocks submission for a document that names the same member twice", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const onValidityChange = vi.fn();
|
||||||
|
renderWithMantine(<RawHarness onValidityChange={onValidityChange} />);
|
||||||
|
await enableRawJson(user);
|
||||||
|
|
||||||
|
await setAceTextByLabel(
|
||||||
|
/Arguments JSON/,
|
||||||
|
'{"mode":"safe","mode":"unsafe"}',
|
||||||
|
);
|
||||||
|
expect(onValidityChange).toHaveBeenLastCalledWith(true);
|
||||||
|
expect(screen.getByText(/`mode` appears twice/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
// Nothing in `values` names a branch, so the picker's index is held by the
|
// Nothing in `values` names a branch, so the picker's index is held by the
|
||||||
// form. Editing the discriminator in the raw document is a change to which
|
// form. Editing the discriminator in the raw document is a change to which
|
||||||
// branch is in effect, and it carries no other signal — without the re-derive
|
// branch is in effect, and it carries no other signal — without the re-derive
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import { ClearButton } from "../../elements/ClearButton/ClearButton";
|
||||||
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
|
import { EnlargeButton } from "../../elements/EnlargeButton/EnlargeButton";
|
||||||
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
import { JsonEditor } from "../../elements/JsonEditor/JsonEditor";
|
||||||
import {
|
import {
|
||||||
|
duplicateKeyError,
|
||||||
|
findDuplicateObjectKey,
|
||||||
hasImpreciseIntegerLiteral,
|
hasImpreciseIntegerLiteral,
|
||||||
isJsonObject,
|
isJsonObject,
|
||||||
IMPRECISE_INTEGER_ERROR,
|
IMPRECISE_INTEGER_ERROR,
|
||||||
|
|
@ -332,6 +334,10 @@ function describeJsonDraftProblem(text: string): string | null {
|
||||||
if (hasImpreciseIntegerLiteral(text)) {
|
if (hasImpreciseIntegerLiteral(text)) {
|
||||||
return `${IMPRECISE_INTEGER_ERROR} — this field will be omitted`;
|
return `${IMPRECISE_INTEGER_ERROR} — this field will be omitted`;
|
||||||
}
|
}
|
||||||
|
const duplicate = findDuplicateObjectKey(text);
|
||||||
|
if (duplicate !== null) {
|
||||||
|
return `${duplicateKeyError(duplicate)} — this field will be omitted`;
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -649,6 +655,10 @@ function parseRawArgumentsDraft(
|
||||||
if (hasImpreciseIntegerLiteral(text)) {
|
if (hasImpreciseIntegerLiteral(text)) {
|
||||||
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
||||||
}
|
}
|
||||||
|
const duplicate = findDuplicateObjectKey(text);
|
||||||
|
if (duplicate !== null) {
|
||||||
|
return { ok: false, error: duplicateKeyError(duplicate) };
|
||||||
|
}
|
||||||
return { ok: true, value: parsed };
|
return { ok: true, value: parsed };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import {
|
import {
|
||||||
|
findDuplicateObjectKey,
|
||||||
hasImpreciseIntegerLiteral,
|
hasImpreciseIntegerLiteral,
|
||||||
parseJsonObjectDraft,
|
parseJsonObjectDraft,
|
||||||
} from "./jsonObjectDraft";
|
} from "./jsonObjectDraft";
|
||||||
|
|
@ -70,6 +71,51 @@ describe("hasImpreciseIntegerLiteral", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `JSON.parse` accepts duplicate member names and keeps the last silently, so
|
||||||
|
// the evidence is gone by the time it returns — nothing downstream can see that
|
||||||
|
// the document said more than the value carries.
|
||||||
|
describe("findDuplicateObjectKey", () => {
|
||||||
|
it("names the repeated member", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"role":"user","role":"admin"}')).toBe(
|
||||||
|
"role",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a document whose names are all distinct", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"a":1,"b":2}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
// The repeat has to be within one set of braces.
|
||||||
|
it("scopes the check per object", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"a":{"a":1}}')).toBeNull();
|
||||||
|
expect(findDuplicateObjectKey('[{"a":1},{"a":2}]')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("finds a repeat nested inside another object or an array", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"outer":{"a":1,"a":2}}')).toBe("a");
|
||||||
|
expect(findDuplicateObjectKey('[{"a":1,"a":2}]')).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compared by decoded value: `"a"` and `"\u0061"` name the same member, so
|
||||||
|
// comparing the raw source would miss a duplicate written the second way.
|
||||||
|
it("compares names by value, not by spelling", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"a":1,"\\u0061":2}')).toBe("a");
|
||||||
|
});
|
||||||
|
|
||||||
|
// A string *value* that repeats is not a duplicate name, and a name that
|
||||||
|
// merely appears inside one is not either.
|
||||||
|
it("does not confuse values with names", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"a":"x","b":"x"}')).toBeNull();
|
||||||
|
expect(findDuplicateObjectKey('{"a":"b","c":1}')).toBeNull();
|
||||||
|
expect(findDuplicateObjectKey('{"a":"}\\"a\\":1{","b":2}')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says nothing about a half-typed draft", () => {
|
||||||
|
expect(findDuplicateObjectKey('{"a":1,"a')).toBeNull();
|
||||||
|
expect(findDuplicateObjectKey("")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("parseJsonObjectDraft", () => {
|
describe("parseJsonObjectDraft", () => {
|
||||||
it("reads empty text as the empty object rather than an error", () => {
|
it("reads empty text as the empty object rather than an error", () => {
|
||||||
expect(parseJsonObjectDraft("")).toEqual({ ok: true, value: {} });
|
expect(parseJsonObjectDraft("")).toEqual({ ok: true, value: {} });
|
||||||
|
|
@ -112,6 +158,12 @@ describe("parseJsonObjectDraft", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects a document that names the same member twice", () => {
|
||||||
|
const result = parseJsonObjectDraft('{"role":"user","role":"admin"}');
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
expect(result.ok === false && result.error).toMatch(/`role` appears twice/);
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects text that is not JSON", () => {
|
it("rejects text that is not JSON", () => {
|
||||||
expect(parseJsonObjectDraft('{"a":')).toEqual({
|
expect(parseJsonObjectDraft('{"a":')).toEqual({
|
||||||
ok: false,
|
ok: false,
|
||||||
|
|
|
||||||
|
|
@ -67,18 +67,10 @@ const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
||||||
*/
|
*/
|
||||||
function* jsonNumberLiterals(text: string): Generator<string> {
|
function* jsonNumberLiterals(text: string): Generator<string> {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let inString = false;
|
|
||||||
while (index < text.length) {
|
while (index < text.length) {
|
||||||
const char = text[index];
|
const char = text[index];
|
||||||
if (inString) {
|
|
||||||
// A backslash escapes the next character, including a closing quote.
|
|
||||||
index += char === "\\" ? 2 : 1;
|
|
||||||
if (char === '"') inString = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (char === '"') {
|
if (char === '"') {
|
||||||
inString = true;
|
index = readStringToken(text, index).next;
|
||||||
index += 1;
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (char === "-" || (char >= "0" && char <= "9")) {
|
if (char === "-" || (char >= "0" && char <= "9")) {
|
||||||
|
|
@ -95,6 +87,97 @@ function* jsonNumberLiterals(text: string): Generator<string> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read one JSON string token starting at the opening quote.
|
||||||
|
*
|
||||||
|
* Returns where the token ends and its raw source, escapes intact — the caller
|
||||||
|
* decodes only when it needs the *value*, which for a key it does: `"a"` and
|
||||||
|
* `"\u0061"` name the same member, so comparing the raw forms would miss a
|
||||||
|
* duplicate written the second way.
|
||||||
|
*
|
||||||
|
* Shared by both scanners here so the escape handling — a backslash consumes
|
||||||
|
* the next character, including a closing quote — is written once.
|
||||||
|
*/
|
||||||
|
function readStringToken(
|
||||||
|
text: string,
|
||||||
|
start: number,
|
||||||
|
): { raw: string; next: number } {
|
||||||
|
let index = start + 1;
|
||||||
|
while (index < text.length) {
|
||||||
|
const char = text[index];
|
||||||
|
if (char === "\\") {
|
||||||
|
index += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
if (char === '"') break;
|
||||||
|
}
|
||||||
|
return { raw: text.slice(start, index), next: index };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first object member name this document declares twice, or `null`.
|
||||||
|
*
|
||||||
|
* `JSON.parse` accepts duplicate names and keeps the last silently, so
|
||||||
|
* `{"role":"user","role":"admin"}` becomes `{"role":"admin"}` — a document that
|
||||||
|
* renders, and submits, as *less* than it says. In a read-only view that is the
|
||||||
|
* Inspector hiding what the server sent; in a draft it is the editor showing
|
||||||
|
* more than the wire will carry. Neither is acceptable, and nothing else here
|
||||||
|
* detects it, because by the time `JSON.parse` returns the evidence is gone.
|
||||||
|
*
|
||||||
|
* Scoped per object, so `{"a":{"a":1}}` and `[{"a":1},{"a":2}]` are fine — the
|
||||||
|
* repeat has to be within one set of braces.
|
||||||
|
*/
|
||||||
|
export function findDuplicateObjectKey(text: string): string | null {
|
||||||
|
// One frame per open brace or bracket; `null` marks an array, whose members
|
||||||
|
// are not named and so cannot collide.
|
||||||
|
const stack: (Set<string> | null)[] = [];
|
||||||
|
let expectKey = false;
|
||||||
|
let index = 0;
|
||||||
|
while (index < text.length) {
|
||||||
|
const char = text[index];
|
||||||
|
if (char === '"') {
|
||||||
|
const { raw, next } = readStringToken(text, index);
|
||||||
|
const frame = stack[stack.length - 1];
|
||||||
|
if (expectKey && frame) {
|
||||||
|
let name: string;
|
||||||
|
try {
|
||||||
|
name = JSON.parse(raw) as string;
|
||||||
|
} catch {
|
||||||
|
// An unterminated string at the end of a half-typed draft. There is
|
||||||
|
// no key here to compare, and the parse will report the real problem.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (frame.has(name)) return name;
|
||||||
|
frame.add(name);
|
||||||
|
expectKey = false;
|
||||||
|
}
|
||||||
|
index = next;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "{") {
|
||||||
|
stack.push(new Set());
|
||||||
|
expectKey = true;
|
||||||
|
} else if (char === "[") {
|
||||||
|
stack.push(null);
|
||||||
|
expectKey = false;
|
||||||
|
} else if (char === "}" || char === "]") {
|
||||||
|
stack.pop();
|
||||||
|
expectKey = false;
|
||||||
|
} else if (char === ",") {
|
||||||
|
// Only an object's separator introduces a name; an array's does not.
|
||||||
|
expectKey = stack[stack.length - 1] != null;
|
||||||
|
}
|
||||||
|
index += 1;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The message every draft parser uses for {@link findDuplicateObjectKey}. */
|
||||||
|
export function duplicateKeyError(key: string): string {
|
||||||
|
return `\`${key}\` appears twice in the same object — JSON keeps only the last, so part of this would not be sent`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The message every draft parser uses for {@link hasImpreciseIntegerLiteral}.
|
* The message every draft parser uses for {@link hasImpreciseIntegerLiteral}.
|
||||||
*
|
*
|
||||||
|
|
@ -148,5 +231,9 @@ export function parseJsonObjectDraft(text: string): JsonObjectDraft {
|
||||||
if (hasImpreciseIntegerLiteral(text)) {
|
if (hasImpreciseIntegerLiteral(text)) {
|
||||||
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
return { ok: false, error: IMPRECISE_INTEGER_ERROR };
|
||||||
}
|
}
|
||||||
|
const duplicate = findDuplicateObjectKey(text);
|
||||||
|
if (duplicate !== null) {
|
||||||
|
return { ok: false, error: duplicateKeyError(duplicate) };
|
||||||
|
}
|
||||||
return { ok: true, value: parsed };
|
return { ok: true, value: parsed };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue