@pyreon/lint 0.11.4 → 0.11.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js ADDED
@@ -0,0 +1,3077 @@
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-effect-assignment.ts
1517
+ function isUpdateCall(node) {
1518
+ return node.type === "CallExpression" && node.callee?.type === "MemberExpression" && node.callee.property?.type === "Identifier" && node.callee.property.name === "update";
1519
+ }
1520
+ const noEffectAssignment = {
1521
+ meta: {
1522
+ id: "pyreon/no-effect-assignment",
1523
+ category: "reactivity",
1524
+ description: "Warn when an effect only contains a single .update() call.",
1525
+ severity: "warn",
1526
+ fixable: false
1527
+ },
1528
+ create(context) {
1529
+ return { CallExpression(node) {
1530
+ if (!isCallTo(node, "effect")) return;
1531
+ const args = node.arguments;
1532
+ if (!args || args.length === 0) return;
1533
+ const fn = args[0];
1534
+ if (!fn) return;
1535
+ let body = null;
1536
+ if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
1537
+ if (!body) return;
1538
+ if (isUpdateCall(body)) {
1539
+ context.report({
1540
+ message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
1541
+ span: getSpan(node)
1542
+ });
1543
+ return;
1544
+ }
1545
+ if (body.type === "BlockStatement") {
1546
+ const stmts = body.body;
1547
+ if (stmts && stmts.length === 1) {
1548
+ const stmt = stmts[0];
1549
+ if (stmt.type === "ExpressionStatement" && isUpdateCall(stmt.expression)) context.report({
1550
+ message: "Effect contains a single `.update()` — consider using `computed()` for derived values.",
1551
+ span: getSpan(node)
1552
+ });
1553
+ }
1554
+ }
1555
+ } };
1556
+ }
1557
+ };
1558
+
1559
+ //#endregion
1560
+ //#region src/rules/reactivity/no-nested-effect.ts
1561
+ const noNestedEffect = {
1562
+ meta: {
1563
+ id: "pyreon/no-nested-effect",
1564
+ category: "reactivity",
1565
+ description: "Warn against nesting effect() inside another effect().",
1566
+ severity: "warn",
1567
+ fixable: false
1568
+ },
1569
+ create(context) {
1570
+ let effectDepth = 0;
1571
+ return {
1572
+ CallExpression(node) {
1573
+ if (!isCallTo(node, "effect")) return;
1574
+ if (effectDepth > 0) context.report({
1575
+ message: "Nested `effect()` — consider using `computed()` for derived values instead.",
1576
+ span: getSpan(node)
1577
+ });
1578
+ effectDepth++;
1579
+ },
1580
+ "CallExpression:exit"(node) {
1581
+ if (isCallTo(node, "effect")) effectDepth--;
1582
+ }
1583
+ };
1584
+ }
1585
+ };
1586
+
1587
+ //#endregion
1588
+ //#region src/rules/reactivity/no-peek-in-tracked.ts
1589
+ const noPeekInTracked = {
1590
+ meta: {
1591
+ id: "pyreon/no-peek-in-tracked",
1592
+ category: "reactivity",
1593
+ description: "Disallow .peek() inside effect() or computed() — it bypasses tracking.",
1594
+ severity: "error",
1595
+ fixable: false
1596
+ },
1597
+ create(context) {
1598
+ let trackedDepth = 0;
1599
+ return {
1600
+ CallExpression(node) {
1601
+ if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth++;
1602
+ if (trackedDepth > 0 && isPeekCall(node)) context.report({
1603
+ message: "`.peek()` inside a tracked scope (effect/computed) bypasses dependency tracking — use a normal signal read instead.",
1604
+ span: getSpan(node)
1605
+ });
1606
+ },
1607
+ "CallExpression:exit"(node) {
1608
+ if (isCallTo(node, "effect") || isCallTo(node, "computed")) trackedDepth--;
1609
+ }
1610
+ };
1611
+ }
1612
+ };
1613
+
1614
+ //#endregion
1615
+ //#region src/rules/reactivity/no-signal-in-loop.ts
1616
+ const noSignalInLoop = {
1617
+ meta: {
1618
+ id: "pyreon/no-signal-in-loop",
1619
+ category: "reactivity",
1620
+ description: "Disallow creating signals or computeds inside loops.",
1621
+ severity: "error",
1622
+ fixable: false
1623
+ },
1624
+ create(context) {
1625
+ let loopDepth = 0;
1626
+ return {
1627
+ ForStatement() {
1628
+ loopDepth++;
1629
+ },
1630
+ "ForStatement:exit"() {
1631
+ loopDepth--;
1632
+ },
1633
+ ForInStatement() {
1634
+ loopDepth++;
1635
+ },
1636
+ "ForInStatement:exit"() {
1637
+ loopDepth--;
1638
+ },
1639
+ ForOfStatement() {
1640
+ loopDepth++;
1641
+ },
1642
+ "ForOfStatement:exit"() {
1643
+ loopDepth--;
1644
+ },
1645
+ WhileStatement() {
1646
+ loopDepth++;
1647
+ },
1648
+ "WhileStatement:exit"() {
1649
+ loopDepth--;
1650
+ },
1651
+ DoWhileStatement() {
1652
+ loopDepth++;
1653
+ },
1654
+ "DoWhileStatement:exit"() {
1655
+ loopDepth--;
1656
+ },
1657
+ CallExpression(node) {
1658
+ if (loopDepth === 0) return;
1659
+ const callee = node.callee;
1660
+ if (!callee || callee.type !== "Identifier") return;
1661
+ if (callee.name === "signal" || callee.name === "computed") context.report({
1662
+ message: `\`${callee.name}()\` inside a loop — signals should be created once at component setup, not on every iteration.`,
1663
+ span: getSpan(node)
1664
+ });
1665
+ }
1666
+ };
1667
+ }
1668
+ };
1669
+
1670
+ //#endregion
1671
+ //#region src/rules/reactivity/no-signal-leak.ts
1672
+ const noSignalLeak = {
1673
+ meta: {
1674
+ id: "pyreon/no-signal-leak",
1675
+ category: "reactivity",
1676
+ description: "Warn about unused signal declarations (potential leaks).",
1677
+ severity: "warn",
1678
+ fixable: false
1679
+ },
1680
+ create(context) {
1681
+ const signalDecls = /* @__PURE__ */ new Map();
1682
+ const identifierOccurrences = /* @__PURE__ */ new Map();
1683
+ return {
1684
+ VariableDeclarator(node) {
1685
+ const init = node.init;
1686
+ if (!init || !isCallTo(init, "signal")) return;
1687
+ const id = node.id;
1688
+ if (!id || id.type !== "Identifier") return;
1689
+ signalDecls.set(id.name, {
1690
+ span: getSpan(node),
1691
+ declStart: id.start,
1692
+ declEnd: id.end
1693
+ });
1694
+ },
1695
+ Identifier(node) {
1696
+ const name = node.name;
1697
+ const existing = identifierOccurrences.get(name);
1698
+ if (existing) existing.push({
1699
+ start: node.start,
1700
+ end: node.end
1701
+ });
1702
+ else identifierOccurrences.set(name, [{
1703
+ start: node.start,
1704
+ end: node.end
1705
+ }]);
1706
+ },
1707
+ "Program:exit"() {
1708
+ for (const [name, { span, declStart, declEnd }] of signalDecls) if ((identifierOccurrences.get(name) ?? []).filter((o) => o.start !== declStart || o.end !== declEnd).length === 0) context.report({
1709
+ message: `Signal \`${name}\` is declared but never used — this may be a signal leak.`,
1710
+ span
1711
+ });
1712
+ }
1713
+ };
1714
+ }
1715
+ };
1716
+
1717
+ //#endregion
1718
+ //#region src/rules/reactivity/no-unbatched-updates.ts
1719
+ const noUnbatchedUpdates = {
1720
+ meta: {
1721
+ id: "pyreon/no-unbatched-updates",
1722
+ category: "reactivity",
1723
+ description: "Warn when 3+ .set() calls occur in the same function without batch().",
1724
+ severity: "warn",
1725
+ fixable: false
1726
+ },
1727
+ create(context) {
1728
+ const scopeStack = [];
1729
+ let batchDepth = 0;
1730
+ function enterScope(node) {
1731
+ scopeStack.push({
1732
+ setCalls: [],
1733
+ hasBatch: false,
1734
+ insideBatch: batchDepth > 0,
1735
+ node
1736
+ });
1737
+ }
1738
+ function exitScope() {
1739
+ const scope = scopeStack.pop();
1740
+ if (!scope) return;
1741
+ if (!scope.hasBatch && !scope.insideBatch && scope.setCalls.length >= 3) context.report({
1742
+ message: `${scope.setCalls.length} signal \`.set()\` calls without \`batch()\` — wrap in \`batch(() => { ... })\` to avoid unnecessary re-renders.`,
1743
+ span: getSpan(scope.node)
1744
+ });
1745
+ }
1746
+ return {
1747
+ FunctionDeclaration(node) {
1748
+ enterScope(node);
1749
+ },
1750
+ "FunctionDeclaration:exit"() {
1751
+ exitScope();
1752
+ },
1753
+ FunctionExpression(node) {
1754
+ enterScope(node);
1755
+ },
1756
+ "FunctionExpression:exit"() {
1757
+ exitScope();
1758
+ },
1759
+ ArrowFunctionExpression(node) {
1760
+ enterScope(node);
1761
+ },
1762
+ "ArrowFunctionExpression:exit"() {
1763
+ exitScope();
1764
+ },
1765
+ CallExpression(node) {
1766
+ const currentScope = scopeStack.length > 0 ? scopeStack[scopeStack.length - 1] : void 0;
1767
+ if (isCallTo(node, "batch")) {
1768
+ batchDepth++;
1769
+ if (currentScope) currentScope.hasBatch = true;
1770
+ }
1771
+ if (currentScope && isSetCall(node)) currentScope.setCalls.push({ span: getSpan(node) });
1772
+ },
1773
+ "CallExpression:exit"(node) {
1774
+ if (isCallTo(node, "batch")) batchDepth--;
1775
+ }
1776
+ };
1777
+ }
1778
+ };
1779
+
1780
+ //#endregion
1781
+ //#region src/rules/reactivity/prefer-computed.ts
1782
+ const preferComputed = {
1783
+ meta: {
1784
+ id: "pyreon/prefer-computed",
1785
+ category: "reactivity",
1786
+ description: "Suggest computed() when an effect only contains a single .set() call.",
1787
+ severity: "warn",
1788
+ fixable: false
1789
+ },
1790
+ create(context) {
1791
+ return { CallExpression(node) {
1792
+ if (!isCallTo(node, "effect")) return;
1793
+ const args = node.arguments;
1794
+ if (!args || args.length === 0) return;
1795
+ const fn = args[0];
1796
+ if (!fn) return;
1797
+ let body = null;
1798
+ if (fn.type === "ArrowFunctionExpression" || fn.type === "FunctionExpression") body = fn.body;
1799
+ if (!body) return;
1800
+ if (body.type === "CallExpression" && isSetCall(body)) {
1801
+ context.report({
1802
+ message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
1803
+ span: getSpan(node)
1804
+ });
1805
+ return;
1806
+ }
1807
+ if (body.type === "BlockStatement") {
1808
+ const stmts = body.body;
1809
+ if (stmts && stmts.length === 1) {
1810
+ const stmt = stmts[0];
1811
+ if (stmt.type === "ExpressionStatement" && isSetCall(stmt.expression)) context.report({
1812
+ message: "Effect contains a single `.set()` — consider using `computed()` instead for derived values.",
1813
+ span: getSpan(node)
1814
+ });
1815
+ }
1816
+ }
1817
+ } };
1818
+ }
1819
+ };
1820
+
1821
+ //#endregion
1822
+ //#region src/rules/router/no-href-navigation.ts
1823
+ const EXTERNAL_PREFIXES = [
1824
+ "http://",
1825
+ "https://",
1826
+ "mailto:",
1827
+ "tel:"
1828
+ ];
1829
+ const noHrefNavigation = {
1830
+ meta: {
1831
+ id: "pyreon/no-href-navigation",
1832
+ category: "router",
1833
+ description: "Warn when `<a href>` is used in files that import @pyreon/router — use `<Link>` instead.",
1834
+ severity: "warn",
1835
+ fixable: false
1836
+ },
1837
+ create(context) {
1838
+ let importsRouter = false;
1839
+ return {
1840
+ ImportDeclaration(node) {
1841
+ const info = extractImportInfo(node);
1842
+ if (info && info.source === "@pyreon/router") importsRouter = true;
1843
+ },
1844
+ JSXOpeningElement(node) {
1845
+ if (!importsRouter) return;
1846
+ const name = node.name;
1847
+ if (!name || name.type !== "JSXIdentifier" || name.name !== "a") return;
1848
+ const hrefAttr = getJSXAttribute(node, "href");
1849
+ if (!hrefAttr) return;
1850
+ const value = hrefAttr.value;
1851
+ if (value?.type === "Literal" && typeof value.value === "string") {
1852
+ const href = value.value;
1853
+ if (href.startsWith("#") || EXTERNAL_PREFIXES.some((p) => href.startsWith(p))) return;
1854
+ }
1855
+ context.report({
1856
+ message: "`<a href>` in a router file — use `<Link>` or `<RouterLink>` for client-side navigation.",
1857
+ span: getSpan(node)
1858
+ });
1859
+ }
1860
+ };
1861
+ }
1862
+ };
1863
+
1864
+ //#endregion
1865
+ //#region src/rules/router/no-imperative-navigate-in-render.ts
1866
+ const noImperativeNavigateInRender = {
1867
+ meta: {
1868
+ id: "pyreon/no-imperative-navigate-in-render",
1869
+ category: "router",
1870
+ description: "Error when navigate() or router.push() is called at the top level of a component — causes infinite render loops.",
1871
+ severity: "error",
1872
+ fixable: false
1873
+ },
1874
+ create(context) {
1875
+ let componentBodyDepth = 0;
1876
+ let safeDepth = 0;
1877
+ return {
1878
+ FunctionDeclaration(node) {
1879
+ const name = node.id?.name ?? "";
1880
+ if (/^[A-Z]/.test(name)) componentBodyDepth++;
1881
+ },
1882
+ "FunctionDeclaration:exit"(node) {
1883
+ const name = node.id?.name ?? "";
1884
+ if (/^[A-Z]/.test(name)) componentBodyDepth--;
1885
+ },
1886
+ VariableDeclarator(node) {
1887
+ const name = node.id?.name ?? "";
1888
+ if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth++;
1889
+ },
1890
+ "VariableDeclarator:exit"(node) {
1891
+ const name = node.id?.name ?? "";
1892
+ if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth--;
1893
+ },
1894
+ CallExpression(node) {
1895
+ if (componentBodyDepth <= 0) return;
1896
+ if (isSafeWrapperCall(node)) safeDepth++;
1897
+ if (safeDepth > 0) return;
1898
+ if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) context.report({
1899
+ 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.",
1900
+ span: getSpan(node)
1901
+ });
1902
+ },
1903
+ "CallExpression:exit"(node) {
1904
+ if (componentBodyDepth <= 0) return;
1905
+ if (isSafeWrapperCall(node)) safeDepth--;
1906
+ }
1907
+ };
1908
+ }
1909
+ };
1910
+ function isSafeWrapperCall(node) {
1911
+ const callee = node.callee;
1912
+ if (!callee || callee.type !== "Identifier") return false;
1913
+ const name = callee.name;
1914
+ return name === "onMount" || name === "effect" || name === "onUnmount";
1915
+ }
1916
+
1917
+ //#endregion
1918
+ //#region src/rules/router/no-missing-fallback.ts
1919
+ function isCatchAllPath(value) {
1920
+ return value === "*" || value.endsWith("*");
1921
+ }
1922
+ function getPathValue(prop) {
1923
+ const key = prop.key;
1924
+ if (!key) return null;
1925
+ if ((key.type === "Identifier" ? key.name : null) !== "path") return null;
1926
+ const val = prop.value;
1927
+ if (val?.type === "Literal" && typeof val.value === "string") return val.value;
1928
+ return null;
1929
+ }
1930
+ function hasPathProperty(obj) {
1931
+ if (!obj || obj.type !== "ObjectExpression") return false;
1932
+ for (const prop of obj.properties ?? []) {
1933
+ if (prop.type !== "Property") continue;
1934
+ if (getPathValue(prop) !== null) return true;
1935
+ }
1936
+ return false;
1937
+ }
1938
+ function hasCatchAllRoute(elements) {
1939
+ for (const elem of elements) {
1940
+ if (!elem || elem.type !== "ObjectExpression") continue;
1941
+ for (const prop of elem.properties ?? []) {
1942
+ if (prop.type !== "Property") continue;
1943
+ const pathVal = getPathValue(prop);
1944
+ if (pathVal !== null && isCatchAllPath(pathVal)) return true;
1945
+ }
1946
+ }
1947
+ return false;
1948
+ }
1949
+ const noMissingFallback = {
1950
+ meta: {
1951
+ id: "pyreon/no-missing-fallback",
1952
+ category: "router",
1953
+ description: "Warn when route config has no catch-all route (`path: \"*\"` or `path: \"/:rest*\"`).",
1954
+ severity: "warn",
1955
+ fixable: false
1956
+ },
1957
+ create(context) {
1958
+ let importsRouter = false;
1959
+ let routeArraySpan = null;
1960
+ let foundCatchAll = false;
1961
+ return {
1962
+ ImportDeclaration(node) {
1963
+ const info = extractImportInfo(node);
1964
+ if (info && info.source === "@pyreon/router") importsRouter = true;
1965
+ },
1966
+ ArrayExpression(node) {
1967
+ if (!importsRouter) return;
1968
+ const elements = node.elements ?? [];
1969
+ if (!elements.some((e) => hasPathProperty(e))) return;
1970
+ if (!routeArraySpan) routeArraySpan = getSpan(node);
1971
+ if (hasCatchAllRoute(elements)) foundCatchAll = true;
1972
+ },
1973
+ "Program:exit"() {
1974
+ if (!importsRouter || !routeArraySpan || foundCatchAll) return;
1975
+ context.report({
1976
+ message: "Route config has no catch-all route — add a `{ path: \"*\", component: NotFound }` for unmatched URLs.",
1977
+ span: routeArraySpan
1978
+ });
1979
+ }
1980
+ };
1981
+ }
1982
+ };
1983
+
1984
+ //#endregion
1985
+ //#region src/rules/router/prefer-use-is-active.ts
1986
+ const preferUseIsActive = {
1987
+ meta: {
1988
+ id: "pyreon/prefer-use-is-active",
1989
+ category: "router",
1990
+ description: "Suggest useIsActive() instead of `location.pathname === \"/foo\"` or `route.path === \"/foo\"` patterns.",
1991
+ severity: "info",
1992
+ fixable: false
1993
+ },
1994
+ create(context) {
1995
+ return { BinaryExpression(node) {
1996
+ if (node.operator !== "===" && node.operator !== "==") return;
1997
+ if (isPathComparison(node.left) || isPathComparison(node.right)) context.report({
1998
+ message: "Manual path comparison — use `useIsActive()` for reactive route matching with segment-aware prefix matching.",
1999
+ span: getSpan(node)
2000
+ });
2001
+ } };
2002
+ }
2003
+ };
2004
+ function isPathComparison(node) {
2005
+ if (!node || node.type !== "MemberExpression") return false;
2006
+ const obj = node.object;
2007
+ const prop = node.property;
2008
+ if (!obj || !prop || prop.type !== "Identifier") return false;
2009
+ if (obj.type === "Identifier" && obj.name === "location" && prop.name === "pathname") return true;
2010
+ if (obj.type === "Identifier" && obj.name === "route" && prop.name === "path") return true;
2011
+ return false;
2012
+ }
2013
+
2014
+ //#endregion
2015
+ //#region src/rules/ssr/no-mismatch-risk.ts
2016
+ const noMismatchRisk = {
2017
+ meta: {
2018
+ id: "pyreon/no-mismatch-risk",
2019
+ category: "ssr",
2020
+ description: "Warn about non-deterministic calls (Date.now, Math.random, crypto.randomUUID) in JSX context that cause hydration mismatches.",
2021
+ severity: "warn",
2022
+ fixable: false
2023
+ },
2024
+ create(context) {
2025
+ let jsxDepth = 0;
2026
+ return {
2027
+ JSXElement() {
2028
+ jsxDepth++;
2029
+ },
2030
+ "JSXElement:exit"() {
2031
+ jsxDepth--;
2032
+ },
2033
+ JSXFragment() {
2034
+ jsxDepth++;
2035
+ },
2036
+ "JSXFragment:exit"() {
2037
+ jsxDepth--;
2038
+ },
2039
+ CallExpression(node) {
2040
+ if (jsxDepth === 0) return;
2041
+ if (isMemberCallTo(node, "Date", "now") || isMemberCallTo(node, "Math", "random") || isMemberCallTo(node, "crypto", "randomUUID")) {
2042
+ const callee = node.callee;
2043
+ const name = `${callee.object.name}.${callee.property.name}`;
2044
+ context.report({
2045
+ message: `\`${name}()\` in JSX context — this produces different values on server and client, causing hydration mismatches.`,
2046
+ span: getSpan(node)
2047
+ });
2048
+ }
2049
+ }
2050
+ };
2051
+ }
2052
+ };
2053
+
2054
+ //#endregion
2055
+ //#region src/rules/ssr/no-window-in-ssr.ts
2056
+ const noWindowInSsr = {
2057
+ meta: {
2058
+ id: "pyreon/no-window-in-ssr",
2059
+ category: "ssr",
2060
+ description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
2061
+ severity: "error",
2062
+ fixable: false
2063
+ },
2064
+ create(context) {
2065
+ let safeDepth = 0;
2066
+ let typeofGuardDepth = 0;
2067
+ return {
2068
+ CallExpression(node) {
2069
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
2070
+ },
2071
+ "CallExpression:exit"(node) {
2072
+ if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
2073
+ },
2074
+ IfStatement(node) {
2075
+ const test = node.test;
2076
+ if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth++;
2077
+ },
2078
+ "IfStatement:exit"(node) {
2079
+ const test = node.test;
2080
+ if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth--;
2081
+ },
2082
+ Identifier(node, parent) {
2083
+ if (safeDepth > 0 || typeofGuardDepth > 0) return;
2084
+ if (!BROWSER_GLOBALS.has(node.name)) return;
2085
+ if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return;
2086
+ if (parent?.type === "ImportSpecifier" || parent?.type === "ImportDefaultSpecifier" || parent?.type === "ImportNamespaceSpecifier") return;
2087
+ if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
2088
+ context.report({
2089
+ message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
2090
+ span: getSpan(node)
2091
+ });
2092
+ }
2093
+ };
2094
+ }
2095
+ };
2096
+
2097
+ //#endregion
2098
+ //#region src/rules/ssr/prefer-request-context.ts
2099
+ const preferRequestContext = {
2100
+ meta: {
2101
+ id: "pyreon/prefer-request-context",
2102
+ category: "ssr",
2103
+ description: "Warn about module-level signal()/createStore() in server files — use request context instead.",
2104
+ severity: "warn",
2105
+ fixable: false
2106
+ },
2107
+ create(context) {
2108
+ const filePath = context.getFilePath();
2109
+ if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
2110
+ let functionDepth = 0;
2111
+ return {
2112
+ FunctionDeclaration() {
2113
+ functionDepth++;
2114
+ },
2115
+ "FunctionDeclaration:exit"() {
2116
+ functionDepth--;
2117
+ },
2118
+ FunctionExpression() {
2119
+ functionDepth++;
2120
+ },
2121
+ "FunctionExpression:exit"() {
2122
+ functionDepth--;
2123
+ },
2124
+ ArrowFunctionExpression() {
2125
+ functionDepth++;
2126
+ },
2127
+ "ArrowFunctionExpression:exit"() {
2128
+ functionDepth--;
2129
+ },
2130
+ CallExpression(node) {
2131
+ if (functionDepth > 0) return;
2132
+ if (isCallTo(node, "signal") || isCallTo(node, "createStore")) {
2133
+ const name = node.callee.name;
2134
+ context.report({
2135
+ message: `Module-level \`${name}()\` in a server file — this state is shared across all requests. Use \`runWithRequestContext()\` for per-request isolation.`,
2136
+ span: getSpan(node)
2137
+ });
2138
+ }
2139
+ }
2140
+ };
2141
+ }
2142
+ };
2143
+
2144
+ //#endregion
2145
+ //#region src/rules/store/no-duplicate-store-id.ts
2146
+ const noDuplicateStoreId = {
2147
+ meta: {
2148
+ id: "pyreon/no-duplicate-store-id",
2149
+ category: "store",
2150
+ description: "Disallow duplicate defineStore() IDs in the same file.",
2151
+ severity: "error",
2152
+ fixable: false
2153
+ },
2154
+ create(context) {
2155
+ const storeIds = /* @__PURE__ */ new Map();
2156
+ return { CallExpression(node) {
2157
+ if (!isCallTo(node, "defineStore")) return;
2158
+ const args = node.arguments;
2159
+ if (!args || args.length === 0) return;
2160
+ const firstArg = args[0];
2161
+ if (!firstArg) return;
2162
+ let id = null;
2163
+ if (firstArg.type === "Literal" || firstArg.type === "StringLiteral") id = firstArg.value;
2164
+ if (typeof id !== "string") return;
2165
+ if (storeIds.has(id)) context.report({
2166
+ message: `Duplicate store ID \`"${id}"\` — each \`defineStore()\` must have a unique ID.`,
2167
+ span: getSpan(node)
2168
+ });
2169
+ else storeIds.set(id, getSpan(node));
2170
+ } };
2171
+ }
2172
+ };
2173
+
2174
+ //#endregion
2175
+ //#region src/rules/store/no-mutate-store-state.ts
2176
+ const noMutateStoreState = {
2177
+ meta: {
2178
+ id: "pyreon/no-mutate-store-state",
2179
+ category: "store",
2180
+ description: "Warn when directly calling .set() on store signals — use store actions instead.",
2181
+ severity: "warn",
2182
+ fixable: false
2183
+ },
2184
+ create(context) {
2185
+ return { CallExpression(node) {
2186
+ const callee = node.callee;
2187
+ if (!callee || callee.type !== "MemberExpression") return;
2188
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
2189
+ const obj = callee.object;
2190
+ if (!obj || obj.type !== "MemberExpression") return;
2191
+ const outerObj = obj.object;
2192
+ if (!outerObj || outerObj.type !== "Identifier") return;
2193
+ const name = outerObj.name;
2194
+ if (name.toLowerCase().includes("store")) context.report({
2195
+ message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
2196
+ span: getSpan(node)
2197
+ });
2198
+ } };
2199
+ }
2200
+ };
2201
+
2202
+ //#endregion
2203
+ //#region src/rules/store/no-store-outside-provider.ts
2204
+ const noStoreOutsideProvider = {
2205
+ meta: {
2206
+ id: "pyreon/no-store-outside-provider",
2207
+ category: "store",
2208
+ description: "Warn when store hooks are used in SSR files without a provider import.",
2209
+ severity: "warn",
2210
+ fixable: false
2211
+ },
2212
+ create(context) {
2213
+ const filePath = context.getFilePath();
2214
+ if (!(filePath.includes("server") || filePath.includes(".server.") || filePath.endsWith("server.ts") || filePath.endsWith("server.tsx"))) return {};
2215
+ let hasProviderImport = false;
2216
+ const storeHookCalls = [];
2217
+ return {
2218
+ ImportDeclaration(node) {
2219
+ const info = extractImportInfo(node);
2220
+ if (!info) return;
2221
+ if (info.specifiers.some((s) => s.imported === "setStoreRegistryProvider" || s.imported === "runWithRequestContext")) hasProviderImport = true;
2222
+ },
2223
+ CallExpression(node) {
2224
+ const callee = node.callee;
2225
+ if (!callee || callee.type !== "Identifier") return;
2226
+ const name = callee.name;
2227
+ if (name.endsWith("Store") && name.startsWith("use")) storeHookCalls.push({
2228
+ name,
2229
+ span: getSpan(node)
2230
+ });
2231
+ },
2232
+ "Program:exit"() {
2233
+ if (hasProviderImport) return;
2234
+ for (const call of storeHookCalls) context.report({
2235
+ message: `\`${call.name}()\` in a server file without a store registry provider — use \`runWithRequestContext()\` or \`setStoreRegistryProvider()\` for SSR isolation.`,
2236
+ span: call.span
2237
+ });
2238
+ }
2239
+ };
2240
+ }
2241
+ };
2242
+
2243
+ //#endregion
2244
+ //#region src/rules/styling/no-dynamic-styled.ts
2245
+ const noDynamicStyled = {
2246
+ meta: {
2247
+ id: "pyreon/no-dynamic-styled",
2248
+ category: "styling",
2249
+ description: "Warn when styled() is called inside a function — it creates new CSS on every render.",
2250
+ severity: "warn",
2251
+ fixable: false
2252
+ },
2253
+ create(context) {
2254
+ let functionDepth = 0;
2255
+ return {
2256
+ FunctionDeclaration() {
2257
+ functionDepth++;
2258
+ },
2259
+ "FunctionDeclaration:exit"() {
2260
+ functionDepth--;
2261
+ },
2262
+ FunctionExpression() {
2263
+ functionDepth++;
2264
+ },
2265
+ "FunctionExpression:exit"() {
2266
+ functionDepth--;
2267
+ },
2268
+ ArrowFunctionExpression() {
2269
+ functionDepth++;
2270
+ },
2271
+ "ArrowFunctionExpression:exit"() {
2272
+ functionDepth--;
2273
+ },
2274
+ CallExpression(node) {
2275
+ if (functionDepth === 0) return;
2276
+ if (isCallTo(node, "styled")) context.report({
2277
+ message: "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
2278
+ span: getSpan(node)
2279
+ });
2280
+ },
2281
+ TaggedTemplateExpression(node) {
2282
+ if (functionDepth === 0) return;
2283
+ const tag = node.tag;
2284
+ if (!tag) return;
2285
+ if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
2286
+ message: "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
2287
+ span: getSpan(node)
2288
+ });
2289
+ }
2290
+ };
2291
+ }
2292
+ };
2293
+
2294
+ //#endregion
2295
+ //#region src/rules/styling/no-inline-style-object.ts
2296
+ const noInlineStyleObject = {
2297
+ meta: {
2298
+ id: "pyreon/no-inline-style-object",
2299
+ category: "styling",
2300
+ description: "Warn against inline style objects in JSX — prefer styled() or css``.",
2301
+ severity: "warn",
2302
+ fixable: false
2303
+ },
2304
+ create(context) {
2305
+ return { JSXAttribute(node) {
2306
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "style") return;
2307
+ const value = node.value;
2308
+ if (!value || value.type !== "JSXExpressionContainer") return;
2309
+ if (value.expression?.type === "ObjectExpression") context.report({
2310
+ message: "Inline style object in JSX — consider using `styled()` or `css\\`...\\`` for better performance and caching.",
2311
+ span: getSpan(node)
2312
+ });
2313
+ } };
2314
+ }
2315
+ };
2316
+
2317
+ //#endregion
2318
+ //#region src/rules/styling/no-theme-outside-provider.ts
2319
+ const noThemeOutsideProvider = {
2320
+ meta: {
2321
+ id: "pyreon/no-theme-outside-provider",
2322
+ category: "styling",
2323
+ description: "Warn when useTheme() is used without PyreonUI or ThemeProvider in the same file.",
2324
+ severity: "warn",
2325
+ fixable: false
2326
+ },
2327
+ create(context) {
2328
+ let hasProviderImport = false;
2329
+ const themeCalls = [];
2330
+ return {
2331
+ ImportDeclaration(node) {
2332
+ const info = extractImportInfo(node);
2333
+ if (!info) return;
2334
+ if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
2335
+ },
2336
+ CallExpression(node) {
2337
+ if (isCallTo(node, "useTheme")) themeCalls.push({ span: getSpan(node) });
2338
+ },
2339
+ "Program:exit"() {
2340
+ if (hasProviderImport) return;
2341
+ for (const call of themeCalls) context.report({
2342
+ message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
2343
+ span: call.span
2344
+ });
2345
+ }
2346
+ };
2347
+ }
2348
+ };
2349
+
2350
+ //#endregion
2351
+ //#region src/rules/styling/prefer-cx.ts
2352
+ const preferCx = {
2353
+ meta: {
2354
+ id: "pyreon/prefer-cx",
2355
+ category: "styling",
2356
+ description: "Suggest cx() for class composition instead of string concatenation or template literals.",
2357
+ severity: "info",
2358
+ fixable: false
2359
+ },
2360
+ create(context) {
2361
+ return { JSXAttribute(node) {
2362
+ if (node.name?.type !== "JSXIdentifier" || node.name.name !== "class") return;
2363
+ const value = node.value;
2364
+ if (!value || value.type !== "JSXExpressionContainer") return;
2365
+ const expr = value.expression;
2366
+ if (!expr) return;
2367
+ if (expr.type === "BinaryExpression" && expr.operator === "+") {
2368
+ context.report({
2369
+ message: "String concatenation in `class` attribute — use `cx()` for cleaner class composition.",
2370
+ span: getSpan(expr)
2371
+ });
2372
+ return;
2373
+ }
2374
+ if (expr.type === "TemplateLiteral" && expr.expressions?.length > 0) context.report({
2375
+ message: "Template literal in `class` attribute — use `cx()` for cleaner class composition.",
2376
+ span: getSpan(expr)
2377
+ });
2378
+ } };
2379
+ }
2380
+ };
2381
+
2382
+ //#endregion
2383
+ //#region src/rules/index.ts
2384
+ const allRules = [
2385
+ noBareSignalInJsx,
2386
+ noSignalInLoop,
2387
+ noNestedEffect,
2388
+ noPeekInTracked,
2389
+ noUnbatchedUpdates,
2390
+ preferComputed,
2391
+ noEffectAssignment,
2392
+ noSignalLeak,
2393
+ noMapInJsx,
2394
+ useByNotKey,
2395
+ noClassName,
2396
+ noHtmlFor,
2397
+ noOnChange,
2398
+ noTernaryConditional,
2399
+ noAndConditional,
2400
+ noIndexAsBy,
2401
+ noMissingForBy,
2402
+ noPropsDestructure,
2403
+ noChildrenAccess,
2404
+ noMissingCleanup,
2405
+ noMountInEffect,
2406
+ noEffectInMount,
2407
+ noDomInSetup,
2408
+ noLargeForWithoutBy,
2409
+ noEffectInFor,
2410
+ noEagerImport,
2411
+ preferShowOverDisplay,
2412
+ noWindowInSsr,
2413
+ noMismatchRisk,
2414
+ preferRequestContext,
2415
+ noCircularImport,
2416
+ noDeepImport,
2417
+ noCrossLayerImport,
2418
+ devGuardWarnings,
2419
+ noErrorWithoutPrefix,
2420
+ noStoreOutsideProvider,
2421
+ noMutateStoreState,
2422
+ noDuplicateStoreId,
2423
+ noUnregisteredField,
2424
+ noSubmitWithoutValidation,
2425
+ preferFieldArray,
2426
+ noInlineStyleObject,
2427
+ noDynamicStyled,
2428
+ preferCx,
2429
+ noThemeOutsideProvider,
2430
+ noRawAddEventListener,
2431
+ noRawSetInterval,
2432
+ noRawLocalStorage,
2433
+ toastA11y,
2434
+ dialogA11y,
2435
+ overlayA11y,
2436
+ noHrefNavigation,
2437
+ noImperativeNavigateInRender,
2438
+ noMissingFallback,
2439
+ preferUseIsActive
2440
+ ];
2441
+
2442
+ //#endregion
2443
+ //#region src/config/presets.ts
2444
+ /** Build a config where every rule uses its default severity. */
2445
+ function buildRecommended() {
2446
+ const rules = {};
2447
+ for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
2448
+ return { rules };
2449
+ }
2450
+ /** Build a config where every warn is promoted to error. */
2451
+ function buildStrict() {
2452
+ const base = buildRecommended();
2453
+ const rules = {};
2454
+ for (const [id, sev] of Object.entries(base.rules)) rules[id] = sev === "warn" ? "error" : sev;
2455
+ return { rules };
2456
+ }
2457
+ /** Build app config — recommended but disable library-only rules. */
2458
+ function buildApp() {
2459
+ return { rules: {
2460
+ ...buildRecommended().rules,
2461
+ "pyreon/dev-guard-warnings": "off",
2462
+ "pyreon/no-error-without-prefix": "off",
2463
+ "pyreon/no-circular-import": "off",
2464
+ "pyreon/no-cross-layer-import": "off"
2465
+ } };
2466
+ }
2467
+ /** Build lib config — strict + all architecture rules as error. */
2468
+ function buildLib() {
2469
+ return { rules: {
2470
+ ...buildStrict().rules,
2471
+ "pyreon/no-circular-import": "error",
2472
+ "pyreon/no-cross-layer-import": "error",
2473
+ "pyreon/dev-guard-warnings": "error",
2474
+ "pyreon/no-error-without-prefix": "error"
2475
+ } };
2476
+ }
2477
+ const presetBuilders = {
2478
+ recommended: buildRecommended,
2479
+ strict: buildStrict,
2480
+ app: buildApp,
2481
+ lib: buildLib
2482
+ };
2483
+ function getPreset(name) {
2484
+ return presetBuilders[name]();
2485
+ }
2486
+
2487
+ //#endregion
2488
+ //#region src/utils/source.ts
2489
+ /**
2490
+ * Fast offset→line/column conversion using binary search over precomputed line starts.
2491
+ */
2492
+ var LineIndex = class {
2493
+ lineStarts;
2494
+ constructor(sourceText) {
2495
+ this.lineStarts = [0];
2496
+ for (let i = 0; i < sourceText.length; i++) if (sourceText[i] === "\n") this.lineStarts.push(i + 1);
2497
+ }
2498
+ /** Convert a byte offset to a 1-based line and 0-based column. */
2499
+ locate(offset) {
2500
+ let lo = 0;
2501
+ let hi = this.lineStarts.length - 1;
2502
+ while (lo <= hi) {
2503
+ const mid = lo + hi >>> 1;
2504
+ if (this.lineStarts[mid] <= offset) lo = mid + 1;
2505
+ else hi = mid - 1;
2506
+ }
2507
+ const line = lo;
2508
+ return {
2509
+ line,
2510
+ column: offset - this.lineStarts[line - 1]
2511
+ };
2512
+ }
2513
+ };
2514
+
2515
+ //#endregion
2516
+ //#region src/utils/index.ts
2517
+ /** Supported JS/TS file extensions for linting. */
2518
+ const JS_EXTENSIONS = new Set([
2519
+ ".ts",
2520
+ ".tsx",
2521
+ ".js",
2522
+ ".jsx",
2523
+ ".mts",
2524
+ ".mjs"
2525
+ ]);
2526
+ /** Check if a file path has a supported JS/TS extension. */
2527
+ function hasJsExtension(filePath) {
2528
+ const ext = filePath.slice(filePath.lastIndexOf("."));
2529
+ return JS_EXTENSIONS.has(ext);
2530
+ }
2531
+
2532
+ //#endregion
2533
+ //#region src/runner.ts
2534
+ function getExtension(filePath) {
2535
+ const lastDot = filePath.lastIndexOf(".");
2536
+ return lastDot === -1 ? "" : filePath.slice(lastDot);
2537
+ }
2538
+ function getLang(ext) {
2539
+ if (ext === ".tsx" || ext === ".jsx") return "tsx";
2540
+ if (ext === ".ts" || ext === ".mts") return "ts";
2541
+ return "js";
2542
+ }
2543
+ function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
2544
+ return {
2545
+ report(partial) {
2546
+ diagnostics.push({
2547
+ ruleId: rule.meta.id,
2548
+ severity,
2549
+ message: partial.message,
2550
+ span: partial.span,
2551
+ loc: lineIndex.locate(partial.span.start),
2552
+ fix: partial.fix
2553
+ });
2554
+ },
2555
+ getSourceText() {
2556
+ return sourceText;
2557
+ },
2558
+ getFilePath() {
2559
+ return filePath;
2560
+ }
2561
+ };
2562
+ }
2563
+ function mergeCallbacks(allCallbacks) {
2564
+ const callbacksByKey = {};
2565
+ for (const callbacks of allCallbacks) for (const [key, fn] of Object.entries(callbacks)) {
2566
+ const existing = callbacksByKey[key];
2567
+ if (existing) existing.push(fn);
2568
+ else callbacksByKey[key] = [fn];
2569
+ }
2570
+ const merged = {};
2571
+ for (const [key, fns] of Object.entries(callbacksByKey)) {
2572
+ const first = fns[0];
2573
+ if (fns.length === 1 && first) merged[key] = first;
2574
+ else merged[key] = (node) => {
2575
+ for (const fn of fns) fn(node);
2576
+ };
2577
+ }
2578
+ return merged;
2579
+ }
2580
+ /**
2581
+ * Lint a single file and return diagnostics.
2582
+ *
2583
+ * @example
2584
+ * ```ts
2585
+ * const result = lintFile("app.tsx", source, allRules, getPreset("recommended"))
2586
+ * for (const d of result.diagnostics) console.log(d.message)
2587
+ * ```
2588
+ */
2589
+ function lintFile(filePath, sourceText, rules, config, cache) {
2590
+ const ext = getExtension(filePath);
2591
+ if (!JS_EXTENSIONS.has(ext)) return {
2592
+ filePath,
2593
+ diagnostics: []
2594
+ };
2595
+ let lineIndex;
2596
+ let program;
2597
+ const cached = cache?.get(sourceText);
2598
+ if (cached) {
2599
+ lineIndex = cached.lineIndex;
2600
+ program = cached.program;
2601
+ } else {
2602
+ lineIndex = new LineIndex(sourceText);
2603
+ try {
2604
+ program = parseSync(filePath, sourceText, {
2605
+ sourceType: "module",
2606
+ lang: getLang(ext)
2607
+ }).program;
2608
+ } catch {
2609
+ return {
2610
+ filePath,
2611
+ diagnostics: []
2612
+ };
2613
+ }
2614
+ cache?.set(sourceText, {
2615
+ program,
2616
+ lineIndex
2617
+ });
2618
+ }
2619
+ const diagnostics = [];
2620
+ const allCallbacks = [];
2621
+ for (const rule of rules) {
2622
+ const severity = config.rules[rule.meta.id];
2623
+ if (severity === void 0 || severity === "off") continue;
2624
+ const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath);
2625
+ allCallbacks.push(rule.create(ctx));
2626
+ }
2627
+ new Visitor(mergeCallbacks(allCallbacks)).visit(program);
2628
+ diagnostics.sort((a, b) => a.span.start - b.span.start);
2629
+ return {
2630
+ filePath,
2631
+ diagnostics
2632
+ };
2633
+ }
2634
+ /**
2635
+ * Apply all auto-fixes to a source text.
2636
+ * Fixes are applied in reverse order to maintain correct offsets.
2637
+ */
2638
+ function applyFixes(sourceText, diagnostics) {
2639
+ const fixable = diagnostics.filter((d) => d.fix !== void 0);
2640
+ if (fixable.length === 0) return sourceText;
2641
+ const sorted = [...fixable].sort((a, b) => {
2642
+ const aFix = a.fix;
2643
+ const bFix = b.fix;
2644
+ if (!aFix || !bFix) return 0;
2645
+ return bFix.span.start - aFix.span.start;
2646
+ });
2647
+ let result = sourceText;
2648
+ for (const diag of sorted) {
2649
+ const fix = diag.fix;
2650
+ if (!fix) continue;
2651
+ result = result.slice(0, fix.span.start) + fix.replacement + result.slice(fix.span.end);
2652
+ }
2653
+ return result;
2654
+ }
2655
+
2656
+ //#endregion
2657
+ //#region src/lint.ts
2658
+ function isHiddenOrVendor(entry) {
2659
+ return entry.startsWith(".") || entry === "node_modules" || entry === "lib" || entry === "dist";
2660
+ }
2661
+ function matchesPatterns(filePath, include, exclude) {
2662
+ if (exclude) {
2663
+ for (const pattern of exclude) if (filePath.includes(pattern)) return false;
2664
+ }
2665
+ if (include && include.length > 0) {
2666
+ for (const pattern of include) if (filePath.includes(pattern)) return true;
2667
+ return false;
2668
+ }
2669
+ return true;
2670
+ }
2671
+ function walkDirectory(dir, files, isIgnored, include, exclude) {
2672
+ let entries;
2673
+ try {
2674
+ entries = readdirSync(dir);
2675
+ } catch {
2676
+ return;
2677
+ }
2678
+ for (const entry of entries) {
2679
+ if (isHiddenOrVendor(entry)) continue;
2680
+ const full = join(dir, entry);
2681
+ if (isIgnored(full)) continue;
2682
+ processEntry(full, files, isIgnored, include, exclude);
2683
+ }
2684
+ }
2685
+ function processEntry(full, files, isIgnored, include, exclude) {
2686
+ let stat;
2687
+ try {
2688
+ stat = statSync(full);
2689
+ } catch {
2690
+ return;
2691
+ }
2692
+ if (stat.isDirectory()) walkDirectory(full, files, isIgnored, include, exclude);
2693
+ else if (stat.isFile() && hasJsExtension(full) && matchesPatterns(full, include, exclude)) files.push(full);
2694
+ }
2695
+ function collectFiles(dir, isIgnored, include, exclude) {
2696
+ const files = [];
2697
+ walkDirectory(dir, files, isIgnored, include, exclude);
2698
+ return files;
2699
+ }
2700
+ function buildConfig(options) {
2701
+ const cwd = resolve(".");
2702
+ const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
2703
+ const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
2704
+ if (fileConfig?.rules) for (const [id, severity] of Object.entries(fileConfig.rules)) config.rules[id] = severity;
2705
+ if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
2706
+ return {
2707
+ config,
2708
+ include: fileConfig?.include,
2709
+ exclude: fileConfig?.exclude,
2710
+ isIgnored: createIgnoreFilter(cwd, options.ignore)
2711
+ };
2712
+ }
2713
+ function gatherFiles(paths, isIgnored, include, exclude) {
2714
+ const files = [];
2715
+ for (const p of paths) {
2716
+ const resolved = resolve(p);
2717
+ let stat;
2718
+ try {
2719
+ stat = statSync(resolved);
2720
+ } catch {
2721
+ continue;
2722
+ }
2723
+ if (stat.isDirectory()) files.push(...collectFiles(resolved, isIgnored, include, exclude));
2724
+ else if (stat.isFile() && !isIgnored(resolved)) files.push(resolved);
2725
+ }
2726
+ return files;
2727
+ }
2728
+ function applyFixesToFile(fileResult, source) {
2729
+ if (fileResult.diagnostics.filter((d) => d.fix).length === 0) return;
2730
+ const fixed = applyFixes(source, fileResult.diagnostics);
2731
+ writeFileSync(fileResult.filePath, fixed, "utf-8");
2732
+ fileResult.fixedSource = fixed;
2733
+ fileResult.diagnostics = fileResult.diagnostics.filter((d) => !d.fix);
2734
+ }
2735
+ function countDiagnostics(fileResult, results) {
2736
+ for (const d of fileResult.diagnostics) if (d.severity === "error") results.totalErrors++;
2737
+ else if (d.severity === "warn") results.totalWarnings++;
2738
+ else if (d.severity === "info") results.totalInfos++;
2739
+ }
2740
+ /**
2741
+ * Lint files and return results.
2742
+ *
2743
+ * @example
2744
+ * ```ts
2745
+ * import { lint } from "@pyreon/lint"
2746
+ *
2747
+ * const result = lint({ paths: ["src/"], preset: "recommended" })
2748
+ * console.log(result.totalErrors) // 0
2749
+ * ```
2750
+ */
2751
+ function lint(options) {
2752
+ const { config, include, exclude, isIgnored } = buildConfig(options);
2753
+ const cache = new AstCache();
2754
+ const files = gatherFiles(options.paths, isIgnored, include, exclude);
2755
+ const results = {
2756
+ files: [],
2757
+ totalErrors: 0,
2758
+ totalWarnings: 0,
2759
+ totalInfos: 0
2760
+ };
2761
+ for (const filePath of files) {
2762
+ let source;
2763
+ try {
2764
+ source = readFileSync(filePath, "utf-8");
2765
+ } catch {
2766
+ continue;
2767
+ }
2768
+ const fileResult = lintFile(filePath, source, allRules, config, cache);
2769
+ if (options.fix) applyFixesToFile(fileResult, source);
2770
+ if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
2771
+ countDiagnostics(fileResult, results);
2772
+ results.files.push(fileResult);
2773
+ }
2774
+ return results;
2775
+ }
2776
+ /**
2777
+ * List all available rules with their metadata.
2778
+ *
2779
+ * @example
2780
+ * ```ts
2781
+ * import { listRules } from "@pyreon/lint"
2782
+ *
2783
+ * for (const rule of listRules()) {
2784
+ * console.log(`${rule.id} (${rule.severity}): ${rule.description}`)
2785
+ * }
2786
+ * ```
2787
+ */
2788
+ function listRules() {
2789
+ return allRules.map((r) => r.meta);
2790
+ }
2791
+
2792
+ //#endregion
2793
+ //#region src/reporter.ts
2794
+ const BOLD = "\x1B[1m";
2795
+ const RED = "\x1B[31m";
2796
+ const YELLOW = "\x1B[33m";
2797
+ const BLUE = "\x1B[34m";
2798
+ const DIM = "\x1B[2m";
2799
+ const RESET = "\x1B[0m";
2800
+ const SEVERITY_SYMBOL = {
2801
+ error: `${RED}\u2716${RESET}`,
2802
+ warn: `${YELLOW}\u26A0${RESET}`,
2803
+ info: `${BLUE}\u2139${RESET}`,
2804
+ off: ""
2805
+ };
2806
+ const SEVERITY_LABEL = {
2807
+ error: `${RED}error${RESET}`,
2808
+ warn: `${YELLOW}warning${RESET}`,
2809
+ info: `${BLUE}info${RESET}`,
2810
+ off: ""
2811
+ };
2812
+ /**
2813
+ * Format results as human-readable colored text.
2814
+ */
2815
+ function formatText(result) {
2816
+ const lines = [];
2817
+ for (const file of result.files) {
2818
+ if (file.diagnostics.length === 0) continue;
2819
+ lines.push("");
2820
+ lines.push(`${BOLD}${file.filePath}${RESET}`);
2821
+ for (const d of file.diagnostics) {
2822
+ const loc = `${DIM}${d.loc.line}:${d.loc.column}${RESET}`;
2823
+ const severity = SEVERITY_LABEL[d.severity];
2824
+ const ruleId = `${DIM}${d.ruleId}${RESET}`;
2825
+ lines.push(` ${loc} ${severity} ${d.message} ${ruleId}`);
2826
+ }
2827
+ }
2828
+ if (result.totalErrors + result.totalWarnings + result.totalInfos > 0) {
2829
+ lines.push("");
2830
+ const parts = [];
2831
+ if (result.totalErrors > 0) parts.push(`${RED}${result.totalErrors} error${result.totalErrors === 1 ? "" : "s"}${RESET}`);
2832
+ if (result.totalWarnings > 0) parts.push(`${YELLOW}${result.totalWarnings} warning${result.totalWarnings === 1 ? "" : "s"}${RESET}`);
2833
+ if (result.totalInfos > 0) parts.push(`${BLUE}${result.totalInfos} info${RESET}`);
2834
+ lines.push(`${SEVERITY_SYMBOL.error} ${parts.join(", ")}`);
2835
+ lines.push("");
2836
+ }
2837
+ return lines.join("\n");
2838
+ }
2839
+ /**
2840
+ * Format results as JSON.
2841
+ */
2842
+ function formatJSON(result) {
2843
+ return JSON.stringify(result, null, 2);
2844
+ }
2845
+ /**
2846
+ * Format results as compact single-line-per-diagnostic output.
2847
+ */
2848
+ function formatCompact(result) {
2849
+ const lines = [];
2850
+ 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}`);
2851
+ return lines.join("\n");
2852
+ }
2853
+
2854
+ //#endregion
2855
+ //#region src/watcher.ts
2856
+ function formatOutput(result, format) {
2857
+ if (format === "json") return formatJSON(result);
2858
+ if (format === "compact") return formatCompact(result);
2859
+ return formatText(result);
2860
+ }
2861
+ /**
2862
+ * Watch directories and re-lint changed files.
2863
+ *
2864
+ * Uses `fs.watch` (recursive) with 100ms debounce.
2865
+ * Caches ASTs for unchanged files.
2866
+ *
2867
+ * @example
2868
+ * ```ts
2869
+ * import { watchAndLint } from "@pyreon/lint"
2870
+ *
2871
+ * watchAndLint({ paths: ["src/"], preset: "recommended", format: "text" })
2872
+ * ```
2873
+ */
2874
+ function watchAndLint(options) {
2875
+ const cache = new AstCache();
2876
+ const config = getPreset(options.preset ?? "recommended");
2877
+ applyOverrides(config, options.ruleOverrides);
2878
+ const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
2879
+ const pending = /* @__PURE__ */ new Map();
2880
+ console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
2881
+ for (const p of options.paths) {
2882
+ const dir = resolve(p);
2883
+ try {
2884
+ watch(dir, { recursive: true }, (_event, filename) => {
2885
+ if (!filename) return;
2886
+ const filePath = resolve(dir, filename);
2887
+ if (!hasJsExtension(filePath) || isIgnored(filePath)) return;
2888
+ const existing = pending.get(filePath);
2889
+ if (existing) clearTimeout(existing);
2890
+ pending.set(filePath, setTimeout(() => {
2891
+ pending.delete(filePath);
2892
+ relintFile(filePath, config, cache, options.format);
2893
+ }, 100));
2894
+ });
2895
+ } catch {
2896
+ console.error(`[pyreon-lint] Could not watch: ${dir}`);
2897
+ }
2898
+ }
2899
+ }
2900
+ function applyOverrides(config, overrides) {
2901
+ if (!overrides) return;
2902
+ for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
2903
+ }
2904
+ function relintFile(filePath, config, cache, format) {
2905
+ let source;
2906
+ try {
2907
+ source = readFileSync(filePath, "utf-8");
2908
+ } catch {
2909
+ return;
2910
+ }
2911
+ const fileResult = lintFile(filePath, source, allRules, config, cache);
2912
+ if (fileResult.diagnostics.length === 0) return;
2913
+ const result = {
2914
+ files: [fileResult],
2915
+ totalErrors: 0,
2916
+ totalWarnings: 0,
2917
+ totalInfos: 0
2918
+ };
2919
+ for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
2920
+ else if (d.severity === "warn") result.totalWarnings++;
2921
+ else if (d.severity === "info") result.totalInfos++;
2922
+ process.stdout.write("\x1B[2J\x1B[H");
2923
+ console.log(formatOutput(result, format));
2924
+ }
2925
+
2926
+ //#endregion
2927
+ //#region src/cli.ts
2928
+ const VERSION = "0.11.4";
2929
+ function printUsage() {
2930
+ console.log(`
2931
+ pyreon-lint [options] [path...]
2932
+
2933
+ Options:
2934
+ --preset <name> Preset: recommended (default), strict, app, lib
2935
+ --fix Auto-fix fixable issues
2936
+ --format <fmt> Output: text (default), json, compact
2937
+ --quiet Only show errors
2938
+ --list List all available rules
2939
+ --rule <id>=<sev> Override rule severity
2940
+ --config <path> Config file path
2941
+ --ignore <path> Ignore file path
2942
+ --watch Watch mode — re-lint on file changes
2943
+ --help, -h Show this help
2944
+ --version, -v Show version
2945
+ `);
2946
+ }
2947
+ function printList() {
2948
+ const rules = listRules();
2949
+ const maxId = Math.max(...rules.map((r) => r.id.length));
2950
+ const maxCat = Math.max(...rules.map((r) => r.category.length));
2951
+ for (const rule of rules) {
2952
+ const fixLabel = rule.fixable ? " [fixable]" : "";
2953
+ const id = rule.id.padEnd(maxId);
2954
+ const cat = rule.category.padEnd(maxCat);
2955
+ const sev = rule.severity.padEnd(5);
2956
+ console.log(` ${id} ${cat} ${sev} ${rule.description}${fixLabel}`);
2957
+ }
2958
+ console.log(`\n ${rules.length} rules total`);
2959
+ }
2960
+ const BOOLEAN_FLAGS = {
2961
+ "--help": "showHelp",
2962
+ "-h": "showHelp",
2963
+ "--version": "showVersion",
2964
+ "-v": "showVersion",
2965
+ "--list": "showList",
2966
+ "--fix": "fix",
2967
+ "--quiet": "quiet",
2968
+ "--watch": "watchMode"
2969
+ };
2970
+ function parseArgs(argv) {
2971
+ const result = {
2972
+ preset: "recommended",
2973
+ fix: false,
2974
+ format: "text",
2975
+ quiet: false,
2976
+ showList: false,
2977
+ showHelp: false,
2978
+ showVersion: false,
2979
+ watchMode: false,
2980
+ configPath: void 0,
2981
+ ignorePath: void 0,
2982
+ ruleOverrides: {},
2983
+ paths: []
2984
+ };
2985
+ for (let i = 0; i < argv.length; i++) {
2986
+ const arg = argv[i];
2987
+ const boolKey = BOOLEAN_FLAGS[arg];
2988
+ if (boolKey) {
2989
+ result[boolKey] = true;
2990
+ continue;
2991
+ }
2992
+ const consumed = parseValueFlag(arg, argv[i + 1], result);
2993
+ i += consumed;
2994
+ }
2995
+ return result;
2996
+ }
2997
+ /** Returns number of extra args consumed (0 or 1). */
2998
+ function parseValueFlag(arg, nextArg, result) {
2999
+ if (arg === "--preset") {
3000
+ result.preset = nextArg ?? "recommended";
3001
+ return 1;
3002
+ }
3003
+ if (arg === "--format") {
3004
+ result.format = nextArg ?? "text";
3005
+ return 1;
3006
+ }
3007
+ if (arg === "--config") {
3008
+ result.configPath = nextArg;
3009
+ return 1;
3010
+ }
3011
+ if (arg === "--ignore") {
3012
+ result.ignorePath = nextArg;
3013
+ return 1;
3014
+ }
3015
+ if (arg === "--rule") {
3016
+ parseRuleOverride(nextArg, result.ruleOverrides);
3017
+ return 1;
3018
+ }
3019
+ if (arg) result.paths.push(arg);
3020
+ return 0;
3021
+ }
3022
+ function parseRuleOverride(val, overrides) {
3023
+ if (!val) return;
3024
+ const eqIdx = val.lastIndexOf("=");
3025
+ if (eqIdx === -1) return;
3026
+ const ruleId = val.slice(0, eqIdx);
3027
+ overrides[ruleId] = val.slice(eqIdx + 1);
3028
+ }
3029
+ function main() {
3030
+ const args = parseArgs(process.argv.slice(2));
3031
+ if (args.showHelp) {
3032
+ printUsage();
3033
+ process.exit(0);
3034
+ }
3035
+ if (args.showVersion) {
3036
+ console.log(`pyreon-lint v${VERSION}`);
3037
+ process.exit(0);
3038
+ }
3039
+ if (args.showList) {
3040
+ printList();
3041
+ process.exit(0);
3042
+ }
3043
+ if (args.paths.length === 0) args.paths.push(".");
3044
+ if (args.watchMode) {
3045
+ watchAndLint({
3046
+ paths: args.paths,
3047
+ preset: args.preset,
3048
+ fix: args.fix,
3049
+ quiet: args.quiet,
3050
+ ruleOverrides: args.ruleOverrides,
3051
+ config: args.configPath,
3052
+ ignore: args.ignorePath,
3053
+ format: args.format
3054
+ });
3055
+ return;
3056
+ }
3057
+ const result = lint({
3058
+ paths: args.paths,
3059
+ preset: args.preset,
3060
+ fix: args.fix,
3061
+ quiet: args.quiet,
3062
+ ruleOverrides: args.ruleOverrides,
3063
+ config: args.configPath,
3064
+ ignore: args.ignorePath
3065
+ });
3066
+ if (args.format === "json") console.log(formatJSON(result));
3067
+ else if (args.format === "compact") console.log(formatCompact(result));
3068
+ else {
3069
+ const output = formatText(result);
3070
+ if (output) console.log(output);
3071
+ }
3072
+ if (result.totalErrors > 0) process.exit(1);
3073
+ }
3074
+ main();
3075
+
3076
+ //#endregion
3077
+ //# sourceMappingURL=cli.js.map