@pyreon/lint 0.12.13 → 0.12.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +55 -2
  2. package/lib/analysis/cli.js.html +1 -1
  3. package/lib/analysis/index.js.html +1 -1
  4. package/lib/cli.js +960 -162
  5. package/lib/cli.js.map +1 -1
  6. package/lib/index.js +935 -161
  7. package/lib/index.js.map +1 -1
  8. package/lib/types/index.d.ts +96 -23
  9. package/lib/types/index.d.ts.map +1 -1
  10. package/package.json +2 -1
  11. package/schema/pyreonlintrc.schema.json +64 -0
  12. package/src/cli.ts +44 -2
  13. package/src/config/presets.ts +13 -1
  14. package/src/index.ts +7 -0
  15. package/src/lint.ts +37 -6
  16. package/src/lsp/index.ts +15 -2
  17. package/src/rules/architecture/dev-guard-warnings.ts +172 -17
  18. package/src/rules/architecture/no-circular-import.ts +7 -0
  19. package/src/rules/architecture/no-process-dev-gate.ts +18 -45
  20. package/src/rules/architecture/require-browser-smoke-test.ts +227 -0
  21. package/src/rules/form/no-submit-without-validation.ts +9 -0
  22. package/src/rules/form/no-unregistered-field.ts +9 -0
  23. package/src/rules/hooks/no-raw-addeventlistener.ts +7 -0
  24. package/src/rules/hooks/no-raw-localstorage.ts +12 -1
  25. package/src/rules/hooks/no-raw-setinterval.ts +14 -0
  26. package/src/rules/index.ts +4 -1
  27. package/src/rules/jsx/no-props-destructure.ts +20 -6
  28. package/src/rules/lifecycle/no-dom-in-setup.ts +67 -7
  29. package/src/rules/reactivity/no-bare-signal-in-jsx.ts +12 -1
  30. package/src/rules/reactivity/no-unbatched-updates.ts +3 -0
  31. package/src/rules/router/no-imperative-navigate-in-render.ts +131 -35
  32. package/src/rules/ssr/no-window-in-ssr.ts +418 -35
  33. package/src/rules/store/no-duplicate-store-id.ts +11 -0
  34. package/src/rules/store/no-mutate-store-state.ts +11 -1
  35. package/src/rules/styling/no-dynamic-styled.ts +13 -24
  36. package/src/rules/styling/no-theme-outside-provider.ts +34 -2
  37. package/src/runner.ts +100 -10
  38. package/src/tests/runner.test.ts +1573 -21
  39. package/src/types.ts +74 -3
  40. package/src/utils/component-context.ts +106 -0
  41. package/src/utils/exempt-paths.ts +39 -0
  42. package/src/utils/file-roles.ts +32 -0
  43. package/src/utils/imports.ts +4 -1
  44. package/src/utils/validate-options.ts +68 -0
  45. package/src/watcher.ts +17 -0
package/lib/index.js CHANGED
@@ -1,7 +1,14 @@
1
1
  import { existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
2
- import { dirname, join, relative, resolve } from "node:path";
2
+ import path, { dirname, join, relative, resolve } from "node:path";
3
3
  import { Visitor, parseSync } from "oxc-parser";
4
4
 
5
+ //#region \0rolldown/runtime.js
6
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, { get: (a, b) => (typeof require !== "undefined" ? require : a)[b] }) : x)(function(x) {
7
+ if (typeof require !== "undefined") return require.apply(this, arguments);
8
+ throw Error("Calling `require` for \"" + x + "\" in an environment that doesn't expose the `require` function. See https://rolldown.rs/in-depth/bundling-cjs#require-external-modules for more details.");
9
+ });
10
+
11
+ //#endregion
5
12
  //#region src/cache.ts
6
13
  /**
7
14
  * Simple in-memory cache for parsed ASTs keyed by file content hash.
@@ -248,7 +255,6 @@ const BROWSER_GLOBALS = new Set([
248
255
  "localStorage",
249
256
  "sessionStorage",
250
257
  "indexedDB",
251
- "fetch",
252
258
  "XMLHttpRequest",
253
259
  "WebSocket",
254
260
  "requestAnimationFrame",
@@ -450,6 +456,44 @@ const toastA11y = {
450
456
  }
451
457
  };
452
458
 
459
+ //#endregion
460
+ //#region src/utils/exempt-paths.ts
461
+ function isPathExempt(ctx) {
462
+ const raw = ctx.getOptions().exemptPaths;
463
+ if (!Array.isArray(raw) || raw.length === 0) return false;
464
+ const filePath = ctx.getFilePath();
465
+ for (const entry of raw) if (typeof entry === "string" && entry.length > 0 && filePath.includes(entry)) return true;
466
+ return false;
467
+ }
468
+
469
+ //#endregion
470
+ //#region src/utils/file-roles.ts
471
+ /**
472
+ * Universal file-path classifiers for lint rules.
473
+ *
474
+ * What belongs here:
475
+ * - Conventions that exist in every project the linter runs on
476
+ * (test files, example directories — the `*.test.*` convention
477
+ * is not Pyreon-specific).
478
+ *
479
+ * What does NOT belong here:
480
+ * - Monorepo-specific paths like `packages/core/runtime-dom/` —
481
+ * those are implementation knowledge of one particular codebase
482
+ * and have no meaning in a user's app. Exemptions for such paths
483
+ * belong in the consuming project's lint config via the
484
+ * `exemptPaths: string[]` rule option — see `utils/exempt-paths.ts`
485
+ * and the Pyreon monorepo's `.pyreonlintrc.json` at repo root for
486
+ * reference.
487
+ */
488
+ /**
489
+ * Matches files that are tests by convention. Universal — the
490
+ * `*.test.*` / `*.spec.*` / `/tests/` / `/__tests__/` conventions
491
+ * exist in every codebase this linter runs on, not just Pyreon.
492
+ */
493
+ function isTestFile(filePath) {
494
+ return filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/__tests__/") || filePath.includes(".test.") || filePath.includes(".spec.");
495
+ }
496
+
453
497
  //#endregion
454
498
  //#region src/rules/architecture/dev-guard-warnings.ts
455
499
  const devGuardWarnings = {
@@ -458,26 +502,122 @@ const devGuardWarnings = {
458
502
  category: "architecture",
459
503
  description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
460
504
  severity: "error",
461
- fixable: false
505
+ fixable: false,
506
+ schema: {
507
+ exemptPaths: "string[]",
508
+ devFlagNames: "string[]"
509
+ }
462
510
  },
463
511
  create(context) {
464
- const filePath = context.getFilePath();
465
- if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/examples/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
512
+ if (isTestFile(context.getFilePath())) return {};
513
+ if (isPathExempt(context)) return {};
514
+ const userFlagNames = context.getOptions().devFlagNames;
515
+ const extraFlagNames = Array.isArray(userFlagNames) ? userFlagNames.filter((n) => typeof n === "string") : [];
516
+ const devFlagBoundConsts = /* @__PURE__ */ new Set();
517
+ function exprResolvesToDevFlag(expr) {
518
+ if (!expr) return false;
519
+ if (expr.type === "ChainExpression") return exprResolvesToDevFlag(expr.expression);
520
+ if (isDevFlag(expr)) return true;
521
+ if (expr.type === "BinaryExpression" && (expr.operator === "===" || expr.operator === "==")) return exprResolvesToDevFlag(expr.left) || exprResolvesToDevFlag(expr.right);
522
+ return false;
523
+ }
524
+ const DEV_FLAG_NAMES = new Set([
525
+ "__DEV__",
526
+ "IS_DEV",
527
+ "IS_DEVELOPMENT",
528
+ "isDev",
529
+ ...extraFlagNames
530
+ ]);
531
+ function isDevFlag(node) {
532
+ if (!node) return false;
533
+ if (node.type === "ChainExpression") return isDevFlag(node.expression);
534
+ if (node.type === "Identifier" && DEV_FLAG_NAMES.has(node.name)) return true;
535
+ if (node.type === "Identifier" && devFlagBoundConsts.has(node.name)) return true;
536
+ if (node.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === "DEV") {
537
+ const obj = node.object;
538
+ if (obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "env" && obj.object?.type === "MetaProperty") return true;
539
+ }
540
+ return false;
541
+ }
542
+ function containsDevGuard(test) {
543
+ if (!test) return false;
544
+ if (isDevFlag(test)) return true;
545
+ if (test.type === "LogicalExpression" && test.operator === "&&") return containsDevGuard(test.left) || containsDevGuard(test.right);
546
+ if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) return isDevFlag(test.left) || isDevFlag(test.right);
547
+ return false;
548
+ }
549
+ function isEarlyReturnDevGuard(node) {
550
+ if (!node || node.type !== "IfStatement") return false;
551
+ const t = node.test;
552
+ const arg = t?.type === "UnaryExpression" && t.operator === "!" ? t.argument : null;
553
+ if (!arg) return false;
554
+ if (!isDevFlag(arg)) return false;
555
+ const c = node.consequent;
556
+ if (c?.type === "ReturnStatement") return true;
557
+ if (c?.type === "BlockStatement" && c.body.length === 1 && c.body[0]?.type === "ReturnStatement") return true;
558
+ return false;
559
+ }
466
560
  let devGuardDepth = 0;
561
+ let catchDepth = 0;
562
+ const funcGuardStack = [];
563
+ function enterFunction(node) {
564
+ const body = node?.body;
565
+ const stmts = body?.type === "BlockStatement" ? body.body : null;
566
+ let guarded = 0;
567
+ if (stmts && stmts.length > 0 && isEarlyReturnDevGuard(stmts[0])) {
568
+ guarded = 1;
569
+ devGuardDepth++;
570
+ }
571
+ funcGuardStack.push(guarded);
572
+ }
573
+ function exitFunction() {
574
+ const g = funcGuardStack.pop() ?? 0;
575
+ if (g > 0) devGuardDepth -= g;
576
+ }
467
577
  return {
578
+ VariableDeclaration(node) {
579
+ for (const decl of node.declarations ?? []) if (decl.id?.type === "Identifier" && exprResolvesToDevFlag(decl.init)) devFlagBoundConsts.add(decl.id.name);
580
+ },
581
+ FunctionDeclaration: enterFunction,
582
+ "FunctionDeclaration:exit": exitFunction,
583
+ FunctionExpression: enterFunction,
584
+ "FunctionExpression:exit": exitFunction,
585
+ ArrowFunctionExpression: enterFunction,
586
+ "ArrowFunctionExpression:exit": exitFunction,
468
587
  IfStatement(node) {
469
- if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth++;
588
+ if (containsDevGuard(node.test)) devGuardDepth++;
470
589
  },
471
590
  "IfStatement:exit"(node) {
472
- if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth--;
591
+ if (containsDevGuard(node.test)) devGuardDepth--;
592
+ },
593
+ LogicalExpression(node) {
594
+ if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth++;
595
+ },
596
+ "LogicalExpression:exit"(node) {
597
+ if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth--;
598
+ },
599
+ ConditionalExpression(node) {
600
+ if (containsDevGuard(node.test)) devGuardDepth++;
601
+ },
602
+ "ConditionalExpression:exit"(node) {
603
+ if (containsDevGuard(node.test)) devGuardDepth--;
604
+ },
605
+ CatchClause() {
606
+ catchDepth++;
607
+ },
608
+ "CatchClause:exit"() {
609
+ catchDepth--;
473
610
  },
474
611
  CallExpression(node) {
475
612
  if (devGuardDepth > 0) return;
476
613
  const callee = node.callee;
477
- if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) context.report({
478
- message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\`.`,
479
- span: getSpan(node)
480
- });
614
+ if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) {
615
+ if (callee.property.name === "error" && catchDepth > 0) return;
616
+ context.report({
617
+ message: `\`console.${callee.property.name}()\` without \`__DEV__\` guard — dev warnings must be tree-shakeable in production. Wrap in \`if (__DEV__) { ... }\` (or \`__DEV__ && ...\`). Production error logging in \`catch\` blocks is exempt for \`console.error\`.`,
618
+ span: getSpan(node)
619
+ });
620
+ }
481
621
  }
482
622
  };
483
623
  }
@@ -514,7 +654,9 @@ const noCircularImport = {
514
654
  fixable: false
515
655
  },
516
656
  create(context) {
517
- const fileLayer = getFileLayer(context.getFilePath());
657
+ const filePath = context.getFilePath();
658
+ if (isTestFile(filePath)) return {};
659
+ const fileLayer = getFileLayer(filePath);
518
660
  if (fileLayer === null) return {};
519
661
  return { ImportDeclaration(node) {
520
662
  const source = node.source?.value;
@@ -704,34 +846,19 @@ const noErrorWithoutPrefix = {
704
846
  * Does NOT delete the const declaration — that has to happen by hand because
705
847
  * the variable name and downstream usages may need updating in callers.
706
848
  *
707
- * **Server-package exception**: server-only files run in Node where `process`
708
- * is always defined, so the pattern is correct there. The rule skips files
709
- * matching `packages/zero/`, `packages/core/server/`, `packages/core/runtime-server/`,
710
- * `packages/tools/vite-plugin/`, and any file containing `/server/` in its
711
- * path. Add new server packages to `SERVER_PACKAGE_PATTERNS` below.
712
- */
713
- /**
714
- * File-path patterns for server-only packages. Substring match against the
715
- * file path passed to the rule. Patterns intentionally do NOT start with `/`
716
- * so they match both absolute paths (`/Users/.../packages/zero/...`) and
717
- * relative paths (`packages/zero/...`) — different lint runners pass paths
718
- * differently.
849
+ * **Server-only exemption**: projects configure `exemptPaths` per-file for
850
+ * server-only code (Node environments where `process` is always defined and
851
+ * the pattern is correct). Configure in `.pyreonlintrc.json`:
852
+ *
853
+ * {
854
+ * "rules": {
855
+ * "pyreon/no-process-dev-gate": [
856
+ * "error",
857
+ * { "exemptPaths": ["packages/zero/", "packages/core/server/"] }
858
+ * ]
859
+ * }
860
+ * }
719
861
  */
720
- const SERVER_PACKAGE_PATTERNS = [
721
- "packages/zero/",
722
- "packages/core/server/",
723
- "packages/core/runtime-server/",
724
- "packages/tools/vite-plugin/",
725
- "packages/tools/cli/",
726
- "packages/tools/lint/",
727
- "packages/tools/mcp/",
728
- "packages/tools/storybook/",
729
- "packages/tools/typescript/",
730
- "scripts/"
731
- ];
732
- function isServerOnlyFile(filePath) {
733
- return SERVER_PACKAGE_PATTERNS.some((pat) => filePath.includes(pat));
734
- }
735
862
  const noProcessDevGate = {
736
863
  meta: {
737
864
  id: "pyreon/no-process-dev-gate",
@@ -741,9 +868,8 @@ const noProcessDevGate = {
741
868
  fixable: true
742
869
  },
743
870
  create(context) {
744
- const filePath = context.getFilePath();
745
- if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/__tests__/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
746
- if (isServerOnlyFile(filePath)) return {};
871
+ if (isTestFile(context.getFilePath())) return {};
872
+ if (isPathExempt(context)) return {};
747
873
  /**
748
874
  * Match the broken pattern at the AST level. We're looking for any
749
875
  * `LogicalExpression` whose two sides are:
@@ -800,6 +926,185 @@ const noProcessDevGate = {
800
926
  }
801
927
  };
802
928
 
929
+ //#endregion
930
+ //#region src/rules/architecture/require-browser-smoke-test.ts
931
+ /**
932
+ * `pyreon/require-browser-smoke-test` — every browser-categorized package
933
+ * must ship at least one `*.browser.test.{ts,tsx}` file under `src/`.
934
+ *
935
+ * Locks in the durability of the T1.1 browser smoke harness (PRs #224,
936
+ * #227, #229, #231). Without this rule, any new browser-running package
937
+ * can quietly ship without a real-browser smoke test and we drift back
938
+ * to the world before T1.1 — where happy-dom silently masks
939
+ * environment-divergence bugs (PR #197 mock-vnode metadata drop, PR
940
+ * #200 `typeof process` dead code, the multi-word event delegation bug
941
+ * fixed alongside PR #231).
942
+ *
943
+ * **What it checks**: when linting a package's `src/index.ts`, the rule
944
+ * looks at the package directory for any file matching
945
+ * `**\/*.browser.test.{ts,tsx}`. If none are found AND the package's
946
+ * name appears in the browser-categorized list, the rule reports an
947
+ * error on `src/index.ts`.
948
+ *
949
+ * **Why src/index.ts only**: the rule needs to fire exactly once per
950
+ * package, not per file. `src/index.ts` is a stable per-package entry
951
+ * point. Files inside the package are not browser-test files
952
+ * themselves, so they get skipped via the path check.
953
+ *
954
+ * **Default browser packages list**: matches the categorization in
955
+ * `.claude/rules/test-environment-parity.md`. Override via the
956
+ * `additionalPackages` option to opt in new packages, or via
957
+ * `exemptPaths` to opt out (e.g. for a brand-new package still under
958
+ * construction).
959
+ *
960
+ * @example Configuration in `.pyreonlintrc.json`
961
+ * ```json
962
+ * {
963
+ * "rules": {
964
+ * "pyreon/require-browser-smoke-test": [
965
+ * "error",
966
+ * {
967
+ * "additionalPackages": ["@my-org/my-browser-pkg"],
968
+ * "exemptPaths": ["packages/experimental/"]
969
+ * }
970
+ * ]
971
+ * }
972
+ * }
973
+ * ```
974
+ *
975
+ * **Known limitation — file existence, not test quality.** The rule only
976
+ * checks that at least one `*.browser.test.*` file exists under `src/`;
977
+ * it cannot assess whether the test is meaningful. A package could ship
978
+ * `sanity.browser.test.ts` with `expect(1).toBe(1)` and satisfy the
979
+ * rule. That's accepted by design — the rule is a *gate* against
980
+ * packages shipping with zero smoke coverage, not a quality check.
981
+ * Review the actual test contents on PR. If drive-by one-liner tests
982
+ * become a pattern, add a per-package coverage threshold or a
983
+ * complementary rule that inspects test file contents.
984
+ */
985
+ /**
986
+ * Single source of truth for browser-categorized packages lives at
987
+ * `.claude/rules/browser-packages.json`. Loading it lazily here means:
988
+ *
989
+ * 1. Updating the list never requires re-publishing `@pyreon/lint`.
990
+ * 2. The script `scripts/check-browser-smoke.ts` + the human-readable
991
+ * `.claude/rules/test-environment-parity.md` share the same source,
992
+ * so they can't drift out of sync silently.
993
+ *
994
+ * The JSON is searched for by walking up from the linted file's directory
995
+ * to the first ancestor containing `.claude/rules/browser-packages.json`.
996
+ * If not found (rule running in a consumer repo that doesn't ship the
997
+ * JSON), the rule falls back to an empty list — `additionalPackages`
998
+ * becomes the only signal and the rule stays opt-in, not a footgun.
999
+ *
1000
+ * Cached globally because the list is tiny and lint runs lint thousands
1001
+ * of files per invocation.
1002
+ */
1003
+ let _cachedBrowserPackages = null;
1004
+ function loadBrowserPackages(fromFile) {
1005
+ if (_cachedBrowserPackages) return _cachedBrowserPackages;
1006
+ let dir = path.dirname(fromFile);
1007
+ for (let i = 0; i < 30; i++) {
1008
+ const candidate = path.join(dir, ".claude", "rules", "browser-packages.json");
1009
+ if (existsSync(candidate)) {
1010
+ try {
1011
+ const fs = __require("node:fs");
1012
+ const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
1013
+ if (Array.isArray(parsed.packages)) {
1014
+ _cachedBrowserPackages = new Set(parsed.packages.filter((p) => typeof p === "string"));
1015
+ return _cachedBrowserPackages;
1016
+ }
1017
+ } catch {}
1018
+ break;
1019
+ }
1020
+ const parent = path.dirname(dir);
1021
+ if (parent === dir) break;
1022
+ dir = parent;
1023
+ }
1024
+ _cachedBrowserPackages = /* @__PURE__ */ new Set();
1025
+ return _cachedBrowserPackages;
1026
+ }
1027
+ /**
1028
+ * Walk a directory looking for `*.browser.test.{ts,tsx}` files. Bails
1029
+ * on the first match — we only need to know `at least one exists`,
1030
+ * not enumerate them. Skips `node_modules`, `lib`, `dist`, and dot
1031
+ * directories so a package's own dependencies don't pollute the check.
1032
+ */
1033
+ function hasBrowserTest(dir) {
1034
+ let entries;
1035
+ try {
1036
+ entries = readdirSync(dir);
1037
+ } catch {
1038
+ return false;
1039
+ }
1040
+ for (const name of entries) {
1041
+ if (name.startsWith(".") || name === "node_modules" || name === "lib" || name === "dist") continue;
1042
+ const full = path.join(dir, name);
1043
+ let isDir = false;
1044
+ try {
1045
+ isDir = statSync(full).isDirectory();
1046
+ } catch {
1047
+ continue;
1048
+ }
1049
+ if (isDir) {
1050
+ if (hasBrowserTest(full)) return true;
1051
+ continue;
1052
+ }
1053
+ if (/\.browser\.test\.(?:ts|tsx)$/.test(name)) return true;
1054
+ }
1055
+ return false;
1056
+ }
1057
+ /**
1058
+ * Read the package.json `name` field for the directory containing the
1059
+ * given src/index.ts file. Returns null if not found.
1060
+ */
1061
+ function readPackageName(srcIndexPath) {
1062
+ const pkgPath = path.resolve(path.dirname(srcIndexPath), "..", "package.json");
1063
+ if (!existsSync(pkgPath)) return null;
1064
+ try {
1065
+ const text = __require("node:fs").readFileSync(pkgPath, "utf8");
1066
+ const parsed = JSON.parse(text);
1067
+ return typeof parsed.name === "string" ? parsed.name : null;
1068
+ } catch {
1069
+ return null;
1070
+ }
1071
+ }
1072
+ const requireBrowserSmokeTest = {
1073
+ meta: {
1074
+ id: "pyreon/require-browser-smoke-test",
1075
+ category: "architecture",
1076
+ description: "Every browser-categorized package must ship at least one `*.browser.test.{ts,tsx}` file under `src/`. Locks in the T1.1 browser smoke harness.",
1077
+ severity: "error",
1078
+ fixable: false,
1079
+ schema: {
1080
+ additionalPackages: "string[]",
1081
+ exemptPaths: "string[]"
1082
+ }
1083
+ },
1084
+ create(context) {
1085
+ const filePath = context.getFilePath();
1086
+ if (!filePath.endsWith("/src/index.ts") && !filePath.endsWith("/src/index.tsx")) return {};
1087
+ if (isPathExempt(context)) return {};
1088
+ const pkgName = readPackageName(filePath);
1089
+ if (pkgName == null) return {};
1090
+ const options = context.getOptions();
1091
+ const additional = Array.isArray(options.additionalPackages) ? options.additionalPackages.filter((s) => typeof s === "string") : [];
1092
+ const browserPackages = new Set(loadBrowserPackages(filePath));
1093
+ for (const p of additional) browserPackages.add(p);
1094
+ if (!browserPackages.has(pkgName)) return {};
1095
+ if (hasBrowserTest(path.dirname(path.dirname(filePath)))) return {};
1096
+ return { "Program:exit"(node) {
1097
+ context.report({
1098
+ message: `[Pyreon] Browser-categorized package "${pkgName}" has no \`*.browser.test.{ts,tsx}\` file. Add at least one real-browser smoke test under \`src/\` to catch environment-divergence bugs that happy-dom hides (typeof process dead code, real pointer events, computed styles, etc.). See .claude/rules/test-environment-parity.md for the recipe.`,
1099
+ span: {
1100
+ start: node.start ?? 0,
1101
+ end: node.end ?? 0
1102
+ }
1103
+ });
1104
+ } };
1105
+ }
1106
+ };
1107
+
803
1108
  //#endregion
804
1109
  //#region src/rules/form/no-submit-without-validation.ts
805
1110
  const noSubmitWithoutValidation = {
@@ -811,6 +1116,7 @@ const noSubmitWithoutValidation = {
811
1116
  fixable: false
812
1117
  },
813
1118
  create(context) {
1119
+ if (isTestFile(context.getFilePath())) return {};
814
1120
  return { CallExpression(node) {
815
1121
  if (!isCallTo(node, "useForm")) return;
816
1122
  const args = node.arguments;
@@ -846,6 +1152,7 @@ const noUnregisteredField = {
846
1152
  fixable: false
847
1153
  },
848
1154
  create(context) {
1155
+ if (isTestFile(context.getFilePath())) return {};
849
1156
  const fieldDecls = /* @__PURE__ */ new Map();
850
1157
  const registeredNames = /* @__PURE__ */ new Set();
851
1158
  return {
@@ -911,9 +1218,11 @@ const noRawAddEventListener = {
911
1218
  category: "hooks",
912
1219
  description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
913
1220
  severity: "info",
914
- fixable: false
1221
+ fixable: false,
1222
+ schema: { exemptPaths: "string[]" }
915
1223
  },
916
1224
  create(context) {
1225
+ if (isPathExempt(context)) return {};
917
1226
  return { CallExpression(node) {
918
1227
  const callee = node.callee;
919
1228
  if (!callee || callee.type !== "MemberExpression") return;
@@ -926,6 +1235,41 @@ const noRawAddEventListener = {
926
1235
  }
927
1236
  };
928
1237
 
1238
+ //#endregion
1239
+ //#region src/utils/component-context.ts
1240
+ const COMPONENT_NAME = /^[A-Z]/;
1241
+ const HOOK_NAME$1 = /^use[A-Z]/;
1242
+ function isComponentOrHookName(name) {
1243
+ if (!name) return false;
1244
+ return COMPONENT_NAME.test(name) || HOOK_NAME$1.test(name);
1245
+ }
1246
+ function createComponentContextTracker() {
1247
+ let depth = 0;
1248
+ function declaratorIsComponentOrHook(node) {
1249
+ if (node?.id?.type !== "Identifier") return false;
1250
+ const init = node.init;
1251
+ if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
1252
+ return isComponentOrHookName(node.id.name);
1253
+ }
1254
+ return {
1255
+ isInComponentOrHook: () => depth > 0,
1256
+ callbacks: {
1257
+ FunctionDeclaration(node) {
1258
+ if (isComponentOrHookName(node.id?.name)) depth++;
1259
+ },
1260
+ "FunctionDeclaration:exit"(node) {
1261
+ if (isComponentOrHookName(node.id?.name)) depth--;
1262
+ },
1263
+ VariableDeclarator(node) {
1264
+ if (declaratorIsComponentOrHook(node)) depth++;
1265
+ },
1266
+ "VariableDeclarator:exit"(node) {
1267
+ if (declaratorIsComponentOrHook(node)) depth--;
1268
+ }
1269
+ }
1270
+ };
1271
+ }
1272
+
929
1273
  //#endregion
930
1274
  //#region src/rules/hooks/no-raw-localstorage.ts
931
1275
  const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
@@ -938,19 +1282,24 @@ const noRawLocalStorage = {
938
1282
  meta: {
939
1283
  id: "pyreon/no-raw-localstorage",
940
1284
  category: "hooks",
941
- description: "Suggest useStorage() instead of raw localStorage/sessionStorage access.",
1285
+ description: "Suggest useStorage() instead of raw localStorage/sessionStorage inside a component or hook.",
942
1286
  severity: "info",
943
1287
  fixable: false
944
1288
  },
945
1289
  create(context) {
946
- return { CallExpression(node) {
947
- const callee = node.callee;
948
- if (!callee || callee.type !== "MemberExpression") return;
949
- if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
950
- message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
951
- span: getSpan(node)
952
- });
953
- } };
1290
+ const ctx = createComponentContextTracker();
1291
+ return {
1292
+ ...ctx.callbacks,
1293
+ CallExpression(node) {
1294
+ if (!ctx.isInComponentOrHook()) return;
1295
+ const callee = node.callee;
1296
+ if (!callee || callee.type !== "MemberExpression") return;
1297
+ if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
1298
+ message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
1299
+ span: getSpan(node)
1300
+ });
1301
+ }
1302
+ };
954
1303
  }
955
1304
  };
956
1305
 
@@ -963,14 +1312,19 @@ const noRawSetInterval = {
963
1312
  category: "hooks",
964
1313
  description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
965
1314
  severity: "info",
966
- fixable: false
1315
+ fixable: false,
1316
+ schema: { exemptPaths: "string[]" }
967
1317
  },
968
1318
  create(context) {
1319
+ if (isPathExempt(context)) return {};
1320
+ const ctx = createComponentContextTracker();
969
1321
  let mountDepth = 0;
970
1322
  return {
1323
+ ...ctx.callbacks,
971
1324
  CallExpression(node) {
972
1325
  if (isCallTo(node, "onMount")) mountDepth++;
973
1326
  if (mountDepth > 0) return;
1327
+ if (!ctx.isInComponentOrHook()) return;
974
1328
  const callee = node.callee;
975
1329
  if (!callee || callee.type !== "Identifier") return;
976
1330
  if (TIMER_FNS.has(callee.name)) context.report({
@@ -1280,24 +1634,28 @@ const noPropsDestructure = {
1280
1634
  },
1281
1635
  create(context) {
1282
1636
  let functionDepth = 0;
1637
+ const callArgFns = /* @__PURE__ */ new WeakSet();
1283
1638
  return {
1639
+ CallExpression(node) {
1640
+ for (const arg of node.arguments ?? []) if (arg?.type === "ArrowFunctionExpression" || arg?.type === "FunctionExpression" || arg?.type === "FunctionDeclaration") callArgFns.add(arg);
1641
+ },
1284
1642
  ArrowFunctionExpression(node) {
1285
1643
  functionDepth++;
1286
- checkFunction(node, context, functionDepth);
1644
+ checkFunction(node, context, functionDepth, callArgFns);
1287
1645
  },
1288
1646
  "ArrowFunctionExpression:exit"() {
1289
1647
  functionDepth--;
1290
1648
  },
1291
1649
  FunctionDeclaration(node) {
1292
1650
  functionDepth++;
1293
- checkFunction(node, context, functionDepth);
1651
+ checkFunction(node, context, functionDepth, callArgFns);
1294
1652
  },
1295
1653
  "FunctionDeclaration:exit"() {
1296
1654
  functionDepth--;
1297
1655
  },
1298
1656
  FunctionExpression(node) {
1299
1657
  functionDepth++;
1300
- checkFunction(node, context, functionDepth);
1658
+ checkFunction(node, context, functionDepth, callArgFns);
1301
1659
  },
1302
1660
  "FunctionExpression:exit"() {
1303
1661
  functionDepth--;
@@ -1305,14 +1663,13 @@ const noPropsDestructure = {
1305
1663
  };
1306
1664
  }
1307
1665
  };
1308
- function checkFunction(node, context, depth) {
1666
+ function checkFunction(node, context, depth, callArgFns) {
1309
1667
  const params = node.params;
1310
1668
  if (!params || params.length === 0) return;
1311
1669
  const firstParam = params[0];
1312
1670
  if (!isDestructuring(firstParam)) return;
1313
1671
  if (depth > 1) return;
1314
- const parent = node.parent;
1315
- if (parent?.type === "CallExpression" && parent.arguments?.includes(node)) return;
1672
+ if (callArgFns.has(node)) return;
1316
1673
  const body = node.body;
1317
1674
  if (!body) return;
1318
1675
  if (containsJSXReturn(body)) {
@@ -1409,9 +1766,47 @@ const noDomInSetup = {
1409
1766
  },
1410
1767
  create(context) {
1411
1768
  let safeDepth = 0;
1769
+ function isSafeContextCall(node) {
1770
+ return isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame");
1771
+ }
1772
+ function isNegatedTypeofDocument(test) {
1773
+ if (!test) return false;
1774
+ if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==") && test.left?.type === "UnaryExpression" && test.left.operator === "typeof" && test.left.argument?.type === "Identifier" && (test.left.argument.name === "document" || test.left.argument.name === "window")) return true;
1775
+ return false;
1776
+ }
1777
+ function isEarlyReturnDocumentGuard(stmt) {
1778
+ if (!stmt || stmt.type !== "IfStatement") return false;
1779
+ if (!isNegatedTypeofDocument(stmt.test)) return false;
1780
+ const c = stmt.consequent;
1781
+ const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
1782
+ if (isTerminator(c)) return true;
1783
+ if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
1784
+ return false;
1785
+ }
1786
+ const earlyReturnStack = [];
1787
+ function pushFunctionScope(node) {
1788
+ const body = node?.body;
1789
+ const stmts = body?.type === "BlockStatement" ? body.body : null;
1790
+ let bumps = 0;
1791
+ if (stmts && stmts.length > 0 && isEarlyReturnDocumentGuard(stmts[0])) {
1792
+ bumps = 1;
1793
+ safeDepth++;
1794
+ }
1795
+ earlyReturnStack.push(bumps);
1796
+ }
1797
+ function popFunctionScope() {
1798
+ const bumps = earlyReturnStack.pop() ?? 0;
1799
+ if (bumps > 0) safeDepth -= bumps;
1800
+ }
1412
1801
  return {
1802
+ FunctionDeclaration: pushFunctionScope,
1803
+ "FunctionDeclaration:exit": popFunctionScope,
1804
+ FunctionExpression: pushFunctionScope,
1805
+ "FunctionExpression:exit": popFunctionScope,
1806
+ ArrowFunctionExpression: pushFunctionScope,
1807
+ "ArrowFunctionExpression:exit": popFunctionScope,
1413
1808
  CallExpression(node) {
1414
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
1809
+ if (isSafeContextCall(node)) safeDepth++;
1415
1810
  if (safeDepth > 0) return;
1416
1811
  const callee = node.callee;
1417
1812
  if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "document" && callee.property?.type === "Identifier" && DOM_METHODS.has(callee.property.name)) context.report({
@@ -1420,7 +1815,7 @@ const noDomInSetup = {
1420
1815
  });
1421
1816
  },
1422
1817
  "CallExpression:exit"(node) {
1423
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
1818
+ if (isSafeContextCall(node)) safeDepth--;
1424
1819
  }
1425
1820
  };
1426
1821
  }
@@ -1642,6 +2037,11 @@ const preferShowOverDisplay = {
1642
2037
 
1643
2038
  //#endregion
1644
2039
  //#region src/rules/reactivity/no-bare-signal-in-jsx.ts
2040
+ const SKIP_NAMES = new Set([
2041
+ "render",
2042
+ "h",
2043
+ "cloneVNode"
2044
+ ]);
1645
2045
  const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/;
1646
2046
  const noBareSignalInJsx = {
1647
2047
  meta: {
@@ -1673,7 +2073,7 @@ const noBareSignalInJsx = {
1673
2073
  const callee = expr.callee;
1674
2074
  if (!callee || callee.type !== "Identifier") return;
1675
2075
  const name = callee.name;
1676
- if (SKIP_PREFIXES.test(name)) return;
2076
+ if (SKIP_NAMES.has(name) || SKIP_PREFIXES.test(name)) return;
1677
2077
  const span = getSpan(node);
1678
2078
  const fixed = `{() => ${context.getSourceText().slice(span.start, span.end).slice(1, -1)}}`;
1679
2079
  context.report({
@@ -1974,9 +2374,11 @@ const noUnbatchedUpdates = {
1974
2374
  category: "reactivity",
1975
2375
  description: "Warn when 3+ .set() calls occur in the same function without batch().",
1976
2376
  severity: "warn",
1977
- fixable: false
2377
+ fixable: false,
2378
+ schema: { exemptPaths: "string[]" }
1978
2379
  },
1979
2380
  create(context) {
2381
+ if (isPathExempt(context)) return {};
1980
2382
  const scopeStack = [];
1981
2383
  let batchDepth = 0;
1982
2384
  function enterScope(node) {
@@ -2125,46 +2527,94 @@ const noImperativeNavigateInRender = {
2125
2527
  },
2126
2528
  create(context) {
2127
2529
  let componentBodyDepth = 0;
2128
- let safeDepth = 0;
2129
- return {
2530
+ let nestedFnDepth = 0;
2531
+ const componentInits = /* @__PURE__ */ new WeakSet();
2532
+ const dangerousBindings = [];
2533
+ const nestedFnStack = [];
2534
+ function isComponentFunctionDecl(node) {
2535
+ return /^[A-Z]/.test(node.id?.name ?? "");
2536
+ }
2537
+ function enterNestedFn(node, bindingName) {
2538
+ nestedFnDepth++;
2539
+ nestedFnStack.push({
2540
+ containsNavigate: false,
2541
+ bindingName
2542
+ });
2543
+ }
2544
+ function exitNestedFn() {
2545
+ nestedFnDepth--;
2546
+ const frame = nestedFnStack.pop();
2547
+ if (!frame) return;
2548
+ if (frame.containsNavigate && frame.bindingName && dangerousBindings.length > 0) dangerousBindings[dangerousBindings.length - 1].add(frame.bindingName);
2549
+ }
2550
+ function isNavigateCall(node) {
2551
+ return isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push");
2552
+ }
2553
+ const callbacks = {
2130
2554
  FunctionDeclaration(node) {
2131
- const name = node.id?.name ?? "";
2132
- if (/^[A-Z]/.test(name)) componentBodyDepth++;
2555
+ if (isComponentFunctionDecl(node)) {
2556
+ componentBodyDepth++;
2557
+ dangerousBindings.push(/* @__PURE__ */ new Set());
2558
+ } else if (componentBodyDepth > 0) enterNestedFn(node, node.id?.type === "Identifier" ? node.id.name : null);
2133
2559
  },
2134
2560
  "FunctionDeclaration:exit"(node) {
2135
- const name = node.id?.name ?? "";
2136
- if (/^[A-Z]/.test(name)) componentBodyDepth--;
2561
+ if (isComponentFunctionDecl(node)) {
2562
+ componentBodyDepth--;
2563
+ dangerousBindings.pop();
2564
+ } else if (componentBodyDepth > 0) exitNestedFn();
2137
2565
  },
2138
2566
  VariableDeclarator(node) {
2139
- const name = node.id?.name ?? "";
2140
- if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth++;
2567
+ if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
2568
+ componentBodyDepth++;
2569
+ dangerousBindings.push(/* @__PURE__ */ new Set());
2570
+ componentInits.add(node.init);
2571
+ }
2141
2572
  },
2142
2573
  "VariableDeclarator:exit"(node) {
2143
- const name = node.id?.name ?? "";
2144
- if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth--;
2574
+ if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
2575
+ componentBodyDepth--;
2576
+ dangerousBindings.pop();
2577
+ }
2578
+ },
2579
+ ArrowFunctionExpression(node) {
2580
+ if (componentInits.has(node)) return;
2581
+ if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
2582
+ },
2583
+ "ArrowFunctionExpression:exit"(node) {
2584
+ if (componentInits.has(node)) return;
2585
+ if (componentBodyDepth > 0) exitNestedFn();
2586
+ },
2587
+ FunctionExpression(node) {
2588
+ if (componentInits.has(node)) return;
2589
+ if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
2590
+ },
2591
+ "FunctionExpression:exit"(node) {
2592
+ if (componentInits.has(node)) return;
2593
+ if (componentBodyDepth > 0) exitNestedFn();
2145
2594
  },
2146
2595
  CallExpression(node) {
2147
2596
  if (componentBodyDepth <= 0) return;
2148
- if (isSafeWrapperCall(node)) safeDepth++;
2149
- if (safeDepth > 0) return;
2150
- if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) context.report({
2151
- 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.",
2597
+ if (isNavigateCall(node)) {
2598
+ if (nestedFnDepth === 0) context.report({
2599
+ 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.",
2600
+ span: getSpan(node)
2601
+ });
2602
+ else if (nestedFnStack.length > 0) nestedFnStack[nestedFnStack.length - 1].containsNavigate = true;
2603
+ return;
2604
+ }
2605
+ if (nestedFnDepth === 0 && node.callee?.type === "Identifier" && dangerousBindings.length > 0 && dangerousBindings[dangerousBindings.length - 1].has(node.callee.name)) context.report({
2606
+ message: "Synchronous call of a nested function that performs imperative navigation — this runs during render and causes infinite loops. Move the call inside `onMount`, `effect`, or an event handler.",
2152
2607
  span: getSpan(node)
2153
2608
  });
2154
- },
2155
- "CallExpression:exit"(node) {
2156
- if (componentBodyDepth <= 0) return;
2157
- if (isSafeWrapperCall(node)) safeDepth--;
2158
2609
  }
2159
2610
  };
2611
+ const bindingAssignmentNames = /* @__PURE__ */ new WeakMap();
2612
+ callbacks.VariableDeclaration = (node) => {
2613
+ for (const decl of node.declarations ?? []) if (decl.id?.type === "Identifier" && (decl.init?.type === "ArrowFunctionExpression" || decl.init?.type === "FunctionExpression")) bindingAssignmentNames.set(decl.init, decl.id.name);
2614
+ };
2615
+ return callbacks;
2160
2616
  }
2161
2617
  };
2162
- function isSafeWrapperCall(node) {
2163
- const callee = node.callee;
2164
- if (!callee || callee.type !== "Identifier") return false;
2165
- const name = callee.name;
2166
- return name === "onMount" || name === "effect" || name === "onUnmount";
2167
- }
2168
2618
 
2169
2619
  //#endregion
2170
2620
  //#region src/rules/router/no-missing-fallback.ts
@@ -2311,32 +2761,236 @@ const noWindowInSsr = {
2311
2761
  category: "ssr",
2312
2762
  description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
2313
2763
  severity: "error",
2314
- fixable: false
2764
+ fixable: false,
2765
+ schema: { exemptPaths: "string[]" }
2315
2766
  },
2316
2767
  create(context) {
2768
+ if (isPathExempt(context)) return {};
2317
2769
  let safeDepth = 0;
2318
2770
  let typeofGuardDepth = 0;
2771
+ let inTypeofExpr = 0;
2772
+ const skipPropertyNodes = /* @__PURE__ */ new WeakSet();
2773
+ let inTsTypePos = 0;
2774
+ const typeofBoundConsts = /* @__PURE__ */ new Set();
2775
+ function isPositiveTypeofCheck(expr) {
2776
+ if (!expr) return false;
2777
+ if (expr.type === "BinaryExpression" && (expr.operator === "!==" || expr.operator === "!=") && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
2778
+ if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
2779
+ return false;
2780
+ }
2781
+ /** Used by VariableDeclaration to decide whether to bind a const. */
2782
+ function isTypeofCheckForBinding(expr) {
2783
+ if (!expr) return false;
2784
+ if (expr.type === "BinaryExpression" && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
2785
+ if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
2786
+ if (expr.type === "LogicalExpression" && expr.operator === "&&") return isTypeofCheckForBinding(expr.left) || isTypeofCheckForBinding(expr.right);
2787
+ if (expr.type === "ConditionalExpression") return isTypeofCheckForBinding(expr.test);
2788
+ if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
2789
+ return false;
2790
+ }
2791
+ /**
2792
+ * `if (test) { … }` — does the test indicate the body is the
2793
+ * BROWSER-SAFE branch? Only positive forms qualify here. Negated
2794
+ * forms (`typeof X === 'undefined'`, `!isBrowser`) are early-return
2795
+ * guards handled separately.
2796
+ */
2797
+ function testIsTypeofGuard(test) {
2798
+ if (!test) return false;
2799
+ if (isPositiveTypeofCheck(test)) return true;
2800
+ if (test.type === "Identifier" && typeofBoundConsts.has(test.name)) return true;
2801
+ if (test.type === "CallExpression" && test.callee?.type === "Identifier" && typeofGuardFunctions.has(test.callee.name)) return true;
2802
+ if (test.type === "LogicalExpression" && test.operator === "&&") return testIsTypeofGuard(test.left) || testIsTypeofGuard(test.right);
2803
+ return false;
2804
+ }
2805
+ const typeofGuardFunctions = new Set([
2806
+ "isBrowser",
2807
+ "isClient",
2808
+ "isServer",
2809
+ "isSSR"
2810
+ ]);
2811
+ function bodyIsTypeofGuard(body) {
2812
+ if (!body) return false;
2813
+ if (body.type !== "BlockStatement") return isReturnedTypeofExpr(body);
2814
+ const stmts = body.body ?? [];
2815
+ if (stmts.length !== 1) return false;
2816
+ const stmt = stmts[0];
2817
+ if (stmt?.type !== "ReturnStatement") return false;
2818
+ return isReturnedTypeofExpr(stmt.argument);
2819
+ }
2820
+ function isReturnedTypeofExpr(expr) {
2821
+ if (!expr) return false;
2822
+ if (isPositiveTypeofCheck(expr)) return true;
2823
+ if (expr.type === "LogicalExpression" && expr.operator === "&&") return isReturnedTypeofExpr(expr.left) && isReturnedTypeofExpr(expr.right);
2824
+ if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
2825
+ return false;
2826
+ }
2827
+ function isNegatedTypeofExpr(test) {
2828
+ if (!test) return false;
2829
+ if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==") && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") return true;
2830
+ if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "Identifier" && typeofBoundConsts.has(test.argument.name)) return true;
2831
+ if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "CallExpression" && test.argument.callee?.type === "Identifier" && typeofGuardFunctions.has(test.argument.callee.name)) return true;
2832
+ if (test.type === "LogicalExpression" && test.operator === "||") return isNegatedTypeofExpr(test.left) && isNegatedTypeofExpr(test.right);
2833
+ return false;
2834
+ }
2835
+ function isEarlyReturnTypeofGuard(stmt) {
2836
+ if (!stmt || stmt.type !== "IfStatement") return false;
2837
+ if (!isNegatedTypeofExpr(stmt.test)) return false;
2838
+ const c = stmt.consequent;
2839
+ const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
2840
+ if (isTerminator(c)) return true;
2841
+ if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
2842
+ return false;
2843
+ }
2844
+ const earlyReturnStack = [];
2845
+ const watchCallbackNodes = /* @__PURE__ */ new WeakSet();
2846
+ const watchCallbackSafeDepthStack = [];
2847
+ const shadowedNamesStack = [];
2848
+ const importShadowedNames = /* @__PURE__ */ new Set();
2849
+ function collectParamNames(params) {
2850
+ const names = /* @__PURE__ */ new Set();
2851
+ const walk = (p) => {
2852
+ if (!p) return;
2853
+ if (p.type === "Identifier" && BROWSER_GLOBALS.has(p.name)) names.add(p.name);
2854
+ else if (p.type === "AssignmentPattern") walk(p.left);
2855
+ else if (p.type === "RestElement") walk(p.argument);
2856
+ else if (p.type === "ArrayPattern") for (const el of p.elements ?? []) walk(el);
2857
+ else if (p.type === "ObjectPattern") for (const prop of p.properties ?? []) if (prop.type === "RestElement") walk(prop.argument);
2858
+ else walk(prop.value);
2859
+ };
2860
+ for (const p of params ?? []) walk(p);
2861
+ return names;
2862
+ }
2863
+ function isNameShadowed(name) {
2864
+ for (let i = shadowedNamesStack.length - 1; i >= 0; i--) if (shadowedNamesStack[i].has(name)) return true;
2865
+ return false;
2866
+ }
2867
+ function pushFunctionScope(node) {
2868
+ earlyReturnStack.push(0);
2869
+ shadowedNamesStack.push(node ? collectParamNames(node.params ?? []) : /* @__PURE__ */ new Set());
2870
+ if (node && watchCallbackNodes.has(node)) {
2871
+ safeDepth++;
2872
+ watchCallbackSafeDepthStack.push(1);
2873
+ } else watchCallbackSafeDepthStack.push(0);
2874
+ }
2875
+ function popFunctionScope() {
2876
+ const bumps = earlyReturnStack.pop() ?? 0;
2877
+ typeofGuardDepth -= bumps;
2878
+ shadowedNamesStack.pop();
2879
+ const watchBump = watchCallbackSafeDepthStack.pop() ?? 0;
2880
+ if (watchBump > 0) safeDepth -= watchBump;
2881
+ }
2882
+ function noteEarlyReturnGuardVisit() {
2883
+ typeofGuardDepth++;
2884
+ if (earlyReturnStack.length > 0) earlyReturnStack[earlyReturnStack.length - 1]++;
2885
+ }
2319
2886
  return {
2887
+ VariableDeclaration(node) {
2888
+ for (const decl of node.declarations ?? []) {
2889
+ if (decl.id?.type !== "Identifier") continue;
2890
+ if (isTypeofCheckForBinding(decl.init)) typeofBoundConsts.add(decl.id.name);
2891
+ if ((decl.init?.type === "ArrowFunctionExpression" || decl.init?.type === "FunctionExpression") && bodyIsTypeofGuard(decl.init.body)) typeofGuardFunctions.add(decl.id.name);
2892
+ }
2893
+ },
2894
+ FunctionDeclaration(node) {
2895
+ if (node.id?.type === "Identifier" && bodyIsTypeofGuard(node.body)) typeofGuardFunctions.add(node.id.name);
2896
+ pushFunctionScope(node);
2897
+ },
2898
+ "FunctionDeclaration:exit": popFunctionScope,
2899
+ FunctionExpression: pushFunctionScope,
2900
+ "FunctionExpression:exit": popFunctionScope,
2901
+ ArrowFunctionExpression: pushFunctionScope,
2902
+ "ArrowFunctionExpression:exit": popFunctionScope,
2320
2903
  CallExpression(node) {
2321
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
2904
+ if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) {
2905
+ safeDepth++;
2906
+ return;
2907
+ }
2908
+ if (isCallTo(node, "watch")) {
2909
+ const cb = node.arguments?.[1];
2910
+ if (cb?.type === "ArrowFunctionExpression" || cb?.type === "FunctionExpression" || cb?.type === "FunctionDeclaration") watchCallbackNodes.add(cb);
2911
+ }
2322
2912
  },
2323
2913
  "CallExpression:exit"(node) {
2324
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
2914
+ if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) safeDepth--;
2325
2915
  },
2326
2916
  IfStatement(node) {
2327
- const test = node.test;
2328
- if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth++;
2917
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
2918
+ else if (isEarlyReturnTypeofGuard(node)) noteEarlyReturnGuardVisit();
2329
2919
  },
2330
2920
  "IfStatement:exit"(node) {
2331
- const test = node.test;
2332
- if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth--;
2921
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
2922
+ },
2923
+ ConditionalExpression(node) {
2924
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
2925
+ },
2926
+ "ConditionalExpression:exit"(node) {
2927
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
2928
+ },
2929
+ UnaryExpression(node) {
2930
+ if (node.operator === "typeof") inTypeofExpr++;
2931
+ },
2932
+ "UnaryExpression:exit"(node) {
2933
+ if (node.operator === "typeof") inTypeofExpr--;
2934
+ },
2935
+ TSTypeAnnotation(_n) {
2936
+ inTsTypePos++;
2937
+ },
2938
+ "TSTypeAnnotation:exit"(_n) {
2939
+ inTsTypePos--;
2940
+ },
2941
+ TSTypeReference(_n) {
2942
+ inTsTypePos++;
2943
+ },
2944
+ "TSTypeReference:exit"(_n) {
2945
+ inTsTypePos--;
2946
+ },
2947
+ TSTypeAliasDeclaration(_n) {
2948
+ inTsTypePos++;
2949
+ },
2950
+ "TSTypeAliasDeclaration:exit"(_n) {
2951
+ inTsTypePos--;
2952
+ },
2953
+ TSInterfaceDeclaration(_n) {
2954
+ inTsTypePos++;
2955
+ },
2956
+ "TSInterfaceDeclaration:exit"(_n) {
2957
+ inTsTypePos--;
2958
+ },
2959
+ TSTypeParameter(_n) {
2960
+ inTsTypePos++;
2961
+ },
2962
+ "TSTypeParameter:exit"(_n) {
2963
+ inTsTypePos--;
2964
+ },
2965
+ MemberExpression(node) {
2966
+ if (!node.computed && node.property?.type === "Identifier") skipPropertyNodes.add(node.property);
2967
+ },
2968
+ Property(node) {
2969
+ if (!node.computed && node.key?.type === "Identifier") skipPropertyNodes.add(node.key);
2970
+ },
2971
+ ImportSpecifier(node) {
2972
+ if (node.imported?.type === "Identifier") skipPropertyNodes.add(node.imported);
2973
+ if (node.local?.type === "Identifier" && node.local !== node.imported) skipPropertyNodes.add(node.local);
2974
+ if (node.local?.type === "Identifier" && BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2975
+ },
2976
+ ImportDefaultSpecifier(node) {
2977
+ if (node.local?.type === "Identifier") {
2978
+ skipPropertyNodes.add(node.local);
2979
+ if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2980
+ }
2981
+ },
2982
+ ImportNamespaceSpecifier(node) {
2983
+ if (node.local?.type === "Identifier") {
2984
+ skipPropertyNodes.add(node.local);
2985
+ if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2986
+ }
2333
2987
  },
2334
- Identifier(node, parent) {
2335
- if (safeDepth > 0 || typeofGuardDepth > 0) return;
2988
+ Identifier(node) {
2989
+ if (safeDepth > 0 || typeofGuardDepth > 0 || inTypeofExpr > 0 || inTsTypePos > 0) return;
2990
+ if (skipPropertyNodes.has(node)) return;
2336
2991
  if (!BROWSER_GLOBALS.has(node.name)) return;
2337
- if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return;
2338
- if (parent?.type === "ImportSpecifier" || parent?.type === "ImportDefaultSpecifier" || parent?.type === "ImportNamespaceSpecifier") return;
2339
- if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
2992
+ if (isNameShadowed(node.name)) return;
2993
+ if (importShadowedNames.has(node.name)) return;
2340
2994
  context.report({
2341
2995
  message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
2342
2996
  span: getSpan(node)
@@ -2404,6 +3058,7 @@ const noDuplicateStoreId = {
2404
3058
  fixable: false
2405
3059
  },
2406
3060
  create(context) {
3061
+ if (isTestFile(context.getFilePath())) return {};
2407
3062
  const storeIds = /* @__PURE__ */ new Map();
2408
3063
  return { CallExpression(node) {
2409
3064
  if (!isCallTo(node, "defineStore")) return;
@@ -2429,25 +3084,30 @@ const noMutateStoreState = {
2429
3084
  meta: {
2430
3085
  id: "pyreon/no-mutate-store-state",
2431
3086
  category: "store",
2432
- description: "Warn when directly calling .set() on store signals — use store actions instead.",
3087
+ description: "Warn when calling .set() on store signals from a component or hook — use store actions instead.",
2433
3088
  severity: "warn",
2434
3089
  fixable: false
2435
3090
  },
2436
3091
  create(context) {
2437
- return { CallExpression(node) {
2438
- const callee = node.callee;
2439
- if (!callee || callee.type !== "MemberExpression") return;
2440
- if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
2441
- const obj = callee.object;
2442
- if (!obj || obj.type !== "MemberExpression") return;
2443
- const outerObj = obj.object;
2444
- if (!outerObj || outerObj.type !== "Identifier") return;
2445
- const name = outerObj.name;
2446
- if (name.toLowerCase().includes("store")) context.report({
2447
- message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
2448
- span: getSpan(node)
2449
- });
2450
- } };
3092
+ const ctx = createComponentContextTracker();
3093
+ return {
3094
+ ...ctx.callbacks,
3095
+ CallExpression(node) {
3096
+ if (!ctx.isInComponentOrHook()) return;
3097
+ const callee = node.callee;
3098
+ if (!callee || callee.type !== "MemberExpression") return;
3099
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
3100
+ const obj = callee.object;
3101
+ if (!obj || obj.type !== "MemberExpression") return;
3102
+ const outerObj = obj.object;
3103
+ if (!outerObj || outerObj.type !== "Identifier") return;
3104
+ const name = outerObj.name;
3105
+ if (name.toLowerCase().includes("store")) context.report({
3106
+ message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
3107
+ span: getSpan(node)
3108
+ });
3109
+ }
3110
+ };
2451
3111
  }
2452
3112
  };
2453
3113
 
@@ -2498,44 +3158,27 @@ const noDynamicStyled = {
2498
3158
  meta: {
2499
3159
  id: "pyreon/no-dynamic-styled",
2500
3160
  category: "styling",
2501
- description: "Warn when styled() is called inside a function — it creates new CSS on every render.",
3161
+ description: "Warn when styled() is called inside a component or hook — it creates new CSS on every render.",
2502
3162
  severity: "warn",
2503
3163
  fixable: false
2504
3164
  },
2505
3165
  create(context) {
2506
- let functionDepth = 0;
3166
+ const ctx = createComponentContextTracker();
2507
3167
  return {
2508
- FunctionDeclaration() {
2509
- functionDepth++;
2510
- },
2511
- "FunctionDeclaration:exit"() {
2512
- functionDepth--;
2513
- },
2514
- FunctionExpression() {
2515
- functionDepth++;
2516
- },
2517
- "FunctionExpression:exit"() {
2518
- functionDepth--;
2519
- },
2520
- ArrowFunctionExpression() {
2521
- functionDepth++;
2522
- },
2523
- "ArrowFunctionExpression:exit"() {
2524
- functionDepth--;
2525
- },
3168
+ ...ctx.callbacks,
2526
3169
  CallExpression(node) {
2527
- if (functionDepth === 0) return;
3170
+ if (!ctx.isInComponentOrHook()) return;
2528
3171
  if (isCallTo(node, "styled")) context.report({
2529
- message: "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
3172
+ message: "`styled()` inside a component or hook — this creates new CSS rules on every render. Move `styled()` to module scope.",
2530
3173
  span: getSpan(node)
2531
3174
  });
2532
3175
  },
2533
3176
  TaggedTemplateExpression(node) {
2534
- if (functionDepth === 0) return;
3177
+ if (!ctx.isInComponentOrHook()) return;
2535
3178
  const tag = node.tag;
2536
3179
  if (!tag) return;
2537
3180
  if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
2538
- message: "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
3181
+ message: "`styled()` tagged template inside a component or hook — this creates new CSS rules on every render. Move to module scope.",
2539
3182
  span: getSpan(node)
2540
3183
  });
2541
3184
  }
@@ -2568,6 +3211,7 @@ const noInlineStyleObject = {
2568
3211
 
2569
3212
  //#endregion
2570
3213
  //#region src/rules/styling/no-theme-outside-provider.ts
3214
+ const HOOK_NAME = /^use[A-Z]/;
2571
3215
  const noThemeOutsideProvider = {
2572
3216
  meta: {
2573
3217
  id: "pyreon/no-theme-outside-provider",
@@ -2578,22 +3222,47 @@ const noThemeOutsideProvider = {
2578
3222
  },
2579
3223
  create(context) {
2580
3224
  let hasProviderImport = false;
3225
+ let hookDepth = 0;
2581
3226
  const themeCalls = [];
3227
+ function declaratorIsHook(node) {
3228
+ if (node?.id?.type !== "Identifier") return false;
3229
+ const init = node.init;
3230
+ if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
3231
+ return HOOK_NAME.test(node.id.name);
3232
+ }
2582
3233
  return {
3234
+ FunctionDeclaration(node) {
3235
+ if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth++;
3236
+ },
3237
+ "FunctionDeclaration:exit"(node) {
3238
+ if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth--;
3239
+ },
3240
+ VariableDeclarator(node) {
3241
+ if (declaratorIsHook(node)) hookDepth++;
3242
+ },
3243
+ "VariableDeclarator:exit"(node) {
3244
+ if (declaratorIsHook(node)) hookDepth--;
3245
+ },
2583
3246
  ImportDeclaration(node) {
2584
3247
  const info = extractImportInfo(node);
2585
3248
  if (!info) return;
2586
3249
  if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
2587
3250
  },
2588
3251
  CallExpression(node) {
2589
- if (isCallTo(node, "useTheme")) themeCalls.push({ span: getSpan(node) });
3252
+ if (isCallTo(node, "useTheme")) themeCalls.push({
3253
+ span: getSpan(node),
3254
+ insideHook: hookDepth > 0
3255
+ });
2590
3256
  },
2591
3257
  "Program:exit"() {
2592
3258
  if (hasProviderImport) return;
2593
- for (const call of themeCalls) context.report({
2594
- message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
2595
- span: call.span
2596
- });
3259
+ for (const call of themeCalls) {
3260
+ if (call.insideHook) continue;
3261
+ context.report({
3262
+ message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
3263
+ span: call.span
3264
+ });
3265
+ }
2597
3266
  }
2598
3267
  };
2599
3268
  }
@@ -2672,6 +3341,7 @@ const allRules = [
2672
3341
  devGuardWarnings,
2673
3342
  noErrorWithoutPrefix,
2674
3343
  noProcessDevGate,
3344
+ requireBrowserSmokeTest,
2675
3345
  noStoreOutsideProvider,
2676
3346
  noMutateStoreState,
2677
3347
  noDuplicateStoreId,
@@ -2702,11 +3372,17 @@ function buildRecommended() {
2702
3372
  for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
2703
3373
  return { rules };
2704
3374
  }
3375
+ function severityOf(entry) {
3376
+ return Array.isArray(entry) ? entry[0] : entry;
3377
+ }
2705
3378
  /** Build a config where every warn is promoted to error. */
2706
3379
  function buildStrict() {
2707
3380
  const base = buildRecommended();
2708
3381
  const rules = {};
2709
- for (const [id, sev] of Object.entries(base.rules)) rules[id] = sev === "warn" ? "error" : sev;
3382
+ for (const [id, entry] of Object.entries(base.rules)) {
3383
+ const sev = severityOf(entry);
3384
+ rules[id] = sev === "warn" ? "error" : sev;
3385
+ }
2710
3386
  return { rules };
2711
3387
  }
2712
3388
  /** Build app config — recommended but disable library-only rules. */
@@ -2716,7 +3392,8 @@ function buildApp() {
2716
3392
  "pyreon/dev-guard-warnings": "off",
2717
3393
  "pyreon/no-error-without-prefix": "off",
2718
3394
  "pyreon/no-circular-import": "off",
2719
- "pyreon/no-cross-layer-import": "off"
3395
+ "pyreon/no-cross-layer-import": "off",
3396
+ "pyreon/require-browser-smoke-test": "off"
2720
3397
  } };
2721
3398
  }
2722
3399
  /** Build lib config — strict + all architecture rules as error. */
@@ -2727,7 +3404,8 @@ function buildLib() {
2727
3404
  "pyreon/no-cross-layer-import": "error",
2728
3405
  "pyreon/dev-guard-warnings": "error",
2729
3406
  "pyreon/no-error-without-prefix": "error",
2730
- "pyreon/no-process-dev-gate": "error"
3407
+ "pyreon/no-process-dev-gate": "error",
3408
+ "pyreon/require-browser-smoke-test": "error"
2731
3409
  } };
2732
3410
  }
2733
3411
  const presetBuilders = {
@@ -2785,8 +3463,46 @@ function hasJsExtension(filePath) {
2785
3463
  return JS_EXTENSIONS.has(ext);
2786
3464
  }
2787
3465
 
3466
+ //#endregion
3467
+ //#region src/utils/validate-options.ts
3468
+ function validateRuleOptions(rule, options) {
3469
+ const schema = rule.meta.schema;
3470
+ const errors = [];
3471
+ const warnings = [];
3472
+ if (!schema) return {
3473
+ errors,
3474
+ warnings
3475
+ };
3476
+ for (const [key, value] of Object.entries(options)) {
3477
+ const expected = schema[key];
3478
+ if (expected === void 0) {
3479
+ warnings.push(`[${rule.meta.id}] unknown option "${key}" — allowed options: ${Object.keys(schema).join(", ") || "(none)"}`);
3480
+ continue;
3481
+ }
3482
+ if (!matchesType(value, expected)) errors.push(`[${rule.meta.id}] option "${key}" must be ${expected}, got ${describe(value)}`);
3483
+ }
3484
+ return {
3485
+ errors,
3486
+ warnings
3487
+ };
3488
+ }
3489
+ function matchesType(value, type) {
3490
+ switch (type) {
3491
+ case "string": return typeof value === "string";
3492
+ case "string[]": return Array.isArray(value) && value.every((x) => typeof x === "string");
3493
+ case "number": return typeof value === "number" && Number.isFinite(value);
3494
+ case "boolean": return typeof value === "boolean";
3495
+ }
3496
+ }
3497
+ function describe(value) {
3498
+ if (value === null) return "null";
3499
+ if (Array.isArray(value)) return `Array<${[...new Set(value.map((x) => x === null ? "null" : typeof x))].join(" | ") || "empty"}>`;
3500
+ return typeof value;
3501
+ }
3502
+
2788
3503
  //#endregion
2789
3504
  //#region src/runner.ts
3505
+ const VALIDATION_CACHE = /* @__PURE__ */ new Map();
2790
3506
  function getExtension(filePath) {
2791
3507
  const lastDot = filePath.lastIndexOf(".");
2792
3508
  return lastDot === -1 ? "" : filePath.slice(lastDot);
@@ -2796,7 +3512,7 @@ function getLang(ext) {
2796
3512
  if (ext === ".ts" || ext === ".mts") return "ts";
2797
3513
  return "js";
2798
3514
  }
2799
- function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
3515
+ function createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath) {
2800
3516
  return {
2801
3517
  report(partial) {
2802
3518
  diagnostics.push({
@@ -2813,6 +3529,9 @@ function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, f
2813
3529
  },
2814
3530
  getFilePath() {
2815
3531
  return filePath;
3532
+ },
3533
+ getOptions() {
3534
+ return options;
2816
3535
  }
2817
3536
  };
2818
3537
  }
@@ -2842,7 +3561,7 @@ function mergeCallbacks(allCallbacks) {
2842
3561
  * for (const d of result.diagnostics) console.log(d.message)
2843
3562
  * ```
2844
3563
  */
2845
- function lintFile(filePath, sourceText, rules, config, cache) {
3564
+ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsSink) {
2846
3565
  const ext = getExtension(filePath);
2847
3566
  if (!JS_EXTENSIONS.has(ext)) return {
2848
3567
  filePath,
@@ -2875,20 +3594,50 @@ function lintFile(filePath, sourceText, rules, config, cache) {
2875
3594
  const diagnostics = [];
2876
3595
  const allCallbacks = [];
2877
3596
  for (const rule of rules) {
2878
- const severity = config.rules[rule.meta.id];
2879
- if (severity === void 0 || severity === "off") continue;
2880
- const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath);
3597
+ const entry = config.rules[rule.meta.id];
3598
+ if (entry === void 0) continue;
3599
+ const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
3600
+ if (severity === "off") continue;
3601
+ const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
3602
+ let cached = VALIDATION_CACHE.get(cacheKey);
3603
+ if (!cached) {
3604
+ const { errors, warnings } = validateRuleOptions(rule, options);
3605
+ const configDiags = [];
3606
+ for (const message of warnings) configDiags.push({
3607
+ ruleId: rule.meta.id,
3608
+ severity: "warn",
3609
+ message
3610
+ });
3611
+ for (const message of errors) configDiags.push({
3612
+ ruleId: rule.meta.id,
3613
+ severity: "error",
3614
+ message
3615
+ });
3616
+ cached = {
3617
+ ok: errors.length === 0,
3618
+ diagnostics: configDiags
3619
+ };
3620
+ VALIDATION_CACHE.set(cacheKey, cached);
3621
+ }
3622
+ if (cached.diagnostics.length > 0) if (configDiagnosticsSink) {
3623
+ for (const d of cached.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
3624
+ } else for (const d of cached.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
3625
+ if (!cached.ok) continue;
3626
+ const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
2881
3627
  allCallbacks.push(rule.create(ctx));
2882
3628
  }
2883
3629
  new Visitor(mergeCallbacks(allCallbacks)).visit(program);
2884
3630
  const lines = sourceText.split("\n");
3631
+ const SUPPRESS_RE = /^\/\/\s*pyreon-lint-(?:ignore|disable-next-line)(?:\s+(\S+))?\s*$/;
2885
3632
  const filtered = diagnostics.filter((d) => {
2886
3633
  const prevLineIdx = d.loc.line - 2;
2887
3634
  if (prevLineIdx < 0) return true;
2888
- const prevLine = lines[prevLineIdx]?.trim();
2889
- if (!prevLine?.startsWith("// pyreon-lint-ignore")) return true;
2890
- const rest = prevLine.slice(21).trim();
2891
- return rest.length > 0 && rest !== d.ruleId;
3635
+ const prevLine = lines[prevLineIdx]?.trim() ?? "";
3636
+ const match = SUPPRESS_RE.exec(prevLine);
3637
+ if (!match) return true;
3638
+ const ruleId = match[1];
3639
+ if (!ruleId) return false;
3640
+ return ruleId !== d.ruleId;
2892
3641
  });
2893
3642
  filtered.sort((a, b) => a.span.start - b.span.start);
2894
3643
  return {
@@ -2966,8 +3715,17 @@ function buildConfig(options) {
2966
3715
  const cwd = resolve(".");
2967
3716
  const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
2968
3717
  const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
2969
- if (fileConfig?.rules) for (const [id, severity] of Object.entries(fileConfig.rules)) config.rules[id] = severity;
3718
+ if (fileConfig?.rules) for (const [id, entry] of Object.entries(fileConfig.rules)) config.rules[id] = entry;
2970
3719
  if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
3720
+ if (options.ruleOptionsOverrides) for (const [id, optionOverrides] of Object.entries(options.ruleOptionsOverrides)) {
3721
+ const existing = config.rules[id];
3722
+ const [currentSeverity, currentOptions] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
3723
+ if (currentSeverity === "off") continue;
3724
+ config.rules[id] = [currentSeverity, {
3725
+ ...currentOptions,
3726
+ ...optionOverrides
3727
+ }];
3728
+ }
2971
3729
  return {
2972
3730
  config,
2973
3731
  include: fileConfig?.include,
@@ -3017,11 +3775,13 @@ function lint(options) {
3017
3775
  const { config, include, exclude, isIgnored } = buildConfig(options);
3018
3776
  const cache = new AstCache();
3019
3777
  const files = gatherFiles(options.paths, isIgnored, include, exclude);
3778
+ const configDiagnostics = [];
3020
3779
  const results = {
3021
3780
  files: [],
3022
3781
  totalErrors: 0,
3023
3782
  totalWarnings: 0,
3024
- totalInfos: 0
3783
+ totalInfos: 0,
3784
+ configDiagnostics
3025
3785
  };
3026
3786
  for (const filePath of files) {
3027
3787
  let source;
@@ -3030,7 +3790,7 @@ function lint(options) {
3030
3790
  } catch {
3031
3791
  continue;
3032
3792
  }
3033
- const fileResult = lintFile(filePath, source, allRules, config, cache);
3793
+ const fileResult = lintFile(filePath, source, allRules, config, cache, configDiagnostics);
3034
3794
  if (options.fix) applyFixesToFile(fileResult, source);
3035
3795
  if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
3036
3796
  countDiagnostics(fileResult, results);
@@ -3137,7 +3897,7 @@ function formatCompact(result) {
3137
3897
  * @module
3138
3898
  */
3139
3899
  const cache = new AstCache();
3140
- const config = getPreset("recommended");
3900
+ let config = getPreset("recommended");
3141
3901
  function toLspDiagnostics(diagnostics) {
3142
3902
  return diagnostics.map((d) => ({
3143
3903
  range: {
@@ -3312,6 +4072,7 @@ function watchAndLint(options) {
3312
4072
  const cache = new AstCache();
3313
4073
  const config = getPreset(options.preset ?? "recommended");
3314
4074
  applyOverrides(config, options.ruleOverrides);
4075
+ applyOptionsOverrides(config, options.ruleOptionsOverrides);
3315
4076
  const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
3316
4077
  const pending = /* @__PURE__ */ new Map();
3317
4078
  console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
@@ -3338,6 +4099,18 @@ function applyOverrides(config, overrides) {
3338
4099
  if (!overrides) return;
3339
4100
  for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
3340
4101
  }
4102
+ function applyOptionsOverrides(config, overrides) {
4103
+ if (!overrides) return;
4104
+ for (const [id, opts] of Object.entries(overrides)) {
4105
+ const existing = config.rules[id];
4106
+ const [severity, current] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
4107
+ if (severity === "off") continue;
4108
+ config.rules[id] = [severity, {
4109
+ ...current,
4110
+ ...opts
4111
+ }];
4112
+ }
4113
+ }
3341
4114
  function relintFile(filePath, config, cache, format) {
3342
4115
  let source;
3343
4116
  try {
@@ -3351,7 +4124,8 @@ function relintFile(filePath, config, cache, format) {
3351
4124
  files: [fileResult],
3352
4125
  totalErrors: 0,
3353
4126
  totalWarnings: 0,
3354
- totalInfos: 0
4127
+ totalInfos: 0,
4128
+ configDiagnostics: []
3355
4129
  };
3356
4130
  for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
3357
4131
  else if (d.severity === "warn") result.totalWarnings++;
@@ -3361,5 +4135,5 @@ function relintFile(filePath, config, cache, format) {
3361
4135
  }
3362
4136
 
3363
4137
  //#endregion
3364
- export { AstCache, LineIndex, allRules, applyFixes, createIgnoreFilter, extractImportInfo, formatCompact, formatJSON, formatText, getLocalName, getPreset, importsName, isPyreonImport, isPyreonPackage, lint, lintFile, listRules, loadConfig, loadConfigFromPath, startLspServer, watchAndLint };
4138
+ export { AstCache, LineIndex, allRules, applyFixes, createIgnoreFilter, extractImportInfo, formatCompact, formatJSON, formatText, getLocalName, getPreset, importsName, isPathExempt, isPyreonImport, isPyreonPackage, isTestFile, lint, lintFile, listRules, loadConfig, loadConfigFromPath, startLspServer, watchAndLint };
3365
4139
  //# sourceMappingURL=index.js.map