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>
146 lines
5.7 KiB
TypeScript
146 lines
5.7 KiB
TypeScript
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 };
|
||
}
|