@tormentalabs/claude-code-wire-compat 0.5.0 → 0.7.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/README.md +19 -7
  3. package/dist/betas.d.ts +65 -7
  4. package/dist/betas.d.ts.map +1 -1
  5. package/dist/betas.js +143 -2
  6. package/dist/betas.js.map +1 -1
  7. package/dist/build-request.d.ts +12 -6
  8. package/dist/build-request.d.ts.map +1 -1
  9. package/dist/build-request.js +93 -50
  10. package/dist/build-request.js.map +1 -1
  11. package/dist/contracts.d.ts +7 -2
  12. package/dist/contracts.d.ts.map +1 -1
  13. package/dist/contracts.js.map +1 -1
  14. package/dist/fingerprint.d.ts.map +1 -1
  15. package/dist/fingerprint.js +5 -0
  16. package/dist/fingerprint.js.map +1 -1
  17. package/dist/headers.d.ts.map +1 -1
  18. package/dist/headers.js +5 -2
  19. package/dist/headers.js.map +1 -1
  20. package/dist/index.d.ts +2 -0
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +2 -0
  23. package/dist/index.js.map +1 -1
  24. package/dist/limits.d.ts +3 -0
  25. package/dist/limits.d.ts.map +1 -0
  26. package/dist/limits.js +4 -0
  27. package/dist/limits.js.map +1 -0
  28. package/dist/model-capabilities.d.ts +3 -3
  29. package/dist/model-capabilities.d.ts.map +1 -1
  30. package/dist/model-capabilities.js +55 -19
  31. package/dist/model-capabilities.js.map +1 -1
  32. package/dist/model-identity.d.ts +9 -1
  33. package/dist/model-identity.d.ts.map +1 -1
  34. package/dist/model-identity.js +19 -1
  35. package/dist/model-identity.js.map +1 -1
  36. package/dist/model-queries.d.ts +17 -5
  37. package/dist/model-queries.d.ts.map +1 -1
  38. package/dist/model-queries.js +33 -6
  39. package/dist/model-queries.js.map +1 -1
  40. package/dist/models.js +1 -1
  41. package/dist/models.js.map +1 -1
  42. package/dist/profiles/beta-registry-2.1.280.d.ts +211 -0
  43. package/dist/profiles/beta-registry-2.1.280.d.ts.map +1 -0
  44. package/dist/profiles/beta-registry-2.1.280.js +292 -0
  45. package/dist/profiles/beta-registry-2.1.280.js.map +1 -0
  46. package/dist/profiles/claude-code-2.1.280.d.ts +3 -0
  47. package/dist/profiles/claude-code-2.1.280.d.ts.map +1 -0
  48. package/dist/profiles/claude-code-2.1.280.js +359 -0
  49. package/dist/profiles/claude-code-2.1.280.js.map +1 -0
  50. package/dist/redaction.d.ts.map +1 -1
  51. package/dist/redaction.js +48 -2
  52. package/dist/redaction.js.map +1 -1
  53. package/dist/request-body.d.ts +4 -2
  54. package/dist/request-body.d.ts.map +1 -1
  55. package/dist/request-body.js +77 -48
  56. package/dist/request-body.js.map +1 -1
  57. package/dist/system-prompt.d.ts.map +1 -1
  58. package/dist/system-prompt.js +23 -22
  59. package/dist/system-prompt.js.map +1 -1
  60. package/dist/thinking.d.ts +53 -2
  61. package/dist/thinking.d.ts.map +1 -1
  62. package/dist/thinking.js +124 -26
  63. package/dist/thinking.js.map +1 -1
  64. package/dist/unicode.d.ts +41 -0
  65. package/dist/unicode.d.ts.map +1 -1
  66. package/dist/unicode.js +37 -0
  67. package/dist/unicode.js.map +1 -1
  68. package/dist/violation.d.ts +23 -0
  69. package/dist/violation.d.ts.map +1 -0
  70. package/dist/violation.js +210 -0
  71. package/dist/violation.js.map +1 -0
  72. package/package.json +6 -2
  73. package/src/betas.ts +208 -9
  74. package/src/build-request.ts +120 -56
  75. package/src/contracts.ts +7 -2
  76. package/src/fingerprint.ts +5 -0
  77. package/src/headers.ts +4 -2
  78. package/src/index.ts +2 -0
  79. package/src/limits.ts +4 -0
  80. package/src/model-capabilities.ts +55 -19
  81. package/src/model-identity.ts +14 -1
  82. package/src/model-queries.ts +38 -6
  83. package/src/models.ts +1 -1
  84. package/src/profiles/beta-registry-2.1.280.ts +309 -0
  85. package/src/profiles/claude-code-2.1.280.ts +364 -0
  86. package/src/redaction.ts +65 -2
  87. package/src/request-body.ts +107 -49
  88. package/src/system-prompt.ts +32 -24
  89. package/src/thinking.ts +148 -26
  90. package/src/unicode.ts +71 -0
  91. package/src/violation.ts +226 -0
package/src/thinking.ts CHANGED
@@ -70,7 +70,12 @@ export interface ResolvedThinking {
70
70
  * suppresses `temperature`, matching upstream `nr`.
71
71
  */
72
72
  readonly requestActive: boolean;
73
- /** Whether `tool_choice` of type `tool` must be demoted to `auto`. */
73
+ /**
74
+ * Whether a forced `tool_choice` (type `tool` or `any`) must be demoted to
75
+ * `auto`. Upstream demotes only `tool`; demoting `any` as well is a
76
+ * deliberate divergence, because the API rejects any forced tool choice
77
+ * while extended thinking is on (see `MEMORY.md`, 2026-09-24).
78
+ */
74
79
  readonly extendedThinkingActive: boolean;
75
80
  }
76
81
 
@@ -275,6 +280,92 @@ export function isThinkingDisplayActive(
275
280
  );
276
281
  }
277
282
 
283
+ /**
284
+ * The three thinking types the resolver can emit, or `undefined` when no
285
+ * `thinking` object reaches the wire at all.
286
+ */
287
+ export type ResolvedThinkingType = "adaptive" | "enabled" | "disabled";
288
+
289
+ /**
290
+ * Narrows an unvalidated caller `thinking` value to its `type` literal.
291
+ *
292
+ * Deliberately tolerant of unvalidated input for the same reason
293
+ * `isThinkingDisplayActive` is: beta composition asks this question before
294
+ * `buildCanonicalBody` has validated the shape. Anything malformed answers
295
+ * `undefined` here and is rejected later by the body validator.
296
+ */
297
+ function thinkingRequestType(
298
+ request: unknown,
299
+ ): ThinkingRequest["type"] | undefined {
300
+ if (request === null || typeof request !== "object") return undefined;
301
+ const type = (request as Record<string, unknown>)["type"];
302
+ if (type === "enabled" || type === "adaptive" || type === "disabled") {
303
+ return type;
304
+ }
305
+ return undefined;
306
+ }
307
+
308
+ /**
309
+ * Decides WHICH thinking object `resolveThinking` will emit, without building
310
+ * it.
311
+ *
312
+ * Split out of `resolveThinking` so that beta composition and body emission
313
+ * answer the same question from one place. A second, independently written
314
+ * copy of this predicate is exactly how a beta header and the body field it
315
+ * is coupled to drift apart.
316
+ */
317
+ export function resolveThinkingType(
318
+ requestType: ThinkingRequest["type"] | undefined,
319
+ capabilities: ClaudeCodeCapabilities,
320
+ ): ResolvedThinkingType | undefined {
321
+ const requestActive = requestType !== undefined && requestType !== "disabled";
322
+ if (requestActive && capabilities.thinking) {
323
+ return capabilities.adaptiveThinking ? "adaptive" : "enabled";
324
+ }
325
+ if (
326
+ requestType === "disabled" &&
327
+ capabilities.thinking &&
328
+ !capabilities.rejectsDisabledThinking
329
+ ) {
330
+ return "disabled";
331
+ }
332
+ return undefined;
333
+ }
334
+
335
+ /**
336
+ * Upstream `ac = Kg && Fg() && iQt(model)`, the predicate the 2.1.280 thinking
337
+ * push sites gate on, with `Fg()` (`experimentalBetasEnabled`) factored OUT.
338
+ *
339
+ * Every push site conjoins the experimental gate itself, so folding it in here
340
+ * would count it twice and make the one site that legitimately does not want it
341
+ * impossible to express. `Kg`'s environment-disable term is not modelled: this
342
+ * package reads no environment.
343
+ *
344
+ * This is deliberately NOT `ResolvedThinking.requestActive`. That one is true
345
+ * whenever the caller asked for thinking at all, including for a model whose
346
+ * capabilities emit no thinking object — which would ship a thinking beta
347
+ * header for a body that carries no thinking block.
348
+ *
349
+ * Upstream `ac` conjoins only the interleaved predicate `iQt`; requiring a
350
+ * resolved `"adaptive"`/`"enabled"` type additionally requires
351
+ * `capabilities.thinking`, one conjunct more than upstream. That extra term is
352
+ * unobservable in practice: `supportsThinking` and `supportsInterleavedThinking`
353
+ * in `model-capabilities.ts` are the same expression, so no derived capability
354
+ * set separates them. Only an explicit caller capability override can, and then
355
+ * the package declines to announce a thinking beta for a request whose body
356
+ * will carry no thinking object.
357
+ */
358
+ export function isThinkingActive(
359
+ request: unknown,
360
+ capabilities: ClaudeCodeCapabilities,
361
+ ): boolean {
362
+ const type = resolveThinkingType(thinkingRequestType(request), capabilities);
363
+ return (
364
+ (type === "adaptive" || type === "enabled") &&
365
+ capabilities.interleavedThinking
366
+ );
367
+ }
368
+
278
369
  /**
279
370
  * Resolves the caller's thinking request into the object the genuine client
280
371
  * would put on the wire.
@@ -283,6 +374,13 @@ export function isThinkingDisplayActive(
283
374
  * for the enabled branch — `budget_tokens` FIRST — and `{type, display}` for
284
375
  * adaptive. Serialised bodies are compared byte for byte, so the insertion
285
376
  * order below must not be rearranged.
377
+ *
378
+ * `displayOverride` is the body-side half of beta push site 12b
379
+ * (`thinkingDisplayOverride` from `composeBetasWithAudit`); it is not
380
+ * caller-facing, which is why `ThinkingDisplay` stays unwidened. When both it
381
+ * and a caller display are present the override wins -- a combination the
382
+ * composition guard makes unreachable, since site 12b requires that the caller
383
+ * supplied no display.
286
384
  */
287
385
  export function resolveThinking(
288
386
  request: ThinkingRequest | undefined,
@@ -291,6 +389,7 @@ export function resolveThinking(
291
389
  betaPolicy: ClaudeCodeBetaPolicy,
292
390
  maxTokens: number,
293
391
  profile: ClaudeCodeProtocolProfile = CLAUDE_CODE_2_1_195_PROFILE,
392
+ displayOverride?: "updates",
294
393
  ): ResolvedThinking {
295
394
  // Upstream `nr = n.type !== "disabled" && !CLAUDE_CODE_DISABLE_THINKING`.
296
395
  const requestActive = request !== undefined && request.type !== "disabled";
@@ -301,38 +400,61 @@ export function resolveThinking(
301
400
  );
302
401
  const display = displayActive ? request?.display : undefined;
303
402
 
403
+ const resolvedType = resolveThinkingType(request?.type, capabilities);
404
+
304
405
  let emitted: Record<string, unknown> | undefined;
305
406
 
306
- if (requestActive && capabilities.thinking) {
307
- if (capabilities.adaptiveThinking) {
308
- emitted = { type: "adaptive" };
309
- if (display !== undefined) emitted["display"] = display;
310
- } else {
311
- // Upstream: `let Tr = wvi(u)` — the model's upper limit minus one —
312
- // overridden by the caller's budget when supplied, then clamped by
313
- // `Tr = Math.min(Fi - 1, Tr)` where `Fi` is the emitted `max_tokens`.
314
- //
315
- // This is the one wire-visible consumer of the request-derived bound: on
316
- // a 2.1.222+ profile a caller asking for a `max_tokens` above the
317
- // catalogue's upper limit seeds the default budget from THEIR number
318
- // minus one, not from the catalogue's.
319
- const requested =
320
- request.budgetTokens ??
321
- modelOutputTokenLimits(normalizedId, profile, maxTokens).upperLimit - 1;
322
- emitted = { budget_tokens: Math.min(maxTokens - 1, requested) };
323
- emitted["type"] = "enabled";
324
- if (display !== undefined) emitted["display"] = display;
325
- }
326
- } else if (
327
- request?.type === "disabled" &&
328
- capabilities.thinking &&
329
- !capabilities.rejectsDisabledThinking
330
- ) {
407
+ if (resolvedType === "adaptive") {
408
+ emitted = { type: "adaptive" };
409
+ if (display !== undefined) emitted["display"] = display;
410
+ // Assignment, not reconstruction: an existing key keeps its position and a
411
+ // new one appends last, matching upstream `{...yc, display: "updates"}`.
412
+ if (displayOverride !== undefined) emitted["display"] = displayOverride;
413
+ } else if (resolvedType === "enabled") {
414
+ // Transcribed from the 2.1.280 analysis document:
415
+ // let hf = mlo(_e);
416
+ // if (r.type === "enabled" && r.budgetTokens !== void 0) hf = r.budgetTokens;
417
+ // hf = Math.max(1024, Math.min(Rv - 1, hf));
418
+ // The default budget is the model's upper limit minus one. The caller's
419
+ // `budgetTokens` replaces it only when the caller itself declared an
420
+ // `enabled` request -- the guard reads the caller's raw `type`, not the
421
+ // resolved one, so an `adaptive` request downgraded to `enabled` on a
422
+ // model without adaptive thinking ignores its budget. The result is
423
+ // clamped to the emitted `max_tokens` minus one, and the floor of 1024 is
424
+ // applied after that clamp, so the floor wins when the two conflict.
425
+ //
426
+ // No older analysis document in this repository transcribes this
427
+ // computation at all, so the floor and the type guard are evidenced for
428
+ // 2.1.280 only. They are applied to every profile as one shared
429
+ // behaviour -- the same deliberate choice already made for the model-id
430
+ // normalizer ladder -- and nothing here asserts whether older clients
431
+ // had them.
432
+ //
433
+ // This is the one wire-visible consumer of the request-derived bound: on
434
+ // a 2.1.222+ profile a caller asking for a `max_tokens` above the
435
+ // catalogue's upper limit seeds the default budget from THEIR number
436
+ // minus one, not from the catalogue's.
437
+ const callerBudget =
438
+ request?.type === "enabled" ? request.budgetTokens : undefined;
439
+ const requested =
440
+ callerBudget ??
441
+ modelOutputTokenLimits(normalizedId, profile, maxTokens).upperLimit - 1;
442
+ emitted = {
443
+ budget_tokens: Math.max(1024, Math.min(maxTokens - 1, requested)),
444
+ };
445
+ emitted["type"] = "enabled";
446
+ if (display !== undefined) emitted["display"] = display;
447
+ // Same in-place assignment as the adaptive arm: order stays
448
+ // `budget_tokens, type, display`.
449
+ if (displayOverride !== undefined) emitted["display"] = displayOverride;
450
+ } else if (resolvedType === "disabled") {
331
451
  emitted = { type: "disabled" };
332
452
  }
333
453
 
334
454
  // Upstream `Jr = Xn?.type === "enabled" || Xn?.type === "adaptive"
335
455
  // || Xn === void 0 && U4e(u)`.
456
+ // Upstream uses this to demote `tool_choice` of type `tool`; the
457
+ // request body also demotes type `any` on it, a documented divergence.
336
458
  const extendedThinkingActive =
337
459
  emitted?.["type"] === "enabled" ||
338
460
  emitted?.["type"] === "adaptive" ||
package/src/unicode.ts CHANGED
@@ -22,3 +22,74 @@ export function classifySurrogateAt(
22
22
  if (unit >= 0xdc00 && unit <= 0xdfff) return "loneSurrogate";
23
23
  return "notSurrogate";
24
24
  }
25
+
26
+ export type TextViolationReason = "control-char" | "lone-surrogate";
27
+
28
+ /**
29
+ * One character-level policy violation found while screening a string.
30
+ *
31
+ * `offset` is the UTF-16 code-unit index of the offending unit and `codeUnit`
32
+ * is the unit itself. Both are safe diagnostics: offsets are numbers and the
33
+ * only code units ever reported are control characters or surrogate halves,
34
+ * never prose content.
35
+ */
36
+ export interface TextViolation {
37
+ readonly reason: TextViolationReason;
38
+ readonly offset: number;
39
+ readonly codeUnit: number;
40
+ }
41
+
42
+ /**
43
+ * Character-level acceptance policy for one string lane.
44
+ *
45
+ * `rejectControls` rejects every C0 control except TAB (0x09), LF (0x0A) and
46
+ * CR (0x0D), plus DEL (0x7F). Lone surrogates are always rejected, rather than
47
+ * depending on whether a caller serializes or directly encodes the string.
48
+ */
49
+ export interface TextPolicy {
50
+ readonly rejectControls: boolean;
51
+ }
52
+
53
+ /** Body prose: every well-formed UTF-16 string is accepted. */
54
+ export const TEXT_POLICY_PROSE: TextPolicy = Object.freeze({
55
+ rejectControls: false,
56
+ });
57
+
58
+ /**
59
+ * Identifier graph screening: C0 except TAB/LF/CR, plus DEL, plus lone
60
+ * surrogates. This is exactly the set the graph inspectors rejected before the
61
+ * shared validator existed.
62
+ */
63
+ export const TEXT_POLICY_IDENTIFIER: TextPolicy = Object.freeze({
64
+ rejectControls: true,
65
+ });
66
+
67
+ /**
68
+ * Screens one string under `policy`, returning the first violation or null.
69
+ *
70
+ * The walk order matches the historical per-module inspectors: at each index
71
+ * the control check runs before the surrogate check, and a well-formed pair
72
+ * consumes two code units. `classifySurrogateAt` is the single surrogate
73
+ * authority; this function never re-implements it.
74
+ */
75
+ export function inspectText(
76
+ value: string,
77
+ policy: TextPolicy,
78
+ ): TextViolation | null {
79
+ for (let index = 0; index < value.length; index += 1) {
80
+ const unit = value.charCodeAt(index);
81
+ if (
82
+ policy.rejectControls &&
83
+ ((unit <= 0x1f && unit !== 0x09 && unit !== 0x0a && unit !== 0x0d) ||
84
+ unit === 0x7f)
85
+ ) {
86
+ return { reason: "control-char", offset: index, codeUnit: unit };
87
+ }
88
+ const classification = classifySurrogateAt(value, index);
89
+ if (classification === "loneSurrogate") {
90
+ return { reason: "lone-surrogate", offset: index, codeUnit: unit };
91
+ }
92
+ if (classification === "surrogatePair") index += 1;
93
+ }
94
+ return null;
95
+ }
@@ -0,0 +1,226 @@
1
+ // SPDX-License-Identifier: GPL-3.0-or-later
2
+
3
+ import type { TextViolation } from "./unicode.js";
4
+
5
+ /*
6
+ * Safe validation-failure diagnostics (decision P1.T1, D4).
7
+ *
8
+ * Every value produced here is safe by construction: reasons come from a
9
+ * closed set, path segments from a closed vocabulary of schema-defined keys
10
+ * plus numeric indices and the `*` placeholder for user-controlled keys, and
11
+ * reported code units only ever come from control or surrogate ranges. No
12
+ * caller text, key name or excerpt can leak through these fields.
13
+ */
14
+
15
+ export const VIOLATION_REASONS: ReadonlySet<string> = new Set([
16
+ "lone-surrogate",
17
+ "control-char",
18
+ ]);
19
+
20
+ /**
21
+ * Schema-defined keys allowed verbatim in a violation path. Any other object
22
+ * key (tool input names, `input_schema` property names, arbitrary metadata
23
+ * members) is user-controlled text and is reported as `*`.
24
+ */
25
+ const PATH_KEY_VOCABULARY: ReadonlySet<string> = new Set([
26
+ "$schema",
27
+ "accept",
28
+ "additionalBetas",
29
+ "additionalProperties",
30
+ "anthropicAdditionalProtection",
31
+ "app",
32
+ "arch",
33
+ "accountUuid",
34
+ "body",
35
+ "cache_control",
36
+ "citations",
37
+ "clientApp",
38
+ "clientRequestId",
39
+ "claudeRemoteContainerId",
40
+ "claudeRemoteSessionId",
41
+ "content",
42
+ "context",
43
+ "crypto",
44
+ "data",
45
+ "default",
46
+ "defer_loading",
47
+ "description",
48
+ "deviceId",
49
+ "document",
50
+ "effort",
51
+ "enum",
52
+ "evidence",
53
+ "examples",
54
+ "extraHeaderPolicy",
55
+ "extraHeaders",
56
+ "headers",
57
+ "id",
58
+ "image",
59
+ "input",
60
+ "input_schema",
61
+ "input_examples",
62
+ "items",
63
+ "max_tokens",
64
+ "maxTokens",
65
+ "media_type",
66
+ "messages",
67
+ "metadata",
68
+ "metadataOverrides",
69
+ "method",
70
+ "model",
71
+ "name",
72
+ "os",
73
+ "output_config",
74
+ "profileOverride",
75
+ "properties",
76
+ "redacted_thinking",
77
+ "required",
78
+ "role",
79
+ "runtime",
80
+ "runtimeVersion",
81
+ "search_results",
82
+ "sessionId",
83
+ "signature",
84
+ "source",
85
+ "stainlessHelper",
86
+ "stainlessRetryCount",
87
+ "stop_sequences",
88
+ "stream",
89
+ "strict",
90
+ "system",
91
+ "text",
92
+ "thinking",
93
+ "timing",
94
+ "title",
95
+ "tool_name",
96
+ "tool_reference",
97
+ "tool_result",
98
+ "tool_use",
99
+ "tool_use_id",
100
+ "tools",
101
+ "type",
102
+ "url",
103
+ "user_id",
104
+ "userId",
105
+ "userIdFields",
106
+ ]);
107
+
108
+ const MAX_PATH_SEGMENTS = 16;
109
+ const MAX_PATH_LENGTH = 256;
110
+ const TRUNCATION_MARKER = "...";
111
+ const OPAQUE_SUBTREES: ReadonlySet<string> = new Set([
112
+ "headers",
113
+ "input",
114
+ "input_schema",
115
+ "input_examples",
116
+ "metadata",
117
+ "metadataOverrides",
118
+ "userIdFields",
119
+ "extraHeaders",
120
+ "data",
121
+ "default",
122
+ "examples",
123
+ ]);
124
+
125
+ export type ViolationPathSegment = string | number;
126
+
127
+ function mapSegment(segment: ViolationPathSegment, opaque: boolean): string {
128
+ if (typeof segment === "number") {
129
+ return Number.isSafeInteger(segment) &&
130
+ !Object.is(segment, -0) &&
131
+ segment >= 0 &&
132
+ segment <= 999_999
133
+ ? String(segment)
134
+ : "*";
135
+ }
136
+ return !opaque && PATH_KEY_VOCABULARY.has(segment) ? segment : "*";
137
+ }
138
+
139
+ /**
140
+ * Renders a pointer-like path (`/messages/0/content`) from walk segments.
141
+ * At most 16 segments, then a trailing `/...`; at most 256 characters.
142
+ */
143
+ export function formatViolationPath(
144
+ segments: readonly ViolationPathSegment[],
145
+ ): string {
146
+ let path = "";
147
+ let opaque = false;
148
+ for (let index = 0; index < segments.length; index += 1) {
149
+ const segment = mapSegment(segments[index] ?? "*", opaque);
150
+ const tailLength = index < segments.length - 1 ? 4 : 0;
151
+ if (
152
+ index >= MAX_PATH_SEGMENTS ||
153
+ path.length + 1 + segment.length + tailLength > MAX_PATH_LENGTH
154
+ ) {
155
+ return `${path}/${TRUNCATION_MARKER}`;
156
+ }
157
+ path += `/${segment}`;
158
+ opaque ||= segment === "*" || OPAQUE_SUBTREES.has(segment);
159
+ }
160
+ return path || "/";
161
+ }
162
+
163
+ export function isValidViolationReason(value: string): boolean {
164
+ return VIOLATION_REASONS.has(value);
165
+ }
166
+
167
+ const PATH_INDEX_SEGMENT = /^(?:0|[1-9]\d{0,5})$/u;
168
+
169
+ /**
170
+ * Revalidates a formatted violation path for the redaction allowlist. Every
171
+ * segment must be a vocabulary key, a decimal index of at most 6 digits, `*`,
172
+ * or the single trailing truncation marker; the whole string is capped.
173
+ */
174
+ export function isValidViolationPath(value: string): boolean {
175
+ if (value.length === 0 || value.length > MAX_PATH_LENGTH) return false;
176
+ if (!value.startsWith("/")) return false;
177
+ if (value === "/") return true;
178
+ const segments = value.slice(1).split("/");
179
+ if (segments.length > MAX_PATH_SEGMENTS + 1) return false;
180
+ let opaque = false;
181
+ for (let index = 0; index < segments.length; index += 1) {
182
+ const segment = segments[index];
183
+ if (segment === undefined) return false;
184
+ if (segment === TRUNCATION_MARKER) return index === segments.length - 1;
185
+ if (index >= MAX_PATH_SEGMENTS) return false;
186
+ if (segment === "*") {
187
+ opaque = true;
188
+ continue;
189
+ }
190
+ if (PATH_INDEX_SEGMENT.test(segment)) continue;
191
+ if (opaque || !PATH_KEY_VOCABULARY.has(segment)) return false;
192
+ opaque ||= OPAQUE_SUBTREES.has(segment);
193
+ }
194
+ return true;
195
+ }
196
+
197
+ /** Numeric range guard for `violationCodeUnit`: controls or surrogates only. */
198
+ export function isValidViolationCodeUnit(value: number): boolean {
199
+ if (!Number.isInteger(value) || Object.is(value, -0) || value < 0)
200
+ return false;
201
+ return (
202
+ value <= 0x1f ||
203
+ (value >= 0x7f && value <= 0x9f) ||
204
+ (value >= 0xd800 && value <= 0xdfff)
205
+ );
206
+ }
207
+
208
+ /**
209
+ * Builds the safeDetails record for one text violation found at `path`.
210
+ * `inKey` distinguishes an offending object key from an offending value.
211
+ */
212
+ export function violationDetails(
213
+ violation: TextViolation,
214
+ path: readonly ViolationPathSegment[],
215
+ textLength: number,
216
+ inKey: boolean,
217
+ ): Readonly<Record<string, string | number | boolean>> {
218
+ return {
219
+ violationReason: violation.reason,
220
+ violationPath: formatViolationPath(path),
221
+ violationOffset: violation.offset,
222
+ violationCodeUnit: violation.codeUnit,
223
+ violationTextLength: textLength,
224
+ violationInKey: inKey,
225
+ };
226
+ }