jev-devtools 0.5.1 → 0.5.2

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
@@ -1,10 +1,10 @@
1
1
  # Jev DevTools
2
2
 
3
- [![npm version 0.5.1](https://img.shields.io/badge/npm-v0.5.1-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.5.1)
3
+ [![npm version 0.5.2](https://img.shields.io/badge/npm-v0.5.2-E551BA?labelColor=0D0A0F&logo=npm)](https://www.npmjs.com/package/jev-devtools/v/0.5.2)
4
4
 
5
5
  A React drawer and Node adapter for inspecting TypeSafe Jev requests with animated diagrams. Includes request history, context, questions, typed answers, transport attempts, and raw snapshots.
6
6
 
7
- **Source version: 0.5.1.** Check the [release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for npm availability. If trying this source before publication, install the local archive below. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
7
+ **Source version: 0.5.2 — pending publication.** npm currently provides 0.5.1. Check the [release history](https://github.com/diegoag64/jev-devtools/blob/main/CHANGELOG.md) for compatibility notes. To try this release candidate, install the local archive below. **Experimental (pre-1.0).** React 18/19 and Node 22.12+ are supported. APIs may change before 1.0. Verification uses sample responses through the real TypeSafe SDK, not live inference.
8
8
 
9
9
  [Website](https://jevtools.dev/) · [Documentation](https://docs.jevtools.dev/) · [Quick start](https://docs.jevtools.dev/getting-started/) · [API reference](https://docs.jevtools.dev/api/reference/) · [Release history](https://docs.jevtools.dev/changelog/)
10
10
 
@@ -48,25 +48,25 @@ To try local changes before a future release:
48
48
  npm ci
49
49
  npm run pack:devtools
50
50
  # From the consuming app:
51
- npm install /absolute/path/to/jev-devtools-0.5.1.tgz
51
+ npm install /absolute/path/to/jev-devtools-0.5.2.tgz
52
52
  ```
53
53
 
54
54
  Mount once. The drawer loads its own styles; no CSS import is needed:
55
55
 
56
56
  ```tsx
57
- import { JevDevtools } from 'jev-devtools';
57
+ import { JevDevtools } from "jev-devtools";
58
58
 
59
- <JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />
59
+ <JevDevtools enabled={import.meta.env.DEV} defaultOpen={false} />;
60
60
  ```
61
61
 
62
62
  Create one shared Node adapter and instrumented client:
63
63
 
64
64
  ```ts
65
- import { createJevDevtools } from 'jev-devtools/server';
65
+ import { createJevDevtools } from "jev-devtools/server";
66
66
 
67
67
  const inspector = createJevDevtools({
68
- enabled: process.env.NODE_ENV === 'development',
69
- origin: 'http://localhost:3000',
68
+ enabled: process.env.NODE_ENV === "development",
69
+ origin: "http://localhost:3000",
70
70
  });
71
71
  const jev = inspector.createClient({ apiKey: process.env.TYPESAFE_API_KEY });
72
72
  app.use(inspector.middleware); // Mount at root, before fallback handlers.
@@ -95,27 +95,49 @@ Define calls, conditions, parallel checks, and named outcomes with `jev-devtools
95
95
  On the server, reuse the instrumented `jev` client and `inspector` from the setup above:
96
96
 
97
97
  ```ts
98
- import { defineJevFlow } from 'jev-devtools/flow';
99
-
100
- const review = defineJevFlow<{ message: string }>('review', (flow) => {
101
- const decision = flow.jev('readiness', {
102
- state: flow.input,
103
- questions: { ready: { type: 'noul', instructions: 'Is this ready for review?' } },
104
- }, { title: 'Assess readiness' });
105
-
106
- return flow.branch('route', {
107
- when: decision.answer('ready').noul.gte(0.9), // Your application's policy.
108
- then: (path) => path.output('ready', { probability: decision.answer('ready').noul }, {
109
- title: 'Ready for review', description: 'Return readiness to the application.',
110
- }),
111
- else: (path) => path.output('manual-review', undefined, { title: 'Needs manual review' }),
112
- }, { title: 'Require at least 90% readiness' });
98
+ import { defineJevFlow } from "jev-devtools/flow";
99
+
100
+ const review = defineJevFlow<{ message: string }>("review", (flow) => {
101
+ const decision = flow.jev(
102
+ "readiness",
103
+ {
104
+ state: flow.input,
105
+ questions: {
106
+ ready: { type: "noul", instructions: "Is this ready for review?" },
107
+ },
108
+ },
109
+ { title: "Assess readiness" },
110
+ );
111
+
112
+ return flow.branch(
113
+ "route",
114
+ {
115
+ when: decision.answer("ready").noul.gte(0.9), // Your application's policy.
116
+ then: (path) =>
117
+ path.output(
118
+ "ready",
119
+ { probability: decision.answer("ready").noul },
120
+ {
121
+ title: "Ready for review",
122
+ description: "Return readiness to the application.",
123
+ },
124
+ ),
125
+ else: (path) =>
126
+ path.output("manual-review", undefined, {
127
+ title: "Needs manual review",
128
+ }),
129
+ },
130
+ { title: "Require at least 90% readiness" },
131
+ );
113
132
  });
114
133
 
115
- const result = await review.run({ message: 'Please review my ticket.' }, {
116
- client: jev,
117
- devtools: inspector,
118
- });
134
+ const result = await review.run(
135
+ { message: "Please review my ticket." },
136
+ {
137
+ client: jev,
138
+ devtools: inspector,
139
+ },
140
+ );
119
141
  // Handle result.outcome in your app. The flow does not execute business actions.
120
142
  ```
121
143
 
@@ -53,7 +53,7 @@ type Request<Q extends Questions> = Omit<SystemOneRequest<Q>, "state"> & {
53
53
  state: unknown;
54
54
  };
55
55
  /** Resolve nested references before invoking a server callback. */
56
- export type Resolved<T> = T extends FlowExpression<infer V> ? V : T extends readonly unknown[] ? {
56
+ export type Resolved<T> = T extends FlowExpression<infer V> ? V : T extends string | number | boolean | null | undefined ? T : T extends readonly unknown[] ? {
57
57
  [K in keyof T]: Resolved<T[K]>;
58
58
  } : T extends object ? {
59
59
  [K in keyof T]: Resolved<T[K]>;
@@ -100,9 +100,11 @@ export interface FlowBuilder<T> {
100
100
  jev<const Q extends Questions>(id: string, request: Request<Q>, display?: FlowDisplay): AnswerRefs<Q>;
101
101
  jev<I, const Q extends Questions>(id: string, request: FlowRequestFactory<I, Q>, display?: FlowDisplay): AnswerRefs<Q>;
102
102
  task<I, O>(id: string, task: FlowTask<I, O>, display?: FlowDisplay): FlowExpression<Awaited<O>>;
103
- /** Execute a reusable item program with bounded concurrency; return results in input order. */
103
+ /** References and scalar arrays already carry their resolved item type. */
104
104
  map<I, C, O>(id: string, options: {
105
- items: FlowExpression<readonly I[]> | readonly I[];
105
+ items: FlowExpression<readonly I[]> | (readonly I[] & readonly (string | number | {
106
+ valueOf(): boolean;
107
+ } | null)[]);
106
108
  input: C;
107
109
  concurrency?: number;
108
110
  maxItems: number;
@@ -111,17 +113,43 @@ export interface FlowBuilder<T> {
111
113
  index: number;
112
114
  context: Resolved<C>;
113
115
  }>) => FlowExpression<O>, display?: FlowDisplay): FlowExpression<O[]>;
114
- /** Pre-test loop with JSON state, explicit context and a required iteration limit. */
116
+ /** Execute a reusable program over resolved items; return results in input order. */
117
+ map<I, C, O>(id: string, options: {
118
+ items: FlowExpression<readonly I[]> | readonly I[];
119
+ input: C;
120
+ concurrency?: number;
121
+ maxItems: number;
122
+ }, build: (item: FlowBuilder<{
123
+ item: Resolved<I>;
124
+ index: number;
125
+ context: Resolved<C>;
126
+ }>) => FlowExpression<O>, display?: FlowDisplay): FlowExpression<O[]>;
127
+ /** References and scalar initial values preserve their generic state type. */
115
128
  loop<S, C>(id: string, options: {
116
129
  initial: S | FlowExpression<S>;
117
130
  input: C;
118
131
  while: (state: FlowExpression<NoInfer<S>>) => FlowExpression<boolean>;
119
132
  maxIterations: number;
133
+ } & {
134
+ initial: FlowExpression<S> | string | number | {
135
+ valueOf(): boolean;
136
+ } | null;
120
137
  }, build: (iteration: FlowBuilder<{
121
138
  state: S;
122
139
  index: number;
123
140
  context: Resolved<C>;
124
141
  }>) => FlowExpression<NoInfer<S>>, display?: FlowDisplay): FlowExpression<S>;
142
+ /** Pre-test loop over resolved JSON state, with explicit context and an iteration limit. */
143
+ loop<S, C>(id: string, options: {
144
+ initial: S | FlowExpression<S>;
145
+ input: C;
146
+ while: (state: FlowExpression<NoInfer<Resolved<S>>>) => FlowExpression<boolean>;
147
+ maxIterations: number;
148
+ }, build: (iteration: FlowBuilder<{
149
+ state: Resolved<S>;
150
+ index: number;
151
+ context: Resolved<C>;
152
+ }>) => FlowExpression<NoInfer<Resolved<S>>>, display?: FlowDisplay): FlowExpression<Resolved<S>>;
125
153
  /** Execute one value-returning path, then continue with its typed result. */
126
154
  if<A, B>(id: string, paths: {
127
155
  when: FlowExpression<boolean>;
package/lib/flow/index.js CHANGED
@@ -6,21 +6,38 @@ export { flowFixture } from "../src/flow-fixture.js";
6
6
  export { compareWithFixture, formatComparison, } from "./compare.js";
7
7
  const owners = new WeakMap();
8
8
  const builderIds = new WeakMap();
9
+ const astBudgets = new WeakMap();
9
10
  function freezeAst(ast, depth = 0) {
10
11
  if (depth > 80)
11
12
  throw new Error("Expression nesting exceeds 80");
12
- if (Object.isFrozen(ast))
13
- return;
13
+ const cached = astBudgets.get(ast);
14
+ if (cached) {
15
+ if (depth + cached.depth > 80)
16
+ throw new Error("Expression nesting exceeds 80");
17
+ return cached;
18
+ }
19
+ const budget = { depth: 0, nodes: 1 };
20
+ const include = (child) => {
21
+ const childBudget = freezeAst(child, depth + 1);
22
+ budget.depth = Math.max(budget.depth, childBudget.depth + 1);
23
+ // Visitors expand each occurrence, even when multiple operands share a child.
24
+ budget.nodes += childBudget.nodes;
25
+ if (budget.nodes > 4096)
26
+ throw new Error("Expression size exceeds 4096 expanded nodes");
27
+ };
14
28
  if (ast.op === "get")
15
- freezeAst(ast.from, depth + 1);
29
+ include(ast.from);
16
30
  if ("args" in ast) {
17
31
  if (ast.args.length > 256)
18
32
  throw new Error("Too many condition operands");
19
33
  for (const child of ast.args)
20
- freezeAst(child, depth + 1);
34
+ include(child);
21
35
  Object.freeze(ast.args);
22
36
  }
23
37
  Object.freeze(ast);
38
+ // Frozen caller-supplied ASTs still need validation; only our cache is trusted.
39
+ astBudgets.set(ast, budget);
40
+ return budget;
24
41
  }
25
42
  /** Inspectable expression, not an application callback. Use get() for input fields. */
26
43
  export class FlowExpression {
@@ -123,21 +140,47 @@ const inspectAst = (ast, ref) => {
123
140
  ast.args.forEach((a) => inspectAst(a, ref));
124
141
  };
125
142
  function describe(ast) {
126
- if (ast.op === "input")
127
- return "input";
128
- if (ast.op === "ref")
129
- return ast.id;
130
- if (ast.op === "get")
131
- return `${describe(ast.from)}.${ast.key}`;
132
- if (ast.op === "literal")
133
- return "[literal]";
134
- return `${ast.op}(${ast.args.map(describe).join(", ")})`;
143
+ const limit = 4096;
144
+ const chunks = [];
145
+ let remaining = limit;
146
+ const append = (text) => {
147
+ chunks.push(text.slice(0, remaining));
148
+ const fits = text.length <= remaining;
149
+ remaining -= Math.min(text.length, remaining);
150
+ return fits;
151
+ };
152
+ const visit = (node) => {
153
+ if (node.op === "input")
154
+ return append("input");
155
+ if (node.op === "ref")
156
+ return append(node.id);
157
+ if (node.op === "get")
158
+ return visit(node.from) && append(".") && append(node.key);
159
+ if (node.op === "literal")
160
+ return append("[literal]");
161
+ if (!append(node.op) || !append("("))
162
+ return false;
163
+ for (let i = 0; i < node.args.length; i++) {
164
+ if (i > 0 && !append(", "))
165
+ return false;
166
+ if (!visit(node.args[i]))
167
+ return false;
168
+ }
169
+ return append(")");
170
+ };
171
+ const complete = visit(ast);
172
+ const text = chunks.join("");
173
+ const marker = "[condition truncated]";
174
+ return complete ? text : text.slice(0, limit - marker.length) + marker;
135
175
  }
136
176
  function conditionRule(ast, requests, tasks) {
137
177
  let remaining = 128;
178
+ const warnings = new Set();
138
179
  const visit = (node) => {
139
- if (--remaining < 0)
180
+ if (--remaining < 0) {
181
+ warnings.add("Rule projection limit reached.");
140
182
  return { op: "literal", value: "[rule truncated]" };
183
+ }
141
184
  if (node.op === "input" || node.op === "ref" || node.op === "get") {
142
185
  const path = [];
143
186
  let base = node;
@@ -179,16 +222,20 @@ function conditionRule(ast, requests, tasks) {
179
222
  : {}),
180
223
  };
181
224
  }
182
- if (node.op === "literal")
225
+ if (node.op === "literal") {
226
+ const scalar = node.value === null ||
227
+ typeof node.value === "string" ||
228
+ typeof node.value === "boolean" ||
229
+ (typeof node.value === "number" && Number.isFinite(node.value));
230
+ if (!scalar)
231
+ warnings.add("Rule literal capture unavailable.");
183
232
  return {
184
233
  op: "literal",
185
- value: node.value === null ||
186
- typeof node.value === "string" ||
187
- typeof node.value === "boolean" ||
188
- (typeof node.value === "number" && Number.isFinite(node.value))
234
+ value: scalar
189
235
  ? node.value
190
236
  : "[value unavailable]",
191
237
  };
238
+ }
192
239
  const args = [];
193
240
  for (const arg of node.args) {
194
241
  args.push(visit(arg));
@@ -197,7 +244,8 @@ function conditionRule(ast, requests, tasks) {
197
244
  }
198
245
  return { op: node.op, args };
199
246
  };
200
- return visit(ast);
247
+ const rule = visit(ast);
248
+ return { conditionRule: rule, conditionCaptureWarnings: [...warnings] };
201
249
  }
202
250
  function evaluate(ast, input, values) {
203
251
  if (ast.op === "input")
@@ -492,7 +540,7 @@ export function defineJevFlow(name, build) {
492
540
  return { ...ast, args: ast.args.map(referenceState) };
493
541
  return ast;
494
542
  };
495
- node.conditionRule = conditionRule(referenceState(when.ast), new Set(), new Set([id]));
543
+ Object.assign(node, conditionRule(referenceState(when.ast), new Set(), new Set([id])));
496
544
  return new FlowExpression({ op: "ref", id }, owner);
497
545
  },
498
546
  if(id, paths, display) {
@@ -502,9 +550,11 @@ export function defineJevFlow(name, build) {
502
550
  step.when = paths.when;
503
551
  const node = nodes.find((n) => n.id === id);
504
552
  node.condition = describe(paths.when.ast);
505
- node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
506
- .filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
507
- .map((n) => n.id)));
553
+ Object.assign(node, conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
554
+ .filter((n) => n.kind === "task" ||
555
+ n.kind === "merge" ||
556
+ n.kind === "loop")
557
+ .map((n) => n.id))));
508
558
  step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, false, true);
509
559
  step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, false, true);
510
560
  // A separate merge keeps the diagram acyclic and gives downstream refs a
@@ -534,9 +584,11 @@ export function defineJevFlow(name, build) {
534
584
  throw new Error("branch requires a condition expression");
535
585
  const node = nodes.find((n) => n.id === id);
536
586
  node.condition = describe(paths.when.ast);
537
- node.conditionRule = conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
538
- .filter((n) => n.kind === "task" || n.kind === "merge" || n.kind === "loop")
539
- .map((n) => n.id)));
587
+ Object.assign(node, conditionRule(paths.when.ast, new Set(nodes.filter((n) => n.kind === "jev").map((n) => n.id)), new Set(nodes
588
+ .filter((n) => n.kind === "task" ||
589
+ n.kind === "merge" ||
590
+ n.kind === "loop")
591
+ .map((n) => n.id))));
540
592
  step.then = block(paths.then, new Set(available), [...path, `${id}:then`], [id], depth + 1, true);
541
593
  step.else = block(paths.else, new Set(available), [...path, `${id}:else`], [id], depth + 1, true);
542
594
  terminal = true;
@@ -624,6 +676,7 @@ export function defineJevFlow(name, build) {
624
676
  if (node.lanes)
625
677
  Object.freeze(node.lanes);
626
678
  freezeRule(node.conditionRule);
679
+ freezeRule(node.conditionCaptureWarnings);
627
680
  freezeRule(node.itemDefinition);
628
681
  Object.freeze(node);
629
682
  }
@@ -684,49 +737,73 @@ export function defineJevFlow(name, build) {
684
737
  if (value && typeof value === "object" && observerData.has(value))
685
738
  return observerData.get(value);
686
739
  let snapshot;
740
+ const warnings = [];
687
741
  try {
688
742
  snapshot = observerSnapshot(value);
689
743
  }
690
744
  catch {
691
745
  // Direct flow outputs may be non-JSON; only their capture is unavailable.
692
746
  snapshot = "[capture unavailable]";
747
+ warnings.push("Observer capture unavailable.");
693
748
  }
749
+ const captured = { data: snapshot, warnings };
694
750
  if (value && typeof value === "object")
695
- observerData.set(value, snapshot);
696
- return snapshot;
751
+ observerData.set(value, captured);
752
+ return captured;
697
753
  };
698
754
  const emit = () => {
699
755
  try {
700
756
  if (observer) {
701
757
  const { value, steps, ...metadata } = run;
758
+ const outcome = value !== undefined ? snapshotData(value) : undefined;
702
759
  observer.update({
703
760
  ...structuredClone(metadata),
704
- steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => [
705
- id,
706
- {
707
- ...step,
708
- ...(step.data !== undefined
709
- ? { data: snapshotData(step.data) }
710
- : {}),
711
- ...(step.loop ? { loop: { ...step.loop } } : {}),
712
- ...(step.captureWarnings
713
- ? { captureWarnings: [...step.captureWarnings] }
714
- : {}),
715
- ...(step.collection
716
- ? {
717
- collection: {
718
- ...step.collection,
719
- // Child runs have already crossed this snapshot boundary.
720
- // Copy mutable collection metadata, reuse detached children.
721
- items: step.collection.items.map((item) => ({
722
- ...item,
723
- })),
724
- },
725
- }
726
- : {}),
727
- },
728
- ])),
729
- ...(value !== undefined ? { value: snapshotData(value) } : {}),
761
+ steps: Object.fromEntries(Object.entries(steps).map(([id, step]) => {
762
+ const data = step.data !== undefined
763
+ ? snapshotData(step.data)
764
+ : undefined;
765
+ return [
766
+ id,
767
+ {
768
+ ...step,
769
+ ...(data ? { data: data.data } : {}),
770
+ ...(step.loop ? { loop: { ...step.loop } } : {}),
771
+ ...(data || step.captureWarnings
772
+ ? {
773
+ captureWarnings: [
774
+ ...new Set([
775
+ ...(step.captureWarnings ?? []),
776
+ ...(data?.warnings ?? []),
777
+ ]),
778
+ ],
779
+ }
780
+ : {}),
781
+ ...(step.collection
782
+ ? {
783
+ collection: {
784
+ ...step.collection,
785
+ // Child runs have already crossed this snapshot boundary.
786
+ // Copy mutable collection metadata, reuse detached children.
787
+ items: step.collection.items.map((item) => ({
788
+ ...item,
789
+ })),
790
+ },
791
+ }
792
+ : {}),
793
+ },
794
+ ];
795
+ })),
796
+ ...(outcome
797
+ ? {
798
+ value: outcome.data,
799
+ captureWarnings: [
800
+ ...new Set([
801
+ ...(run.captureWarnings ?? []),
802
+ ...outcome.warnings,
803
+ ]),
804
+ ],
805
+ }
806
+ : {}),
730
807
  });
731
808
  }
732
809
  }
@@ -1004,6 +1081,7 @@ export function defineJevFlow(name, build) {
1004
1081
  }
1005
1082
  catch {
1006
1083
  state.data = "Selected value could not be captured as JSON.";
1084
+ state.captureWarnings = ["Selected value capture unavailable."];
1007
1085
  }
1008
1086
  }
1009
1087
  else if (step.kind === "branch" || step.kind === "if") {
@@ -36,14 +36,17 @@ export class FlowRecorder {
36
36
  redactRules(node.itemDefinition, [...prefix, node.id, "items"]);
37
37
  if (node.conditionRule === undefined)
38
38
  continue;
39
+ const warnings = new Set(node.conditionCaptureWarnings);
39
40
  try {
40
- node.conditionRule = capture(node.conditionRule, new Set(), this.options.redact
41
+ node.conditionRule = capture(node.conditionRule, warnings, this.options.redact
41
42
  ? (path, value) => this.options.redact(["conditions", ...prefix, node.id, ...path], value)
42
43
  : undefined);
43
44
  }
44
45
  catch {
45
46
  node.conditionRule = "[rule capture unavailable]";
47
+ warnings.add("Rule capture unavailable.");
46
48
  }
49
+ node.conditionCaptureWarnings = [...warnings];
47
50
  }
48
51
  };
49
52
  redactRules(stored);
@@ -117,20 +120,28 @@ export class FlowRecorder {
117
120
  {
118
121
  ...step,
119
122
  data: captured.data,
120
- captureWarnings: captured.warnings,
123
+ captureWarnings: [
124
+ ...new Set([
125
+ ...(step.captureWarnings ?? []),
126
+ ...captured.warnings,
127
+ ]),
128
+ ],
121
129
  },
122
130
  ];
123
131
  })),
124
132
  };
125
133
  if ("value" in stored) {
134
+ const warnings = new Set(run.captureWarnings);
126
135
  try {
127
- stored.value = capture(stored.value, new Set(), this.options.redact
136
+ stored.value = capture(stored.value, warnings, this.options.redact
128
137
  ? (path, value) => this.options.redact([...prefix, ...path], value)
129
138
  : undefined);
130
139
  }
131
140
  catch {
132
141
  stored.value = "[capture unavailable]";
142
+ warnings.add("Outcome capture unavailable.");
133
143
  }
144
+ stored.captureWarnings = [...warnings];
134
145
  }
135
146
  if (prefix.length)
136
147
  this.itemCaptures.set(run, stored);
@@ -36,10 +36,16 @@ export function capture(value, warnings, redact) {
36
36
  let budget = 96_000;
37
37
  const visit = (input, path, depth) => {
38
38
  const key = (path.at(-1) ?? "").replace(/[-_]/g, "").toLowerCase();
39
- if (credentialKeys.has(key))
39
+ if (credentialKeys.has(key)) {
40
+ warnings.add("Capture contains redacted values.");
40
41
  return "[redacted]";
41
- if (redact)
42
- input = redact(path, input);
42
+ }
43
+ if (redact) {
44
+ const redacted = redact(path, input);
45
+ if (!Object.is(redacted, input))
46
+ warnings.add("Capture contains redacted values.");
47
+ input = redacted;
48
+ }
43
49
  budget -= 8 + (path.at(-1)?.length ?? 0);
44
50
  if (budget <= 0 || depth > 12) {
45
51
  warnings.add("Capture size/depth limit reached.");