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