@pyreon/lint 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/lib/analysis/index.js.html +5406 -0
  4. package/lib/index.js +2955 -0
  5. package/lib/index.js.map +1 -0
  6. package/lib/types/index.d.ts +260 -0
  7. package/lib/types/index.d.ts.map +1 -0
  8. package/package.json +56 -0
  9. package/src/cache.ts +51 -0
  10. package/src/cli.ts +199 -0
  11. package/src/config/ignore.ts +159 -0
  12. package/src/config/loader.ts +72 -0
  13. package/src/config/presets.ts +62 -0
  14. package/src/index.ts +40 -0
  15. package/src/lint.ts +226 -0
  16. package/src/reporter.ts +85 -0
  17. package/src/rules/accessibility/dialog-a11y.ts +32 -0
  18. package/src/rules/accessibility/overlay-a11y.ts +33 -0
  19. package/src/rules/accessibility/toast-a11y.ts +38 -0
  20. package/src/rules/architecture/dev-guard-warnings.ts +57 -0
  21. package/src/rules/architecture/no-circular-import.ts +59 -0
  22. package/src/rules/architecture/no-cross-layer-import.ts +75 -0
  23. package/src/rules/architecture/no-deep-import.ts +32 -0
  24. package/src/rules/architecture/no-error-without-prefix.ts +75 -0
  25. package/src/rules/form/no-submit-without-validation.ts +45 -0
  26. package/src/rules/form/no-unregistered-field.ts +45 -0
  27. package/src/rules/form/prefer-field-array.ts +41 -0
  28. package/src/rules/hooks/no-raw-addeventlistener.ts +28 -0
  29. package/src/rules/hooks/no-raw-localstorage.ts +35 -0
  30. package/src/rules/hooks/no-raw-setinterval.ts +41 -0
  31. package/src/rules/index.ts +208 -0
  32. package/src/rules/jsx/no-and-conditional.ts +32 -0
  33. package/src/rules/jsx/no-children-access.ts +44 -0
  34. package/src/rules/jsx/no-classname.ts +27 -0
  35. package/src/rules/jsx/no-htmlfor.ts +27 -0
  36. package/src/rules/jsx/no-index-as-by.ts +70 -0
  37. package/src/rules/jsx/no-map-in-jsx.ts +43 -0
  38. package/src/rules/jsx/no-missing-for-by.ts +27 -0
  39. package/src/rules/jsx/no-onchange.ts +46 -0
  40. package/src/rules/jsx/no-props-destructure.ts +64 -0
  41. package/src/rules/jsx/no-ternary-conditional.ts +32 -0
  42. package/src/rules/jsx/use-by-not-key.ts +33 -0
  43. package/src/rules/lifecycle/no-dom-in-setup.ts +53 -0
  44. package/src/rules/lifecycle/no-effect-in-mount.ts +36 -0
  45. package/src/rules/lifecycle/no-missing-cleanup.ts +80 -0
  46. package/src/rules/lifecycle/no-mount-in-effect.ts +35 -0
  47. package/src/rules/performance/no-eager-import.ts +28 -0
  48. package/src/rules/performance/no-effect-in-for.ts +41 -0
  49. package/src/rules/performance/no-large-for-without-by.ts +28 -0
  50. package/src/rules/performance/prefer-show-over-display.ts +47 -0
  51. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +56 -0
  52. package/src/rules/reactivity/no-effect-assignment.ts +65 -0
  53. package/src/rules/reactivity/no-nested-effect.ts +33 -0
  54. package/src/rules/reactivity/no-peek-in-tracked.ts +35 -0
  55. package/src/rules/reactivity/no-signal-in-loop.ts +59 -0
  56. package/src/rules/reactivity/no-signal-leak.ts +58 -0
  57. package/src/rules/reactivity/no-unbatched-updates.ts +77 -0
  58. package/src/rules/reactivity/prefer-computed.ts +56 -0
  59. package/src/rules/router/index.ts +4 -0
  60. package/src/rules/router/no-href-navigation.ts +51 -0
  61. package/src/rules/router/no-imperative-navigate-in-render.ts +83 -0
  62. package/src/rules/router/no-missing-fallback.ts +87 -0
  63. package/src/rules/router/prefer-use-is-active.ts +45 -0
  64. package/src/rules/ssr/no-mismatch-risk.ts +47 -0
  65. package/src/rules/ssr/no-window-in-ssr.ts +76 -0
  66. package/src/rules/ssr/prefer-request-context.ts +56 -0
  67. package/src/rules/store/no-duplicate-store-id.ts +43 -0
  68. package/src/rules/store/no-mutate-store-state.ts +37 -0
  69. package/src/rules/store/no-store-outside-provider.ts +59 -0
  70. package/src/rules/styling/no-dynamic-styled.ts +60 -0
  71. package/src/rules/styling/no-inline-style-object.ts +30 -0
  72. package/src/rules/styling/no-theme-outside-provider.ts +45 -0
  73. package/src/rules/styling/prefer-cx.ts +44 -0
  74. package/src/runner.ts +170 -0
  75. package/src/tests/runner.test.ts +1043 -0
  76. package/src/types.ts +125 -0
  77. package/src/utils/ast.ts +192 -0
  78. package/src/utils/imports.ts +122 -0
  79. package/src/utils/index.ts +39 -0
  80. package/src/utils/source.ts +36 -0
  81. package/src/watcher.ts +118 -0
@@ -0,0 +1,1043 @@
1
+ import { AstCache } from "../cache"
2
+ import { createIgnoreFilter } from "../config/ignore"
3
+ import { loadConfig } from "../config/loader"
4
+ import { getPreset } from "../config/presets"
5
+ import { allRules } from "../rules/index"
6
+ import { applyFixes, lintFile } from "../runner"
7
+ import type { LintConfig, Rule } from "../types"
8
+ import { LineIndex } from "../utils/source"
9
+
10
+ // Helper to create a config that enables all rules at default severity
11
+ function defaultConfig(): LintConfig {
12
+ return getPreset("recommended")
13
+ }
14
+
15
+ // Helper to lint a string with specific rules
16
+ function lintSource(
17
+ source: string,
18
+ rules?: Rule[],
19
+ filePath?: string,
20
+ ): ReturnType<typeof lintFile> {
21
+ return lintFile(filePath ?? "test.tsx", source, rules ?? allRules, defaultConfig())
22
+ }
23
+
24
+ // Helper to find diagnostics by rule ID
25
+ function findByRule(result: ReturnType<typeof lintFile>, ruleId: string) {
26
+ return result.diagnostics.filter((d) => d.ruleId === ruleId)
27
+ }
28
+
29
+ // Helper to lint with a single rule by ID
30
+ function lintWith(ruleId: string, source: string, filePath?: string) {
31
+ const rule = allRules.find((r) => r.meta.id === ruleId)
32
+ if (!rule) throw new Error(`Rule not found: ${ruleId}`)
33
+ return lintFile(filePath ?? "test.tsx", source, [rule], defaultConfig())
34
+ }
35
+
36
+ // ── Rule Metadata ───────────────────────────────────────────────────────────
37
+
38
+ describe("Rule metadata", () => {
39
+ it("should have 55 rules", () => {
40
+ expect(allRules.length).toBe(55)
41
+ })
42
+
43
+ it("should have unique rule IDs", () => {
44
+ const ids = allRules.map((r) => r.meta.id)
45
+ const unique = new Set(ids)
46
+ expect(unique.size).toBe(ids.length)
47
+ })
48
+
49
+ it("all rule IDs should start with pyreon/", () => {
50
+ for (const rule of allRules) {
51
+ expect(rule.meta.id).toMatch(/^pyreon\//)
52
+ }
53
+ })
54
+
55
+ it("all rules should have valid categories", () => {
56
+ const validCategories = new Set([
57
+ "reactivity",
58
+ "jsx",
59
+ "lifecycle",
60
+ "performance",
61
+ "ssr",
62
+ "architecture",
63
+ "store",
64
+ "form",
65
+ "styling",
66
+ "hooks",
67
+ "accessibility",
68
+ "router",
69
+ ])
70
+ for (const rule of allRules) {
71
+ expect(validCategories.has(rule.meta.category)).toBe(true)
72
+ }
73
+ })
74
+
75
+ it("should have correct category counts", () => {
76
+ const counts: Record<string, number> = {}
77
+ for (const rule of allRules) {
78
+ counts[rule.meta.category] = (counts[rule.meta.category] ?? 0) + 1
79
+ }
80
+ expect(counts.reactivity).toBe(8)
81
+ expect(counts.jsx).toBe(11)
82
+ expect(counts.lifecycle).toBe(4)
83
+ expect(counts.performance).toBe(4)
84
+ expect(counts.ssr).toBe(3)
85
+ expect(counts.architecture).toBe(5)
86
+ expect(counts.store).toBe(3)
87
+ expect(counts.form).toBe(3)
88
+ expect(counts.styling).toBe(4)
89
+ expect(counts.hooks).toBe(3)
90
+ expect(counts.accessibility).toBe(3)
91
+ expect(counts.router).toBe(4)
92
+ })
93
+ })
94
+
95
+ // ── Runner Basics ───────────────────────────────────────────────────────────
96
+
97
+ describe("Runner", () => {
98
+ it("should parse valid TypeScript/JSX", () => {
99
+ const result = lintSource(`const x = 1`)
100
+ expect(result.diagnostics).toBeDefined()
101
+ })
102
+
103
+ it("should skip non-JS files", () => {
104
+ const result = lintFile("test.css", "body { color: red }", allRules, defaultConfig())
105
+ expect(result.diagnostics.length).toBe(0)
106
+ })
107
+
108
+ it("should sort diagnostics by position", () => {
109
+ const source = `
110
+ const a = signal(0)
111
+ const b = signal(0)
112
+ // These are just declared, not used
113
+ `
114
+ const rule = allRules.find((r) => r.meta.id === "pyreon/no-signal-leak")
115
+ if (!rule) throw new Error("Rule not found")
116
+ const result = lintSource(source, [rule])
117
+ if (result.diagnostics.length >= 2) {
118
+ const first = result.diagnostics[0]
119
+ const second = result.diagnostics[1]
120
+ if (first && second) {
121
+ expect(first.span.start).toBeLessThanOrEqual(second.span.start)
122
+ }
123
+ }
124
+ })
125
+
126
+ it("should apply fixes in reverse order", () => {
127
+ const source = `<div className="a" htmlFor="b" />`
128
+ const result = lintSource(source)
129
+ const classnameDiags = findByRule(result, "pyreon/no-classname")
130
+ const htmlforDiags = findByRule(result, "pyreon/no-htmlfor")
131
+
132
+ // Both should be fixable
133
+ expect(classnameDiags.length).toBeGreaterThanOrEqual(1)
134
+ expect(htmlforDiags.length).toBeGreaterThanOrEqual(1)
135
+ expect(classnameDiags[0]?.fix).toBeDefined()
136
+ expect(htmlforDiags[0]?.fix).toBeDefined()
137
+
138
+ const fixed = applyFixes(source, result.diagnostics)
139
+ expect(fixed).toContain("class=")
140
+ expect(fixed).toContain("for=")
141
+ expect(fixed).not.toContain("className")
142
+ expect(fixed).not.toContain("htmlFor")
143
+ })
144
+
145
+ it("should use AST cache when provided", () => {
146
+ const cache = new AstCache()
147
+ const source = `const x = 1`
148
+ const config = defaultConfig()
149
+
150
+ // First lint populates cache
151
+ lintFile("test.ts", source, allRules, config, cache)
152
+ expect(cache.size).toBe(1)
153
+
154
+ // Second lint reuses cache
155
+ lintFile("test.ts", source, allRules, config, cache)
156
+ expect(cache.size).toBe(1)
157
+ })
158
+ })
159
+
160
+ // ── Source Utilities ─────────────────────────────────────────────────────────
161
+
162
+ describe("LineIndex", () => {
163
+ it("should compute line/column for single-line source", () => {
164
+ const idx = new LineIndex("hello world")
165
+ expect(idx.locate(0)).toEqual({ line: 1, column: 0 })
166
+ expect(idx.locate(5)).toEqual({ line: 1, column: 5 })
167
+ })
168
+
169
+ it("should compute line/column for multi-line source", () => {
170
+ const idx = new LineIndex("line1\nline2\nline3")
171
+ expect(idx.locate(0)).toEqual({ line: 1, column: 0 })
172
+ expect(idx.locate(6)).toEqual({ line: 2, column: 0 })
173
+ expect(idx.locate(12)).toEqual({ line: 3, column: 0 })
174
+ expect(idx.locate(8)).toEqual({ line: 2, column: 2 })
175
+ })
176
+
177
+ it("should handle empty source", () => {
178
+ const idx = new LineIndex("")
179
+ expect(idx.locate(0)).toEqual({ line: 1, column: 0 })
180
+ })
181
+ })
182
+
183
+ // ── AST Cache ──────────────────────────────────────────────────────────────
184
+
185
+ describe("AstCache", () => {
186
+ it("should store and retrieve entries", () => {
187
+ const cache = new AstCache()
188
+ const lineIndex = new LineIndex("test")
189
+ const program = { type: "Program" }
190
+
191
+ cache.set("test", { program, lineIndex })
192
+ expect(cache.size).toBe(1)
193
+
194
+ const result = cache.get("test")
195
+ expect(result).toBeDefined()
196
+ expect(result!.program).toBe(program)
197
+ })
198
+
199
+ it("should return undefined for missing entries", () => {
200
+ const cache = new AstCache()
201
+ expect(cache.get("missing")).toBeUndefined()
202
+ })
203
+
204
+ it("should clear all entries", () => {
205
+ const cache = new AstCache()
206
+ const lineIndex = new LineIndex("a")
207
+ cache.set("a", { program: {}, lineIndex })
208
+ cache.set("b", { program: {}, lineIndex })
209
+ expect(cache.size).toBe(2)
210
+
211
+ cache.clear()
212
+ expect(cache.size).toBe(0)
213
+ })
214
+
215
+ it("should use content-based keys (different content = different entry)", () => {
216
+ const cache = new AstCache()
217
+ const lineIndex = new LineIndex("a")
218
+ cache.set("content1", { program: { id: 1 }, lineIndex })
219
+ cache.set("content2", { program: { id: 2 }, lineIndex })
220
+ expect(cache.size).toBe(2)
221
+ })
222
+ })
223
+
224
+ // ── Reactivity Rules ────────────────────────────────────────────────────────
225
+
226
+ describe("Reactivity rules", () => {
227
+ it("pyreon/no-bare-signal-in-jsx: flags {count()} in JSX", () => {
228
+ const source = `const App = () => <div>{count()}</div>`
229
+ const result = lintSource(source)
230
+ const diags = findByRule(result, "pyreon/no-bare-signal-in-jsx")
231
+ expect(diags.length).toBe(1)
232
+ expect(diags[0]?.fix).toBeDefined()
233
+ })
234
+
235
+ it("pyreon/no-bare-signal-in-jsx: skips PascalCase and use* calls", () => {
236
+ const source = `const App = () => <div>{MyComponent()}{useTheme()}</div>`
237
+ const result = lintSource(source)
238
+ const diags = findByRule(result, "pyreon/no-bare-signal-in-jsx")
239
+ expect(diags.length).toBe(0)
240
+ })
241
+
242
+ it("pyreon/no-signal-in-loop: flags signal inside for loop", () => {
243
+ const source = `for (let i = 0; i < 10; i++) { const s = signal(0) }`
244
+ const result = lintSource(source)
245
+ const diags = findByRule(result, "pyreon/no-signal-in-loop")
246
+ expect(diags.length).toBe(1)
247
+ })
248
+
249
+ it("pyreon/no-signal-in-loop: clean when outside loop", () => {
250
+ const source = `const s = signal(0)`
251
+ const result = lintSource(source)
252
+ const diags = findByRule(result, "pyreon/no-signal-in-loop")
253
+ expect(diags.length).toBe(0)
254
+ })
255
+
256
+ it("pyreon/no-nested-effect: flags effect inside effect", () => {
257
+ const source = `effect(() => { effect(() => {}) })`
258
+ const result = lintSource(source)
259
+ const diags = findByRule(result, "pyreon/no-nested-effect")
260
+ expect(diags.length).toBe(1)
261
+ })
262
+
263
+ it("pyreon/no-peek-in-tracked: flags .peek() inside effect", () => {
264
+ const source = `effect(() => { const v = x.peek() })`
265
+ const result = lintSource(source)
266
+ const diags = findByRule(result, "pyreon/no-peek-in-tracked")
267
+ expect(diags.length).toBe(1)
268
+ })
269
+
270
+ it("pyreon/no-unbatched-updates: flags 3+ .set() without batch", () => {
271
+ const source = `function update() { a.set(1); b.set(2); c.set(3) }`
272
+ const result = lintSource(source)
273
+ const diags = findByRule(result, "pyreon/no-unbatched-updates")
274
+ expect(diags.length).toBe(1)
275
+ })
276
+
277
+ it("pyreon/no-unbatched-updates: clean with batch", () => {
278
+ const source = `function update() { batch(() => { a.set(1); b.set(2); c.set(3) }) }`
279
+ const result = lintSource(source)
280
+ // The outer function has batch, so no warning on it
281
+ // The inner arrow gets checked too but has no .set() calls directly
282
+ const diags = findByRule(result, "pyreon/no-unbatched-updates")
283
+ expect(diags.length).toBe(0)
284
+ })
285
+
286
+ it("pyreon/prefer-computed: flags effect with single .set()", () => {
287
+ const source = `effect(() => { x.set(a() + b()) })`
288
+ const result = lintSource(source)
289
+ const diags = findByRule(result, "pyreon/prefer-computed")
290
+ expect(diags.length).toBe(1)
291
+ })
292
+
293
+ it("pyreon/no-effect-assignment: flags effect with single .update()", () => {
294
+ const source = `effect(() => { x.update(v => v + 1) })`
295
+ const result = lintSource(source)
296
+ const diags = findByRule(result, "pyreon/no-effect-assignment")
297
+ expect(diags.length).toBe(1)
298
+ })
299
+
300
+ it("pyreon/no-signal-leak: flags unused signal declarations", () => {
301
+ const source = `const unused = signal(0)`
302
+ const result = lintSource(source)
303
+ const diags = findByRule(result, "pyreon/no-signal-leak")
304
+ expect(diags.length).toBe(1)
305
+ })
306
+
307
+ it("pyreon/no-signal-leak: clean when signal is used", () => {
308
+ const source = `const count = signal(0)\nconst double = computed(() => count() * 2)`
309
+ const result = lintSource(source)
310
+ const diags = findByRule(result, "pyreon/no-signal-leak")
311
+ expect(diags.length).toBe(0)
312
+ })
313
+ })
314
+
315
+ // ── JSX Rules ───────────────────────────────────────────────────────────────
316
+
317
+ describe("JSX rules", () => {
318
+ it("pyreon/no-map-in-jsx: flags .map() in JSX", () => {
319
+ const source = `const App = () => <ul>{items.map(i => <li>{i}</li>)}</ul>`
320
+ const result = lintSource(source)
321
+ const diags = findByRule(result, "pyreon/no-map-in-jsx")
322
+ expect(diags.length).toBe(1)
323
+ })
324
+
325
+ it("pyreon/use-by-not-key: flags key on <For>", () => {
326
+ const source = `const App = () => <For each={items} key={r => r.id}>{r => <li />}</For>`
327
+ const result = lintSource(source)
328
+ const diags = findByRule(result, "pyreon/use-by-not-key")
329
+ expect(diags.length).toBe(1)
330
+ expect(diags[0]?.fix).toBeDefined()
331
+ })
332
+
333
+ it("pyreon/no-classname: flags className and fixes to class", () => {
334
+ const source = `const App = () => <div className="foo" />`
335
+ const result = lintSource(source)
336
+ const diags = findByRule(result, "pyreon/no-classname")
337
+ expect(diags.length).toBe(1)
338
+ expect(diags[0]?.fix).toBeDefined()
339
+ })
340
+
341
+ it("pyreon/no-htmlfor: flags htmlFor and fixes to for", () => {
342
+ const source = `const App = () => <label htmlFor="name" />`
343
+ const result = lintSource(source)
344
+ const diags = findByRule(result, "pyreon/no-htmlfor")
345
+ expect(diags.length).toBe(1)
346
+ })
347
+
348
+ it("pyreon/no-onchange: flags onChange on input", () => {
349
+ const source = `const App = () => <input onChange={handler} />`
350
+ const result = lintSource(source)
351
+ const diags = findByRule(result, "pyreon/no-onchange")
352
+ expect(diags.length).toBe(1)
353
+ })
354
+
355
+ it("pyreon/no-onchange: clean on non-input elements", () => {
356
+ const source = `const App = () => <div onChange={handler} />`
357
+ const result = lintSource(source)
358
+ const diags = findByRule(result, "pyreon/no-onchange")
359
+ expect(diags.length).toBe(0)
360
+ })
361
+
362
+ it("pyreon/no-ternary-conditional: flags ternary with JSX", () => {
363
+ const source = `const App = () => <div>{flag ? <span>a</span> : <span>b</span>}</div>`
364
+ const result = lintSource(source)
365
+ const diags = findByRule(result, "pyreon/no-ternary-conditional")
366
+ expect(diags.length).toBe(1)
367
+ })
368
+
369
+ it("pyreon/no-and-conditional: flags && with JSX", () => {
370
+ const source = `const App = () => <div>{flag && <span>yes</span>}</div>`
371
+ const result = lintSource(source)
372
+ const diags = findByRule(result, "pyreon/no-and-conditional")
373
+ expect(diags.length).toBe(1)
374
+ })
375
+
376
+ it("pyreon/no-missing-for-by: flags <For> without by", () => {
377
+ const source = `const App = () => <For each={items}>{r => <li />}</For>`
378
+ const result = lintSource(source)
379
+ const diags = findByRule(result, "pyreon/no-missing-for-by")
380
+ expect(diags.length).toBe(1)
381
+ })
382
+
383
+ it("pyreon/no-missing-for-by: clean when by is present", () => {
384
+ const source = `const App = () => <For each={items} by={r => r.id}>{r => <li />}</For>`
385
+ const result = lintSource(source)
386
+ const diags = findByRule(result, "pyreon/no-missing-for-by")
387
+ expect(diags.length).toBe(0)
388
+ })
389
+
390
+ it("pyreon/no-props-destructure: flags destructured props in component", () => {
391
+ const source = `const App = ({ name }) => <div>{name}</div>`
392
+ const result = lintSource(source)
393
+ const diags = findByRule(result, "pyreon/no-props-destructure")
394
+ expect(diags.length).toBe(1)
395
+ })
396
+
397
+ it("pyreon/no-props-destructure: clean for non-component functions", () => {
398
+ const source = `const fn = ({ a, b }) => a + b`
399
+ const result = lintSource(source)
400
+ const diags = findByRule(result, "pyreon/no-props-destructure")
401
+ expect(diags.length).toBe(0)
402
+ })
403
+
404
+ it("pyreon/no-index-as-by: flags by={(_, i) => i}", () => {
405
+ const source = `const App = () => <For each={items} by={(_, i) => i}>{r => <li />}</For>`
406
+ const result = lintSource(source)
407
+ const diags = findByRule(result, "pyreon/no-index-as-by")
408
+ expect(diags.length).toBe(1)
409
+ })
410
+
411
+ it("pyreon/no-children-access: flags props.children in renderer file", () => {
412
+ const result = lintWith(
413
+ "pyreon/no-children-access",
414
+ `import { renderToString } from "@pyreon/runtime-server"\nconst c = props.children`,
415
+ "renderer.ts",
416
+ )
417
+ expect(result.diagnostics.length).toBe(1)
418
+ })
419
+
420
+ it("pyreon/no-children-access: clean in non-renderer file", () => {
421
+ const result = lintWith(
422
+ "pyreon/no-children-access",
423
+ `const c = props.children`,
424
+ "component.tsx",
425
+ )
426
+ expect(result.diagnostics.length).toBe(0)
427
+ })
428
+ })
429
+
430
+ // ── Lifecycle Rules ─────────────────────────────────────────────────────────
431
+
432
+ describe("Lifecycle rules", () => {
433
+ it("pyreon/no-missing-cleanup: flags onMount with setInterval and no return", () => {
434
+ const source = `onMount(() => { setInterval(() => {}, 1000) })`
435
+ const result = lintSource(source)
436
+ const diags = findByRule(result, "pyreon/no-missing-cleanup")
437
+ expect(diags.length).toBe(1)
438
+ })
439
+
440
+ it("pyreon/no-missing-cleanup: clean when onMount returns cleanup", () => {
441
+ const source = `onMount(() => { const id = setInterval(() => {}, 1000); return () => clearInterval(id) })`
442
+ const result = lintSource(source)
443
+ const diags = findByRule(result, "pyreon/no-missing-cleanup")
444
+ expect(diags.length).toBe(0)
445
+ })
446
+
447
+ it("pyreon/no-mount-in-effect: flags onMount inside effect", () => {
448
+ const source = `effect(() => { onMount(() => {}) })`
449
+ const result = lintSource(source)
450
+ const diags = findByRule(result, "pyreon/no-mount-in-effect")
451
+ expect(diags.length).toBe(1)
452
+ })
453
+
454
+ it("pyreon/no-effect-in-mount: flags effect inside onMount", () => {
455
+ const source = `onMount(() => { effect(() => {}) })`
456
+ const result = lintSource(source)
457
+ const diags = findByRule(result, "pyreon/no-effect-in-mount")
458
+ expect(diags.length).toBe(1)
459
+ })
460
+
461
+ it("pyreon/no-dom-in-setup: flags document.querySelector outside onMount", () => {
462
+ const source = `const el = document.querySelector(".app")`
463
+ const result = lintSource(source)
464
+ const diags = findByRule(result, "pyreon/no-dom-in-setup")
465
+ expect(diags.length).toBe(1)
466
+ })
467
+
468
+ it("pyreon/no-dom-in-setup: clean inside onMount", () => {
469
+ const source = `onMount(() => { document.querySelector(".app") })`
470
+ const result = lintSource(source)
471
+ const diags = findByRule(result, "pyreon/no-dom-in-setup")
472
+ expect(diags.length).toBe(0)
473
+ })
474
+ })
475
+
476
+ // ── Performance Rules ───────────────────────────────────────────────────────
477
+
478
+ describe("Performance rules", () => {
479
+ it("pyreon/no-eager-import: flags static import of heavy packages", () => {
480
+ const source = `import { Chart } from "@pyreon/charts"`
481
+ const result = lintSource(source)
482
+ const diags = findByRule(result, "pyreon/no-eager-import")
483
+ expect(diags.length).toBe(1)
484
+ })
485
+
486
+ it("pyreon/no-eager-import: clean for lightweight packages", () => {
487
+ const source = `import { signal } from "@pyreon/reactivity"`
488
+ const result = lintSource(source)
489
+ const diags = findByRule(result, "pyreon/no-eager-import")
490
+ expect(diags.length).toBe(0)
491
+ })
492
+
493
+ it("pyreon/no-effect-in-for: flags effect() inside <For>", () => {
494
+ const result = lintWith(
495
+ "pyreon/no-effect-in-for",
496
+ `const App = () => <For each={items}>{r => { effect(() => {}); return <li /> }}</For>`,
497
+ )
498
+ expect(result.diagnostics.length).toBe(1)
499
+ })
500
+
501
+ it("pyreon/no-effect-in-for: clean when effect is outside <For>", () => {
502
+ const result = lintWith(
503
+ "pyreon/no-effect-in-for",
504
+ `effect(() => {})\nconst App = () => <For each={items}>{r => <li />}</For>`,
505
+ )
506
+ expect(result.diagnostics.length).toBe(0)
507
+ })
508
+
509
+ it("pyreon/no-large-for-without-by: flags <For> without by prop", () => {
510
+ const result = lintWith(
511
+ "pyreon/no-large-for-without-by",
512
+ `const App = () => <For each={items}>{r => <li />}</For>`,
513
+ )
514
+ expect(result.diagnostics.length).toBe(1)
515
+ })
516
+
517
+ it("pyreon/no-large-for-without-by: clean with by prop", () => {
518
+ const result = lintWith(
519
+ "pyreon/no-large-for-without-by",
520
+ `const App = () => <For each={items} by={r => r.id}>{r => <li />}</For>`,
521
+ )
522
+ expect(result.diagnostics.length).toBe(0)
523
+ })
524
+
525
+ it("pyreon/prefer-show-over-display: flags conditional display style", () => {
526
+ const result = lintWith(
527
+ "pyreon/prefer-show-over-display",
528
+ `const App = () => <div style={{ display: visible ? "block" : "none" }} />`,
529
+ )
530
+ expect(result.diagnostics.length).toBe(1)
531
+ })
532
+
533
+ it("pyreon/prefer-show-over-display: clean with static display", () => {
534
+ const result = lintWith(
535
+ "pyreon/prefer-show-over-display",
536
+ `const App = () => <div style={{ display: "block" }} />`,
537
+ )
538
+ expect(result.diagnostics.length).toBe(0)
539
+ })
540
+ })
541
+
542
+ // ── SSR Rules ───────────────────────────────────────────────────────────────
543
+
544
+ describe("SSR rules", () => {
545
+ it("pyreon/no-window-in-ssr: flags window outside onMount", () => {
546
+ const source = `const w = window.innerWidth`
547
+ const result = lintSource(source)
548
+ const diags = findByRule(result, "pyreon/no-window-in-ssr")
549
+ expect(diags.length).toBeGreaterThanOrEqual(1)
550
+ })
551
+
552
+ it("pyreon/no-window-in-ssr: clean inside onMount", () => {
553
+ const source = `onMount(() => { const w = window.innerWidth })`
554
+ const result = lintSource(source)
555
+ const diags = findByRule(result, "pyreon/no-window-in-ssr")
556
+ expect(diags.length).toBe(0)
557
+ })
558
+
559
+ it("pyreon/no-window-in-ssr: clean with typeof guard", () => {
560
+ const source = `if (typeof window !== "undefined") { const w = window.innerWidth }`
561
+ const result = lintSource(source)
562
+ const diags = findByRule(result, "pyreon/no-window-in-ssr")
563
+ expect(diags.length).toBe(0)
564
+ })
565
+
566
+ it("pyreon/no-mismatch-risk: flags Date.now() in JSX", () => {
567
+ const source = `const App = () => <div>{Date.now()}</div>`
568
+ const result = lintSource(source)
569
+ const diags = findByRule(result, "pyreon/no-mismatch-risk")
570
+ expect(diags.length).toBe(1)
571
+ })
572
+
573
+ it("pyreon/prefer-request-context: flags module-level signal in server file", () => {
574
+ const source = `const state = signal(0)`
575
+ const result = lintFile("app.server.ts", source, allRules, defaultConfig())
576
+ const diags = findByRule(result, "pyreon/prefer-request-context")
577
+ expect(diags.length).toBe(1)
578
+ })
579
+ })
580
+
581
+ // ── Architecture Rules ──────────────────────────────────────────────────────
582
+
583
+ describe("Architecture rules", () => {
584
+ it("pyreon/no-deep-import: flags @pyreon/*/src/ imports", () => {
585
+ const source = `import { something } from "@pyreon/core/src/signal"`
586
+ const result = lintSource(source)
587
+ const diags = findByRule(result, "pyreon/no-deep-import")
588
+ expect(diags.length).toBe(1)
589
+ })
590
+
591
+ it("pyreon/no-deep-import: clean for normal imports", () => {
592
+ const source = `import { signal } from "@pyreon/reactivity"`
593
+ const result = lintSource(source)
594
+ const diags = findByRule(result, "pyreon/no-deep-import")
595
+ expect(diags.length).toBe(0)
596
+ })
597
+
598
+ it("pyreon/dev-guard-warnings: flags console.warn without __DEV__", () => {
599
+ const source = `console.warn("something")`
600
+ const result = lintSource(source)
601
+ const diags = findByRule(result, "pyreon/dev-guard-warnings")
602
+ expect(diags.length).toBe(1)
603
+ })
604
+
605
+ it("pyreon/dev-guard-warnings: clean inside __DEV__ guard", () => {
606
+ const source = `if (__DEV__) { console.warn("something") }`
607
+ const result = lintSource(source)
608
+ const diags = findByRule(result, "pyreon/dev-guard-warnings")
609
+ expect(diags.length).toBe(0)
610
+ })
611
+
612
+ it("pyreon/dev-guard-warnings: clean in test files", () => {
613
+ const source = `console.warn("test warning")`
614
+ const result = lintFile("src/tests/foo.test.ts", source, allRules, defaultConfig())
615
+ const diags = findByRule(result, "pyreon/dev-guard-warnings")
616
+ expect(diags.length).toBe(0)
617
+ })
618
+
619
+ it("pyreon/no-error-without-prefix: flags throw without [Pyreon]", () => {
620
+ const source = `throw new Error("something went wrong")`
621
+ const result = lintSource(source)
622
+ const diags = findByRule(result, "pyreon/no-error-without-prefix")
623
+ expect(diags.length).toBe(1)
624
+ expect(diags[0]?.fix).toBeDefined()
625
+ })
626
+
627
+ it("pyreon/no-error-without-prefix: clean with [Pyreon] prefix", () => {
628
+ const source = `throw new Error("[Pyreon] something went wrong")`
629
+ const result = lintSource(source)
630
+ const diags = findByRule(result, "pyreon/no-error-without-prefix")
631
+ expect(diags.length).toBe(0)
632
+ })
633
+ })
634
+
635
+ // ── Store Rules ─────────────────────────────────────────────────────────────
636
+
637
+ describe("Store rules", () => {
638
+ it("pyreon/no-duplicate-store-id: flags duplicate IDs", () => {
639
+ const source = `
640
+ defineStore("counter", () => {})
641
+ defineStore("counter", () => {})
642
+ `
643
+ const result = lintSource(source)
644
+ const diags = findByRule(result, "pyreon/no-duplicate-store-id")
645
+ expect(diags.length).toBe(1)
646
+ })
647
+
648
+ it("pyreon/no-duplicate-store-id: clean with unique IDs", () => {
649
+ const source = `
650
+ defineStore("counter", () => {})
651
+ defineStore("user", () => {})
652
+ `
653
+ const result = lintSource(source)
654
+ const diags = findByRule(result, "pyreon/no-duplicate-store-id")
655
+ expect(diags.length).toBe(0)
656
+ })
657
+
658
+ it("pyreon/no-mutate-store-state: flags store.signal.set()", () => {
659
+ const result = lintWith("pyreon/no-mutate-store-state", `userStore.count.set(5)`)
660
+ expect(result.diagnostics.length).toBe(1)
661
+ })
662
+
663
+ it("pyreon/no-mutate-store-state: clean for non-store .set()", () => {
664
+ const result = lintWith("pyreon/no-mutate-store-state", `count.set(5)`)
665
+ expect(result.diagnostics.length).toBe(0)
666
+ })
667
+
668
+ it("pyreon/no-store-outside-provider: flags store hooks in server files without provider", () => {
669
+ const result = lintWith(
670
+ "pyreon/no-store-outside-provider",
671
+ `useCounterStore()`,
672
+ "app.server.ts",
673
+ )
674
+ expect(result.diagnostics.length).toBe(1)
675
+ })
676
+
677
+ it("pyreon/no-store-outside-provider: clean when provider is imported", () => {
678
+ const result = lintWith(
679
+ "pyreon/no-store-outside-provider",
680
+ `import { runWithRequestContext } from "@pyreon/reactivity"\nuseCounterStore()`,
681
+ "app.server.ts",
682
+ )
683
+ expect(result.diagnostics.length).toBe(0)
684
+ })
685
+
686
+ it("pyreon/no-store-outside-provider: clean in non-server files", () => {
687
+ const result = lintWith("pyreon/no-store-outside-provider", `useCounterStore()`, "app.tsx")
688
+ expect(result.diagnostics.length).toBe(0)
689
+ })
690
+ })
691
+
692
+ // ── Form Rules ──────────────────────────────────────────────────────────────
693
+
694
+ describe("Form rules", () => {
695
+ it("pyreon/no-submit-without-validation: flags useForm with onSubmit but no validators", () => {
696
+ const source = `const form = useForm({ initialValues: {}, onSubmit: () => {} })`
697
+ const result = lintSource(source)
698
+ const diags = findByRule(result, "pyreon/no-submit-without-validation")
699
+ expect(diags.length).toBe(1)
700
+ })
701
+
702
+ it("pyreon/no-submit-without-validation: clean with validators", () => {
703
+ const source = `const form = useForm({ initialValues: {}, onSubmit: () => {}, validators: {} })`
704
+ const result = lintSource(source)
705
+ const diags = findByRule(result, "pyreon/no-submit-without-validation")
706
+ expect(diags.length).toBe(0)
707
+ })
708
+
709
+ it("pyreon/no-unregistered-field: flags useField without register()", () => {
710
+ const result = lintWith("pyreon/no-unregistered-field", `const name = useField(form, "name")`)
711
+ expect(result.diagnostics.length).toBe(1)
712
+ expect(result.diagnostics[0]!.message).toContain("register")
713
+ })
714
+
715
+ it("pyreon/no-unregistered-field: clean when register is called", () => {
716
+ const result = lintWith(
717
+ "pyreon/no-unregistered-field",
718
+ `const name = useField(form, "name")\nname.register()`,
719
+ )
720
+ expect(result.diagnostics.length).toBe(0)
721
+ })
722
+
723
+ it("pyreon/prefer-field-array: flags signal([]) in form files", () => {
724
+ const result = lintWith(
725
+ "pyreon/prefer-field-array",
726
+ `import { useForm } from "@pyreon/form"\nconst items = signal([])`,
727
+ )
728
+ expect(result.diagnostics.length).toBe(1)
729
+ expect(result.diagnostics[0]!.message).toContain("useFieldArray")
730
+ })
731
+
732
+ it("pyreon/prefer-field-array: clean when not in form file", () => {
733
+ const result = lintWith("pyreon/prefer-field-array", `const items = signal([])`)
734
+ expect(result.diagnostics.length).toBe(0)
735
+ })
736
+ })
737
+
738
+ // ── Styling Rules ───────────────────────────────────────────────────────────
739
+
740
+ describe("Styling rules", () => {
741
+ it("pyreon/no-inline-style-object: flags style={{...}} in JSX", () => {
742
+ const source = `const App = () => <div style={{ color: "red" }} />`
743
+ const result = lintSource(source)
744
+ const diags = findByRule(result, "pyreon/no-inline-style-object")
745
+ expect(diags.length).toBe(1)
746
+ })
747
+
748
+ it("pyreon/no-dynamic-styled: flags styled() inside function", () => {
749
+ const source = `function App() { const Box = styled("div"); return null }`
750
+ const result = lintSource(source)
751
+ const diags = findByRule(result, "pyreon/no-dynamic-styled")
752
+ expect(diags.length).toBe(1)
753
+ })
754
+
755
+ it("pyreon/no-dynamic-styled: clean at module level", () => {
756
+ const source = `const Box = styled("div")`
757
+ const result = lintSource(source)
758
+ const diags = findByRule(result, "pyreon/no-dynamic-styled")
759
+ expect(diags.length).toBe(0)
760
+ })
761
+
762
+ it("pyreon/prefer-cx: flags string concatenation in class attribute", () => {
763
+ const result = lintWith("pyreon/prefer-cx", `const App = () => <div class={"foo " + bar} />`)
764
+ expect(result.diagnostics.length).toBe(1)
765
+ expect(result.diagnostics[0]!.message).toContain("cx()")
766
+ })
767
+
768
+ it("pyreon/prefer-cx: flags template literal in class attribute", () => {
769
+ const result = lintWith("pyreon/prefer-cx", "const App = () => <div class={`foo ${bar}`} />")
770
+ expect(result.diagnostics.length).toBe(1)
771
+ })
772
+
773
+ it("pyreon/prefer-cx: clean with plain string class", () => {
774
+ const result = lintWith("pyreon/prefer-cx", `const App = () => <div class="foo bar" />`)
775
+ expect(result.diagnostics.length).toBe(0)
776
+ })
777
+
778
+ it("pyreon/no-theme-outside-provider: flags useTheme() without provider import", () => {
779
+ const result = lintWith("pyreon/no-theme-outside-provider", `const theme = useTheme()`)
780
+ expect(result.diagnostics.length).toBe(1)
781
+ })
782
+
783
+ it("pyreon/no-theme-outside-provider: clean when PyreonUI is imported", () => {
784
+ const result = lintWith(
785
+ "pyreon/no-theme-outside-provider",
786
+ `import { PyreonUI } from "@pyreon/ui-core"\nconst theme = useTheme()`,
787
+ )
788
+ expect(result.diagnostics.length).toBe(0)
789
+ })
790
+ })
791
+
792
+ // ── Hooks Rules ─────────────────────────────────────────────────────────────
793
+
794
+ describe("Hooks rules", () => {
795
+ it("pyreon/no-raw-addeventlistener: flags .addEventListener()", () => {
796
+ const source = `el.addEventListener("click", handler)`
797
+ const result = lintSource(source)
798
+ const diags = findByRule(result, "pyreon/no-raw-addeventlistener")
799
+ expect(diags.length).toBe(1)
800
+ })
801
+
802
+ it("pyreon/no-raw-setinterval: flags setInterval outside onMount", () => {
803
+ const source = `setInterval(() => {}, 1000)`
804
+ const result = lintSource(source)
805
+ const diags = findByRule(result, "pyreon/no-raw-setinterval")
806
+ expect(diags.length).toBe(1)
807
+ })
808
+
809
+ it("pyreon/no-raw-localstorage: flags localStorage.getItem()", () => {
810
+ const source = `const v = localStorage.getItem("key")`
811
+ const result = lintSource(source)
812
+ const diags = findByRule(result, "pyreon/no-raw-localstorage")
813
+ expect(diags.length).toBe(1)
814
+ })
815
+ })
816
+
817
+ // ── Accessibility Rules ─────────────────────────────────────────────────────
818
+
819
+ describe("Accessibility rules", () => {
820
+ it("pyreon/dialog-a11y: flags <dialog> without aria-label", () => {
821
+ const source = `const App = () => <dialog><p>Hello</p></dialog>`
822
+ const result = lintSource(source)
823
+ const diags = findByRule(result, "pyreon/dialog-a11y")
824
+ expect(diags.length).toBe(1)
825
+ })
826
+
827
+ it("pyreon/dialog-a11y: clean with aria-label", () => {
828
+ const source = `const App = () => <dialog aria-label="My dialog"><p>Hello</p></dialog>`
829
+ const result = lintSource(source)
830
+ const diags = findByRule(result, "pyreon/dialog-a11y")
831
+ expect(diags.length).toBe(0)
832
+ })
833
+
834
+ it("pyreon/overlay-a11y: flags <Overlay> without role", () => {
835
+ const source = `const App = () => <Overlay><p>Hello</p></Overlay>`
836
+ const result = lintSource(source)
837
+ const diags = findByRule(result, "pyreon/overlay-a11y")
838
+ expect(diags.length).toBe(1)
839
+ })
840
+
841
+ it("pyreon/overlay-a11y: clean with role", () => {
842
+ const source = `const App = () => <Overlay role="dialog"><p>Hello</p></Overlay>`
843
+ const result = lintSource(source)
844
+ const diags = findByRule(result, "pyreon/overlay-a11y")
845
+ expect(diags.length).toBe(0)
846
+ })
847
+
848
+ it("pyreon/toast-a11y: flags Toast component without role or aria-live", () => {
849
+ const result = lintWith("pyreon/toast-a11y", `const App = () => <ToastItem message="hello" />`)
850
+ expect(result.diagnostics.length).toBe(1)
851
+ expect(result.diagnostics[0]!.message).toContain("role")
852
+ })
853
+
854
+ it("pyreon/toast-a11y: clean with role attribute", () => {
855
+ const result = lintWith(
856
+ "pyreon/toast-a11y",
857
+ `const App = () => <ToastItem role="alert" message="hello" />`,
858
+ )
859
+ expect(result.diagnostics.length).toBe(0)
860
+ })
861
+
862
+ it("pyreon/toast-a11y: clean with aria-live attribute", () => {
863
+ const result = lintWith(
864
+ "pyreon/toast-a11y",
865
+ `const App = () => <ToastItem aria-live="polite" message="hello" />`,
866
+ )
867
+ expect(result.diagnostics.length).toBe(0)
868
+ })
869
+
870
+ it("pyreon/toast-a11y: skips Toaster container", () => {
871
+ const result = lintWith("pyreon/toast-a11y", `const App = () => <Toaster />`)
872
+ expect(result.diagnostics.length).toBe(0)
873
+ })
874
+
875
+ it("pyreon/toast-a11y: skips non-toast PascalCase components", () => {
876
+ const result = lintWith("pyreon/toast-a11y", `const App = () => <Button />`)
877
+ expect(result.diagnostics.length).toBe(0)
878
+ })
879
+ })
880
+
881
+ // ── Router Rules ────────────────────────────────────────────────────────────
882
+
883
+ describe("Router rules", () => {
884
+ it("pyreon/no-href-navigation: flags <a href> in router file", () => {
885
+ const result = lintWith(
886
+ "pyreon/no-href-navigation",
887
+ `import { Link } from "@pyreon/router"\nconst App = () => <a href="/about">About</a>`,
888
+ )
889
+ expect(result.diagnostics.length).toBe(1)
890
+ expect(result.diagnostics[0]!.message).toContain("<Link>")
891
+ })
892
+
893
+ it("pyreon/no-href-navigation: clean for external URLs", () => {
894
+ const result = lintWith(
895
+ "pyreon/no-href-navigation",
896
+ `import { Link } from "@pyreon/router"\nconst App = () => <a href="https://example.com">External</a>`,
897
+ )
898
+ expect(result.diagnostics.length).toBe(0)
899
+ })
900
+
901
+ it("pyreon/no-href-navigation: clean for anchor links", () => {
902
+ const result = lintWith(
903
+ "pyreon/no-href-navigation",
904
+ `import { Link } from "@pyreon/router"\nconst App = () => <a href="#section">Jump</a>`,
905
+ )
906
+ expect(result.diagnostics.length).toBe(0)
907
+ })
908
+
909
+ it("pyreon/no-href-navigation: clean without router import", () => {
910
+ const result = lintWith(
911
+ "pyreon/no-href-navigation",
912
+ `const App = () => <a href="/about">About</a>`,
913
+ )
914
+ expect(result.diagnostics.length).toBe(0)
915
+ })
916
+
917
+ it("pyreon/no-imperative-navigate-in-render: flags navigate() in component body", () => {
918
+ const result = lintWith(
919
+ "pyreon/no-imperative-navigate-in-render",
920
+ `const App = () => { navigate("/home"); return <div /> }`,
921
+ )
922
+ expect(result.diagnostics.length).toBe(1)
923
+ expect(result.diagnostics[0]!.message).toContain("infinite")
924
+ })
925
+
926
+ it("pyreon/no-imperative-navigate-in-render: clean inside onMount", () => {
927
+ const result = lintWith(
928
+ "pyreon/no-imperative-navigate-in-render",
929
+ `const App = () => { onMount(() => { navigate("/home") }); return <div /> }`,
930
+ )
931
+ expect(result.diagnostics.length).toBe(0)
932
+ })
933
+
934
+ it("pyreon/no-imperative-navigate-in-render: clean in non-component", () => {
935
+ const result = lintWith(
936
+ "pyreon/no-imperative-navigate-in-render",
937
+ `const handle = () => { navigate("/home") }`,
938
+ )
939
+ expect(result.diagnostics.length).toBe(0)
940
+ })
941
+
942
+ it("pyreon/no-missing-fallback: flags route config without catch-all", () => {
943
+ const result = lintWith(
944
+ "pyreon/no-missing-fallback",
945
+ `import { Router } from "@pyreon/router"\nconst routes = [{ path: "/", component: Home }, { path: "/about", component: About }]`,
946
+ )
947
+ expect(result.diagnostics.length).toBe(1)
948
+ expect(result.diagnostics[0]!.message).toContain("catch-all")
949
+ })
950
+
951
+ it("pyreon/no-missing-fallback: clean with catch-all route", () => {
952
+ const result = lintWith(
953
+ "pyreon/no-missing-fallback",
954
+ `import { Router } from "@pyreon/router"\nconst routes = [{ path: "/", component: Home }, { path: "*", component: NotFound }]`,
955
+ )
956
+ expect(result.diagnostics.length).toBe(0)
957
+ })
958
+
959
+ it("pyreon/no-missing-fallback: clean without router import", () => {
960
+ const result = lintWith(
961
+ "pyreon/no-missing-fallback",
962
+ `const routes = [{ path: "/", component: Home }]`,
963
+ )
964
+ expect(result.diagnostics.length).toBe(0)
965
+ })
966
+
967
+ it("pyreon/prefer-use-is-active: flags location.pathname === comparison", () => {
968
+ const result = lintWith(
969
+ "pyreon/prefer-use-is-active",
970
+ `const active = location.pathname === "/admin"`,
971
+ )
972
+ expect(result.diagnostics.length).toBe(1)
973
+ expect(result.diagnostics[0]!.message).toContain("useIsActive")
974
+ })
975
+
976
+ it("pyreon/prefer-use-is-active: flags route.path === comparison", () => {
977
+ const result = lintWith("pyreon/prefer-use-is-active", `const active = route.path === "/admin"`)
978
+ expect(result.diagnostics.length).toBe(1)
979
+ })
980
+
981
+ it("pyreon/prefer-use-is-active: clean for unrelated comparisons", () => {
982
+ const result = lintWith("pyreon/prefer-use-is-active", `const active = name === "admin"`)
983
+ expect(result.diagnostics.length).toBe(0)
984
+ })
985
+ })
986
+
987
+ // ── Config Loading ──────────────────────────────────────────────────────────
988
+
989
+ describe("Config loading", () => {
990
+ it("loadConfig returns null when no config file exists", () => {
991
+ // Use a path where there's definitely no config
992
+ const result = loadConfig("/tmp/nonexistent-pyreon-dir-12345")
993
+ expect(result).toBeNull()
994
+ })
995
+ })
996
+
997
+ // ── Ignore Filter ───────────────────────────────────────────────────────────
998
+
999
+ describe("Ignore filter", () => {
1000
+ it("createIgnoreFilter returns a function", () => {
1001
+ const filter = createIgnoreFilter("/tmp/nonexistent-pyreon-dir-12345")
1002
+ expect(typeof filter).toBe("function")
1003
+ })
1004
+
1005
+ it("filter returns false for paths when no ignore files exist", () => {
1006
+ const filter = createIgnoreFilter("/tmp/nonexistent-pyreon-dir-12345")
1007
+ expect(filter("/tmp/nonexistent-pyreon-dir-12345/src/app.ts")).toBe(false)
1008
+ })
1009
+ })
1010
+
1011
+ // ── Presets ─────────────────────────────────────────────────────────────────
1012
+
1013
+ describe("Presets", () => {
1014
+ it("recommended should include all rules", () => {
1015
+ const config = getPreset("recommended")
1016
+ expect(Object.keys(config.rules).length).toBe(55)
1017
+ })
1018
+
1019
+ it("strict should promote all warns to errors", () => {
1020
+ const recommended = getPreset("recommended")
1021
+ const strict = getPreset("strict")
1022
+ for (const [id, sev] of Object.entries(recommended.rules)) {
1023
+ if (sev === "warn") {
1024
+ expect(strict.rules[id]).toBe("error")
1025
+ }
1026
+ }
1027
+ })
1028
+
1029
+ it("app should disable library-specific rules", () => {
1030
+ const app = getPreset("app")
1031
+ expect(app.rules["pyreon/dev-guard-warnings"]).toBe("off")
1032
+ expect(app.rules["pyreon/no-error-without-prefix"]).toBe("off")
1033
+ expect(app.rules["pyreon/no-circular-import"]).toBe("off")
1034
+ expect(app.rules["pyreon/no-cross-layer-import"]).toBe("off")
1035
+ })
1036
+
1037
+ it("lib should have architecture rules as error", () => {
1038
+ const lib = getPreset("lib")
1039
+ expect(lib.rules["pyreon/no-circular-import"]).toBe("error")
1040
+ expect(lib.rules["pyreon/no-cross-layer-import"]).toBe("error")
1041
+ expect(lib.rules["pyreon/dev-guard-warnings"]).toBe("error")
1042
+ })
1043
+ })