agent-first-data 0.35.0 → 0.36.0

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.
package/README.md CHANGED
@@ -17,10 +17,12 @@ console.log(render(event.toJSON(), "json"));
17
17
  console.log(render(event.toJSON(), "plain"));
18
18
  ```
19
19
 
20
- Useful names use TypeScript casing: `render`, `outputOptionsForPolicy`, `redactedValue`, `redactUrlSecrets`, `redactUrlsInText`, `redactArgv`, `normalizeUtcOffset`, `isValidRfc3339Date`, `isValidRfc3339Time`, `isValidRfc3339`, `isValidBcp47`, `decodeProtocolEvent`, `cliParseOutput`, `cliParseLogFilters`, `buildCliError`, and `buildCliVersion`.
20
+ Useful names use TypeScript casing: `render`, `outputOptionsForPolicy`, `redactedValue`, `redactUrlSecrets`, `redactUrlsInText`, `redactArgv`, `normalizeUtcOffset`, `isValidRfc3339Date`, `isValidRfc3339Time`, `isValidRfc3339`, `isValidBcp47`, `decodeProtocolEvent`, `cliParseOutput`, `cliParseLogFilters`, `buildCliError`, `buildCliVersion`, and `CliEmitter`.
21
21
 
22
22
  ## Behavior Notes
23
23
 
24
+ - `OutputOptions.redaction.revealNames` reveals exact structured field names at the selected output boundary. Children still pass through redaction; URL parameters and argv receive no exemption.
25
+ - The protocol reader accepts base-profile events. An absent `error.retryable` remains nullable; strict validation is a separate opt-in.
24
26
  - Default redaction replaces every `_secret` or configured secret-name subtree with `***`, including objects and arrays.
25
27
  - `_url` fields scrub userinfo passwords and secret-named query parameters; surrounding whitespace is trimmed, and internal whitespace or a `://` that is not the start of a clean URL redacts the whole field, where whitespace is the explicit Unicode `White_Space` set the spec enumerates, not the language's native predicate.
26
28
  - `OutputOptions.redaction.urlNames` applies URL treatment to exact legacy field names
@@ -29,7 +31,7 @@ Useful names use TypeScript casing: `render`, `outputOptionsForPolicy`, `redacte
29
31
  - YAML keeps original keys and values (structure-preserving, like JSON), sorting keys by UTF-16 code unit order and quoting/escaping unsafe keys and string scalars. Plain strips formatting suffixes, formats values, sorts the same way, and renders nested objects/arrays as canonical JSON; it quotes a value only when it contains ASCII space, tab, newline, carriage return, form feed, vertical tab, NBSP, `=`, `"`, or `\`, and escapes `\`, `"`, newline, carriage return, tab, form feed, and vertical tab inside the quotes.
30
32
  - Logging records use `kind:"log"` with a nested `log` payload and a separate `level` field, so error-level logs are not terminal protocol errors.
31
33
  - `buildCliError(message, hint?)` returns a strict-ready CLI error with `error.retryable:false` and `trace:{}`.
32
- - Do not add a raw version/help pre-parser. Until the TypeScript `CliSpec` compiler lands, applications should keep lifecycle parsing in their own parser rather than claim AFDATA closed-world help compatibility.
34
+ - Do not add a raw version/help pre-parser. This SDK provides emitters and readers; argv and lifecycle parsing belong to the application. Claim help-v2 or closed-world compatibility only when using a registry compiler.
33
35
 
34
36
  ## Reference
35
37
 
@@ -44,7 +44,7 @@
44
44
  },
45
45
  "exit_codes": {
46
46
  "type": "array",
47
- "description": "Exit codes this CLI returns beyond AFDATA's 0/1/2, rendered into the reference's exit-code table. 0/1/2 are AFDATA's own and may not be redeclared.",
47
+ "description": "Exit codes this CLI returns beyond AFDATA's 0/1/2/4, rendered into the reference's exit-code table. AFDATA's codes may not be redeclared.",
48
48
  "items": {
49
49
  "type": "object",
50
50
  "additionalProperties": false,
@@ -56,7 +56,10 @@
56
56
  "code": {
57
57
  "type": "integer",
58
58
  "minimum": 3,
59
- "maximum": 255
59
+ "maximum": 255,
60
+ "not": {
61
+ "const": 4
62
+ }
60
63
  },
61
64
  "meaning": {
62
65
  "$ref": "#/$defs/nonEmpty"
package/dist/cli.d.ts CHANGED
@@ -108,6 +108,9 @@ export declare class CliEmitter {
108
108
  private terminalEmitted;
109
109
  private diagnostic?;
110
110
  private strictProtocol;
111
+ private writeFailed;
112
+ /** Where an event-stream emitter reports a failed write; tests replace it. */
113
+ private processStderr;
111
114
  /**
112
115
  * Create an event-stream emitter: every event, including `error`, goes to the
113
116
  * single `writer`. Use CliEmitter.finite for a one-shot command that should
@@ -154,10 +157,17 @@ export declare class CliEmitter {
154
157
  * exit code, so a one-shot CLI need not hand-roll the emit-then-exit dance.
155
158
  *
156
159
  * A successful write returns `successCode`; a broken pipe (the reader hung up)
157
- * returns `0`; any other write or validation failure returns `4`. A library
158
- * never calls process.exit itself — return this code and let the caller exit.
160
+ * returns `0` and says nothing. Any other write failure returns `4` after
161
+ * reporting `output_write_failed` as an `error` event: on the diagnostic
162
+ * writer in finite mode, on the process's stderr in event-stream mode, whose
163
+ * only writer is the one that just failed. That report is best effort. An
164
+ * event the emitter refuses (a protocol violation or a second terminal
165
+ * event) is the command's own failure, not an output one, and returns `1`.
166
+ * A library never calls process.exit itself — return this code and let the
167
+ * caller exit.
159
168
  */
160
169
  finish(event: Event, successCode: number): number;
170
+ private reportOutputWriteFailed;
161
171
  /**
162
172
  * Convenience over finish: emit a `result` payload and return `0` on success.
163
173
  *
package/dist/cli.js CHANGED
@@ -156,6 +156,9 @@ export class CliEmitter {
156
156
  terminalEmitted = false;
157
157
  diagnostic;
158
158
  strictProtocol = false;
159
+ writeFailed = false;
160
+ /** Where an event-stream emitter reports a failed write; tests replace it. */
161
+ processStderr = processStderrWriter;
159
162
  /**
160
163
  * Create an event-stream emitter: every event, including `error`, goes to the
161
164
  * single `writer`. Use CliEmitter.finite for a one-shot command that should
@@ -217,6 +220,7 @@ export class CliEmitter {
217
220
  return this;
218
221
  }
219
222
  emit(event) {
223
+ this.writeFailed = false;
220
224
  const jsonValue = event.toJSON();
221
225
  validateProtocolEvent(jsonValue, this.strictProtocol);
222
226
  const kind = jsonValue.kind;
@@ -233,7 +237,15 @@ export class CliEmitter {
233
237
  else {
234
238
  throw new Error(`unsupported event kind ${JSON.stringify(kind)}`);
235
239
  }
236
- this.sinkFor(kind)(`${render(jsonValue, this.format, this.outputOptions)}\n`);
240
+ const line = `${render(jsonValue, this.format, this.outputOptions)}\n`;
241
+ try {
242
+ this.sinkFor(kind)(line);
243
+ }
244
+ catch (err) {
245
+ // Marks the failure as output's, not the event's, for finish().
246
+ this.writeFailed = true;
247
+ throw err;
248
+ }
237
249
  if (kind === "result" || kind === "error")
238
250
  this.terminalEmitted = true;
239
251
  }
@@ -265,8 +277,14 @@ export class CliEmitter {
265
277
  * exit code, so a one-shot CLI need not hand-roll the emit-then-exit dance.
266
278
  *
267
279
  * A successful write returns `successCode`; a broken pipe (the reader hung up)
268
- * returns `0`; any other write or validation failure returns `4`. A library
269
- * never calls process.exit itself — return this code and let the caller exit.
280
+ * returns `0` and says nothing. Any other write failure returns `4` after
281
+ * reporting `output_write_failed` as an `error` event: on the diagnostic
282
+ * writer in finite mode, on the process's stderr in event-stream mode, whose
283
+ * only writer is the one that just failed. That report is best effort. An
284
+ * event the emitter refuses (a protocol violation or a second terminal
285
+ * event) is the command's own failure, not an output one, and returns `1`.
286
+ * A library never calls process.exit itself — return this code and let the
287
+ * caller exit.
270
288
  */
271
289
  finish(event, successCode) {
272
290
  try {
@@ -274,7 +292,28 @@ export class CliEmitter {
274
292
  return successCode;
275
293
  }
276
294
  catch (err) {
277
- return CliEmitter.isBrokenPipe(err) ? 0 : 4;
295
+ if (CliEmitter.isBrokenPipe(err))
296
+ return 0;
297
+ if (!this.writeFailed)
298
+ return 1;
299
+ this.reportOutputWriteFailed();
300
+ return 4;
301
+ }
302
+ }
303
+ reportOutputWriteFailed() {
304
+ try {
305
+ const event = jsonError("output_write_failed", "could not write command output").build();
306
+ if (this.diagnostic) {
307
+ this.emit(event);
308
+ }
309
+ else {
310
+ // Event-stream mode has no diagnostic writer, so the report goes to
311
+ // the process's stderr through an emitter of its own.
312
+ CliEmitter.stream(this.processStderr, this.format).withStrictProtocol().emit(event);
313
+ }
314
+ }
315
+ catch {
316
+ // Best effort: an unwritable stderr leaves only the exit code.
278
317
  }
279
318
  }
280
319
  /**
package/dist/format.d.ts CHANGED
@@ -10,9 +10,10 @@
10
10
  * Number literal fidelity: decodeProtocolEvent parses with `lossless-json`
11
11
  * instead of JSON.parse, so a number's exact source literal (arbitrary
12
12
  * precision integers, high-precision floats, exponent forms, "-0") survives
13
- * decode. Parsed numbers become LosslessNumber internally; every JSON/YAML
14
- * rendering path here treats it as an opaque scalar leaf (never destructured
15
- * as a plain object) and re-emits its exact literal text. Native bigint values
13
+ * decode. Parsed numbers become LosslessNumber internally; every rendering
14
+ * path here treats it as an opaque scalar leaf (never destructured as a plain
15
+ * object). JSON re-emits its exact literal text; YAML/plain render it in the
16
+ * spec's canonical number form without losing a digit. Native bigint values
16
17
  * are also supported so callers can render integers beyond Number's safe
17
18
  * range without first corrupting them.
18
19
  */
@@ -22,7 +23,8 @@ export type JsonValue = string | number | bigint | boolean | null | JsonValue[]
22
23
  export type LogLevel = "debug" | "info" | "warn" | "error";
23
24
  /**
24
25
  * Event is an opaque type wrapping a protocol event envelope.
25
- * Guaranteed to be strict-valid by construction.
26
+ * Non-error builders retain caller-provided traces verbatim; validate the
27
+ * envelope before accepting externally supplied traces as protocol-valid.
26
28
  */
27
29
  export declare class Event {
28
30
  private readonly value;
@@ -100,7 +102,7 @@ export type DecodedError = {
100
102
  kind: "error";
101
103
  code: string;
102
104
  message: string;
103
- retryable: boolean;
105
+ retryable: boolean | null;
104
106
  hint?: string;
105
107
  fields: Record<string, JsonValue>;
106
108
  trace?: Record<string, JsonValue>;
@@ -117,12 +119,12 @@ export type DecodedLog = {
117
119
  };
118
120
  /** Discriminated union of decoded protocol events, narrow on `kind`. */
119
121
  export type DecodedEvent = DecodedResult | DecodedError | DecodedProgress | DecodedLog;
120
- /** Thrown by decodeProtocolEvent for malformed JSON or a strict-invalid event. */
122
+ /** Thrown by decodeProtocolEvent for malformed JSON or a base-invalid event. */
121
123
  export declare class EventDecodeError extends Error {
122
124
  constructor(message: string);
123
125
  }
124
126
  /**
125
- * Parse one protocol line (a single JSON text value), strict-validate it, and
127
+ * Parse one protocol line (a single JSON text value), base-validate it, and
126
128
  * return a typed decoded event.
127
129
  */
128
130
  export declare function decodeProtocolEvent(text: string): DecodedEvent;
@@ -158,6 +160,8 @@ export type OutputOptions = {
158
160
  * redaction in addition to _url/_URL fields.
159
161
  */
160
162
  urlNames?: readonly string[];
163
+ /** Exact structured field names to reveal; URL parameters and argv stay redacted. */
164
+ revealNames?: readonly string[];
161
165
  };
162
166
  /** Rendering style for plain (logfmt) output only. Omitted means readable.
163
167
  * JSON and YAML are structure-preserving and ignore this setting. */
@@ -191,6 +195,8 @@ export declare function redactedValue(value: unknown, options?: {
191
195
  policy?: RedactionPolicy;
192
196
  secretNames?: readonly string[];
193
197
  urlNames?: readonly string[];
198
+ /** Exact structured field names to reveal; URL parameters and argv stay redacted. */
199
+ revealNames?: readonly string[];
194
200
  }): JsonValue;
195
201
  /**
196
202
  * Redact secret components of a single URL string.
package/dist/format.js CHANGED
@@ -10,9 +10,10 @@
10
10
  * Number literal fidelity: decodeProtocolEvent parses with `lossless-json`
11
11
  * instead of JSON.parse, so a number's exact source literal (arbitrary
12
12
  * precision integers, high-precision floats, exponent forms, "-0") survives
13
- * decode. Parsed numbers become LosslessNumber internally; every JSON/YAML
14
- * rendering path here treats it as an opaque scalar leaf (never destructured
15
- * as a plain object) and re-emits its exact literal text. Native bigint values
13
+ * decode. Parsed numbers become LosslessNumber internally; every rendering
14
+ * path here treats it as an opaque scalar leaf (never destructured as a plain
15
+ * object). JSON re-emits its exact literal text; YAML/plain render it in the
16
+ * spec's canonical number form without losing a digit. Native bigint values
16
17
  * are also supported so callers can render integers beyond Number's safe
17
18
  * range without first corrupting them.
18
19
  */
@@ -22,7 +23,8 @@ import { parse as losslessParse, stringify as losslessStringify, isLosslessNumbe
22
23
  // ═══════════════════════════════════════════
23
24
  /**
24
25
  * Event is an opaque type wrapping a protocol event envelope.
25
- * Guaranteed to be strict-valid by construction.
26
+ * Non-error builders retain caller-provided traces verbatim; validate the
27
+ * envelope before accepting externally supplied traces as protocol-valid.
26
28
  */
27
29
  export class Event {
28
30
  value;
@@ -113,7 +115,7 @@ export class JsonErrorBuilder {
113
115
  this.issues.push(`cannot write reserved error field ${JSON.stringify(name)}`);
114
116
  }
115
117
  else {
116
- this.extensionFields[name] = value;
118
+ setJsonField(this.extensionFields, name, value);
117
119
  }
118
120
  return this;
119
121
  }
@@ -127,7 +129,7 @@ export class JsonErrorBuilder {
127
129
  this.issues.push(`cannot write reserved error field ${JSON.stringify(key)}`);
128
130
  }
129
131
  else {
130
- this.extensionFields[key] = val;
132
+ setJsonField(this.extensionFields, key, val);
131
133
  }
132
134
  }
133
135
  return this;
@@ -142,7 +144,7 @@ export class JsonErrorBuilder {
142
144
  this.issues.push(`cannot write reserved error field ${JSON.stringify(key)}`);
143
145
  }
144
146
  else {
145
- this.extensionFields[key] = val;
147
+ setJsonField(this.extensionFields, key, val);
146
148
  }
147
149
  }
148
150
  return this;
@@ -277,6 +279,9 @@ function validateErrorPayload(error) {
277
279
  if ("hint" in error && typeof error.hint !== "string") {
278
280
  throw new Error("event.error.hint must be a string when present");
279
281
  }
282
+ if ("retryable" in error && typeof error.retryable !== "boolean") {
283
+ throw new Error("event.error.retryable must be a boolean when present");
284
+ }
280
285
  }
281
286
  /** Validate finite CLI lifecycle: (log | progress)* -> exactly one terminal. `strict` also enforces the recommended strict protocol profile (default true). */
282
287
  export function validateProtocolStream(events, strict = true) {
@@ -319,7 +324,7 @@ function validateStrictError(error) {
319
324
  throw new Error("event.error.hint must be a string when present in strict mode");
320
325
  }
321
326
  }
322
- /** Thrown by decodeProtocolEvent for malformed JSON or a strict-invalid event. */
327
+ /** Thrown by decodeProtocolEvent for malformed JSON or a base-invalid event. */
323
328
  export class EventDecodeError extends Error {
324
329
  constructor(message) {
325
330
  super(message);
@@ -327,8 +332,70 @@ export class EventDecodeError extends Error {
327
332
  }
328
333
  }
329
334
  const ERROR_RESERVED_FIELDS = new Set(["code", "message", "hint", "retryable"]);
335
+ /** How many arrays and objects a protocol line may nest, counting the envelope object. Every SDK enforces the same bound. */
336
+ const MAX_PROTOCOL_NESTING = 127;
337
+ function hexEscape(text, at) {
338
+ if (text.charCodeAt(at) !== 0x5c || text.charCodeAt(at + 1) !== 0x75)
339
+ return null;
340
+ const digits = text.slice(at + 2, at + 6);
341
+ return /^[0-9a-fA-F]{4}$/.test(digits) ? parseInt(digits, 16) : null;
342
+ }
343
+ /**
344
+ * The decode rules lossless-json does not enforce: a nesting bound, and no
345
+ * lone UTF-16 surrogate, escaped or raw (lossless-json keeps one in the
346
+ * string). Only strings and escapes are tracked; the rest of the grammar is
347
+ * left to the parser.
348
+ */
349
+ function checkProtocolLine(text) {
350
+ let depth = 0;
351
+ let inString = false;
352
+ for (let i = 0; i < text.length; i++) {
353
+ const c = text.charCodeAt(i);
354
+ if (inString) {
355
+ if (c === 0x22) {
356
+ inString = false;
357
+ }
358
+ else if (c === 0x5c) {
359
+ const unit = hexEscape(text, i);
360
+ if (unit !== null && unit >= 0xd800 && unit <= 0xdbff) {
361
+ const low = hexEscape(text, i + 6);
362
+ if (low === null || low < 0xdc00 || low > 0xdfff) {
363
+ throw new SyntaxError(`lone surrogate escape at position ${i}`);
364
+ }
365
+ i += 11;
366
+ }
367
+ else if (unit !== null && unit >= 0xdc00 && unit <= 0xdfff) {
368
+ throw new SyntaxError(`lone surrogate escape at position ${i}`);
369
+ }
370
+ else {
371
+ i++;
372
+ }
373
+ }
374
+ else if (c >= 0xd800 && c <= 0xdbff) {
375
+ const next = text.charCodeAt(i + 1);
376
+ if (!(next >= 0xdc00 && next <= 0xdfff))
377
+ throw new SyntaxError(`lone surrogate at position ${i}`);
378
+ i++;
379
+ }
380
+ else if (c >= 0xdc00 && c <= 0xdfff) {
381
+ throw new SyntaxError(`lone surrogate at position ${i}`);
382
+ }
383
+ }
384
+ else if (c === 0x22) {
385
+ inString = true;
386
+ }
387
+ else if (c === 0x5b || c === 0x7b) {
388
+ depth++;
389
+ if (depth > MAX_PROTOCOL_NESTING)
390
+ throw new SyntaxError(`nesting deeper than ${MAX_PROTOCOL_NESTING}`);
391
+ }
392
+ else if (c === 0x5d || c === 0x7d) {
393
+ depth--;
394
+ }
395
+ }
396
+ }
330
397
  /**
331
- * Parse one protocol line (a single JSON text value), strict-validate it, and
398
+ * Parse one protocol line (a single JSON text value), base-validate it, and
332
399
  * return a typed decoded event.
333
400
  */
334
401
  export function decodeProtocolEvent(text) {
@@ -338,13 +405,16 @@ export function decodeProtocolEvent(text) {
338
405
  // source literal (see module-level fidelity note) instead of collapsing
339
406
  // it through float64. isSyntaxError-compatible: throws the same
340
407
  // SyntaxError shape JSON.parse would.
341
- parsed = losslessParse(text);
408
+ checkProtocolLine(text);
409
+ // A repeated key keeps its last value, as JSON.parse and every other
410
+ // SDK's decoder do; lossless-json would otherwise throw.
411
+ parsed = losslessParse(text, null, { onDuplicateKey: ({ newValue }) => newValue });
342
412
  }
343
413
  catch (e) {
344
414
  throw new EventDecodeError(`invalid JSON: ${e.message}`);
345
415
  }
346
416
  try {
347
- validateProtocolEvent(parsed, true);
417
+ validateProtocolEvent(parsed, false);
348
418
  }
349
419
  catch (e) {
350
420
  throw new EventDecodeError(e.message);
@@ -359,13 +429,13 @@ export function decodeProtocolEvent(text) {
359
429
  const fields = {};
360
430
  for (const [k, v] of Object.entries(error)) {
361
431
  if (!ERROR_RESERVED_FIELDS.has(k))
362
- fields[k] = v;
432
+ setJsonField(fields, k, v);
363
433
  }
364
434
  const decoded = {
365
435
  kind: "error",
366
436
  code: error.code,
367
437
  message: error.message,
368
- retryable: error.retryable,
438
+ retryable: typeof error.retryable === "boolean" ? error.retryable : null,
369
439
  fields,
370
440
  trace,
371
441
  };
@@ -443,8 +513,12 @@ export function formatJsonValue(value, options = {}) {
443
513
  * and formatting for everything else.
444
514
  */
445
515
  function canonicalJsonString(value) {
446
- return losslessStringify(value) ?? "null";
516
+ return losslessStringify(value, null, undefined, NUMBER_STRINGIFIERS) ?? "null";
447
517
  }
518
+ // JSON.stringify spells negative zero "0"; the other SDKs keep its sign.
519
+ const NUMBER_STRINGIFIERS = [
520
+ { test: (value) => Object.is(value, -0), stringify: () => "-0" },
521
+ ];
448
522
  /**
449
523
  * Format as multi-line YAML. Structure-preserving: like JSON, original keys,
450
524
  * scalar types, and numeric semantics are kept after redaction; secrets are
@@ -761,6 +835,7 @@ function isRedacted(value) {
761
835
  const DEFAULT_CONTEXT = {
762
836
  secretNames: new Set(),
763
837
  urlNames: new Set(),
838
+ revealNames: new Set(),
764
839
  };
765
840
  function secretNameSet(redactionOptions) {
766
841
  return new Set(redactionOptions.secretNames ?? []);
@@ -769,6 +844,7 @@ function contextFromOptions(redactionOptions) {
769
844
  return {
770
845
  secretNames: secretNameSet(redactionOptions),
771
846
  urlNames: new Set(redactionOptions.urlNames ?? []),
847
+ revealNames: new Set(redactionOptions.revealNames ?? []),
772
848
  };
773
849
  }
774
850
  function keyHasSecretSuffix(key) {
@@ -791,7 +867,7 @@ function redactSecrets(value, context = DEFAULT_CONTEXT, depth = 0) {
791
867
  if (isObject(value)) {
792
868
  for (const k of Object.keys(value)) {
793
869
  const v = value[k];
794
- if (isSecretKey(k, context.secretNames)) {
870
+ if (isSecretKey(k, context.secretNames) && !context.revealNames.has(k)) {
795
871
  // A null secret is an absent secret. Masking it would manufacture the
796
872
  // appearance of a configured credential — readers cannot tell
797
873
  // "***"-because-set from "***"-because-null. Redaction hides a value
@@ -832,7 +908,7 @@ function redactUrlValue(value, context, depth) {
832
908
  if (isObject(value)) {
833
909
  for (const key of Object.keys(value)) {
834
910
  const item = value[key];
835
- if (isSecretKey(key, context.secretNames)) {
911
+ if (isSecretKey(key, context.secretNames) && !context.revealNames.has(key)) {
836
912
  if (item !== null)
837
913
  value[key] = REDACTED_MARKER;
838
914
  }
@@ -869,7 +945,7 @@ function applyRedactionPolicyWithContext(value, redactionPolicy, context) {
869
945
  * when s is a processable URL, or null when it is not (so callers can keep the
870
946
  * original). Only secret spans change; all other bytes are preserved.
871
947
  */
872
- function redactUrlInStr(s, secretNames) {
948
+ function redactUrlInStr(s, secretNames, depth = 0) {
873
949
  // Precondition (spec): a single, whitespace-free, scheme-prefixed URL.
874
950
  // The gate is scheme + no-whitespace only — NOT "parses via `new URL`". Span
875
951
  // location below is purely byte-wise (we never re-serialize the URL), so a
@@ -879,6 +955,9 @@ function redactUrlInStr(s, secretNames) {
879
955
  if (!s.includes("://") || !isSingleUrl(s)) {
880
956
  return null;
881
957
  }
958
+ // Bound recursive redirect inspection, failing closed before stack growth.
959
+ if (depth >= 16)
960
+ return REDACTED_MARKER;
882
961
  const schemeSep = s.indexOf("://");
883
962
  const scheme = s.slice(0, schemeSep);
884
963
  const rest = s.slice(schemeSep + 3);
@@ -891,8 +970,31 @@ function redactUrlInStr(s, secretNames) {
891
970
  break;
892
971
  }
893
972
  }
973
+ // An unencoded '?' or '#' inside a password ends that authority early and
974
+ // leaves the rest of the password in the query or fragment. When a ':'
975
+ // precedes that early end and an '@' follows it before the first '/', the
976
+ // userinfo runs to that '@' — how libpq reads such a connection string.
977
+ const slash = rest.indexOf("/");
978
+ const firstSlash = slash < 0 ? rest.length : slash;
979
+ const at = rest.slice(0, firstSlash).lastIndexOf("@");
980
+ if (rest.slice(0, authEnd).includes(":") && at > authEnd) {
981
+ authEnd = rest.length;
982
+ for (let i = at; i < rest.length; i++) {
983
+ const c = rest[i];
984
+ if (c === "/" || c === "?" || c === "#") {
985
+ authEnd = i;
986
+ break;
987
+ }
988
+ }
989
+ }
894
990
  const authority = rest.slice(0, authEnd);
895
991
  const remainder = rest.slice(authEnd);
992
+ // An unencoded '/' inside a password ends the authority at a "port" that is
993
+ // not a number, with the '@' somewhere after it. Where the password ends
994
+ // cannot be known, so the whole value is replaced.
995
+ if (!authority.includes("@") && hasNonNumericPort(authority) && remainder.includes("@")) {
996
+ return REDACTED_MARKER;
997
+ }
896
998
  const newAuthority = redactUserinfoPassword(authority);
897
999
  // `remainder` is `path[?query][#fragment]`; '#' ends the query, so split the
898
1000
  // fragment off first and the query out of what is left.
@@ -901,14 +1003,26 @@ function redactUrlInStr(s, secretNames) {
901
1003
  const fragment = hash >= 0 ? remainder.slice(hash + 1) : null;
902
1004
  const q = beforeFragment.indexOf("?");
903
1005
  const newBeforeFragment = q >= 0
904
- ? `${beforeFragment.slice(0, q)}?${redactQuery(beforeFragment.slice(q + 1), secretNames)}`
1006
+ ? `${beforeFragment.slice(0, q)}?${redactQuery(beforeFragment.slice(q + 1), secretNames, depth)}`
905
1007
  : beforeFragment;
906
1008
  // A fragment gets the same treatment as the query: `k=v&k=v` after the '#' is
907
1009
  // exactly how an OAuth implicit-flow response hands back a token, so a
908
1010
  // secret-named fragment parameter must not survive where the identically
909
1011
  // named query parameter would not. A fragment that is not in that shape has
910
1012
  // no '=' in its segments and passes through byte-for-byte.
911
- const newFragment = fragment === null ? "" : `#${redactQuery(fragment, secretNames)}`;
1013
+ // A hash route (`#/cb?k=v`) carries its parameters after its first '?',
1014
+ // while a form reader keeps that '?' inside a value; redact both readings:
1015
+ // the fragment as parameters first, so a secret value absorbs its '?',
1016
+ // then whatever follows the first '?' that survives.
1017
+ let newFragment = "";
1018
+ if (fragment !== null) {
1019
+ const params = redactQuery(fragment, secretNames, depth);
1020
+ const routeQuery = params.indexOf("?");
1021
+ newFragment =
1022
+ routeQuery >= 0
1023
+ ? `#${params.slice(0, routeQuery)}?${redactQuery(params.slice(routeQuery + 1), secretNames, depth)}`
1024
+ : `#${params}`;
1025
+ }
912
1026
  return `${scheme}://${newAuthority}${newBeforeFragment}${newFragment}`;
913
1027
  }
914
1028
  function redactUrlsInTextWithContext(text, context) {
@@ -1071,7 +1185,7 @@ function redactUserinfoPassword(authority) {
1071
1185
  * Redact the values of secret-named query parameters, preserving raw bytes of
1072
1186
  * every other segment (keys, benign values, encoding, ordering, separators).
1073
1187
  */
1074
- function redactQuery(query, secretNames) {
1188
+ function redactQuery(query, secretNames, depth) {
1075
1189
  return query
1076
1190
  .split("&")
1077
1191
  .map((segment) => {
@@ -1083,19 +1197,33 @@ function redactQuery(query, secretNames) {
1083
1197
  if (isSecretKey(name, secretNames)) {
1084
1198
  return `${rawKey}=${REDACTED_MARKER}`;
1085
1199
  }
1200
+ // A redirect target carries its own parameters: redact a nested URL in
1201
+ // place, or the whole value when it arrived encoded.
1202
+ const rawValue = segment.slice(eq + 1);
1203
+ const value = formDecode(rawValue);
1204
+ const redacted = redactUrlInStr(value, secretNames, depth + 1);
1205
+ if (redacted !== null && redacted !== value) {
1206
+ return value === rawValue ? `${rawKey}=${redacted}` : `${rawKey}=${REDACTED_MARKER}`;
1207
+ }
1086
1208
  return segment;
1087
1209
  })
1088
1210
  .join("&");
1089
1211
  }
1090
- /** Form-decode a query-parameter name: '+' → space, then percent-decode. */
1212
+ /**
1213
+ * True when the authority's text after its last ':' is not a port number. A
1214
+ * bracketed IPv6 literal without a port ends in ']' and has none.
1215
+ */
1216
+ function hasNonNumericPort(authority) {
1217
+ const colon = authority.lastIndexOf(":");
1218
+ if (colon < 0 || authority.endsWith("]"))
1219
+ return false;
1220
+ return !/^[0-9]*$/.test(authority.slice(colon + 1));
1221
+ }
1222
+ /** Form-decode a query-parameter name or value: '+' → space, then percent-decode. */
1091
1223
  function formDecode(rawKey) {
1092
- const withSpaces = rawKey.replace(/\+/g, " ");
1093
- try {
1094
- return decodeURIComponent(withSpaces);
1095
- }
1096
- catch {
1097
- return withSpaces;
1098
- }
1224
+ // URLSearchParams keeps malformed escapes literal and replaces invalid
1225
+ // UTF-8, while still decoding valid escapes elsewhere in the value.
1226
+ return new URLSearchParams(`value=${rawKey.replace(/&/g, "%26")}`).get("value") ?? "";
1099
1227
  }
1100
1228
  /**
1101
1229
  * True when s begins with a URL scheme (ALPHA *(ALPHA / DIGIT / "+" / "-" /
@@ -1164,46 +1292,70 @@ function tryStripGenericMicro(key) {
1164
1292
  return null;
1165
1293
  return [stripped, code];
1166
1294
  }
1167
- function isInt(value) {
1168
- return typeof value === "number" && Number.isInteger(value);
1169
- }
1295
+ /**
1296
+ * The exact digits of an integer-valued number or decimal integer string, of
1297
+ * any magnitude. Bitcoin amounts and `_epoch_ns` only append a unit or divide,
1298
+ * so unlike money suffixes they are not bounded by i64: an integer literal
1299
+ * keeps its digits, and a float counts when its value is an integer below 1e21
1300
+ * (the canonical number form's integer range). A numeric -0 is zero.
1301
+ */
1170
1302
  function decimalIntText(value) {
1171
- if (typeof value === "string" && /^-?\d+$/.test(value))
1172
- return value;
1303
+ if (typeof value === "string")
1304
+ return /^-?\d+$/.test(value) ? value : null;
1173
1305
  if (typeof value === "bigint")
1174
1306
  return value.toString();
1175
- if (isInt(value) && Number.isSafeInteger(value))
1176
- return String(value);
1177
- if (isLosslessNumber(value)) {
1178
- const text = value.toString();
1179
- return /^-?\d+$/.test(text) ? text : null;
1307
+ let literal;
1308
+ if (typeof value === "number") {
1309
+ if (!Number.isFinite(value))
1310
+ return null;
1311
+ literal = value.toString();
1180
1312
  }
1181
- return null;
1313
+ else if (isLosslessNumber(value)) {
1314
+ literal = value.toString();
1315
+ }
1316
+ else {
1317
+ return null;
1318
+ }
1319
+ const integer = /[.eE]/.test(literal) ? integralFloatText(literal) : literal;
1320
+ if (integer === null)
1321
+ return null;
1322
+ return integer === "-0" ? "0" : integer;
1182
1323
  }
1183
1324
  const MIN_I64 = -9223372036854775808n;
1184
1325
  const MAX_I64 = 9223372036854775807n;
1185
- function signedI64(value) {
1186
- let text = null;
1187
- if (typeof value === "bigint") {
1188
- text = value.toString();
1189
- }
1190
- else if (typeof value === "number" && Number.isSafeInteger(value)) {
1191
- text = String(value);
1326
+ const MAX_U64 = 18446744073709551615n;
1327
+ // An integer literal stays exact; a decoded integral float (`3.0`) counts as
1328
+ // an integer like any other.
1329
+ function integerValue(value) {
1330
+ if (typeof value === "bigint")
1331
+ return value;
1332
+ if (typeof value === "number") {
1333
+ // Read through the shortest spelling, the text the canonical number form
1334
+ // prints, so 2**60 counts as 1152921504606847000 exactly as a decoded
1335
+ // literal of that spelling does in every SDK; BigInt(value) would give
1336
+ // ...846976 and disagree with Rust.
1337
+ if (!Number.isFinite(value))
1338
+ return null;
1339
+ const literal = value.toString();
1340
+ const integer = /[.eE]/.test(literal) ? integralFloatText(literal) : literal;
1341
+ return integer !== null ? BigInt(integer) : null;
1192
1342
  }
1193
- else if (isLosslessNumber(value)) {
1343
+ if (isLosslessNumber(value)) {
1194
1344
  const literal = value.toString();
1195
1345
  if (/^-?\d+$/.test(literal))
1196
- text = literal;
1197
- }
1198
- if (text === null)
1199
- return null;
1200
- try {
1201
- const integer = BigInt(text);
1202
- return integer >= MIN_I64 && integer <= MAX_I64 ? integer : null;
1203
- }
1204
- catch {
1205
- return null;
1346
+ return BigInt(literal);
1347
+ const integer = integralFloatText(literal);
1348
+ return integer !== null ? BigInt(integer) : null;
1206
1349
  }
1350
+ return null;
1351
+ }
1352
+ function signedI64(value) {
1353
+ const integer = integerValue(value);
1354
+ return integer !== null && integer >= MIN_I64 && integer <= MAX_I64 ? integer : null;
1355
+ }
1356
+ function unsignedU64(value) {
1357
+ const integer = integerValue(value);
1358
+ return integer !== null && integer >= 0n && integer <= MAX_U64 ? integer : null;
1207
1359
  }
1208
1360
  function formatCents(value, symbol) {
1209
1361
  const sign = value < 0n ? "-" : "";
@@ -1234,24 +1386,14 @@ function isNum(value) {
1234
1386
  }
1235
1387
  function tryProcessField(key, value) {
1236
1388
  let stripped;
1237
- // Plain-format suffix processing below does real arithmetic (division,
1238
- // modulo, Math.floor/abs) that only works on a native JS number. A decoded
1239
- // LosslessNumber is normalized to one here via Number() \u2014 which never
1240
- // throws, unlike LosslessNumber's own valueOf() (documented to throw for
1241
- // values it can't safely represent as number/bigint) \u2014 so an ordinary
1242
- // decoded value (e.g. duration_ms: 42) keeps formatting exactly as before,
1243
- // and an exotic decoded value degrades gracefully (loses precision here,
1244
- // same as it always would for a >2^53 native number) rather than crashing.
1245
- // This is Plain's documented lossy/human path; JSON/YAML stay exact via
1246
- // yamlScalar/plainScalar/canonicalJsonString, and decimalIntText below
1247
- // (msats/sats/epoch_ns) keeps exactness by reading `value` directly instead
1248
- // of `numeric`.
1249
- const numeric = isLosslessNumber(value) ? Number(value.toString()) : value;
1389
+ // Appending a unit does no arithmetic and must retain decoded digits.
1390
+ const isNumber = isNum(value) || isLosslessNumber(value);
1250
1391
  // Group 1: compound timestamp suffixes
1251
1392
  stripped = stripSuffixCI(key, "_epoch_ms");
1252
1393
  if (stripped !== null) {
1253
- if (isInt(numeric)) {
1254
- const formatted = formatRfc3339Ms(numeric);
1394
+ const integer = signedI64(value);
1395
+ if (integer !== null) {
1396
+ const formatted = formatRfc3339Ms(Number(integer));
1255
1397
  if (formatted !== null)
1256
1398
  return [stripped, formatted];
1257
1399
  }
@@ -1259,8 +1401,9 @@ function tryProcessField(key, value) {
1259
1401
  }
1260
1402
  stripped = stripSuffixCI(key, "_epoch_s");
1261
1403
  if (stripped !== null) {
1262
- if (isInt(numeric)) {
1263
- const formatted = formatRfc3339Ms(numeric * 1000);
1404
+ const integer = signedI64(value);
1405
+ if (integer !== null) {
1406
+ const formatted = formatRfc3339Ms(Number(integer) * 1000);
1264
1407
  if (formatted !== null)
1265
1408
  return [stripped, formatted];
1266
1409
  }
@@ -1322,20 +1465,20 @@ function tryProcessField(key, value) {
1322
1465
  }
1323
1466
  stripped = stripSuffixCI(key, "_minutes");
1324
1467
  if (stripped !== null) {
1325
- if (isNum(numeric))
1326
- return [stripped, `${plainScalar(numeric)} minutes`];
1468
+ if (isNumber)
1469
+ return [stripped, `${plainScalar(value)} minutes`];
1327
1470
  return null;
1328
1471
  }
1329
1472
  stripped = stripSuffixCI(key, "_hours");
1330
1473
  if (stripped !== null) {
1331
- if (isNum(numeric))
1332
- return [stripped, `${plainScalar(numeric)} hours`];
1474
+ if (isNumber)
1475
+ return [stripped, `${plainScalar(value)} hours`];
1333
1476
  return null;
1334
1477
  }
1335
1478
  stripped = stripSuffixCI(key, "_days");
1336
1479
  if (stripped !== null) {
1337
- if (isNum(numeric))
1338
- return [stripped, `${plainScalar(numeric)} days`];
1480
+ if (isNumber)
1481
+ return [stripped, `${plainScalar(value)} days`];
1339
1482
  return null;
1340
1483
  }
1341
1484
  // Group 4: single-unit suffixes
@@ -1355,14 +1498,15 @@ function tryProcessField(key, value) {
1355
1498
  }
1356
1499
  stripped = stripSuffixCI(key, "_bytes");
1357
1500
  if (stripped !== null) {
1358
- if (isInt(numeric) && numeric >= 0)
1359
- return [stripped, formatBytesHuman(numeric)];
1501
+ const bytes = unsignedU64(value);
1502
+ if (bytes !== null)
1503
+ return [stripped, formatBytesHuman(Number(bytes))];
1360
1504
  return null;
1361
1505
  }
1362
1506
  stripped = stripSuffixCI(key, "_percent");
1363
1507
  if (stripped !== null) {
1364
- if (isNum(numeric))
1365
- return [stripped, `${plainScalar(numeric)}%`];
1508
+ if (isNumber)
1509
+ return [stripped, `${plainScalar(value)}%`];
1366
1510
  return null;
1367
1511
  }
1368
1512
  // Group 5: short suffixes (last to avoid false positives)
@@ -1378,27 +1522,27 @@ function tryProcessField(key, value) {
1378
1522
  }
1379
1523
  stripped = stripSuffixCI(key, "_ns");
1380
1524
  if (stripped !== null) {
1381
- if (isNum(numeric))
1382
- return [stripped, `${plainScalar(numeric)}ns`];
1525
+ if (isNumber)
1526
+ return [stripped, `${plainScalar(value)}ns`];
1383
1527
  return null;
1384
1528
  }
1385
1529
  stripped = stripSuffixCI(key, "_us");
1386
1530
  if (stripped !== null) {
1387
- if (isNum(numeric))
1388
- return [stripped, `${plainScalar(numeric)}\u03bcs`];
1531
+ if (isNumber)
1532
+ return [stripped, `${plainScalar(value)}\u03bcs`];
1389
1533
  return null;
1390
1534
  }
1391
1535
  stripped = stripSuffixCI(key, "_ms");
1392
1536
  if (stripped !== null) {
1393
- const fv = formatMsValue(numeric);
1537
+ const fv = formatMsValue(value);
1394
1538
  if (fv !== null)
1395
1539
  return [stripped, fv];
1396
1540
  return null;
1397
1541
  }
1398
1542
  stripped = stripSuffixCI(key, "_s");
1399
1543
  if (stripped !== null) {
1400
- if (isNum(numeric))
1401
- return [stripped, `${plainScalar(numeric)}s`];
1544
+ if (isNumber)
1545
+ return [stripped, `${plainScalar(value)}s`];
1402
1546
  return null;
1403
1547
  }
1404
1548
  return null;
@@ -1445,17 +1589,18 @@ function processObjectFields(obj) {
1445
1589
  // Formatting Helpers
1446
1590
  // ═══════════════════════════════════════════
1447
1591
  function formatMsAsSeconds(ms) {
1448
- const formatted = (ms / 1000).toFixed(3);
1592
+ const formatted = toFixedHalfEven(ms / 1000, 3);
1449
1593
  const trimmed = formatted.replace(/0+$/, "");
1450
1594
  if (trimmed.endsWith("."))
1451
1595
  return trimmed + "0s";
1452
1596
  return trimmed + "s";
1453
1597
  }
1454
1598
  function formatMsValue(value) {
1455
- if (!isNum(value))
1599
+ const numeric = isLosslessNumber(value) ? Number(value.toString()) : value;
1600
+ if (!isNum(numeric) || !Number.isFinite(numeric))
1456
1601
  return null;
1457
- if (Math.abs(value) >= 1000)
1458
- return formatMsAsSeconds(value);
1602
+ if (Math.abs(numeric) >= 1000)
1603
+ return formatMsAsSeconds(numeric);
1459
1604
  return `${plainScalar(value)}ms`;
1460
1605
  }
1461
1606
  const MIN_RFC3339_MS = -62135596800000;
@@ -1469,25 +1614,30 @@ function formatRfc3339Ms(ms) {
1469
1614
  return d.toISOString().replace(/(\.\d{3})\d*Z$/, "$1Z");
1470
1615
  }
1471
1616
  /**
1472
- * Round to `digits` decimals using round-half-to-even (banker's rounding) and
1473
- * format with a fixed decimal count. `Number.toFixed` rounds half away from
1474
- * zero, which diverges from the printf `%.1f` used by Rust/Go/Python on exact
1475
- * ties (e.g. 1280 bytes = 1.25 KiB → "1.2KiB", not "1.3KiB").
1617
+ * Format `x` with `digits` decimals the way printf `%.Nf` does in Rust, Go and
1618
+ * Python: round the double's exact binary value, half to even.
1619
+ *
1620
+ * `Number.toFixed` already rounds the exact value, but breaks a true tie
1621
+ * (1.0625 at 3 places) away from zero and switches to exponent form from
1622
+ * 1e21. So take the exact expansion — 100 places covers every double this
1623
+ * can tie on — and round the decimal string here. An epsilon test on
1624
+ * `x * 10 ** digits` is not a substitute: it calls near-ties ties.
1476
1625
  */
1477
1626
  function toFixedHalfEven(x, digits) {
1478
- const factor = 10 ** digits;
1479
- const scaled = x * factor;
1480
- const floor = Math.floor(scaled);
1481
- const diff = scaled - floor;
1482
- const eps = 1e-9;
1483
- let rounded;
1484
- if (diff > 0.5 + eps)
1485
- rounded = floor + 1;
1486
- else if (diff < 0.5 - eps)
1487
- rounded = floor;
1488
- else
1489
- rounded = floor % 2 === 0 ? floor : floor + 1;
1490
- return (rounded / factor).toFixed(digits);
1627
+ const negative = x < 0;
1628
+ const a = Math.abs(x);
1629
+ // Every double from 1e21 up is an integer, which BigInt spells exactly.
1630
+ const exact = a >= 1e21 ? `${BigInt(a)}.${"0".repeat(100)}` : a.toFixed(100);
1631
+ const [whole, fraction] = exact.split(".");
1632
+ const next = fraction.charCodeAt(digits) - 48;
1633
+ let units = BigInt(whole + fraction.slice(0, digits));
1634
+ const tieBroken = /[1-9]/.test(fraction.slice(digits + 1)) || units % 2n === 1n;
1635
+ if (next > 5 || (next === 5 && tieBroken))
1636
+ units += 1n;
1637
+ const text = units.toString().padStart(digits + 1, "0");
1638
+ const point = text.length - digits;
1639
+ const body = digits === 0 ? text : `${text.slice(0, point)}.${text.slice(point)}`;
1640
+ return negative ? `-${body}` : body;
1491
1641
  }
1492
1642
  function formatBytesHuman(bytes) {
1493
1643
  const KiB = 1024;
@@ -1641,6 +1791,100 @@ function isAmbiguousYamlKey(key) {
1641
1791
  return ["inf", "infinity", "nan"].includes(key.replace(/^[+-]/, "").toLowerCase())
1642
1792
  || /^[-+]?(?:[0-9]+\.?[0-9]*|\.[0-9]+)(?:[eE][-+]?[0-9]+)?$/.test(key);
1643
1793
  }
1794
+ /**
1795
+ * Render a number's literal canonically for YAML/plain: a float literal whose
1796
+ * value is an integer below 1e21 in magnitude renders as that integer ("3.0"
1797
+ * -> "3", "-0.0" -> "-0"); anything else keeps its digits, placed by
1798
+ * placeDecimalPoint. Native numbers and decoded literals share it, and it
1799
+ * works on the text so a digit is never lost to a float64 round trip.
1800
+ */
1801
+ function formatNumberLiteral(literal) {
1802
+ return integralFloatText(literal) ?? placeDecimalPoint(literal) ?? normalizeExponent(literal);
1803
+ }
1804
+ const I64_MIN = -(2n ** 63n);
1805
+ const I64_MAX = 2n ** 63n - 1n;
1806
+ /**
1807
+ * Spell a non-integral float literal with the notation `Number#toString`
1808
+ * picks: positional when the value lies in [1e-6, 1e21), exponent form
1809
+ * otherwise. A decoded literal carries its own spelling and the other SDKs'
1810
+ * native formatters switch elsewhere, so the choice is made here, on the
1811
+ * digits as written. Null for integer literals and exponents beyond i64,
1812
+ * which keep their spelling.
1813
+ */
1814
+ function placeDecimalPoint(literal) {
1815
+ if (!/[.eE]/.test(literal))
1816
+ return null;
1817
+ const sign = literal.startsWith("-") ? "-" : "";
1818
+ const unsigned = sign ? literal.slice(1) : literal;
1819
+ const e = unsigned.search(/[eE]/);
1820
+ const mantissa = e >= 0 ? unsigned.slice(0, e) : unsigned;
1821
+ const [whole, fraction = ""] = mantissa.split(".");
1822
+ const digits = whole + fraction;
1823
+ const significant = digits.replace(/^0+/, "");
1824
+ if (!significant)
1825
+ return null;
1826
+ const shape = /^([-+]?)0*(\d{0,19})$/.exec(e >= 0 ? unsigned.slice(e + 1) : "0");
1827
+ if (!shape || (e >= 0 && unsigned.length === e + 1 + shape[1].length))
1828
+ return null;
1829
+ const exponent = BigInt(shape[1] + (shape[2] || "0"));
1830
+ // point: how many significant digits precede the decimal point.
1831
+ const point = BigInt(whole.length - (digits.length - significant.length)) + exponent;
1832
+ if (exponent < I64_MIN || exponent > I64_MAX || point - 1n < I64_MIN || point > I64_MAX) {
1833
+ return null;
1834
+ }
1835
+ let text;
1836
+ if (point >= 1n && point <= 21n) {
1837
+ const integer = significant.slice(0, Number(point));
1838
+ const rest = significant.slice(Number(point));
1839
+ if (!rest)
1840
+ return null;
1841
+ text = `${integer}.${rest}`;
1842
+ }
1843
+ else if (point >= -5n && point <= 0n) {
1844
+ text = `0.${"0".repeat(Number(-point))}${significant}`;
1845
+ }
1846
+ else {
1847
+ const scale = point - 1n;
1848
+ const rest = significant.length > 1 ? `.${significant.slice(1)}` : "";
1849
+ text = `${significant[0]}${rest}e${scale < 0n ? "-" : "+"}${scale < 0n ? -scale : scale}`;
1850
+ }
1851
+ return sign + text;
1852
+ }
1853
+ function integralFloatText(literal) {
1854
+ if (!/[.eE]/.test(literal))
1855
+ return null;
1856
+ const negative = literal.startsWith("-");
1857
+ const unsigned = negative ? literal.slice(1) : literal;
1858
+ const e = unsigned.search(/[eE]/);
1859
+ const mantissa = e >= 0 ? unsigned.slice(0, e) : unsigned;
1860
+ const [whole, fraction = ""] = mantissa.split(".");
1861
+ const digits = whole + fraction;
1862
+ if (!/[^0]/.test(digits))
1863
+ return negative ? "-0" : "0";
1864
+ const exponent = e >= 0 ? Number(unsigned.slice(e + 1)) : 0;
1865
+ if (!Number.isSafeInteger(exponent))
1866
+ return null;
1867
+ const point = whole.length + exponent;
1868
+ const split = Math.min(Math.max(point, 0), digits.length);
1869
+ if (/[^0]/.test(digits.slice(split)))
1870
+ return null;
1871
+ const padding = Math.min(Math.max(point - digits.length, 0), 22);
1872
+ const integer = (digits.slice(0, split) + "0".repeat(padding)).replace(/^0+/, "");
1873
+ if (integer.length > 21)
1874
+ return null;
1875
+ return (negative ? "-" : "") + (integer || "0");
1876
+ }
1877
+ function normalizeExponent(literal) {
1878
+ const match = /^(.*?)[eE]([-+]?)0*(\d*)$/.exec(literal);
1879
+ if (!match)
1880
+ return literal;
1881
+ // An unsigned exponent is positive; spell it like toString() does (e+23).
1882
+ return `${match[1]}e${match[2] || "+"}${match[3] || "0"}`;
1883
+ }
1884
+ function numberText(value) {
1885
+ // `toString` spells negative zero "0"; the canonical form keeps the sign.
1886
+ return Object.is(value, -0) ? "-0" : formatNumberLiteral(value.toString());
1887
+ }
1644
1888
  function yamlScalar(value) {
1645
1889
  if (typeof value === "string")
1646
1890
  return `"${escapeYamlStr(value)}"`;
@@ -1649,14 +1893,14 @@ function yamlScalar(value) {
1649
1893
  if (typeof value === "boolean")
1650
1894
  return value.toString();
1651
1895
  if (typeof value === "number")
1652
- return value.toString();
1896
+ return numberText(value);
1653
1897
  if (typeof value === "bigint")
1654
1898
  return value.toString();
1655
- // Decoded number: emit the exact source literal unquoted, matching JSON's
1656
- // structure-preserving fidelity contract for YAML too.
1899
+ // Decoded number: its exact digits, in the same canonical form a native
1900
+ // number gets.
1657
1901
  if (isLosslessNumber(value))
1658
- return value.toString();
1659
- return `"${escapeYamlStr(canonicalJsonString(sortJsonValue(value)))}"`;
1902
+ return formatNumberLiteral(value.toString());
1903
+ return `"${escapeYamlStr(embeddedJson(value))}"`;
1660
1904
  }
1661
1905
  // ═══════════════════════════════════════════
1662
1906
  // Plain Rendering (logfmt)
@@ -1717,19 +1961,19 @@ function plainScalar(value) {
1717
1961
  if (typeof value === "boolean")
1718
1962
  return value.toString();
1719
1963
  if (typeof value === "number")
1720
- return value.toString();
1964
+ return numberText(value);
1721
1965
  if (typeof value === "bigint")
1722
1966
  return value.toString();
1723
- // Decoded number: emit the exact source literal (Plain is documented lossy
1724
- // for arithmetic-derived formatting, but a bare pass-through scalar still
1725
- // gets its exact digits, not a mangled float64 round-trip).
1967
+ // Decoded number: its exact digits (Plain is documented lossy only for
1968
+ // arithmetic-derived formatting), in the same canonical form a native
1969
+ // number gets.
1726
1970
  if (isLosslessNumber(value))
1727
- return value.toString();
1728
- return canonicalJsonString(sortJsonValue(value));
1971
+ return formatNumberLiteral(value.toString());
1972
+ return embeddedJson(value);
1729
1973
  }
1730
1974
  function plainScalarRaw(value) {
1731
1975
  if (isObject(value) || Array.isArray(value)) {
1732
- return canonicalJsonString(sortJsonValue(value));
1976
+ return embeddedJson(value);
1733
1977
  }
1734
1978
  return plainScalar(value);
1735
1979
  }
@@ -1754,6 +1998,11 @@ function quoteLogfmtKey(key) {
1754
1998
  // ═══════════════════════════════════════════
1755
1999
  // Utilities
1756
2000
  // ═══════════════════════════════════════════
2001
+ // Arbitrary JSON names, including __proto__, are own data properties. A normal
2002
+ // bracket assignment would invoke Object.prototype's legacy prototype setter.
2003
+ function setJsonField(target, key, value) {
2004
+ Object.defineProperty(target, key, { value, enumerable: true, writable: true, configurable: true });
2005
+ }
1757
2006
  function sanitizeForJson(value, stack = new WeakSet(), depth = 0) {
1758
2007
  if (depth >= MAX_DEPTH)
1759
2008
  return MAX_DEPTH_MARKER;
@@ -1768,8 +2017,9 @@ function sanitizeForJson(value, stack = new WeakSet(), depth = 0) {
1768
2017
  const n = value;
1769
2018
  if (!Number.isFinite(n))
1770
2019
  return "<unsupported:number>";
1771
- if (Number.isInteger(n) && !Number.isSafeInteger(n))
1772
- return "<unsupported:unsafe-integer>";
2020
+ // An integral number past 2^53 is still a float64, like every SDK's native
2021
+ // float, and renders by the same canonical rule. A caller that needs such
2022
+ // an integer exactly passes a bigint.
1773
2023
  return n;
1774
2024
  }
1775
2025
  if (t === "bigint")
@@ -1804,7 +2054,7 @@ function sanitizeForJson(value, stack = new WeakSet(), depth = 0) {
1804
2054
  stack.add(obj);
1805
2055
  const out = {};
1806
2056
  for (const [k, v] of Object.entries(obj)) {
1807
- out[k] = sanitizeForJson(v, stack, depth + 1);
2057
+ setJsonField(out, k, sanitizeForJson(v, stack, depth + 1));
1808
2058
  }
1809
2059
  stack.delete(obj);
1810
2060
  return out;
@@ -1823,17 +2073,23 @@ function isObject(value) {
1823
2073
  function sortedObjectKeys(obj) {
1824
2074
  return Object.keys(obj).sort(jcsCompare);
1825
2075
  }
1826
- function sortJsonValue(value) {
2076
+ /**
2077
+ * A container nested inside a Plain array (or in a YAML scalar position) is
2078
+ * written as compact JSON with sorted keys. Its number leaves take the
2079
+ * canonical number form, the same text the value prints as a scalar, so a
2080
+ * nested `1.0` reads `1` and a decoded `1e5` reads `100000` in every SDK
2081
+ * rather than in each runtime's own JSON spelling.
2082
+ */
2083
+ function embeddedJson(value) {
1827
2084
  if (Array.isArray(value))
1828
- return value.map(sortJsonValue);
2085
+ return `[${value.map(embeddedJson).join(",")}]`;
1829
2086
  if (isObject(value)) {
1830
- const out = {};
1831
- for (const key of sortedObjectKeys(value)) {
1832
- out[key] = sortJsonValue(value[key]);
1833
- }
1834
- return out;
2087
+ const members = sortedObjectKeys(value).map((key) => `${JSON.stringify(key)}:${embeddedJson(value[key])}`);
2088
+ return `{${members.join(",")}}`;
1835
2089
  }
1836
- return value;
2090
+ if (typeof value === "string")
2091
+ return JSON.stringify(value);
2092
+ return plainScalar(value);
1837
2093
  }
1838
2094
  function jcsCompare(a, b) {
1839
2095
  const ua = Array.from(a).flatMap((c) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-first-data",
3
- "version": "0.35.0",
4
- "description": "A naming convention that lets AI agents understand your data without being told what it means, plus a CLI and library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.",
3
+ "version": "0.36.0",
4
+ "description": "A naming convention that lets AI agents understand your data without being told what it means, plus the afdata CLI and a Rust library for reading Markdown structure and safely editing structured JSON, TOML, YAML, dotenv, and INI documents.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
@@ -24,14 +24,14 @@
24
24
  "scripts": {
25
25
  "build": "tsc",
26
26
  "prepack": "npm run build",
27
- "test": "tsx --test src/*.test.ts"
27
+ "test": "node --import tsx --test src/*.test.ts"
28
28
  },
29
29
  "devDependencies": {
30
- "@types/node": "^26.6.4",
30
+ "@types/node": "^26.6.6",
31
31
  "tsx": "^4.23.15",
32
32
  "typescript": "^7.0.2"
33
33
  },
34
34
  "dependencies": {
35
- "lossless-json": "^4.3.1"
35
+ "lossless-json": "^4.3.3"
36
36
  }
37
37
  }