@pyreon/lint 0.12.12 → 0.12.14

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/cli.js CHANGED
@@ -1,8 +1,15 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, readFileSync, readdirSync, statSync, watch, writeFileSync } from "node:fs";
3
- import { dirname, join, relative, resolve } from "node:path";
3
+ import path, { dirname, join, relative, resolve } from "node:path";
4
4
  import { Visitor, parseSync } from "oxc-parser";
5
5
 
6
+ //#region \0rolldown/runtime.js
7
+ 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) {
8
+ if (typeof require !== "undefined") return require.apply(this, arguments);
9
+ 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.");
10
+ });
11
+
12
+ //#endregion
6
13
  //#region src/cache.ts
7
14
  /**
8
15
  * Simple in-memory cache for parsed ASTs keyed by file content hash.
@@ -249,7 +256,6 @@ const BROWSER_GLOBALS = new Set([
249
256
  "localStorage",
250
257
  "sessionStorage",
251
258
  "indexedDB",
252
- "fetch",
253
259
  "XMLHttpRequest",
254
260
  "WebSocket",
255
261
  "requestAnimationFrame",
@@ -438,6 +444,44 @@ const toastA11y = {
438
444
  }
439
445
  };
440
446
 
447
+ //#endregion
448
+ //#region src/utils/exempt-paths.ts
449
+ function isPathExempt(ctx) {
450
+ const raw = ctx.getOptions().exemptPaths;
451
+ if (!Array.isArray(raw) || raw.length === 0) return false;
452
+ const filePath = ctx.getFilePath();
453
+ for (const entry of raw) if (typeof entry === "string" && entry.length > 0 && filePath.includes(entry)) return true;
454
+ return false;
455
+ }
456
+
457
+ //#endregion
458
+ //#region src/utils/file-roles.ts
459
+ /**
460
+ * Universal file-path classifiers for lint rules.
461
+ *
462
+ * What belongs here:
463
+ * - Conventions that exist in every project the linter runs on
464
+ * (test files, example directories — the `*.test.*` convention
465
+ * is not Pyreon-specific).
466
+ *
467
+ * What does NOT belong here:
468
+ * - Monorepo-specific paths like `packages/core/runtime-dom/` —
469
+ * those are implementation knowledge of one particular codebase
470
+ * and have no meaning in a user's app. Exemptions for such paths
471
+ * belong in the consuming project's lint config via the
472
+ * `exemptPaths: string[]` rule option — see `utils/exempt-paths.ts`
473
+ * and the Pyreon monorepo's `.pyreonlintrc.json` at repo root for
474
+ * reference.
475
+ */
476
+ /**
477
+ * Matches files that are tests by convention. Universal — the
478
+ * `*.test.*` / `*.spec.*` / `/tests/` / `/__tests__/` conventions
479
+ * exist in every codebase this linter runs on, not just Pyreon.
480
+ */
481
+ function isTestFile(filePath) {
482
+ return filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/__tests__/") || filePath.includes(".test.") || filePath.includes(".spec.");
483
+ }
484
+
441
485
  //#endregion
442
486
  //#region src/rules/architecture/dev-guard-warnings.ts
443
487
  const devGuardWarnings = {
@@ -446,26 +490,122 @@ const devGuardWarnings = {
446
490
  category: "architecture",
447
491
  description: "Require console.warn/error calls to be wrapped in `if (__DEV__)` guards.",
448
492
  severity: "error",
449
- fixable: false
493
+ fixable: false,
494
+ schema: {
495
+ exemptPaths: "string[]",
496
+ devFlagNames: "string[]"
497
+ }
450
498
  },
451
499
  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 {};
500
+ if (isTestFile(context.getFilePath())) return {};
501
+ if (isPathExempt(context)) return {};
502
+ const userFlagNames = context.getOptions().devFlagNames;
503
+ const extraFlagNames = Array.isArray(userFlagNames) ? userFlagNames.filter((n) => typeof n === "string") : [];
504
+ const devFlagBoundConsts = /* @__PURE__ */ new Set();
505
+ function exprResolvesToDevFlag(expr) {
506
+ if (!expr) return false;
507
+ if (expr.type === "ChainExpression") return exprResolvesToDevFlag(expr.expression);
508
+ if (isDevFlag(expr)) return true;
509
+ if (expr.type === "BinaryExpression" && (expr.operator === "===" || expr.operator === "==")) return exprResolvesToDevFlag(expr.left) || exprResolvesToDevFlag(expr.right);
510
+ return false;
511
+ }
512
+ const DEV_FLAG_NAMES = new Set([
513
+ "__DEV__",
514
+ "IS_DEV",
515
+ "IS_DEVELOPMENT",
516
+ "isDev",
517
+ ...extraFlagNames
518
+ ]);
519
+ function isDevFlag(node) {
520
+ if (!node) return false;
521
+ if (node.type === "ChainExpression") return isDevFlag(node.expression);
522
+ if (node.type === "Identifier" && DEV_FLAG_NAMES.has(node.name)) return true;
523
+ if (node.type === "Identifier" && devFlagBoundConsts.has(node.name)) return true;
524
+ if (node.type === "MemberExpression" && node.property?.type === "Identifier" && node.property.name === "DEV") {
525
+ const obj = node.object;
526
+ if (obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "env" && obj.object?.type === "MetaProperty") return true;
527
+ }
528
+ return false;
529
+ }
530
+ function containsDevGuard(test) {
531
+ if (!test) return false;
532
+ if (isDevFlag(test)) return true;
533
+ if (test.type === "LogicalExpression" && test.operator === "&&") return containsDevGuard(test.left) || containsDevGuard(test.right);
534
+ if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==")) return isDevFlag(test.left) || isDevFlag(test.right);
535
+ return false;
536
+ }
537
+ function isEarlyReturnDevGuard(node) {
538
+ if (!node || node.type !== "IfStatement") return false;
539
+ const t = node.test;
540
+ const arg = t?.type === "UnaryExpression" && t.operator === "!" ? t.argument : null;
541
+ if (!arg) return false;
542
+ if (!isDevFlag(arg)) return false;
543
+ const c = node.consequent;
544
+ if (c?.type === "ReturnStatement") return true;
545
+ if (c?.type === "BlockStatement" && c.body.length === 1 && c.body[0]?.type === "ReturnStatement") return true;
546
+ return false;
547
+ }
454
548
  let devGuardDepth = 0;
549
+ let catchDepth = 0;
550
+ const funcGuardStack = [];
551
+ function enterFunction(node) {
552
+ const body = node?.body;
553
+ const stmts = body?.type === "BlockStatement" ? body.body : null;
554
+ let guarded = 0;
555
+ if (stmts && stmts.length > 0 && isEarlyReturnDevGuard(stmts[0])) {
556
+ guarded = 1;
557
+ devGuardDepth++;
558
+ }
559
+ funcGuardStack.push(guarded);
560
+ }
561
+ function exitFunction() {
562
+ const g = funcGuardStack.pop() ?? 0;
563
+ if (g > 0) devGuardDepth -= g;
564
+ }
455
565
  return {
566
+ VariableDeclaration(node) {
567
+ for (const decl of node.declarations ?? []) if (decl.id?.type === "Identifier" && exprResolvesToDevFlag(decl.init)) devFlagBoundConsts.add(decl.id.name);
568
+ },
569
+ FunctionDeclaration: enterFunction,
570
+ "FunctionDeclaration:exit": exitFunction,
571
+ FunctionExpression: enterFunction,
572
+ "FunctionExpression:exit": exitFunction,
573
+ ArrowFunctionExpression: enterFunction,
574
+ "ArrowFunctionExpression:exit": exitFunction,
456
575
  IfStatement(node) {
457
- if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth++;
576
+ if (containsDevGuard(node.test)) devGuardDepth++;
458
577
  },
459
578
  "IfStatement:exit"(node) {
460
- if (node.test?.type === "Identifier" && node.test.name === "__DEV__") devGuardDepth--;
579
+ if (containsDevGuard(node.test)) devGuardDepth--;
580
+ },
581
+ LogicalExpression(node) {
582
+ if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth++;
583
+ },
584
+ "LogicalExpression:exit"(node) {
585
+ if (node.operator === "&&" && containsDevGuard(node.left)) devGuardDepth--;
586
+ },
587
+ ConditionalExpression(node) {
588
+ if (containsDevGuard(node.test)) devGuardDepth++;
589
+ },
590
+ "ConditionalExpression:exit"(node) {
591
+ if (containsDevGuard(node.test)) devGuardDepth--;
592
+ },
593
+ CatchClause() {
594
+ catchDepth++;
595
+ },
596
+ "CatchClause:exit"() {
597
+ catchDepth--;
461
598
  },
462
599
  CallExpression(node) {
463
600
  if (devGuardDepth > 0) return;
464
601
  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
- });
602
+ if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "console" && callee.property?.type === "Identifier" && (callee.property.name === "warn" || callee.property.name === "error")) {
603
+ if (callee.property.name === "error" && catchDepth > 0) return;
604
+ context.report({
605
+ 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\`.`,
606
+ span: getSpan(node)
607
+ });
608
+ }
469
609
  }
470
610
  };
471
611
  }
@@ -502,7 +642,9 @@ const noCircularImport = {
502
642
  fixable: false
503
643
  },
504
644
  create(context) {
505
- const fileLayer = getFileLayer(context.getFilePath());
645
+ const filePath = context.getFilePath();
646
+ if (isTestFile(filePath)) return {};
647
+ const fileLayer = getFileLayer(filePath);
506
648
  if (fileLayer === null) return {};
507
649
  return { ImportDeclaration(node) {
508
650
  const source = node.source?.value;
@@ -692,34 +834,19 @@ const noErrorWithoutPrefix = {
692
834
  * Does NOT delete the const declaration — that has to happen by hand because
693
835
  * the variable name and downstream usages may need updating in callers.
694
836
  *
695
- * **Server-package exception**: server-only files run in Node where `process`
696
- * is always defined, so the pattern is correct there. The rule skips files
697
- * matching `packages/zero/`, `packages/core/server/`, `packages/core/runtime-server/`,
698
- * `packages/tools/vite-plugin/`, and any file containing `/server/` in its
699
- * path. Add new server packages to `SERVER_PACKAGE_PATTERNS` below.
700
- */
701
- /**
702
- * File-path patterns for server-only packages. Substring match against the
703
- * file path passed to the rule. Patterns intentionally do NOT start with `/`
704
- * so they match both absolute paths (`/Users/.../packages/zero/...`) and
705
- * relative paths (`packages/zero/...`) — different lint runners pass paths
706
- * differently.
837
+ * **Server-only exemption**: projects configure `exemptPaths` per-file for
838
+ * server-only code (Node environments where `process` is always defined and
839
+ * the pattern is correct). Configure in `.pyreonlintrc.json`:
840
+ *
841
+ * {
842
+ * "rules": {
843
+ * "pyreon/no-process-dev-gate": [
844
+ * "error",
845
+ * { "exemptPaths": ["packages/zero/", "packages/core/server/"] }
846
+ * ]
847
+ * }
848
+ * }
707
849
  */
708
- const SERVER_PACKAGE_PATTERNS = [
709
- "packages/zero/",
710
- "packages/core/server/",
711
- "packages/core/runtime-server/",
712
- "packages/tools/vite-plugin/",
713
- "packages/tools/cli/",
714
- "packages/tools/lint/",
715
- "packages/tools/mcp/",
716
- "packages/tools/storybook/",
717
- "packages/tools/typescript/",
718
- "scripts/"
719
- ];
720
- function isServerOnlyFile(filePath) {
721
- return SERVER_PACKAGE_PATTERNS.some((pat) => filePath.includes(pat));
722
- }
723
850
  const noProcessDevGate = {
724
851
  meta: {
725
852
  id: "pyreon/no-process-dev-gate",
@@ -729,9 +856,8 @@ const noProcessDevGate = {
729
856
  fixable: true
730
857
  },
731
858
  create(context) {
732
- const filePath = context.getFilePath();
733
- if (filePath.includes("/tests/") || filePath.includes("/test/") || filePath.includes("/__tests__/") || filePath.includes(".test.") || filePath.includes(".spec.")) return {};
734
- if (isServerOnlyFile(filePath)) return {};
859
+ if (isTestFile(context.getFilePath())) return {};
860
+ if (isPathExempt(context)) return {};
735
861
  /**
736
862
  * Match the broken pattern at the AST level. We're looking for any
737
863
  * `LogicalExpression` whose two sides are:
@@ -788,6 +914,185 @@ const noProcessDevGate = {
788
914
  }
789
915
  };
790
916
 
917
+ //#endregion
918
+ //#region src/rules/architecture/require-browser-smoke-test.ts
919
+ /**
920
+ * `pyreon/require-browser-smoke-test` — every browser-categorized package
921
+ * must ship at least one `*.browser.test.{ts,tsx}` file under `src/`.
922
+ *
923
+ * Locks in the durability of the T1.1 browser smoke harness (PRs #224,
924
+ * #227, #229, #231). Without this rule, any new browser-running package
925
+ * can quietly ship without a real-browser smoke test and we drift back
926
+ * to the world before T1.1 — where happy-dom silently masks
927
+ * environment-divergence bugs (PR #197 mock-vnode metadata drop, PR
928
+ * #200 `typeof process` dead code, the multi-word event delegation bug
929
+ * fixed alongside PR #231).
930
+ *
931
+ * **What it checks**: when linting a package's `src/index.ts`, the rule
932
+ * looks at the package directory for any file matching
933
+ * `**\/*.browser.test.{ts,tsx}`. If none are found AND the package's
934
+ * name appears in the browser-categorized list, the rule reports an
935
+ * error on `src/index.ts`.
936
+ *
937
+ * **Why src/index.ts only**: the rule needs to fire exactly once per
938
+ * package, not per file. `src/index.ts` is a stable per-package entry
939
+ * point. Files inside the package are not browser-test files
940
+ * themselves, so they get skipped via the path check.
941
+ *
942
+ * **Default browser packages list**: matches the categorization in
943
+ * `.claude/rules/test-environment-parity.md`. Override via the
944
+ * `additionalPackages` option to opt in new packages, or via
945
+ * `exemptPaths` to opt out (e.g. for a brand-new package still under
946
+ * construction).
947
+ *
948
+ * @example Configuration in `.pyreonlintrc.json`
949
+ * ```json
950
+ * {
951
+ * "rules": {
952
+ * "pyreon/require-browser-smoke-test": [
953
+ * "error",
954
+ * {
955
+ * "additionalPackages": ["@my-org/my-browser-pkg"],
956
+ * "exemptPaths": ["packages/experimental/"]
957
+ * }
958
+ * ]
959
+ * }
960
+ * }
961
+ * ```
962
+ *
963
+ * **Known limitation — file existence, not test quality.** The rule only
964
+ * checks that at least one `*.browser.test.*` file exists under `src/`;
965
+ * it cannot assess whether the test is meaningful. A package could ship
966
+ * `sanity.browser.test.ts` with `expect(1).toBe(1)` and satisfy the
967
+ * rule. That's accepted by design — the rule is a *gate* against
968
+ * packages shipping with zero smoke coverage, not a quality check.
969
+ * Review the actual test contents on PR. If drive-by one-liner tests
970
+ * become a pattern, add a per-package coverage threshold or a
971
+ * complementary rule that inspects test file contents.
972
+ */
973
+ /**
974
+ * Single source of truth for browser-categorized packages lives at
975
+ * `.claude/rules/browser-packages.json`. Loading it lazily here means:
976
+ *
977
+ * 1. Updating the list never requires re-publishing `@pyreon/lint`.
978
+ * 2. The script `scripts/check-browser-smoke.ts` + the human-readable
979
+ * `.claude/rules/test-environment-parity.md` share the same source,
980
+ * so they can't drift out of sync silently.
981
+ *
982
+ * The JSON is searched for by walking up from the linted file's directory
983
+ * to the first ancestor containing `.claude/rules/browser-packages.json`.
984
+ * If not found (rule running in a consumer repo that doesn't ship the
985
+ * JSON), the rule falls back to an empty list — `additionalPackages`
986
+ * becomes the only signal and the rule stays opt-in, not a footgun.
987
+ *
988
+ * Cached globally because the list is tiny and lint runs lint thousands
989
+ * of files per invocation.
990
+ */
991
+ let _cachedBrowserPackages = null;
992
+ function loadBrowserPackages(fromFile) {
993
+ if (_cachedBrowserPackages) return _cachedBrowserPackages;
994
+ let dir = path.dirname(fromFile);
995
+ for (let i = 0; i < 30; i++) {
996
+ const candidate = path.join(dir, ".claude", "rules", "browser-packages.json");
997
+ if (existsSync(candidate)) {
998
+ try {
999
+ const fs = __require("node:fs");
1000
+ const parsed = JSON.parse(fs.readFileSync(candidate, "utf8"));
1001
+ if (Array.isArray(parsed.packages)) {
1002
+ _cachedBrowserPackages = new Set(parsed.packages.filter((p) => typeof p === "string"));
1003
+ return _cachedBrowserPackages;
1004
+ }
1005
+ } catch {}
1006
+ break;
1007
+ }
1008
+ const parent = path.dirname(dir);
1009
+ if (parent === dir) break;
1010
+ dir = parent;
1011
+ }
1012
+ _cachedBrowserPackages = /* @__PURE__ */ new Set();
1013
+ return _cachedBrowserPackages;
1014
+ }
1015
+ /**
1016
+ * Walk a directory looking for `*.browser.test.{ts,tsx}` files. Bails
1017
+ * on the first match — we only need to know `at least one exists`,
1018
+ * not enumerate them. Skips `node_modules`, `lib`, `dist`, and dot
1019
+ * directories so a package's own dependencies don't pollute the check.
1020
+ */
1021
+ function hasBrowserTest(dir) {
1022
+ let entries;
1023
+ try {
1024
+ entries = readdirSync(dir);
1025
+ } catch {
1026
+ return false;
1027
+ }
1028
+ for (const name of entries) {
1029
+ if (name.startsWith(".") || name === "node_modules" || name === "lib" || name === "dist") continue;
1030
+ const full = path.join(dir, name);
1031
+ let isDir = false;
1032
+ try {
1033
+ isDir = statSync(full).isDirectory();
1034
+ } catch {
1035
+ continue;
1036
+ }
1037
+ if (isDir) {
1038
+ if (hasBrowserTest(full)) return true;
1039
+ continue;
1040
+ }
1041
+ if (/\.browser\.test\.(?:ts|tsx)$/.test(name)) return true;
1042
+ }
1043
+ return false;
1044
+ }
1045
+ /**
1046
+ * Read the package.json `name` field for the directory containing the
1047
+ * given src/index.ts file. Returns null if not found.
1048
+ */
1049
+ function readPackageName(srcIndexPath) {
1050
+ const pkgPath = path.resolve(path.dirname(srcIndexPath), "..", "package.json");
1051
+ if (!existsSync(pkgPath)) return null;
1052
+ try {
1053
+ const text = __require("node:fs").readFileSync(pkgPath, "utf8");
1054
+ const parsed = JSON.parse(text);
1055
+ return typeof parsed.name === "string" ? parsed.name : null;
1056
+ } catch {
1057
+ return null;
1058
+ }
1059
+ }
1060
+ const requireBrowserSmokeTest = {
1061
+ meta: {
1062
+ id: "pyreon/require-browser-smoke-test",
1063
+ category: "architecture",
1064
+ 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.",
1065
+ severity: "error",
1066
+ fixable: false,
1067
+ schema: {
1068
+ additionalPackages: "string[]",
1069
+ exemptPaths: "string[]"
1070
+ }
1071
+ },
1072
+ create(context) {
1073
+ const filePath = context.getFilePath();
1074
+ if (!filePath.endsWith("/src/index.ts") && !filePath.endsWith("/src/index.tsx")) return {};
1075
+ if (isPathExempt(context)) return {};
1076
+ const pkgName = readPackageName(filePath);
1077
+ if (pkgName == null) return {};
1078
+ const options = context.getOptions();
1079
+ const additional = Array.isArray(options.additionalPackages) ? options.additionalPackages.filter((s) => typeof s === "string") : [];
1080
+ const browserPackages = new Set(loadBrowserPackages(filePath));
1081
+ for (const p of additional) browserPackages.add(p);
1082
+ if (!browserPackages.has(pkgName)) return {};
1083
+ if (hasBrowserTest(path.dirname(path.dirname(filePath)))) return {};
1084
+ return { "Program:exit"(node) {
1085
+ context.report({
1086
+ 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.`,
1087
+ span: {
1088
+ start: node.start ?? 0,
1089
+ end: node.end ?? 0
1090
+ }
1091
+ });
1092
+ } };
1093
+ }
1094
+ };
1095
+
791
1096
  //#endregion
792
1097
  //#region src/rules/form/no-submit-without-validation.ts
793
1098
  const noSubmitWithoutValidation = {
@@ -799,6 +1104,7 @@ const noSubmitWithoutValidation = {
799
1104
  fixable: false
800
1105
  },
801
1106
  create(context) {
1107
+ if (isTestFile(context.getFilePath())) return {};
802
1108
  return { CallExpression(node) {
803
1109
  if (!isCallTo(node, "useForm")) return;
804
1110
  const args = node.arguments;
@@ -834,6 +1140,7 @@ const noUnregisteredField = {
834
1140
  fixable: false
835
1141
  },
836
1142
  create(context) {
1143
+ if (isTestFile(context.getFilePath())) return {};
837
1144
  const fieldDecls = /* @__PURE__ */ new Map();
838
1145
  const registeredNames = /* @__PURE__ */ new Set();
839
1146
  return {
@@ -899,9 +1206,11 @@ const noRawAddEventListener = {
899
1206
  category: "hooks",
900
1207
  description: "Suggest useEventListener() instead of raw .addEventListener() calls.",
901
1208
  severity: "info",
902
- fixable: false
1209
+ fixable: false,
1210
+ schema: { exemptPaths: "string[]" }
903
1211
  },
904
1212
  create(context) {
1213
+ if (isPathExempt(context)) return {};
905
1214
  return { CallExpression(node) {
906
1215
  const callee = node.callee;
907
1216
  if (!callee || callee.type !== "MemberExpression") return;
@@ -914,6 +1223,41 @@ const noRawAddEventListener = {
914
1223
  }
915
1224
  };
916
1225
 
1226
+ //#endregion
1227
+ //#region src/utils/component-context.ts
1228
+ const COMPONENT_NAME = /^[A-Z]/;
1229
+ const HOOK_NAME$1 = /^use[A-Z]/;
1230
+ function isComponentOrHookName(name) {
1231
+ if (!name) return false;
1232
+ return COMPONENT_NAME.test(name) || HOOK_NAME$1.test(name);
1233
+ }
1234
+ function createComponentContextTracker() {
1235
+ let depth = 0;
1236
+ function declaratorIsComponentOrHook(node) {
1237
+ if (node?.id?.type !== "Identifier") return false;
1238
+ const init = node.init;
1239
+ if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
1240
+ return isComponentOrHookName(node.id.name);
1241
+ }
1242
+ return {
1243
+ isInComponentOrHook: () => depth > 0,
1244
+ callbacks: {
1245
+ FunctionDeclaration(node) {
1246
+ if (isComponentOrHookName(node.id?.name)) depth++;
1247
+ },
1248
+ "FunctionDeclaration:exit"(node) {
1249
+ if (isComponentOrHookName(node.id?.name)) depth--;
1250
+ },
1251
+ VariableDeclarator(node) {
1252
+ if (declaratorIsComponentOrHook(node)) depth++;
1253
+ },
1254
+ "VariableDeclarator:exit"(node) {
1255
+ if (declaratorIsComponentOrHook(node)) depth--;
1256
+ }
1257
+ }
1258
+ };
1259
+ }
1260
+
917
1261
  //#endregion
918
1262
  //#region src/rules/hooks/no-raw-localstorage.ts
919
1263
  const STORAGE_OBJECTS = new Set(["localStorage", "sessionStorage"]);
@@ -926,19 +1270,24 @@ const noRawLocalStorage = {
926
1270
  meta: {
927
1271
  id: "pyreon/no-raw-localstorage",
928
1272
  category: "hooks",
929
- description: "Suggest useStorage() instead of raw localStorage/sessionStorage access.",
1273
+ description: "Suggest useStorage() instead of raw localStorage/sessionStorage inside a component or hook.",
930
1274
  severity: "info",
931
1275
  fixable: false
932
1276
  },
933
1277
  create(context) {
934
- return { CallExpression(node) {
935
- const callee = node.callee;
936
- if (!callee || callee.type !== "MemberExpression") return;
937
- if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
938
- message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
939
- span: getSpan(node)
940
- });
941
- } };
1278
+ const ctx = createComponentContextTracker();
1279
+ return {
1280
+ ...ctx.callbacks,
1281
+ CallExpression(node) {
1282
+ if (!ctx.isInComponentOrHook()) return;
1283
+ const callee = node.callee;
1284
+ if (!callee || callee.type !== "MemberExpression") return;
1285
+ if (callee.object?.type === "Identifier" && STORAGE_OBJECTS.has(callee.object.name) && callee.property?.type === "Identifier" && STORAGE_METHODS.has(callee.property.name)) context.report({
1286
+ message: `Raw \`${callee.object.name}.${callee.property.name}()\` — consider using \`useStorage()\` from \`@pyreon/storage\` for reactive, cross-tab synced storage.`,
1287
+ span: getSpan(node)
1288
+ });
1289
+ }
1290
+ };
942
1291
  }
943
1292
  };
944
1293
 
@@ -951,14 +1300,19 @@ const noRawSetInterval = {
951
1300
  category: "hooks",
952
1301
  description: "Suggest wrapping setInterval/setTimeout in onMount for automatic cleanup.",
953
1302
  severity: "info",
954
- fixable: false
1303
+ fixable: false,
1304
+ schema: { exemptPaths: "string[]" }
955
1305
  },
956
1306
  create(context) {
1307
+ if (isPathExempt(context)) return {};
1308
+ const ctx = createComponentContextTracker();
957
1309
  let mountDepth = 0;
958
1310
  return {
1311
+ ...ctx.callbacks,
959
1312
  CallExpression(node) {
960
1313
  if (isCallTo(node, "onMount")) mountDepth++;
961
1314
  if (mountDepth > 0) return;
1315
+ if (!ctx.isInComponentOrHook()) return;
962
1316
  const callee = node.callee;
963
1317
  if (!callee || callee.type !== "Identifier") return;
964
1318
  if (TIMER_FNS.has(callee.name)) context.report({
@@ -1268,24 +1622,28 @@ const noPropsDestructure = {
1268
1622
  },
1269
1623
  create(context) {
1270
1624
  let functionDepth = 0;
1625
+ const callArgFns = /* @__PURE__ */ new WeakSet();
1271
1626
  return {
1627
+ CallExpression(node) {
1628
+ for (const arg of node.arguments ?? []) if (arg?.type === "ArrowFunctionExpression" || arg?.type === "FunctionExpression" || arg?.type === "FunctionDeclaration") callArgFns.add(arg);
1629
+ },
1272
1630
  ArrowFunctionExpression(node) {
1273
1631
  functionDepth++;
1274
- checkFunction(node, context, functionDepth);
1632
+ checkFunction(node, context, functionDepth, callArgFns);
1275
1633
  },
1276
1634
  "ArrowFunctionExpression:exit"() {
1277
1635
  functionDepth--;
1278
1636
  },
1279
1637
  FunctionDeclaration(node) {
1280
1638
  functionDepth++;
1281
- checkFunction(node, context, functionDepth);
1639
+ checkFunction(node, context, functionDepth, callArgFns);
1282
1640
  },
1283
1641
  "FunctionDeclaration:exit"() {
1284
1642
  functionDepth--;
1285
1643
  },
1286
1644
  FunctionExpression(node) {
1287
1645
  functionDepth++;
1288
- checkFunction(node, context, functionDepth);
1646
+ checkFunction(node, context, functionDepth, callArgFns);
1289
1647
  },
1290
1648
  "FunctionExpression:exit"() {
1291
1649
  functionDepth--;
@@ -1293,14 +1651,13 @@ const noPropsDestructure = {
1293
1651
  };
1294
1652
  }
1295
1653
  };
1296
- function checkFunction(node, context, depth) {
1654
+ function checkFunction(node, context, depth, callArgFns) {
1297
1655
  const params = node.params;
1298
1656
  if (!params || params.length === 0) return;
1299
1657
  const firstParam = params[0];
1300
1658
  if (!isDestructuring(firstParam)) return;
1301
1659
  if (depth > 1) return;
1302
- const parent = node.parent;
1303
- if (parent?.type === "CallExpression" && parent.arguments?.includes(node)) return;
1660
+ if (callArgFns.has(node)) return;
1304
1661
  const body = node.body;
1305
1662
  if (!body) return;
1306
1663
  if (containsJSXReturn(body)) {
@@ -1397,9 +1754,47 @@ const noDomInSetup = {
1397
1754
  },
1398
1755
  create(context) {
1399
1756
  let safeDepth = 0;
1757
+ function isSafeContextCall(node) {
1758
+ return isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame");
1759
+ }
1760
+ function isNegatedTypeofDocument(test) {
1761
+ if (!test) return false;
1762
+ 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;
1763
+ return false;
1764
+ }
1765
+ function isEarlyReturnDocumentGuard(stmt) {
1766
+ if (!stmt || stmt.type !== "IfStatement") return false;
1767
+ if (!isNegatedTypeofDocument(stmt.test)) return false;
1768
+ const c = stmt.consequent;
1769
+ const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
1770
+ if (isTerminator(c)) return true;
1771
+ if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
1772
+ return false;
1773
+ }
1774
+ const earlyReturnStack = [];
1775
+ function pushFunctionScope(node) {
1776
+ const body = node?.body;
1777
+ const stmts = body?.type === "BlockStatement" ? body.body : null;
1778
+ let bumps = 0;
1779
+ if (stmts && stmts.length > 0 && isEarlyReturnDocumentGuard(stmts[0])) {
1780
+ bumps = 1;
1781
+ safeDepth++;
1782
+ }
1783
+ earlyReturnStack.push(bumps);
1784
+ }
1785
+ function popFunctionScope() {
1786
+ const bumps = earlyReturnStack.pop() ?? 0;
1787
+ if (bumps > 0) safeDepth -= bumps;
1788
+ }
1400
1789
  return {
1790
+ FunctionDeclaration: pushFunctionScope,
1791
+ "FunctionDeclaration:exit": popFunctionScope,
1792
+ FunctionExpression: pushFunctionScope,
1793
+ "FunctionExpression:exit": popFunctionScope,
1794
+ ArrowFunctionExpression: pushFunctionScope,
1795
+ "ArrowFunctionExpression:exit": popFunctionScope,
1401
1796
  CallExpression(node) {
1402
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
1797
+ if (isSafeContextCall(node)) safeDepth++;
1403
1798
  if (safeDepth > 0) return;
1404
1799
  const callee = node.callee;
1405
1800
  if (callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "document" && callee.property?.type === "Identifier" && DOM_METHODS.has(callee.property.name)) context.report({
@@ -1408,7 +1803,7 @@ const noDomInSetup = {
1408
1803
  });
1409
1804
  },
1410
1805
  "CallExpression:exit"(node) {
1411
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
1806
+ if (isSafeContextCall(node)) safeDepth--;
1412
1807
  }
1413
1808
  };
1414
1809
  }
@@ -1630,6 +2025,11 @@ const preferShowOverDisplay = {
1630
2025
 
1631
2026
  //#endregion
1632
2027
  //#region src/rules/reactivity/no-bare-signal-in-jsx.ts
2028
+ const SKIP_NAMES = new Set([
2029
+ "render",
2030
+ "h",
2031
+ "cloneVNode"
2032
+ ]);
1633
2033
  const SKIP_PREFIXES = /^(use|get|is|has|[A-Z])/;
1634
2034
  const noBareSignalInJsx = {
1635
2035
  meta: {
@@ -1661,7 +2061,7 @@ const noBareSignalInJsx = {
1661
2061
  const callee = expr.callee;
1662
2062
  if (!callee || callee.type !== "Identifier") return;
1663
2063
  const name = callee.name;
1664
- if (SKIP_PREFIXES.test(name)) return;
2064
+ if (SKIP_NAMES.has(name) || SKIP_PREFIXES.test(name)) return;
1665
2065
  const span = getSpan(node);
1666
2066
  const fixed = `{() => ${context.getSourceText().slice(span.start, span.end).slice(1, -1)}}`;
1667
2067
  context.report({
@@ -1962,9 +2362,11 @@ const noUnbatchedUpdates = {
1962
2362
  category: "reactivity",
1963
2363
  description: "Warn when 3+ .set() calls occur in the same function without batch().",
1964
2364
  severity: "warn",
1965
- fixable: false
2365
+ fixable: false,
2366
+ schema: { exemptPaths: "string[]" }
1966
2367
  },
1967
2368
  create(context) {
2369
+ if (isPathExempt(context)) return {};
1968
2370
  const scopeStack = [];
1969
2371
  let batchDepth = 0;
1970
2372
  function enterScope(node) {
@@ -2113,46 +2515,94 @@ const noImperativeNavigateInRender = {
2113
2515
  },
2114
2516
  create(context) {
2115
2517
  let componentBodyDepth = 0;
2116
- let safeDepth = 0;
2117
- return {
2518
+ let nestedFnDepth = 0;
2519
+ const componentInits = /* @__PURE__ */ new WeakSet();
2520
+ const dangerousBindings = [];
2521
+ const nestedFnStack = [];
2522
+ function isComponentFunctionDecl(node) {
2523
+ return /^[A-Z]/.test(node.id?.name ?? "");
2524
+ }
2525
+ function enterNestedFn(node, bindingName) {
2526
+ nestedFnDepth++;
2527
+ nestedFnStack.push({
2528
+ containsNavigate: false,
2529
+ bindingName
2530
+ });
2531
+ }
2532
+ function exitNestedFn() {
2533
+ nestedFnDepth--;
2534
+ const frame = nestedFnStack.pop();
2535
+ if (!frame) return;
2536
+ if (frame.containsNavigate && frame.bindingName && dangerousBindings.length > 0) dangerousBindings[dangerousBindings.length - 1].add(frame.bindingName);
2537
+ }
2538
+ function isNavigateCall(node) {
2539
+ return isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push");
2540
+ }
2541
+ const callbacks = {
2118
2542
  FunctionDeclaration(node) {
2119
- const name = node.id?.name ?? "";
2120
- if (/^[A-Z]/.test(name)) componentBodyDepth++;
2543
+ if (isComponentFunctionDecl(node)) {
2544
+ componentBodyDepth++;
2545
+ dangerousBindings.push(/* @__PURE__ */ new Set());
2546
+ } else if (componentBodyDepth > 0) enterNestedFn(node, node.id?.type === "Identifier" ? node.id.name : null);
2121
2547
  },
2122
2548
  "FunctionDeclaration:exit"(node) {
2123
- const name = node.id?.name ?? "";
2124
- if (/^[A-Z]/.test(name)) componentBodyDepth--;
2549
+ if (isComponentFunctionDecl(node)) {
2550
+ componentBodyDepth--;
2551
+ dangerousBindings.pop();
2552
+ } else if (componentBodyDepth > 0) exitNestedFn();
2125
2553
  },
2126
2554
  VariableDeclarator(node) {
2127
- const name = node.id?.name ?? "";
2128
- if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth++;
2555
+ if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
2556
+ componentBodyDepth++;
2557
+ dangerousBindings.push(/* @__PURE__ */ new Set());
2558
+ componentInits.add(node.init);
2559
+ }
2129
2560
  },
2130
2561
  "VariableDeclarator:exit"(node) {
2131
- const name = node.id?.name ?? "";
2132
- if (/^[A-Z]/.test(name) && node.init?.type === "ArrowFunctionExpression") componentBodyDepth--;
2562
+ if (/^[A-Z]/.test(node.id?.name ?? "") && (node.init?.type === "ArrowFunctionExpression" || node.init?.type === "FunctionExpression")) {
2563
+ componentBodyDepth--;
2564
+ dangerousBindings.pop();
2565
+ }
2566
+ },
2567
+ ArrowFunctionExpression(node) {
2568
+ if (componentInits.has(node)) return;
2569
+ if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
2570
+ },
2571
+ "ArrowFunctionExpression:exit"(node) {
2572
+ if (componentInits.has(node)) return;
2573
+ if (componentBodyDepth > 0) exitNestedFn();
2574
+ },
2575
+ FunctionExpression(node) {
2576
+ if (componentInits.has(node)) return;
2577
+ if (componentBodyDepth > 0) enterNestedFn(node, bindingAssignmentNames.get(node) ?? null);
2578
+ },
2579
+ "FunctionExpression:exit"(node) {
2580
+ if (componentInits.has(node)) return;
2581
+ if (componentBodyDepth > 0) exitNestedFn();
2133
2582
  },
2134
2583
  CallExpression(node) {
2135
2584
  if (componentBodyDepth <= 0) return;
2136
- if (isSafeWrapperCall(node)) safeDepth++;
2137
- if (safeDepth > 0) return;
2138
- if (isCallTo(node, "navigate") || isMemberCallTo(node, "router", "push")) context.report({
2139
- 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.",
2585
+ if (isNavigateCall(node)) {
2586
+ if (nestedFnDepth === 0) context.report({
2587
+ 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.",
2588
+ span: getSpan(node)
2589
+ });
2590
+ else if (nestedFnStack.length > 0) nestedFnStack[nestedFnStack.length - 1].containsNavigate = true;
2591
+ return;
2592
+ }
2593
+ if (nestedFnDepth === 0 && node.callee?.type === "Identifier" && dangerousBindings.length > 0 && dangerousBindings[dangerousBindings.length - 1].has(node.callee.name)) context.report({
2594
+ 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.",
2140
2595
  span: getSpan(node)
2141
2596
  });
2142
- },
2143
- "CallExpression:exit"(node) {
2144
- if (componentBodyDepth <= 0) return;
2145
- if (isSafeWrapperCall(node)) safeDepth--;
2146
2597
  }
2147
2598
  };
2599
+ const bindingAssignmentNames = /* @__PURE__ */ new WeakMap();
2600
+ callbacks.VariableDeclaration = (node) => {
2601
+ 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);
2602
+ };
2603
+ return callbacks;
2148
2604
  }
2149
2605
  };
2150
- function isSafeWrapperCall(node) {
2151
- const callee = node.callee;
2152
- if (!callee || callee.type !== "Identifier") return false;
2153
- const name = callee.name;
2154
- return name === "onMount" || name === "effect" || name === "onUnmount";
2155
- }
2156
2606
 
2157
2607
  //#endregion
2158
2608
  //#region src/rules/router/no-missing-fallback.ts
@@ -2299,32 +2749,236 @@ const noWindowInSsr = {
2299
2749
  category: "ssr",
2300
2750
  description: "Disallow browser globals outside onMount/effect/typeof guards — they break SSR.",
2301
2751
  severity: "error",
2302
- fixable: false
2752
+ fixable: false,
2753
+ schema: { exemptPaths: "string[]" }
2303
2754
  },
2304
2755
  create(context) {
2756
+ if (isPathExempt(context)) return {};
2305
2757
  let safeDepth = 0;
2306
2758
  let typeofGuardDepth = 0;
2759
+ let inTypeofExpr = 0;
2760
+ const skipPropertyNodes = /* @__PURE__ */ new WeakSet();
2761
+ let inTsTypePos = 0;
2762
+ const typeofBoundConsts = /* @__PURE__ */ new Set();
2763
+ function isPositiveTypeofCheck(expr) {
2764
+ if (!expr) return false;
2765
+ if (expr.type === "BinaryExpression" && (expr.operator === "!==" || expr.operator === "!=") && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
2766
+ if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
2767
+ return false;
2768
+ }
2769
+ /** Used by VariableDeclaration to decide whether to bind a const. */
2770
+ function isTypeofCheckForBinding(expr) {
2771
+ if (!expr) return false;
2772
+ if (expr.type === "BinaryExpression" && expr.left?.type === "UnaryExpression" && expr.left.operator === "typeof") return true;
2773
+ if (expr.type === "UnaryExpression" && expr.operator === "typeof") return true;
2774
+ if (expr.type === "LogicalExpression" && expr.operator === "&&") return isTypeofCheckForBinding(expr.left) || isTypeofCheckForBinding(expr.right);
2775
+ if (expr.type === "ConditionalExpression") return isTypeofCheckForBinding(expr.test);
2776
+ if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
2777
+ return false;
2778
+ }
2779
+ /**
2780
+ * `if (test) { … }` — does the test indicate the body is the
2781
+ * BROWSER-SAFE branch? Only positive forms qualify here. Negated
2782
+ * forms (`typeof X === 'undefined'`, `!isBrowser`) are early-return
2783
+ * guards handled separately.
2784
+ */
2785
+ function testIsTypeofGuard(test) {
2786
+ if (!test) return false;
2787
+ if (isPositiveTypeofCheck(test)) return true;
2788
+ if (test.type === "Identifier" && typeofBoundConsts.has(test.name)) return true;
2789
+ if (test.type === "CallExpression" && test.callee?.type === "Identifier" && typeofGuardFunctions.has(test.callee.name)) return true;
2790
+ if (test.type === "LogicalExpression" && test.operator === "&&") return testIsTypeofGuard(test.left) || testIsTypeofGuard(test.right);
2791
+ return false;
2792
+ }
2793
+ const typeofGuardFunctions = new Set([
2794
+ "isBrowser",
2795
+ "isClient",
2796
+ "isServer",
2797
+ "isSSR"
2798
+ ]);
2799
+ function bodyIsTypeofGuard(body) {
2800
+ if (!body) return false;
2801
+ if (body.type !== "BlockStatement") return isReturnedTypeofExpr(body);
2802
+ const stmts = body.body ?? [];
2803
+ if (stmts.length !== 1) return false;
2804
+ const stmt = stmts[0];
2805
+ if (stmt?.type !== "ReturnStatement") return false;
2806
+ return isReturnedTypeofExpr(stmt.argument);
2807
+ }
2808
+ function isReturnedTypeofExpr(expr) {
2809
+ if (!expr) return false;
2810
+ if (isPositiveTypeofCheck(expr)) return true;
2811
+ if (expr.type === "LogicalExpression" && expr.operator === "&&") return isReturnedTypeofExpr(expr.left) && isReturnedTypeofExpr(expr.right);
2812
+ if (expr.type === "Identifier" && typeofBoundConsts.has(expr.name)) return true;
2813
+ return false;
2814
+ }
2815
+ function isNegatedTypeofExpr(test) {
2816
+ if (!test) return false;
2817
+ if (test.type === "BinaryExpression" && (test.operator === "===" || test.operator === "==") && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") return true;
2818
+ if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "Identifier" && typeofBoundConsts.has(test.argument.name)) return true;
2819
+ if (test.type === "UnaryExpression" && test.operator === "!" && test.argument?.type === "CallExpression" && test.argument.callee?.type === "Identifier" && typeofGuardFunctions.has(test.argument.callee.name)) return true;
2820
+ if (test.type === "LogicalExpression" && test.operator === "||") return isNegatedTypeofExpr(test.left) && isNegatedTypeofExpr(test.right);
2821
+ return false;
2822
+ }
2823
+ function isEarlyReturnTypeofGuard(stmt) {
2824
+ if (!stmt || stmt.type !== "IfStatement") return false;
2825
+ if (!isNegatedTypeofExpr(stmt.test)) return false;
2826
+ const c = stmt.consequent;
2827
+ const isTerminator = (s) => s?.type === "ReturnStatement" || s?.type === "ThrowStatement";
2828
+ if (isTerminator(c)) return true;
2829
+ if (c?.type === "BlockStatement" && c.body.length === 1 && isTerminator(c.body[0])) return true;
2830
+ return false;
2831
+ }
2832
+ const earlyReturnStack = [];
2833
+ const watchCallbackNodes = /* @__PURE__ */ new WeakSet();
2834
+ const watchCallbackSafeDepthStack = [];
2835
+ const shadowedNamesStack = [];
2836
+ const importShadowedNames = /* @__PURE__ */ new Set();
2837
+ function collectParamNames(params) {
2838
+ const names = /* @__PURE__ */ new Set();
2839
+ const walk = (p) => {
2840
+ if (!p) return;
2841
+ if (p.type === "Identifier" && BROWSER_GLOBALS.has(p.name)) names.add(p.name);
2842
+ else if (p.type === "AssignmentPattern") walk(p.left);
2843
+ else if (p.type === "RestElement") walk(p.argument);
2844
+ else if (p.type === "ArrayPattern") for (const el of p.elements ?? []) walk(el);
2845
+ else if (p.type === "ObjectPattern") for (const prop of p.properties ?? []) if (prop.type === "RestElement") walk(prop.argument);
2846
+ else walk(prop.value);
2847
+ };
2848
+ for (const p of params ?? []) walk(p);
2849
+ return names;
2850
+ }
2851
+ function isNameShadowed(name) {
2852
+ for (let i = shadowedNamesStack.length - 1; i >= 0; i--) if (shadowedNamesStack[i].has(name)) return true;
2853
+ return false;
2854
+ }
2855
+ function pushFunctionScope(node) {
2856
+ earlyReturnStack.push(0);
2857
+ shadowedNamesStack.push(node ? collectParamNames(node.params ?? []) : /* @__PURE__ */ new Set());
2858
+ if (node && watchCallbackNodes.has(node)) {
2859
+ safeDepth++;
2860
+ watchCallbackSafeDepthStack.push(1);
2861
+ } else watchCallbackSafeDepthStack.push(0);
2862
+ }
2863
+ function popFunctionScope() {
2864
+ const bumps = earlyReturnStack.pop() ?? 0;
2865
+ typeofGuardDepth -= bumps;
2866
+ shadowedNamesStack.pop();
2867
+ const watchBump = watchCallbackSafeDepthStack.pop() ?? 0;
2868
+ if (watchBump > 0) safeDepth -= watchBump;
2869
+ }
2870
+ function noteEarlyReturnGuardVisit() {
2871
+ typeofGuardDepth++;
2872
+ if (earlyReturnStack.length > 0) earlyReturnStack[earlyReturnStack.length - 1]++;
2873
+ }
2307
2874
  return {
2875
+ VariableDeclaration(node) {
2876
+ for (const decl of node.declarations ?? []) {
2877
+ if (decl.id?.type !== "Identifier") continue;
2878
+ if (isTypeofCheckForBinding(decl.init)) typeofBoundConsts.add(decl.id.name);
2879
+ if ((decl.init?.type === "ArrowFunctionExpression" || decl.init?.type === "FunctionExpression") && bodyIsTypeofGuard(decl.init.body)) typeofGuardFunctions.add(decl.id.name);
2880
+ }
2881
+ },
2882
+ FunctionDeclaration(node) {
2883
+ if (node.id?.type === "Identifier" && bodyIsTypeofGuard(node.body)) typeofGuardFunctions.add(node.id.name);
2884
+ pushFunctionScope(node);
2885
+ },
2886
+ "FunctionDeclaration:exit": popFunctionScope,
2887
+ FunctionExpression: pushFunctionScope,
2888
+ "FunctionExpression:exit": popFunctionScope,
2889
+ ArrowFunctionExpression: pushFunctionScope,
2890
+ "ArrowFunctionExpression:exit": popFunctionScope,
2308
2891
  CallExpression(node) {
2309
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth++;
2892
+ if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) {
2893
+ safeDepth++;
2894
+ return;
2895
+ }
2896
+ if (isCallTo(node, "watch")) {
2897
+ const cb = node.arguments?.[1];
2898
+ if (cb?.type === "ArrowFunctionExpression" || cb?.type === "FunctionExpression" || cb?.type === "FunctionDeclaration") watchCallbackNodes.add(cb);
2899
+ }
2310
2900
  },
2311
2901
  "CallExpression:exit"(node) {
2312
- if (isCallTo(node, "onMount") || isCallTo(node, "effect")) safeDepth--;
2902
+ if (isCallTo(node, "onMount") || isCallTo(node, "onUnmount") || isCallTo(node, "onCleanup") || isCallTo(node, "effect") || isCallTo(node, "renderEffect") || isCallTo(node, "requestAnimationFrame")) safeDepth--;
2313
2903
  },
2314
2904
  IfStatement(node) {
2315
- const test = node.test;
2316
- if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth++;
2905
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
2906
+ else if (isEarlyReturnTypeofGuard(node)) noteEarlyReturnGuardVisit();
2317
2907
  },
2318
2908
  "IfStatement:exit"(node) {
2319
- const test = node.test;
2320
- if (test?.type === "BinaryExpression" && test.left?.type === "UnaryExpression" && test.left.operator === "typeof") typeofGuardDepth--;
2909
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
2910
+ },
2911
+ ConditionalExpression(node) {
2912
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth++;
2913
+ },
2914
+ "ConditionalExpression:exit"(node) {
2915
+ if (testIsTypeofGuard(node.test)) typeofGuardDepth--;
2916
+ },
2917
+ UnaryExpression(node) {
2918
+ if (node.operator === "typeof") inTypeofExpr++;
2919
+ },
2920
+ "UnaryExpression:exit"(node) {
2921
+ if (node.operator === "typeof") inTypeofExpr--;
2922
+ },
2923
+ TSTypeAnnotation(_n) {
2924
+ inTsTypePos++;
2925
+ },
2926
+ "TSTypeAnnotation:exit"(_n) {
2927
+ inTsTypePos--;
2928
+ },
2929
+ TSTypeReference(_n) {
2930
+ inTsTypePos++;
2931
+ },
2932
+ "TSTypeReference:exit"(_n) {
2933
+ inTsTypePos--;
2321
2934
  },
2322
- Identifier(node, parent) {
2323
- if (safeDepth > 0 || typeofGuardDepth > 0) return;
2935
+ TSTypeAliasDeclaration(_n) {
2936
+ inTsTypePos++;
2937
+ },
2938
+ "TSTypeAliasDeclaration:exit"(_n) {
2939
+ inTsTypePos--;
2940
+ },
2941
+ TSInterfaceDeclaration(_n) {
2942
+ inTsTypePos++;
2943
+ },
2944
+ "TSInterfaceDeclaration:exit"(_n) {
2945
+ inTsTypePos--;
2946
+ },
2947
+ TSTypeParameter(_n) {
2948
+ inTsTypePos++;
2949
+ },
2950
+ "TSTypeParameter:exit"(_n) {
2951
+ inTsTypePos--;
2952
+ },
2953
+ MemberExpression(node) {
2954
+ if (!node.computed && node.property?.type === "Identifier") skipPropertyNodes.add(node.property);
2955
+ },
2956
+ Property(node) {
2957
+ if (!node.computed && node.key?.type === "Identifier") skipPropertyNodes.add(node.key);
2958
+ },
2959
+ ImportSpecifier(node) {
2960
+ if (node.imported?.type === "Identifier") skipPropertyNodes.add(node.imported);
2961
+ if (node.local?.type === "Identifier" && node.local !== node.imported) skipPropertyNodes.add(node.local);
2962
+ if (node.local?.type === "Identifier" && BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2963
+ },
2964
+ ImportDefaultSpecifier(node) {
2965
+ if (node.local?.type === "Identifier") {
2966
+ skipPropertyNodes.add(node.local);
2967
+ if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2968
+ }
2969
+ },
2970
+ ImportNamespaceSpecifier(node) {
2971
+ if (node.local?.type === "Identifier") {
2972
+ skipPropertyNodes.add(node.local);
2973
+ if (BROWSER_GLOBALS.has(node.local.name)) importShadowedNames.add(node.local.name);
2974
+ }
2975
+ },
2976
+ Identifier(node) {
2977
+ if (safeDepth > 0 || typeofGuardDepth > 0 || inTypeofExpr > 0 || inTsTypePos > 0) return;
2978
+ if (skipPropertyNodes.has(node)) return;
2324
2979
  if (!BROWSER_GLOBALS.has(node.name)) return;
2325
- if (parent?.type === "UnaryExpression" && parent.operator === "typeof") return;
2326
- if (parent?.type === "ImportSpecifier" || parent?.type === "ImportDefaultSpecifier" || parent?.type === "ImportNamespaceSpecifier") return;
2327
- if (parent?.type === "MemberExpression" && parent.property === node && !parent.computed) return;
2980
+ if (isNameShadowed(node.name)) return;
2981
+ if (importShadowedNames.has(node.name)) return;
2328
2982
  context.report({
2329
2983
  message: `Browser global \`${node.name}\` used outside \`onMount\`/\`effect\`/typeof guard — this will fail during SSR. Wrap in \`onMount(() => { ... })\`.`,
2330
2984
  span: getSpan(node)
@@ -2392,6 +3046,7 @@ const noDuplicateStoreId = {
2392
3046
  fixable: false
2393
3047
  },
2394
3048
  create(context) {
3049
+ if (isTestFile(context.getFilePath())) return {};
2395
3050
  const storeIds = /* @__PURE__ */ new Map();
2396
3051
  return { CallExpression(node) {
2397
3052
  if (!isCallTo(node, "defineStore")) return;
@@ -2417,25 +3072,30 @@ const noMutateStoreState = {
2417
3072
  meta: {
2418
3073
  id: "pyreon/no-mutate-store-state",
2419
3074
  category: "store",
2420
- description: "Warn when directly calling .set() on store signals — use store actions instead.",
3075
+ description: "Warn when calling .set() on store signals from a component or hook — use store actions instead.",
2421
3076
  severity: "warn",
2422
3077
  fixable: false
2423
3078
  },
2424
3079
  create(context) {
2425
- return { CallExpression(node) {
2426
- const callee = node.callee;
2427
- if (!callee || callee.type !== "MemberExpression") return;
2428
- if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
2429
- const obj = callee.object;
2430
- if (!obj || obj.type !== "MemberExpression") return;
2431
- const outerObj = obj.object;
2432
- if (!outerObj || outerObj.type !== "Identifier") return;
2433
- const name = outerObj.name;
2434
- if (name.toLowerCase().includes("store")) context.report({
2435
- message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
2436
- span: getSpan(node)
2437
- });
2438
- } };
3080
+ const ctx = createComponentContextTracker();
3081
+ return {
3082
+ ...ctx.callbacks,
3083
+ CallExpression(node) {
3084
+ if (!ctx.isInComponentOrHook()) return;
3085
+ const callee = node.callee;
3086
+ if (!callee || callee.type !== "MemberExpression") return;
3087
+ if (callee.property?.type !== "Identifier" || callee.property.name !== "set") return;
3088
+ const obj = callee.object;
3089
+ if (!obj || obj.type !== "MemberExpression") return;
3090
+ const outerObj = obj.object;
3091
+ if (!outerObj || outerObj.type !== "Identifier") return;
3092
+ const name = outerObj.name;
3093
+ if (name.toLowerCase().includes("store")) context.report({
3094
+ message: `Direct \`.set()\` on store state \`${name}\` — use store actions to mutate state for better traceability.`,
3095
+ span: getSpan(node)
3096
+ });
3097
+ }
3098
+ };
2439
3099
  }
2440
3100
  };
2441
3101
 
@@ -2486,44 +3146,27 @@ const noDynamicStyled = {
2486
3146
  meta: {
2487
3147
  id: "pyreon/no-dynamic-styled",
2488
3148
  category: "styling",
2489
- description: "Warn when styled() is called inside a function — it creates new CSS on every render.",
3149
+ description: "Warn when styled() is called inside a component or hook — it creates new CSS on every render.",
2490
3150
  severity: "warn",
2491
3151
  fixable: false
2492
3152
  },
2493
3153
  create(context) {
2494
- let functionDepth = 0;
3154
+ const ctx = createComponentContextTracker();
2495
3155
  return {
2496
- FunctionDeclaration() {
2497
- functionDepth++;
2498
- },
2499
- "FunctionDeclaration:exit"() {
2500
- functionDepth--;
2501
- },
2502
- FunctionExpression() {
2503
- functionDepth++;
2504
- },
2505
- "FunctionExpression:exit"() {
2506
- functionDepth--;
2507
- },
2508
- ArrowFunctionExpression() {
2509
- functionDepth++;
2510
- },
2511
- "ArrowFunctionExpression:exit"() {
2512
- functionDepth--;
2513
- },
3156
+ ...ctx.callbacks,
2514
3157
  CallExpression(node) {
2515
- if (functionDepth === 0) return;
3158
+ if (!ctx.isInComponentOrHook()) return;
2516
3159
  if (isCallTo(node, "styled")) context.report({
2517
- message: "`styled()` inside a function — this creates new CSS rules on every render. Move `styled()` to module scope.",
3160
+ message: "`styled()` inside a component or hook — this creates new CSS rules on every render. Move `styled()` to module scope.",
2518
3161
  span: getSpan(node)
2519
3162
  });
2520
3163
  },
2521
3164
  TaggedTemplateExpression(node) {
2522
- if (functionDepth === 0) return;
3165
+ if (!ctx.isInComponentOrHook()) return;
2523
3166
  const tag = node.tag;
2524
3167
  if (!tag) return;
2525
3168
  if (tag.type === "CallExpression" && isCallTo(tag, "styled")) context.report({
2526
- message: "`styled()` tagged template inside a function — this creates new CSS rules on every render. Move to module scope.",
3169
+ message: "`styled()` tagged template inside a component or hook — this creates new CSS rules on every render. Move to module scope.",
2527
3170
  span: getSpan(node)
2528
3171
  });
2529
3172
  }
@@ -2556,6 +3199,7 @@ const noInlineStyleObject = {
2556
3199
 
2557
3200
  //#endregion
2558
3201
  //#region src/rules/styling/no-theme-outside-provider.ts
3202
+ const HOOK_NAME = /^use[A-Z]/;
2559
3203
  const noThemeOutsideProvider = {
2560
3204
  meta: {
2561
3205
  id: "pyreon/no-theme-outside-provider",
@@ -2566,22 +3210,47 @@ const noThemeOutsideProvider = {
2566
3210
  },
2567
3211
  create(context) {
2568
3212
  let hasProviderImport = false;
3213
+ let hookDepth = 0;
2569
3214
  const themeCalls = [];
3215
+ function declaratorIsHook(node) {
3216
+ if (node?.id?.type !== "Identifier") return false;
3217
+ const init = node.init;
3218
+ if (init?.type !== "ArrowFunctionExpression" && init?.type !== "FunctionExpression") return false;
3219
+ return HOOK_NAME.test(node.id.name);
3220
+ }
2570
3221
  return {
3222
+ FunctionDeclaration(node) {
3223
+ if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth++;
3224
+ },
3225
+ "FunctionDeclaration:exit"(node) {
3226
+ if (node.id?.name && HOOK_NAME.test(node.id.name)) hookDepth--;
3227
+ },
3228
+ VariableDeclarator(node) {
3229
+ if (declaratorIsHook(node)) hookDepth++;
3230
+ },
3231
+ "VariableDeclarator:exit"(node) {
3232
+ if (declaratorIsHook(node)) hookDepth--;
3233
+ },
2571
3234
  ImportDeclaration(node) {
2572
3235
  const info = extractImportInfo(node);
2573
3236
  if (!info) return;
2574
3237
  if (info.specifiers.some((s) => s.imported === "PyreonUI" || s.imported === "ThemeProvider")) hasProviderImport = true;
2575
3238
  },
2576
3239
  CallExpression(node) {
2577
- if (isCallTo(node, "useTheme")) themeCalls.push({ span: getSpan(node) });
3240
+ if (isCallTo(node, "useTheme")) themeCalls.push({
3241
+ span: getSpan(node),
3242
+ insideHook: hookDepth > 0
3243
+ });
2578
3244
  },
2579
3245
  "Program:exit"() {
2580
3246
  if (hasProviderImport) return;
2581
- for (const call of themeCalls) context.report({
2582
- message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
2583
- span: call.span
2584
- });
3247
+ for (const call of themeCalls) {
3248
+ if (call.insideHook) continue;
3249
+ context.report({
3250
+ message: "`useTheme()` without a `PyreonUI` or `ThemeProvider` import — the theme context may not be available.",
3251
+ span: call.span
3252
+ });
3253
+ }
2585
3254
  }
2586
3255
  };
2587
3256
  }
@@ -2660,6 +3329,7 @@ const allRules = [
2660
3329
  devGuardWarnings,
2661
3330
  noErrorWithoutPrefix,
2662
3331
  noProcessDevGate,
3332
+ requireBrowserSmokeTest,
2663
3333
  noStoreOutsideProvider,
2664
3334
  noMutateStoreState,
2665
3335
  noDuplicateStoreId,
@@ -2690,11 +3360,17 @@ function buildRecommended() {
2690
3360
  for (const rule of allRules) rules[rule.meta.id] = rule.meta.severity;
2691
3361
  return { rules };
2692
3362
  }
3363
+ function severityOf(entry) {
3364
+ return Array.isArray(entry) ? entry[0] : entry;
3365
+ }
2693
3366
  /** Build a config where every warn is promoted to error. */
2694
3367
  function buildStrict() {
2695
3368
  const base = buildRecommended();
2696
3369
  const rules = {};
2697
- for (const [id, sev] of Object.entries(base.rules)) rules[id] = sev === "warn" ? "error" : sev;
3370
+ for (const [id, entry] of Object.entries(base.rules)) {
3371
+ const sev = severityOf(entry);
3372
+ rules[id] = sev === "warn" ? "error" : sev;
3373
+ }
2698
3374
  return { rules };
2699
3375
  }
2700
3376
  /** Build app config — recommended but disable library-only rules. */
@@ -2704,7 +3380,8 @@ function buildApp() {
2704
3380
  "pyreon/dev-guard-warnings": "off",
2705
3381
  "pyreon/no-error-without-prefix": "off",
2706
3382
  "pyreon/no-circular-import": "off",
2707
- "pyreon/no-cross-layer-import": "off"
3383
+ "pyreon/no-cross-layer-import": "off",
3384
+ "pyreon/require-browser-smoke-test": "off"
2708
3385
  } };
2709
3386
  }
2710
3387
  /** Build lib config — strict + all architecture rules as error. */
@@ -2715,7 +3392,8 @@ function buildLib() {
2715
3392
  "pyreon/no-cross-layer-import": "error",
2716
3393
  "pyreon/dev-guard-warnings": "error",
2717
3394
  "pyreon/no-error-without-prefix": "error",
2718
- "pyreon/no-process-dev-gate": "error"
3395
+ "pyreon/no-process-dev-gate": "error",
3396
+ "pyreon/require-browser-smoke-test": "error"
2719
3397
  } };
2720
3398
  }
2721
3399
  const presetBuilders = {
@@ -2773,8 +3451,46 @@ function hasJsExtension(filePath) {
2773
3451
  return JS_EXTENSIONS.has(ext);
2774
3452
  }
2775
3453
 
3454
+ //#endregion
3455
+ //#region src/utils/validate-options.ts
3456
+ function validateRuleOptions(rule, options) {
3457
+ const schema = rule.meta.schema;
3458
+ const errors = [];
3459
+ const warnings = [];
3460
+ if (!schema) return {
3461
+ errors,
3462
+ warnings
3463
+ };
3464
+ for (const [key, value] of Object.entries(options)) {
3465
+ const expected = schema[key];
3466
+ if (expected === void 0) {
3467
+ warnings.push(`[${rule.meta.id}] unknown option "${key}" — allowed options: ${Object.keys(schema).join(", ") || "(none)"}`);
3468
+ continue;
3469
+ }
3470
+ if (!matchesType(value, expected)) errors.push(`[${rule.meta.id}] option "${key}" must be ${expected}, got ${describe(value)}`);
3471
+ }
3472
+ return {
3473
+ errors,
3474
+ warnings
3475
+ };
3476
+ }
3477
+ function matchesType(value, type) {
3478
+ switch (type) {
3479
+ case "string": return typeof value === "string";
3480
+ case "string[]": return Array.isArray(value) && value.every((x) => typeof x === "string");
3481
+ case "number": return typeof value === "number" && Number.isFinite(value);
3482
+ case "boolean": return typeof value === "boolean";
3483
+ }
3484
+ }
3485
+ function describe(value) {
3486
+ if (value === null) return "null";
3487
+ if (Array.isArray(value)) return `Array<${[...new Set(value.map((x) => x === null ? "null" : typeof x))].join(" | ") || "empty"}>`;
3488
+ return typeof value;
3489
+ }
3490
+
2776
3491
  //#endregion
2777
3492
  //#region src/runner.ts
3493
+ const VALIDATION_CACHE = /* @__PURE__ */ new Map();
2778
3494
  function getExtension(filePath) {
2779
3495
  const lastDot = filePath.lastIndexOf(".");
2780
3496
  return lastDot === -1 ? "" : filePath.slice(lastDot);
@@ -2784,7 +3500,7 @@ function getLang(ext) {
2784
3500
  if (ext === ".ts" || ext === ".mts") return "ts";
2785
3501
  return "js";
2786
3502
  }
2787
- function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath) {
3503
+ function createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath) {
2788
3504
  return {
2789
3505
  report(partial) {
2790
3506
  diagnostics.push({
@@ -2801,6 +3517,9 @@ function createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, f
2801
3517
  },
2802
3518
  getFilePath() {
2803
3519
  return filePath;
3520
+ },
3521
+ getOptions() {
3522
+ return options;
2804
3523
  }
2805
3524
  };
2806
3525
  }
@@ -2830,7 +3549,7 @@ function mergeCallbacks(allCallbacks) {
2830
3549
  * for (const d of result.diagnostics) console.log(d.message)
2831
3550
  * ```
2832
3551
  */
2833
- function lintFile(filePath, sourceText, rules, config, cache) {
3552
+ function lintFile(filePath, sourceText, rules, config, cache, configDiagnosticsSink) {
2834
3553
  const ext = getExtension(filePath);
2835
3554
  if (!JS_EXTENSIONS.has(ext)) return {
2836
3555
  filePath,
@@ -2863,20 +3582,50 @@ function lintFile(filePath, sourceText, rules, config, cache) {
2863
3582
  const diagnostics = [];
2864
3583
  const allCallbacks = [];
2865
3584
  for (const rule of rules) {
2866
- const severity = config.rules[rule.meta.id];
2867
- if (severity === void 0 || severity === "off") continue;
2868
- const ctx = createRuleContext(rule, severity, diagnostics, lineIndex, sourceText, filePath);
3585
+ const entry = config.rules[rule.meta.id];
3586
+ if (entry === void 0) continue;
3587
+ const [severity, options] = Array.isArray(entry) ? [entry[0], entry[1] ?? {}] : [entry, {}];
3588
+ if (severity === "off") continue;
3589
+ const cacheKey = `${rule.meta.id}::${JSON.stringify(options)}`;
3590
+ let cached = VALIDATION_CACHE.get(cacheKey);
3591
+ if (!cached) {
3592
+ const { errors, warnings } = validateRuleOptions(rule, options);
3593
+ const configDiags = [];
3594
+ for (const message of warnings) configDiags.push({
3595
+ ruleId: rule.meta.id,
3596
+ severity: "warn",
3597
+ message
3598
+ });
3599
+ for (const message of errors) configDiags.push({
3600
+ ruleId: rule.meta.id,
3601
+ severity: "error",
3602
+ message
3603
+ });
3604
+ cached = {
3605
+ ok: errors.length === 0,
3606
+ diagnostics: configDiags
3607
+ };
3608
+ VALIDATION_CACHE.set(cacheKey, cached);
3609
+ }
3610
+ if (cached.diagnostics.length > 0) if (configDiagnosticsSink) {
3611
+ for (const d of cached.diagnostics) if (!configDiagnosticsSink.some((x) => x.ruleId === d.ruleId && x.message === d.message)) configDiagnosticsSink.push(d);
3612
+ } else for (const d of cached.diagnostics) (d.severity === "error" ? console.error : console.warn)(`[pyreon-lint] ${d.message}`);
3613
+ if (!cached.ok) continue;
3614
+ const ctx = createRuleContext(rule, severity, options, diagnostics, lineIndex, sourceText, filePath);
2869
3615
  allCallbacks.push(rule.create(ctx));
2870
3616
  }
2871
3617
  new Visitor(mergeCallbacks(allCallbacks)).visit(program);
2872
3618
  const lines = sourceText.split("\n");
3619
+ const SUPPRESS_RE = /^\/\/\s*pyreon-lint-(?:ignore|disable-next-line)(?:\s+(\S+))?\s*$/;
2873
3620
  const filtered = diagnostics.filter((d) => {
2874
3621
  const prevLineIdx = d.loc.line - 2;
2875
3622
  if (prevLineIdx < 0) return true;
2876
- const prevLine = lines[prevLineIdx]?.trim();
2877
- if (!prevLine?.startsWith("// pyreon-lint-ignore")) return true;
2878
- const rest = prevLine.slice(21).trim();
2879
- return rest.length > 0 && rest !== d.ruleId;
3623
+ const prevLine = lines[prevLineIdx]?.trim() ?? "";
3624
+ const match = SUPPRESS_RE.exec(prevLine);
3625
+ if (!match) return true;
3626
+ const ruleId = match[1];
3627
+ if (!ruleId) return false;
3628
+ return ruleId !== d.ruleId;
2880
3629
  });
2881
3630
  filtered.sort((a, b) => a.span.start - b.span.start);
2882
3631
  return {
@@ -2954,8 +3703,17 @@ function buildConfig(options) {
2954
3703
  const cwd = resolve(".");
2955
3704
  const fileConfig = options.config ? loadConfigFromPath(options.config) : loadConfig(cwd);
2956
3705
  const config = getPreset(options.preset ?? fileConfig?.preset ?? "recommended");
2957
- if (fileConfig?.rules) for (const [id, severity] of Object.entries(fileConfig.rules)) config.rules[id] = severity;
3706
+ if (fileConfig?.rules) for (const [id, entry] of Object.entries(fileConfig.rules)) config.rules[id] = entry;
2958
3707
  if (options.ruleOverrides) for (const [id, severity] of Object.entries(options.ruleOverrides)) config.rules[id] = severity;
3708
+ if (options.ruleOptionsOverrides) for (const [id, optionOverrides] of Object.entries(options.ruleOptionsOverrides)) {
3709
+ const existing = config.rules[id];
3710
+ const [currentSeverity, currentOptions] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
3711
+ if (currentSeverity === "off") continue;
3712
+ config.rules[id] = [currentSeverity, {
3713
+ ...currentOptions,
3714
+ ...optionOverrides
3715
+ }];
3716
+ }
2959
3717
  return {
2960
3718
  config,
2961
3719
  include: fileConfig?.include,
@@ -3005,11 +3763,13 @@ function lint(options) {
3005
3763
  const { config, include, exclude, isIgnored } = buildConfig(options);
3006
3764
  const cache = new AstCache();
3007
3765
  const files = gatherFiles(options.paths, isIgnored, include, exclude);
3766
+ const configDiagnostics = [];
3008
3767
  const results = {
3009
3768
  files: [],
3010
3769
  totalErrors: 0,
3011
3770
  totalWarnings: 0,
3012
- totalInfos: 0
3771
+ totalInfos: 0,
3772
+ configDiagnostics
3013
3773
  };
3014
3774
  for (const filePath of files) {
3015
3775
  let source;
@@ -3018,7 +3778,7 @@ function lint(options) {
3018
3778
  } catch {
3019
3779
  continue;
3020
3780
  }
3021
- const fileResult = lintFile(filePath, source, allRules, config, cache);
3781
+ const fileResult = lintFile(filePath, source, allRules, config, cache, configDiagnostics);
3022
3782
  if (options.fix) applyFixesToFile(fileResult, source);
3023
3783
  if (options.quiet) fileResult.diagnostics = fileResult.diagnostics.filter((d) => d.severity === "error");
3024
3784
  countDiagnostics(fileResult, results);
@@ -3063,7 +3823,7 @@ function listRules() {
3063
3823
  * @module
3064
3824
  */
3065
3825
  const cache = new AstCache();
3066
- const config = getPreset("recommended");
3826
+ let config = getPreset("recommended");
3067
3827
  function toLspDiagnostics(diagnostics) {
3068
3828
  return diagnostics.map((d) => ({
3069
3829
  range: {
@@ -3300,6 +4060,7 @@ function watchAndLint(options) {
3300
4060
  const cache = new AstCache();
3301
4061
  const config = getPreset(options.preset ?? "recommended");
3302
4062
  applyOverrides(config, options.ruleOverrides);
4063
+ applyOptionsOverrides(config, options.ruleOptionsOverrides);
3303
4064
  const isIgnored = createIgnoreFilter(resolve("."), options.ignore);
3304
4065
  const pending = /* @__PURE__ */ new Map();
3305
4066
  console.log(`\x1b[2m[pyreon-lint] Watching for changes...\x1b[0m\n`);
@@ -3326,6 +4087,18 @@ function applyOverrides(config, overrides) {
3326
4087
  if (!overrides) return;
3327
4088
  for (const [id, severity] of Object.entries(overrides)) config.rules[id] = severity;
3328
4089
  }
4090
+ function applyOptionsOverrides(config, overrides) {
4091
+ if (!overrides) return;
4092
+ for (const [id, opts] of Object.entries(overrides)) {
4093
+ const existing = config.rules[id];
4094
+ const [severity, current] = Array.isArray(existing) ? [existing[0], existing[1] ?? {}] : [existing ?? "off", {}];
4095
+ if (severity === "off") continue;
4096
+ config.rules[id] = [severity, {
4097
+ ...current,
4098
+ ...opts
4099
+ }];
4100
+ }
4101
+ }
3329
4102
  function relintFile(filePath, config, cache, format) {
3330
4103
  let source;
3331
4104
  try {
@@ -3339,7 +4112,8 @@ function relintFile(filePath, config, cache, format) {
3339
4112
  files: [fileResult],
3340
4113
  totalErrors: 0,
3341
4114
  totalWarnings: 0,
3342
- totalInfos: 0
4115
+ totalInfos: 0,
4116
+ configDiagnostics: []
3343
4117
  };
3344
4118
  for (const d of fileResult.diagnostics) if (d.severity === "error") result.totalErrors++;
3345
4119
  else if (d.severity === "warn") result.totalWarnings++;
@@ -3361,7 +4135,8 @@ function printUsage() {
3361
4135
  --format <fmt> Output: text (default), json, compact
3362
4136
  --quiet Only show errors
3363
4137
  --list List all available rules
3364
- --rule <id>=<sev> Override rule severity
4138
+ --rule <id>=<sev> Override rule severity (e.g. --rule pyreon/no-window-in-ssr=off)
4139
+ --rule-options <id>=<json> Override rule options (e.g. --rule-options pyreon/no-window-in-ssr='{"exemptPaths":["src/foundation/"]}')
3365
4140
  --config <path> Config file path
3366
4141
  --ignore <path> Ignore file path
3367
4142
  --watch Watch mode — re-lint on file changes
@@ -3408,6 +4183,7 @@ function parseArgs(argv) {
3408
4183
  configPath: void 0,
3409
4184
  ignorePath: void 0,
3410
4185
  ruleOverrides: {},
4186
+ ruleOptionsOverrides: {},
3411
4187
  paths: []
3412
4188
  };
3413
4189
  for (let i = 0; i < argv.length; i++) {
@@ -3444,6 +4220,10 @@ function parseValueFlag(arg, nextArg, result) {
3444
4220
  parseRuleOverride(nextArg, result.ruleOverrides);
3445
4221
  return 1;
3446
4222
  }
4223
+ if (arg === "--rule-options") {
4224
+ parseRuleOptionsOverride(nextArg, result.ruleOptionsOverrides);
4225
+ return 1;
4226
+ }
3447
4227
  if (arg) result.paths.push(arg);
3448
4228
  return 0;
3449
4229
  }
@@ -3454,6 +4234,21 @@ function parseRuleOverride(val, overrides) {
3454
4234
  const ruleId = val.slice(0, eqIdx);
3455
4235
  overrides[ruleId] = val.slice(eqIdx + 1);
3456
4236
  }
4237
+ /** Exported for testing only. */
4238
+ function parseRuleOptionsOverride(val, overrides) {
4239
+ if (!val) return;
4240
+ const eqIdx = val.indexOf("=");
4241
+ if (eqIdx === -1) return;
4242
+ const ruleId = val.slice(0, eqIdx);
4243
+ const json = val.slice(eqIdx + 1);
4244
+ try {
4245
+ const parsed = JSON.parse(json);
4246
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) overrides[ruleId] = parsed;
4247
+ else console.error(`[pyreon-lint] --rule-options ${ruleId}: expected JSON object, got ${typeof parsed}`);
4248
+ } catch (err) {
4249
+ console.error(`[pyreon-lint] --rule-options ${ruleId}: invalid JSON — ${err.message}`);
4250
+ }
4251
+ }
3457
4252
  function main() {
3458
4253
  const args = parseArgs(process.argv.slice(2));
3459
4254
  if (args.showHelp) {
@@ -3480,6 +4275,7 @@ function main() {
3480
4275
  fix: args.fix,
3481
4276
  quiet: args.quiet,
3482
4277
  ruleOverrides: args.ruleOverrides,
4278
+ ruleOptionsOverrides: args.ruleOptionsOverrides,
3483
4279
  config: args.configPath,
3484
4280
  ignore: args.ignorePath,
3485
4281
  format: args.format
@@ -3492,6 +4288,7 @@ function main() {
3492
4288
  fix: args.fix,
3493
4289
  quiet: args.quiet,
3494
4290
  ruleOverrides: args.ruleOverrides,
4291
+ ruleOptionsOverrides: args.ruleOptionsOverrides,
3495
4292
  config: args.configPath,
3496
4293
  ignore: args.ignorePath
3497
4294
  });
@@ -3503,7 +4300,8 @@ function main() {
3503
4300
  }
3504
4301
  if (result.totalErrors > 0) process.exit(1);
3505
4302
  }
3506
- main();
4303
+ if (import.meta.main === true) main();
3507
4304
 
3508
4305
  //#endregion
4306
+ export { parseRuleOptionsOverride };
3509
4307
  //# sourceMappingURL=cli.js.map