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 +4 -2
- package/assets/cli-spec-v1.schema.json +5 -2
- package/dist/cli.d.ts +12 -2
- package/dist/cli.js +43 -4
- package/dist/format.d.ts +13 -7
- package/dist/format.js +391 -135
- package/package.json +5 -5
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 `
|
|
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.
|
|
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.
|
|
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
|
|
158
|
-
*
|
|
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
|
-
|
|
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
|
|
269
|
-
*
|
|
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
|
-
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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),
|
|
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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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),
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
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
|
-
|
|
1168
|
-
|
|
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"
|
|
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
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1307
|
+
let literal;
|
|
1308
|
+
if (typeof value === "number") {
|
|
1309
|
+
if (!Number.isFinite(value))
|
|
1310
|
+
return null;
|
|
1311
|
+
literal = value.toString();
|
|
1180
1312
|
}
|
|
1181
|
-
|
|
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
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
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
|
-
|
|
1343
|
+
if (isLosslessNumber(value)) {
|
|
1194
1344
|
const literal = value.toString();
|
|
1195
1345
|
if (/^-?\d+$/.test(literal))
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
//
|
|
1238
|
-
|
|
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
|
-
|
|
1254
|
-
|
|
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
|
-
|
|
1263
|
-
|
|
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 (
|
|
1326
|
-
return [stripped, `${plainScalar(
|
|
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 (
|
|
1332
|
-
return [stripped, `${plainScalar(
|
|
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 (
|
|
1338
|
-
return [stripped, `${plainScalar(
|
|
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
|
-
|
|
1359
|
-
|
|
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 (
|
|
1365
|
-
return [stripped, `${plainScalar(
|
|
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 (
|
|
1382
|
-
return [stripped, `${plainScalar(
|
|
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 (
|
|
1388
|
-
return [stripped, `${plainScalar(
|
|
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(
|
|
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 (
|
|
1401
|
-
return [stripped, `${plainScalar(
|
|
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
|
|
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
|
-
|
|
1599
|
+
const numeric = isLosslessNumber(value) ? Number(value.toString()) : value;
|
|
1600
|
+
if (!isNum(numeric) || !Number.isFinite(numeric))
|
|
1456
1601
|
return null;
|
|
1457
|
-
if (Math.abs(
|
|
1458
|
-
return formatMsAsSeconds(
|
|
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
|
-
*
|
|
1473
|
-
*
|
|
1474
|
-
*
|
|
1475
|
-
*
|
|
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
|
|
1479
|
-
const
|
|
1480
|
-
|
|
1481
|
-
const
|
|
1482
|
-
const
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
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
|
|
1896
|
+
return numberText(value);
|
|
1653
1897
|
if (typeof value === "bigint")
|
|
1654
1898
|
return value.toString();
|
|
1655
|
-
// Decoded number:
|
|
1656
|
-
//
|
|
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(
|
|
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
|
|
1964
|
+
return numberText(value);
|
|
1721
1965
|
if (typeof value === "bigint")
|
|
1722
1966
|
return value.toString();
|
|
1723
|
-
// Decoded number:
|
|
1724
|
-
//
|
|
1725
|
-
// gets
|
|
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
|
|
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
|
|
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
|
-
|
|
1772
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
2085
|
+
return `[${value.map(embeddedJson).join(",")}]`;
|
|
1829
2086
|
if (isObject(value)) {
|
|
1830
|
-
const
|
|
1831
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "A naming convention that lets AI agents understand your data without being told what it means, plus
|
|
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.
|
|
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.
|
|
35
|
+
"lossless-json": "^4.3.3"
|
|
36
36
|
}
|
|
37
37
|
}
|