@pyreon/lint 0.14.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -53,8 +53,8 @@ function lintWith(ruleId: string, source: string, filePath?: string) {
53
53
  // ── Rule Metadata ───────────────────────────────────────────────────────────
54
54
 
55
55
  describe('Rule metadata', () => {
56
- it('should have 59 rules', () => {
57
- expect(allRules.length).toBe(59)
56
+ it('should have 62 rules', () => {
57
+ expect(allRules.length).toBe(62)
58
58
  })
59
59
 
60
60
  it('should have unique rule IDs', () => {
@@ -94,9 +94,9 @@ describe('Rule metadata', () => {
94
94
  for (const rule of allRules) {
95
95
  counts[rule.meta.category] = (counts[rule.meta.category] ?? 0) + 1
96
96
  }
97
- expect(counts.reactivity).toBe(10)
97
+ expect(counts.reactivity).toBe(12)
98
98
  expect(counts.jsx).toBe(11)
99
- expect(counts.lifecycle).toBe(4)
99
+ expect(counts.lifecycle).toBe(5)
100
100
  expect(counts.performance).toBe(4)
101
101
  expect(counts.ssr).toBe(3)
102
102
  expect(counts.architecture).toBe(7)
@@ -277,6 +277,61 @@ describe('Reactivity rules', () => {
277
277
  expect(diags.length).toBe(1)
278
278
  })
279
279
 
280
+ // ── Audit bug #1: async-effect catch ────────────────────────────────────
281
+ it('pyreon/no-async-effect: flags async arrow in effect()', () => {
282
+ const source = `effect(async () => { const id = userId(); const data = await fetch('/x'); name.set(data) })`
283
+ const result = lintSource(source)
284
+ const diags = findByRule(result, 'pyreon/no-async-effect')
285
+ expect(diags.length).toBe(1)
286
+ expect(diags[0]?.message).toContain('async')
287
+ expect(diags[0]?.message).toContain('await')
288
+ })
289
+
290
+ it('pyreon/no-async-effect: flags async function expression in effect()', () => {
291
+ const source = `effect(async function () { await x() })`
292
+ const result = lintSource(source)
293
+ const diags = findByRule(result, 'pyreon/no-async-effect')
294
+ expect(diags.length).toBe(1)
295
+ })
296
+
297
+ it('pyreon/no-async-effect: flags async in renderEffect()', () => {
298
+ const source = `renderEffect(async () => { await fetch('/x') })`
299
+ const result = lintSource(source)
300
+ const diags = findByRule(result, 'pyreon/no-async-effect')
301
+ expect(diags.length).toBe(1)
302
+ expect(diags[0]?.message).toContain('renderEffect')
303
+ })
304
+
305
+ it('pyreon/no-async-effect: flags async in computed()', () => {
306
+ const source = `computed(async () => { return await fetch('/x') })`
307
+ const result = lintSource(source)
308
+ const diags = findByRule(result, 'pyreon/no-async-effect')
309
+ expect(diags.length).toBe(1)
310
+ expect(diags[0]?.message).toContain('computed')
311
+ expect(diags[0]?.message).toContain('createResource')
312
+ })
313
+
314
+ it('pyreon/no-async-effect: clean for synchronous effect', () => {
315
+ const source = `effect(() => { name.set(userId()) })`
316
+ const result = lintSource(source)
317
+ const diags = findByRule(result, 'pyreon/no-async-effect')
318
+ expect(diags.length).toBe(0)
319
+ })
320
+
321
+ it('pyreon/no-async-effect: clean for synchronous computed', () => {
322
+ const source = `computed(() => userId() + ':' + name())`
323
+ const result = lintSource(source)
324
+ const diags = findByRule(result, 'pyreon/no-async-effect')
325
+ expect(diags.length).toBe(0)
326
+ })
327
+
328
+ it('pyreon/no-async-effect: does not flag async on non-effect calls', () => {
329
+ const source = `setTimeout(async () => { await x() }, 100)`
330
+ const result = lintSource(source)
331
+ const diags = findByRule(result, 'pyreon/no-async-effect')
332
+ expect(diags.length).toBe(0)
333
+ })
334
+
280
335
  it('pyreon/no-peek-in-tracked: flags .peek() inside effect', () => {
281
336
  const source = `effect(() => { const v = x.peek() })`
282
337
  const result = lintSource(source)
@@ -327,6 +382,50 @@ describe('Reactivity rules', () => {
327
382
  const diags = findByRule(result, 'pyreon/no-signal-leak')
328
383
  expect(diags.length).toBe(0)
329
384
  })
385
+
386
+ it('pyreon/no-signal-call-write: flags `sig(value)` write attempt', () => {
387
+ const source = `const count = signal(0)\nfunction inc() { count(5) }`
388
+ const result = lintSource(source)
389
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
390
+ expect(diags.length).toBe(1)
391
+ expect(diags[0]?.message).toContain('count.set(value)')
392
+ })
393
+
394
+ it('pyreon/no-signal-call-write: clean for zero-arg read', () => {
395
+ const source = `const count = signal(0)\nfunction read() { return count() }`
396
+ const result = lintSource(source)
397
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
398
+ expect(diags.length).toBe(0)
399
+ })
400
+
401
+ it('pyreon/no-signal-call-write: clean for `.set()` member call', () => {
402
+ const source = `const count = signal(0)\nfunction inc() { count.set(5) }`
403
+ const result = lintSource(source)
404
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
405
+ expect(diags.length).toBe(0)
406
+ })
407
+
408
+ it('pyreon/no-signal-call-write: also covers computed bindings', () => {
409
+ const source = `const total = computed(() => 0)\nfunction wrong() { total(5) }`
410
+ const result = lintSource(source)
411
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
412
+ expect(diags.length).toBe(1)
413
+ })
414
+
415
+ it('pyreon/no-signal-call-write: ignores non-const signal-like declarations', () => {
416
+ // `let` may be reassigned to a non-signal — too risky to flag.
417
+ const source = `let count = signal(0)\nfunction inc() { count(5) }`
418
+ const result = lintSource(source)
419
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
420
+ expect(diags.length).toBe(0)
421
+ })
422
+
423
+ it('pyreon/no-signal-call-write: clean when name does not bind to a signal', () => {
424
+ const source = `function increment(x) { return x + 1 }\nincrement(5)`
425
+ const result = lintSource(source)
426
+ const diags = findByRule(result, 'pyreon/no-signal-call-write')
427
+ expect(diags.length).toBe(0)
428
+ })
330
429
  })
331
430
 
332
431
  // ── JSX Rules ───────────────────────────────────────────────────────────────
@@ -418,6 +517,40 @@ describe('JSX rules', () => {
418
517
  expect(diags.length).toBe(0)
419
518
  })
420
519
 
520
+ it('pyreon/no-props-destructure: clean for render-prop callbacks', () => {
521
+ // <For>{(item) => <li>...</li>}</For> — array element, not Pyreon props.
522
+ // The `depth > 1` exemption covers this.
523
+ const source = `const App = () => <For each={items}>{({id}) => <li>{id}</li>}</For>`
524
+ const result = lintSource(source)
525
+ const diags = findByRule(result, 'pyreon/no-props-destructure')
526
+ expect(diags.length).toBe(0)
527
+ })
528
+
529
+ it('pyreon/no-props-destructure: flags inside known component-factory call', () => {
530
+ // `lazy(({foo}) => <div>{foo}</div>)` — foo IS a Pyreon prop, so
531
+ // destructuring loses reactivity.
532
+ const source = `const Wrapped = lazy(({ foo }) => <div>{foo}</div>)`
533
+ const result = lintSource(source)
534
+ const diags = findByRule(result, 'pyreon/no-props-destructure')
535
+ expect(diags.length).toBe(1)
536
+ })
537
+
538
+ it('pyreon/no-props-destructure: respects exemptPaths option', () => {
539
+ const config = configWithExemptPaths('pyreon/no-props-destructure', [
540
+ 'examples/legacy/',
541
+ ])
542
+ const source = `const App = ({ name }) => <div>{name}</div>`
543
+ const exempted = lintFile(
544
+ 'examples/legacy/old.tsx',
545
+ source,
546
+ allRules,
547
+ config,
548
+ )
549
+ expect(findByRule(exempted, 'pyreon/no-props-destructure').length).toBe(0)
550
+ const checked = lintFile('src/App.tsx', source, allRules, config)
551
+ expect(findByRule(checked, 'pyreon/no-props-destructure').length).toBe(1)
552
+ })
553
+
421
554
  it('pyreon/no-index-as-by: flags by={(_, i) => i}', () => {
422
555
  const source = `const App = () => <For each={items} by={(_, i) => i}>{r => <li />}</For>`
423
556
  const result = lintSource(source)
@@ -488,6 +621,302 @@ describe('Lifecycle rules', () => {
488
621
  const diags = findByRule(result, 'pyreon/no-dom-in-setup')
489
622
  expect(diags.length).toBe(0)
490
623
  })
624
+
625
+ it('pyreon/no-imperative-effect-on-create: flags fetch() inside effect', () => {
626
+ const source = `const App = () => { effect(() => fetch('/api')) }`
627
+ const result = lintSource(source)
628
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
629
+ expect(diags.length).toBe(1)
630
+ expect(diags[0]?.message).toContain('fetch')
631
+ })
632
+
633
+ it('pyreon/no-imperative-effect-on-create: flags addEventListener inside effect', () => {
634
+ const source = `const App = () => { effect(() => { document.addEventListener('click', () => {}) }) }`
635
+ const result = lintSource(source)
636
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
637
+ expect(diags.length).toBe(1)
638
+ expect(diags[0]?.message).toContain('addEventListener')
639
+ })
640
+
641
+ it('pyreon/no-imperative-effect-on-create: flags setTimeout inside effect', () => {
642
+ const source = `effect(() => setTimeout(() => doStuff(), 100))`
643
+ const result = lintSource(source)
644
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
645
+ expect(diags.length).toBe(1)
646
+ expect(diags[0]?.message).toContain('setTimeout')
647
+ })
648
+
649
+ it('pyreon/no-imperative-effect-on-create: flags await inside async effect', () => {
650
+ const source = `effect(async () => { await loadData() })`
651
+ const result = lintSource(source)
652
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
653
+ expect(diags.length).toBe(1)
654
+ expect(diags[0]?.message).toContain('await')
655
+ })
656
+
657
+ it('pyreon/no-imperative-effect-on-create: flags document/window member access', () => {
658
+ const source = `effect(() => { const w = window.innerWidth })`
659
+ const result = lintSource(source)
660
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
661
+ expect(diags.length).toBe(1)
662
+ })
663
+
664
+ it('pyreon/no-imperative-effect-on-create: clean for pure reactive tracking', () => {
665
+ // The idiomatic pattern — effect tracks signals, no imperative work.
666
+ const source = `effect(() => { sum.set(a() + b()) })`
667
+ const result = lintSource(source)
668
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
669
+ expect(diags.length).toBe(0)
670
+ })
671
+
672
+ it('pyreon/no-imperative-effect-on-create: clean for console.log debug effect', () => {
673
+ const source = `effect(() => console.log('count:', count()))`
674
+ const result = lintSource(source)
675
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
676
+ expect(diags.length).toBe(0)
677
+ })
678
+
679
+ it('pyreon/no-imperative-effect-on-create: clean when wrapped in onMount', () => {
680
+ const source = `onMount(() => { effect(() => fetch('/api')) })`
681
+ const result = lintSource(source)
682
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
683
+ expect(diags.length).toBe(0)
684
+ })
685
+
686
+ it('pyreon/no-imperative-effect-on-create: clean for nested function (not run synchronously)', () => {
687
+ // The fetch is inside a function the effect attaches as a handler —
688
+ // it runs later, not at effect setup, so the bug class doesn't apply.
689
+ const source = `effect(() => { const handler = () => fetch('/api'); attach(handler) })`
690
+ const result = lintSource(source)
691
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
692
+ expect(diags.length).toBe(0)
693
+ })
694
+
695
+ it('pyreon/no-imperative-effect-on-create: flags localStorage.setItem inside effect', () => {
696
+ const source = `effect(() => { localStorage.setItem('k', count()) })`
697
+ const result = lintSource(source)
698
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
699
+ expect(diags.length).toBe(1)
700
+ })
701
+
702
+ it('pyreon/no-imperative-effect-on-create: flags new IntersectionObserver inside effect', () => {
703
+ const source = `effect(() => { const io = new IntersectionObserver(() => {}) })`
704
+ const result = lintSource(source)
705
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
706
+ expect(diags.length).toBe(1)
707
+ expect(diags[0]?.message).toContain('IntersectionObserver')
708
+ })
709
+
710
+ it('pyreon/no-imperative-effect-on-create: flags new ResizeObserver inside effect', () => {
711
+ const source = `effect(() => { new ResizeObserver(() => {}).observe(el) })`
712
+ const result = lintSource(source)
713
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
714
+ expect(diags.length).toBe(1)
715
+ expect(diags[0]?.message).toContain('ResizeObserver')
716
+ })
717
+
718
+ it('pyreon/no-imperative-effect-on-create: flags new WebSocket inside effect', () => {
719
+ const source = `effect(() => { const ws = new WebSocket(url()) })`
720
+ const result = lintSource(source)
721
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
722
+ expect(diags.length).toBe(1)
723
+ expect(diags[0]?.message).toContain('WebSocket')
724
+ })
725
+
726
+ it('pyreon/no-imperative-effect-on-create: flags fetch inside IIFE inside effect', () => {
727
+ // IIFE — `(async () => { await fetch() })()` runs synchronously at
728
+ // the call site, so its body should be walked even though it's
729
+ // structurally a "nested function".
730
+ const source = `effect(() => { (async () => { await fetch('/api') })() })`
731
+ const result = lintSource(source)
732
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
733
+ expect(diags.length).toBe(1)
734
+ })
735
+
736
+ it('pyreon/no-imperative-effect-on-create: flags fetch inside arrow-IIFE expression-body', () => {
737
+ const source = `effect(() => (() => fetch('/api'))())`
738
+ const result = lintSource(source)
739
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
740
+ expect(diags.length).toBe(1)
741
+ })
742
+
743
+ it('pyreon/no-imperative-effect-on-create: clean for empty IIFE', () => {
744
+ const source = `effect(() => { (() => {})() })`
745
+ const result = lintSource(source)
746
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
747
+ expect(diags.length).toBe(0)
748
+ })
749
+
750
+ it('pyreon/no-imperative-effect-on-create: still bails at handler inside IIFE', () => {
751
+ // `setTimeout(...)` IS flagged, but the inner fetch (inside the
752
+ // setTimeout callback — a real deferred handler) should NOT
753
+ // double-fire. Single diagnostic for setTimeout, not two.
754
+ const source = `effect(() => { (() => { setTimeout(() => fetch('/api')) })() })`
755
+ const result = lintSource(source)
756
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
757
+ expect(diags.length).toBe(1)
758
+ expect(diags[0]?.message).toContain('setTimeout')
759
+ })
760
+
761
+ // ── Coverage: every entry in each IMPERATIVE_* set must fire ──
762
+ //
763
+ // Set-lookup typos in the rule source would slip through happy-path
764
+ // tests because the happy paths only cover a few entries from each
765
+ // set. These table-driven tests exercise EVERY entry — a typo that
766
+ // breaks lookup of any single name fails one of these.
767
+
768
+ describe('pyreon/no-imperative-effect-on-create: coverage', () => {
769
+ const globalCalls = [
770
+ 'fetch',
771
+ 'setTimeout',
772
+ 'setInterval',
773
+ 'requestAnimationFrame',
774
+ 'requestIdleCallback',
775
+ 'queueMicrotask',
776
+ ]
777
+ for (const name of globalCalls) {
778
+ it(`flags global call: ${name}(...)`, () => {
779
+ const source = `effect(() => { ${name}(() => {}) })`
780
+ const result = lintSource(source)
781
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
782
+ expect(diags.length).toBe(1)
783
+ expect(diags[0]?.message).toContain(name)
784
+ })
785
+ }
786
+
787
+ const memberMethods = [
788
+ 'addEventListener',
789
+ 'removeEventListener',
790
+ 'querySelector',
791
+ 'querySelectorAll',
792
+ 'getElementById',
793
+ 'getElementsByClassName',
794
+ 'getElementsByTagName',
795
+ 'getBoundingClientRect',
796
+ 'getComputedStyle',
797
+ 'focus',
798
+ 'blur',
799
+ 'scrollIntoView',
800
+ 'scrollTo',
801
+ 'scrollBy',
802
+ 'requestFullscreen',
803
+ 'play',
804
+ 'pause',
805
+ ]
806
+ for (const method of memberMethods) {
807
+ it(`flags member method: el.${method}(...)`, () => {
808
+ const source = `effect(() => { el.${method}() })`
809
+ const result = lintSource(source)
810
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
811
+ expect(diags.length).toBe(1)
812
+ expect(diags[0]?.message).toContain(method)
813
+ })
814
+ }
815
+
816
+ const constructors = [
817
+ 'IntersectionObserver',
818
+ 'ResizeObserver',
819
+ 'MutationObserver',
820
+ 'PerformanceObserver',
821
+ 'Worker',
822
+ 'SharedWorker',
823
+ 'WebSocket',
824
+ 'EventSource',
825
+ 'BroadcastChannel',
826
+ ]
827
+ for (const ctor of constructors) {
828
+ it(`flags constructor: new ${ctor}(...)`, () => {
829
+ const source = `effect(() => { new ${ctor}('arg') })`
830
+ const result = lintSource(source)
831
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
832
+ expect(diags.length).toBe(1)
833
+ expect(diags[0]?.message).toContain(ctor)
834
+ })
835
+ }
836
+
837
+ const browserObjects = [
838
+ 'document',
839
+ 'window',
840
+ 'navigator',
841
+ 'localStorage',
842
+ 'sessionStorage',
843
+ ]
844
+ for (const obj of browserObjects) {
845
+ it(`flags browser-object member read: ${obj}.X`, () => {
846
+ const source = `effect(() => { const x = ${obj}.something })`
847
+ const result = lintSource(source)
848
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
849
+ expect(diags.length).toBe(1)
850
+ expect(diags[0]?.message).toContain(obj)
851
+ })
852
+ }
853
+
854
+ const promiseMethods = ['then', 'catch', 'finally']
855
+ for (const method of promiseMethods) {
856
+ it(`flags promise method: p.${method}(...)`, () => {
857
+ const source = `effect(() => { p.${method}(() => {}) })`
858
+ const result = lintSource(source)
859
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
860
+ expect(diags.length).toBe(1)
861
+ expect(diags[0]?.message).toContain(method)
862
+ })
863
+ }
864
+ })
865
+
866
+ // ── Edge cases ──
867
+
868
+ it('pyreon/no-imperative-effect-on-create: function-expression IIFE form', () => {
869
+ // `(function () { await fetch() })()` — the function-keyword IIFE
870
+ // shape, less common than arrow IIFEs but still valid.
871
+ const source = `effect(() => { (async function () { await fetch('/api') })() })`
872
+ const result = lintSource(source)
873
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
874
+ expect(diags.length).toBe(1)
875
+ })
876
+
877
+ it('pyreon/no-imperative-effect-on-create: clean when effect callback is a fn reference', () => {
878
+ // `effect(handler)` — no inline body to walk. Rule must not crash
879
+ // and must not false-fire on the handler reference itself.
880
+ const source = `function track() { fetch('/api') }; effect(track)`
881
+ const result = lintSource(source)
882
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
883
+ expect(diags.length).toBe(0)
884
+ })
885
+
886
+ it('pyreon/no-imperative-effect-on-create: clean inside renderEffect wrapper', () => {
887
+ const source = `renderEffect(() => { effect(() => fetch('/api')) })`
888
+ const result = lintSource(source)
889
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
890
+ expect(diags.length).toBe(0)
891
+ })
892
+
893
+ it('pyreon/no-imperative-effect-on-create: clean inside onCleanup wrapper', () => {
894
+ const source = `onCleanup(() => { effect(() => setTimeout(() => {}, 100)) })`
895
+ const result = lintSource(source)
896
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
897
+ expect(diags.length).toBe(0)
898
+ })
899
+
900
+ it('pyreon/no-imperative-effect-on-create: known limitation — bracket access not detected', () => {
901
+ // The rule only matches dotted member access. `el['addEventListener']`
902
+ // bypasses detection — documented limitation. If a future enhancement
903
+ // adds bracket-access support, this test should be flipped to assert 1.
904
+ const source = `effect(() => { el['addEventListener']('click', fn) })`
905
+ const result = lintSource(source)
906
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
907
+ expect(diags.length).toBe(0)
908
+ })
909
+
910
+ it('pyreon/no-imperative-effect-on-create: known limitation — non-DOM identifier with matching method', () => {
911
+ // `myObj.focus()` flags even when `myObj` isn't a DOM element. The
912
+ // rule has no type info; method-name matching is the heuristic. This
913
+ // is an acceptable false-positive trade-off — the alternative (full
914
+ // type checking) is out of scope for an AST walker.
915
+ const source = `const myObj = { focus: () => null }; effect(() => myObj.focus())`
916
+ const result = lintSource(source)
917
+ const diags = findByRule(result, 'pyreon/no-imperative-effect-on-create')
918
+ expect(diags.length).toBe(1)
919
+ })
491
920
  })
492
921
 
493
922
  // ── Performance Rules ───────────────────────────────────────────────────────
@@ -747,7 +1176,7 @@ describe('Architecture rules', () => {
747
1176
  const diags = findByRule(result, 'pyreon/no-process-dev-gate')
748
1177
  expect(diags.length).toBe(1)
749
1178
  expect(diags[0]?.fix).toBeDefined()
750
- expect(diags[0]?.fix?.replacement).toBe('import.meta.env?.DEV === true')
1179
+ expect(diags[0]?.fix?.replacement).toBe(`process.env.NODE_ENV !== 'production'`)
751
1180
  })
752
1181
 
753
1182
  it('pyreon/no-process-dev-gate: flags the reversed pattern (NODE_ENV first)', () => {
@@ -776,8 +1205,13 @@ describe('Architecture rules', () => {
776
1205
  expect(diags.length).toBe(1)
777
1206
  })
778
1207
 
779
- it('pyreon/no-process-dev-gate: clean for the correct import.meta.env.DEV pattern', () => {
780
- const source = `if (!import.meta.env?.DEV) return`
1208
+ it('pyreon/no-process-dev-gate: clean for the bundler-agnostic process.env.NODE_ENV pattern', () => {
1209
+ // The recommended pattern: bare `process.env.NODE_ENV !== 'production'`
1210
+ // (no `typeof process` guard). Every modern bundler (Vite, Webpack,
1211
+ // esbuild, Rollup, Parcel, Bun) auto-replaces `process.env.NODE_ENV`
1212
+ // at consumer build time. Cross-bundler library convention used by
1213
+ // React, Vue, Preact, Solid.
1214
+ const source = `if (process.env.NODE_ENV !== 'production') console.warn('hi')`
781
1215
  const result = lintFile(
782
1216
  'packages/core/runtime-dom/src/transition.ts',
783
1217
  source,
@@ -788,6 +1222,107 @@ describe('Architecture rules', () => {
788
1222
  expect(diags.length).toBe(0)
789
1223
  })
790
1224
 
1225
+ // Regression: `import.meta.env.DEV` is Vite/Rolldown-specific. In a
1226
+ // Pyreon library shipped to consumers using Webpack (Next.js), esbuild,
1227
+ // Rollup, Parcel, or Bun, `import.meta.env.DEV` is undefined and dev
1228
+ // warnings never fire — even in development. PR #200 made this the
1229
+ // recommended replacement for the broken `typeof process` compound;
1230
+ // that direction was wrong for library code. The bundler-agnostic
1231
+ // standard is bare `process.env.NODE_ENV !== 'production'`.
1232
+ it('pyreon/no-process-dev-gate: flags `import.meta.env.DEV` Vite-tied pattern', () => {
1233
+ const source = `const __DEV__ = import.meta.env?.DEV === true`
1234
+ const result = lintFile(
1235
+ 'packages/core/runtime-dom/src/transition.ts',
1236
+ source,
1237
+ allRules,
1238
+ defaultConfig(),
1239
+ )
1240
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1241
+ expect(diags.length).toBe(1)
1242
+ expect(diags[0]?.fix?.replacement).toBe(`process.env.NODE_ENV !== 'production'`)
1243
+ })
1244
+
1245
+ it('pyreon/no-process-dev-gate: flags bare `import.meta.env.DEV` truthy check', () => {
1246
+ const source = `if (import.meta.env?.DEV) console.warn('hi')`
1247
+ const result = lintFile(
1248
+ 'packages/core/runtime-dom/src/transition.ts',
1249
+ source,
1250
+ allRules,
1251
+ defaultConfig(),
1252
+ )
1253
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1254
+ expect(diags.length).toBe(1)
1255
+ })
1256
+
1257
+ it('pyreon/no-process-dev-gate: flags negated `!import.meta.env.DEV` early-return', () => {
1258
+ const source = `function f() { if (!import.meta.env?.DEV) return; console.warn('hi') }`
1259
+ const result = lintFile(
1260
+ 'packages/core/runtime-dom/src/transition.ts',
1261
+ source,
1262
+ allRules,
1263
+ defaultConfig(),
1264
+ )
1265
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1266
+ expect(diags.length).toBe(1)
1267
+ })
1268
+
1269
+ it('pyreon/no-process-dev-gate: flags `(import.meta as ViteMeta).env?.DEV === true` cast variant', () => {
1270
+ const source = `interface ViteMeta { env?: { DEV?: boolean } }
1271
+ const __DEV__ = (import.meta as ViteMeta).env?.DEV === true`
1272
+ const result = lintFile(
1273
+ 'packages/ui-system/styler/src/sheet.ts',
1274
+ source,
1275
+ allRules,
1276
+ defaultConfig(),
1277
+ )
1278
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1279
+ expect(diags.length).toBe(1)
1280
+ expect(diags[0]?.fix?.replacement).toBe(`process.env.NODE_ENV !== 'production'`)
1281
+ })
1282
+
1283
+ it('pyreon/no-process-dev-gate: does NOT flag `process.env.NODE_ENV` (the recommended pattern)', () => {
1284
+ const source = `const __DEV__ = process.env.NODE_ENV !== 'production'`
1285
+ const result = lintFile(
1286
+ 'packages/core/runtime-dom/src/transition.ts',
1287
+ source,
1288
+ allRules,
1289
+ defaultConfig(),
1290
+ )
1291
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1292
+ expect(diags.length).toBe(0)
1293
+ })
1294
+
1295
+ // Regression: the optional-chaining variant `process?.env?.NODE_ENV` was
1296
+ // silently missed. ESTree wraps `process?.env?.NODE_ENV` in a
1297
+ // `ChainExpression` containing nested `MemberExpression` nodes with
1298
+ // `optional: true`, but the original rule only matched plain
1299
+ // `MemberExpression`. The bug shipped to `packages/ui-system/ui-core/src/context.tsx`
1300
+ // — a real browser package whose dev warning was dead code in production.
1301
+ it('pyreon/no-process-dev-gate: flags optional-chaining variant (process?.env?.NODE_ENV)', () => {
1302
+ const source = `const __DEV__ = typeof process !== 'undefined' && process?.env?.NODE_ENV !== 'production'`
1303
+ const result = lintFile(
1304
+ 'packages/ui-system/ui-core/src/context.tsx',
1305
+ source,
1306
+ allRules,
1307
+ defaultConfig(),
1308
+ )
1309
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1310
+ expect(diags.length).toBe(1)
1311
+ expect(diags[0]?.fix?.replacement).toBe(`process.env.NODE_ENV !== 'production'`)
1312
+ })
1313
+
1314
+ it('pyreon/no-process-dev-gate: flags partial optional-chaining (process.env?.NODE_ENV)', () => {
1315
+ const source = `const __DEV__ = typeof process !== 'undefined' && process.env?.NODE_ENV !== 'production'`
1316
+ const result = lintFile(
1317
+ 'packages/ui-system/ui-core/src/context.tsx',
1318
+ source,
1319
+ allRules,
1320
+ defaultConfig(),
1321
+ )
1322
+ const diags = findByRule(result, 'pyreon/no-process-dev-gate')
1323
+ expect(diags.length).toBe(1)
1324
+ })
1325
+
791
1326
  it('pyreon/no-process-dev-gate: exempt via configured exemptPaths (server-only code)', () => {
792
1327
  const source = `const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
793
1328
  const cfg = configWithExemptPaths('pyreon/no-process-dev-gate', [
@@ -1420,7 +1955,7 @@ describe('Ignore filter', () => {
1420
1955
  describe('Presets', () => {
1421
1956
  it('recommended should include all rules', () => {
1422
1957
  const config = getPreset('recommended')
1423
- expect(Object.keys(config.rules).length).toBe(59)
1958
+ expect(Object.keys(config.rules).length).toBe(62)
1424
1959
  })
1425
1960
 
1426
1961
  it('strict should promote all warns to errors', () => {