@pyreon/lint 0.11.4 → 0.11.6

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