@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.
- package/README.md +55 -2
- package/lib/analysis/cli.js.html +1 -1
- package/lib/analysis/index.js.html +1 -1
- package/lib/cli.js +960 -162
- package/lib/cli.js.map +1 -1
- package/lib/index.js +935 -161
- package/lib/index.js.map +1 -1
- package/lib/types/index.d.ts +96 -23
- package/lib/types/index.d.ts.map +1 -1
- package/package.json +2 -1
- package/schema/pyreonlintrc.schema.json +64 -0
- package/src/cli.ts +44 -2
- package/src/config/presets.ts +13 -1
- package/src/index.ts +7 -0
- package/src/lint.ts +37 -6
- package/src/lsp/index.ts +15 -2
- package/src/rules/architecture/dev-guard-warnings.ts +172 -17
- package/src/rules/architecture/no-circular-import.ts +7 -0
- package/src/rules/architecture/no-process-dev-gate.ts +18 -45
- package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
- package/src/rules/form/no-submit-without-validation.ts +9 -0
- package/src/rules/form/no-unregistered-field.ts +9 -0
- package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
- package/src/rules/hooks/no-raw-localstorage.ts +12 -1
- package/src/rules/hooks/no-raw-setinterval.ts +14 -0
- package/src/rules/index.ts +4 -1
- package/src/rules/jsx/no-props-destructure.ts +20 -6
- package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
- package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
- package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
- package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
- package/src/rules/ssr/no-window-in-ssr.ts +418 -35
- package/src/rules/store/no-duplicate-store-id.ts +11 -0
- package/src/rules/store/no-mutate-store-state.ts +11 -1
- package/src/rules/styling/no-dynamic-styled.ts +13 -24
- package/src/rules/styling/no-theme-outside-provider.ts +34 -2
- package/src/runner.ts +100 -10
- package/src/tests/runner.test.ts +1573 -21
- package/src/types.ts +74 -3
- package/src/utils/component-context.ts +106 -0
- package/src/utils/exempt-paths.ts +39 -0
- package/src/utils/file-roles.ts +32 -0
- package/src/utils/imports.ts +4 -1
- package/src/utils/validate-options.ts +68 -0
- package/src/watcher.ts +17 -0
package/src/tests/runner.test.ts
CHANGED
|
@@ -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
|
|
40
|
-
expect(allRules.length).toBe(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
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:
|
|
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,
|
|
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(
|
|
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-
|
|
1063
|
-
const source = `
|
|
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-
|
|
1070
|
-
|
|
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(
|
|
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
|
+
})
|