inspector/clients/web/src/utils/jsonObjectDraft.ts
cliffhall fcdc65b289 fix(web): read the literal, not the parsed number; project cursors and tool args
Copilot's eighth pass, three comments, all real.

- The precision check now reads the **source text**. Inferring the literal from
  `String(value)` was wrong in both directions: it accepted every full-form
  integer at or above 1e21 (`1000000000000000000001` parses to `1e21` and is
  written back as `1e+21`), and refused `2^54`, which is written out in full
  and exactly representable. `hasImpreciseIntegerLiteral` scans the draft for
  numeric tokens — skipping strings, so a digit-string value is untouched — and
  compares `BigInt(literal)` against `BigInt(Number(literal))`, which is exact.

- `""` is now reported as a surviving cursor for `tools/list` only. It builds
  its params with `cursor !== undefined` and carries the empty string
  deliberately; the other four list adapters use a truthiness check and drop
  it, so the editor showed `{"cursor":""}` while `{}` went out. The asymmetry
  looks like a latent bug in those four, but this function's job is to describe
  what the dispatch does.

- `callTool` runs every string-valued argument through `convertToolParameters`,
  so `{"count":"2"}` against a numeric schema is sent as `{"count":2}`.
  `reshapedReplayParam` now takes the tool and detects that by running the same
  conversion and comparing, rather than restating its rules — the two cannot
  drift. `tools` is threaded to `ProtocolEntry` for it, optional throughout: an
  unknown tool skips the check rather than blocking a replay.

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>
2026-08-27 04:56:22 -04:00

146 lines
5.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { StrictJsonObject } from "@inspector/core/json/jsonUtils.js";
import { isSerializableJson } from "@inspector/core/json/jsonUtils.js";
/**
* What a JSON-object editor's draft text currently means: the object to emit,
* or why it cannot be emitted.
*/
export type JsonObjectDraft =
| { ok: true; value: StrictJsonObject }
| { ok: false; error: string };
/**
* A plain JSON object, i.e. not an array and not a scalar.
*
* The `StrictJsonObject` narrowing is sound because the only caller parses with
* `JSON.parse`, whose output cannot contain `undefined` — the value that
* separates the strict type from `JsonValue`.
*/
export function isJsonObject(value: unknown): value is StrictJsonObject {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
/**
* Whether the draft text writes a whole number out in full that `JSON.parse`
* cannot represent exactly.
*
* `isSerializableJson` catches a literal too large to represent at all —
* `1e400` parses to `Infinity` and writes back as `null`. This catches the
* quieter one: `{"id":9007199254740993}` parses to `…992`, and
* `{"id":1000000000000000000001}` parses to `1e21` and is written back as
* `1e+21`. Either way the editor shows digits the wire will not carry.
* Snowflake-style ids are the realistic case.
*
* It reads the **source text**, which is the only place the answer exists: the
* parsed number has already lost whatever it lost, so nothing about its value
* distinguishes `1000000000000000000001` from `1e21`. An earlier attempt
* inferred it from `String(value)` and was wrong in both directions — it
* refused `2^54` (written in full and exactly representable) and accepted every
* full-form literal at or above 1e21, where JS switches to exponent form.
*
* Only integer literals written out in full are checked. An exponent or a
* fraction is a double by nature and is sent as the double it parsed to, so
* nothing is lost between typing and sending — and `{"n":1e308}` stays
* acceptable, which `parseJsonObjectDraft`'s own tests pin.
*/
export function hasImpreciseIntegerLiteral(text: string): boolean {
for (const literal of jsonNumberLiterals(text)) {
if (!/^-?\d+$/.test(literal)) continue;
// BigInt on both sides, so the comparison is exact: `Number(literal)` is an
// integer-valued double, and converting it back shows what survived.
if (BigInt(literal) !== BigInt(Number(literal))) return true;
}
return false;
}
/** Matches one JSON number token, anchored where the scan currently stands. */
const JSON_NUMBER = /-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
/**
* The numeric literals of a JSON document, in source order, skipping anything
* inside a string.
*
* The string-skipping is the whole reason this is a scanner rather than a
* regex over the text: `{"id":"9007199254740993"}` carries those digits as a
* *string*, which is sent back exactly as written and must not be refused.
* Object keys are strings too, so they are skipped by the same rule.
*/
function* jsonNumberLiterals(text: string): Generator<string> {
let index = 0;
let inString = false;
while (index < text.length) {
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 === '"') {
inString = true;
index += 1;
continue;
}
if (char === "-" || (char >= "0" && char <= "9")) {
JSON_NUMBER.lastIndex = index;
const match = JSON_NUMBER.exec(text);
/* v8 ignore next -- the sticky pattern always matches from a digit or a
`-`, which is the only way this branch is entered. */
if (!match) break;
yield match[0];
index = JSON_NUMBER.lastIndex;
continue;
}
index += 1;
}
}
/** The message every draft parser uses for {@link hasRoundedInteger}. */
export const ROUNDED_INTEGER_ERROR =
"A whole number written out in full must be within ±(2^53 − 1) — a longer one loses digits when parsed, so a different value would be sent";
/**
* Read a JSON-object editor's draft text.
*
* Empty text is the object `{}` rather than an error — clearing the box is how
* "send nothing" is spelled, and an empty editor wearing a red error would read
* as broken.
*
* The two failure modes are reported separately because they are different
* mistakes: text that is not JSON at all is usually mid-edit, while text that
* is valid JSON but not an object (`[1,2]`, `"a"`, `42`) is a shape the caller
* cannot use no matter how finished it is.
*
* Lives in `utils` rather than beside its component because it is a pure
* transform — and because exporting it from the component's module trips
* `react-refresh/only-export-components`.
*/
export function parseJsonObjectDraft(text: string): JsonObjectDraft {
if (text.trim() === "") return { ok: true, value: {} };
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: "Not valid JSON — changes are not applied" };
}
if (!isJsonObject(parsed)) {
return {
ok: false,
error: "Must be a JSON object (`{ … }`) — changes are not applied",
};
}
// `JSON.parse` accepts numeric literals it cannot represent: `1e400` parses
// to `Infinity`, which `JSON.stringify` then writes as `null`. Accepting it
// here would show the user one value and send another.
if (!isSerializableJson(parsed)) {
return {
ok: false,
error:
"Numbers must be finite — a value like `1e400` overflows and would be sent as null",
};
}
if (hasImpreciseIntegerLiteral(text)) {
return { ok: false, error: ROUNDED_INTEGER_ERROR };
}
return { ok: true, value: parsed };
}