@assistant-ui/react-generative-ui 0.0.15 → 0.0.17

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/dist/convert/boundSpec.d.ts +51 -0
  2. package/dist/convert/boundSpec.d.ts.map +1 -0
  3. package/dist/convert/boundSpec.js +108 -0
  4. package/dist/convert/boundSpec.js.map +1 -0
  5. package/dist/convert/copyBounded.d.ts +28 -0
  6. package/dist/convert/copyBounded.d.ts.map +1 -0
  7. package/dist/convert/copyBounded.js +38 -0
  8. package/dist/convert/copyBounded.js.map +1 -0
  9. package/dist/convert/isElement.d.ts +6 -0
  10. package/dist/convert/isElement.d.ts.map +1 -0
  11. package/dist/convert/isElement.js +6 -0
  12. package/dist/convert/isElement.js.map +1 -0
  13. package/dist/convert/takeRun.d.ts +9 -0
  14. package/dist/convert/takeRun.d.ts.map +1 -0
  15. package/dist/convert/takeRun.js +20 -0
  16. package/dist/convert/takeRun.js.map +1 -0
  17. package/dist/slack/constants.d.ts +1 -26
  18. package/dist/slack/constants.d.ts.map +1 -1
  19. package/dist/slack/constants.js +1 -26
  20. package/dist/slack/constants.js.map +1 -1
  21. package/dist/slack/fromSlackBlocks.js +6 -10
  22. package/dist/slack/fromSlackBlocks.js.map +1 -1
  23. package/dist/slack/toSlackBlocks.d.ts.map +1 -1
  24. package/dist/slack/toSlackBlocks.js +28 -95
  25. package/dist/slack/toSlackBlocks.js.map +1 -1
  26. package/dist/teams/constants.d.ts +1 -28
  27. package/dist/teams/constants.d.ts.map +1 -1
  28. package/dist/teams/constants.js +1 -32
  29. package/dist/teams/constants.js.map +1 -1
  30. package/dist/teams/toAdaptiveCard.d.ts.map +1 -1
  31. package/dist/teams/toAdaptiveCard.js +12 -23
  32. package/dist/teams/toAdaptiveCard.js.map +1 -1
  33. package/dist/teams/toTeamsAttachments.d.ts.map +1 -1
  34. package/dist/teams/toTeamsAttachments.js +5 -9
  35. package/dist/teams/toTeamsAttachments.js.map +1 -1
  36. package/dist/vocabulary/list.js +4 -2
  37. package/dist/vocabulary/list.js.map +1 -1
  38. package/package.json +6 -6
  39. package/src/convert/boundSpec.test.ts +192 -0
  40. package/src/convert/boundSpec.ts +134 -0
  41. package/src/convert/copyBounded.test.ts +131 -0
  42. package/src/convert/copyBounded.ts +38 -0
  43. package/src/convert/isElement.ts +6 -0
  44. package/src/convert/takeRun.test.ts +64 -0
  45. package/src/convert/takeRun.ts +19 -0
  46. package/src/slack/constants.ts +0 -29
  47. package/src/slack/fromSlackBlocks.test.ts +105 -7
  48. package/src/slack/fromSlackBlocks.ts +9 -9
  49. package/src/slack/toSlackBlocks.test.ts +82 -2
  50. package/src/slack/toSlackBlocks.ts +68 -164
  51. package/src/teams/constants.ts +0 -42
  52. package/src/teams/toAdaptiveCard.test.ts +74 -2
  53. package/src/teams/toAdaptiveCard.ts +29 -36
  54. package/src/teams/toTeamsAttachments.ts +4 -17
  55. package/src/vocabulary/list.test.tsx +35 -9
  56. package/src/vocabulary/list.tsx +4 -2
  57. package/dist/teams/boundSpec.d.ts +0 -20
  58. package/dist/teams/boundSpec.d.ts.map +0 -1
  59. package/dist/teams/boundSpec.js +0 -67
  60. package/dist/teams/boundSpec.js.map +0 -1
  61. package/src/teams/boundSpec.ts +0 -102
@@ -0,0 +1,134 @@
1
+ import { copyBounded } from "./copyBounded";
2
+
3
+ /** The maximum traversal depth, counted in visited values, not tree levels. */
4
+ export const MAX_TRAVERSAL_DEPTH = 64;
5
+
6
+ /**
7
+ * The element-nesting ceiling a caller observes. `boundNode` visits an element
8
+ * and its `children` array separately, so one level of nesting spends two of
9
+ * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
10
+ * the looser of the two. A root array costs one more level than a root object.
11
+ */
12
+ export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
13
+
14
+ /**
15
+ * The maximum number of entries kept at any single level (root, or a
16
+ * `children` array at any depth) of the raw spec, before it ever reaches
17
+ * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass
18
+ * can never be made to walk further than this regardless of what the array
19
+ * claims about itself.
20
+ */
21
+ export const CHILDREN_CAP = 200;
22
+
23
+ /**
24
+ * The total number of nodes {@link boundSpec} will visit across one walk,
25
+ * regardless of how many times a shared reference recurs. Bounds the
26
+ * combinatorial work a DAG of shared or self-referential nodes would
27
+ * otherwise force even though each individual array stays within
28
+ * {@link CHILDREN_CAP}.
29
+ */
30
+ export const NODE_BUDGET = 5000;
31
+
32
+ export type ClampReason = "children" | "budget" | "cycle" | "depth";
33
+
34
+ /** Renders the warning detail a protocol reports for one {@link ClampReason}. */
35
+ export function clampReasonDetail(reason: ClampReason): string {
36
+ if (reason === "budget") {
37
+ return `the tree was truncated after ${NODE_BUDGET} nodes.`;
38
+ }
39
+ if (reason === "cycle") return "a self-referencing node was dropped.";
40
+ if (reason === "depth") {
41
+ return `nodes deeper than ${MAX_ELEMENT_DEPTH} levels were dropped.`;
42
+ }
43
+ return `children were clamped to ${CHILDREN_CAP} entries.`;
44
+ }
45
+
46
+ interface BoundState {
47
+ remaining: number;
48
+ exhausted: boolean;
49
+ }
50
+
51
+ function boundNode(
52
+ value: unknown,
53
+ depth: number,
54
+ onClamp: (reason: ClampReason) => void,
55
+ state: BoundState,
56
+ ancestors: WeakSet<object>,
57
+ ): unknown {
58
+ if (state.remaining <= 0) {
59
+ if (!state.exhausted) {
60
+ state.exhausted = true;
61
+ onClamp("budget");
62
+ }
63
+ return null;
64
+ }
65
+ state.remaining -= 1;
66
+ if (depth > MAX_TRAVERSAL_DEPTH) {
67
+ onClamp("depth");
68
+ return null;
69
+ }
70
+ if (Array.isArray(value)) {
71
+ if (ancestors.has(value)) {
72
+ onClamp("cycle");
73
+ return null;
74
+ }
75
+ ancestors.add(value);
76
+ const { items: bounded, truncated } = copyBounded(value, CHILDREN_CAP);
77
+ if (truncated) onClamp("children");
78
+ const result = bounded.map((item) =>
79
+ boundNode(item, depth + 1, onClamp, state, ancestors),
80
+ );
81
+ ancestors.delete(value);
82
+ return result;
83
+ }
84
+ if (value !== null && typeof value === "object") {
85
+ const record = value as Record<string, unknown>;
86
+ const children = record["children"];
87
+ // The spread performs a `children` read of its own, so the copy states the
88
+ // bounded value even when there is none.
89
+ if (children === undefined) return { ...record, children: undefined };
90
+ if (ancestors.has(value)) {
91
+ onClamp("cycle");
92
+ return null;
93
+ }
94
+ ancestors.add(value);
95
+ const result = {
96
+ ...record,
97
+ children: boundNode(children, depth + 1, onClamp, state, ancestors),
98
+ };
99
+ ancestors.delete(value);
100
+ return result;
101
+ }
102
+ return value;
103
+ }
104
+
105
+ /**
106
+ * Produces a bounded plain copy of a raw generative-ui spec before it
107
+ * reaches `normalizeSpec`, whose own traversal of the root array or any
108
+ * `children` array walks the full reported length of a hostile proxied
109
+ * array before any per-field cap downstream ever applies. Every array
110
+ * (root, or `children` at any depth) is capped to {@link CHILDREN_CAP}
111
+ * entries by index, which bounds a proxy however it fabricates `length` or
112
+ * replaces its array methods; recursion itself is capped at
113
+ * {@link MAX_TRAVERSAL_DEPTH}. Every object is returned as a plain copy whose
114
+ * `children` came from a single read, because a record that answers
115
+ * `[[HasProperty]]` and `[[Get]]`, or two `[[Get]]`s, differently would
116
+ * otherwise hand the bound one value and `normalizeSpec` another, skipping
117
+ * every bound here for that subtree. `onClamp` fires once per level that was
118
+ * truncated, receiving the reason for that truncation: `"children"`,
119
+ * `"depth"`, `"budget"`, or `"cycle"`. The walk also spends a total budget of
120
+ * {@link NODE_BUDGET} nodes, so shared references cannot multiply work
121
+ * exponentially, and a node that is its own ancestor is cut to `null`.
122
+ */
123
+ export function boundSpec(
124
+ spec: unknown,
125
+ onClamp: (reason: ClampReason) => void,
126
+ ): unknown {
127
+ return boundNode(
128
+ spec,
129
+ 0,
130
+ onClamp,
131
+ { remaining: NODE_BUDGET, exhausted: false },
132
+ new WeakSet(),
133
+ );
134
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { copyBounded } from "./copyBounded";
3
+
4
+ const hostileSpecies = (real: unknown[], injected: unknown[]) => {
5
+ function HostileCtor() {
6
+ return { map: () => injected, some: () => true, length: 0 };
7
+ }
8
+ const constructor = { [Symbol.species]: HostileCtor };
9
+ return new Proxy(real, {
10
+ get: (target, prop, receiver) =>
11
+ prop === "constructor"
12
+ ? constructor
13
+ : Reflect.get(target, prop, receiver),
14
+ });
15
+ };
16
+
17
+ describe("copyBounded", () => {
18
+ it("copies every entry of a shorter array", () => {
19
+ expect(copyBounded(["a", "b"], 5).items).toEqual(["a", "b"]);
20
+ });
21
+
22
+ it("stops at the cap", () => {
23
+ expect(copyBounded(["a", "b", "c"], 2).items).toEqual(["a", "b"]);
24
+ });
25
+
26
+ it("returns a fresh array rather than the input", () => {
27
+ const input = ["a"];
28
+ expect(copyBounded(input, 5).items).not.toBe(input);
29
+ });
30
+
31
+ it("leaves an absent index absent, as slice does", () => {
32
+ const sparse: unknown[] = [];
33
+ sparse[0] = "a";
34
+ sparse[2] = "c";
35
+
36
+ const { items: result } = copyBounded(sparse, 5);
37
+
38
+ expect(result).toHaveLength(3);
39
+ expect(1 in result).toBe(false);
40
+ expect(result).toEqual(sparse.slice(0, 5));
41
+ });
42
+
43
+ it("does not read an index the input reports as absent", () => {
44
+ const reads: string[] = [];
45
+ const probe = new Proxy(["a", "b"], {
46
+ has: (target, prop) => prop !== "1" && Reflect.has(target, prop),
47
+ get: (target, prop, receiver) => {
48
+ if (typeof prop === "string" && /^\d+$/.test(prop)) reads.push(prop);
49
+ return Reflect.get(target, prop, receiver);
50
+ },
51
+ });
52
+
53
+ const { items: result } = copyBounded(probe, 2);
54
+
55
+ expect(reads).toEqual(["0"]);
56
+ expect(1 in result).toBe(false);
57
+ });
58
+
59
+ it("reports truncation from the same length read that bounds the copy", () => {
60
+ let reads = 0;
61
+ const shifty = new Proxy(["a", "b", "c"], {
62
+ get: (target, prop, receiver) => {
63
+ if (prop === "length") {
64
+ reads += 1;
65
+ return reads === 1 ? 3 : 1;
66
+ }
67
+ return Reflect.get(target, prop, receiver);
68
+ },
69
+ });
70
+
71
+ const { items, truncated } = copyBounded(shifty, 2);
72
+
73
+ expect(items).toHaveLength(2);
74
+ expect(truncated).toBe(true);
75
+ });
76
+
77
+ it("matches slice for every shape of reported length", () => {
78
+ const reportedLengths = [2.5, Infinity, NaN, -1, "3", 2, 0, -0.5, 1e21];
79
+ const lying = (length: unknown) =>
80
+ new Proxy(["a", "b", "c"], {
81
+ get: (target, prop, receiver) =>
82
+ prop === "length" ? length : Reflect.get(target, prop, receiver),
83
+ });
84
+
85
+ for (const cap of [0, 1, 2, 5]) {
86
+ for (const length of reportedLengths) {
87
+ const { items, truncated } = copyBounded(lying(length), cap);
88
+
89
+ expect(items).toEqual(
90
+ Array.prototype.slice.call(lying(length), 0, cap),
91
+ );
92
+ expect(truncated).toBe(
93
+ Array.prototype.slice.call(lying(length), 0, cap + 1).length > cap,
94
+ );
95
+ }
96
+ }
97
+ });
98
+
99
+ it("bounds an array whose reported length is fabricated", () => {
100
+ const hostile = new Proxy(["a", "b"], {
101
+ get: (target, prop, receiver) =>
102
+ prop === "length"
103
+ ? Number.MAX_SAFE_INTEGER
104
+ : Reflect.get(target, prop, receiver),
105
+ });
106
+
107
+ expect(copyBounded(hostile, 3).items).toEqual(["a", "b", undefined]);
108
+ });
109
+
110
+ it("bounds an array that replaces its slice", () => {
111
+ const hostile = new Proxy(["a", "b"], {
112
+ get: (target, prop, receiver) =>
113
+ prop === "slice"
114
+ ? () => Array(500).fill("injected")
115
+ : Reflect.get(target, prop, receiver),
116
+ });
117
+
118
+ expect(copyBounded(hostile, 5).items).toEqual(["a", "b"]);
119
+ });
120
+
121
+ it("bounds an array that redirects Symbol.species to a foreign object", () => {
122
+ const injected = Array(500).fill("injected");
123
+ const { items: result } = copyBounded(
124
+ hostileSpecies(["a", "b"], injected),
125
+ 5,
126
+ );
127
+
128
+ expect(Array.isArray(result)).toBe(true);
129
+ expect(result).toEqual(["a", "b"]);
130
+ });
131
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Copies at most `cap` entries out of `value` by index.
3
+ *
4
+ * Every array method dispatches on the input: a replaced `slice` is called
5
+ * directly, and the intrinsic `Array.prototype.slice.call` still reaches a
6
+ * `constructor` with a `@@species` whose `[[Construct]]` returns an arbitrary
7
+ * object, which then supplies the `map` or `some` the caller runs next. Reading
8
+ * indices consults neither, so it is the only form that bounds a hostile array.
9
+ *
10
+ * Absent indices stay absent, matching what `slice` produces for a sparse
11
+ * input, so callers that skip holes still skip them. `truncated` comes from the
12
+ * same `length` read as the bound, so an input cannot report one figure to the
13
+ * cap and another to the caller's warning.
14
+ *
15
+ * A reported `length` is normalized the way `ToLength` does before it reaches
16
+ * `count`, because a fractional one would otherwise reach `items.length` and
17
+ * throw `RangeError` where `slice` returns a bounded result. `truncated` reads
18
+ * that same normalized figure, so a `length` whose `valueOf` answers differently
19
+ * on a second conversion cannot separate the warning from the bound.
20
+ */
21
+ export const copyBounded = <T>(
22
+ value: readonly T[],
23
+ cap: number,
24
+ ): { readonly items: T[]; readonly truncated: boolean } => {
25
+ const reported = value.length;
26
+ const integral = Math.trunc(Number(reported));
27
+ const usable =
28
+ Number.isNaN(integral) || integral <= 0
29
+ ? 0
30
+ : Math.min(integral, Number.MAX_SAFE_INTEGER);
31
+ const count = Math.min(cap, usable);
32
+ const items: T[] = [];
33
+ for (let index = 0; index < count; index += 1) {
34
+ if (index in value) items[index] = value[index] as T;
35
+ }
36
+ items.length = count;
37
+ return { items, truncated: usable > cap };
38
+ };
@@ -0,0 +1,6 @@
1
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
2
+
3
+ export const isElement = (
4
+ node: NormalizedUINode,
5
+ ): node is NormalizedUIElement =>
6
+ typeof node === "object" && node !== null && !Array.isArray(node);
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
3
+ import { takeRun } from "./takeRun";
4
+
5
+ const element = (type: string): NormalizedUIElement => ({ type, props: {} });
6
+
7
+ const isFact = (candidate: NormalizedUIElement) => candidate.type === "Fact";
8
+
9
+ describe("takeRun", () => {
10
+ it("collects the contiguous matching run and reports the resume index", () => {
11
+ const fact = element("Fact");
12
+ const nodes = [fact, fact, element("Text"), fact];
13
+
14
+ expect(takeRun(nodes, 0, isFact)).toEqual({ run: [fact, fact], next: 2 });
15
+ });
16
+
17
+ it("starts at the given index rather than the head of the list", () => {
18
+ const fact = element("Fact");
19
+ const nodes = [element("Text"), fact, fact];
20
+
21
+ expect(takeRun(nodes, 1, isFact)).toEqual({ run: [fact, fact], next: 3 });
22
+ });
23
+
24
+ it("stops at the end of the list", () => {
25
+ const fact = element("Fact");
26
+
27
+ expect(takeRun([fact, fact], 0, isFact)).toEqual({
28
+ run: [fact, fact],
29
+ next: 2,
30
+ });
31
+ });
32
+
33
+ it("returns an empty run without advancing when the first node does not match", () => {
34
+ expect(takeRun([element("Text"), element("Fact")], 0, isFact)).toEqual({
35
+ run: [],
36
+ next: 0,
37
+ });
38
+ });
39
+
40
+ it("stops on every non-element node shape", () => {
41
+ const fact = element("Fact");
42
+ const nonElements: (NormalizedUINode | undefined)[] = [
43
+ undefined,
44
+ null,
45
+ "",
46
+ "text",
47
+ 0,
48
+ 1,
49
+ [],
50
+ [fact],
51
+ ];
52
+
53
+ for (const nonElement of nonElements) {
54
+ expect(takeRun([fact, nonElement, fact], 0, isFact)).toEqual({
55
+ run: [fact],
56
+ next: 1,
57
+ });
58
+ }
59
+ });
60
+
61
+ it("stops past the end of the list", () => {
62
+ expect(takeRun([element("Fact")], 1, isFact)).toEqual({ run: [], next: 1 });
63
+ });
64
+ });
@@ -0,0 +1,19 @@
1
+ import type { NormalizedUIElement, NormalizedUINode } from "../ir";
2
+ import { isElement } from "./isElement";
3
+
4
+ export function takeRun(
5
+ nodes: readonly (NormalizedUINode | undefined)[],
6
+ index: number,
7
+ matches: (element: NormalizedUIElement) => boolean,
8
+ ): { run: NormalizedUIElement[]; next: number } {
9
+ const run: NormalizedUIElement[] = [];
10
+ let next = index;
11
+ while (next < nodes.length) {
12
+ const candidate = nodes[next];
13
+ if (candidate === undefined || !isElement(candidate) || !matches(candidate))
14
+ break;
15
+ run.push(candidate);
16
+ next += 1;
17
+ }
18
+ return { run, next };
19
+ }
@@ -11,35 +11,6 @@ import type {
11
11
  SlackTextObject,
12
12
  } from "./types";
13
13
 
14
- /** The maximum traversal depth, counted in visited values, not tree levels. */
15
- export const MAX_TRAVERSAL_DEPTH = 64;
16
-
17
- /**
18
- * The element-nesting ceiling a caller observes. `boundNode` visits an element
19
- * and its `children` array separately, so one level of nesting spends two of
20
- * {@link MAX_TRAVERSAL_DEPTH}; it runs before the conversion walk and is never
21
- * the looser of the two. A root array costs one more level than a root object.
22
- */
23
- export const MAX_ELEMENT_DEPTH = MAX_TRAVERSAL_DEPTH / 2;
24
-
25
- /**
26
- * The maximum number of entries kept at any single level (root, or a
27
- * `children` array at any depth) of the raw spec, before it ever reaches
28
- * `normalizeSpec`. Bounds a hostile array's reported `length` so the pre-pass
29
- * can never be made to walk further than this regardless of what the array
30
- * claims about itself.
31
- */
32
- export const CHILDREN_CAP = 200;
33
-
34
- /**
35
- * The total number of nodes the pre-normalization bounding walk will visit
36
- * across one call, regardless of how many times a shared reference recurs.
37
- * Bounds the combinatorial work a DAG of shared or self-referential nodes
38
- * would otherwise force even though each individual array stays within
39
- * {@link CHILDREN_CAP}.
40
- */
41
- export const NODE_BUDGET = 5000;
42
-
43
14
  /** The block limit for a Slack message. */
44
15
  export const MESSAGE_BLOCK_CAP = 50;
45
16
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
  import type { UIElement } from "../ir";
3
- import { INBOUND_BLOCK_CAP } from "./constants";
3
+ import { INBOUND_BLOCK_CAP, SELECT_OPTION_CAP } from "./constants";
4
4
  import { fromSlackBlocks } from "./fromSlackBlocks";
5
5
  import { toSlackBlocks } from "./toSlackBlocks";
6
6
  import type { ToSlackBlocksOptions } from "./types";
@@ -1118,14 +1118,112 @@ describe("fromSlackBlocks checkbox and radio caps", () => {
1118
1118
  });
1119
1119
  });
1120
1120
 
1121
- it("bounds hostile initial_options arrays when deriving defaultChecked", () => {
1122
- const hostile = new Proxy([], {
1123
- get(target, prop) {
1124
- if (prop === "length") return 2 ** 31;
1125
- if (prop === "slice") return Array.prototype.slice.bind(target);
1126
- return Reflect.get(target, prop);
1121
+ it("bounds static-select options when an array replaces its slice", () => {
1122
+ const hostile = new Proxy(
1123
+ Array.from({ length: SELECT_OPTION_CAP + 1 }, (_, index) => ({
1124
+ text: { type: "plain_text", text: `Option ${index}` },
1125
+ value: `value-${index}`,
1126
+ })),
1127
+ {
1128
+ get(target, prop, receiver) {
1129
+ if (prop === "slice") {
1130
+ return () =>
1131
+ Array.from({ length: SELECT_OPTION_CAP * 2 }, () => ({
1132
+ text: { type: "plain_text", text: "Injected" },
1133
+ value: "injected",
1134
+ }));
1135
+ }
1136
+ return Reflect.get(target, prop, receiver);
1137
+ },
1138
+ },
1139
+ );
1140
+
1141
+ const { nodes } = fromSlackBlocks([
1142
+ {
1143
+ type: "actions",
1144
+ elements: [
1145
+ { type: "static_select", action_id: "pick", options: hostile },
1146
+ ],
1127
1147
  },
1148
+ ]);
1149
+ const select = nodes[0] as unknown as { options: unknown[] };
1150
+
1151
+ expect(select.options).toHaveLength(SELECT_OPTION_CAP);
1152
+ expect(select.options[0]).toEqual({ label: "Option 0", value: "value-0" });
1153
+ expect(select.options.at(-1)).toEqual({
1154
+ label: `Option ${SELECT_OPTION_CAP - 1}`,
1155
+ value: `value-${SELECT_OPTION_CAP - 1}`,
1128
1156
  });
1157
+ });
1158
+
1159
+ it("skips a hole in a section fields array instead of decoding a blank fact", () => {
1160
+ const fields: unknown[] = [];
1161
+ fields[0] = { type: "mrkdwn", text: "*A*\nyes" };
1162
+ fields[2] = { type: "mrkdwn", text: "*C*\nyes" };
1163
+
1164
+ const { nodes, warnings } = fromSlackBlocks([{ type: "section", fields }]);
1165
+
1166
+ expect(warnings).toEqual([]);
1167
+ expect(nodes.filter(Boolean)).toEqual([
1168
+ { $type: "Fact", label: "A", value: "yes" },
1169
+ { $type: "Fact", label: "C", value: "yes" },
1170
+ ]);
1171
+ });
1172
+
1173
+ it("bounds static-select options when an array redirects Symbol.species", () => {
1174
+ function HostileCtor() {
1175
+ return {
1176
+ map: () =>
1177
+ Array.from({ length: SELECT_OPTION_CAP * 5 }, () => ({
1178
+ label: "Injected",
1179
+ value: "injected",
1180
+ })),
1181
+ length: 0,
1182
+ };
1183
+ }
1184
+ const constructor = { [Symbol.species]: HostileCtor };
1185
+ const hostile = new Proxy(
1186
+ Array.from({ length: SELECT_OPTION_CAP + 1 }, (_, index) => ({
1187
+ text: { type: "plain_text", text: `Option ${index}` },
1188
+ value: `value-${index}`,
1189
+ })),
1190
+ {
1191
+ get: (target, prop, receiver) =>
1192
+ prop === "constructor"
1193
+ ? constructor
1194
+ : Reflect.get(target, prop, receiver),
1195
+ },
1196
+ );
1197
+
1198
+ const { nodes } = fromSlackBlocks([
1199
+ {
1200
+ type: "actions",
1201
+ elements: [
1202
+ { type: "static_select", action_id: "pick", options: hostile },
1203
+ ],
1204
+ },
1205
+ ]);
1206
+ const select = nodes[0] as unknown as { options: unknown[] };
1207
+
1208
+ expect(select.options).toHaveLength(SELECT_OPTION_CAP);
1209
+ expect(select.options[0]).toEqual({ label: "Option 0", value: "value-0" });
1210
+ });
1211
+
1212
+ it("bounds hostile initial_options arrays when deriving defaultChecked", () => {
1213
+ const hostile = new Proxy(
1214
+ [{ text: { type: "plain_text", text: "Other" }, value: "other" }],
1215
+ {
1216
+ get(target, prop, receiver) {
1217
+ if (prop === "length") return 2 ** 31;
1218
+ if (prop === "slice") {
1219
+ return () => [
1220
+ { text: { type: "plain_text", text: "First" }, value: "first" },
1221
+ ];
1222
+ }
1223
+ return Reflect.get(target, prop, receiver);
1224
+ },
1225
+ },
1226
+ );
1129
1227
  const { nodes } = fromSlackBlocks([
1130
1228
  {
1131
1229
  type: "actions",
@@ -1,3 +1,4 @@
1
+ import { copyBounded } from "../convert/copyBounded";
1
2
  import type { Action, UIElement } from "../ir";
2
3
  import {
3
4
  ACTIONS_ELEMENT_CAP,
@@ -34,18 +35,16 @@ const warn = (
34
35
  };
35
36
 
36
37
  /**
37
- * The shared bounded-iteration primitive: slices `value` to `cap` entries
38
- * without ever reading past that many indices, so a hostile array (sparse or
39
- * proxied with a fabricated `length`) cannot stall the event loop. `slice`
40
- * itself performs the bounded read; only its own reported length is
41
- * inspected to detect truncation.
38
+ * The shared bounded-iteration primitive: copies `value` to at most `cap`
39
+ * entries without ever reading past that many indices, so a hostile array
40
+ * (sparse or proxied with a fabricated `length`) cannot stall the event loop.
42
41
  */
43
42
  const clampArray = (
44
43
  value: unknown,
45
44
  cap: number,
46
45
  ): { readonly items: unknown[]; readonly truncated: boolean } => {
47
46
  if (!Array.isArray(value)) return { items: [], truncated: false };
48
- return { items: value.slice(0, cap), truncated: value.length > cap };
47
+ return copyBounded(value, cap);
49
48
  };
50
49
 
51
50
  const boundedArray = (
@@ -238,9 +237,10 @@ const checkboxFrom = (element: Record<string, unknown>): UIElement => {
238
237
  : [];
239
238
  const firstValue =
240
239
  isRecord(first) && typeof first["value"] === "string" ? first["value"] : "";
241
- const firstChecked = initialOptions
242
- .slice(0, SELECT_OPTION_CAP)
243
- .some((option) => isRecord(option) && option["value"] === firstValue);
240
+ const firstChecked = copyBounded(
241
+ initialOptions,
242
+ SELECT_OPTION_CAP,
243
+ ).items.some((option) => isRecord(option) && option["value"] === firstValue);
244
244
  return {
245
245
  $type: "Checkbox",
246
246
  label: isRecord(first) ? textOf(first["text"]) : "",