jq79 0.6.4 → 0.6.6

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/src/reactive.ts CHANGED
@@ -62,20 +62,69 @@ const walkLeaves = (obj: Record<string, any>, path: string, visit: (dotKey: stri
62
62
  //
63
63
  // Which of the two directions actually wakes an effect is the thing that makes
64
64
  // this fast, and it is not symmetric - see effectsFor and
65
- // TODOS/2026-08-23.narrow-the-wake-rule.md
65
+ // RECORD/2026-08-23.narrow-the-wake-rule.md
66
66
  //
67
67
  // `children`/`own`/`deep` are allocated on first use and `parent`/`segment` let
68
68
  // a removal walk back up without re-splitting the path: a 10,000-row table is
69
69
  // ~40,000 nodes, and three eager allocations each (a Map and two Sets, almost
70
70
  // all of them staying empty) cost more than the index saves
71
+ // The effects registered on one node, for one of the two channels. Almost every
72
+ // dep has exactly ONE effect - `rows.7.label` is read by one text binding and
73
+ // nothing else - so the first one lives in a slot of its own and a Set is
74
+ // allocated only for the rest. It is the mirror of what indexEffect does one
75
+ // layer up with soleDep/soleNode, and it is worth it because deleting an effect
76
+ // from a one-entry Set was 60% of disposing it
77
+ // (RECORD/2026-08-27.one-effect-per-node.md).
78
+ //
79
+ // TWO fields rather than one union, and that is the whole design: a field that
80
+ // holds either an Effect or a Set makes every read of it polymorphic, and these
81
+ // are read on the notify walk - the hot path of every write in the library. One
82
+ // field of one shape each keeps both reads monomorphic.
83
+ //
84
+ // No invariant that `one` fills before `many`: an effect removed from `one`
85
+ // leaves it null with `many` still populated, and nothing has to shuffle
71
86
  type TrieNode = {
72
87
  children: Map<string, TrieNode> | null
73
- own: Set<Effect> | null
74
- deep: Set<Effect> | null
88
+ own: Effect | null
89
+ ownMany: Set<Effect> | null
90
+ deep: Effect | null
91
+ deepMany: Set<Effect> | null
75
92
  parent: TrieNode | null
76
93
  segment: string
77
94
  }
78
95
 
96
+ export const addOwn = (node: TrieNode, effect: Effect) => {
97
+ if (node.own === null) node.own = effect
98
+ else if (node.own !== effect) (node.ownMany ??= new Set()).add(effect)
99
+ }
100
+
101
+ export const addDeep = (node: TrieNode, effect: Effect) => {
102
+ if (node.deep === null) node.deep = effect
103
+ else if (node.deep !== effect) (node.deepMany ??= new Set()).add(effect)
104
+ }
105
+
106
+ // a node whose last effect leaves has to end up empty on both fields, or
107
+ // isEmptyNode stops pruning it and the trie grows over the dead rows
108
+ export const removeOwn = (node: TrieNode, effect: Effect) => {
109
+ if (node.own === effect) node.own = null
110
+ else if (node.ownMany?.delete(effect) && node.ownMany.size === 0) node.ownMany = null
111
+ }
112
+
113
+ export const removeDeep = (node: TrieNode, effect: Effect) => {
114
+ if (node.deep === effect) node.deep = null
115
+ else if (node.deepMany?.delete(effect) && node.deepMany.size === 0) node.deepMany = null
116
+ }
117
+
118
+ export const eachOwn = (node: TrieNode, visit: (effect: Effect) => void) => {
119
+ if (node.own !== null) visit(node.own)
120
+ node.ownMany?.forEach(visit)
121
+ }
122
+
123
+ export const eachDeep = (node: TrieNode, visit: (effect: Effect) => void) => {
124
+ if (node.deep !== null) visit(node.deep)
125
+ node.deepMany?.forEach(visit)
126
+ }
127
+
79
128
  // the dep an `ownKeys` read records, as a reserved last segment on the
80
129
  // enumerated object's own path: an ordinary trie child that no walk for a real
81
130
  // key can reach, and that the subtree sweep still reaches when the whole object
@@ -92,10 +141,10 @@ const LENGTH_SUFFIX = ".length"
92
141
 
93
142
 
94
143
  const createTrieNode = (parent: TrieNode | null, segment: string): TrieNode =>
95
- ({ children: null, own: null, deep: null, parent, segment })
144
+ ({ children: null, own: null, ownMany: null, deep: null, deepMany: null, parent, segment })
96
145
 
97
146
  const isEmptyNode = (node: TrieNode): boolean =>
98
- !node.own?.size && !node.deep?.size && !node.children?.size
147
+ node.own === null && node.deep === null && !node.ownMany?.size && !node.deepMany?.size && !node.children?.size
99
148
 
100
149
  // reads the raw object behind a store proxy. Module-level (not per-store) so a
101
150
  // value that is already reactive - in this store or in another one - can be
@@ -144,7 +193,7 @@ export const untracked = <T>(fn: () => T): T => {
144
193
  // own, plus any it was attached to), each keeping that store's trie in step
145
194
  // with the deps of the last settled run. It lives on the effect rather than
146
195
  // in a per-store map because `run` has to reach it without a lookup
147
- type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
196
+ export type Effect = { deps: Set<string>; run: () => void; reindex: Set<(deps: Set<string>) => void>; deep: boolean; order: number }
148
197
 
149
198
  // creation order, module-wide. The flat `effects` set used to give this for
150
199
  // free - iterating it ran effects oldest-first, so a parent's bindings always
@@ -224,7 +273,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
224
273
  }
225
274
  node = child
226
275
  })
227
- ;((effect.deep ? (node.deep ??= new Set()) : (node.own ??= new Set()))).add(effect)
276
+ if (effect.deep) addDeep(node, effect)
277
+ else addOwn(node, effect)
228
278
  return node
229
279
  }
230
280
 
@@ -234,7 +284,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
234
284
  // table is 30,000 of these, and splitting each path again to find a node the
235
285
  // caller was already holding is most of what that used to cost
236
286
  const removeDep = (node: TrieNode, effect: Effect) => {
237
- ;(effect.deep ? node.deep : node.own)?.delete(effect)
287
+ if (effect.deep) removeDeep(node, effect)
288
+ else removeOwn(node, effect)
238
289
  let current: TrieNode | null = node
239
290
  while (current?.parent && isEmptyNode(current)) {
240
291
  current.parent.children!.delete(current.segment)
@@ -262,7 +313,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
262
313
  // `data[5].label = x`: an effect that read the array on its way to row
263
314
  // 7's label has no stake in row 5's, and waking all of them is what made
264
315
  // 100 row writes cost 100,000 effect runs - see
265
- // TODOS/2026-08-23.narrow-the-wake-rule.md
316
+ // RECORD/2026-08-23.narrow-the-wake-rule.md
266
317
  //
267
318
  // Returned as a snapshot, so the effects it wakes can reindex themselves -
268
319
  // or dispose each other - while it drains
@@ -272,8 +323,8 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
272
323
  const pending = from.children ? [...from.children.values()] : []
273
324
  while (pending.length) {
274
325
  const next = pending.pop()!
275
- next.own?.forEach(effect => matched.add(effect))
276
- next.deep?.forEach(effect => matched.add(effect))
326
+ eachOwn(next, effect => matched.add(effect))
327
+ eachDeep(next, effect => matched.add(effect))
277
328
  next.children?.forEach(child => pending.push(child))
278
329
  }
279
330
  }
@@ -285,21 +336,21 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
285
336
  node = node.children?.get(segments[depth])
286
337
  if (!node) return matched
287
338
  path = path ? `${path}.${segments[depth]}` : segments[depth]
288
- node.deep?.forEach(effect => matched.add(effect))
339
+ eachDeep(node, effect => matched.add(effect))
289
340
  // a nested store sits here: an effect that read through it holds this
290
341
  // path and nothing below it, so its own set is the whole channel
291
- if (bridges.has(path)) node.own?.forEach(effect => matched.add(effect))
342
+ if (bridges.has(path)) eachOwn(node, effect => matched.add(effect))
292
343
  // ...whereas an array's length stands for the array: everything that
293
344
  // read an element has to hear a truncation, and those deps are below
294
345
  if (depth === segments.length - 2 && segments[depth + 1] === "length") {
295
- node.own?.forEach(effect => matched.add(effect))
346
+ eachOwn(node, effect => matched.add(effect))
296
347
  sweep(node)
297
348
  }
298
349
  }
299
350
  node = node.children?.get(segments[segments.length - 1])
300
351
  if (!node) return matched
301
- node.own?.forEach(effect => matched.add(effect))
302
- node.deep?.forEach(effect => matched.add(effect))
352
+ eachOwn(node, effect => matched.add(effect))
353
+ eachDeep(node, effect => matched.add(effect))
303
354
  sweep(node)
304
355
  return matched
305
356
  }
@@ -458,7 +509,12 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
458
509
  // holding the same elements (see notifyReplaced)
459
510
  const wakeExactly = (dep: string) => {
460
511
  const node = nodeAt(dep)
461
- if (node) runMatched(new Set([...(node.own ?? []), ...(node.deep ?? [])]))
512
+ if (node) {
513
+ const matched = new Set<Effect>()
514
+ eachOwn(node, effect => matched.add(effect))
515
+ eachDeep(node, effect => matched.add(effect))
516
+ runMatched(matched)
517
+ }
462
518
  }
463
519
 
464
520
  // an object's key set changed. Effects only - a key set isn't a value, so
@@ -506,9 +562,6 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
506
562
  isPlainData(previous) && isPlainData(next) &&
507
563
  Array.isArray(previous) === Array.isArray(next)
508
564
 
509
- const keyCount = (container: any): number =>
510
- Array.isArray(container) ? container.length : Object.keys(container).length
511
-
512
565
  // which keys hold a different value than they did, or null when so many do
513
566
  // that notifying them one at a time would cost more than sweeping the
514
567
  // container. Arrays - the case this exists for - are walked by index, so a
@@ -520,7 +573,12 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
520
573
  // `exact` marks the keys as slots whose occupant *moved* rather than values
521
574
  // that changed: nothing under them is different, so the subtree each one
522
575
  // would otherwise sweep must be left alone (see splicedAt)
523
- type Difference = { keys: string[]; exact: boolean }
576
+ // `keysChanged` answers the `@keys` dep - what an effect records when it reads
577
+ // Object.keys(container). It is not `keys.length > 0`: a replacement can
578
+ // change every value and no key name at all, and it used to be decided by
579
+ // comparing key COUNTS, which misses swapping one name for another
580
+ // (RECORD/2026-08-25.two-defects-a-review-found.md)
581
+ type Difference = { keys: string[]; exact: boolean; keysChanged: boolean }
524
582
 
525
583
  const NOT_SPLICED = -1
526
584
 
@@ -530,7 +588,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
530
588
  // container differs" to the walk below, which gives up and has the whole
531
589
  // subtree swept; this recognises the shift for what it is. O(n), no
532
590
  // allocation, and O(1) to reject on any pair whose lengths differ by
533
- // anything but one. See TODOS/2026-08-23.notify-a-splice.md
591
+ // anything but one. See RECORD/2026-08-23.notify-a-splice.md
534
592
  const splicedAt = (previous: any[], next: any[]): number => {
535
593
  const grew = next.length > previous.length
536
594
  const shorter = grew ? previous : next
@@ -547,6 +605,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
547
605
  if (Array.isArray(next)) {
548
606
  const before = previous.length
549
607
  const after = next.length
608
+ // an array's keys are 0..length-1, so its length IS its key set - and
609
+ // nothing here walks the 10,000 elements a second time to learn that
610
+ const keysChanged = before !== after
550
611
  // nothing on one side means nothing to reuse on the other
551
612
  if (!before || !after) return GIVE_UP
552
613
  const span = Math.max(before, after)
@@ -555,7 +616,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
555
616
  if (start !== NOT_SPLICED) {
556
617
  const slots: string[] = []
557
618
  for (let index = start; index < span; index++) slots.push(String(index))
558
- return { keys: slots, exact: true }
619
+ return { keys: slots, exact: true, keysChanged }
559
620
  }
560
621
  }
561
622
  const changed: string[] = []
@@ -564,13 +625,22 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
564
625
  changed.push(String(index))
565
626
  if (changed.length * 2 >= span) return GIVE_UP
566
627
  }
567
- return { keys: changed, exact: false }
628
+ return { keys: changed, exact: false, keysChanged }
568
629
  }
569
- const keys = new Set([...Object.keys(previous), ...Object.keys(next)])
630
+ const previousKeys = Object.keys(previous)
631
+ const nextKeys = Object.keys(next)
632
+ const keys = new Set([...previousKeys, ...nextKeys])
570
633
  if (!keys.size) return GIVE_UP
571
634
  const changed: string[] = []
572
635
  keys.forEach(key => { if (!Object.is($toRaw(previous[key]), $toRaw(next[key]))) changed.push(key) })
573
- return changed.length * 2 >= keys.size ? GIVE_UP : { keys: changed, exact: false }
636
+ // Object.keys answers in insertion order, so the ORDER is part of the answer
637
+ // and a rebuild that sorts the same names - Object.fromEntries(entries.sort())
638
+ // - changes what an effect reading it sees. A union larger than either side
639
+ // would catch a swapped name; walking the two arrays catches that and the
640
+ // reorder both, off arrays this already had to build
641
+ const keysChanged =
642
+ previousKeys.length !== nextKeys.length || previousKeys.some((key, index) => key !== nextKeys[index])
643
+ return changed.length * 2 >= keys.size ? GIVE_UP : { keys: changed, exact: false, keysChanged }
574
644
  }
575
645
 
576
646
  // A container replaced by another container: notify the elements that
@@ -578,7 +648,7 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
578
648
  // `data = [...data, ...more]` holds the very same row objects at every index
579
649
  // it had before, so waking all thirty thousand of their bindings to re-render
580
650
  // identical output is ~150ms of a 208ms append - see
581
- // TODOS/2026-08-23.notify-the-difference.md
651
+ // RECORD/2026-08-23.notify-the-difference.md
582
652
  //
583
653
  // One level deep on purpose: an element that differs is a changed value, and
584
654
  // notifying it sweeps its own subtree, which is what a changed value deserves.
@@ -591,8 +661,10 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
591
661
  const matched = new Set<Effect>()
592
662
  const collectExact = (dep: string) => {
593
663
  const node = nodeAt(dep)
594
- node?.own?.forEach(effect => matched.add(effect))
595
- node?.deep?.forEach(effect => matched.add(effect))
664
+ if (node) {
665
+ eachOwn(node, effect => matched.add(effect))
666
+ eachDeep(node, effect => matched.add(effect))
667
+ }
596
668
  }
597
669
 
598
670
  const difference = whatChanged(previous, next)
@@ -631,7 +703,9 @@ export const $reactive = <T extends Record<string, any>>(data: T): ReactiveDeepD
631
703
  if (difference.exact) collectExact(child)
632
704
  else effectsFor(child).forEach(effect => matched.add(effect))
633
705
  })
634
- if (keyCount(previous) !== keyCount(next)) collectExact(keysPath(dotKey))
706
+ // the key SET, not the count: replacing { a, b, c, d } with { a, b, c, e }
707
+ // leaves both at four and changes what Object.keys answers
708
+ if (difference.keysChanged) collectExact(keysPath(dotKey))
635
709
  // an array's length is a real dep (a `:each` reads it on its way through
636
710
  // list.map) and is not one of the keys walked above. Collected exactly:
637
711
  // the sweep a length write normally carries is for a truncation, and the
@@ -939,7 +1013,7 @@ export type EffectScope = {
939
1013
  // is 70,000 objects that exist to hold, on the common path, three disposers.
940
1014
  // The prototype's methods are shared, and a row that registers nothing (a
941
1015
  // static template) now allocates one object and no arrays at all.
942
- // See TODOS/2026-08-23.where-the-create-time-goes.md
1016
+ // See RECORD/2026-08-23.where-the-create-time-goes.md
943
1017
  //
944
1018
  // Prototype methods need their receiver: a caller that hands one on as a bare
945
1019
  // function (`runSetupScript(..., fx.effect, ...)` did) must wrap it instead
package/src/transform.ts CHANGED
@@ -752,3 +752,150 @@ export const transformFactoryScript = (src: string): string | null => {
752
752
  return isFactory ? out : null
753
753
  }
754
754
 
755
+
756
+ // ---------------------------------------------------------------------------
757
+ // free identifiers in a template expression
758
+ //
759
+ // What a compiled expression reads from the scope, so it can be resolved by
760
+ // name instead of by `with ($scope)` - which is 60% of an evaluation over a
761
+ // store proxy, because it resolves every mention through [[HasProperty]] (the
762
+ // proxy's `has` trap as well as its `get`) and consults Symbol.unscopables
763
+ // besides. See RECORD/2026-08-27.name-resolution-without-with.md
764
+ //
765
+ // This is a scanner, not a parser, and it is deliberately conservative: `null`
766
+ // means "keep `with`", and every shape that binds a name, assigns to one or
767
+ // resolves one dynamically returns it. The caller falls back rather than
768
+ // guesses, so a name this misses costs a slower evaluation, never a wrong one.
769
+ // ---------------------------------------------------------------------------
770
+
771
+ // words that scan as an identifier and are never a free variable read
772
+ const NOT_A_FREE_NAME = new Set([
773
+ "true", "false", "null", "undefined", "this", "typeof", "instanceof", "in", "new", "void",
774
+ "delete", "of", "await", "yield", "NaN", "Infinity", "arguments", "case", "do", "else", "return",
775
+ ])
776
+
777
+ // anything that introduces a binding or reaches the scope by another route.
778
+ // A dynamic `import(...)` is absent deliberately: naming it here would put the
779
+ // word next to a quote in the bundle, which is what tests/no-bundle.test.ts
780
+ // greps for. It needs no entry - `import` is collected as a free name, the
781
+ // prologue's `const import = …` will not compile, and compileScoped falls back
782
+ const BINDS_A_NAME = new Set(["function", "class", "let", "const", "var", "eval", "with"])
783
+
784
+ // the compiled function's own parameters and temporary: an expression that
785
+ // mentions one would be reading the codegen's variable, not the author's
786
+ const CODEGEN_NAMES = new Set(["$scope", "$r", "$t"])
787
+
788
+ // operators that write, or bind - any of them and the expression keeps `with`
789
+ const WRITES_RE = /^(?:\+\+|--|=>|\*\*=|<<=|>>>=|>>=|&&=|\|\|=|\?\?=|[+\-*/%&|^]=(?!=))/
790
+ // operators that merely read, longest first so `===` is never read as `==` + `=`
791
+ const READS_RE = /^(?:>>>|===|!==|\*\*|<<|>>|==|!=|<=|>=|&&|\|\||\?\?|\?\.)/
792
+
793
+ const isIdentifierStart = (ch: string): boolean => /[A-Za-z_$]/.test(ch)
794
+
795
+ // a numeric literal, consumed whole so `1e5` does not scan as the name `e5`
796
+ const NUMBER_RE = /^(?:0[xXbBoO][\da-fA-F_]+|\d[\d_]*(?:\.[\d_]*)?(?:[eE][+-]?\d+)?)n?/
797
+
798
+ export const freeIdentifiers = (expr: string): string[] | null => {
799
+ const names = new Set<string>()
800
+ // one entry per open bracket, counting the `?`s waiting for their `:` at that
801
+ // depth - which is what tells `{ danger: x }`'s key from `a ? b : c`'s colon
802
+ const ternaries: number[] = [0]
803
+ const brackets: string[] = []
804
+ let shortCircuits = false
805
+ let prev = "" // the last meaningful token: "name", "value", "." or "?." or a punctuator
806
+ let i = 0
807
+
808
+ while (i < expr.length) {
809
+ const ch = expr[i]
810
+ if (/\s/.test(ch)) { i++; continue }
811
+ if (ch === "/" && expr[i + 1] === "/") { i = skipLineComment(expr, i); continue }
812
+ if (ch === "/" && expr[i + 1] === "*") { i = skipBlockComment(expr, i); continue }
813
+ if (ch === "/" && regexAllowed(expr, i)) { i = skipRegex(expr, i); prev = "value"; continue }
814
+ if (ch === '"' || ch === "'") { i = skipString(expr, i); prev = "value"; continue }
815
+
816
+ if (ch === "`") {
817
+ // a template literal reads names inside every ${...}, and nothing else
818
+ let j = i + 1
819
+ while (j < expr.length) {
820
+ if (expr[j] === "\\") { j += 2; continue }
821
+ if (expr[j] === "`") { j++; break }
822
+ if (expr[j] === "$" && expr[j + 1] === "{") {
823
+ let depth = 1
824
+ const start = (j += 2)
825
+ while (j < expr.length && depth > 0) {
826
+ if (expr[j] === "{") depth++
827
+ else if (expr[j] === "}") depth--
828
+ j++
829
+ }
830
+ const inner = freeIdentifiers(expr.slice(start, j - 1))
831
+ if (inner === null) return null
832
+ inner.forEach(name => names.add(name))
833
+ continue
834
+ }
835
+ j++
836
+ }
837
+ i = j
838
+ prev = "value"
839
+ continue
840
+ }
841
+
842
+ if (/\d/.test(ch) && prev !== "." && prev !== "?.") {
843
+ const match = NUMBER_RE.exec(expr.slice(i))
844
+ i += match ? match[0].length : 1
845
+ prev = "value"
846
+ continue
847
+ }
848
+
849
+ if (isIdentifierStart(ch)) {
850
+ let end = i
851
+ while (end < expr.length && /[\w$]/.test(expr[end])) end++
852
+ const word = expr.slice(i, end)
853
+ i = end
854
+ if (BINDS_A_NAME.has(word)) return null
855
+ if (prev === "." || prev === "?.") { prev = "value"; continue } // a property, not a name
856
+ if (NOT_A_FREE_NAME.has(word)) { prev = word === "this" ? "value" : "op"; continue }
857
+ if (CODEGEN_NAMES.has(word)) return null
858
+ let after = i
859
+ while (after < expr.length && /\s/.test(expr[after])) after++
860
+ // A free name that is CALLED is called with the with-object as its
861
+ // receiver: `add()` inside `with ($scope)` runs with `this === $scope`,
862
+ // and a factory component's `{ items: [], add() { this.items… } }`
863
+ // depends on it. A `const` cannot reproduce that - the call would get
864
+ // `undefined` - so a called name keeps `with`. Measured at 11 of this
865
+ // repository's 239 resolvable expressions, and 8 of those are handlers
866
+ if (expr[after] === "(") return null
867
+ // `{ danger: … }` names a key; `a ? b : c` names a value
868
+ const isKey = expr[after] === ":" && expr[after + 1] !== ":" &&
869
+ brackets[brackets.length - 1] === "{" && ternaries[ternaries.length - 1] === 0
870
+ if (!isKey) names.add(word)
871
+ prev = "value"
872
+ continue
873
+ }
874
+
875
+ const rest = expr.slice(i)
876
+ if (WRITES_RE.test(rest)) return null
877
+ const reads = READS_RE.exec(rest)
878
+ if (reads) {
879
+ if (reads[0] === "&&" || reads[0] === "||" || reads[0] === "??") shortCircuits = true
880
+ prev = reads[0] === "?." ? "?." : "op"
881
+ i += reads[0].length
882
+ continue
883
+ }
884
+ if (ch === "=") return null // a bare `=` left after READS_RE is an assignment
885
+
886
+ if (ch === "(" || ch === "[" || ch === "{") { brackets.push(ch); ternaries.push(0) }
887
+ else if (ch === ")" || ch === "]" || ch === "}") { brackets.pop(); if (ternaries.length > 1) ternaries.pop() }
888
+ else if (ch === "?") { ternaries[ternaries.length - 1]++; shortCircuits = true }
889
+ else if (ch === ":" && ternaries[ternaries.length - 1] > 0) ternaries[ternaries.length - 1]--
890
+
891
+ prev = ch
892
+ i++
893
+ }
894
+
895
+ // every name is resolved before the expression runs, so a branch that would
896
+ // not have been taken is read anyway: one more dep than `with` would track,
897
+ // and a ReferenceError for a name only the dead branch mentions. With one
898
+ // name there is no branch to be wrong about
899
+ if (shortCircuits && names.size > 1) return null
900
+ return [...names]
901
+ }