poetry-core 0.1.0 → 0.1.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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -1
  3. data/README.md +0 -4
  4. data/app/components/poetry/core/component.rb +246 -7
  5. data/app/components/poetry/core/concerns/options.rb +1 -0
  6. data/app/components/poetry/core/concerns/styles.rb +46 -5
  7. data/app/components/poetry/core/style.rb +32 -1
  8. data/app/javascript/poetry/core/drawer_controller.js +28 -0
  9. data/app/javascript/poetry/core/hover_card_controller.js +26 -11
  10. data/app/javascript/poetry/core/message_scroller_controller.js +82 -4
  11. data/app/javascript/poetry/core/navigation_menu_controller.js +59 -4
  12. data/app/javascript/poetry/core/number_field_controller.js +24 -14
  13. data/config/component_registry.yml +2 -0
  14. data/config/controllers_manifest.json +2 -3
  15. data/lib/poetry/core/check/stable_identity.rb +14 -1
  16. data/lib/poetry/core/check.rb +182 -41
  17. data/lib/poetry/core/config.rb +15 -5
  18. data/lib/poetry/core/css/modes.rb +127 -0
  19. data/lib/poetry/core/css/override_scan.rb +17 -2
  20. data/lib/poetry/core/css/resolver.rb +21 -4
  21. data/lib/poetry/core/css/token_collisions.rb +184 -0
  22. data/lib/poetry/core/engine.rb +22 -0
  23. data/lib/poetry/core/host_components.rb +159 -0
  24. data/lib/poetry/core/host_helpers.rb +110 -0
  25. data/lib/poetry/core/html/attributes.rb +33 -2
  26. data/lib/poetry/core/llms_text.rb +39 -7
  27. data/lib/poetry/core/registry.rb +154 -5
  28. data/lib/poetry/core/skill_text.rb +27 -1
  29. data/lib/poetry/core/stimulus/declarations.rb +8 -3
  30. data/lib/poetry/core/stimulus/host_manifest.rb +556 -0
  31. data/lib/poetry/core/stimulus/manifest.rb +24 -0
  32. data/lib/poetry/core/tokens/generator.rb +10 -1
  33. data/lib/poetry/core/version.rb +1 -1
  34. data/tokens/tailwind-theme.css +1 -1
  35. metadata +6 -1
@@ -8,8 +8,9 @@ import { tabbableWithin } from "@poetry/controllers/helpers/tabbable"
8
8
  // pointer-only enrichment behind a LINK. Two timers (open 600 / close
9
9
  // 300, over the trigger+content
10
10
  // pair, re-enter cancels - the grace window, no polygon), the touch double-guard
11
- // (pointerType 'touch' no-ops AND touchstart preventDefaults so a tap can
12
- // never synthesize a focus-open - a tap just navigates the link), the focus
11
+ // (pointerType 'touch' no-ops AND the pointerdown latch swallows the focus a
12
+ // tap or click causes, so a tap keeps its click and just navigates the
13
+ // link - cancelling touchstart would cancel that click with it), the focus
13
14
  // mirror (trigger focus opens immediately / blur closes - a keyboard user
14
15
  // SEES the card), the per-open TABINDEX STRIP (every tabbable inside is
15
16
  // forced tabindex=-1: keyboard and touch users never reach inside,
@@ -28,6 +29,11 @@ const CONTENT_SELECTOR = '[data-slot="hover-card-content"]'
28
29
  const EVENT_PREFIX = "poetry:hover-card"
29
30
 
30
31
  const DISMISSABLE = "poetry--core--dismissable"
32
+ // The pointerdown latch releases once the press has fully landed: the
33
+ // compatibility mouseup (a tap dispatches its mouse events, focus among
34
+ // them, AFTER its pointerup - so pointerup is too early), a cancelled
35
+ // touch, or any key (keyboard use is never mid-press).
36
+ const LATCH_RELEASE_EVENTS = ["mouseup", "pointercancel", "keydown"]
31
37
  const POPPER_STRATEGY = "data-poetry--core--popper-strategy-value"
32
38
 
33
39
  export default class HoverCardController extends Controller {
@@ -49,7 +55,12 @@ export default class HoverCardController extends Controller {
49
55
  #containSelection = false
50
56
  #hasSelection = false
51
57
  #previousBodyUserSelect = null
58
+ #isPointerDown = false
52
59
  #onPointerup = () => this.#handlePointerup()
60
+ #releaseLatch = () => {
61
+ this.#isPointerDown = false
62
+ for (const type of LATCH_RELEASE_EVENTS) document.removeEventListener(type, this.#releaseLatch)
63
+ }
53
64
 
54
65
  /**
55
66
  * Wires the content listeners (portal-safe) and reconciles a
@@ -93,6 +104,7 @@ export default class HoverCardController extends Controller {
93
104
 
94
105
  this.#wired = []
95
106
  document.removeEventListener("pointerup", this.#onPointerup)
107
+ this.#releaseLatch()
96
108
  }
97
109
 
98
110
  /**
@@ -152,9 +164,11 @@ export default class HoverCardController extends Controller {
152
164
  /**
153
165
  * The trigger's focus action: opens IMMEDIATELY, skipping the timers -
154
166
  * a keyboard user sees the preview even though they
155
- * cannot enter it.
167
+ * cannot enter it. Focus a pointer caused (the latch) does not open.
156
168
  */
157
169
  focusOpen() {
170
+ if (this.#isPointerDown) return
171
+
158
172
  this.#clearCloseTimer()
159
173
  this.#show()
160
174
  }
@@ -169,15 +183,16 @@ export default class HoverCardController extends Controller {
169
183
  }
170
184
 
171
185
  /**
172
- * The trigger's touchstart action - the touch guard: preventDefault so
173
- * a tap can never synthesize a focus event (a focus-opened card on
174
- * touch would be unreachable). The tap still navigates the
175
- * link.
176
- *
177
- * @param {TouchEvent} event
186
+ * The trigger's pointerdown action - the latch: the focus a tap or a
187
+ * click causes must not open the card (a pointer user never gets a
188
+ * focus-opened card; on touch it would be unreachable), so focusOpen
189
+ * stays shut until the press has landed (LATCH_RELEASE_EVENTS). The
190
+ * event is never cancelled: cancelling touchstart would cancel the
191
+ * tap's click, and the tap must still navigate the link.
178
192
  */
179
- touchGuard(event) {
180
- event.preventDefault()
193
+ pointerDown() {
194
+ this.#isPointerDown = true
195
+ for (const type of LATCH_RELEASE_EVENTS) document.addEventListener(type, this.#releaseLatch)
181
196
  }
182
197
 
183
198
  // --- open / close ---
@@ -27,6 +27,17 @@ import {
27
27
  // auto-scroll animation cannot release itself.
28
28
  const AUTOSCROLLING_CLEAR_DELAY = 180
29
29
 
30
+ // The opening-position hold: data-pending-scroll sits on the root and the
31
+ // viewport until defaultScrollPosition end / last-anchor is applied (or
32
+ // skipped, on an empty transcript), so the dictionary can hide the viewport
33
+ // instead of painting the top of the thread and then jumping. The server
34
+ // renders the attribute for those two positions (a scroll container always
35
+ // opens at the top, and the first paint must not wait for this controller);
36
+ // from connect on the controller owns it. Mount-only: a live value change
37
+ // re-applies the position without hiding the viewport again.
38
+ const PENDING_SCROLL_ATTRIBUTE = "data-pending-scroll"
39
+ const HELD_SCROLL_POSITIONS = new Set(["end", "last-anchor"])
40
+
30
41
  // Viewport keys that count as deliberate scroll intent and release follow.
31
42
  const USER_SCROLL_KEYS = new Set([
32
43
  "ArrowDown",
@@ -56,6 +67,10 @@ const USER_SCROLL_KEYS = new Set([
56
67
  // anchored-to-message a turn held at the reading line while a reply streams
57
68
  // settling-jump a programmatic jump animating; intent suppressed
58
69
  //
70
+ // Presence attributes mirrored on the root AND the viewport: data-scrollable
71
+ // (which edges have room), data-autoscrolling (a programmatic scroll is
72
+ // settling), data-pending-scroll (the opening-position hold, above).
73
+ //
59
74
  // Viewport scroll/wheel/touchmove/keydown listeners are wired here (passive
60
75
  // flags need addEventListener) - do NOT also declare them as data-actions.
61
76
  // The jump button IS a data-action: click->...#jump.
@@ -101,6 +116,7 @@ export default class extends Controller {
101
116
  this.prependRestore = null
102
117
  this.pendingScrollToMessage = null
103
118
  this.defaultScrollPositionApplied = false
119
+ this.pendingScroll = HELD_SCROLL_POSITIONS.has(this.defaultScrollPositionValue)
104
120
  this.spacerHeight = 0
105
121
  this.spacerGap = getFlexGap(this.#spacer()?.parentElement ?? null)
106
122
  this.handledScrollAnchors = new WeakSet()
@@ -150,6 +166,16 @@ export default class extends Controller {
150
166
  // defaultScrollPosition once, commits scrollable state.
151
167
  this.#handleContentChange()
152
168
 
169
+ // The hold releases with the opening position (inside the mount pass),
170
+ // or right away when there is nothing to scroll. A Turbo morph that
171
+ // re-stamps the server's attribute afterwards is stripped by the
172
+ // observer; a cache snapshot re-arms it (see #rearmPendingScroll).
173
+ if (this.itemCount === 0) this.pendingScroll = false
174
+ this.#writePendingScroll()
175
+ this.#observePendingScroll(viewport)
176
+ this.onBeforeCache = () => this.#rearmPendingScroll()
177
+ document.addEventListener("turbo:before-cache", this.onBeforeCache)
178
+
153
179
  if (this.trackVisibilityValue) this.#observeVisibility()
154
180
 
155
181
  this.started = true
@@ -189,6 +215,9 @@ export default class extends Controller {
189
215
  this.contentResizeObserver = null
190
216
  this.visibilityObserver?.disconnect()
191
217
  this.visibilityObserver = null
218
+ this.pendingScrollObserver?.disconnect()
219
+ this.pendingScrollObserver = null
220
+ document.removeEventListener("turbo:before-cache", this.onBeforeCache)
192
221
  this.observedRows.clear()
193
222
  this.visibleMessageIds.clear()
194
223
 
@@ -661,7 +690,7 @@ export default class extends Controller {
661
690
 
662
691
  if (!handled) return false
663
692
 
664
- this.defaultScrollPositionApplied = true
693
+ this.#markDefaultScrollPositionApplied()
665
694
 
666
695
  return true
667
696
  }
@@ -708,6 +737,55 @@ export default class extends Controller {
708
737
  return true
709
738
  }
710
739
 
740
+ // --- the opening-position hold ---
741
+
742
+ #markDefaultScrollPositionApplied() {
743
+ this.defaultScrollPositionApplied = true
744
+
745
+ if (!this.pendingScroll) return
746
+
747
+ this.pendingScroll = false
748
+ this.#writePendingScroll()
749
+ }
750
+
751
+ #writePendingScroll() {
752
+ for (const element of [this.element, this.#viewport()]) {
753
+ if (!element) continue
754
+
755
+ if (this.pendingScroll) element.setAttribute(PENDING_SCROLL_ATTRIBUTE, "")
756
+ else element.removeAttribute(PENDING_SCROLL_ATTRIBUTE)
757
+ }
758
+ }
759
+
760
+ // A Turbo morph re-stamps the server-rendered attribute onto the live
761
+ // elements after the position already applied: strip it. Never re-add
762
+ // through this path - an inline script that scrolled and released the
763
+ // hold before connect must keep its release.
764
+ #observePendingScroll(viewport) {
765
+ if (typeof MutationObserver === "undefined") return
766
+
767
+ this.pendingScrollObserver = new MutationObserver(() => {
768
+ if (!this.pendingScroll) this.#writePendingScroll()
769
+ })
770
+
771
+ for (const element of [this.element, viewport]) {
772
+ this.pendingScrollObserver.observe(element, {
773
+ attributes: true,
774
+ attributeFilter: [PENDING_SCROLL_ATTRIBUTE]
775
+ })
776
+ }
777
+ }
778
+
779
+ // A cached snapshot clones the elements without their scroll position, so
780
+ // a restore would paint the top of the thread until reconnect re-applies
781
+ // the opening position: re-arm the hold before Turbo caches the page.
782
+ #rearmPendingScroll() {
783
+ if (!HELD_SCROLL_POSITIONS.has(this.defaultScrollPositionValue)) return
784
+
785
+ this.pendingScroll = true
786
+ this.#writePendingScroll()
787
+ }
788
+
711
789
  // --- scroll commands (mechanics, split from the policy above as in source) ---
712
790
 
713
791
  #setAutoScrolling(autoscrolling) {
@@ -878,7 +956,7 @@ export default class extends Controller {
878
956
  if (!element) {
879
957
  if (this.itemCount === 0) {
880
958
  this.pendingScrollToMessage = { messageId, options }
881
- this.defaultScrollPositionApplied = true
959
+ this.#markDefaultScrollPositionApplied()
882
960
 
883
961
  return true
884
962
  }
@@ -886,7 +964,7 @@ export default class extends Controller {
886
964
  return false
887
965
  }
888
966
 
889
- this.defaultScrollPositionApplied = true
967
+ this.#markDefaultScrollPositionApplied()
890
968
 
891
969
  if (this.#scrollToElement(element, options)) {
892
970
  this.pendingScrollToMessage = null
@@ -910,7 +988,7 @@ export default class extends Controller {
910
988
  if (!this.#scrollToElement(element, pending.options)) return false
911
989
 
912
990
  this.pendingScrollToMessage = null
913
- this.defaultScrollPositionApplied = true
991
+ this.#markDefaultScrollPositionApplied()
914
992
 
915
993
  return true
916
994
  }
@@ -44,6 +44,7 @@ export default class NavigationMenuController extends Controller {
44
44
  #cancelExit = new Map() // value -> abandon-this-panel's-exit (per panel, not global)
45
45
  #onOutsidePress = null
46
46
  #unsubscribeBeforeCache = null
47
+ #itemsObserver = null
47
48
  #sizeGeneration = 0
48
49
 
49
50
  /**
@@ -62,6 +63,16 @@ export default class NavigationMenuController extends Controller {
62
63
  this.#close()
63
64
  flushPendingExits()
64
65
  })
66
+
67
+ // A morph or stream that removes the OPEN trigger's item takes the
68
+ // per-item panel with it but leaves an adopted viewport panel behind,
69
+ // and either way the bar still counts itself open: close as if the
70
+ // trigger had been dismissed, and drop any adopted panel no trigger
71
+ // owns any more.
72
+ if (typeof MutationObserver !== "undefined") {
73
+ this.#itemsObserver = new MutationObserver(() => this.#reconcileRemovedItems())
74
+ this.#itemsObserver.observe(this.element, { childList: true, subtree: true })
75
+ }
65
76
  }
66
77
 
67
78
  /**
@@ -73,6 +84,8 @@ export default class NavigationMenuController extends Controller {
73
84
  this.#unbindOutsidePress()
74
85
  this.#unsubscribeBeforeCache?.()
75
86
  this.#unsubscribeBeforeCache = null
87
+ this.#itemsObserver?.disconnect()
88
+ this.#itemsObserver = null
76
89
  }
77
90
 
78
91
  /**
@@ -82,7 +95,7 @@ export default class NavigationMenuController extends Controller {
82
95
  */
83
96
  toggle(event) {
84
97
  const value = this.#valueFrom(event)
85
- if (value === null) return
98
+ if (value === null || this.#disabled(value)) return
86
99
 
87
100
  this.#clearTimer()
88
101
  if (this.#openValue === value) this.#close()
@@ -101,7 +114,7 @@ export default class NavigationMenuController extends Controller {
101
114
  if (event.pointerType === "touch") return // touch is click's job
102
115
 
103
116
  const value = this.#valueFrom(event)
104
- if (value === null) return
117
+ if (value === null || this.#disabled(value)) return
105
118
  if (value === this.#openValue) {
106
119
  this.#clearTimer() // re-entering the open item cancels a pending close
107
120
  return
@@ -152,6 +165,20 @@ export default class NavigationMenuController extends Controller {
152
165
  return
153
166
  }
154
167
 
168
+ // ArrowDown on a trigger opens its panel and is consumed; focus stays
169
+ // on the trigger (Tab enters the panel). A top-level link is not a
170
+ // trigger and keeps the key.
171
+ if (event.key === "ArrowDown") {
172
+ const trigger = event.target instanceof Element ? event.target.closest(TRIGGER_SELECTOR) : null
173
+ const value = this.#valueFrom(event)
174
+ if (!trigger || value === null || this.#disabled(value)) return
175
+
176
+ event.preventDefault()
177
+ this.#clearTimer()
178
+ if (this.#openValue !== value) this.#open(value)
179
+ return
180
+ }
181
+
155
182
  if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return
156
183
 
157
184
  const stops = this.#arrowStops()
@@ -211,6 +238,28 @@ export default class NavigationMenuController extends Controller {
211
238
  this.#bindOutsidePress()
212
239
  }
213
240
 
241
+ #reconcileRemovedItems() {
242
+ const viewport = this.#viewport()
243
+ if (viewport) {
244
+ for (const panel of viewport.querySelectorAll("[data-viewport-panel]")) {
245
+ if (!this.#ownerOf(panel)) panel.remove()
246
+ }
247
+ }
248
+ if (this.#openValue === null || this.#triggerFor(this.#openValue)) return
249
+
250
+ this.#clearTimer()
251
+ this.#close()
252
+ }
253
+
254
+ // The trigger whose aria-controls names this adopted panel, if it still
255
+ // exists.
256
+ #ownerOf(panel) {
257
+ if (!panel.id) return null
258
+
259
+ return [...this.element.querySelectorAll(TRIGGER_SELECTOR)]
260
+ .find((trigger) => trigger.getAttribute("aria-controls") === panel.id) ?? null
261
+ }
262
+
214
263
  #close() {
215
264
  if (this.#openValue === null) return
216
265
 
@@ -434,6 +483,12 @@ export default class NavigationMenuController extends Controller {
434
483
  return this.#itemFor(value)?.querySelector(TRIGGER_SELECTOR) ?? null
435
484
  }
436
485
 
486
+ // A disabled trigger (native disabled, data-disabled) never opens its
487
+ // panel - not by click, not by hover - and the arrows step over it.
488
+ #disabled(value) {
489
+ return this.#triggerFor(value)?.disabled === true
490
+ }
491
+
437
492
  // An adopted panel (viewport mode) no longer lives inside its item -
438
493
  // the trigger's aria-controls id finds it wherever it moved.
439
494
  #panelFor(value) {
@@ -449,10 +504,10 @@ export default class NavigationMenuController extends Controller {
449
504
  .find((item) => item.dataset.value === value) ?? null
450
505
  }
451
506
 
452
- // Arrow stops: every trigger and top-level link in the bar, DOM order.
507
+ // Arrow stops: every enabled trigger and top-level link in the bar, DOM order.
453
508
  #arrowStops() {
454
509
  return [...this.element.querySelectorAll(
455
510
  `${TRIGGER_SELECTOR}, [data-slot="navigation-menu-list"] > ${ITEM_SELECTOR} > a`
456
- )]
511
+ )].filter((stop) => !stop.disabled)
457
512
  }
458
513
  }
@@ -32,7 +32,6 @@ export default class extends Controller {
32
32
  static events = ["poetry:number-field:change", "poetry:number-field:commit"]
33
33
 
34
34
  #value = null
35
- #focusedOnce = false
36
35
  #holdTimer = null
37
36
  #holdInterval = null
38
37
  #holdChanged = false
@@ -119,15 +118,6 @@ export default class extends Controller {
119
118
  if (parsed !== null) this.#apply(parsed, { display: false })
120
119
  }
121
120
 
122
- /** The focus action: the first focus parks the caret at the end. */
123
- focus() {
124
- if (this.#focusedOnce) return
125
-
126
- this.#focusedOnce = true
127
- const end = this.inputTarget.value.length
128
- this.inputTarget.setSelectionRange(end, end)
129
- }
130
-
131
121
  /**
132
122
  * The blur action - the text commit point: empty clears, unparseable
133
123
  * text is left as typed with no commit, parseable text clamps and
@@ -161,7 +151,7 @@ export default class extends Controller {
161
151
  if (event.button !== 0 || this.inputTarget.readOnly) return
162
152
 
163
153
  // Mouse focuses the input (touch would pop the software keyboard).
164
- if (event.pointerType !== "touch") this.inputTarget.focus()
154
+ if (event.pointerType !== "touch") this.#focusInput()
165
155
  this.#pressTicked = true
166
156
  this.#holdChanged = false
167
157
  const direction = this.#directionFor(event.currentTarget)
@@ -224,6 +214,17 @@ export default class extends Controller {
224
214
  return changed
225
215
  }
226
216
 
217
+ // Programmatic focus (the steppers) parks the caret at the end BEFORE
218
+ // focusing: every engine restores the stored selection on focus, and a
219
+ // host's own focus handler can still choose differently. Keyboard and
220
+ // pointer focus keep the browser's native selection (Tab selects the
221
+ // value, a click places the caret).
222
+ #focusInput() {
223
+ const end = this.inputTarget.value.length
224
+ this.inputTarget.setSelectionRange(end, end)
225
+ this.inputTarget.focus()
226
+ }
227
+
227
228
  #directionFor(button) {
228
229
  return this.hasIncrementTarget && button === this.incrementTarget ? 1 : -1
229
230
  }
@@ -240,13 +241,22 @@ export default class extends Controller {
240
241
  this.#apply(candidate, { display: true })
241
242
  }
242
243
 
244
+ // The dominant axis decides (a precision touchpad emits sub-pixel
245
+ // cross-axis noise): a sideways gesture is page scroll and passes
246
+ // through untouched, except under Shift, which some browsers deliver
247
+ // on the horizontal axis - there the horizontal delta is the intended
248
+ // one. Positive is "down", stepping the value down.
243
249
  #wheel(event) {
244
250
  if (event.ctrlKey || document.activeElement !== this.inputTarget) return
245
251
 
246
- event.preventDefault()
247
- if (event.deltaY === 0) return
252
+ const horizontal = Math.abs(event.deltaX) > Math.abs(event.deltaY)
253
+ if (horizontal && !event.shiftKey) return
254
+
255
+ const delta = horizontal ? event.deltaX : event.deltaY
256
+ if (delta === 0) return
248
257
 
249
- this.#step(event.deltaY > 0 ? -1 : 1, event)
258
+ event.preventDefault()
259
+ this.#step(delta > 0 ? -1 : 1, event)
250
260
  this.#commit()
251
261
  }
252
262
 
@@ -14,7 +14,9 @@ components:
14
14
  description: The element to render ('div', 'section', 'span', ...); void elements
15
15
  (br, hr, img, ...) self-close and take no content block.
16
16
  slots: []
17
+ helper: poetry_box
17
18
  identity: false
18
19
  parts:
19
20
  - name: box
20
21
  description: The rendered element itself - the whole component is one part
22
+ internal: true
@@ -595,9 +595,9 @@
595
595
  "disconnect",
596
596
  "focusOpen",
597
597
  "openValueChanged",
598
+ "pointerDown",
598
599
  "pointerEnter",
599
- "pointerLeave",
600
- "touchGuard"
600
+ "pointerLeave"
601
601
  ],
602
602
  "events": [
603
603
  "poetry:hover-card:closed",
@@ -841,7 +841,6 @@
841
841
  "blur",
842
842
  "connect",
843
843
  "disconnect",
844
- "focus",
845
844
  "hiddenChanged",
846
845
  "input",
847
846
  "keydown",
@@ -68,6 +68,19 @@ module Poetry
68
68
 
69
69
  private
70
70
 
71
+ # The gem convention plus every helper the catalog knows by a name
72
+ # of its own (an app component's declared helper), so the
73
+ # heuristics cover app components too.
74
+ def helper_pattern
75
+ @helper_pattern ||= begin
76
+ declared = @catalog.helper_names.reject { |name| name.start_with?("poetry_") }
77
+ # A bare call: not after a dot or a colon (`u.badge`, `Foo::badge`).
78
+ Regexp.union(/(?<![\w.:])(poetry_[a-z0-9_]+)\b/, *declared.map do |name|
79
+ /(?<![\w.:])(#{Regexp.escape(name)})\b/
80
+ end)
81
+ end
82
+ end
83
+
71
84
  def frame_for(code)
72
85
  return :cache if code.match?(CACHE_OPENER)
73
86
  return :loop if code.match?(LOOP_OPENER)
@@ -76,7 +89,7 @@ module Poetry
76
89
  end
77
90
 
78
91
  def flag_helpers(code, line, stack, findings)
79
- code.scan(/\b(poetry_[a-z0-9_]+)\b/) do |(helper)|
92
+ code.scan(helper_pattern) do |(helper)|
80
93
  next unless @catalog.helper?(helper)
81
94
  next if @catalog.identity_free?(helper)
82
95
  next if code.match?(IDENTITY_ARG)