@pyreon/lint 0.12.12 → 0.12.14

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 (45) hide show
  1. package/README.md +55 -2
  2. package/lib/analysis/cli.js.html +1 -1
  3. package/lib/analysis/index.js.html +1 -1
  4. package/lib/cli.js +960 -162
  5. package/lib/cli.js.map +1 -1
  6. package/lib/index.js +935 -161
  7. package/lib/index.js.map +1 -1
  8. package/lib/types/index.d.ts +96 -23
  9. package/lib/types/index.d.ts.map +1 -1
  10. package/package.json +2 -1
  11. package/schema/pyreonlintrc.schema.json +64 -0
  12. package/src/cli.ts +44 -2
  13. package/src/config/presets.ts +13 -1
  14. package/src/index.ts +7 -0
  15. package/src/lint.ts +37 -6
  16. package/src/lsp/index.ts +15 -2
  17. package/src/rules/architecture/dev-guard-warnings.ts +172 -17
  18. package/src/rules/architecture/no-circular-import.ts +7 -0
  19. package/src/rules/architecture/no-process-dev-gate.ts +18 -45
  20. package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
  21. package/src/rules/form/no-submit-without-validation.ts +9 -0
  22. package/src/rules/form/no-unregistered-field.ts +9 -0
  23. package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
  24. package/src/rules/hooks/no-raw-localstorage.ts +12 -1
  25. package/src/rules/hooks/no-raw-setinterval.ts +14 -0
  26. package/src/rules/index.ts +4 -1
  27. package/src/rules/jsx/no-props-destructure.ts +20 -6
  28. package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
  29. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
  30. package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
  31. package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
  32. package/src/rules/ssr/no-window-in-ssr.ts +418 -35
  33. package/src/rules/store/no-duplicate-store-id.ts +11 -0
  34. package/src/rules/store/no-mutate-store-state.ts +11 -1
  35. package/src/rules/styling/no-dynamic-styled.ts +13 -24
  36. package/src/rules/styling/no-theme-outside-provider.ts +34 -2
  37. package/src/runner.ts +100 -10
  38. package/src/tests/runner.test.ts +1573 -21
  39. package/src/types.ts +74 -3
  40. package/src/utils/component-context.ts +106 -0
  41. package/src/utils/exempt-paths.ts +39 -0
  42. package/src/utils/file-roles.ts +32 -0
  43. package/src/utils/imports.ts +4 -1
  44. package/src/utils/validate-options.ts +68 -0
  45. package/src/watcher.ts +17 -0
@@ -4,7 +4,7 @@ import { loadConfig } from '../config/loader'
4
4
  import { getPreset } from '../config/presets'
5
5
  import { allRules } from '../rules/index'
6
6
  import { applyFixes, lintFile } from '../runner'
7
- import type { LintConfig, Rule } from '../types'
7
+ import type { ConfigDiagnostic, LintConfig, Rule } from '../types'
8
8
  import { LineIndex } from '../utils/source'
9
9
 
10
10
  // Helper to create a config that enables all rules at default severity
@@ -12,6 +12,23 @@ function defaultConfig(): LintConfig {
12
12
  return getPreset('recommended')
13
13
  }
14
14
 
15
+ /** Build a config where one rule is configured with `exemptPaths` options. */
16
+ function configWithExemptPaths(ruleId: string, paths: string[]): LintConfig {
17
+ const base = getPreset('recommended')
18
+ const existing = base.rules[ruleId]
19
+ const severity = Array.isArray(existing) ? existing[0] : existing
20
+ if (severity === undefined || severity === 'off') {
21
+ throw new Error(`configWithExemptPaths: rule ${ruleId} is off in recommended preset`)
22
+ }
23
+ return {
24
+ ...base,
25
+ rules: {
26
+ ...base.rules,
27
+ [ruleId]: [severity, { exemptPaths: paths }] as const,
28
+ },
29
+ }
30
+ }
31
+
15
32
  // Helper to lint a string with specific rules
16
33
  function lintSource(
17
34
  source: string,
@@ -36,8 +53,8 @@ function lintWith(ruleId: string, source: string, filePath?: string) {
36
53
  // ── Rule Metadata ───────────────────────────────────────────────────────────
37
54
 
38
55
  describe('Rule metadata', () => {
39
- it('should have 58 rules', () => {
40
- expect(allRules.length).toBe(58)
56
+ it('should have 59 rules', () => {
57
+ expect(allRules.length).toBe(59)
41
58
  })
42
59
 
43
60
  it('should have unique rule IDs', () => {
@@ -82,7 +99,7 @@ describe('Rule metadata', () => {
82
99
  expect(counts.lifecycle).toBe(4)
83
100
  expect(counts.performance).toBe(4)
84
101
  expect(counts.ssr).toBe(3)
85
- expect(counts.architecture).toBe(6)
102
+ expect(counts.architecture).toBe(7)
86
103
  expect(counts.store).toBe(3)
87
104
  expect(counts.form).toBe(3)
88
105
  expect(counts.styling).toBe(4)
@@ -563,6 +580,30 @@ describe('SSR rules', () => {
563
580
  expect(diags.length).toBe(0)
564
581
  })
565
582
 
583
+ it('pyreon/no-window-in-ssr: exempt via configured exemptPaths', () => {
584
+ const source = `const w = window.innerWidth; document.createElement('div')`
585
+ const cfg = configWithExemptPaths('pyreon/no-window-in-ssr', [
586
+ 'packages/core/runtime-dom/',
587
+ ])
588
+ const result = lintFile('packages/core/runtime-dom/src/foo.ts', source, allRules, cfg)
589
+ const diags = findByRule(result, 'pyreon/no-window-in-ssr')
590
+ expect(diags.length).toBe(0)
591
+ })
592
+
593
+ it('pyreon/no-window-in-ssr: fires in same path when exemptPaths is not configured', () => {
594
+ const source = `const w = window.innerWidth`
595
+ const result = lintFile(
596
+ 'packages/core/runtime-dom/src/foo.ts',
597
+ source,
598
+ allRules,
599
+ defaultConfig(),
600
+ )
601
+ const diags = findByRule(result, 'pyreon/no-window-in-ssr')
602
+ // With no exemptPaths configured, the rule applies everywhere —
603
+ // this is the correct default for a rule shipping to user apps.
604
+ expect(diags.length).toBeGreaterThanOrEqual(1)
605
+ })
606
+
566
607
  it('pyreon/no-mismatch-risk: flags Date.now() in JSX', () => {
567
608
  const source = `const App = () => <div>{Date.now()}</div>`
568
609
  const result = lintSource(source)
@@ -616,6 +657,75 @@ describe('Architecture rules', () => {
616
657
  expect(diags.length).toBe(0)
617
658
  })
618
659
 
660
+ it('pyreon/dev-guard-warnings: clean inside `if (__DEV__ && X)` compound guard', () => {
661
+ const source = `if (__DEV__ && cond) { console.warn("x") }`
662
+ const result = lintSource(source)
663
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
664
+ expect(diags.length).toBe(0)
665
+ })
666
+
667
+ it('pyreon/dev-guard-warnings: clean inside `if (X && __DEV__)` compound guard', () => {
668
+ const source = `if (cond && __DEV__) { console.warn("x") }`
669
+ const result = lintSource(source)
670
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
671
+ expect(diags.length).toBe(0)
672
+ })
673
+
674
+ it('pyreon/dev-guard-warnings: clean inside `__DEV__ && console.warn(...)` short-circuit', () => {
675
+ const source = `__DEV__ && console.warn("x")`
676
+ const result = lintSource(source)
677
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
678
+ expect(diags.length).toBe(0)
679
+ })
680
+
681
+ it('pyreon/dev-guard-warnings: clean inside `__DEV__ ? warn : null` ternary', () => {
682
+ const source = `__DEV__ ? console.warn("x") : null`
683
+ const result = lintSource(source)
684
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
685
+ expect(diags.length).toBe(0)
686
+ })
687
+
688
+ it('pyreon/dev-guard-warnings: exempts console.error inside catch (production error reporting)', () => {
689
+ const source = `try { foo() } catch (err) { console.error("[Pyreon]", err) }`
690
+ const result = lintSource(source)
691
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
692
+ expect(diags.length).toBe(0)
693
+ })
694
+
695
+ it('pyreon/dev-guard-warnings: still flags console.warn inside catch (warns must be DEV-only)', () => {
696
+ const source = `try { foo() } catch (err) { console.warn("[Pyreon]", err) }`
697
+ const result = lintSource(source)
698
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
699
+ expect(diags.length).toBe(1)
700
+ })
701
+
702
+ it('pyreon/dev-guard-warnings: clean inside `if (import.meta.env.DEV)` guard', () => {
703
+ const source = `if (import.meta.env.DEV) { console.warn("x") }`
704
+ const result = lintSource(source)
705
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
706
+ expect(diags.length).toBe(0)
707
+ })
708
+
709
+ it('pyreon/dev-guard-warnings: clean after early-return DEV guard at top of function', () => {
710
+ const source = `function warn() {
711
+ if (!__DEV__) return
712
+ console.warn("a"); console.warn("b"); console.warn("c")
713
+ }`
714
+ const result = lintSource(source)
715
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
716
+ expect(diags.length).toBe(0)
717
+ })
718
+
719
+ it('pyreon/dev-guard-warnings: clean after early-return `import.meta.env.DEV` guard', () => {
720
+ const source = `function warn() {
721
+ if (!import.meta.env?.DEV) return
722
+ console.warn("x")
723
+ }`
724
+ const result = lintSource(source)
725
+ const diags = findByRule(result, 'pyreon/dev-guard-warnings')
726
+ expect(diags.length).toBe(0)
727
+ })
728
+
619
729
  // ── pyreon/no-process-dev-gate ────────────────────────────────────────────
620
730
  // The recurring browser-dead-code bug we fixed in PR #200. Tests cover:
621
731
  // - the canonical broken pattern (typeof process first, NODE_ENV second)
@@ -678,27 +788,29 @@ describe('Architecture rules', () => {
678
788
  expect(diags.length).toBe(0)
679
789
  })
680
790
 
681
- it('pyreon/no-process-dev-gate: exempts server-only packages (Node always has process)', () => {
791
+ it('pyreon/no-process-dev-gate: exempt via configured exemptPaths (server-only code)', () => {
682
792
  const source = `const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
683
- // packages/core/server/ is the SSR adapter — runs in Node, the pattern is correct there.
684
- const result = lintFile(
685
- 'packages/core/server/src/handler.ts',
686
- source,
687
- allRules,
688
- defaultConfig(),
689
- )
793
+ const cfg = configWithExemptPaths('pyreon/no-process-dev-gate', [
794
+ 'packages/core/server/',
795
+ ])
796
+ const result = lintFile('packages/core/server/src/handler.ts', source, allRules, cfg)
690
797
  const diags = findByRule(result, 'pyreon/no-process-dev-gate')
691
798
  expect(diags.length).toBe(0)
692
799
  })
693
800
 
694
- it('pyreon/no-process-dev-gate: exempts runtime-server, zero, vite-plugin', () => {
801
+ it('pyreon/no-process-dev-gate: exemptPaths covers multiple directories', () => {
695
802
  const source = `const __DEV__ = typeof process !== 'undefined' && process.env.NODE_ENV !== 'production'`
803
+ const cfg = configWithExemptPaths('pyreon/no-process-dev-gate', [
804
+ 'packages/core/runtime-server/',
805
+ 'packages/zero/',
806
+ 'packages/tools/vite-plugin/',
807
+ ])
696
808
  for (const path of [
697
809
  'packages/core/runtime-server/src/index.ts',
698
810
  'packages/zero/zero/src/logger.ts',
699
811
  'packages/tools/vite-plugin/src/index.ts',
700
812
  ]) {
701
- const result = lintFile(path, source, allRules, defaultConfig())
813
+ const result = lintFile(path, source, allRules, cfg)
702
814
  const diags = findByRule(result, 'pyreon/no-process-dev-gate')
703
815
  expect(diags.length, `expected ${path} to be exempt`).toBe(0)
704
816
  }
@@ -915,8 +1027,11 @@ defineStore("user", () => {})
915
1027
  expect(diags.length).toBe(0)
916
1028
  })
917
1029
 
918
- it('pyreon/no-mutate-store-state: flags store.signal.set()', () => {
919
- const result = lintWith('pyreon/no-mutate-store-state', `userStore.count.set(5)`)
1030
+ it('pyreon/no-mutate-store-state: flags store.signal.set() inside a component', () => {
1031
+ const result = lintWith(
1032
+ 'pyreon/no-mutate-store-state',
1033
+ `function MyComp() { userStore.count.set(5) }`,
1034
+ )
920
1035
  expect(result.diagnostics.length).toBe(1)
921
1036
  })
922
1037
 
@@ -1059,15 +1174,47 @@ describe('Hooks rules', () => {
1059
1174
  expect(diags.length).toBe(1)
1060
1175
  })
1061
1176
 
1062
- it('pyreon/no-raw-setinterval: flags setInterval outside onMount', () => {
1063
- const source = `setInterval(() => {}, 1000)`
1177
+ it('pyreon/no-raw-addeventlistener: exempt via configured exemptPaths', () => {
1178
+ const source = `el.addEventListener("click", handler)`
1179
+ const cfg = configWithExemptPaths('pyreon/no-raw-addeventlistener', [
1180
+ 'packages/core/runtime-dom/',
1181
+ 'packages/fundamentals/hooks/',
1182
+ ])
1183
+ for (const path of [
1184
+ 'packages/core/runtime-dom/src/delegate.ts',
1185
+ 'packages/fundamentals/hooks/src/useClickOutside.ts',
1186
+ ]) {
1187
+ const result = lintFile(path, source, allRules, cfg)
1188
+ const diags = findByRule(result, 'pyreon/no-raw-addeventlistener')
1189
+ expect(diags.length, `expected ${path} to be exempt`).toBe(0)
1190
+ }
1191
+ })
1192
+
1193
+ it('pyreon/no-raw-setinterval: flags setInterval inside a component (outside onMount)', () => {
1194
+ const source = `function MyComp() { setInterval(() => {}, 1000) }`
1064
1195
  const result = lintSource(source)
1065
1196
  const diags = findByRule(result, 'pyreon/no-raw-setinterval')
1066
1197
  expect(diags.length).toBe(1)
1067
1198
  })
1068
1199
 
1069
- it('pyreon/no-raw-localstorage: flags localStorage.getItem()', () => {
1070
- const source = `const v = localStorage.getItem("key")`
1200
+ it('pyreon/no-raw-setinterval: exempt via configured exemptPaths', () => {
1201
+ // Wrap in a component so component-context fires; exemptPaths then overrides.
1202
+ const source = `function useInterval() { setInterval(() => callback(), d) }`
1203
+ const cfg = configWithExemptPaths('pyreon/no-raw-setinterval', [
1204
+ 'packages/fundamentals/hooks/',
1205
+ ])
1206
+ const result = lintFile(
1207
+ 'packages/fundamentals/hooks/src/useInterval.ts',
1208
+ source,
1209
+ allRules,
1210
+ cfg,
1211
+ )
1212
+ const diags = findByRule(result, 'pyreon/no-raw-setinterval')
1213
+ expect(diags.length).toBe(0)
1214
+ })
1215
+
1216
+ it('pyreon/no-raw-localstorage: flags localStorage.getItem() inside a component', () => {
1217
+ const source = `function MyComp() { const v = localStorage.getItem("key") }`
1071
1218
  const result = lintSource(source)
1072
1219
  const diags = findByRule(result, 'pyreon/no-raw-localstorage')
1073
1220
  expect(diags.length).toBe(1)
@@ -1273,7 +1420,7 @@ describe('Ignore filter', () => {
1273
1420
  describe('Presets', () => {
1274
1421
  it('recommended should include all rules', () => {
1275
1422
  const config = getPreset('recommended')
1276
- expect(Object.keys(config.rules).length).toBe(58)
1423
+ expect(Object.keys(config.rules).length).toBe(59)
1277
1424
  })
1278
1425
 
1279
1426
  it('strict should promote all warns to errors', () => {
@@ -1310,3 +1457,1408 @@ describe('Presets', () => {
1310
1457
  expect(lib.rules['pyreon/no-process-dev-gate']).toBe('error')
1311
1458
  })
1312
1459
  })
1460
+
1461
+ // ── Component-context detection (B-rules) ──────────────────────────────────
1462
+ //
1463
+ // Four rules only matter inside a component or hook setup body. They no
1464
+ // longer rely on `isTestFile` path heuristics — the
1465
+ // `createComponentContextTracker()` utility detects the semantic context
1466
+ // directly. Module-level + utility-function callsites are exempt by design;
1467
+ // test callbacks (anonymous arrows passed to `it()`) are exempt because
1468
+ // they aren't named like a component or hook.
1469
+
1470
+ describe('component-context exemption (rules that only fire inside components/hooks)', () => {
1471
+ const cases: Array<[string, string]> = [
1472
+ ['pyreon/no-raw-setinterval', `setInterval(() => {}, 100)`],
1473
+ ['pyreon/no-dynamic-styled', `function helper() { styled('div')\`color:red\` }`],
1474
+ ['pyreon/no-raw-localstorage', `localStorage.getItem('k')`],
1475
+ ['pyreon/no-mutate-store-state', `store.count.set(5)`],
1476
+ ]
1477
+
1478
+ for (const [rule, source] of cases) {
1479
+ it(`${rule}: silent at module scope (no component on the stack)`, () => {
1480
+ const result = lintSource(source)
1481
+ const diags = findByRule(result, rule)
1482
+ expect(diags.length).toBe(0)
1483
+ })
1484
+
1485
+ it(`${rule}: silent in plain utility function (PascalCase / use-prefix not present)`, () => {
1486
+ const wrapped = `function doStuff() { ${source} }`
1487
+ const result = lintSource(wrapped)
1488
+ const diags = findByRule(result, rule)
1489
+ expect(diags.length).toBe(0)
1490
+ })
1491
+
1492
+ it(`${rule}: silent in a test callback (anonymous arrow)`, () => {
1493
+ const wrapped = `it('does the thing', () => { ${source} })`
1494
+ const result = lintSource(wrapped)
1495
+ const diags = findByRule(result, rule)
1496
+ expect(diags.length).toBe(0)
1497
+ })
1498
+
1499
+ it(`${rule}: fires inside a PascalCase component`, () => {
1500
+ const wrapped = `function MyComp() { ${source} }`
1501
+ const result = lintSource(wrapped)
1502
+ const diags = findByRule(result, rule)
1503
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1504
+ })
1505
+
1506
+ it(`${rule}: fires inside a use-prefixed hook`, () => {
1507
+ const wrapped = `function useThing() { ${source} }`
1508
+ const result = lintSource(wrapped)
1509
+ const diags = findByRule(result, rule)
1510
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1511
+ })
1512
+
1513
+ it(`${rule}: fires inside an arrow-form component (\`const MyComp = () => …\`)`, () => {
1514
+ const wrapped = `const MyComp = () => { ${source} }`
1515
+ const result = lintSource(wrapped)
1516
+ const diags = findByRule(result, rule)
1517
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1518
+ })
1519
+
1520
+ it(`${rule}: fires inside an arrow-form hook (\`const useFoo = () => …\`)`, () => {
1521
+ const wrapped = `const useFoo = () => { ${source} }`
1522
+ const result = lintSource(wrapped)
1523
+ const diags = findByRule(result, rule)
1524
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1525
+ })
1526
+ }
1527
+ })
1528
+
1529
+ // Arrow-form hook implementations are now correctly detected by
1530
+ // `no-theme-outside-provider` (fixes a silent bug where the rule ignored
1531
+ // the most common React/Solid hook idiom).
1532
+ describe('no-theme-outside-provider: arrow-form hook implementation', () => {
1533
+ it('silent inside `const useFoo = (...) => { useTheme() }` (hook delegates provider to caller)', () => {
1534
+ const source = `
1535
+ import { useTheme } from '@pyreon/styler'
1536
+ export const useThemeValue = (path) => {
1537
+ const theme = useTheme()
1538
+ return theme?.[path]
1539
+ }
1540
+ `
1541
+ const result = lintSource(source)
1542
+ const diags = findByRule(result, 'pyreon/no-theme-outside-provider')
1543
+ expect(diags.length).toBe(0)
1544
+ })
1545
+
1546
+ it('still fires inside a non-hook arrow function calling useTheme', () => {
1547
+ const source = `
1548
+ import { useTheme } from '@pyreon/styler'
1549
+ export const getColor = () => useTheme()?.colors?.primary
1550
+ `
1551
+ const result = lintSource(source)
1552
+ const diags = findByRule(result, 'pyreon/no-theme-outside-provider')
1553
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1554
+ })
1555
+ })
1556
+
1557
+ // `no-window-in-ssr` recognizes the const-captured typeof guard idiom:
1558
+ // `const isBrowser = typeof window !== 'undefined'; if (isBrowser) { window.X }`.
1559
+ describe('no-window-in-ssr: const-captured typeof guard', () => {
1560
+ it('silent under `if (isBrowser)` after `const isBrowser = typeof window !== "undefined"`', () => {
1561
+ const source = `
1562
+ const isBrowser = typeof window !== 'undefined'
1563
+ if (isBrowser) {
1564
+ window.addEventListener('online', () => {})
1565
+ }
1566
+ `
1567
+ const result = lintSource(source)
1568
+ const diags = findByRule(result, 'pyreon/no-window-in-ssr')
1569
+ expect(diags.length).toBe(0)
1570
+ })
1571
+
1572
+ it('still fires when the const is not a typeof check', () => {
1573
+ const source = `
1574
+ const isBrowser = true
1575
+ if (isBrowser) {
1576
+ window.addEventListener('online', () => {})
1577
+ }
1578
+ `
1579
+ const result = lintSource(source)
1580
+ const diags = findByRule(result, 'pyreon/no-window-in-ssr')
1581
+ expect(diags.length).toBeGreaterThanOrEqual(1)
1582
+ })
1583
+ })
1584
+
1585
+ // `no-window-in-ssr` precision improvements introduced alongside the hooks
1586
+ // anti-pattern cleanup. Each block targets one of the silent-false-positive
1587
+ // sources previously caused by oxc's visitor not passing `parent`.
1588
+ describe('no-window-in-ssr: precision (oxc no-parent fixes)', () => {
1589
+ it('silent when `typeof X` is the expression itself (the mention of X is not a global ref)', () => {
1590
+ const source = `const t = typeof window`
1591
+ const result = lintSource(source)
1592
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1593
+ })
1594
+
1595
+ it('silent for member-expression property names (x.addEventListener)', () => {
1596
+ const source = `function f(x) { x.addEventListener('click', () => {}) }`
1597
+ const result = lintSource(source)
1598
+ // `addEventListener` is a property name, not a global — must not fire.
1599
+ // (`x` is also not a browser global.)
1600
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1601
+ })
1602
+
1603
+ it('silent for object-property keys ({ document: 1 })', () => {
1604
+ const source = `const o = { document: 1, window: 2 }`
1605
+ const result = lintSource(source)
1606
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1607
+ })
1608
+
1609
+ it('silent for import-specifier names', () => {
1610
+ const source = `import { window as w } from './foo'`
1611
+ const result = lintSource(source)
1612
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1613
+ })
1614
+
1615
+ it('silent for TS type-position identifiers (let x: Window)', () => {
1616
+ const source = `let x: Window | null = null`
1617
+ const result = lintSource(source)
1618
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1619
+ })
1620
+
1621
+ it('silent under early-return-on-typeof guard', () => {
1622
+ const source = `
1623
+ function load() {
1624
+ if (typeof window === 'undefined') return
1625
+ window.addEventListener('online', () => {})
1626
+ }
1627
+ `
1628
+ const result = lintSource(source)
1629
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1630
+ })
1631
+
1632
+ it('silent under OR-chained early-return-on-typeof guard', () => {
1633
+ const source = `
1634
+ function load() {
1635
+ if (typeof window === 'undefined' || typeof IntersectionObserver === 'undefined') return
1636
+ const o = new IntersectionObserver(() => {})
1637
+ window.addEventListener('online', () => {})
1638
+ }
1639
+ `
1640
+ const result = lintSource(source)
1641
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1642
+ })
1643
+
1644
+ it('silent inside onUnmount / onCleanup / renderEffect', () => {
1645
+ const source = `
1646
+ onUnmount(() => { window.removeEventListener('x', () => {}) })
1647
+ onCleanup(() => { document.body.style.overflow = '' })
1648
+ renderEffect(() => { const w = window.innerWidth })
1649
+ `
1650
+ const result = lintSource(source)
1651
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1652
+ })
1653
+
1654
+ it('silent in ternary consequent of typeof check', () => {
1655
+ const source = `const w = typeof window !== 'undefined' ? window.innerWidth : 0`
1656
+ const result = lintSource(source)
1657
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1658
+ })
1659
+
1660
+ it('still fires for the negated form body (safety check on testIsTypeofGuard split)', () => {
1661
+ // `if (typeof window === 'undefined') { window.X }` — the body is the
1662
+ // SSR-fallback branch, NOT a browser-safe zone. Must fire.
1663
+ const source = `
1664
+ if (typeof window === 'undefined') {
1665
+ const w = window.innerWidth
1666
+ }
1667
+ `
1668
+ const result = lintSource(source)
1669
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
1670
+ })
1671
+ })
1672
+
1673
+ // Additional precision: `IS_BROWSER && active()` as ternary/if test — the
1674
+ // LogicalAnd short-circuits so the body only runs when the typeof-derived
1675
+ // const is truthy. Common pattern in Portal/Overlay conditional rendering.
1676
+ describe('no-window-in-ssr: logical-and guards with typeof-derived const', () => {
1677
+ it('silent under `IS_BROWSER && cond()` ternary test', () => {
1678
+ const source = `
1679
+ const IS_BROWSER = typeof window !== 'undefined'
1680
+ const vnode = IS_BROWSER && cond() ? document.body : null
1681
+ `
1682
+ const result = lintSource(source)
1683
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1684
+ })
1685
+
1686
+ it('silent under `cond() && IS_BROWSER` ternary test (either side)', () => {
1687
+ const source = `
1688
+ const IS_BROWSER = typeof window !== 'undefined'
1689
+ const vnode = cond() && IS_BROWSER ? document.body : null
1690
+ `
1691
+ const result = lintSource(source)
1692
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1693
+ })
1694
+
1695
+ it('fires when neither side is a typeof guard', () => {
1696
+ const source = `
1697
+ const vnode = flag && cond() ? document.body : null
1698
+ `
1699
+ const result = lintSource(source)
1700
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
1701
+ })
1702
+ })
1703
+
1704
+ // The `render` VNode-producing helper from `@pyreon/ui-core` takes a
1705
+ // ComponentFn/string/VNode and returns a VNodeChild — its call sites in JSX
1706
+ // always produce a VNode, not a signal value. Exempted from the bare-signal
1707
+ // heuristic.
1708
+ describe('no-bare-signal-in-jsx: render() helper exemption', () => {
1709
+ it('silent for render(content, props) in JSX text', () => {
1710
+ const source = `const App = () => <div>{render(own.trigger, { active: true })}</div>`
1711
+ const result = lintSource(source)
1712
+ expect(findByRule(result, 'pyreon/no-bare-signal-in-jsx').length).toBe(0)
1713
+ })
1714
+
1715
+ it('still fires for other bare identifiers in JSX text', () => {
1716
+ const source = `const App = () => <div>{count()}</div>`
1717
+ const result = lintSource(source)
1718
+ expect(findByRule(result, 'pyreon/no-bare-signal-in-jsx').length).toBeGreaterThanOrEqual(1)
1719
+ })
1720
+
1721
+ it('silent for h() hyperscript in JSX text (VNode-producing helper)', () => {
1722
+ const source = `const App = () => <Show>{h('span', null, 'y')}</Show>`
1723
+ const result = lintSource(source)
1724
+ expect(findByRule(result, 'pyreon/no-bare-signal-in-jsx').length).toBe(0)
1725
+ })
1726
+
1727
+ it('silent for cloneVNode() in JSX text (VNode-producing helper)', () => {
1728
+ const source = `const App = (props) => <Show>{cloneVNode(props.children, { ref: r })}</Show>`
1729
+ const result = lintSource(source)
1730
+ expect(findByRule(result, 'pyreon/no-bare-signal-in-jsx').length).toBe(0)
1731
+ })
1732
+ })
1733
+
1734
+ // `no-window-in-ssr` — `watch()` from @pyreon/reactivity and
1735
+ // `requestAnimationFrame` are safe contexts: their callbacks only fire
1736
+ // after initial setup / inside a browser frame. watch is handled
1737
+ // precisely: only the 2nd arg (callback) is safe, the 1st (source) is
1738
+ // evaluated at setup and stays under normal analysis.
1739
+ describe('no-window-in-ssr: watch() and requestAnimationFrame safe contexts', () => {
1740
+ it('silent in the callback arg of watch(source, callback)', () => {
1741
+ const source = `
1742
+ watch(() => stage(), (s) => {
1743
+ cancelAnimationFrame(frameId)
1744
+ })
1745
+ `
1746
+ const result = lintSource(source)
1747
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1748
+ })
1749
+
1750
+ it('FIRES for browser globals in the SOURCE arg of watch (setup-time)', () => {
1751
+ // The source arg runs at setup to track signals — browser globals there
1752
+ // would break SSR. Only the 2nd-arg callback is deferred.
1753
+ const source = `
1754
+ watch(() => window.innerWidth, (w) => {})
1755
+ `
1756
+ const result = lintSource(source)
1757
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
1758
+ })
1759
+
1760
+ it('silent inside requestAnimationFrame callback', () => {
1761
+ const source = `
1762
+ requestAnimationFrame(() => {
1763
+ const w = window.innerWidth
1764
+ })
1765
+ `
1766
+ const result = lintSource(source)
1767
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1768
+ })
1769
+ })
1770
+
1771
+ // `no-window-in-ssr` — parameter-shadowing and typeof-derived const via
1772
+ // logical-and chains. Driven by false-positives surfaced in @pyreon/router.
1773
+ describe('no-window-in-ssr: parameter shadowing + typeof-derived AND chains', () => {
1774
+ it('silent when a function parameter named `location` shadows the global', () => {
1775
+ const source = `
1776
+ function push(location) {
1777
+ if (typeof location === 'string') return location.toUpperCase()
1778
+ return JSON.stringify(location)
1779
+ }
1780
+ `
1781
+ const result = lintSource(source)
1782
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1783
+ })
1784
+
1785
+ it('still fires when the global is used in a sibling scope without shadowing', () => {
1786
+ const source = `
1787
+ function push(location) { return location }
1788
+ const hostname = window.location.hostname
1789
+ `
1790
+ const result = lintSource(source)
1791
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
1792
+ })
1793
+
1794
+ it('silent under `if (useVT)` when useVT binds from `_isBrowser && ... && typeof X === "function"`', () => {
1795
+ const source = `
1796
+ const _isBrowser = typeof window !== 'undefined'
1797
+ const useVT = _isBrowser && meta && typeof document.startViewTransition === 'function'
1798
+ if (useVT) {
1799
+ document.startViewTransition(() => {})
1800
+ }
1801
+ `
1802
+ const result = lintSource(source)
1803
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1804
+ })
1805
+
1806
+ it('silent for destructured parameter named `location`', () => {
1807
+ const source = `
1808
+ function route({ location, path }) { return location + path }
1809
+ `
1810
+ const result = lintSource(source)
1811
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1812
+ })
1813
+
1814
+ it('silent when a browser-global name is shadowed by a top-level import binding', () => {
1815
+ // `import { history } from '@codemirror/commands'` — every later
1816
+ // `history` identifier in the file is the import, not `window.history`.
1817
+ const source = `
1818
+ import { history } from '@codemirror/commands'
1819
+ const ext = history()
1820
+ `
1821
+ const result = lintSource(source)
1822
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1823
+ })
1824
+
1825
+ it('silent for default-import binding shadowing a browser global', () => {
1826
+ const source = `
1827
+ import history from '@codemirror/commands'
1828
+ const ext = history()
1829
+ `
1830
+ const result = lintSource(source)
1831
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1832
+ })
1833
+
1834
+ it('silent for namespace-import binding shadowing a browser global', () => {
1835
+ const source = `
1836
+ import * as location from './location-utils'
1837
+ const x = location()
1838
+ `
1839
+ const result = lintSource(source)
1840
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1841
+ })
1842
+
1843
+ it('silent under `if (handler)` when handler is bound via `_isBrowser ? fn : null`', () => {
1844
+ // Ternary-derived bindings: `handler` is only non-null when the guard
1845
+ // was truthy, so `if (handler)` implicitly asserts the guard held.
1846
+ const source = `
1847
+ const _isBrowser = typeof window !== 'undefined'
1848
+ const handler = _isBrowser ? (e) => e.preventDefault() : null
1849
+ if (handler) {
1850
+ window.addEventListener('beforeunload', handler)
1851
+ }
1852
+ `
1853
+ const result = lintSource(source)
1854
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1855
+ })
1856
+
1857
+ it('silent under `if (h)` when h is bound via `_isBrowser && mode === X ? fn : null`', () => {
1858
+ const source = `
1859
+ const _isBrowser = typeof window !== 'undefined'
1860
+ const h = _isBrowser && mode === 'history' ? () => {} : null
1861
+ if (h) { window.addEventListener('popstate', h) }
1862
+ `
1863
+ const result = lintSource(source)
1864
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1865
+ })
1866
+ })
1867
+
1868
+ // Inline suppression — both the long-form and short-form syntaxes are accepted.
1869
+ describe('inline suppression comments', () => {
1870
+ it('suppresses next-line diagnostic via `// pyreon-lint-ignore <rule-id>`', () => {
1871
+ const source = [
1872
+ 'const x = signal(0)',
1873
+ '// pyreon-lint-ignore pyreon/no-peek-in-tracked',
1874
+ 'const c = computed(() => x.peek())',
1875
+ ].join('\n')
1876
+ const result = lintSource(source)
1877
+ expect(findByRule(result, 'pyreon/no-peek-in-tracked').length).toBe(0)
1878
+ })
1879
+
1880
+ it('suppresses next-line diagnostic via `// pyreon-lint-disable-next-line <rule-id>` (alias)', () => {
1881
+ const source = [
1882
+ 'const x = signal(0)',
1883
+ '// pyreon-lint-disable-next-line pyreon/no-peek-in-tracked',
1884
+ 'const c = computed(() => x.peek())',
1885
+ ].join('\n')
1886
+ const result = lintSource(source)
1887
+ expect(findByRule(result, 'pyreon/no-peek-in-tracked').length).toBe(0)
1888
+ })
1889
+
1890
+ it('still fires when the suppression comment names a different rule', () => {
1891
+ const source = [
1892
+ 'const x = signal(0)',
1893
+ '// pyreon-lint-disable-next-line pyreon/no-window-in-ssr',
1894
+ 'const c = computed(() => x.peek())',
1895
+ ].join('\n')
1896
+ const result = lintSource(source)
1897
+ expect(findByRule(result, 'pyreon/no-peek-in-tracked').length).toBeGreaterThanOrEqual(1)
1898
+ })
1899
+
1900
+ it('bare suppression (no rule id) suppresses ALL diagnostics on the next line', () => {
1901
+ const source = [
1902
+ 'const x = signal(0)',
1903
+ '// pyreon-lint-disable-next-line',
1904
+ 'const c = computed(() => x.peek())',
1905
+ ].join('\n')
1906
+ const result = lintSource(source)
1907
+ expect(findByRule(result, 'pyreon/no-peek-in-tracked').length).toBe(0)
1908
+ })
1909
+
1910
+ it('does NOT match typoed variants like `// pyreon-lint-ignored` (word-boundary)', () => {
1911
+ const source = [
1912
+ 'const x = signal(0)',
1913
+ '// pyreon-lint-ignored pyreon/no-peek-in-tracked',
1914
+ 'const c = computed(() => x.peek())',
1915
+ ].join('\n')
1916
+ const result = lintSource(source)
1917
+ expect(findByRule(result, 'pyreon/no-peek-in-tracked').length).toBeGreaterThanOrEqual(1)
1918
+ })
1919
+ })
1920
+
1921
+ // Early-return guards with `throw` (not just `return`) — common in entry-point
1922
+ // functions like `startClient` that hard-fail in SSR rather than silently
1923
+ // no-op. Both `no-window-in-ssr` and `no-dom-in-setup` recognise the form.
1924
+ describe('early-return guards: throw terminator', () => {
1925
+ it('no-window-in-ssr silent under `if (typeof X === "undefined") throw …` early-return', () => {
1926
+ const source = `
1927
+ function startClient() {
1928
+ if (typeof document === 'undefined') throw new Error('browser only')
1929
+ const el = document.body
1930
+ }
1931
+ `
1932
+ const result = lintSource(source)
1933
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1934
+ })
1935
+
1936
+ it('no-dom-in-setup silent under `if (typeof document === "undefined") return` at function head', () => {
1937
+ const source = `
1938
+ function loadScript() {
1939
+ if (typeof document === 'undefined') return
1940
+ const x = document.getElementById('app')
1941
+ }
1942
+ `
1943
+ const result = lintSource(source)
1944
+ expect(findByRule(result, 'pyreon/no-dom-in-setup').length).toBe(0)
1945
+ })
1946
+
1947
+ it('no-dom-in-setup silent under `if (typeof window === "undefined") throw …` early-return', () => {
1948
+ const source = `
1949
+ function init() {
1950
+ if (typeof window === 'undefined') throw new Error('browser only')
1951
+ const x = document.querySelector('#app')
1952
+ }
1953
+ `
1954
+ const result = lintSource(source)
1955
+ expect(findByRule(result, 'pyreon/no-dom-in-setup').length).toBe(0)
1956
+ })
1957
+
1958
+ it('no-dom-in-setup still fires without an early-return guard', () => {
1959
+ const source = `
1960
+ function init() {
1961
+ const x = document.querySelector('#app')
1962
+ }
1963
+ `
1964
+ const result = lintSource(source)
1965
+ expect(findByRule(result, 'pyreon/no-dom-in-setup').length).toBeGreaterThanOrEqual(1)
1966
+ })
1967
+
1968
+ it('no-window-in-ssr does NOT fire on `fetch` (universal in Node 18+/Bun/Deno/browsers/edge)', () => {
1969
+ const source = `
1970
+ async function loadJson(url) {
1971
+ const res = await fetch(url)
1972
+ return res.json()
1973
+ }
1974
+ `
1975
+ const result = lintSource(source)
1976
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1977
+ })
1978
+ })
1979
+
1980
+ // Typeof-guard functions — `function isBrowser() { return typeof window !==
1981
+ // 'undefined' }` is structurally a typeof check at its call sites. Common
1982
+ // pattern in storage adapters and SSR-aware utilities. Conventional names
1983
+ // (`isBrowser`/`isClient`/`isServer`/`isSSR`) are pre-seeded so cross-module
1984
+ // imports work without follow-the-import analysis.
1985
+ describe('no-window-in-ssr: typeof-guard functions', () => {
1986
+ it('silent under `if (!isBrowser()) return` early-return (locally-defined)', () => {
1987
+ const source = `
1988
+ function isBrowser() { return typeof window !== 'undefined' }
1989
+ function getStorage() {
1990
+ if (!isBrowser()) return null
1991
+ return window.localStorage
1992
+ }
1993
+ `
1994
+ const result = lintSource(source)
1995
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
1996
+ })
1997
+
1998
+ it('silent under `if (isBrowser())` positive guard (arrow form)', () => {
1999
+ const source = `
2000
+ const isBrowser = () => typeof window !== 'undefined'
2001
+ function attach() {
2002
+ if (isBrowser()) {
2003
+ window.addEventListener('storage', () => {})
2004
+ }
2005
+ }
2006
+ `
2007
+ const result = lintSource(source)
2008
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
2009
+ })
2010
+
2011
+ it('silent under `!isBrowser()` even when imported (name-based fallback)', () => {
2012
+ const source = `
2013
+ import { isBrowser } from './utils'
2014
+ function getStorage() {
2015
+ if (!isBrowser()) return null
2016
+ return window.localStorage
2017
+ }
2018
+ `
2019
+ const result = lintSource(source)
2020
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
2021
+ })
2022
+
2023
+ it('silent for a function with AND-chained typeof body', () => {
2024
+ const source = `
2025
+ function isBrowser() {
2026
+ return typeof window !== 'undefined' && typeof document !== 'undefined'
2027
+ }
2028
+ function read() {
2029
+ if (!isBrowser()) return null
2030
+ return document.cookie
2031
+ }
2032
+ `
2033
+ const result = lintSource(source)
2034
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
2035
+ })
2036
+
2037
+ it('still fires for a non-conventional function name with no typeof body', () => {
2038
+ const source = `
2039
+ function notAGuard() { return true }
2040
+ function getStorage() {
2041
+ if (!notAGuard()) return null
2042
+ return window.localStorage
2043
+ }
2044
+ `
2045
+ const result = lintSource(source)
2046
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
2047
+ })
2048
+ })
2049
+
2050
+ // `no-imperative-navigate-in-render` — navigate calls inside nested
2051
+ // functions (event handlers, effect callbacks, ref callbacks) are deferred
2052
+ // execution and must not be flagged.
2053
+ describe('no-imperative-navigate-in-render: deferred-execution callbacks', () => {
2054
+ it('silent when router.push is inside an event handler assigned to a local const', () => {
2055
+ const source = `
2056
+ const RouterLink = (props) => {
2057
+ const handleClick = (e) => {
2058
+ router.push(props.to)
2059
+ }
2060
+ return <a onClick={handleClick} />
2061
+ }
2062
+ `
2063
+ const result = lintSource(source)
2064
+ expect(findByRule(result, 'pyreon/no-imperative-navigate-in-render').length).toBe(0)
2065
+ })
2066
+
2067
+ it('silent when navigate is inside a setTimeout callback', () => {
2068
+ const source = `
2069
+ const Comp = (props) => {
2070
+ setTimeout(() => { navigate('/home') }, 100)
2071
+ return <div />
2072
+ }
2073
+ `
2074
+ const result = lintSource(source)
2075
+ expect(findByRule(result, 'pyreon/no-imperative-navigate-in-render').length).toBe(0)
2076
+ })
2077
+
2078
+ it('still fires for router.push directly in component body', () => {
2079
+ const source = `
2080
+ const Comp = (props) => {
2081
+ router.push('/elsewhere')
2082
+ return <div />
2083
+ }
2084
+ `
2085
+ const result = lintSource(source)
2086
+ expect(findByRule(result, 'pyreon/no-imperative-navigate-in-render').length).toBeGreaterThanOrEqual(1)
2087
+ })
2088
+
2089
+ it('fires when a nested fn is DEFINED and immediately CALLED in the render body', () => {
2090
+ // `const fn = () => router.push(); fn()` — the navigate runs synchronously
2091
+ // on every render (same infinite-loop bug as a direct call).
2092
+ const source = `
2093
+ const Comp = (props) => {
2094
+ const goHome = () => router.push('/home')
2095
+ goHome()
2096
+ return <div />
2097
+ }
2098
+ `
2099
+ const result = lintSource(source)
2100
+ expect(findByRule(result, 'pyreon/no-imperative-navigate-in-render').length).toBeGreaterThanOrEqual(1)
2101
+ })
2102
+
2103
+ it('silent when a nested fn containing navigate is defined but NOT called synchronously', () => {
2104
+ // Defined-and-stored pattern — the navigate never runs during render.
2105
+ const source = `
2106
+ const Comp = (props) => {
2107
+ const goHome = () => navigate('/home')
2108
+ return <a onClick={goHome} />
2109
+ }
2110
+ `
2111
+ const result = lintSource(source)
2112
+ expect(findByRule(result, 'pyreon/no-imperative-navigate-in-render').length).toBe(0)
2113
+ })
2114
+ })
2115
+
2116
+ // `no-dom-in-setup` — recognises `requestAnimationFrame`, `onUnmount`,
2117
+ // `onCleanup`, `renderEffect` as safe contexts (post-mount browser only).
2118
+ describe('no-dom-in-setup: expanded safe-context set', () => {
2119
+ it('silent inside requestAnimationFrame callback', () => {
2120
+ const source = `
2121
+ requestAnimationFrame(() => {
2122
+ const el = document.getElementById('x')
2123
+ })
2124
+ `
2125
+ const result = lintSource(source)
2126
+ expect(findByRule(result, 'pyreon/no-dom-in-setup').length).toBe(0)
2127
+ })
2128
+
2129
+ it('silent inside onCleanup / onUnmount / renderEffect', () => {
2130
+ const source = `
2131
+ onCleanup(() => { document.querySelector('.x') })
2132
+ onUnmount(() => { document.getElementById('y') })
2133
+ renderEffect(() => { document.getElementsByClassName('z') })
2134
+ `
2135
+ const result = lintSource(source)
2136
+ expect(findByRule(result, 'pyreon/no-dom-in-setup').length).toBe(0)
2137
+ })
2138
+ })
2139
+
2140
+ // `dev-guard-warnings` — conventional name-based flag recognition. The rule
2141
+ // can't follow cross-module imports to verify a binding really resolves to
2142
+ // `import.meta.env.DEV`, so well-known dev-flag identifiers (`__DEV__`,
2143
+ // `IS_DEV`, `IS_DEVELOPMENT`, `isDev`) are recognised by name.
2144
+ describe('dev-guard-warnings: dev-flag identifier conventions', () => {
2145
+ it('silent under `if (!IS_DEVELOPMENT) return` early-return guard', () => {
2146
+ const source = `
2147
+ function devWarn(msg) {
2148
+ if (!IS_DEVELOPMENT) return
2149
+ console.warn(msg)
2150
+ }
2151
+ `
2152
+ const result = lintSource(source)
2153
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBe(0)
2154
+ })
2155
+
2156
+ it('silent under `IS_DEV && console.warn(...)` logical-and guard', () => {
2157
+ const source = `IS_DEV && console.warn('hello')`
2158
+ const result = lintSource(source)
2159
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBe(0)
2160
+ })
2161
+
2162
+ it('still fires without any guard', () => {
2163
+ const source = `console.warn('no guard')`
2164
+ const result = lintSource(source)
2165
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBeGreaterThanOrEqual(1)
2166
+ })
2167
+
2168
+ it('silent under locally-bound dev flag: `const D = import.meta.env.DEV === true; if (D) { console.warn(…) }`', () => {
2169
+ const source = `
2170
+ const D = import.meta.env.DEV === true
2171
+ if (D) { console.warn('boom') }
2172
+ `
2173
+ const result = lintSource(source)
2174
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBe(0)
2175
+ })
2176
+
2177
+ it('user-supplied `devFlagNames` extends the default set', () => {
2178
+ const source = `
2179
+ if (__DEBUG__) { console.warn('hello') }
2180
+ // Built-ins still work:
2181
+ if (__DEV__) { console.warn('world') }
2182
+ `
2183
+ const base = getPreset('recommended')
2184
+ const existing = base.rules['pyreon/dev-guard-warnings']
2185
+ const severity = Array.isArray(existing) ? existing[0] : existing
2186
+ const cfg: LintConfig = {
2187
+ ...base,
2188
+ rules: {
2189
+ ...base.rules,
2190
+ 'pyreon/dev-guard-warnings': [severity as 'error', { devFlagNames: ['__DEBUG__'] }],
2191
+ },
2192
+ }
2193
+ const result = lintFile('src/foo.ts', source, allRules, cfg)
2194
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBe(0)
2195
+ })
2196
+
2197
+ it('unknown identifier does NOT act as a guard (negative case)', () => {
2198
+ const source = `if (random_flag) { console.warn('hi') }`
2199
+ const result = lintSource(source)
2200
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBeGreaterThanOrEqual(1)
2201
+ })
2202
+ })
2203
+
2204
+ // ── Test-file heuristic (C-rules) ────────────────────────────────────────────
2205
+ //
2206
+ // Three rules can't distinguish "intentional test stub" from "real production
2207
+ // usage" purely from the AST (the rule's premise is intent-dependent: did
2208
+ // you forget validation, did you mean to duplicate this id, did you mean
2209
+ // to leave the field unregistered). For these we keep an explicit test-file
2210
+ // heuristic and document it as such — a `// pyreon-lint-disable-next-line`
2211
+ // is the right tool for intentional exceptions in production code.
2212
+ //
2213
+ // `no-circular-import` also keeps file-path skip but for a different reason —
2214
+ // tests don't ship as part of the layered production dep graph, so the
2215
+ // layer-order discipline genuinely doesn't apply to them. Categorical, not
2216
+ // a heuristic.
2217
+
2218
+ describe('test-file heuristic (rules that intentionally skip *.test.* files)', () => {
2219
+ const cases: Array<[string, string, string]> = [
2220
+ ['pyreon/no-submit-without-validation', 'src/tests/form.test.tsx', `useForm({ onSubmit: () => {} })`],
2221
+ ['pyreon/no-duplicate-store-id', 'src/tests/store.test.ts', `defineStore('a', () => {}); defineStore('a', () => {})`],
2222
+ ['pyreon/no-unregistered-field', 'src/tests/form.test.ts', `const f = useField(form, 'x')`],
2223
+ ['pyreon/no-circular-import', 'packages/core/runtime-dom/src/tests/integration.test.ts', `import { renderToString } from '@pyreon/runtime-server'`],
2224
+ ]
2225
+
2226
+ for (const [rule, filePath, source] of cases) {
2227
+ it(`${rule}: exempt in ${filePath}`, () => {
2228
+ const result = lintFile(filePath, source, allRules, defaultConfig())
2229
+ const diags = findByRule(result, rule)
2230
+ expect(diags.length).toBe(0)
2231
+ })
2232
+ }
2233
+ })
2234
+
2235
+ // ── Rule options schema validation ───────────────────────────────────────────
2236
+
2237
+ describe('rule options schema', () => {
2238
+ beforeEach(async () => {
2239
+ const { _resetConfigDiagnosticsCache } = await import('../runner')
2240
+ _resetConfigDiagnosticsCache()
2241
+ })
2242
+
2243
+ it('wrong-typed option disables the rule + surfaces a config diagnostic', () => {
2244
+ // `exemptPaths` declared as string[]; user passes a string. Cast via
2245
+ // JSON.parse so we exercise the runtime validator (TypeScript would
2246
+ // otherwise reject the literal at compile time).
2247
+ const badOptions = JSON.parse(
2248
+ `{"exemptPaths":"packages/core/runtime-dom/"}`,
2249
+ ) as Record<string, unknown>
2250
+ const cfg: LintConfig = {
2251
+ rules: { 'pyreon/no-window-in-ssr': ['error', badOptions] },
2252
+ }
2253
+ const source = `const w = window.innerWidth`
2254
+ const sink: ConfigDiagnostic[] = []
2255
+ const result = lintFile(
2256
+ 'packages/core/runtime-dom/src/foo.ts',
2257
+ source,
2258
+ allRules,
2259
+ cfg,
2260
+ undefined,
2261
+ sink,
2262
+ )
2263
+ // Rule disabled → no file diagnostic for this rule (even though the
2264
+ // source WOULD trip it with a valid config).
2265
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
2266
+ // Config diagnostic surfaces in the sink (not just stderr).
2267
+ expect(sink.some((d) => d.severity === 'error' && d.message.includes('must be string[]'))).toBe(
2268
+ true,
2269
+ )
2270
+ })
2271
+
2272
+ it('unknown option surfaces a warning diagnostic but keeps the rule enabled', () => {
2273
+ const cfg: LintConfig = {
2274
+ rules: {
2275
+ 'pyreon/no-window-in-ssr': [
2276
+ 'error',
2277
+ { typo: 'oops', exemptPaths: ['packages/core/runtime-dom/'] },
2278
+ ],
2279
+ },
2280
+ }
2281
+ const source = `const w = window.innerWidth`
2282
+ const sink: ConfigDiagnostic[] = []
2283
+ const result = lintFile(
2284
+ 'packages/core/runtime-dom/src/foo.ts',
2285
+ source,
2286
+ allRules,
2287
+ cfg,
2288
+ undefined,
2289
+ sink,
2290
+ )
2291
+ // Rule still works (real exemptPaths still applied).
2292
+ expect(findByRule(result, 'pyreon/no-window-in-ssr').length).toBe(0)
2293
+ // Warning surfaces.
2294
+ expect(sink.some((d) => d.severity === 'warn' && d.message.includes('unknown option "typo"'))).toBe(
2295
+ true,
2296
+ )
2297
+ })
2298
+
2299
+ it('schema-less rules accept any options without validation diagnostics', () => {
2300
+ // `no-map-in-jsx` has no schema — options pass through.
2301
+ const cfg: LintConfig = {
2302
+ rules: { 'pyreon/no-map-in-jsx': ['warn', { whatever: [1, 2, 3] }] },
2303
+ }
2304
+ const source = `const X = () => <div>{items.map(i => <span>{i}</span>)}</div>`
2305
+ const sink: ConfigDiagnostic[] = []
2306
+ const result = lintFile('src/Foo.tsx', source, allRules, cfg, undefined, sink)
2307
+ expect(findByRule(result, 'pyreon/no-map-in-jsx').length).toBeGreaterThanOrEqual(1)
2308
+ expect(sink.length).toBe(0)
2309
+ })
2310
+
2311
+ it('config diagnostics flow through `lint()` to LintResult.configDiagnostics', async () => {
2312
+ // End-to-end: `lint()` on a tmp dir with bad config → result has the diagnostic.
2313
+ const { tmpdir } = await import('node:os')
2314
+ const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
2315
+ const { join } = await import('node:path')
2316
+ const dir = mkdtempSync(join(tmpdir(), 'pyreon-lint-cfg-bad-'))
2317
+ try {
2318
+ writeFileSync(join(dir, '.pyreonlintrc.json'), JSON.stringify({
2319
+ preset: 'recommended',
2320
+ rules: { 'pyreon/no-window-in-ssr': ['error', { exemptPaths: 'oops-not-array' }] },
2321
+ }))
2322
+ writeFileSync(join(dir, 'foo.ts'), `const w = window.innerWidth`)
2323
+
2324
+ const { lint } = await import('../lint')
2325
+ const result = lint({ paths: [dir], config: join(dir, '.pyreonlintrc.json') })
2326
+ expect(result.configDiagnostics.length).toBeGreaterThanOrEqual(1)
2327
+ expect(result.configDiagnostics.some((d) => d.message.includes('must be string[]'))).toBe(true)
2328
+ } finally {
2329
+ rmSync(dir, { recursive: true, force: true })
2330
+ }
2331
+ })
2332
+ })
2333
+
2334
+ // ── CLI --rule-options parser ────────────────────────────────────────────────
2335
+
2336
+ describe('parseRuleOptionsOverride (CLI flag parser)', () => {
2337
+ let parser: typeof import('../cli').parseRuleOptionsOverride
2338
+ beforeAll(async () => {
2339
+ parser = (await import('../cli')).parseRuleOptionsOverride
2340
+ })
2341
+
2342
+ it('parses valid `id={"key":[...]}` payload', () => {
2343
+ const sink: Record<string, Record<string, unknown>> = {}
2344
+ parser(`pyreon/no-window-in-ssr={"exemptPaths":["src/foundation/"]}`, sink)
2345
+ expect(sink['pyreon/no-window-in-ssr']).toEqual({ exemptPaths: ['src/foundation/'] })
2346
+ })
2347
+
2348
+ it('rejects malformed JSON (logs to stderr, leaves sink untouched)', () => {
2349
+ const errs: string[] = []
2350
+ const orig = console.error
2351
+ console.error = (...a: unknown[]) => errs.push(a.map(String).join(' '))
2352
+ try {
2353
+ const sink: Record<string, Record<string, unknown>> = {}
2354
+ parser(`pyreon/foo={not json}`, sink)
2355
+ expect(Object.keys(sink).length).toBe(0)
2356
+ expect(errs.some((e) => e.includes('invalid JSON'))).toBe(true)
2357
+ } finally {
2358
+ console.error = orig
2359
+ }
2360
+ })
2361
+
2362
+ it('rejects non-object JSON payload (e.g. an array)', () => {
2363
+ const errs: string[] = []
2364
+ const orig = console.error
2365
+ console.error = (...a: unknown[]) => errs.push(a.map(String).join(' '))
2366
+ try {
2367
+ const sink: Record<string, Record<string, unknown>> = {}
2368
+ parser(`pyreon/foo=[1,2,3]`, sink)
2369
+ expect(Object.keys(sink).length).toBe(0)
2370
+ expect(errs.some((e) => e.includes('expected JSON object'))).toBe(true)
2371
+ } finally {
2372
+ console.error = orig
2373
+ }
2374
+ })
2375
+
2376
+ it('ignores empty value', () => {
2377
+ const sink: Record<string, Record<string, unknown>> = {}
2378
+ parser(undefined, sink)
2379
+ parser('', sink)
2380
+ expect(Object.keys(sink).length).toBe(0)
2381
+ })
2382
+
2383
+ it('ignores value missing the `=` separator', () => {
2384
+ const sink: Record<string, Record<string, unknown>> = {}
2385
+ parser('pyreon/foo-no-eq', sink)
2386
+ expect(Object.keys(sink).length).toBe(0)
2387
+ })
2388
+
2389
+ it('preserves rule IDs that contain `=` after the first separator', () => {
2390
+ const sink: Record<string, Record<string, unknown>> = {}
2391
+ parser(`pyreon/x={"foo":"a=b=c"}`, sink)
2392
+ expect(sink['pyreon/x']).toEqual({ foo: 'a=b=c' })
2393
+ })
2394
+ })
2395
+
2396
+ // ── CLI ruleOptionsOverrides → lint() integration ───────────────────────────
2397
+
2398
+ describe('lint() ruleOptionsOverrides (CLI --rule-options pathway)', () => {
2399
+ it('CLI options override merge on top of file-config options', async () => {
2400
+ const { tmpdir } = await import('node:os')
2401
+ const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
2402
+ const { join } = await import('node:path')
2403
+ const dir = mkdtempSync(join(tmpdir(), 'pyreon-lint-cli-opts-'))
2404
+ try {
2405
+ // No rc file — just CLI override.
2406
+ writeFileSync(join(dir, 'foo.tsx'), `const w = window.innerWidth`)
2407
+ const { lint } = await import('../lint')
2408
+
2409
+ // Without override → rule fires.
2410
+ const before = lint({ paths: [dir] })
2411
+ const beforeCount = before.files.flatMap((f) => f.diagnostics)
2412
+ .filter((d) => d.ruleId === 'pyreon/no-window-in-ssr').length
2413
+ expect(beforeCount).toBeGreaterThanOrEqual(1)
2414
+
2415
+ // With CLI override exempting the dir → rule silent.
2416
+ const after = lint({
2417
+ paths: [dir],
2418
+ ruleOptionsOverrides: {
2419
+ 'pyreon/no-window-in-ssr': { exemptPaths: [dir] },
2420
+ },
2421
+ })
2422
+ const afterCount = after.files.flatMap((f) => f.diagnostics)
2423
+ .filter((d) => d.ruleId === 'pyreon/no-window-in-ssr').length
2424
+ expect(afterCount).toBe(0)
2425
+ } finally {
2426
+ rmSync(dir, { recursive: true, force: true })
2427
+ }
2428
+ })
2429
+ })
2430
+
2431
+ // ── /examples/ regression assertion ─────────────────────────────────────────
2432
+ //
2433
+ // This PR moves the `/examples/` skip out of `dev-guard-warnings` rule source
2434
+ // (Pyreon convention, not universal) and into the monorepo's own
2435
+ // `.pyreonlintrc.json`. Without that config, the rule now fires inside
2436
+ // `/examples/`. This test pins that behavior change so future readers see
2437
+ // it intentionally regressed for users who relied on the implicit skip.
2438
+
2439
+ describe('dev-guard-warnings: /examples/ skip moved to config', () => {
2440
+ it('fires in /examples/ when no exemptPaths are configured', () => {
2441
+ const source = `console.warn("oops")`
2442
+ const result = lintFile(
2443
+ 'examples/my-app/src/index.tsx',
2444
+ source,
2445
+ allRules,
2446
+ defaultConfig(),
2447
+ )
2448
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBeGreaterThanOrEqual(1)
2449
+ })
2450
+
2451
+ it('silent in /examples/ when configured via exemptPaths', () => {
2452
+ const source = `console.warn("oops")`
2453
+ const cfg = configWithExemptPaths('pyreon/dev-guard-warnings', ['examples/'])
2454
+ const result = lintFile('examples/my-app/src/index.tsx', source, allRules, cfg)
2455
+ expect(findByRule(result, 'pyreon/dev-guard-warnings').length).toBe(0)
2456
+ })
2457
+ })
2458
+
2459
+ // ── End-to-end: .pyreonlintrc.json round-trip ────────────────────────────────
2460
+
2461
+ describe('config-file round-trip', () => {
2462
+ it('loads tuple-form rule entries from .pyreonlintrc.json and applies exemptPaths', async () => {
2463
+ const { tmpdir } = await import('node:os')
2464
+ const { mkdtempSync, writeFileSync, rmSync } = await import('node:fs')
2465
+ const { join } = await import('node:path')
2466
+ const dir = mkdtempSync(join(tmpdir(), 'pyreon-lint-cfg-'))
2467
+ try {
2468
+ // Write a config that uses the tuple form.
2469
+ writeFileSync(
2470
+ join(dir, '.pyreonlintrc.json'),
2471
+ JSON.stringify({
2472
+ preset: 'recommended',
2473
+ rules: {
2474
+ 'pyreon/no-window-in-ssr': ['error', { exemptPaths: ['src/foundation/'] }],
2475
+ },
2476
+ }),
2477
+ )
2478
+
2479
+ const { loadConfig } = await import('../config/loader')
2480
+ const loaded = loadConfig(dir)
2481
+ expect(loaded).toBeTruthy()
2482
+ expect(loaded?.rules?.['pyreon/no-window-in-ssr']).toBeInstanceOf(Array)
2483
+
2484
+ // Build a runtime config from the loaded file + exercise the rule.
2485
+ const base = getPreset('recommended')
2486
+ const runtimeCfg: LintConfig = {
2487
+ ...base,
2488
+ rules: { ...base.rules, ...(loaded?.rules ?? {}) },
2489
+ }
2490
+
2491
+ // In an exempt path — rule silent.
2492
+ const exempt = lintFile(
2493
+ 'src/foundation/raw-window.ts',
2494
+ `const w = window.innerWidth`,
2495
+ allRules,
2496
+ runtimeCfg,
2497
+ )
2498
+ expect(findByRule(exempt, 'pyreon/no-window-in-ssr').length).toBe(0)
2499
+
2500
+ // Outside the exempt path — rule fires.
2501
+ const fires = lintFile(
2502
+ 'src/components/Hero.tsx',
2503
+ `const w = window.innerWidth`,
2504
+ allRules,
2505
+ runtimeCfg,
2506
+ )
2507
+ expect(findByRule(fires, 'pyreon/no-window-in-ssr').length).toBeGreaterThanOrEqual(1)
2508
+ } finally {
2509
+ rmSync(dir, { recursive: true, force: true })
2510
+ }
2511
+ })
2512
+ })
2513
+
2514
+ // ── pyreon/require-browser-smoke-test ─────────────────────────────────────
2515
+ //
2516
+ // Locks in the durability of the T1.1 browser smoke harness. Without this
2517
+ // rule, any new browser-running package can quietly ship without smoke
2518
+ // coverage and we drift back to the world before T1.1.
2519
+
2520
+ describe('pyreon/require-browser-smoke-test', () => {
2521
+ // Helpers shared by the suite — set up a fake MONOREPO with a
2522
+ // `.claude/rules/browser-packages.json` at the root and a fake
2523
+ // package under `packages/<name>/`. The rule discovers the JSON by
2524
+ // walking upward from the linted file, so we mirror that structure.
2525
+ async function setupFakePackage(opts: {
2526
+ pkgName: string
2527
+ withBrowserTest: boolean
2528
+ browserPackagesOverride?: string[]
2529
+ }): Promise<{ rootDir: string; indexPath: string; pkgDir: string; cleanup: () => void }> {
2530
+ const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs')
2531
+ const { tmpdir } = await import('node:os')
2532
+ const { join } = await import('node:path')
2533
+ const rule = await import('../rules/architecture/require-browser-smoke-test')
2534
+
2535
+ // Reset the module-level cache so earlier tests' JSON doesn't leak in.
2536
+ rule._resetBrowserPackagesCache()
2537
+
2538
+ const rootDir = mkdtempSync(join(tmpdir(), 'pyreon-require-browser-smoke-'))
2539
+ mkdirSync(join(rootDir, '.claude', 'rules'), { recursive: true })
2540
+ const packages = opts.browserPackagesOverride ?? [
2541
+ '@pyreon/runtime-dom',
2542
+ '@pyreon/router',
2543
+ '@pyreon/head',
2544
+ '@pyreon/flow',
2545
+ '@pyreon/code',
2546
+ '@pyreon/charts',
2547
+ '@pyreon/document-primitives',
2548
+ '@pyreon/connector-document',
2549
+ '@pyreon/elements',
2550
+ '@pyreon/styler',
2551
+ '@pyreon/unistyle',
2552
+ '@pyreon/rocketstyle',
2553
+ '@pyreon/coolgrid',
2554
+ '@pyreon/kinetic',
2555
+ '@pyreon/ui-components',
2556
+ '@pyreon/ui-primitives',
2557
+ '@pyreon/ui-theme',
2558
+ '@pyreon/react-compat',
2559
+ '@pyreon/preact-compat',
2560
+ '@pyreon/vue-compat',
2561
+ '@pyreon/solid-compat',
2562
+ ]
2563
+ writeFileSync(
2564
+ join(rootDir, '.claude', 'rules', 'browser-packages.json'),
2565
+ JSON.stringify({ packages }),
2566
+ )
2567
+
2568
+ const pkgDir = join(rootDir, 'packages', opts.pkgName.replace('@pyreon/', ''))
2569
+ mkdirSync(join(pkgDir, 'src'), { recursive: true })
2570
+ writeFileSync(
2571
+ join(pkgDir, 'package.json'),
2572
+ JSON.stringify({ name: opts.pkgName, version: '0.0.0' }),
2573
+ )
2574
+ const indexPath = join(pkgDir, 'src', 'index.ts')
2575
+ writeFileSync(indexPath, `export const x = 1\n`)
2576
+ if (opts.withBrowserTest) {
2577
+ writeFileSync(
2578
+ join(pkgDir, 'src', 'mount.browser.test.ts'),
2579
+ `import { it } from 'vitest'; it('ok', () => {})\n`,
2580
+ )
2581
+ }
2582
+ return {
2583
+ rootDir,
2584
+ pkgDir,
2585
+ indexPath,
2586
+ cleanup: () => {
2587
+ rule._resetBrowserPackagesCache()
2588
+ rmSync(rootDir, { recursive: true, force: true })
2589
+ },
2590
+ }
2591
+ }
2592
+
2593
+ it('reports an error when a browser package ships no .browser.test.* file', async () => {
2594
+ const fake = await setupFakePackage({
2595
+ pkgName: '@pyreon/runtime-dom', // a real browser package name
2596
+ withBrowserTest: false,
2597
+ })
2598
+ try {
2599
+ const result = lintWith(
2600
+ 'pyreon/require-browser-smoke-test',
2601
+ `export const x = 1`,
2602
+ fake.indexPath,
2603
+ )
2604
+ const diags = findByRule(result, 'pyreon/require-browser-smoke-test')
2605
+ expect(diags.length).toBe(1)
2606
+ expect(diags[0]?.message).toMatch(/no `\*\.browser\.test/)
2607
+ expect(diags[0]?.message).toMatch(/@pyreon\/runtime-dom/)
2608
+ } finally {
2609
+ fake.cleanup()
2610
+ }
2611
+ })
2612
+
2613
+ it('passes when the package has at least one .browser.test.ts file', async () => {
2614
+ const fake = await setupFakePackage({
2615
+ pkgName: '@pyreon/runtime-dom',
2616
+ withBrowserTest: true,
2617
+ })
2618
+ try {
2619
+ const result = lintWith(
2620
+ 'pyreon/require-browser-smoke-test',
2621
+ `export const x = 1`,
2622
+ fake.indexPath,
2623
+ )
2624
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2625
+ } finally {
2626
+ fake.cleanup()
2627
+ }
2628
+ })
2629
+
2630
+ it('skips packages not in the browser-categorized list', async () => {
2631
+ const fake = await setupFakePackage({
2632
+ pkgName: '@pyreon/server', // server-only, not categorized
2633
+ withBrowserTest: false,
2634
+ })
2635
+ try {
2636
+ const result = lintWith(
2637
+ 'pyreon/require-browser-smoke-test',
2638
+ `export const x = 1`,
2639
+ fake.indexPath,
2640
+ )
2641
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2642
+ } finally {
2643
+ fake.cleanup()
2644
+ }
2645
+ })
2646
+
2647
+ it('runs only on src/index.ts — internal files are skipped', async () => {
2648
+ const fake = await setupFakePackage({
2649
+ pkgName: '@pyreon/runtime-dom',
2650
+ withBrowserTest: false,
2651
+ })
2652
+ try {
2653
+ const { writeFileSync } = await import('node:fs')
2654
+ const { join } = await import('node:path')
2655
+ const internalPath = join(fake.pkgDir, 'src', 'internal.ts')
2656
+ writeFileSync(internalPath, `export const y = 2\n`)
2657
+ const result = lintWith(
2658
+ 'pyreon/require-browser-smoke-test',
2659
+ `export const y = 2`,
2660
+ internalPath,
2661
+ )
2662
+ // Internal file → no report (rule short-circuits).
2663
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2664
+ } finally {
2665
+ fake.cleanup()
2666
+ }
2667
+ })
2668
+
2669
+ it('additionalPackages option opts new packages into the requirement', async () => {
2670
+ const fake = await setupFakePackage({
2671
+ pkgName: '@my-org/my-browser-pkg', // not in the default list
2672
+ withBrowserTest: false,
2673
+ })
2674
+ try {
2675
+ const cfg: LintConfig = {
2676
+ rules: {
2677
+ 'pyreon/require-browser-smoke-test': [
2678
+ 'error',
2679
+ { additionalPackages: ['@my-org/my-browser-pkg'] },
2680
+ ],
2681
+ },
2682
+ }
2683
+ const result = lintFile(
2684
+ fake.indexPath,
2685
+ `export const x = 1`,
2686
+ allRules,
2687
+ cfg,
2688
+ )
2689
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(1)
2690
+ } finally {
2691
+ fake.cleanup()
2692
+ }
2693
+ })
2694
+
2695
+ it('exemptPaths option opts packages out of the requirement', async () => {
2696
+ const fake = await setupFakePackage({
2697
+ pkgName: '@pyreon/runtime-dom',
2698
+ withBrowserTest: false,
2699
+ })
2700
+ try {
2701
+ const cfg: LintConfig = {
2702
+ rules: {
2703
+ 'pyreon/require-browser-smoke-test': [
2704
+ 'error',
2705
+ { exemptPaths: [fake.pkgDir] },
2706
+ ],
2707
+ },
2708
+ }
2709
+ const result = lintFile(
2710
+ fake.indexPath,
2711
+ `export const x = 1`,
2712
+ allRules,
2713
+ cfg,
2714
+ )
2715
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2716
+ } finally {
2717
+ fake.cleanup()
2718
+ }
2719
+ })
2720
+
2721
+ // ── Edge cases for the directory walker + single-source-of-truth ──────
2722
+
2723
+ it('finds a .browser.test.tsx file (not just .ts extension)', async () => {
2724
+ const fake = await setupFakePackage({
2725
+ pkgName: '@pyreon/runtime-dom',
2726
+ withBrowserTest: false,
2727
+ })
2728
+ try {
2729
+ const { writeFileSync } = await import('node:fs')
2730
+ const { join } = await import('node:path')
2731
+ writeFileSync(
2732
+ join(fake.pkgDir, 'src', 'mount.browser.test.tsx'),
2733
+ `export {}`,
2734
+ )
2735
+ const result = lintWith(
2736
+ 'pyreon/require-browser-smoke-test',
2737
+ `export const x = 1`,
2738
+ fake.indexPath,
2739
+ )
2740
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2741
+ } finally {
2742
+ fake.cleanup()
2743
+ }
2744
+ })
2745
+
2746
+ it('finds a browser test nested deep inside src/', async () => {
2747
+ const fake = await setupFakePackage({
2748
+ pkgName: '@pyreon/runtime-dom',
2749
+ withBrowserTest: false,
2750
+ })
2751
+ try {
2752
+ const { mkdirSync, writeFileSync } = await import('node:fs')
2753
+ const { join } = await import('node:path')
2754
+ const deep = join(fake.pkgDir, 'src', 'a', 'b', 'c', 'd')
2755
+ mkdirSync(deep, { recursive: true })
2756
+ writeFileSync(join(deep, 'nested.browser.test.ts'), `export {}`)
2757
+ const result = lintWith(
2758
+ 'pyreon/require-browser-smoke-test',
2759
+ `export const x = 1`,
2760
+ fake.indexPath,
2761
+ )
2762
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2763
+ } finally {
2764
+ fake.cleanup()
2765
+ }
2766
+ })
2767
+
2768
+ it('skips node_modules / lib / dist / dotfolders when scanning', async () => {
2769
+ // A package has NO real browser test but its node_modules contains
2770
+ // transitive browser tests (e.g. a dependency's dist). The rule must
2771
+ // not count those — they're not this package's contract.
2772
+ const fake = await setupFakePackage({
2773
+ pkgName: '@pyreon/runtime-dom',
2774
+ withBrowserTest: false,
2775
+ })
2776
+ try {
2777
+ const { mkdirSync, writeFileSync } = await import('node:fs')
2778
+ const { join } = await import('node:path')
2779
+ const nm = join(fake.pkgDir, 'node_modules', 'some-dep', 'src')
2780
+ mkdirSync(nm, { recursive: true })
2781
+ writeFileSync(join(nm, 'fake.browser.test.ts'), `export {}`)
2782
+ const lib = join(fake.pkgDir, 'lib')
2783
+ mkdirSync(lib, { recursive: true })
2784
+ writeFileSync(join(lib, 'built.browser.test.ts'), `export {}`)
2785
+ const result = lintWith(
2786
+ 'pyreon/require-browser-smoke-test',
2787
+ `export const x = 1`,
2788
+ fake.indexPath,
2789
+ )
2790
+ // Still reports missing — node_modules/lib contents don't count.
2791
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(1)
2792
+ } finally {
2793
+ fake.cleanup()
2794
+ }
2795
+ })
2796
+
2797
+ it('falls back to empty list when browser-packages.json is absent (safe default)', async () => {
2798
+ // A consumer repo that uses @pyreon/lint but doesn't ship the JSON.
2799
+ // The rule should become a no-op (or only fire on explicit
2800
+ // additionalPackages) so it doesn't false-positive every index.ts.
2801
+ const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs')
2802
+ const { tmpdir } = await import('node:os')
2803
+ const { join } = await import('node:path')
2804
+ const rule = await import('../rules/architecture/require-browser-smoke-test')
2805
+ rule._resetBrowserPackagesCache()
2806
+
2807
+ const rootDir = mkdtempSync(join(tmpdir(), 'pyreon-lint-no-json-'))
2808
+ try {
2809
+ const pkgDir = join(rootDir, 'packages', 'runtime-dom')
2810
+ mkdirSync(join(pkgDir, 'src'), { recursive: true })
2811
+ writeFileSync(
2812
+ join(pkgDir, 'package.json'),
2813
+ JSON.stringify({ name: '@pyreon/runtime-dom' }),
2814
+ )
2815
+ const indexPath = join(pkgDir, 'src', 'index.ts')
2816
+ writeFileSync(indexPath, `export const x = 1`)
2817
+ const result = lintWith(
2818
+ 'pyreon/require-browser-smoke-test',
2819
+ `export const x = 1`,
2820
+ indexPath,
2821
+ )
2822
+ // No JSON found → empty list → rule stays silent.
2823
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2824
+ } finally {
2825
+ rule._resetBrowserPackagesCache()
2826
+ rmSync(rootDir, { recursive: true, force: true })
2827
+ }
2828
+ })
2829
+
2830
+ it('malformed browser-packages.json falls back to empty (no crash, no false positives)', async () => {
2831
+ const { mkdirSync, mkdtempSync, rmSync, writeFileSync } = await import('node:fs')
2832
+ const { tmpdir } = await import('node:os')
2833
+ const { join } = await import('node:path')
2834
+ const rule = await import('../rules/architecture/require-browser-smoke-test')
2835
+ rule._resetBrowserPackagesCache()
2836
+
2837
+ const rootDir = mkdtempSync(join(tmpdir(), 'pyreon-lint-bad-json-'))
2838
+ try {
2839
+ mkdirSync(join(rootDir, '.claude', 'rules'), { recursive: true })
2840
+ writeFileSync(
2841
+ join(rootDir, '.claude', 'rules', 'browser-packages.json'),
2842
+ `{ not valid json`,
2843
+ )
2844
+ const pkgDir = join(rootDir, 'packages', 'runtime-dom')
2845
+ mkdirSync(join(pkgDir, 'src'), { recursive: true })
2846
+ writeFileSync(
2847
+ join(pkgDir, 'package.json'),
2848
+ JSON.stringify({ name: '@pyreon/runtime-dom' }),
2849
+ )
2850
+ const indexPath = join(pkgDir, 'src', 'index.ts')
2851
+ writeFileSync(indexPath, `export const x = 1`)
2852
+ const result = lintWith(
2853
+ 'pyreon/require-browser-smoke-test',
2854
+ `export const x = 1`,
2855
+ indexPath,
2856
+ )
2857
+ // Parse failure → empty list → rule stays silent (no crash).
2858
+ expect(findByRule(result, 'pyreon/require-browser-smoke-test').length).toBe(0)
2859
+ } finally {
2860
+ rule._resetBrowserPackagesCache()
2861
+ rmSync(rootDir, { recursive: true, force: true })
2862
+ }
2863
+ })
2864
+ })