@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
package/lib/index.js ADDED
@@ -0,0 +1,2955 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ import { Visitor, parseSync } from "oxc-parser";
4
+
5
+ //#region src/cache.ts
6
+ /**
7
+ * Simple in-memory cache for parsed ASTs keyed by file content hash.
8
+ *
9
+ * Uses FNV-1a hash of source text as cache key for fast lookups
10
+ * during repeat runs (e.g., watch mode).
11
+ *
12
+ * @example
13
+ * ```ts
14
+ * import { AstCache } from "@pyreon/lint"
15
+ *
16
+ * const cache = new AstCache()
17
+ * const cached = cache.get(sourceText)
18
+ * if (!cached) {
19
+ * const parsed = parse(sourceText)
20
+ * cache.set(sourceText, parsed)
21
+ * }
22
+ * ```
23
+ */
24
+ var AstCache = class {
25
+ cache = /* @__PURE__ */ new Map();
26
+ get(sourceText) {
27
+ const key = fnv1aHash(sourceText);
28
+ return this.cache.get(key);
29
+ }
30
+ set(sourceText, value) {
31
+ const key = fnv1aHash(sourceText);
32
+ this.cache.set(key, value);
33
+ }
34
+ clear() {
35
+ this.cache.clear();
36
+ }
37
+ get size() {
38
+ return this.cache.size;
39
+ }
40
+ };
41
+ /** FNV-1a hash — fast, non-cryptographic, low collision rate. */
42
+ function fnv1aHash(str) {
43
+ let hash = 2166136261;
44
+ for (let i = 0; i < str.length; i++) {
45
+ hash ^= str.charCodeAt(i);
46
+ hash = hash * 16777619 | 0;
47
+ }
48
+ return (hash >>> 0).toString(36);
49
+ }
50
+
51
+ //#endregion
52
+ //#region src/config/ignore.ts
53
+ /**
54
+ * Create a filter function that returns true if a file path should be ignored.
55
+ *
56
+ * Loads patterns from `.pyreonlintignore` and `.gitignore` in the given directory.
57
+ *
58
+ * @example
59
+ * ```ts
60
+ * import { createIgnoreFilter } from "@pyreon/lint"
61
+ *
62
+ * const isIgnored = createIgnoreFilter(process.cwd())
63
+ * if (!isIgnored("src/app.tsx")) lintFile(...)
64
+ * ```
65
+ */
66
+ function createIgnoreFilter(cwd, extraIgnore) {
67
+ const patterns = [];
68
+ const resolvedCwd = resolve(cwd);
69
+ loadPatternsFromFile(join(resolvedCwd, ".pyreonlintignore"), patterns);
70
+ loadPatternsFromFile(join(resolvedCwd, ".gitignore"), patterns);
71
+ if (extraIgnore) loadPatternsFromFile(resolve(extraIgnore), patterns);
72
+ const matchers = patterns.map((p) => compileMatcher(p));
73
+ return (filePath) => {
74
+ const normalized = relative(resolvedCwd, resolve(filePath)).replace(/\\/g, "/");
75
+ for (const matcher of matchers) if (matcher(normalized)) return true;
76
+ return false;
77
+ };
78
+ }
79
+ function loadPatternsFromFile(filePath, patterns) {
80
+ if (!existsSync(filePath)) return;
81
+ try {
82
+ const content = readFileSync(filePath, "utf-8");
83
+ for (const line of content.split("\n")) {
84
+ const trimmed = line.trim();
85
+ if (!trimmed || trimmed.startsWith("#")) continue;
86
+ patterns.push(trimmed);
87
+ }
88
+ } catch {}
89
+ }
90
+ /**
91
+ * Compile a gitignore-style pattern into a matcher function.
92
+ * Supports: `*` (any non-slash chars), `**` (any path segment), `?` (single char),
93
+ * leading `/` (root-anchored), trailing `/` (directory only).
94
+ */
95
+ function compileMatcher(pattern) {
96
+ let p = pattern;
97
+ let anchored = false;
98
+ if (p.startsWith("!")) return () => false;
99
+ if (p.startsWith("/")) {
100
+ anchored = true;
101
+ p = p.slice(1);
102
+ }
103
+ let dirOnly = false;
104
+ if (p.endsWith("/")) {
105
+ dirOnly = true;
106
+ p = p.slice(0, -1);
107
+ }
108
+ const regex = globToRegex(p);
109
+ return (path) => {
110
+ if (dirOnly) {
111
+ if (anchored) return regex.test(path) || path.startsWith(`${p}/`) || path === p;
112
+ return regex.test(path) || path.includes(`/${p}/`) || path.startsWith(`${p}/`) || path === p;
113
+ }
114
+ if (anchored) return regex.test(path);
115
+ if (regex.test(path)) return true;
116
+ const lastSlash = path.lastIndexOf("/");
117
+ if (lastSlash !== -1) {
118
+ const basename = path.slice(lastSlash + 1);
119
+ return regex.test(basename);
120
+ }
121
+ return false;
122
+ };
123
+ }
124
+ const GLOB_CHAR_MAP = {
125
+ "?": "[^/]",
126
+ ".": "\\.",
127
+ "/": "/"
128
+ };
129
+ function handleStar(glob, pos) {
130
+ if (glob[pos + 1] === "*") {
131
+ if (glob[pos + 2] === "/") return {
132
+ pattern: "(?:.*/)?",
133
+ advance: 3
134
+ };
135
+ return {
136
+ pattern: ".*",
137
+ advance: 2
138
+ };
139
+ }
140
+ return {
141
+ pattern: "[^/]*",
142
+ advance: 1
143
+ };
144
+ }
145
+ function globToRegex(glob) {
146
+ let result = "^";
147
+ let i = 0;
148
+ while (i < glob.length) {
149
+ const ch = glob[i];
150
+ if (ch === "*") {
151
+ const star = handleStar(glob, i);
152
+ result += star.pattern;
153
+ i += star.advance;
154
+ } else {
155
+ result += GLOB_CHAR_MAP[ch] ?? escapeRegex(ch);
156
+ i++;
157
+ }
158
+ }
159
+ result += "$";
160
+ return new RegExp(result);
161
+ }
162
+ function escapeRegex(str) {
163
+ return str.replace(/[\\^$+{}[\]|()]/g, "\\$&");
164
+ }
165
+
166
+ //#endregion
167
+ //#region src/config/loader.ts
168
+ const CONFIG_FILENAMES = [
169
+ ".pyreonlintrc.json",
170
+ ".pyreonlintrc",
171
+ "pyreonlint.config.json"
172
+ ];
173
+ /**
174
+ * Load a lint config file from the given directory or its parents.
175
+ *
176
+ * Search order:
177
+ * 1. `.pyreonlintrc.json`
178
+ * 2. `.pyreonlintrc`
179
+ * 3. `pyreonlint.config.json`
180
+ * 4. `package.json` `"pyreonlint"` field
181
+ *
182
+ * @example
183
+ * ```ts
184
+ * import { loadConfig } from "@pyreon/lint"
185
+ *
186
+ * const config = loadConfig(process.cwd())
187
+ * if (config) console.log(config.preset)
188
+ * ```
189
+ */
190
+ function loadConfig(cwd) {
191
+ let dir = resolve(cwd);
192
+ const root = dirname(dir);
193
+ while (true) {
194
+ const found = searchDirectory(dir);
195
+ if (found !== null) return found;
196
+ const parent = dirname(dir);
197
+ if (parent === dir || parent === root) break;
198
+ dir = parent;
199
+ }
200
+ return null;
201
+ }
202
+ function searchDirectory(dir) {
203
+ for (const filename of CONFIG_FILENAMES) {
204
+ const content = tryReadJson(join(dir, filename));
205
+ if (content !== null) return content;
206
+ }
207
+ return extractPkgConfig(join(dir, "package.json"));
208
+ }
209
+ function extractPkgConfig(pkgPath) {
210
+ const pkg = tryReadJson(pkgPath);
211
+ if (pkg === null || typeof pkg !== "object" || !("pyreonlint" in pkg)) return null;
212
+ const field = pkg.pyreonlint;
213
+ if (field && typeof field === "object") return field;
214
+ return null;
215
+ }
216
+ /**
217
+ * Load a config file from a specific path.
218
+ */
219
+ function loadConfigFromPath(filePath) {
220
+ return tryReadJson(resolve(filePath));
221
+ }
222
+ function tryReadJson(filePath) {
223
+ if (!existsSync(filePath)) return null;
224
+ try {
225
+ const raw = readFileSync(filePath, "utf-8").trim();
226
+ if (!raw) return null;
227
+ return JSON.parse(raw);
228
+ } catch {
229
+ return null;
230
+ }
231
+ }
232
+
233
+ //#endregion
234
+ //#region src/utils/imports.ts
235
+ const PYREON_PREFIX = "@pyreon/";
236
+ const HEAVY_PACKAGES = new Set([
237
+ "@pyreon/charts",
238
+ "@pyreon/code",
239
+ "@pyreon/document",
240
+ "@pyreon/flow"
241
+ ]);
242
+ const BROWSER_GLOBALS = new Set([
243
+ "window",
244
+ "document",
245
+ "navigator",
246
+ "location",
247
+ "history",
248
+ "localStorage",
249
+ "sessionStorage",
250
+ "indexedDB",
251
+ "fetch",
252
+ "XMLHttpRequest",
253
+ "WebSocket",
254
+ "requestAnimationFrame",
255
+ "cancelAnimationFrame",
256
+ "IntersectionObserver",
257
+ "MutationObserver",
258
+ "ResizeObserver",
259
+ "matchMedia",
260
+ "getComputedStyle",
261
+ "addEventListener",
262
+ "removeEventListener"
263
+ ]);
264
+ function isPyreonImport(source) {
265
+ return source.startsWith(PYREON_PREFIX);
266
+ }
267
+ function isPyreonPackage(source) {
268
+ return source.startsWith(PYREON_PREFIX);
269
+ }
270
+ function extractImportInfo(node) {
271
+ if (node.type !== "ImportDeclaration") return null;
272
+ const source = node.source?.value;
273
+ if (!source) return null;
274
+ const specifiers = [];
275
+ let isDefault = false;
276
+ let isNamespace = false;
277
+ for (const spec of node.specifiers ?? []) if (spec.type === "ImportDefaultSpecifier") {
278
+ isDefault = true;
279
+ specifiers.push({
280
+ imported: "default",
281
+ local: spec.local?.name ?? ""
282
+ });
283
+ } else if (spec.type === "ImportNamespaceSpecifier") {
284
+ isNamespace = true;
285
+ specifiers.push({
286
+ imported: "*",
287
+ local: spec.local?.name ?? ""
288
+ });
289
+ } else if (spec.type === "ImportSpecifier") {
290
+ const imported = spec.imported?.type === "Identifier" ? spec.imported.name : spec.imported?.value ?? "";
291
+ specifiers.push({
292
+ imported,
293
+ local: spec.local?.name ?? ""
294
+ });
295
+ }
296
+ return {
297
+ source,
298
+ specifiers,
299
+ isDefault,
300
+ isNamespace
301
+ };
302
+ }
303
+ function importsName(imports, name, fromPackage) {
304
+ return imports.some((imp) => (!fromPackage || imp.source === fromPackage) && imp.specifiers.some((s) => s.imported === name));
305
+ }
306
+ function getLocalName(imports, name, fromPackage) {
307
+ for (const imp of imports) {
308
+ if (fromPackage && imp.source !== fromPackage) continue;
309
+ for (const s of imp.specifiers) if (s.imported === name) return s.local;
310
+ }
311
+ return null;
312
+ }
313
+
314
+ //#endregion
315
+ //#region src/utils/ast.ts
316
+ /** Check if a node is a call expression to a specific function name. */
317
+ function isCallTo(node, name) {
318
+ return node.type === "CallExpression" && node.callee?.type === "Identifier" && node.callee.name === name;
319
+ }
320
+ /** Check if a node is a member call like `obj.method()`. */
321
+ function isMemberCallTo(node, objectName, methodName) {
322
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.object?.type === "Identifier" && node.callee.object.name === objectName && node.callee.property?.type === "Identifier" && node.callee.property.name === methodName;
323
+ }
324
+ /** Get a JSX attribute by name from an opening element. */
325
+ function getJSXAttribute(openingElement, attrName) {
326
+ const attrs = openingElement.attributes ?? [];
327
+ for (const attr of attrs) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === attrName) return attr;
328
+ return null;
329
+ }
330
+ /** Check if a JSX opening element has an attribute. */
331
+ function hasJSXAttribute(openingElement, attrName) {
332
+ return getJSXAttribute(openingElement, attrName) !== null;
333
+ }
334
+ /** Check if a node is an array .map() call. */
335
+ function isArrayMapCall(node) {
336
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "map";
337
+ }
338
+ /** Check if a node is a destructuring pattern. */
339
+ function isDestructuring(node) {
340
+ return node.type === "ObjectPattern" || node.type === "ArrayPattern";
341
+ }
342
+ /** Check if a node is a ternary with JSX in either branch. */
343
+ function isTernaryWithJSX(node) {
344
+ if (node.type !== "ConditionalExpression") return false;
345
+ return containsJSX(node.consequent) || containsJSX(node.alternate);
346
+ }
347
+ /** Check if a node contains JSX anywhere. */
348
+ function containsJSX(node) {
349
+ if (!node) return false;
350
+ if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
351
+ if (node.type === "ParenthesizedExpression") return containsJSX(node.expression);
352
+ return false;
353
+ }
354
+ /** Check if a node is a logical AND with JSX. */
355
+ function isLogicalAndWithJSX(node) {
356
+ if (node.type !== "LogicalExpression" || node.operator !== "&&") return false;
357
+ return containsJSX(node.right);
358
+ }
359
+ /** Check if a node is a .peek() call. */
360
+ function isPeekCall(node) {
361
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "peek";
362
+ }
363
+ /** Check if a node is a .set() call. */
364
+ function isSetCall(node) {
365
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "set";
366
+ }
367
+ /** Get the span (byte offsets) of a node. */
368
+ function getSpan(node) {
369
+ return {
370
+ start: node.start,
371
+ end: node.end
372
+ };
373
+ }
374
+
375
+ //#endregion
376
+ //#region src/rules/accessibility/dialog-a11y.ts
377
+ const dialogA11y = {
378
+ meta: {
379
+ id: "pyreon/dialog-a11y",
380
+ category: "accessibility",
381
+ description: "Warn when <dialog> is missing aria-label or aria-labelledby.",
382
+ severity: "warn",
383
+ fixable: false
384
+ },
385
+ create(context) {
386
+ return { JSXOpeningElement(node) {
387
+ const name = node.name;
388
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "dialog") return;
389
+ const hasLabel = hasJSXAttribute(node, "aria-label");
390
+ const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby");
391
+ if (!hasLabel && !hasLabelledBy) context.report({
392
+ message: "`<dialog>` missing `aria-label` or `aria-labelledby` — provide an accessible label for screen readers.",
393
+ span: getSpan(node)
394
+ });
395
+ } };
396
+ }
397
+ };
398
+
399
+ //#endregion
400
+ //#region src/rules/accessibility/overlay-a11y.ts
401
+ const overlayA11y = {
402
+ meta: {
403
+ id: "pyreon/overlay-a11y",
404
+ category: "accessibility",
405
+ description: "Warn when <Overlay> is missing role, aria-label, or aria-labelledby.",
406
+ severity: "warn",
407
+ fixable: false
408
+ },
409
+ create(context) {
410
+ return { JSXOpeningElement(node) {
411
+ const name = node.name;
412
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "Overlay") return;
413
+ const hasRole = hasJSXAttribute(node, "role");
414
+ const hasLabel = hasJSXAttribute(node, "aria-label");
415
+ const hasLabelledBy = hasJSXAttribute(node, "aria-labelledby");
416
+ if (!hasRole && !hasLabel && !hasLabelledBy) context.report({
417
+ message: "`<Overlay>` missing `role`, `aria-label`, or `aria-labelledby` — provide accessibility attributes for screen readers.",
418
+ span: getSpan(node)
419
+ });
420
+ } };
421
+ }
422
+ };
423
+
424
+ //#endregion
425
+ //#region src/rules/accessibility/toast-a11y.ts
426
+ const toastA11y = {
427
+ meta: {
428
+ id: "pyreon/toast-a11y",
429
+ category: "accessibility",
430
+ description: "Warn when toast-like components are missing role or aria-live attributes.",
431
+ severity: "warn",
432
+ fixable: false
433
+ },
434
+ create(context) {
435
+ return { JSXOpeningElement(node) {
436
+ const name = node.name;
437
+ if (!name || name.type !== "JSXIdentifier") return;
438
+ const tagName = name.name;
439
+ if (tagName === "Toaster") return;
440
+ const firstChar = tagName[0];
441
+ if (!firstChar || firstChar !== firstChar.toUpperCase()) return;
442
+ if (!tagName.toLowerCase().includes("toast")) return;
443
+ const hasRole = hasJSXAttribute(node, "role");
444
+ const hasAriaLive = hasJSXAttribute(node, "aria-live");
445
+ if (!hasRole && !hasAriaLive) context.report({
446
+ message: `Toast component \`<${tagName}>\` missing \`role\` or \`aria-live\` — add \`role="alert"\` and \`aria-live="polite"\` for screen reader accessibility.`,
447
+ span: getSpan(node)
448
+ });
449
+ } };
450
+ }
451
+ };
452
+
453
+ //#endregion
454
+ //#region src/rules/architecture/dev-guard-warnings.ts
455
+ const devGuardWarnings = {
456
+ meta: {
457
+ id: "pyreon/dev-guard-warnings",
458
+ category: "architecture",
459
+ description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
460
+ severity: "error",
461
+ fixable: false
462
+ },
463
+ create(context) {
464
+ const filePath = context.getFilePath();
465
+ if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/examples/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
466
+ let devGuardDepth = 0;
467
+ return {
468
+ IfStatement(node) {
469
+ if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth++;
470
+ },
471
+ "IfStatement:exit"(node) {
472
+ if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth--;
473
+ },
474
+ CallExpression(node) {
475
+ if (devGuardDepth > 0) return;
476
+ const callee = node.callee;
477
+ if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) context.report({
478
+ message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
479
+ span: getSpan(node)
480
+ });
481
+ }
482
+ };
483
+ }
484
+ };
485
+
486
+ //#endregion
487
+ //#region src/rules/architecture/no-circular-import.ts
488
+ const LAYER_ORDER = {
489
+ "@pyreon/reactivity": 0,
490
+ "@pyreon/core": 1,
491
+ "@pyreon/compiler": 1,
492
+ "@pyreon/runtime-dom": 2,
493
+ "@pyreon/runtime-server": 2,
494
+ "@pyreon/router": 3,
495
+ "@pyreon/head": 4,
496
+ "@pyreon/server": 5
497
+ };
498
+ function getLayer(source) {
499
+ return LAYER_ORDER[source] ?? null;
500
+ }
501
+ function getFileLayer(filePath) {
502
+ for (const [pkg, layer] of Object.entries(LAYER_ORDER)) {
503
+ const pkgName = pkg.replace("@pyreon/", "");
504
+ if (filePath.includes(`/packages/core/${pkgName}/`)) return layer;
505
+ }
506
+ return null;
507
+ }
508
+ const noCircularImport = {
509
+ meta: {
510
+ id: "pyreon/no-circular-import",
511
+ category: "architecture",
512
+ description: "Enforce package layer order to prevent circular imports between core packages.",
513
+ severity: "error",
514
+ fixable: false
515
+ },
516
+ create(context) {
517
+ const fileLayer = getFileLayer(context.getFilePath());
518
+ if (fileLayer === null) return {};
519
+ return { ImportDeclaration(node) {
520
+ const source = node.source?.value;
521
+ if (!source || !isPyreonImport(source)) return;
522
+ const importLayer = getLayer(source);
523
+ if (importLayer === null) return;
524
+ if (importLayer >= fileLayer) context.report({
525
+ message: `Importing \`${source}\` (layer ${importLayer}) from layer ${fileLayer} — this violates the package layer order and may cause circular imports.`,
526
+ span: getSpan(node)
527
+ });
528
+ } };
529
+ }
530
+ };
531
+
532
+ //#endregion
533
+ //#region src/rules/architecture/no-cross-layer-import.ts
534
+ const CORE_PACKAGES = new Set([
535
+ "@pyreon/reactivity",
536
+ "@pyreon/core",
537
+ "@pyreon/compiler",
538
+ "@pyreon/runtime-dom",
539
+ "@pyreon/runtime-server",
540
+ "@pyreon/router",
541
+ "@pyreon/head",
542
+ "@pyreon/server"
543
+ ]);
544
+ const UI_PACKAGES = new Set([
545
+ "@pyreon/ui-core",
546
+ "@pyreon/styler",
547
+ "@pyreon/unistyle",
548
+ "@pyreon/elements",
549
+ "@pyreon/attrs",
550
+ "@pyreon/rocketstyle",
551
+ "@pyreon/coolgrid",
552
+ "@pyreon/kinetic",
553
+ "@pyreon/kinetic-presets",
554
+ "@pyreon/connector-document",
555
+ "@pyreon/document-primitives"
556
+ ]);
557
+ function getImportCategory(source) {
558
+ if (CORE_PACKAGES.has(source)) return "core";
559
+ if (UI_PACKAGES.has(source)) return "ui-system";
560
+ return null;
561
+ }
562
+ function getFileCategory(filePath) {
563
+ if (filePath.includes("/packages/core/")) return "core";
564
+ if (filePath.includes("/packages/ui-system/")) return "ui-system";
565
+ if (filePath.includes("/packages/fundamentals/")) return "fundamentals";
566
+ if (filePath.includes("/packages/tools/")) return "tools";
567
+ return null;
568
+ }
569
+ const noCrossLayerImport = {
570
+ meta: {
571
+ id: "pyreon/no-cross-layer-import",
572
+ category: "architecture",
573
+ description: "Prevent core packages from importing ui-system packages.",
574
+ severity: "error",
575
+ fixable: false
576
+ },
577
+ create(context) {
578
+ if (getFileCategory(context.getFilePath()) !== "core") return {};
579
+ return { ImportDeclaration(node) {
580
+ const source = node.source?.value;
581
+ if (!source || !isPyreonImport(source)) return;
582
+ if (getImportCategory(source) === "ui-system") context.report({
583
+ message: `Core package importing ui-system package \`${source}\` — core packages must not depend on ui-system.`,
584
+ span: getSpan(node)
585
+ });
586
+ } };
587
+ }
588
+ };
589
+
590
+ //#endregion
591
+ //#region src/rules/architecture/no-deep-import.ts
592
+ const DEEP_IMPORT_PATTERN = /@pyreon\/[^/]+\/(src|dist|lib)\//;
593
+ const noDeepImport = {
594
+ meta: {
595
+ id: "pyreon/no-deep-import",
596
+ category: "architecture",
597
+ description: "Disallow importing from @pyreon/*/src/, /dist/, or /lib/ — use public exports instead.",
598
+ severity: "warn",
599
+ fixable: false
600
+ },
601
+ create(context) {
602
+ return { ImportDeclaration(node) {
603
+ const source = node.source?.value;
604
+ if (!source || !isPyreonImport(source)) return;
605
+ if (DEEP_IMPORT_PATTERN.test(source)) context.report({
606
+ message: `Deep import \`${source}\` — import from the package's public exports instead.`,
607
+ span: getSpan(node)
608
+ });
609
+ } };
610
+ }
611
+ };
612
+
613
+ //#endregion
614
+ //#region src/rules/architecture/no-error-without-prefix.ts
615
+ const noErrorWithoutPrefix = {
616
+ meta: {
617
+ id: "pyreon/no-error-without-prefix",
618
+ category: "architecture",
619
+ description: "Require error messages to be prefixed with [Pyreon].",
620
+ severity: "warn",
621
+ fixable: true
622
+ },
623
+ create(context) {
624
+ const filePath = context.getFilePath();
625
+ if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
626
+ return { ThrowStatement(node) {
627
+ const arg = node.argument;
628
+ if (!arg || arg.type !== "NewExpression") return;
629
+ const callee = arg.callee;
630
+ if (!callee || callee.type !== "Identifier" || callee.name !== "Error") return;
631
+ const args = arg.arguments;
632
+ if (!args || args.length === 0) return;
633
+ const firstArg = args[0];
634
+ if (!firstArg) return;
635
+ if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") {
636
+ const value = firstArg.value;
637
+ if (typeof value === "string" && !value.startsWith("[Pyreon]")) {
638
+ const argSpan = getSpan(firstArg);
639
+ const quote = context.getSourceText()[argSpan.start];
640
+ const fixedValue = `${quote}[Pyreon] ${value}${quote}`;
641
+ context.report({
642
+ message: "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
643
+ span: getSpan(node),
644
+ fix: {
645
+ span: argSpan,
646
+ replacement: fixedValue
647
+ }
648
+ });
649
+ }
650
+ }
651
+ if (firstArg.type === "TemplateLiteral") {
652
+ const quasis = firstArg.quasis;
653
+ if (quasis && quasis.length > 0) {
654
+ const first = quasis[0];
655
+ if (!(first.value?.raw ?? first.value?.cooked ?? "").startsWith("[Pyreon]")) {
656
+ const argSpan = getSpan(firstArg);
657
+ const fixed = context.getSourceText().slice(argSpan.start, argSpan.end).replace(/^`/, "`[Pyreon] ");
658
+ context.report({
659
+ message: "Error message missing `[Pyreon]` prefix — all framework errors should be prefixed for identification.",
660
+ span: getSpan(node),
661
+ fix: {
662
+ span: argSpan,
663
+ replacement: fixed
664
+ }
665
+ });
666
+ }
667
+ }
668
+ }
669
+ } };
670
+ }
671
+ };
672
+
673
+ //#endregion
674
+ //#region src/rules/form/no-submit-without-validation.ts
675
+ const noSubmitWithoutValidation = {
676
+ meta: {
677
+ id: "pyreon/no-submit-without-validation",
678
+ category: "form",
679
+ description: "Warn when useForm() has onSubmit but no validators or schema.",
680
+ severity: "warn",
681
+ fixable: false
682
+ },
683
+ create(context) {
684
+ return { CallExpression(node) {
685
+ if (!isCallTo(node, "useForm")) return;
686
+ const args = node.arguments;
687
+ if (!args || args.length === 0) return;
688
+ const options = args[0];
689
+ if (!options || options.type !== "ObjectExpression") return;
690
+ let hasOnSubmit = false;
691
+ let hasValidation = false;
692
+ for (const prop of options.properties ?? []) {
693
+ if (prop.type !== "Property") continue;
694
+ const key = prop.key;
695
+ if (!key) continue;
696
+ const name = key.type === "Identifier" ? key.name : null;
697
+ if (name === "onSubmit") hasOnSubmit = true;
698
+ if (name === "validators" || name === "schema") hasValidation = true;
699
+ }
700
+ if (hasOnSubmit && !hasValidation) context.report({
701
+ message: "`useForm()` has `onSubmit` without `validators` or `schema` — consider adding validation for data integrity.",
702
+ span: getSpan(node)
703
+ });
704
+ } };
705
+ }
706
+ };
707
+
708
+ //#endregion
709
+ //#region src/rules/form/no-unregistered-field.ts
710
+ const noUnregisteredField = {
711
+ meta: {
712
+ id: "pyreon/no-unregistered-field",
713
+ category: "form",
714
+ description: "Warn when useField() is called without a corresponding register() call.",
715
+ severity: "warn",
716
+ fixable: false
717
+ },
718
+ create(context) {
719
+ const fieldDecls = /* @__PURE__ */ new Map();
720
+ const registeredNames = /* @__PURE__ */ new Set();
721
+ return {
722
+ VariableDeclarator(node) {
723
+ const init = node.init;
724
+ if (!init || !isCallTo(init, "useField")) return;
725
+ const id = node.id;
726
+ if (!id || id.type !== "Identifier") return;
727
+ fieldDecls.set(id.name, { span: getSpan(node) });
728
+ },
729
+ CallExpression(node) {
730
+ const callee = node.callee;
731
+ if (!callee || callee.type !== "MemberExpression") return;
732
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "register") return;
733
+ if (callee.object?.type === "Identifier") registeredNames.add(callee.object.name);
734
+ },
735
+ "Program:exit"() {
736
+ for (const [name, { span }] of fieldDecls) if (!registeredNames.has(name)) context.report({
737
+ message: `\`useField()\` result \`${name}\` is never registered — call \`${name}.register()\` to connect it to the form.`,
738
+ span
739
+ });
740
+ }
741
+ };
742
+ }
743
+ };
744
+
745
+ //#endregion
746
+ //#region src/rules/form/prefer-field-array.ts
747
+ const preferFieldArray = {
748
+ meta: {
749
+ id: "pyreon/prefer-field-array",
750
+ category: "form",
751
+ description: "Suggest useFieldArray() instead of signal([]) in files that import @pyreon/form.",
752
+ severity: "info",
753
+ fixable: false
754
+ },
755
+ create(context) {
756
+ let importsForm = false;
757
+ return {
758
+ ImportDeclaration(node) {
759
+ const info = extractImportInfo(node);
760
+ if (info && info.source === "@pyreon/form") importsForm = true;
761
+ },
762
+ CallExpression(node) {
763
+ if (!importsForm) return;
764
+ if (!isCallTo(node, "signal")) return;
765
+ const args = node.arguments;
766
+ if (!args || args.length === 0) return;
767
+ if (args[0]?.type === "ArrayExpression") context.report({
768
+ message: "`signal([])` in a form file — consider using `useFieldArray()` for dynamic array fields with stable keys.",
769
+ span: getSpan(node)
770
+ });
771
+ }
772
+ };
773
+ }
774
+ };
775
+
776
+ //#endregion
777
+ //#region src/rules/hooks/no-raw-addeventlistener.ts
778
+ const noRawAddEventListener = {
779
+ meta: {
780
+ id: "pyreon/no-raw-addeventlistener",
781
+ category: "hooks",
782
+ description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
783
+ severity: "info",
784
+ fixable: false
785
+ },
786
+ create(context) {
787
+ return { CallExpression(node) {
788
+ const callee = node.callee;
789
+ if (!callee || callee.type !== "MemberExpression") return;
790
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "addEventListener") return;
791
+ context.report({
792
+ message: "Raw `.addEventListener()` — consider using `useEventListener()` from `@pyreon/hooks` for auto-cleanup on unmount.",
793
+ span: getSpan(node)
794
+ });
795
+ } };
796
+ }
797
+ };
798
+
799
+ //#endregion
800
+ //#region src/rules/hooks/no-raw-localstorage.ts
801
+ const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
802
+ const STORAGE_METHODS = new Set([
803
+ "getItem",
804
+ "setItem",
805
+ "removeItem"
806
+ ]);
807
+ const noRawLocalStorage = {
808
+ meta: {
809
+ id: "pyreon/no-raw-localstorage",
810
+ category: "hooks",
811
+ description: "Suggest useStorage() instead of raw localStorage/sessionStorage access.",
812
+ severity: "info",
813
+ fixable: false
814
+ },
815
+ create(context) {
816
+ return { CallExpression(node) {
817
+ const callee = node.callee;
818
+ if (!callee || callee.type !== "MemberExpression") return;
819
+ if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
820
+ message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
821
+ span: getSpan(node)
822
+ });
823
+ } };
824
+ }
825
+ };
826
+
827
+ //#endregion
828
+ //#region src/rules/hooks/no-raw-setinterval.ts
829
+ const TIMER_FNS = new Set(["setInterval", "setTimeout"]);
830
+ const noRawSetInterval = {
831
+ meta: {
832
+ id: "pyreon/no-raw-setinterval",
833
+ category: "hooks",
834
+ description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
835
+ severity: "info",
836
+ fixable: false
837
+ },
838
+ create(context) {
839
+ let mountDepth = 0;
840
+ return {
841
+ CallExpression(node) {
842
+ if (isCallTo(node, "onMount")) mountDepth++;
843
+ if (mountDepth > 0) return;
844
+ const callee = node.callee;
845
+ if (!callee || callee.type !== "Identifier") return;
846
+ if (TIMER_FNS.has(callee.name)) context.report({
847
+ message: `\`${callee.name}()\` outside \`onMount\` — wrap in \`onMount(() => { ... return () => clear... })\` for automatic cleanup.`,
848
+ span: getSpan(node)
849
+ });
850
+ },
851
+ "CallExpression:exit"(node) {
852
+ if (isCallTo(node, "onMount")) mountDepth--;
853
+ }
854
+ };
855
+ }
856
+ };
857
+
858
+ //#endregion
859
+ //#region src/rules/jsx/no-and-conditional.ts
860
+ const noAndConditional = {
861
+ meta: {
862
+ id: "pyreon/no-and-conditional",
863
+ category: "jsx",
864
+ description: "Prefer <Show> over `&&` with JSX in expression containers.",
865
+ severity: "warn",
866
+ fixable: false
867
+ },
868
+ create(context) {
869
+ let jsxExpressionDepth = 0;
870
+ return {
871
+ JSXExpressionContainer() {
872
+ jsxExpressionDepth++;
873
+ },
874
+ "JSXExpressionContainer:exit"() {
875
+ jsxExpressionDepth--;
876
+ },
877
+ LogicalExpression(node) {
878
+ if (jsxExpressionDepth === 0) return;
879
+ if (!isLogicalAndWithJSX(node)) return;
880
+ context.report({
881
+ message: "`&&` with JSX — use `<Show>` for conditional rendering.",
882
+ span: getSpan(node)
883
+ });
884
+ }
885
+ };
886
+ }
887
+ };
888
+
889
+ //#endregion
890
+ //#region src/rules/jsx/no-children-access.ts
891
+ const noChildrenAccess = {
892
+ meta: {
893
+ id: "pyreon/no-children-access",
894
+ category: "jsx",
895
+ description: "Inform about direct props.children access in renderer files.",
896
+ severity: "info",
897
+ fixable: false
898
+ },
899
+ create(context) {
900
+ const imports = [];
901
+ let isRendererFile = false;
902
+ return {
903
+ ImportDeclaration(node) {
904
+ const info = extractImportInfo(node);
905
+ if (info) {
906
+ imports.push(info);
907
+ if (info.source === "@pyreon/runtime-server" || info.source === "@pyreon/runtime-dom") isRendererFile = true;
908
+ }
909
+ },
910
+ MemberExpression(node) {
911
+ if (!isRendererFile) return;
912
+ if (node.object?.type === "Identifier" && node.property?.type === "Identifier" && node.property.name === "children") context.report({
913
+ message: "Direct `props.children` access in a renderer file — children are already merged via `mergeChildrenIntoProps`.",
914
+ span: getSpan(node)
915
+ });
916
+ }
917
+ };
918
+ }
919
+ };
920
+
921
+ //#endregion
922
+ //#region src/rules/jsx/no-classname.ts
923
+ const noClassName = {
924
+ meta: {
925
+ id: "pyreon/no-classname",
926
+ category: "jsx",
927
+ description: "Use `class` instead of `className` — Pyreon uses standard HTML attributes.",
928
+ severity: "error",
929
+ fixable: true
930
+ },
931
+ create(context) {
932
+ return { JSXAttribute(node) {
933
+ if (node.name?.type !== "JSXIdentifier") return;
934
+ if (node.name.name !== "className") return;
935
+ const nameSpan = getSpan(node.name);
936
+ context.report({
937
+ message: "Use `class` instead of `className` — Pyreon uses standard HTML attributes.",
938
+ span: getSpan(node),
939
+ fix: {
940
+ span: nameSpan,
941
+ replacement: "class"
942
+ }
943
+ });
944
+ } };
945
+ }
946
+ };
947
+
948
+ //#endregion
949
+ //#region src/rules/jsx/no-htmlfor.ts
950
+ const noHtmlFor = {
951
+ meta: {
952
+ id: "pyreon/no-htmlfor",
953
+ category: "jsx",
954
+ description: "Use `for` instead of `htmlFor` — Pyreon uses standard HTML attributes.",
955
+ severity: "error",
956
+ fixable: true
957
+ },
958
+ create(context) {
959
+ return { JSXAttribute(node) {
960
+ if (node.name?.type !== "JSXIdentifier") return;
961
+ if (node.name.name !== "htmlFor") return;
962
+ const nameSpan = getSpan(node.name);
963
+ context.report({
964
+ message: "Use `for` instead of `htmlFor` — Pyreon uses standard HTML attributes.",
965
+ span: getSpan(node),
966
+ fix: {
967
+ span: nameSpan,
968
+ replacement: "for"
969
+ }
970
+ });
971
+ } };
972
+ }
973
+ };
974
+
975
+ //#endregion
976
+ //#region src/rules/jsx/no-index-as-by.ts
977
+ const noIndexAsBy = {
978
+ meta: {
979
+ id: "pyreon/no-index-as-by",
980
+ category: "jsx",
981
+ description: "Disallow using index as `by` prop on <For> — use a unique key instead.",
982
+ severity: "warn",
983
+ fixable: false
984
+ },
985
+ create(context) {
986
+ return { JSXOpeningElement(node) {
987
+ const name = node.name;
988
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
989
+ const byAttr = getJSXAttribute(node, "by");
990
+ if (!byAttr) return;
991
+ const value = byAttr.value;
992
+ if (!value || value.type !== "JSXExpressionContainer") return;
993
+ const expr = value.expression;
994
+ if (!expr) return;
995
+ if (expr.type === "ArrowFunctionExpression" || expr.type === "FunctionExpression") {
996
+ const params = expr.params;
997
+ if (!params || params.length < 2) return;
998
+ const secondParam = params[1];
999
+ if (!secondParam || secondParam.type !== "Identifier") return;
1000
+ const indexName = secondParam.name;
1001
+ const body = expr.body;
1002
+ if (body?.type === "Identifier" && body.name === indexName) context.report({
1003
+ message: "Using index as `by` prop on `<For>` — use a unique key from the data instead.",
1004
+ span: getSpan(byAttr)
1005
+ });
1006
+ if (body?.type === "BlockStatement") {
1007
+ const stmts = body.body;
1008
+ if (stmts?.length === 1) {
1009
+ const stmt = stmts[0];
1010
+ if (stmt.type === "ReturnStatement" && stmt.argument?.type === "Identifier" && stmt.argument.name === indexName) context.report({
1011
+ message: "Using index as `by` prop on `<For>` — use a unique key from the data instead.",
1012
+ span: getSpan(byAttr)
1013
+ });
1014
+ }
1015
+ }
1016
+ }
1017
+ } };
1018
+ }
1019
+ };
1020
+
1021
+ //#endregion
1022
+ //#region src/rules/jsx/no-map-in-jsx.ts
1023
+ const noMapInJsx = {
1024
+ meta: {
1025
+ id: "pyreon/no-map-in-jsx",
1026
+ category: "jsx",
1027
+ description: "Prefer <For> over .map() inside JSX for reactive list rendering.",
1028
+ severity: "warn",
1029
+ fixable: false
1030
+ },
1031
+ create(context) {
1032
+ let jsxDepth = 0;
1033
+ return {
1034
+ JSXElement() {
1035
+ jsxDepth++;
1036
+ },
1037
+ "JSXElement:exit"() {
1038
+ jsxDepth--;
1039
+ },
1040
+ JSXFragment() {
1041
+ jsxDepth++;
1042
+ },
1043
+ "JSXFragment:exit"() {
1044
+ jsxDepth--;
1045
+ },
1046
+ CallExpression(node) {
1047
+ if (jsxDepth === 0) return;
1048
+ if (!isArrayMapCall(node)) return;
1049
+ const args = node.arguments;
1050
+ if (!args || args.length === 0) return;
1051
+ if (!args[0]) return;
1052
+ context.report({
1053
+ message: "`.map()` in JSX — use `<For>` for reactive list rendering instead.",
1054
+ span: getSpan(node)
1055
+ });
1056
+ }
1057
+ };
1058
+ }
1059
+ };
1060
+
1061
+ //#endregion
1062
+ //#region src/rules/jsx/no-missing-for-by.ts
1063
+ const noMissingForBy = {
1064
+ meta: {
1065
+ id: "pyreon/no-missing-for-by",
1066
+ category: "jsx",
1067
+ description: "Warn when <For> is used without a `by` prop.",
1068
+ severity: "warn",
1069
+ fixable: false
1070
+ },
1071
+ create(context) {
1072
+ return { JSXOpeningElement(node) {
1073
+ const name = node.name;
1074
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
1075
+ if (hasJSXAttribute(node, "by")) return;
1076
+ context.report({
1077
+ message: "`<For>` without `by` prop — provide a key function for efficient reconciliation.",
1078
+ span: getSpan(node)
1079
+ });
1080
+ } };
1081
+ }
1082
+ };
1083
+
1084
+ //#endregion
1085
+ //#region src/rules/jsx/no-onchange.ts
1086
+ const INPUT_TAGS = new Set([
1087
+ "input",
1088
+ "textarea",
1089
+ "select"
1090
+ ]);
1091
+ const noOnChange = {
1092
+ meta: {
1093
+ id: "pyreon/no-onchange",
1094
+ category: "jsx",
1095
+ description: "Prefer `onInput` over `onChange` on input elements for keypress-by-keypress updates.",
1096
+ severity: "warn",
1097
+ fixable: true
1098
+ },
1099
+ create(context) {
1100
+ let currentTag = null;
1101
+ return { JSXOpeningElement(node) {
1102
+ const name = node.name;
1103
+ if (name?.type === "JSXIdentifier" && INPUT_TAGS.has(name.name)) currentTag = name.name;
1104
+ else currentTag = null;
1105
+ if (!currentTag) return;
1106
+ const attrs = node.attributes ?? [];
1107
+ for (const attr of attrs) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === "onChange") {
1108
+ const nameSpan = getSpan(attr.name);
1109
+ context.report({
1110
+ message: `Use \`onInput\` instead of \`onChange\` on \`<${currentTag}>\` for keypress-by-keypress updates.`,
1111
+ span: getSpan(attr),
1112
+ fix: {
1113
+ span: nameSpan,
1114
+ replacement: "onInput"
1115
+ }
1116
+ });
1117
+ }
1118
+ } };
1119
+ }
1120
+ };
1121
+
1122
+ //#endregion
1123
+ //#region src/rules/jsx/no-props-destructure.ts
1124
+ function containsJSXReturn(node) {
1125
+ if (!node) return false;
1126
+ if (node.type === "JSXElement" || node.type === "JSXFragment") return true;
1127
+ if (node.type === "ParenthesizedExpression") return containsJSXReturn(node.expression);
1128
+ if (node.type === "BlockStatement") {
1129
+ for (const stmt of node.body ?? []) if (stmt.type === "ReturnStatement" && containsJSXReturn(stmt.argument)) return true;
1130
+ }
1131
+ return false;
1132
+ }
1133
+ const noPropsDestructure = {
1134
+ meta: {
1135
+ id: "pyreon/no-props-destructure",
1136
+ category: "jsx",
1137
+ description: "Disallow destructuring props in component functions — it breaks signal reactivity.",
1138
+ severity: "error",
1139
+ fixable: false
1140
+ },
1141
+ create(context) {
1142
+ return {
1143
+ ArrowFunctionExpression(node) {
1144
+ checkFunction(node, context);
1145
+ },
1146
+ FunctionDeclaration(node) {
1147
+ checkFunction(node, context);
1148
+ },
1149
+ FunctionExpression(node) {
1150
+ checkFunction(node, context);
1151
+ }
1152
+ };
1153
+ }
1154
+ };
1155
+ function checkFunction(node, context) {
1156
+ const params = node.params;
1157
+ if (!params || params.length === 0) return;
1158
+ const firstParam = params[0];
1159
+ if (!isDestructuring(firstParam)) return;
1160
+ const body = node.body;
1161
+ if (!body) return;
1162
+ if (containsJSXReturn(body)) context.report({
1163
+ message: "Destructured props in a component function — this breaks signal reactivity. Use `props.x` or `splitProps()` instead.",
1164
+ span: getSpan(firstParam)
1165
+ });
1166
+ }
1167
+
1168
+ //#endregion
1169
+ //#region src/rules/jsx/no-ternary-conditional.ts
1170
+ const noTernaryConditional = {
1171
+ meta: {
1172
+ id: "pyreon/no-ternary-conditional",
1173
+ category: "jsx",
1174
+ description: "Prefer <Show> over ternary expressions with JSX branches.",
1175
+ severity: "warn",
1176
+ fixable: false
1177
+ },
1178
+ create(context) {
1179
+ let jsxExpressionDepth = 0;
1180
+ return {
1181
+ JSXExpressionContainer() {
1182
+ jsxExpressionDepth++;
1183
+ },
1184
+ "JSXExpressionContainer:exit"() {
1185
+ jsxExpressionDepth--;
1186
+ },
1187
+ ConditionalExpression(node) {
1188
+ if (jsxExpressionDepth === 0) return;
1189
+ if (!isTernaryWithJSX(node)) return;
1190
+ context.report({
1191
+ message: "Ternary with JSX — use `<Show>` for more efficient conditional rendering.",
1192
+ span: getSpan(node)
1193
+ });
1194
+ }
1195
+ };
1196
+ }
1197
+ };
1198
+
1199
+ //#endregion
1200
+ //#region src/rules/jsx/use-by-not-key.ts
1201
+ const useByNotKey = {
1202
+ meta: {
1203
+ id: "pyreon/use-by-not-key",
1204
+ category: "jsx",
1205
+ description: "Use `by` prop on <For> instead of `key` — JSX reserves `key` for VNode reconciliation.",
1206
+ severity: "error",
1207
+ fixable: true
1208
+ },
1209
+ create(context) {
1210
+ return { JSXOpeningElement(node) {
1211
+ if ((node.name?.type === "JSXIdentifier" ? node.name.name : null) !== "For") return;
1212
+ const keyAttr = getJSXAttribute(node, "key");
1213
+ if (!keyAttr) return;
1214
+ if (hasJSXAttribute(node, "by")) return;
1215
+ const attrSpan = getSpan(keyAttr.name);
1216
+ context.report({
1217
+ message: "Use `by` prop on `<For>` instead of `key` — JSX reserves `key` for VNode reconciliation.",
1218
+ span: getSpan(keyAttr),
1219
+ fix: {
1220
+ span: attrSpan,
1221
+ replacement: "by"
1222
+ }
1223
+ });
1224
+ } };
1225
+ }
1226
+ };
1227
+
1228
+ //#endregion
1229
+ //#region src/rules/lifecycle/no-dom-in-setup.ts
1230
+ const DOM_METHODS = new Set([
1231
+ "querySelector",
1232
+ "querySelectorAll",
1233
+ "getElementById",
1234
+ "getElementsByClassName",
1235
+ "getElementsByTagName"
1236
+ ]);
1237
+ const noDomInSetup = {
1238
+ meta: {
1239
+ id: "pyreon/no-dom-in-setup",
1240
+ category: "lifecycle",
1241
+ description: "Warn when DOM query methods are used outside onMount or effect.",
1242
+ severity: "warn",
1243
+ fixable: false
1244
+ },
1245
+ create(context) {
1246
+ let safeDepth = 0;
1247
+ return {
1248
+ CallExpression(node) {
1249
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
1250
+ if (safeDepth > 0) return;
1251
+ const callee = node.callee;
1252
+ if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "document" && callee.property?.type === "Identifier" && DOM_METHODS.has(callee.property.name)) context.report({
1253
+ message: `\`document.${callee.property.name}()\` outside \`onMount\`/\`effect\` — DOM is not available during SSR or setup phase.`,
1254
+ span: getSpan(node)
1255
+ });
1256
+ },
1257
+ "CallExpression:exit"(node) {
1258
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
1259
+ }
1260
+ };
1261
+ }
1262
+ };
1263
+
1264
+ //#endregion
1265
+ //#region src/rules/lifecycle/no-effect-in-mount.ts
1266
+ const noEffectInMount = {
1267
+ meta: {
1268
+ id: "pyreon/no-effect-in-mount",
1269
+ category: "lifecycle",
1270
+ description: "Inform when effect() is created inside onMount — effects are typically created at setup time.",
1271
+ severity: "info",
1272
+ fixable: false
1273
+ },
1274
+ create(context) {
1275
+ let mountDepth = 0;
1276
+ return {
1277
+ CallExpression(node) {
1278
+ if (isCallTo(node, "onMount")) mountDepth++;
1279
+ if (mountDepth > 0 && isCallTo(node, "effect")) context.report({
1280
+ message: "`effect()` inside `onMount` — effects are typically created at component setup time, not inside lifecycle hooks.",
1281
+ span: getSpan(node)
1282
+ });
1283
+ },
1284
+ "CallExpression:exit"(node) {
1285
+ if (isCallTo(node, "onMount")) mountDepth--;
1286
+ }
1287
+ };
1288
+ }
1289
+ };
1290
+
1291
+ //#endregion
1292
+ //#region src/rules/lifecycle/no-missing-cleanup.ts
1293
+ const NEEDS_CLEANUP = new Set(["setInterval", "addEventListener"]);
1294
+ const noMissingCleanup = {
1295
+ meta: {
1296
+ id: "pyreon/no-missing-cleanup",
1297
+ category: "lifecycle",
1298
+ description: "Warn when onMount uses setInterval/addEventListener without returning a cleanup function.",
1299
+ severity: "warn",
1300
+ fixable: false
1301
+ },
1302
+ create(context) {
1303
+ return { CallExpression(node) {
1304
+ if (!isCallTo(node, "onMount")) return;
1305
+ const args = node.arguments;
1306
+ if (!args || args.length === 0) return;
1307
+ const fn = args[0];
1308
+ if (!fn) return;
1309
+ if (fn.type !== "ArrowFunctionExpression" && fn.type !== "FunctionExpression") return;
1310
+ const body = fn.body;
1311
+ if (!body) return;
1312
+ if (body.type !== "BlockStatement") return;
1313
+ let hasCleanupTarget = false;
1314
+ let hasReturn = false;
1315
+ function walk(n) {
1316
+ if (!n) return;
1317
+ if (n.type === "CallExpression") {
1318
+ const callee = n.callee;
1319
+ if (callee?.type === "Identifier" && NEEDS_CLEANUP.has(callee.name)) hasCleanupTarget = true;
1320
+ if (callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && NEEDS_CLEANUP.has(callee.property.name)) hasCleanupTarget = true;
1321
+ }
1322
+ if (n.type === "ReturnStatement" && n.argument) hasReturn = true;
1323
+ for (const key of Object.keys(n)) {
1324
+ const child = n[key];
1325
+ if (child && typeof child === "object") {
1326
+ if (Array.isArray(child)) {
1327
+ for (const item of child) if (item && typeof item.type === "string") walk(item);
1328
+ } else if (typeof child.type === "string") walk(child);
1329
+ }
1330
+ }
1331
+ }
1332
+ walk(body);
1333
+ if (hasCleanupTarget && !hasReturn) context.report({
1334
+ message: "`onMount` uses `setInterval`/`addEventListener` without returning a cleanup function — this will cause a memory leak.",
1335
+ span: getSpan(node)
1336
+ });
1337
+ } };
1338
+ }
1339
+ };
1340
+
1341
+ //#endregion
1342
+ //#region src/rules/lifecycle/no-mount-in-effect.ts
1343
+ const noMountInEffect = {
1344
+ meta: {
1345
+ id: "pyreon/no-mount-in-effect",
1346
+ category: "lifecycle",
1347
+ description: "Warn when onMount is called inside effect().",
1348
+ severity: "warn",
1349
+ fixable: false
1350
+ },
1351
+ create(context) {
1352
+ let effectDepth = 0;
1353
+ return {
1354
+ CallExpression(node) {
1355
+ if (isCallTo(node, "effect")) effectDepth++;
1356
+ if (effectDepth > 0 && isCallTo(node, "onMount")) context.report({
1357
+ message: "`onMount` inside `effect()` — `onMount` runs once on mount, not on every effect re-run.",
1358
+ span: getSpan(node)
1359
+ });
1360
+ },
1361
+ "CallExpression:exit"(node) {
1362
+ if (isCallTo(node, "effect")) effectDepth--;
1363
+ }
1364
+ };
1365
+ }
1366
+ };
1367
+
1368
+ //#endregion
1369
+ //#region src/rules/performance/no-eager-import.ts
1370
+ const noEagerImport = {
1371
+ meta: {
1372
+ id: "pyreon/no-eager-import",
1373
+ category: "performance",
1374
+ description: "Suggest lazy-loading heavy Pyreon packages (charts, code, document, flow).",
1375
+ severity: "info",
1376
+ fixable: false
1377
+ },
1378
+ create(context) {
1379
+ return { ImportDeclaration(node) {
1380
+ const source = node.source?.value;
1381
+ if (!source) return;
1382
+ if (HEAVY_PACKAGES.has(source)) context.report({
1383
+ message: `Static import of \`${source}\` — consider using \`lazy()\` or dynamic \`import()\` to reduce initial bundle size.`,
1384
+ span: getSpan(node)
1385
+ });
1386
+ } };
1387
+ }
1388
+ };
1389
+
1390
+ //#endregion
1391
+ //#region src/rules/performance/no-effect-in-for.ts
1392
+ const noEffectInFor = {
1393
+ meta: {
1394
+ id: "pyreon/no-effect-in-for",
1395
+ category: "performance",
1396
+ description: "Warn when effect() is created inside <For> — creates effects per item on every reconciliation.",
1397
+ severity: "warn",
1398
+ fixable: false
1399
+ },
1400
+ create(context) {
1401
+ let forJsxDepth = 0;
1402
+ return {
1403
+ JSXOpeningElement(node) {
1404
+ const name = node.name;
1405
+ if (name?.type === "JSXIdentifier" && name.name === "For") forJsxDepth++;
1406
+ },
1407
+ JSXClosingElement(node) {
1408
+ const name = node.name;
1409
+ if (name?.type === "JSXIdentifier" && name.name === "For") forJsxDepth--;
1410
+ },
1411
+ CallExpression(node) {
1412
+ if (forJsxDepth === 0) return;
1413
+ if (isCallTo(node, "effect")) context.report({
1414
+ message: "`effect()` inside `<For>` — this creates a new effect for every item on each reconciliation. Lift the effect outside.",
1415
+ span: getSpan(node)
1416
+ });
1417
+ }
1418
+ };
1419
+ }
1420
+ };
1421
+
1422
+ //#endregion
1423
+ //#region src/rules/performance/no-large-for-without-by.ts
1424
+ const noLargeForWithoutBy = {
1425
+ meta: {
1426
+ id: "pyreon/no-large-for-without-by",
1427
+ category: "performance",
1428
+ description: "Error when <For> is used without a `by` prop — critical for reconciliation performance.",
1429
+ severity: "error",
1430
+ fixable: false
1431
+ },
1432
+ create(context) {
1433
+ return { JSXOpeningElement(node) {
1434
+ const name = node.name;
1435
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "For") return;
1436
+ if (hasJSXAttribute(node, "by")) return;
1437
+ context.report({
1438
+ message: "`<For>` without `by` prop — provide a key function for efficient reconciliation.",
1439
+ span: getSpan(node)
1440
+ });
1441
+ } };
1442
+ }
1443
+ };
1444
+
1445
+ //#endregion
1446
+ //#region src/rules/performance/prefer-show-over-display.ts
1447
+ const preferShowOverDisplay = {
1448
+ meta: {
1449
+ id: "pyreon/prefer-show-over-display",
1450
+ category: "performance",
1451
+ description: "Suggest <Show> over conditional `display` style property in JSX.",
1452
+ severity: "info",
1453
+ fixable: false
1454
+ },
1455
+ create(context) {
1456
+ return { JSXAttribute(node) {
1457
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
1458
+ const value = node.value;
1459
+ if (!value || value.type !== "JSXExpressionContainer") return;
1460
+ const expr = value.expression;
1461
+ if (!expr || expr.type !== "ObjectExpression") return;
1462
+ for (const prop of expr.properties ?? []) {
1463
+ if (prop.type !== "Property") continue;
1464
+ const key = prop.key;
1465
+ if (!key) continue;
1466
+ if ((key.type === "Identifier" ? key.name : key.type === "Literal" ? key.value : null) === "display") {
1467
+ const val = prop.value;
1468
+ if (val?.type === "ConditionalExpression" || val?.type === "LogicalExpression" || val?.type === "CallExpression") context.report({
1469
+ message: "Conditional `display` style — consider using `<Show>` for conditional rendering instead of toggling CSS display.",
1470
+ span: getSpan(prop)
1471
+ });
1472
+ }
1473
+ }
1474
+ } };
1475
+ }
1476
+ };
1477
+
1478
+ //#endregion
1479
+ //#region src/rules/reactivity/no-bare-signal-in-jsx.ts
1480
+ const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/;
1481
+ const noBareSignalInJsx = {
1482
+ meta: {
1483
+ id: "pyreon/no-bare-signal-in-jsx",
1484
+ category: "reactivity",
1485
+ description: "Disallow bare signal calls in JSX text positions. Wrap in `() =>` for reactivity.",
1486
+ severity: "error",
1487
+ fixable: true
1488
+ },
1489
+ create(context) {
1490
+ let jsxDepth = 0;
1491
+ return {
1492
+ JSXElement() {
1493
+ jsxDepth++;
1494
+ },
1495
+ "JSXElement:exit"() {
1496
+ jsxDepth--;
1497
+ },
1498
+ JSXFragment() {
1499
+ jsxDepth++;
1500
+ },
1501
+ "JSXFragment:exit"() {
1502
+ jsxDepth--;
1503
+ },
1504
+ JSXExpressionContainer(node) {
1505
+ if (jsxDepth === 0) return;
1506
+ const expr = node.expression;
1507
+ if (!expr || expr.type !== "CallExpression") return;
1508
+ const callee = expr.callee;
1509
+ if (!callee || callee.type !== "Identifier") return;
1510
+ const name = callee.name;
1511
+ if (SKIP_PREFIXES.test(name)) return;
1512
+ const span = getSpan(node);
1513
+ const fixed = `{() => ${context.getSourceText().slice(span.start, span.end).slice(1, -1)}}`;
1514
+ context.report({
1515
+ message: `Bare signal call \`${name}()\` in JSX text — wrap in \`() => ${name}()\` for fine-grained reactivity.`,
1516
+ span,
1517
+ fix: {
1518
+ span,
1519
+ replacement: fixed
1520
+ }
1521
+ });
1522
+ }
1523
+ };
1524
+ }
1525
+ };
1526
+
1527
+ //#endregion
1528
+ //#region src/rules/reactivity/no-effect-assignment.ts
1529
+ function isUpdateCall(node) {
1530
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "update";
1531
+ }
1532
+ const noEffectAssignment = {
1533
+ meta: {
1534
+ id: "pyreon/no-effect-assignment",
1535
+ category: "reactivity",
1536
+ description: "Warn when an effect only contains a single .update() call.",
1537
+ severity: "warn",
1538
+ fixable: false
1539
+ },
1540
+ create(context) {
1541
+ return { CallExpression(node) {
1542
+ if (!isCallTo(node, "effect")) return;
1543
+ const args = node.arguments;
1544
+ if (!args || args.length === 0) return;
1545
+ const fn = args[0];
1546
+ if (!fn) return;
1547
+ let body = null;
1548
+ if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
1549
+ if (!body) return;
1550
+ if (isUpdateCall(body)) {
1551
+ context.report({
1552
+ message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
1553
+ span: getSpan(node)
1554
+ });
1555
+ return;
1556
+ }
1557
+ if (body.type === "BlockStatement") {
1558
+ const stmts = body.body;
1559
+ if (stmts && stmts.length === 1) {
1560
+ const stmt = stmts[0];
1561
+ if (stmt.type === "ExpressionStatement" && isUpdateCall(stmt.expression)) context.report({
1562
+ message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
1563
+ span: getSpan(node)
1564
+ });
1565
+ }
1566
+ }
1567
+ } };
1568
+ }
1569
+ };
1570
+
1571
+ //#endregion
1572
+ //#region src/rules/reactivity/no-nested-effect.ts
1573
+ const noNestedEffect = {
1574
+ meta: {
1575
+ id: "pyreon/no-nested-effect",
1576
+ category: "reactivity",
1577
+ description: "Warn against nesting effect() inside another effect().",
1578
+ severity: "warn",
1579
+ fixable: false
1580
+ },
1581
+ create(context) {
1582
+ let effectDepth = 0;
1583
+ return {
1584
+ CallExpression(node) {
1585
+ if (!isCallTo(node, "effect")) return;
1586
+ if (effectDepth > 0) context.report({
1587
+ message: "Nested `effect()` — consider using `computed()` for derived values instead.",
1588
+ span: getSpan(node)
1589
+ });
1590
+ effectDepth++;
1591
+ },
1592
+ "CallExpression:exit"(node) {
1593
+ if (isCallTo(node, "effect")) effectDepth--;
1594
+ }
1595
+ };
1596
+ }
1597
+ };
1598
+
1599
+ //#endregion
1600
+ //#region src/rules/reactivity/no-peek-in-tracked.ts
1601
+ const noPeekInTracked = {
1602
+ meta: {
1603
+ id: "pyreon/no-peek-in-tracked",
1604
+ category: "reactivity",
1605
+ description: "Disallow .peek() inside effect() or computed() — it bypasses tracking.",
1606
+ severity: "error",
1607
+ fixable: false
1608
+ },
1609
+ create(context) {
1610
+ let trackedDepth = 0;
1611
+ return {
1612
+ CallExpression(node) {
1613
+ if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth++;
1614
+ if (trackedDepth > 0 && isPeekCall(node)) context.report({
1615
+ message: "`.peek()` inside a tracked scope (effect/computed) bypasses dependency tracking — use a normal signal read instead.",
1616
+ span: getSpan(node)
1617
+ });
1618
+ },
1619
+ "CallExpression:exit"(node) {
1620
+ if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth--;
1621
+ }
1622
+ };
1623
+ }
1624
+ };
1625
+
1626
+ //#endregion
1627
+ //#region src/rules/reactivity/no-signal-in-loop.ts
1628
+ const noSignalInLoop = {
1629
+ meta: {
1630
+ id: "pyreon/no-signal-in-loop",
1631
+ category: "reactivity",
1632
+ description: "Disallow creating signals or computeds inside loops.",
1633
+ severity: "error",
1634
+ fixable: false
1635
+ },
1636
+ create(context) {
1637
+ let loopDepth = 0;
1638
+ return {
1639
+ ForStatement() {
1640
+ loopDepth++;
1641
+ },
1642
+ "ForStatement:exit"() {
1643
+ loopDepth--;
1644
+ },
1645
+ ForInStatement() {
1646
+ loopDepth++;
1647
+ },
1648
+ "ForInStatement:exit"() {
1649
+ loopDepth--;
1650
+ },
1651
+ ForOfStatement() {
1652
+ loopDepth++;
1653
+ },
1654
+ "ForOfStatement:exit"() {
1655
+ loopDepth--;
1656
+ },
1657
+ WhileStatement() {
1658
+ loopDepth++;
1659
+ },
1660
+ "WhileStatement:exit"() {
1661
+ loopDepth--;
1662
+ },
1663
+ DoWhileStatement() {
1664
+ loopDepth++;
1665
+ },
1666
+ "DoWhileStatement:exit"() {
1667
+ loopDepth--;
1668
+ },
1669
+ CallExpression(node) {
1670
+ if (loopDepth === 0) return;
1671
+ const callee = node.callee;
1672
+ if (!callee || callee.type !== "Identifier") return;
1673
+ if (callee.name === "signal" || callee.name === "computed") context.report({
1674
+ message: `\`${callee.name}()\` inside a loop — signals should be created once at component setup, not on every iteration.`,
1675
+ span: getSpan(node)
1676
+ });
1677
+ }
1678
+ };
1679
+ }
1680
+ };
1681
+
1682
+ //#endregion
1683
+ //#region src/rules/reactivity/no-signal-leak.ts
1684
+ const noSignalLeak = {
1685
+ meta: {
1686
+ id: "pyreon/no-signal-leak",
1687
+ category: "reactivity",
1688
+ description: "Warn about unused signal declarations (potential leaks).",
1689
+ severity: "warn",
1690
+ fixable: false
1691
+ },
1692
+ create(context) {
1693
+ const signalDecls = /* @__PURE__ */ new Map();
1694
+ const identifierOccurrences = /* @__PURE__ */ new Map();
1695
+ return {
1696
+ VariableDeclarator(node) {
1697
+ const init = node.init;
1698
+ if (!init || !isCallTo(init, "signal")) return;
1699
+ const id = node.id;
1700
+ if (!id || id.type !== "Identifier") return;
1701
+ signalDecls.set(id.name, {
1702
+ span: getSpan(node),
1703
+ declStart: id.start,
1704
+ declEnd: id.end
1705
+ });
1706
+ },
1707
+ Identifier(node) {
1708
+ const name = node.name;
1709
+ const existing = identifierOccurrences.get(name);
1710
+ if (existing) existing.push({
1711
+ start: node.start,
1712
+ end: node.end
1713
+ });
1714
+ else identifierOccurrences.set(name, [{
1715
+ start: node.start,
1716
+ end: node.end
1717
+ }]);
1718
+ },
1719
+ "Program:exit"() {
1720
+ for (const [name, { span, declStart, declEnd }] of signalDecls) if ((identifierOccurrences.get(name) ?? []).filter((o) => o.start !== declStart || o.end !== declEnd).length === 0) context.report({
1721
+ message: `Signal \`${name}\` is declared but never used — this may be a signal leak.`,
1722
+ span
1723
+ });
1724
+ }
1725
+ };
1726
+ }
1727
+ };
1728
+
1729
+ //#endregion
1730
+ //#region src/rules/reactivity/no-unbatched-updates.ts
1731
+ const noUnbatchedUpdates = {
1732
+ meta: {
1733
+ id: "pyreon/no-unbatched-updates",
1734
+ category: "reactivity",
1735
+ description: "Warn when 3+ .set() calls occur in the same function without batch().",
1736
+ severity: "warn",
1737
+ fixable: false
1738
+ },
1739
+ create(context) {
1740
+ const scopeStack = [];
1741
+ let batchDepth = 0;
1742
+ function enterScope(node) {
1743
+ scopeStack.push({
1744
+ setCalls: [],
1745
+ hasBatch: false,
1746
+ insideBatch: batchDepth > 0,
1747
+ node
1748
+ });
1749
+ }
1750
+ function exitScope() {
1751
+ const scope = scopeStack.pop();
1752
+ if (!scope) return;
1753
+ if (!scope.hasBatch && !scope.insideBatch && scope.setCalls.length >= 3) context.report({
1754
+ message: `${scope.setCalls.length} signal \`.set()\` calls without \`batch()\` — wrap in \`batch(() => { ... })\` to avoid unnecessary re-renders.`,
1755
+ span: getSpan(scope.node)
1756
+ });
1757
+ }
1758
+ return {
1759
+ FunctionDeclaration(node) {
1760
+ enterScope(node);
1761
+ },
1762
+ "FunctionDeclaration:exit"() {
1763
+ exitScope();
1764
+ },
1765
+ FunctionExpression(node) {
1766
+ enterScope(node);
1767
+ },
1768
+ "FunctionExpression:exit"() {
1769
+ exitScope();
1770
+ },
1771
+ ArrowFunctionExpression(node) {
1772
+ enterScope(node);
1773
+ },
1774
+ "ArrowFunctionExpression:exit"() {
1775
+ exitScope();
1776
+ },
1777
+ CallExpression(node) {
1778
+ const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : void 0;
1779
+ if (isCallTo(node, "batch")) {
1780
+ batchDepth++;
1781
+ if (currentScope) currentScope.hasBatch = true;
1782
+ }
1783
+ if (currentScope && isSetCall(node)) currentScope.setCalls.push({ span: getSpan(node) });
1784
+ },
1785
+ "CallExpression:exit"(node) {
1786
+ if (isCallTo(node, "batch")) batchDepth--;
1787
+ }
1788
+ };
1789
+ }
1790
+ };
1791
+
1792
+ //#endregion
1793
+ //#region src/rules/reactivity/prefer-computed.ts
1794
+ const preferComputed = {
1795
+ meta: {
1796
+ id: "pyreon/prefer-computed",
1797
+ category: "reactivity",
1798
+ description: "Suggest computed() when an effect only contains a single .set() call.",
1799
+ severity: "warn",
1800
+ fixable: false
1801
+ },
1802
+ create(context) {
1803
+ return { CallExpression(node) {
1804
+ if (!isCallTo(node, "effect")) return;
1805
+ const args = node.arguments;
1806
+ if (!args || args.length === 0) return;
1807
+ const fn = args[0];
1808
+ if (!fn) return;
1809
+ let body = null;
1810
+ if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
1811
+ if (!body) return;
1812
+ if (body.type === "CallExpression" && isSetCall(body)) {
1813
+ context.report({
1814
+ message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
1815
+ span: getSpan(node)
1816
+ });
1817
+ return;
1818
+ }
1819
+ if (body.type === "BlockStatement") {
1820
+ const stmts = body.body;
1821
+ if (stmts && stmts.length === 1) {
1822
+ const stmt = stmts[0];
1823
+ if (stmt.type === "ExpressionStatement" && isSetCall(stmt.expression)) context.report({
1824
+ message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
1825
+ span: getSpan(node)
1826
+ });
1827
+ }
1828
+ }
1829
+ } };
1830
+ }
1831
+ };
1832
+
1833
+ //#endregion
1834
+ //#region src/rules/router/no-href-navigation.ts
1835
+ const EXTERNAL_PREFIXES = [
1836
+ "http://",
1837
+ "https://",
1838
+ "mailto:",
1839
+ "tel:"
1840
+ ];
1841
+ const noHrefNavigation = {
1842
+ meta: {
1843
+ id: "pyreon/no-href-navigation",
1844
+ category: "router",
1845
+ description: "Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.",
1846
+ severity: "warn",
1847
+ fixable: false
1848
+ },
1849
+ create(context) {
1850
+ let importsRouter = false;
1851
+ return {
1852
+ ImportDeclaration(node) {
1853
+ const info = extractImportInfo(node);
1854
+ if (info && info.source === "@pyreon/router") importsRouter = true;
1855
+ },
1856
+ JSXOpeningElement(node) {
1857
+ if (!importsRouter) return;
1858
+ const name = node.name;
1859
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "a") return;
1860
+ const hrefAttr = getJSXAttribute(node, "href");
1861
+ if (!hrefAttr) return;
1862
+ const value = hrefAttr.value;
1863
+ if (value?.type === "Literal" && typeof value.value === "string") {
1864
+ const href = value.value;
1865
+ if (href.startsWith("#") || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return;
1866
+ }
1867
+ context.report({
1868
+ message: "`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.",
1869
+ span: getSpan(node)
1870
+ });
1871
+ }
1872
+ };
1873
+ }
1874
+ };
1875
+
1876
+ //#endregion
1877
+ //#region src/rules/router/no-imperative-navigate-in-render.ts
1878
+ const noImperativeNavigateInRender = {
1879
+ meta: {
1880
+ id: "pyreon/no-imperative-navigate-in-render",
1881
+ category: "router",
1882
+ description: "Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.",
1883
+ severity: "error",
1884
+ fixable: false
1885
+ },
1886
+ create(context) {
1887
+ let componentBodyDepth = 0;
1888
+ let safeDepth = 0;
1889
+ return {
1890
+ FunctionDeclaration(node) {
1891
+ const name = node.id?.name ?? "";
1892
+ if (/^[A-Z]/.test(name)) componentBodyDepth++;
1893
+ },
1894
+ "FunctionDeclaration:exit"(node) {
1895
+ const name = node.id?.name ?? "";
1896
+ if (/^[A-Z]/.test(name)) componentBodyDepth--;
1897
+ },
1898
+ VariableDeclarator(node) {
1899
+ const name = node.id?.name ?? "";
1900
+ if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth++;
1901
+ },
1902
+ "VariableDeclarator:exit"(node) {
1903
+ const name = node.id?.name ?? "";
1904
+ if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth--;
1905
+ },
1906
+ CallExpression(node) {
1907
+ if (componentBodyDepth <= 0) return;
1908
+ if (isSafeWrapperCall(node)) safeDepth++;
1909
+ if (safeDepth > 0) return;
1910
+ if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) context.report({
1911
+ message: "Imperative navigation at the top level of a component — this runs on every render and causes infinite loops. Move inside `onMount`, `effect`, or an event handler.",
1912
+ span: getSpan(node)
1913
+ });
1914
+ },
1915
+ "CallExpression:exit"(node) {
1916
+ if (componentBodyDepth <= 0) return;
1917
+ if (isSafeWrapperCall(node)) safeDepth--;
1918
+ }
1919
+ };
1920
+ }
1921
+ };
1922
+ function isSafeWrapperCall(node) {
1923
+ const callee = node.callee;
1924
+ if (!callee || callee.type !== "Identifier") return false;
1925
+ const name = callee.name;
1926
+ return name === "onMount" || name === "effect" || name === "onUnmount";
1927
+ }
1928
+
1929
+ //#endregion
1930
+ //#region src/rules/router/no-missing-fallback.ts
1931
+ function isCatchAllPath(value) {
1932
+ return value === "*" || value.endsWith("*");
1933
+ }
1934
+ function getPathValue(prop) {
1935
+ const key = prop.key;
1936
+ if (!key) return null;
1937
+ if ((key.type === "Identifier" ? key.name : null) !== "path") return null;
1938
+ const val = prop.value;
1939
+ if (val?.type === "Literal" && typeof val.value === "string") return val.value;
1940
+ return null;
1941
+ }
1942
+ function hasPathProperty(obj) {
1943
+ if (!obj || obj.type !== "ObjectExpression") return false;
1944
+ for (const prop of obj.properties ?? []) {
1945
+ if (prop.type !== "Property") continue;
1946
+ if (getPathValue(prop) !== null) return true;
1947
+ }
1948
+ return false;
1949
+ }
1950
+ function hasCatchAllRoute(elements) {
1951
+ for (const elem of elements) {
1952
+ if (!elem || elem.type !== "ObjectExpression") continue;
1953
+ for (const prop of elem.properties ?? []) {
1954
+ if (prop.type !== "Property") continue;
1955
+ const pathVal = getPathValue(prop);
1956
+ if (pathVal !== null && isCatchAllPath(pathVal)) return true;
1957
+ }
1958
+ }
1959
+ return false;
1960
+ }
1961
+ const noMissingFallback = {
1962
+ meta: {
1963
+ id: "pyreon/no-missing-fallback",
1964
+ category: "router",
1965
+ description: "Warn when route config has no catch-all route (`path: \"*\"` or `path: \"/:rest*\"`).",
1966
+ severity: "warn",
1967
+ fixable: false
1968
+ },
1969
+ create(context) {
1970
+ let importsRouter = false;
1971
+ let routeArraySpan = null;
1972
+ let foundCatchAll = false;
1973
+ return {
1974
+ ImportDeclaration(node) {
1975
+ const info = extractImportInfo(node);
1976
+ if (info && info.source === "@pyreon/router") importsRouter = true;
1977
+ },
1978
+ ArrayExpression(node) {
1979
+ if (!importsRouter) return;
1980
+ const elements = node.elements ?? [];
1981
+ if (!elements.some((e) => hasPathProperty(e))) return;
1982
+ if (!routeArraySpan) routeArraySpan = getSpan(node);
1983
+ if (hasCatchAllRoute(elements)) foundCatchAll = true;
1984
+ },
1985
+ "Program:exit"() {
1986
+ if (!importsRouter || !routeArraySpan || foundCatchAll) return;
1987
+ context.report({
1988
+ message: "Route config has no catch-all route — add a `{ path: \"*\", component: NotFound }` for unmatched URLs.",
1989
+ span: routeArraySpan
1990
+ });
1991
+ }
1992
+ };
1993
+ }
1994
+ };
1995
+
1996
+ //#endregion
1997
+ //#region src/rules/router/prefer-use-is-active.ts
1998
+ const preferUseIsActive = {
1999
+ meta: {
2000
+ id: "pyreon/prefer-use-is-active",
2001
+ category: "router",
2002
+ description: "Suggest useIsActive() instead of `location.pathname === \"/foo\"` or `route.path === \"/foo\"` patterns.",
2003
+ severity: "info",
2004
+ fixable: false
2005
+ },
2006
+ create(context) {
2007
+ return { BinaryExpression(node) {
2008
+ if (node.operator !== "===" && node.operator !== "==") return;
2009
+ if (isPathComparison(node.left) || isPathComparison(node.right)) context.report({
2010
+ message: "Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.",
2011
+ span: getSpan(node)
2012
+ });
2013
+ } };
2014
+ }
2015
+ };
2016
+ function isPathComparison(node) {
2017
+ if (!node || node.type !== "MemberExpression") return false;
2018
+ const obj = node.object;
2019
+ const prop = node.property;
2020
+ if (!obj || !prop || prop.type !== "Identifier") return false;
2021
+ if (obj.type === "Identifier" && obj.name === "location" && prop.name === "pathname") return true;
2022
+ if (obj.type === "Identifier" && obj.name === "route" && prop.name === "path") return true;
2023
+ return false;
2024
+ }
2025
+
2026
+ //#endregion
2027
+ //#region src/rules/ssr/no-mismatch-risk.ts
2028
+ const noMismatchRisk = {
2029
+ meta: {
2030
+ id: "pyreon/no-mismatch-risk",
2031
+ category: "ssr",
2032
+ description: "Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.",
2033
+ severity: "warn",
2034
+ fixable: false
2035
+ },
2036
+ create(context) {
2037
+ let jsxDepth = 0;
2038
+ return {
2039
+ JSXElement() {
2040
+ jsxDepth++;
2041
+ },
2042
+ "JSXElement:exit"() {
2043
+ jsxDepth--;
2044
+ },
2045
+ JSXFragment() {
2046
+ jsxDepth++;
2047
+ },
2048
+ "JSXFragment:exit"() {
2049
+ jsxDepth--;
2050
+ },
2051
+ CallExpression(node) {
2052
+ if (jsxDepth === 0) return;
2053
+ if (isMemberCallTo(node, "Date", "now") || isMemberCallTo(node, "Math", "random") || isMemberCallTo(node, "crypto", "randomUUID")) {
2054
+ const callee = node.callee;
2055
+ const name = `${callee.object.name}.${callee.property.name}`;
2056
+ context.report({
2057
+ message: `\`${name}()\` in JSX context — this produces different values on server and client, causing hydration mismatches.`,
2058
+ span: getSpan(node)
2059
+ });
2060
+ }
2061
+ }
2062
+ };
2063
+ }
2064
+ };
2065
+
2066
+ //#endregion
2067
+ //#region src/rules/ssr/no-window-in-ssr.ts
2068
+ const noWindowInSsr = {
2069
+ meta: {
2070
+ id: "pyreon/no-window-in-ssr",
2071
+ category: "ssr",
2072
+ description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
2073
+ severity: "error",
2074
+ fixable: false
2075
+ },
2076
+ create(context) {
2077
+ let safeDepth = 0;
2078
+ let typeofGuardDepth = 0;
2079
+ return {
2080
+ CallExpression(node) {
2081
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
2082
+ },
2083
+ "CallExpression:exit"(node) {
2084
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
2085
+ },
2086
+ IfStatement(node) {
2087
+ const test = node.test;
2088
+ if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth++;
2089
+ },
2090
+ "IfStatement:exit"(node) {
2091
+ const test = node.test;
2092
+ if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth--;
2093
+ },
2094
+ Identifier(node, parent) {
2095
+ if (safeDepth > 0 || typeofGuardDepth > 0) return;
2096
+ if (!BROWSER_GLOBALS.has(node.name)) return;
2097
+ if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return;
2098
+ if (parent?.type === "ImportSpecifier" || parent?.type === "ImportDefaultSpecifier" || parent?.type === "ImportNamespaceSpecifier") return;
2099
+ if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
2100
+ context.report({
2101
+ message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
2102
+ span: getSpan(node)
2103
+ });
2104
+ }
2105
+ };
2106
+ }
2107
+ };
2108
+
2109
+ //#endregion
2110
+ //#region src/rules/ssr/prefer-request-context.ts
2111
+ const preferRequestContext = {
2112
+ meta: {
2113
+ id: "pyreon/prefer-request-context",
2114
+ category: "ssr",
2115
+ description: "Warn about module-level signal()/createStore() in server files — use request context instead.",
2116
+ severity: "warn",
2117
+ fixable: false
2118
+ },
2119
+ create(context) {
2120
+ const filePath = context.getFilePath();
2121
+ if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
2122
+ let functionDepth = 0;
2123
+ return {
2124
+ FunctionDeclaration() {
2125
+ functionDepth++;
2126
+ },
2127
+ "FunctionDeclaration:exit"() {
2128
+ functionDepth--;
2129
+ },
2130
+ FunctionExpression() {
2131
+ functionDepth++;
2132
+ },
2133
+ "FunctionExpression:exit"() {
2134
+ functionDepth--;
2135
+ },
2136
+ ArrowFunctionExpression() {
2137
+ functionDepth++;
2138
+ },
2139
+ "ArrowFunctionExpression:exit"() {
2140
+ functionDepth--;
2141
+ },
2142
+ CallExpression(node) {
2143
+ if (functionDepth > 0) return;
2144
+ if (isCallTo(node, "signal") || isCallTo(node, "createStore")) {
2145
+ const name = node.callee.name;
2146
+ context.report({
2147
+ message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
2148
+ span: getSpan(node)
2149
+ });
2150
+ }
2151
+ }
2152
+ };
2153
+ }
2154
+ };
2155
+
2156
+ //#endregion
2157
+ //#region src/rules/store/no-duplicate-store-id.ts
2158
+ const noDuplicateStoreId = {
2159
+ meta: {
2160
+ id: "pyreon/no-duplicate-store-id",
2161
+ category: "store",
2162
+ description: "Disallow duplicate defineStore() IDs in the same file.",
2163
+ severity: "error",
2164
+ fixable: false
2165
+ },
2166
+ create(context) {
2167
+ const storeIds = /* @__PURE__ */ new Map();
2168
+ return { CallExpression(node) {
2169
+ if (!isCallTo(node, "defineStore")) return;
2170
+ const args = node.arguments;
2171
+ if (!args || args.length === 0) return;
2172
+ const firstArg = args[0];
2173
+ if (!firstArg) return;
2174
+ let id = null;
2175
+ if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") id = firstArg.value;
2176
+ if (typeof id !== "string") return;
2177
+ if (storeIds.has(id)) context.report({
2178
+ message: `Duplicate store ID \`"${id}"\` — each \`defineStore()\` must have a unique ID.`,
2179
+ span: getSpan(node)
2180
+ });
2181
+ else storeIds.set(id, getSpan(node));
2182
+ } };
2183
+ }
2184
+ };
2185
+
2186
+ //#endregion
2187
+ //#region src/rules/store/no-mutate-store-state.ts
2188
+ const noMutateStoreState = {
2189
+ meta: {
2190
+ id: "pyreon/no-mutate-store-state",
2191
+ category: "store",
2192
+ description: "Warn when directly calling .set() on store signals — use store actions instead.",
2193
+ severity: "warn",
2194
+ fixable: false
2195
+ },
2196
+ create(context) {
2197
+ return { CallExpression(node) {
2198
+ const callee = node.callee;
2199
+ if (!callee || callee.type !== "MemberExpression") return;
2200
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
2201
+ const obj = callee.object;
2202
+ if (!obj || obj.type !== "MemberExpression") return;
2203
+ const outerObj = obj.object;
2204
+ if (!outerObj || outerObj.type !== "Identifier") return;
2205
+ const name = outerObj.name;
2206
+ if (name.toLowerCase().includes("store")) context.report({
2207
+ message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
2208
+ span: getSpan(node)
2209
+ });
2210
+ } };
2211
+ }
2212
+ };
2213
+
2214
+ //#endregion
2215
+ //#region src/rules/store/no-store-outside-provider.ts
2216
+ const noStoreOutsideProvider = {
2217
+ meta: {
2218
+ id: "pyreon/no-store-outside-provider",
2219
+ category: "store",
2220
+ description: "Warn when store hooks are used in SSR files without a provider import.",
2221
+ severity: "warn",
2222
+ fixable: false
2223
+ },
2224
+ create(context) {
2225
+ const filePath = context.getFilePath();
2226
+ if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
2227
+ let hasProviderImport = false;
2228
+ const storeHookCalls = [];
2229
+ return {
2230
+ ImportDeclaration(node) {
2231
+ const info = extractImportInfo(node);
2232
+ if (!info) return;
2233
+ if (info.specifiers.some((s) => s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext")) hasProviderImport = true;
2234
+ },
2235
+ CallExpression(node) {
2236
+ const callee = node.callee;
2237
+ if (!callee || callee.type !== "Identifier") return;
2238
+ const name = callee.name;
2239
+ if (name.endsWith("Store") && name.startsWith("use")) storeHookCalls.push({
2240
+ name,
2241
+ span: getSpan(node)
2242
+ });
2243
+ },
2244
+ "Program:exit"() {
2245
+ if (hasProviderImport) return;
2246
+ for (const call of storeHookCalls) context.report({
2247
+ message: `\`${call.name}()\` in a server file without a store registry provider — use \`runWithRequestContext()\` or \`setStoreRegistryProvider()\` for SSR isolation.`,
2248
+ span: call.span
2249
+ });
2250
+ }
2251
+ };
2252
+ }
2253
+ };
2254
+
2255
+ //#endregion
2256
+ //#region src/rules/styling/no-dynamic-styled.ts
2257
+ const noDynamicStyled = {
2258
+ meta: {
2259
+ id: "pyreon/no-dynamic-styled",
2260
+ category: "styling",
2261
+ description: "Warn when styled() is called inside a function — it creates new CSS on every render.",
2262
+ severity: "warn",
2263
+ fixable: false
2264
+ },
2265
+ create(context) {
2266
+ let functionDepth = 0;
2267
+ return {
2268
+ FunctionDeclaration() {
2269
+ functionDepth++;
2270
+ },
2271
+ "FunctionDeclaration:exit"() {
2272
+ functionDepth--;
2273
+ },
2274
+ FunctionExpression() {
2275
+ functionDepth++;
2276
+ },
2277
+ "FunctionExpression:exit"() {
2278
+ functionDepth--;
2279
+ },
2280
+ ArrowFunctionExpression() {
2281
+ functionDepth++;
2282
+ },
2283
+ "ArrowFunctionExpression:exit"() {
2284
+ functionDepth--;
2285
+ },
2286
+ CallExpression(node) {
2287
+ if (functionDepth === 0) return;
2288
+ if (isCallTo(node, "styled")) context.report({
2289
+ message: "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
2290
+ span: getSpan(node)
2291
+ });
2292
+ },
2293
+ TaggedTemplateExpression(node) {
2294
+ if (functionDepth === 0) return;
2295
+ const tag = node.tag;
2296
+ if (!tag) return;
2297
+ if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
2298
+ message: "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
2299
+ span: getSpan(node)
2300
+ });
2301
+ }
2302
+ };
2303
+ }
2304
+ };
2305
+
2306
+ //#endregion
2307
+ //#region src/rules/styling/no-inline-style-object.ts
2308
+ const noInlineStyleObject = {
2309
+ meta: {
2310
+ id: "pyreon/no-inline-style-object",
2311
+ category: "styling",
2312
+ description: "Warn against inline style objects in JSX — prefer styled() or css``.",
2313
+ severity: "warn",
2314
+ fixable: false
2315
+ },
2316
+ create(context) {
2317
+ return { JSXAttribute(node) {
2318
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
2319
+ const value = node.value;
2320
+ if (!value || value.type !== "JSXExpressionContainer") return;
2321
+ if (value.expression?.type === "ObjectExpression") context.report({
2322
+ message: "Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
2323
+ span: getSpan(node)
2324
+ });
2325
+ } };
2326
+ }
2327
+ };
2328
+
2329
+ //#endregion
2330
+ //#region src/rules/styling/no-theme-outside-provider.ts
2331
+ const noThemeOutsideProvider = {
2332
+ meta: {
2333
+ id: "pyreon/no-theme-outside-provider",
2334
+ category: "styling",
2335
+ description: "Warn when useTheme() is used without PyreonUI or ThemeProvider in the same file.",
2336
+ severity: "warn",
2337
+ fixable: false
2338
+ },
2339
+ create(context) {
2340
+ let hasProviderImport = false;
2341
+ const themeCalls = [];
2342
+ return {
2343
+ ImportDeclaration(node) {
2344
+ const info = extractImportInfo(node);
2345
+ if (!info) return;
2346
+ if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
2347
+ },
2348
+ CallExpression(node) {
2349
+ if (isCallTo(node, "useTheme")) themeCalls.push({ span: getSpan(node) });
2350
+ },
2351
+ "Program:exit"() {
2352
+ if (hasProviderImport) return;
2353
+ for (const call of themeCalls) context.report({
2354
+ message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
2355
+ span: call.span
2356
+ });
2357
+ }
2358
+ };
2359
+ }
2360
+ };
2361
+
2362
+ //#endregion
2363
+ //#region src/rules/styling/prefer-cx.ts
2364
+ const preferCx = {
2365
+ meta: {
2366
+ id: "pyreon/prefer-cx",
2367
+ category: "styling",
2368
+ description: "Suggest cx() for class composition instead of string concatenation or template literals.",
2369
+ severity: "info",
2370
+ fixable: false
2371
+ },
2372
+ create(context) {
2373
+ return { JSXAttribute(node) {
2374
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return;
2375
+ const value = node.value;
2376
+ if (!value || value.type !== "JSXExpressionContainer") return;
2377
+ const expr = value.expression;
2378
+ if (!expr) return;
2379
+ if (expr.type === "BinaryExpression" && expr.operator === "+") {
2380
+ context.report({
2381
+ message: "String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
2382
+ span: getSpan(expr)
2383
+ });
2384
+ return;
2385
+ }
2386
+ if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) context.report({
2387
+ message: "Template literal in `class` attribute — use `cx()` for cleaner class composition.",
2388
+ span: getSpan(expr)
2389
+ });
2390
+ } };
2391
+ }
2392
+ };
2393
+
2394
+ //#endregion
2395
+ //#region src/rules/index.ts
2396
+ const allRules = [
2397
+ noBareSignalInJsx,
2398
+ noSignalInLoop,
2399
+ noNestedEffect,
2400
+ noPeekInTracked,
2401
+ noUnbatchedUpdates,
2402
+ preferComputed,
2403
+ noEffectAssignment,
2404
+ noSignalLeak,
2405
+ noMapInJsx,
2406
+ useByNotKey,
2407
+ noClassName,
2408
+ noHtmlFor,
2409
+ noOnChange,
2410
+ noTernaryConditional,
2411
+ noAndConditional,
2412
+ noIndexAsBy,
2413
+ noMissingForBy,
2414
+ noPropsDestructure,
2415
+ noChildrenAccess,
2416
+ noMissingCleanup,
2417
+ noMountInEffect,
2418
+ noEffectInMount,
2419
+ noDomInSetup,
2420
+ noLargeForWithoutBy,
2421
+ noEffectInFor,
2422
+ noEagerImport,
2423
+ preferShowOverDisplay,
2424
+ noWindowInSsr,
2425
+ noMismatchRisk,
2426
+ preferRequestContext,
2427
+ noCircularImport,
2428
+ noDeepImport,
2429
+ noCrossLayerImport,
2430
+ devGuardWarnings,
2431
+ noErrorWithoutPrefix,
2432
+ noStoreOutsideProvider,
2433
+ noMutateStoreState,
2434
+ noDuplicateStoreId,
2435
+ noUnregisteredField,
2436
+ noSubmitWithoutValidation,
2437
+ preferFieldArray,
2438
+ noInlineStyleObject,
2439
+ noDynamicStyled,
2440
+ preferCx,
2441
+ noThemeOutsideProvider,
2442
+ noRawAddEventListener,
2443
+ noRawSetInterval,
2444
+ noRawLocalStorage,
2445
+ toastA11y,
2446
+ dialogA11y,
2447
+ overlayA11y,
2448
+ noHrefNavigation,
2449
+ noImperativeNavigateInRender,
2450
+ noMissingFallback,
2451
+ preferUseIsActive
2452
+ ];
2453
+
2454
+ //#endregion
2455
+ //#region src/config/presets.ts
2456
+ /** Build a config where every rule uses its default severity. */
2457
+ function buildRecommended() {
2458
+ const rules = {};
2459
+ for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
2460
+ return { rules };
2461
+ }
2462
+ /** Build a config where every warn is promoted to error. */
2463
+ function buildStrict() {
2464
+ const base = buildRecommended();
2465
+ const rules = {};
2466
+ for (const [id, sev] of Object.entries(base.rules)) rules[id] = sev === "warn" ? "error" : sev;
2467
+ return { rules };
2468
+ }
2469
+ /** Build app config — recommended but disable library-only rules. */
2470
+ function buildApp() {
2471
+ return { rules: {
2472
+ ...buildRecommended().rules,
2473
+ "pyreon/dev-guard-warnings": "off",
2474
+ "pyreon/no-error-without-prefix": "off",
2475
+ "pyreon/no-circular-import": "off",
2476
+ "pyreon/no-cross-layer-import": "off"
2477
+ } };
2478
+ }
2479
+ /** Build lib config — strict + all architecture rules as error. */
2480
+ function buildLib() {
2481
+ return { rules: {
2482
+ ...buildStrict().rules,
2483
+ "pyreon/no-circular-import": "error",
2484
+ "pyreon/no-cross-layer-import": "error",
2485
+ "pyreon/dev-guard-warnings": "error",
2486
+ "pyreon/no-error-without-prefix": "error"
2487
+ } };
2488
+ }
2489
+ const presetBuilders = {
2490
+ recommended: buildRecommended,
2491
+ strict: buildStrict,
2492
+ app: buildApp,
2493
+ lib: buildLib
2494
+ };
2495
+ function getPreset(name) {
2496
+ return presetBuilders[name]();
2497
+ }
2498
+
2499
+ //#endregion
2500
+ //#region src/utils/source.ts
2501
+ /**
2502
+ * Fast offset→line/column conversion using binary search over precomputed line starts.
2503
+ */
2504
+ var LineIndex = class {
2505
+ lineStarts;
2506
+ constructor(sourceText) {
2507
+ this.lineStarts = [0];
2508
+ for (let i = 0; i < sourceText.length; i++) if (sourceText[i] === "\n") this.lineStarts.push(i + 1);
2509
+ }
2510
+ /** Convert a byte offset to a 1-based line and 0-based column. */
2511
+ locate(offset) {
2512
+ let lo = 0;
2513
+ let hi = this.lineStarts.length - 1;
2514
+ while (lo <= hi) {
2515
+ const mid = lo + hi >>> 1;
2516
+ if (this.lineStarts[mid] <= offset) lo = mid + 1;
2517
+ else hi = mid - 1;
2518
+ }
2519
+ const line = lo;
2520
+ return {
2521
+ line,
2522
+ column: offset - this.lineStarts[line - 1]
2523
+ };
2524
+ }
2525
+ };
2526
+
2527
+ //#endregion
2528
+ //#region src/runner.ts
2529
+ const JS_EXTENSIONS$2 = new Set([
2530
+ ".ts",
2531
+ ".tsx",
2532
+ ".js",
2533
+ ".jsx",
2534
+ ".mts",
2535
+ ".mjs"
2536
+ ]);
2537
+ function getExtension(filePath) {
2538
+ const lastDot = filePath.lastIndexOf(".");
2539
+ return lastDot === -1 ? "" : filePath.slice(lastDot);
2540
+ }
2541
+ function getLang(ext) {
2542
+ if (ext === ".tsx" || ext === ".jsx") return "tsx";
2543
+ if (ext === ".ts" || ext === ".mts") return "ts";
2544
+ return "js";
2545
+ }
2546
+ function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
2547
+ return {
2548
+ report(partial) {
2549
+ diagnostics.push({
2550
+ ruleId: rule.meta.id,
2551
+ severity,
2552
+ message: partial.message,
2553
+ span: partial.span,
2554
+ loc: lineIndex.locate(partial.span.start),
2555
+ fix: partial.fix
2556
+ });
2557
+ },
2558
+ getSourceText() {
2559
+ return sourceText;
2560
+ },
2561
+ getFilePath() {
2562
+ return filePath;
2563
+ }
2564
+ };
2565
+ }
2566
+ function mergeCallbacks(allCallbacks) {
2567
+ const callbacksByKey = {};
2568
+ for (const callbacks of allCallbacks) for (const [key, fn] of Object.entries(callbacks)) {
2569
+ const existing = callbacksByKey[key];
2570
+ if (existing) existing.push(fn);
2571
+ else callbacksByKey[key] = [fn];
2572
+ }
2573
+ const merged = {};
2574
+ for (const [key, fns] of Object.entries(callbacksByKey)) {
2575
+ const first = fns[0];
2576
+ if (fns.length === 1 && first) merged[key] = first;
2577
+ else merged[key] = (node) => {
2578
+ for (const fn of fns) fn(node);
2579
+ };
2580
+ }
2581
+ return merged;
2582
+ }
2583
+ /**
2584
+ * Lint a single file and return diagnostics.
2585
+ *
2586
+ * @example
2587
+ * ```ts
2588
+ * const result = lintFile("app.tsx", source, allRules, getPreset("recommended"))
2589
+ * for (const d of result.diagnostics) console.log(d.message)
2590
+ * ```
2591
+ */
2592
+ function lintFile(filePath, sourceText, rules, config, cache) {
2593
+ const ext = getExtension(filePath);
2594
+ if (!JS_EXTENSIONS$2.has(ext)) return {
2595
+ filePath,
2596
+ diagnostics: []
2597
+ };
2598
+ let lineIndex;
2599
+ let program;
2600
+ const cached = cache?.get(sourceText);
2601
+ if (cached) {
2602
+ lineIndex = cached.lineIndex;
2603
+ program = cached.program;
2604
+ } else {
2605
+ lineIndex = new LineIndex(sourceText);
2606
+ try {
2607
+ program = parseSync(filePath, sourceText, {
2608
+ sourceType: "module",
2609
+ lang: getLang(ext)
2610
+ }).program;
2611
+ } catch {
2612
+ return {
2613
+ filePath,
2614
+ diagnostics: []
2615
+ };
2616
+ }
2617
+ cache?.set(sourceText, {
2618
+ program,
2619
+ lineIndex
2620
+ });
2621
+ }
2622
+ const diagnostics = [];
2623
+ const allCallbacks = [];
2624
+ for (const rule of rules) {
2625
+ const severity = config.rules[rule.meta.id];
2626
+ if (severity === void 0 || severity === "off") continue;
2627
+ const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath);
2628
+ allCallbacks.push(rule.create(ctx));
2629
+ }
2630
+ new Visitor(mergeCallbacks(allCallbacks)).visit(program);
2631
+ diagnostics.sort((a, b) => a.span.start - b.span.start);
2632
+ return {
2633
+ filePath,
2634
+ diagnostics
2635
+ };
2636
+ }
2637
+ /**
2638
+ * Apply all auto-fixes to a source text.
2639
+ * Fixes are applied in reverse order to maintain correct offsets.
2640
+ */
2641
+ function applyFixes(sourceText, diagnostics) {
2642
+ const fixable = diagnostics.filter((d) => d.fix !== void 0);
2643
+ if (fixable.length === 0) return sourceText;
2644
+ const sorted = [...fixable].sort((a, b) => {
2645
+ const aFix = a.fix;
2646
+ const bFix = b.fix;
2647
+ if (!aFix || !bFix) return 0;
2648
+ return bFix.span.start - aFix.span.start;
2649
+ });
2650
+ let result = sourceText;
2651
+ for (const diag of sorted) {
2652
+ const fix = diag.fix;
2653
+ if (!fix) continue;
2654
+ result = result.slice(0, fix.span.start) + fix.replacement + result.slice(fix.span.end);
2655
+ }
2656
+ return result;
2657
+ }
2658
+
2659
+ //#endregion
2660
+ //#region src/lint.ts
2661
+ const JS_EXTENSIONS$1 = new Set([
2662
+ ".ts",
2663
+ ".tsx",
2664
+ ".js",
2665
+ ".jsx",
2666
+ ".mts",
2667
+ ".mjs"
2668
+ ]);
2669
+ function isHiddenOrVendor(entry) {
2670
+ return entry.startsWith(".") || entry === "node_modules" || entry === "lib" || entry === "dist";
2671
+ }
2672
+ function hasJsExtension$1(filePath) {
2673
+ const ext = filePath.slice(filePath.lastIndexOf("."));
2674
+ return JS_EXTENSIONS$1.has(ext);
2675
+ }
2676
+ function matchesPatterns(filePath, include, exclude) {
2677
+ if (exclude) {
2678
+ for (const pattern of exclude) if (filePath.includes(pattern)) return false;
2679
+ }
2680
+ if (include && include.length > 0) {
2681
+ for (const pattern of include) if (filePath.includes(pattern)) return true;
2682
+ return false;
2683
+ }
2684
+ return true;
2685
+ }
2686
+ function walkDirectory(dir, files, isIgnored, include, exclude) {
2687
+ let entries;
2688
+ try {
2689
+ entries = readdirSync(dir);
2690
+ } catch {
2691
+ return;
2692
+ }
2693
+ for (const entry of entries) {
2694
+ if (isHiddenOrVendor(entry)) continue;
2695
+ const full = join(dir, entry);
2696
+ if (isIgnored(full)) continue;
2697
+ processEntry(full, files, isIgnored, include, exclude);
2698
+ }
2699
+ }
2700
+ function processEntry(full, files, isIgnored, include, exclude) {
2701
+ let stat;
2702
+ try {
2703
+ stat = statSync(full);
2704
+ } catch {
2705
+ return;
2706
+ }
2707
+ if (stat.isDirectory()) walkDirectory(full, files, isIgnored, include, exclude);
2708
+ else if (stat.isFile() && hasJsExtension$1(full) && matchesPatterns(full, include, exclude)) files.push(full);
2709
+ }
2710
+ function collectFiles(dir, isIgnored, include, exclude) {
2711
+ const files = [];
2712
+ walkDirectory(dir, files, isIgnored, include, exclude);
2713
+ return files;
2714
+ }
2715
+ function buildConfig(options) {
2716
+ const cwd = resolve(".");
2717
+ const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
2718
+ const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
2719
+ if (fileConfig?.rules) for (const [id, severity] of Object.entries(fileConfig.rules)) config.rules[id] = severity;
2720
+ if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
2721
+ return {
2722
+ config,
2723
+ include: fileConfig?.include,
2724
+ exclude: fileConfig?.exclude,
2725
+ isIgnored: createIgnoreFilter(cwd, options.ignore)
2726
+ };
2727
+ }
2728
+ function gatherFiles(paths, isIgnored, include, exclude) {
2729
+ const files = [];
2730
+ for (const p of paths) {
2731
+ const resolved = resolve(p);
2732
+ let stat;
2733
+ try {
2734
+ stat = statSync(resolved);
2735
+ } catch {
2736
+ continue;
2737
+ }
2738
+ if (stat.isDirectory()) files.push(...collectFiles(resolved, isIgnored, include, exclude));
2739
+ else if (stat.isFile() && !isIgnored(resolved)) files.push(resolved);
2740
+ }
2741
+ return files;
2742
+ }
2743
+ function applyFixesToFile(fileResult, source) {
2744
+ if (fileResult.diagnostics.filter((d) => d.fix).length === 0) return;
2745
+ const fixed = applyFixes(source, fileResult.diagnostics);
2746
+ writeFileSync(fileResult.filePath, fixed, "utf-8");
2747
+ fileResult.fixedSource = fixed;
2748
+ fileResult.diagnostics = fileResult.diagnostics.filter((d) => !d.fix);
2749
+ }
2750
+ function countDiagnostics(fileResult, results) {
2751
+ for (const d of fileResult.diagnostics) if (d.severity === "error") results.totalErrors++;
2752
+ else if (d.severity === "warn") results.totalWarnings++;
2753
+ else if (d.severity === "info") results.totalInfos++;
2754
+ }
2755
+ /**
2756
+ * Lint files and return results.
2757
+ *
2758
+ * @example
2759
+ * ```ts
2760
+ * import { lint } from "@pyreon/lint"
2761
+ *
2762
+ * const result = lint({ paths: ["src/"], preset: "recommended" })
2763
+ * console.log(result.totalErrors) // 0
2764
+ * ```
2765
+ */
2766
+ function lint(options) {
2767
+ const { config, include, exclude, isIgnored } = buildConfig(options);
2768
+ const cache = new AstCache();
2769
+ const files = gatherFiles(options.paths, isIgnored, include, exclude);
2770
+ const results = {
2771
+ files: [],
2772
+ totalErrors: 0,
2773
+ totalWarnings: 0,
2774
+ totalInfos: 0
2775
+ };
2776
+ for (const filePath of files) {
2777
+ let source;
2778
+ try {
2779
+ source = readFileSync(filePath, "utf-8");
2780
+ } catch {
2781
+ continue;
2782
+ }
2783
+ const fileResult = lintFile(filePath, source, allRules, config, cache);
2784
+ if (options.fix) applyFixesToFile(fileResult, source);
2785
+ if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
2786
+ countDiagnostics(fileResult, results);
2787
+ results.files.push(fileResult);
2788
+ }
2789
+ return results;
2790
+ }
2791
+ /**
2792
+ * List all available rules with their metadata.
2793
+ *
2794
+ * @example
2795
+ * ```ts
2796
+ * import { listRules } from "@pyreon/lint"
2797
+ *
2798
+ * for (const rule of listRules()) {
2799
+ * console.log(`${rule.id} (${rule.severity}): ${rule.description}`)
2800
+ * }
2801
+ * ```
2802
+ */
2803
+ function listRules() {
2804
+ return allRules.map((r) => r.meta);
2805
+ }
2806
+
2807
+ //#endregion
2808
+ //#region src/reporter.ts
2809
+ const BOLD = "\x1B[1m";
2810
+ const RED = "\x1B[31m";
2811
+ const YELLOW = "\x1B[33m";
2812
+ const BLUE = "\x1B[34m";
2813
+ const DIM = "\x1B[2m";
2814
+ const RESET = "\x1B[0m";
2815
+ const SEVERITY_SYMBOL = {
2816
+ error: `${RED}\u2716${RESET}`,
2817
+ warn: `${YELLOW}\u26A0${RESET}`,
2818
+ info: `${BLUE}\u2139${RESET}`,
2819
+ off: ""
2820
+ };
2821
+ const SEVERITY_LABEL = {
2822
+ error: `${RED}error${RESET}`,
2823
+ warn: `${YELLOW}warning${RESET}`,
2824
+ info: `${BLUE}info${RESET}`,
2825
+ off: ""
2826
+ };
2827
+ /**
2828
+ * Format results as human-readable colored text.
2829
+ */
2830
+ function formatText(result) {
2831
+ const lines = [];
2832
+ for (const file of result.files) {
2833
+ if (file.diagnostics.length === 0) continue;
2834
+ lines.push("");
2835
+ lines.push(`${BOLD}${file.filePath}${RESET}`);
2836
+ for (const d of file.diagnostics) {
2837
+ const loc = `${DIM}${d.loc.line}:${d.loc.column}${RESET}`;
2838
+ const severity = SEVERITY_LABEL[d.severity];
2839
+ const ruleId = `${DIM}${d.ruleId}${RESET}`;
2840
+ lines.push(` ${loc} ${severity} ${d.message} ${ruleId}`);
2841
+ }
2842
+ }
2843
+ if (result.totalErrors + result.totalWarnings + result.totalInfos > 0) {
2844
+ lines.push("");
2845
+ const parts = [];
2846
+ if (result.totalErrors > 0) parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ? "" : "s"}${RESET}`);
2847
+ if (result.totalWarnings > 0) parts.push(`${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ? "" : "s"}${RESET}`);
2848
+ if (result.totalInfos > 0) parts.push(`${BLUE}${result.totalInfos} info${RESET}`);
2849
+ lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(", ")}`);
2850
+ lines.push("");
2851
+ }
2852
+ return lines.join("\n");
2853
+ }
2854
+ /**
2855
+ * Format results as JSON.
2856
+ */
2857
+ function formatJSON(result) {
2858
+ return JSON.stringify(result, null, 2);
2859
+ }
2860
+ /**
2861
+ * Format results as compact single-line-per-diagnostic output.
2862
+ */
2863
+ function formatCompact(result) {
2864
+ const lines = [];
2865
+ for (const file of result.files) for (const d of file.diagnostics) lines.push(`${file.filePath}:${d.loc.line}:${d.loc.column}: ${d.severity} [${d.ruleId}] ${d.message}`);
2866
+ return lines.join("\n");
2867
+ }
2868
+
2869
+ //#endregion
2870
+ //#region src/watcher.ts
2871
+ const JS_EXTENSIONS = new Set([
2872
+ ".ts",
2873
+ ".tsx",
2874
+ ".js",
2875
+ ".jsx",
2876
+ ".mts",
2877
+ ".mjs"
2878
+ ]);
2879
+ function hasJsExtension(filePath) {
2880
+ const ext = filePath.slice(filePath.lastIndexOf("."));
2881
+ return JS_EXTENSIONS.has(ext);
2882
+ }
2883
+ function formatOutput(result, format) {
2884
+ if (format === "json") return formatJSON(result);
2885
+ if (format === "compact") return formatCompact(result);
2886
+ return formatText(result);
2887
+ }
2888
+ /**
2889
+ * Watch directories and re-lint changed files.
2890
+ *
2891
+ * Uses `fs.watch` (recursive) with 100ms debounce.
2892
+ * Caches ASTs for unchanged files.
2893
+ *
2894
+ * @example
2895
+ * ```ts
2896
+ * import { watchAndLint } from "@pyreon/lint"
2897
+ *
2898
+ * watchAndLint({ paths: ["src/"], preset: "recommended", format: "text" })
2899
+ * ```
2900
+ */
2901
+ function watchAndLint(options) {
2902
+ const cache = new AstCache();
2903
+ const config = getPreset(options.preset ?? "recommended");
2904
+ applyOverrides(config, options.ruleOverrides);
2905
+ const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
2906
+ const pending = /* @__PURE__ */ new Map();
2907
+ console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
2908
+ for (const p of options.paths) {
2909
+ const dir = resolve(p);
2910
+ try {
2911
+ watch(dir, { recursive: true }, (_event, filename) => {
2912
+ if (!filename) return;
2913
+ const filePath = resolve(dir, filename);
2914
+ if (!hasJsExtension(filePath) || isIgnored(filePath)) return;
2915
+ const existing = pending.get(filePath);
2916
+ if (existing) clearTimeout(existing);
2917
+ pending.set(filePath, setTimeout(() => {
2918
+ pending.delete(filePath);
2919
+ relintFile(filePath, config, cache, options.format);
2920
+ }, 100));
2921
+ });
2922
+ } catch {
2923
+ console.error(`[pyreon-lint] Could not watch: ${dir}`);
2924
+ }
2925
+ }
2926
+ }
2927
+ function applyOverrides(config, overrides) {
2928
+ if (!overrides) return;
2929
+ for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
2930
+ }
2931
+ function relintFile(filePath, config, cache, format) {
2932
+ let source;
2933
+ try {
2934
+ source = readFileSync(filePath, "utf-8");
2935
+ } catch {
2936
+ return;
2937
+ }
2938
+ const fileResult = lintFile(filePath, source, allRules, config, cache);
2939
+ if (fileResult.diagnostics.length === 0) return;
2940
+ const result = {
2941
+ files: [fileResult],
2942
+ totalErrors: 0,
2943
+ totalWarnings: 0,
2944
+ totalInfos: 0
2945
+ };
2946
+ for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
2947
+ else if (d.severity === "warn") result.totalWarnings++;
2948
+ else if (d.severity === "info") result.totalInfos++;
2949
+ process.stdout.write("\x1B[2J\x1B[H");
2950
+ console.log(formatOutput(result, format));
2951
+ }
2952
+
2953
+ //#endregion
2954
+ export { AstCache, LineIndex, allRules, applyFixes, createIgnoreFilter, extractImportInfo, formatCompact, formatJSON, formatText, getLocalName, getPreset, importsName, isPyreonImport, isPyreonPackage, lint, lintFile, listRules, loadConfig, loadConfigFromPath, watchAndLint };
2955
+ //# sourceMappingURL=index.js.map